This commit is contained in:
@@ -41,6 +41,13 @@ function sdkKey(npm: string): string | undefined {
|
|||||||
return "gateway"
|
return "gateway"
|
||||||
case "@openrouter/ai-sdk-provider":
|
case "@openrouter/ai-sdk-provider":
|
||||||
return "openrouter"
|
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
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -427,6 +434,36 @@ export function topK(model: Provider.Model) {
|
|||||||
const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
|
const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
|
||||||
const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
|
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 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
|
||||||
|
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 {
|
function anthropicAdaptiveEfforts(apiId: string): string[] | null {
|
||||||
if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) {
|
if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) {
|
||||||
return ["low", "medium", "high", "xhigh", "max"]
|
return ["low", "medium", "high", "xhigh", "max"]
|
||||||
@@ -476,6 +513,21 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
|||||||
if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("claude")) return {}
|
if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("claude")) return {}
|
||||||
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
|
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [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":
|
case "@ai-sdk/gateway":
|
||||||
if (model.id.includes("anthropic")) {
|
if (model.id.includes("anthropic")) {
|
||||||
if (adaptiveEfforts) {
|
if (adaptiveEfforts) {
|
||||||
@@ -595,28 +647,12 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
case "@ai-sdk/openai":
|
case "@ai-sdk/openai": {
|
||||||
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
|
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
|
||||||
if (id === "gpt-5-pro") return {}
|
const efforts = openaiReasoningEfforts(model.api.id, model.release_date)
|
||||||
const openaiEfforts = iife(() => {
|
if (!efforts) return {}
|
||||||
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
|
|
||||||
})
|
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
openaiEfforts.map((effort) => [
|
efforts.map((effort) => [
|
||||||
effort,
|
effort,
|
||||||
{
|
{
|
||||||
reasoningEffort: effort,
|
reasoningEffort: effort,
|
||||||
@@ -625,6 +661,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case "@ai-sdk/anthropic":
|
case "@ai-sdk/anthropic":
|
||||||
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
|
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2883,6 +2883,36 @@ describe("ProviderTransform.variants", () => {
|
|||||||
const result = ProviderTransform.variants(model)
|
const result = ProviderTransform.variants(model)
|
||||||
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
|
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", () => {
|
describe("@ai-sdk/anthropic", () => {
|
||||||
@@ -3330,4 +3360,83 @@ describe("ProviderTransform.variants", () => {
|
|||||||
expect(result).toEqual({})
|
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" } })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user