From 278361cb48e40292a96ae785daae6f4e15d4040e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 23:38:42 -0500 Subject: [PATCH] fix(core): derive models.dev reasoning variants --- packages/client/src/generated/types.ts | 5 + packages/core/src/config/plugin/provider.ts | 18 ++++ packages/core/src/config/provider.ts | 1 + packages/core/src/model.ts | 7 +- packages/core/src/plugin/models-dev.ts | 86 +++++++++++---- packages/core/src/provider.ts | 21 +++- packages/core/src/session/runner/model.ts | 7 ++ .../plugin/fixtures/models-dev-reasoning.json | 56 ++++++++++ packages/core/test/plugin/models-dev.test.ts | 101 ++++++++++-------- .../core/test/session-runner-model.test.ts | 4 + packages/schema/src/provider.ts | 6 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 9 ++ 12 files changed, 257 insertions(+), 64 deletions(-) create mode 100644 packages/core/test/plugin/fixtures/models-dev-reasoning.json diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index e8718caf7a..c00585e21e 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -153,6 +153,7 @@ export type AgentListOutput = { readonly request: { readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } + readonly providerOptions?: { readonly [x: string]: { readonly [x: string]: JsonValue } } } readonly system?: string readonly description?: string @@ -2147,12 +2148,14 @@ export type ModelListOutput = { readonly request: { readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } + readonly providerOptions?: { readonly [x: string]: { readonly [x: string]: JsonValue } } readonly variant?: string } readonly variants: ReadonlyArray<{ readonly id: string readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } + readonly providerOptions?: { readonly [x: string]: { readonly [x: string]: JsonValue } } }> readonly time: { readonly released: number } readonly cost: ReadonlyArray<{ @@ -2211,6 +2214,7 @@ export type ProviderListOutput = { readonly request: { readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } + readonly providerOptions?: { readonly [x: string]: { readonly [x: string]: JsonValue } } } }> } @@ -2244,6 +2248,7 @@ export type ProviderGetOutput = { readonly request: { readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } + readonly providerOptions?: { readonly [x: string]: { readonly [x: string]: JsonValue } } } } } diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 6f6e0528da..24837f4139 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -56,6 +56,12 @@ export const Plugin = define({ if (item.request !== undefined) { Object.assign(provider.request.headers, item.request.headers) Object.assign(provider.request.body, item.request.body) + if (item.request.providerOptions !== undefined) { + provider.request.providerOptions = ProviderV2.mergeProviderOptions( + provider.request.providerOptions, + item.request.providerOptions, + ) + } } }) for (const [id, config] of Object.entries(item.models ?? {})) { @@ -73,6 +79,12 @@ export const Plugin = define({ if (config.request !== undefined) { Object.assign(model.request.headers, config.request.headers) Object.assign(model.request.body, config.request.body) + if (config.request.providerOptions !== undefined) { + model.request.providerOptions = ProviderV2.mergeProviderOptions( + model.request.providerOptions, + config.request.providerOptions, + ) + } if (config.request.variant !== undefined) model.request.variant = config.request.variant } if (config.variants !== undefined) { @@ -88,6 +100,12 @@ export const Plugin = define({ } Object.assign(existing.headers, variant.headers) Object.assign(existing.body, variant.body) + if (variant.providerOptions !== undefined) { + existing.providerOptions = ProviderV2.mergeProviderOptions( + existing.providerOptions, + variant.providerOptions, + ) + } } } if (config.cost !== undefined) { diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index 1b54757078..0b1f4fba32 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -7,6 +7,7 @@ import { ModelV2 } from "../model" export class Request extends Schema.Class("ConfigV2.Provider.Request")({ headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + providerOptions: ProviderV2.ProviderOptions.pipe(Schema.optional), }) {} class Cache extends Schema.Class("ConfigV2.Model.Cost.Cache")({ diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 52fff98733..41abbcb2d2 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -26,8 +26,13 @@ export type Api = Model.Api export const Info = Model.Info export type Info = Model.Info -export type MutableInfo = Omit, "api"> & { +export type MutableRequest = ProviderV2.MutableRequest & { variant?: string } +export type MutableVariant = ProviderV2.MutableRequest & { id: VariantID } + +export type MutableInfo = Omit, "api" | "request" | "variants"> & { api: ProviderV2.MutableApi + request: MutableRequest + variants: MutableVariant[] } export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index aacc3bd2c5..448a61daeb 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -70,25 +70,75 @@ 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(model: ModelsDev.Model, packageName: string | undefined): ModelV2Info["variants"] { - const result = new Map() - if (packageName === "@ai-sdk/openai" || packageName === "@ai-sdk/openai-compatible") { - const option = model.reasoning_options?.find((option) => option.type === "effort") - for (const value of option?.values ?? []) { - const id = value === null ? "none" : value - if (typeof id !== "string") continue - const variantID = ModelV2.VariantID.make(id) - result.set(variantID, { - id: variantID, - headers: {}, - body: - packageName === "@ai-sdk/openai" - ? { include: ["reasoning.encrypted_content"], reasoning: { effort: id, summary: "auto" } } - : { reasoning_effort: id }, - }) +const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] + +function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): ModelV2Info["variants"] { + const npm = model.provider?.npm ?? provider.npm + const options = model.reasoning_options ?? [] + const effort = options.find((option) => option.type === "effort") + if (effort?.type === "effort") { + return effort.values.flatMap((value) => { + const raw: unknown = value + const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined + if (id === undefined) return [] + const providerOptions = providerOptionsForEffort(npm, id) + return providerOptions ? [{ id, headers: {}, body: {}, providerOptions }] : [] + }) + } + + const budget = options.find((option) => option.type === "budget_tokens") + if (budget?.type === "budget_tokens") return budgetVariants(npm, budget) + + // Toggle-only reasoning is intentionally left for a follow-up because V1 has + // provider/model-specific behavior like MiniMax M3 adaptive thinking and + // Qwen/GLM enable_thinking request shapes in packages/opencode. + return [] +} + +function providerOptionsForEffort(npm: string | undefined, effort: string): ProviderV2.ProviderOptions | undefined { + if (npm === "@openrouter/ai-sdk-provider") return { openrouter: { reasoning: { effort } } } + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { + return { anthropic: { thinking: { type: "adaptive" }, effort } } + } + if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") { + return { gemini: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } } + } + if (npm === "@ai-sdk/azure") return { openai: { reasoningEffort: effort }, azure: { reasoningEffort: effort } } + if (npm === "@ai-sdk/openai") { + return { + openai: { + reasoningEffort: effort, + reasoningSummary: "auto", + include: OPENAI_INCLUDE_ENCRYPTED_REASONING, + }, } } - return [...result.values()] + if (npm === "@ai-sdk/openai-compatible") return { openai: { reasoningEffort: effort } } +} + +function budgetVariants( + npm: string | undefined, + option: Extract[number], { type: "budget_tokens" }>, +): ModelV2Info["variants"] { + const max = option.max + const high = option.max === undefined ? Math.max(option.min ?? 0, 16_000) : Math.min(Math.max(option.min ?? 0, 16_000), option.max) + return [ + { id: "high", budget: high }, + ...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]), + ].flatMap((item) => { + const providerOptions = providerOptionsForBudget(npm, item.budget) + return providerOptions ? [{ id: item.id, headers: {}, body: {}, providerOptions }] : [] + }) +} + +function providerOptionsForBudget(npm: string | undefined, budget: number): ProviderV2.ProviderOptions | undefined { + if (npm === "@openrouter/ai-sdk-provider") return { openrouter: { reasoning: { max_tokens: budget } } } + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { + return { anthropic: { thinking: { type: "enabled", budgetTokens: budget } } } + } + if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") { + return { gemini: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } } + } } function modeName(model: ModelsDev.Model, mode: string) { @@ -193,7 +243,7 @@ export const ModelsDevPlugin = define({ for (const model of Object.values(item.models)) { const baseCost = cost(model.cost) - const variants = reasoningVariants(model, model.provider?.npm ?? item.npm) + 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) => diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 03f7d7eef3..dc03bc0c70 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -19,7 +19,26 @@ export type MutableApi = T extends Api export const Request = Provider.Request export type Request = Provider.Request +export const ProviderOptions = Provider.ProviderOptions +export type ProviderOptions = Provider.ProviderOptions + +export const mergeProviderOptions = (...items: ReadonlyArray): ProviderOptions | undefined => { + const result: Record = {} + for (const item of items) { + if (!item) continue + for (const [provider, options] of Object.entries(item)) { + result[provider] = { ...result[provider], ...options } + } + } + return Object.keys(result).length === 0 ? undefined : result +} + export const Info = Provider.Info export type Info = Provider.Info -export type MutableInfo = Omit, "api"> & { api: MutableApi } +export type MutableRequest = Omit, "providerOptions"> & { providerOptions?: ProviderOptions } + +export type MutableInfo = Omit, "api" | "request"> & { + api: MutableApi + request: MutableRequest +} diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 74ecbe2656..6e64ec3366 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -96,6 +96,7 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { provider: model.providerID, endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url }, headers: model.request.headers, + providerOptions: model.request.providerOptions, http: { body: httpBody }, limits: { context: model.limit.context, output: model.limit.output }, }) @@ -120,6 +121,12 @@ export const withVariant = ( ? produce(model, (draft) => { Object.assign(draft.request.headers, variant.headers) Object.assign(draft.request.body, variant.body) + if (variant.providerOptions !== undefined) { + draft.request.providerOptions = ProviderV2.mergeProviderOptions( + draft.request.providerOptions, + variant.providerOptions, + ) + } }) : model, ) 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 0000000000..df33c3993a --- /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", + "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": ["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 } + } + } + } +} diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 8073bfdc68..a7317477b6 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -173,14 +173,14 @@ describe("ModelsDevPlugin", () => { ), ) - it.effect("derives OpenAI reasoning variants from models.dev reasoning options", () => + it.effect("converts reasoning options into provider option 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.json") + Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json") Flag.OPENCODE_DISABLE_MODELS_FETCH = true return previous }), @@ -188,17 +188,6 @@ describe("ModelsDevPlugin", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - yield* catalog.transform((catalog) => { - catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5"), (model) => { - model.variants = [ - { - id: ModelV2.VariantID.make("high"), - headers: { custom: "true" }, - body: { custom: true }, - }, - ] - }) - }) yield* ModelsDevPlugin.effect( host({ catalog: catalogHost(catalog), @@ -206,42 +195,67 @@ describe("ModelsDevPlugin", () => { }), ) - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5")))?.variants).toEqual([ - { - id: ModelV2.VariantID.make("none"), - headers: {}, - body: { + 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("low"), + ModelV2.VariantID.make("high"), + ]) + expect(model?.variants).toContainEqual({ + id: ModelV2.VariantID.make("low"), + headers: {}, + body: {}, + providerOptions: { + openai: { + reasoningEffort: "low", + reasoningSummary: "auto", include: ["reasoning.encrypted_content"], - reasoning: { effort: "none", summary: "auto" }, }, }, - expect.objectContaining({ - id: "low", - body: { + }) + expect(model?.variants).toContainEqual({ + id: ModelV2.VariantID.make("high"), + headers: {}, + body: {}, + providerOptions: { + openai: { + reasoningEffort: "high", + reasoningSummary: "auto", include: ["reasoning.encrypted_content"], - reasoning: { effort: "low", summary: "auto" }, }, - }), - expect.objectContaining({ - id: "medium", - body: { - include: ["reasoning.encrypted_content"], - reasoning: { effort: "medium", summary: "auto" }, - }, - }), - expect.objectContaining({ - id: "high", - headers: { custom: "true" }, - body: { custom: true }, - }), - expect.objectContaining({ - id: "xhigh", - body: { - include: ["reasoning.encrypted_content"], - reasoning: { effort: "xhigh", summary: "auto" }, - }, - }), + }, + }) + + 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("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: {}, + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 16000 } }, + }, + }) + expect(budgetModel?.variants).toContainEqual({ + id: ModelV2.VariantID.make("max"), + headers: {}, + body: {}, + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 64000 } }, + }, + }) }).pipe(Effect.provide(ModelsDev.defaultLayer)), (previous) => Effect.sync(() => { @@ -250,5 +264,4 @@ describe("ModelsDevPlugin", () => { }), ), ) - }) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 49bbce95a3..c1ecc29bef 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -106,6 +106,7 @@ describe("SessionRunnerModel", () => { { id: ModelV2.VariantID.make("high"), headers: { "x-variant": "high" }, + providerOptions: { openai: { reasoningEffort: "high" } }, body: { store: false, service_tier: "priority", @@ -139,6 +140,9 @@ describe("SessionRunnerModel", () => { temperature: 0.2, reasoning: { effort: "high" }, }) + expect(resolved.route.defaults.providerOptions).toEqual({ + openai: { store: false, reasoningEffort: "high" }, + }) }), ) diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index 51ff4b3791..507618ec08 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -43,10 +43,16 @@ export const Api = Schema.Union([AISDK, Native]) .annotate({ identifier: "Provider.Api" }) export type Api = typeof Api.Type +export const ProviderOptions = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({ + identifier: "Provider.Options", +}) +export type ProviderOptions = typeof ProviderOptions.Type + export interface Request extends Schema.Schema.Type {} export const Request = Schema.Struct({ headers: Schema.Record(Schema.String, Schema.String), body: Schema.Record(Schema.String, Schema.Json), + providerOptions: ProviderOptions.pipe(optional), }).annotate({ identifier: "Provider.Request" }) export interface Info extends Schema.Schema.Type {} diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index dbc173c829..77f57d44f0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4077,6 +4077,12 @@ export type LocationInfo = { } } +export type ProviderOptions = { + [key: string]: { + [key: string]: unknown + } +} + export type ProviderRequest = { headers: { [key: string]: string @@ -4084,6 +4090,7 @@ export type ProviderRequest = { body: { [key: string]: unknown } + providerOptions?: ProviderOptions } export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" @@ -5106,6 +5113,7 @@ export type ModelV2Info = { body: { [key: string]: unknown } + providerOptions?: ProviderOptions variant?: string } variants: Array<{ @@ -5116,6 +5124,7 @@ export type ModelV2Info = { body: { [key: string]: unknown } + providerOptions?: ProviderOptions }> time: { released: number