fix(provider): derive reasoning variants from models.dev

This commit is contained in:
Aiden Cline 2026-07-08 19:39:15 -05:00
parent 9644a008a4
commit b41b10faeb
13 changed files with 2675 additions and 2206 deletions

View file

@ -44,6 +44,22 @@ const Cost = Schema.Struct({
),
})
export const ReasoningOption = Schema.Union([
Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.Union([Schema.String, Schema.Null])),
}),
Schema.Struct({
type: Schema.Literal("toggle"),
}),
Schema.Struct({
type: Schema.Literal("budget_tokens"),
min: Schema.optional(Schema.Finite),
max: Schema.optional(Schema.Finite),
}),
])
export type ReasoningOption = typeof ReasoningOption.Type
export const Model = Schema.Struct({
id: Schema.String,
name: Schema.String,
@ -51,6 +67,7 @@ export const Model = Schema.Struct({
release_date: Schema.String,
attachment: Schema.Boolean,
reasoning: Schema.Boolean,
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
temperature: Schema.Boolean,
tool_call: Schema.Boolean,
interleaved: Schema.optional(

View file

@ -2,8 +2,11 @@ import { define } from "./internal"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ModelsDev } from "../models-dev"
import { ProviderV2 } from "../provider"
import { ReasoningVariants } from "../reasoning-variants"
import { ConfigProviderOptionsV1 } from "../v1/config/provider-options"
function released(date: string) {
const time = Date.parse(date)
@ -69,10 +72,29 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"]
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
}
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): ModelV2Info["variants"] {
const npm = model.provider?.npm ?? provider.npm
const lowerer = ConfigProviderOptionsV1.get(npm)
return Object.entries(ReasoningVariants.generate(npm, model.reasoning_options)).map(([id, settings]) => ({
id: ModelV2.VariantID.make(id),
headers: {},
body: lowerer.request(settings),
}))
}
function modeName(model: ModelsDev.Model, mode: string) {
return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
}
function mergeVariants(model: ModelV2Info, next: ModelV2Info["variants"]) {
const existing = new Map(model.variants.map((variant) => [variant.id, variant]))
const nextIDs = new Set(next.map((variant) => variant.id))
model.variants = [
...next.map((variant) => existing.get(variant.id) ?? variant),
...model.variants.filter((variant) => !nextIDs.has(variant.id)),
]
}
function applyModel(
draft: ModelV2Info,
model: ModelsDev.Model,
@ -80,6 +102,7 @@ function applyModel(
readonly name?: string
readonly cost?: ModelV2Info["cost"]
readonly request?: NonNullable<NonNullable<ModelsDev.Model["experimental"]>["modes"]>[string]["provider"]
readonly variants?: ModelV2Info["variants"]
} = {},
) {
draft.name = input.name ?? model.name
@ -102,7 +125,7 @@ function applyModel(
input: [...(model.modalities?.input ?? [])],
output: [...(model.modalities?.output ?? [])],
}
draft.variants = []
mergeVariants(draft, input.variants ?? [])
draft.time.released = released(model.release_date)
draft.cost = input.cost ?? cost(model.cost)
draft.status = model.status ?? "active"
@ -161,13 +184,17 @@ export const ModelsDevPlugin = define({
for (const model of Object.values(item.models)) {
const baseCost = cost(model.cost)
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost }))
const variants = reasoningVariants(item, model)
catalog.model.update(providerID, model.id, (draft) =>
applyModel(draft, model, { cost: baseCost, variants }),
)
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
applyModel(draft, model, {
name: modeName(model, mode),
cost: mergeCost(baseCost, options.cost),
request: options.provider,
variants,
}),
)
}

View file

@ -0,0 +1,62 @@
export * as ReasoningVariants from "./reasoning-variants"
import type { ModelsDev } from "./models-dev"
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
export function generate(npm: string | undefined, options: ReadonlyArray<ModelsDev.ReasoningOption> | undefined) {
const effort = options?.find((option) => option.type === "effort")
if (effort?.type === "effort") {
return Object.fromEntries(
effort.values.flatMap((value) => {
const raw: unknown = value
const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined
if (id === undefined) return []
const settings = settingsForEffort(npm, id)
return settings ? [[id, settings] as const] : []
}),
)
}
const budget = options?.find((option) => option.type === "budget_tokens")
if (budget?.type !== "budget_tokens") return {}
const max = budget.max
const high = max === undefined ? Math.max(budget.min ?? 0, 16_000) : Math.min(Math.max(budget.min ?? 0, 16_000), max)
return Object.fromEntries(
[{ id: "high", budget: high }, ...(max === undefined || max === high ? [] : [{ id: "max", budget: max }])].flatMap(
(item) => {
const settings = settingsForBudget(npm, item.budget)
return settings ? [[item.id, settings] as const] : []
},
),
)
}
function settingsForEffort(npm: string | undefined, effort: string) {
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } }
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
return { thinking: { type: "adaptive", display: "summarized" }, effort }
}
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
}
if (npm === "@ai-sdk/azure") return { reasoningEffort: effort }
if (npm === "@ai-sdk/openai") {
return {
reasoningEffort: effort,
reasoningSummary: "auto",
include: OPENAI_INCLUDE_ENCRYPTED_REASONING,
}
}
if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort }
}
function settingsForBudget(npm: string | undefined, budget: number) {
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } }
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
return { thinking: { type: "enabled", budgetTokens: budget } }
}
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
}
}

View file

@ -0,0 +1,67 @@
{
"openai": {
"id": "openai",
"name": "OpenAI",
"env": ["OPENAI_API_KEY"],
"npm": "@ai-sdk/openai",
"api": "https://api.openai.com/v1",
"models": {
"gpt-reasoning": {
"id": "gpt-reasoning",
"name": "GPT Reasoning",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [
{ "type": "effort", "values": [null, "low", "high"] },
{ "type": "budget_tokens", "min": 1024, "max": 64000 },
{ "type": "toggle" }
],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 },
"experimental": {
"modes": {
"high": {
"provider": {
"headers": { "x-mode": "high" },
"body": { "service_tier": "priority" }
}
}
}
}
}
}
},
"anthropic": {
"id": "anthropic",
"name": "Anthropic",
"env": ["ANTHROPIC_API_KEY"],
"npm": "@ai-sdk/anthropic",
"api": "https://api.anthropic.com/v1",
"models": {
"claude-budget": {
"id": "claude-budget",
"name": "Claude Budget",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 64000 }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 }
},
"claude-effort": {
"id": "claude-effort",
"name": "Claude Effort",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "effort", "values": ["low"] }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 }
}
}
}
}

View file

@ -10,5 +10,25 @@
"name": "Local",
"env": [],
"models": {}
},
"opencode": {
"id": "opencode",
"name": "OpenCode",
"env": [],
"npm": "@ai-sdk/openai-compatible",
"models": {
"gpt-5.5": {
"id": "gpt-5.5",
"name": "GPT-5.5",
"release_date": "2026-04-23",
"attachment": true,
"reasoning": true,
"reasoning_options": [{ "type": "effort", "values": [null, "none", "low", "medium", "high", "xhigh"] }],
"temperature": false,
"tool_call": true,
"limit": { "context": 400000, "output": 128000 },
"provider": { "npm": "@ai-sdk/openai", "api": "https://console.opencode.ai/inference/openai/v1" }
}
}
}
}

View file

@ -167,4 +167,94 @@ describe("ModelsDevPlugin", () => {
}),
),
)
it.effect("converts reasoning options into request variants", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
path: Flag.OPENCODE_MODELS_PATH,
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
}
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
return previous
}),
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
)
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
expect(model?.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("none"),
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
headers: {},
body: {
reasoning: { effort: "low", summary: "auto" },
include: ["reasoning.encrypted_content"],
},
})
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
headers: {},
body: {
reasoning: { effort: "high", summary: "auto" },
include: ["reasoning.encrypted_content"],
},
})
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
expect(mode).toMatchObject({
id: "gpt-reasoning-high",
name: "GPT Reasoning High",
request: {
headers: { "x-mode": "high" },
body: { service_tier: "priority" },
},
})
expect(mode?.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("none"),
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
headers: {},
body: { thinking: { type: "enabled", budget_tokens: 16000 } },
})
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("max"),
headers: {},
body: { thinking: { type: "enabled", budget_tokens: 64000 } },
})
const effortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-effort"))
expect(effortModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
headers: {},
body: {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "low" },
},
})
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
Flag.OPENCODE_MODELS_PATH = previous.path
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
}),
),
)
})

View file

@ -974,6 +974,21 @@ const ProviderInterleaved = Schema.Union([
}),
])
const ProviderReasoningOption = Schema.Union([
Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.String),
}),
Schema.Struct({
type: Schema.Literal("toggle"),
}),
Schema.Struct({
type: Schema.Literal("budget_tokens"),
min: optional(Schema.Finite),
max: optional(Schema.Finite),
}),
])
const ProviderCapabilities = Schema.Struct({
temperature: Schema.Boolean,
reasoning: Schema.Boolean,
@ -1026,6 +1041,7 @@ export const Model = Schema.Struct({
name: Schema.String,
family: optional(Schema.String),
capabilities: ProviderCapabilities,
reasoning_options: optional(Schema.Array(ProviderReasoningOption)),
cost: ProviderCost,
limit: ProviderLimit,
status: ModelStatus,
@ -1189,6 +1205,15 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
return result
}
function reasoningOptions(model: ModelsDev.Model): Model["reasoning_options"] {
return model.reasoning_options?.map((option) => {
if (option.type === "effort") {
return { ...option, values: [...new Set(option.values.map((value) => value ?? "none"))] }
}
return { ...option }
})
}
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
const base: Model = {
id: ModelV2.ID.make(model.id),
@ -1230,6 +1255,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
},
interleaved: model.interleaved ?? false,
},
reasoning_options: reasoningOptions(model),
release_date: model.release_date ?? "",
variants: {},
}
@ -1460,6 +1486,7 @@ const layer = Layer.effect(
? { field: "reasoning_content" }
: false),
},
reasoning_options: existingModel?.reasoning_options,
cost: {
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,

View file

@ -1,9 +1,9 @@
import type { ModelMessage, ToolResultPart } from "ai"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
import { ReasoningVariants } from "@opencode-ai/core/reasoning-variants"
import type * as Provider from "./provider"
import type * as ModelsDev from "@opencode-ai/core/models-dev"
import { iife } from "@/util/iife"
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
@ -516,559 +516,20 @@ export function topK(model: Provider.Model) {
return undefined
}
const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
const OPENAI_GPT5_1_EFFORTS = ["none", ...WIDELY_SUPPORTED_EFFORTS]
const OPENAI_GPT5_2_PLUS_EFFORTS = [...OPENAI_GPT5_1_EFFORTS, "xhigh"]
const OPENAI_GPT5_PRO_EFFORTS = ["high"]
const OPENAI_GPT5_PRO_2_PLUS_EFFORTS = ["medium", "high", "xhigh"]
const OPENAI_GPT5_CHAT_EFFORTS = ["medium"]
const OPENAI_GPT5_CODEX_XHIGH_EFFORTS = [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
const OPENAI_GPT5_CODEX_3_PLUS_EFFORTS = ["none", ...OPENAI_GPT5_CODEX_XHIGH_EFFORTS]
export function variants(model: Provider.Model): Record<string, Record<string, unknown>> {
const generated = ReasoningVariants.generate(model.api.npm, model.reasoning_options)
if (Object.keys(generated).length > 0) return generated
// 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(?:[.-]|$)/
const GPT5_VERSION_RE = /(?:^|\/)gpt-5[.-](\d+)(?:[.-]|$)/
const GPT5_PRO_RE = /(?:^|\/)gpt-5[.-]?pro(?:[.-]|$)/
const GPT5_VERSIONED_PRO_RE = /(?:^|\/)gpt-5[.-]\d+[.-]pro(?:[.-]|$)/
function gpt5Version(apiId: string) {
return Number(GPT5_VERSION_RE.exec(apiId)?.[1]) || undefined
}
function versionedGpt5ReasoningEfforts(apiId: string) {
if (GPT5_VERSIONED_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_2_PLUS_EFFORTS
const version = gpt5Version(apiId)
if (version === undefined) return undefined
if (version === 1) return OPENAI_GPT5_1_EFFORTS
return OPENAI_GPT5_2_PLUS_EFFORTS
}
function gpt5CodexReasoningEfforts(apiId: string) {
if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("codex")) return undefined
const version = gpt5Version(apiId)
if (version !== undefined && version >= 3) return OPENAI_GPT5_CODEX_3_PLUS_EFFORTS
if (apiId.includes("codex-max") || (version !== undefined && version >= 2)) return OPENAI_GPT5_CODEX_XHIGH_EFFORTS
return WIDELY_SUPPORTED_EFFORTS
}
function gpt5ChatReasoningEfforts(apiId: string) {
if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("-chat")) return undefined
return gpt5Version(apiId) === undefined ? [] : OPENAI_GPT5_CHAT_EFFORTS
}
// Computes the reasoning_effort tiers an OpenAI (or OpenAI-compatible upstream
// routed through it, e.g. cf-ai-gateway) model exposes. Effort order: weakest
// to strongest.
function openaiReasoningEfforts(apiId: string, releaseDate: string) {
const id = apiId.toLowerCase()
if (id.includes("deep-research")) return ["medium"]
const chatEfforts = gpt5ChatReasoningEfforts(id)
if (chatEfforts) return chatEfforts
if (GPT5_PRO_RE.test(id)) return OPENAI_GPT5_PRO_EFFORTS
const codexEfforts = gpt5CodexReasoningEfforts(id)
if (codexEfforts) return codexEfforts
const versionedEfforts = versionedGpt5ReasoningEfforts(id)
// GPT-5.1 replaced GPT-5's `minimal` effort with `none`; GPT-5.2+
// additionally accepts `xhigh`. Model pages list the supported subset.
if (versionedEfforts) return versionedEfforts
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 openaiCompatibleReasoningEfforts(id: string) {
const apiId = id.toLowerCase()
const chatEfforts = gpt5ChatReasoningEfforts(apiId)
if (chatEfforts) return chatEfforts
if (GPT5_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_EFFORTS
return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS
}
function anthropicOpus47OrLater(apiId: string) {
// Matches "opus-4.7" (Anthropic/Bedrock/Vertex) and "claude-4.7-opus" (SAP AI Core inverted).
// Greedy \d+ correctly extends to multi-digit majors (e.g. "claude-10.0-opus") for forward compatibility.
const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)|claude-(\d+)[.-](\d+)-opus(?:[.@-]|$)/i.exec(apiId)
if (!version) return false
const major = Number(version[1] ?? version[3])
const minor = Number(version[2] ?? version[4])
return major > 4 || (major === 4 && minor >= 7)
}
function anthropicSonnet5OrLater(apiId: string) {
const version = /sonnet-(\d+)(?:[.@-]|$)|claude-(\d+)-sonnet(?:[.@-]|$)/i.exec(apiId)
if (!version) return false
return Number(version[1] ?? version[2]) >= 5
}
function anthropicAdaptiveEfforts(apiId: string): string[] | null {
if (anthropicOpus47OrLater(apiId) || anthropicSonnet5OrLater(apiId) || apiId.includes("fable-5")) {
return ["low", "medium", "high", "xhigh", "max"]
}
const ids = `${model.id} ${model.api.id}`.toLowerCase()
if (
["opus-4-6", "opus-4.6", "4-6-opus", "4.6-opus", "sonnet-4-6", "sonnet-4.6", "4-6-sonnet", "4.6-sonnet"].some((v) =>
apiId.includes(v),
)
model.api.npm === "@ai-sdk/openai-compatible" &&
["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))
) {
return ["low", "medium", "high", "max"]
}
return null
}
function anthropicOmitsThinking(apiId: string) {
return anthropicOpus47OrLater(apiId) || anthropicSonnet5OrLater(apiId) || apiId.includes("fable-5")
}
function googleThinkingLevelEfforts(apiId: string) {
const id = apiId.toLowerCase()
if (!id.includes("gemini-3")) return ["low", "high"]
if (id.includes("flash-image")) return ["minimal", "high"]
if (id.includes("pro-image")) return ["high"]
if (id.includes("flash")) return ["minimal", "low", "medium", "high"]
return ["low", "medium", "high"]
}
function googleThinkingBudgetMax(apiId: string) {
const id = apiId.toLowerCase()
if (id.includes("2.5") && id.includes("pro") && !id.includes("flash")) return 32_768
return 24_576
}
// SAP's Zod schema drops unknown top-level keys; reasoning controls survive
// only via `modelParams` (catchall), forwarded verbatim by the SAP SDKs.
function wrapInSapModelParams(variants: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
return Object.fromEntries(Object.entries(variants).map(([k, v]) => [k, { modelParams: v }]))
}
function googleThinkingVariants(model: Provider.Model): Record<string, Record<string, any>> {
const id = model.api.id.toLowerCase()
if (id.includes("2.5")) {
return {
high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
max: {
thinkingConfig: { includeThoughts: true, thinkingBudget: googleThinkingBudgetMax(id) },
},
}
}
return Object.fromEntries(
googleThinkingLevelEfforts(id).map((effort) => [
effort,
{ thinkingConfig: { includeThoughts: true, thinkingLevel: effort } },
]),
)
}
export function variants(model: Provider.Model): Record<string, Record<string, any>> {
if (!model.capabilities.reasoning) return {}
const id = model.id.toLowerCase()
const glm52 = ["glm-5.2", "glm-5-2", "glm-5p2"].some(
(name) => id.includes(name) || model.api.id.toLowerCase().includes(name),
)
if (
model.api.id.toLowerCase().includes("minimax-m3") &&
["@ai-sdk/anthropic", "@ai-sdk/openai-compatible"].includes(model.api.npm)
) {
return {
none: { thinking: { type: "disabled" } },
thinking: { thinking: { type: "adaptive" } },
}
}
const adaptiveThinkingOmitted = anthropicOmitsThinking(model.api.id)
const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id)
if (glm52 && model.api.npm === "@openrouter/ai-sdk-provider") {
// OpenRouter maps xhigh to GLM-5.2's native max effort.
return {
high: { reasoning: { effort: "high" } },
xhigh: { reasoning: { effort: "xhigh" } },
}
}
if (glm52 && model.api.npm === "@ai-sdk/openai-compatible") {
return {
high: { reasoningEffort: "high" },
max: { reasoningEffort: "max" },
}
}
if (glm52 && model.api.npm === "@ai-sdk/anthropic") {
return {
high: { effort: "high" },
max: { effort: "max" },
}
}
if (
id.includes("deepseek-chat") ||
id.includes("deepseek-reasoner") ||
id.includes("deepseek-r1") ||
id.includes("deepseek-v3") ||
id.includes("minimax") ||
(id.includes("glm") && !glm52) ||
id.includes("kimi") ||
id.includes("k2p") ||
id.includes("qwen") ||
id.includes("big-pickle")
)
return {}
// see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks
if (id.includes("grok") && id.includes("grok-3-mini")) {
if (model.api.npm === "@openrouter/ai-sdk-provider") {
return {
low: { reasoning: { effort: "low" } },
high: { reasoning: { effort: "high" } },
}
}
return {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
}
}
if (id.includes("grok")) return {}
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
return Object.fromEntries(
(model.api.id.startsWith("openai/") || id.includes("gpt")
? openaiCompatibleReasoningEfforts(model.api.id)
: WIDELY_SUPPORTED_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)
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) {
return Object.fromEntries(
adaptiveEfforts.map((effort) => [
effort,
{
thinking: {
type: "adaptive",
// Newer adaptive-only models default `display` to "omitted", which
// returns empty thinking blocks. Force "summarized" so summaries
// survive (4.6/Sonnet 4.6 already default to "summarized").
...(adaptiveThinkingOmitted ? { display: "summarized" } : {}),
},
effort,
},
]),
)
}
return {
high: {
thinking: {
type: "enabled",
budgetTokens: 16000,
},
},
max: {
thinking: {
type: "enabled",
budgetTokens: 31999,
},
},
}
}
if (model.id.includes("google")) {
if (id.includes("2.5")) {
return {
high: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 16000,
},
},
max: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: googleThinkingBudgetMax(id),
},
},
}
}
return Object.fromEntries(
["low", "high"].map((effort) => [
effort,
{
includeThoughts: true,
thinkingLevel: effort,
},
]),
)
}
return Object.fromEntries(
openaiCompatibleReasoningEfforts(model.api.id).map((effort) => [effort, { reasoningEffort: effort }]),
)
case "@ai-sdk/github-copilot":
if (model.id.includes("gemini")) {
// currently github copilot only returns thinking
return {}
}
if (model.id.includes("claude")) {
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
}
const copilotEfforts = iife(() => {
if (id.includes("5.1-codex-max") || id.includes("5.2") || id.includes("5.3"))
return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
const arr = [...WIDELY_SUPPORTED_EFFORTS]
if (id.includes("gpt-5") && model.release_date >= "2025-12-04") arr.push("xhigh")
return arr
})
return Object.fromEntries(
copilotEfforts.map((effort) => [
effort,
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
case "@ai-sdk/cerebras":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras
case "@ai-sdk/togetherai":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/togetherai
case "@ai-sdk/xai":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/xai
case "@ai-sdk/deepinfra":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra
case "venice-ai-sdk-provider":
// https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort
case "@ai-sdk/openai-compatible":
if (model.api.id.toLowerCase().includes("north-mini-code")) {
return Object.fromEntries(["none", "high"].map((effort) => [effort, { reasoningEffort: effort }]))
}
const efforts = [...WIDELY_SUPPORTED_EFFORTS]
if (model.api.id.toLowerCase().includes("deepseek-v4")) {
efforts.push("max")
}
return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/azure":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure
if (id === "o1-mini") return {}
return Object.fromEntries(
openaiReasoningEfforts(id, model.release_date).map((effort) => [
effort,
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
case "@ai-sdk/amazon-bedrock/mantle":
case "@ai-sdk/openai": {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
const efforts = openaiReasoningEfforts(model.api.id, model.release_date)
return Object.fromEntries(
efforts.map((effort) => [
effort,
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
}
case "@ai-sdk/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
case "@ai-sdk/google-vertex/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
if (adaptiveEfforts) {
let efforts = [...adaptiveEfforts]
if (model.providerID === "github-copilot") {
if (model.api.id.includes("opus-4.7")) {
efforts = ["medium"]
}
// Efforts currently supported are: low, medium, high
efforts = efforts.filter((v) => v !== "max" && v !== "xhigh")
}
return Object.fromEntries(
efforts.map((effort) => [
effort,
{
thinking: {
type: "adaptive",
...(adaptiveThinkingOmitted ? { display: "summarized" } : {}),
},
effort,
},
]),
)
}
if (["opus-4-5", "opus-4.5"].some((v) => model.api.id.includes(v))) {
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { effort }]))
}
return {
high: {
thinking: {
type: "enabled",
budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)),
},
},
max: {
thinking: {
type: "enabled",
budgetTokens: Math.min(31_999, model.limit.output - 1),
},
},
}
case "@ai-sdk/amazon-bedrock":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
if (adaptiveEfforts) {
return Object.fromEntries(
adaptiveEfforts.map((effort) => [
effort,
{
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
...(adaptiveThinkingOmitted ? { display: "summarized" } : {}),
},
},
]),
)
}
// For Anthropic models on Bedrock, use reasoningConfig with budgetTokens
if (model.api.id.includes("anthropic")) {
return {
high: {
reasoningConfig: {
type: "enabled",
budgetTokens: 16000,
},
},
max: {
reasoningConfig: {
type: "enabled",
budgetTokens: 31999,
},
},
}
}
// For Amazon Nova models, use reasoningConfig with maxReasoningEffort
return Object.fromEntries(
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
effort,
{
reasoningConfig: {
type: "enabled",
maxReasoningEffort: effort,
},
},
]),
)
case "@ai-sdk/google-vertex":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex
case "@ai-sdk/google":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai
return googleThinkingVariants(model)
case "@ai-sdk/mistral":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/mistral
// https://docs.mistral.ai/capabilities/reasoning/adjustable
if (!model.capabilities.reasoning) return {}
// Only Mistral Small 4 and Medium 3.5 support reasoning
const MISTRAL_REASONING_IDS = [
"mistral-small-2603",
"mistral-small-latest",
"mistral-medium-3.5",
"mistral-medium-2604",
]
const mistralId = model.api.id.toLowerCase()
if (!MISTRAL_REASONING_IDS.some((id) => mistralId.includes(id))) return {}
return {
high: { reasoningEffort: "high" },
}
case "@ai-sdk/cohere":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/cohere
return {}
case "@ai-sdk/groq":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/groq
const groqEffort = ["none", ...WIDELY_SUPPORTED_EFFORTS]
return Object.fromEntries(
groqEffort.map((effort) => [
effort,
{
reasoningEffort: effort,
},
]),
)
case "@ai-sdk/perplexity":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity
return {}
case "@jerome-benoit/sap-ai-provider-v2": {
if (id.includes("anthropic")) {
if (adaptiveEfforts) {
// Bedrock adaptive splits `effort` out into `output_config` (vs Anthropic
// native which inlines it). Opus 4.7+ flipped `display` default to "omitted".
return wrapInSapModelParams(
Object.fromEntries(
adaptiveEfforts.map((effort) => [
effort,
{
thinking: { type: "adaptive", ...(adaptiveThinkingOmitted ? { display: "summarized" } : {}) },
output_config: { effort },
},
]),
),
)
}
return wrapInSapModelParams({
high: { thinking: { type: "enabled", budget_tokens: 16000 } },
max: { thinking: { type: "enabled", budget_tokens: 31999 } },
})
}
if (id.includes("gemini") && id.includes("2.5")) {
return wrapInSapModelParams(googleThinkingVariants(model))
}
if (id.includes("gpt") || /\bo[1-9]/.test(id)) {
const efforts = openaiReasoningEfforts(id, model.release_date)
return wrapInSapModelParams(Object.fromEntries(efforts.map((effort) => [effort, { reasoning_effort: effort }])))
}
return wrapInSapModelParams(
Object.fromEntries(["low", "medium", "high"].map((effort) => [effort, { reasoning_effort: effort }])),
)
}
}
return {}
}

View file

@ -109,18 +109,6 @@ describe("cf-ai-gateway end-to-end (regression: #24432)", () => {
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.

View file

@ -1411,6 +1411,8 @@ test("models.dev normalization fills required response fields", () => {
id: "gpt-5.4",
name: "GPT-5.4",
family: "gpt",
reasoning: true,
reasoning_options: [{ type: "effort", values: [null, "none", "high"] }],
cost: { input: 2.5, output: 15 },
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
},
@ -1420,7 +1422,8 @@ test("models.dev normalization fills required response fields", () => {
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"]
expect(model.api.url).toBe("")
expect(model.capabilities.temperature).toBe(false)
expect(model.capabilities.reasoning).toBe(false)
expect(model.capabilities.reasoning).toBe(true)
expect(model.reasoning_options).toEqual([{ type: "effort", values: ["none", "high"] }])
expect(model.capabilities.attachment).toBe(false)
expect(model.capabilities.toolcall).toBe(true)
expect(model.release_date).toBe("")
@ -1430,8 +1433,7 @@ it.instance("model variants are generated for reasoning models", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
// Claude sonnet 4 has reasoning capability
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-opus-4-7"]
expect(model.capabilities.reasoning).toBe(true)
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
@ -1443,7 +1445,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-opus-4-7"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
// max variant should still exist
@ -1453,7 +1455,7 @@ it.instance(
config: {
provider: {
anthropic: {
models: { "claude-sonnet-4-20250514": { variants: { high: { disabled: true } } } },
models: { "claude-opus-4-7": { variants: { high: { disabled: true } } } },
},
},
},
@ -1465,7 +1467,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-opus-4-7"]
expect(model.variants!["high"]).toBeDefined()
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
}),
@ -1474,7 +1476,7 @@ it.instance(
provider: {
anthropic: {
models: {
"claude-sonnet-4-20250514": {
"claude-opus-4-7": {
variants: { high: { thinking: { type: "enabled", budgetTokens: 20000 } } },
},
},
@ -1489,7 +1491,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-opus-4-7"]
expect(model.variants!["max"]).toBeDefined()
expect(model.variants!["max"].disabled).toBeUndefined()
expect(model.variants!["max"].customField).toBe("test")
@ -1499,7 +1501,7 @@ it.instance(
provider: {
anthropic: {
models: {
"claude-sonnet-4-20250514": {
"claude-opus-4-7": {
variants: { max: { disabled: false, customField: "test" } },
},
},
@ -1514,7 +1516,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-opus-4-7"]
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBe(0)
}),
@ -1523,8 +1525,14 @@ it.instance(
provider: {
anthropic: {
models: {
"claude-sonnet-4-20250514": {
variants: { high: { disabled: true }, max: { disabled: true } },
"claude-opus-4-7": {
variants: {
low: { disabled: true },
medium: { disabled: true },
high: { disabled: true },
xhigh: { disabled: true },
max: { disabled: true },
},
},
},
},
@ -1538,7 +1546,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-opus-4-7"]
expect(model.variants!["high"]).toBeDefined()
// Should have both the generated thinking config and the custom option
expect(model.variants!["high"].thinking).toBeDefined()
@ -1549,7 +1557,7 @@ it.instance(
provider: {
anthropic: {
models: {
"claude-sonnet-4-20250514": { variants: { high: { extraOption: "custom-value" } } },
"claude-opus-4-7": { variants: { high: { extraOption: "custom-value" } } },
},
},
},

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2059,6 +2059,20 @@ export type Model = {
field: "reasoning" | "reasoning_content" | "reasoning_details"
}
}
reasoning_options?: Array<
| {
type: "effort"
values: Array<string>
}
| {
type: "toggle"
}
| {
type: "budget_tokens"
min?: number
max?: number
}
>
cost: {
input: number
output: number