diff --git a/.gitignore b/.gitignore index fc175568df6..cac8cd01755 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ target # Local dev files opencode-dev logs/ +packages/opencode/.*build diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d17a50ffec8..d90e55e072b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -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" @@ -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) => Promise @@ -71,6 +71,21 @@ export namespace Provider { options?: Record }> + 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 = { async anthropic() { return { @@ -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)) { + for (const entry of init as Iterable) { + 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)) { + 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) { - 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 = { + ..._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 @@ -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, }, } }, diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index f6d2df9dd5b..75d5ba5889e 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -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 = [] + 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 = [] + 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) => {