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:
MC
2026-04-30 22:29:37 +01:00
committed by GitHub
parent c9ddca1968
commit 24fb6b833b
13 changed files with 755 additions and 10 deletions

View File

@@ -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)) {