Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton ce0b726758 refactor(core): scope variant id string to its branch 2026-06-28 13:37:00 -04:00
Kit Langton 7c4c4c7239 feat(core): generate Anthropic thinking variants for reasoning models 2026-06-28 13:26:34 -04:00
13 changed files with 125 additions and 14 deletions
+1
View File
@@ -1960,6 +1960,7 @@ export type ModelsListOutput = {
}
readonly capabilities: {
readonly tools: boolean
readonly reasoning: boolean
readonly input: ReadonlyArray<string>
readonly output: ReadonlyArray<string>
}
@@ -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],
}
+1
View File
@@ -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 ?? [])],
}
@@ -135,6 +135,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
: { id: model.api.id, type: "native", url: config.provider.api, settings: {} }
}
if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call
if (config.reasoning !== undefined) model.capabilities.reasoning = config.reasoning
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
const packageName = config.provider?.npm ?? item.npm
+30 -6
View File
@@ -28,12 +28,36 @@ export const Plugin = define({
})
export function generate(model: ModelV2Info): ModelV2Info["variants"] {
if (model.api.type !== "aisdk" || model.api.package !== "@ai-sdk/openai-compatible") return []
const ids = `${model.id} ${model.api.id}`.toLowerCase()
if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => 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) } },
}))
}
+10 -2
View File
@@ -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,
+2 -2
View File
@@ -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 }])
@@ -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 },
})
+69
View File
@@ -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(
[],
)
}),
)
})
@@ -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 } },
+2 -1
View File
@@ -24,6 +24,7 @@ export type Family = typeof Family.Type
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
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 },
+1
View File
@@ -4787,6 +4787,7 @@ export type ModelApi =
export type ModelCapabilities = {
tools: boolean
reasoning: boolean
input: Array<string>
output: Array<string>
}
+4 -1
View File
@@ -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": {