Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/core/src/model-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/models-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,38 @@ 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,
family: Schema.optional(Schema.String),
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(
Expand Down
21 changes: 18 additions & 3 deletions packages/core/src/plugin/models-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -39,15 +40,29 @@ 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),
headers: { ...(item.provider?.headers ?? {}) },
...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({
Expand Down Expand Up @@ -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"
Expand Down
187 changes: 187 additions & 0 deletions packages/core/src/reasoning-variants.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 22 additions & 0 deletions packages/core/src/v1/config/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 6 additions & 1 deletion packages/core/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
},
Expand Down
Loading
Loading