From 85c140fd92dad14b453d5fbded807935c85e8865 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 3 May 2026 19:01:46 +1000 Subject: [PATCH 1/4] refactor: extract openaiReasoningEfforts helper, fix gpt-5.x family detection The OpenAI variants block held two undocumented date literals ("2025-11-13", "2025-12-04") and a matcher that missed dotted gpt-5.x ids. The dates gate which reasoning_effort tiers a model exposes, mirroring OpenAI's API rollout schedule, but nothing in the file said so. Extract OPENAI_NONE_EFFORT_RELEASE_DATE / OPENAI_XHIGH_EFFORT_RELEASE_DATE with a comment naming what the dates mean, and lift the effort selection into openaiReasoningEfforts(id, releaseDate). Replace the "id.includes('gpt-5-') || id === 'gpt-5'" check with an anchored regex that matches gpt-5, gpt-5-nano, gpt-5.4, openai/gpt-5.5, and rejects gpt-50 / gpt-5o. The regex change is a real behaviour fix: gpt-5.x models now correctly expose the "minimal" reasoning_effort tier, which OpenAI's API has accepted on the gpt-5 family since launch. Previously variant=minimal was a no-op for any gpt-5.x model. Adds two regression tests pinning both halves: the dotted id now gets minimal, and the gpt-50 lookalike still does not. --- packages/opencode/src/provider/transform.ts | 55 ++++++++++++------- .../opencode/test/provider/transform.test.ts | 30 ++++++++++ 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 2fa7649c75f9..66763da56c3d 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -427,6 +427,36 @@ export function topK(model: Provider.Model) { const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +// OpenAI rolled out the `none` reasoning_effort tier on this date (Responses API). +// Models released before it 400 on `reasoning_effort: "none"`, so we only expose +// it as a variant for models new enough to accept it. +const OPENAI_NONE_EFFORT_RELEASE_DATE = "2025-11-13" + +// OpenAI rolled out the `xhigh` reasoning_effort tier on this date. Same reasoning. +const OPENAI_XHIGH_EFFORT_RELEASE_DATE = "2025-12-04" + +// Matches members of the gpt-5 family across the id formats we encounter: +// "gpt-5", "gpt-5-nano", "gpt-5.4", "openai/gpt-5.4-codex". +// Anchored to start-of-string or "/" so it doesn't false-match "gpt-50" or "gpt-5o". +const GPT5_FAMILY_RE = /(?:^|\/)gpt-5(?:[.-]|$)/ + +// Computes the reasoning_effort tiers an OpenAI (or OpenAI-compatible upstream +// routed through it, e.g. cf-ai-gateway) model exposes. Returns null for models +// with no tunable effort knob (gpt-5-pro). Effort order: weakest → strongest. +function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | null { + const id = apiId.toLowerCase() + if (id === "gpt-5-pro" || id === "openai/gpt-5-pro") return null + if (id.includes("codex")) { + if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] + return [...WIDELY_SUPPORTED_EFFORTS] + } + const efforts = [...WIDELY_SUPPORTED_EFFORTS] + if (GPT5_FAMILY_RE.test(id)) efforts.unshift("minimal") + if (releaseDate >= OPENAI_NONE_EFFORT_RELEASE_DATE) efforts.unshift("none") + if (releaseDate >= OPENAI_XHIGH_EFFORT_RELEASE_DATE) efforts.push("xhigh") + return efforts +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) { return ["low", "medium", "high", "xhigh", "max"] @@ -595,28 +625,12 @@ export function variants(model: Provider.Model): Record { - if (id.includes("codex")) { - if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] - return WIDELY_SUPPORTED_EFFORTS - } - const arr = [...WIDELY_SUPPORTED_EFFORTS] - if (id.includes("gpt-5-") || id === "gpt-5") { - arr.unshift("minimal") - } - if (model.release_date >= "2025-11-13") { - arr.unshift("none") - } - if (model.release_date >= "2025-12-04") { - arr.push("xhigh") - } - return arr - }) + const efforts = openaiReasoningEfforts(model.api.id, model.release_date) + if (!efforts) return {} return Object.fromEntries( - openaiEfforts.map((effort) => [ + efforts.map((effort) => [ effort, { reasoningEffort: effort, @@ -625,6 +639,7 @@ export function variants(model: Provider.Model): Record { 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", () => { From a98026011a29bc19ed5907a1d1cbb72cca7c5cd3 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 3 May 2026 19:02:27 +1000 Subject: [PATCH 2/4] fix(cf-ai-gateway): route provider options through openaiCompatible key Variant input (variant: xhigh) and provider options (provider.cloudflare-ai-gateway.models..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. --- packages/opencode/src/provider/transform.ts | 22 ++++ .../test/provider/cf-ai-gateway-e2e.test.ts | 110 ++++++++++++++++++ .../opencode/test/provider/transform.test.ts | 79 +++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 66763da56c3d..3899e3561c72 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -41,6 +41,13 @@ function sdkKey(npm: string): string | undefined { return "gateway" case "@openrouter/ai-sdk-provider": return "openrouter" + case "ai-gateway-provider": + // ai-gateway-provider/unified wraps createOpenAICompatible({ name: "Unified" }), + // and @ai-sdk/openai-compatible parses compatibleOptions from one of + // "openai-compatible" / "openaiCompatible" / "Unified" / "unified". The + // "openai-compatible" key emits a deprecation warning at runtime, so we + // pick the camelCase form the SDK now treats as canonical. + return "openaiCompatible" } return undefined } @@ -506,6 +513,21 @@ export function variants(model: Provider.Model): Record [effort, { reasoning: { effort } }])) + case "ai-gateway-provider": { + // Cloudflare AI Gateway routes every upstream through its OpenAI-compatible + // /v1/compat endpoint, so the body is always OAI-shaped. The gateway + // translates `reasoning_effort` to the upstream provider's native control + // (e.g. Anthropic thinking budgets) when needed. Variants therefore stay + // OAI-style for all upstreams, with an extended effort set for OpenAI + // models that support it. + if (model.api.id.startsWith("openai/")) { + const efforts = openaiReasoningEfforts(model.api.id, model.release_date) + if (!efforts) return {} + return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])) + } + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + } + case "@ai-sdk/gateway": if (model.id.includes("anthropic")) { if (adaptiveEfforts) { diff --git a/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts new file mode 100644 index 000000000000..386a766934c1 --- /dev/null +++ b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts @@ -0,0 +1,110 @@ +// 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 { generateText } from "ai" +import { createAiGateway } from "ai-gateway-provider" +import { createUnified } from "ai-gateway-provider/providers/unified" +import { ProviderTransform } from "@/provider/transform" + +type Captured = { url: string; outerBody: any } + +const realFetch = globalThis.fetch +let captured: Captured | null = null + +beforeEach(() => { + captured = null + globalThis.fetch = (async (input: any, init?: any) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + if (url.startsWith("https://gateway.ai.cloudflare.com/")) { + const bodyText = 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) + }) as typeof fetch +}) + +afterEach(() => { + globalThis.fetch = realFetch +}) + +const cfModel = (apiId: string, releaseDate = "2026-03-05"): any => ({ + id: `cloudflare-ai-gateway/${apiId}`, + providerID: "cloudflare-ai-gateway", + 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, +}) + +async function callThroughGateway(apiId: string, providerOptions: Record) { + const aigateway = createAiGateway({ accountId: "test", gateway: "test", apiKey: "test" }) + const unified = createUnified() + await generateText({ model: aigateway(unified(apiId)), prompt: "hi", providerOptions }) + // ai-gateway-provider sends an array; each entry's `query` is the upstream body. + return captured?.outerBody?.[0]?.query as Record | undefined +} + +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() + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c3007258a359..81b0f9ead625 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3360,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 = {}) => + ({ + 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" } }) + }) }) From 291d329ed7ae5851f63a99b1a78d76c5385cd251 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 4 May 2026 13:09:27 +1000 Subject: [PATCH 3/4] docs(transform): replace unicode arrow with ASCII in openaiReasoningEfforts comment A reviewer comment on PR #25573 flagged that the new comment introduced a unicode arrow inconsistent with the repo's ASCII-default style for code comments. The arrow shows up as a glyph that does not survive every editor or terminal cleanly and breaks grep on plain ASCII. Reword "weakest -> strongest" using ASCII so the comment matches the surrounding style guidelines without changing its meaning. --- packages/opencode/src/provider/transform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 3899e3561c72..26c73e009d56 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -449,7 +449,7 @@ const GPT5_FAMILY_RE = /(?:^|\/)gpt-5(?:[.-]|$)/ // Computes the reasoning_effort tiers an OpenAI (or OpenAI-compatible upstream // routed through it, e.g. cf-ai-gateway) model exposes. Returns null for models -// with no tunable effort knob (gpt-5-pro). Effort order: weakest → strongest. +// with no tunable effort knob (gpt-5-pro). Effort order: weakest to strongest. function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | null { const id = apiId.toLowerCase() if (id === "gpt-5-pro" || id === "openai/gpt-5-pro") return null From 6ae21d020197b5fbb5960c8636145dd3c6ec3598 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 4 May 2026 13:09:39 +1000 Subject: [PATCH 4/4] test(cf-ai-gateway): tighten e2e regression test types and ASCII-ify comments Reviewer feedback on PR #25573 flagged two issues in the new regression test: the file used em dashes and unicode arrows in comments against the repo's ASCII-default style, and several `any` annotations (captured body, fetch parameters, mock model, providerOptions) bypassed the type system in a file whose entire purpose is catching upstream contract drift. The wide `any` annotations meant a future change to `Provider.Model` or to the AI SDK's `providerOptions` shape would silently typecheck instead of surfacing here, which defeats the regression test. The comments used unicode purely cosmetically. Replace `any` with the actual types the runtime uses: `Provider.Model` for the mock factory (constructed via `ModelID.make` / `ProviderID.make` to satisfy the branded ID schema), `Record>` for providerOptions to match what `generateText` accepts, `Parameters` for the stub's signature, and a typed `isRecord` guard to narrow parsed JSON without an `any` cast. Preserve Bun's `preconnect` method on the stub via `Object.assign` so the assignment satisfies `typeof fetch` honestly. Convert the unicode arrows and em dashes in the file's comments to ASCII. All 283 provider tests still pass and `bunx tsc --noEmit` is clean. --- .../test/provider/cf-ai-gateway-e2e.test.ts | 57 +++++++++++++------ 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts index 386a766934c1..5fab6a492e41 100644 --- a/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts +++ b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts @@ -4,25 +4,37 @@ // 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. +// 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: any } +type Captured = { url: string; outerBody: unknown } +type ProviderOptions = Record> const realFetch = globalThis.fetch let captured: Captured | null = null +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + beforeEach(() => { captured = null - globalThis.fetch = (async (input: any, init?: any) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + const handle = async ( + input: Parameters[0], + init?: Parameters[1], + ): Promise => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url if (url.startsWith("https://gateway.ai.cloudflare.com/")) { - const bodyText = init?.body ?? "" + const bodyText = typeof init?.body === "string" ? init.body : "" captured = { url, outerBody: bodyText ? JSON.parse(bodyText) : null } return new Response( JSON.stringify({ @@ -37,16 +49,20 @@ beforeEach(() => { ) } return realFetch(input, init) - }) as typeof fetch + } + // `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"): any => ({ - id: `cloudflare-ai-gateway/${apiId}`, - providerID: "cloudflare-ai-gateway", +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, @@ -65,21 +81,30 @@ const cfModel = (apiId: string, releaseDate = "2026-03-05"): any => ({ release_date: releaseDate, }) -async function callThroughGateway(apiId: string, providerOptions: Record) { +// 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 | 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 }) - // ai-gateway-provider sends an array; each entry's `query` is the upstream body. - return captured?.outerBody?.[0]?.query as Record | undefined + 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 + // 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" } })