From 9c02a747738463b92c7aa30734722d2bdcb21281 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 11 Sep 2026 21:06:12 -0500 Subject: [PATCH 01/19] feat(core): register native packages from models.dev normalize() maps each models.dev npm package onto its @opencode/ai equivalent where one exists; the 13 npm packages without one stay on aisdk:. The generic openai-compatible package receives the provider identity as a per-model setting, which the AI SDK path inferred. --- packages/core/src/models-dev.ts | 45 +++++++++++++++++++++++++++---- packages/core/test/models.test.ts | 14 +++++----- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 9818ff38379c..e5bbdfed3290 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -79,15 +79,42 @@ export type Snapshot = { readonly environment: readonly string[] } +// models.dev npm packages with a native @opencode/ai equivalent. Anything else stays on the AI SDK. +const NATIVE_PACKAGES: Readonly> = { + "@ai-sdk/amazon-bedrock": "@opencode/ai/providers/amazon-bedrock", + "@ai-sdk/anthropic": "@opencode/ai/providers/anthropic", + "@ai-sdk/azure": "@opencode/ai/providers/azure/responses", + "@ai-sdk/cerebras": "@opencode/ai/providers/cerebras", + "@ai-sdk/deepinfra": "@opencode/ai/providers/deepinfra", + "@ai-sdk/google": "@opencode/ai/providers/google", + "@ai-sdk/google-vertex": "@opencode/ai/providers/google-vertex", + "@ai-sdk/google-vertex/anthropic": "@opencode/ai/providers/google-vertex/messages", + "@ai-sdk/groq": "@opencode/ai/providers/groq", + "@ai-sdk/mistral": "@opencode/ai/providers/mistral", + "@ai-sdk/openai": "@opencode/ai/providers/openai", + "@ai-sdk/openai-compatible": "@opencode/ai/providers/openai-compatible", + "@ai-sdk/togetherai": "@opencode/ai/providers/togetherai", + "@ai-sdk/xai": "@opencode/ai/providers/xai", + "@openrouter/ai-sdk-provider": "@opencode/ai/providers/openrouter", +} + +function nativePackage(npm: string, modelID?: string) { + // Mantle only appears as a per-model override; gpt-oss models are chat-only there. + if (npm === "@ai-sdk/amazon-bedrock/mantle") + return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID?.includes("gpt-oss") ? "chat" : "responses"}` + return NATIVE_PACKAGES[npm] ?? Provider.aisdk(npm) +} + function normalize(input: Record): readonly Snapshot[] { const providers: Snapshot[] = [] for (const item of Object.values(input)) { const providerID = Provider.ID.make(item.id) + const pkg = nativePackage(item.npm) const info = { id: providerID, name: item.name, activation: "auto", - package: Provider.aisdk(item.npm), + package: pkg, ...(item.api ? { settings: { baseURL: item.api } } : {}), } satisfies Provider.Info const models: Model.Info[] = [] @@ -95,11 +122,11 @@ function normalize(input: Record): readonly Snapshot[] { const baseCost = cost(model.cost) const variants = reasoningVariants(item, model) const id = Model.ID.make(model.id) - models.push(modelInfo(providerID, id, model, { cost: baseCost, variants })) + models.push(modelInfo(providerID, pkg, id, model, { cost: baseCost, variants })) for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { const modeID = Model.ID.make(`${model.id}-${mode}`) models.push( - modelInfo(providerID, modeID, model, { + modelInfo(providerID, pkg, modeID, model, { name: modeName(model, mode), cost: mergeCost(baseCost, options.cost), request: options.provider, @@ -481,6 +508,7 @@ function modeName(model: SourceModel, mode: string) { function modelInfo( providerID: Provider.ID, + providerPackage: string, id: Model.ID, model: SourceModel, input: { @@ -490,6 +518,13 @@ function modelInfo( readonly variants?: NonNullable } = {}, ): Model.Info { + const pkg = model.provider?.npm ? nativePackage(model.provider.npm, model.id) : undefined + // The generic package takes the provider identity as a setting; the AI SDK inferred it. Set per model so it + // never merges into a model that overrides to a different package. + const settings = { + ...(model.provider?.api ? { baseURL: model.provider.api } : {}), + ...((pkg ?? providerPackage) === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}), + } return { id, modelID: Model.ID.make(model.id), @@ -497,8 +532,8 @@ function modelInfo( name: input.name ?? model.name, compatibility: Model.compatibility(model.interleaved), family: model.family ? Model.Family.make(model.family) : undefined, - package: model.provider?.npm ? Provider.aisdk(model.provider.npm) : undefined, - settings: model.provider?.api ? { baseURL: model.provider.api } : undefined, + package: pkg, + settings: Object.keys(settings).length === 0 ? undefined : settings, capabilities: { tools: model.tool_call, input: [...(model.modalities?.input ?? [])], diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 1672464ce92c..e7a80e7b7399 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -49,7 +49,7 @@ const fixtureSnapshot = [ id: Provider.ID.make("acme"), name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", }, models: [ { @@ -60,7 +60,7 @@ const fixtureSnapshot = [ compatibility: { reasoningField: "vendor_reasoning" }, family: undefined, package: undefined, - settings: undefined, + settings: { provider: "acme" }, capabilities: { tools: true, input: [], output: [] }, variants: [], time: { released: Date.parse("2026-01-01") }, @@ -112,7 +112,7 @@ const fixture2Snapshot = [ id: Provider.ID.make("beta"), name: "Beta", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", }, models: [ { @@ -122,7 +122,7 @@ const fixture2Snapshot = [ name: "Beta One", family: undefined, package: undefined, - settings: undefined, + settings: { provider: "beta" }, capabilities: { tools: false, input: [], output: [] }, variants: [], time: { released: Date.parse("2026-02-01") }, @@ -234,7 +234,7 @@ describe("ModelsDev Service", () => { }), ) - it.live("normalizes provider and model AI SDK packages from models.dev", () => + it.live("maps models.dev npm packages onto native packages", () => Effect.gen(function* () { const cache = makeCache() writeCache(cache, { @@ -254,8 +254,8 @@ describe("ModelsDev Service", () => { cache, ModelsDev.Service.use((service) => service.get()), ) - expect(result[0]?.info.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible")) - expect(result[0]?.models[0]?.package).toBe(Provider.aisdk("@ai-sdk/openai")) + expect(result[0]?.info.package).toBe("@opencode/ai/providers/openai-compatible") + expect(result[0]?.models[0]?.package).toBe("@opencode/ai/providers/openai") }), ) From da01d849bedcfcb951637729e30efb8f94b9f16c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 11 Sep 2026 21:40:37 -0500 Subject: [PATCH 02/19] feat(core): route models.dev providers to their dedicated native packages MiniMax uses the native Messages package, Meta the native Responses package, and Baseten, DeepSeek, Fireworks and Cloudflare Workers AI their own packages instead of generic openai-compatible. Alibaba, Z.AI, Moonshot and the coding plans stay generic for now. --- packages/core/src/models-dev.ts | 35 +++++++++++++++++++++++---------- packages/core/src/provider.ts | 2 ++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index e5bbdfed3290..5de0580aec48 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -98,10 +98,26 @@ const NATIVE_PACKAGES: Readonly> = { "@openrouter/ai-sdk-provider": "@opencode/ai/providers/openrouter", } -function nativePackage(npm: string, modelID?: string) { +// models.dev providers with a dedicated @opencode/ai package that their npm cannot express. Alibaba, Z.AI, +// Moonshot and the coding plans are not listed yet, so they resolve to openai-compatible like their npm says. +const NATIVE_PROVIDERS: Readonly> = { + baseten: "@opencode/ai/providers/baseten", + "cloudflare-workers-ai": "@opencode/ai/providers/cloudflare-workers-ai", + deepseek: "@opencode/ai/providers/deepseek", + "fireworks-ai": "@opencode/ai/providers/fireworks", + meta: "@opencode/ai/providers/meta/responses", + minimax: "@opencode/ai/providers/minimax/messages", + "minimax-cn": "@opencode/ai/providers/minimax/messages", + "minimax-coding-plan": "@opencode/ai/providers/minimax/messages", + "minimax-cn-coding-plan": "@opencode/ai/providers/minimax/messages", +} + +function nativePackage(provider: SourceProvider, model?: SourceModel) { + const npm = model?.provider?.npm ?? provider.npm // Mantle only appears as a per-model override; gpt-oss models are chat-only there. if (npm === "@ai-sdk/amazon-bedrock/mantle") - return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID?.includes("gpt-oss") ? "chat" : "responses"}` + return `@opencode/ai/providers/amazon-bedrock/mantle/${model?.id.includes("gpt-oss") ? "chat" : "responses"}` + if (model?.provider?.npm === undefined && NATIVE_PROVIDERS[provider.id]) return NATIVE_PROVIDERS[provider.id] return NATIVE_PACKAGES[npm] ?? Provider.aisdk(npm) } @@ -109,12 +125,11 @@ function normalize(input: Record): readonly Snapshot[] { const providers: Snapshot[] = [] for (const item of Object.values(input)) { const providerID = Provider.ID.make(item.id) - const pkg = nativePackage(item.npm) const info = { id: providerID, name: item.name, activation: "auto", - package: pkg, + package: nativePackage(item), ...(item.api ? { settings: { baseURL: item.api } } : {}), } satisfies Provider.Info const models: Model.Info[] = [] @@ -122,11 +137,11 @@ function normalize(input: Record): readonly Snapshot[] { const baseCost = cost(model.cost) const variants = reasoningVariants(item, model) const id = Model.ID.make(model.id) - models.push(modelInfo(providerID, pkg, id, model, { cost: baseCost, variants })) + models.push(modelInfo(item, id, model, { cost: baseCost, variants })) for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { const modeID = Model.ID.make(`${model.id}-${mode}`) models.push( - modelInfo(providerID, pkg, modeID, model, { + modelInfo(item, modeID, model, { name: modeName(model, mode), cost: mergeCost(baseCost, options.cost), request: options.provider, @@ -507,8 +522,7 @@ function modeName(model: SourceModel, mode: string) { } function modelInfo( - providerID: Provider.ID, - providerPackage: string, + provider: SourceProvider, id: Model.ID, model: SourceModel, input: { @@ -518,12 +532,13 @@ function modelInfo( readonly variants?: NonNullable } = {}, ): Model.Info { - const pkg = model.provider?.npm ? nativePackage(model.provider.npm, model.id) : undefined + const providerID = Provider.ID.make(provider.id) + const pkg = model.provider?.npm ? nativePackage(provider, model) : undefined // The generic package takes the provider identity as a setting; the AI SDK inferred it. Set per model so it // never merges into a model that overrides to a different package. const settings = { ...(model.provider?.api ? { baseURL: model.provider.api } : {}), - ...((pkg ?? providerPackage) === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}), + ...(nativePackage(provider, model) === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}), } return { id, diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 2ca054b87c01..6e082d02ddb9 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -73,6 +73,8 @@ const builtins = new Map Promise>([ ["@opencode/ai/providers/google-vertex/responses", () => import("@opencode/ai/providers/google-vertex/responses")], ["@opencode/ai/providers/google-vertex/messages", () => import("@opencode/ai/providers/google-vertex/messages")], ["@opencode/ai/providers/groq", () => import("@opencode/ai/providers/groq")], + ["@opencode/ai/providers/meta/responses", () => import("@opencode/ai/providers/meta/responses")], + ["@opencode/ai/providers/minimax/messages", () => import("@opencode/ai/providers/minimax/messages")], ["@opencode/ai/providers/mistral", () => import("@opencode/ai/providers/mistral")], ["@opencode/ai/providers/openai", () => import("@opencode/ai/providers/openai")], ["@opencode/ai/providers/openai/chat", () => import("@opencode/ai/providers/openai/chat")], From 4e9a0d512501e13c1a9cd0271211bb68df6640e8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 11 Sep 2026 22:39:19 -0500 Subject: [PATCH 03/19] refactor(core): move reasoning variant generation into Variant Variant.resolve(model, options) builds effort, toggle and budget variants for a catalog model from a per-package spelling table keyed by the package as it appears in the catalog, native or aisdk:. models.dev is the first caller; the variant plugin and config best-guess variants follow. --- packages/core/src/models-dev.ts | 304 +------------------------------- packages/core/src/variant.ts | 300 +++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 299 deletions(-) create mode 100644 packages/core/src/variant.ts diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 5de0580aec48..6de95ef802a6 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -10,6 +10,7 @@ import { makeGlobalNode } from "@opencode/util/effect/app-node" import { httpClient } from "@opencode/util/effect/app-node-platform" import { Model } from "./model.js" import { Provider } from "./provider.js" +import { Variant } from "./variant.js" import { KV } from "./kv.js" import snapshotText from "./models-dev/snapshot.txt" with { type: "text" } @@ -25,11 +26,6 @@ type Cost = { readonly context_over_200k?: Omit } -type ReasoningOption = - | { readonly type: "effort"; readonly values: readonly (string | null)[] } - | { readonly type: "toggle" } - | { readonly type: "budget_tokens"; readonly min?: number; readonly max?: number } - type Modality = "text" | "audio" | "image" | "video" | "pdf" type SourceModel = { @@ -39,7 +35,7 @@ type SourceModel = { readonly release_date: string readonly attachment: boolean readonly reasoning: boolean - readonly reasoning_options?: readonly ReasoningOption[] + readonly reasoning_options?: readonly Variant.Option[] readonly temperature?: boolean readonly tool_call: boolean readonly interleaved?: boolean | string | { readonly field: string } @@ -135,9 +131,10 @@ function normalize(input: Record): readonly Snapshot[] { const models: Model.Info[] = [] for (const model of Object.values(item.models)) { const baseCost = cost(model.cost) - const variants = reasoningVariants(item, model) const id = Model.ID.make(model.id) - models.push(modelInfo(item, id, model, { cost: baseCost, variants })) + const base = modelInfo(item, id, model, { cost: baseCost }) + const variants = Variant.resolve({ ...base, package: nativePackage(item, model) }, model.reasoning_options ?? []) + models.push({ ...base, variants }) for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { const modeID = Model.ID.make(`${model.id}-${mode}`) models.push( @@ -226,297 +223,6 @@ function mergeCost(base: Model.Info["cost"], override: SourceModel["cost"] | und ] } -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 - if (!options?.length) return [] - const toggle = options.some((option) => option.type === "toggle") - const effort = options.find((option) => option.type === "effort") - if (effort?.type === "effort") { - 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: Model.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 [ - ...(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, modelID: string, effort: string): Provider.Settings | undefined { - if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { 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/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" || 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, - model: SourceModel, - option: Extract[number], { type: "budget_tokens" }>, -): NonNullable { - 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 }, - { id: "max", budget: maximum }, - ].flatMap((item) => { - const settings = settingsForBudget(npm, model.id, item.budget) - return settings ? [{ id: Model.VariantID.make(item.id), settings }] : [] - }) -} - -function toggleVariants(npm: string, modelID: string): NonNullable { - if (npm === "@ai-sdk/gateway") { - const upstream = gatewayPackage(modelID) - if (upstream) return toggleVariants(upstream, modelID) - return [ - { - id: Model.VariantID.make("none"), - settings: { reasoning: { enabled: false } }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { reasoning: { enabled: true } }, - }, - ] - } - if (npm === "@openrouter/ai-sdk-provider") - return [ - { id: Model.VariantID.make("none"), settings: { reasoning: { enabled: false } } }, - { id: Model.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } }, - ] - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") - return [ - { id: Model.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, - { - id: Model.VariantID.make("thinking"), - settings: { - thinking: { type: "adaptive", display: "summarized" }, - }, - }, - ] - if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") - return [ - { - id: Model.VariantID.make("none"), - settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } }, - }, - ] - if (npm === "@ai-sdk/amazon-bedrock") { - const anthropic = modelID.includes("anthropic") - return [ - { - id: Model.VariantID.make("none"), - settings: { - additionalModelRequestFields: anthropic - ? { thinking: { type: "disabled" } } - : { reasoningConfig: { type: "disabled" } }, - }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { - additionalModelRequestFields: anthropic - ? { thinking: { type: "adaptive", display: "summarized" } } - : { reasoningConfig: { type: "enabled" } }, - }, - }, - ] - } - if (npm === "@ai-sdk/alibaba") - return [ - { id: Model.VariantID.make("none"), settings: { enableThinking: false } }, - { id: Model.VariantID.make("thinking"), settings: { enableThinking: true } }, - ] - if (npm === "@ai-sdk/cohere") - return [ - { id: Model.VariantID.make("none"), settings: { thinking: { type: "disabled" } } }, - { id: Model.VariantID.make("thinking"), settings: { thinking: { type: "enabled" } } }, - ] - if (npm === "@jerome-benoit/sap-ai-provider-v2") { - if (modelID.includes("gemini")) - return [ - { - id: Model.VariantID.make("none"), - settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } } }, - }, - ] - if (modelID.includes("cohere")) - return [ - { - id: Model.VariantID.make("none"), - settings: { modelParams: { thinking: { type: "disabled" } } }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { modelParams: { thinking: { type: "enabled" } } }, - }, - ] - if (modelID.includes("amazon--nova")) - return [ - { - id: Model.VariantID.make("none"), - settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } } }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "enabled" } } } }, - }, - ] - if (modelID.includes("anthropic")) - return [ - { - id: Model.VariantID.make("none"), - settings: { - modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } }, - }, - }, - { - id: Model.VariantID.make("thinking"), - settings: { - modelParams: { - additionalModelRequestFields: { - thinking: { type: "adaptive", display: "summarized" }, - }, - }, - }, - }, - ] - } - return [] -} - -function settingsForBudget(npm: string, modelID: string, budget: number): Provider.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) { return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` } diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts new file mode 100644 index 000000000000..f712fb95b209 --- /dev/null +++ b/packages/core/src/variant.ts @@ -0,0 +1,300 @@ +export * as Variant from "./variant.js" + +import { Model } from "./model.js" +import { Provider } from "./provider.js" + +/** What a provider says a model can do with reasoning. Same shape as models.dev `reasoning_options`. */ +export type Option = + | { readonly type: "effort"; readonly values: readonly (string | null)[] } + | { readonly type: "toggle" } + | { readonly type: "budget_tokens"; readonly min?: number; readonly max?: number } + +type Overlay = Omit +type Pair = readonly [off: Overlay, on: Overlay] + +/** How one package spells reasoning controls. Any operation a package cannot express is left undefined. */ +type Package = { + readonly effort?: (modelID: string, effort: string) => Overlay | undefined + readonly toggle?: (modelID: string) => Pair | undefined + readonly budget?: (modelID: string, budget: number) => Overlay | undefined +} + +/** + * Variants for a catalog model. `model.package` must be the effective package (provider or model override); + * models without one, or on a package this table does not know, get no generated variants. + */ +export function resolve(model: Model.Info, options: readonly Option[]): Model.Info["variants"] { + const pkg = model.package === undefined ? undefined : PACKAGES[model.package] + if (!pkg || options.length === 0) return [] + const modelID = model.modelID ?? model.id + const toggle = options.some((option) => option.type === "toggle") ? toggleVariants(pkg, modelID) : [] + const off = toggle.filter((variant) => variant.id === "none") + const effort = options.find((option) => option.type === "effort") + if (effort?.type === "effort") { + const variants = [ + ...off, + ...effort.values.flatMap((value) => { + if (value === null || value === "null") return [] + if (value === "none" && off.length > 0) return [] + const overlay = pkg.effort?.(modelID, value) + return overlay ? [{ id: Model.VariantID.make(value), ...overlay }] : [] + }), + ] + 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 [...off, ...budgetVariants(pkg, model, budget)] + return toggle +} + +function toggleVariants(pkg: Package, modelID: string): Model.Info["variants"] { + const pair = pkg.toggle?.(modelID) + if (!pair) return [] + return [ + { id: Model.VariantID.make("none"), ...pair[0] }, + { id: Model.VariantID.make("thinking"), ...pair[1] }, + ] +} + +const OUTPUT_TOKEN_MAX = 32_000 + +function budgetVariants( + pkg: Package, + model: Model.Info, + option: Extract, +): Model.Info["variants"] { + 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) + const modelID = model.modelID ?? model.id + return [ + { id: "high", budget: high }, + { id: "max", budget: maximum }, + ].flatMap((item) => { + const overlay = pkg.budget?.(modelID, item.budget) + return overlay ? [{ id: Model.VariantID.make(item.id), ...overlay }] : [] + }) +} + +// Package families. Each is one spelling of reasoning controls; the table below maps catalog packages onto them. + +const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] + +const openai: Package = { + effort: (_, effort) => ({ + settings: { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING }, + }), +} + +const effortOnly: Package = { + effort: (_, effort) => ({ settings: { reasoningEffort: effort } }), +} + +const anthropic: Package = { + effort: (modelID, effort) => ({ + settings: anthropicManualThinking(modelID) + ? { effort } + : { thinking: { type: "adaptive", display: "summarized" }, effort }, + }), + toggle: () => [ + { settings: { thinking: { type: "disabled" } } }, + { settings: { thinking: { type: "adaptive", display: "summarized" } } }, + ], + budget: (_, budget) => ({ settings: { thinking: { type: "enabled", budgetTokens: budget } } }), +} + +const google: Package = { + effort: (_, effort) => ({ settings: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } }), + toggle: () => [ + { settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } }, + { settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } } }, + ], + budget: (_, budget) => ({ settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } }), +} + +const bedrock: Package = { + effort: (modelID, effort) => ({ + settings: modelID.includes("anthropic") + ? { + reasoningConfig: { + ...(anthropicManualThinking(modelID) ? {} : { type: "adaptive", display: "summarized" }), + maxReasoningEffort: effort, + }, + } + : { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }, + }), + toggle: (modelID) => + modelID.includes("anthropic") + ? [ + { settings: { additionalModelRequestFields: { thinking: { type: "disabled" } } } }, + { settings: { additionalModelRequestFields: { thinking: { type: "adaptive", display: "summarized" } } } }, + ] + : [ + { settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } } }, + { settings: { additionalModelRequestFields: { reasoningConfig: { type: "enabled" } } } }, + ], + budget: (_, budget) => ({ settings: { reasoningConfig: { type: "enabled", budgetTokens: budget } } }), +} + +const openrouter: Package = { + effort: (_, effort) => ({ settings: { reasoning: { effort } } }), + toggle: () => [{ settings: { reasoning: { enabled: false } } }, { settings: { reasoning: { enabled: true } } }], + budget: (_, budget) => ({ settings: { reasoning: { max_tokens: budget } } }), +} + +const alibaba: Package = { + toggle: () => [{ settings: { enableThinking: false } }, { settings: { enableThinking: true } }], + budget: (_, budget) => ({ settings: { enableThinking: true, thinkingBudget: budget } }), +} + +const cohere: Package = { + toggle: () => [{ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "enabled" } } }], + budget: (_, budget) => ({ settings: { thinking: { type: "enabled", tokenBudget: budget } } }), +} + +const copilot: Package = { + effort: (modelID, effort) => { + if (modelID.includes("gemini")) return + if (modelID.includes("claude")) return { settings: { reasoningEffort: effort } } + return openai.effort?.(modelID, effort) + }, +} + +// Vercel's gateway relays other labs' models; it uses their spellings when the model id names one. +const gateway: Package = { + effort: (modelID, effort) => (gatewayUpstream(modelID) ?? effortOnly).effort?.(modelID, effort), + toggle: (modelID) => (gatewayUpstream(modelID) ?? openrouter).toggle?.(modelID), + budget: (modelID, budget) => (gatewayUpstream(modelID) ?? openrouter).budget?.(modelID, budget), +} + +function gatewayUpstream(modelID: string): Package | undefined { + const separator = modelID.indexOf("/") + if (separator <= 0) return + const prefix = modelID.slice(0, separator) + if (prefix === "anthropic") return anthropic + if (prefix === "google") return google + if (prefix === "amazon") return bedrock + if (prefix === "alibaba") return alibaba +} + +// SAP AI Core wraps every upstream in `modelParams` with each lab's own field names. +const sap: Package = { + effort: (modelID, effort) => { + if (modelID.includes("anthropic")) + return { + settings: { + modelParams: { + additionalModelRequestFields: { + ...(anthropicManualThinking(modelID) ? {} : { thinking: { type: "adaptive", display: "summarized" } }), + output_config: { effort }, + }, + }, + }, + } + if (modelID.includes("gemini")) + return { settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } } } + if (modelID.includes("amazon--nova")) + return { settings: { modelParams: { additionalModelRequestFields: { output_config: { effort } } } } } + return { settings: { modelParams: { reasoning_effort: effort } } } + }, + toggle: (modelID) => { + if (modelID.includes("gemini")) + return [ + { settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } } }, + { settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } } } }, + ] + if (modelID.includes("cohere")) + return [ + { settings: { modelParams: { thinking: { type: "disabled" } } } }, + { settings: { modelParams: { thinking: { type: "enabled" } } } }, + ] + if (modelID.includes("amazon--nova")) + return [ + { settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } } } }, + { settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "enabled" } } } } }, + ] + if (modelID.includes("anthropic")) + return [ + { settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } } } }, + { + settings: { + modelParams: { additionalModelRequestFields: { thinking: { type: "adaptive", display: "summarized" } } }, + }, + }, + ] + }, + budget: (modelID, budget) => { + if (modelID.includes("anthropic")) + return { + settings: { + modelParams: { additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: budget } } }, + }, + } + if (modelID.includes("gemini")) + return { settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } } } + if (modelID.includes("cohere")) + return { settings: { modelParams: { thinking: { type: "enabled", token_budget: budget } } } } + }, +} + +// Claude before 4.6 has no adaptive thinking; effort is sent on its own. +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) +} + +/** Catalog package → spelling. Native packages first, then AI SDK packages that have no native equivalent. */ +const PACKAGES: Readonly> = { + "@opencode/ai/providers/openai": openai, + "@opencode/ai/providers/azure/responses": openai, + "@opencode/ai/providers/amazon-bedrock/mantle/chat": openai, + "@opencode/ai/providers/amazon-bedrock/mantle/responses": openai, + "@opencode/ai/providers/meta/responses": openai, + "@opencode/ai/providers/anthropic": anthropic, + "@opencode/ai/providers/google-vertex/messages": anthropic, + "@opencode/ai/providers/minimax/messages": anthropic, + "@opencode/ai/providers/google": google, + "@opencode/ai/providers/google-vertex": google, + "@opencode/ai/providers/amazon-bedrock": bedrock, + "@opencode/ai/providers/openrouter": openrouter, + "@opencode/ai/providers/openai-compatible": effortOnly, + "@opencode/ai/providers/baseten": effortOnly, + "@opencode/ai/providers/cerebras": effortOnly, + "@opencode/ai/providers/cloudflare-workers-ai": effortOnly, + "@opencode/ai/providers/deepinfra": effortOnly, + "@opencode/ai/providers/deepseek": effortOnly, + "@opencode/ai/providers/fireworks": effortOnly, + "@opencode/ai/providers/groq": effortOnly, + "@opencode/ai/providers/mistral": effortOnly, + "@opencode/ai/providers/togetherai": effortOnly, + "@opencode/ai/providers/xai": effortOnly, + + [Provider.aisdk("@ai-sdk/openai")]: openai, + [Provider.aisdk("@ai-sdk/azure")]: openai, + [Provider.aisdk("@ai-sdk/amazon-bedrock/mantle")]: openai, + [Provider.aisdk("@ai-sdk/anthropic")]: anthropic, + [Provider.aisdk("@ai-sdk/google-vertex/anthropic")]: anthropic, + [Provider.aisdk("@ai-sdk/google")]: google, + [Provider.aisdk("@ai-sdk/google-vertex")]: google, + [Provider.aisdk("@ai-sdk/amazon-bedrock")]: bedrock, + [Provider.aisdk("@openrouter/ai-sdk-provider")]: openrouter, + [Provider.aisdk("@ai-sdk/gateway")]: gateway, + [Provider.aisdk("@ai-sdk/github-copilot")]: copilot, + [Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")]: sap, + [Provider.aisdk("@ai-sdk/alibaba")]: alibaba, + [Provider.aisdk("@ai-sdk/cohere")]: cohere, + [Provider.aisdk("@ai-sdk/openai-compatible")]: effortOnly, + [Provider.aisdk("@ai-sdk/xai")]: effortOnly, + [Provider.aisdk("@ai-sdk/mistral")]: effortOnly, + [Provider.aisdk("@ai-sdk/groq")]: effortOnly, + [Provider.aisdk("@ai-sdk/cerebras")]: effortOnly, + [Provider.aisdk("@ai-sdk/deepinfra")]: effortOnly, + [Provider.aisdk("@ai-sdk/togetherai")]: effortOnly, + [Provider.aisdk("venice-ai-sdk-provider")]: effortOnly, + [Provider.aisdk("ai-gateway-provider")]: effortOnly, +} From 7365850df47077ceedd7b95e3e6b4f14b775031a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 12 Sep 2026 22:46:15 -0500 Subject: [PATCH 04/19] chore(core): trim variant comments --- packages/core/src/models-dev.ts | 6 +----- packages/core/src/variant.ts | 12 +----------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 6de95ef802a6..0cdba700d0ba 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -75,7 +75,6 @@ export type Snapshot = { readonly environment: readonly string[] } -// models.dev npm packages with a native @opencode/ai equivalent. Anything else stays on the AI SDK. const NATIVE_PACKAGES: Readonly> = { "@ai-sdk/amazon-bedrock": "@opencode/ai/providers/amazon-bedrock", "@ai-sdk/anthropic": "@opencode/ai/providers/anthropic", @@ -94,8 +93,6 @@ const NATIVE_PACKAGES: Readonly> = { "@openrouter/ai-sdk-provider": "@opencode/ai/providers/openrouter", } -// models.dev providers with a dedicated @opencode/ai package that their npm cannot express. Alibaba, Z.AI, -// Moonshot and the coding plans are not listed yet, so they resolve to openai-compatible like their npm says. const NATIVE_PROVIDERS: Readonly> = { baseten: "@opencode/ai/providers/baseten", "cloudflare-workers-ai": "@opencode/ai/providers/cloudflare-workers-ai", @@ -240,8 +237,7 @@ function modelInfo( ): Model.Info { const providerID = Provider.ID.make(provider.id) const pkg = model.provider?.npm ? nativePackage(provider, model) : undefined - // The generic package takes the provider identity as a setting; the AI SDK inferred it. Set per model so it - // never merges into a model that overrides to a different package. + // Per model, so it never merges into a model that overrides to a different package. const settings = { ...(model.provider?.api ? { baseURL: model.provider.api } : {}), ...(nativePackage(provider, model) === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}), diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts index f712fb95b209..1d902870622b 100644 --- a/packages/core/src/variant.ts +++ b/packages/core/src/variant.ts @@ -3,7 +3,6 @@ export * as Variant from "./variant.js" import { Model } from "./model.js" import { Provider } from "./provider.js" -/** What a provider says a model can do with reasoning. Same shape as models.dev `reasoning_options`. */ export type Option = | { readonly type: "effort"; readonly values: readonly (string | null)[] } | { readonly type: "toggle" } @@ -12,17 +11,13 @@ export type Option = type Overlay = Omit type Pair = readonly [off: Overlay, on: Overlay] -/** How one package spells reasoning controls. Any operation a package cannot express is left undefined. */ type Package = { readonly effort?: (modelID: string, effort: string) => Overlay | undefined readonly toggle?: (modelID: string) => Pair | undefined readonly budget?: (modelID: string, budget: number) => Overlay | undefined } -/** - * Variants for a catalog model. `model.package` must be the effective package (provider or model override); - * models without one, or on a package this table does not know, get no generated variants. - */ +// `model.package` must be the effective package: a model-level override or the provider's. export function resolve(model: Model.Info, options: readonly Option[]): Model.Info["variants"] { const pkg = model.package === undefined ? undefined : PACKAGES[model.package] if (!pkg || options.length === 0) return [] @@ -76,8 +71,6 @@ function budgetVariants( }) } -// Package families. Each is one spelling of reasoning controls; the table below maps catalog packages onto them. - const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] const openai: Package = { @@ -160,7 +153,6 @@ const copilot: Package = { }, } -// Vercel's gateway relays other labs' models; it uses their spellings when the model id names one. const gateway: Package = { effort: (modelID, effort) => (gatewayUpstream(modelID) ?? effortOnly).effort?.(modelID, effort), toggle: (modelID) => (gatewayUpstream(modelID) ?? openrouter).toggle?.(modelID), @@ -177,7 +169,6 @@ function gatewayUpstream(modelID: string): Package | undefined { if (prefix === "alibaba") return alibaba } -// SAP AI Core wraps every upstream in `modelParams` with each lab's own field names. const sap: Package = { effort: (modelID, effort) => { if (modelID.includes("anthropic")) @@ -248,7 +239,6 @@ function anthropicManualThinking(modelID: string) { return major < 4 || (major === 4 && minor < 6) } -/** Catalog package → spelling. Native packages first, then AI SDK packages that have no native equivalent. */ const PACKAGES: Readonly> = { "@opencode/ai/providers/openai": openai, "@opencode/ai/providers/azure/responses": openai, From 1755f0d26fee6caee6f5572cb556c7edfd09fee0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 12 Sep 2026 22:50:02 -0500 Subject: [PATCH 05/19] refactor(core): group variant spellings by family --- packages/core/src/variant.ts | 171 +++++++++++++++++++---------------- 1 file changed, 94 insertions(+), 77 deletions(-) diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts index 1d902870622b..eb34cd788551 100644 --- a/packages/core/src/variant.ts +++ b/packages/core/src/variant.ts @@ -11,7 +11,8 @@ export type Option = type Overlay = Omit type Pair = readonly [off: Overlay, on: Overlay] -type Package = { +type Family = { + readonly packages: readonly string[] readonly effort?: (modelID: string, effort: string) => Overlay | undefined readonly toggle?: (modelID: string) => Pair | undefined readonly budget?: (modelID: string, budget: number) => Overlay | undefined @@ -19,10 +20,10 @@ type Package = { // `model.package` must be the effective package: a model-level override or the provider's. export function resolve(model: Model.Info, options: readonly Option[]): Model.Info["variants"] { - const pkg = model.package === undefined ? undefined : PACKAGES[model.package] - if (!pkg || options.length === 0) return [] + const family = FAMILIES.find((item) => item.packages.includes(model.package ?? "")) + if (!family || options.length === 0) return [] const modelID = model.modelID ?? model.id - const toggle = options.some((option) => option.type === "toggle") ? toggleVariants(pkg, modelID) : [] + const toggle = options.some((option) => option.type === "toggle") ? toggleVariants(family, modelID) : [] const off = toggle.filter((variant) => variant.id === "none") const effort = options.find((option) => option.type === "effort") if (effort?.type === "effort") { @@ -31,19 +32,19 @@ export function resolve(model: Model.Info, options: readonly Option[]): Model.In ...effort.values.flatMap((value) => { if (value === null || value === "null") return [] if (value === "none" && off.length > 0) return [] - const overlay = pkg.effort?.(modelID, value) + const overlay = family.effort?.(modelID, value) return overlay ? [{ id: Model.VariantID.make(value), ...overlay }] : [] }), ] 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 [...off, ...budgetVariants(pkg, model, budget)] + if (budget?.type === "budget_tokens") return [...off, ...budgetVariants(family, model, budget)] return toggle } -function toggleVariants(pkg: Package, modelID: string): Model.Info["variants"] { - const pair = pkg.toggle?.(modelID) +function toggleVariants(family: Family, modelID: string): Model.Info["variants"] { + const pair = family.toggle?.(modelID) if (!pair) return [] return [ { id: Model.VariantID.make("none"), ...pair[0] }, @@ -54,7 +55,7 @@ function toggleVariants(pkg: Package, modelID: string): Model.Info["variants"] { const OUTPUT_TOKEN_MAX = 32_000 function budgetVariants( - pkg: Package, + family: Family, model: Model.Info, option: Extract, ): Model.Info["variants"] { @@ -66,24 +67,63 @@ function budgetVariants( { id: "high", budget: high }, { id: "max", budget: maximum }, ].flatMap((item) => { - const overlay = pkg.budget?.(modelID, item.budget) + const overlay = family.budget?.(modelID, item.budget) return overlay ? [{ id: Model.VariantID.make(item.id), ...overlay }] : [] }) } const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] -const openai: Package = { +const openaiResponses: Family = { + packages: [ + "@opencode/ai/providers/openai", + "@opencode/ai/providers/azure/responses", + "@opencode/ai/providers/amazon-bedrock/mantle/chat", + "@opencode/ai/providers/amazon-bedrock/mantle/responses", + "@opencode/ai/providers/meta/responses", + Provider.aisdk("@ai-sdk/openai"), + Provider.aisdk("@ai-sdk/azure"), + Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), + ], effort: (_, effort) => ({ settings: { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING }, }), } -const effortOnly: Package = { +const openaiChat: Family = { + packages: [ + "@opencode/ai/providers/openai-compatible", + "@opencode/ai/providers/baseten", + "@opencode/ai/providers/cerebras", + "@opencode/ai/providers/cloudflare-workers-ai", + "@opencode/ai/providers/deepinfra", + "@opencode/ai/providers/deepseek", + "@opencode/ai/providers/fireworks", + "@opencode/ai/providers/groq", + "@opencode/ai/providers/mistral", + "@opencode/ai/providers/togetherai", + "@opencode/ai/providers/xai", + Provider.aisdk("@ai-sdk/openai-compatible"), + Provider.aisdk("@ai-sdk/xai"), + Provider.aisdk("@ai-sdk/mistral"), + Provider.aisdk("@ai-sdk/groq"), + Provider.aisdk("@ai-sdk/cerebras"), + Provider.aisdk("@ai-sdk/deepinfra"), + Provider.aisdk("@ai-sdk/togetherai"), + Provider.aisdk("venice-ai-sdk-provider"), + Provider.aisdk("ai-gateway-provider"), + ], effort: (_, effort) => ({ settings: { reasoningEffort: effort } }), } -const anthropic: Package = { +const anthropic: Family = { + packages: [ + "@opencode/ai/providers/anthropic", + "@opencode/ai/providers/google-vertex/messages", + "@opencode/ai/providers/minimax/messages", + Provider.aisdk("@ai-sdk/anthropic"), + Provider.aisdk("@ai-sdk/google-vertex/anthropic"), + ], effort: (modelID, effort) => ({ settings: anthropicManualThinking(modelID) ? { effort } @@ -96,7 +136,13 @@ const anthropic: Package = { budget: (_, budget) => ({ settings: { thinking: { type: "enabled", budgetTokens: budget } } }), } -const google: Package = { +const gemini: Family = { + packages: [ + "@opencode/ai/providers/google", + "@opencode/ai/providers/google-vertex", + Provider.aisdk("@ai-sdk/google"), + Provider.aisdk("@ai-sdk/google-vertex"), + ], effort: (_, effort) => ({ settings: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } }), toggle: () => [ { settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } }, @@ -105,7 +151,8 @@ const google: Package = { budget: (_, budget) => ({ settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } }), } -const bedrock: Package = { +const bedrockConverse: Family = { + packages: ["@opencode/ai/providers/amazon-bedrock", Provider.aisdk("@ai-sdk/amazon-bedrock")], effort: (modelID, effort) => ({ settings: modelID.includes("anthropic") ? { @@ -129,47 +176,53 @@ const bedrock: Package = { budget: (_, budget) => ({ settings: { reasoningConfig: { type: "enabled", budgetTokens: budget } } }), } -const openrouter: Package = { +const openrouter: Family = { + packages: ["@opencode/ai/providers/openrouter", Provider.aisdk("@openrouter/ai-sdk-provider")], effort: (_, effort) => ({ settings: { reasoning: { effort } } }), toggle: () => [{ settings: { reasoning: { enabled: false } } }, { settings: { reasoning: { enabled: true } } }], budget: (_, budget) => ({ settings: { reasoning: { max_tokens: budget } } }), } -const alibaba: Package = { +const alibaba: Family = { + packages: [Provider.aisdk("@ai-sdk/alibaba")], toggle: () => [{ settings: { enableThinking: false } }, { settings: { enableThinking: true } }], budget: (_, budget) => ({ settings: { enableThinking: true, thinkingBudget: budget } }), } -const cohere: Package = { +const cohere: Family = { + packages: [Provider.aisdk("@ai-sdk/cohere")], toggle: () => [{ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "enabled" } } }], budget: (_, budget) => ({ settings: { thinking: { type: "enabled", tokenBudget: budget } } }), } -const copilot: Package = { +const githubCopilot: Family = { + packages: [Provider.aisdk("@ai-sdk/github-copilot")], effort: (modelID, effort) => { if (modelID.includes("gemini")) return if (modelID.includes("claude")) return { settings: { reasoningEffort: effort } } - return openai.effort?.(modelID, effort) + return openaiResponses.effort?.(modelID, effort) }, } -const gateway: Package = { - effort: (modelID, effort) => (gatewayUpstream(modelID) ?? effortOnly).effort?.(modelID, effort), - toggle: (modelID) => (gatewayUpstream(modelID) ?? openrouter).toggle?.(modelID), - budget: (modelID, budget) => (gatewayUpstream(modelID) ?? openrouter).budget?.(modelID, budget), +const vercelGateway: Family = { + packages: [Provider.aisdk("@ai-sdk/gateway")], + effort: (modelID, effort) => (vercelGatewayUpstream(modelID) ?? openaiChat).effort?.(modelID, effort), + toggle: (modelID) => (vercelGatewayUpstream(modelID) ?? openrouter).toggle?.(modelID), + budget: (modelID, budget) => (vercelGatewayUpstream(modelID) ?? openrouter).budget?.(modelID, budget), } -function gatewayUpstream(modelID: string): Package | undefined { +function vercelGatewayUpstream(modelID: string): Family | undefined { const separator = modelID.indexOf("/") if (separator <= 0) return const prefix = modelID.slice(0, separator) if (prefix === "anthropic") return anthropic - if (prefix === "google") return google - if (prefix === "amazon") return bedrock + if (prefix === "google") return gemini + if (prefix === "amazon") return bedrockConverse if (prefix === "alibaba") return alibaba } -const sap: Package = { +const sapAICore: Family = { + packages: [Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")], effort: (modelID, effort) => { if (modelID.includes("anthropic")) return { @@ -239,52 +292,16 @@ function anthropicManualThinking(modelID: string) { return major < 4 || (major === 4 && minor < 6) } -const PACKAGES: Readonly> = { - "@opencode/ai/providers/openai": openai, - "@opencode/ai/providers/azure/responses": openai, - "@opencode/ai/providers/amazon-bedrock/mantle/chat": openai, - "@opencode/ai/providers/amazon-bedrock/mantle/responses": openai, - "@opencode/ai/providers/meta/responses": openai, - "@opencode/ai/providers/anthropic": anthropic, - "@opencode/ai/providers/google-vertex/messages": anthropic, - "@opencode/ai/providers/minimax/messages": anthropic, - "@opencode/ai/providers/google": google, - "@opencode/ai/providers/google-vertex": google, - "@opencode/ai/providers/amazon-bedrock": bedrock, - "@opencode/ai/providers/openrouter": openrouter, - "@opencode/ai/providers/openai-compatible": effortOnly, - "@opencode/ai/providers/baseten": effortOnly, - "@opencode/ai/providers/cerebras": effortOnly, - "@opencode/ai/providers/cloudflare-workers-ai": effortOnly, - "@opencode/ai/providers/deepinfra": effortOnly, - "@opencode/ai/providers/deepseek": effortOnly, - "@opencode/ai/providers/fireworks": effortOnly, - "@opencode/ai/providers/groq": effortOnly, - "@opencode/ai/providers/mistral": effortOnly, - "@opencode/ai/providers/togetherai": effortOnly, - "@opencode/ai/providers/xai": effortOnly, - - [Provider.aisdk("@ai-sdk/openai")]: openai, - [Provider.aisdk("@ai-sdk/azure")]: openai, - [Provider.aisdk("@ai-sdk/amazon-bedrock/mantle")]: openai, - [Provider.aisdk("@ai-sdk/anthropic")]: anthropic, - [Provider.aisdk("@ai-sdk/google-vertex/anthropic")]: anthropic, - [Provider.aisdk("@ai-sdk/google")]: google, - [Provider.aisdk("@ai-sdk/google-vertex")]: google, - [Provider.aisdk("@ai-sdk/amazon-bedrock")]: bedrock, - [Provider.aisdk("@openrouter/ai-sdk-provider")]: openrouter, - [Provider.aisdk("@ai-sdk/gateway")]: gateway, - [Provider.aisdk("@ai-sdk/github-copilot")]: copilot, - [Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")]: sap, - [Provider.aisdk("@ai-sdk/alibaba")]: alibaba, - [Provider.aisdk("@ai-sdk/cohere")]: cohere, - [Provider.aisdk("@ai-sdk/openai-compatible")]: effortOnly, - [Provider.aisdk("@ai-sdk/xai")]: effortOnly, - [Provider.aisdk("@ai-sdk/mistral")]: effortOnly, - [Provider.aisdk("@ai-sdk/groq")]: effortOnly, - [Provider.aisdk("@ai-sdk/cerebras")]: effortOnly, - [Provider.aisdk("@ai-sdk/deepinfra")]: effortOnly, - [Provider.aisdk("@ai-sdk/togetherai")]: effortOnly, - [Provider.aisdk("venice-ai-sdk-provider")]: effortOnly, - [Provider.aisdk("ai-gateway-provider")]: effortOnly, -} +const FAMILIES = [ + openaiResponses, + openaiChat, + anthropic, + gemini, + bedrockConverse, + openrouter, + vercelGateway, + githubCopilot, + sapAICore, + alibaba, + cohere, +] From bca964c63da6bea5968e19b1e70764a264011e1c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 12 Sep 2026 23:37:51 -0500 Subject: [PATCH 06/19] refactor(core): one variant format per package Reasoning variants are built from three controls: effort, toggle and token budget. Each package entry says how it spells each one; shared spellings are plain functions and shared formats are plain values, so packages that behave alike point at the same format and packages that differ spread and override. Native Bedrock variants now ride in body.additionalModelRequestFields, matching what the AI SDK translator produced. --- packages/core/src/variant.ts | 460 ++++++++++--------- packages/core/test/plugin/models-dev.test.ts | 6 +- 2 files changed, 251 insertions(+), 215 deletions(-) diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts index eb34cd788551..276a87eca577 100644 --- a/packages/core/src/variant.ts +++ b/packages/core/src/variant.ts @@ -9,21 +9,20 @@ export type Option = | { readonly type: "budget_tokens"; readonly min?: number; readonly max?: number } type Overlay = Omit -type Pair = readonly [off: Overlay, on: Overlay] +type Toggle = readonly [off: Overlay, on: Overlay] -type Family = { - readonly packages: readonly string[] - readonly effort?: (modelID: string, effort: string) => Overlay | undefined - readonly toggle?: (modelID: string) => Pair | undefined - readonly budget?: (modelID: string, budget: number) => Overlay | undefined +// How one package spells the three reasoning controls. `undefined` means this package or model has no way to say it. +type Format = { + readonly effort?: (model: Model.Info, value: string) => Overlay | undefined + readonly toggle?: (model: Model.Info) => Toggle | undefined + readonly budget?: (model: Model.Info, tokens: number) => Overlay | undefined } // `model.package` must be the effective package: a model-level override or the provider's. export function resolve(model: Model.Info, options: readonly Option[]): Model.Info["variants"] { - const family = FAMILIES.find((item) => item.packages.includes(model.package ?? "")) - if (!family || options.length === 0) return [] - const modelID = model.modelID ?? model.id - const toggle = options.some((option) => option.type === "toggle") ? toggleVariants(family, modelID) : [] + const format = model.package === undefined ? undefined : FORMATS[model.package] + if (!format || options.length === 0) return [] + const toggle = options.some((option) => option.type === "toggle") ? toggleVariants(format, model) : [] const off = toggle.filter((variant) => variant.id === "none") const effort = options.find((option) => option.type === "effort") if (effort?.type === "effort") { @@ -32,19 +31,19 @@ export function resolve(model: Model.Info, options: readonly Option[]): Model.In ...effort.values.flatMap((value) => { if (value === null || value === "null") return [] if (value === "none" && off.length > 0) return [] - const overlay = family.effort?.(modelID, value) + const overlay = format.effort?.(model, value) return overlay ? [{ id: Model.VariantID.make(value), ...overlay }] : [] }), ] 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 [...off, ...budgetVariants(family, model, budget)] + if (budget?.type === "budget_tokens") return [...off, ...budgetVariants(format, model, budget)] return toggle } -function toggleVariants(family: Family, modelID: string): Model.Info["variants"] { - const pair = family.toggle?.(modelID) +function toggleVariants(format: Format, model: Model.Info): Model.Info["variants"] { + const pair = format.toggle?.(model) if (!pair) return [] return [ { id: Model.VariantID.make("none"), ...pair[0] }, @@ -55,236 +54,230 @@ function toggleVariants(family: Family, modelID: string): Model.Info["variants"] const OUTPUT_TOKEN_MAX = 32_000 function budgetVariants( - family: Family, + format: Format, model: Model.Info, option: Extract, ): Model.Info["variants"] { 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) - const modelID = model.modelID ?? model.id return [ { id: "high", budget: high }, { id: "max", budget: maximum }, ].flatMap((item) => { - const overlay = family.budget?.(modelID, item.budget) + const overlay = format.budget?.(model, item.budget) return overlay ? [{ id: Model.VariantID.make(item.id), ...overlay }] : [] }) } -const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] - -const openaiResponses: Family = { - packages: [ - "@opencode/ai/providers/openai", - "@opencode/ai/providers/azure/responses", - "@opencode/ai/providers/amazon-bedrock/mantle/chat", - "@opencode/ai/providers/amazon-bedrock/mantle/responses", - "@opencode/ai/providers/meta/responses", - Provider.aisdk("@ai-sdk/openai"), - Provider.aisdk("@ai-sdk/azure"), - Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), - ], - effort: (_, effort) => ({ - settings: { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING }, - }), +// ── Wire spellings ───────────────────────────────────────────────────────────────────────────────────────── +// One function per way a control appears on the wire. Packages below compose these. + +const pair = (spell: (on: boolean) => Overlay): Toggle => [spell(false), spell(true)] + +const ENCRYPTED_REASONING = ["reasoning.encrypted_content"] +const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" } + +// effort +const chatReasoningEffort = (value: string): Overlay => ({ settings: { reasoningEffort: value } }) +const responsesReasoningEffort = (value: string): Overlay => ({ + settings: { reasoningEffort: value, reasoningSummary: "auto", include: ENCRYPTED_REASONING }, +}) +const anthropicEffort = (model: Model.Info, value: string): Overlay => ({ + settings: claudeThinksManually(model) ? { effort: value } : { thinking: ADAPTIVE_THINKING, effort: value }, +}) +const geminiThinkingLevel = (value: string): Overlay => ({ + settings: { thinkingConfig: { includeThoughts: true, thinkingLevel: value } }, +}) +const openrouterReasoningEffort = (value: string): Overlay => ({ settings: { reasoning: { effort: value } } }) + +// toggle +const anthropicThinking = pair((on) => ({ settings: { thinking: on ? ADAPTIVE_THINKING : { type: "disabled" } } })) +const geminiThinking = pair((on) => ({ + settings: { + thinkingConfig: on ? { includeThoughts: true, thinkingBudget: -1 } : { includeThoughts: false, thinkingBudget: 0 }, + }, +})) +const openrouterReasoning = pair((on) => ({ settings: { reasoning: { enabled: on } } })) +const enableThinking = pair((on) => ({ settings: { enableThinking: on } })) +const cohereThinking = pair((on) => ({ settings: { thinking: { type: on ? "enabled" : "disabled" } } })) + +// budget +const anthropicBudget = (tokens: number): Overlay => ({ + settings: { thinking: { type: "enabled", budgetTokens: tokens } }, +}) +const geminiBudget = (tokens: number): Overlay => ({ + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: tokens } }, +}) +const openrouterBudget = (tokens: number): Overlay => ({ settings: { reasoning: { max_tokens: tokens } } }) +const cohereBudget = (tokens: number): Overlay => ({ settings: { thinking: { type: "enabled", tokenBudget: tokens } } }) +const enableThinkingBudget = (tokens: number): Overlay => ({ + settings: { enableThinking: true, thinkingBudget: tokens }, +}) + +// ── Shared formats ───────────────────────────────────────────────────────────────────────────────────────── + +const openaiChat: Format = { + effort: (_, value) => chatReasoningEffort(value), } -const openaiChat: Family = { - packages: [ - "@opencode/ai/providers/openai-compatible", - "@opencode/ai/providers/baseten", - "@opencode/ai/providers/cerebras", - "@opencode/ai/providers/cloudflare-workers-ai", - "@opencode/ai/providers/deepinfra", - "@opencode/ai/providers/deepseek", - "@opencode/ai/providers/fireworks", - "@opencode/ai/providers/groq", - "@opencode/ai/providers/mistral", - "@opencode/ai/providers/togetherai", - "@opencode/ai/providers/xai", - Provider.aisdk("@ai-sdk/openai-compatible"), - Provider.aisdk("@ai-sdk/xai"), - Provider.aisdk("@ai-sdk/mistral"), - Provider.aisdk("@ai-sdk/groq"), - Provider.aisdk("@ai-sdk/cerebras"), - Provider.aisdk("@ai-sdk/deepinfra"), - Provider.aisdk("@ai-sdk/togetherai"), - Provider.aisdk("venice-ai-sdk-provider"), - Provider.aisdk("ai-gateway-provider"), - ], - effort: (_, effort) => ({ settings: { reasoningEffort: effort } }), +const openaiResponses: Format = { + effort: (_, value) => responsesReasoningEffort(value), } -const anthropic: Family = { - packages: [ - "@opencode/ai/providers/anthropic", - "@opencode/ai/providers/google-vertex/messages", - "@opencode/ai/providers/minimax/messages", - Provider.aisdk("@ai-sdk/anthropic"), - Provider.aisdk("@ai-sdk/google-vertex/anthropic"), - ], - effort: (modelID, effort) => ({ - settings: anthropicManualThinking(modelID) - ? { effort } - : { thinking: { type: "adaptive", display: "summarized" }, effort }, - }), - toggle: () => [ - { settings: { thinking: { type: "disabled" } } }, - { settings: { thinking: { type: "adaptive", display: "summarized" } } }, - ], - budget: (_, budget) => ({ settings: { thinking: { type: "enabled", budgetTokens: budget } } }), +const anthropicMessages: Format = { + effort: anthropicEffort, + toggle: () => anthropicThinking, + budget: (_, tokens) => anthropicBudget(tokens), } -const gemini: Family = { - packages: [ - "@opencode/ai/providers/google", - "@opencode/ai/providers/google-vertex", - Provider.aisdk("@ai-sdk/google"), - Provider.aisdk("@ai-sdk/google-vertex"), - ], - effort: (_, effort) => ({ settings: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } }), - toggle: () => [ - { settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } }, - { settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } } }, - ], - budget: (_, budget) => ({ settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } }), +const gemini: Format = { + effort: (_, value) => geminiThinkingLevel(value), + toggle: () => geminiThinking, + budget: (_, tokens) => geminiBudget(tokens), } -const bedrockConverse: Family = { - packages: ["@opencode/ai/providers/amazon-bedrock", Provider.aisdk("@ai-sdk/amazon-bedrock")], - effort: (modelID, effort) => ({ - settings: modelID.includes("anthropic") - ? { - reasoningConfig: { - ...(anthropicManualThinking(modelID) ? {} : { type: "adaptive", display: "summarized" }), - maxReasoningEffort: effort, - }, - } - : { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }, - }), - toggle: (modelID) => - modelID.includes("anthropic") - ? [ - { settings: { additionalModelRequestFields: { thinking: { type: "disabled" } } } }, - { settings: { additionalModelRequestFields: { thinking: { type: "adaptive", display: "summarized" } } } }, - ] - : [ - { settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } } }, - { settings: { additionalModelRequestFields: { reasoningConfig: { type: "enabled" } } } }, - ], - budget: (_, budget) => ({ settings: { reasoningConfig: { type: "enabled", budgetTokens: budget } } }), +const openrouter: Format = { + effort: (_, value) => openrouterReasoningEffort(value), + toggle: () => openrouterReasoning, + budget: (_, tokens) => openrouterBudget(tokens), } -const openrouter: Family = { - packages: ["@opencode/ai/providers/openrouter", Provider.aisdk("@openrouter/ai-sdk-provider")], - effort: (_, effort) => ({ settings: { reasoning: { effort } } }), - toggle: () => [{ settings: { reasoning: { enabled: false } } }, { settings: { reasoning: { enabled: true } } }], - budget: (_, budget) => ({ settings: { reasoning: { max_tokens: budget } } }), +// Bedrock Converse has no reasoning options; everything rides in `additionalModelRequestFields`. +const bedrockConverse: Format = { + effort: (model, value) => { + const id = modelID(model) + if (id.includes("anthropic")) + return bedrockFields({ + ...(claudeThinksManually(model) ? {} : { thinking: ADAPTIVE_THINKING }), + output_config: { effort: value }, + }) + if (id.includes("openai.gpt-oss")) return bedrockFields({ reasoning_effort: value }) + if (id.includes("openai.")) return bedrockFields({ reasoning: { effort: value } }) + return bedrockFields({ reasoningConfig: { type: "enabled", maxReasoningEffort: value } }) + }, + toggle: (model) => + modelID(model).includes("anthropic") + ? pair((on) => bedrockFields({ thinking: on ? ADAPTIVE_THINKING : { type: "disabled" } })) + : pair((on) => bedrockFields({ reasoningConfig: { type: on ? "enabled" : "disabled" } })), + budget: (model, tokens) => + modelID(model).includes("anthropic") + ? bedrockFields({ thinking: { type: "enabled", budget_tokens: tokens } }) + : bedrockFields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }), } -const alibaba: Family = { - packages: [Provider.aisdk("@ai-sdk/alibaba")], - toggle: () => [{ settings: { enableThinking: false } }, { settings: { enableThinking: true } }], - budget: (_, budget) => ({ settings: { enableThinking: true, thinkingBudget: budget } }), +const bedrockFields = (fields: Record): Overlay => ({ body: { additionalModelRequestFields: fields } }) + +// ── AI SDK-only formats (no native package; translated by AISDKNative at resolve time) ──────────────────── + +const githubCopilot: Format = { + effort: (model, value) => { + const id = modelID(model) + if (id.includes("gemini")) return + if (id.includes("claude")) return chatReasoningEffort(value) + return responsesReasoningEffort(value) + }, } -const cohere: Family = { - packages: [Provider.aisdk("@ai-sdk/cohere")], - toggle: () => [{ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "enabled" } } }], - budget: (_, budget) => ({ settings: { thinking: { type: "enabled", tokenBudget: budget } } }), +const alibabaAISDK: Format = { + toggle: () => enableThinking, + budget: (_, tokens) => enableThinkingBudget(tokens), } -const githubCopilot: Family = { - packages: [Provider.aisdk("@ai-sdk/github-copilot")], - effort: (modelID, effort) => { - if (modelID.includes("gemini")) return - if (modelID.includes("claude")) return { settings: { reasoningEffort: effort } } - return openaiResponses.effort?.(modelID, effort) - }, +const cohere: Format = { + toggle: () => cohereThinking, + budget: (_, tokens) => cohereBudget(tokens), } -const vercelGateway: Family = { - packages: [Provider.aisdk("@ai-sdk/gateway")], - effort: (modelID, effort) => (vercelGatewayUpstream(modelID) ?? openaiChat).effort?.(modelID, effort), - toggle: (modelID) => (vercelGatewayUpstream(modelID) ?? openrouter).toggle?.(modelID), - budget: (modelID, budget) => (vercelGatewayUpstream(modelID) ?? openrouter).budget?.(modelID, budget), +// Bedrock Converse via the AI SDK spells everything as `reasoningConfig` settings; AISDKNative turns it into body. +const bedrockAISDK: Format = { + effort: (model, value) => ({ + settings: modelID(model).includes("anthropic") + ? { reasoningConfig: { ...(claudeThinksManually(model) ? {} : ADAPTIVE_THINKING), maxReasoningEffort: value } } + : { reasoningConfig: { type: "enabled", maxReasoningEffort: value } }, + }), + toggle: (model) => + modelID(model).includes("anthropic") + ? pair((on) => ({ + settings: { additionalModelRequestFields: { thinking: on ? ADAPTIVE_THINKING : { type: "disabled" } } }, + })) + : pair((on) => ({ + settings: { additionalModelRequestFields: { reasoningConfig: { type: on ? "enabled" : "disabled" } } }, + })), + budget: (_, tokens) => ({ settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } } }), +} + +// Vercel's gateway relays other labs' models and takes their spelling when the id names one. +const vercelGateway: Format = { + effort: (model, value) => (gatewayUpstream(model) ?? openaiChat).effort?.(model, value), + toggle: (model) => (gatewayUpstream(model) ?? openrouter).toggle?.(model), + budget: (model, tokens) => (gatewayUpstream(model) ?? openrouter).budget?.(model, tokens), } -function vercelGatewayUpstream(modelID: string): Family | undefined { - const separator = modelID.indexOf("/") +function gatewayUpstream(model: Model.Info): Format | undefined { + const id = modelID(model) + const separator = id.indexOf("/") if (separator <= 0) return - const prefix = modelID.slice(0, separator) - if (prefix === "anthropic") return anthropic + const prefix = id.slice(0, separator) + if (prefix === "anthropic") return anthropicMessages if (prefix === "google") return gemini - if (prefix === "amazon") return bedrockConverse - if (prefix === "alibaba") return alibaba + if (prefix === "amazon") return bedrockAISDK + if (prefix === "alibaba") return alibabaAISDK } -const sapAICore: Family = { - packages: [Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")], - effort: (modelID, effort) => { - if (modelID.includes("anthropic")) - return { - settings: { - modelParams: { - additionalModelRequestFields: { - ...(anthropicManualThinking(modelID) ? {} : { thinking: { type: "adaptive", display: "summarized" } }), - output_config: { effort }, - }, - }, +// SAP AI Core wraps each upstream's fields in `modelParams`. +const sapAICore: Format = { + effort: (model, value) => { + const id = modelID(model) + if (id.includes("anthropic")) + return sap({ + additionalModelRequestFields: { + ...(claudeThinksManually(model) ? {} : { thinking: ADAPTIVE_THINKING }), + output_config: { effort: value }, }, - } - if (modelID.includes("gemini")) - return { settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } } } - if (modelID.includes("amazon--nova")) - return { settings: { modelParams: { additionalModelRequestFields: { output_config: { effort } } } } } - return { settings: { modelParams: { reasoning_effort: effort } } } + }) + if (id.includes("gemini")) return sap({ thinkingConfig: { includeThoughts: true, thinkingLevel: value } }) + if (id.includes("amazon--nova")) return sap({ additionalModelRequestFields: { output_config: { effort: value } } }) + return sap({ reasoning_effort: value }) }, - toggle: (modelID) => { - if (modelID.includes("gemini")) - return [ - { settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } } }, - { settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } } } }, - ] - if (modelID.includes("cohere")) - return [ - { settings: { modelParams: { thinking: { type: "disabled" } } } }, - { settings: { modelParams: { thinking: { type: "enabled" } } } }, - ] - if (modelID.includes("amazon--nova")) - return [ - { settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } } } }, - { settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "enabled" } } } } }, - ] - if (modelID.includes("anthropic")) - return [ - { settings: { modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } } } }, - { - settings: { - modelParams: { additionalModelRequestFields: { thinking: { type: "adaptive", display: "summarized" } } }, - }, - }, - ] + toggle: (model) => { + const id = modelID(model) + if (id.includes("gemini")) + return pair((on) => + sap({ + thinkingConfig: on + ? { includeThoughts: true, thinkingBudget: -1 } + : { includeThoughts: false, thinkingBudget: 0 }, + }), + ) + if (id.includes("cohere")) return pair((on) => sap({ thinking: { type: on ? "enabled" : "disabled" } })) + if (id.includes("amazon--nova")) + return pair((on) => sap({ additionalModelRequestFields: { thinking: { type: on ? "enabled" : "disabled" } } })) + if (id.includes("anthropic")) + return pair((on) => + sap({ additionalModelRequestFields: { thinking: on ? ADAPTIVE_THINKING : { type: "disabled" } } }), + ) }, - budget: (modelID, budget) => { - if (modelID.includes("anthropic")) - return { - settings: { - modelParams: { additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: budget } } }, - }, - } - if (modelID.includes("gemini")) - return { settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } } } - if (modelID.includes("cohere")) - return { settings: { modelParams: { thinking: { type: "enabled", token_budget: budget } } } } + budget: (model, tokens) => { + const id = modelID(model) + if (id.includes("anthropic")) + return sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }) + if (id.includes("gemini")) return sap({ thinkingConfig: { includeThoughts: true, thinkingBudget: tokens } }) + if (id.includes("cohere")) return sap({ thinking: { type: "enabled", token_budget: tokens } }) }, } +const sap = (modelParams: Record): Overlay => ({ settings: { modelParams } }) + +// ── Model checks ─────────────────────────────────────────────────────────────────────────────────────────── + +const modelID = (model: Model.Info) => model.modelID ?? model.id + // Claude before 4.6 has no adaptive thinking; effort is sent on its own. -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) +function claudeThinksManually(model: Model.Info) { + const id = modelID(model) + const familyFirst = /(?:claude-)?(?:opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?/i.exec(id) + const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(?:opus|sonnet|haiku)/i.exec(id) const major = Number(familyFirst?.[1] ?? versionFirst?.[1]) const rawMinor = Number(familyFirst?.[2] ?? versionFirst?.[2] ?? 0) if (!Number.isFinite(major)) return false @@ -292,16 +285,59 @@ function anthropicManualThinking(modelID: string) { return major < 4 || (major === 4 && minor < 6) } -const FAMILIES = [ - openaiResponses, - openaiChat, - anthropic, - gemini, - bedrockConverse, - openrouter, - vercelGateway, - githubCopilot, - sapAICore, - alibaba, - cohere, -] +// ── Packages ─────────────────────────────────────────────────────────────────────────────────────────────── +// One entry per catalog package. Share a format by pointing at it; specialise by spreading and overriding. + +const FORMATS: Readonly> = { + "@opencode/ai/providers/openai": openaiResponses, + "@opencode/ai/providers/azure/responses": openaiResponses, + "@opencode/ai/providers/amazon-bedrock/mantle/chat": openaiResponses, + "@opencode/ai/providers/amazon-bedrock/mantle/responses": openaiResponses, + "@opencode/ai/providers/meta/responses": openaiResponses, + + "@opencode/ai/providers/openai-compatible": openaiChat, + "@opencode/ai/providers/baseten": openaiChat, + "@opencode/ai/providers/cerebras": openaiChat, + "@opencode/ai/providers/cloudflare-workers-ai": openaiChat, + "@opencode/ai/providers/deepinfra": openaiChat, + "@opencode/ai/providers/deepseek": openaiChat, + "@opencode/ai/providers/fireworks": openaiChat, + "@opencode/ai/providers/groq": openaiChat, + "@opencode/ai/providers/mistral": openaiChat, + "@opencode/ai/providers/togetherai": openaiChat, + "@opencode/ai/providers/xai": openaiChat, + + "@opencode/ai/providers/anthropic": anthropicMessages, + "@opencode/ai/providers/google-vertex/messages": anthropicMessages, + "@opencode/ai/providers/minimax/messages": anthropicMessages, + + "@opencode/ai/providers/google": gemini, + "@opencode/ai/providers/google-vertex": gemini, + + "@opencode/ai/providers/amazon-bedrock": bedrockConverse, + "@opencode/ai/providers/openrouter": openrouter, + + [Provider.aisdk("@ai-sdk/openai")]: openaiResponses, + [Provider.aisdk("@ai-sdk/azure")]: openaiResponses, + [Provider.aisdk("@ai-sdk/amazon-bedrock/mantle")]: openaiResponses, + [Provider.aisdk("@ai-sdk/openai-compatible")]: openaiChat, + [Provider.aisdk("@ai-sdk/cerebras")]: openaiChat, + [Provider.aisdk("@ai-sdk/deepinfra")]: openaiChat, + [Provider.aisdk("@ai-sdk/groq")]: openaiChat, + [Provider.aisdk("@ai-sdk/mistral")]: openaiChat, + [Provider.aisdk("@ai-sdk/togetherai")]: openaiChat, + [Provider.aisdk("@ai-sdk/xai")]: openaiChat, + [Provider.aisdk("venice-ai-sdk-provider")]: openaiChat, + [Provider.aisdk("ai-gateway-provider")]: openaiChat, + [Provider.aisdk("@ai-sdk/anthropic")]: anthropicMessages, + [Provider.aisdk("@ai-sdk/google-vertex/anthropic")]: anthropicMessages, + [Provider.aisdk("@ai-sdk/google")]: gemini, + [Provider.aisdk("@ai-sdk/google-vertex")]: gemini, + [Provider.aisdk("@ai-sdk/amazon-bedrock")]: bedrockAISDK, + [Provider.aisdk("@openrouter/ai-sdk-provider")]: openrouter, + [Provider.aisdk("@ai-sdk/gateway")]: vercelGateway, + [Provider.aisdk("@ai-sdk/github-copilot")]: githubCopilot, + [Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")]: sapAICore, + [Provider.aisdk("@ai-sdk/alibaba")]: alibabaAISDK, + [Provider.aisdk("@ai-sdk/cohere")]: cohere, +} diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 1db646b4d74e..2cc5861a86e6 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -1119,15 +1119,15 @@ describe("ModelsDevPlugin", () => { expect(bedrock?.variants).toEqual([ { id: Model.VariantID.make("none"), - settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } }, + body: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } }, }, { id: Model.VariantID.make("low"), - settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, + body: { additionalModelRequestFields: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } } }, }, { id: Model.VariantID.make("high"), - settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, + body: { additionalModelRequestFields: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } } }, }, ]) From 84fcf5f34a30b9ac42b124a54e2fa9f025c32b71 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 13 Sep 2026 16:08:06 -0500 Subject: [PATCH 07/19] refactor(core): one variant protocol per package Each package maps to a protocol: a function that, given a model and one reasoning control it supports (effort, toggle or token budget), returns the variants for it in that protocol's vocabulary. Model checks such as Claude's manual-effort generations are local variables inside the protocol, so they steer both the spelling and the assumed values. Nothing declared means the model is assumed to support effort and the protocol chooses the values, so config models without a variants key get that protocol's defaults through the same code path models.dev records use. models.dev strips its null placeholders before calling in. The GitHub Copilot entry is removed: the Copilot plugin builds variants from Copilot's own model list. --- packages/core/src/config/plugin/provider.ts | 10 +- packages/core/src/models-dev.ts | 16 +- packages/core/src/variant.ts | 465 ++++++++++---------- 3 files changed, 250 insertions(+), 241 deletions(-) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index c54675e902ea..93e069700ecd 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -6,8 +6,9 @@ import { ConfigProvider } from "@opencode/schema/config/provider" import { Money } from "@opencode/schema/money" import { Effect } from "effect" import { Config } from "../../config.js" -import { Provider } from "../../provider.js" import { Model } from "../../model.js" +import { Provider } from "../../provider.js" +import { Variant } from "../../variant.js" import { ConfigEntryObserver } from "./entry-observer.js" export const Plugin = define({ @@ -155,6 +156,13 @@ export const Plugin = define({ } if (config.disabled !== undefined) model.enabled = !config.disabled if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } + if (config.variants === undefined && !base) + model.variants = [ + ...Variant.resolve({ + ...model, + package: model.package ?? catalog.provider.get(providerID)?.provider.package, + }), + ] }) } } diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 0cdba700d0ba..11f066aec3ae 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -35,7 +35,10 @@ type SourceModel = { readonly release_date: string readonly attachment: boolean readonly reasoning: boolean - readonly reasoning_options?: readonly Variant.Option[] + readonly reasoning_options?: readonly ( + | { readonly type: "effort"; readonly values: readonly (string | null)[] } + | Exclude + )[] readonly temperature?: boolean readonly tool_call: boolean readonly interleaved?: boolean | string | { readonly field: string } @@ -130,7 +133,7 @@ function normalize(input: Record): readonly Snapshot[] { const baseCost = cost(model.cost) const id = Model.ID.make(model.id) const base = modelInfo(item, id, model, { cost: baseCost }) - const variants = Variant.resolve({ ...base, package: nativePackage(item, model) }, model.reasoning_options ?? []) + const variants = Variant.resolve({ ...base, package: nativePackage(item, model) }, supports(model)) models.push({ ...base, variants }) for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { const modeID = Model.ID.make(`${model.id}-${mode}`) @@ -224,6 +227,15 @@ function modeName(model: SourceModel, mode: string) { return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` } +// models.dev writes `null` (sometimes the string "null") for "no reasoning" inside effort lists. +function supports(model: SourceModel): readonly Variant.Support[] { + return (model.reasoning_options ?? []).map((option) => + option.type === "effort" + ? { type: "effort", values: option.values.filter((value): value is string => value !== null && value !== "null") } + : option, + ) +} + function modelInfo( provider: SourceProvider, id: Model.ID, diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts index 276a87eca577..8e5a044af7d5 100644 --- a/packages/core/src/variant.ts +++ b/packages/core/src/variant.ts @@ -3,292 +3,282 @@ export * as Variant from "./variant.js" import { Model } from "./model.js" import { Provider } from "./provider.js" -export type Option = - | { readonly type: "effort"; readonly values: readonly (string | null)[] } +// A reasoning control a model is known to support. Effort without `values` means the protocol picks them. +export type Support = + | { readonly type: "effort"; readonly values?: readonly string[] } | { readonly type: "toggle" } | { readonly type: "budget_tokens"; readonly min?: number; readonly max?: number } -type Overlay = Omit -type Toggle = readonly [off: Overlay, on: Overlay] +type Variants = Model.Info["variants"] +type Overlay = Omit -// How one package spells the three reasoning controls. `undefined` means this package or model has no way to say it. -type Format = { - readonly effort?: (model: Model.Info, value: string) => Overlay | undefined - readonly toggle?: (model: Model.Info) => Toggle | undefined - readonly budget?: (model: Model.Info, tokens: number) => Overlay | undefined -} +// One per package: the variants a model gets for one supported control, in that protocol's vocabulary. +type Protocol = (model: Model.Info, support: Support) => Variants // `model.package` must be the effective package: a model-level override or the provider's. -export function resolve(model: Model.Info, options: readonly Option[]): Model.Info["variants"] { - const format = model.package === undefined ? undefined : FORMATS[model.package] - if (!format || options.length === 0) return [] - const toggle = options.some((option) => option.type === "toggle") ? toggleVariants(format, model) : [] - const off = toggle.filter((variant) => variant.id === "none") - const effort = options.find((option) => option.type === "effort") - if (effort?.type === "effort") { - const variants = [ - ...off, - ...effort.values.flatMap((value) => { - if (value === null || value === "null") return [] - if (value === "none" && off.length > 0) return [] - const overlay = format.effort?.(model, value) - return overlay ? [{ id: Model.VariantID.make(value), ...overlay }] : [] - }), - ] - 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 [...off, ...budgetVariants(format, model, budget)] - return toggle +// With nothing declared, a model is assumed to support effort and the protocol chooses the values. +export function resolve(model: Model.Info, supports: readonly Support[] = [{ type: "effort" }]): Variants { + const protocol = model.package === undefined ? undefined : PROTOCOLS[model.package] + if (!protocol) return [] + const toggle = supports.some((support) => support.type === "toggle") ? protocol(model, { type: "toggle" }) : [] + const effort = supports.find((support) => support.type === "effort") + const budget = supports.find((support) => support.type === "budget_tokens") + const main = effort ? protocol(model, effort) : budget ? protocol(model, budget) : toggle + const variants = [...toggle.filter((variant) => variant.id === "none"), ...main] + return variants.filter((variant, index) => variants.findIndex((other) => other.id === variant.id) === index) } -function toggleVariants(format: Format, model: Model.Info): Model.Info["variants"] { - const pair = format.toggle?.(model) - if (!pair) return [] - return [ - { id: Model.VariantID.make("none"), ...pair[0] }, - { id: Model.VariantID.make("thinking"), ...pair[1] }, - ] -} +// ── Building blocks ──────────────────────────────────────────────────────────────────────────────────────── +const EFFORTS = ["low", "medium", "high"] +const ENCRYPTED_REASONING = ["reasoning.encrypted_content"] +const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" } const OUTPUT_TOKEN_MAX = 32_000 -function budgetVariants( - format: Format, +const variant = (id: string, overlay: Overlay): Variants[number] => ({ id: Model.VariantID.make(id), ...overlay }) + +const efforts = (values: readonly string[], spell: (effort: string) => Overlay): Variants => + values.map((effort) => variant(effort, spell(effort))) + +const toggle = (off: Overlay, on: Overlay): Variants => [variant("none", off), variant("thinking", on)] + +function budgets( model: Model.Info, - option: Extract, -): Model.Info["variants"] { - const maximum = Math.min(option.max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1) + support: Extract, + spell: (tokens: number) => Overlay, +): Variants { + const maximum = Math.min(support.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 }, - { id: "max", budget: maximum }, - ].flatMap((item) => { - const overlay = format.budget?.(model, item.budget) - return overlay ? [{ id: Model.VariantID.make(item.id), ...overlay }] : [] - }) + const high = Math.min(Math.max(support.min ?? 0, Math.floor((maximum + 1) / 2)), maximum) + return [variant("high", spell(high)), variant("max", spell(maximum))] } -// ── Wire spellings ───────────────────────────────────────────────────────────────────────────────────────── -// One function per way a control appears on the wire. Packages below compose these. - -const pair = (spell: (on: boolean) => Overlay): Toggle => [spell(false), spell(true)] - -const ENCRYPTED_REASONING = ["reasoning.encrypted_content"] -const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" } +const modelID = (model: Model.Info) => model.modelID ?? model.id -// effort -const chatReasoningEffort = (value: string): Overlay => ({ settings: { reasoningEffort: value } }) -const responsesReasoningEffort = (value: string): Overlay => ({ - settings: { reasoningEffort: value, reasoningSummary: "auto", include: ENCRYPTED_REASONING }, -}) -const anthropicEffort = (model: Model.Info, value: string): Overlay => ({ - settings: claudeThinksManually(model) ? { effort: value } : { thinking: ADAPTIVE_THINKING, effort: value }, -}) -const geminiThinkingLevel = (value: string): Overlay => ({ - settings: { thinkingConfig: { includeThoughts: true, thinkingLevel: value } }, -}) -const openrouterReasoningEffort = (value: string): Overlay => ({ settings: { reasoning: { effort: value } } }) - -// toggle -const anthropicThinking = pair((on) => ({ settings: { thinking: on ? ADAPTIVE_THINKING : { type: "disabled" } } })) -const geminiThinking = pair((on) => ({ - settings: { - thinkingConfig: on ? { includeThoughts: true, thinkingBudget: -1 } : { includeThoughts: false, thinkingBudget: 0 }, - }, -})) -const openrouterReasoning = pair((on) => ({ settings: { reasoning: { enabled: on } } })) -const enableThinking = pair((on) => ({ settings: { enableThinking: on } })) -const cohereThinking = pair((on) => ({ settings: { thinking: { type: on ? "enabled" : "disabled" } } })) - -// budget -const anthropicBudget = (tokens: number): Overlay => ({ - settings: { thinking: { type: "enabled", budgetTokens: tokens } }, -}) -const geminiBudget = (tokens: number): Overlay => ({ - settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: tokens } }, -}) -const openrouterBudget = (tokens: number): Overlay => ({ settings: { reasoning: { max_tokens: tokens } } }) -const cohereBudget = (tokens: number): Overlay => ({ settings: { thinking: { type: "enabled", tokenBudget: tokens } } }) -const enableThinkingBudget = (tokens: number): Overlay => ({ - settings: { enableThinking: true, thinkingBudget: tokens }, -}) +// Claude before 4.6 has no adaptive thinking; effort is sent on its own. +function claudeThinksManually(model: Model.Info) { + const id = modelID(model) + const familyFirst = /(?:claude-)?(?:opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?/i.exec(id) + const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(?:opus|sonnet|haiku)/i.exec(id) + 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) +} -// ── Shared formats ───────────────────────────────────────────────────────────────────────────────────────── +// ── Protocols ────────────────────────────────────────────────────────────────────────────────────────────── -const openaiChat: Format = { - effort: (_, value) => chatReasoningEffort(value), +const openaiChat: Protocol = (_, support) => { + if (support.type !== "effort") return [] + return efforts(support.values ?? EFFORTS, (effort) => ({ settings: { reasoningEffort: effort } })) } -const openaiResponses: Format = { - effort: (_, value) => responsesReasoningEffort(value), +const openaiResponses: Protocol = (_, support) => { + if (support.type !== "effort") return [] + return efforts(support.values ?? ["none", "minimal", ...EFFORTS, "xhigh"], responsesEffort) } -const anthropicMessages: Format = { - effort: anthropicEffort, - toggle: () => anthropicThinking, - budget: (_, tokens) => anthropicBudget(tokens), +const responsesEffort = (effort: string): Overlay => ({ + settings: { reasoningEffort: effort, reasoningSummary: "auto", include: ENCRYPTED_REASONING }, +}) + +const anthropicMessages: Protocol = (model, support) => { + const manual = claudeThinksManually(model) + switch (support.type) { + case "effort": + return efforts(support.values ?? (manual ? EFFORTS : [...EFFORTS, "xhigh", "max"]), (effort) => ({ + settings: manual ? { effort } : { thinking: ADAPTIVE_THINKING, effort }, + })) + case "toggle": + return toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: ADAPTIVE_THINKING } }) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + settings: { thinking: { type: "enabled", budgetTokens: tokens } }, + })) + } } -const gemini: Format = { - effort: (_, value) => geminiThinkingLevel(value), - toggle: () => geminiThinking, - budget: (_, tokens) => geminiBudget(tokens), +const gemini: Protocol = (model, support) => { + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => ({ + settings: { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }, + })) + case "toggle": + return toggle( + { settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } }, + { settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } } }, + ) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: tokens } }, + })) + } } -const openrouter: Format = { - effort: (_, value) => openrouterReasoningEffort(value), - toggle: () => openrouterReasoning, - budget: (_, tokens) => openrouterBudget(tokens), +const openrouter: Protocol = (model, support) => { + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => ({ settings: { reasoning: { effort } } })) + case "toggle": + return toggle({ settings: { reasoning: { enabled: false } } }, { settings: { reasoning: { enabled: true } } }) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ settings: { reasoning: { max_tokens: tokens } } })) + } } // Bedrock Converse has no reasoning options; everything rides in `additionalModelRequestFields`. -const bedrockConverse: Format = { - effort: (model, value) => { - const id = modelID(model) - if (id.includes("anthropic")) - return bedrockFields({ - ...(claudeThinksManually(model) ? {} : { thinking: ADAPTIVE_THINKING }), - output_config: { effort: value }, +const bedrockConverse: Protocol = (model, support) => { + const id = modelID(model) + const claude = id.includes("anthropic") + const fields = (fields: Record): Overlay => ({ body: { additionalModelRequestFields: fields } }) + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => { + if (claude) + return fields({ + ...(claudeThinksManually(model) ? {} : { thinking: ADAPTIVE_THINKING }), + output_config: { effort }, + }) + if (id.includes("openai.gpt-oss")) return fields({ reasoning_effort: effort }) + if (id.includes("openai.")) return fields({ reasoning: { effort } }) + return fields({ reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }) }) - if (id.includes("openai.gpt-oss")) return bedrockFields({ reasoning_effort: value }) - if (id.includes("openai.")) return bedrockFields({ reasoning: { effort: value } }) - return bedrockFields({ reasoningConfig: { type: "enabled", maxReasoningEffort: value } }) - }, - toggle: (model) => - modelID(model).includes("anthropic") - ? pair((on) => bedrockFields({ thinking: on ? ADAPTIVE_THINKING : { type: "disabled" } })) - : pair((on) => bedrockFields({ reasoningConfig: { type: on ? "enabled" : "disabled" } })), - budget: (model, tokens) => - modelID(model).includes("anthropic") - ? bedrockFields({ thinking: { type: "enabled", budget_tokens: tokens } }) - : bedrockFields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }), + case "toggle": + return claude + ? toggle(fields({ thinking: { type: "disabled" } }), fields({ thinking: ADAPTIVE_THINKING })) + : toggle(fields({ reasoningConfig: { type: "disabled" } }), fields({ reasoningConfig: { type: "enabled" } })) + case "budget_tokens": + return budgets(model, support, (tokens) => + claude + ? fields({ thinking: { type: "enabled", budget_tokens: tokens } }) + : fields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }), + ) + } } -const bedrockFields = (fields: Record): Overlay => ({ body: { additionalModelRequestFields: fields } }) +// ── AI SDK-only protocols (no native package; translated by AISDKNative at resolve time) ────────────────── -// ── AI SDK-only formats (no native package; translated by AISDKNative at resolve time) ──────────────────── - -const githubCopilot: Format = { - effort: (model, value) => { - const id = modelID(model) - if (id.includes("gemini")) return - if (id.includes("claude")) return chatReasoningEffort(value) - return responsesReasoningEffort(value) - }, -} - -const alibabaAISDK: Format = { - toggle: () => enableThinking, - budget: (_, tokens) => enableThinkingBudget(tokens), +const alibabaAISDK: Protocol = (model, support) => { + switch (support.type) { + case "effort": + return [] + case "toggle": + return toggle({ settings: { enableThinking: false } }, { settings: { enableThinking: true } }) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ settings: { enableThinking: true, thinkingBudget: tokens } })) + } } -const cohere: Format = { - toggle: () => cohereThinking, - budget: (_, tokens) => cohereBudget(tokens), +const cohere: Protocol = (model, support) => { + switch (support.type) { + case "effort": + return [] + case "toggle": + return toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "enabled" } } }) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ settings: { thinking: { type: "enabled", tokenBudget: tokens } } })) + } } // Bedrock Converse via the AI SDK spells everything as `reasoningConfig` settings; AISDKNative turns it into body. -const bedrockAISDK: Format = { - effort: (model, value) => ({ - settings: modelID(model).includes("anthropic") - ? { reasoningConfig: { ...(claudeThinksManually(model) ? {} : ADAPTIVE_THINKING), maxReasoningEffort: value } } - : { reasoningConfig: { type: "enabled", maxReasoningEffort: value } }, - }), - toggle: (model) => - modelID(model).includes("anthropic") - ? pair((on) => ({ - settings: { additionalModelRequestFields: { thinking: on ? ADAPTIVE_THINKING : { type: "disabled" } } }, - })) - : pair((on) => ({ - settings: { additionalModelRequestFields: { reasoningConfig: { type: on ? "enabled" : "disabled" } } }, - })), - budget: (_, tokens) => ({ settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } } }), +const bedrockAISDK: Protocol = (model, support) => { + const claude = modelID(model).includes("anthropic") + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => ({ + settings: claude + ? { + reasoningConfig: { + ...(claudeThinksManually(model) ? {} : ADAPTIVE_THINKING), + maxReasoningEffort: effort, + }, + } + : { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }, + })) + case "toggle": + return claude + ? toggle( + { settings: { additionalModelRequestFields: { thinking: { type: "disabled" } } } }, + { settings: { additionalModelRequestFields: { thinking: ADAPTIVE_THINKING } } }, + ) + : toggle( + { settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } } }, + { settings: { additionalModelRequestFields: { reasoningConfig: { type: "enabled" } } } }, + ) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } }, + })) + } } // Vercel's gateway relays other labs' models and takes their spelling when the id names one. -const vercelGateway: Format = { - effort: (model, value) => (gatewayUpstream(model) ?? openaiChat).effort?.(model, value), - toggle: (model) => (gatewayUpstream(model) ?? openrouter).toggle?.(model), - budget: (model, tokens) => (gatewayUpstream(model) ?? openrouter).budget?.(model, tokens), -} - -function gatewayUpstream(model: Model.Info): Format | undefined { - const id = modelID(model) - const separator = id.indexOf("/") - if (separator <= 0) return - const prefix = id.slice(0, separator) - if (prefix === "anthropic") return anthropicMessages - if (prefix === "google") return gemini - if (prefix === "amazon") return bedrockAISDK - if (prefix === "alibaba") return alibabaAISDK +const vercelGateway: Protocol = (model, support) => { + const prefix = modelID(model).split("/")[0] + if (prefix === "anthropic") return anthropicMessages(model, support) + if (prefix === "google") return gemini(model, support) + if (prefix === "amazon") return bedrockAISDK(model, support) + if (prefix === "alibaba") return alibabaAISDK(model, support) + return support.type === "effort" ? openaiChat(model, support) : openrouter(model, support) } // SAP AI Core wraps each upstream's fields in `modelParams`. -const sapAICore: Format = { - effort: (model, value) => { - const id = modelID(model) - if (id.includes("anthropic")) - return sap({ - additionalModelRequestFields: { - ...(claudeThinksManually(model) ? {} : { thinking: ADAPTIVE_THINKING }), - output_config: { effort: value }, - }, - }) - if (id.includes("gemini")) return sap({ thinkingConfig: { includeThoughts: true, thinkingLevel: value } }) - if (id.includes("amazon--nova")) return sap({ additionalModelRequestFields: { output_config: { effort: value } } }) - return sap({ reasoning_effort: value }) - }, - toggle: (model) => { - const id = modelID(model) - if (id.includes("gemini")) - return pair((on) => - sap({ - thinkingConfig: on - ? { includeThoughts: true, thinkingBudget: -1 } - : { includeThoughts: false, thinkingBudget: 0 }, - }), - ) - if (id.includes("cohere")) return pair((on) => sap({ thinking: { type: on ? "enabled" : "disabled" } })) - if (id.includes("amazon--nova")) - return pair((on) => sap({ additionalModelRequestFields: { thinking: { type: on ? "enabled" : "disabled" } } })) - if (id.includes("anthropic")) - return pair((on) => - sap({ additionalModelRequestFields: { thinking: on ? ADAPTIVE_THINKING : { type: "disabled" } } }), - ) - }, - budget: (model, tokens) => { - const id = modelID(model) - if (id.includes("anthropic")) - return sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }) - if (id.includes("gemini")) return sap({ thinkingConfig: { includeThoughts: true, thinkingBudget: tokens } }) - if (id.includes("cohere")) return sap({ thinking: { type: "enabled", token_budget: tokens } }) - }, -} - -const sap = (modelParams: Record): Overlay => ({ settings: { modelParams } }) - -// ── Model checks ─────────────────────────────────────────────────────────────────────────────────────────── - -const modelID = (model: Model.Info) => model.modelID ?? model.id - -// Claude before 4.6 has no adaptive thinking; effort is sent on its own. -function claudeThinksManually(model: Model.Info) { +const sapAICore: Protocol = (model, support) => { const id = modelID(model) - const familyFirst = /(?:claude-)?(?:opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?/i.exec(id) - const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(?:opus|sonnet|haiku)/i.exec(id) - 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) + const sap = (modelParams: Record): Overlay => ({ settings: { modelParams } }) + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => { + if (id.includes("anthropic")) + return sap({ + additionalModelRequestFields: { + ...(claudeThinksManually(model) ? {} : { thinking: ADAPTIVE_THINKING }), + output_config: { effort }, + }, + }) + if (id.includes("gemini")) return sap({ thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }) + if (id.includes("amazon--nova")) return sap({ additionalModelRequestFields: { output_config: { effort } } }) + return sap({ reasoning_effort: effort }) + }) + case "toggle": + if (id.includes("gemini")) + return toggle( + sap({ thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } }), + sap({ thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } }), + ) + if (id.includes("cohere")) + return toggle(sap({ thinking: { type: "disabled" } }), sap({ thinking: { type: "enabled" } })) + if (id.includes("amazon--nova")) + return toggle( + sap({ additionalModelRequestFields: { thinking: { type: "disabled" } } }), + sap({ additionalModelRequestFields: { thinking: { type: "enabled" } } }), + ) + if (id.includes("anthropic")) + return toggle( + sap({ additionalModelRequestFields: { thinking: { type: "disabled" } } }), + sap({ additionalModelRequestFields: { thinking: ADAPTIVE_THINKING } }), + ) + return [] + case "budget_tokens": + if (id.includes("anthropic")) + return budgets(model, support, (tokens) => + sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }), + ) + if (id.includes("gemini")) + return budgets(model, support, (tokens) => + sap({ thinkingConfig: { includeThoughts: true, thinkingBudget: tokens } }), + ) + if (id.includes("cohere")) + return budgets(model, support, (tokens) => sap({ thinking: { type: "enabled", token_budget: tokens } })) + return [] + } } // ── Packages ─────────────────────────────────────────────────────────────────────────────────────────────── -// One entry per catalog package. Share a format by pointing at it; specialise by spreading and overriding. -const FORMATS: Readonly> = { +const PROTOCOLS: Readonly> = { "@opencode/ai/providers/openai": openaiResponses, "@opencode/ai/providers/azure/responses": openaiResponses, "@opencode/ai/providers/amazon-bedrock/mantle/chat": openaiResponses, @@ -336,7 +326,6 @@ const FORMATS: Readonly> = { [Provider.aisdk("@ai-sdk/amazon-bedrock")]: bedrockAISDK, [Provider.aisdk("@openrouter/ai-sdk-provider")]: openrouter, [Provider.aisdk("@ai-sdk/gateway")]: vercelGateway, - [Provider.aisdk("@ai-sdk/github-copilot")]: githubCopilot, [Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")]: sapAICore, [Provider.aisdk("@ai-sdk/alibaba")]: alibabaAISDK, [Provider.aisdk("@ai-sdk/cohere")]: cohere, From 0a8b5564f7745843ef43e3b7ff85465f46f1aa2c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 13 Sep 2026 22:19:22 -0500 Subject: [PATCH 08/19] fix(core): match native packages in provider plugins models.dev records have carried native packages since the catalog started registering them, so guards written against AI SDK npm names silently stopped matching. Match the native package instead. --- .../src/plugin/provider/amazon-bedrock.ts | 6 ++--- packages/core/src/plugin/provider/azure.ts | 14 ++++++++---- packages/core/src/plugin/provider/cerebras.ts | 4 +--- packages/core/src/plugin/provider/kilo.ts | 4 +--- .../core/src/plugin/provider/llmgateway.ts | 3 +-- packages/core/src/plugin/provider/nvidia.ts | 4 +--- .../core/src/plugin/provider/openrouter.ts | 7 ++---- packages/core/src/plugin/provider/zenmux.ts | 4 +--- .../plugin/provider-amazon-bedrock.test.ts | 10 ++++----- .../core/test/plugin/provider-azure.test.ts | 22 +++++++++---------- .../test/plugin/provider-cerebras.test.ts | 2 +- .../core/test/plugin/provider-kilo.test.ts | 6 ++--- .../test/plugin/provider-llmgateway.test.ts | 4 ++-- .../core/test/plugin/provider-nvidia.test.ts | 6 ++--- .../test/plugin/provider-openrouter.test.ts | 4 ++-- .../core/test/plugin/provider-zenmux.test.ts | 6 ++--- 16 files changed, 49 insertions(+), 57 deletions(-) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index d1f07e57982a..a8cb75e5c200 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -13,10 +13,8 @@ const CHAIN_ENV = [ "AWS_CONTAINER_CREDENTIALS_FULL_URI", ] -const isBedrock = (item: { readonly package: string }) => { - const name = Provider.packageName(item.package) - return name.startsWith("@ai-sdk/amazon-bedrock") || name.startsWith("@opencode/ai/providers/amazon-bedrock") -} +const isBedrock = (item: { readonly package: string }) => + item.package.startsWith("@opencode/ai/providers/amazon-bedrock") export const AmazonBedrockPlugin = define({ id: "opencode.provider.amazon.bedrock", diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 4a855cdfeaea..0e4f8343734c 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -134,7 +134,10 @@ export const AzurePlugin = define({ yield* load() yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure") + if ( + item.provider.id !== Provider.ID.azure && + !item.provider.package.startsWith("@opencode/ai/providers/azure/") + ) continue const resourceName = resolveResourceName(item.provider.settings, loaded.resource) if (resourceName) @@ -151,7 +154,10 @@ export const AzurePlugin = define({ }) yield* ctx.model.transform((models) => { for (const item of models.provider.list()) { - if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure") + if ( + item.provider.id !== Provider.ID.azure && + !item.provider.package.startsWith("@opencode/ai/providers/azure/") + ) continue const resourceName = resolveResourceName(item.provider.settings, loaded.resource) for (const model of models.list(item.provider.id)) { @@ -216,9 +222,9 @@ function expandResourceName(baseURL: string, resourceName: string) { } function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) { - if (Provider.packageName(model.package ?? provider.package) !== "@ai-sdk/azure") return false + if ((model.package ?? provider.package) !== "@opencode/ai/providers/azure/responses") return false const settings = Provider.mergeOverlay(provider.settings, model.settings) - if (settings?.useCompletionUrls === true || settings?.useDeploymentBasedUrls === true) return false + if (settings?.useDeploymentBasedUrls === true) return false if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false if (typeof settings?.baseURL !== "string") return true return /^https:\/\/[^/]+\.openai\.azure\.com(?:\/|$)/i.test(settings.baseURL) diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index a73a266686cf..492448f193fd 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,14 +1,12 @@ import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" -import { Provider } from "../../provider.js" export const CerebrasPlugin = define({ id: "opencode.provider.cerebras", effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - const name = Provider.packageName(item.provider.package) - if (name !== "@ai-sdk/cerebras" && name !== "@opencode/ai/providers/cerebras") continue + if (item.provider.package !== "@opencode/ai/providers/cerebras") continue evt.update(item.provider.id, (provider) => { provider.headers = { ...provider.headers, "X-Cerebras-3rd-Party-Integration": "opencode" } }) diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 7941b75ba160..f588b182a7a9 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,14 +1,12 @@ import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" -import { Provider } from "../../provider.js" export const KiloPlugin = define({ id: "opencode.provider.kilo", effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.package !== "@opencode/ai/providers/openai-compatible") continue if (item.provider.settings?.baseURL !== "https://api.kilo.ai/api/gateway") continue evt.update(item.provider.id, (provider) => { provider.headers = { ...provider.headers, "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" } diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index a019cb3a3dc4..652f7fe515dd 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -11,8 +11,7 @@ export const LLMGatewayPlugin = define({ yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { if (item.provider.activation === "disabled") continue - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.package !== "@opencode/ai/providers/openai-compatible") continue if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue if (!configured.has(Integration.ID.make(item.provider.id))) continue evt.update(item.provider.id, (provider) => { diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 94a8c7f082e2..883981a494fd 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,14 +1,12 @@ import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" -import { Provider } from "../../provider.js" export const NvidiaPlugin = define({ id: "opencode.provider.nvidia", effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.package !== "@opencode/ai/providers/openai-compatible") continue if (item.provider.settings?.baseURL !== "https://integrate.api.nvidia.com/v1") continue evt.update(item.provider.id, (provider) => { provider.headers = { diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index c62999f31625..91886973400b 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,6 +1,5 @@ import { Effect } from "effect" import { Model } from "../../model.js" -import { Provider } from "../../provider.js" import { define } from "@opencode/plugin/effect/plugin" export const OpenRouterPlugin = define({ @@ -8,8 +7,7 @@ export const OpenRouterPlugin = define({ effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@openrouter/ai-sdk-provider") continue + if (item.provider.package !== "@opencode/ai/providers/openrouter") continue evt.update(item.provider.id, (provider) => { provider.headers = { ...provider.headers, "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" } }) @@ -17,8 +15,7 @@ export const OpenRouterPlugin = define({ }) yield* ctx.model.transform((models) => { for (const item of models.provider.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@openrouter/ai-sdk-provider") continue + if (item.provider.package !== "@opencode/ai/providers/openrouter") continue for (const modelID of [Model.ID.make("gpt-5-chat-latest"), Model.ID.make("openai/gpt-5-chat")]) { if (!models.get(item.provider.id, modelID)) continue models.update(item.provider.id, modelID, (model) => { diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 140e5345707a..81a7103c95a7 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,14 +1,12 @@ import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" -import { Provider } from "../../provider.js" export const ZenmuxPlugin = define({ id: "opencode.provider.zenmux", effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.package !== "@opencode/ai/providers/openai-compatible") continue if (item.provider.settings?.baseURL !== "https://zenmux.ai/api/v1") continue evt.update(item.provider.id, (provider) => { provider.headers = { diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index b2079cdf922c..f3624e8a674d 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -56,7 +56,7 @@ const seedBedrock = Effect.fn(function* (settings?: Provider.Settings) { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.amazonBedrock, (item) => { - item.package = Provider.aisdk("@ai-sdk/amazon-bedrock") + item.package = "@opencode/ai/providers/amazon-bedrock" if (settings) item.settings = settings }) }) @@ -70,7 +70,7 @@ describe("AmazonBedrockPlugin", () => { const catalog = yield* seedBedrock({ endpoint: "https://bedrock.example" }) yield* addPlugin() const result = required(yield* catalog.get(Provider.ID.amazonBedrock)) - expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock")) + expect(result.package).toBe("@opencode/ai/providers/amazon-bedrock") expect(result.settings).toEqual({ baseURL: "https://bedrock.example", region: "us-east-1" }) }), ), @@ -151,7 +151,7 @@ describe("AmazonBedrockPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.amazonBedrock, (item) => { - item.package = Provider.aisdk("@ai-sdk/amazon-bedrock") + item.package = "@opencode/ai/providers/amazon-bedrock" item.activation = "disabled" }) }) @@ -205,13 +205,13 @@ describe("AmazonBedrockPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("mantle"), (item) => { - item.package = Provider.aisdk("@ai-sdk/amazon-bedrock/mantle") + item.package = "@opencode/ai/providers/amazon-bedrock/mantle/responses" }) catalog.update(Provider.ID.make("native"), (item) => { item.package = "@opencode/ai/providers/amazon-bedrock" }) catalog.update(Provider.ID.make("other"), (item) => { - item.package = Provider.aisdk("@ai-sdk/anthropic") + item.package = "@opencode/ai/providers/anthropic" }) }) yield* addPlugin() diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 4d2ecc462e8b..d9d549761145 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -244,7 +244,7 @@ describe("AzurePlugin", () => { const models = yield* Model.Service yield* catalog.transform((editor) => { editor.update(Provider.ID.azure, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/azure") + provider.package = "@opencode/ai/providers/azure/responses" }) editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {}) editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {}) @@ -311,7 +311,7 @@ describe("AzurePlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (item) => { - item.package = Provider.aisdk("@ai-sdk/azure") + item.package = "@opencode/ai/providers/azure/responses" }) }) yield* addPlugin() @@ -326,7 +326,7 @@ describe("AzurePlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (item) => { - item.package = Provider.aisdk("@ai-sdk/azure") + item.package = "@opencode/ai/providers/azure/responses" }) }) yield* addPlugin() @@ -342,14 +342,14 @@ describe("AzurePlugin", () => { const models = yield* Model.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.activation = "enabled" provider.settings = { baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai", } }) catalog.models.update(Provider.ID.azure, Model.ID.make("anthropic"), (model) => { - model.package = Provider.aisdk("@ai-sdk/anthropic") + model.package = "@opencode/ai/providers/anthropic" model.settings = { resourceName: "model-resource", baseURL: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/anthropic/v1", @@ -378,7 +378,7 @@ describe("AzurePlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (item) => { - item.package = Provider.aisdk("@ai-sdk/azure") + item.package = "@opencode/ai/providers/azure/responses" item.settings = { resourceName: "from-config" } }) catalog.update(Provider.ID.openai, () => {}) @@ -396,7 +396,7 @@ describe("AzurePlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (item) => { - item.package = Provider.aisdk("@ai-sdk/azure") + item.package = "@opencode/ai/providers/azure/responses" item.settings = { resourceName: "" } }) }) @@ -412,7 +412,7 @@ describe("AzurePlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.azure, (item) => { - item.package = Provider.aisdk("@ai-sdk/azure") + item.package = "@opencode/ai/providers/azure/responses" item.settings = { resourceName: " " } }) }) @@ -437,12 +437,12 @@ describe("AzurePlugin", () => { } yield* catalog.transform((editor) => { editor.update(Provider.ID.azure, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/azure") + provider.package = "@opencode/ai/providers/azure/responses" provider.activation = "enabled" }) editor.models.update(Provider.ID.azure, models.responses, () => {}) editor.models.update(Provider.ID.azure, models.chat, (model) => { - model.settings = { useCompletionUrls: true } + model.package = "@opencode/ai/providers/azure/chat" }) editor.models.update(Provider.ID.azure, models.preview, (model) => { model.settings = { apiVersion: "2025-04-01-preview" } @@ -454,7 +454,7 @@ describe("AzurePlugin", () => { model.settings = { baseURL: "https://gateway.example/azure" } }) editor.models.update(Provider.ID.azure, models.nonAzure, (model) => { - model.package = Provider.aisdk("@ai-sdk/anthropic") + model.package = "@opencode/ai/providers/anthropic" }) }) diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index 3afc1b5cfcc3..02d8979c0a15 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -21,7 +21,7 @@ describe("CerebrasPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("cerebras"), (item) => { - item.package = Provider.aisdk("@ai-sdk/cerebras") + item.package = "@opencode/ai/providers/cerebras" item.headers = { ...item.headers, Existing: "1" } }) }) diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 19a65285c2d0..b8298f64df85 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -26,7 +26,7 @@ describe("KiloPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("kilo"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" } provider.headers = { Existing: "value" } }) @@ -47,7 +47,7 @@ describe("KiloPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("kilo"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" } }) }) @@ -71,7 +71,7 @@ describe("KiloPlugin", () => { provider.package = Provider.aisdk("kilo") }) catalog.update(Provider.ID.make("custom-kilo"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" } }) }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index ce0bba271ac2..416d6d19e745 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -32,7 +32,7 @@ describe("LLMGatewayPlugin", () => { }) yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("llmgateway"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.llmgateway.io/v1" } provider.headers = { Existing: "value" } }) @@ -59,7 +59,7 @@ describe("LLMGatewayPlugin", () => { yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("llmgateway"), (provider) => { provider.activation = "disabled" - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.llmgateway.io/v1" } }) }) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 74a9d4c47d32..af8e6c5ea343 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -26,7 +26,7 @@ describe("NvidiaPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("nvidia"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" } provider.headers = { Existing: "value" } }) @@ -48,7 +48,7 @@ describe("NvidiaPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("nvidia"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" } }) }) @@ -67,7 +67,7 @@ describe("NvidiaPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("nvidia"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" } provider.headers = { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" } }) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index de5a4fc40c05..0e414506c222 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -27,7 +27,7 @@ describe("OpenRouterPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.openrouter, (provider) => { - provider.package = Provider.aisdk("@openrouter/ai-sdk-provider") + provider.package = "@opencode/ai/providers/openrouter" provider.headers = { Existing: "value" } }) catalog.update(Provider.ID.make("nvidia"), () => {}) @@ -49,7 +49,7 @@ describe("OpenRouterPlugin", () => { const models = yield* Model.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.openrouter, (provider) => { - provider.package = Provider.aisdk("@openrouter/ai-sdk-provider") + provider.package = "@opencode/ai/providers/openrouter" }) catalog.update(Provider.ID.openai, () => {}) catalog.models.update(Provider.ID.openrouter, Model.ID.make("openai/gpt-5-chat"), () => {}) diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 221fd030fad4..a13e8b507d2b 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -31,7 +31,7 @@ describe("ZenmuxPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("zenmux"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" } }) }) @@ -47,7 +47,7 @@ describe("ZenmuxPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("zenmux"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" } provider.headers = { ...provider.headers, Existing: "value" } }) @@ -67,7 +67,7 @@ describe("ZenmuxPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("zenmux"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" } provider.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) From b87611e3b64d7e3253b3f597dc4a5d88819d8148 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 13 Sep 2026 22:52:42 -0500 Subject: [PATCH 09/19] fix(core): write native packages for Copilot Anthropic models and test fixtures Copilot's Anthropic-protocol models were registered as aisdk:@ai-sdk/anthropic; the native Anthropic package replaces it. Test fixtures standing in for models.dev records now seed the native packages models.dev emits. --- packages/core/src/github-copilot/models.ts | 2 +- .../core/test/github-copilot/models.test.ts | 2 +- packages/core/test/location-layer.test.ts | 2 +- packages/core/test/plugin/models-dev.test.ts | 20 +++++++++---------- .../test/plugin/provider-digitalocean.test.ts | 4 ++-- .../test/plugin/provider-lmstudio.test.ts | 2 +- .../core/test/plugin/provider-ollama.test.ts | 2 +- .../core/test/plugin/provider-poe.test.ts | 2 +- .../plugin/provider-snowflake-cortex.test.ts | 4 ++-- .../core/test/plugin/provider-vllm.test.ts | 2 +- .../core/test/plugin/provider-xai.test.ts | 2 +- packages/core/test/session-error.test.ts | 2 +- 12 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/core/src/github-copilot/models.ts b/packages/core/src/github-copilot/models.ts index fa6efaaaf2a4..8af9fbf496f7 100644 --- a/packages/core/src/github-copilot/models.ts +++ b/packages/core/src/github-copilot/models.ts @@ -159,7 +159,7 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo providerID: Provider.ID.githubCopilot, family: previous?.family ?? Model.Family.make(remote.capabilities.family), name: previous?.name ?? remote.name, - package: Provider.aisdk(messages ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"), + package: messages ? "@opencode/ai/providers/anthropic" : Provider.aisdk("@ai-sdk/github-copilot"), settings: Provider.mergeOverlay(previous?.settings, { baseURL: messages ? `${baseURL}/v1` : baseURL, ...(endpoint ? { endpoint } : {}), diff --git a/packages/core/test/github-copilot/models.test.ts b/packages/core/test/github-copilot/models.test.ts index b08a4f6e194c..0080cf0d9335 100644 --- a/packages/core/test/github-copilot/models.test.ts +++ b/packages/core/test/github-copilot/models.test.ts @@ -105,7 +105,7 @@ test("defensively syncs advertised Copilot models", async () => { Model.VariantID.make("high"), ]) expect(model?.capabilities.input).toEqual(["text", "image", "pdf"]) - expect(models.get(Model.ID.make("claude-sonnet"))?.package).toBe(Provider.aisdk("@ai-sdk/anthropic")) + expect(models.get(Model.ID.make("claude-sonnet"))?.package).toBe("@opencode/ai/providers/anthropic") expect(models.get(Model.ID.make("claude-sonnet"))?.settings).toMatchObject({ baseURL: `${server.url.origin}/v1`, endpoint: "messages", diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index f036be057890..718385c2ac33 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -658,7 +658,7 @@ describe("LocationServiceMap", () => { const modelState = yield* Model.Service yield* providers.transform((editor) => { editor.update(Provider.ID.make("aliased"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai") + provider.package = "@opencode/ai/providers/openai" }) editor.models.update(Provider.ID.make("aliased"), Model.ID.make("fast"), (model) => { // Catalog id and package model id intentionally differ, like gpt-5.5-fast -> gpt-5.5. diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 2cc5861a86e6..65dbe0ad17e6 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -75,7 +75,7 @@ const richSnapshot = (name = "Acme") => { id: providerID, name, activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://api.acme.test/v1", thinking: { type: "adaptive", display: "summarized" } }, headers: { "x-acme": "provider" }, body: { service_tier: "default", tags: ["stable"] }, @@ -305,7 +305,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", }, environment: [], models: [ @@ -362,7 +362,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://api.acme.test/v1" }, headers: { "x-acme": "provider" }, }, @@ -441,7 +441,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://api.acme.test/v1" }, }, environment: [], @@ -493,7 +493,7 @@ describe("ModelsDevPlugin", () => { providerID, name: "GPT-5.4 Fast", family: Model.Family.make("gpt"), - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://api.acme.test/v1" }, headers: { "x-mode": "fast" }, body: { service_tier: "priority" }, @@ -556,7 +556,7 @@ describe("ModelsDevPlugin", () => { modelID: "gpt-5.4", providerID: "acme", name: "GPT-5.4 Fast", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://api.acme.test/v1" }, headers: { "x-mode": "fast" }, body: { service_tier: "priority" }, @@ -619,7 +619,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", }, environment: [], models: [ @@ -713,7 +713,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" }, }, environment: ["ACME_HOST", "ACME_MODEL_PATH", "ACME_API_KEY"], @@ -765,7 +765,7 @@ describe("ModelsDevPlugin", () => { id: providerID, name: "Acme", activation: "auto", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", }, environment: [], models: [ @@ -884,7 +884,7 @@ describe("ModelsDevPlugin", () => { id: Provider.ID.make("google-vertex"), name: "Google Vertex", activation: "auto", - package: Provider.aisdk("@ai-sdk/google-vertex"), + package: "@opencode/ai/providers/google-vertex", }, environment: ["GOOGLE_VERTEX_PROJECT", "GOOGLE_VERTEX_LOCATION", "GOOGLE_APPLICATION_CREDENTIALS"], models: [], diff --git a/packages/core/test/plugin/provider-digitalocean.test.ts b/packages/core/test/plugin/provider-digitalocean.test.ts index 332ea4c20452..c62517606996 100644 --- a/packages/core/test/plugin/provider-digitalocean.test.ts +++ b/packages/core/test/plugin/provider-digitalocean.test.ts @@ -38,7 +38,7 @@ const discovery = Effect.gen(function* () { const models = yield* Model.Service yield* providers.transform((draft) => { draft.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://inference.do-ai.run/v1" } }) draft.models.update(providerID, Model.ID.make("snapshot-model"), () => {}) @@ -109,7 +109,7 @@ describe("DigitalOceanPlugin", () => { expect(yield* fixture.models.get(providerID, Model.ID.make("router:alpha"))).toMatchObject({ name: "alpha", family: "digitalocean-inference-routers", - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://inference.do-ai.run/v1" }, capabilities: { tools: true, input: ["text"], output: ["text"] }, limit: { context: 128_000, output: 8_192 }, diff --git a/packages/core/test/plugin/provider-lmstudio.test.ts b/packages/core/test/plugin/provider-lmstudio.test.ts index c3047340d6c1..ec3754c00e43 100644 --- a/packages/core/test/plugin/provider-lmstudio.test.ts +++ b/packages/core/test/plugin/provider-lmstudio.test.ts @@ -304,7 +304,7 @@ describe("LMStudioPlugin", () => { yield* providers.transform((editor) => { editor.update(providerID, (provider) => { provider.name = "LMStudio" - provider.package = "aisdk:@ai-sdk/openai-compatible" + provider.package = "@opencode/ai/providers/openai-compatible" provider.integrationID = Integration.ID.make("lmstudio") }) editor.models.update(providerID, Model.ID.make("static-model"), () => {}) diff --git a/packages/core/test/plugin/provider-ollama.test.ts b/packages/core/test/plugin/provider-ollama.test.ts index 3513a9a6d91a..4c445c5b51a6 100644 --- a/packages/core/test/plugin/provider-ollama.test.ts +++ b/packages/core/test/plugin/provider-ollama.test.ts @@ -201,7 +201,7 @@ describe("OllamaPlugin", () => { yield* providers.transform((editor) => { editor.update(providerID, (provider) => { provider.name = "Ollama" - provider.package = "aisdk:@ai-sdk/openai-compatible" + provider.package = "@opencode/ai/providers/openai-compatible" provider.integrationID = Integration.ID.make("ollama") }) editor.models.update(providerID, Model.ID.make("static-model"), () => {}) diff --git a/packages/core/test/plugin/provider-poe.test.ts b/packages/core/test/plugin/provider-poe.test.ts index 707e9d348b0c..f7aaebc97ccd 100644 --- a/packages/core/test/plugin/provider-poe.test.ts +++ b/packages/core/test/plugin/provider-poe.test.ts @@ -42,7 +42,7 @@ const fixture = Effect.gen(function* () { }) yield* providers.transform((editor) => { editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.poe.com/v1" } }) editor.models.update(providerID, modelID, () => {}) diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index 44861ed034d3..5651dcac311b 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -56,7 +56,7 @@ const fixture = Effect.fn(function* () { const host = yield* PluginHost.make(plugin) yield* providers.transform((editor) => { editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://${SNOWFLAKE_ACCOUNT}.snowflakecomputing.com/api/v2/cortex/v1" } }) editor.models.update(providerID, modelID, () => {}) @@ -226,7 +226,7 @@ it.live("uses environment tokens rather than the account identifier and bypasses name: "SNOWFLAKE_CORTEX_TOKEN", }) expect((yield* test.providers.get(providerID))?.package).toBe( - Provider.aisdk("@ai-sdk/openai-compatible"), + "@opencode/ai/providers/openai-compatible", ) expect(yield* test.hooks.has("aisdk", "sdk", providerID)).toBe(false) diff --git a/packages/core/test/plugin/provider-vllm.test.ts b/packages/core/test/plugin/provider-vllm.test.ts index efc36e1cc1f2..744f1ca2e478 100644 --- a/packages/core/test/plugin/provider-vllm.test.ts +++ b/packages/core/test/plugin/provider-vllm.test.ts @@ -185,7 +185,7 @@ describe("VLLMPlugin", () => { yield* providers.transform((editor) => { editor.update(providerID, (provider) => { provider.name = "vLLM" - provider.package = "aisdk:@ai-sdk/openai-compatible" + provider.package = "@opencode/ai/providers/openai-compatible" provider.integrationID = Integration.ID.make("vllm") provider.activation = "auto" }) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index cebf008270c2..f269c33561db 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -75,7 +75,7 @@ describe("XAIPlugin", () => { const providerID = Provider.ID.make("xai") yield* providers.transform((editor) => { editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/xai") + provider.package = "@opencode/ai/providers/xai" provider.activation = "enabled" }) editor.models.update(providerID, Model.ID.make("grok-4.6"), () => {}) diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 858e8f485994..2c280ba6d04a 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -144,7 +144,7 @@ describe("toSessionError", () => { const configuration = new ModelResolver.ModelConfigurationError({ providerID: Provider.ID.make("azure"), modelID: ID.make("gpt-5.4-nano"), - package: "aisdk:@ai-sdk/azure", + package: "@opencode/ai/providers/azure/responses", detail: "Azure requires resourceName or baseURL", }) expect(toSessionError(configuration)).toEqual({ From fa97400bd111e15955bcbfd3303ff5933a72ff97 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 14 Sep 2026 00:05:55 -0500 Subject: [PATCH 10/19] fix(core): native packages in Cloudflare, Vertex and Modal providers Cloudflare Workers AI and Google Vertex drop their AI SDK hooks; the packages they matched no longer reach the catalog, and the resolver already expands endpoint variables on every route. Vertex expands its endpoint templates on model settings too, where MaaS models carry them. Modal registers the native openai-compatible package directly. --- packages/core/src/modal/models.ts | 4 +- .../plugin/provider/cloudflare-workers-ai.ts | 2 +- .../core/src/plugin/provider/google-vertex.ts | 51 ++--- packages/core/test/modal/models.test.ts | 2 +- .../provider-cloudflare-workers-ai.test.ts | 213 +++--------------- .../plugin/provider-google-vertex.test.ts | 113 ++++------ 6 files changed, 103 insertions(+), 282 deletions(-) diff --git a/packages/core/src/modal/models.ts b/packages/core/src/modal/models.ts index 81cfca15511c..2832117492a6 100644 --- a/packages/core/src/modal/models.ts +++ b/packages/core/src/modal/models.ts @@ -107,8 +107,8 @@ function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Mo remote.interleaved === undefined ? previous?.compatibility : (Model.compatibility(remote.interleaved) ?? previous?.compatibility), - package: Provider.aisdk("@ai-sdk/openai-compatible"), - settings: Provider.mergeOverlay(previous?.settings, { baseURL }), + package: "@opencode/ai/providers/openai-compatible", + settings: Provider.mergeOverlay(previous?.settings, { baseURL, provider: providerID }), headers: previous?.headers, body: previous?.body, capabilities: { diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index e23bbfeed338..5f50f4d13121 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -39,7 +39,7 @@ export const CloudflareWorkersAIPlugin = define({ const item = evt.get(providerID) if (!item) return evt.update(item.provider.id, (provider) => { - if (!Provider.isAISDK(provider.package)) return + if (provider.package === "@opencode/ai/providers/cloudflare-workers-ai") return if (typeof provider.settings?.baseURL === "string") return const accountId = resolveAccountId(provider.settings ?? {}) if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) } diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 2b02cbe08da8..b31fd9afef5d 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -38,33 +38,16 @@ function replaceVertexVars(value: string, project: string | undefined, location: .replaceAll("${GOOGLE_VERTEX_ENDPOINT}", vertexEndpoint(location)) } -function authFetch(fetchWithRuntimeOptions?: unknown) { - // Native Vertex SDKs handle ADC internally. OpenAI-compatible Vertex endpoints - // do not, so inject a Google access token into their fetch path. - return async (input: Parameters[0], init?: RequestInit) => { - const { GoogleAuth } = await import("google-auth-library") - const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }) - const client = await auth.getClient() - const token = await client.getAccessToken() - const headers = new Headers(init?.headers) - headers.set("Authorization", `Bearer ${token.token}`) - return typeof fetchWithRuntimeOptions === "function" - ? fetchWithRuntimeOptions(input, { ...init, headers }) - : fetch(input, { ...init, headers }) - } -} - export const GoogleVertexPlugin = define({ id: "opencode.provider.google.vertex", effect: Effect.fn(function* (ctx) { yield* ctx.provider.transform((evt) => { for (const item of evt.list()) { - if (!Provider.isAISDK(item.provider.package)) continue if ( - Provider.packageName(item.provider.package) !== "@ai-sdk/google-vertex" && + !item.provider.package.startsWith("@opencode/ai/providers/google-vertex") && !( item.provider.id === Provider.ID.googleVertex && - Provider.packageName(item.provider.package)?.includes("@ai-sdk/openai-compatible") + item.provider.package === "@opencode/ai/providers/openai-compatible" ) ) continue @@ -85,15 +68,29 @@ export const GoogleVertexPlugin = define({ }) } }) - yield* ctx.aisdk.hook( - "sdk", - Effect.fn(function* (evt) { - if (evt.model.providerID === Provider.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { - evt.options.fetch = authFetch(evt.options.fetch) - return + yield* ctx.model.transform((models) => { + for (const item of models.provider.list()) { + if ( + !item.provider.package.startsWith("@opencode/ai/providers/google-vertex") && + !( + item.provider.id === Provider.ID.googleVertex && + item.provider.package === "@opencode/ai/providers/openai-compatible" + ) + ) + continue + const project = resolveProject(item.provider.settings ?? {}) + const location = String(resolveLocation(item.provider.settings ?? {})) + for (const model of models.list(item.provider.id)) { + if (typeof model.settings?.baseURL !== "string") continue + models.update(item.provider.id, model.id, (draft) => { + draft.settings = { + ...draft.settings, + baseURL: replaceVertexVars(String(draft.settings?.baseURL), project, location), + } + }) } - }), - ) + } + }) yield* ctx.aisdk.hook( "language", Effect.fn(function* (evt) { diff --git a/packages/core/test/modal/models.test.ts b/packages/core/test/modal/models.test.ts index fb12f0446cbe..07518f370c96 100644 --- a/packages/core/test/modal/models.test.ts +++ b/packages/core/test/modal/models.test.ts @@ -65,7 +65,7 @@ test("maps live Modal models onto catalog templates", async () => { expect(model?.family).toBe(Model.Family.make("catalog-family")) expect(model?.providerID).toBe(providerID) expect(model?.modelID).toBe(Model.ID.make("live-model")) - expect(model?.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible")) + expect(model?.package).toBe("@opencode/ai/providers/openai-compatible") expect(model?.settings).toMatchObject({ baseURL: `${server.url.origin}/v1` }) expect(model?.compatibility).toMatchObject({ reasoningField: "reasoning_content" }) expect(model?.capabilities).toMatchObject({ tools: true, input: ["text", "image"], output: ["text"] }) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 68bd651e3986..3c19baecb467 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,17 +1,15 @@ -import { AISDK } from "@opencode/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Model } from "@opencode/core/model" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { CloudflareWorkersAIPlugin } from "@opencode/core/plugin/provider/cloudflare-workers-ai" import { Provider } from "@opencode/core/provider" import { Integration } from "@opencode/core/integration" -import { fakeSelectorSdk } from "../fixture/selector" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" const it = testEffect(PluginTestLayer) +const providerID = Provider.ID.make("cloudflare-workers-ai") const addPlugin = Effect.fn(function* () { const plugin = yield* Plugin.Service @@ -45,24 +43,11 @@ function withEnv(vars: Record, effect: () = ) } -function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { - return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( - modelID, - ) -} - -type CloudflareConfig = { - url: (input: { path: string; modelId: string }) => string - headers: () => Record | Promise> -} - -function cloudflareURL(sdk: unknown, modelID = "@cf/model") { - return cloudflareLanguage(sdk, modelID).config.url({ path: "/chat/completions", modelId: modelID }) -} - -function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") { - return cloudflareLanguage(sdk, modelID).config.headers() -} +const seed = Effect.fn(function* (fn: (provider: Provider.MutableInfo) => void) { + const catalog = yield* Provider.Service + yield* catalog.transform((catalog) => catalog.update(providerID, fn)) + return catalog +}) describe("CloudflareWorkersAIPlugin", () => { it.effect("registers an account form when the environment does not provide one", () => @@ -87,199 +72,65 @@ describe("CloudflareWorkersAIPlugin", () => { ), ) - it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () => + it.effect("routes the OpenAI-compatible package to the native Workers AI package", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { - const aisdk = yield* AISDK.Service - const catalog = yield* Provider.Service - yield* catalog.transform((catalog) => - catalog.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { - provider.package = Provider.aisdk("test-provider") - }), - ) + const catalog = yield* seed((provider) => { + provider.package = "@opencode/ai/providers/cloudflare-workers-ai" + }) yield* addPlugin() const integrations = yield* Integration.Service expect((yield* integrations.get(Integration.ID.make("cloudflare-workers-ai")))?.methods).toContainEqual({ type: "key", label: "API key", }) - const provider = required(yield* catalog.get(Provider.ID.make("cloudflare-workers-ai"))) - const sdk = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), - modelID: Model.ID.make("@cf/model"), - package: provider.package, - settings: provider.settings, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, - }) - expect(provider).toMatchObject({ - package: "aisdk:test-provider", - settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" }, - }) - expect(sdk.sdk).toBeDefined() + expect(required(yield* catalog.get(providerID)).package).toBe( + "@opencode/ai/providers/cloudflare-workers-ai", + ) }), ), ) - it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () => + it.effect("leaves the native Workers AI package to derive its own endpoint", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => Effect.gen(function* () { - const catalog = yield* Provider.Service - yield* catalog.transform((catalog) => - catalog.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { - provider.package = Provider.aisdk("test-provider") - provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } - }), - ) - yield* addPlugin() - expect(required(yield* catalog.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({ - package: "aisdk:test-provider", - settings: { baseURL: "https://proxy.example/v1" }, + const catalog = yield* seed((provider) => { + provider.package = "@opencode/ai/providers/cloudflare-workers-ai" }) - }), - ), - ) - - it.effect("allows a configured baseURL without account ID", () => - withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - const catalog = yield* Provider.Service - yield* catalog.transform((catalog) => - catalog.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { - provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } - }), - ) yield* addPlugin() - const integrations = yield* Integration.Service - expect((yield* integrations.get(Integration.ID.make("cloudflare-workers-ai")))?.methods).toContainEqual({ - type: "key", - label: "API key", - }) - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), - modelID: Model.ID.make("@cf/model"), - package: "aisdk:@ai-sdk/openai-compatible", - settings: { baseURL: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, - }) - expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions") + expect(required(yield* catalog.get(providerID)).settings?.baseURL).toBeUndefined() }), ), ) - it.effect("uses env account ID over configured account ID", () => - withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => + it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => Effect.gen(function* () { - const catalog = yield* Provider.Service - yield* catalog.transform((catalog) => - catalog.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { - provider.package = Provider.aisdk("test-provider") - provider.settings = { ...provider.settings, accountId: "configured-acct" } - }), - ) + const catalog = yield* seed((provider) => { + provider.package = Provider.aisdk("test-provider") + provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } + }) yield* addPlugin() - expect(required(yield* catalog.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({ + expect(required(yield* catalog.get(providerID))).toMatchObject({ package: "aisdk:test-provider", - settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" }, + settings: { baseURL: "https://proxy.example/v1" }, }) }), ), ) - it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () => - withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => + it.effect("uses env account ID over configured account ID", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => Effect.gen(function* () { - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), - modelID: Model.ID.make("@cf/model"), - package: "aisdk:@ai-sdk/openai-compatible", - settings: { baseURL: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - apiKey: "auth-key", - baseURL: "https://proxy.example/v1", - headers: { custom: "header" }, - }, + const catalog = yield* seed((provider) => { + provider.package = Provider.aisdk("test-provider") + provider.settings = { ...provider.settings, accountId: "configured-acct" } }) - const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk))) - expect(headers.authorization).toBe("Bearer env-key") - expect(headers.custom).toBe("header") - expect(headers["user-agent"]).toMatch(/^opencode\/.* cloudflare-workers-ai \(.+\) ai-sdk\/openai-compatible\//) - }), - ), - ) - - it.effect("expands account ID vars in endpoint URLs", () => - withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), - modelID: Model.ID.make("@cf/model"), - package: "aisdk:@ai-sdk/openai-compatible", - settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", - }, - }) - expect(cloudflareURL(result.sdk)).toBe( - "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions", - ) - }), - ), - ) - - it.effect("selects languageModel with the API model ID", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin() - const result = yield* aisdk.runLanguage({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("alias")), - modelID: Model.ID.make("@cf/api-model"), + expect(required(yield* catalog.get(providerID))).toMatchObject({ package: "aisdk:test-provider", - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }) - expect(result.language).toBeDefined() - expect(calls).toEqual(["languageModel:@cf/api-model"]) - }), - ) - - it.effect("does not create an SDK for non OpenAI-compatible packages", () => - withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), - modelID: Model.ID.make("@cf/model"), - package: "aisdk:@ai-sdk/anthropic", - settings: { baseURL: "https://proxy.example/v1" }, - }), - package: "@ai-sdk/anthropic", - options: { name: "cloudflare-workers-ai" }, + settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" }, }) - expect(result.sdk).toBeUndefined() }), ), ) diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index ece0b05bfda0..8035f442f912 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -1,5 +1,5 @@ import { AISDK } from "@opencode/core/aisdk" -import { describe, expect, mock } from "bun:test" +import { describe, expect } from "bun:test" import { Effect } from "effect" import { Model } from "@opencode/core/model" import { Plugin } from "@opencode/core/plugin" @@ -10,7 +10,6 @@ import { fakeSelectorSdk } from "../fixture/selector" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" -const googleAuthOptions: Record[] = [] const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { @@ -46,29 +45,13 @@ function withEnv(vars: Record, effect: () = ) } -void mock.module("google-auth-library", () => ({ - GoogleAuth: class { - constructor(options: Record) { - googleAuthOptions.push(options) - } - - async getClient() { - return { - async getAccessToken() { - return { token: "vertex-token" } - }, - } - } - }, -})) - describe("GoogleVertexPlugin", () => { it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () => Effect.gen(function* () { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.opencode, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { ...provider.settings, baseURL: "https://opencode.ai/zen/v1" } }), ) @@ -94,7 +77,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/google-vertex/chat" provider.settings = { ...provider.settings, baseURL: @@ -107,7 +90,7 @@ describe("GoogleVertexPlugin", () => { expect(provider.settings?.project).toBe("google-cloud-project") expect(provider.settings?.location).toBe("google-vertex-location") expect(provider).toMatchObject({ - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode/ai/providers/google-vertex/chat", settings: { baseURL: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location", @@ -130,7 +113,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/google-vertex") + provider.package = "@opencode/ai/providers/google-vertex" }), ) yield* addPlugin() @@ -153,7 +136,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/google-vertex") + provider.package = "@opencode/ai/providers/google-vertex" }), ) yield* addPlugin() @@ -179,7 +162,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/google-vertex/chat" provider.settings = { ...provider.settings, baseURL: @@ -192,7 +175,7 @@ describe("GoogleVertexPlugin", () => { expect(provider.settings?.project).toBe("vertex-project") expect(provider).toMatchObject({ - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode/ai/providers/google-vertex/chat", settings: { baseURL: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4", @@ -218,7 +201,7 @@ describe("GoogleVertexPlugin", () => { const models = yield* Model.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/google-vertex/chat" provider.settings = { ...provider.settings, baseURL: @@ -234,7 +217,7 @@ describe("GoogleVertexPlugin", () => { expect(provider.settings?.project).toBe("config-project") expect(provider.settings?.location).toBe("global") expect(provider).toMatchObject({ - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode/ai/providers/google-vertex/chat", settings: { baseURL: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global" }, }) expect(model.settings).toEqual(provider.settings) @@ -247,7 +230,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = "@opencode/ai/providers/google-vertex/chat" provider.settings = { ...provider.settings, baseURL: @@ -259,12 +242,42 @@ describe("GoogleVertexPlugin", () => { yield* addPlugin() const provider = required(yield* catalog.get(Provider.ID.make("google-vertex"))) expect(provider).toMatchObject({ - package: "aisdk:@ai-sdk/openai-compatible", + package: "@opencode/ai/providers/google-vertex/chat", settings: { baseURL: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu" }, }) }), ) + it.effect("expands endpoint templates on Vertex chat models", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const models = yield* Model.Service + const modelID = Model.ID.make("meta/llama-maas") + yield* catalog.transform((catalog) => { + catalog.update(Provider.ID.googleVertex, (provider) => { + provider.package = "@opencode/ai/providers/google-vertex" + provider.settings = { project: "config-project", location: "eu" } + }) + catalog.models.update(Provider.ID.googleVertex, modelID, (model) => { + model.package = "@opencode/ai/providers/google-vertex/chat" + model.settings = { + baseURL: + "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/endpoints/openapi", + } + }) + }) + yield* addPlugin() + expect(required(yield* models.get(Provider.ID.googleVertex, modelID))).toMatchObject({ + package: "@opencode/ai/providers/google-vertex/chat", + settings: { + project: "config-project", + location: "eu", + baseURL: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu/endpoints/openapi", + }, + }) + }), + ) + it.effect("defaults location to us-central1 when only project is configured", () => withEnv( { @@ -280,7 +293,7 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Provider.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.make("google-vertex"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/google-vertex") + provider.package = "@opencode/ai/providers/google-vertex" provider.settings = { ...provider.settings, project: "config-project" } }), ) @@ -292,46 +305,6 @@ describe("GoogleVertexPlugin", () => { ), ) - it.effect("wraps an injected transport with Google auth for OpenAI-compatible Vertex endpoints", () => - Effect.gen(function* () { - googleAuthOptions.length = 0 - const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - yield* aisdk.hook.sdk((evt) => - Effect.promise(async () => { - if (evt.model.providerID !== "google-vertex") return - if (evt.package !== "@ai-sdk/openai-compatible") return - expect(typeof evt.options.fetch).toBe("function") - await evt.options.fetch("https://vertex.example", { - headers: { "x-test": "1" }, - }) - }), - ) - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("gemini")), - modelID: Model.ID.make("gemini"), - package: "aisdk:@ai-sdk/openai-compatible", - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "google-vertex", - fetch: async (input: Parameters[0], init?: RequestInit) => { - fetchCalls.push({ input, init }) - return new Response("ok") - }, - }, - }) - const vertexCalls = fetchCalls.filter((call) => call.input === "https://vertex.example") - expect(vertexCalls).toHaveLength(1) - expect(googleAuthOptions).toEqual([{ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }]) - expect(new Headers(vertexCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token") - expect(new Headers(vertexCalls[0].init?.headers).get("x-test")).toBe("1") - }), - ) - it.effect("trims model IDs before selecting language models", () => Effect.gen(function* () { const plugin = yield* Plugin.Service From 9b0ee17de4e17d2f9f18b09bef14307cec5be8d1 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 14 Sep 2026 01:36:30 -0500 Subject: [PATCH 11/19] refactor(core): rewrite AI SDK packages to native on catalog write The catalog editor is the single place AI SDK packages become native. Every provider and model write runs AISDKNative.rewrite, which picks the native package and translates AI SDK settings into the native spelling based on the package the target will run under, so options written on a model that inherits its provider's package are translated too. The resolver no longer maps at request time, and the config plugin and V1 migration no longer carry their own rewrite calls. Host-specific packages are keyed by the models.dev provider ID, covering every regional and plan sibling for Alibaba, Moonshot, MiniMax, Z.AI and Meta, with the wire format following the source package. Bedrock body options and the Mantle chat/responses choice depend on the model, so they are only translated on model writes; provider-level values stay inert. Native Bedrock credentials pass through unchanged, and the legacy @opencode-ai/ai scope normalizes on write. The VariantPlugin GLM hack and the AI SDK twin protocol entries are removed since AI SDK packages with native twins never reach the variant table anymore. --- packages/core/src/aisdk-native.ts | 308 +++++++++++------- packages/core/src/model-resolver.ts | 24 +- packages/core/src/model.ts | 8 + packages/core/src/models-dev.ts | 38 +-- packages/core/src/plugin/internal.ts | 2 - packages/core/src/plugin/variant.ts | 41 --- packages/core/src/provider.ts | 44 ++- packages/core/src/variant.ts | 47 +-- packages/core/test/aisdk-native.test.ts | 15 +- packages/core/test/catalog.test.ts | 70 +++- packages/core/test/model-resolver.test.ts | 38 ++- .../core/test/plugin/provider-openai.test.ts | 14 +- .../test/plugin/provider-opencode.test.ts | 25 +- packages/core/test/plugin/variant.test.ts | 64 ---- 14 files changed, 389 insertions(+), 349 deletions(-) delete mode 100644 packages/core/src/plugin/variant.ts delete mode 100644 packages/core/test/plugin/variant.test.ts diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index 217d8fce280e..5608d6b27efe 100644 --- a/packages/core/src/aisdk-native.ts +++ b/packages/core/src/aisdk-native.ts @@ -3,122 +3,152 @@ export * as AISDKNative from "./aisdk-native.js" import { Effect, Option, Schema, Struct } from "effect" import { Provider } from "./provider.js" -export interface Mapping { - readonly package: string - readonly settings: Provider.Settings - readonly headers?: Readonly> - readonly body?: Readonly> +type Overlays = { + settings?: Provider.Settings + headers?: Record + body?: Record } -export interface MapInput { - readonly packageName: string | undefined - readonly settings: Provider.Settings - readonly modelID: string - readonly providerID: string +type Target = Overlays & { + package?: string + variants?: (Overlays & { id: ID })[] } -// A wrongly typed legacy value is dropped rather than failing the whole decode. -const lenient = (schema: S) => - Schema.optional(Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined))))) +type Context = { readonly providerID: string; readonly canonical?: string; readonly modelID?: string } -const Credentials = Schema.Struct({ - accessKeyId: Schema.String, - secretAccessKey: Schema.String, - sessionToken: lenient(Schema.String), - region: lenient(Schema.String), +export function rewrite( + target: Target, + input: Context & { readonly specifier: string | undefined }, +) { + if (input.specifier === undefined) return + const plain = resolve(input.specifier, input) + if (!plain) return + const settings = decode(target.settings ?? {}) + const replacement = resolve(input.specifier, { ...input, settings }) ?? plain + const translated = options(replacement, input.modelID, settings) + if (target.package !== undefined || replacement !== input.specifier) target.package = replacement + target.settings = + target.settings === undefined + ? undefined + : { + ...translated.settings, + ...(!NATIVE.has(input.specifier) && replacement === "@opencode/ai/providers/openai-compatible" + ? { provider: input.canonical ?? input.providerID } + : {}), + } + target.headers = Provider.mergeHeaders(translated.headers, target.headers) + target.body = Provider.mergeOverlay(translated.body, target.body) + target.variants = target.variants?.map((variant) => { + const overlay = options(replacement, input.modelID, decode(variant.settings ?? {})) + const headers = Provider.mergeHeaders(overlay.headers, variant.headers) + const body = Provider.mergeOverlay(overlay.body, variant.body) + return { + id: variant.id, + ...(variant.settings === undefined ? {} : { settings: overlay.settings }), + ...(headers === undefined ? {} : { headers }), + ...(body === undefined ? {} : { body }), + } + }) +} + +const PACKAGES: Readonly> = { + "@ai-sdk/amazon-bedrock": "@opencode/ai/providers/amazon-bedrock", + "@ai-sdk/anthropic": "@opencode/ai/providers/anthropic", + "@ai-sdk/azure": "@opencode/ai/providers/azure/responses", + "@ai-sdk/cerebras": "@opencode/ai/providers/cerebras", + "@ai-sdk/deepinfra": "@opencode/ai/providers/deepinfra", + "@ai-sdk/google": "@opencode/ai/providers/google", + "@ai-sdk/google-vertex": "@opencode/ai/providers/google-vertex", + "@ai-sdk/google-vertex/anthropic": "@opencode/ai/providers/google-vertex/messages", + "@ai-sdk/groq": "@opencode/ai/providers/groq", + "@ai-sdk/mistral": "@opencode/ai/providers/mistral", + "@ai-sdk/openai": "@opencode/ai/providers/openai", + "@ai-sdk/openai-compatible": "@opencode/ai/providers/openai-compatible", + "@ai-sdk/togetherai": "@opencode/ai/providers/togetherai", + "@ai-sdk/xai": "@opencode/ai/providers/xai", + "@openrouter/ai-sdk-provider": "@opencode/ai/providers/openrouter", +} + +const protocols = (name: string) => ({ + "@ai-sdk/openai-compatible": `@opencode/ai/providers/${name}/chat`, + "@ai-sdk/anthropic": `@opencode/ai/providers/${name}/messages`, + "@ai-sdk/openai": `@opencode/ai/providers/${name}/responses`, }) -/** AI SDK settings whose spelling differs from the native package. Everything else passes through. */ -const Legacy = Schema.StructWithRest( - Schema.Struct({ - apiKey: lenient(Schema.String), - baseURL: lenient(Schema.String), - headers: lenient(Schema.Record(Schema.String, Schema.String)), - extraBody: lenient(Schema.Record(Schema.String, Schema.Unknown)), - useCompletionUrls: lenient(Schema.Boolean), - // Bedrock - auth: lenient(Schema.Literals(["bearer", "sigv4"])), - bearerToken: lenient(Schema.String), - endpoint: lenient(Schema.String), - region: lenient(Schema.String), - credentials: lenient(Credentials), - accessKeyId: lenient(Schema.String), - secretAccessKey: lenient(Schema.String), - sessionToken: lenient(Schema.String), - anthropicBeta: lenient(Schema.Array(Schema.String)), - serviceTier: lenient(Schema.String), - reasoningConfig: lenient( - Schema.Struct({ - type: lenient(Schema.String), - display: lenient(Schema.String), - maxReasoningEffort: lenient(Schema.String), - budgetTokens: lenient(Schema.Number), - }), - ), - additionalModelRequestFields: lenient( - Schema.StructWithRest( - Schema.Struct({ - anthropic_beta: lenient(Schema.Array(Schema.String)), - output_config: lenient(Schema.Record(Schema.String, Schema.Unknown)), - reasoning: lenient(Schema.Record(Schema.String, Schema.Unknown)), - }), - [Schema.Record(Schema.String, Schema.Unknown)], - ), - ), - // OpenRouter - appName: lenient(Schema.String), - appUrl: lenient(Schema.String), - api_keys: lenient(Schema.Record(Schema.String, Schema.String)), - }), - [Schema.Record(Schema.String, Schema.Unknown)], -) -type Legacy = typeof Legacy.Type -const decode = Schema.decodeUnknownSync(Legacy) +const HOSTS: Readonly>>> = { + alibaba: protocols("alibaba"), + "alibaba-cn": protocols("alibaba"), + "alibaba-coding-plan": protocols("alibaba"), + "alibaba-coding-plan-cn": protocols("alibaba"), + "alibaba-token-plan": protocols("alibaba"), + "alibaba-token-plan-cn": protocols("alibaba"), + baseten: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/baseten" }, + "cloudflare-workers-ai": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/cloudflare-workers-ai" }, + deepseek: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/deepseek" }, + "fireworks-ai": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/fireworks" }, + "google-vertex": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/google-vertex/chat" }, + "kimi-for-coding": protocols("moonshot"), + meta: protocols("meta"), + minimax: protocols("minimax"), + "minimax-cn": protocols("minimax"), + "minimax-coding-plan": protocols("minimax"), + "minimax-cn-coding-plan": protocols("minimax"), + moonshotai: protocols("moonshot"), + "moonshotai-cn": protocols("moonshot"), + zai: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/zai/chat" }, + "zai-coding-plan": protocols("zai-coding-plan"), + zhipuai: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/zai/chat" }, + "zhipuai-coding-plan": protocols("zai-coding-plan"), +} -/** Maps a legacy AI SDK package onto the native package that replaces it. */ -export function map(input: MapInput): Mapping | undefined { - const settings = decode(input.settings) - const native = mapPackage(input.packageName, input.modelID, settings) - if (!native) return - const converse = native === "@opencode/ai/providers/amazon-bedrock" - const mapped = { - ...Struct.omit(settings, ["headers", "extraBody", "useCompletionUrls", ...OPENROUTER_KEYS]), - ...(native === "@opencode/ai/providers/openai-compatible" ? { provider: input.providerID } : {}), - } +const NATIVE = new Set([ + ...Object.values(PACKAGES), + ...Object.values(HOSTS).flatMap((host) => Object.values(host)), + "@opencode/ai/providers/azure/chat", + "@opencode/ai/providers/amazon-bedrock/mantle", + "@opencode/ai/providers/amazon-bedrock/mantle/chat", + "@opencode/ai/providers/amazon-bedrock/mantle/responses", +]) + +export function native(npm: string, context: Context & { readonly settings?: Provider.Settings }): string | undefined { + const host = HOSTS[context.providerID]?.[npm] + if (host) return host + if (npm === "@ai-sdk/amazon-bedrock/mantle") return mantle(context.modelID) + if (npm === "@ai-sdk/azure" && context.settings?.useCompletionUrls === true) + return "@opencode/ai/providers/azure/chat" + return PACKAGES[npm] +} + +const mantle = (modelID: string | undefined) => { + if (modelID === undefined) return "@opencode/ai/providers/amazon-bedrock/mantle" + return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}` +} + +function resolve(specifier: string, context: Context & { readonly settings?: Provider.Settings }): string | undefined { + const npm = Provider.packageName(specifier) + if (Provider.isAISDK(specifier) || npm in PACKAGES || npm in (HOSTS[context.providerID] ?? {})) + return native(npm, context) + if (npm === "@opencode/ai/providers/amazon-bedrock/mantle") return mantle(context.modelID) + if (npm === "@opencode/ai/providers/azure/responses" && context.settings?.useCompletionUrls === true) + return "@opencode/ai/providers/azure/chat" + return NATIVE.has(npm) ? npm : undefined +} + +type Overlay = { + readonly settings: Provider.Settings + readonly headers?: Readonly> + readonly body?: Readonly> +} + +function options(replacement: string, modelID: string | undefined, settings: Legacy): Overlay { + const converse = replacement === "@opencode/ai/providers/amazon-bedrock" && modelID !== undefined + const kept = Struct.omit(settings, ["headers", "extraBody", "useCompletionUrls", ...OPENROUTER_KEYS]) return { - package: native, - settings: native.startsWith("@opencode/ai/providers/amazon-bedrock") ? bedrockSettings(mapped, converse) : mapped, + settings: replacement.startsWith("@opencode/ai/providers/amazon-bedrock") ? bedrockSettings(kept, converse) : kept, ...(settings.headers === undefined ? {} : { headers: settings.headers }), ...(settings.extraBody === undefined ? {} : { body: settings.extraBody }), - ...(converse ? bedrockRequest(input.modelID, settings) : {}), - ...(native === "@opencode/ai/providers/openrouter" ? openRouterRequest(settings) : {}), - } -} - -function mapPackage(packageName: string | undefined, modelID: string, settings: Legacy) { - switch (packageName) { - case "@ai-sdk/anthropic": - case "@ai-sdk/cerebras": - case "@ai-sdk/deepinfra": - case "@ai-sdk/google": - case "@ai-sdk/google-vertex": - case "@ai-sdk/groq": - case "@ai-sdk/mistral": - case "@ai-sdk/openai": - case "@ai-sdk/togetherai": - case "@ai-sdk/xai": - case "@ai-sdk/amazon-bedrock": - return `@opencode/ai/providers/${packageName.slice("@ai-sdk/".length)}` - case "@ai-sdk/amazon-bedrock/mantle": - return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}` - case "@ai-sdk/azure": - return `@opencode/ai/providers/azure/${settings.useCompletionUrls === true ? "chat" : "responses"}` - case "@ai-sdk/google-vertex/anthropic": - return "@opencode/ai/providers/google-vertex/messages" - case "@ai-sdk/openai-compatible": - return settings.baseURL === undefined ? undefined : "@opencode/ai/providers/openai-compatible" - case "@openrouter/ai-sdk-provider": - return "@opencode/ai/providers/openrouter" + ...(converse ? bedrockRequest(modelID, settings) : {}), + ...(replacement === "@opencode/ai/providers/openrouter" ? openRouterRequest(settings) : {}), } } @@ -145,11 +175,11 @@ function bedrockSettings(settings: Legacy, converse: boolean) { ? {} : { baseURL: region === undefined ? baseURL : baseURL.replaceAll("${AWS_REGION}", region) }), ...(settings.apiKey === undefined && settings.bearerToken !== undefined ? { apiKey: settings.bearerToken } : {}), - ...(region === undefined || credentials.accessKeyId === undefined || credentials.secretAccessKey === undefined + ...(credentials.accessKeyId === undefined || credentials.secretAccessKey === undefined ? {} : { credentials: { - region, + ...(region === undefined ? {} : { region }), accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey, ...(credentials.sessionToken === undefined ? {} : { sessionToken: credentials.sessionToken }), @@ -158,13 +188,13 @@ function bedrockSettings(settings: Legacy, converse: boolean) { } } -function bedrockRequest(modelID: string, settings: Legacy): Pick { +function bedrockRequest(modelID: string | undefined, settings: Legacy): Pick { const additional = settings.additionalModelRequestFields ?? {} const reasoning = settings.reasoningConfig - const anthropic = modelID.includes("anthropic") - const openai = modelID.includes("openai.") + const anthropic = modelID?.includes("anthropic") ?? false + const openai = modelID?.includes("openai.") ?? false // gpt-oss (Harmony) takes the flat chat-completions `reasoning_effort`; GPT-5.6+ take Responses-style `reasoning.effort`. - const harmony = modelID.includes("openai.gpt-oss") + const harmony = modelID?.includes("openai.gpt-oss") ?? false const effort = reasoning?.maxReasoningEffort const type = reasoning?.type const budget = reasoning?.budgetTokens @@ -204,7 +234,7 @@ function bedrockRequest(modelID: string, settings: Legacy): Pick { +function openRouterRequest(settings: Legacy): Pick { const headers = Provider.mergeHeaders( { @@ -218,3 +248,57 @@ function openRouterRequest(settings: Legacy): Pick { ) ?? {} return Object.keys(headers).length === 0 ? {} : { headers } } + +const lenient = (schema: S) => + Schema.optional(Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined))))) + +const Credentials = Schema.Struct({ + accessKeyId: Schema.String, + secretAccessKey: Schema.String, + sessionToken: lenient(Schema.String), + region: lenient(Schema.String), +}) + +const Legacy = Schema.StructWithRest( + Schema.Struct({ + apiKey: lenient(Schema.String), + baseURL: lenient(Schema.String), + headers: lenient(Schema.Record(Schema.String, Schema.String)), + extraBody: lenient(Schema.Record(Schema.String, Schema.Unknown)), + useCompletionUrls: lenient(Schema.Boolean), + auth: lenient(Schema.Literals(["bearer", "sigv4"])), + bearerToken: lenient(Schema.String), + endpoint: lenient(Schema.String), + region: lenient(Schema.String), + credentials: lenient(Credentials), + accessKeyId: lenient(Schema.String), + secretAccessKey: lenient(Schema.String), + sessionToken: lenient(Schema.String), + anthropicBeta: lenient(Schema.Array(Schema.String)), + serviceTier: lenient(Schema.String), + reasoningConfig: lenient( + Schema.Struct({ + type: lenient(Schema.String), + display: lenient(Schema.String), + maxReasoningEffort: lenient(Schema.String), + budgetTokens: lenient(Schema.Number), + }), + ), + additionalModelRequestFields: lenient( + Schema.StructWithRest( + Schema.Struct({ + anthropic_beta: lenient(Schema.Array(Schema.String)), + output_config: lenient(Schema.Record(Schema.String, Schema.Unknown)), + reasoning: lenient(Schema.Record(Schema.String, Schema.Unknown)), + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), + appName: lenient(Schema.String), + appUrl: lenient(Schema.String), + api_keys: lenient(Schema.Record(Schema.String, Schema.String)), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +type Legacy = typeof Legacy.Type +const decode = Schema.decodeUnknownSync(Legacy) diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 205352b574cd..6d4b94e6ead0 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -5,7 +5,6 @@ import { LanguageModel, ProviderConfigurationError } from "@opencode/ai" import { Auth } from "@opencode/ai/route" import { Context, Effect, Layer, Schema, Struct } from "effect" import { AISDK } from "./aisdk.js" -import { AISDKNative } from "./aisdk-native.js" import { Credential } from "./credential.js" import { Integration } from "./integration.js" import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js" @@ -193,19 +192,9 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct dependencies?: Dependencies, ) { const resolved = prepareRuntimeModel(model, credential) - const packageName = Provider.packageName(resolved.package) const configuration = credential?.type === "key" ? credential.configuration : undefined const configured = { ...resolved.settings, ...credential?.metadata, ...configuration } - const mapping = Provider.isAISDK(resolved.package) - ? AISDKNative.map({ - packageName, - settings: configured, - modelID: resolved.modelID ?? resolved.id, - providerID: resolved.canonical ?? resolved.providerID, - }) - : undefined - const native = mapping?.package ?? packageName - if (Provider.isAISDK(resolved.package) && !mapping) { + if (Provider.isAISDK(resolved.package)) { const loadAISDK = dependencies?.loadAISDK if (!loadAISDK) return yield* unsupported(resolved) const settings = yield* prepareProviderSettings( @@ -220,10 +209,9 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct Effect.mapError((error) => initialization(resolved, "init", error.cause)), ) } - if (!native) return yield* unsupported(resolved) - - const specifier = native - const mapped = yield* prepareProviderSettings(resolved, Provider.nativeSettings(mapping?.settings ?? configured)) + const specifier = Provider.packageName(resolved.package) + if (!specifier) return yield* unsupported(resolved) + const mapped = yield* prepareProviderSettings(resolved, Provider.nativeSettings(configured)) const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe( Effect.mapError((error) => initialization(resolved, "load", error.cause)), ) @@ -231,8 +219,8 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct ...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped), ...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }), ...nativeCredentialSettings(specifier, credential), - headers: Provider.mergeHeaders(mapping?.headers, resolved.headers), - body: Provider.mergeOverlay(mapping?.body, resolved.body), + headers: resolved.headers, + body: resolved.body, } return yield* Effect.try({ try: () => { diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 8c21f3bdab9a..67b8d5054276 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -7,6 +7,7 @@ import { Bus } from "./bus.js" import { State } from "./state.js" import { Location } from "./location.js" import { freeze } from "immer" +import { AISDKNative } from "./aisdk-native.js" export const ID = Model.ID export type ID = typeof ID.Type @@ -111,6 +112,13 @@ const layer = Layer.effect( update(model) model.id = modelID model.providerID = providerID + const provider = input?.records.get(providerID)?.provider + AISDKNative.rewrite(model, { + specifier: model.package ?? provider?.package, + providerID, + canonical: model.canonical ?? provider?.canonical, + modelID: model.modelID ?? modelID, + }) models.set(modelID, model) }, remove: (providerID, modelID) => { diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 11f066aec3ae..2d2f8a4f9bb4 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -9,6 +9,7 @@ import { Bus } from "./bus.js" import { makeGlobalNode } from "@opencode/util/effect/app-node" import { httpClient } from "@opencode/util/effect/app-node-platform" import { Model } from "./model.js" +import { AISDKNative } from "./aisdk-native.js" import { Provider } from "./provider.js" import { Variant } from "./variant.js" import { KV } from "./kv.js" @@ -78,43 +79,9 @@ export type Snapshot = { readonly environment: readonly string[] } -const NATIVE_PACKAGES: Readonly> = { - "@ai-sdk/amazon-bedrock": "@opencode/ai/providers/amazon-bedrock", - "@ai-sdk/anthropic": "@opencode/ai/providers/anthropic", - "@ai-sdk/azure": "@opencode/ai/providers/azure/responses", - "@ai-sdk/cerebras": "@opencode/ai/providers/cerebras", - "@ai-sdk/deepinfra": "@opencode/ai/providers/deepinfra", - "@ai-sdk/google": "@opencode/ai/providers/google", - "@ai-sdk/google-vertex": "@opencode/ai/providers/google-vertex", - "@ai-sdk/google-vertex/anthropic": "@opencode/ai/providers/google-vertex/messages", - "@ai-sdk/groq": "@opencode/ai/providers/groq", - "@ai-sdk/mistral": "@opencode/ai/providers/mistral", - "@ai-sdk/openai": "@opencode/ai/providers/openai", - "@ai-sdk/openai-compatible": "@opencode/ai/providers/openai-compatible", - "@ai-sdk/togetherai": "@opencode/ai/providers/togetherai", - "@ai-sdk/xai": "@opencode/ai/providers/xai", - "@openrouter/ai-sdk-provider": "@opencode/ai/providers/openrouter", -} - -const NATIVE_PROVIDERS: Readonly> = { - baseten: "@opencode/ai/providers/baseten", - "cloudflare-workers-ai": "@opencode/ai/providers/cloudflare-workers-ai", - deepseek: "@opencode/ai/providers/deepseek", - "fireworks-ai": "@opencode/ai/providers/fireworks", - meta: "@opencode/ai/providers/meta/responses", - minimax: "@opencode/ai/providers/minimax/messages", - "minimax-cn": "@opencode/ai/providers/minimax/messages", - "minimax-coding-plan": "@opencode/ai/providers/minimax/messages", - "minimax-cn-coding-plan": "@opencode/ai/providers/minimax/messages", -} - function nativePackage(provider: SourceProvider, model?: SourceModel) { const npm = model?.provider?.npm ?? provider.npm - // Mantle only appears as a per-model override; gpt-oss models are chat-only there. - if (npm === "@ai-sdk/amazon-bedrock/mantle") - return `@opencode/ai/providers/amazon-bedrock/mantle/${model?.id.includes("gpt-oss") ? "chat" : "responses"}` - if (model?.provider?.npm === undefined && NATIVE_PROVIDERS[provider.id]) return NATIVE_PROVIDERS[provider.id] - return NATIVE_PACKAGES[npm] ?? Provider.aisdk(npm) + return AISDKNative.native(npm, { providerID: provider.id, modelID: model?.id }) ?? Provider.aisdk(npm) } function normalize(input: Record): readonly Snapshot[] { @@ -227,7 +194,6 @@ function modeName(model: SourceModel, mode: string) { return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` } -// models.dev writes `null` (sometimes the string "null") for "no reasoning" inside effort lists. function supports(model: SourceModel): readonly Variant.Support[] { return (model.reasoning_options ?? []).map((option) => option.type === "effort" diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index bb1bdb46880f..294e68a4d33e 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -91,7 +91,6 @@ import { WebSearchPlugins } from "./websearch/index.js" import { SkillPlugin } from "./skill.js" import { VcsHgPlugin } from "./vcs/hg.js" import { OptimizePlugin } from "./optimize.js" -import { VariantPlugin } from "./variant.js" import { VcsGitPlugin } from "./vcs/git.js" import { WarmingPlugin } from "./warming.js" import { WellKnownPlugin } from "../wellknown/plugin.js" @@ -245,7 +244,6 @@ const post = [ ConfigProviderPlugin.Plugin, ConfigWebSearchPlugin.Plugin, ConfigWorktreePlugin.Plugin, - VariantPlugin.Plugin, ConfigPolicyPlugin.Plugin, ] as const satisfies readonly InternalPlugin[] diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts deleted file mode 100644 index 794a87ed72d4..000000000000 --- a/packages/core/src/plugin/variant.ts +++ /dev/null @@ -1,41 +0,0 @@ -export * as VariantPlugin from "./variant.js" - -import { Effect } from "effect" -import { define } from "@opencode/plugin/effect/plugin" -import { Model } from "@opencode/schema/model" -import { Provider } from "../provider.js" - -export const Plugin = define({ - id: "opencode.variant", - effect: Effect.fn(function* (ctx) { - yield* ctx.model.transform((models) => { - for (const model of models.list()) { - const generated = generate(model, models.provider.get(model.providerID)?.provider) - if (generated.length === 0) continue - models.update(model.providerID, model.id, (draft) => { - const variants = draft.variants ?? [] - const explicit = new Map(variants.map((variant) => [variant.id, variant])) - const generatedIDs = new Set(generated.map((variant) => variant.id)) - draft.variants = [ - ...generated.map((variant) => explicit.get(variant.id) ?? variant), - ...variants.filter((variant) => !generatedIDs.has(variant.id)), - ] - }) - } - }) - }), -}) - -export function generate( - model: { readonly id: string; readonly modelID?: string; readonly package?: string }, - provider?: { readonly package: string }, -): NonNullable { - const packageName = model.package ?? provider?.package - if (!Provider.isAISDK(packageName) || Provider.packageName(packageName) !== "@ai-sdk/openai-compatible") return [] - const ids = `${model.id} ${model.modelID ?? ""}`.toLowerCase() - if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return [] - return ["high", "max"].map((id) => ({ - id: Model.VariantID.make(id), - settings: { reasoningEffort: id }, - })) -} diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 6e082d02ddb9..f1bfe668aa66 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -16,6 +16,7 @@ import { IntegrationConnection } from "./integration/connection.js" import { Credential } from "@opencode/schema/credential" import { Location } from "./location.js" import { freeze } from "immer" +import { AISDKNative } from "./aisdk-native.js" export const ID = Provider.ID export type ID = typeof ID.Type @@ -55,6 +56,9 @@ const builtins = new Map Promise>([ "@opencode/ai/providers/amazon-bedrock/mantle/responses", () => import("@opencode/ai/providers/amazon-bedrock/mantle/responses"), ], + ["@opencode/ai/providers/alibaba/chat", () => import("@opencode/ai/providers/alibaba/chat")], + ["@opencode/ai/providers/alibaba/messages", () => import("@opencode/ai/providers/alibaba/messages")], + ["@opencode/ai/providers/alibaba/responses", () => import("@opencode/ai/providers/alibaba/responses")], ["@opencode/ai/providers/anthropic", () => import("@opencode/ai/providers/anthropic")], ["@opencode/ai/providers/azure", () => import("@opencode/ai/providers/azure")], ["@opencode/ai/providers/azure/chat", () => import("@opencode/ai/providers/azure/chat")], @@ -73,9 +77,16 @@ const builtins = new Map Promise>([ ["@opencode/ai/providers/google-vertex/responses", () => import("@opencode/ai/providers/google-vertex/responses")], ["@opencode/ai/providers/google-vertex/messages", () => import("@opencode/ai/providers/google-vertex/messages")], ["@opencode/ai/providers/groq", () => import("@opencode/ai/providers/groq")], + ["@opencode/ai/providers/meta/chat", () => import("@opencode/ai/providers/meta/chat")], + ["@opencode/ai/providers/meta/messages", () => import("@opencode/ai/providers/meta/messages")], ["@opencode/ai/providers/meta/responses", () => import("@opencode/ai/providers/meta/responses")], + ["@opencode/ai/providers/minimax/chat", () => import("@opencode/ai/providers/minimax/chat")], ["@opencode/ai/providers/minimax/messages", () => import("@opencode/ai/providers/minimax/messages")], + ["@opencode/ai/providers/minimax/responses", () => import("@opencode/ai/providers/minimax/responses")], ["@opencode/ai/providers/mistral", () => import("@opencode/ai/providers/mistral")], + ["@opencode/ai/providers/moonshot/chat", () => import("@opencode/ai/providers/moonshot/chat")], + ["@opencode/ai/providers/moonshot/messages", () => import("@opencode/ai/providers/moonshot/messages")], + ["@opencode/ai/providers/moonshot/responses", () => import("@opencode/ai/providers/moonshot/responses")], ["@opencode/ai/providers/openai", () => import("@opencode/ai/providers/openai")], ["@opencode/ai/providers/openai/chat", () => import("@opencode/ai/providers/openai/chat")], ["@opencode/ai/providers/openai/responses", () => import("@opencode/ai/providers/openai/responses")], @@ -83,6 +94,13 @@ const builtins = new Map Promise>([ ["@opencode/ai/providers/openrouter", () => import("@opencode/ai/providers/openrouter")], ["@opencode/ai/providers/togetherai", () => import("@opencode/ai/providers/togetherai")], ["@opencode/ai/providers/xai", () => import("@opencode/ai/providers/xai")], + ["@opencode/ai/providers/zai/chat", () => import("@opencode/ai/providers/zai/chat")], + ["@opencode/ai/providers/zai-coding-plan/chat", () => import("@opencode/ai/providers/zai-coding-plan/chat")], + ["@opencode/ai/providers/zai-coding-plan/messages", () => import("@opencode/ai/providers/zai-coding-plan/messages")], + [ + "@opencode/ai/providers/zai-coding-plan/responses", + () => import("@opencode/ai/providers/zai-coding-plan/responses"), + ], ]) export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: string, npm?: Npm.Interface) { @@ -271,6 +289,11 @@ const layer = Layer.effect( const record = entry(id) update(record.provider) record.provider.id = id + AISDKNative.rewrite(record.provider, { + specifier: record.provider.package, + providerID: id, + canonical: record.provider.canonical, + }) }, remove: (id) => { records.delete(id) @@ -280,19 +303,26 @@ const layer = Layer.effect( entry(id).models = index(values) }, update: (providerID, modelID, update) => { + const record = entry(providerID) const target = writable(providerID) const current = target.get(modelID) const model = // An earlier add/set can publish and freeze an owned model within this fold. - ( - current && models.has(current) && !Object.isFrozen(current) - ? current - : current - ? structuredClone(current) - : Model.Info.default(providerID, modelID) - ) as DeepMutable + ( + current && models.has(current) && !Object.isFrozen(current) + ? current + : current + ? structuredClone(current) + : Model.Info.default(providerID, modelID) + ) as DeepMutable update(model) model.id = modelID model.providerID = providerID + AISDKNative.rewrite(model, { + specifier: model.package ?? record.provider.package, + providerID, + canonical: model.canonical ?? record.provider.canonical, + modelID: model.modelID ?? modelID, + }) models.add(model) target.set(modelID, model) }, diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts index 8e5a044af7d5..a908765fe1a4 100644 --- a/packages/core/src/variant.ts +++ b/packages/core/src/variant.ts @@ -3,7 +3,6 @@ export * as Variant from "./variant.js" import { Model } from "./model.js" import { Provider } from "./provider.js" -// A reasoning control a model is known to support. Effort without `values` means the protocol picks them. export type Support = | { readonly type: "effort"; readonly values?: readonly string[] } | { readonly type: "toggle" } @@ -12,11 +11,8 @@ export type Support = type Variants = Model.Info["variants"] type Overlay = Omit -// One per package: the variants a model gets for one supported control, in that protocol's vocabulary. type Protocol = (model: Model.Info, support: Support) => Variants -// `model.package` must be the effective package: a model-level override or the provider's. -// With nothing declared, a model is assumed to support effort and the protocol chooses the values. export function resolve(model: Model.Info, supports: readonly Support[] = [{ type: "effort" }]): Variants { const protocol = model.package === undefined ? undefined : PROTOCOLS[model.package] if (!protocol) return [] @@ -28,8 +24,6 @@ export function resolve(model: Model.Info, supports: readonly Support[] = [{ typ return variants.filter((variant, index) => variants.findIndex((other) => other.id === variant.id) === index) } -// ── Building blocks ──────────────────────────────────────────────────────────────────────────────────────── - const EFFORTS = ["low", "medium", "high"] const ENCRYPTED_REASONING = ["reasoning.encrypted_content"] const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" } @@ -67,8 +61,6 @@ function claudeThinksManually(model: Model.Info) { return major < 4 || (major === 4 && minor < 6) } -// ── Protocols ────────────────────────────────────────────────────────────────────────────────────────────── - const openaiChat: Protocol = (_, support) => { if (support.type !== "effort") return [] return efforts(support.values ?? EFFORTS, (effort) => ({ settings: { reasoningEffort: effort } })) @@ -128,7 +120,6 @@ const openrouter: Protocol = (model, support) => { } } -// Bedrock Converse has no reasoning options; everything rides in `additionalModelRequestFields`. const bedrockConverse: Protocol = (model, support) => { const id = modelID(model) const claude = id.includes("anthropic") @@ -158,8 +149,6 @@ const bedrockConverse: Protocol = (model, support) => { } } -// ── AI SDK-only protocols (no native package; translated by AISDKNative at resolve time) ────────────────── - const alibabaAISDK: Protocol = (model, support) => { switch (support.type) { case "effort": @@ -182,7 +171,6 @@ const cohere: Protocol = (model, support) => { } } -// Bedrock Converse via the AI SDK spells everything as `reasoningConfig` settings; AISDKNative turns it into body. const bedrockAISDK: Protocol = (model, support) => { const claude = modelID(model).includes("anthropic") switch (support.type) { @@ -214,7 +202,6 @@ const bedrockAISDK: Protocol = (model, support) => { } } -// Vercel's gateway relays other labs' models and takes their spelling when the id names one. const vercelGateway: Protocol = (model, support) => { const prefix = modelID(model).split("/")[0] if (prefix === "anthropic") return anthropicMessages(model, support) @@ -224,7 +211,6 @@ const vercelGateway: Protocol = (model, support) => { return support.type === "effort" ? openaiChat(model, support) : openrouter(model, support) } -// SAP AI Core wraps each upstream's fields in `modelParams`. const sapAICore: Protocol = (model, support) => { const id = modelID(model) const sap = (modelParams: Record): Overlay => ({ settings: { modelParams } }) @@ -276,16 +262,20 @@ const sapAICore: Protocol = (model, support) => { } } -// ── Packages ─────────────────────────────────────────────────────────────────────────────────────────────── - const PROTOCOLS: Readonly> = { "@opencode/ai/providers/openai": openaiResponses, "@opencode/ai/providers/azure/responses": openaiResponses, "@opencode/ai/providers/amazon-bedrock/mantle/chat": openaiResponses, "@opencode/ai/providers/amazon-bedrock/mantle/responses": openaiResponses, + "@opencode/ai/providers/alibaba/responses": openaiResponses, "@opencode/ai/providers/meta/responses": openaiResponses, + "@opencode/ai/providers/minimax/responses": openaiResponses, + "@opencode/ai/providers/moonshot/responses": openaiResponses, + "@opencode/ai/providers/zai-coding-plan/responses": openaiResponses, "@opencode/ai/providers/openai-compatible": openaiChat, + "@opencode/ai/providers/google-vertex/chat": openaiChat, + "@opencode/ai/providers/alibaba/chat": openaiChat, "@opencode/ai/providers/baseten": openaiChat, "@opencode/ai/providers/cerebras": openaiChat, "@opencode/ai/providers/cloudflare-workers-ai": openaiChat, @@ -293,13 +283,22 @@ const PROTOCOLS: Readonly> = { "@opencode/ai/providers/deepseek": openaiChat, "@opencode/ai/providers/fireworks": openaiChat, "@opencode/ai/providers/groq": openaiChat, + "@opencode/ai/providers/meta/chat": openaiChat, + "@opencode/ai/providers/minimax/chat": openaiChat, "@opencode/ai/providers/mistral": openaiChat, + "@opencode/ai/providers/moonshot/chat": openaiChat, "@opencode/ai/providers/togetherai": openaiChat, "@opencode/ai/providers/xai": openaiChat, + "@opencode/ai/providers/zai/chat": openaiChat, + "@opencode/ai/providers/zai-coding-plan/chat": openaiChat, "@opencode/ai/providers/anthropic": anthropicMessages, "@opencode/ai/providers/google-vertex/messages": anthropicMessages, + "@opencode/ai/providers/alibaba/messages": anthropicMessages, + "@opencode/ai/providers/meta/messages": anthropicMessages, "@opencode/ai/providers/minimax/messages": anthropicMessages, + "@opencode/ai/providers/moonshot/messages": anthropicMessages, + "@opencode/ai/providers/zai-coding-plan/messages": anthropicMessages, "@opencode/ai/providers/google": gemini, "@opencode/ai/providers/google-vertex": gemini, @@ -307,24 +306,8 @@ const PROTOCOLS: Readonly> = { "@opencode/ai/providers/amazon-bedrock": bedrockConverse, "@opencode/ai/providers/openrouter": openrouter, - [Provider.aisdk("@ai-sdk/openai")]: openaiResponses, - [Provider.aisdk("@ai-sdk/azure")]: openaiResponses, - [Provider.aisdk("@ai-sdk/amazon-bedrock/mantle")]: openaiResponses, - [Provider.aisdk("@ai-sdk/openai-compatible")]: openaiChat, - [Provider.aisdk("@ai-sdk/cerebras")]: openaiChat, - [Provider.aisdk("@ai-sdk/deepinfra")]: openaiChat, - [Provider.aisdk("@ai-sdk/groq")]: openaiChat, - [Provider.aisdk("@ai-sdk/mistral")]: openaiChat, - [Provider.aisdk("@ai-sdk/togetherai")]: openaiChat, - [Provider.aisdk("@ai-sdk/xai")]: openaiChat, [Provider.aisdk("venice-ai-sdk-provider")]: openaiChat, [Provider.aisdk("ai-gateway-provider")]: openaiChat, - [Provider.aisdk("@ai-sdk/anthropic")]: anthropicMessages, - [Provider.aisdk("@ai-sdk/google-vertex/anthropic")]: anthropicMessages, - [Provider.aisdk("@ai-sdk/google")]: gemini, - [Provider.aisdk("@ai-sdk/google-vertex")]: gemini, - [Provider.aisdk("@ai-sdk/amazon-bedrock")]: bedrockAISDK, - [Provider.aisdk("@openrouter/ai-sdk-provider")]: openrouter, [Provider.aisdk("@ai-sdk/gateway")]: vercelGateway, [Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")]: sapAICore, [Provider.aisdk("@ai-sdk/alibaba")]: alibabaAISDK, diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index 11c657a3396d..09e67a1ddaa4 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -1,8 +1,19 @@ import { describe, expect, test } from "bun:test" import { AISDKNative } from "@opencode/core/aisdk-native" -const map = (packageName: string, settings: Readonly>, modelID = "test-model") => - AISDKNative.map({ packageName, settings, modelID, providerID: "test-provider" }) +function map(packageName: string, settings: Readonly>, modelID = "test-model") { + const target: { + package?: string + settings?: Record + headers?: Record + body?: Record + } = { + package: `aisdk:${packageName}`, + settings: { ...settings }, + } + AISDKNative.rewrite(target, { specifier: target.package, providerID: "test-provider", modelID }) + return target.package?.startsWith("aisdk:") ? undefined : target +} describe("AISDKNative", () => { test("maps OpenAI-family packages and request options to native providers", () => { diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 2887d48c9813..61fc7e75ff7a 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -244,13 +244,13 @@ describe("Provider and Model", () => { const providerID = Provider.ID.make("test") yield* providers.transform((editor) => editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = Provider.aisdk("custom-sdk") provider.settings = { baseURL: "https://override.example.com" } }), ) expect(required(yield* providers.get(providerID))).toMatchObject({ - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: Provider.aisdk("custom-sdk"), settings: { baseURL: "https://override.example.com" }, }) }), @@ -264,21 +264,21 @@ describe("Provider and Model", () => { const modelID = Model.ID.make("model") yield* providers.transform((editor) => { editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = Provider.aisdk("custom-sdk") provider.settings = { baseURL: "https://provider.example.com" } }) }) yield* models.transform((editor) => { editor.update(providerID, modelID, (model) => { model.modelID = Model.ID.make("upstream-model") - model.package = Provider.aisdk("@ai-sdk/openai-compatible") + model.package = Provider.aisdk("custom-sdk") model.settings = { baseURL: "https://override.example.com" } }) }) expect(required(yield* models.get(providerID, modelID))).toMatchObject({ modelID: Model.ID.make("upstream-model"), - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: Provider.aisdk("custom-sdk"), settings: { baseURL: "https://override.example.com" }, }) }), @@ -292,19 +292,75 @@ describe("Provider and Model", () => { const modelID = Model.ID.make("model") yield* providers.transform((editor) => { editor.update(providerID, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.package = Provider.aisdk("custom-sdk") provider.settings = { baseURL: "https://provider.example.com" } }) editor.models.update(providerID, modelID, () => {}) }) expect(required(yield* models.get(providerID, modelID))).toMatchObject({ - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: Provider.aisdk("custom-sdk"), settings: { baseURL: "https://provider.example.com" }, }) }), ) + it.effect("rewrites AI SDK packages and their options on write", () => + Effect.gen(function* () { + const providers = yield* Provider.Service + const models = yield* Model.Service + const providerID = Provider.ID.make("bedrock") + const modelID = Model.ID.make("claude") + yield* providers.transform((editor) => { + editor.update(providerID, (provider) => { + provider.package = Provider.aisdk("@ai-sdk/amazon-bedrock") + provider.settings = { region: "us-east-1", extraBody: { tag: "provider" } } + }) + editor.models.update(providerID, modelID, () => {}) + editor.update(Provider.ID.make("gitlab"), (provider) => { + provider.package = Provider.aisdk("@ai-sdk/gitlab") + provider.settings = { extraBody: { kept: true } } + }) + }) + yield* models.transform((editor) => { + editor.update(providerID, modelID, (model) => { + model.modelID = Model.ID.make("anthropic.claude-opus-4-8") + model.package = Provider.aisdk("@ai-sdk/amazon-bedrock") + model.settings = { reasoningConfig: { type: "adaptive", maxReasoningEffort: "high" } } + model.variants = [ + { + id: Model.VariantID.make("max"), + settings: { reasoningConfig: { type: "adaptive", maxReasoningEffort: "max" } }, + }, + ] + }) + }) + + expect(required(yield* providers.get(providerID))).toMatchObject({ + package: "@opencode/ai/providers/amazon-bedrock", + settings: { region: "us-east-1" }, + body: { tag: "provider" }, + }) + const model = required(yield* models.get(providerID, modelID)) + expect(model.package).toBe("@opencode/ai/providers/amazon-bedrock") + expect(model.settings).not.toHaveProperty("reasoningConfig") + expect(model.body).toMatchObject({ + additionalModelRequestFields: { thinking: { type: "adaptive" }, output_config: { effort: "high" } }, + }) + expect(model.variants).toEqual([ + { + id: Model.VariantID.make("max"), + settings: {}, + body: { additionalModelRequestFields: { thinking: { type: "adaptive" }, output_config: { effort: "max" } } }, + }, + ]) + expect(required(yield* providers.get(Provider.ID.make("gitlab")))).toMatchObject({ + package: Provider.aisdk("@ai-sdk/gitlab"), + settings: { extraBody: { kept: true } }, + }) + }), + ) + it.effect("resolves provider and model overlay merges", () => Effect.gen(function* () { const providers = yield* Provider.Service diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index f24fb05e5988..8382576916cc 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -4,6 +4,7 @@ import { OpenAIChat } from "@opencode/ai/protocols" import { compileRequest } from "@opencode/ai/route/client" import { ConfigProvider, Effect, Layer } from "effect" import { Headers } from "effect/unstable/http" +import { AISDKNative } from "@opencode/core/aisdk-native" import { Credential } from "@opencode/core/credential" import { Integration } from "@opencode/core/integration" import { Compatibility, ID, Info, Model, VariantID } from "@opencode/core/model" @@ -25,8 +26,8 @@ interface ModelOptions { readonly limit?: Info["limit"] } -const model = (packageName: string | undefined, options: ModelOptions = {}) => - Info.make({ +function model(packageName: string | undefined, options: ModelOptions = {}) { + const info: Model.MutableInfo = { id: ID.make("test-model"), modelID: ID.make(options.modelID ?? "api-test-model"), providerID: options.providerID ?? Provider.ID.make("test-provider"), @@ -38,13 +39,21 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) => headers: options.headers ?? { "x-test": "header" }, body: options.body ?? { custom_extension: { enabled: true } }, capabilities: { tools: true, input: ["text"], output: ["text"] }, - variants: options.variants ?? [], + variants: structuredClone(options.variants ?? []) as Model.MutableInfo["variants"], time: { released: 0 }, cost: [], status: "active", enabled: true, limit: options.limit ?? { context: 100, output: 20 }, + } + AISDKNative.rewrite(info, { + specifier: packageName, + providerID: info.providerID, + canonical: info.canonical, + modelID: info.modelID, }) + return Info.make(info) +} function withEnv(variables: Record, effect: () => Effect.Effect) { return Effect.acquireUseRelease( @@ -131,9 +140,22 @@ describe("ModelResolver", () => { }), ) - it.effect("keeps explicitly selected compatible packages generic for known provider IDs", () => + it.effect("routes compatible packages to the host's dedicated package when one exists", () => Effect.gen(function* () { - for (const providerID of ["baseten", "cerebras", "deepinfra", "deepseek", "fireworks-ai", "groq", "togetherai"]) { + const routes = { + "alibaba-coding-plan-cn": "alibaba-chat", + baseten: "baseten-chat", + deepseek: "deepseek-chat", + "fireworks-ai": "fireworks-chat", + "moonshotai-cn": "moonshot-chat", + zhipuai: "zai-chat", + "zhipuai-coding-plan": "zai-coding-chat", + cerebras: "openai-compatible-chat", + deepinfra: "openai-compatible-chat", + groq: "openai-compatible-chat", + togetherai: "openai-compatible-chat", + } + for (const [providerID, route] of Object.entries(routes)) { const selected = yield* ModelResolver.fromCatalogModel( model(Provider.aisdk("@ai-sdk/openai-compatible"), { providerID: Provider.ID.make(providerID), @@ -141,7 +163,7 @@ describe("ModelResolver", () => { }), ) expect(String(selected.provider)).toBe(providerID) - expect(selected.route.id).toBe("openai-compatible-chat") + expect(selected.route.id).toBe(route) expect(selected.route.endpoint.baseURL).toBe("https://provider.example/v1/openai") const prepared = yield* compileRequest(LLM.request({ model: selected, prompt: "Hello" })) expect(prepared.body.messages).toEqual([{ role: "user", content: "Hello" }]) @@ -214,7 +236,7 @@ describe("ModelResolver", () => { id: "bedrock-mantle-responses", endpoint: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" }, }) - expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1") + expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.us-west-2.api.aws/openai/v1") }), ), ) @@ -1401,7 +1423,7 @@ describe("ModelResolver", () => { _tag: "SessionRunnerModel.ModelConfigurationError", providerID: "azure", modelID: "test-model", - package: "aisdk:@ai-sdk/azure", + package: "@opencode/ai/providers/azure/responses", detail: "Azure requires resourceName or baseURL", }) expect(failure.message).toBe("Cannot initialize azure/test-model: Azure requires resourceName or baseURL") diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 34b5b4a7cc13..843ebc712809 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -87,7 +87,7 @@ describe("OpenAIPlugin", () => { const credentials = yield* Credential.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.openai, (draft) => { - draft.package = Provider.aisdk("@ai-sdk/openai") + draft.package = "@opencode/ai/providers/openai" }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), (model) => { model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } @@ -143,7 +143,7 @@ describe("OpenAIPlugin", () => { const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us") const provider = required(yield* catalog.get(Provider.ID.openai)) - expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai")) + expect(provider.package).toBe("@opencode/ai/providers/openai") expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" }) expect(provider.headers).toMatchObject({ originator: "opencode", @@ -157,7 +157,7 @@ describe("OpenAIPlugin", () => { expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us") expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) const eligible = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) - expect(eligible.package).toBe(Provider.aisdk("@ai-sdk/openai")) + expect(eligible.package).toBe("@opencode/ai/providers/openai") expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" }) expect(eligible.cost).toEqual([]) expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) @@ -173,9 +173,7 @@ describe("OpenAIPlugin", () => { expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true) expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.10"))).enabled).toBe(true) expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(false) - expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe( - false, - ) + expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe(false) expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-4.99"))).enabled).toBe(false) }), ) @@ -187,7 +185,7 @@ describe("OpenAIPlugin", () => { const credentials = yield* Credential.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.openai, (draft) => { - draft.package = Provider.aisdk("@ai-sdk/openai") + draft.package = "@opencode/ai/providers/openai" }) catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), (model) => { model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } @@ -204,7 +202,7 @@ describe("OpenAIPlugin", () => { const provider = required(yield* catalog.get(Provider.ID.openai)) const model = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) - expect(model.package).toBe(Provider.aisdk("@ai-sdk/openai")) + expect(model.package).toBe("@opencode/ai/providers/openai") expect(model.enabled).toBe(true) expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) expect(model.capabilities.responsesWebsockets).toBe(true) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 53e6cb45fd0d..202207358926 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -432,11 +432,11 @@ describe("OpencodePlugin", () => { const integrations = yield* Integration.Service yield* providers.transform((editor) => { editor.update(Provider.ID.openai, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai") + provider.package = "@opencode/ai/providers/openai" provider.integrationID = Integration.ID.make("openai") }) editor.models.update(Provider.ID.openai, Model.ID.make("api-model"), (model) => { - model.package = Provider.aisdk("@ai-sdk/openai") + model.package = "@opencode/ai/providers/openai" model.settings = { baseURL: "https://upstream.example/v1" } model.variants = [ { @@ -468,7 +468,7 @@ describe("OpencodePlugin", () => { canonical: "openai", name: "Remote", integrationID: "opencode", - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: `${server.url.origin}/v1`, custom: "value" }, headers: { "x-org-id": "org" }, }) @@ -485,13 +485,18 @@ describe("OpencodePlugin", () => { capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }], limit: { context: 1000, output: 100 }, - package: Provider.aisdk("@ai-sdk/openai-compatible"), + package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 }, headers: { "x-org-id": "org" }, }) - expect(model.settings).toEqual({ baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 }) + expect(model.settings).toEqual({ + baseURL: `${server.url.origin}/v1`, + custom: "value", + temperature: 0.5, + provider: "openai", + }) const override = required(yield* models.get(Provider.ID.make("remote"), Model.ID.make("override"))) - expect(override.package).toBe(Provider.aisdk("@ai-sdk/anthropic")) + expect(override.package).toBe("@opencode/ai/providers/anthropic") expect(override.settings?.baseURL).toBe(`${server.url.origin}/anthropic`) expect(model.variants).toEqual([ { @@ -506,9 +511,7 @@ describe("OpencodePlugin", () => { headers: { "x-variant": "high" }, }, ]) - expect( - required(yield* models.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled, - ).toBe(false) + expect(required(yield* models.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled).toBe(false) expect(yield* models.get(Provider.ID.make("remote"), Model.ID.make("stale"))).toBeDefined() expect( (yield* providers.snapshot()).records.get(Provider.ID.openai)?.models.get(Model.ID.make("api-model")) @@ -1120,9 +1123,7 @@ describe("OpencodePlugin", () => { }) yield* addPlugin() expect(required(yield* catalog.get(Provider.ID.opencode)).settings?.apiKey).toBe("public") - expect(required(yield* models.get(Provider.ID.opencode, Model.ID.make("output-only"))).enabled).toBe( - true, - ) + expect(required(yield* models.get(Provider.ID.opencode, Model.ID.make("output-only"))).enabled).toBe(true) }), ), ) diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts deleted file mode 100644 index 59b3257c3bbe..000000000000 --- a/packages/core/test/plugin/variant.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect } from "bun:test" -import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" -import { LayerNode } from "@opencode/util/effect/layer-node" -import { Location } from "@opencode/core/location" -import { Model } from "@opencode/core/model" -import { VariantPlugin } from "@opencode/core/plugin/variant" -import { Provider } from "@opencode/core/provider" -import { AbsolutePath } from "@opencode/core/schema" -import { Effect, Layer } from "effect" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { modelHost, host } from "./host" - -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), -) -const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Provider.node, Model.node]), [Location.node.replace(locationLayer)]), -) - -describe("VariantPlugin", () => { - it.effect("adds GLM 5.2 variants after catalog sources", () => - Effect.gen(function* () { - const providers = yield* Provider.Service - const models = yield* Model.Service - yield* providers.transform((editor) => { - editor.update(Provider.ID.opencode, (provider) => { - provider.package = Provider.aisdk("@ai-sdk/openai-compatible") - }) - editor.models.update(Provider.ID.opencode, Model.ID.make("glm-5.2"), (model) => { - model.modelID = Model.ID.make("glm-5.2") - model.package = Provider.aisdk("@ai-sdk/openai-compatible") - }) - }) - yield* VariantPlugin.Plugin.effect(host({ model: modelHost(models) })) - - expect((yield* models.get(Provider.ID.opencode, Model.ID.make("glm-5.2")))?.variants).toEqual([ - expect.objectContaining({ id: "high", settings: { reasoningEffort: "high" } }), - expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }), - ]) - }), - ) - - it.effect("keeps explicit variants over generated defaults", () => - Effect.gen(function* () { - const providers = yield* Provider.Service - const models = yield* Model.Service - yield* providers.transform((editor) => { - editor.models.update(Provider.ID.opencode, Model.ID.make("glm-5.2"), (model) => { - model.modelID = Model.ID.make("glm-5.2") - model.package = Provider.aisdk("@ai-sdk/openai-compatible") - model.variants = [{ id: Model.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }] - }) - }) - yield* VariantPlugin.Plugin.effect(host({ model: modelHost(models) })) - - expect((yield* models.get(Provider.ID.opencode, Model.ID.make("glm-5.2")))?.variants).toEqual([ - expect.objectContaining({ id: "high", headers: { custom: "true" } }), - expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }), - ]) - }), - ) -}) From 0c1e30c5899f7219ed4197549d572bfc44f6b16e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 14 Sep 2026 02:04:25 -0500 Subject: [PATCH 12/19] fix(core): generate variants after package rewrite --- packages/core/src/config/plugin/provider.ts | 6 ++-- packages/core/test/config/provider.test.ts | 34 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 93e069700ecd..9f12e0f7270e 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -156,14 +156,16 @@ export const Plugin = define({ } if (config.disabled !== undefined) model.enabled = !config.disabled if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } - if (config.variants === undefined && !base) + }) + if (config.variants === undefined && !base) + catalog.model.update(providerID, id, (model) => { model.variants = [ ...Variant.resolve({ ...model, package: model.package ?? catalog.provider.get(providerID)?.provider.package, }), ] - }) + }) } } }) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index d3083d3a0846..cb79beeac579 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -374,6 +374,40 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ) + it.effect("generates variants after rewriting a configured model package", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* addPlugin([ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai", + models: { + claude: { + modelID: "claude-opus-4-8", + package: "aisdk:@ai-sdk/anthropic", + }, + }, + }, + }, + }), + }), + ]) + + const model = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("claude"))) + expect(model.package).toBe("@opencode/ai/providers/anthropic") + expect(model.variants.map((variant) => variant.id)).toEqual([ + Model.VariantID.make("low"), + Model.VariantID.make("medium"), + Model.VariantID.make("high"), + Model.VariantID.make("xhigh"), + Model.VariantID.make("max"), + ]) + }), + ) + it.effect("keeps configured model variant bodies unchanged", () => Effect.gen(function* () { const models = yield* Model.Service From 1dff31ad7b236a45405d1dbcf2c5a387e8abeabc Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 14 Sep 2026 02:28:18 -0500 Subject: [PATCH 13/19] fix(core): specialize Messages reasoning variants --- packages/core/src/variant.ts | 118 +++++++++++++++---- packages/core/test/plugin/models-dev.test.ts | 14 ++- packages/core/test/variant.test.ts | 95 +++++++++++++++ 3 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 packages/core/test/variant.test.ts diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts index a908765fe1a4..086c5b88510c 100644 --- a/packages/core/src/variant.ts +++ b/packages/core/src/variant.ts @@ -27,7 +27,6 @@ export function resolve(model: Model.Info, supports: readonly Support[] = [{ typ const EFFORTS = ["low", "medium", "high"] const ENCRYPTED_REASONING = ["reasoning.encrypted_content"] const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" } -const OUTPUT_TOKEN_MAX = 32_000 const variant = (id: string, overlay: Overlay): Variants[number] => ({ id: Model.VariantID.make(id), ...overlay }) @@ -41,7 +40,7 @@ function budgets( support: Extract, spell: (tokens: number) => Overlay, ): Variants { - const maximum = Math.min(support.max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1) + const maximum = Math.min(support.max ?? model.limit.output - 1, model.limit.output - 1) if (maximum <= 0) return [] const high = Math.min(Math.max(support.min ?? 0, Math.floor((maximum + 1) / 2)), maximum) return [variant("high", spell(high)), variant("max", spell(maximum))] @@ -49,16 +48,26 @@ function budgets( const modelID = (model: Model.Info) => model.modelID ?? model.id -// Claude before 4.6 has no adaptive thinking; effort is sent on its own. -function claudeThinksManually(model: Model.Info) { +function claudeInfo(model: Model.Info) { const id = modelID(model) - const familyFirst = /(?:claude-)?(?:opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?/i.exec(id) - const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(?:opus|sonnet|haiku)/i.exec(id) - 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) + const familyFirst = /(?:claude-)?(opus|sonnet|haiku|fable|mythos)-(\d+)(?:[.-](\d+))?/i.exec(id) + const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(opus|sonnet|haiku|fable|mythos)/i.exec(id) + const family = (familyFirst?.[1] ?? versionFirst?.[3])?.toLowerCase() + const major = Number(familyFirst?.[2] ?? versionFirst?.[1]) + const minor = Number(familyFirst?.[3] ?? versionFirst?.[2] ?? 0) + return { + family, + major, + minor, + manual: (major === 3 && minor === 7) || (major === 4 && minor < 6), + always: family === "fable" || family === "mythos" || id.toLowerCase().includes("mythos-preview"), + } +} + +function manualThinking(model: Model.Info): Overlay | undefined { + const tokens = Math.min(16_000, model.limit.output - 1) + if (tokens < 1024) return + return { settings: { thinking: { type: "enabled", budgetTokens: tokens } } } } const openaiChat: Protocol = (_, support) => { @@ -76,14 +85,57 @@ const responsesEffort = (effort: string): Overlay => ({ }) const anthropicMessages: Protocol = (model, support) => { - const manual = claudeThinksManually(model) + const info = claudeInfo(model) + const opus45 = info.family === "opus" && info.major === 4 && info.minor === 5 switch (support.type) { - case "effort": - return efforts(support.values ?? (manual ? EFFORTS : [...EFFORTS, "xhigh", "max"]), (effort) => ({ - settings: manual ? { effort } : { thinking: ADAPTIVE_THINKING, effort }, + case "effort": { + if (info.manual && !opus45) return anthropicMessages(model, { type: "budget_tokens", min: 1024 }) + const thinking = opus45 ? manualThinking(model) : { settings: { thinking: ADAPTIVE_THINKING } } + if (!thinking) return [] + const defaults = info.major === 4 && info.minor === 6 ? [...EFFORTS, "max"] : [...EFFORTS, "xhigh", "max"] + const values = support.values ?? defaults + return efforts(values, (effort) => ({ + settings: { ...thinking.settings, effort }, + })) + } + case "toggle": { + if (info.always) return [] + const thinking = info.manual ? manualThinking(model) : { settings: { thinking: ADAPTIVE_THINKING } } + if (!thinking) return [] + return toggle({ settings: { thinking: { type: "disabled" } } }, thinking) + } + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + settings: { thinking: { type: "enabled", budgetTokens: tokens } }, })) + } +} + +const minimaxMessages: Protocol = (model, support) => { + if (/minimax[-.]?m2(?:[.-]|$)/i.test(modelID(model))) return [] + const configurable = support.type === "toggle" || (support.type === "effort" && support.values === undefined) + if (!configurable) return [] + return toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "adaptive" } } }) +} + +const moonshotMessages: Protocol = (model, support) => { + if (support.type !== "effort" || /kimi[-.]?k2/i.test(modelID(model))) return [] + return efforts(support.values ?? ["low", "high", "max"], (effort) => ({ settings: { effort } })) +} + +const alibabaMessages: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + switch (support.type) { + case "effort": { + const hosted = id.includes("glm") || id.includes("deepseek") + const values = support.values ?? (hosted ? ["high", "max"] : ["low", "medium", "xhigh"]) + return efforts(values, (effort) => { + if (effort === "none") return { settings: { thinking: { type: "disabled" } } } + return { settings: { thinking: { type: "enabled" }, effort } } + }) + } case "toggle": - return toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: ADAPTIVE_THINKING } }) + return toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "enabled" } } }) case "budget_tokens": return budgets(model, support, (tokens) => ({ settings: { thinking: { type: "enabled", budgetTokens: tokens } }, @@ -91,6 +143,26 @@ const anthropicMessages: Protocol = (model, support) => { } } +const zaiMessages: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + const forced = id.includes("glm-5.3") || id.includes("glm-5-3") || id.includes("glm-5p3") + switch (support.type) { + case "effort": + return efforts(support.values ?? (forced ? ["low", "high", "max"] : ["high", "max"]), (effort) => ({ + settings: { + thinking: { type: effort === "none" || effort === "minimal" ? "disabled" : "enabled" }, + effort, + }, + })) + case "toggle": + return forced + ? [] + : toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "enabled" } } }) + case "budget_tokens": + return [] + } +} + const gemini: Protocol = (model, support) => { switch (support.type) { case "effort": @@ -129,7 +201,7 @@ const bedrockConverse: Protocol = (model, support) => { return efforts(support.values ?? EFFORTS, (effort) => { if (claude) return fields({ - ...(claudeThinksManually(model) ? {} : { thinking: ADAPTIVE_THINKING }), + ...(claudeInfo(model).manual ? {} : { thinking: ADAPTIVE_THINKING }), output_config: { effort }, }) if (id.includes("openai.gpt-oss")) return fields({ reasoning_effort: effort }) @@ -179,7 +251,7 @@ const bedrockAISDK: Protocol = (model, support) => { settings: claude ? { reasoningConfig: { - ...(claudeThinksManually(model) ? {} : ADAPTIVE_THINKING), + ...(claudeInfo(model).manual ? {} : ADAPTIVE_THINKING), maxReasoningEffort: effort, }, } @@ -220,7 +292,7 @@ const sapAICore: Protocol = (model, support) => { if (id.includes("anthropic")) return sap({ additionalModelRequestFields: { - ...(claudeThinksManually(model) ? {} : { thinking: ADAPTIVE_THINKING }), + ...(claudeInfo(model).manual ? {} : { thinking: ADAPTIVE_THINKING }), output_config: { effort }, }, }) @@ -294,11 +366,11 @@ const PROTOCOLS: Readonly> = { "@opencode/ai/providers/anthropic": anthropicMessages, "@opencode/ai/providers/google-vertex/messages": anthropicMessages, - "@opencode/ai/providers/alibaba/messages": anthropicMessages, + "@opencode/ai/providers/alibaba/messages": alibabaMessages, "@opencode/ai/providers/meta/messages": anthropicMessages, - "@opencode/ai/providers/minimax/messages": anthropicMessages, - "@opencode/ai/providers/moonshot/messages": anthropicMessages, - "@opencode/ai/providers/zai-coding-plan/messages": anthropicMessages, + "@opencode/ai/providers/minimax/messages": minimaxMessages, + "@opencode/ai/providers/moonshot/messages": moonshotMessages, + "@opencode/ai/providers/zai-coding-plan/messages": zaiMessages, "@opencode/ai/providers/google": gemini, "@opencode/ai/providers/google-vertex": gemini, diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 65dbe0ad17e6..bda6ec4aa94c 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -959,11 +959,11 @@ describe("ModelsDevPlugin", () => { const budgetModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-budget")) expect(budgetModel?.variants).toContainEqual({ id: Model.VariantID.make("high"), - settings: { thinking: { type: "enabled", budgetTokens: 16000 } }, + settings: { thinking: { type: "enabled", budgetTokens: 32000 } }, }) expect(budgetModel?.variants).toContainEqual({ id: Model.VariantID.make("max"), - settings: { thinking: { type: "enabled", budgetTokens: 31999 } }, + settings: { thinking: { type: "enabled", budgetTokens: 63999 } }, }) const anthropicEffortModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4.7")) @@ -986,8 +986,14 @@ describe("ModelsDevPlugin", () => { const opus45 = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4-5")) expect(opus45?.variants).toEqual([ - { id: Model.VariantID.make("low"), settings: { effort: "low" } }, - { id: Model.VariantID.make("high"), settings: { effort: "high" } }, + { + id: Model.VariantID.make("low"), + settings: { effort: "low", thinking: { type: "enabled", budgetTokens: 8191 } }, + }, + { + id: Model.VariantID.make("high"), + settings: { effort: "high", thinking: { type: "enabled", budgetTokens: 8191 } }, + }, ]) const grok = yield* modelState.get(Provider.ID.make("xai"), Model.ID.make("grok-4.5")) diff --git a/packages/core/test/variant.test.ts b/packages/core/test/variant.test.ts new file mode 100644 index 000000000000..a5018f48e0a4 --- /dev/null +++ b/packages/core/test/variant.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "bun:test" +import { Model } from "@opencode/core/model" +import { Provider } from "@opencode/core/provider" +import { Variant } from "@opencode/core/variant" + +const model = (packageName: string, modelID: string) => { + const result = Model.Info.default(Provider.ID.make("test"), Model.ID.make(modelID)) as Model.MutableInfo + result.package = packageName + result.modelID = Model.ID.make(modelID) + return Model.Info.make(result) +} + +const resolve = (input: Model.Info, supports: readonly Variant.Support[]) => + Variant.resolve(input, supports).map((item) => ({ ...item, id: String(item.id) })) + +test("spells Messages variants for each provider", () => { + expect( + resolve(model("@opencode/ai/providers/anthropic", "claude-opus-4-5"), [ + { type: "effort", values: ["low", "high"] }, + { type: "budget_tokens", min: 1024 }, + ]), + ).toEqual([ + { + id: "low", + settings: { effort: "low", thinking: { type: "enabled", budgetTokens: 16_000 } }, + }, + { + id: "high", + settings: { effort: "high", thinking: { type: "enabled", budgetTokens: 16_000 } }, + }, + ]) + + expect( + resolve(model("@opencode/ai/providers/anthropic", "claude-opus-4-8"), [{ type: "effort", values: ["low", "max"] }]), + ).toEqual([ + { id: "low", settings: { effort: "low", thinking: { type: "adaptive", display: "summarized" } } }, + { id: "max", settings: { effort: "max", thinking: { type: "adaptive", display: "summarized" } } }, + ]) + + expect(resolve(model("@opencode/ai/providers/minimax/messages", "MiniMax-M3"), [{ type: "toggle" }])).toEqual([ + { id: "none", settings: { thinking: { type: "disabled" } } }, + { id: "thinking", settings: { thinking: { type: "adaptive" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/moonshot/messages", "k3"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ]), + ).toEqual([ + { id: "low", settings: { effort: "low" } }, + { id: "high", settings: { effort: "high" } }, + { id: "max", settings: { effort: "max" } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/alibaba/messages", "qwen3.8-max"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "xhigh"] }, + { type: "budget_tokens", min: 1024, max: 262_144 }, + ]), + ).toEqual([ + { id: "none", settings: { thinking: { type: "disabled" } } }, + { id: "low", settings: { effort: "low", thinking: { type: "enabled" } } }, + { id: "medium", settings: { effort: "medium", thinking: { type: "enabled" } } }, + { id: "xhigh", settings: { effort: "xhigh", thinking: { type: "enabled" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/zai-coding-plan/messages", "glm-5.3"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + { type: "budget_tokens", min: 1024 }, + ]), + ).toEqual([ + { id: "low", settings: { effort: "low", thinking: { type: "enabled" } } }, + { id: "high", settings: { effort: "high", thinking: { type: "enabled" } } }, + { id: "max", settings: { effort: "max", thinking: { type: "enabled" } } }, + ]) +}) + +test("recognizes Claude version spellings and future models", () => { + for (const id of ["claude-sonnet-3.7", "claude-sonnet-3-7", "claude-3.7-sonnet", "claude-3-7-sonnet"]) + expect(resolve(model("@opencode/ai/providers/anthropic", id), [{ type: "effort" }])).toEqual([ + { id: "high", settings: { thinking: { type: "enabled", budgetTokens: 16000 } } }, + { id: "max", settings: { thinking: { type: "enabled", budgetTokens: 31999 } } }, + ]) + + expect(resolve(model("@opencode/ai/providers/anthropic", "claude-opus-6"), [{ type: "effort" }])).toEqual( + ["low", "medium", "high", "xhigh", "max"].map((effort) => ({ + id: effort, + settings: { effort, thinking: { type: "adaptive", display: "summarized" } }, + })), + ) +}) From 2c7999045bf05ea05558661d995d1b625d372fd1 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 14 Sep 2026 02:36:38 -0500 Subject: [PATCH 14/19] fix(core): specialize direct chat reasoning variants --- packages/core/src/variant.ts | 84 ++++++++++++++++++++++++++++-- packages/core/test/variant.test.ts | 73 +++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 6 deletions(-) diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts index 086c5b88510c..5ce43d7de7d5 100644 --- a/packages/core/src/variant.ts +++ b/packages/core/src/variant.ts @@ -84,6 +84,80 @@ const responsesEffort = (effort: string): Overlay => ({ settings: { reasoningEffort: effort, reasoningSummary: "auto", include: ENCRYPTED_REASONING }, }) +const deepseekChat: Protocol = (_, support) => { + switch (support.type) { + case "effort": + return efforts(support.values ?? ["low", "high", "max"], (effort) => { + if (effort === "none") return { body: { thinking: { type: "disabled" } } } + return { settings: { reasoningEffort: effort }, body: { thinking: { type: "enabled" } } } + }) + case "toggle": + return toggle({ body: { thinking: { type: "disabled" } } }, { body: { thinking: { type: "enabled" } } }) + case "budget_tokens": + return [] + } +} + +const moonshotChat: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + const toggleable = id.includes("k2.5") || id.includes("k2-5") || id.includes("k2.6") || id.includes("k2-6") + const fixed = id.includes("k2-thinking") || id.includes("k2.7-code") || id.includes("k2-7-code") + if (fixed) return [] + if (toggleable && (support.type === "toggle" || (support.type === "effort" && support.values === undefined))) + return toggle({ settings: { thinking: { type: "disabled" } } }, { settings: { thinking: { type: "enabled" } } }) + if (toggleable || support.type !== "effort") return [] + return efforts(support.values ?? ["low", "high", "max"], (effort) => ({ + settings: { reasoningEffort: effort }, + })) +} + +const alibabaChat: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + const fixed = + id.includes("-thinking") || id.includes("qwq") || id.includes("deepseek-r1") || id.includes("minimax-m2") + switch (support.type) { + case "effort": { + if (fixed && support.values === undefined) return [] + const hosted = id.includes("glm") || id.includes("deepseek") + const values = support.values ?? (hosted ? ["high", "max"] : ["low", "medium", "xhigh"]) + return efforts(values, (effort) => { + if (effort === "none") return { settings: { enableThinking: false } } + return { settings: { enableThinking: true, reasoningEffort: effort } } + }) + } + case "toggle": + return toggle({ settings: { enableThinking: false } }, { settings: { enableThinking: true } }) + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + settings: { enableThinking: true, thinkingBudget: tokens }, + })) + } +} + +const zaiChat: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + const version = /glm-?(\d+)(?:(?:[.-]|p)(\d+))?/.exec(id) + const major = Number(version?.[1]) + const minor = Number(version?.[2] ?? 0) + const coding = model.package?.includes("zai-coding-plan") ?? false + const latest = coding || !Number.isFinite(major) || major > 5 || (major === 5 && minor >= 3) + const effort = major === 5 && minor === 2 + if (support.type === "toggle" || (!latest && !effort && support.type === "effort" && support.values === undefined)) { + if (latest) return [] + return toggle( + { settings: { thinking: { type: "disabled" } } }, + { settings: { thinking: { type: "enabled", clear_thinking: false } } }, + ) + } + if (support.type !== "effort") return [] + return efforts(support.values ?? (latest ? ["low", "high", "max"] : ["high", "max"]), (value) => { + if (value === "none" || value === "minimal") return { settings: { thinking: { type: "disabled" } } } + return { + settings: { thinking: { type: "enabled", clear_thinking: false }, reasoningEffort: value }, + } + }) +} + const anthropicMessages: Protocol = (model, support) => { const info = claudeInfo(model) const opus45 = info.family === "opus" && info.major === 4 && info.minor === 5 @@ -347,22 +421,22 @@ const PROTOCOLS: Readonly> = { "@opencode/ai/providers/openai-compatible": openaiChat, "@opencode/ai/providers/google-vertex/chat": openaiChat, - "@opencode/ai/providers/alibaba/chat": openaiChat, + "@opencode/ai/providers/alibaba/chat": alibabaChat, "@opencode/ai/providers/baseten": openaiChat, "@opencode/ai/providers/cerebras": openaiChat, "@opencode/ai/providers/cloudflare-workers-ai": openaiChat, "@opencode/ai/providers/deepinfra": openaiChat, - "@opencode/ai/providers/deepseek": openaiChat, + "@opencode/ai/providers/deepseek": deepseekChat, "@opencode/ai/providers/fireworks": openaiChat, "@opencode/ai/providers/groq": openaiChat, "@opencode/ai/providers/meta/chat": openaiChat, "@opencode/ai/providers/minimax/chat": openaiChat, "@opencode/ai/providers/mistral": openaiChat, - "@opencode/ai/providers/moonshot/chat": openaiChat, + "@opencode/ai/providers/moonshot/chat": moonshotChat, "@opencode/ai/providers/togetherai": openaiChat, "@opencode/ai/providers/xai": openaiChat, - "@opencode/ai/providers/zai/chat": openaiChat, - "@opencode/ai/providers/zai-coding-plan/chat": openaiChat, + "@opencode/ai/providers/zai/chat": zaiChat, + "@opencode/ai/providers/zai-coding-plan/chat": zaiChat, "@opencode/ai/providers/anthropic": anthropicMessages, "@opencode/ai/providers/google-vertex/messages": anthropicMessages, diff --git a/packages/core/test/variant.test.ts b/packages/core/test/variant.test.ts index a5018f48e0a4..5dacd52a09e4 100644 --- a/packages/core/test/variant.test.ts +++ b/packages/core/test/variant.test.ts @@ -3,10 +3,11 @@ import { Model } from "@opencode/core/model" import { Provider } from "@opencode/core/provider" import { Variant } from "@opencode/core/variant" -const model = (packageName: string, modelID: string) => { +const model = (packageName: string, modelID: string, output?: number) => { const result = Model.Info.default(Provider.ID.make("test"), Model.ID.make(modelID)) as Model.MutableInfo result.package = packageName result.modelID = Model.ID.make(modelID) + if (output !== undefined) result.limit = { ...result.limit, output } return Model.Info.make(result) } @@ -93,3 +94,73 @@ test("recognizes Claude version spellings and future models", () => { })), ) }) + +test("spells Chat Completions variants for direct providers", () => { + expect( + resolve(model("@opencode/ai/providers/deepseek", "deepseek-v4-flash"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ]), + ).toEqual([ + { id: "none", body: { thinking: { type: "disabled" } } }, + { id: "low", settings: { reasoningEffort: "low" }, body: { thinking: { type: "enabled" } } }, + { id: "high", settings: { reasoningEffort: "high" }, body: { thinking: { type: "enabled" } } }, + { id: "max", settings: { reasoningEffort: "max" }, body: { thinking: { type: "enabled" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/moonshot/chat", "kimi-k3"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ]), + ).toEqual([ + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + { id: "max", settings: { reasoningEffort: "max" } }, + ]) + + expect(resolve(model("@opencode/ai/providers/moonshot/chat", "kimi-k2.6"), [{ type: "toggle" }])).toEqual([ + { id: "none", settings: { thinking: { type: "disabled" } } }, + { id: "thinking", settings: { thinking: { type: "enabled" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/alibaba/chat", "qwen3.8-max"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "xhigh"] }, + { type: "budget_tokens", min: 0, max: 262_144 }, + ]), + ).toEqual([ + { id: "none", settings: { enableThinking: false } }, + { id: "low", settings: { enableThinking: true, reasoningEffort: "low" } }, + { id: "medium", settings: { enableThinking: true, reasoningEffort: "medium" } }, + { id: "xhigh", settings: { enableThinking: true, reasoningEffort: "xhigh" } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/alibaba/chat", "qwen3.7-plus", 300_000), [ + { type: "toggle" }, + { type: "budget_tokens", min: 0, max: 262_144 }, + ]), + ).toEqual([ + { id: "none", settings: { enableThinking: false } }, + { id: "high", settings: { enableThinking: true, thinkingBudget: 131_072 } }, + { id: "max", settings: { enableThinking: true, thinkingBudget: 262_144 } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/zai/chat", "glm-5.3"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ]), + ).toEqual([ + { id: "low", settings: { thinking: { type: "enabled", clear_thinking: false }, reasoningEffort: "low" } }, + { id: "high", settings: { thinking: { type: "enabled", clear_thinking: false }, reasoningEffort: "high" } }, + { id: "max", settings: { thinking: { type: "enabled", clear_thinking: false }, reasoningEffort: "max" } }, + ]) + + expect(resolve(model("@opencode/ai/providers/zai/chat", "glm-4.7"), [{ type: "toggle" }])).toEqual([ + { id: "none", settings: { thinking: { type: "disabled" } } }, + { id: "thinking", settings: { thinking: { type: "enabled", clear_thinking: false } } }, + ]) +}) From fddf7d21207fb47136c5b1bf0303db7f1f640ea5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 14 Sep 2026 02:45:00 -0500 Subject: [PATCH 15/19] fix(core): specialize hosted reasoning variants --- packages/core/src/variant.ts | 93 +++++++++++++++++++++++++++++- packages/core/test/variant.test.ts | 85 ++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 5 deletions(-) diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts index 5ce43d7de7d5..11a42ac010a6 100644 --- a/packages/core/src/variant.ts +++ b/packages/core/src/variant.ts @@ -98,6 +98,93 @@ const deepseekChat: Protocol = (_, support) => { } } +const nvidiaTemplate = (chat_template_kwargs: Record): Overlay => ({ + body: { chat_template_kwargs }, +}) + +const nvidiaToggle = (key: string): Variants => + toggle(nvidiaTemplate({ [key]: false }), nvidiaTemplate({ [key]: true })) + +const nvidiaChat: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + if (id.includes("deepseek-v4")) { + if (support.type === "toggle") return nvidiaToggle("thinking") + if (support.type !== "effort") return [] + return efforts(support.values ?? ["none", "high", "max"], (effort) => + effort === "none" + ? nvidiaTemplate({ thinking: false }) + : nvidiaTemplate({ thinking: true, reasoning_effort: effort }), + ) + } + if (id.includes("kimi-k2.6") || id.includes("kimi-k2-6")) return nvidiaToggle("thinking") + if (id.includes("kimi-k3")) { + if (support.type === "toggle") return nvidiaToggle("thinking") + if (support.type !== "effort") return [] + return efforts(support.values ?? ["low", "high", "max"], (effort) => ({ + settings: { reasoningEffort: effort }, + })) + } + if (id.includes("minimax-m3")) + return toggle(nvidiaTemplate({ thinking_mode: "disabled" }), nvidiaTemplate({ thinking_mode: "enabled" })) + if (id.includes("glm")) + return toggle( + nvidiaTemplate({ enable_thinking: false }), + nvidiaTemplate({ enable_thinking: true, clear_thinking: false }), + ) + const toggleOnly = id.includes("qwen") || id.includes("gemma") || id.includes("nemotron") + switch (support.type) { + case "effort": + return toggleOnly && support.values === undefined ? nvidiaToggle("enable_thinking") : openaiChat(model, support) + case "toggle": + return nvidiaToggle("enable_thinking") + case "budget_tokens": + return budgets(model, support, (tokens) => ({ + body: { chat_template_kwargs: { enable_thinking: true }, reasoning_budget: tokens }, + })) + } +} + +const basetenTemplate = (enable_thinking: boolean): Overlay => ({ + body: { chat_template_args: { enable_thinking } }, +}) + +const basetenChat: Protocol = (model, support) => { + const id = modelID(model).toLowerCase() + switch (support.type) { + case "effort": { + const glm = id.includes("glm-5.2") || id.includes("glm-5-2") || id.includes("glm-5p2") + return efforts(support.values ?? EFFORTS, (effort) => ({ + settings: { reasoningEffort: effort }, + ...(glm ? basetenTemplate(effort !== "none") : {}), + ...(id.includes("deepseek-v4-pro-0813") && effort !== "none" + ? { body: { thinking: { type: "enabled" } } } + : {}), + })) + } + case "toggle": + return toggle(basetenTemplate(false), basetenTemplate(true)) + case "budget_tokens": + return [] + } +} + +const deepinfraChat: Protocol = (model, support) => { + if (/kimi[-.]?k2[.-]7-code/i.test(modelID(model))) return [] + switch (support.type) { + case "effort": + return efforts(support.values ?? EFFORTS, (effort) => ({ settings: { reasoningEffort: effort } })) + case "toggle": + return toggle({ body: { reasoning: { enabled: false } } }, { body: { reasoning: { enabled: true } } }) + case "budget_tokens": + return [] + } +} + +const openaiCompatible: Protocol = (model, support) => { + if (model.providerID === "nvidia") return nvidiaChat(model, support) + return openaiChat(model, support) +} + const moonshotChat: Protocol = (model, support) => { const id = modelID(model).toLowerCase() const toggleable = id.includes("k2.5") || id.includes("k2-5") || id.includes("k2.6") || id.includes("k2-6") @@ -419,13 +506,13 @@ const PROTOCOLS: Readonly> = { "@opencode/ai/providers/moonshot/responses": openaiResponses, "@opencode/ai/providers/zai-coding-plan/responses": openaiResponses, - "@opencode/ai/providers/openai-compatible": openaiChat, + "@opencode/ai/providers/openai-compatible": openaiCompatible, "@opencode/ai/providers/google-vertex/chat": openaiChat, "@opencode/ai/providers/alibaba/chat": alibabaChat, - "@opencode/ai/providers/baseten": openaiChat, + "@opencode/ai/providers/baseten": basetenChat, "@opencode/ai/providers/cerebras": openaiChat, "@opencode/ai/providers/cloudflare-workers-ai": openaiChat, - "@opencode/ai/providers/deepinfra": openaiChat, + "@opencode/ai/providers/deepinfra": deepinfraChat, "@opencode/ai/providers/deepseek": deepseekChat, "@opencode/ai/providers/fireworks": openaiChat, "@opencode/ai/providers/groq": openaiChat, diff --git a/packages/core/test/variant.test.ts b/packages/core/test/variant.test.ts index 5dacd52a09e4..85050e953bb1 100644 --- a/packages/core/test/variant.test.ts +++ b/packages/core/test/variant.test.ts @@ -3,8 +3,8 @@ import { Model } from "@opencode/core/model" import { Provider } from "@opencode/core/provider" import { Variant } from "@opencode/core/variant" -const model = (packageName: string, modelID: string, output?: number) => { - const result = Model.Info.default(Provider.ID.make("test"), Model.ID.make(modelID)) as Model.MutableInfo +const model = (packageName: string, modelID: string, output?: number, providerID = "test") => { + const result = Model.Info.default(Provider.ID.make(providerID), Model.ID.make(modelID)) as Model.MutableInfo result.package = packageName result.modelID = Model.ID.make(modelID) if (output !== undefined) result.limit = { ...result.limit, output } @@ -164,3 +164,84 @@ test("spells Chat Completions variants for direct providers", () => { { id: "thinking", settings: { thinking: { type: "enabled", clear_thinking: false } } }, ]) }) + +test("spells Chat Completions variants for hosting providers", () => { + expect( + resolve(model("@opencode/ai/providers/openai-compatible", "deepseek-ai/deepseek-v4-pro", undefined, "nvidia"), [ + { type: "effort", values: ["none", "high", "max"] }, + ]), + ).toEqual([ + { id: "none", body: { chat_template_kwargs: { thinking: false } } }, + { id: "high", body: { chat_template_kwargs: { thinking: true, reasoning_effort: "high" } } }, + { id: "max", body: { chat_template_kwargs: { thinking: true, reasoning_effort: "max" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/openai-compatible", "moonshotai/kimi-k2.6", undefined, "nvidia"), [ + { type: "effort", values: ["none", "low", "high", "max"] }, + ]), + ).toEqual([ + { id: "none", body: { chat_template_kwargs: { thinking: false } } }, + { id: "thinking", body: { chat_template_kwargs: { thinking: true } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/openai-compatible", "moonshotai/kimi-k3", undefined, "nvidia"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ]), + ).toEqual([ + { id: "none", body: { chat_template_kwargs: { thinking: false } } }, + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + { id: "max", settings: { reasoningEffort: "max" } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/openai-compatible", "minimaxai/minimax-m3", undefined, "nvidia"), [ + { type: "toggle" }, + ]), + ).toEqual([ + { id: "none", body: { chat_template_kwargs: { thinking_mode: "disabled" } } }, + { id: "thinking", body: { chat_template_kwargs: { thinking_mode: "enabled" } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/baseten", "zai-org/GLM-5.2"), [ + { type: "effort", values: ["none", "high", "max"] }, + ]), + ).toEqual([ + { + id: "none", + settings: { reasoningEffort: "none" }, + body: { chat_template_args: { enable_thinking: false } }, + }, + { + id: "high", + settings: { reasoningEffort: "high" }, + body: { chat_template_args: { enable_thinking: true } }, + }, + { + id: "max", + settings: { reasoningEffort: "max" }, + body: { chat_template_args: { enable_thinking: true } }, + }, + ]) + + expect(resolve(model("@opencode/ai/providers/baseten", "moonshotai/Kimi-K2.6"), [{ type: "toggle" }])).toEqual([ + { id: "none", body: { chat_template_args: { enable_thinking: false } } }, + { id: "thinking", body: { chat_template_args: { enable_thinking: true } } }, + ]) + + expect( + resolve(model("@opencode/ai/providers/deepinfra", "zai-org/GLM-5.2"), [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "xhigh"] }, + ]), + ).toEqual([ + { id: "none", body: { reasoning: { enabled: false } } }, + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + { id: "xhigh", settings: { reasoningEffort: "xhigh" } }, + ]) +}) From 949ca218503f3f163b02831aa66358602d2257f3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 14 Sep 2026 03:08:06 -0500 Subject: [PATCH 16/19] fix(core): restore Workers AI environment auth --- .../ai/src/providers/cloudflare-workers-ai.ts | 2 +- packages/ai/test/provider/cloudflare.test.ts | 44 +++++++++++-------- packages/core/src/plugin/models-dev.ts | 2 + packages/core/test/plugin/models-dev.test.ts | 30 ++++++++++++- 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/ai/src/providers/cloudflare-workers-ai.ts b/packages/ai/src/providers/cloudflare-workers-ai.ts index 813f9960311a..6c53c3020395 100644 --- a/packages/ai/src/providers/cloudflare-workers-ai.ts +++ b/packages/ai/src/providers/cloudflare-workers-ai.ts @@ -7,7 +7,7 @@ import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/ import type { OpenAIProviderOptionsInput } from "./openai-options.js" export const id = ProviderID.make("cloudflare-workers-ai") -export const authEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const +export const authEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN", "CLOUDFLARE_API_TOKEN"] as const type WorkersAIURL = AtLeastOne<{ readonly accountId: string diff --git a/packages/ai/test/provider/cloudflare.test.ts b/packages/ai/test/provider/cloudflare.test.ts index 62356069e200..7f00f590cc89 100644 --- a/packages/ai/test/provider/cloudflare.test.ts +++ b/packages/ai/test/provider/cloudflare.test.ts @@ -255,25 +255,31 @@ describe("Cloudflare", () => { it.effect("supports direct Workers AI token aliases through auth config", () => Effect.gen(function* () { - yield* LLM.generate( - LLM.request({ - model: CloudflareWorkersAI.configure({ - accountId: "test-account", - }).model("@cf/meta/llama-3.1-8b-instruct"), - prompt: "Say hello.", - }), - ).pipe( - withEnv({ CLOUDFLARE_WORKERS_AI_TOKEN: "test-token" }), - Effect.provide( - dynamicResponse((input) => - Effect.gen(function* () { - const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(web.headers.get("authorization")).toBe("Bearer test-token") - return input.respond( - sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), - { headers: { "content-type": "text/event-stream" } }, - ) - }), + yield* Effect.forEach(["CLOUDFLARE_WORKERS_AI_TOKEN", "CLOUDFLARE_API_TOKEN"], (name) => + LLM.generate( + LLM.request({ + model: CloudflareWorkersAI.configure({ + accountId: "test-account", + }).model("@cf/meta/llama-3.1-8b-instruct"), + prompt: "Say hello.", + }), + ).pipe( + withEnv({ + CLOUDFLARE_API_KEY: undefined, + CLOUDFLARE_WORKERS_AI_TOKEN: name === "CLOUDFLARE_WORKERS_AI_TOKEN" ? "test-token" : undefined, + CLOUDFLARE_API_TOKEN: name === "CLOUDFLARE_API_TOKEN" ? "test-token" : undefined, + }), + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer test-token") + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), ), ), ) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index c17017188988..bb4268f06b15 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -77,6 +77,8 @@ function environmentNames(provider: ModelsDev.Snapshot) { // Vertex. Those configure Google auth rather than carrying a key, so only the // Express Mode key may become a credential; GoogleVertexPlugin handles activation. if (provider.info.id === Provider.ID.googleVertex) return ["GOOGLE_VERTEX_API_KEY"] + if (provider.info.id === "cloudflare-workers-ai") + return ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN", "CLOUDFLARE_API_TOKEN"] return [...provider.environment] } diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index bda6ec4aa94c..a571d7760fea 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -863,7 +863,7 @@ describe("ModelsDevPlugin", () => { }), ) - it.effect("advertises only key-bearing Google Vertex environment variables", () => + it.effect("advertises only credential-bearing environment variables", () => Effect.gen(function* () { const integrations = yield* Integration.Service const providers = yield* Provider.Service @@ -879,6 +879,16 @@ describe("ModelsDevPlugin", () => { ModelsDev.Service.of({ get: () => Effect.succeed([ + { + info: { + id: Provider.ID.make("cloudflare-workers-ai"), + name: "Cloudflare Workers AI", + activation: "auto", + package: "@opencode/ai/providers/cloudflare-workers-ai", + }, + environment: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY"], + models: [], + }, { info: { id: Provider.ID.make("google-vertex"), @@ -900,6 +910,24 @@ describe("ModelsDevPlugin", () => { expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toMatchObject({ methods: [{ type: "key" }, { type: "env", names: ["GOOGLE_VERTEX_API_KEY"] }], }) + expect(yield* integrations.get(Integration.ID.make("cloudflare-workers-ai"))).toMatchObject({ + methods: [ + { type: "key" }, + { + type: "env", + names: ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN", "CLOUDFLARE_API_TOKEN"], + }, + ], + }) + yield* withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_API_KEY: "token" }, () => + integrations.connection + .active(Integration.ID.make("cloudflare-workers-ai")) + .pipe( + Effect.tap((connection) => + Effect.sync(() => expect(connection).toEqual({ type: "env", name: "CLOUDFLARE_API_KEY" })), + ), + ), + ) }), ) From 25e1e8446c7fe153ae45e44d2376604a4a3dea69 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 14 Sep 2026 13:25:00 -0500 Subject: [PATCH 17/19] fix(core): generate configured variants in model registry --- packages/core/src/config/plugin/provider.ts | 6 +++--- packages/core/test/config/provider.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 9f12e0f7270e..9b348c3ac14d 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -157,12 +157,12 @@ export const Plugin = define({ if (config.disabled !== undefined) model.enabled = !config.disabled if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } }) - if (config.variants === undefined && !base) - catalog.model.update(providerID, id, (model) => { + if (config.variants === undefined && !source?.base) + models.update(providerID, id, (model) => { model.variants = [ ...Variant.resolve({ ...model, - package: model.package ?? catalog.provider.get(providerID)?.provider.package, + package: model.package ?? models.provider.get(providerID)?.provider.package, }), ] }) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index cb79beeac579..4ac70d0ec851 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -376,7 +376,7 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("generates variants after rewriting a configured model package", () => Effect.gen(function* () { - const catalog = yield* Catalog.Service + const models = yield* Model.Service yield* addPlugin([ new Document({ type: "document", @@ -396,7 +396,7 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ]) - const model = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("claude"))) + const model = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("claude"))) expect(model.package).toBe("@opencode/ai/providers/anthropic") expect(model.variants.map((variant) => variant.id)).toEqual([ Model.VariantID.make("low"), From 00e277a44e39791e066fbc57a2c62c5ce9300f15 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 14 Sep 2026 13:25:07 -0500 Subject: [PATCH 18/19] fix(core): preserve Workers AI account configuration --- packages/core/src/models-dev.ts | 7 +++++-- .../core/src/plugin/provider/cloudflare-workers-ai.ts | 7 +++++-- packages/core/test/model-resolver.test.ts | 11 +++++++++++ packages/core/test/models.test.ts | 10 ++++++++++ .../plugin/provider-cloudflare-workers-ai.test.ts | 4 ++-- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 2d2f8a4f9bb4..82de7b33daf9 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -88,12 +88,15 @@ function normalize(input: Record): readonly Snapshot[] { const providers: Snapshot[] = [] for (const item of Object.values(input)) { const providerID = Provider.ID.make(item.id) + const packageName = nativePackage(item) const info = { id: providerID, name: item.name, activation: "auto", - package: nativePackage(item), - ...(item.api ? { settings: { baseURL: item.api } } : {}), + package: packageName, + ...(item.api && packageName !== "@opencode/ai/providers/cloudflare-workers-ai" + ? { settings: { baseURL: item.api } } + : {}), } satisfies Provider.Info const models: Model.Info[] = [] for (const model of Object.values(item.models)) { diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 5f50f4d13121..443921c735e6 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -39,10 +39,13 @@ export const CloudflareWorkersAIPlugin = define({ const item = evt.get(providerID) if (!item) return evt.update(item.provider.id, (provider) => { - if (provider.package === "@opencode/ai/providers/cloudflare-workers-ai") return if (typeof provider.settings?.baseURL === "string") return const accountId = resolveAccountId(provider.settings ?? {}) - if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) } + if (!accountId) return + provider.settings = + provider.package === "@opencode/ai/providers/cloudflare-workers-ai" + ? { ...provider.settings, accountId } + : { ...provider.settings, baseURL: workersEndpoint(accountId) } }) }) yield* ctx.aisdk.hook( diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 8382576916cc..353e93edf58e 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -1244,6 +1244,16 @@ describe("ModelResolver", () => { settings: { region: "us-east-1", topP: 0.6 }, }), ) + const workers = yield* ModelResolver.fromCatalogModel( + model("@opencode/ai/providers/cloudflare-workers-ai", { + modelID: "@cf/meta/llama-3.1-8b-instruct", + }), + Credential.Key.make({ + type: "key", + key: "workers-secret", + configuration: { accountId: "account" }, + }), + ) expect(google.route.id).toBe("gemini") expect(google.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } }) @@ -1281,6 +1291,7 @@ describe("ModelResolver", () => { expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } }) expect(mantle.route.id).toBe("bedrock-mantle-chat") expect(mantle.route.defaults.generation).toEqual({ topP: 0.6 }) + expect(workers.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/account/ai/v1") }), ) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index e7a80e7b7399..c0780e57f7a4 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -247,6 +247,14 @@ describe("ModelsDev Service", () => { }, }, }, + "cloudflare-workers-ai": { + id: "cloudflare-workers-ai", + name: "Cloudflare Workers AI", + env: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY"], + npm: "@ai-sdk/openai-compatible", + api: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + models: {}, + }, }) const state = yield* Ref.make(initialState) const result = yield* provided( @@ -256,6 +264,8 @@ describe("ModelsDev Service", () => { ) expect(result[0]?.info.package).toBe("@opencode/ai/providers/openai-compatible") expect(result[0]?.models[0]?.package).toBe("@opencode/ai/providers/openai") + expect(result[1]?.info.package).toBe("@opencode/ai/providers/cloudflare-workers-ai") + expect(result[1]?.info.settings).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 3c19baecb467..178d62104ac4 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -91,14 +91,14 @@ describe("CloudflareWorkersAIPlugin", () => { ), ) - it.effect("leaves the native Workers AI package to derive its own endpoint", () => + it.effect("gives the native Workers AI package its account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => Effect.gen(function* () { const catalog = yield* seed((provider) => { provider.package = "@opencode/ai/providers/cloudflare-workers-ai" }) yield* addPlugin() - expect(required(yield* catalog.get(providerID)).settings?.baseURL).toBeUndefined() + expect(required(yield* catalog.get(providerID)).settings).toEqual({ accountId: "acct" }) }), ), ) From ba9bf914d34cf07c919d79f9a18e04bac4076d37 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 14 Sep 2026 17:18:39 -0500 Subject: [PATCH 19/19] refactor(core): remove obsolete AI SDK provider paths --- bun.lock | 118 +----- .../ai/src/providers/cloudflare-ai-gateway.ts | 129 ++++-- packages/ai/test/provider/cloudflare.test.ts | 104 ++--- packages/core/package.json | 8 +- packages/core/src/aisdk-native.ts | 8 + packages/core/src/plugin/provider.ts | 4 - packages/core/src/plugin/provider/alibaba.ts | 10 - .../plugin/provider/cloudflare-ai-gateway.ts | 70 +-- .../plugin/provider/cloudflare-workers-ai.ts | 54 --- .../src/plugin/provider/openai-compatible.ts | 18 - packages/core/src/variant.ts | 10 +- packages/core/test/aisdk-native.test.ts | 22 +- packages/core/test/aisdk.test.ts | 113 ----- packages/core/test/model-resolver.test.ts | 2 + .../provider-cloudflare-ai-gateway.test.ts | 397 +----------------- .../core/test/plugin/provider-factory.test.ts | 2 - .../plugin/provider-openai-compatible.test.ts | 108 ----- packages/core/test/provider-mistral.test.ts | 282 ------------- packages/core/test/variant.test.ts | 22 + 19 files changed, 199 insertions(+), 1282 deletions(-) delete mode 100644 packages/core/src/plugin/provider/alibaba.ts delete mode 100644 packages/core/src/plugin/provider/openai-compatible.ts delete mode 100644 packages/core/test/plugin/provider-openai-compatible.test.ts delete mode 100644 packages/core/test/provider-mistral.test.ts diff --git a/bun.lock b/bun.lock index 1eea6f8d83fa..ce00c1142dc0 100644 --- a/bun.lock +++ b/bun.lock @@ -343,14 +343,8 @@ "name": "@opencode/core", "version": "2.0.3", "dependencies": { - "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.88", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google-vertex": "4.0.128", - "@ai-sdk/mistral": "3.0.51", - "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", @@ -369,7 +363,6 @@ "@parcel/watcher": "2.5.1", "@silvia-odwyer/photon-node": "0.3.4", "@standard-schema/spec": "catalog:", - "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "diff": "catalog:", "drizzle-orm": "catalog:", @@ -393,6 +386,7 @@ "zod": "catalog:", }, "devDependencies": { + "@ai-sdk/openai-compatible": "2.0.41", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@modelcontextprotocol/server": "2.0.0", @@ -1086,13 +1080,11 @@ "ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch", "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "solid-js@1.9.15": "patches/solid-js@1.9.15.patch", "vite@8.2.2": "patches/vite@8.2.2.patch", "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", }, @@ -1195,38 +1187,10 @@ "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="], - "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], - - "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="], - - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], - - "@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="], - - "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="], - "@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="], - "@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8pcrQvEQCbvrrQKnD6hclBbI0hUgSrgyADykRbabxv/g9vPurfMC6n23J7dD+KZ3EcCoW+qz3IUIfySJ58gBOg=="], - - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="], - - "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZgkausouWvO9U4ZtowNJ093bSNYOvH8zqls3uLC3+oxzWvbbTZO8SOdmFk0+gGafsXFJvq2yUX9+rEeJPwOJLw=="], - - "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.77", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.68", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ce5IVMrW38P63MlkElmpzrFGh8rMm7no5vJ1dp+eDYV83OtDo5fAqQkcCXC7nQtQTfaTErOK9PEdNh13syUmQg=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], - "@ai-sdk/google": ["@ai-sdk/google@3.0.73", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-o2MuIeyvZrFIeIbnbA8Thrr63irdyUBh0uWBZ2lY6yFeXuE/tcwyXF74bDKS4KvTu84uFpQfpbS/LXHGKKXz+g=="], - - "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.128", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.77", "@ai-sdk/google": "3.0.73", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jK8fixb4km2yfgvb9DUFQRpV/jiDB0v9gyxHoHfPydaQvz+CpAz8DTt1quyaM+Wg9G2R8Zo68CYmHbIkUqW2AA=="], - - "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], - - "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="], - - "@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], - "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="], @@ -1237,8 +1201,6 @@ "@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="], - "@ai-sdk/xai": ["@ai-sdk/xai@3.0.123", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.69", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WNASvd1C516oh2qYIj9EvAVPdU+Abads8DQWU6p9lQtvFFeGh8QW+3LDOARZd1GCINUFfw5yadEK845SMQKLsA=="], - "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -2259,8 +2221,6 @@ "@opencode/www": ["@opencode/www@workspace:services/www"], - "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.10.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], @@ -3379,8 +3339,6 @@ "ai": ["ai@7.0.66", "", { "dependencies": { "@ai-sdk/gateway": "4.0.52", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg=="], - "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -5947,80 +5905,14 @@ "@actions/http-client/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="], - - "@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="], - - "@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - - "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - - "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - - "@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="], - - "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/deepgram/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - - "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - - "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - - "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.68", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JiWi/Av2R+SsiGo8rv3b0yWWr3uZH+ll4ky8AcQUUGWzKcMxotQvy3oOoSR4QY34OJ4aLtctHhhzxxEMWSkfIw=="], - - "@ai-sdk/fireworks/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - - "@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - - "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.77", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ML8C2M1YvPA1ulEx4TiyF0k1xvC2ikEiPBIC1PPQ0a5xELUGrO2lAaEzsTEoJ+eCeDd8PSBuFJjs+r+9yIwQXA=="], - - "@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="], - - "@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - - "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - - "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], - - "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - "@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="], "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C99M0T0SpRkcCClmJxbQkpSqGmxLfh3NhTsNF3aNaUQZZ7oXN5sPWi9LGs49X5Q/r9FWxBYeZARXs15xxtGIig=="], - - "@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - "@astrojs/cloudflare/@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.52.1", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "5.20260811.1-alpha", "unenv": "2.0.0-rc.24", "workerd": "1.20260811.1", "wrangler": "4.123.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-O2IkD8E03qsUxNBdDRfDI9JPl25XIPt+76WJf8tgm206c34jYk8nn12ylBiOI/6NA8XUFhLjRTF1BGY+kVylXw=="], "@astrojs/cloudflare/wrangler": ["wrangler@4.123.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260811.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260811.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260811.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q=="], @@ -6869,14 +6761,6 @@ "@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], - "@ai-sdk/deepgram/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - - "@ai-sdk/elevenlabs/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - - "@ai-sdk/fireworks/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - - "@ai-sdk/xai/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "@astrojs/cloudflare/@cloudflare/vite-plugin/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], "@astrojs/cloudflare/@cloudflare/vite-plugin/miniflare": ["miniflare@5.20260811.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260811.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA=="], diff --git a/packages/ai/src/providers/cloudflare-ai-gateway.ts b/packages/ai/src/providers/cloudflare-ai-gateway.ts index 7d5e7ac61d4c..d516440b7c3e 100644 --- a/packages/ai/src/providers/cloudflare-ai-gateway.ts +++ b/packages/ai/src/providers/cloudflare-ai-gateway.ts @@ -1,10 +1,9 @@ -import type { Config, Redacted } from "effect" +import { Schema, type Config, type Redacted } from "effect" import type { ProviderPackage } from "../provider-package.js" -import { OpenAIChat } from "../protocols/openai-chat.js" +import { AnthropicMessages, OpenAIChat, OpenAIResponses } from "../protocols/index.js" import { Auth } from "../route/auth.js" import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js" -import { Route, type RouteDefaultsInput } from "../route/client.js" -import { Endpoint } from "../route/endpoint.js" +import type { RouteDefaultsInput } from "../route/client.js" import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js" import type { OpenAIProviderOptionsInput } from "./openai-options.js" @@ -14,21 +13,29 @@ export const authEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const type GatewayURL = AtLeastOne<{ readonly accountId: string readonly baseURL: string -}> & { +}> + +type GatewayOptions = { readonly gatewayId?: string + readonly metadata?: unknown + readonly cacheTtl?: number + readonly cacheKey?: string + readonly skipCache?: boolean + readonly collectLog?: boolean } export type LanguageModelOptions = GatewayURL & + GatewayOptions & Omit & ProviderAuthOption<"optional"> & { - /** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */ readonly gatewayApiKey?: string | Redacted.Redacted | Config.Config readonly providerOptions?: OpenAIProviderOptionsInput } export type Settings = ProviderPackage.Settings & OpenAIProviderOptionsInput & - GatewayURL & { + GatewayURL & + GatewayOptions & { readonly apiKey?: string readonly gatewayApiKey?: string } @@ -40,73 +47,113 @@ export const baseURL = (input: GatewayURL) => { provider: id, message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied", }) - return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat` + return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1` } +export const responsesRoute = OpenAIResponses.route.with({ + id: "cloudflare-ai-gateway-responses", + provider: id, + endpoint: { baseURL: undefined }, +}) + +export const messagesRoute = AnthropicMessages.route.with({ + id: "cloudflare-ai-gateway-messages", + provider: id, + endpoint: { baseURL: undefined }, +}) + +export const route = OpenAIChat.route.with({ + id: "cloudflare-ai-gateway-chat", + provider: id, + endpoint: { baseURL: undefined }, +}) + +export const routes = [responsesRoute, messagesRoute, route] + const auth = (input: LanguageModelOptions) => { if ("auth" in input && input.auth) return input.auth - const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey") + return Auth.optional(input.gatewayApiKey ?? ("apiKey" in input ? input.apiKey : undefined), "apiKey") .orElse(Auth.config(authEnvVars[0])) .orElse(Auth.config(authEnvVars[1])) - .pipe(Auth.bearerHeader("cf-aig-authorization")) - if (!("apiKey" in input) || input.apiKey === undefined) return gateway - if (input.gatewayApiKey === undefined) return Auth.bearer(input.apiKey) - return Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey)) + .bearer() } -export const route = Route.make({ - id: "cloudflare-ai-gateway", - provider: id, - providerMetadataKey: "cloudflare-ai-gateway", - protocol: OpenAIChat.protocol, - endpoint: Endpoint.path("/chat/completions"), - framing: OpenAIChat.framing, +const headers = (input: LanguageModelOptions) => ({ + ...(input.gatewayId === undefined ? {} : { "cf-aig-gateway-id": input.gatewayId.trim() || "default" }), + ...(input.metadata === undefined + ? {} + : { "cf-aig-metadata": Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(input.metadata) }), + ...(input.cacheTtl === undefined ? {} : { "cf-aig-cache-ttl": String(input.cacheTtl) }), + ...(input.cacheKey === undefined ? {} : { "cf-aig-cache-key": input.cacheKey }), + ...(input.skipCache === undefined ? {} : { "cf-aig-skip-cache": String(input.skipCache) }), + ...(input.collectLog === undefined ? {} : { "cf-aig-collect-log": String(input.collectLog) }), + ...input.headers, }) -export const routes = [route] +const modelID = (input: string | ModelID) => { + const value = String(input) + if (value.startsWith("workers-ai/")) return value.slice("workers-ai/".length) + if (value.startsWith("anthropic/")) return `anthropic/${value.slice("anthropic/".length).replaceAll(".", "-")}` + return value +} export const configure = (input: LanguageModelOptions) => { - const { - accountId: _accountId, - gatewayId: _gatewayId, - apiKey: _apiKey, - gatewayApiKey: _gatewayApiKey, - baseURL: _baseURL, - auth: _auth, - ...defaults - } = input - const configured = route.with({ - ...defaults, + const defaults = { endpoint: { baseURL: baseURL(input) }, auth: auth(input), - }) + headers: headers(input), + http: input.http, + providerOptions: input.providerOptions, + } + const responses = responsesRoute.with(defaults) + const messages = messagesRoute.with(defaults) + const chat = route.with(defaults) return { id, - model: (modelID: string | ModelID) => configured.model({ id: modelID }), + model: (input: string | ModelID) => { + const wire = modelID(input) + if (String(input).startsWith("openai/")) return responses.model({ id: wire }) + if (String(input).startsWith("anthropic/")) return messages.model({ id: wire }) + return chat.model({ id: wire }) + }, configure, } } export const provider = { id, configure } -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { - const { - accountId: _, +export const model: ProviderPackage.Definition["model"] = ( + modelID, + { + accountId, apiKey, - baseURL: _url, + baseURL: configuredBaseURL, body, + cacheKey, + cacheTtl, + collectLog, gatewayApiKey, - gatewayId: _id, + gatewayId, headers, + metadata, + skipCache, ...providerOptions - } = settings + }, +) => { + const connection = configuredBaseURL === undefined ? { accountId: accountId ?? "" } : { baseURL: configuredBaseURL } return configure({ + ...connection, apiKey, + cacheKey, + cacheTtl, + collectLog, gatewayApiKey, - baseURL: baseURL(settings), + gatewayId, headers: headers === undefined ? undefined : { ...headers }, http: body === undefined ? undefined : { body: { ...body } }, + metadata, providerOptions, + skipCache, }).model(modelID) } diff --git a/packages/ai/test/provider/cloudflare.test.ts b/packages/ai/test/provider/cloudflare.test.ts index 7f00f590cc89..89fcb7607fc9 100644 --- a/packages/ai/test/provider/cloudflare.test.ts +++ b/packages/ai/test/provider/cloudflare.test.ts @@ -20,33 +20,40 @@ const deltaChunk = (delta: object, finishReason: string | null = null) => ({ }) describe("Cloudflare", () => { - it.effect("prepares AI Gateway models through the OpenAI-compatible Chat protocol", () => + it.effect("selects native AI Gateway protocols by model ID", () => Effect.gen(function* () { - const model = CloudflareAIGateway.configure({ + const gateway = CloudflareAIGateway.configure({ accountId: "test-account", gatewayId: "test-gateway", apiKey: "test-token", - }).model("workers-ai/@cf/meta/llama-3.3-70b-instruct") - - expect(model).toMatchObject({ - id: "workers-ai/@cf/meta/llama-3.3-70b-instruct", - provider: "cloudflare-ai-gateway", - route: { id: "cloudflare-ai-gateway" }, }) - expect(model.route.endpoint.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat") - - const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) + const responses = yield* compileRequest( + LLM.request({ model: gateway.model("openai/gpt-5.4"), prompt: "Say hello." }), + ) + const messages = yield* compileRequest( + LLM.request({ model: gateway.model("anthropic/claude-haiku-4.5"), prompt: "Say hello." }), + ) + const chat = yield* compileRequest(LLM.request({ model: gateway.model("xai/grok-4.6"), prompt: "Say hello." })) + const workers = yield* compileRequest( + LLM.request({ model: gateway.model("workers-ai/@cf/meta/llama-3.3-70b-instruct"), prompt: "Say hello." }), + ) - expect(prepared.route).toBe("cloudflare-ai-gateway") - expect(prepared.body).toMatchObject({ - model: "workers-ai/@cf/meta/llama-3.3-70b-instruct", + expect(responses.route).toBe("cloudflare-ai-gateway-responses") + expect(responses.body).toMatchObject({ model: "openai/gpt-5.4", stream: true }) + expect(messages.route).toBe("cloudflare-ai-gateway-messages") + expect(messages.body).toMatchObject({ model: "anthropic/claude-haiku-4-5" }) + expect(chat.route).toBe("cloudflare-ai-gateway-chat") + expect(chat.body).toMatchObject({ model: "xai/grok-4.6", stream: true }) + expect(workers.route).toBe("cloudflare-ai-gateway-chat") + expect(workers.body).toMatchObject({ + model: "@cf/meta/llama-3.3-70b-instruct", messages: [{ role: "user", content: "Say hello." }], stream: true, }) }), ) - it.effect("posts to the derived gateway endpoint with bearer auth", () => + it.effect("posts to the Cloudflare REST API with gateway options", () => Effect.gen(function* () { const response = yield* LLM.generate( LLM.request({ @@ -54,7 +61,12 @@ describe("Cloudflare", () => { accountId: "test-account", gatewayId: "test-gateway", apiKey: "test-token", - }).model("openai/gpt-4o-mini"), + cacheKey: "cache-key", + cacheTtl: 300, + collectLog: false, + metadata: { invoked_by: "test" }, + skipCache: true, + }).model("xai/grok-4.6"), prompt: "Say hello.", }), ).pipe( @@ -62,12 +74,16 @@ describe("Cloudflare", () => { dynamicResponse((input) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(web.url).toBe( - "https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat/chat/completions", - ) + expect(web.url).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions") expect(web.headers.get("authorization")).toBe("Bearer test-token") + expect(web.headers.get("cf-aig-gateway-id")).toBe("test-gateway") + expect(web.headers.get("cf-aig-cache-key")).toBe("cache-key") + expect(web.headers.get("cf-aig-cache-ttl")).toBe("300") + expect(web.headers.get("cf-aig-collect-log")).toBe("false") + expect(web.headers.get("cf-aig-metadata")).toBe('{"invoked_by":"test"}') + expect(web.headers.get("cf-aig-skip-cache")).toBe("true") expect(decodeJson(input.text)).toMatchObject({ - model: "openai/gpt-4o-mini", + model: "xai/grok-4.6", stream: true, messages: [{ role: "user", content: "Say hello." }], }) @@ -90,7 +106,7 @@ describe("Cloudflare", () => { accountId: "test-account", gatewayId: "test-gateway", apiKey: "test-token", - }).model("anthropic/claude-sonnet-4.6") + }).model("xai/grok-4.6") const details = [ { type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 }, { type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 }, @@ -137,59 +153,19 @@ describe("Cloudflare", () => { }), ) - test("defaults AI Gateway id to default when omitted or blank", () => { - expect( - CloudflareAIGateway.configure({ - accountId: "test-account", - gatewayId: "", - gatewayApiKey: "test-token", - }).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL, - ).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat") - }) - - it.effect("supports authenticated AI Gateway plus upstream provider auth", () => - Effect.gen(function* () { - yield* LLM.generate( - LLM.request({ - model: CloudflareAIGateway.configure({ - accountId: "test-account", - gatewayApiKey: "gateway-token", - apiKey: "provider-token", - }).model("openai/gpt-4o-mini"), - prompt: "Say hello.", - }), - ).pipe( - Effect.provide( - dynamicResponse((input) => - Effect.gen(function* () { - const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(web.url).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat/chat/completions") - expect(web.headers.get("cf-aig-authorization")).toBe("Bearer gateway-token") - expect(web.headers.get("authorization")).toBe("Bearer provider-token") - return input.respond( - sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), - { headers: { "content-type": "text/event-stream" } }, - ) - }), - ), - ), - ) - }), - ) - it.effect("allows a fully configured baseURL override", () => Effect.gen(function* () { const prepared = yield* compileRequest( LLM.request({ model: CloudflareAIGateway.configure({ - baseURL: "https://gateway.proxy.test/v1/custom/compat", + baseURL: "https://gateway.proxy.test/v1", apiKey: "test-token", - }).model("openai/gpt-4o-mini"), + }).model("xai/grok-4.6"), prompt: "Say hello.", }), ) - expect(prepared.model.route.endpoint.baseURL).toBe("https://gateway.proxy.test/v1/custom/compat") + expect(prepared.model.route.endpoint.baseURL).toBe("https://gateway.proxy.test/v1") }), ) diff --git a/packages/core/package.json b/packages/core/package.json index 03f1c6a9672c..218979b4d87c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -81,6 +81,7 @@ } }, "devDependencies": { + "@ai-sdk/openai-compatible": "2.0.41", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@modelcontextprotocol/server": "2.0.0", @@ -100,14 +101,8 @@ "drizzle-kit": "catalog:" }, "dependencies": { - "@ai-sdk/alibaba": "1.0.17", - "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.88", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/gateway": "3.0.104", - "@ai-sdk/google-vertex": "4.0.128", - "@ai-sdk/mistral": "3.0.51", - "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", @@ -126,7 +121,6 @@ "@parcel/watcher": "2.5.1", "@silvia-odwyer/photon-node": "0.3.4", "@standard-schema/spec": "catalog:", - "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "diff": "catalog:", "drizzle-orm": "catalog:", diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index 5608d6b27efe..c5b2f69a8874 100644 --- a/packages/core/src/aisdk-native.ts +++ b/packages/core/src/aisdk-native.ts @@ -53,6 +53,7 @@ export function rewrite( const PACKAGES: Readonly> = { "@ai-sdk/amazon-bedrock": "@opencode/ai/providers/amazon-bedrock", + "@ai-sdk/alibaba": "@opencode/ai/providers/alibaba/chat", "@ai-sdk/anthropic": "@opencode/ai/providers/anthropic", "@ai-sdk/azure": "@opencode/ai/providers/azure/responses", "@ai-sdk/cerebras": "@opencode/ai/providers/cerebras", @@ -67,6 +68,7 @@ const PACKAGES: Readonly> = { "@ai-sdk/togetherai": "@opencode/ai/providers/togetherai", "@ai-sdk/xai": "@opencode/ai/providers/xai", "@openrouter/ai-sdk-provider": "@opencode/ai/providers/openrouter", + "ai-gateway-provider": "@opencode/ai/providers/cloudflare-ai-gateway", } const protocols = (name: string) => ({ @@ -83,6 +85,12 @@ const HOSTS: Readonly>>> = { "alibaba-token-plan": protocols("alibaba"), "alibaba-token-plan-cn": protocols("alibaba"), baseten: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/baseten" }, + "cloudflare-ai-gateway": { + "@ai-sdk/anthropic": "@opencode/ai/providers/cloudflare-ai-gateway", + "@ai-sdk/openai": "@opencode/ai/providers/cloudflare-ai-gateway", + "@ai-sdk/openai-compatible": "@opencode/ai/providers/cloudflare-ai-gateway", + "ai-gateway-provider": "@opencode/ai/providers/cloudflare-ai-gateway", + }, "cloudflare-workers-ai": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/cloudflare-workers-ai" }, deepseek: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/deepseek" }, "fireworks-ai": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/fireworks" }, diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 731bdabddb1a..d91e920fbe7c 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -1,4 +1,3 @@ -import { AlibabaPlugin } from "./provider/alibaba.js" import { AmazonBedrockPlugin } from "./provider/amazon-bedrock.js" import { AzurePlugin } from "./provider/azure.js" import { CerebrasPlugin } from "./provider/cerebras.js" @@ -19,7 +18,6 @@ import { NvidiaPlugin } from "./provider/nvidia.js" import { OllamaPlugin } from "./provider/ollama.js" import { OpenAIPlugin } from "./provider/openai.js" import { SnowflakeCortexPlugin } from "./provider/snowflake-cortex.js" -import { OpenAICompatiblePlugin } from "./provider/openai-compatible.js" import { OpencodePlugin } from "./provider/opencode.js" import { OpenRouterPlugin } from "./provider/openrouter.js" import { PerplexityPlugin } from "./provider/perplexity.js" @@ -33,7 +31,6 @@ import { ZenmuxPlugin } from "./provider/zenmux.js" import type { PluginInternal } from "./internal.js" export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ - AlibabaPlugin, AmazonBedrockPlugin, AzurePlugin, CerebrasPlugin, @@ -53,7 +50,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ OllamaPlugin, OpencodePlugin, SnowflakeCortexPlugin, - OpenAICompatiblePlugin, OpenAIPlugin, OpenRouterPlugin, PerplexityPlugin, diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts deleted file mode 100644 index fed3f14fcfec..000000000000 --- a/packages/core/src/plugin/provider/alibaba.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createProviderPlugin } from "./factory.js" - -export const AlibabaPlugin = createProviderPlugin({ - id: "opencode.provider.alibaba", - package: "@ai-sdk/alibaba", - load: async (options) => { - const { createAlibaba } = await import("@ai-sdk/alibaba") - return createAlibaba(options) - }, -}) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index 45d1ff7fa4c4..f62b1f894cd0 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,6 +1,4 @@ -import os from "os" -import { App } from "../../app.js" -import { Effect, Option, Schema } from "effect" +import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" import { Form } from "@opencode/schema/form" import { Provider } from "../../provider.js" @@ -49,75 +47,9 @@ export const CloudflareAIGatewayPlugin = define({ }, }) }) - yield* ctx.aisdk.hook( - "sdk", - Effect.fn(function* (evt) { - if (evt.package !== "ai-gateway-provider") return - if (evt.options.baseURL) return - - const config = gatewayConfig(evt.options) - if (!config) return - const metadata = gatewayMetadata(evt.options) - const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")) - const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")) - const gateway = createAiGateway({ - accountId: config.accountId, - gateway: config.gatewayId, - apiKey: config.apiKey, - options: gatewayOptions(evt.options, metadata, ctx.app), - } as any) - const unified = createUnified({ apiKey: config.apiKey }) - evt.sdk = { - languageModel(modelID: string) { - return gateway(unified(modelID)) - }, - } - }), - ) }), }) -type GatewayConfig = { - accountId: string - gatewayId: string - apiKey: string -} - -const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) - -function gatewayConfig(options: Record): GatewayConfig | undefined { - const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") - // Credential projection copies key metadata into options. The form stores the - // gateway as gatewayId, while older config examples may use gateway. - const gatewayId = - process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway") - const apiKey = process.env.CLOUDFLARE_API_TOKEN ?? process.env.CF_AIG_TOKEN ?? stringOption(options, "apiKey") - if (!accountId || !gatewayId || !apiKey) return undefined - - return { accountId, gatewayId, apiKey } -} - -function gatewayMetadata(options: Record) { - // Preserve the legacy cf-aig-metadata header escape hatch for gateway logging - // metadata, but prefer the typed metadata option when present. - if (options.metadata !== undefined) return options.metadata - const raw = (options.headers as Record | undefined)?.["cf-aig-metadata"] - return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined -} - -function gatewayOptions(options: Record, metadata: unknown, app: App.Info) { - return { - metadata, - cacheTtl: options.cacheTtl, - cacheKey: options.cacheKey, - skipCache: options.skipCache, - collectLog: options.collectLog, - headers: { - "User-Agent": `${App.useragent(app)} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, - }, - } -} - function stringOption(options: Record, key: string) { return typeof options[key] === "string" ? options[key] : undefined } diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 443921c735e6..1edfb10cd764 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,5 +1,3 @@ -import os from "os" -import { App } from "../../app.js" import { Effect } from "effect" import { define } from "@opencode/plugin/effect/plugin" import { Form } from "@opencode/schema/form" @@ -48,33 +46,6 @@ export const CloudflareWorkersAIPlugin = define({ : { ...provider.settings, baseURL: workersEndpoint(accountId) } }) }) - yield* ctx.aisdk.hook( - "sdk", - Effect.fn(function* (evt) { - if (evt.model.providerID !== providerID) return - if (evt.package !== "@ai-sdk/openai-compatible") return - - const accountId = resolveAccountId(evt.options) - if (!hasWorkersEndpoint(evt.model) && !accountId) return - const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) - evt.sdk = mod.createOpenAICompatible( - sdkOptions( - { - ...evt.options, - baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined), - }, - ctx.app, - ) as any, - ) - }), - ) - yield* ctx.aisdk.hook( - "language", - Effect.fn(function* (evt) { - if (evt.model.providerID !== providerID) return - evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id) - }), - ) }), }) @@ -86,31 +57,6 @@ function workersEndpoint(accountId: string) { return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1` } -function hasWorkersEndpoint(model: { - readonly package?: string - readonly settings?: Readonly> -}) { - return Provider.isAISDK(model.package) && typeof model.settings?.baseURL === "string" -} - -function sdkOptions(options: Record, app: App.Info) { - return { - ...options, - baseURL: expandAccountId(options.baseURL), - apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey, - headers: { - "User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, - ...options.headers, - }, - name: providerID, - } -} - -function expandAccountId(baseURL: unknown) { - if (typeof baseURL !== "string") return baseURL - return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}") -} - function stringOption(options: Record, key: string) { return typeof options[key] === "string" ? options[key] : undefined } diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts deleted file mode 100644 index 0e256c1b034f..000000000000 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Effect } from "effect" -import { define } from "@opencode/plugin/effect/plugin" - -export const OpenAICompatiblePlugin = define({ - id: "opencode.provider.openai.compatible", - effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", - Effect.fn(function* (evt) { - if (evt.sdk) return - if (!evt.package.includes("@ai-sdk/openai-compatible")) return - if (evt.options.includeUsage !== false) evt.options.includeUsage = true - const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) - evt.sdk = mod.createOpenAICompatible(evt.options as any) - }), - ) - }), -}) diff --git a/packages/core/src/variant.ts b/packages/core/src/variant.ts index 11a42ac010a6..969524a12b84 100644 --- a/packages/core/src/variant.ts +++ b/packages/core/src/variant.ts @@ -84,6 +84,13 @@ const responsesEffort = (effort: string): Overlay => ({ settings: { reasoningEffort: effort, reasoningSummary: "auto", include: ENCRYPTED_REASONING }, }) +const cloudflareAIGateway: Protocol = (model, support) => { + const id = modelID(model) + if (id.startsWith("openai/")) return openaiResponses(model, support) + if (id.startsWith("anthropic/")) return anthropicMessages(model, support) + return openaiChat(model, support) +} + const deepseekChat: Protocol = (_, support) => { switch (support.type) { case "effort": @@ -540,9 +547,8 @@ const PROTOCOLS: Readonly> = { "@opencode/ai/providers/openrouter": openrouter, [Provider.aisdk("venice-ai-sdk-provider")]: openaiChat, - [Provider.aisdk("ai-gateway-provider")]: openaiChat, + "@opencode/ai/providers/cloudflare-ai-gateway": cloudflareAIGateway, [Provider.aisdk("@ai-sdk/gateway")]: vercelGateway, [Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")]: sapAICore, - [Provider.aisdk("@ai-sdk/alibaba")]: alibabaAISDK, [Provider.aisdk("@ai-sdk/cohere")]: cohere, } diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index 09e67a1ddaa4..998dd1f12316 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test" import { AISDKNative } from "@opencode/core/aisdk-native" -function map(packageName: string, settings: Readonly>, modelID = "test-model") { +function map( + packageName: string, + settings: Readonly>, + modelID = "test-model", + providerID = "test-provider", +) { const target: { package?: string settings?: Record @@ -11,11 +16,24 @@ function map(packageName: string, settings: Readonly>, m package: `aisdk:${packageName}`, settings: { ...settings }, } - AISDKNative.rewrite(target, { specifier: target.package, providerID: "test-provider", modelID }) + AISDKNative.rewrite(target, { specifier: target.package, providerID, modelID }) return target.package?.startsWith("aisdk:") ? undefined : target } describe("AISDKNative", () => { + test("keeps Cloudflare AI Gateway models on its native gateway package", () => { + for (const packageName of [ + "ai-gateway-provider", + "@ai-sdk/openai", + "@ai-sdk/anthropic", + "@ai-sdk/openai-compatible", + ]) { + expect(map(packageName, {}, "openai/gpt-5.4", "cloudflare-ai-gateway")?.package).toBe( + "@opencode/ai/providers/cloudflare-ai-gateway", + ) + } + }) + test("maps OpenAI-family packages and request options to native providers", () => { expect( map("@ai-sdk/openai", { diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index ec362a6740b3..c1afc35b46b2 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -1,6 +1,5 @@ import { APICallError } from "@ai-sdk/provider" import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider" -import { createMistral } from "@ai-sdk/mistral" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" import { AISDK } from "@opencode/core/aisdk" import { SessionRunnerRetry } from "@opencode/core/session/runner/retry" @@ -533,118 +532,6 @@ it.effect("normalizes file data across AI SDK prompt parts", () => }), ) -it.effect("normalizes user and tool media through the real Mistral provider", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - let body: { messages?: unknown[] } | undefined - const mockFetch = Object.assign( - async (_input: Parameters[0], init?: RequestInit) => { - body = JSON.parse(String(init?.body)) - const chunks = [ - { - id: "response-1", - created: 0, - model: "pixtral-large-latest", - choices: [{ index: 0, delta: { content: [{ type: "text", text: "I see it." }] } }], - }, - { - id: "response-1", - created: 0, - model: "pixtral-large-latest", - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ] - return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), { - headers: { "Content-Type": "text/event-stream" }, - }) - }, - { preconnect: fetch.preconnect }, - ) - yield* aisdk.hook.sdk((event) => { - event.sdk = createMistral({ apiKey: "test", fetch: mockFetch }) - }) - - const resolved = yield* aisdk.model({ - ...model("@ai-sdk/mistral"), - modelID: Model.ID.make("pixtral-large-latest"), - }) - yield* LLMClient.generate( - LLM.request({ - model: resolved, - messages: [ - Message.user([ - { type: "text", text: "Inspect the attachments." }, - { type: "media", mediaType: "image/png", data: new Uint8Array([0, 1, 2, 3]) }, - { type: "media", mediaType: "image/png", data: "AQID" }, - { type: "media", mediaType: "image/png", data: "data:image/png;base64,BAUG" }, - { type: "media", mediaType: "image/png", data: "http://example.com/image.png" }, - { type: "media", mediaType: "application/pdf", data: "https://example.com/document.pdf" }, - ]), - Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }), - Message.tool({ - type: "tool-result", - id: "call_1", - name: "screenshot", - result: { - type: "content", - value: [ - { type: "text", text: "Screenshot captured" }, - { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" }, - { - type: "file", - uri: "https://example.com/tool-document.pdf", - mime: "application/pdf", - name: "tool-document.pdf", - }, - ], - }, - }), - ], - }), - ).pipe(Effect.provide(client)) - - expect(body?.messages).toEqual([ - { - role: "user", - content: [ - { type: "text", text: "Inspect the attachments." }, - { type: "image_url", image_url: "data:image/png;base64,AAECAw==" }, - { type: "image_url", image_url: "data:image/png;base64,AQID" }, - { type: "image_url", image_url: "data:image/png;base64,BAUG" }, - { type: "image_url", image_url: "http://example.com/image.png" }, - { type: "document_url", document_url: "https://example.com/document.pdf" }, - ], - }, - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { name: "screenshot", arguments: "{}" }, - }, - ], - }, - { - role: "tool", - name: "screenshot", - tool_call_id: "call_1", - content: '[{"type":"text","text":"Screenshot captured"}]', - }, - { - role: "user", - content: [ - { type: "text", text: "Attached media from tool result:" }, - { type: "image_url", image_url: "data:image/png;base64,AAAA" }, - { type: "document_url", document_url: "https://example.com/tool-document.pdf" }, - ], - }, - ]) - }), -) - it.effect("does not treat SSE comment heartbeats as model progress", () => Effect.gen(function* () { const aisdk = yield* AISDK.Service diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 353e93edf58e..29ddfe746e3a 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -1051,6 +1051,7 @@ describe("ModelResolver", () => { it.effect("never loads the AI SDK for packages with native implementations", () => Effect.gen(function* () { const packages = [ + ["@ai-sdk/alibaba", "@opencode/ai/providers/alibaba/chat", "api-model"], ["@ai-sdk/anthropic", "@opencode/ai/providers/anthropic", "api-model"], ["@ai-sdk/amazon-bedrock", "@opencode/ai/providers/amazon-bedrock", "api-model"], ["@ai-sdk/amazon-bedrock/mantle", "@opencode/ai/providers/amazon-bedrock/mantle/chat", "openai.gpt-oss-120b"], @@ -1067,6 +1068,7 @@ describe("ModelResolver", () => { ["@openrouter/ai-sdk-provider", "@opencode/ai/providers/openrouter", "api-model"], ["@ai-sdk/togetherai", "@opencode/ai/providers/togetherai", "api-model"], ["@ai-sdk/xai", "@opencode/ai/providers/xai", "api-model"], + ["ai-gateway-provider", "@opencode/ai/providers/cloudflare-ai-gateway", "xai/grok-4.6"], ] as const yield* Effect.forEach(packages, ([catalogPackage, nativePackage, modelID]) => diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index defb761edb37..1a122d2f6f96 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,13 +1,10 @@ -import { AISDK } from "@opencode/core/aisdk" -import { describe, expect, mock } from "bun:test" +import { describe, expect } from "bun:test" import { Effect } from "effect" -import { Model } from "@opencode/core/model" +import { Integration } from "@opencode/core/integration" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { CloudflareAIGatewayPlugin } from "@opencode/core/plugin/provider/cloudflare-ai-gateway" -import { Provider } from "@opencode/core/provider" import { withEnv } from "../fixture/env" -import { Integration } from "@opencode/core/integration" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -19,68 +16,6 @@ const addPlugin = Effect.fn(function* () { yield* CloudflareAIGatewayPlugin.effect(host) }) -const aiGatewayCalls: Record[] = [] -const unifiedCalls: string[] = [] -const gatewayModelCalls: unknown[] = [] - -function captureAiGatewayOptions(options: Record) { - const nested = - options.options && typeof options.options === "object" ? (options.options as Record) : undefined - return { - ...options, - ...(nested - ? { - options: { - ...nested, - headers: - nested.headers && typeof nested.headers === "object" - ? { ...(nested.headers as Record) } - : nested.headers, - }, - } - : {}), - } -} - -function resetCalls() { - aiGatewayCalls.length = 0 - unifiedCalls.length = 0 - gatewayModelCalls.length = 0 -} - -function cloudflareEnv(overrides: Record = {}) { - return { - CLOUDFLARE_ACCOUNT_ID: "env-account", - CLOUDFLARE_GATEWAY_ID: "env-gateway", - CLOUDFLARE_API_TOKEN: "env-token", - CF_AIG_TOKEN: undefined, - ...overrides, - } -} - -mock.module("ai-gateway-provider", () => ({ - createAiGateway(options: Record) { - aiGatewayCalls.push(captureAiGatewayOptions(options)) - return (input: unknown) => { - gatewayModelCalls.push(input) - return { - modelId: input, - provider: "cloudflare-ai-gateway", - specificationVersion: "v3", - } - } - }, -})) - -mock.module("ai-gateway-provider/providers/unified", () => ({ - createUnified() { - return (modelID: string) => { - unifiedCalls.push(modelID) - return { unifiedModelID: modelID } - } - }, -})) - describe("CloudflareAIGatewayPlugin", () => { it.effect("registers account and gateway forms when the environment does not provide them", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }, () => @@ -99,331 +34,15 @@ describe("CloudflareAIGatewayPlugin", () => { ), ) - it.effect("requires account, gateway, and token before creating the unified SDK", () => - withEnv( - { - CLOUDFLARE_ACCOUNT_ID: "acct", - CLOUDFLARE_GATEWAY_ID: "gateway", - CLOUDFLARE_API_TOKEN: "token", - CF_AIG_TOKEN: undefined, - }, - () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }) - expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined() - }), - ), - ) - - it.effect("passes legacy metadata, cache, log, and User-Agent values under the AI Gateway options key", () => - withEnv(cloudflareEnv(), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - metadata: { invoked_by: "test", project: "opencode" }, - cacheTtl: 300, - cacheKey: "cache-key", - skipCache: true, - collectLog: false, - }, - }) - - expect(aiGatewayCalls).toHaveLength(1) - expect(aiGatewayCalls[0]).toEqual({ - accountId: "env-account", - gateway: "env-gateway", - apiKey: "env-token", - options: { - metadata: { invoked_by: "test", project: "opencode" }, - cacheTtl: 300, - cacheKey: "cache-key", - skipCache: true, - collectLog: false, - headers: { - "User-Agent": expect.stringContaining("opencode/"), - }, - }, - }) - }), - ), - ) - - it.effect("parses legacy cf-aig-metadata header when metadata option is absent", () => - withEnv(cloudflareEnv(), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - headers: { - "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), - }, - }, - }) - - expect(aiGatewayCalls[0]?.options).toMatchObject({ - metadata: { invoked_by: "header", project: "opencode" }, - }) - }), - ), - ) - - it.effect("prefers Cloudflare env values over auth/config-derived options", () => - withEnv(cloudflareEnv(), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gateway: "auth-gateway", - apiKey: "auth-token", - }, - }) - - expect(aiGatewayCalls[0]).toMatchObject({ - accountId: "env-account", - gateway: "env-gateway", - apiKey: "env-token", - }) - }), - ), - ) - - it.effect("accepts gatewayId metadata copied from auth into provider options", () => - withEnv( - cloudflareEnv({ - CLOUDFLARE_ACCOUNT_ID: undefined, - CLOUDFLARE_GATEWAY_ID: undefined, - CLOUDFLARE_API_TOKEN: undefined, - }), - () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gatewayId: "auth-gateway", - apiKey: "auth-token", - }, - }) - - expect(aiGatewayCalls[0]).toMatchObject({ - accountId: "auth-account", - gateway: "auth-gateway", - apiKey: "auth-token", - }) - }), - ), - ) - - it.effect("falls back to CF_AIG_TOKEN when CLOUDFLARE_API_TOKEN is unset", () => - withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }) - - expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" }) - }), - ), - ) - - it.effect("does not create an SDK when account and gateway IDs are missing", () => - withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }) - - expect(result.sdk).toBeUndefined() - expect(aiGatewayCalls).toHaveLength(0) - }), - ), - ) - - it.effect("does not create an SDK when the token is missing", () => - withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }) - - expect(result.sdk).toBeUndefined() - expect(aiGatewayCalls).toHaveLength(0) - }), - ), - ) - - it.effect("does not replace a configured baseURL with the Cloudflare AI Gateway SDK", () => - withEnv( - cloudflareEnv({ - CLOUDFLARE_ACCOUNT_ID: undefined, - CLOUDFLARE_GATEWAY_ID: undefined, - CLOUDFLARE_API_TOKEN: undefined, - }), - () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - const catalog = yield* Provider.Service - yield* catalog.transform((catalog) => - catalog.update(Provider.ID.make("cloudflare-ai-gateway"), (provider) => { - provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } - }), - ) - yield* addPlugin() - const integrations = yield* Integration.Service - expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({ - type: "key", - label: "Gateway API token", - }) - - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, - }) - - expect(result.sdk).toBeUndefined() - expect(aiGatewayCalls).toHaveLength(0) - }), - ), - ) - - it.effect("maps provider/model IDs through the unified Cloudflare provider unchanged", () => - withEnv(cloudflareEnv(), () => - Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default( - Provider.ID.make("cloudflare-ai-gateway"), - Model.ID.make("anthropic/claude-sonnet-4-5"), - ), - modelID: Model.ID.make("anthropic/claude-sonnet-4-5"), - package: "aisdk:test-provider", - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }) - - expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({ - modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" }, - provider: "cloudflare-ai-gateway", - specificationVersion: "v3", - }) - expect(unifiedCalls).toEqual(["anthropic/claude-sonnet-4-5"]) - expect(gatewayModelCalls).toEqual([{ unifiedModelID: "anthropic/claude-sonnet-4-5" }]) - }), - ), - ) - - it.effect("ignores non Cloudflare AI Gateway packages", () => - withEnv(cloudflareEnv(), () => + it.effect("uses environment account and gateway values without requesting form fields", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_GATEWAY_ID: "gateway" }, () => Effect.gen(function* () { - resetCalls() - const aisdk = yield* AISDK.Service yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")), - modelID: Model.ID.make("openai/gpt-5"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-ai-gateway" }, + const integrations = yield* Integration.Service + expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({ + type: "key", + label: "Gateway API token", }) - - expect(result.sdk).toBeUndefined() - expect(aiGatewayCalls).toHaveLength(0) }), ), ) diff --git a/packages/core/test/plugin/provider-factory.test.ts b/packages/core/test/plugin/provider-factory.test.ts index bcc885befdc7..19a40a14a772 100644 --- a/packages/core/test/plugin/provider-factory.test.ts +++ b/packages/core/test/plugin/provider-factory.test.ts @@ -4,7 +4,6 @@ import { AISDK } from "@opencode/core/aisdk" import { Model } from "@opencode/core/model" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" -import { AlibabaPlugin } from "@opencode/core/plugin/provider/alibaba" import { CoherePlugin } from "@opencode/core/plugin/provider/cohere" import { GatewayPlugin } from "@opencode/core/plugin/provider/gateway" import { PerplexityPlugin } from "@opencode/core/plugin/provider/perplexity" @@ -16,7 +15,6 @@ import { PluginTestLayer } from "./fixture" const modelID = Model.ID.make("test-model") const options = { name: "custom-provider", apiKey: "test", baseURL: "https://example.test" } const providers = [ - { id: "alibaba", plugin: AlibabaPlugin, package: "@ai-sdk/alibaba", provider: "alibaba.chat" }, { id: "cohere", plugin: CoherePlugin, package: "@ai-sdk/cohere", provider: "cohere.chat" }, { id: "gateway", plugin: GatewayPlugin, package: "@ai-sdk/gateway", provider: "gateway" }, { id: "perplexity", plugin: PerplexityPlugin, package: "@ai-sdk/perplexity", provider: "perplexity" }, diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts deleted file mode 100644 index f9a4b815c31b..000000000000 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { AISDK } from "@opencode/core/aisdk" -import { describe, expect } from "bun:test" -import { Effect } from "effect" -import { Model } from "@opencode/core/model" -import { Plugin } from "@opencode/core/plugin" -import { PluginHost } from "@opencode/core/plugin/host" -import { OpenAICompatiblePlugin } from "@opencode/core/plugin/provider/openai-compatible" -import { Provider } from "@opencode/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* Plugin.Service - const host = yield* PluginHost.make(plugin) - yield* OpenAICompatiblePlugin.effect(host) -}) - -describe("OpenAICompatiblePlugin", () => { - it.effect("preserves explicit includeUsage false and defaults it to true", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - yield* addPlugin() - const defaulted = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("model")), - modelID: Model.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom" }, - }) - const disabled = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("model")), - modelID: Model.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom", includeUsage: false }, - }) - expect(defaulted.options.includeUsage).toBe(true) - expect(disabled.options.includeUsage).toBe(false) - }), - ) - - it.effect("defaults includeUsage for OpenAI-compatible package matches", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("model")), - modelID: Model.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", - options: { name: "custom" }, - }) - expect(result.options.includeUsage).toBe(true) - }), - ) - - it.effect("uses the provider ID as the OpenAI-compatible provider name", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - const observed: string[] = [] - yield* addPlugin() - yield* aisdk.hook.sdk((event) => - Effect.sync(() => { - observed.push(event.sdk.languageModel("model").provider) - }), - ) - yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("custom-provider"), Model.ID.make("model")), - modelID: Model.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom-provider", baseURL: "https://example.com/v1" }, - }) - expect(observed).toEqual(["custom-provider.chat"]) - }), - ) - - it.effect("does not overwrite an SDK created by an earlier provider-specific plugin", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - const sentinel = { languageModel: (modelID: string) => ({ modelID }) } - yield* aisdk.hook.sdk((event) => { - event.sdk = sentinel - }) - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("model")), - modelID: Model.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai" }, - }) - expect(result.sdk).toBe(sentinel) - }), - ) -}) diff --git a/packages/core/test/provider-mistral.test.ts b/packages/core/test/provider-mistral.test.ts deleted file mode 100644 index 6e3176695f67..000000000000 --- a/packages/core/test/provider-mistral.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { createMistral } from "@ai-sdk/mistral" -import { expect, test } from "bun:test" - -test("Mistral sends promptCacheKey as prompt_cache_key", async () => { - let body: Record | undefined - const mockFetch = Object.assign( - async (_input: Parameters[0], init?: RequestInit) => { - body = JSON.parse(String(init?.body)) - return Response.json({ - id: "response-1", - created: 0, - model: "mistral-large-latest", - object: "chat.completion", - choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }) - }, - { preconnect: fetch.preconnect }, - ) - const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-large-latest") - - await model.doGenerate({ - prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], - providerOptions: { mistral: { promptCacheKey: "session-123" } }, - }) - - expect(body?.prompt_cache_key).toBe("session-123") -}) - -test("Mistral round-trips native reasoning in assistant history", async () => { - let body: { messages?: unknown[] } | undefined - const mockFetch = Object.assign( - async (_input: Parameters[0], init?: RequestInit) => { - body = JSON.parse(String(init?.body)) - return Response.json({ - id: "response-1", - created: 0, - model: "mistral-small-latest", - object: "chat.completion", - choices: [ - { - index: 0, - message: { - role: "assistant", - content: [ - { - type: "thinking", - thinking: [ - { type: "text", text: "The user is greeting me." }, - { - type: "tool_reference", - tool: "web_search", - title: "Example result", - url: "https://example.com/tool", - favicon: "https://example.com/favicon.ico", - description: "Example description", - }, - { type: "reference", reference_ids: [1, "source-2"] }, - ], - closed: true, - signature: "sig-123", - }, - { type: "text", text: "Hi" }, - ], - }, - finish_reason: "stop", - }, - ], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }) - }, - { preconnect: fetch.preconnect }, - ) - const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") - - const first = await model.doGenerate({ - prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], - }) - const reasoning = first.content.find((part) => part.type === "reasoning") - const text = first.content.find((part) => part.type === "text") - if (!reasoning || !text) throw new Error("expected reasoning and text") - - await model.doGenerate({ - prompt: [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - { - role: "assistant", - content: [{ ...reasoning, providerOptions: reasoning.providerMetadata }, text], - }, - { role: "user", content: [{ type: "text", text: "Hello again" }] }, - ], - }) - - expect(body?.messages?.[1]).toEqual({ - role: "assistant", - content: [ - { - type: "thinking", - thinking: [ - { type: "text", text: "The user is greeting me." }, - { - type: "tool_reference", - tool: "web_search", - title: "Example result", - url: "https://example.com/tool", - favicon: "https://example.com/favicon.ico", - description: "Example description", - }, - { type: "reference", reference_ids: [1, "source-2"] }, - ], - closed: true, - signature: "sig-123", - }, - { type: "text", text: "Hi" }, - ], - }) - - await model.doGenerate({ - prompt: [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - { - role: "assistant", - content: [ - { type: "reasoning", text: "thinking" }, - { type: "text", text: "Hi" }, - ], - }, - { role: "user", content: [{ type: "text", text: "Hello again" }] }, - ], - }) - expect(body?.messages?.[1]).toEqual({ role: "assistant", content: "thinkingHi" }) -}) - -test("Mistral preserves native reasoning metadata while streaming", async () => { - const chunks = [ - { - id: "response-1", - created: 0, - model: "mistral-small-latest", - choices: [ - { - index: 0, - delta: { - role: "assistant", - content: [ - { - type: "thinking", - thinking: [ - { type: "text", text: "thinking" }, - { - type: "tool_reference", - tool: "web_search", - title: "Example result", - url: "https://example.com/tool", - favicon: "https://example.com/favicon.ico", - description: "Example description", - }, - ], - }, - ], - }, - }, - ], - }, - { - id: "response-1", - created: 0, - model: "mistral-small-latest", - choices: [ - { - index: 0, - delta: { - content: [ - { - type: "thinking", - thinking: [{ type: "reference", reference_ids: [1, "source-2"] }], - closed: true, - signature: "sig-123", - }, - ], - }, - }, - ], - }, - { - id: "response-1", - created: 0, - model: "mistral-small-latest", - choices: [{ index: 0, delta: { content: [{ type: "text", text: "answer" }] } }], - }, - { - id: "response-1", - created: 0, - model: "mistral-small-latest", - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ] - const mockFetch = Object.assign( - async () => - new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), { - headers: { "Content-Type": "text/event-stream" }, - }), - { preconnect: fetch.preconnect }, - ) - const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") - const result = await model.doStream({ - prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], - }) - const events = [] - for await (const event of result.stream) events.push(event) - - expect(events.find((event) => event.type === "reasoning-end")?.providerMetadata).toEqual({ - mistral: { - thinking: { - type: "thinking", - thinking: [ - { type: "text", text: "thinking" }, - { - type: "tool_reference", - tool: "web_search", - title: "Example result", - url: "https://example.com/tool", - favicon: "https://example.com/favicon.ico", - description: "Example description", - }, - { type: "reference", reference_ids: [1, "source-2"] }, - ], - closed: true, - signature: "sig-123", - }, - }, - }) - expect( - events - .filter((event) => event.type === "reasoning-start" || event.type === "reasoning-delta") - .every((event) => event.providerMetadata === undefined), - ).toBe(true) -}) - -test("Mistral preserves metadata-only thinking chunks", async () => { - const thinking = { - type: "thinking" as const, - thinking: [ - { - type: "tool_reference", - tool: "web_search", - title: "Example result", - url: "https://example.com/tool", - favicon: "https://example.com/favicon.ico", - description: "Example description", - }, - { type: "reference", reference_ids: [1, "source-2"] }, - ], - closed: true, - signature: "sig-123", - } - const mockFetch = Object.assign( - async () => - Response.json({ - id: "response-1", - created: 0, - model: "mistral-small-latest", - object: "chat.completion", - choices: [{ index: 0, message: { role: "assistant", content: [thinking] }, finish_reason: "stop" }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }), - { preconnect: fetch.preconnect }, - ) - const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") - const result = await model.doGenerate({ - prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], - }) - - expect(result.content).toEqual([ - { - type: "reasoning", - text: "", - providerMetadata: { mistral: { thinking } }, - }, - ]) -}) diff --git a/packages/core/test/variant.test.ts b/packages/core/test/variant.test.ts index 85050e953bb1..f4fe560ddce0 100644 --- a/packages/core/test/variant.test.ts +++ b/packages/core/test/variant.test.ts @@ -95,6 +95,28 @@ test("recognizes Claude version spellings and future models", () => { ) }) +test("spells Cloudflare AI Gateway variants for their upstream routes", () => { + const pkg = "@opencode/ai/providers/cloudflare-ai-gateway" + expect(resolve(model(pkg, "openai/gpt-5.4"), [{ type: "effort", values: ["low", "xhigh"] }])).toEqual([ + { + id: "low", + settings: { reasoningEffort: "low", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] }, + }, + { + id: "xhigh", + settings: { reasoningEffort: "xhigh", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] }, + }, + ]) + expect(resolve(model(pkg, "anthropic/claude-sonnet-4-6"), [{ type: "effort", values: ["low", "high"] }])).toEqual([ + { id: "low", settings: { effort: "low", thinking: { type: "adaptive", display: "summarized" } } }, + { id: "high", settings: { effort: "high", thinking: { type: "adaptive", display: "summarized" } } }, + ]) + expect(resolve(model(pkg, "xai/grok-4.6"), [{ type: "effort", values: ["low", "high"] }])).toEqual([ + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + ]) +}) + test("spells Chat Completions variants for direct providers", () => { expect( resolve(model("@opencode/ai/providers/deepseek", "deepseek-v4-flash"), [