diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index d6ba76db60c0..55ffd2fad242 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -25,9 +25,12 @@ export const CloudflareAIGatewayPlugin = define({ apiKey: config.apiKey, options: gatewayOptions(evt.options, metadata), } as any) - const unified = createUnified({ apiKey: config.apiKey }) evt.sdk = { languageModel(modelID: string) { + // Only Workers AI sub-requests are authenticated by the Cloudflare token (the upstream + // is Cloudflare itself); third-party providers must not receive it as their Authorization + // header, so they rely on the gateway's stored/BYOK keys instead. + const unified = createUnified(modelID.startsWith("workers-ai/") ? { apiKey: config.apiKey } : {}) return gateway(unified(modelID)) }, } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d7c85f2ee6ae..a42c7ba3ecc0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -816,12 +816,14 @@ function custom(dep: CustomDep): Record { apiKey: apiToken, ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}), }) - const unified = createUnified({ apiKey: apiToken }) - return { autoload: true, async getModel(_sdk: any, modelID: string, _options?: Record) { - // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5") + // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5"). + // Only Workers AI sub-requests are authenticated by the Cloudflare token (the upstream + // is Cloudflare itself); third-party providers must not receive it as their Authorization + // header, so they rely on the gateway's stored/BYOK keys instead. + const unified = createUnified(modelID.startsWith("workers-ai/") ? { apiKey: apiToken } : {}) return aigateway(unified(modelID)) }, options: {}, diff --git a/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts index f062868c427a..dcaccf0ab0f9 100644 --- a/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts +++ b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts @@ -16,7 +16,7 @@ import type * as Provider from "@/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -type Captured = { url: string; outerBody: unknown } +type Captured = { url: string; outerBody: unknown; headers: Record } type ProviderOptions = Record> const realFetch = globalThis.fetch @@ -32,7 +32,11 @@ beforeEach(() => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url if (url.startsWith("https://gateway.ai.cloudflare.com/")) { const bodyText = typeof init?.body === "string" ? init.body : "" - captured = { url, outerBody: bodyText ? JSON.parse(bodyText) : null } + captured = { + url, + outerBody: bodyText ? JSON.parse(bodyText) : null, + headers: Object.fromEntries(new Headers(init?.headers).entries()), + } return new Response( JSON.stringify({ id: "chatcmpl-test", @@ -88,9 +92,19 @@ function extractUpstreamQuery(body: unknown): Record | undefine return isRecord(query) ? query : undefined } -async function callThroughGateway(apiId: string, providerOptions: ProviderOptions) { - const aigateway = createAiGateway({ accountId: "test", gateway: "test", apiKey: "test" }) - const unified = createUnified() +// Each step descriptor also carries the `headers` forwarded to the upstream provider. +function extractUpstreamHeaders(body: unknown): Record | undefined { + if (!Array.isArray(body) || body.length === 0) return undefined + const first = body[0] + if (!isRecord(first)) return undefined + const headers = first.headers + return isRecord(headers) ? headers : undefined +} + +async function callThroughGateway(apiId: string, providerOptions: ProviderOptions, gatewayToken = "test") { + const aigateway = createAiGateway({ accountId: "test", gateway: "test", apiKey: gatewayToken }) + // Mirrors the runtime: only workers-ai sub-requests get the Cloudflare token as their apiKey. + const unified = createUnified(apiId.startsWith("workers-ai/") ? { apiKey: gatewayToken } : {}) await generateText({ model: aigateway(unified(apiId)), prompt: "hi", providerOptions }) return extractUpstreamQuery(captured?.outerBody) } @@ -129,4 +143,18 @@ describe("cf-ai-gateway end-to-end (regression: #24432)", () => { }) expect(upstream?.reasoning_effort).toBeUndefined() }) + + test("third-party models do NOT forward the Cloudflare token upstream (regression: #32052)", async () => { + await callThroughGateway("openai/gpt-5.4", {}, "cf-gateway-secret") + + expect(captured?.headers["cf-aig-authorization"]).toBe("Bearer cf-gateway-secret") + expect(JSON.stringify(captured?.outerBody)).not.toContain("cf-gateway-secret") + }) + + test("workers-ai models DO forward the Cloudflare token upstream (regression: #32051)", async () => { + await callThroughGateway("workers-ai/@cf/google/gemma-4-26b-a4b-it", {}, "cf-gateway-secret") + + expect(captured?.headers["cf-aig-authorization"]).toBe("Bearer cf-gateway-secret") + expect(extractUpstreamHeaders(captured?.outerBody)?.["authorization"]).toBe("Bearer cf-gateway-secret") + }) })