From ae494ffce58293ed79d713000fb331e66590d06b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:27:14 -0500 Subject: [PATCH] fix(core): retire legacy provider aliases (#40487) --- packages/ai/src/providers/azure.ts | 45 ++- .../src/providers/google-vertex-messages.ts | 3 +- packages/ai/test/provider-package.test.ts | 21 ++ .../ai/test/provider/google-vertex.test.ts | 12 +- packages/core/src/aisdk-native.ts | 15 + packages/core/src/catalog.ts | 2 +- packages/core/src/config/plugin/provider.ts | 150 +++++----- packages/core/src/plugin/models-dev.ts | 21 +- packages/core/src/plugin/provider.ts | 6 +- packages/core/src/plugin/provider/azure.ts | 71 ++--- .../core/src/plugin/provider/google-vertex.ts | 75 ++--- packages/core/src/session/runner/model.ts | 4 + packages/core/src/v1/config/migrate.ts | 65 ++++- packages/core/test/aisdk-native.test.ts | 25 ++ packages/core/test/config/config.test.ts | 105 +++++++ packages/core/test/location-layer.test.ts | 44 +++ packages/core/test/model-resolver.test.ts | 62 +++- packages/core/test/plugin/models-dev.test.ts | 66 ++++- .../provider-azure-cognitive-services.test.ts | 205 ------------- .../core/test/plugin/provider-azure.test.ts | 50 ++++ .../provider-google-vertex-anthropic.test.ts | 271 ------------------ .../plugin/provider-google-vertex.test.ts | 21 ++ 22 files changed, 634 insertions(+), 705 deletions(-) delete mode 100644 packages/core/test/plugin/provider-azure-cognitive-services.test.ts delete mode 100644 packages/core/test/plugin/provider-google-vertex-anthropic.test.ts diff --git a/packages/ai/src/providers/azure.ts b/packages/ai/src/providers/azure.ts index cf17808f9cc..e3cac473b5c 100644 --- a/packages/ai/src/providers/azure.ts +++ b/packages/ai/src/providers/azure.ts @@ -5,6 +5,7 @@ import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" +import { ProviderShared } from "../protocols/shared" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" export const id = ProviderID.make("azure") @@ -19,7 +20,7 @@ export type LanguageModelOptions = AzureURL & ProviderAuthOption<"optional"> & { readonly apiVersion?: string readonly queryParams?: Record - readonly useCompletionUrls?: boolean + readonly useDeploymentBasedUrls?: boolean readonly providerOptions?: OpenAIProviderOptionsInput } export type Config = LanguageModelOptions @@ -29,27 +30,22 @@ export type Settings = ProviderPackage.Settings & readonly apiKey?: string readonly apiVersion?: string readonly queryParams?: Readonly> + readonly useDeploymentBasedUrls?: boolean readonly providerOptions?: OpenAIProviderOptionsInput } -const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1` +const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai` const responsesRoute = OpenAIResponses.route.with({ id: "azure-openai-responses", provider: id, auth: routeAuth, - endpoint: { - query: { "api-version": "v1" }, - }, }) const chatRoute = OpenAIChat.route.with({ id: "azure-openai-chat", provider: id, auth: routeAuth, - endpoint: { - query: { "api-version": "v1" }, - }, }) export const routes = [responsesRoute, chatRoute] @@ -59,7 +55,7 @@ const defaults = (input: Config) => { apiKey: _, apiVersion: _apiVersion, resourceName: _resourceName, - useCompletionUrls: _useCompletionUrls, + useDeploymentBasedUrls: _useDeploymentBasedUrls, baseURL: _baseURL, queryParams: _queryParams, ...rest @@ -80,37 +76,39 @@ const auth = (input: Config) => { ) } -const configuredRoute = (route: RouteDef, input: Config) => +const configuredRoute = (route: RouteDef, input: Config, modelID: string | ModelID) => route.with({ auth: auth(input), - endpoint: { - // AtLeastOne guarantees at least one is set; baseURL wins if both are. - baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!), - query: { - ...(input.apiVersion ? { "api-version": input.apiVersion } : {}), - ...input.queryParams, - }, - }, + endpoint: endpoint(input, modelID), }) +function endpoint(input: Config, modelID: string | ModelID) { + const baseURL = ProviderShared.trimBaseUrl(input.baseURL ?? resourceBaseURL(input.resourceName!)) + const query = { "api-version": input.apiVersion ?? "v1", ...input.queryParams } + + if (input.useDeploymentBasedUrls) return { baseURL: `${baseURL}/deployments/${modelID}`, query } + if (input.baseURL !== undefined && !new URL(input.baseURL).hostname.endsWith(".openai.azure.com")) { + return { baseURL, query: input.queryParams } + } + return { baseURL: `${baseURL}/v1`, query } +} + export const configure = (input: Config) => { - const configuredResponsesRoute = configuredRoute(responsesRoute, input) - const configuredChatRoute = configuredRoute(chatRoute, input) const modelDefaults = defaults(input) const responses = (modelID: string | ModelID) => - configuredResponsesRoute + configuredRoute(responsesRoute, input, modelID) .with(withOpenAIOptions(modelID, modelDefaults)) .model({ id: modelID }) const chat = (modelID: string | ModelID) => - configuredChatRoute + configuredRoute(chatRoute, input, modelID) .with(withOpenAIOptions(modelID, modelDefaults)) .model({ id: modelID }) return { id, - model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)), + model: responses, responses, chat, configure, @@ -131,6 +129,7 @@ const config = (settings: Settings): Config => { limits: settings.limits, providerOptions: settings.providerOptions, queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, + useDeploymentBasedUrls: settings.useDeploymentBasedUrls, } if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL } if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName } diff --git a/packages/ai/src/providers/google-vertex-messages.ts b/packages/ai/src/providers/google-vertex-messages.ts index 9dbbc441a1c..abd5d0f2108 100644 --- a/packages/ai/src/providers/google-vertex-messages.ts +++ b/packages/ai/src/providers/google-vertex-messages.ts @@ -15,8 +15,7 @@ export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput const VERSION = "vertex-2023-10-16" as const -// models.dev uses this provider id even though the API contract is Anthropic Messages. -export const id = ProviderID.make("google-vertex-anthropic") +export const id = ProviderID.make("google-vertex") export type Config = RouteDefaultsInput & GoogleVertexShared.OAuthOptions & { diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index 1e5caf308cd..73c65709645 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -209,6 +209,27 @@ describe("provider package entrypoints", () => { expect(chat.route.id).toBe("azure-openai-chat") }) + test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => { + const Azure = await import("@opencode-ai/ai/providers/azure") + const deployment = Azure.model("custom-deployment", { + apiKey: "fixture", + resourceName: "opencode-test", + apiVersion: "2025-01-01-preview", + useDeploymentBasedUrls: true, + }) + const gateway = Azure.model("gateway-model", { + apiKey: "fixture", + baseURL: "https://gateway.example/azure/", + }) + + expect(deployment.route.endpoint).toMatchObject({ + baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment", + query: { "api-version": "2025-01-01-preview" }, + }) + expect(gateway.route.endpoint.baseURL).toBe("https://gateway.example/azure") + expect(gateway.route.endpoint.query).toBeUndefined() + }) + test("maps Google package settings onto the Gemini model", async () => { const Google = await import("@opencode-ai/ai/providers/google") const selected = Google.model("gemini-2.5-flash", { diff --git a/packages/ai/test/provider/google-vertex.test.ts b/packages/ai/test/provider/google-vertex.test.ts index f260f3577b7..5aa29d4bc56 100644 --- a/packages/ai/test/provider/google-vertex.test.ts +++ b/packages/ai/test/provider/google-vertex.test.ts @@ -56,13 +56,14 @@ describe("Google Vertex providers", () => { it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () => Effect.gen(function* () { + const model = GoogleVertexMessages.configure({ + accessToken: "vertex-token", + location: "eu", + project: "vertex-project", + }).model("claude-sonnet-4-6") const response = yield* LLMClient.generate( LLM.request({ - model: GoogleVertexMessages.configure({ - accessToken: "vertex-token", - location: "eu", - project: "vertex-project", - }).model("claude-sonnet-4-6"), + model, prompt: "Say hello.", }), ).pipe( @@ -97,6 +98,7 @@ describe("Google Vertex providers", () => { ), ) + expect(model.provider).toBe("google-vertex") expect(response.text).toBe("Hello.") }), ) diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index 61502d8bc1e..de27a782537 100644 --- a/packages/core/src/aisdk-native.ts +++ b/packages/core/src/aisdk-native.ts @@ -27,6 +27,21 @@ export function map(input: MapInput): Mapping | undefined { } case "@ai-sdk/amazon-bedrock/mantle": return mapBedrockMantle(input, baseSettings) + case "@ai-sdk/azure": + return { + package: `@opencode-ai/ai/providers/azure/${input.settings.useCompletionUrls === true ? "chat" : "responses"}`, + settings: { + ...baseSettings, + ...mapAPIKey(input.settings), + ...(typeof input.settings.resourceName === "string" ? { resourceName: input.settings.resourceName } : {}), + ...(typeof input.settings.apiVersion === "string" ? { apiVersion: input.settings.apiVersion } : {}), + ...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}), + ...(typeof input.settings.useDeploymentBasedUrls === "boolean" + ? { useDeploymentBasedUrls: input.settings.useDeploymentBasedUrls } + : {}), + ...mapOpenAIOptions(input.settings), + }, + } case "@ai-sdk/google": return { package: "@opencode-ai/ai/providers/google", diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 9bc9611cf89..3314769d04f 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -213,7 +213,7 @@ const layer = Layer.effect( const provider = record.provider // TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments. - if (providerID === Provider.ID.azure || providerID === Provider.ID.make("azure-cognitive-services")) { + if (providerID === Provider.ID.azure) { return } diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index ec00ddf36c7..5593c69b8fe 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -12,96 +12,84 @@ export const Plugin = define({ const config = yield* Config.Service const loaded = { entries: yield* config.entries() } yield* ctx.integration.transform((integrations) => { - const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document") const configuredIntegrations = new Set( - files.flatMap((file) => - Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => - provider.env === undefined ? [] : [id], - ), - ), + configuredProviders(loaded.entries).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])), ) - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const integrationID = id - if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue - integrations.update(integrationID, (integration) => { - integration.name = item.name ?? integration.name + for (const [id, provider] of configuredProviders(loaded.entries)) { + const integrationID = id + if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue + integrations.update(integrationID, (integration) => { + integration.name = provider.name ?? integration.name + }) + if (provider.env !== undefined) { + integrations.method.update({ + integrationID, + method: { type: "env", names: [...provider.env] }, }) - if (item.env !== undefined) { - integrations.method.update({ - integrationID, - method: { type: "env", names: [...item.env] }, - }) - } } } }) yield* ctx.catalog.transform((catalog) => { - const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document") const configuredDefault = Config.latest(loaded.entries, "model") if (configuredDefault !== undefined) catalog.model.default.set(configuredDefault.providerID, configuredDefault.model) - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const providerID = id - catalog.provider.update(providerID, (provider) => { - if (item.name !== undefined) provider.name = item.name - if (item.package !== undefined) provider.package = item.package - if (item.settings !== undefined) - provider.settings = Provider.mergeOverlay(provider.settings, item.settings) - if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers) - if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body) + for (const [id, item] of configuredProviders(loaded.entries)) { + const providerID = id + catalog.provider.update(providerID, (provider) => { + if (item.name !== undefined) provider.name = item.name + if (item.package !== undefined) provider.package = item.package + if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings) + if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers) + if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body) + }) + for (const [id, config] of Object.entries(item.models ?? {})) { + catalog.model.update(providerID, id, (model) => { + if (config.family !== undefined) model.family = config.family + if (config.name !== undefined) model.name = config.name + if (config.modelID !== undefined) model.modelID = config.modelID + if (config.compatibility !== undefined) + model.compatibility = { ...model.compatibility, ...config.compatibility } + if (config.package !== undefined) model.package = config.package + if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings) + if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers) + if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body) + if (config.capabilities !== undefined) { + model.capabilities = { + tools: config.capabilities.tools, + input: [...config.capabilities.input], + output: [...config.capabilities.output], + } + } + if (config.variants !== undefined) { + model.variants ??= [] + for (const variant of config.variants) { + let existing = model.variants.find((item) => item.id === variant.id) + if (!existing) { + existing = { id: variant.id } + model.variants.push(existing) + } + if (variant.settings !== undefined) + existing.settings = Provider.mergeOverlay(existing.settings, variant.settings) + if (variant.headers !== undefined) + existing.headers = Provider.mergeHeaders(existing.headers, variant.headers) + if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body) + } + } + if (config.cost !== undefined) { + model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ + tier: cost.tier && { ...cost.tier }, + input: cost.input, + output: cost.output, + cache: { + read: cost.cache?.read ?? Money.USDPerMillionTokens.zero, + write: cost.cache?.write ?? Money.USDPerMillionTokens.zero, + }, + })) + } + if (config.disabled !== undefined) model.enabled = !config.disabled + if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } }) - for (const [id, config] of Object.entries(item.models ?? {})) { - catalog.model.update(providerID, id, (model) => { - if (config.family !== undefined) model.family = config.family - if (config.name !== undefined) model.name = config.name - if (config.modelID !== undefined) model.modelID = config.modelID - if (config.compatibility !== undefined) - model.compatibility = { ...model.compatibility, ...config.compatibility } - if (config.package !== undefined) model.package = config.package - if (config.settings !== undefined) - model.settings = Provider.mergeOverlay(model.settings, config.settings) - if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers) - if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body) - if (config.capabilities !== undefined) { - model.capabilities = { - tools: config.capabilities.tools, - input: [...config.capabilities.input], - output: [...config.capabilities.output], - } - } - if (config.variants !== undefined) { - model.variants ??= [] - for (const variant of config.variants) { - let existing = model.variants.find((item) => item.id === variant.id) - if (!existing) { - existing = { id: variant.id } - model.variants.push(existing) - } - if (variant.settings !== undefined) - existing.settings = Provider.mergeOverlay(existing.settings, variant.settings) - if (variant.headers !== undefined) - existing.headers = Provider.mergeHeaders(existing.headers, variant.headers) - if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body) - } - } - if (config.cost !== undefined) { - model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ - tier: cost.tier && { ...cost.tier }, - input: cost.input, - output: cost.output, - cache: { - read: cost.cache?.read ?? Money.USDPerMillionTokens.zero, - write: cost.cache?.write ?? Money.USDPerMillionTokens.zero, - }, - })) - } - if (config.disabled !== undefined) model.enabled = !config.disabled - if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } - }) - } } } }) @@ -118,3 +106,9 @@ export const Plugin = define({ ) }), }) + +function configuredProviders(entries: readonly Config.Entry[]) { + return entries + .filter((entry): entry is Config.Document => entry.type === "document") + .flatMap((file) => Object.entries(file.info.providers ?? {})) +} diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index b643cfd7558..3c40df7c7a3 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -3,13 +3,14 @@ import { Integration } from "@opencode-ai/schema/integration" import { Effect, Stream } from "effect" import { Bus } from "../bus" import { ModelsDev } from "../models-dev" +import { Provider } from "../provider" export const ModelsDevPlugin = define({ id: "opencode.models-dev", effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service const bus = yield* Bus.Service - const loaded = { data: structuredClone(yield* modelsDev.get()) } + const loaded = { data: snapshots(yield* modelsDev.get()) } yield* ctx.integration.transform((integrations) => { for (const provider of loaded.data) { if (provider.environment.length === 0) continue @@ -21,7 +22,10 @@ export const ModelsDevPlugin = define({ }) integrations.method.update({ integrationID, - method: { type: "env", names: [...provider.environment] }, + method: { + type: "env", + names: environmentNames(provider), + }, }) } }) @@ -39,7 +43,7 @@ export const ModelsDevPlugin = define({ yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe( Stream.runForEach(() => modelsDev.get().pipe( - Effect.tap((data) => Effect.sync(() => (loaded.data = structuredClone(data)))), + Effect.tap((data) => Effect.sync(() => (loaded.data = snapshots(data)))), Effect.andThen(ctx.integration.reload()), Effect.andThen(ctx.catalog.reload()), ), @@ -48,3 +52,14 @@ export const ModelsDevPlugin = define({ ) }), }) + +function environmentNames(provider: ModelsDev.Snapshot) { + if (provider.info.id !== Provider.ID.azure) return [...provider.environment] + return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"] +} + +function snapshots(data: readonly ModelsDev.Snapshot[]) { + return structuredClone(data).filter( + (provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic", + ) +} diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 876976059a9..3fd0f0de25b 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -1,7 +1,7 @@ import { AlibabaPlugin } from "./provider/alibaba" import { AmazonBedrockPlugin } from "./provider/amazon-bedrock" import { AnthropicPlugin } from "./provider/anthropic" -import { AzureCognitiveServicesPlugin, AzurePlugin } from "./provider/azure" +import { AzurePlugin } from "./provider/azure" import { CerebrasPlugin } from "./provider/cerebras" import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway" import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai" @@ -11,7 +11,7 @@ import { DynamicProviderPlugin } from "./provider/dynamic" import { GatewayPlugin } from "./provider/gateway" import { GithubCopilotPlugin } from "./provider/github-copilot" import { GitLabPlugin } from "./provider/gitlab" -import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/google-vertex" +import { GoogleVertexPlugin } from "./provider/google-vertex" import { GroqPlugin } from "./provider/groq" import { KiloPlugin } from "./provider/kilo" import { LLMGatewayPlugin } from "./provider/llmgateway" @@ -35,7 +35,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ AlibabaPlugin, AmazonBedrockPlugin, AnthropicPlugin, - AzureCognitiveServicesPlugin, AzurePlugin, CerebrasPlugin, CloudflareAIGatewayPlugin, @@ -45,7 +44,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ GatewayPlugin, GithubCopilotPlugin, GitLabPlugin, - GoogleVertexAnthropicPlugin, GoogleVertexPlugin, GroqPlugin, KiloPlugin, diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index ccc12eef7f4..d2cd18897e8 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -15,15 +15,28 @@ export const AzurePlugin = define({ effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@ai-sdk/azure") continue - const configured = item.provider.settings?.resourceName - const resourceName = - typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME + if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure") + continue + const resourceName = resolveResourceName(item.provider.settings) if (!resourceName) continue evt.provider.update(item.provider.id, (provider) => { - provider.settings = { ...provider.settings, resourceName } + provider.settings = { + ...provider.settings, + resourceName, + ...(typeof provider.settings?.baseURL === "string" + ? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) } + : {}), + } }) + for (const model of item.models.values()) { + evt.model.update(item.provider.id, model.id, (draft) => { + if (typeof draft.settings?.baseURL !== "string") return + draft.settings.baseURL = expandResourceName( + draft.settings.baseURL, + resolveResourceName(draft.settings, resourceName) ?? resourceName, + ) + }) + } } }) yield* ctx.aisdk.hook( @@ -36,9 +49,7 @@ export const AzurePlugin = define({ !evt.options.baseURL && (!Provider.isAISDK(evt.model.package) || typeof evt.model.settings?.baseURL !== "string") ) { - throw new Error( - "AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it", - ) + throw new Error("Azure resource name is missing; set AZURE_RESOURCE_NAME or configure resourceName/baseURL") } } const mod = yield* Effect.promise(() => import("@ai-sdk/azure")) @@ -59,34 +70,14 @@ export const AzurePlugin = define({ }), }) -export const AzureCognitiveServicesPlugin = define({ - id: "opencode.provider.azure-cognitive-services", - effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform((evt) => { - const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME - if (!resourceName) return - for (const item of evt.provider.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue - if (!item.provider.id.includes("azure-cognitive-services")) continue - evt.provider.update(item.provider.id, (provider) => { - provider.settings = { - ...provider.settings, - baseURL: `https://${resourceName}.cognitiveservices.azure.com/openai`, - } - }) - } - }) - yield* ctx.aisdk.hook( - "language", - Effect.fn(function* (evt) { - if (evt.model.providerID !== Provider.ID.make("azure-cognitive-services")) return - evt.language = selectLanguage( - evt.sdk, - evt.model.modelID ?? evt.model.id, - Boolean(evt.options.useCompletionUrls), - ) - }), - ) - }), -}) +function resolveResourceName(settings: Readonly> | undefined, fallback?: string) { + const configured = settings?.resourceName + if (typeof configured === "string" && configured.trim() !== "") return configured + return fallback ?? process.env.AZURE_RESOURCE_NAME ?? process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME +} + +function expandResourceName(baseURL: string, resourceName: string) { + return baseURL + .replaceAll("${AZURE_RESOURCE_NAME}", resourceName) + .replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName) +} diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 8014f2662c0..859a6e41779 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -92,6 +92,22 @@ export const GoogleVertexPlugin = define({ evt.options.fetch = authFetch(evt.options.fetch) return } + if (evt.package === "@ai-sdk/google-vertex/anthropic") { + const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) + const project = resolveProject(evt.options) + const location = String(resolveLocation(evt.options)) + const regionalBaseURL = + (location === "eu" || location === "us") && project && !evt.options.baseURL + ? `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models` + : undefined + evt.sdk = mod.createVertexAnthropic({ + ...evt.options, + project, + location, + ...(regionalBaseURL ? { baseURL: regionalBaseURL } : {}), + }) + return + } if (evt.package !== "@ai-sdk/google-vertex") return const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex")) const project = resolveProject(evt.options) @@ -114,62 +130,3 @@ export const GoogleVertexPlugin = define({ ) }), }) - -export const GoogleVertexAnthropicPlugin = define({ - id: "opencode.provider.google-vertex-anthropic", - effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform((evt) => { - for (const item of evt.provider.list()) { - if (!Provider.isAISDK(item.provider.package)) continue - if (Provider.packageName(item.provider.package) !== "@ai-sdk/google-vertex/anthropic") continue - const project = - item.provider.settings?.project ?? - process.env.GOOGLE_CLOUD_PROJECT ?? - process.env.GCP_PROJECT ?? - process.env.GCLOUD_PROJECT - const location = - item.provider.settings?.location ?? - process.env.GOOGLE_CLOUD_LOCATION ?? - process.env.VERTEX_LOCATION ?? - "global" - evt.provider.update(item.provider.id, (provider) => { - provider.settings = { ...provider.settings, ...(project ? { project } : {}), location } - }) - } - }) - yield* ctx.aisdk.hook( - "sdk", - Effect.fn(function* (evt) { - if (evt.package !== "@ai-sdk/google-vertex/anthropic") return - const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) - const project = - typeof evt.options.project === "string" - ? evt.options.project - : (process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT) - const location = - typeof evt.options.location === "string" - ? evt.options.location - : (process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global") - evt.sdk = mod.createVertexAnthropic({ - ...evt.options, - project, - location, - // Continental multi-regions (eu, us) require Regional Endpoint Platform - // domains; the default {region}-aiplatform.googleapis.com does not resolve. - ...((location === "eu" || location === "us") && project && !evt.options.baseURL - ? { - baseURL: `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models`, - } - : {}), - }) - }), - ) - yield* ctx.aisdk.hook( - "language", - Effect.fn(function* (evt) { - if (evt.model.providerID !== Provider.ID.make("google-vertex-anthropic")) return - evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) - }), - ) - }), -}) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 383e8fe1939..f3e6b9263a7 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -23,6 +23,10 @@ export class ModelUnavailableError extends Schema.TaggedErrorClass ({ action: "provider.use" as const, - resource, + resource: providerID(resource), effect: "allow" as const, })), ]), ...(info.disabled_providers ?? []).map((resource) => ({ action: "provider.use" as const, - resource, + resource: providerID(resource), effect: "deny" as const, })), ] @@ -188,7 +188,7 @@ function modelSelection(input?: string, variant?: string) { if (input === undefined || !/^[^/#]+\/[^#]+$/.test(input)) return undefined const separator = input.indexOf("/") return { - providerID: input.slice(0, separator), + providerID: providerID(input.slice(0, separator)), model: input.slice(separator + 1), ...(variant === undefined || variant.length === 0 || variant.includes("#") ? {} : { variant }), } @@ -234,10 +234,23 @@ function migrateMcp(info: ConfigMCPV1.Info) { function providers(info?: Readonly>) { if (!info) return undefined - return Object.fromEntries(Object.entries(info).map(([name, provider]) => [name, migrateProvider(provider)])) + return Object.fromEntries( + Object.entries(info).flatMap(([name, provider]) => { + const id = providerID(name) + // If both names are present, keep the settings under the current name and ignore the old one. + if (id !== name && info[id]) return [] + return [[id, migrateProvider(name, provider)]] + }), + ) } -function migrateProvider(info: ConfigProviderV1.Info) { +function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) { + if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info) + if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info) + return migrateStandardProvider(info) +} + +function migrateStandardProvider(info: ConfigProviderV1.Info) { const options = ConfigProviderOptionsV1.provider(info.options ?? {}) return { name: info.name, @@ -252,6 +265,48 @@ function migrateProvider(info: ConfigProviderV1.Info) { } } +function migrateAzureCognitiveServicesProvider(info: ConfigProviderV1.Info) { + const standard = migrateStandardProvider(info) + const migrated = { + ...standard, + env: standard.env?.filter((name) => name !== "AZURE_COGNITIVE_SERVICES_RESOURCE_NAME"), + } + if (info.npm !== "@ai-sdk/openai-compatible" || info.api) return migrated + return { + ...migrated, + settings: { + ...migrated.settings, + baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai", + }, + } +} + +function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) { + const migrated = migrateStandardProvider(info) + const packageName = migrated.package ?? Provider.aisdk("@ai-sdk/google-vertex/anthropic") + return { + ...migrated, + // The current Google Vertex provider includes Gemini and Claude. Keep the Anthropic SDK on Claude models + // instead of changing the package inherited by every model on the provider. + package: undefined, + models: + migrated.models && + Object.fromEntries( + Object.entries(migrated.models).map(([name, model]) => [ + name, + model.package ? model : { ...model, package: packageName }, + ]), + ), + } +} + +// Rename these only in files detected as V1 by a field that exists only in the old config format. +function providerID(input: string) { + if (input === "azure-cognitive-services") return "azure" + if (input === "google-vertex-anthropic") return "google-vertex" + return input +} + function migrateModel(info: typeof ConfigProviderV1.Model.Type) { const settings = info.options && ConfigProviderOptionsV1.model(info.options) const costs = info.cost && [ diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index 6a39c82dfcb..fafcc5d683c 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -16,6 +16,31 @@ describe("AISDKNative", () => { }) }) + test("maps Azure deployments and settings to native routes", () => { + const settings = { + apiKey: "secret", + resourceName: "resource", + apiVersion: "2025-01-01-preview", + queryParams: { feature: "enabled" }, + useDeploymentBasedUrls: true, + reasoningEffort: "high", + } + expect(map("@ai-sdk/azure", settings, "deployment")).toEqual({ + package: "@opencode-ai/ai/providers/azure/responses", + settings: { + apiKey: "secret", + resourceName: "resource", + apiVersion: "2025-01-01-preview", + queryParams: { feature: "enabled" }, + useDeploymentBasedUrls: true, + providerOptions: { openai: { reasoningEffort: "high" } }, + }, + }) + expect(map("@ai-sdk/azure", { ...settings, useCompletionUrls: true }, "custom-deployment")?.package).toBe( + "@opencode-ai/ai/providers/azure/chat", + ) + }) + test("maps Bedrock provider and request options", () => { expect( map( diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index aac9e95a4a7..c43fc9d330d 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -475,6 +475,111 @@ describe("Config", () => { }), ) + it.effect("renames old provider IDs while migrating v1 configuration", () => + Effect.sync(() => { + const migrated = ConfigMigrateV1.migrate({ + model: "azure-cognitive-services/deployment", + enabled_providers: ["google-vertex-anthropic"], + disabled_providers: ["azure-cognitive-services"], + agent: { + reviewer: { model: "google-vertex-anthropic/claude-sonnet" }, + }, + command: { + review: { template: "Review", model: "azure-cognitive-services/deployment" }, + }, + provider: { + "azure-cognitive-services": { + npm: "@ai-sdk/azure", + env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"], + models: { deployment: {} }, + }, + "google-vertex-anthropic": { + npm: "@ai-sdk/google-vertex/anthropic", + options: { project: "test-project", location: "us-central1" }, + models: { "claude-sonnet": {} }, + }, + }, + }) + + expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" }) + expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" }) + expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" }) + expect(migrated.experimental?.policies).toEqual([ + { action: "provider.use", resource: "*", effect: "deny" }, + { action: "provider.use", resource: "google-vertex", effect: "allow" }, + { action: "provider.use", resource: "azure", effect: "deny" }, + ]) + expect(migrated.providers?.azure).toMatchObject({ + env: ["AZURE_COGNITIVE_SERVICES_API_KEY"], + package: Provider.aisdk("@ai-sdk/azure"), + models: { deployment: {} }, + }) + expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined() + expect(migrated.providers?.["google-vertex"]).toMatchObject({ + package: undefined, + settings: { project: "test-project", location: "us-central1" }, + models: { + "claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") }, + }, + }) + expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined() + }), + ) + + it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () => + Effect.sync(() => { + const migrated = ConfigMigrateV1.migrate({ + provider: { + "azure-cognitive-services": { + npm: "@ai-sdk/openai-compatible", + env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"], + }, + }, + }) + + expect(migrated.providers?.azure).toMatchObject({ + env: ["AZURE_COGNITIVE_SERVICES_API_KEY"], + package: Provider.aisdk("@ai-sdk/openai-compatible"), + settings: { + baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai", + }, + }) + }), + ) + + it.effect("ignores old provider IDs when the current provider ID is configured", () => + Effect.sync(() => { + const migrated = ConfigMigrateV1.migrate({ + provider: { + azure: { models: { current: {} } }, + "azure-cognitive-services": { models: { legacy: {} } }, + "google-vertex": { models: { gemini: {} } }, + "google-vertex-anthropic": { models: { claude: {} } }, + }, + }) + + expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() }) + expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() }) + }), + ) + + it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () => + Effect.sync(() => { + const migrated = ConfigMigrateV1.migrate({ + provider: { + "google-vertex-anthropic": { + models: { claude: {} }, + }, + }, + }) + + expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined() + expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe( + Provider.aisdk("@ai-sdk/google-vertex/anthropic"), + ) + }), + ) + it.effect("migrates v1 interleaved fields to compatibility", () => Effect.sync(() => { const migrated = ConfigMigrateV1.migrate({ diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index bf897a6b1a6..a70810d0622 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -666,6 +666,50 @@ describe("LocationServiceMap", () => { ), ) + it.live("explains replacements for unavailable legacy provider models", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + for (const [providerID, replacement] of [ + ["azure-cognitive-services", "azure"], + ["google-vertex-anthropic", "google-vertex"], + ] as const) { + const failure = yield* SessionRunnerModel.Service.use((models) => + models.resolve( + Session.Info.make({ + id: Session.ID.make(`ses_removed_${providerID}`), + projectID: Project.ID.global, + title: "test", + model: { + id: Model.ID.make("chat"), + providerID: Provider.ID.make(providerID), + }, + cost: Money.USD.zero, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location, + }), + ), + ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.ModelUnavailableError", + providerID, + modelID: "chat", + }) + expect(failure.message).toBe( + `Model unavailable: ${providerID}/chat. This provider has been deprecated; use ${replacement}/chat instead.`, + ) + } + }), + ), + ), + ) + it.live("preserves the selected catalog identity when the package model id differs", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index b74a9b799c2..7e0d71085e2 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -12,6 +12,7 @@ import { ModelResolver } from "@opencode-ai/core/model-resolver" import { it } from "./lib/effect" interface ModelOptions { + readonly providerID?: Provider.ID readonly modelID?: string readonly compatibility?: Compatibility readonly settings?: Info["settings"] @@ -25,7 +26,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) => Info.make({ id: ID.make("test-model"), modelID: ID.make(options.modelID ?? "api-test-model"), - providerID: Provider.ID.make("test-provider"), + providerID: options.providerID ?? Provider.ID.make("test-provider"), name: "Test model", compatibility: options.compatibility, package: packageName, @@ -42,6 +43,65 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) => }) describe("ModelResolver", () => { + it.effect("constructs native Azure requests with deployment IDs and projected resource URLs", () => + Effect.gen(function* () { + const responses = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/azure"), { + providerID: Provider.ID.azure, + modelID: "responses-deployment", + settings: { resourceName: "modern-resource", apiVersion: "2025-01-01-preview" }, + }), + Credential.Key.make({ type: "key", key: "secret" }), + ) + const chat = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/azure"), { + providerID: Provider.ID.azure, + modelID: "chat-deployment", + settings: { resourceName: "modern-resource", useCompletionUrls: true }, + }), + ) + const deployment = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/azure"), { + providerID: Provider.ID.azure, + modelID: "legacy-url-deployment", + settings: { + resourceName: "modern-resource", + apiVersion: "2025-01-01-preview", + useDeploymentBasedUrls: true, + }, + }), + ) + const compatible = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/openai-compatible"), { + providerID: Provider.ID.azure, + modelID: "legacy-deployment", + settings: { + resourceName: "legacy-resource", + baseURL: "https://legacy-resource.cognitiveservices.azure.com/openai", + }, + }), + ) + + expect(responses).toMatchObject({ id: "responses-deployment", provider: "azure" }) + expect(responses.route).toMatchObject({ + id: "azure-openai-responses", + endpoint: { + baseURL: "https://modern-resource.openai.azure.com/openai/v1", + query: { "api-version": "2025-01-01-preview" }, + }, + }) + expect(chat).toMatchObject({ id: "chat-deployment", provider: "azure" }) + expect(chat.route.id).toBe("azure-openai-chat") + expect(deployment).toMatchObject({ id: "legacy-url-deployment", provider: "azure" }) + expect(deployment.route.endpoint).toMatchObject({ + baseURL: "https://modern-resource.openai.azure.com/openai/deployments/legacy-url-deployment", + query: { "api-version": "2025-01-01-preview" }, + }) + expect(compatible).toMatchObject({ id: "legacy-deployment", provider: "azure" }) + expect(compatible.route.endpoint.baseURL).toBe("https://legacy-resource.cognitiveservices.azure.com/openai") + }), + ) + it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () => Effect.gen(function* () { const credential = Credential.Key.make({ type: "key", key: "secret" }) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 80e929bf3c7..229d9d91bf1 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -11,6 +11,7 @@ import { Location } from "@opencode-ai/core/location" import { Model } from "@opencode-ai/core/model" import { ModelsDev } from "@opencode-ai/core/models-dev" import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { Provider } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" @@ -220,6 +221,61 @@ describe("ModelsDevPlugin", () => { }).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev.json")))), ) + it.effect("omits legacy provider aliases", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service + const snapshots = [ + ["azure", "Azure", "AZURE_API_KEY", "@ai-sdk/azure"], + ["azure-cognitive-services", "Azure Cognitive Services", "AZURE_COGNITIVE_SERVICES_API_KEY", "@ai-sdk/azure"], + ["google-vertex", "Google Vertex", "GOOGLE_APPLICATION_CREDENTIALS", "@ai-sdk/google-vertex"], + [ + "google-vertex-anthropic", + "Google Vertex Anthropic", + "GOOGLE_APPLICATION_CREDENTIALS", + "@ai-sdk/google-vertex/anthropic", + ], + ].map(([id, name, environment, packageName]) => ({ + info: { + id: Provider.ID.make(id), + name, + package: Provider.aisdk(packageName), + }, + environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment], + models: [], + })) satisfies readonly ModelsDev.Snapshot[] + + yield* ModelsDevPlugin.effect( + host({ + catalog: catalogHost(catalog), + integration: integrationHost(integrations), + }), + ).pipe( + Effect.provideService( + ModelsDev.Service, + ModelsDev.Service.of({ + get: () => Effect.succeed(snapshots), + refresh: () => Effect.void, + }), + ), + ) + + expect(yield* catalog.provider.get(Provider.ID.azure)).toBeDefined() + expect(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).toBeDefined() + expect(yield* catalog.provider.get(Provider.ID.make("azure-cognitive-services"))).toBeUndefined() + expect(yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic"))).toBeUndefined() + expect(yield* integrations.get(Integration.ID.make("azure"))).toBeDefined() + expect(yield* integrations.get(Integration.ID.make("azure"))).toMatchObject({ + methods: [{ type: "key" }, { type: "env", names: ["AZURE_API_KEY", "AZURE_COGNITIVE_SERVICES_API_KEY"] }], + }) + expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toBeDefined() + expect(yield* integrations.get(Integration.ID.make("azure-cognitive-services"))).toBeUndefined() + expect(yield* integrations.get(Integration.ID.make("google-vertex-anthropic"))).toBeUndefined() + expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure-cognitive-services") + expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google-vertex-anthropic") + }), + ) + it.effect("converts reasoning options into settings variants", () => Effect.gen(function* () { const catalog = yield* Catalog.Service @@ -389,10 +445,7 @@ describe("ModelsDevPlugin", () => { }, ]) - const openrouter = yield* catalog.model.get( - Provider.ID.make("openrouter"), - Model.ID.make("openrouter-toggle"), - ) + const openrouter = yield* catalog.model.get(Provider.ID.make("openrouter"), Model.ID.make("openrouter-toggle")) expect(openrouter?.variants).toEqual([ { id: Model.VariantID.make("none"), settings: { reasoning: { enabled: false } } }, { id: Model.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } }, @@ -414,10 +467,7 @@ describe("ModelsDevPlugin", () => { }, ]) - const vertex = yield* catalog.model.get( - Provider.ID.make("google-vertex"), - Model.ID.make("gemini-2.5-flash-lite"), - ) + const vertex = yield* catalog.model.get(Provider.ID.make("google-vertex"), Model.ID.make("gemini-2.5-flash-lite")) expect(vertex?.variants).toEqual([ { id: Model.VariantID.make("none"), diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts deleted file mode 100644 index c53bb4a524a..00000000000 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Effect } from "effect" -import { Catalog } from "@opencode-ai/core/catalog" -import { Model } from "@opencode-ai/core/model" -import { Plugin } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" -import { Provider } from "@opencode-ai/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 aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* AzureCognitiveServicesPlugin.effect(host) -}) - -function required(value: T | undefined): T { - if (value === undefined) throw new Error("Expected value") - return value -} - -function withEnv(vars: Record, fx: () => Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) - Object.entries(vars).forEach(([key, value]) => { - if (value === undefined) delete process.env[key] - else process.env[key] = value - }) - return previous - }), - fx, - (previous) => - Effect.sync(() => { - Object.entries(previous).forEach(([key, value]) => { - if (value === undefined) delete process.env[key] - else process.env[key] = value - }) - }), - ) -} - -function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -describe("AzureCognitiveServicesPlugin", () => { - it.effect("maps the resource env var to the Azure SDK baseURL", () => - withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - yield* catalog.transform((catalog) => { - catalog.provider.update(Provider.ID.make("azure-cognitive-services"), (item) => { - item.package = Provider.aisdk("@ai-sdk/openai-compatible") - }) - }) - yield* addPlugin() - const result = required(yield* catalog.provider.get(Provider.ID.make("azure-cognitive-services"))) - expect(result).toMatchObject({ - package: "aisdk:@ai-sdk/openai-compatible", - settings: { baseURL: "https://cognitive.cognitiveservices.azure.com/openai" }, - }) - expect(result.settings?.resourceName).toBeUndefined() - }), - ), - ) - - it.effect("leaves baseURL unset without resource env and ignores other providers", () => - withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - yield* catalog.transform((catalog) => { - const azure = Provider.Info.make({ - ...Provider.Info.empty(Provider.ID.make("azure-cognitive-services")), - package: "aisdk:@ai-sdk/openai-compatible", - }) - const openai = Provider.Info.make({ - ...Provider.Info.empty(Provider.ID.openai), - package: "aisdk:test-provider", - }) - catalog.provider.update(azure.id, (item) => { - item.package = azure.package - item.package = azure.package - }) - catalog.provider.update(openai.id, (item) => { - item.package = openai.package - item.package = openai.package - }) - }) - yield* addPlugin() - const azure = required(yield* catalog.provider.get(Provider.ID.make("azure-cognitive-services"))) - const openai = required(yield* catalog.provider.get(Provider.ID.openai)) - expect(azure.settings?.baseURL).toBeUndefined() - expect(azure).toMatchObject({ package: "aisdk:@ai-sdk/openai-compatible" }) - expect(openai.settings?.baseURL).toBeUndefined() - expect(openai).toMatchObject({ package: "aisdk:test-provider" }) - }), - ), - ) - - it.effect("selects chat only for completion URLs", () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin() - yield* aisdk.runLanguage({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("deployment")), - modelID: Model.ID.make("deployment"), - package: "aisdk:test-provider", - }), - sdk: fakeSelectorSdk(calls), - options: { useCompletionUrls: true }, - }) - expect(calls).toEqual(["chat:deployment"]) - }), - ) - - it.effect("uses the legacy Azure selector order and provider guard", () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin() - yield* aisdk.runLanguage({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("deployment")), - modelID: Model.ID.make("deployment"), - package: "aisdk:test-provider", - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }) - const ignored = yield* aisdk.runLanguage({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.openai, Model.ID.make("deployment")), - modelID: Model.ID.make("deployment"), - package: "aisdk:test-provider", - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }) - expect(calls).toEqual(["responses:deployment"]) - expect(ignored.language).toBeUndefined() - }), - ) - - it.effect("falls back from responses to messages, chat, then languageModel", () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - const sdk = fakeSelectorSdk(calls) - yield* addPlugin() - yield* aisdk.runLanguage({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("messages-deployment")), - modelID: Model.ID.make("messages-deployment"), - package: "aisdk:test-provider", - }), - sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, - options: {}, - }) - yield* aisdk.runLanguage({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("chat-deployment")), - modelID: Model.ID.make("chat-deployment"), - package: "aisdk:test-provider", - }), - sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, - options: {}, - }) - yield* aisdk.runLanguage({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("azure-cognitive-services"), Model.ID.make("language-deployment")), - modelID: Model.ID.make("language-deployment"), - package: "aisdk:test-provider", - }), - sdk: { languageModel: sdk.languageModel }, - options: {}, - }) - expect(calls).toEqual([ - "messages:messages-deployment", - "chat:chat-deployment", - "languageModel:language-deployment", - ]) - }), - ) -}) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 85f405e6cbe..266f6bcc94c 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -75,6 +75,56 @@ describe("AzurePlugin", () => { ), ) + it.effect("resolves resourceName from the legacy env", () => + withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "legacy-resource" }, () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => { + catalog.provider.update(Provider.ID.azure, (item) => { + item.package = Provider.aisdk("@ai-sdk/azure") + }) + }) + yield* addPlugin() + expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings?.resourceName).toBe("legacy-resource") + }), + ), + ) + + it.effect("expands provider and model resource URLs", () => + withEnv({ AZURE_RESOURCE_NAME: "from-env", AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "legacy-env" }, () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => { + catalog.provider.update(Provider.ID.azure, (provider) => { + provider.package = Provider.aisdk("@ai-sdk/openai-compatible") + provider.settings = { + baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai", + } + }) + catalog.model.update(Provider.ID.azure, Model.ID.make("anthropic"), (model) => { + model.package = Provider.aisdk("@ai-sdk/anthropic") + model.settings = { + resourceName: "model-resource", + baseURL: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/anthropic/v1", + } + }) + }) + yield* addPlugin() + + expect(required(yield* catalog.provider.get(Provider.ID.azure)).settings).toMatchObject({ + resourceName: "from-env", + baseURL: "https://from-env.cognitiveservices.azure.com/openai", + }) + expect( + required(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("anthropic"))).settings, + ).toMatchObject({ + resourceName: "model-resource", + baseURL: "https://model-resource.services.ai.azure.com/anthropic/v1", + }) + }), + ), + ) + it.effect("keeps explicit resourceName over env and ignores other providers", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts deleted file mode 100644 index 4762e9b34c9..00000000000 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { describe, expect } from "bun:test" -import { Effect } from "effect" -import { Catalog } from "@opencode-ai/core/catalog" -import { Model } from "@opencode-ai/core/model" -import { Plugin } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" -import { Provider } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) { - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* definition.effect(host) -}) - -function withEnv(vars: Record, effect: () => Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) - Object.entries(vars).forEach(([key, value]) => { - if (value === undefined) delete process.env[key] - else process.env[key] = value - }) - return previous - }), - effect, - (previous) => - Effect.sync(() => { - Object.entries(previous).forEach(([key, value]) => { - if (value === undefined) delete process.env[key] - else process.env[key] = value - }) - }), - ) -} - -function selector(calls: string[]) { - return (id: string) => { - calls.push(`languageModel:${id}`) - return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 - } -} - -describe("GoogleVertexAnthropicPlugin", () => { - it.effect("resolves legacy project and location env on provider update", () => - withEnv( - { - GOOGLE_CLOUD_PROJECT: "cloud-project", - GCP_PROJECT: "gcp-project", - GCLOUD_PROJECT: "gcloud-project", - GOOGLE_CLOUD_LOCATION: "cloud-location", - VERTEX_LOCATION: "vertex-location", - GOOGLE_VERTEX_LOCATION: "google-vertex-location", - }, - () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - yield* catalog.transform((catalog) => - catalog.provider.update(Provider.ID.make("google-vertex-anthropic"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/google-vertex/anthropic") - }), - ) - yield* addPlugin(GoogleVertexAnthropicPlugin) - expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.project).toBe( - "cloud-project", - ) - expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.location).toBe( - "cloud-location", - ) - }), - ), - ) - - it.effect("keeps configured project and location over env fallback", () => - withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - yield* catalog.transform((catalog) => - catalog.provider.update(Provider.ID.make("google-vertex-anthropic"), (provider) => { - provider.package = Provider.aisdk("@ai-sdk/google-vertex/anthropic") - provider.settings = { ...provider.settings, project: "configured-project", location: "configured-location" } - }), - ) - yield* addPlugin(GoogleVertexAnthropicPlugin) - expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.project).toBe( - "configured-project", - ) - expect((yield* catalog.provider.get(Provider.ID.make("google-vertex-anthropic")))?.settings?.location).toBe( - "configured-location", - ) - }), - ), - ) - - it.effect("creates SDKs from legacy env fallback and default location", () => - withEnv( - { - GOOGLE_CLOUD_PROJECT: undefined, - GCP_PROJECT: "gcp-project", - GCLOUD_PROJECT: "gcloud-project", - GOOGLE_CLOUD_LOCATION: undefined, - VERTEX_LOCATION: undefined, - GOOGLE_VERTEX_LOCATION: "ignored-location", - }, - () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default( - Provider.ID.make("google-vertex-anthropic"), - Model.ID.make("claude-sonnet-4-5"), - ), - modelID: Model.ID.make("claude-sonnet-4-5"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex-anthropic" }, - }) - expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( - "https://aiplatform.googleapis.com/v1/projects/gcp-project/locations/global/publishers/anthropic/models", - ) - }), - ), - ) - - it.effect("uses GOOGLE_CLOUD_LOCATION before VERTEX_LOCATION when creating SDKs", () => - withEnv( - { GOOGLE_CLOUD_PROJECT: "project", GOOGLE_CLOUD_LOCATION: "cloud-location", VERTEX_LOCATION: "vertex-location" }, - () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default( - Provider.ID.make("google-vertex-anthropic"), - Model.ID.make("claude-sonnet-4-5"), - ), - modelID: Model.ID.make("claude-sonnet-4-5"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex-anthropic" }, - }) - expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( - "https://cloud-location-aiplatform.googleapis.com/v1/projects/project/locations/cloud-location/publishers/anthropic/models", - ) - }), - ), - ) - - it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("claude-sonnet-4-5")), - modelID: Model.ID.make("claude-sonnet-4-5"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "eu" }, - }) - expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( - "https://aiplatform.eu.rep.googleapis.com/v1/projects/project/locations/eu/publishers/anthropic/models", - ) - }), - ) - - it.effect("keeps configured baseURL for google-vertex Anthropic models", () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("claude-sonnet-4-5")), - modelID: Model.ID.make("claude-sonnet-4-5"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, - }) - expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe("https://proxy.example/v1") - }), - ) - - it.effect("selects google-vertex Anthropic language models through plugins", () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(GoogleVertexPlugin) - yield* addPlugin(GoogleVertexAnthropicPlugin) - const sdkResult = yield* aisdk.runSDK({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make(" claude-sonnet-4-5 ")), - modelID: Model.ID.make(" claude-sonnet-4-5 "), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "us" }, - }) - const languageResult = yield* aisdk.runLanguage({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make(" claude-sonnet-4-5 ")), - modelID: Model.ID.make(" claude-sonnet-4-5 "), - package: "aisdk:test-provider", - }), - sdk: sdkResult.sdk, - options: {}, - }) - const language = languageResult.language as unknown as { config: { baseURL: string }; modelId: string } - expect(language.config.baseURL).toBe( - "https://aiplatform.us.rep.googleapis.com/v1/projects/project/locations/us/publishers/anthropic/models", - ) - expect(language.modelId).toBe("claude-sonnet-4-5") - }), - ) - - it.effect("trims model IDs before selecting language models", () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin(GoogleVertexAnthropicPlugin) - yield* aisdk.runLanguage({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("google-vertex-anthropic"), Model.ID.make(" claude-sonnet-4-5 ")), - modelID: Model.ID.make(" claude-sonnet-4-5 "), - package: "aisdk:test-provider", - }), - sdk: { languageModel: selector(calls) }, - options: {}, - }) - expect(calls).toEqual(["languageModel:claude-sonnet-4-5"]) - }), - ) - - it.effect("ignores non Vertex Anthropic providers for language selection", () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* aisdk.runLanguage({ - model: Model.Info.make({ - ...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("claude-sonnet-4-5")), - modelID: Model.ID.make("claude-sonnet-4-5"), - package: "aisdk:test-provider", - }), - sdk: { languageModel: selector(calls) }, - options: {}, - }) - expect(calls).toEqual([]) - expect(result.language).toBeUndefined() - }), - ) -}) diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index d7b89e01067..9ed9274189c 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -309,6 +309,27 @@ describe("GoogleVertexPlugin", () => { ), ) + it.effect("creates Anthropic SDKs for canonical Google Vertex models", () => + Effect.gen(function* () { + const plugin = yield* Plugin.Service + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: Model.Info.make({ + ...Model.Info.default(Provider.ID.googleVertex, Model.ID.make("claude-sonnet-4-6@default")), + modelID: Model.ID.make("claude-sonnet-4-6@default"), + package: "aisdk:@ai-sdk/google-vertex/anthropic", + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "eu" }, + }) + + expect(result.sdk.languageModel("claude-sonnet-4-6@default").config.baseURL).toBe( + "https://aiplatform.eu.rep.googleapis.com/v1/projects/project/locations/eu/publishers/anthropic/models", + ) + }), + ) + it.effect("keeps Google auth fetch for OpenAI-compatible Vertex endpoints", () => Effect.gen(function* () { googleAuthOptions.length = 0