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.
This commit is contained in:
parent
c939aa04fe
commit
f7dcfc2680
15 changed files with 954 additions and 62 deletions
|
|
@ -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>(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue