diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 0d31b32d08..3c33131131 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -48,6 +48,9 @@ export const Plugin = PluginV2.define({ if (config.capabilities !== undefined) { model.capabilities = { tools: config.capabilities.tools, + reasoningOptions: config.capabilities.reasoningOptions?.map((option) => + option.type === "effort" ? { ...option, values: [...option.values] } : { ...option }, + ), input: [...config.capabilities.input], output: [...config.capabilities.output], } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 3b0beece55..17a6e56ac1 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -2,6 +2,7 @@ import { DateTime, Schema } from "effect" import { DateTimeUtcFromMillis } from "effect/Schema" import { ProviderV2 } from "./provider" import { ModelRequest } from "./model-request" +import { ModelsDev } from "./models-dev" export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) export type ID = typeof ID.Type @@ -15,6 +16,7 @@ export type Family = typeof Family.Type export const Capabilities = Schema.Struct({ tools: Schema.Boolean, + reasoningOptions: Schema.Array(ModelsDev.ResolvedReasoningOption).pipe(Schema.optional), // mime patterns, image, audio, video/*, text/* input: Schema.String.pipe(Schema.Array), output: Schema.String.pipe(Schema.Array), diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 3f9f670374..e0c248c7b3 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -43,6 +43,64 @@ const Cost = Schema.Struct({ ), }) +const reasoningOption = (fields: Fields) => + Schema.StructWithRest(Schema.Struct(fields), [Schema.Record(Schema.String, Schema.MutableJson)]) + +export const ReasoningOption = Schema.Union([ + reasoningOption({ type: Schema.Literal("toggle") }), + reasoningOption({ + type: Schema.Literal("effort"), + values: Schema.Array(Schema.NullOr(Schema.String)), + }), + reasoningOption({ + type: Schema.Literal("budget_tokens"), + min: Schema.optional(Schema.Finite), + max: Schema.optional(Schema.Finite), + }), + reasoningOption({ type: Schema.String }), +]) +export type ReasoningOption = typeof ReasoningOption.Type + +export const ResolvedReasoningOption = Schema.Union([ + Schema.Struct({ type: Schema.Literal("toggle") }), + Schema.Struct({ + type: Schema.Literal("effort"), + values: Schema.Array(Schema.String), + }), + Schema.Struct({ + type: Schema.Literal("budget_tokens"), + min: Schema.optional(Schema.Finite), + max: Schema.optional(Schema.Finite), + }), +]) +export type ResolvedReasoningOption = + | { type: "toggle" } + | { type: "effort"; values: string[] } + | { type: "budget_tokens"; min?: number; max?: number } + +export function resolveReasoningOptions( + options: readonly ReasoningOption[] | undefined, +): ResolvedReasoningOption[] | undefined { + if (!options) return + return options + .map((option): ResolvedReasoningOption | undefined => { + if (option.type === "toggle") return { type: "toggle" } + if (option.type === "effort" && Array.isArray(option.values)) { + return { + type: "effort", + values: option.values.filter((value): value is string => typeof value === "string"), + } + } + if (option.type !== "budget_tokens") return + return { + type: "budget_tokens", + ...(typeof option.min === "number" && Number.isFinite(option.min) ? { min: option.min } : {}), + ...(typeof option.max === "number" && Number.isFinite(option.max) ? { max: option.max } : {}), + } + }) + .filter((option): option is ResolvedReasoningOption => option !== undefined) +} + export const Model = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -50,6 +108,7 @@ export const Model = Schema.Struct({ 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( diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 6424c6b6ab..3da810a4a2 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -99,6 +99,7 @@ export const ModelsDevPlugin = PluginV2.define({ } draft.capabilities = { tools: model.tool_call, + reasoningOptions: ModelsDev.resolveReasoningOptions(model.reasoning_options), input: [...(model.modalities?.input ?? [])], output: [...(model.modalities?.output ?? [])], } diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 31a3e57c10..c7bba24e3d 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" -import { Effect, Layer, Ref } from "effect" +import { describe, expect, beforeAll, beforeEach, afterAll, test } from "bun:test" +import { Effect, Layer, Ref, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { FSUtil } from "@opencode-ai/core/fs-util" import { Flag } from "@opencode-ai/core/flag/flag" @@ -125,6 +125,19 @@ const initialState: MockState = { calls: [], } +test("reasoning options preserve unknown types and fields", () => { + const options: ModelsDev.ReasoningOption[] = [ + { type: "effort", values: [null, "low", "ultrathink"], default: "low" }, + { type: "future_dynamic_budget", curve: { min: 1, max: 10 }, enabled: true }, + ] + const model = Schema.decodeUnknownSync(ModelsDev.Model)({ + ...fixture.acme.models["acme-1"], + reasoning_options: options, + }) + + expect(model.reasoning_options).toEqual(options) +}) + describe("ModelsDev Service", () => { it.live("get() returns providers from disk when cache file exists", () => Effect.gen(function* () { diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts new file mode 100644 index 0000000000..81b7363384 --- /dev/null +++ b/packages/core/test/plugin/models-dev.test.ts @@ -0,0 +1,68 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" +import { testEffect } from "../lib/effect" + +const reasoningOptions: ModelsDev.ReasoningOption[] = [ + { type: "toggle" }, + { type: "effort", values: [null, "low", "ultrathink"], default: "low" }, + { type: "budget_tokens", min: 1024, future: true }, + { type: "future_dynamic_budget", curve: { min: 1, max: 10 }, enabled: true }, +] + +const modelsDev = Layer.succeed( + ModelsDev.Service, + ModelsDev.Service.of({ + get: () => + Effect.succeed({ + acme: { + id: "acme", + name: "Acme", + env: [], + models: { + "acme-1": { + id: "acme-1", + name: "Acme One", + release_date: "2026-01-01", + attachment: false, + reasoning: true, + reasoning_options: reasoningOptions, + temperature: true, + tool_call: true, + limit: { context: 128_000, output: 8_192 }, + }, + }, + }, + }), + refresh: () => Effect.void, + }), +) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("test") })), +) +const catalog = Catalog.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)) +const it = testEffect(Layer.merge(catalog, modelsDev)) + +describe("ModelsDevPlugin", () => { + it.effect("maps known reasoning options into typed V2 capabilities", () => + Effect.gen(function* () { + yield* ModelsDevPlugin.effect + const model = yield* (yield* Catalog.Service).model.get(ProviderV2.ID.make("acme"), ModelV2.ID.make("acme-1")) + + expect(model.capabilities.reasoningOptions).toEqual([ + { type: "toggle" }, + { type: "effort", values: ["low", "ultrathink"] }, + { type: "budget_tokens", min: 1024 }, + ]) + }), + ) +}) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 86515068d4..a13c4e76d7 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -960,6 +960,7 @@ const ProviderInterleaved = Schema.Union([ const ProviderCapabilities = Schema.Struct({ temperature: Schema.Boolean, reasoning: Schema.Boolean, + reasoningOptions: optionalOmitUndefined(Schema.Array(ModelsDev.ResolvedReasoningOption)), attachment: Schema.Boolean, toolcall: Schema.Boolean, input: ProviderModalities, @@ -1178,6 +1179,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model capabilities: { temperature: model.temperature ?? false, reasoning: model.reasoning ?? false, + reasoningOptions: ModelsDev.resolveReasoningOptions(model.reasoning_options), attachment: model.attachment ?? false, toolcall: model.tool_call ?? true, input: { @@ -1400,6 +1402,7 @@ export const layer = Layer.effect( capabilities: { temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, + reasoningOptions: existingModel?.capabilities.reasoningOptions, attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, input: { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 6edfc97ca0..6f5a15cf70 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1286,6 +1286,12 @@ test("mode cost preserves over-200k pricing from base model", () => { }) test("models.dev normalization fills required response fields", () => { + const reasoningOptions: ModelsDev.ReasoningOption[] = [ + { type: "toggle" }, + { type: "effort", values: [null, "low", "ultrathink"], default: "low" }, + { type: "budget_tokens", min: 1024, future: true }, + { type: "future_dynamic_budget", curve: { min: 1, max: 10 } }, + ] const provider = { id: "gateway", name: "Gateway", @@ -1295,6 +1301,7 @@ test("models.dev normalization fills required response fields", () => { id: "gpt-5.4", name: "GPT-5.4", family: "gpt", + reasoning_options: reasoningOptions, cost: { input: 2.5, output: 15 }, limit: { context: 1_050_000, input: 922_000, output: 128_000 }, }, @@ -1305,6 +1312,11 @@ test("models.dev normalization fills required response fields", () => { expect(model.api.url).toBe("") expect(model.capabilities.temperature).toBe(false) expect(model.capabilities.reasoning).toBe(false) + expect(model.capabilities.reasoningOptions).toEqual([ + { type: "toggle" }, + { type: "effort", values: ["low", "ultrathink"] }, + { type: "budget_tokens", min: 1024 }, + ]) expect(model.capabilities.attachment).toBe(false) expect(model.capabilities.toolcall).toBe(true) expect(model.release_date).toBe("") diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2dce3f6f59..2bded90cca 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2082,6 +2082,20 @@ export type Model = { capabilities: { temperature: boolean reasoning: boolean + reasoningOptions?: Array< + | { + type: "toggle" + } + | { + type: "effort" + values: Array + } + | { + type: "budget_tokens" + min?: number + max?: number + } + > attachment: boolean toolcall: boolean input: { @@ -2878,6 +2892,20 @@ export type ModelV2Info = { } capabilities: { tools: boolean + reasoningOptions?: Array< + | { + type: "toggle" + } + | { + type: "effort" + values: Array + } + | { + type: "budget_tokens" + min?: number + max?: number + } + > input: Array output: Array } @@ -4230,6 +4258,20 @@ export type ModelV2Info1 = { } capabilities: { tools: boolean + reasoningOptions?: Array< + | { + type: "toggle" + } + | { + type: "effort" + values: Array + } + | { + type: "budget_tokens" + min?: number + max?: number + } + > input: Array output: Array }