Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 278361cb48 fix(core): derive models.dev reasoning variants 2026-06-30 23:38:42 -05:00
12 changed files with 257 additions and 64 deletions
+5
View File
@@ -153,6 +153,7 @@ export type AgentListOutput = {
readonly request: {
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
readonly providerOptions?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
readonly system?: string
readonly description?: string
@@ -2147,12 +2148,14 @@ export type ModelListOutput = {
readonly request: {
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
readonly providerOptions?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly variant?: string
}
readonly variants: ReadonlyArray<{
readonly id: string
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
readonly providerOptions?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}>
readonly time: { readonly released: number }
readonly cost: ReadonlyArray<{
@@ -2211,6 +2214,7 @@ export type ProviderListOutput = {
readonly request: {
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
readonly providerOptions?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
}>
}
@@ -2244,6 +2248,7 @@ export type ProviderGetOutput = {
readonly request: {
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
readonly providerOptions?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
}
}
@@ -56,6 +56,12 @@ export const Plugin = define({
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
if (item.request.providerOptions !== undefined) {
provider.request.providerOptions = ProviderV2.mergeProviderOptions(
provider.request.providerOptions,
item.request.providerOptions,
)
}
}
})
for (const [id, config] of Object.entries(item.models ?? {})) {
@@ -73,6 +79,12 @@ export const Plugin = define({
if (config.request !== undefined) {
Object.assign(model.request.headers, config.request.headers)
Object.assign(model.request.body, config.request.body)
if (config.request.providerOptions !== undefined) {
model.request.providerOptions = ProviderV2.mergeProviderOptions(
model.request.providerOptions,
config.request.providerOptions,
)
}
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
@@ -88,6 +100,12 @@ export const Plugin = define({
}
Object.assign(existing.headers, variant.headers)
Object.assign(existing.body, variant.body)
if (variant.providerOptions !== undefined) {
existing.providerOptions = ProviderV2.mergeProviderOptions(
existing.providerOptions,
variant.providerOptions,
)
}
}
}
if (config.cost !== undefined) {
+1
View File
@@ -7,6 +7,7 @@ import { ModelV2 } from "../model"
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
providerOptions: ProviderV2.ProviderOptions.pipe(Schema.optional),
}) {}
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
+6 -1
View File
@@ -26,8 +26,13 @@ export type Api = Model.Api
export const Info = Model.Info
export type Info = Model.Info
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
export type MutableRequest = ProviderV2.MutableRequest & { variant?: string }
export type MutableVariant = ProviderV2.MutableRequest & { id: VariantID }
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request" | "variants"> & {
api: ProviderV2.MutableApi<Api>
request: MutableRequest
variants: MutableVariant[]
}
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
+68 -18
View File
@@ -70,25 +70,75 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"]
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
}
function reasoningVariants(model: ModelsDev.Model, packageName: string | undefined): ModelV2Info["variants"] {
const result = new Map<ModelV2.VariantID, ModelV2Info["variants"][number]>()
if (packageName === "@ai-sdk/openai" || packageName === "@ai-sdk/openai-compatible") {
const option = model.reasoning_options?.find((option) => option.type === "effort")
for (const value of option?.values ?? []) {
const id = value === null ? "none" : value
if (typeof id !== "string") continue
const variantID = ModelV2.VariantID.make(id)
result.set(variantID, {
id: variantID,
headers: {},
body:
packageName === "@ai-sdk/openai"
? { include: ["reasoning.encrypted_content"], reasoning: { effort: id, summary: "auto" } }
: { reasoning_effort: id },
})
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): ModelV2Info["variants"] {
const npm = model.provider?.npm ?? provider.npm
const options = model.reasoning_options ?? []
const effort = options.find((option) => option.type === "effort")
if (effort?.type === "effort") {
return effort.values.flatMap((value) => {
const raw: unknown = value
const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined
if (id === undefined) return []
const providerOptions = providerOptionsForEffort(npm, id)
return providerOptions ? [{ id, headers: {}, body: {}, providerOptions }] : []
})
}
const budget = options.find((option) => option.type === "budget_tokens")
if (budget?.type === "budget_tokens") return budgetVariants(npm, budget)
// Toggle-only reasoning is intentionally left for a follow-up because V1 has
// provider/model-specific behavior like MiniMax M3 adaptive thinking and
// Qwen/GLM enable_thinking request shapes in packages/opencode.
return []
}
function providerOptionsForEffort(npm: string | undefined, effort: string): ProviderV2.ProviderOptions | undefined {
if (npm === "@openrouter/ai-sdk-provider") return { openrouter: { reasoning: { effort } } }
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
return { anthropic: { thinking: { type: "adaptive" }, effort } }
}
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
return { gemini: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } }
}
if (npm === "@ai-sdk/azure") return { openai: { reasoningEffort: effort }, azure: { reasoningEffort: effort } }
if (npm === "@ai-sdk/openai") {
return {
openai: {
reasoningEffort: effort,
reasoningSummary: "auto",
include: OPENAI_INCLUDE_ENCRYPTED_REASONING,
},
}
}
return [...result.values()]
if (npm === "@ai-sdk/openai-compatible") return { openai: { reasoningEffort: effort } }
}
function budgetVariants(
npm: string | undefined,
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
): ModelV2Info["variants"] {
const max = option.max
const high = option.max === undefined ? Math.max(option.min ?? 0, 16_000) : Math.min(Math.max(option.min ?? 0, 16_000), option.max)
return [
{ id: "high", budget: high },
...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]),
].flatMap((item) => {
const providerOptions = providerOptionsForBudget(npm, item.budget)
return providerOptions ? [{ id: item.id, headers: {}, body: {}, providerOptions }] : []
})
}
function providerOptionsForBudget(npm: string | undefined, budget: number): ProviderV2.ProviderOptions | undefined {
if (npm === "@openrouter/ai-sdk-provider") return { openrouter: { reasoning: { max_tokens: budget } } }
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
return { anthropic: { thinking: { type: "enabled", budgetTokens: budget } } }
}
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
return { gemini: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } }
}
}
function modeName(model: ModelsDev.Model, mode: string) {
@@ -193,7 +243,7 @@ export const ModelsDevPlugin = define({
for (const model of Object.values(item.models)) {
const baseCost = cost(model.cost)
const variants = reasoningVariants(model, model.provider?.npm ?? item.npm)
const variants = reasoningVariants(item, model)
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
+20 -1
View File
@@ -19,7 +19,26 @@ export type MutableApi<T extends Api = Api> = T extends Api
export const Request = Provider.Request
export type Request = Provider.Request
export const ProviderOptions = Provider.ProviderOptions
export type ProviderOptions = Provider.ProviderOptions
export const mergeProviderOptions = (...items: ReadonlyArray<ProviderOptions | undefined>): ProviderOptions | undefined => {
const result: Record<string, ProviderOptions[string]> = {}
for (const item of items) {
if (!item) continue
for (const [provider, options] of Object.entries(item)) {
result[provider] = { ...result[provider], ...options }
}
}
return Object.keys(result).length === 0 ? undefined : result
}
export const Info = Provider.Info
export type Info = Provider.Info
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
export type MutableRequest = Omit<Types.DeepMutable<Request>, "providerOptions"> & { providerOptions?: ProviderOptions }
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request"> & {
api: MutableApi
request: MutableRequest
}
@@ -96,6 +96,7 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
provider: model.providerID,
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
headers: model.request.headers,
providerOptions: model.request.providerOptions,
http: { body: httpBody },
limits: { context: model.limit.context, output: model.limit.output },
})
@@ -120,6 +121,12 @@ export const withVariant = (
? produce(model, (draft) => {
Object.assign(draft.request.headers, variant.headers)
Object.assign(draft.request.body, variant.body)
if (variant.providerOptions !== undefined) {
draft.request.providerOptions = ProviderV2.mergeProviderOptions(
draft.request.providerOptions,
variant.providerOptions,
)
}
})
: model,
)
@@ -0,0 +1,56 @@
{
"openai": {
"id": "openai",
"name": "OpenAI",
"env": ["OPENAI_API_KEY"],
"npm": "@ai-sdk/openai",
"api": "https://api.openai.com/v1",
"models": {
"gpt-reasoning": {
"id": "gpt-reasoning",
"name": "GPT Reasoning",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [
{ "type": "effort", "values": ["low", "high"] },
{ "type": "budget_tokens", "min": 1024, "max": 64000 },
{ "type": "toggle" }
],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 },
"experimental": {
"modes": {
"high": {
"provider": {
"headers": { "x-mode": "high" },
"body": { "service_tier": "priority" }
}
}
}
}
}
}
},
"anthropic": {
"id": "anthropic",
"name": "Anthropic",
"env": ["ANTHROPIC_API_KEY"],
"npm": "@ai-sdk/anthropic",
"api": "https://api.anthropic.com/v1",
"models": {
"claude-budget": {
"id": "claude-budget",
"name": "Claude Budget",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 64000 }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 }
}
}
}
}
+57 -44
View File
@@ -173,14 +173,14 @@ describe("ModelsDevPlugin", () => {
),
)
it.effect("derives OpenAI reasoning variants from models.dev reasoning options", () =>
it.effect("converts reasoning options into provider option variants", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
path: Flag.OPENCODE_MODELS_PATH,
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
}
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
return previous
}),
@@ -188,17 +188,6 @@ describe("ModelsDevPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* catalog.transform((catalog) => {
catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5"), (model) => {
model.variants = [
{
id: ModelV2.VariantID.make("high"),
headers: { custom: "true" },
body: { custom: true },
},
]
})
})
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
@@ -206,42 +195,67 @@ describe("ModelsDevPlugin", () => {
}),
)
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5")))?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
headers: {},
body: {
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
expect(model?.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
headers: {},
body: {},
providerOptions: {
openai: {
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
reasoning: { effort: "none", summary: "auto" },
},
},
expect.objectContaining({
id: "low",
body: {
})
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
headers: {},
body: {},
providerOptions: {
openai: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
reasoning: { effort: "low", summary: "auto" },
},
}),
expect.objectContaining({
id: "medium",
body: {
include: ["reasoning.encrypted_content"],
reasoning: { effort: "medium", summary: "auto" },
},
}),
expect.objectContaining({
id: "high",
headers: { custom: "true" },
body: { custom: true },
}),
expect.objectContaining({
id: "xhigh",
body: {
include: ["reasoning.encrypted_content"],
reasoning: { effort: "xhigh", summary: "auto" },
},
}),
},
})
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
expect(mode).toMatchObject({
id: "gpt-reasoning-high",
name: "GPT Reasoning High",
request: {
headers: { "x-mode": "high" },
body: { service_tier: "priority" },
},
})
expect(mode?.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
headers: {},
body: {},
providerOptions: {
anthropic: { thinking: { type: "enabled", budgetTokens: 16000 } },
},
})
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("max"),
headers: {},
body: {},
providerOptions: {
anthropic: { thinking: { type: "enabled", budgetTokens: 64000 } },
},
})
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
(previous) =>
Effect.sync(() => {
@@ -250,5 +264,4 @@ describe("ModelsDevPlugin", () => {
}),
),
)
})
@@ -106,6 +106,7 @@ describe("SessionRunnerModel", () => {
{
id: ModelV2.VariantID.make("high"),
headers: { "x-variant": "high" },
providerOptions: { openai: { reasoningEffort: "high" } },
body: {
store: false,
service_tier: "priority",
@@ -139,6 +140,9 @@ describe("SessionRunnerModel", () => {
temperature: 0.2,
reasoning: { effort: "high" },
})
expect(resolved.route.defaults.providerOptions).toEqual({
openai: { store: false, reasoningEffort: "high" },
})
}),
)
+6
View File
@@ -43,10 +43,16 @@ export const Api = Schema.Union([AISDK, Native])
.annotate({ identifier: "Provider.Api" })
export type Api = typeof Api.Type
export const ProviderOptions = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({
identifier: "Provider.Options",
})
export type ProviderOptions = typeof ProviderOptions.Type
export interface Request extends Schema.Schema.Type<typeof Request> {}
export const Request = Schema.Struct({
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.Record(Schema.String, Schema.Json),
providerOptions: ProviderOptions.pipe(optional),
}).annotate({ identifier: "Provider.Request" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
+9
View File
@@ -4077,6 +4077,12 @@ export type LocationInfo = {
}
}
export type ProviderOptions = {
[key: string]: {
[key: string]: unknown
}
}
export type ProviderRequest = {
headers: {
[key: string]: string
@@ -4084,6 +4090,7 @@ export type ProviderRequest = {
body: {
[key: string]: unknown
}
providerOptions?: ProviderOptions
}
export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info"
@@ -5106,6 +5113,7 @@ export type ModelV2Info = {
body: {
[key: string]: unknown
}
providerOptions?: ProviderOptions
variant?: string
}
variants: Array<{
@@ -5116,6 +5124,7 @@ export type ModelV2Info = {
body: {
[key: string]: unknown
}
providerOptions?: ProviderOptions
}>
time: {
released: number