diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 82582afdc34..602133d9047 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -44,6 +44,21 @@ const Cost = Schema.Struct({ ), }) +const ReasoningOption = 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: Schema.optional(Schema.Finite), + max: Schema.optional(Schema.Finite), + }), +]) + export const Model = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -51,11 +66,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, + reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), interleaved: Schema.optional( Schema.Union([ Schema.Literal(true), diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 21e0f5653c3..a3ed86dc8cd 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, test } from "bun:test" -import { Effect, Layer, Ref, Schema } from "effect" +import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" +import { Effect, Layer, Ref } 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,21 +126,6 @@ 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 b1263dcd6e0..69e29d3930e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -981,21 +981,6 @@ 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, @@ -1054,7 +1039,6 @@ 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> @@ -1218,36 +1202,6 @@ 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), @@ -1290,13 +1244,14 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model interleaved: model.interleaved ?? false, }, release_date: model.release_date ?? "", - reasoning_options: reasoningOptions(model.reasoning_options), variants: {}, } + const variants = ProviderTransform.reasoningVariants(model, base) ?? ProviderTransform.variants(base) + return { ...base, - variants: mapValues(ProviderTransform.variants(base), (v) => v), + variants: mapValues(variants, (v) => v), } } @@ -1537,10 +1492,13 @@ 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 ?? {}) + const variants = + existingModel?.api.npm === parsedModel.api.npm + ? (existingModel.variants ?? ProviderTransform.variants(parsedModel)) + : ProviderTransform.variants(parsedModel) + const merged = mergeDeep(variants, model.variants ?? {}) parsedModel.variants = mapValues( pickBy(merged, (v) => !v.disabled), (v) => omit(v, ["disabled"]), @@ -1668,7 +1626,7 @@ const layer = Layer.effect( ) delete provider.models[modelID] - if (!model.variants || Object.keys(model.variants).length === 0) { + if (model.variants === undefined) { model.variants = mapValues(ProviderTransform.variants(model), (v) => v) } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7a6f1921646..73caf5ab6bf 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1578,4 +1578,189 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 return schema } +export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model): Provider.Model["variants"] { + const options = model.reasoning_options + if (options === undefined) return + if (options.length === 0) return {} + + const effort = options.find((option) => option.type === "effort") + if (effort) return nonEmptyVariants(effortVariants(target, effort.values)) + + const toggle = options.some((option) => option.type === "toggle") + const budget = options.find((option) => option.type === "budget_tokens") + if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined + + return nonEmptyVariants({ + ...(toggle ? reasoningToggle(target) : {}), + ...budgetVariants(target, budget.min, budget.max), + }) +} + +function effortVariants(model: Provider.Model, values: readonly unknown[]) { + return Object.fromEntries( + values.flatMap((value) => { + const id = (() => { + if (value === null) return "none" + if (typeof value === "string") return value + })() + if (id === undefined) return [] + const settings = reasoningEffort(model, id) + return settings ? [[id, settings]] : [] + }), + ) +} + +function budgetVariants(model: Provider.Model, min?: number, max?: number) { + const limit = model.limit.output - 1 + if (limit <= 0) return {} + const high = Math.min( + max === undefined ? Math.max(min ?? 0, 16_000) : Math.min(Math.max(min ?? 0, 16_000), max), + limit, + ) + const maximum = max === undefined ? undefined : Math.min(max, limit) + return Object.fromEntries( + [ + { id: "high", budget: high }, + ...(maximum === undefined || maximum === high ? [] : [{ id: "max", budget: maximum }]), + ].flatMap((item) => { + const settings = reasoningBudget(model, item.budget) + return settings ? [[item.id, settings]] : [] + }), + ) +} + +function nonEmptyVariants(variants: NonNullable): Provider.Model["variants"] { + return Object.keys(variants).length > 0 ? variants : undefined +} + +function reasoningToggle(model: Provider.Model): NonNullable { + if (model.api.npm === "@ai-sdk/alibaba") + return { + none: { enableThinking: false }, + high: { enableThinking: true }, + } + if (model.api.npm === "@ai-sdk/cohere") + return { + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled" } }, + } + return {} +} + +function reasoningEffort(model: Provider.Model, effort: string) { + switch (model.api.npm) { + case "@openrouter/ai-sdk-provider": + return { reasoning: { effort } } + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": + return anthropicEffort(model, effort) + case "@ai-sdk/google": + case "@ai-sdk/google-vertex": + return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } + case "@ai-sdk/amazon-bedrock": + if (anthropicAdaptiveEfforts(model.api.id)) + return { + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: effort, + ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}), + }, + } + if (model.api.id.includes("anthropic")) return + return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } } + case "@ai-sdk/gateway": + if (model.id.includes("anthropic")) return { thinking: { type: "adaptive", display: "summarized" }, effort } + if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } + return { reasoningEffort: effort } + case "@ai-sdk/github-copilot": + // OAuth discovery replaces these with variants from Copilot's /models capabilities. + if (model.id.includes("gemini")) return + if (model.id.includes("claude")) return { reasoningEffort: effort } + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@ai-sdk/openai": + case "@ai-sdk/amazon-bedrock/mantle": + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@ai-sdk/azure": + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@jerome-benoit/sap-ai-provider-v2": + if (model.id.includes("anthropic")) + return { modelParams: { thinking: { type: "adaptive", display: "summarized" }, output_config: { effort } } } + return { modelParams: { reasoning_effort: effort } } + case "@ai-sdk/openai-compatible": + case "@ai-sdk/xai": + case "@ai-sdk/mistral": + case "@ai-sdk/groq": + case "@ai-sdk/cerebras": + case "@ai-sdk/deepinfra": + case "@ai-sdk/togetherai": + case "venice-ai-sdk-provider": + case "ai-gateway-provider": + return { reasoningEffort: effort } + case "@ai-sdk/cohere": + case "@ai-sdk/perplexity": + case "@ai-sdk/vercel": + case "@ai-sdk/alibaba": + case "gitlab-ai-provider": + return + } +} + +function anthropicEffort(model: Provider.Model, effort: string) { + if (["opus-4-5", "opus-4.5"].some((value) => model.api.id.includes(value))) return { effort } + if (!anthropicAdaptiveEfforts(model.api.id)) return + return { + thinking: { + type: "adaptive", + ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}), + }, + effort, + } +} + +function reasoningBudget(model: Provider.Model, budget: number) { + switch (model.api.npm) { + case "@openrouter/ai-sdk-provider": + return { reasoning: { max_tokens: budget } } + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": + return { thinking: { type: "enabled", budgetTokens: budget } } + case "@ai-sdk/google": + case "@ai-sdk/google-vertex": + return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } + case "@ai-sdk/amazon-bedrock": + return { reasoningConfig: { type: "enabled", budgetTokens: budget } } + case "@ai-sdk/gateway": + if (model.id.includes("anthropic")) return { thinking: { type: "enabled", budgetTokens: budget } } + if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } + return + case "@ai-sdk/cohere": + return { thinking: { type: "enabled", tokenBudget: budget } } + case "@ai-sdk/alibaba": + return { enableThinking: true, thinkingBudget: budget } + case "@jerome-benoit/sap-ai-provider-v2": + if (model.id.includes("anthropic")) + return { modelParams: { thinking: { type: "enabled", budget_tokens: budget } } } + if (model.id.includes("gemini")) + return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } } + return + case "@ai-sdk/amazon-bedrock/mantle": + case "@ai-sdk/azure": + case "@ai-sdk/cerebras": + case "@ai-sdk/deepinfra": + case "@ai-sdk/github-copilot": + case "@ai-sdk/groq": + case "@ai-sdk/mistral": + case "@ai-sdk/openai": + case "@ai-sdk/openai-compatible": + case "@ai-sdk/perplexity": + case "@ai-sdk/togetherai": + case "@ai-sdk/vercel": + case "@ai-sdk/xai": + case "ai-gateway-provider": + case "gitlab-ai-provider": + case "venice-ai-sdk-provider": + return + } +} + export * as ProviderTransform from "./transform" diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index b5f8b8ecf8e..b78bdae8bc6 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -562,6 +562,45 @@ it.instance( }, ) +it.instance( + "model config preserves explicitly empty models.dev variants", + Effect.gen(function* () { + yield* set("OPENAI_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.openai].models["custom-gpt-chat"] + expect(model.name).toBe("Custom GPT Chat") + expect(model.variants).toEqual({}) + }), + { + config: { + provider: { + openai: { models: { "custom-gpt-chat": { id: "gpt-5-chat-latest", name: "Custom GPT Chat" } } }, + }, + }, + }, +) + +it.instance( + "model config regenerates variants when overriding the provider package", + Effect.gen(function* () { + yield* set("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"] + expect(model.variants?.low).toEqual({ reasoningEffort: "low" }) + expect(model.variants?.max).toBeUndefined() + }), + { + config: { + provider: { + anthropic: { + npm: "@ai-sdk/openai-compatible", + models: { "claude-sonnet-4-6": { name: "Claude via OpenAI" } }, + }, + }, + }, + }, +) + it.instance( "disabled_providers prevents loading even with env var", Effect.gen(function* () { @@ -1426,52 +1465,60 @@ 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", +test("models.dev reasoning options replace generated variants and unsupported options fall back", () => { + const provider = { + id: "reasoning", + name: "Reasoning", 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: { + explicit: { id: "gpt-5.4", - name: "Reasoner", + name: "Explicit", reasoning: true, - limit: { context: 128_000, output: 16_000 }, + reasoning_options: [{ type: "effort", values: ["low"] }], + limit: { context: 128_000, output: 64_000 }, + }, + empty: { + id: "gpt-5.4", + name: "Empty", + reasoning: true, + reasoning_options: [], + limit: { context: 128_000, output: 64_000 }, + }, + fallback: { + id: "gpt-5.4", + name: "Fallback", + reasoning: true, + reasoning_options: [{ type: "toggle" }], + limit: { context: 128_000, output: 64_000 }, + }, + override: { + id: "gemini-3-pro", + name: "Override", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["high"] }], + provider: { npm: "@ai-sdk/google" }, + limit: { context: 128_000, output: 64_000 }, + experimental: { modes: { fast: {} } }, }, }, - } as unknown as ModelsDev.Provider) + } as unknown as ModelsDev.Provider - expect(provider.models.reasoner.reasoning_options).toEqual([]) + const models = Provider.fromModelsDevProvider(provider).models + expect(models.explicit.variants).toEqual({ + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }) + expect(models.empty.variants).toEqual({}) + expect(Object.keys(models.fallback.variants ?? {})).toEqual(["none", "low", "medium", "high", "xhigh"]) + expect(models.override.variants).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }) + expect(models["gemini-3-pro-fast"].variants).toEqual(models.override.variants) }) test("public provider info omits invalid models", () => { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 3ef2ca3bd0b..a144997405a 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4,6 +4,7 @@ import { ProviderTransform } from "@/provider/transform" import { LLMRequestPrep } from "@/session/llm/request" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { ModelsDev } from "@opencode-ai/core/models-dev" import { jsonSchema } from "ai" describe("ProviderTransform.options - setCacheKey", () => { @@ -2988,6 +2989,242 @@ describe("ProviderTransform.temperature - Cohere North", () => { }) }) +describe("ProviderTransform.reasoningVariants", () => { + const model = (reasoning_options: ModelsDev.Model["reasoning_options"]) => ({ reasoning_options }) as ModelsDev.Model + const target = (npm: string, id = "test-model") => + ({ id, api: { id, npm, url: "" }, capabilities: { reasoning: true }, limit: { output: 64_000 } }) as any + + test("respects explicitly empty reasoning options", () => { + expect(ProviderTransform.reasoningVariants(model([]), target("@ai-sdk/openai"))).toEqual({}) + }) + + test.each([ + ["@openrouter/ai-sdk-provider", { reasoning: { effort: "high" } }], + ["@ai-sdk/anthropic", { thinking: { type: "adaptive" }, effort: "high" }, "claude-opus-4-6"], + [ + "@ai-sdk/google-vertex/anthropic", + { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + "claude-opus-4-7", + ], + ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], + ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], + [ + "@ai-sdk/azure", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/openai", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/amazon-bedrock/mantle", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/github-copilot", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + ["@ai-sdk/openai-compatible", { reasoningEffort: "high" }], + ["@ai-sdk/xai", { reasoningEffort: "high" }], + ["@ai-sdk/mistral", { reasoningEffort: "high" }], + ["@ai-sdk/groq", { reasoningEffort: "high" }], + ["@ai-sdk/cerebras", { reasoningEffort: "high" }], + ["@ai-sdk/deepinfra", { reasoningEffort: "high" }], + ["@ai-sdk/togetherai", { reasoningEffort: "high" }], + ["venice-ai-sdk-provider", { reasoningEffort: "high" }], + ["ai-gateway-provider", { reasoningEffort: "high" }], + ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }], + ])("converts effort for %s", (npm, expected, ...args) => { + const id = args[0] as string | undefined + expect(ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm, id))).toEqual( + { high: expected }, + ) + }) + + test("uses bare effort for Claude Opus 4.5", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/anthropic", "claude-opus-4-5"), + ), + ).toEqual({ high: { effort: "high" } }) + }) + + test("leaves legacy Anthropic effort options to budget fallback", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/anthropic", "claude-sonnet-4"), + ), + ).toBeUndefined() + }) + + test("uses adaptive reasoning config for Anthropic models on Bedrock", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/amazon-bedrock", "anthropic.claude-opus-4-7-v1:0"), + ), + ).toEqual({ + high: { + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }, + }) + }) + + test("leaves legacy Anthropic Bedrock effort options to budget fallback", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/amazon-bedrock", "anthropic.claude-sonnet-4-v1:0"), + ), + ).toBeUndefined() + }) + + test.each([ + ["@openrouter/ai-sdk-provider", { reasoning: { max_tokens: 16_000 } }], + ["@ai-sdk/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/google-vertex/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }], + ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }], + ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/cohere", { thinking: { type: "enabled", tokenBudget: 16_000 } }], + ["@ai-sdk/alibaba", { enableThinking: true, thinkingBudget: 16_000 }], + ])("converts token budgets for %s", (npm, high) => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 16_000 }]), target(npm)), + ).toEqual({ high }) + }) + + test("maps null effort to none", () => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "effort", values: [null] }]), target("@ai-sdk/openai")), + ).toEqual({ + none: { + reasoningEffort: "none", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }) + }) + + test.each([ + ["@ai-sdk/alibaba", { none: { enableThinking: false }, high: { enableThinking: true } }], + [ + "@ai-sdk/cohere", + { + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled" } }, + }, + ], + ])("converts toggle options for %s", (npm, expected) => { + expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target(npm))).toEqual(expected) + }) + + test("combines Cohere toggle and budget options", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "toggle" }, { type: "budget_tokens", min: 1 }]), + target("@ai-sdk/cohere"), + ), + ).toEqual({ + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled", tokenBudget: 16_000 } }, + }) + }) + + test("generates bounded high and max token budgets", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), + target("@ai-sdk/anthropic"), + ), + ).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 16_000 } }, + max: { thinking: { type: "enabled", budgetTokens: 63_999 } }, + }) + }) + + test("caps token budgets below the model output limit", () => { + const anthropic = target("@ai-sdk/anthropic") + anthropic.limit.output = 5_000 + expect( + ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), anthropic), + ).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 4_999 } }, + }) + }) + + test("prefers effort options over token budgets", () => { + expect( + ProviderTransform.reasoningVariants( + model([ + { type: "budget_tokens", min: 1_024, max: 64_000 }, + { type: "effort", values: ["low"] }, + ]), + target("@ai-sdk/openai"), + ), + ).toEqual({ + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }) + }) + + test("leaves unsupported options for heuristic fallback", () => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target("@ai-sdk/perplexity")), + ).toBeUndefined() + expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target("@ai-sdk/openai"))).toBeUndefined() + }) + + test("uses model-family options for gateway and GitHub Copilot", () => { + const effort = model([{ type: "effort", values: ["high"] }]) + expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "anthropic/claude-sonnet-4"))).toEqual( + { + high: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + ) + expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "google/gemini-3-pro"))).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }) + expect( + ProviderTransform.reasoningVariants(effort, target("@ai-sdk/github-copilot", "gemini-3-pro")), + ).toBeUndefined() + }) + + test.each(["@ai-sdk/cohere", "@ai-sdk/perplexity", "@ai-sdk/vercel", "@ai-sdk/alibaba", "gitlab-ai-provider"])( + "does not invent effort controls for %s", + (npm) => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm)), + ).toBeUndefined() + }, + ) +}) + describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 38c2e1a2b4e..5e067f3afb2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2100,20 +2100,6 @@ export type Model = { [key: string]: string } release_date: string - reasoning_options?: Array< - | { - type: "effort" - values: Array - } - | { - type: "toggle" - } - | { - type: "budget_tokens" - min?: number - max?: number - } - > variants?: { [key: string]: { [key: string]: unknown diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 8fc81713bcf..d97885b5539 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -21777,58 +21777,6 @@ "release_date": { "type": "string" }, - "reasoning_options": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["effort"] - }, - "values": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "values"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["toggle"] - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["budget_tokens"] - }, - "min": { - "type": "number" - }, - "max": { - "type": "number" - } - }, - "required": ["type"], - "additionalProperties": false - } - ] - } - }, "variants": { "type": "object", "additionalProperties": {