From 88c5c2fc39ac01f1a4a81bfc0e0bc1217c0f32e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Thu, 11 Jun 2026 13:19:59 +0200 Subject: [PATCH] fix(core): restore reasoning effort variants in model catalog Add fallback reasoning-effort variants for reasoning-capable GPT, Gemini, and Claude models when models.dev does not provide experimental modes. Preserve models.dev-provided modes unchanged This restores normal TUI variant cycling via ctrl+t and the command palette for models whose catalog entries have reasoning=true but no modes Add regression coverage for reasoning models without modes --- packages/core/src/plugin/models-dev.ts | 112 +++++++++++++++++- .../plugin/fixtures/models-dev-reasoning.json | 56 +++++++++ packages/core/test/plugin/models-dev.test.ts | 41 +++++++ 3 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 packages/core/test/plugin/fixtures/models-dev-reasoning.json diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index b406a1aaf02e..2c4ddbe8ef81 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -42,14 +42,114 @@ function cost(input: ModelsDev.Model["cost"]) { } function variants(model: ModelsDev.Model, packageName?: string) { - return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => { + const existing = Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => { const request = ModelRequest.normalizeAiSdkOptions(packageName, item.provider?.body ?? {}) - return { - id: ModelV2.VariantID.make(id), - headers: { ...(item.provider?.headers ?? {}) }, - ...request, - } + return variant(id, { headers: { ...(item.provider?.headers ?? {}) }, ...request }) }) + if (existing.length > 0) return existing + return fallbackReasoningVariants(model, packageName) +} + +function variant(id: string, input: Partial = {}): ModelV2.Info["variants"][number] { + return { + id: ModelV2.VariantID.make(id), + headers: input.headers ?? {}, + body: input.body ?? {}, + generation: input.generation ?? {}, + options: input.options ?? {}, + } +} + +function fallbackReasoningVariants(model: ModelsDev.Model, packageName?: string) { + if (!model.reasoning) return [] + const id = model.id.toLowerCase() + + if (id.includes("gemini")) return geminiVariants(model, packageName) + if (id.includes("claude")) return claudeVariants(model, packageName) + if (/(?:^|[/.-])(?:gpt-|o[1-9])/.test(id)) return openaiVariants(id, packageName) + return [] +} + +function openaiVariants(id: string, packageName?: string) { + return reasoningEffortVariants(openaiEfforts(id), packageName) +} + +function openaiEfforts(id: string) { + if (/(?:^|\/)gpt-5[.-]?pro(?:[.-]|$)/.test(id)) return ["high"] + if (/(?:^|\/)gpt-5[.-]\d+[.-]pro(?:[.-]|$)/.test(id)) return ["medium", "high", "xhigh"] + const version = Number(/(?:^|\/)gpt-5[.-](\d+)(?:[.-]|$)/.exec(id)?.[1]) || undefined + if (version === 1) return ["low", "medium", "high"] + if (version !== undefined && version >= 2) return ["low", "medium", "high", "xhigh"] + if (id.includes("gpt-5")) return ["low", "medium", "high", "xhigh"] + return ["low", "medium", "high"] +} + +function reasoningEffortVariants(efforts: string[], packageName?: string) { + return efforts.map((effort) => variant(effort, ModelRequest.normalizeAiSdkOptions(packageName, { reasoningEffort: effort }))) +} + +function geminiVariants(model: ModelsDev.Model, packageName?: string) { + const id = model.id.toLowerCase() + if (packageName !== "@ai-sdk/google" && packageName !== "@ai-sdk/google-vertex") { + return reasoningEffortVariants(["low", "medium", "high"], packageName) + } + if (id.includes("2.5")) { + return [ + variant("high", { body: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } } }), + variant( + "max", + { + body: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: id.includes("pro") && !id.includes("flash") ? 32_768 : 24_576, + }, + }, + }, + ), + ] + } + const efforts = id.includes("flash") ? ["minimal", "low", "medium", "high"] : ["low", "medium", "high"] + return efforts.map((effort) => + variant(effort, { body: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } }), + ) +} + +function claudeVariants(model: ModelsDev.Model, packageName?: string) { + const id = model.id.toLowerCase() + if (packageName !== "@ai-sdk/anthropic" && packageName !== "@ai-sdk/google-vertex/anthropic") { + return reasoningEffortVariants(["low", "medium", "high"], packageName) + } + if (id.includes("opus-4.7") || id.includes("fable-5")) { + return anthropicAdaptiveVariants(["low", "medium", "high", "xhigh", "max"], true) + } + if (["opus-4.6", "opus-4-6", "sonnet-4.6", "sonnet-4-6"].some((value) => id.includes(value))) { + return anthropicAdaptiveVariants(["low", "medium", "high", "max"]) + } + return [ + variant( + "high", + { options: { thinking: { type: "enabled", budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)) } } }, + ), + variant( + "max", + { options: { thinking: { type: "enabled", budgetTokens: Math.min(31_999, model.limit.output - 1) } } }, + ), + ] +} + +function anthropicAdaptiveVariants(efforts: string[], summarized = false) { + return efforts.map((effort) => + variant( + effort, + { + options: { + thinking: { type: "adaptive", ...(summarized ? { display: "summarized" } : {}) }, + effort, + }, + }, + ), + ) } export const ModelsDevPlugin = PluginV2.define({ diff --git a/packages/core/test/plugin/fixtures/models-dev-reasoning.json b/packages/core/test/plugin/fixtures/models-dev-reasoning.json new file mode 100644 index 000000000000..d244dee1c248 --- /dev/null +++ b/packages/core/test/plugin/fixtures/models-dev-reasoning.json @@ -0,0 +1,56 @@ +{ + "openai": { + "id": "openai", + "name": "OpenAI", + "env": ["OPENAI_API_KEY"], + "npm": "@ai-sdk/openai", + "models": { + "gpt-5.2": { + "id": "gpt-5.2", + "name": "GPT 5.2", + "release_date": "2025-12-01", + "attachment": false, + "reasoning": true, + "temperature": true, + "tool_call": true, + "limit": { "context": 200000, "output": 64000 } + } + } + }, + "google": { + "id": "google", + "name": "Google", + "env": ["GOOGLE_GENERATIVE_AI_API_KEY"], + "npm": "@ai-sdk/google", + "models": { + "gemini-2.5-pro": { + "id": "gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "release_date": "2025-01-01", + "attachment": true, + "reasoning": true, + "temperature": true, + "tool_call": true, + "limit": { "context": 1000000, "output": 64000 } + } + } + }, + "anthropic": { + "id": "anthropic", + "name": "Anthropic", + "env": ["ANTHROPIC_API_KEY"], + "npm": "@ai-sdk/anthropic", + "models": { + "claude-sonnet-4.6": { + "id": "claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "release_date": "2025-11-01", + "attachment": true, + "reasoning": true, + "temperature": true, + "tool_call": true, + "limit": { "context": 200000, "output": 64000 } + } + } + } +} diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 7de776c98d2f..5022a8929d08 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -62,4 +62,45 @@ describe("ModelsDevPlugin", () => { }), ), ) + + it.effect("adds reasoning variants when models.dev modes are absent", () => + 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* () { + yield* ModelsDevPlugin.effect + const catalog = yield* Catalog.Service + + expect((yield* catalog.model.get("openai" as any, "gpt-5.2" as any)).variants.map((item) => String(item.id))).toEqual([ + "low", + "medium", + "high", + "xhigh", + ]) + expect( + (yield* catalog.model.get("google" as any, "gemini-2.5-pro" as any)).variants.map((item) => + String(item.id), + ), + ).toEqual(["high", "max"]) + expect( + (yield* catalog.model.get("anthropic" as any, "claude-sonnet-4.6" as any)).variants.map((item) => + String(item.id), + ), + ).toEqual(["low", "medium", "high", "max"]) + }).pipe(Effect.provide(ModelsDev.defaultLayer)), + (previous) => + Effect.sync(() => { + Flag.OPENCODE_MODELS_PATH = previous.path + Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled + }), + ), + ) })