Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline f7dcfc2680 feat: drive reasoning variants from models.dev reasoning_options
Parse the curated reasoning_options field from models.dev api.json and use
its effort values to generate reasoning variants instead of the hardcoded
per-package tables, in both the v1 provider catalog and the v2 catalog plugin.

- core: ModelsDev.ReasoningOption discriminated union (toggle | effort |
  budget_tokens); effort values stay open strings and unknown option types
  are tolerated since api.json is cast, not decoded
- core: ReasoningVariants shared per-package effort encoder used by v1
  ProviderTransform.variants and the v2 ModelsDevPlugin
- core: v2 catalog generates effort variants from reasoning_options;
  curated experimental modes win id collisions; anthropic profile gains
  the effort semantic
- llm: anthropic protocol supports adaptive thinking, lowers effort to
  output_config.effort, and sends the effort-2025-11-24 beta header
- opencode: resolved Provider.Model carries capabilities.reasoningOptions
  (unknown types and null effort values dropped at the mapping boundary);
  config models accept reasoning_options; models without usable effort
  data fall back to the hardcoded tables unchanged

Catalog-wide audit vs live api.json: 2975 models byte-identical, 38 diffs,
all data correcting stale hardcoded effort lists.
2026-06-09 17:21:11 -05:00
15 changed files with 954 additions and 62 deletions
+10 -1
View File
@@ -84,7 +84,16 @@ const profiles = new Map<string, Profile>([
]),
},
],
["@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
+25
View File
@@ -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<typeof ReasoningOption>
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(
+18 -3
View File
@@ -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"
+187
View File
@@ -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<string | null> }> | undefined,
): Record<string, Record<string, unknown>> | 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<string, Record<string, unknown>>,
): Record<string, Record<string, unknown>> {
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<string, Record<string, unknown>> {
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<string, Record<string, unknown>> {
const fromEffort = (encode: (effort: string) => Record<string, unknown>) =>
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 }))
}
+22
View File
@@ -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(
+6 -1
View File
@@ -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" },
},
},
@@ -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<ModelsDev.Model> & { 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<string, ModelsDev.Provider> = {
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" },
})
}),
)
})
@@ -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<typeof AnthropicMessagesBody>
@@ -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"
@@ -57,6 +57,58 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("lowers enabled thinking to a budget", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
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<AnthropicMessages.AnthropicMessagesBody>(
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<string, string>[] = []
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<AnthropicMessages.AnthropicMessagesBody>(
@@ -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: {
+21 -51
View File
@@ -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<ModelsDev.Model["modalities"]>["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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
}
@@ -595,34 +591,6 @@ function openaiCompatibleReasoningEfforts(id: string) {
return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS
}
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)
}
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<string, Record<string, any>>): Record<string, Record<string, any>> {
return Object.fromEntries(Object.entries(variants).map(([k, v]) => [k, { modelParams: v }]))
}
function googleThinkingVariants(model: Provider.Model): Record<string, Record<string, any>> {
const id = model.api.id.toLowerCase()
if (id.includes("2.5")) {
@@ -665,6 +627,14 @@ function googleThinkingVariants(model: Provider.Model): Record<string, Record<st
export function variants(model: Provider.Model): Record<string, Record<string, any>> {
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<string, Record<string, a
thinking: { thinking: { type: "adaptive" } },
}
}
const adaptiveThinkingOmitted = anthropicOmitsThinking(model.api.id)
const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id)
const adaptiveThinkingOmitted = ReasoningVariants.anthropicOmitsThinking(model.api.id)
const adaptiveEfforts = ReasoningVariants.anthropicAdaptiveEfforts(model.api.id)
if (
id.includes("deepseek-chat") ||
id.includes("deepseek-reasoner") ||
@@ -815,7 +785,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
include: ReasoningVariants.INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@@ -849,7 +819,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
include: ReasoningVariants.INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@@ -863,7 +833,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
include: ReasoningVariants.INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@@ -1010,7 +980,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
if (adaptiveEfforts) {
// Bedrock adaptive splits `effort` out into `output_config` (vs Anthropic
// native which inlines it). Opus 4.7+ flipped `display` default to "omitted".
return wrapInSapModelParams(
return ReasoningVariants.wrapInSapModelParams(
Object.fromEntries(
adaptiveEfforts.map((effort) => [
effort,
@@ -1022,19 +992,19 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
),
)
}
return wrapInSapModelParams({
return ReasoningVariants.wrapInSapModelParams({
high: { thinking: { type: "enabled", budget_tokens: 16000 } },
max: { thinking: { type: "enabled", budget_tokens: 31999 } },
})
}
if (id.includes("gemini") && id.includes("2.5")) {
return wrapInSapModelParams(googleThinkingVariants(model))
return ReasoningVariants.wrapInSapModelParams(googleThinkingVariants(model))
}
if (id.includes("gpt") || /\bo[1-9]/.test(id)) {
const efforts = openaiReasoningEfforts(id, model.release_date)
return wrapInSapModelParams(Object.fromEntries(efforts.map((effort) => [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"
}
}
@@ -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()
})
})
@@ -3962,6 +3962,226 @@ describe("ProviderTransform.variants", () => {
})
})
describe("ProviderTransform.variants - models.dev reasoning_options", () => {
const createModel = (overrides: Partial<any> = {}): 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 = {
+28
View File
@@ -1816,6 +1816,20 @@ export type ProviderConfig = {
release_date?: string
attachment?: boolean
reasoning?: boolean
reasoning_options?: Array<
| {
type: "toggle"
}
| {
type: "effort"
values: Array<string>
}
| {
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<string>
}
| {
type: "budget_tokens"
min?: number
max?: number
}
>
attachment: boolean
toolcall: boolean
input: {
+104
View File
@@ -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"
},