fix(opencode): defer reasoning variant migration

This commit is contained in:
Aiden Cline 2026-07-13 22:10:36 +00:00
commit 0c2d7ee40b
4 changed files with 3 additions and 548 deletions

View file

@ -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)
}

View file

@ -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"]>): Provider.Model["variants"] {
return Object.keys(variants).length > 0 ? variants : undefined
}
function reasoningToggle(model: Provider.Model): NonNullable<Provider.Model["variants"]> {
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"

View file

@ -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",

View file

@ -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> = {}): any => ({
id: "test/test-model",