diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0f8cbd23f775..63ead3108486 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -871,7 +871,7 @@ function custom(dep: CustomDep): Record { name: "cloudflare-ai-gateway", baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`, apiKey: apiToken, - headers: { "cf-aig-gateway-id": gateway }, + headers: aiGatewayRestHeaders(gateway, opts.headers["User-Agent"], opts), })(modelID) }, options: {}, @@ -1250,6 +1250,33 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { // passthrough SDKs (Responses / Messages APIs). Resolving the native npm before // variants are computed makes reasoning variants produce payloads the native // SDKs understand (e.g. anthropic `effort` instead of compat `reasoningEffort`). +/** + * Per-request AI Gateway controls for the catalog REST API. That endpoint cannot + * take the aigateway wrapper's options bag, so the gateway evaluates these + * cf-aig-* request headers instead. + */ +export function aiGatewayRestHeaders( + gateway: string, + userAgent: string, + control: { + metadata?: unknown + cacheTtl?: number | string + cacheKey?: string + skipCache?: boolean + collectLog?: boolean + }, +) { + return { + "cf-aig-gateway-id": gateway, + "User-Agent": userAgent, + ...(control.metadata !== undefined ? { "cf-aig-metadata": JSON.stringify(control.metadata) } : {}), + ...(control.cacheTtl !== undefined ? { "cf-aig-cache-ttl": String(control.cacheTtl) } : {}), + ...(control.cacheKey !== undefined ? { "cf-aig-cache-key": String(control.cacheKey) } : {}), + ...(control.skipCache !== undefined ? { "cf-aig-skip-cache": String(control.skipCache) } : {}), + ...(control.collectLog !== undefined ? { "cf-aig-collect-log": String(control.collectLog) } : {}), + } +} + function cloudflareGatewayNpm(providerID: string, modelID: string) { if (providerID !== "cloudflare-ai-gateway") return undefined if (modelID.startsWith("openai/")) return "@ai-sdk/openai" diff --git a/packages/opencode/test/provider/cf-ai-gateway-rest-options.test.ts b/packages/opencode/test/provider/cf-ai-gateway-rest-options.test.ts new file mode 100644 index 000000000000..9c379c86c5d5 --- /dev/null +++ b/packages/opencode/test/provider/cf-ai-gateway-rest-options.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { aiGatewayRestHeaders } from "@/provider/provider" + +describe("aiGatewayRestHeaders", () => { + test("maps every gateway control option to its cf-aig-* request header", () => { + const headers = aiGatewayRestHeaders("my-gateway", "opencode/test", { + metadata: { team: "core", cost_center: 7 }, + cacheTtl: 300, + cacheKey: "prompt-v1", + skipCache: true, + collectLog: false, + }) + + expect(headers).toEqual({ + "cf-aig-gateway-id": "my-gateway", + "User-Agent": "opencode/test", + "cf-aig-metadata": JSON.stringify({ team: "core", cost_center: 7 }), + "cf-aig-cache-ttl": "300", + "cf-aig-cache-key": "prompt-v1", + "cf-aig-skip-cache": "true", + "cf-aig-collect-log": "false", + }) + }) + + test("omits headers for options that are not set", () => { + const headers = aiGatewayRestHeaders("my-gateway", "opencode/test", {}) + + expect(headers).toEqual({ + "cf-aig-gateway-id": "my-gateway", + "User-Agent": "opencode/test", + }) + }) + + test("keeps explicitly false boolean controls on the wire while omitting unset ones", () => { + const headers = aiGatewayRestHeaders("g", "ua", { skipCache: false }) + + // skipCache: false is meaningful to the gateway (explicitly re-enable caching), + // so it must serialize rather than disappear. + expect(headers["cf-aig-skip-cache"]).toBe("false") + expect(headers).not.toHaveProperty("cf-aig-cache-key") + expect(headers).not.toHaveProperty("cf-aig-metadata") + }) +})