Skip to content
Closed
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
5 changes: 4 additions & 1 deletion packages/core/src/plugin/provider/cloudflare-ai-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
},
}
Expand Down
8 changes: 5 additions & 3 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,12 +816,14 @@ 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").
// 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: {},
Expand Down
38 changes: 33 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,19 @@ 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 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)
}
Expand Down Expand Up @@ -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")
})
})
Loading