diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 517f226d13..b512b6d12f 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -103,7 +103,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { } function prepareOptions(model: ModelV2.Info, pkg: string) { - const projected = mapBodyToProviderOptions(model) + const projected = mapBodyToProviderOptions(model, pkg) const options: Record = { name: model.providerID, ...(model.settings ?? {}), @@ -265,7 +265,7 @@ export const locationLayer = Layer.effect( cause: new Error(`Unsupported package ${model.package}`), }) - const packageName = ProviderV2.packageName(model.package) ?? "" + const packageName = ProviderV2.packageName(model.package) const options = prepareOptions(model, packageName) const sdkKey = cacheKey({ providerID: model.providerID, @@ -301,11 +301,17 @@ export const locationLayer = Layer.effect( export const defaultLayer = locationLayer function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { - const packageName = ProviderV2.packageName(info.package) - const projected = mapBodyToProviderOptions(info) + const packageName = ProviderV2.packageName(info.package!) + const projected = mapBodyToProviderOptions(info, packageName) const optionKey = providerOptionKey(packageName, info.providerID) + const providerOptions = (() => { + if (projected.settings === undefined) return + if (packageName === "@ai-sdk/gateway") return gatewayProviderOptions(info.modelID ?? info.id, projected.settings) + if (packageName === "@ai-sdk/azure") return { openai: projected.settings, azure: projected.settings } + return { [optionKey]: projected.settings } + })() const route: AnyRoute = { - id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`, + id: `ai-sdk:${packageName}`, provider: ProviderID.make(info.providerID), providerMetadataKey: optionKey, protocol: "ai-sdk", @@ -326,7 +332,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { headers: info.headers, }, limits: { context: info.limit.context, output: info.limit.output }, - providerOptions: projected.settings === undefined ? undefined : { [optionKey]: projected.settings }, + providerOptions, }, body: { schema: Schema.Unknown, @@ -340,13 +346,35 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route }) } +function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly>) { + const gateway = + typeof settings.gateway === "object" && settings.gateway !== null && !Array.isArray(settings.gateway) + ? Object.fromEntries(Object.entries(settings.gateway)) + : undefined + const model = Object.fromEntries(Object.entries(settings).filter(([key]) => key !== "gateway")) + if (Object.keys(model).length === 0) return gateway === undefined ? undefined : { gateway } + + const separator = modelID.indexOf("/") + const prefix = separator > 0 ? modelID.slice(0, separator) : undefined + if (prefix) + return { ...(gateway === undefined ? {} : { gateway }), [prefix === "amazon" ? "bedrock" : prefix]: model } + if (typeof gateway === "object" && gateway !== null && !Array.isArray(gateway)) + return { gateway: { ...gateway, ...model } } + return { gateway: model } +} + function providerOptionKey(packageName: string | undefined, providerID: ProviderV2.ID) { if (packageName === "@ai-sdk/google") return "google" if (packageName === "@ai-sdk/google-vertex") return "vertex" if (packageName === "@ai-sdk/google-vertex/anthropic") return "anthropic" - if (packageName === "@ai-sdk/amazon-bedrock" || packageName === "@ai-sdk/amazon-bedrock/mantle") return "bedrock" + if (packageName === "@ai-sdk/amazon-bedrock") return "bedrock" + if (packageName === "@ai-sdk/amazon-bedrock/mantle") return "openai" if (packageName === "@ai-sdk/azure") return "azure" + if (packageName === "@ai-sdk/github-copilot") return "copilot" + if (packageName === "@jerome-benoit/sap-ai-provider-v2") return "sap-ai" + if (packageName === "@ai-sdk/openai-compatible") return providerID.split(".")[0] if (packageName === "@openrouter/ai-sdk-provider") return "openrouter" + if (packageName === "ai-gateway-provider") return "openaiCompatible" if (packageName?.startsWith("@ai-sdk/")) return packageName.slice("@ai-sdk/".length) return providerID } @@ -361,14 +389,18 @@ function requestSettings(settings: Readonly> | undefined return Object.keys(result).length === 0 ? undefined : result } -function mapBodyToProviderOptions(model: ModelV2.Info) { +function mapBodyToProviderOptions(model: ModelV2.Info, packageName: string) { const settings = requestSettings(model.settings) - if (!Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)) - return { settings, body: model.body } + const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning) + const forceReasoning = + ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(packageName) && + (pro || settings?.reasoningEffort !== undefined || settings?.reasoningSummary !== undefined) + const normalized = forceReasoning ? ProviderV2.mergeOverlay(settings, { forceReasoning: true }) : settings + if (!pro) return { settings: normalized, body: model.body } const body = { ...model.body } delete body.reasoning return { - settings: ProviderV2.mergeOverlay(settings, { reasoningMode: "pro" }), + settings: ProviderV2.mergeOverlay(normalized, { reasoningMode: "pro" }), body: Object.keys(body).length === 0 ? undefined : body, } } diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 9cbc3fa73c..0caf414cce 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -56,7 +56,10 @@ type SourceModel = { string, { readonly cost?: Cost - readonly provider?: { readonly body?: ProviderV2.Settings; readonly headers?: Readonly> } + readonly provider?: { + readonly body?: ProviderV2.Settings + readonly headers?: Readonly> + } } > > @@ -70,7 +73,7 @@ type SourceProvider = { readonly name: string readonly env: readonly string[] readonly id: string - readonly npm?: string + readonly npm: string readonly models: Readonly> } @@ -87,7 +90,7 @@ function normalize(input: Record): readonly Snapshot[] { const info = { id: providerID, name: item.name, - package: item.npm ? ProviderV2.aisdk(item.npm) : "", + package: ProviderV2.aisdk(item.npm), ...(item.api ? { settings: { baseURL: item.api } } : {}), } satisfies ProviderV2.Info const models: ModelV2.Info[] = [] @@ -185,61 +188,294 @@ function mergeCost(base: ModelV2.Info["cost"], override: SourceModel["cost"] | u } const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] +const OUTPUT_TOKEN_MAX = 32_000 function reasoningVariants(provider: SourceProvider, model: SourceModel): NonNullable { const npm = model.provider?.npm ?? provider.npm - const options = model.reasoning_options ?? [] + const options = model.reasoning_options + if (!options?.length) return [] + const toggle = options.some((option) => option.type === "toggle") const effort = options.find((option) => option.type === "effort") if (effort?.type === "effort") { - return effort.values.flatMap((value) => { - const raw: unknown = value - const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined - if (id === undefined) return [] - const settings = settingsForEffort(npm, id) - return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : [] - }) + const off = toggle ? toggleVariants(npm, model.id).filter((variant) => variant.id === "none") : [] + const variants = [ + ...off, + ...effort.values.flatMap((value) => { + const raw: unknown = value + const id = typeof raw === "string" && raw !== "null" ? raw : undefined + if (id === undefined) return [] + if (id === "none" && off.length > 0) return [] + const settings = settingsForEffort(npm, model.id, id) + return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : [] + }), + ] + return [...new Map(variants.map((variant) => [variant.id, variant])).values()] } const budget = options.find((option) => option.type === "budget_tokens") - if (budget?.type === "budget_tokens") return budgetVariants(npm, budget) + if (budget?.type === "budget_tokens") + return [ + ...(toggle ? toggleVariants(npm, model.id).filter((variant) => variant.id === "none") : []), + ...budgetVariants(npm, model, budget), + ] + if (toggle) return toggleVariants(npm, model.id) return [] } -function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined { +function settingsForEffort(npm: string, modelID: string, effort: string): ProviderV2.Settings | undefined { if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } } - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") - return { thinking: { type: "adaptive", display: "summarized" }, effort } + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { + if (anthropicManualThinking(modelID)) return { effort } + return { + thinking: { type: "adaptive", display: "summarized" }, + effort, + } + } if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } - if (npm === "@ai-sdk/azure") return { reasoningEffort: effort } - if (npm === "@ai-sdk/openai") + if (npm === "@ai-sdk/amazon-bedrock") { + if (modelID.includes("anthropic")) + return { + reasoningConfig: { + ...(anthropicManualThinking(modelID) ? {} : { type: "adaptive", display: "summarized" }), + maxReasoningEffort: effort, + }, + } + return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } } + } + if (npm === "@ai-sdk/gateway") { + const upstream = gatewayPackage(modelID) + if (upstream) return settingsForEffort(upstream, modelID, effort) + return { reasoningEffort: effort } + } + if (npm === "@ai-sdk/github-copilot") { + if (modelID.includes("gemini")) return + if (modelID.includes("claude")) return { reasoningEffort: effort } return { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING } - if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort } + } + if (npm === "@ai-sdk/openai" || npm === "@ai-sdk/amazon-bedrock/mantle" || npm === "@ai-sdk/azure") + return { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING } + if (npm === "@jerome-benoit/sap-ai-provider-v2") { + if (modelID.includes("anthropic")) + return { + modelParams: { + additionalModelRequestFields: { + ...(anthropicManualThinking(modelID) ? {} : { thinking: { type: "adaptive", display: "summarized" } }), + output_config: { effort }, + }, + }, + } + if (modelID.includes("gemini")) + return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } } + if (modelID.includes("amazon--nova")) + return { modelParams: { additionalModelRequestFields: { output_config: { effort } } } } + return { modelParams: { reasoning_effort: effort } } + } + if ( + [ + "@ai-sdk/openai-compatible", + "@ai-sdk/xai", + "@ai-sdk/mistral", + "@ai-sdk/groq", + "@ai-sdk/cerebras", + "@ai-sdk/deepinfra", + "@ai-sdk/togetherai", + "venice-ai-sdk-provider", + "ai-gateway-provider", + ].includes(npm) + ) + return { reasoningEffort: effort } } function budgetVariants( - npm: string | undefined, + npm: string, + model: SourceModel, option: Extract[number], { type: "budget_tokens" }>, ): NonNullable { - const max = option.max - const high = - option.max === undefined - ? Math.max(option.min ?? 0, 16_000) - : Math.min(Math.max(option.min ?? 0, 16_000), option.max) + const maximum = Math.min(option.max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1) + if (maximum <= 0) return [] + const high = Math.min(Math.max(option.min ?? 0, Math.floor((maximum + 1) / 2)), maximum) return [ { id: "high", budget: high }, - ...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]), + { id: "max", budget: maximum }, ].flatMap((item) => { - const settings = settingsForBudget(npm, item.budget) + const settings = settingsForBudget(npm, model.id, item.budget) return settings ? [{ id: ModelV2.VariantID.make(item.id), settings }] : [] }) } -function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined { +function toggleVariants(npm: string, modelID: string): NonNullable { + if (npm === "@ai-sdk/gateway") { + const upstream = gatewayPackage(modelID) + if (upstream) return toggleVariants(upstream, modelID) + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { reasoning: { enabled: false } }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { reasoning: { enabled: true } }, + }, + ] + } + if (npm === "@openrouter/ai-sdk-provider") + return [ + { id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } }, + { id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } }, + ] + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") + return [ + { id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { + thinking: { type: "adaptive", display: "summarized" }, + }, + }, + ] + if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } }, + }, + ] + if (npm === "@ai-sdk/amazon-bedrock") { + const anthropic = modelID.includes("anthropic") + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { + additionalModelRequestFields: anthropic + ? { thinking: { type: "disabled" } } + : { reasoningConfig: { type: "disabled" } }, + }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { + additionalModelRequestFields: anthropic + ? { thinking: { type: "adaptive", display: "summarized" } } + : { reasoningConfig: { type: "enabled" } }, + }, + }, + ] + } + if (npm === "@ai-sdk/alibaba") + return [ + { id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } }, + { id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } }, + ] + if (npm === "@ai-sdk/cohere") + return [ + { id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, + { id: ModelV2.VariantID.make("thinking"), settings: { thinking: { type: "enabled" } } }, + ] + if (npm === "@jerome-benoit/sap-ai-provider-v2") { + if (modelID.includes("gemini")) + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } } }, + }, + ] + if (modelID.includes("cohere")) + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { modelParams: { thinking: { type: "disabled" } } }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { modelParams: { thinking: { type: "enabled" } } }, + }, + ] + if (modelID.includes("amazon--nova")) + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } } }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "enabled" } } } }, + }, + ] + if (modelID.includes("anthropic")) + return [ + { + id: ModelV2.VariantID.make("none"), + settings: { + modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } }, + }, + }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { + modelParams: { + additionalModelRequestFields: { + thinking: { type: "adaptive", display: "summarized" }, + }, + }, + }, + }, + ] + } + return [] +} + +function settingsForBudget(npm: string, modelID: string, budget: number): ProviderV2.Settings | undefined { if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } } if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") return { thinking: { type: "enabled", budgetTokens: budget } } if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } + if (npm === "@ai-sdk/amazon-bedrock") return { reasoningConfig: { type: "enabled", budgetTokens: budget } } + if (npm === "@ai-sdk/gateway") { + const upstream = gatewayPackage(modelID) + return upstream ? settingsForBudget(upstream, modelID, budget) : { reasoning: { max_tokens: budget } } + } + if (npm === "@ai-sdk/cohere") return { thinking: { type: "enabled", tokenBudget: budget } } + if (npm === "@ai-sdk/alibaba") return { enableThinking: true, thinkingBudget: budget } + if (npm === "@jerome-benoit/sap-ai-provider-v2") { + if (modelID.includes("anthropic")) + return { + modelParams: { + additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: budget } }, + }, + } + if (modelID.includes("gemini")) + return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } } + if (modelID.includes("cohere")) return { modelParams: { thinking: { type: "enabled", token_budget: budget } } } + } +} + +function gatewayPackage(modelID: string) { + const separator = modelID.indexOf("/") + if (separator <= 0) return + const prefix = modelID.slice(0, separator) + if (prefix === "anthropic") return "@ai-sdk/anthropic" + if (prefix === "google") return "@ai-sdk/google" + if (prefix === "amazon") return "@ai-sdk/amazon-bedrock" + if (prefix === "alibaba") return "@ai-sdk/alibaba" +} + +function anthropicManualThinking(modelID: string) { + const familyFirst = /(?:claude-)?(?:opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?/i.exec(modelID) + const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(?:opus|sonnet|haiku)/i.exec(modelID) + const major = Number(familyFirst?.[1] ?? versionFirst?.[1]) + const rawMinor = Number(familyFirst?.[2] ?? versionFirst?.[2] ?? 0) + if (!Number.isFinite(major)) return false + const minor = rawMinor > 9 ? 0 : rawMinor + return major < 4 || (major === 4 && minor < 6) } function modeName(model: SourceModel, mode: string) { diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 165ffa516b..44285d1119 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -11,9 +11,12 @@ export const ID = Provider.ID export type ID = typeof ID.Type export const AISDK_PREFIX = "aisdk:" -export const isAISDK = (value: string | undefined) => value?.startsWith(AISDK_PREFIX) ?? false +export const isAISDK = (value: string | undefined): value is string => value?.startsWith(AISDK_PREFIX) ?? false export const aisdk = (value: string) => (isAISDK(value) ? value : `${AISDK_PREFIX}${value}`) -export const packageName = (value: string | undefined) => { +export function packageName(value: string): string +export function packageName(value: undefined): undefined +export function packageName(value: string | undefined): string | undefined +export function packageName(value: string | undefined) { if (value === undefined || !isAISDK(value)) return value return value.slice(AISDK_PREFIX.length) } diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 7efd04a4f6..dcfae24674 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -86,7 +86,105 @@ it.effect("maps pro reasoning bodies to AI SDK provider options", () => ) expect(body).toBeUndefined() - expect(prepared.body.providerOptions).toEqual({ openai: { reasoningMode: "pro" } }) + expect(prepared.body.providerOptions).toEqual({ + openai: { forceReasoning: true, reasoningMode: "pro" }, + }) + }), +) + +it.effect("maps package-specific AI SDK provider option keys", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* aisdk.hook.sdk((event) => { + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const cases = [ + ["@ai-sdk/github-copilot", "copilot", { reasoningEffort: "high" }], + ["@ai-sdk/amazon-bedrock/mantle", "openai", { reasoningEffort: "high", forceReasoning: true }], + ["@ai-sdk/openai-compatible", "test-provider", { reasoningEffort: "high" }], + ["@jerome-benoit/sap-ai-provider-v2", "sap-ai", { reasoningEffort: "high" }], + ["ai-gateway-provider", "openaiCompatible", { reasoningEffort: "high" }], + ] as const + for (const [packageName, key, settings] of cases) { + const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" })) + const prepared = yield* LLMClient.prepare( + LLM.request({ model: resolved, prompt: "Hello" }), + ) + expect(prepared.body.providerOptions).toEqual({ [key]: settings }) + } + }), +) + +it.effect("forces reasoning and projects both Azure AI SDK namespaces", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* aisdk.hook.sdk((event) => { + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" })) + const openaiPrepared = yield* LLMClient.prepare( + LLM.request({ model: openai, prompt: "Hello" }), + ) + expect(openaiPrepared.body.providerOptions).toEqual({ + openai: { reasoningEffort: "high", forceReasoning: true }, + }) + + const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" })) + const azurePrepared = yield* LLMClient.prepare( + LLM.request({ model: azure, prompt: "Hello" }), + ) + expect(azurePrepared.body.providerOptions).toEqual({ + openai: { reasoningEffort: "high", forceReasoning: true }, + azure: { reasoningEffort: "high", forceReasoning: true }, + }) + }), +) + +it.effect("routes AI Gateway model options by upstream prefix", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* aisdk.hook.sdk((event) => { + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const anthropic = yield* aisdk.model({ + ...model("@ai-sdk/gateway", { + gateway: { order: ["anthropic"] }, + thinking: { type: "adaptive" }, + }), + modelID: ModelV2.ID.make("anthropic/claude-sonnet-5"), + }) + const anthropicPrepared = yield* LLMClient.prepare( + LLM.request({ model: anthropic, prompt: "Hello" }), + ) + expect(anthropicPrepared.body.providerOptions).toEqual({ + gateway: { order: ["anthropic"] }, + anthropic: { thinking: { type: "adaptive" } }, + }) + + const bedrock = yield* aisdk.model({ + ...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }), + modelID: ModelV2.ID.make("amazon/nova-2-lite"), + }) + const bedrockPrepared = yield* LLMClient.prepare( + LLM.request({ model: bedrock, prompt: "Hello" }), + ) + expect(bedrockPrepared.body.providerOptions).toEqual({ + bedrock: { reasoningConfig: { type: "enabled" } }, + }) + + const fallback = yield* aisdk.model({ + ...model("@ai-sdk/gateway", { reasoningEffort: "high" }), + modelID: ModelV2.ID.make("deepseek/deepseek-v4"), + }) + const fallbackPrepared = yield* LLMClient.prepare( + LLM.request({ model: fallback, prompt: "Hello" }), + ) + expect(fallbackPrepared.body.providerOptions).toEqual({ + deepseek: { reasoningEffort: "high" }, + }) }), ) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index f3d32c04db..638cfc463e 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -37,6 +37,7 @@ const fixture = { id: "acme", name: "Acme", env: ["ACME_API_KEY"], + npm: "@ai-sdk/openai-compatible", models: { "acme-1": { id: "acme-1", @@ -57,7 +58,7 @@ const fixtureSnapshot = [ info: { id: ProviderV2.ID.make("acme"), name: "Acme", - package: "", + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), }, models: [ { @@ -97,6 +98,7 @@ const fixture2 = { id: "beta", name: "Beta", env: ["BETA_API_KEY"], + npm: "@ai-sdk/openai-compatible", models: { "beta-1": { id: "beta-1", @@ -117,7 +119,7 @@ const fixture2Snapshot = [ info: { id: ProviderV2.ID.make("beta"), name: "Beta", - package: "", + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), }, models: [ { diff --git a/packages/core/test/plugin/fixtures/models-dev-reasoning.json b/packages/core/test/plugin/fixtures/models-dev-reasoning.json index 410b7c2572..b0bc8f5216 100644 --- a/packages/core/test/plugin/fixtures/models-dev-reasoning.json +++ b/packages/core/test/plugin/fixtures/models-dev-reasoning.json @@ -13,7 +13,7 @@ "attachment": false, "reasoning": true, "reasoning_options": [ - { "type": "effort", "values": ["low", "high"] }, + { "type": "effort", "values": [null, "null", "low", "high"] }, { "type": "budget_tokens", "min": 1024, "max": 64000 }, { "type": "toggle" } ], @@ -27,6 +27,11 @@ "headers": { "x-mode": "high" }, "body": { "service_tier": "priority" } } + }, + "pro": { + "provider": { + "body": { "reasoning": { "mode": "pro" } } + } } } } @@ -49,18 +54,296 @@ "reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 64000 }], "temperature": true, "tool_call": true, - "limit": { "context": 128000, "output": 8192 } + "limit": { "context": 128000, "output": 64000 } }, "claude-effort": { - "id": "claude-effort", + "id": "claude-opus-4.7", "name": "Claude Effort", "release_date": "2026-01-01", "attachment": false, "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 8192 } + }, + "claude-toggle": { + "id": "claude-toggle", + "name": "Claude Toggle", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 8192 } + }, + "claude-opus-4-5": { + "id": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [ + { "type": "effort", "values": ["low", "high"] }, + { "type": "budget_tokens", "min": 1024 } + ], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 8192 } + } + } + }, + "xai": { + "id": "xai", + "name": "xAI", + "env": ["XAI_API_KEY"], + "npm": "@ai-sdk/xai", + "models": { + "grok-4.5": { + "id": "grok-4.5", + "name": "Grok 4.5", + "release_date": "2026-07-08", + "attachment": true, + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["low", "medium", "high"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 500000, "output": 500000 } + } + } + }, + "opencode-go": { + "id": "opencode-go", + "name": "OpenCode Go", + "env": ["OPENCODE_API_KEY"], + "npm": "@ai-sdk/openai-compatible", + "models": { + "minimax-m3": { + "id": "minimax-m3", + "name": "MiniMax-M3", + "release_date": "2026-05-31", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }], + "temperature": true, + "tool_call": true, + "limit": { "context": 1000000, "output": 131072 }, + "provider": { "npm": "@ai-sdk/anthropic" } + } + } + }, + "alibaba": { + "id": "alibaba", + "name": "Alibaba", + "env": ["ALIBABA_API_KEY"], + "npm": "@ai-sdk/alibaba", + "models": { + "toggle-only": { + "id": "toggle-only", + "name": "Toggle Only", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "toggle-budget": { + "id": "toggle-budget", + "name": "Toggle Budget", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "vercel": { + "id": "vercel", + "name": "Vercel AI Gateway", + "env": ["AI_GATEWAY_API_KEY"], + "npm": "@ai-sdk/gateway", + "models": { + "alibaba/qwen-toggle": { + "id": "alibaba/qwen-toggle", + "name": "Gateway Alibaba Toggle", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "amazon/nova-2-lite": { + "id": "amazon/nova-2-lite", + "name": "Gateway Nova 2 Lite", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "deepseek/deepseek-toggle": { + "id": "deepseek/deepseek-toggle", + "name": "Gateway DeepSeek Toggle", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "openrouter": { + "id": "openrouter", + "name": "OpenRouter", + "env": ["OPENROUTER_API_KEY"], + "npm": "@openrouter/ai-sdk-provider", + "models": { + "openrouter-toggle": { + "id": "openrouter-toggle", + "name": "OpenRouter Toggle", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "google": { + "id": "google", + "name": "Google", + "env": ["GOOGLE_GENERATIVE_AI_API_KEY"], + "npm": "@ai-sdk/google", + "models": { + "gemini-2.5-flash": { + "id": "gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "min": 0, "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "google-vertex": { + "id": "google-vertex", + "name": "Google Vertex", + "env": ["GOOGLE_VERTEX_PROJECT"], + "npm": "@ai-sdk/google-vertex", + "models": { + "gemini-2.5-flash-lite": { + "id": "gemini-2.5-flash-lite", + "name": "Gemini 2.5 Flash Lite", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "min": 512, "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "amazon-bedrock": { + "id": "amazon-bedrock", + "name": "Amazon Bedrock", + "env": ["AWS_ACCESS_KEY_ID"], + "npm": "@ai-sdk/amazon-bedrock", + "models": { + "amazon.nova-2-lite-v1:0": { + "id": "amazon.nova-2-lite-v1:0", + "name": "Nova 2 Lite", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + } + } + }, + "sap-ai-core": { + "id": "sap-ai-core", + "name": "SAP AI Core", + "env": ["AICORE_SERVICE_KEY"], + "npm": "@jerome-benoit/sap-ai-provider-v2", + "models": { + "gemini-2.5-flash": { + "id": "gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "min": 0, "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "amazon--nova-lite": { + "id": "amazon--nova-lite", + "name": "Nova Lite", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "toggle" }, { "type": "effort", "values": ["low", "high"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "cohere--command-a-reasoning": { + "id": "cohere--command-a-reasoning", + "name": "Command A Reasoning", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [ + { "type": "toggle" }, + { "type": "effort", "values": ["low", "high"] }, + { "type": "budget_tokens", "min": 1 } + ], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } + }, + "anthropic--claude-4.7-opus": { + "id": "anthropic--claude-4.7-opus", + "name": "Claude 4.7 Opus", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, "reasoning_options": [{ "type": "effort", "values": ["low"] }], "temperature": true, "tool_call": true, - "limit": { "context": 128000, "output": 8192 } + "limit": { "context": 128000, "output": 20000 } + }, + "anthropic--claude-4-sonnet": { + "id": "anthropic--claude-4-sonnet", + "name": "Claude 4 Sonnet", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 16000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 20000 } } } } diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 096d73f5bf..f07c96b439 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -292,6 +292,12 @@ describe("ModelsDevPlugin", () => { ModelV2.VariantID.make("high"), ]) + const pro = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-pro")) + expect(pro).toMatchObject({ + id: "gpt-reasoning-pro", + body: { reasoning: { mode: "pro" } }, + }) + const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget")) expect(budgetModel?.variants).toContainEqual({ id: ModelV2.VariantID.make("high"), @@ -299,17 +305,292 @@ describe("ModelsDevPlugin", () => { }) expect(budgetModel?.variants).toContainEqual({ id: ModelV2.VariantID.make("max"), - settings: { thinking: { type: "enabled", budgetTokens: 64000 } }, + settings: { thinking: { type: "enabled", budgetTokens: 31999 } }, }) const anthropicEffortModel = yield* catalog.model.get( ProviderV2.ID.anthropic, - ModelV2.ID.make("claude-effort"), + ModelV2.ID.make("claude-opus-4.7"), ) - expect(anthropicEffortModel?.variants).toContainEqual({ - id: ModelV2.VariantID.make("low"), - settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, - }) + expect(anthropicEffortModel?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, + { + id: ModelV2.VariantID.make("low"), + settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, + }, + ]) + + const anthropicToggleModel = yield* catalog.model.get( + ProviderV2.ID.anthropic, + ModelV2.ID.make("claude-toggle"), + ) + expect(anthropicToggleModel?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { thinking: { type: "adaptive", display: "summarized" } }, + }, + ]) + + const opus45 = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4-5")) + expect(opus45?.variants).toEqual([ + { id: ModelV2.VariantID.make("low"), settings: { effort: "low" } }, + { id: ModelV2.VariantID.make("high"), settings: { effort: "high" } }, + ]) + + const grok = yield* catalog.model.get(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4.5")) + expect(grok?.variants).toEqual( + ["low", "medium", "high"].map((id) => ({ + id: ModelV2.VariantID.make(id), + settings: { reasoningEffort: id }, + })), + ) + + const minimax = yield* catalog.model.get(ProviderV2.ID.make("opencode-go"), ModelV2.ID.make("minimax-m3")) + expect(minimax?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, + { + id: ModelV2.VariantID.make("thinking"), + settings: { thinking: { type: "adaptive", display: "summarized" } }, + }, + ]) + + const toggle = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-only")) + expect(toggle?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } }, + { id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } }, + ]) + + const combined = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-budget")) + expect(combined?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } }, + { + id: ModelV2.VariantID.make("high"), + settings: { enableThinking: true, thinkingBudget: 8000 }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { enableThinking: true, thinkingBudget: 16000 }, + }, + ]) + + const gateway = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("alibaba/qwen-toggle")) + expect(gateway?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } }, + { + id: ModelV2.VariantID.make("high"), + settings: { enableThinking: true, thinkingBudget: 8000 }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { enableThinking: true, thinkingBudget: 16000 }, + }, + ]) + + const gatewayNova = yield* catalog.model.get( + ProviderV2.ID.make("vercel"), + ModelV2.ID.make("amazon/nova-2-lite"), + ) + expect(gatewayNova?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } }, + }, + { + id: ModelV2.VariantID.make("low"), + settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, + }, + ]) + + const gatewayFallback = yield* catalog.model.get( + ProviderV2.ID.make("vercel"), + ModelV2.ID.make("deepseek/deepseek-toggle"), + ) + expect(gatewayFallback?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { reasoning: { enabled: false } }, + }, + { + id: ModelV2.VariantID.make("low"), + settings: { reasoningEffort: "low" }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { reasoningEffort: "high" }, + }, + ]) + + const openrouter = yield* catalog.model.get( + ProviderV2.ID.make("openrouter"), + ModelV2.ID.make("openrouter-toggle"), + ) + expect(openrouter?.variants).toEqual([ + { id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } }, + { id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } }, + ]) + + const google = yield* catalog.model.get(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini-2.5-flash")) + expect(google?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }, + }, + ]) + + const vertex = yield* catalog.model.get( + ProviderV2.ID.make("google-vertex"), + ModelV2.ID.make("gemini-2.5-flash-lite"), + ) + expect(vertex?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }, + }, + ]) + + const bedrock = yield* catalog.model.get( + ProviderV2.ID.make("amazon-bedrock"), + ModelV2.ID.make("amazon.nova-2-lite-v1:0"), + ) + expect(bedrock?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } }, + }, + { + id: ModelV2.VariantID.make("low"), + settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, + }, + ]) + + const sapGemini = yield* catalog.model.get( + ProviderV2.ID.make("sap-ai-core"), + ModelV2.ID.make("gemini-2.5-flash"), + ) + expect(sapGemini?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } } }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } }, + }, + ]) + + const sapNova = yield* catalog.model.get( + ProviderV2.ID.make("sap-ai-core"), + ModelV2.ID.make("amazon--nova-lite"), + ) + expect(sapNova?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { + modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } }, + }, + }, + { + id: ModelV2.VariantID.make("low"), + settings: { + modelParams: { additionalModelRequestFields: { output_config: { effort: "low" } } }, + }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { + modelParams: { additionalModelRequestFields: { output_config: { effort: "high" } } }, + }, + }, + ]) + + const sapCohere = yield* catalog.model.get( + ProviderV2.ID.make("sap-ai-core"), + ModelV2.ID.make("cohere--command-a-reasoning"), + ) + expect(sapCohere?.variants).toEqual([ + { + id: ModelV2.VariantID.make("none"), + settings: { modelParams: { thinking: { type: "disabled" } } }, + }, + { + id: ModelV2.VariantID.make("low"), + settings: { modelParams: { reasoning_effort: "low" } }, + }, + { + id: ModelV2.VariantID.make("high"), + settings: { modelParams: { reasoning_effort: "high" } }, + }, + ]) + + const sapAnthropicEffort = yield* catalog.model.get( + ProviderV2.ID.make("sap-ai-core"), + ModelV2.ID.make("anthropic--claude-4.7-opus"), + ) + expect(sapAnthropicEffort?.variants).toEqual([ + { + id: ModelV2.VariantID.make("low"), + settings: { + modelParams: { + additionalModelRequestFields: { + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort: "low" }, + }, + }, + }, + }, + ]) + + const sapAnthropicBudget = yield* catalog.model.get( + ProviderV2.ID.make("sap-ai-core"), + ModelV2.ID.make("anthropic--claude-4-sonnet"), + ) + expect(sapAnthropicBudget?.variants).toEqual([ + { + id: ModelV2.VariantID.make("high"), + settings: { + modelParams: { + additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 8000 } }, + }, + }, + }, + { + id: ModelV2.VariantID.make("max"), + settings: { + modelParams: { + additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 16000 } }, + }, + }, + }, + ]) }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))), (previous) => Effect.sync(() => {