Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7dcfc2680 |
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user