Skip to content

Commit ad683bd

Browse files
fix(cf-ai-gateway): route provider options through openaiCompatible key
Variant input (variant: xhigh) and provider options (provider.cloudflare-ai-gateway.models.<id>.options.reasoningEffort) on cf-ai-gateway models routed through ai-gateway-provider were silently dropped. Outgoing requests to gateway.ai.cloudflare.com used the OpenAI-compatible endpoint without the reasoning_effort field set, so OpenAI upstreams ran at their default effort regardless of user config. sdkKey() had no case for "ai-gateway-provider" and fell back to the providerID "cloudflare-ai-gateway". providerOptions() therefore wrote the payload under that key, but ai-gateway-provider/unified wraps createOpenAICompatible({ name: "Unified" }), and @ai-sdk/openai-compatible only reads compatibleOptions from "openai-compatible" / "openaiCompatible" / "Unified" / "unified". The wrong key was never read, so reasoningEffort never reached the request body. variants() likewise had no "ai-gateway-provider" case, so workflow variant inputs produced an empty options object. Add the sdkKey case returning "openaiCompatible" (the camelCase form avoids the SDK's deprecation warning emitted on the kebab form). Add a variants case that dispatches on the model.api.id upstream prefix and reuses openaiReasoningEfforts() for openai/* models, falling back to WIDELY_SUPPORTED_EFFORTS for other upstreams since the Cloudflare /v1/compat endpoint translates reasoning_effort to provider-native controls. Adds an end-to-end test that wires the actual ai-gateway-provider + @ai-sdk/openai-compatible chain through a stubbed fetch and asserts reasoning_effort lands in the body Cloudflare AI Gateway forwards upstream. The test also pins the legacy buggy key so a future refactor that resurrects providerID-keyed providerOptions fails before it reaches users. Fixes #24432.
1 parent df0d5f6 commit ad683bd

3 files changed

Lines changed: 211 additions & 0 deletions

File tree

packages/opencode/src/provider/transform.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ function sdkKey(npm: string): string | undefined {
4141
return "gateway"
4242
case "@openrouter/ai-sdk-provider":
4343
return "openrouter"
44+
case "ai-gateway-provider":
45+
// ai-gateway-provider/unified wraps createOpenAICompatible({ name: "Unified" }),
46+
// and @ai-sdk/openai-compatible parses compatibleOptions from one of
47+
// "openai-compatible" / "openaiCompatible" / "Unified" / "unified". The
48+
// "openai-compatible" key emits a deprecation warning at runtime, so we
49+
// pick the camelCase form the SDK now treats as canonical.
50+
return "openaiCompatible"
4451
}
4552
return undefined
4653
}
@@ -506,6 +513,21 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
506513
if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("claude")) return {}
507514
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
508515

516+
case "ai-gateway-provider": {
517+
// Cloudflare AI Gateway routes every upstream through its OpenAI-compatible
518+
// /v1/compat endpoint, so the body is always OAI-shaped. The gateway
519+
// translates `reasoning_effort` to the upstream provider's native control
520+
// (e.g. Anthropic thinking budgets) when needed. Variants therefore stay
521+
// OAI-style for all upstreams, with an extended effort set for OpenAI
522+
// models that support it.
523+
if (model.api.id.startsWith("openai/")) {
524+
const efforts = openaiReasoningEfforts(model.api.id, model.release_date)
525+
if (!efforts) return {}
526+
return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]))
527+
}
528+
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
529+
}
530+
509531
case "@ai-sdk/gateway":
510532
if (model.id.includes("anthropic")) {
511533
if (adaptiveEfforts) {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// End-to-end regression test for opencode#24432.
2+
//
3+
// Routes through the actual ai-gateway-provider + @ai-sdk/openai-compatible
4+
// chain that provider.ts:811 builds at runtime, with only the network boundary
5+
// stubbed. Asserts that `reasoning_effort` (and other provider options the
6+
// transform emits) actually land in the body Cloudflare AI Gateway forwards
7+
// upstream — which is the only place the bug was observable.
8+
9+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
10+
import { generateText } from "ai"
11+
import { createAiGateway } from "ai-gateway-provider"
12+
import { createUnified } from "ai-gateway-provider/providers/unified"
13+
import { ProviderTransform } from "@/provider/transform"
14+
15+
type Captured = { url: string; outerBody: any }
16+
17+
const realFetch = globalThis.fetch
18+
let captured: Captured | null = null
19+
20+
beforeEach(() => {
21+
captured = null
22+
globalThis.fetch = (async (input: any, init?: any) => {
23+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
24+
if (url.startsWith("https://gateway.ai.cloudflare.com/")) {
25+
const bodyText = init?.body ?? ""
26+
captured = { url, outerBody: bodyText ? JSON.parse(bodyText) : null }
27+
return new Response(
28+
JSON.stringify({
29+
id: "chatcmpl-test",
30+
object: "chat.completion",
31+
created: 0,
32+
model: "openai/gpt-5.4",
33+
choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
34+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
35+
}),
36+
{ status: 200, headers: { "Content-Type": "application/json" } },
37+
)
38+
}
39+
return realFetch(input, init)
40+
}) as typeof fetch
41+
})
42+
43+
afterEach(() => {
44+
globalThis.fetch = realFetch
45+
})
46+
47+
const cfModel = (apiId: string, releaseDate = "2026-03-05"): any => ({
48+
id: `cloudflare-ai-gateway/${apiId}`,
49+
providerID: "cloudflare-ai-gateway",
50+
api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: "ai-gateway-provider" },
51+
capabilities: {
52+
reasoning: true,
53+
temperature: false,
54+
attachment: true,
55+
toolcall: true,
56+
input: { text: true, audio: false, image: true, video: false, pdf: true },
57+
output: { text: true, audio: false, image: false, video: false, pdf: false },
58+
interleaved: false,
59+
},
60+
cost: { input: 1, output: 1, cache: { read: 0, write: 0 } },
61+
limit: { context: 1_000_000, output: 128_000 },
62+
status: "active",
63+
options: {},
64+
headers: {},
65+
release_date: releaseDate,
66+
})
67+
68+
async function callThroughGateway(apiId: string, providerOptions: Record<string, any>) {
69+
const aigateway = createAiGateway({ accountId: "test", gateway: "test", apiKey: "test" })
70+
const unified = createUnified()
71+
await generateText({ model: aigateway(unified(apiId)), prompt: "hi", providerOptions })
72+
// ai-gateway-provider sends an array; each entry's `query` is the upstream body.
73+
return captured?.outerBody?.[0]?.query as Record<string, any> | undefined
74+
}
75+
76+
describe("cf-ai-gateway end-to-end (regression: #24432)", () => {
77+
test("ProviderTransform.providerOptions output puts reasoning_effort on the wire", async () => {
78+
// The full chain the runtime exercises:
79+
// transform.providerOptions() → openaiCompatible key
80+
// → @ai-sdk/openai-compatible reads it as compatibleOptions
81+
// → emits body.reasoning_effort
82+
// → ai-gateway-provider wraps the body and forwards to gateway.ai.cloudflare.com
83+
const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), { reasoningEffort: "xhigh" })
84+
expect(opts).toEqual({ openaiCompatible: { reasoningEffort: "xhigh" } })
85+
86+
const upstream = await callThroughGateway("openai/gpt-5.4", opts)
87+
expect(upstream?.reasoning_effort).toBe("xhigh")
88+
})
89+
90+
test("variants() output for openai/gpt-5.4 lands xhigh on the wire", async () => {
91+
// The other half of the bug: workflow `variant: xhigh` flows through variants()
92+
// and must reach the wire. variants() returns the providerOptions payload
93+
// unwrapped; providerOptions() wraps it under the SDK key.
94+
const variants = ProviderTransform.variants(cfModel("openai/gpt-5.4"))
95+
expect(variants.xhigh).toEqual({ reasoningEffort: "xhigh" })
96+
97+
const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), variants.xhigh)
98+
const upstream = await callThroughGateway("openai/gpt-5.4", opts)
99+
expect(upstream?.reasoning_effort).toBe("xhigh")
100+
})
101+
102+
test("legacy buggy key 'cloudflare-ai-gateway' does NOT reach the wire (proves the bug)", async () => {
103+
// Sanity: confirms the bug class. If a future change accidentally restores
104+
// providerID-keyed providerOptions, this test fails before users notice.
105+
const upstream = await callThroughGateway("openai/gpt-5.4", {
106+
"cloudflare-ai-gateway": { reasoningEffort: "high" },
107+
})
108+
expect(upstream?.reasoning_effort).toBeUndefined()
109+
})
110+
})

packages/opencode/test/provider/transform.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3360,4 +3360,83 @@ describe("ProviderTransform.variants", () => {
33603360
expect(result).toEqual({})
33613361
})
33623362
})
3363+
3364+
describe("ai-gateway-provider (cloudflare-ai-gateway)", () => {
3365+
const cfModel = (apiId: string, releaseDate = "2024-01-01") =>
3366+
createMockModel({
3367+
id: `cloudflare-ai-gateway/${apiId}`,
3368+
providerID: "cloudflare-ai-gateway",
3369+
api: {
3370+
id: apiId,
3371+
url: "https://gateway.ai.cloudflare.com/v1/compat",
3372+
npm: "ai-gateway-provider",
3373+
},
3374+
release_date: releaseDate,
3375+
})
3376+
3377+
test("openai gpt-5.4 includes xhigh effort (regression: variant=xhigh used to be silently ignored)", () => {
3378+
const result = ProviderTransform.variants(cfModel("openai/gpt-5.4", "2026-03-05"))
3379+
expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" })
3380+
expect(result.high).toEqual({ reasoningEffort: "high" })
3381+
expect(Object.keys(result)).toContain("minimal")
3382+
})
3383+
3384+
test("openai gpt-5.2-codex includes xhigh", () => {
3385+
const result = ProviderTransform.variants(cfModel("openai/gpt-5.2-codex", "2025-12-11"))
3386+
expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" })
3387+
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
3388+
})
3389+
3390+
test("openai gpt-4o (no reasoning) returns empty", () => {
3391+
const model = cfModel("openai/gpt-4o")
3392+
model.capabilities.reasoning = false
3393+
const result = ProviderTransform.variants(model)
3394+
expect(result).toEqual({})
3395+
})
3396+
3397+
test("non-openai upstream falls back to widely-supported OAI efforts", () => {
3398+
const result = ProviderTransform.variants(cfModel("anthropic/claude-sonnet-4-6"))
3399+
expect(result).toEqual({
3400+
low: { reasoningEffort: "low" },
3401+
medium: { reasoningEffort: "medium" },
3402+
high: { reasoningEffort: "high" },
3403+
})
3404+
})
3405+
})
3406+
})
3407+
3408+
describe("ProviderTransform.providerOptions - ai-gateway-provider", () => {
3409+
const createModel = (overrides: Partial<any> = {}) =>
3410+
({
3411+
id: "cloudflare-ai-gateway/openai/gpt-5.4",
3412+
providerID: "cloudflare-ai-gateway",
3413+
api: {
3414+
id: "openai/gpt-5.4",
3415+
url: "https://gateway.ai.cloudflare.com/v1/compat",
3416+
npm: "ai-gateway-provider",
3417+
},
3418+
capabilities: {
3419+
temperature: false,
3420+
reasoning: true,
3421+
attachment: true,
3422+
toolcall: true,
3423+
input: { text: true, audio: false, image: true, video: false, pdf: true },
3424+
output: { text: true, audio: false, image: false, video: false, pdf: false },
3425+
interleaved: false,
3426+
},
3427+
cost: { input: 1, output: 1, cache: { read: 0, write: 0 } },
3428+
limit: { context: 1_000_000, output: 128_000 },
3429+
status: "active",
3430+
options: {},
3431+
headers: {},
3432+
release_date: "2026-03-05",
3433+
...overrides,
3434+
}) as any
3435+
3436+
test("routes options under openaiCompatible (the key @ai-sdk/openai-compatible reads)", () => {
3437+
// Regression: previously fell back to providerID="cloudflare-ai-gateway",
3438+
// which @ai-sdk/openai-compatible never reads, silently dropping reasoningEffort.
3439+
const result = ProviderTransform.providerOptions(createModel(), { reasoningEffort: "high" })
3440+
expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } })
3441+
})
33633442
})

0 commit comments

Comments
 (0)