diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3b3188c874..91665e4cb6 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -1960,6 +1960,7 @@ export type ModelsListOutput = { } readonly capabilities: { readonly tools: boolean + readonly reasoning: boolean readonly input: ReadonlyArray readonly output: ReadonlyArray } diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 6f6e0528da..def2c15faf 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -66,6 +66,7 @@ export const Plugin = define({ if (config.capabilities !== undefined) { model.capabilities = { tools: config.capabilities.tools, + reasoning: config.capabilities.reasoning, input: [...config.capabilities.input], output: [...config.capabilities.output], } diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 1adc979d25..a5838db715 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -108,6 +108,7 @@ export const ModelsDevPlugin = define({ } draft.capabilities = { tools: model.tool_call, + reasoning: model.reasoning, input: [...(model.modalities?.input ?? [])], output: [...(model.modalities?.output ?? [])], } diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index f07d72e5f4..91aee68485 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -135,6 +135,7 @@ export const OpencodePlugin = define ids.includes(name))) return [] - return ["high", "max"].map((id) => ({ - id, + if (model.api.type !== "aisdk") return [] + + if (model.api.package === "@ai-sdk/openai-compatible") { + const ids = `${model.id} ${model.api.id}`.toLowerCase() + if (["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) + return ["high", "max"].map((id) => ({ + id, + headers: {}, + body: { reasoning_effort: id }, + })) + return [] + } + + if (model.api.package === "@ai-sdk/anthropic") return anthropicThinking(model) + + return [] +} + +// Anthropic thinking levels lower to the Messages API `thinking` body, which the +// v2 Anthropic protocol only accepts as `{ type: "enabled", budget_tokens }`. +// Budgets scale with the model output limit so the larger tier stays under the cap. +function anthropicThinking(model: ModelV2Info): ModelV2Info["variants"] { + if (!model.capabilities.reasoning) return [] + const budget = (target: number) => Math.max(1024, Math.min(target, model.limit.output - 1)) + return [ + { id: "high", target: 16_000 }, + { id: "max", target: 31_999 }, + ].map((tier) => ({ + id: tier.id, headers: {}, - body: { reasoning_effort: id }, + body: { thinking: { type: "enabled", budget_tokens: budget(tier.target) } }, })) } diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index ebe1b905ff..2939e16591 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -211,8 +211,16 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st : []), ] const capabilities = - info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined - ? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] } + info.tool_call !== undefined || + info.reasoning !== undefined || + info.modalities?.input !== undefined || + info.modalities?.output !== undefined + ? { + tools: info.tool_call ?? false, + reasoning: info.reasoning ?? false, + input: info.modalities?.input ?? [], + output: info.modalities?.output ?? [], + } : undefined return { family: info.family, diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 605bcd63cf..ed085ca5f8 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -174,7 +174,7 @@ describe("ConfigProviderPlugin.Plugin", () => { models: { chat: { name: "First", - capabilities: { tools: true, input: ["text"], output: ["text"] }, + capabilities: { tools: true, reasoning: false, input: ["text"], output: ["text"] }, disabled: true, limit: { context: 100, output: 50 }, cost: { input: 1, output: 2 }, @@ -251,7 +251,7 @@ describe("ConfigProviderPlugin.Plugin", () => { expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(model.api.id).toBe(ModelV2.ID.make("api-chat")) expect(model.name).toBe("Last") - expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) + expect(model.capabilities).toEqual({ tools: true, reasoning: false, input: ["text"], output: ["text"] }) expect(model.enabled).toBe(false) expect(model.limit).toEqual({ context: 100, output: 75 }) expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 45d423f958..7f33b39f0c 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -111,6 +111,7 @@ describe("OpencodePlugin", () => { family: "remote", release_date: "2026-01-02", tool_call: true, + reasoning: true, modalities: { input: ["text", "image"], output: ["text"] }, options: { apiKey: "model-secret", temperature: 0.5 }, variants: { high: { apiKey: "variant-secret", temperature: 0.2 } }, @@ -170,7 +171,7 @@ describe("OpencodePlugin", () => { expect(model).toMatchObject({ name: "Remote Model", family: "remote", - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + capabilities: { tools: true, reasoning: true, input: ["text", "image"], output: ["text"] }, cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }], limit: { context: 1000, output: 100 }, }) diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts index 45476f366c..a625c33cc2 100644 --- a/packages/core/test/plugin/variant.test.ts +++ b/packages/core/test/plugin/variant.test.ts @@ -76,4 +76,73 @@ describe("VariantPlugin", () => { ]) }), ) + + it.effect("adds Anthropic thinking variants for reasoning models", () => + Effect.gen(function* () { + const service = yield* Catalog.Service + yield* service.transform((catalog) => { + catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("claude-opus-4-8"), (model) => { + model.api = { + id: ModelV2.ID.make("claude-opus-4-8"), + type: "aisdk", + package: "@ai-sdk/anthropic", + } + model.capabilities.reasoning = true + model.limit = { context: 200_000, output: 64_000 } + }) + }) + yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) + + expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("claude-opus-4-8")))?.variants).toEqual([ + expect.objectContaining({ id: "high", body: { thinking: { type: "enabled", budget_tokens: 16_000 } } }), + expect.objectContaining({ id: "max", body: { thinking: { type: "enabled", budget_tokens: 31_999 } } }), + ]) + }), + ) + + it.effect("clamps Anthropic thinking budgets to the output limit", () => + Effect.gen(function* () { + const service = yield* Catalog.Service + yield* service.transform((catalog) => { + catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("claude-haiku-4-5"), (model) => { + model.api = { + id: ModelV2.ID.make("claude-haiku-4-5"), + type: "aisdk", + package: "@ai-sdk/anthropic", + } + model.capabilities.reasoning = true + model.limit = { context: 200_000, output: 8_000 } + }) + }) + yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) + + expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("claude-haiku-4-5")))?.variants).toEqual( + [ + expect.objectContaining({ id: "high", body: { thinking: { type: "enabled", budget_tokens: 7_999 } } }), + expect.objectContaining({ id: "max", body: { thinking: { type: "enabled", budget_tokens: 7_999 } } }), + ], + ) + }), + ) + + it.effect("skips Anthropic models without reasoning capability", () => + Effect.gen(function* () { + const service = yield* Catalog.Service + yield* service.transform((catalog) => { + catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("claude-3-5-haiku"), (model) => { + model.api = { + id: ModelV2.ID.make("claude-3-5-haiku"), + type: "aisdk", + package: "@ai-sdk/anthropic", + } + model.capabilities.reasoning = false + }) + }) + yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) + + expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("claude-3-5-haiku")))?.variants).toEqual( + [], + ) + }), + ) }) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 49bbce95a3..7ad9549597 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -28,7 +28,7 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => providerID: ProviderV2.ID.make("test-provider"), name: "Test model", api: { id: ModelV2.ID.make("api-test-model"), ...api }, - capabilities: { tools: true, input: ["text"], output: ["text"] }, + capabilities: { tools: true, reasoning: false, input: ["text"], output: ["text"] }, request: { headers: { "x-test": "header" }, body: { apiKey: "secret", custom_extension: { enabled: true } }, diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 10fa175e24..b1d9677a52 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -24,6 +24,7 @@ export type Family = typeof Family.Type export interface Capabilities extends Schema.Schema.Type {} export const Capabilities = Schema.Struct({ tools: Schema.Boolean, + reasoning: Schema.Boolean, input: Schema.Array(Schema.String), output: Schema.Array(Schema.String), }).annotate({ identifier: "Model.Capabilities" }) @@ -93,7 +94,7 @@ export const Info = Schema.Struct({ providerID, name: modelID, api: { id: modelID, type: "native", settings: {} }, - capabilities: { tools: false, input: [], output: [] }, + capabilities: { tools: false, reasoning: false, input: [], output: [] }, request: { headers: {}, body: {} }, variants: [], time: { released: 0 }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5e067f3afb..9f518e413f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4787,6 +4787,7 @@ export type ModelApi = export type ModelCapabilities = { tools: boolean + reasoning: boolean input: Array output: Array } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b30c3beb35..b8b605b249 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -29767,6 +29767,9 @@ "tools": { "type": "boolean" }, + "reasoning": { + "type": "boolean" + }, "input": { "type": "array", "items": { @@ -29780,7 +29783,7 @@ } } }, - "required": ["tools", "input", "output"], + "required": ["tools", "reasoning", "input", "output"], "additionalProperties": false }, "ModelCost": {