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.
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
22
packages/ai/src/providers/cloudflare.ts
Normal file
22
packages/ai/src/providers/cloudflare.ts
Normal file
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user