From 0c2d7ee40bea976ce560ac7f763a77e4a06b96ad Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 13 Jul 2026 22:10:36 +0000 Subject: [PATCH] fix(opencode): defer reasoning variant migration --- packages/opencode/src/provider/provider.ts | 12 +- packages/opencode/src/provider/transform.ts | 181 ------------ .../opencode/test/provider/provider.test.ts | 95 ------- .../opencode/test/provider/transform.test.ts | 263 ------------------ 4 files changed, 3 insertions(+), 548 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 811e44a180..27f5b6e572 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1250,11 +1250,9 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model variants: {}, } - const variants = ProviderTransform.reasoningVariants(model, base) ?? ProviderTransform.variants(base) - return { ...base, - variants: mapValues(variants, (v) => v), + variants: mapValues(ProviderTransform.variants(base), (v) => v), } } @@ -1510,11 +1508,7 @@ const layer = Layer.effect( release_date: model.release_date ?? existingModel?.release_date ?? "", variants: {}, } - const variants = - existingModel?.api.npm === parsedModel.api.npm - ? (existingModel.variants ?? ProviderTransform.variants(parsedModel)) - : ProviderTransform.variants(parsedModel) - const merged = mergeDeep(variants, model.variants ?? {}) + const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) parsedModel.variants = mapValues( pickBy(merged, (v) => !v.disabled), (v) => omit(v, ["disabled"]), @@ -1645,7 +1639,7 @@ const layer = Layer.effect( ) delete provider.models[modelID] - if (model.variants === undefined) { + if (!model.variants || Object.keys(model.variants).length === 0) { model.variants = mapValues(ProviderTransform.variants(model), (v) => v) } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 8ef3d222e8..a996ab3669 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1582,185 +1582,4 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 return schema } -export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model): Provider.Model["variants"] { - const options = model.reasoning_options - if (options === undefined) return - if (options.length === 0) return {} - - const effort = options.find((option) => option.type === "effort") - if (effort) return nonEmptyVariants(effortVariants(target, effort.values)) - - const toggle = options.some((option) => option.type === "toggle") - const budget = options.find((option) => option.type === "budget_tokens") - if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined - - return nonEmptyVariants({ - ...(toggle ? reasoningToggle(target) : {}), - ...budgetVariants(target, budget.min, budget.max), - }) -} - -function effortVariants(model: Provider.Model, values: readonly unknown[]) { - return Object.fromEntries( - values.flatMap((value) => { - const id = (() => { - if (value === null) return "none" - if (typeof value === "string") return value - })() - if (id === undefined) return [] - const settings = reasoningEffort(model, id) - return settings ? [[id, settings]] : [] - }), - ) -} - -function budgetVariants(model: Provider.Model, min?: number, max?: number) { - const maximum = Math.min(max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1) - if (maximum <= 0) return {} - const high = Math.min(Math.max(min ?? 0, Math.floor((maximum + 1) / 2)), maximum) - return Object.fromEntries( - [ - { id: "high", budget: high }, - { id: "max", budget: maximum }, - ].flatMap((item) => { - const settings = reasoningBudget(model, item.budget) - return settings ? [[item.id, settings]] : [] - }), - ) -} - -function nonEmptyVariants(variants: NonNullable): Provider.Model["variants"] { - return Object.keys(variants).length > 0 ? variants : undefined -} - -function reasoningToggle(model: Provider.Model): NonNullable { - if (model.api.npm === "@ai-sdk/alibaba") - return { - none: { enableThinking: false }, - high: { enableThinking: true }, - } - if (model.api.npm === "@ai-sdk/cohere") - return { - none: { thinking: { type: "disabled" } }, - high: { thinking: { type: "enabled" } }, - } - return {} -} - -function reasoningEffort(model: Provider.Model, effort: string) { - switch (model.api.npm) { - case "@openrouter/ai-sdk-provider": - return { reasoning: { effort } } - case "@ai-sdk/anthropic": - case "@ai-sdk/google-vertex/anthropic": - return anthropicEffort(model, effort) - case "@ai-sdk/google": - case "@ai-sdk/google-vertex": - return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } - case "@ai-sdk/amazon-bedrock": - if (anthropicAdaptiveEfforts(model.api.id)) - return { - reasoningConfig: { - type: "adaptive", - maxReasoningEffort: effort, - ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}), - }, - } - if (model.api.id.includes("anthropic")) return - return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } } - case "@ai-sdk/gateway": - if (model.id.includes("anthropic")) return { thinking: { type: "adaptive", display: "summarized" }, effort } - if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } - return { reasoningEffort: effort } - case "@ai-sdk/github-copilot": - // OAuth discovery replaces these with variants from Copilot's /models capabilities. - if (model.id.includes("gemini")) return - if (model.id.includes("claude")) return { reasoningEffort: effort } - return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } - case "@ai-sdk/openai": - case "@ai-sdk/amazon-bedrock/mantle": - return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } - case "@ai-sdk/azure": - return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } - case "@jerome-benoit/sap-ai-provider-v2": - if (model.id.includes("anthropic")) - return { modelParams: { thinking: { type: "adaptive", display: "summarized" }, output_config: { effort } } } - return { modelParams: { reasoning_effort: effort } } - case "@ai-sdk/openai-compatible": - case "@ai-sdk/xai": - case "@ai-sdk/mistral": - case "@ai-sdk/groq": - case "@ai-sdk/cerebras": - case "@ai-sdk/deepinfra": - case "@ai-sdk/togetherai": - case "venice-ai-sdk-provider": - case "ai-gateway-provider": - return { reasoningEffort: effort } - case "@ai-sdk/cohere": - case "@ai-sdk/perplexity": - case "@ai-sdk/vercel": - case "@ai-sdk/alibaba": - case "gitlab-ai-provider": - return - } -} - -function anthropicEffort(model: Provider.Model, effort: string) { - if (["opus-4-5", "opus-4.5"].some((value) => model.api.id.includes(value))) return { effort } - if (!anthropicAdaptiveEfforts(model.api.id)) return - return { - thinking: { - type: "adaptive", - ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}), - }, - effort, - } -} - -function reasoningBudget(model: Provider.Model, budget: number) { - switch (model.api.npm) { - case "@openrouter/ai-sdk-provider": - return { reasoning: { max_tokens: budget } } - case "@ai-sdk/anthropic": - case "@ai-sdk/google-vertex/anthropic": - return { thinking: { type: "enabled", budgetTokens: budget } } - case "@ai-sdk/google": - case "@ai-sdk/google-vertex": - return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } - case "@ai-sdk/amazon-bedrock": - return { reasoningConfig: { type: "enabled", budgetTokens: budget } } - case "@ai-sdk/gateway": - if (model.id.includes("anthropic")) return { thinking: { type: "enabled", budgetTokens: budget } } - if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } - return - case "@ai-sdk/cohere": - return { thinking: { type: "enabled", tokenBudget: budget } } - case "@ai-sdk/alibaba": - return { enableThinking: true, thinkingBudget: budget } - case "@jerome-benoit/sap-ai-provider-v2": - if (model.id.includes("anthropic")) - return { modelParams: { thinking: { type: "enabled", budget_tokens: budget } } } - if (model.id.includes("gemini")) - return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } } - return - case "@ai-sdk/amazon-bedrock/mantle": - case "@ai-sdk/azure": - case "@ai-sdk/cerebras": - case "@ai-sdk/deepinfra": - case "@ai-sdk/github-copilot": - case "@ai-sdk/groq": - case "@ai-sdk/mistral": - case "@ai-sdk/openai": - case "@ai-sdk/openai-compatible": - case "@ai-sdk/perplexity": - case "@ai-sdk/togetherai": - case "@ai-sdk/vercel": - case "@ai-sdk/xai": - case "ai-gateway-provider": - case "gitlab-ai-provider": - case "venice-ai-sdk-provider": - return - } -} - export * as ProviderTransform from "./transform" diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 27978776be..964c7d5dfd 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -564,45 +564,6 @@ it.instance( }, ) -it.instance( - "model config preserves explicitly empty models.dev variants", - Effect.gen(function* () { - yield* set("OPENAI_API_KEY", "test-api-key") - const providers = yield* list - const model = providers[ProviderV2.ID.openai].models["custom-gpt-chat"] - expect(model.name).toBe("Custom GPT Chat") - expect(model.variants).toEqual({}) - }), - { - config: { - provider: { - openai: { models: { "custom-gpt-chat": { id: "gpt-5-chat-latest", name: "Custom GPT Chat" } } }, - }, - }, - }, -) - -it.instance( - "model config regenerates variants when overriding the provider package", - 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-6"] - expect(model.variants?.low).toEqual({ reasoningEffort: "low" }) - expect(model.variants?.max).toBeUndefined() - }), - { - config: { - provider: { - anthropic: { - npm: "@ai-sdk/openai-compatible", - models: { "claude-sonnet-4-6": { name: "Claude via OpenAI" } }, - }, - }, - }, - }, -) - it.instance( "disabled_providers prevents loading even with env var", Effect.gen(function* () { @@ -1479,62 +1440,6 @@ test("models.dev normalization fills required response fields", () => { expect(model.release_date).toBe("") }) -test("models.dev reasoning options replace generated variants and unsupported options fall back", () => { - const provider = { - id: "reasoning", - name: "Reasoning", - env: [], - npm: "@ai-sdk/openai", - models: { - explicit: { - id: "gpt-5.4", - name: "Explicit", - reasoning: true, - reasoning_options: [{ type: "effort", values: ["low"] }], - limit: { context: 128_000, output: 64_000 }, - }, - empty: { - id: "gpt-5.4", - name: "Empty", - reasoning: true, - reasoning_options: [], - limit: { context: 128_000, output: 64_000 }, - }, - fallback: { - id: "gpt-5.4", - name: "Fallback", - reasoning: true, - reasoning_options: [{ type: "toggle" }], - limit: { context: 128_000, output: 64_000 }, - }, - override: { - id: "gemini-3-pro", - name: "Override", - reasoning: true, - reasoning_options: [{ type: "effort", values: ["high"] }], - provider: { npm: "@ai-sdk/google" }, - limit: { context: 128_000, output: 64_000 }, - experimental: { modes: { fast: {} } }, - }, - }, - } as unknown as ModelsDev.Provider - - const models = Provider.fromModelsDevProvider(provider).models - expect(models.explicit.variants).toEqual({ - low: { - reasoningEffort: "low", - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }, - }) - expect(models.empty.variants).toEqual({}) - expect(Object.keys(models.fallback.variants ?? {})).toEqual(["none", "low", "medium", "high", "xhigh"]) - expect(models.override.variants).toEqual({ - high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, - }) - expect(models["gemini-3-pro-fast"].variants).toEqual(models.override.variants) -}) - test("public provider info omits invalid models", () => { const provider = Provider.fromModelsDevProvider({ id: "test", diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4c462bd3dc..2eb31ac86b 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4,7 +4,6 @@ import { ProviderTransform } from "@/provider/transform" import { LLMRequestPrep } from "@/session/llm/request" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { ModelsDev } from "@opencode-ai/core/models-dev" import { jsonSchema } from "ai" describe("ProviderTransform.options - setCacheKey", () => { @@ -3026,268 +3025,6 @@ describe("ProviderTransform.temperature - Cohere North", () => { }) }) -describe("ProviderTransform.reasoningVariants", () => { - const model = (reasoning_options: ModelsDev.Model["reasoning_options"]) => ({ reasoning_options }) as ModelsDev.Model - const target = (npm: string, id = "test-model") => - ({ id, api: { id, npm, url: "" }, capabilities: { reasoning: true }, limit: { output: 64_000 } }) as any - - test("respects explicitly empty reasoning options", () => { - expect(ProviderTransform.reasoningVariants(model([]), target("@ai-sdk/openai"))).toEqual({}) - }) - - test.each([ - ["@openrouter/ai-sdk-provider", { reasoning: { effort: "high" } }], - ["@ai-sdk/anthropic", { thinking: { type: "adaptive" }, effort: "high" }, "claude-opus-4-6"], - [ - "@ai-sdk/google-vertex/anthropic", - { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, - "claude-opus-4-7", - ], - ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], - ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], - [ - "@ai-sdk/azure", - { - reasoningEffort: "high", - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }, - ], - [ - "@ai-sdk/openai", - { - reasoningEffort: "high", - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }, - ], - [ - "@ai-sdk/amazon-bedrock/mantle", - { - reasoningEffort: "high", - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }, - ], - [ - "@ai-sdk/github-copilot", - { - reasoningEffort: "high", - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }, - ], - ["@ai-sdk/openai-compatible", { reasoningEffort: "high" }], - ["@ai-sdk/xai", { reasoningEffort: "high" }], - ["@ai-sdk/mistral", { reasoningEffort: "high" }], - ["@ai-sdk/groq", { reasoningEffort: "high" }], - ["@ai-sdk/cerebras", { reasoningEffort: "high" }], - ["@ai-sdk/deepinfra", { reasoningEffort: "high" }], - ["@ai-sdk/togetherai", { reasoningEffort: "high" }], - ["venice-ai-sdk-provider", { reasoningEffort: "high" }], - ["ai-gateway-provider", { reasoningEffort: "high" }], - ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }], - ])("converts effort for %s", (npm, expected, ...args) => { - const id = args[0] as string | undefined - expect(ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm, id))).toEqual( - { high: expected }, - ) - }) - - test("uses bare effort for Claude Opus 4.5", () => { - expect( - ProviderTransform.reasoningVariants( - model([{ type: "effort", values: ["high"] }]), - target("@ai-sdk/anthropic", "claude-opus-4-5"), - ), - ).toEqual({ high: { effort: "high" } }) - }) - - test("leaves legacy Anthropic effort options to budget fallback", () => { - expect( - ProviderTransform.reasoningVariants( - model([{ type: "effort", values: ["high"] }]), - target("@ai-sdk/anthropic", "claude-sonnet-4"), - ), - ).toBeUndefined() - }) - - test("uses adaptive reasoning config for Anthropic models on Bedrock", () => { - expect( - ProviderTransform.reasoningVariants( - model([{ type: "effort", values: ["high"] }]), - target("@ai-sdk/amazon-bedrock", "anthropic.claude-opus-4-7-v1:0"), - ), - ).toEqual({ - high: { - reasoningConfig: { - type: "adaptive", - maxReasoningEffort: "high", - display: "summarized", - }, - }, - }) - }) - - test("leaves legacy Anthropic Bedrock effort options to budget fallback", () => { - expect( - ProviderTransform.reasoningVariants( - model([{ type: "effort", values: ["high"] }]), - target("@ai-sdk/amazon-bedrock", "anthropic.claude-sonnet-4-v1:0"), - ), - ).toBeUndefined() - }) - - test.each([ - ["@openrouter/ai-sdk-provider", { reasoning: { max_tokens: 16_000 } }], - ["@ai-sdk/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }], - ["@ai-sdk/google-vertex/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }], - ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }], - ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }], - ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", budgetTokens: 16_000 } }], - ["@ai-sdk/cohere", { thinking: { type: "enabled", tokenBudget: 16_000 } }], - ["@ai-sdk/alibaba", { enableThinking: true, thinkingBudget: 16_000 }], - ])("converts token budgets for %s", (npm, high) => { - const variants = ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024 }]), target(npm)) - expect(variants?.high).toEqual(high) - expect(Object.keys(variants ?? {})).toEqual(["high", "max"]) - }) - - test("maps null effort to none", () => { - expect( - ProviderTransform.reasoningVariants(model([{ type: "effort", values: [null] }]), target("@ai-sdk/openai")), - ).toEqual({ - none: { - reasoningEffort: "none", - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }, - }) - }) - - test.each([ - ["@ai-sdk/alibaba", { none: { enableThinking: false }, high: { enableThinking: true } }], - [ - "@ai-sdk/cohere", - { - none: { thinking: { type: "disabled" } }, - high: { thinking: { type: "enabled" } }, - }, - ], - ])("converts toggle options for %s", (npm, expected) => { - expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target(npm))).toEqual(expected) - }) - - test("combines Cohere toggle and budget options", () => { - expect( - ProviderTransform.reasoningVariants( - model([{ type: "toggle" }, { type: "budget_tokens", min: 1 }]), - target("@ai-sdk/cohere"), - ), - ).toEqual({ - none: { thinking: { type: "disabled" } }, - high: { thinking: { type: "enabled", tokenBudget: 16_000 } }, - max: { thinking: { type: "enabled", tokenBudget: 31_999 } }, - }) - }) - - test("generates bounded high and max token budgets", () => { - expect( - ProviderTransform.reasoningVariants( - model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), - target("@ai-sdk/anthropic"), - ), - ).toEqual({ - high: { thinking: { type: "enabled", budgetTokens: 16_000 } }, - max: { thinking: { type: "enabled", budgetTokens: 31_999 } }, - }) - }) - - test("caps token budgets below the model output limit", () => { - const anthropic = target("@ai-sdk/anthropic") - anthropic.limit.output = 5_000 - expect( - ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), anthropic), - ).toEqual({ - high: { thinking: { type: "enabled", budgetTokens: 2_500 } }, - max: { thinking: { type: "enabled", budgetTokens: 4_999 } }, - }) - }) - - test("derives high and max budgets when models.dev omits max", () => { - expect( - ProviderTransform.reasoningVariants( - model([{ type: "budget_tokens", min: 1_024 }]), - target("@ai-sdk/anthropic", "claude-haiku-4-5"), - ), - ).toEqual({ - high: { thinking: { type: "enabled", budgetTokens: 16_000 } }, - max: { thinking: { type: "enabled", budgetTokens: 31_999 } }, - }) - }) - - test("preserves explicit inclusive budget maxima", () => { - expect( - ProviderTransform.reasoningVariants( - model([{ type: "budget_tokens", min: 1_024, max: 24_576 }]), - target("@ai-sdk/google", "gemini-2.5-pro"), - ), - ).toEqual({ - high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 12_288 } }, - max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 24_576 } }, - }) - }) - - test("prefers effort options over token budgets", () => { - expect( - ProviderTransform.reasoningVariants( - model([ - { type: "budget_tokens", min: 1_024, max: 64_000 }, - { type: "effort", values: ["low"] }, - ]), - target("@ai-sdk/openai"), - ), - ).toEqual({ - low: { - reasoningEffort: "low", - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }, - }) - }) - - test("leaves unsupported options for heuristic fallback", () => { - expect( - ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target("@ai-sdk/perplexity")), - ).toBeUndefined() - expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target("@ai-sdk/openai"))).toBeUndefined() - }) - - test("uses model-family options for gateway and GitHub Copilot", () => { - const effort = model([{ type: "effort", values: ["high"] }]) - expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "anthropic/claude-sonnet-4"))).toEqual( - { - high: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, - }, - ) - expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "google/gemini-3-pro"))).toEqual({ - high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, - }) - expect( - ProviderTransform.reasoningVariants(effort, target("@ai-sdk/github-copilot", "gemini-3-pro")), - ).toBeUndefined() - }) - - test.each(["@ai-sdk/cohere", "@ai-sdk/perplexity", "@ai-sdk/vercel", "@ai-sdk/alibaba", "gitlab-ai-provider"])( - "does not invent effort controls for %s", - (npm) => { - expect( - ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm)), - ).toBeUndefined() - }, - ) -}) - describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model",