diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 3539060724..964e4b1ac7 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -44,10 +44,7 @@ const Cost = Schema.Struct({ ), }) -const ReasoningEffortValue = Schema.Union([ - Schema.Null, - Schema.Literals(["none", "minimal", "low", "medium", "high", "xhigh", "max", "default"]), -]) +const ReasoningEffortValue = Schema.Union([Schema.Null, Schema.String]) export const ReasoningOption = Schema.Union([ Schema.Struct({ @@ -72,7 +69,9 @@ export const Model = Schema.Struct({ release_date: Schema.String, attachment: Schema.Boolean, reasoning: Schema.Boolean, - reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), + // models.dev is external metadata and reasoning controls are expected to evolve. + // Keep the fetched value opaque here; provider normalization extracts the known subset. + reasoning_options: Schema.optional(Schema.Unknown), temperature: Schema.Boolean, tool_call: Schema.Boolean, interleaved: Schema.optional( diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2084040eaa..b76ea6f9c7 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1186,11 +1186,37 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { return result } +type ReasoningOption = NonNullable[number] + function reasoningOptions(model: ModelsDev.Model): Model["reasoning_options"] { - return model.reasoning_options?.map((option) => { - if (option.type === "effort") return { ...option, values: [...option.values] } - return { ...option } + if (!Array.isArray(model.reasoning_options)) return undefined + const normalized = model.reasoning_options.flatMap((option) => { + const normalized = normalizeReasoningOption(option) + return normalized ? [normalized] : [] }) + return normalized.length > 0 || model.reasoning_options.length === 0 ? normalized : undefined +} + +function normalizeReasoningOption(option: unknown): ReasoningOption | undefined { + if (!isRecord(option) || typeof option.type !== "string") return undefined + if (option.type === "effort") { + if (!Array.isArray(option.values)) return undefined + 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") { + 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 }), + } + } + return undefined } function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { diff --git a/packages/opencode/test/provider/model-status.test.ts b/packages/opencode/test/provider/model-status.test.ts index 35a859120b..ba6f360b15 100644 --- a/packages/opencode/test/provider/model-status.test.ts +++ b/packages/opencode/test/provider/model-status.test.ts @@ -58,4 +58,23 @@ describe("provider model status schemas", () => { }).status, ).toBe("active") }) + + test("keeps models.dev reasoning options opaque for forward compatibility", () => { + expect(() => + Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "test-model", + name: "Test Model", + release_date: "2026-01-01", + attachment: false, + reasoning: true, + reasoning_options: { + future: "shape", + values: [{ type: "new_control", values: ["turbo"] }], + }, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }), + ).not.toThrow() + }) }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index d29101bace..f56f1c96f3 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1450,6 +1450,55 @@ test("models.dev normalization preserves reasoning options for variant generatio expect(model.variants!.xhigh).toEqual({ reasoningEffort: "xhigh" }) }) +test("models.dev normalization ignores unknown reasoning options and passes new effort strings", () => { + const provider = { + id: "custom", + name: "Custom", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + reasoner: { + id: "reasoner", + name: "Reasoner", + reasoning: true, + reasoning_options: [ + { type: "future_control", values: ["turbo"] }, + { type: "effort", values: ["turbo", 123, null] }, + { type: "budget_tokens", min: "1024", max: 16_000 }, + "invalid", + ], + cost: { input: 1, output: 2 }, + limit: { context: 128_000, output: 32_000 }, + }, + changed: { + id: "changed", + name: "Changed", + reasoning: true, + reasoning_options: { type: "effort", values: ["turbo"] }, + cost: { input: 1, output: 2 }, + limit: { context: 128_000, output: 32_000 }, + }, + future: { + id: "future", + name: "Future", + reasoning: true, + reasoning_options: [{ type: "future_control", values: ["turbo"] }], + cost: { input: 1, output: 2 }, + limit: { context: 128_000, output: 32_000 }, + }, + }, + } as unknown as ModelsDev.Provider + + const models = Provider.fromModelsDevProvider(provider).models + expect(models.reasoner.reasoning_options).toEqual([ + { type: "effort", values: ["turbo", null] }, + { type: "budget_tokens", max: 16_000 }, + ]) + expect(models.reasoner.variants).toEqual({ turbo: { reasoningEffort: "turbo" } }) + expect(models.changed.reasoning_options).toBeUndefined() + expect(models.future.reasoning_options).toBeUndefined() +}) + it.instance("model variants are generated for reasoning models", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key")