diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index c2325cb57f..82582afdc3 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -51,6 +51,9 @@ export const Model = Schema.Struct({ release_date: Schema.String, attachment: Schema.Boolean, reasoning: Schema.Boolean, + // models.dev is external metadata and reasoning controls are expected to evolve. + // Provider normalization extracts the subset understood by this client. + reasoning_options: Schema.optional(Schema.Unknown), temperature: Schema.Boolean, tool_call: Schema.Boolean, interleaved: Schema.optional( diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index a3ed86dc8c..21e0f5653c 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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" @@ -126,6 +126,21 @@ const initialState: MockState = { calls: [], } +test("models.dev model schema keeps reasoning options permissive", () => { + const model = Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "acme-1", + name: "Acme One", + release_date: "2026-01-01", + attachment: false, + reasoning: true, + reasoning_options: [{ type: "future_control", value: { nested: true } }, "future-shape"], + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }) + expect(model.reasoning_options).toEqual([{ type: "future_control", value: { nested: true } }, "future-shape"]) +}) + describe("ModelsDev Service", () => { it.live("get() returns providers from disk when cache file exists", () => Effect.gen(function* () { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c0a222d649..b1263dcd6e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -981,6 +981,21 @@ const ProviderInterleaved = Schema.Union([ }), ]) +const ProviderReasoningOption = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("effort"), + values: Schema.Array(Schema.NullOr(Schema.String)), + }), + Schema.Struct({ + type: Schema.Literal("toggle"), + }), + Schema.Struct({ + type: Schema.Literal("budget_tokens"), + min: optional(Schema.Finite), + max: optional(Schema.Finite), + }), +]) + const ProviderCapabilities = Schema.Struct({ temperature: Schema.Boolean, reasoning: Schema.Boolean, @@ -1039,6 +1054,7 @@ export const Model = Schema.Struct({ options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, + reasoning_options: optional(Schema.Array(ProviderReasoningOption)), variants: optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), }).annotate({ identifier: "Model" }) export type Model = Types.DeepMutable> @@ -1202,6 +1218,36 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { return result } +type ReasoningOption = NonNullable[number] + +function reasoningOptions(input: unknown): Model["reasoning_options"] { + if (!Array.isArray(input)) return [] + return input.flatMap((option) => { + const normalized = normalizeReasoningOption(option) + return normalized ? [normalized] : [] + }) +} + +function normalizeReasoningOption(option: unknown): ReasoningOption | undefined { + if (!isRecord(option)) return + if (option.type === "effort") { + if (!Array.isArray(option.values)) return + return { + type: "effort", + values: option.values.filter((value): value is string | null => value === null || typeof value === "string"), + } + } + if (option.type === "toggle") return { type: "toggle" } + if (option.type !== "budget_tokens") return + const min = typeof option.min === "number" && Number.isFinite(option.min) ? option.min : undefined + const max = typeof option.max === "number" && Number.isFinite(option.max) ? option.max : undefined + return { + type: "budget_tokens", + ...(min === undefined ? {} : { min }), + ...(max === undefined ? {} : { max }), + } +} + function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const base: Model = { id: ModelV2.ID.make(model.id), @@ -1244,6 +1290,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model interleaved: model.interleaved ?? false, }, release_date: model.release_date ?? "", + reasoning_options: reasoningOptions(model.reasoning_options), variants: {}, } @@ -1490,6 +1537,7 @@ const layer = Layer.effect( headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), family: model.family ?? existingModel?.family ?? "", release_date: model.release_date ?? existingModel?.release_date ?? "", + reasoning_options: existingModel?.reasoning_options, variants: {}, } const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index be1c090674..b5f8b8ecf8 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1426,6 +1426,54 @@ test("models.dev normalization fills required response fields", () => { expect(model.release_date).toBe("") }) +test("models.dev reasoning options normalize to known shapes", () => { + const provider = Provider.fromModelsDevProvider({ + id: "openai", + name: "OpenAI", + env: [], + npm: "@ai-sdk/openai", + models: { + reasoner: { + id: "reasoner", + name: "Reasoner", + reasoning: true, + reasoning_options: [ + { type: "future_control", value: true }, + { type: "effort", values: ["high", null, 42] }, + { type: "toggle" }, + { type: "budget_tokens", min: 1024, max: "invalid" }, + ], + limit: { context: 128_000, output: 16_000 }, + }, + }, + } as unknown as ModelsDev.Provider) + + expect(provider.models.reasoner.reasoning_options).toEqual([ + { type: "effort", values: ["high", null] }, + { type: "toggle" }, + { type: "budget_tokens", min: 1024 }, + ]) +}) + +test("models.dev models without reasoning options normalize to an empty list", () => { + const provider = Provider.fromModelsDevProvider({ + id: "openai", + name: "OpenAI", + env: [], + npm: "@ai-sdk/openai", + models: { + reasoner: { + id: "gpt-5.4", + name: "Reasoner", + reasoning: true, + limit: { context: 128_000, output: 16_000 }, + }, + }, + } as unknown as ModelsDev.Provider) + + expect(provider.models.reasoner.reasoning_options).toEqual([]) +}) + test("public provider info omits invalid models", () => { const provider = Provider.fromModelsDevProvider({ id: "test",