From a47ff5c746d172685c68adc6263e0720682d792e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 9 Jun 2026 19:31:10 -0500 Subject: [PATCH 1/2] feat(core): sync models.dev reasoning options --- packages/core/src/config/plugin/provider.ts | 1 + packages/core/src/model.ts | 1 + packages/core/src/models-dev.ts | 6 ++ packages/core/src/plugin/models-dev.ts | 1 + packages/core/test/models.test.ts | 17 ++++- packages/core/test/plugin/models-dev.test.ts | 68 +++++++++++++++++++ packages/opencode/src/provider/provider.ts | 5 ++ .../opencode/test/provider/provider.test.ts | 6 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 9 +++ 9 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 packages/core/test/plugin/models-dev.test.ts diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 0d31b32d08fd..c7951b9954e2 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -48,6 +48,7 @@ export const Plugin = PluginV2.define({ if (config.capabilities !== undefined) { model.capabilities = { tools: config.capabilities.tools, + reasoningOptions: config.capabilities.reasoningOptions?.map((option) => ({ ...option })), input: [...config.capabilities.input], output: [...config.capabilities.output], } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 3b0beece55fe..e847b79dda4c 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -15,6 +15,7 @@ export type Family = typeof Family.Type export const Capabilities = Schema.Struct({ tools: Schema.Boolean, + reasoningOptions: Schema.Array(Schema.Record(Schema.String, Schema.Any)).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 3f9f670374e5..0097d7930c58 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -43,6 +43,11 @@ const Cost = Schema.Struct({ ), }) +export const ReasoningOption = Schema.StructWithRest(Schema.Struct({ type: Schema.String }), [ + Schema.Record(Schema.String, Schema.MutableJson), +]) +export type ReasoningOption = typeof ReasoningOption.Type + export const Model = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -50,6 +55,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 6424c6b6ab7f..c6e06ef73570 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: model.reasoning_options?.map((option) => ({ ...option })), 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 31a3e57c10c6..c7bba24e3ddf 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 000000000000..db0603ade3cc --- /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: "effort", values: [null, "low", "ultrathink"], default: "low" }, + { 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("preserves reasoning options in V2 model 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(reasoningOptions) + }), + ) +}) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 86515068d46e..a4909559af9e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -957,9 +957,12 @@ const ProviderInterleaved = Schema.Union([ }), ]) +const ProviderReasoningOption = Schema.Record(Schema.String, Schema.Any) + const ProviderCapabilities = Schema.Struct({ temperature: Schema.Boolean, reasoning: Schema.Boolean, + reasoningOptions: optionalOmitUndefined(Schema.Array(ProviderReasoningOption)), attachment: Schema.Boolean, toolcall: Schema.Boolean, input: ProviderModalities, @@ -1178,6 +1181,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model capabilities: { temperature: model.temperature ?? false, reasoning: model.reasoning ?? false, + reasoningOptions: model.reasoning_options?.map((option) => ({ ...option })), attachment: model.attachment ?? false, toolcall: model.tool_call ?? true, input: { @@ -1400,6 +1404,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 6edfc97ca06e..d8bbcfee1126 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1286,6 +1286,10 @@ test("mode cost preserves over-200k pricing from base model", () => { }) test("models.dev normalization fills required response fields", () => { + const reasoningOptions: ModelsDev.ReasoningOption[] = [ + { type: "effort", values: [null, "low", "ultrathink"], default: "low" }, + { type: "future_dynamic_budget", curve: { min: 1, max: 10 } }, + ] const provider = { id: "gateway", name: "Gateway", @@ -1295,6 +1299,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 +1310,7 @@ 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(reasoningOptions) 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 2dce3f6f59bd..b7ada8a938a0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2082,6 +2082,9 @@ export type Model = { capabilities: { temperature: boolean reasoning: boolean + reasoningOptions?: Array<{ + [key: string]: unknown + }> attachment: boolean toolcall: boolean input: { @@ -2878,6 +2881,9 @@ export type ModelV2Info = { } capabilities: { tools: boolean + reasoningOptions?: Array<{ + [key: string]: unknown + }> input: Array output: Array } @@ -4230,6 +4236,9 @@ export type ModelV2Info1 = { } capabilities: { tools: boolean + reasoningOptions?: Array<{ + [key: string]: unknown + }> input: Array output: Array } From e8c63dbf83f2882cdc104a6e7b3a2b231662b41e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 9 Jun 2026 19:39:42 -0500 Subject: [PATCH 2/2] fix(core): type models.dev reasoning options --- packages/core/src/config/plugin/provider.ts | 4 +- packages/core/src/model.ts | 3 +- packages/core/src/models-dev.ts | 57 ++++++++++++++++++- packages/core/src/plugin/models-dev.ts | 2 +- packages/core/test/plugin/models-dev.test.ts | 20 +++---- packages/opencode/src/provider/provider.ts | 6 +- .../opencode/test/provider/provider.test.ts | 8 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 51 ++++++++++++++--- 8 files changed, 122 insertions(+), 29 deletions(-) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index c7951b9954e2..3c3313113102 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -48,7 +48,9 @@ export const Plugin = PluginV2.define({ if (config.capabilities !== undefined) { model.capabilities = { tools: config.capabilities.tools, - reasoningOptions: config.capabilities.reasoningOptions?.map((option) => ({ ...option })), + 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 e847b79dda4c..17a6e56ac19f 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,7 +16,7 @@ export type Family = typeof Family.Type export const Capabilities = Schema.Struct({ tools: Schema.Boolean, - reasoningOptions: Schema.Array(Schema.Record(Schema.String, Schema.Any)).pipe(Schema.optional), + 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 0097d7930c58..e0c248c7b352 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -43,11 +43,64 @@ const Cost = Schema.Struct({ ), }) -export const ReasoningOption = Schema.StructWithRest(Schema.Struct({ type: Schema.String }), [ - Schema.Record(Schema.String, Schema.MutableJson), +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, diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index c6e06ef73570..3da810a4a276 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -99,7 +99,7 @@ export const ModelsDevPlugin = PluginV2.define({ } draft.capabilities = { tools: model.tool_call, - reasoningOptions: model.reasoning_options?.map((option) => ({ ...option })), + reasoningOptions: ModelsDev.resolveReasoningOptions(model.reasoning_options), input: [...(model.modalities?.input ?? [])], output: [...(model.modalities?.output ?? [])], } diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index db0603ade3cc..81b73633847a 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -12,7 +12,9 @@ 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 }, ] @@ -47,22 +49,20 @@ 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 catalog = Catalog.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)) const it = testEffect(Layer.merge(catalog, modelsDev)) describe("ModelsDevPlugin", () => { - it.effect("preserves reasoning options in V2 model capabilities", () => + 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"), - ) + const model = yield* (yield* Catalog.Service).model.get(ProviderV2.ID.make("acme"), ModelV2.ID.make("acme-1")) - expect(model.capabilities.reasoningOptions).toEqual(reasoningOptions) + 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 a4909559af9e..a13c4e76d71e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -957,12 +957,10 @@ const ProviderInterleaved = Schema.Union([ }), ]) -const ProviderReasoningOption = Schema.Record(Schema.String, Schema.Any) - const ProviderCapabilities = Schema.Struct({ temperature: Schema.Boolean, reasoning: Schema.Boolean, - reasoningOptions: optionalOmitUndefined(Schema.Array(ProviderReasoningOption)), + reasoningOptions: optionalOmitUndefined(Schema.Array(ModelsDev.ResolvedReasoningOption)), attachment: Schema.Boolean, toolcall: Schema.Boolean, input: ProviderModalities, @@ -1181,7 +1179,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model capabilities: { temperature: model.temperature ?? false, reasoning: model.reasoning ?? false, - reasoningOptions: model.reasoning_options?.map((option) => ({ ...option })), + reasoningOptions: ModelsDev.resolveReasoningOptions(model.reasoning_options), attachment: model.attachment ?? false, toolcall: model.tool_call ?? true, input: { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index d8bbcfee1126..6f5a15cf7035 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1287,7 +1287,9 @@ 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 = { @@ -1310,7 +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(reasoningOptions) + 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 b7ada8a938a0..2bded90cca4f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2082,9 +2082,20 @@ export type Model = { capabilities: { temperature: boolean reasoning: boolean - reasoningOptions?: Array<{ - [key: string]: unknown - }> + reasoningOptions?: Array< + | { + type: "toggle" + } + | { + type: "effort" + values: Array + } + | { + type: "budget_tokens" + min?: number + max?: number + } + > attachment: boolean toolcall: boolean input: { @@ -2881,9 +2892,20 @@ export type ModelV2Info = { } capabilities: { tools: boolean - reasoningOptions?: Array<{ - [key: string]: unknown - }> + reasoningOptions?: Array< + | { + type: "toggle" + } + | { + type: "effort" + values: Array + } + | { + type: "budget_tokens" + min?: number + max?: number + } + > input: Array output: Array } @@ -4236,9 +4258,20 @@ export type ModelV2Info1 = { } capabilities: { tools: boolean - reasoningOptions?: Array<{ - [key: string]: unknown - }> + reasoningOptions?: Array< + | { + type: "toggle" + } + | { + type: "effort" + values: Array + } + | { + type: "budget_tokens" + min?: number + max?: number + } + > input: Array output: Array }