fix(cf-ai-gateway): route provider options through openaiCompatible key (#24432) (#25573)

This commit is contained in:
Nathan Nguyen
2026-05-06 07:55:08 +10:00
committed by GitHub
parent 25547e9337
commit ca77b8f8e9
3 changed files with 301 additions and 20 deletions

View File

@@ -0,0 +1,135 @@
// End-to-end regression test for opencode#24432.
//
// Routes through the actual ai-gateway-provider + @ai-sdk/openai-compatible
// chain that provider.ts:811 builds at runtime, with only the network boundary
// stubbed. Asserts that `reasoning_effort` (and other provider options the
// transform emits) actually land in the body Cloudflare AI Gateway forwards
// upstream, which is the only place the bug was observable.
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { JSONValue } from "ai"
import { generateText } from "ai"
import { createAiGateway } from "ai-gateway-provider"
import { createUnified } from "ai-gateway-provider/providers/unified"
import { ProviderTransform } from "@/provider/transform"
import type * as Provider from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
type Captured = { url: string; outerBody: unknown }
type ProviderOptions = Record<string, Record<string, JSONValue>>
const realFetch = globalThis.fetch
let captured: Captured | null = null
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
beforeEach(() => {
captured = null
const handle = async (
input: Parameters<typeof fetch>[0],
init?: Parameters<typeof fetch>[1],
): Promise<Response> => {
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 }
return new Response(
JSON.stringify({
id: "chatcmpl-test",
object: "chat.completion",
created: 0,
model: "openai/gpt-5.4",
choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}
return realFetch(input, init)
}
// `typeof fetch` includes Bun's `preconnect` method; preserve it from realFetch.
const stubFetch: typeof fetch = Object.assign(handle, { preconnect: realFetch.preconnect.bind(realFetch) })
globalThis.fetch = stubFetch
})
afterEach(() => {
globalThis.fetch = realFetch
})
const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({
id: ModelID.make(`cloudflare-ai-gateway/${apiId}`),
providerID: ProviderID.make("cloudflare-ai-gateway"),
name: apiId,
api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: "ai-gateway-provider" },
capabilities: {
reasoning: true,
temperature: false,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 1, output: 1, cache: { read: 0, write: 0 } },
limit: { context: 1_000_000, output: 128_000 },
status: "active",
options: {},
headers: {},
release_date: releaseDate,
})
// ai-gateway-provider sends an array of step descriptors; each entry's `query`
// is the body forwarded to the upstream provider.
function extractUpstreamQuery(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 query = first.query
return isRecord(query) ? query : undefined
}
async function callThroughGateway(apiId: string, providerOptions: ProviderOptions) {
const aigateway = createAiGateway({ accountId: "test", gateway: "test", apiKey: "test" })
const unified = createUnified()
await generateText({ model: aigateway(unified(apiId)), prompt: "hi", providerOptions })
return extractUpstreamQuery(captured?.outerBody)
}
describe("cf-ai-gateway end-to-end (regression: #24432)", () => {
test("ProviderTransform.providerOptions output puts reasoning_effort on the wire", async () => {
// The full chain the runtime exercises:
// transform.providerOptions() -> openaiCompatible key
// -> @ai-sdk/openai-compatible reads it as compatibleOptions
// -> emits body.reasoning_effort
// -> ai-gateway-provider wraps the body and forwards to gateway.ai.cloudflare.com
const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), { reasoningEffort: "xhigh" })
expect(opts).toEqual({ openaiCompatible: { reasoningEffort: "xhigh" } })
const upstream = await callThroughGateway("openai/gpt-5.4", opts)
expect(upstream?.reasoning_effort).toBe("xhigh")
})
test("variants() output for openai/gpt-5.4 lands xhigh on the wire", async () => {
// The other half of the bug: workflow `variant: xhigh` flows through variants()
// and must reach the wire. variants() returns the providerOptions payload
// unwrapped; providerOptions() wraps it under the SDK key.
const variants = ProviderTransform.variants(cfModel("openai/gpt-5.4"))
expect(variants.xhigh).toEqual({ reasoningEffort: "xhigh" })
const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), variants.xhigh)
const upstream = await callThroughGateway("openai/gpt-5.4", opts)
expect(upstream?.reasoning_effort).toBe("xhigh")
})
test("legacy buggy key 'cloudflare-ai-gateway' does NOT reach the wire (proves the bug)", async () => {
// Sanity: confirms the bug class. If a future change accidentally restores
// providerID-keyed providerOptions, this test fails before users notice.
const upstream = await callThroughGateway("openai/gpt-5.4", {
"cloudflare-ai-gateway": { reasoningEffort: "high" },
})
expect(upstream?.reasoning_effort).toBeUndefined()
})
})

View File

@@ -2883,6 +2883,36 @@ describe("ProviderTransform.variants", () => {
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
})
test("dotted gpt-5.x ids include 'minimal' (regression: matcher used to miss gpt-5.4)", () => {
const model = createMockModel({
id: "gpt-5.4",
providerID: "openai",
api: {
id: "gpt-5.4",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
release_date: "2026-03-05",
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
})
test("gpt-50 (lookalike) does not get gpt-5 family treatment", () => {
const model = createMockModel({
id: "gpt-50",
providerID: "openai",
api: {
id: "gpt-50",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
release_date: "2024-01-01",
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
})
})
describe("@ai-sdk/anthropic", () => {
@@ -3330,4 +3360,83 @@ describe("ProviderTransform.variants", () => {
expect(result).toEqual({})
})
})
describe("ai-gateway-provider (cloudflare-ai-gateway)", () => {
const cfModel = (apiId: string, releaseDate = "2024-01-01") =>
createMockModel({
id: `cloudflare-ai-gateway/${apiId}`,
providerID: "cloudflare-ai-gateway",
api: {
id: apiId,
url: "https://gateway.ai.cloudflare.com/v1/compat",
npm: "ai-gateway-provider",
},
release_date: releaseDate,
})
test("openai gpt-5.4 includes xhigh effort (regression: variant=xhigh used to be silently ignored)", () => {
const result = ProviderTransform.variants(cfModel("openai/gpt-5.4", "2026-03-05"))
expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" })
expect(result.high).toEqual({ reasoningEffort: "high" })
expect(Object.keys(result)).toContain("minimal")
})
test("openai gpt-5.2-codex includes xhigh", () => {
const result = ProviderTransform.variants(cfModel("openai/gpt-5.2-codex", "2025-12-11"))
expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" })
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
})
test("openai gpt-4o (no reasoning) returns empty", () => {
const model = cfModel("openai/gpt-4o")
model.capabilities.reasoning = false
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
test("non-openai upstream falls back to widely-supported OAI efforts", () => {
const result = ProviderTransform.variants(cfModel("anthropic/claude-sonnet-4-6"))
expect(result).toEqual({
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
high: { reasoningEffort: "high" },
})
})
})
})
describe("ProviderTransform.providerOptions - ai-gateway-provider", () => {
const createModel = (overrides: Partial<any> = {}) =>
({
id: "cloudflare-ai-gateway/openai/gpt-5.4",
providerID: "cloudflare-ai-gateway",
api: {
id: "openai/gpt-5.4",
url: "https://gateway.ai.cloudflare.com/v1/compat",
npm: "ai-gateway-provider",
},
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 1, output: 1, cache: { read: 0, write: 0 } },
limit: { context: 1_000_000, output: 128_000 },
status: "active",
options: {},
headers: {},
release_date: "2026-03-05",
...overrides,
}) as any
test("routes options under openaiCompatible (the key @ai-sdk/openai-compatible reads)", () => {
// Regression: previously fell back to providerID="cloudflare-ai-gateway",
// which @ai-sdk/openai-compatible never reads, silently dropping reasoningEffort.
const result = ProviderTransform.providerOptions(createModel(), { reasoningEffort: "high" })
expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } })
})
})