Compare commits

...
Author SHA1 Message Date
Aiden Cline 9ef5212e09 Revert "fix(provider): preserve legacy reasoning fallback guards"
This reverts commit 5d3588962a.
2026-07-01 11:51:19 -05:00
Aiden Cline 5d3588962a fix(provider): preserve legacy reasoning fallback guards 2026-07-01 11:43:38 -05:00
Aiden Cline 3e9cbc9178 fix(provider): keep budget variants distinct 2026-07-01 11:18:35 -05:00
Aiden Cline 60493ab948 fix(provider): clamp reasoning budgets to output cap 2026-07-01 11:11:33 -05:00
Aiden Cline 383fe107ed fix(provider): preserve custom model variant fallbacks 2026-07-01 10:49:58 -05:00
Aiden Cline 6ea601eb37 fix(provider): keep reasoning options structured 2026-06-30 23:37:21 -05:00
Aiden Cline 6413d66aa2 fix(provider): loosen models.dev reasoning options 2026-06-30 23:22:41 -05:00
Aiden Cline 6e3c76e86e Merge remote-tracking branch 'origin/dev' into reasoning-model-api
# Conflicts:
#	packages/opencode/src/provider/transform.ts
#	packages/opencode/test/provider/transform.test.ts
2026-06-30 23:04:06 -05:00
Aiden Cline 1cb7b81552 fix(provider): force openai reasoning variants 2026-06-30 21:42:19 -05:00
Aiden Cline 3f0f90e94e fix(provider): restore gateway openai fallback efforts 2026-06-30 18:44:35 -05:00
Aiden Cline 36ed0f4878 Revert "fix(provider): prefer gemini 2.5 reasoning budgets"
This reverts commit 49a02e2b95.
2026-06-30 17:41:07 -05:00
Aiden Cline 49a02e2b95 fix(provider): prefer gemini 2.5 reasoning budgets 2026-06-30 17:09:17 -05:00
Aiden Cline e9c243cb67 fix(provider): support openrouter reasoning budgets 2026-06-30 16:59:56 -05:00
Aiden Cline eb6efbccfd fix(provider): support mistral reasoning options 2026-06-30 15:50:47 -05:00
Aiden Cline c7cbbda8f2 fix(provider): nest gateway google thinking options 2026-06-30 15:43:00 -05:00
Aiden Cline 996a0628ef feat(provider): use models.dev reasoning options 2026-06-30 15:25:14 -05:00
Aiden Cline ed20d69d80 test(opencode): refresh models fixture 2026-06-30 15:11:27 -05:00
9 changed files with 181058 additions and 112898 deletions
+27
View File
@@ -44,6 +44,32 @@ const Cost = Schema.Struct({
),
})
const ReasoningEffortValue = Schema.Union([Schema.Null, Schema.String])
export const KnownReasoningOption = Schema.Union([
Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(ReasoningEffortValue),
}),
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 KnownReasoningOption = typeof KnownReasoningOption.Type
export const ReasoningOption = Schema.Union([
KnownReasoningOption,
Schema.Struct({
type: Schema.String,
}),
])
export type ReasoningOption = typeof ReasoningOption.Type
export const Model = Schema.Struct({
id: Schema.String,
name: Schema.String,
@@ -51,6 +77,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(
@@ -1022,6 +1022,7 @@ export const Model = Schema.Struct({
name: Schema.String,
family: optional(Schema.String),
capabilities: ProviderCapabilities,
reasoning_options: optional(Schema.Array(ModelsDev.KnownReasoningOption)),
cost: ProviderCost,
limit: ProviderLimit,
status: ModelStatus,
@@ -1185,6 +1186,39 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
return result
}
type ReasoningOption = NonNullable<Model["reasoning_options"]>[number]
function reasoningOptions(model: ModelsDev.Model): Model["reasoning_options"] {
if (!Array.isArray(model.reasoning_options)) return undefined
const normalized = model.reasoning_options.flatMap((option) => {
const normalized = normalizeReasoningOption(option)
return normalized ? [normalized] : []
})
return normalized.length > 0 || model.reasoning_options.length === 0 ? normalized : undefined
}
function normalizeReasoningOption(option: unknown): ReasoningOption | undefined {
if (!isRecord(option) || typeof option.type !== "string") return undefined
if (option.type === "effort") {
if (!Array.isArray(option.values)) return undefined
return {
type: "effort",
values: option.values.filter((value): value is string | null => value === null || typeof value === "string"),
}
}
if (option.type === "toggle") return { type: "toggle" }
if (option.type === "budget_tokens") {
const min = typeof option.min === "number" && Number.isFinite(option.min) ? option.min : undefined
const max = typeof option.max === "number" && Number.isFinite(option.max) ? option.max : undefined
return {
type: "budget_tokens",
...(min === undefined ? {} : { min }),
...(max === undefined ? {} : { max }),
}
}
return undefined
}
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
const base: Model = {
id: ModelV2.ID.make(model.id),
@@ -1226,6 +1260,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
},
interleaved: model.interleaved ?? false,
},
reasoning_options: reasoningOptions(model),
release_date: model.release_date ?? "",
variants: {},
}
@@ -1404,6 +1439,7 @@ const layer = Layer.effect(
}
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
const catalogModel = existing?.models[model.id ?? modelID]
const existingModel = parsed.models[model.id ?? modelID]
const apiID = model.id ?? existingModel?.api.id ?? modelID
const apiNpm =
@@ -1456,6 +1492,10 @@ const layer = Layer.effect(
? { field: "reasoning_content" }
: false),
},
// Config models don't define reasoning_options. Inherit them only
// when the config model is explicitly based on a catalog model;
// pure custom models keep undefined so legacy heuristics still run.
reasoning_options: catalogModel?.reasoning_options,
cost: {
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
+305 -429
View File
@@ -3,7 +3,6 @@ import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
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]
@@ -517,134 +516,8 @@ export function topK(model: Provider.Model) {
}
const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
const ANTHROPIC_EFFORTS = [...WIDELY_SUPPORTED_EFFORTS, "xhigh", "max"]
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]
// 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"]
}
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),
)
) {
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.
@@ -652,31 +525,298 @@ function wrapInSapModelParams(variants: Record<string, Record<string, any>>): Re
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) },
},
}
}
function idIncludes(model: Provider.Model, value: string) {
return model.id.toLowerCase().includes(value) || model.api.id.toLowerCase().includes(value)
}
function anthropicAdaptiveVariants() {
return Object.fromEntries(
googleThinkingLevelEfforts(id).map((effort) => [
ANTHROPIC_EFFORTS.map((effort) => [
effort,
{
thinking: anthropicAdaptiveThinking(),
effort,
},
]),
)
}
function bedrockAnthropicAdaptiveVariants() {
return Object.fromEntries(
ANTHROPIC_EFFORTS.map((effort) => [
effort,
{
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
display: "summarized",
},
},
]),
)
}
function sapAnthropicAdaptiveVariants() {
return wrapInSapModelParams(
Object.fromEntries(
ANTHROPIC_EFFORTS.map((effort) => [
effort,
{
thinking: anthropicAdaptiveThinking(),
output_config: { effort },
},
]),
),
)
}
function googleThinkingLevelVariants() {
return Object.fromEntries(
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
effort,
{ thinkingConfig: { includeThoughts: true, thinkingLevel: effort } },
]),
)
}
function openAIReasoningEffortVariants() {
return Object.fromEntries(
OPENAI_EFFORTS.map((effort) => [
effort,
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
}
function openAICompatibleReasoningEffortVariants() {
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
}
type ReasoningOption = NonNullable<Provider.Model["reasoning_options"]>[number]
type ReasoningEffortOption = Extract<ReasoningOption, { type: "effort" }>
type ReasoningBudgetOption = Extract<ReasoningOption, { type: "budget_tokens" }>
type ReasoningEffortValue = ReasoningEffortOption["values"][number]
function reasoningOption<T extends ReasoningOption["type"]>(
model: Provider.Model,
type: T,
): Extract<ReasoningOption, { type: T }> | undefined {
return model.reasoning_options?.find((option): option is Extract<ReasoningOption, { type: T }> => option.type === type)
}
function reasoningBudgetVariants(
budget: ReasoningBudgetOption,
make: (budgetTokens: number) => Record<string, any>,
model: Provider.Model,
) {
const max = Math.max(1, Math.min(budget.max ?? 31_999, maxOutputTokens(model) - 1))
const min = Math.min(budget.min ?? 1, max)
const high = Math.max(min, max > 16_000 ? 16_000 : Math.ceil(max / 2))
return {
high: make(high),
max: make(max),
}
}
function anthropicAdaptiveFromOptions(effort: ReasoningEffortOption) {
return effort.values.includes("max")
}
function anthropicAdaptiveThinking() {
return {
type: "adaptive",
display: "summarized",
}
}
function effortVariant(model: Provider.Model, effort: ReasoningEffortValue) {
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
return { reasoning: { effort } }
case "ai-gateway-provider":
return { reasoningEffort: effort }
case "@ai-sdk/gateway":
if (idIncludes(model, "anthropic")) {
const option = reasoningOption(model, "effort")
if (option && anthropicAdaptiveFromOptions(option)) {
return {
thinking: anthropicAdaptiveThinking(),
effort,
}
}
return { effort }
}
if (idIncludes(model, "google")) return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
return { reasoningEffort: effort }
case "@ai-sdk/anthropic":
case "@ai-sdk/google-vertex/anthropic": {
const option = reasoningOption(model, "effort")
if (option && anthropicAdaptiveFromOptions(option)) {
return {
thinking: anthropicAdaptiveThinking(),
effort,
}
}
return { effort }
}
case "@ai-sdk/amazon-bedrock":
if (model.api.id.includes("anthropic")) {
const option = reasoningOption(model, "effort")
const adaptive = option ? anthropicAdaptiveFromOptions(option) : false
return {
reasoningConfig: {
type: adaptive ? "adaptive" : "enabled",
maxReasoningEffort: effort,
...(adaptive ? { display: "summarized" } : {}),
},
}
}
return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }
case "@ai-sdk/google":
case "@ai-sdk/google-vertex":
return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
case "@jerome-benoit/sap-ai-provider-v2": {
if (!model.api.id.includes("anthropic")) return { modelParams: { reasoning_effort: effort } }
const option = reasoningOption(model, "effort")
const modelParams =
option && anthropicAdaptiveFromOptions(option)
? {
thinking: anthropicAdaptiveThinking(),
output_config: { effort },
}
: { output_config: { effort } }
return { modelParams }
}
case "@ai-sdk/azure":
return {
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
}
case "@ai-sdk/amazon-bedrock/mantle":
case "@ai-sdk/openai":
return {
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
}
case "@ai-sdk/github-copilot":
return model.id.includes("claude")
? { reasoningEffort: effort }
: {
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
}
case "@ai-sdk/cerebras":
case "@ai-sdk/togetherai":
case "@ai-sdk/xai":
case "@ai-sdk/deepinfra":
case "venice-ai-sdk-provider":
case "@ai-sdk/openai-compatible":
case "@ai-sdk/groq":
case "@ai-sdk/mistral":
return { reasoningEffort: effort }
}
return undefined
}
function reasoningOptionVariants(model: Provider.Model): Record<string, Record<string, any>> | undefined {
if (!model.reasoning_options) return undefined
const effort = reasoningOption(model, "effort")
const budget = reasoningOption(model, "budget_tokens")
if (effort) {
return Object.fromEntries(
effort.values.flatMap((value): [string, Record<string, any>][] => {
// A null effort is a provider-specific escape hatch in models.dev. Skip
// exposing it as a selectable variant until variant IDs can represent it cleanly.
if (value === null) return []
const variant = effortVariant(model, value)
return variant ? [[value, variant]] : []
}),
)
}
// Toggle-only support needs a product decision about how to expose an on/off
// variant without clobbering users' current effort selection. Existing
// hand-authored toggle variants, like MiniMax M3 below, remain preserved.
if (!budget) return {}
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
return reasoningBudgetVariants(budget, (max_tokens) => ({ reasoning: { max_tokens } }), model)
case "@ai-sdk/gateway":
if (idIncludes(model, "anthropic")) {
return reasoningBudgetVariants(
budget,
(budgetTokens) => ({ thinking: { type: "enabled", budgetTokens } }),
model,
)
}
if (idIncludes(model, "google")) {
return reasoningBudgetVariants(
budget,
(thinkingBudget) => ({ thinkingConfig: { includeThoughts: true, thinkingBudget } }),
model,
)
}
break
case "@ai-sdk/anthropic":
case "@ai-sdk/google-vertex/anthropic":
return reasoningBudgetVariants(budget, (budgetTokens) => ({ thinking: { type: "enabled", budgetTokens } }), model)
case "@ai-sdk/amazon-bedrock":
if (model.api.id.includes("anthropic")) {
return reasoningBudgetVariants(
budget,
(budgetTokens) => ({ reasoningConfig: { type: "enabled", budgetTokens } }),
model,
)
}
break
case "@ai-sdk/google":
case "@ai-sdk/google-vertex":
return reasoningBudgetVariants(
budget,
(thinkingBudget) => ({ thinkingConfig: { includeThoughts: true, thinkingBudget } }),
model,
)
case "@jerome-benoit/sap-ai-provider-v2":
if (model.api.id.includes("anthropic")) {
return wrapInSapModelParams(
reasoningBudgetVariants(budget, (budget_tokens) => ({ thinking: { type: "enabled", budget_tokens } }), model),
)
}
break
}
return {}
}
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),
)
// Historical exception: MiniMax M3's Anthropic-compatible surface exposes an
// explicit thinking toggle that predates models.dev reasoning_options.
if (
model.api.id.toLowerCase().includes("minimax-m3") &&
["@ai-sdk/anthropic", "@ai-sdk/openai-compatible"].includes(model.api.npm)
@@ -686,161 +826,27 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
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 {}
const fromReasoningOptions = reasoningOptionVariants(model)
if (fromReasoningOptions) return fromReasoningOptions
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 } }]),
)
return Object.fromEntries(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 }]))
}
case "ai-gateway-provider":
if (idIncludes(model, "openai")) return openAICompatibleReasoningEffortVariants()
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 }]),
)
if (idIncludes(model, "anthropic")) return anthropicAdaptiveVariants()
if (idIncludes(model, "google")) return googleThinkingLevelVariants()
if (idIncludes(model, "openai")) return openAIReasoningEffortVariants()
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.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) => [
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
effort,
{
reasoningEffort: effort,
@@ -861,125 +867,26 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
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 }]))
return Object.fromEntries(WIDELY_SUPPORTED_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,
},
]),
)
return openAIReasoningEffortVariants()
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,
},
]),
)
return openAIReasoningEffortVariants()
}
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),
},
},
}
return anthropicAdaptiveVariants()
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
if (idIncludes(model, "anthropic")) return bedrockAnthropicAdaptiveVariants()
return Object.fromEntries(
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
effort,
@@ -996,21 +903,11 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
// 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)
return googleThinkingLevelVariants()
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" },
}
@@ -1036,33 +933,12 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
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 }])))
if (idIncludes(model, "anthropic")) return sapAnthropicAdaptiveVariants()
if (idIncludes(model, "google")) return wrapInSapModelParams(googleThinkingLevelVariants())
if (idIncludes(model, "openai")) {
return wrapInSapModelParams(
Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning_effort: effort }])),
)
}
return wrapInSapModelParams(
Object.fromEntries(["low", "medium", "high"].map((effort) => [effort, { reasoning_effort: effort }])),
@@ -58,4 +58,24 @@ describe("provider model status schemas", () => {
}).status,
).toBe("active")
})
test("accepts future models.dev reasoning options without rejecting known structure", () => {
expect(() =>
Schema.decodeUnknownSync(ModelsDev.Model)({
id: "test-model",
name: "Test Model",
release_date: "2026-01-01",
attachment: false,
reasoning: true,
reasoning_options: [
{ type: "effort", values: ["turbo"] },
{ type: "toggle", modes: ["auto", "off"] },
{ type: "new_control", values: ["turbo"] },
],
temperature: true,
tool_call: true,
limit: { context: 128000, output: 8192 },
}),
).not.toThrow()
})
})
@@ -1426,6 +1426,69 @@ test("models.dev normalization fills required response fields", () => {
expect(model.release_date).toBe("")
})
test("models.dev normalization preserves reasoning options for variant generation", () => {
const provider = {
id: "custom",
name: "Custom",
env: [],
npm: "@ai-sdk/openai-compatible",
models: {
reasoner: {
id: "reasoner",
name: "Reasoner",
reasoning: true,
reasoning_options: [{ type: "effort", values: ["high", "xhigh"] }],
cost: { input: 1, output: 2 },
limit: { context: 128_000, output: 32_000 },
},
},
} as unknown as ModelsDev.Provider
const model = Provider.fromModelsDevProvider(provider).models.reasoner
expect(model.reasoning_options).toEqual([{ type: "effort", values: ["high", "xhigh"] }])
expect(Object.keys(model.variants!)).toEqual(["high", "xhigh"])
expect(model.variants!.xhigh).toEqual({ reasoningEffort: "xhigh" })
})
test("models.dev normalization ignores unknown reasoning options and passes new effort strings", () => {
const provider = {
id: "custom",
name: "Custom",
env: [],
npm: "@ai-sdk/openai-compatible",
models: {
reasoner: {
id: "reasoner",
name: "Reasoner",
reasoning: true,
reasoning_options: [
{ type: "future_control", values: ["turbo"] },
{ type: "effort", values: ["turbo", null] },
{ type: "budget_tokens", max: 16_000 },
],
cost: { input: 1, output: 2 },
limit: { context: 128_000, output: 32_000 },
},
future: {
id: "future",
name: "Future",
reasoning: true,
reasoning_options: [{ type: "future_control", values: ["turbo"] }],
cost: { input: 1, output: 2 },
limit: { context: 128_000, output: 32_000 },
},
},
} as unknown as ModelsDev.Provider
const models = Provider.fromModelsDevProvider(provider).models
expect(models.reasoner.reasoning_options).toEqual([
{ type: "effort", values: ["turbo", null] },
{ type: "budget_tokens", max: 16_000 },
])
expect(models.reasoner.variants).toEqual({ turbo: { reasoningEffort: "turbo" } })
expect(models.future.reasoning_options).toBeUndefined()
})
it.instance("model variants are generated for reasoning models", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
@@ -1524,7 +1587,13 @@ it.instance(
anthropic: {
models: {
"claude-sonnet-4-20250514": {
variants: { high: { disabled: true }, max: { disabled: true } },
variants: {
low: { disabled: true },
medium: { disabled: true },
high: { disabled: true },
xhigh: { disabled: true },
max: { disabled: true },
},
},
},
},
@@ -1575,6 +1644,39 @@ it.instance(
},
)
it.instance(
"custom config reasoning model without variants uses fallback variants",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderV2.ID.make("custom-reasoning-fallback")].models["reasoning-model"]
expect(model.reasoning_options).toBeUndefined()
expect(model.variants).toEqual({
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
high: { reasoningEffort: "high" },
})
}),
{
config: {
provider: {
"custom-reasoning-fallback": {
name: "Custom Reasoning Fallback",
npm: "@ai-sdk/openai-compatible",
env: [],
models: {
"reasoning-model": {
name: "Reasoning Model",
reasoning: true,
limit: { context: 128000, output: 16000 },
},
},
options: { apiKey: "test-key" },
},
},
},
},
)
it.instance(
"custom model with variants enabled and disabled",
Effect.gen(function* () {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -316,9 +316,7 @@ describe("tool.read truncation", () => {
it.instance("truncates large file by bytes and sets truncated metadata", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
const target = 60 * 1024
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
const content = `${"x".repeat(80)}\n`.repeat(1_000)
yield* put(path.join(test.directory, "large.json"), content)
const result = yield* run({ filePath: path.join(test.directory, "large.json") })
@@ -163,8 +163,7 @@ describe("Truncate", () => {
it.live("large single-line file truncates with byte message", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const fsys = yield* FSUtil.Service
const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json"))
const content = "a".repeat(Truncate.MAX_BYTES + 1)
const result = yield* svc.output(content)
expect(result.truncated).toBe(true)