diff --git a/packages/core/src/model-request.ts b/packages/core/src/model-request.ts index f9f4f56936dd..4b5e11ecb81b 100644 --- a/packages/core/src/model-request.ts +++ b/packages/core/src/model-request.ts @@ -84,7 +84,16 @@ const profiles = new Map([ ]), }, ], - ["@ai-sdk/anthropic", { namespace: "anthropic", semantics: new Map([["thinking", "thinking"]]) }], + [ + "@ai-sdk/anthropic", + { + namespace: "anthropic", + semantics: new Map([ + ["thinking", "thinking"], + ["effort", "effort"], + ]), + }, + ], ]) export const namespace = (packageName: string) => profiles.get(packageName)?.namespace diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 3f9f670374e5..b3330d9b8cbb 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -43,6 +43,30 @@ const Cost = Schema.Struct({ ), }) +// models.dev curates reasoning_options per provider model as a discriminated +// union. The shape is expected to evolve, so stay lenient where it can grow: +// effort values are open strings (tiers like "xhigh" were added over time) and +// budget bounds are optional. The api.json payload is cast, never decoded, so +// option types this union doesn't know about yet can appear at runtime; +// consumers must filter for the types they understand instead of matching +// exhaustively. +export const ReasoningOption = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("toggle"), + }), + Schema.Struct({ + type: Schema.Literal("effort"), + // null means the provider accepts an explicit "no reasoning" effort. + values: Schema.Array(Schema.NullOr(Schema.String)), + }), + Schema.Struct({ + type: Schema.Literal("budget_tokens"), + min: Schema.optional(Schema.Finite), + max: Schema.optional(Schema.Finite), + }), +]) +export type ReasoningOption = Schema.Schema.Type + export const Model = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -50,6 +74,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( diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 6424c6b6ab7f..74ed22070637 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -6,6 +6,7 @@ import { ModelRequest } from "../model-request" import { ModelsDev } from "../models-dev" import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" +import { ReasoningVariants } from "../reasoning-variants" function released(date: string) { const time = Date.parse(date) @@ -39,8 +40,8 @@ function cost(input: ModelsDev.Model["cost"]) { ] } -function variants(model: ModelsDev.Model, packageName?: string) { - return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => { +function variants(model: ModelsDev.Model, providerID: string, packageName?: string) { + const modes = Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => { const request = ModelRequest.normalizeAiSdkOptions(packageName, item.provider?.body ?? {}) return { id: ModelV2.VariantID.make(id), @@ -48,6 +49,20 @@ function variants(model: ModelsDev.Model, packageName?: string) { ...request, } }) + // reasoning_options effort data generates effort variants with the same wire + // encodings the v1 catalog uses; curated experimental modes win on id collision. + const efforts = ReasoningVariants.fromOptions( + { npm: packageName, apiID: model.id, modelID: model.id, providerID }, + model.reasoning_options, + ) + const fromEfforts = Object.entries(efforts ?? {}) + .filter(([id]) => !modes.some((mode) => mode.id === id)) + .map(([id, body]) => ({ + id: ModelV2.VariantID.make(id), + headers: {}, + ...ModelRequest.normalizeAiSdkOptions(packageName, body), + })) + return [...modes, ...fromEfforts] } export const ModelsDevPlugin = PluginV2.define({ @@ -102,7 +117,7 @@ export const ModelsDevPlugin = PluginV2.define({ input: [...(model.modalities?.input ?? [])], output: [...(model.modalities?.output ?? [])], } - draft.variants = variants(model, model.provider?.npm ?? item.npm) + draft.variants = variants(model, item.id, model.provider?.npm ?? item.npm) draft.time.released = released(model.release_date) draft.cost = cost(model.cost) draft.status = model.status ?? "active" diff --git a/packages/core/src/reasoning-variants.ts b/packages/core/src/reasoning-variants.ts new file mode 100644 index 000000000000..0c7e8d148ed0 --- /dev/null +++ b/packages/core/src/reasoning-variants.ts @@ -0,0 +1,187 @@ +export * as ReasoningVariants from "./reasoning-variants" + +// Generates reasoning variants from models.dev `reasoning_options` data. The +// data only says WHICH efforts a model supports - the wire encoding for each +// SDK package lives here so the v1 provider catalog and the v2 catalog plugin +// stay in lockstep. Null effort values and option types we don't understand +// (toggle, budget_tokens, future additions) are ignored, so models without +// usable effort data return undefined and callers fall back to their own +// defaults. + +// OpenAI Responses `include` value that returns the encrypted reasoning state +// needed for stateless multi-turn reasoning (store: false). Hoisted so every +// branch that requests it stays in lockstep. +export const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const + +export interface Target { + readonly npm?: string + readonly apiID: string + readonly modelID: string + readonly providerID: string +} + +export function fromOptions( + target: Target, + options: ReadonlyArray<{ readonly type: string; readonly values?: ReadonlyArray }> | undefined, +): Record> | undefined { + const efforts = [ + ...new Set( + (options ?? []) + .flatMap((option) => (option.type === "effort" ? (option.values ?? []) : [])) + .filter((value): value is string => typeof value === "string"), + ), + ] + if (efforts.length === 0) return undefined + return effortVariants(target, efforts) +} + +export function anthropicOpus47OrLater(apiID: string) { + // Matches "opus-4.7" (Anthropic/Bedrock/Vertex) and "claude-4.7-opus" (SAP AI Core inverted). + // Greedy \d+ correctly extends to multi-digit majors (e.g. "claude-10.0-opus") for forward compatibility. + const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)|claude-(\d+)[.-](\d+)-opus(?:[.@-]|$)/i.exec(apiID) + if (!version) return false + const major = Number(version[1] ?? version[3]) + const minor = Number(version[2] ?? version[4]) + return major > 4 || (major === 4 && minor >= 7) +} + +export function anthropicAdaptiveEfforts(apiID: string): string[] | null { + if (anthropicOpus47OrLater(apiID) || apiID.includes("fable-5")) { + return ["low", "medium", "high", "xhigh", "max"] + } + if ( + ["opus-4-6", "opus-4.6", "4-6-opus", "4.6-opus", "sonnet-4-6", "sonnet-4.6", "4-6-sonnet", "4.6-sonnet"].some((v) => + apiID.includes(v), + ) + ) { + return ["low", "medium", "high", "max"] + } + return null +} + +export function anthropicOmitsThinking(apiID: string) { + return anthropicOpus47OrLater(apiID) || apiID.includes("fable-5") +} + +// SAP's Zod schema drops unknown top-level keys; reasoning controls survive +// only via `modelParams` (catchall), forwarded verbatim by the SAP SDKs. +export function wrapInSapModelParams( + variants: Record>, +): Record> { + return Object.fromEntries(Object.entries(variants).map(([k, v]) => [k, { modelParams: v }])) +} + +function copilotAnthropicEfforts(apiID: string, efforts: string[]) { + // Efforts currently supported by copilot are: low, medium, high + if (apiID.includes("opus-4.7")) return ["medium"] + return efforts.filter((v) => v !== "max" && v !== "xhigh") +} + +function anthropicEffortVariants(target: Target, efforts: string[]): Record> { + const filtered = target.providerID === "github-copilot" ? copilotAnthropicEfforts(target.apiID, efforts) : efforts + const adaptive = anthropicAdaptiveEfforts(target.apiID) !== null + return Object.fromEntries( + filtered.map((effort) => [ + effort, + adaptive + ? { + thinking: { + type: "adaptive", + // Newer adaptive-only models default `display` to "omitted", which + // returns empty thinking blocks. Force "summarized" so summaries + // survive (4.6/Sonnet 4.6 already default to "summarized"). + ...(anthropicOmitsThinking(target.apiID) ? { display: "summarized" } : {}), + }, + effort, + } + : { effort }, + ]), + ) +} + +function effortVariants(target: Target, efforts: string[]): Record> { + const fromEffort = (encode: (effort: string) => Record) => + Object.fromEntries(efforts.map((effort) => [effort, encode(effort)])) + + switch (target.npm) { + case "@openrouter/ai-sdk-provider": + return fromEffort((effort) => ({ reasoning: { effort } })) + + case "@ai-sdk/gateway": + if (target.modelID.includes("anthropic")) return anthropicEffortVariants(target, efforts) + if (target.modelID.includes("google")) + return fromEffort((effort) => ({ includeThoughts: true, thinkingLevel: effort })) + return fromEffort((effort) => ({ reasoningEffort: effort })) + + case "@ai-sdk/github-copilot": + // currently github copilot only returns thinking + if (target.modelID.includes("gemini")) return {} + if (target.modelID.includes("claude")) return fromEffort((effort) => ({ reasoningEffort: effort })) + return fromEffort((effort) => ({ + reasoningEffort: effort, + reasoningSummary: "auto", + include: INCLUDE_ENCRYPTED_REASONING, + })) + + case "@ai-sdk/azure": + case "@ai-sdk/amazon-bedrock/mantle": + case "@ai-sdk/openai": + return fromEffort((effort) => ({ + reasoningEffort: effort, + reasoningSummary: "auto", + include: INCLUDE_ENCRYPTED_REASONING, + })) + + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": + return anthropicEffortVariants(target, efforts) + + case "@ai-sdk/amazon-bedrock": + if (anthropicAdaptiveEfforts(target.apiID)) { + return fromEffort((effort) => ({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: effort, + ...(anthropicOmitsThinking(target.apiID) ? { display: "summarized" } : {}), + }, + })) + } + return fromEffort((effort) => ({ + reasoningConfig: { + type: "enabled", + maxReasoningEffort: effort, + }, + })) + + case "@ai-sdk/google-vertex": + case "@ai-sdk/google": + return fromEffort((effort) => ({ thinkingConfig: { includeThoughts: true, thinkingLevel: effort } })) + + case "@jerome-benoit/sap-ai-provider-v2": { + if (target.modelID.toLowerCase().includes("anthropic")) { + const adaptive = anthropicAdaptiveEfforts(target.apiID) !== null + // Bedrock-flavored Anthropic splits `effort` out into `output_config` (vs + // Anthropic native which inlines it). + return wrapInSapModelParams( + fromEffort((effort) => + adaptive + ? { + thinking: { + type: "adaptive", + ...(anthropicOmitsThinking(target.apiID) ? { display: "summarized" } : {}), + }, + output_config: { effort }, + } + : { output_config: { effort } }, + ), + ) + } + return wrapInSapModelParams(fromEffort((effort) => ({ reasoning_effort: effort }))) + } + } + + // OpenAI-compatible `reasoning_effort` is the dominant convention; it covers + // @ai-sdk/openai-compatible, cerebras, togetherai, xai, deepinfra, venice, + // mistral, groq, ai-gateway-provider, and unknown future packages. + return fromEffort((effort) => ({ reasoningEffort: effort })) +} diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index d54a3f08f926..ef860b108b8c 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -12,6 +12,28 @@ export const Model = Schema.Struct({ release_date: Schema.optional(Schema.String), attachment: Schema.optional(Schema.Boolean), reasoning: Schema.optional(Schema.Boolean), + reasoning_options: Schema.optional( + // Mirrors the models.dev reasoning_options union; effort values stay open + // strings so new tiers don't break configs. + Schema.mutable( + Schema.Array( + Schema.Union([ + Schema.Struct({ + type: Schema.Literal("toggle"), + }), + Schema.Struct({ + type: Schema.Literal("effort"), + values: Schema.mutable(Schema.Array(Schema.NullOr(Schema.String))), + }), + Schema.Struct({ + type: Schema.Literal("budget_tokens"), + min: Schema.optional(Schema.Finite), + max: Schema.optional(Schema.Finite), + }), + ]), + ), + ), + ).annotate({ description: "Reasoning controls this model supports; effort values drive reasoning variants" }), temperature: Schema.optional(Schema.Boolean), tool_call: Schema.optional(Schema.Boolean), interleaved: Schema.optional( diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 04e1c062f6a8..01a75c8c7679 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -568,7 +568,12 @@ describe("Config", () => { model: { request: { body: { - output_config: { effort: "high", task_budget: 4096 }, + // `effort` stays aisdk-shaped: the catalog re-partitions it + // into the semantic anthropic effort option, which lowers to + // output_config.effort plus the effort beta header. taskBudget + // has no semantic and lowers to a raw output_config overlay. + effort: "high", + output_config: { task_budget: 4096 }, metadata: { user_id: "user-1" }, }, }, diff --git a/packages/core/test/models-dev-plugin.test.ts b/packages/core/test/models-dev-plugin.test.ts new file mode 100644 index 000000000000..d3f7e1c6dcea --- /dev/null +++ b/packages/core/test/models-dev-plugin.test.ts @@ -0,0 +1,116 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "./fixture/location" +import { testEffect } from "./lib/effect" + +const model = (input: Partial & { id: string }): ModelsDev.Model => ({ + name: input.id, + release_date: "2026-01-01", + attachment: false, + reasoning: true, + temperature: true, + tool_call: true, + limit: { context: 200_000, output: 64_000 }, + ...input, +}) + +const fixture: Record = { + anthropic: { + id: "anthropic", + name: "Anthropic", + env: [], + npm: "@ai-sdk/anthropic", + models: { + "claude-sonnet-4-6": model({ + id: "claude-sonnet-4-6", + reasoning_options: [ + { type: "effort", values: ["low", "medium", "high", "max"] }, + { type: "budget_tokens", min: 1024 }, + ], + }), + }, + }, + compat: { + id: "compat", + name: "Compat", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "deepseek-v4": model({ + id: "deepseek-v4", + reasoning_options: [{ type: "toggle" }, { type: "effort", values: [null, "high", "max"] }], + experimental: { + modes: { + high: { provider: { body: { reasoning_effort: "high", custom: true } } }, + }, + }, + }), + }, + }, +} + +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("test") })), +) +const modelsDevLayer = Layer.succeed( + ModelsDev.Service, + ModelsDev.Service.of({ + get: () => Effect.succeed(fixture), + refresh: () => Effect.void, + }), +) +const it = testEffect( + Layer.mergeAll(modelsDevLayer, Catalog.locationLayer).pipe( + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(locationLayer), + ), +) + +describe("ModelsDevPlugin reasoning_options", () => { + it.effect("generates anthropic effort variants as semantic thinking + effort options", () => + Effect.gen(function* () { + yield* ModelsDevPlugin.effect + const catalog = yield* Catalog.Service + const info = yield* catalog.model.get(ProviderV2.ID.make("anthropic"), ModelV2.ID.make("claude-sonnet-4-6")) + expect(info.variants.map((variant) => variant.id)).toEqual( + ["low", "medium", "high", "max"].map((id) => ModelV2.VariantID.make(id)), + ) + expect(info.variants[2]).toMatchObject({ + id: "high", + headers: {}, + body: {}, + options: { thinking: { type: "adaptive" }, effort: "high" }, + }) + }), + ) + + it.effect("merges effort variants after curated experimental modes, skipping null values and collisions", () => + Effect.gen(function* () { + yield* ModelsDevPlugin.effect + const catalog = yield* Catalog.Service + const info = yield* catalog.model.get(ProviderV2.ID.make("compat"), ModelV2.ID.make("deepseek-v4")) + expect(info.variants.map((variant) => variant.id)).toEqual(["high", "max"].map((id) => ModelV2.VariantID.make(id))) + // curated mode wins the "high" id; its body keys survive partitioning + expect(info.variants[0]).toMatchObject({ + id: "high", + body: { custom: true }, + options: { reasoningEffort: "high" }, + }) + // data-driven effort variant for "max" uses the openai-compatible encoding + expect(info.variants[1]).toMatchObject({ + id: "max", + body: {}, + options: { reasoningEffort: "max" }, + }) + }), + ) +}) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index a37cd2c9a758..1124ffb1385d 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -146,10 +146,20 @@ const AnthropicToolChoice = Schema.Union([ Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }), ]) -const AnthropicThinking = Schema.Struct({ - type: Schema.tag("enabled"), - budget_tokens: Schema.Number, -}) +const AnthropicThinking = Schema.Union([ + Schema.Struct({ + type: Schema.tag("enabled"), + budget_tokens: Schema.Number, + }), + // Adaptive thinking (Claude 4.6+) lets the model choose its own budget. + // `display` controls how thinking is surfaced ("summarized" forces summaries + // on models that default to "omitted"); keep it an open string so new display + // modes flow through. + Schema.Struct({ + type: Schema.tag("adaptive"), + display: Schema.optional(Schema.String), + }), +]) const AnthropicBodyFields = { model: Schema.String, @@ -164,6 +174,8 @@ const AnthropicBodyFields = { top_k: Schema.optional(Schema.Number), stop_sequences: optionalArray(Schema.String), thinking: Schema.optional(AnthropicThinking), + // Reasoning effort (beta `effort-2025-11-24`); open string so new tiers flow through. + output_config: Schema.optional(Schema.Struct({ effort: Schema.String })), } const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) export type AnthropicMessagesBody = Schema.Schema.Type @@ -490,7 +502,14 @@ const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthr const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) { const thinking = anthropicOptions(request)?.thinking - if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined + if (!ProviderShared.isRecord(thinking)) return undefined + if (thinking.type === "adaptive") { + return { + type: "adaptive" as const, + ...(typeof thinking.display === "string" ? { display: thinking.display } : {}), + } + } + if (thinking.type !== "enabled") return undefined const budget = typeof thinking.budgetTokens === "number" ? thinking.budgetTokens @@ -501,6 +520,14 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re return { type: "enabled" as const, budget_tokens: budget } }) +// Reasoning effort lowers to `output_config.effort` (mirrors @ai-sdk/anthropic); +// the matching beta header is added by the route headers hook below. +const lowerOutputConfig = (request: LLMRequest) => { + const effort = anthropicOptions(request)?.effort + if (typeof effort !== "string") return undefined + return { effort } +} + const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation @@ -539,6 +566,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques top_k: generation?.topK, stop_sequences: generation?.stop, thinking: yield* lowerThinking(request), + output_config: lowerOutputConfig(request), } }) @@ -839,7 +867,12 @@ export const route = Route.make({ endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), auth: Auth.none, framing: Framing.sse, - headers: () => ({ "anthropic-version": "2023-06-01" }), + // `output_config.effort` is beta-gated. Explicit per-request `anthropic-beta` + // headers override this hook (the transport spreads request headers last). + headers: ({ request }) => ({ + "anthropic-version": "2023-06-01", + ...(typeof anthropicOptions(request)?.effort === "string" ? { "anthropic-beta": "effort-2025-11-24" } : {}), + }), }) export * as AnthropicMessages from "./anthropic-messages" diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index dabf512f6b69..46a1307d67da 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -57,6 +57,58 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("lowers enabled thinking to a budget", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 16_000 } } }, + }), + ) + expect(prepared.body.thinking).toEqual({ type: "enabled", budget_tokens: 16_000 }) + expect(prepared.body.output_config).toBeUndefined() + }), + ) + + it.effect("lowers adaptive thinking and effort to output_config", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + providerOptions: { + anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + }), + ) + expect(prepared.body.thinking).toEqual({ type: "adaptive", display: "summarized" }) + expect(prepared.body.output_config).toEqual({ effort: "high" }) + }), + ) + + it.effect("adds the effort beta header only when effort is set", () => + Effect.gen(function* () { + const seen: Record[] = [] + const body = () => + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 1 } } }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ) + const layer = dynamicResponse((input) => + Effect.sync(() => { + seen.push({ ...input.request.headers }) + return input.respond(body(), { headers: { "content-type": "text/event-stream" } }) + }), + ) + yield* LLMClient.generate( + LLM.updateRequest(request, { providerOptions: { anthropic: { effort: "high" } } }), + ).pipe(Effect.provide(layer)) + yield* LLMClient.generate(request).pipe(Effect.provide(layer)) + + expect(seen[0]["anthropic-version"]).toBe("2023-06-01") + expect(seen[0]["anthropic-beta"]).toBe("effort-2025-11-24") + expect(seen[1]["anthropic-beta"]).toBeUndefined() + }), + ) + it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 86515068d46e..3c338de539fb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -957,9 +957,30 @@ const ProviderInterleaved = Schema.Union([ }), ]) +// Mirrors the models.dev reasoning_options union. Option types this union does +// not know about yet, and null effort values (models.dev's marker for "accepts +// an explicit no-reasoning effort"), are dropped when mapping models.dev data +// into the resolved catalog, so this schema only ever sees known variants; +// effort values stay open strings so new tiers flow through. +const ProviderReasoningOption = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("toggle"), + }), + Schema.Struct({ + type: Schema.Literal("effort"), + values: Schema.Array(Schema.String), + }), + Schema.Struct({ + type: Schema.Literal("budget_tokens"), + min: optionalOmitUndefined(Schema.Finite), + max: optionalOmitUndefined(Schema.Finite), + }), +]) + const ProviderCapabilities = Schema.Struct({ temperature: Schema.Boolean, reasoning: Schema.Boolean, + reasoningOptions: optionalOmitUndefined(Schema.Array(ProviderReasoningOption)), attachment: Schema.Boolean, toolcall: Schema.Boolean, input: ProviderModalities, @@ -1155,6 +1176,20 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { return result } +// models.dev api.json is cast rather than decoded, so reasoning_options can +// contain option types newer than our union at runtime; drop those (and null +// effort values) here so downstream schema boundaries only ever see known +// variants. +function reasoningOptions(options: ModelsDev.Model["reasoning_options"]): Model["capabilities"]["reasoningOptions"] { + return options + ?.filter((option) => option.type === "toggle" || option.type === "effort" || option.type === "budget_tokens") + .map((option) => + option.type === "effort" + ? { ...option, values: option.values.filter((value): value is string => value !== null) } + : { ...option }, + ) +} + function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const base: Model = { id: ModelV2.ID.make(model.id), @@ -1178,6 +1213,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model capabilities: { temperature: model.temperature ?? false, reasoning: model.reasoning ?? false, + reasoningOptions: reasoningOptions(model.reasoning_options), attachment: model.attachment ?? false, toolcall: model.tool_call ?? true, input: { @@ -1400,6 +1436,8 @@ export const layer = Layer.effect( capabilities: { temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, + reasoningOptions: + reasoningOptions(model.reasoning_options) ?? existingModel?.capabilities.reasoningOptions, attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, input: { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cce3c7014bc1..0fd011831e34 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -3,6 +3,7 @@ import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" import type * as Provider from "./provider" import type * as ModelsDev from "@opencode-ai/core/models-dev" +import { ReasoningVariants } from "@opencode-ai/core/reasoning-variants" import { iife } from "@/util/iife" type Modality = NonNullable["input"][number] @@ -17,11 +18,6 @@ function mimeToModality(mime: string): Modality | undefined { export const OUTPUT_TOKEN_MAX = 32_000 -// OpenAI Responses `include` value that returns the encrypted reasoning state -// needed for stateless multi-turn reasoning (store: false). Hoisted so every -// branch that requests it stays in lockstep. -const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const - export function sanitizeSurrogates(content: string) { return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? 4 || (major === 4 && minor >= 7) -} - -function anthropicAdaptiveEfforts(apiId: string): string[] | null { - if (anthropicOpus47OrLater(apiId) || apiId.includes("fable-5")) { - return ["low", "medium", "high", "xhigh", "max"] - } - if ( - ["opus-4-6", "opus-4.6", "4-6-opus", "4.6-opus", "sonnet-4-6", "sonnet-4.6", "4-6-sonnet", "4.6-sonnet"].some((v) => - apiId.includes(v), - ) - ) { - return ["low", "medium", "high", "max"] - } - return null -} - -function anthropicOmitsThinking(apiId: string) { - return anthropicOpus47OrLater(apiId) || apiId.includes("fable-5") -} - function googleThinkingLevelEfforts(apiId: string) { const id = apiId.toLowerCase() if (!id.includes("gemini-3")) return ["low", "high"] @@ -638,12 +606,6 @@ function googleThinkingBudgetMax(apiId: string) { return 24_576 } -// SAP's Zod schema drops unknown top-level keys; reasoning controls survive -// only via `modelParams` (catchall), forwarded verbatim by the SAP SDKs. -function wrapInSapModelParams(variants: Record>): Record> { - return Object.fromEntries(Object.entries(variants).map(([k, v]) => [k, { modelParams: v }])) -} - function googleThinkingVariants(model: Provider.Model): Record> { const id = model.api.id.toLowerCase() if (id.includes("2.5")) { @@ -665,6 +627,14 @@ function googleThinkingVariants(model: Provider.Model): Record> { if (!model.capabilities.reasoning) return {} + // models.dev reasoning_options effort data drives variants when present; + // models without usable effort data fall back to the hardcoded tables below. + const fromData = ReasoningVariants.fromOptions( + { npm: model.api.npm, apiID: model.api.id, modelID: model.id, providerID: model.providerID }, + model.capabilities.reasoningOptions, + ) + if (fromData) return fromData + const id = model.id.toLowerCase() if ( model.api.id.toLowerCase().includes("minimax-m3") && @@ -675,8 +645,8 @@ export function variants(model: Provider.Model): Record [ effort, @@ -1022,19 +992,19 @@ export function variants(model: Provider.Model): Record [effort, { reasoning_effort: effort }]))) + return ReasoningVariants.wrapInSapModelParams(Object.fromEntries(efforts.map((effort) => [effort, { reasoning_effort: effort }]))) } - return wrapInSapModelParams( + return ReasoningVariants.wrapInSapModelParams( Object.fromEntries(["low", "medium", "high"].map((effort) => [effort, { reasoning_effort: effort }])), ) } @@ -1161,7 +1131,7 @@ export function options(input: { result["reasoningSummary"] = "auto" } if (input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle") { - result["include"] = INCLUDE_ENCRYPTED_REASONING + result["include"] = ReasoningVariants.INCLUDE_ENCRYPTED_REASONING } } @@ -1178,7 +1148,7 @@ export function options(input: { if (input.model.providerID.startsWith("opencode")) { result["promptCacheKey"] = input.sessionID - result["include"] = INCLUDE_ENCRYPTED_REASONING + result["include"] = ReasoningVariants.INCLUDE_ENCRYPTED_REASONING result["reasoningSummary"] = "auto" } } diff --git a/packages/opencode/test/provider/reasoning-options.test.ts b/packages/opencode/test/provider/reasoning-options.test.ts new file mode 100644 index 000000000000..3f7dc5e18a39 --- /dev/null +++ b/packages/opencode/test/provider/reasoning-options.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigProviderV1 } from "@opencode-ai/core/v1/config/provider" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { Provider } from "@/provider/provider" + +const options = [ + { type: "toggle" }, + { type: "effort", values: [null, "low", "medium", "high", "xhigh", "max", "ultrathink"] }, + { type: "budget_tokens", min: 1024 }, + { type: "budget_tokens", min: 0, max: 24_576 }, +] + +describe("reasoning_options schemas", () => { + test("models.dev model schema decodes all known option shapes", () => { + const model = Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "test-model", + name: "Test Model", + release_date: "2026-01-01", + attachment: false, + reasoning: true, + reasoning_options: options, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }) + expect(model.reasoning_options).toEqual(options as typeof model.reasoning_options) + }) + + test("config model schema decodes reasoning_options", () => { + const model = Schema.decodeUnknownSync(ConfigProviderV1.Model)({ reasoning_options: options }) + expect(model.reasoning_options).toEqual(options as typeof model.reasoning_options) + }) + + test("provider capabilities decode reasoningOptions", () => { + // The resolved catalog never carries null effort values; they are stripped + // when mapping models.dev data, so the resolved schema rejects them. + const resolved = [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "high", "xhigh", "max", "ultrathink"] }, + { type: "budget_tokens", min: 1024 }, + { type: "budget_tokens", min: 0, max: 24_576 }, + ] + const capabilities = Schema.decodeUnknownSync(Provider.Model.fields.capabilities)({ + temperature: true, + reasoning: true, + reasoningOptions: resolved, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }) + expect(capabilities.reasoningOptions).toEqual(resolved as typeof capabilities.reasoningOptions) + expect(() => + Schema.decodeUnknownSync(Provider.Model.fields.capabilities)({ + temperature: true, + reasoning: true, + reasoningOptions: [{ type: "effort", values: [null, "low"] }], + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }), + ).toThrow() + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c3c8cbf81710..7f3f2047e63e 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3962,6 +3962,226 @@ describe("ProviderTransform.variants", () => { }) }) +describe("ProviderTransform.variants - models.dev reasoning_options", () => { + const createModel = (overrides: Partial = {}): any => ({ + id: "test/test-model", + providerID: "test", + api: { + id: "test-model", + url: "https://api.test.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200_000, output: 64_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2024-01-01", + ...overrides, + }) + + const withOptions = (model: any, reasoningOptions: any[]) => { + model.capabilities.reasoningOptions = reasoningOptions + return model + } + + test("effort values drive variants for openai-compatible packages", () => { + const model = withOptions(createModel(), [{ type: "toggle" }, { type: "effort", values: ["high", "max"] }]) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + }) + + test("effort values drive variants for openai with summary and encrypted reasoning", () => { + const model = withOptions(createModel({ api: { id: "gpt-x", url: "", npm: "@ai-sdk/openai" } }), [ + { type: "effort", values: ["low", "high"] }, + ]) + expect(ProviderTransform.variants(model)).toEqual({ + low: { reasoningEffort: "low", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] }, + high: { reasoningEffort: "high", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] }, + }) + }) + + test("null efforts, duplicates, and unknown option types are ignored", () => { + const model = withOptions(createModel(), [ + { type: "future-thing", anything: true }, + { type: "effort", values: [null, "low"] }, + { type: "effort", values: ["low", "high"] }, + ]) + expect(ProviderTransform.variants(model)).toEqual({ + low: { reasoningEffort: "low" }, + high: { reasoningEffort: "high" }, + }) + }) + + test("toggle-only data falls back to the hardcoded tables", () => { + const model = withOptions( + createModel({ + id: "minimax/minimax-m3", + providerID: "minimax", + api: { id: "MiniMax-M3", url: "", npm: "@ai-sdk/anthropic" }, + }), + [{ type: "toggle" }], + ) + expect(ProviderTransform.variants(model)).toEqual({ + none: { thinking: { type: "disabled" } }, + thinking: { thinking: { type: "adaptive" } }, + }) + }) + + test("budget-only data falls back to the hardcoded anthropic budget variants", () => { + const model = withOptions( + createModel({ + id: "anthropic/claude-haiku-4-5", + providerID: "anthropic", + api: { id: "claude-haiku-4-5", url: "", npm: "@ai-sdk/anthropic" }, + }), + [{ type: "budget_tokens", min: 1024 }], + ) + expect(ProviderTransform.variants(model)).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 16_000 } }, + max: { thinking: { type: "enabled", budgetTokens: 31_999 } }, + }) + }) + + test("empty effort values fall back to the hardcoded tables", () => { + const model = withOptions(createModel({ api: { id: "gpt-x", url: "", npm: "@ai-sdk/openai" } }), [ + { type: "effort", values: [] }, + ]) + expect(Object.keys(ProviderTransform.variants(model))).toEqual(["low", "medium", "high"]) + }) + + test("anthropic adaptive models wrap data efforts in adaptive thinking", () => { + const model = withOptions( + createModel({ + id: "anthropic/claude-sonnet-4-6", + providerID: "anthropic", + api: { id: "claude-sonnet-4-6", url: "", npm: "@ai-sdk/anthropic" }, + }), + [ + { type: "effort", values: ["low", "medium", "high", "max"] }, + { type: "budget_tokens", min: 1024 }, + ], + ) + expect(ProviderTransform.variants(model)).toEqual({ + low: { thinking: { type: "adaptive" }, effort: "low" }, + medium: { thinking: { type: "adaptive" }, effort: "medium" }, + high: { thinking: { type: "adaptive" }, effort: "high" }, + max: { thinking: { type: "adaptive" }, effort: "max" }, + }) + }) + + test("anthropic non-adaptive models encode data efforts as plain effort", () => { + const model = withOptions( + createModel({ + id: "anthropic/claude-opus-4-5", + providerID: "anthropic", + api: { id: "claude-opus-4-5", url: "", npm: "@ai-sdk/anthropic" }, + }), + [{ type: "effort", values: ["low", "medium", "high"] }], + ) + expect(ProviderTransform.variants(model)).toEqual({ + low: { effort: "low" }, + medium: { effort: "medium" }, + high: { effort: "high" }, + }) + }) + + test("github-copilot anthropic models filter unsupported data efforts", () => { + const model = withOptions( + createModel({ + id: "github-copilot/claude-sonnet-4-6", + providerID: "github-copilot", + api: { id: "claude-sonnet-4-6", url: "", npm: "@ai-sdk/anthropic" }, + }), + [{ type: "effort", values: ["low", "medium", "high", "max"] }], + ) + expect(Object.keys(ProviderTransform.variants(model))).toEqual(["low", "medium", "high"]) + }) + + test("openrouter encodes data efforts as reasoning.effort", () => { + const model = withOptions( + createModel({ + id: "openrouter/x-ai/grok-4.3", + providerID: "openrouter", + api: { id: "x-ai/grok-4.3", url: "", npm: "@openrouter/ai-sdk-provider" }, + }), + [{ type: "effort", values: ["low", "high"] }], + ) + expect(ProviderTransform.variants(model)).toEqual({ + low: { reasoning: { effort: "low" } }, + high: { reasoning: { effort: "high" } }, + }) + }) + + test("google encodes data efforts as thinkingConfig levels", () => { + const model = withOptions( + createModel({ + id: "google/gemini-3-pro-preview", + providerID: "google", + api: { id: "gemini-3-pro-preview", url: "", npm: "@ai-sdk/google" }, + }), + [{ type: "effort", values: ["low", "high"] }], + ) + expect(ProviderTransform.variants(model)).toEqual({ + low: { thinkingConfig: { includeThoughts: true, thinkingLevel: "low" } }, + high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }) + }) + + test("bedrock non-adaptive models encode data efforts as enabled maxReasoningEffort", () => { + const model = withOptions( + createModel({ + id: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0", + providerID: "amazon-bedrock", + api: { id: "anthropic.claude-opus-4-5-20251101-v1:0", url: "", npm: "@ai-sdk/amazon-bedrock" }, + }), + [{ type: "effort", values: ["low", "medium", "high"] }], + ) + expect(ProviderTransform.variants(model)).toEqual({ + low: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, + medium: { reasoningConfig: { type: "enabled", maxReasoningEffort: "medium" } }, + high: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, + }) + }) + + test("sap anthropic adaptive models wrap data efforts in modelParams", () => { + const model = withOptions( + createModel({ + id: "sap-ai-core/anthropic--claude-4.6-sonnet", + providerID: "sap-ai-core", + api: { id: "anthropic--claude-4.6-sonnet", url: "", npm: "@jerome-benoit/sap-ai-provider-v2" }, + }), + [{ type: "effort", values: ["low", "max"] }], + ) + expect(ProviderTransform.variants(model)).toEqual({ + low: { modelParams: { thinking: { type: "adaptive" }, output_config: { effort: "low" } } }, + max: { modelParams: { thinking: { type: "adaptive" }, output_config: { effort: "max" } } }, + }) + }) + + test("unknown packages default to openai-compatible reasoningEffort", () => { + const model = withOptions(createModel({ api: { id: "some-model", url: "", npm: "@ai-sdk/some-future-sdk" } }), [ + { type: "effort", values: ["low", "ultrathink"] }, + ]) + expect(ProviderTransform.variants(model)).toEqual({ + low: { reasoningEffort: "low" }, + ultrathink: { reasoningEffort: "ultrathink" }, + }) + }) +}) + describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => { const createModel = (apiId: string) => { const model = { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2dce3f6f59bd..ce34fad84347 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1816,6 +1816,20 @@ export type ProviderConfig = { release_date?: string attachment?: boolean reasoning?: boolean + reasoning_options?: Array< + | { + type: "toggle" + } + | { + type: "effort" + values: Array + } + | { + type: "budget_tokens" + min?: number + max?: number + } + > temperature?: boolean tool_call?: boolean interleaved?: @@ -2082,6 +2096,20 @@ export type Model = { capabilities: { temperature: boolean reasoning: boolean + reasoningOptions?: Array< + | { + type: "toggle" + } + | { + type: "effort" + values: Array + } + | { + type: "budget_tokens" + min?: number + max?: number + } + > attachment: boolean toolcall: boolean input: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 32479cf82f93..740245a06faf 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -17917,6 +17917,58 @@ "reasoning": { "type": "boolean" }, + "reasoning_options": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["toggle"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["effort"] + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "values"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["budget_tokens"] + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, "temperature": { "type": "boolean" }, @@ -18652,6 +18704,58 @@ "reasoning": { "type": "boolean" }, + "reasoningOptions": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["toggle"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["effort"] + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "values"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["budget_tokens"] + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, "attachment": { "type": "boolean" },