From 34c64fb45b9df8868bf95a7399739e26f0fb1067 Mon Sep 17 00:00:00 2001 From: Keefe Tang Date: Wed, 24 Jun 2026 11:13:32 +1000 Subject: [PATCH] fix(provider): scope AI Gateway token to first-party Workers AI models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #32052 fixed #32051 (Workers AI 401s) by passing apiKey to createUnified, but applied it to every model — so the Cloudflare API token was sent as the upstream Authorization header for third-party providers (OpenAI, Anthropic), causing them to 401 with "Invalid API Key". Scope token forwarding to be model-aware: attach the Cloudflare token only for first-party Workers AI models, whose upstream is Cloudflare itself. The Unified API addresses Workers AI both as "workers-ai/..." and as bare "@cf/..." ids, so match both; "@cf/" is Cloudflare's reserved namespace, so this never matches a third-party model. Other providers receive no upstream Authorization and fall back to the gateway's stored/BYOK keys. Applied in both the v1 provider (provider.ts) and v2 plugin (core/.../cloudflare-ai-gateway.ts) paths. Tests assert both directions, including that third-party sub-requests carry no upstream authorization header. Reapplies and extends the approach from #33407. --- .../plugin/provider/cloudflare-ai-gateway.ts | 8 +++- packages/opencode/src/provider/provider.ts | 11 +++-- .../test/provider/cf-ai-gateway-e2e.test.ts | 48 +++++++++++++++++-- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index d416f6f19d3a..2803cb7a8e8f 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -24,9 +24,15 @@ 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) { + // Workers AI is the only first-party provider whose upstream is Cloudflare itself, so it is + // the only one that should receive the Cloudflare token as its upstream Authorization header. + // The Unified API addresses Workers AI both with the explicit "workers-ai/" prefix and as + // bare "@cf/..." ids. Third-party providers must not receive the token; they rely on the + // gateway's stored/BYOK keys instead. + const isWorkersAi = modelID.startsWith("workers-ai/") || modelID.startsWith("@cf/") + const unified = createUnified(isWorkersAi ? { apiKey: config.apiKey } : {}) return gateway(unified(modelID)) }, } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 088aa2fd7664..424a60033564 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -816,12 +816,17 @@ 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"). + // Workers AI is the only first-party provider whose upstream is Cloudflare itself, so it is + // the only one that should receive the Cloudflare token as its upstream Authorization header. + // The Unified API addresses Workers AI both with the explicit "workers-ai/" prefix and as + // bare "@cf/..." ids. Third-party providers must not receive the token; they rely on the + // gateway's stored/BYOK keys instead. + const isWorkersAi = modelID.startsWith("workers-ai/") || modelID.startsWith("@cf/") + const unified = createUnified(isWorkersAi ? { 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..603a2ecd1b92 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,20 @@ 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 first-party Workers AI sub-requests (workers-ai/ or bare @cf/) get the token. + const isWorkersAi = apiId.startsWith("workers-ai/") || apiId.startsWith("@cf/") + const unified = createUnified(isWorkersAi ? { apiKey: gatewayToken } : {}) await generateText({ model: aigateway(unified(apiId)), prompt: "hi", providerOptions }) return extractUpstreamQuery(captured?.outerBody) } @@ -129,4 +144,27 @@ 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") + // Security invariant: the Cloudflare token must never become the upstream provider's Authorization. + expect(extractUpstreamHeaders(captured?.outerBody)?.["authorization"]).toBeUndefined() + 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") + }) + + test("bare @cf/ Workers AI models DO forward the Cloudflare token upstream (regression: #32051)", async () => { + await callThroughGateway("@cf/meta/llama-3.1-8b-instruct", {}, "cf-gateway-secret") + + expect(captured?.headers["cf-aig-authorization"]).toBe("Bearer cf-gateway-secret") + expect(extractUpstreamHeaders(captured?.outerBody)?.["authorization"]).toBe("Bearer cf-gateway-secret") + }) })