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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ target
# Local dev files
opencode-dev
logs/
packages/opencode/.*build
114 changes: 104 additions & 10 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
import { createOpenaiCompatible as createOpenaiCompatibleWithResponses } from "./sdk/openai-compatible/src"
import { createXai } from "@ai-sdk/xai"
import { createMistral } from "@ai-sdk/mistral"
import { createGroq } from "@ai-sdk/groq"
Expand Down Expand Up @@ -61,7 +61,7 @@ export namespace Provider {
"@ai-sdk/perplexity": createPerplexity,
"@ai-sdk/vercel": createVercel,
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
"@ai-sdk/github-copilot": createOpenaiCompatibleWithResponses,
}

type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
Expand All @@ -71,6 +71,21 @@ export namespace Provider {
options?: Record<string, any>
}>

const RESPONSES_MODELS_PREFIXES = ["gpt-4.1", "gpt-5", "o1", "o3", "o4"]
const shouldUseResponsesAPI = (modelID: string) => {
const id = modelID.split("/").pop()?.toLowerCase() ?? modelID.toLowerCase()
return RESPONSES_MODELS_PREFIXES.some((prefix) => id.startsWith(prefix))
}

const gatewayBase = (accountId: string, gateway: string) =>
`https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}`
const gatewayBaseForModel = (accountId: string, gateway: string, modelID: string) => {
const namespace = modelID.split("/")[0]
if (namespace === "openai") return `${gatewayBase(accountId, gateway)}/openai`
if (namespace === "anthropic") return `${gatewayBase(accountId, gateway)}/anthropic`
return `${gatewayBase(accountId, gateway)}/compat`
}

const CUSTOM_LOADERS: Record<string, CustomLoader> = {
async anthropic() {
return {
Expand Down Expand Up @@ -371,13 +386,95 @@ export namespace Provider {
return undefined
})()

const hasCfAigAuthorizationHeader = (init?: unknown) => {
if (!init) return false
if (init instanceof Headers) return init.has("cf-aig-authorization")
if (Array.isArray(init)) {
for (const entry of init) {
if (!Array.isArray(entry)) continue
const [key] = entry
if (typeof key === "string" && key.toLowerCase() === "cf-aig-authorization") return true
}
return false
}
if (typeof init === "object" && init !== null) {
if (Symbol.iterator in (init as Record<string, unknown>)) {
for (const entry of init as Iterable<readonly [string, string]>) {
const [key] = entry
if (typeof key === "string" && key.toLowerCase() === "cf-aig-authorization") return true
}
return false
}
for (const key of Object.keys(init as Record<string, unknown>)) {
if (key.toLowerCase() === "cf-aig-authorization") return true
}
}
return false
}

const gatewayAuthHeaders = { value: input.options?.headers }

const sharedFetch: typeof fetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(gatewayAuthHeaders.value)
const requestHeaders = new Headers(init?.headers)
for (const [key, value] of requestHeaders.entries()) headers.set(key, value)
const shouldStripAuthorization =
Boolean(apiToken) ||
hasCfAigAuthorizationHeader(gatewayAuthHeaders.value) ||
hasCfAigAuthorizationHeader(headers)
if (shouldStripAuthorization) headers.delete("Authorization")
return fetch(input, { ...init, headers })
},
{ preconnect: fetch.preconnect },
)

return {
autoload: true,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.chat(modelID)
const baseURL = gatewayBaseForModel(accountId, gateway, modelID)
const [namespace, ...rest] = modelID.split("/")
const wireModelID =
rest.length > 0 && (namespace === "openai" || namespace === "anthropic") ? rest.join("/") : modelID

gatewayAuthHeaders.value = _options?.headers ?? gatewayAuthHeaders.value

const opts: Record<string, any> = {
..._options,
baseURL,
fetch: sharedFetch,
}

if (shouldUseResponsesAPI(modelID)) {
// Some models (gpt-5.x, o-series) only support the Responses API. Gateway's
// SDK may not expose `responses`, so create an OpenAI-compatible provider
// with the same options to force `/responses`.
const compat = createOpenaiCompatibleWithResponses({
name: input.id,
...opts,
})
if (typeof compat.responses === "function") {
return compat.responses(wireModelID)
}

// Fallback: use the OpenAI provider (which exposes responses) with the same baseURL/headers/fetch.
const fallback = createOpenAI({
name: input.id,
apiKey: undefined,
baseURL,
headers: opts["headers"],
fetch: sharedFetch,
})
if (typeof fallback.responses === "function") {
return fallback.responses(wireModelID)
}
}
if (sdk.languageModel) return sdk.languageModel(wireModelID)
if (sdk.chat) return sdk.chat(wireModelID)
return sdk(wireModelID)
},
options: {
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
baseURL: `${gatewayBase(accountId, gateway)}/compat`,
headers: {
// Cloudflare AI Gateway uses cf-aig-authorization for authenticated gateways
// This enables Unified Billing where Cloudflare handles upstream provider auth
Expand All @@ -386,12 +483,9 @@ export namespace Provider {
"X-Title": "opencode",
},
// Custom fetch to strip Authorization header - AI Gateway uses cf-aig-authorization instead
// Sending Authorization header with invalid value causes auth errors
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers)
headers.delete("Authorization")
return fetch(input, { ...init, headers })
},
// Sending Authorization header with invalid value causes auth errors. Preserve Authorization
// when no cf-aig-authorization is provided so upstream keys still work.
fetch: sharedFetch,
},
}
},
Expand Down
132 changes: 132 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,138 @@ test("model headers are preserved", async () => {
})
})

test("cloudflare gateway strips Authorization when gateway auth configured", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"cloudflare-ai-gateway": {
models: {
"openai/gpt-4.1-mini": {
name: "CF GPT-4.1 Mini",
tool_call: true,
limit: { context: 8000, output: 4000 },
},
},
options: {
headers: {
"cf-aig-authorization": "Bearer config-token",
},
},
},
},
}),
)
},
})

await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("CLOUDFLARE_ACCOUNT_ID", "acc")
Env.set("CLOUDFLARE_GATEWAY_ID", "gate")
Env.set("CLOUDFLARE_API_TOKEN", "")
},
fn: async () => {
const providers = await Provider.list()
const provider = providers["cloudflare-ai-gateway"]
expect(provider).toBeDefined()

const model = await Provider.getModel("cloudflare-ai-gateway", "openai/gpt-4.1-mini")
const fetchFn = provider.options.fetch as typeof fetch
const calls: Array<RequestInit | undefined> = []
const originalFetch = globalThis.fetch
try {
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push(init)
return Promise.resolve(new Response("ok"))
}) as typeof fetch

await Provider.getLanguage(model)
await fetchFn("https://example.com", {
headers: {
Authorization: "Bearer to-strip",
},
})
} finally {
globalThis.fetch = originalFetch
}

const call = calls[calls.length - 1]
const headers = new Headers(call?.headers)
expect(headers.has("Authorization")).toBe(false)
expect(headers.get("cf-aig-authorization")).toBe("Bearer config-token")
},
})
})

test("cloudflare gateway keeps Authorization without gateway auth", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"cloudflare-ai-gateway": {
models: {
"openai/gpt-4.1-mini": {
name: "CF GPT-4.1 Mini",
tool_call: true,
limit: { context: 8000, output: 4000 },
},
},
options: {},
},
},
}),
)
},
})

await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("CLOUDFLARE_ACCOUNT_ID", "acc")
Env.set("CLOUDFLARE_GATEWAY_ID", "gate")
Env.set("CLOUDFLARE_API_TOKEN", "")
},
fn: async () => {
const providers = await Provider.list()
const provider = providers["cloudflare-ai-gateway"]
expect(provider).toBeDefined()

const model = await Provider.getModel("cloudflare-ai-gateway", "openai/gpt-4.1-mini")
const fetchFn = provider.options.fetch as typeof fetch
const calls: Array<RequestInit | undefined> = []
const originalFetch = globalThis.fetch
try {
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push(init)
return Promise.resolve(new Response("ok"))
}) as typeof fetch

await Provider.getLanguage(model)
await fetchFn("https://example.com", {
headers: {
Authorization: "Bearer keep-me",
},
})
} finally {
globalThis.fetch = originalFetch
}

const call = calls[calls.length - 1]
const headers = new Headers(call?.headers)
expect(headers.get("Authorization")).toBe("Bearer keep-me")
expect(headers.has("cf-aig-authorization")).toBe(false)
},
})
})

test("provider env fallback - second env var used if first missing", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
Expand Down