From d6e08b3da011a871088963fb7baffec730687eab Mon Sep 17 00:00:00 2001 From: MC Date: Mon, 27 Apr 2026 17:41:54 -0400 Subject: [PATCH] feat(ai): add Cloudflare Workers AI as a provider (#3851) * feat(ai): add Cloudflare Workers AI as a provider Cloudflare Workers AI hosts open-weight LLMs (Kimi K2.6, GPT-OSS, GLM-4.7, Llama 4, Gemma 4, Nemotron 3) on Cloudflare's GPU network with an OpenAI-compatible endpoint. Reuses the openai-completions API protocol; the per-account URL contains a {CLOUDFLARE_ACCOUNT_ID} placeholder resolved at request time by a small helper. Pi automatically sets x-session-affinity for prefix caching: https://developers.cloudflare.com/workers-ai/features/prompt-caching/ Auth: CLOUDFLARE_API_KEY (matches pi's *_API_KEY convention) + CLOUDFLARE_ACCOUNT_ID. The User-Agent identifies traffic as 'pi-coding-agent' in Cloudflare analytics. Verified end-to-end against a real Cloudflare account: 17 e2e tests pass across stream/empty/tokens/unicode/tool-call-without-result/ total-tokens against @cf/moonshotai/kimi-k2.6. Cloudflare AI Gateway is a separate, larger change (it requires routing through provider-specific subpaths with the matching API protocol per upstream) and will land in a follow-up PR. * refactor(ai): move Cloudflare User-Agent and session-affinity flag to per-model metadata Instead of conditionally setting them in openai-completions.ts based on provider detection, declare them as model-level fields in the catalog (headers + compat). This is consistent with how the github-copilot and kimi-coding entries already declare their static headers. packages/ai/scripts/generate-models.ts: emit headers and compat fields on each cloudflare-workers-ai entry (CLOUDFLARE_STATIC_HEADERS). packages/ai/src/providers/openai-completions.ts: drop the isCloudflareProvider conditional that injected User-Agent and the isCloudflareWorkersAI override of sendSessionAffinityHeaders. packages/ai/src/models.generated.ts: re-spliced 8 cloudflare-workers-ai entries with headers + compat. Behavior is unchanged - verified via fetch interceptor that User-Agent and x-session-affinity / session_id / x-client-request-id are still sent on outbound requests. 5/5 e2e tests pass. --- packages/ai/README.md | 4 +- packages/ai/scripts/generate-models.ts | 33 ++++ packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/models.generated.ts | 154 ++++++++++++++++++ packages/ai/src/providers/cloudflare.ts | 22 +++ .../ai/src/providers/openai-completions.ts | 7 +- packages/ai/src/types.ts | 3 +- .../ai/test/cross-provider-handoff.test.ts | 2 + packages/ai/test/empty.test.ts | 23 +++ packages/ai/test/stream.test.ts | 27 +++ packages/ai/test/tokens.test.ts | 11 ++ .../ai/test/tool-call-without-result.test.ts | 15 ++ packages/ai/test/total-tokens.test.ts | 28 ++++ packages/ai/test/unicode-surrogate.test.ts | 23 +++ packages/coding-agent/README.md | 1 + packages/coding-agent/docs/providers.md | 13 ++ packages/coding-agent/src/cli/args.ts | 2 + .../coding-agent/src/core/model-resolver.ts | 1 + .../src/modes/interactive/interactive-mode.ts | 1 + 19 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 packages/ai/src/providers/cloudflare.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index b41b7790..18d3b945 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -57,6 +57,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **Mistral** - **Groq** - **Cerebras** +- **Cloudflare Workers AI** - **xAI** - **OpenRouter** - **Vercel AI Gateway** @@ -844,7 +845,7 @@ const ollamaReasoningModel: Model<'openai-completions'> = { ### OpenAI Compatibility Settings -The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, zAi, OpenCode, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags. +The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags. ```typescript interface OpenAICompletionsCompat { @@ -1028,6 +1029,7 @@ In Node.js environments, you can set environment variables to avoid passing API | Mistral | `MISTRAL_API_KEY` | | Groq | `GROQ_API_KEY` | | Cerebras | `CEREBRAS_API_KEY` | +| Cloudflare Workers AI | `CLOUDFLARE_API_KEY` + `CLOUDFLARE_ACCOUNT_ID` | | xAI | `XAI_API_KEY` | | Fireworks | `FIREWORKS_API_KEY` | | OpenRouter | `OPENROUTER_API_KEY` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index f572b864..9ea22854 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -3,6 +3,7 @@ import { writeFileSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; +import { CLOUDFLARE_WORKERS_AI_BASE_URL } from "../src/providers/cloudflare.js"; import { Api, type AnthropicMessagesCompat, @@ -63,6 +64,10 @@ const KIMI_STATIC_HEADERS = { "User-Agent": "KimiCLI/1.5", } as const; +const CLOUDFLARE_STATIC_HEADERS = { + "User-Agent": "pi-coding-agent", +} as const; + const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1"; const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh"; const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]); @@ -376,6 +381,34 @@ async function loadModelsDevData(): Promise[]> { } } + // Process Cloudflare Workers AI models + if (data["cloudflare-workers-ai"]?.models) { + for (const [modelId, model] of Object.entries(data["cloudflare-workers-ai"].models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + + models.push({ + id: modelId, + name: m.name || modelId, + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: CLOUDFLARE_WORKERS_AI_BASE_URL, + reasoning: m.reasoning === true, + input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + headers: { ...CLOUDFLARE_STATIC_HEADERS }, + compat: { sendSessionAffinityHeaders: true }, + }); + } + } + // Process xAi models if (data.xai?.models) { for (const [modelId, model] of Object.entries(data.xai.models)) { diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 76d4b7e9..f15f0559 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -118,6 +118,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { opencode: "OPENCODE_API_KEY", "opencode-go": "OPENCODE_API_KEY", "kimi-coding": "KIMI_API_KEY", + "cloudflare-workers-ai": "CLOUDFLARE_API_KEY", }; const envVar = envMap[provider]; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index c4f49b66..eddb394f 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -15374,4 +15374,158 @@ export const MODELS = { maxTokens: 131072, } satisfies Model<"openai-completions">, }, + "cloudflare-workers-ai": { + "@cf/google/gemma-4-26b-a4b-it": { + id: "@cf/google/gemma-4-26b-a4b-it", + name: "Gemma 4 26B A4B IT", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + headers: {"User-Agent":"pi-coding-agent"}, + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "@cf/meta/llama-4-scout-17b-16e-instruct": { + id: "@cf/meta/llama-4-scout-17b-16e-instruct", + name: "Llama 4 Scout 17B 16E Instruct", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + headers: {"User-Agent":"pi-coding-agent"}, + compat: {"sendSessionAffinityHeaders":true}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.27, + output: 0.85, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "@cf/moonshotai/kimi-k2.5": { + id: "@cf/moonshotai/kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + headers: {"User-Agent":"pi-coding-agent"}, + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "@cf/moonshotai/kimi-k2.6": { + id: "@cf/moonshotai/kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + headers: {"User-Agent":"pi-coding-agent"}, + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "@cf/nvidia/nemotron-3-120b-a12b": { + id: "@cf/nvidia/nemotron-3-120b-a12b", + name: "Nemotron 3 Super 120B", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + headers: {"User-Agent":"pi-coding-agent"}, + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "@cf/openai/gpt-oss-120b": { + id: "@cf/openai/gpt-oss-120b", + name: "GPT OSS 120B", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + headers: {"User-Agent":"pi-coding-agent"}, + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.35, + output: 0.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "@cf/openai/gpt-oss-20b": { + id: "@cf/openai/gpt-oss-20b", + name: "GPT OSS 20B", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + headers: {"User-Agent":"pi-coding-agent"}, + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.2, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "@cf/zai-org/glm-4.7-flash": { + id: "@cf/zai-org/glm-4.7-flash", + name: "GLM-4.7-Flash", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + headers: {"User-Agent":"pi-coding-agent"}, + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.06, + output: 0.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, } as const; diff --git a/packages/ai/src/providers/cloudflare.ts b/packages/ai/src/providers/cloudflare.ts new file mode 100644 index 00000000..75b1e162 --- /dev/null +++ b/packages/ai/src/providers/cloudflare.ts @@ -0,0 +1,22 @@ +import type { Model } from "../types.js"; + +/** Workers AI endpoint. `{CLOUDFLARE_ACCOUNT_ID}` is substituted at request time. */ +export const CLOUDFLARE_WORKERS_AI_BASE_URL = + "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1"; + +export function isCloudflareProvider(provider: string): boolean { + return provider === "cloudflare-workers-ai"; +} + +/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from process.env. */ +export function resolveCloudflareBaseUrl(model: Model<"openai-completions">): string { + const url = model.baseUrl; + if (!url.includes("{")) return url; + return url.replace(/\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name: string) => { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required for provider ${model.provider} but is not set.`); + } + return value; + }); +} diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index e9a9cd17..f0e167e9 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -34,6 +34,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord } from "../utils/headers.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; import { buildBaseOptions, clampReasoning } from "./simple-options.js"; import { transformMessages } from "./transform-messages.js"; @@ -456,7 +457,7 @@ function createClient( return new OpenAI({ apiKey, - baseURL: model.baseUrl, + baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl, dangerouslyAllowBrowser: true, defaultHeaders: headers, }); @@ -1022,6 +1023,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const baseUrl = model.baseUrl; const isZai = provider === "zai" || baseUrl.includes("api.z.ai"); + const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com"); const isNonStandard = provider === "cerebras" || @@ -1032,7 +1034,8 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet baseUrl.includes("deepseek.com") || isZai || provider === "opencode" || - baseUrl.includes("opencode.ai"); + baseUrl.includes("opencode.ai") || + isCloudflareWorkersAI; const useMaxTokens = baseUrl.includes("chutes.ai"); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 817f389a..8ffd785f 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -41,7 +41,8 @@ export type KnownProvider = | "fireworks" | "opencode" | "opencode-go" - | "kimi-coding"; + | "kimi-coding" + | "cloudflare-workers-ai"; export type Provider = KnownProvider | string; export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh"; diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index d9e1b988..f3c95008 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -84,6 +84,8 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ { provider: "xai", model: "grok-code-fast-1", label: "xai-grok-code-fast-1" }, // Cerebras { provider: "cerebras", model: "zai-glm-4.7", label: "cerebras-zai-glm-4.7" }, + // Cloudflare Workers AI + { provider: "cloudflare-workers-ai", model: "@cf/moonshotai/kimi-k2.6", label: "cloudflare-kimi-k2.6" }, // Groq { provider: "groq", model: "openai/gpt-oss-120b", label: "groq-gpt-oss-120b" }, // Hugging Face diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index dd967975..3b2553ea 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -308,6 +308,29 @@ describe("AI Providers Empty Message Tests", () => { }); }); + describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)( + "Cloudflare Workers AI Provider Empty Messages", + () => { + const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"); + + it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { + await testEmptyMessage(llm); + }); + + it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => { + await testEmptyStringMessage(llm); + }); + + it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { + await testWhitespaceOnlyMessage(llm); + }); + + it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { + await testEmptyAssistantMessage(llm); + }); + }, + ); + describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider Empty Messages", () => { const llm = getModel("huggingface", "moonshotai/Kimi-K2.5"); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index ec212789..687a094f 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -616,6 +616,33 @@ describe("Generate E2E Tests", () => { }); }); + describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)( + "Cloudflare Workers AI Provider (Kimi K2.6 via OpenAI Completions)", + () => { + const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"); + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, { reasoningEffort: "medium" }); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, { reasoningEffort: "medium" }); + }); + }, + ); + describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider (Kimi-K2.5 via OpenAI Completions)", () => { const llm = getModel("huggingface", "moonshotai/Kimi-K2.5"); diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index f1a9d002..a128ec0d 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -159,6 +159,17 @@ describe("Token Statistics on Abort", () => { }); }); + describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)( + "Cloudflare Workers AI Provider", + () => { + const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"); + + it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + await testTokensOnAbort(llm); + }); + }, + ); + describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider", () => { const llm = getModel("huggingface", "moonshotai/Kimi-K2.5"); diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 63b52f80..9c732cd1 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -168,6 +168,21 @@ describe("Tool Call Without Result Tests", () => { }); }); + describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)( + "Cloudflare Workers AI Provider", + () => { + const model = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"); + + it( + "should filter out tool calls without corresponding tool results", + { retry: 3, timeout: 30000 }, + async () => { + await testToolCallWithoutResult(model); + }, + ); + }, + ); + describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider", () => { const model = getModel("huggingface", "moonshotai/Kimi-K2.5"); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 314f3122..9d7235d5 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -306,6 +306,34 @@ describe("totalTokens field", () => { ); }); + // ========================================================================= + // Cloudflare Workers AI + // ========================================================================= + + describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)( + "Cloudflare Workers AI", + () => { + it( + "@cf/moonshotai/kimi-k2.6 - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"); + + console.log(`\nCloudflare Workers AI / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { + apiKey: process.env.CLOUDFLARE_API_KEY, + }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); + }, + ); + // ========================================================================= // Hugging Face // ========================================================================= diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 6cad7ff1..7172624a 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -611,6 +611,29 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); }); + describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)( + "Cloudflare Workers AI Provider Unicode Handling", + () => { + const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"); + + it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { + await testEmojiInToolResults(llm); + }); + + it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { + await testRealWorldLinkedInData(llm); + }); + + it( + "should handle unpaired high surrogate (0xD83D) in tool results", + { retry: 3, timeout: 30000 }, + async () => { + await testUnpairedHighSurrogate(llm); + }, + ); + }, + ); + describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider Unicode Handling", () => { const llm = getModel("huggingface", "moonshotai/Kimi-K2.5"); diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 1f00df53..aafc1a64 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -114,6 +114,7 @@ For each built-in provider, pi maintains a list of tool-capable models, updated - Mistral - Groq - Cerebras +- Cloudflare Workers AI - xAI - OpenRouter - Vercel AI Gateway diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 89b651bc..436d04e2 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -61,6 +61,7 @@ pi | Mistral | `MISTRAL_API_KEY` | `mistral` | | Groq | `GROQ_API_KEY` | `groq` | | Cerebras | `CEREBRAS_API_KEY` | `cerebras` | +| Cloudflare Workers AI | `CLOUDFLARE_API_KEY` (+ `CLOUDFLARE_ACCOUNT_ID`) | `cloudflare-workers-ai` | | xAI | `XAI_API_KEY` | `xai` | | OpenRouter | `OPENROUTER_API_KEY` | `openrouter` | | Vercel AI Gateway | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway` | @@ -172,6 +173,18 @@ export AWS_BEDROCK_SKIP_AUTH=1 export AWS_BEDROCK_FORCE_HTTP1=1 ``` +### Cloudflare Workers AI + +`CLOUDFLARE_API_KEY` can be set via `/login`. `CLOUDFLARE_ACCOUNT_ID` must be set as an environment variable. + +```bash +export CLOUDFLARE_API_KEY=... # or use /login +export CLOUDFLARE_ACCOUNT_ID=... +pi --provider cloudflare-workers-ai --model "@cf/moonshotai/kimi-k2.6" +``` + +Pi automatically sets `x-session-affinity` for [prefix caching](https://developers.cloudflare.com/workers-ai/features/prompt-caching/) discounts. + ### Google Vertex AI Uses Application Default Credentials: diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index ae334801..988fd4f7 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -316,6 +316,8 @@ ${chalk.bold("Environment Variables:")} MINIMAX_API_KEY - MiniMax API key OPENCODE_API_KEY - OpenCode Zen/OpenCode Go API key KIMI_API_KEY - Kimi For Coding API key + CLOUDFLARE_API_KEY - Cloudflare API token (Workers AI) + CLOUDFLARE_ACCOUNT_ID - Cloudflare account id (required for Workers AI) AWS_PROFILE - AWS profile for Amazon Bedrock AWS_ACCESS_KEY_ID - AWS access key for Amazon Bedrock AWS_SECRET_ACCESS_KEY - AWS secret key for Amazon Bedrock diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index ee5dd4c7..df7f244f 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -37,6 +37,7 @@ export const defaultModelPerProvider: Record = { opencode: "kimi-k2.6", "opencode-go": "kimi-k2.6", "kimi-coding": "kimi-for-coding", + "cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.6", }; export interface ScopedModel { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e29c4408..f27d89a8 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -182,6 +182,7 @@ const API_KEY_LOGIN_PROVIDERS: Record = { [BEDROCK_PROVIDER_ID]: "Amazon Bedrock", "azure-openai-responses": "Azure OpenAI Responses", cerebras: "Cerebras", + "cloudflare-workers-ai": "Cloudflare Workers AI", deepseek: "DeepSeek", fireworks: "Fireworks", google: "Google Gemini",