Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/core/src/plugin/provider/cloudflare-ai-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
},
}
Expand Down
11 changes: 8 additions & 3 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,12 +816,17 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
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<string, any>) {
// 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: {},
Expand Down
48 changes: 43 additions & 5 deletions packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }
type ProviderOptions = Record<string, Record<string, JSONValue>>

const realFetch = globalThis.fetch
Expand All @@ -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",
Expand Down Expand Up @@ -88,9 +92,20 @@ function extractUpstreamQuery(body: unknown): Record<string, unknown> | 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<string, unknown> | 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)
}
Expand Down Expand Up @@ -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")
})
})
Loading