From 8ef9428ec4015fa8be77db49866cc142cff83d49 Mon Sep 17 00:00:00 2001 From: bryanster <45668775+bryanster@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:36:48 +0200 Subject: [PATCH] fix: google-vertex support multi-region endpoints --- packages/ai/src/providers/google-vertex.ts | 8 ++--- packages/ai/src/stream.ts | 4 +-- packages/ai/test/issue-1270-repro.test.ts | 38 ++++++++++++++++++++++ packages/catalog/src/hosts.ts | 18 ++++++++++ packages/catalog/test/hosts.test.ts | 9 +++++ 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 647c2c8d5..ad307eac8 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -1,4 +1,5 @@ import { $env } from "@oh-my-pi/pi-utils"; +import { resolveVertexEndpointHost } from "@oh-my-pi/pi-catalog/hosts"; import * as AIError from "../error"; import type { Context, Model, StreamFunction } from "../types"; import type { AssistantMessageEventStream } from "../utils/event-stream"; @@ -69,7 +70,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( // global-only request. const explicitLocation = options?.location; const location = explicitLocation ?? resolveAmbientLocation() ?? "global"; - const host = resolveEndpointHost(location); + const host = resolveVertexEndpointHost(location); const path = `${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`; const useGlobalFallback = !explicitLocation && host !== "aiplatform.googleapis.com"; return { @@ -87,7 +88,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( const project = resolveProject(options); const location = resolveLocation(options); const accessToken = await getVertexAccessToken({ signal: options?.signal, fetch: options?.fetch }); - const host = resolveEndpointHost(location); + const host = resolveVertexEndpointHost(location); const url = `https://${host}/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`; return { params, @@ -117,9 +118,6 @@ function resolveProject(options?: GoogleVertexOptions): string { return project; } -function resolveEndpointHost(location: string): string { - return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`; -} function resolveAmbientLocation(): string | undefined { return $env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION || undefined; } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 462ec5690..119b3bf31 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic"; import type { Effort } from "@oh-my-pi/pi-catalog/effort"; -import { isVertexExpressOpenAIUrl, isVertexRawPredictUrl } from "@oh-my-pi/pi-catalog/hosts"; +import { isVertexExpressOpenAIUrl, isVertexRawPredictUrl, resolveVertexEndpointHost } from "@oh-my-pi/pi-catalog/hosts"; import { mapEffortToAnthropicAdaptiveEffort, mapEffortToGoogleThinkingLevel, @@ -662,7 +662,7 @@ function resolveVertexRequest(input: string | URL | Request): string | URL | Req url.includes("{location}") || url.includes("%7Bproject%7D") || url.includes("%7Blocation%7D"); - const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`; + const host = resolveVertexEndpointHost(location); const rewritten = hasPlaceholder ? url .replace("https://{location}-aiplatform.googleapis.com", `https://${host}`) diff --git a/packages/ai/test/issue-1270-repro.test.ts b/packages/ai/test/issue-1270-repro.test.ts index 5d3582254..007f31c81 100644 --- a/packages/ai/test/issue-1270-repro.test.ts +++ b/packages/ai/test/issue-1270-repro.test.ts @@ -62,4 +62,42 @@ describe("issue #1270: Vertex AI global endpoint", () => { "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse", ); }); + + it.each([ + { + location: "eu", + host: "aiplatform.eu.rep.googleapis.com", + }, + { + location: "us", + host: "aiplatform.us.rep.googleapis.com", + }, + { + location: "europe-west4", + host: "europe-west4-aiplatform.googleapis.com", + }, + ] as const)("uses the $host host for location $location", async ({ location, host }) => { + delete Bun.env.GOOGLE_CLOUD_API_KEY; + delete Bun.env.GOOGLE_APPLICATION_CREDENTIALS; + + const urls: string[] = []; + const stream = streamGoogleVertex(model, context, { + project: "vertex-project", + location, + fetch: async input => { + const url = input instanceof Request ? input.url : input.toString(); + urls.push(url); + if (url === METADATA_TOKEN_URL || url === OAUTH_TOKEN_URL) { + return new Response(JSON.stringify({ access_token: "token", expires_in: 3600 })); + } + return new Response('{"error":{"message":"stop after capture"}}', { status: 400 }); + }, + }); + + await stream.result(); + + expect(urls).toContain( + `https://${host}/v1/projects/vertex-project/locations/${location}/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse`, + ); + }); }); diff --git a/packages/catalog/src/hosts.ts b/packages/catalog/src/hosts.ts index d18328711..ae3e769de 100644 --- a/packages/catalog/src/hosts.ts +++ b/packages/catalog/src/hosts.ts @@ -111,6 +111,24 @@ function includesAsciiCaseInsensitive(value: string, lowerNeedle: string): boole // --- Endpoint-shape predicates (URL path/verb shapes, not vendor hosts) --- +/** + * Hostname for a Vertex AI GenerateContent / rawPredict / OpenAI-compat + * request for the given location. + * + * - `global` → global endpoint (`aiplatform.googleapis.com`) + * - `eu` / `us` multi-regions → REP endpoints (`aiplatform.{eu|us}.rep.googleapis.com`) + * - every other location → regional (`{location}-aiplatform.googleapis.com`) + * + * Multi-region codes do NOT follow the regional `{location}-aiplatform` pattern; + * interpolating them that way yields hosts like `eu-aiplatform.googleapis.com` + * that 404. + */ +export function resolveVertexEndpointHost(location: string): string { + if (location === "global") return "aiplatform.googleapis.com"; + if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com`; + return `${location}-aiplatform.googleapis.com`; +} + /** Vertex AI express-mode OpenAI-compatible endpoint (`…/endpoints/openapi`). */ export function isVertexExpressOpenAIUrl(baseUrl: string): boolean { return baseUrl.includes("/endpoints/openapi"); diff --git a/packages/catalog/test/hosts.test.ts b/packages/catalog/test/hosts.test.ts index 68b61b146..263f88c5f 100644 --- a/packages/catalog/test/hosts.test.ts +++ b/packages/catalog/test/hosts.test.ts @@ -5,6 +5,7 @@ import { isVertexExpressOpenAIUrl, isVertexRawPredictUrl, modelMatchesHost, + resolveVertexEndpointHost, } from "@oh-my-pi/pi-catalog/hosts"; describe("hostMatchesUrl", () => { @@ -66,6 +67,14 @@ describe("endpoint shape predicates", () => { ).toBe(true); }); + test("resolves Vertex endpoint hosts for global, multi-region, and regional locations", () => { + expect(resolveVertexEndpointHost("global")).toBe("aiplatform.googleapis.com"); + expect(resolveVertexEndpointHost("eu")).toBe("aiplatform.eu.rep.googleapis.com"); + expect(resolveVertexEndpointHost("us")).toBe("aiplatform.us.rep.googleapis.com"); + expect(resolveVertexEndpointHost("europe-west4")).toBe("europe-west4-aiplatform.googleapis.com"); + expect(resolveVertexEndpointHost("us-central1")).toBe("us-central1-aiplatform.googleapis.com"); + }); + test("requires all DashScope compatible-mode URL markers", () => { expect(isDashscopeCompatibleModeUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")).toBe(true); expect(isDashscopeCompatibleModeUrl("https://example.aliyuncs.com/compatible-mode/v1")).toBe(false);