feat(ai): add Cloudflare AI Gateway as a provider (#3856)
* feat(ai): add Cloudflare AI Gateway as a provider
Routes through Cloudflare's Unified API (`/compat`) for Workers AI and
Anthropic models, and through the provider-specific `/openai` subpath
for OpenAI models so reasoning models (gpt-5.x, o-series) can hit
`/v1/responses` natively. Once `/compat` adds Responses-API support,
the OpenAI subpath can be folded back in.
Catalog layout:
workers-ai/@cf/... -> openai-completions, gateway/.../compat
anthropic/... -> openai-completions, gateway/.../compat
<native-id> -> openai-responses, gateway/.../openai
(gpt-5.1, claude-... no, sorry: gpt-5.x and o-series only;
prefix stripped because the OpenAI SDK posts native ids)
Touches:
packages/ai/src/types.ts add cloudflare-ai-gateway to KnownProvider
packages/ai/src/env-api-keys.ts map to CLOUDFLARE_API_KEY
packages/ai/src/providers/cloudflare.ts add CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL
and CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL
packages/ai/src/providers/openai-responses.ts one-line dispatch through resolveCloudflareBaseUrl
(matches what openai-completions.ts already does)
packages/ai/scripts/generate-models.ts branch openai/* vs workers-ai/anthropic/*
packages/ai/src/models.generated.ts spliced 34 entries
packages/ai/test/stream.test.ts 3 e2e blocks (one per upstream)
packages/coding-agent/* defaultModelPerProvider, login, env docs,
README, providers.md
Verified end-to-end against a real Cloudflare account with unified
billing: 9/9 e2e tests pass across all three upstreams (Workers AI
Kimi K2.6, OpenAI gpt-5.1 reasoning, Anthropic claude-sonnet-4-5).
* refactor(ai): move AI Gateway User-Agent and per-route session-affinity flag to catalog
Mirrors the same per-model metadata refactor done for Workers AI in the
parent branch. All cloudflare-ai-gateway entries get the User-Agent
header. Only workers-ai/* gateway entries set
`compat.sendSessionAffinityHeaders: true` because the gateway
forwards that header to the underlying Workers AI runtime; anthropic/*
upstream and openai/* (openai-responses) don't use it.
packages/ai/scripts/generate-models.ts: emit headers (always) and
per-upstream compat (workers-ai only) on each cloudflare-ai-gateway
entry.
packages/ai/src/models.generated.ts: re-spliced 35 entries with
headers + conditional compat.
Behavior unchanged - 9/9 e2e tests pass across all three upstream
families.
* fix(ai): align AI Gateway with telemetry-aware UA helper
Adapts to badlogic/pi-mono#3851's follow-up fix ("honor telemetry for
Cloudflare attribution headers", fbb5eed) which moved the
'User-Agent: pi-coding-agent' header out of per-model catalog metadata
and into a centralized telemetry-honoring helper
(coding-agent/src/core/sdk.ts:getAttributionHeaders).
- packages/coding-agent/src/core/sdk.ts: extend the cloudflare branch of
getAttributionHeaders to also match cloudflare-ai-gateway and
gateway.ai.cloudflare.com.
- packages/ai/scripts/generate-models.ts and src/models.generated.ts:
drop 'headers' from the 35 cloudflare-ai-gateway entries (constant
CLOUDFLARE_STATIC_HEADERS no longer exists). Per-route
compat.sendSessionAffinityHeaders is unchanged.
End-to-end behavior unchanged: 9/9 tests still pass across all three
upstream families (Workers AI, Anthropic, OpenAI Responses).
---------
Co-authored-by: Mario Zechner <badlogicgames@gmail.com>
This commit is contained in:
@@ -3,7 +3,11 @@
|
||||
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 {
|
||||
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
|
||||
CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL,
|
||||
CLOUDFLARE_WORKERS_AI_BASE_URL,
|
||||
} from "../src/providers/cloudflare.js";
|
||||
import {
|
||||
Api,
|
||||
type AnthropicMessagesCompat,
|
||||
@@ -404,6 +408,57 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Process Cloudflare AI Gateway models
|
||||
if (data["cloudflare-ai-gateway"]?.models) {
|
||||
for (const [prefixedId, model] of Object.entries(data["cloudflare-ai-gateway"].models)) {
|
||||
const m = model as ModelsDevModel;
|
||||
if (m.tool_call !== true) continue;
|
||||
|
||||
const slashIdx = prefixedId.indexOf("/");
|
||||
if (slashIdx === -1) continue;
|
||||
const upstream = prefixedId.slice(0, slashIdx);
|
||||
const nativeId = prefixedId.slice(slashIdx + 1);
|
||||
|
||||
let api: "openai-completions" | "openai-responses";
|
||||
let baseUrl: string;
|
||||
let id: string;
|
||||
if (upstream === "openai") {
|
||||
api = "openai-responses";
|
||||
baseUrl = CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL;
|
||||
id = nativeId;
|
||||
} else if (upstream === "workers-ai" || upstream === "anthropic") {
|
||||
api = "openai-completions";
|
||||
baseUrl = CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL;
|
||||
id = prefixedId;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// workers-ai/* through the gateway forwards x-session-affinity to
|
||||
// the underlying Workers AI runtime for prefix-cache routing.
|
||||
const compat = upstream === "workers-ai" ? { sendSessionAffinityHeaders: true } : undefined;
|
||||
|
||||
models.push({
|
||||
id,
|
||||
name: m.name || id,
|
||||
api,
|
||||
provider: "cloudflare-ai-gateway",
|
||||
baseUrl,
|
||||
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,
|
||||
...(compat ? { compat } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process xAi models
|
||||
if (data.xai?.models) {
|
||||
for (const [modelId, model] of Object.entries(data.xai.models)) {
|
||||
|
||||
Reference in New Issue
Block a user