Browse Source

fix(vertex): Vertex (Antropic) provider: use .rep.googleapis.com for continental multi-region endpoints (us, eu) (#28347)

Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
JPFrancoia 3 months ago
parent
commit
7a9724496b

+ 17 - 9
packages/core/src/plugin/provider/google-vertex.ts

@@ -25,7 +25,8 @@ function resolveLocation(options: Record<string, any>) {
 }
 
 function vertexEndpoint(location: string) {
-  return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
+  if (location === "global") return "aiplatform.googleapis.com"
+  return `${location}-aiplatform.googleapis.com`
 }
 
 function replaceVertexVars(value: string, project: string | undefined, location: string) {
@@ -131,16 +132,23 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
       "aisdk.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:
-            typeof evt.options.project === "string"
-              ? evt.options.project
-              : (process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT),
-          location:
-            typeof evt.options.location === "string"
-              ? evt.options.location
-              : (process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global"),
+          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` }
+            : {}),
         })
       }),
       "aisdk.language": Effect.fn(function* (evt) {

+ 68 - 1
packages/core/test/plugin/provider-google-vertex-anthropic.test.ts

@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
 import { Effect } from "effect"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
-import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
+import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
 
@@ -109,6 +109,73 @@ describe("GoogleVertexAnthropicPlugin", () => {
     ),
   )
 
+  it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () =>
+    Effect.gen(function* () {
+      const plugin = yield* PluginV2.Service
+      yield* plugin.add(GoogleVertexAnthropicPlugin)
+      const result = yield* plugin.trigger(
+        "aisdk.sdk",
+        {
+          model: model("google-vertex", "claude-sonnet-4-5"),
+          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* PluginV2.Service
+      yield* plugin.add(GoogleVertexAnthropicPlugin)
+      const result = yield* plugin.trigger(
+        "aisdk.sdk",
+        {
+          model: model("google-vertex", "claude-sonnet-4-5"),
+          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 V2 plugins", () =>
+    Effect.gen(function* () {
+      const plugin = yield* PluginV2.Service
+      yield* plugin.add(GoogleVertexPlugin)
+      yield* plugin.add(GoogleVertexAnthropicPlugin)
+      const sdkResult = yield* plugin.trigger(
+        "aisdk.sdk",
+        {
+          model: model("google-vertex", " claude-sonnet-4-5 "),
+          package: "@ai-sdk/google-vertex/anthropic",
+          options: { name: "google-vertex", project: "project", location: "us" },
+        },
+        {},
+      )
+      const languageResult = yield* plugin.trigger(
+        "aisdk.language",
+        {
+          model: model("google-vertex", " claude-sonnet-4-5 "),
+          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* PluginV2.Service

+ 26 - 0
packages/core/test/plugin/provider-google-vertex.test.ts

@@ -160,6 +160,32 @@ describe("GoogleVertexPlugin", () => {
     ),
   )
 
+  it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
+    Effect.gen(function* () {
+      const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
+      yield* plugin.add(GoogleVertexPlugin)
+      const load = yield* catalog.loader()
+      yield* load((catalog) =>
+        catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
+          provider.endpoint = {
+            type: "aisdk",
+            package: "@ai-sdk/openai-compatible",
+            url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
+          }
+          provider.options.aisdk.provider.project = "config-project"
+          provider.options.aisdk.provider.location = "eu"
+        }),
+      )
+      const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
+      expect(provider.endpoint).toEqual({
+        type: "aisdk",
+        package: "@ai-sdk/openai-compatible",
+        url: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu",
+      })
+    }),
+  )
+
   it.effect("defaults location to us-central1 when only project is configured", () =>
     withEnv(
       {

+ 21 - 0
packages/opencode/src/provider/provider.ts

@@ -85,6 +85,13 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
   })
 }
 
+function googleVertexAnthropicBaseURL(project: string | undefined, location: string | undefined) {
+  if (!project) return
+  if (location !== "eu" && location !== "us") return
+  // Continental multi-regions require Regional Endpoint Platform domains.
+  return `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models`
+}
+
 type BundledSDK = {
   languageModel(modelId: string): LanguageModelV3
 }
@@ -507,11 +514,13 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
       const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global"
       const autoload = Boolean(project)
       if (!autoload) return { autoload: false }
+      const baseURL = googleVertexAnthropicBaseURL(project, location)
       return {
         autoload: true,
         options: {
           project,
           location,
+          ...(baseURL && { baseURL }),
         },
         async getModel(sdk: any, modelID) {
           const id = String(modelID).trim()
@@ -1516,6 +1525,18 @@ export const layer = Layer.effect(
         const provider = s.providers[model.providerID]
         const options = { ...provider.options }
 
+        if (
+          model.providerID === "google-vertex" &&
+          model.api.npm === "@ai-sdk/google-vertex/anthropic" &&
+          !options.baseURL
+        ) {
+          const baseURL = googleVertexAnthropicBaseURL(
+            typeof options.project === "string" ? options.project : undefined,
+            typeof options.location === "string" ? options.location : undefined,
+          )
+          if (baseURL) options.baseURL = baseURL
+        }
+
         if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
           delete options.fetch
         }

+ 50 - 0
packages/opencode/test/provider/provider.test.ts

@@ -73,6 +73,8 @@ const paid = (providers: Record<string, { models: Record<string, { cost: { input
   return Object.values(item.models).filter((model) => model.cost.input > 0).length
 }
 
+const languageBaseURL = (language: unknown) => (language as { config: { baseURL: string } }).config.baseURL
+
 const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer))
 const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true }))
 
@@ -1546,6 +1548,54 @@ it.instance(
   },
 )
 
+it.instance("Google Vertex: uses REP endpoint for Claude continental multi-regions", () =>
+  Effect.gen(function* () {
+    yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
+    yield* set("VERTEX_LOCATION", "eu")
+    const provider = yield* Provider.Service
+    const model = yield* provider.getModel(
+      ProviderID.make("google-vertex"),
+      ModelID.make("claude-sonnet-4-6@default"),
+    )
+    const language = yield* provider.getLanguage(model)
+    expect(languageBaseURL(language)).toBe(
+      "https://aiplatform.eu.rep.googleapis.com/v1/projects/test-project/locations/eu/publishers/anthropic/models",
+    )
+  }),
+)
+
+it.instance("Google Vertex Anthropic: uses REP endpoint for continental multi-regions", () =>
+  Effect.gen(function* () {
+    yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
+    yield* set("VERTEX_LOCATION", "us")
+    const provider = yield* Provider.Service
+    const model = yield* provider.getModel(
+      ProviderID.make("google-vertex-anthropic"),
+      ModelID.make("claude-sonnet-4-6@default"),
+    )
+    const language = yield* provider.getLanguage(model)
+    expect(languageBaseURL(language)).toBe(
+      "https://aiplatform.us.rep.googleapis.com/v1/projects/test-project/locations/us/publishers/anthropic/models",
+    )
+  }),
+)
+
+it.instance("Google Vertex: keeps regional Claude endpoints unchanged", () =>
+  Effect.gen(function* () {
+    yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
+    yield* set("VERTEX_LOCATION", "europe-west1")
+    const provider = yield* Provider.Service
+    const model = yield* provider.getModel(
+      ProviderID.make("google-vertex"),
+      ModelID.make("claude-sonnet-4-6@default"),
+    )
+    const language = yield* provider.getLanguage(model)
+    expect(languageBaseURL(language)).toBe(
+      "https://europe-west1-aiplatform.googleapis.com/v1/projects/test-project/locations/europe-west1/publishers/anthropic/models",
+    )
+  }),
+)
+
 it.instance("cloudflare-ai-gateway loads with env variables", () =>
   Effect.gen(function* () {
     yield* set("CLOUDFLARE_ACCOUNT_ID", "test-account")