From e75020b6d94a3700a478e24702bfa223877336fd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 4 Aug 2026 10:44:36 -0500 Subject: [PATCH] fix(core): reject removed providers --- .../src/providers/google-vertex-messages.ts | 3 +- .../ai/test/provider/google-vertex.test.ts | 12 +- packages/core/src/catalog.ts | 7 +- packages/core/src/config/plugin/provider.ts | 4 +- packages/core/src/generate.ts | 4 + packages/core/src/model-resolver.ts | 24 +- packages/core/src/plugin/models-dev.ts | 7 +- packages/core/src/plugin/provider.ts | 6 +- packages/core/src/plugin/provider/azure.ts | 32 --- .../core/src/plugin/provider/google-vertex.ts | 75 ++--- packages/core/src/provider.ts | 6 + packages/core/src/session/runner/model.ts | 9 + packages/core/src/session/to-session-error.ts | 1 + packages/core/test/config/provider.test.ts | 31 ++ packages/core/test/location-layer.test.ts | 84 ++++++ packages/core/test/plugin/models-dev.test.ts | 3 + .../provider-azure-cognitive-services.test.ts | 205 ------------- .../provider-google-vertex-anthropic.test.ts | 271 ------------------ .../plugin/provider-google-vertex.test.ts | 21 ++ 19 files changed, 220 insertions(+), 585 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/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/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/catalog.ts b/packages/core/src/catalog.ts index 9bc9611cf89..ffac1bc5c33 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -51,6 +51,7 @@ export interface Interface extends State.Transformable { readonly get: (providerID: Provider.ID, modelID: Model.ID) => Effect.Effect readonly all: () => Effect.Effect readonly available: () => Effect.Effect + readonly configured: () => Effect.Effect readonly default: () => Effect.Effect readonly small: (providerID: Provider.ID) => Effect.Effect } @@ -194,6 +195,10 @@ const layer = Layer.effect( ) }), + configured: Effect.fn("Catalog.model.configured")(function* () { + return state.get().defaultModel + }), + default: Effect.fn("Catalog.model.default")(function* () { const defaultModel = state.get().defaultModel if (defaultModel) { @@ -213,7 +218,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..716cee54e11 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -16,12 +16,13 @@ export const Plugin = define({ const configuredIntegrations = new Set( files.flatMap((file) => Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => - provider.env === undefined ? [] : [id], + provider.env === undefined || Provider.replacement(Provider.ID.make(id)) ? [] : [id], ), ), ) for (const file of files) { for (const [id, item] of Object.entries(file.info.providers ?? {})) { + if (Provider.replacement(Provider.ID.make(id))) continue const integrationID = id if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue integrations.update(integrationID, (integration) => { @@ -44,6 +45,7 @@ export const Plugin = define({ catalog.model.default.set(configuredDefault.providerID, configuredDefault.model) for (const file of files) { for (const [id, item] of Object.entries(file.info.providers ?? {})) { + if (Provider.replacement(Provider.ID.make(id))) continue const providerID = id catalog.provider.update(providerID, (provider) => { if (item.name !== undefined) provider.name = item.name diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index 5244f51cc84..39ccc081607 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -47,6 +47,10 @@ export const layer = Layer.effect( input.model ? new ModelSelectionError({ message: error.message }) : new UnavailableError({ message: error.message, service: error.providerID }), + "SessionRunnerModel.ProviderRemovedError": (error) => + input.model + ? new ModelSelectionError({ message: error.message }) + : new UnavailableError({ message: error.message, service: error.providerID }), }), ) if (!resolved) diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index fbf345a02af..a2536e00d0c 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -46,7 +46,24 @@ export class UnsupportedPackageError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.ProviderRemovedError", + { + providerID: Provider.ID, + replacement: Provider.ID, + modelID: ID, + }, +) { + override get message() { + return `Provider "${this.providerID}" no longer exists. Change "${this.providerID}/${this.modelID}" to "${this.replacement}/${this.modelID}".` + } +} + +export type Error = + | VariantUnavailableError + | UnsupportedPackageError + | ProviderRemovedError + | Integration.AuthorizationError export interface Resolved { /** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */ @@ -296,6 +313,11 @@ export const layer = Layer.effect( }) return Service.of({ resolve: Effect.fn("ModelResolver.resolve")(function* (requested) { + const configured = requested ? undefined : yield* catalog.model.configured() + const providerID = requested?.providerID ?? configured?.providerID + const modelID = requested?.id ?? configured?.modelID + const replacement = providerID && Provider.replacement(providerID) + if (replacement && modelID) return yield* new ProviderRemovedError({ providerID, replacement, modelID }) const selected = requested ? yield* catalog.model.get(requested.providerID, requested.id) : yield* catalog.model diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index f4976408207..236bba8b322 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -3,8 +3,7 @@ import { Integration } from "@opencode-ai/schema/integration" import { Effect, Stream } from "effect" import { Bus } from "../bus" import { ModelsDev } from "../models-dev" - -const legacyProviders = new Set(["azure-cognitive-services", "google-vertex-anthropic"]) +import { Provider } from "../provider" export const ModelsDevPlugin = define({ id: "opencode.models-dev", @@ -14,7 +13,7 @@ export const ModelsDevPlugin = define({ const loaded = { data: structuredClone(yield* modelsDev.get()) } yield* ctx.integration.transform((integrations) => { for (const provider of loaded.data) { - if (legacyProviders.has(provider.info.id)) continue + if (Provider.replacement(provider.info.id)) continue if (provider.environment.length === 0) continue const integrationID = provider.info.id integrations.update(integrationID, (integration) => (integration.name = provider.info.name)) @@ -30,7 +29,7 @@ export const ModelsDevPlugin = define({ }) yield* ctx.catalog.transform((catalog) => { for (const provider of loaded.data) { - if (legacyProviders.has(provider.info.id)) continue + if (Provider.replacement(provider.info.id)) continue catalog.provider.update(provider.info.id, (draft) => { Object.assign(draft, provider.info) draft.integrationID = Integration.ID.make(provider.info.id) diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 7c17bcc4c74..b422ab4d998 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" @@ -12,7 +12,7 @@ import { GatewayPlugin } from "./provider/gateway" import { GithubCopilotPlugin } from "./provider/github-copilot" import { GitLabPlugin } from "./provider/gitlab" import { GooglePlugin } from "./provider/google" -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" @@ -36,7 +36,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ AlibabaPlugin, AmazonBedrockPlugin, AnthropicPlugin, - AzureCognitiveServicesPlugin, AzurePlugin, CerebrasPlugin, CloudflareAIGatewayPlugin, @@ -47,7 +46,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ GithubCopilotPlugin, GitLabPlugin, GooglePlugin, - GoogleVertexAnthropicPlugin, GoogleVertexPlugin, GroqPlugin, KiloPlugin, diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index ccc12eef7f4..a5e3b714368 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -58,35 +58,3 @@ 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), - ) - }), - ) - }), -}) diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 8014f2662c0..0e5245fca66 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)) + evt.sdk = mod.createVertexAnthropic({ + ...evt.options, + project, + location, + ...((location === "eu" || location === "us") && project && !evt.options.baseURL + ? { + baseURL: `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models`, + } + : {}), + }) + 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/provider.ts b/packages/core/src/provider.ts index 869e0c9ee71..7a3dc5d7a93 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -10,6 +10,12 @@ import { importModule, resolveModule } from "@opencode-ai/util/runtime-import" export const ID = Provider.ID export type ID = typeof ID.Type +export function replacement(providerID: ID) { + if (providerID === ID.make("azure-cognitive-services")) return ID.azure + if (providerID === ID.make("google-vertex-anthropic")) return ID.googleVertex + return undefined +} + export const AISDK_PREFIX = "aisdk:" export const isAISDK = (value: string | undefined): value is string => value?.startsWith(AISDK_PREFIX) ?? false export const aisdk = (value: string) => (isAISDK(value) ? value : `${AISDK_PREFIX}${value}`) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 383e8fe1939..505c039e95d 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -30,6 +30,8 @@ export const VariantUnavailableError = ModelResolver.VariantUnavailableError export type VariantUnavailableError = ModelResolver.VariantUnavailableError export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError +export const ProviderRemovedError = ModelResolver.ProviderRemovedError +export type ProviderRemovedError = ModelResolver.ProviderRemovedError export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error export type Resolved = ModelResolver.Resolved @@ -72,6 +74,13 @@ const layer = Layer.effect( if (resolved) return resolved return yield* new ModelNotSelectedError({ sessionID: session.id }) } + const replacement = Provider.replacement(session.model.providerID) + if (replacement) + return yield* new ProviderRemovedError({ + providerID: session.model.providerID, + replacement, + modelID: session.model.id, + }) const selected = (yield* catalog.model.available()).find( (model) => model.providerID === session.model?.providerID && model.id === session.model.id, ) diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index 0a57bb296db..dc0b9bb813d 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -51,6 +51,7 @@ export function toSessionError(cause: unknown): SessionError.Error { if ( cause instanceof SessionRunnerModel.ModelNotSelectedError || cause instanceof SessionRunnerModel.ModelUnavailableError || + cause instanceof SessionRunnerModel.ProviderRemovedError || cause instanceof SessionRunnerModel.VariantUnavailableError || cause instanceof SessionRunnerModel.UnsupportedPackageError ) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index de10dbcd368..cc7f978c6f8 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -49,6 +49,37 @@ function withEnv(vars: Record, effect: () = const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProviderPlugin.Plugin", () => { + it.effect("does not recreate removed providers from config", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service + yield* addPlugin([ + new Config.Document({ + type: "document", + info: decode({ + providers: { + "azure-cognitive-services": { + env: ["AZURE_COGNITIVE_SERVICES_API_KEY"], + package: "aisdk:@ai-sdk/azure", + models: { deployment: {} }, + }, + "google-vertex-anthropic": { + env: ["GOOGLE_APPLICATION_CREDENTIALS"], + package: "aisdk:@ai-sdk/google-vertex/anthropic", + models: { claude: {} }, + }, + }, + }), + }), + ]) + + 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-cognitive-services"))).toBeUndefined() + expect(yield* integrations.get(Integration.ID.make("google-vertex-anthropic"))).toBeUndefined() + }), + ) + it.effect("defaults custom models to agent capabilities", () => Effect.gen(function* () { const catalog = yield* Catalog.Service diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index bf897a6b1a6..bec7e6e0b5b 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -666,6 +666,90 @@ describe("LocationServiceMap", () => { ), ) + it.live("explains replacements for removed providers", () => + 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.ProviderRemovedError", + providerID, + replacement, + modelID: "chat", + }) + expect(failure.message).toBe( + `Provider "${providerID}" no longer exists. Change "${providerID}/chat" to "${replacement}/chat".`, + ) + } + }), + ), + ), + ) + + it.live("rejects a removed provider configured as the default model", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile( + path.join(dir.path, "opencode.json"), + JSON.stringify({ model: "azure-cognitive-services/deployment" }), + ), + ) + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(location) + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context)) + const failure = yield* SessionRunnerModel.Service.use((models) => + models.resolve( + Session.Info.make({ + id: Session.ID.make("ses_removed_default"), + projectID: Project.ID.global, + title: "test", + 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(context), Effect.flip) + + expect(failure.message).toBe( + 'Provider "azure-cognitive-services" no longer exists. Change "azure-cognitive-services/deployment" to "azure/deployment".', + ) + }), + ), + ), + ) + it.live("preserves the selected catalog identity when the package model id differs", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index b1296ec04c6..a9b384bc1c9 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" @@ -267,6 +268,8 @@ describe("ModelsDevPlugin", () => { 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") }), ) 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-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