Skip to content

Commit 69e6e3d

Browse files
Add Cloudflare Workers AI provider (#5595)
Add Cloudflare Workers AI as a selectable OpenAI-compatible LLM provider with setup guidance and seeded model fallback.
1 parent bc9c48f commit 69e6e3d

7 files changed

Lines changed: 137 additions & 1 deletion

File tree

apps/desktop/src/settings/ai/llm/configure.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ function ProviderContext({ providerId }: { providerId: ProviderId }) {
5050
? "Enter your **Azure AI Foundry endpoint** as the Base URL and your **API key**. Supports Claude and other models deployed via Azure AI Foundry. [Report issues](https://github.com/fastrepl/char/issues/3928)"
5151
: providerId === "google_generative_ai"
5252
? "Visit [AI Studio](https://aistudio.google.com/api-keys) to create an API key."
53-
: "";
53+
: providerId === "cloudflare_workers_ai"
54+
? "Enter the Workers AI **OpenAI-compatible base URL** as `https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1` and use a Cloudflare API token with Workers AI access."
55+
: "";
5456

5557
if (!content) {
5658
return null;

apps/desktop/src/settings/ai/llm/select.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { listAnthropicModels } from "~/settings/ai/shared/list-anthropic";
2525
import { listAzureAIModels } from "~/settings/ai/shared/list-azure-ai";
2626
import { listAzureOpenAIModels } from "~/settings/ai/shared/list-azure-openai";
27+
import { listCloudflareWorkersAIModels } from "~/settings/ai/shared/list-cloudflare-workers-ai";
2728
import {
2829
type InputModality,
2930
type ListModelsResult,
@@ -301,6 +302,10 @@ function useConfiguredMapping(): Record<string, ProviderStatus> {
301302
case "openai":
302303
listModelsFunc = () => listOpenAIModels(baseUrl, apiKey);
303304
break;
305+
case "cloudflare_workers_ai":
306+
listModelsFunc = () =>
307+
listCloudflareWorkersAIModels(baseUrl, apiKey);
308+
break;
304309
case "anthropic":
305310
listModelsFunc = () => listAnthropicModels(baseUrl, apiKey);
306311
break;

apps/desktop/src/settings/ai/llm/shared.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,26 @@ const _PROVIDERS = [
9696
baseUrl: "https://api.openai.com/v1",
9797
requirements: [{ kind: "requires_config", fields: ["api_key"] }],
9898
},
99+
{
100+
id: "cloudflare_workers_ai",
101+
displayName: "Cloudflare Workers AI",
102+
badge: null,
103+
icon: <Icon icon="simple-icons:cloudflare" width={16} />,
104+
baseUrl: undefined,
105+
requirements: [
106+
{ kind: "requires_config", fields: ["base_url", "api_key"] },
107+
],
108+
links: {
109+
models: {
110+
label: "Available models",
111+
url: "https://developers.cloudflare.com/workers-ai/models/",
112+
},
113+
setup: {
114+
label: "Setup guide",
115+
url: "https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/",
116+
},
117+
},
118+
},
99119
{
100120
id: "anthropic",
101121
displayName: "Anthropic",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, test } from "vitest";
2+
3+
import {
4+
CLOUDFLARE_WORKERS_AI_MODELS,
5+
createStaticCloudflareWorkersAIModelResult,
6+
} from "./list-cloudflare-workers-ai";
7+
8+
describe("createStaticCloudflareWorkersAIModelResult", () => {
9+
test("keeps a selectable fallback for Workers AI chat models", () => {
10+
const result = createStaticCloudflareWorkersAIModelResult();
11+
12+
expect(result.models).toEqual([...CLOUDFLARE_WORKERS_AI_MODELS]);
13+
expect(result.models[0]).toBe("@cf/moonshotai/kimi-k2.6");
14+
expect(result.metadata["@cf/moonshotai/kimi-k2.6"]).toEqual({
15+
input_modalities: ["text", "image"],
16+
});
17+
expect(result.metadata["@cf/openai/gpt-oss-120b"]).toEqual({
18+
input_modalities: ["text"],
19+
});
20+
});
21+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { ListModelsResult, ModelMetadata } from "./list-common";
2+
import { DEFAULT_RESULT } from "./list-common";
3+
4+
export const CLOUDFLARE_WORKERS_AI_MODELS = [
5+
"@cf/moonshotai/kimi-k2.6",
6+
"@cf/zai-org/glm-4.7-flash",
7+
"@cf/openai/gpt-oss-120b",
8+
"@cf/meta/llama-4-scout-17b-16e-instruct",
9+
"@cf/google/gemma-4-26b-a4b-it",
10+
"@cf/nvidia/nemotron-3-120b-a12b",
11+
"@cf/openai/gpt-oss-20b",
12+
"@cf/qwen/qwen3-30b-a3b-fp8",
13+
"@cf/mistralai/mistral-small-3.1-24b-instruct",
14+
"@cf/meta/llama-3.3-70b-instruct-fp8-fast",
15+
] as const;
16+
17+
const VISION_MODELS = new Set<string>([
18+
"@cf/moonshotai/kimi-k2.6",
19+
"@cf/meta/llama-4-scout-17b-16e-instruct",
20+
"@cf/google/gemma-4-26b-a4b-it",
21+
]);
22+
23+
function isCloudflareWorkersAIModel(
24+
model: string,
25+
): model is (typeof CLOUDFLARE_WORKERS_AI_MODELS)[number] {
26+
return (CLOUDFLARE_WORKERS_AI_MODELS as readonly string[]).includes(model);
27+
}
28+
29+
export function getCloudflareWorkersAIModelMetadata(
30+
model: string | undefined,
31+
): ModelMetadata | undefined {
32+
if (!model || !isCloudflareWorkersAIModel(model)) {
33+
return undefined;
34+
}
35+
36+
return {
37+
input_modalities: VISION_MODELS.has(model) ? ["text", "image"] : ["text"],
38+
};
39+
}
40+
41+
export function createStaticCloudflareWorkersAIModelResult(): ListModelsResult {
42+
const metadata: Record<string, ModelMetadata> = {};
43+
44+
for (const model of CLOUDFLARE_WORKERS_AI_MODELS) {
45+
metadata[model] = getCloudflareWorkersAIModelMetadata(model)!;
46+
}
47+
48+
return {
49+
models: [...CLOUDFLARE_WORKERS_AI_MODELS],
50+
ignored: [],
51+
metadata,
52+
};
53+
}
54+
55+
export async function listCloudflareWorkersAIModels(
56+
baseUrl: string,
57+
_apiKey: string,
58+
): Promise<ListModelsResult> {
59+
if (!baseUrl) {
60+
return DEFAULT_RESULT;
61+
}
62+
63+
return createStaticCloudflareWorkersAIModelResult();
64+
}

apps/desktop/src/settings/ai/shared/model-capabilities.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,19 @@ describe("modelSupportsImageInput", () => {
3030
expect(modelSupportsImageInput("ollama", "llava:latest")).toBe(true);
3131
expect(modelSupportsImageInput("custom", "llama-3.1-8b")).toBe(false);
3232
});
33+
34+
it("uses Workers AI model metadata for image input support", () => {
35+
expect(
36+
modelSupportsImageInput(
37+
"cloudflare_workers_ai",
38+
"@cf/moonshotai/kimi-k2.6",
39+
),
40+
).toBe(true);
41+
expect(
42+
modelSupportsImageInput(
43+
"cloudflare_workers_ai",
44+
"@cf/openai/gpt-oss-120b",
45+
),
46+
).toBe(false);
47+
});
3348
});

apps/desktop/src/settings/ai/shared/model-capabilities.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { getCloudflareWorkersAIModelMetadata } from "./list-cloudflare-workers-ai";
2+
13
const TEXT_ONLY_MODEL_RE =
24
/(?:^|[/:\-.])(?:gpt-3\.5|claude-2|claude-instant|davinci|babbage|curie|ada|dall-e|sora|gpt-image|image-generation|embed|embedding|whisper|tts|transcribe|moderation|realtime|computer)(?:$|[/:\-.])/i;
35

@@ -12,6 +14,13 @@ export function modelSupportsImageInput(
1214
return false;
1315
}
1416

17+
if (providerId === "cloudflare_workers_ai") {
18+
const metadata = getCloudflareWorkersAIModelMetadata(modelId);
19+
if (metadata?.input_modalities) {
20+
return metadata.input_modalities.includes("image");
21+
}
22+
}
23+
1524
if (TEXT_ONLY_MODEL_RE.test(modelId)) {
1625
return false;
1726
}

0 commit comments

Comments
 (0)