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

This commit is contained in:
Aiden Cline 2026-07-08 19:39:15 -05:00
commit b41b10faeb
13 changed files with 2671 additions and 2202 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
}),
),
)
})