From f09b4ebe314ea357f5d0a5528965219871ca8831 Mon Sep 17 00:00:00 2001 From: Damian Finol Date: Sun, 24 May 2026 21:37:52 -0300 Subject: [PATCH] fix(google-vertex): support production ADC modes (#83971) Fix Google Vertex production ADC mode support by routing explicit google-vertex models to the Vertex transport and relying on google-auth-library for request-time ADC resolution. Verification: - pnpm install --frozen-lockfile - pnpm test extensions/google/transport-stream.test.ts extensions/google/index.test.ts src/config/zod-schema.models.test.ts src/agents/pi-embedded-runner/model.inline-provider.test.ts -- --reporter=verbose - pnpm check:changed - GitHub PR checks green on c4b7cad4df0ffccb9f420aae7634bd5f5461c018 - Live ADC smoke reached Google Vertex auth/transport and failed only because the configured redacted project has the Vertex AI API disabled Co-authored-by: Damian Finol --- CHANGELOG.md | 1 + extensions/google/index.test.ts | 19 +++ extensions/google/package.json | 3 +- extensions/google/provider-registration.ts | 3 +- extensions/google/transport-stream.test.ts | 120 +++++++++++++- extensions/google/vertex-adc.ts | 155 +++++++++++++++--- pnpm-lock.yaml | 3 + .../model.inline-provider.test.ts | 18 ++ .../model.inline-provider.ts | 1 + src/config/types.models.ts | 1 + src/config/zod-schema.models.test.ts | 25 +++ 11 files changed, 313 insertions(+), 36 deletions(-) create mode 100644 src/config/zod-schema.models.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8694e02b298..b165c38f0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Codex: log when implicit app-server `never` approvals are promoted for OpenClaw tool policy, including whether the trigger was a `before_tool_call` hook or trusted tool policy. +- Google Vertex: support production ADC modes such as Workload Identity Federation, service-account credentials, and metadata-server ADC for the native Vertex transport. (#83971) Thanks @damianFelixPago. ## 2026.5.25 diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index b0cdec99cba..dd546cc3fef 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -199,6 +199,25 @@ describe("google provider plugin hooks", () => { runCase(cliProvider, "google-gemini-cli"); }); + it("wires Vertex transport before request-time metadata ADC detection", async () => { + const { providers } = await registerProviderPlugin({ + plugin: googleProviderPlugin, + id: "google", + name: "Google Provider", + }); + const provider = requireRegisteredProvider(providers, "google"); + + expect( + provider.createStreamFn?.({ + model: { + api: "google-vertex", + provider: "google", + id: "gemini-2.5-pro", + }, + } as never), + ).toEqual(expect.any(Function)); + }); + it("advertises adaptive thinking for Gemini dynamic thinking", async () => { const { providers } = await registerProviderPlugin({ plugin: googleProviderPlugin, diff --git a/extensions/google/package.json b/extensions/google/package.json index 7e9c2241c0a..82737378996 100644 --- a/extensions/google/package.json +++ b/extensions/google/package.json @@ -6,7 +6,8 @@ "type": "module", "dependencies": { "@earendil-works/pi-ai": "0.75.4", - "@google/genai": "2.5.0" + "@google/genai": "2.5.0", + "google-auth-library": "10.6.2" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/google/provider-registration.ts b/extensions/google/provider-registration.ts index f97d0cb9d26..d7ee167f21c 100644 --- a/extensions/google/provider-registration.ts +++ b/extensions/google/provider-registration.ts @@ -13,7 +13,6 @@ import { createGoogleGenerativeAiTransportStreamFn, createGoogleVertexTransportStreamFn, } from "./transport-stream.js"; -import { hasGoogleVertexAuthorizedUserAdcSync } from "./vertex-adc.js"; export function buildGoogleProvider(): ProviderPlugin { return { @@ -57,7 +56,7 @@ export function buildGoogleProvider(): ProviderPlugin { if (model.api === "google-generative-ai") { return createGoogleGenerativeAiTransportStreamFn(); } - if (model.api === "google-vertex" && hasGoogleVertexAuthorizedUserAdcSync()) { + if (model.api === "google-vertex") { return createGoogleVertexTransportStreamFn(); } return undefined; diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index b19f6ca0eb0..542eb8e306a 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -4,21 +4,40 @@ import path from "node:path"; import type { Model } from "@earendil-works/pi-ai"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const { buildGuardedModelFetchMock, guardedFetchMock } = vi.hoisted(() => ({ - buildGuardedModelFetchMock: vi.fn(), - guardedFetchMock: vi.fn(), -})); +const { + buildGuardedModelFetchMock, + guardedFetchMock, + googleAuthGetAccessTokenMock, + googleAuthMock, +} = vi.hoisted(() => { + const googleAuthGetAccessTokenMock = vi.fn(); + return { + buildGuardedModelFetchMock: vi.fn(), + guardedFetchMock: vi.fn(), + googleAuthGetAccessTokenMock, + googleAuthMock: vi.fn(function GoogleAuthMock() { + return { + getAccessToken: googleAuthGetAccessTokenMock, + }; + }), + }; +}); vi.mock("openclaw/plugin-sdk/provider-transport-runtime", async (importOriginal) => ({ ...(await importOriginal()), buildGuardedModelFetch: buildGuardedModelFetchMock, })); +vi.mock("google-auth-library", () => ({ + GoogleAuth: googleAuthMock, +})); + let buildGoogleGenerativeAiParams: typeof import("./transport-stream.js").buildGoogleGenerativeAiParams; let buildGoogleGemini3FirstResponseRetryParams: typeof import("./transport-stream.js").buildGoogleGemini3FirstResponseRetryParams; let createGoogleGenerativeAiTransportStreamFn: typeof import("./transport-stream.js").createGoogleGenerativeAiTransportStreamFn; let createGoogleVertexTransportStreamFn: typeof import("./transport-stream.js").createGoogleVertexTransportStreamFn; let hasGoogleVertexAuthorizedUserAdcSync: typeof import("./vertex-adc.js").hasGoogleVertexAuthorizedUserAdcSync; +let resolveGoogleVertexAuthorizedUserHeaders: typeof import("./vertex-adc.js").resolveGoogleVertexAuthorizedUserHeaders; let resetGoogleVertexAuthorizedUserTokenCacheForTest: typeof import("./vertex-adc.js").resetGoogleVertexAuthorizedUserTokenCacheForTest; const MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL = Symbol.for( @@ -254,13 +273,18 @@ describe("google transport stream", () => { createGoogleGenerativeAiTransportStreamFn, createGoogleVertexTransportStreamFn, } = await import("./transport-stream.js")); - ({ hasGoogleVertexAuthorizedUserAdcSync, resetGoogleVertexAuthorizedUserTokenCacheForTest } = - await import("./vertex-adc.js")); + ({ + hasGoogleVertexAuthorizedUserAdcSync, + resolveGoogleVertexAuthorizedUserHeaders, + resetGoogleVertexAuthorizedUserTokenCacheForTest, + } = await import("./vertex-adc.js")); }); beforeEach(() => { buildGuardedModelFetchMock.mockReset(); guardedFetchMock.mockReset(); + googleAuthGetAccessTokenMock.mockReset(); + googleAuthMock.mockClear(); buildGuardedModelFetchMock.mockReturnValue(guardedFetchMock); resetGoogleVertexAuthorizedUserTokenCacheForTest(); }); @@ -271,6 +295,7 @@ describe("google transport stream", () => { afterAll(() => { vi.doUnmock("openclaw/plugin-sdk/provider-transport-runtime"); + vi.doUnmock("google-auth-library"); vi.resetModules(); }); @@ -695,6 +720,89 @@ describe("google transport stream", () => { }); }); + it("detects supported Vertex ADC sources synchronously", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-detect-")); + for (const type of ["authorized_user", "external_account", "service_account"]) { + const credentialsPath = path.join(tempDir, `${type}.json`); + await writeFile(credentialsPath, JSON.stringify({ type }), "utf8"); + + expect( + hasGoogleVertexAuthorizedUserAdcSync({ + GOOGLE_APPLICATION_CREDENTIALS: credentialsPath, + }), + ).toBe(true); + } + + expect( + hasGoogleVertexAuthorizedUserAdcSync({ + HOME: path.join(tempDir, "empty-home"), + KUBERNETES_SERVICE_HOST: "10.0.0.1", + }), + ).toBe(false); + }); + + it("resolves non-file Vertex ADC through google-auth-library without OAuth refresh fetch", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-authlib-")); + vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", ""); + vi.stubEnv("HOME", path.join(tempDir, "home")); + vi.stubEnv("APPDATA", ""); + googleAuthGetAccessTokenMock.mockResolvedValueOnce("ya29.google-auth-token"); + const tokenFetchMock = vi.fn(); + + await expect(resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock)).resolves.toEqual({ + Authorization: "Bearer ya29.google-auth-token", + }); + + expect(googleAuthMock).toHaveBeenCalledWith({ + scopes: ["https://www.googleapis.com/auth/cloud-platform"], + }); + expect(googleAuthGetAccessTokenMock).toHaveBeenCalledTimes(1); + expect(tokenFetchMock).not.toHaveBeenCalled(); + }); + + it("uses google-auth-library bearer auth for Google Vertex credential marker requests", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-authlib-stream-")); + vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", ""); + vi.stubEnv("HOME", path.join(tempDir, "home")); + vi.stubEnv("APPDATA", ""); + vi.stubEnv("GOOGLE_CLOUD_PROJECT", "vertex-project"); + vi.stubEnv("GOOGLE_CLOUD_LOCATION", "us-central1"); + googleAuthGetAccessTokenMock.mockResolvedValueOnce("ya29.transport-token"); + const tokenFetchMock = vi.fn(); + guardedFetchMock.mockResolvedValueOnce( + buildSseResponse([ + { + candidates: [{ content: { parts: [{ text: "ok" }] }, finishReason: "STOP" }], + }, + ]), + ); + + const streamFn = createGoogleVertexTransportStreamFn(); + const stream = await Promise.resolve( + streamFn( + buildGoogleVertexModel(), + { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + } as Parameters[1], + { + apiKey: "gcp-vertex-credentials", + fetch: tokenFetchMock, + } as Parameters[2], + ), + ); + await stream.result(); + + expect(tokenFetchMock).not.toHaveBeenCalled(); + const guardedCall = requireMockCall(guardedFetchMock, 0, "guarded fetch"); + const guardedInit = requireRequestInit(guardedCall, "guarded fetch"); + expectHeaders(guardedInit, { + Authorization: "Bearer ya29.transport-token", + "Content-Type": "application/json", + accept: "text/event-stream", + }); + expect(new Headers(guardedInit.headers).has("x-goog-api-key")).toBe(false); + }); + it("refreshes authorized_user ADC before Google Vertex requests", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-")); const credentialsPath = path.join(tempDir, "application_default_credentials.json"); diff --git a/extensions/google/vertex-adc.ts b/extensions/google/vertex-adc.ts index a6712df1030..7e0742e08ca 100644 --- a/extensions/google/vertex-adc.ts +++ b/extensions/google/vertex-adc.ts @@ -17,13 +17,33 @@ type GoogleVertexAuthorizedUserToken = { refreshToken: string; }; +type GoogleVertexAdcToken = { + token: string; + expiresAtMs: number; +}; + const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_VERTEX_OAUTH_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; +// Hold tokens slightly less long than reported expiry (Google's recommendation +// is a 60s buffer) so we don't ship a request that's already revoked when it +// leaves the gateway. +const GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS = 60_000; let cachedGoogleVertexAuthorizedUserToken: GoogleVertexAuthorizedUserToken | undefined; +let cachedGoogleAuthClient: + | { + promise: Promise<{ + getAccessToken: () => Promise; + }>; + } + | undefined; +let cachedGoogleVertexAdcToken: GoogleVertexAdcToken | undefined; export function resetGoogleVertexAuthorizedUserTokenCacheForTest(): void { cachedGoogleVertexAuthorizedUserToken = undefined; + cachedGoogleAuthClient = undefined; + cachedGoogleVertexAdcToken = undefined; } function normalizeOptionalString(value: unknown): string | undefined { @@ -85,24 +105,45 @@ async function readGoogleAuthorizedUserCredentials( }; } +function readGoogleAdcCredentialsTypeSync(credentialsPath: string): string | undefined { + try { + const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + const type = (parsed as { type?: unknown }).type; + return typeof type === "string" ? type : undefined; + } catch { + return undefined; + } +} + +/** + * Returns true when a file/env Application Default Credentials source usable + * for Google Vertex AI is detectable synchronously. We still call the function + * `...AuthorizedUserAdcSync` for backwards compatibility with older tests; the + * predicate now also covers: + * + * 1. `authorized_user` credentials file (existing case - `gcloud auth + * application-default login` produces this). + * 2. `external_account` credentials file (Workload Identity Federation). + * 3. `service_account` credentials file (raw GSA key - rarely used in + * OpenClaw, included for completeness). + * Metadata-server ADC is intentionally not detected here: `google-auth-library` + * probes the default metadata hosts asynchronously at request time, and the + * provider wires the Vertex transport without this sync predicate. + */ export function hasGoogleVertexAuthorizedUserAdcSync( env: NodeJS.ProcessEnv = process.env, ): boolean { const credentialsPath = resolveGoogleApplicationCredentialsPath(env); - if (!credentialsPath) { - return false; - } - try { - const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as unknown; - return ( - Boolean(parsed) && - typeof parsed === "object" && - !Array.isArray(parsed) && - (parsed as { type?: unknown }).type === "authorized_user" - ); - } catch { - return false; + if (credentialsPath) { + const type = readGoogleAdcCredentialsTypeSync(credentialsPath); + if (type === "authorized_user" || type === "external_account" || type === "service_account") { + return true; + } } + return false; } async function refreshGoogleVertexAuthorizedUserAccessToken(params: { @@ -123,7 +164,7 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: { if ( cached?.credentialsPath === params.credentialsPath && cached.refreshToken === refreshToken && - cached.expiresAtMs - Date.now() > 60_000 + cached.expiresAtMs - Date.now() > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS ) { return cached.token; } @@ -166,23 +207,83 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: { return token; } +async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise { + // Lazy-import + cache so we don't pay the google-auth-library load cost on + // gateway startup; only when we actually need a non-authorized_user token. + if (!cachedGoogleAuthClient) { + cachedGoogleAuthClient = { + promise: import("google-auth-library").then(({ GoogleAuth }) => { + // GoogleAuth handles every ADC variant we care about for GKE: + // - external_account (Workload Identity Federation: STS exchange) + // - service_account (raw GSA key: JWT-bearer) + // - GKE Workload Identity (metadata server when no credentials file) + // - Compute Engine / Cloud Run / GAE metadata server fallback + // It also caches tokens internally and refreshes before expiry. + return new GoogleAuth({ + scopes: [GOOGLE_VERTEX_OAUTH_SCOPE], + }); + }), + }; + } + const auth = await cachedGoogleAuthClient.promise; + + const cached = cachedGoogleVertexAdcToken; + if (cached && cached.expiresAtMs - Date.now() > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS) { + return cached.token; + } + + const token = await auth.getAccessToken(); + const normalized = normalizeOptionalString(token); + if (!normalized) { + throw new Error( + "Google Vertex ADC fallback (google-auth-library) did not return an access token. " + + "Verify the GKE Workload Identity binding (KSA \u2192 GSA), `GOOGLE_APPLICATION_CREDENTIALS`, " + + "or other ADC source is reachable from this pod.", + ); + } + // google-auth-library doesn't expose token expiry on the simple + // `getAccessToken()` return type, so we cache for a conservative 5 minutes. + // The library itself already refreshes well before its own internal expiry, + // so this cache is mainly to avoid hot-loop calls into the auth client. + cachedGoogleVertexAdcToken = { + token: normalized, + expiresAtMs: Date.now() + 5 * 60_000, + }; + return normalized; +} + +/** + * Resolve `Authorization: Bearer ...` headers for Google Vertex calls. + * + * We try the hand-rolled `authorized_user` refresh path first (preserves the + * existing fetchImpl test seam and the OpenClaw upstream behaviour); when the + * configured ADC source is anything other than `authorized_user` (the common + * production cases on GKE: Workload Identity, Workload Identity Federation, + * service-account JSON keys), we hand off to `google-auth-library` which + * understands all of those natively. + * + * Note: the function is still named `...AuthorizedUserHeaders` to avoid a + * symbol rename across the existing patch surface; the docstring above is + * the truth, the name is legacy. + */ export async function resolveGoogleVertexAuthorizedUserHeaders( fetchImpl?: typeof fetch, ): Promise> { const credentialsPath = resolveGoogleApplicationCredentialsPath(); - if (!credentialsPath) { - throw new Error( - "Google Vertex ADC credentials not found. Set GOOGLE_APPLICATION_CREDENTIALS or run gcloud auth application-default login.", - ); + if (credentialsPath) { + const credentials = await readGoogleAuthorizedUserCredentials(credentialsPath); + if (credentials) { + const token = await refreshGoogleVertexAuthorizedUserAccessToken({ + credentialsPath, + credentials, + fetchImpl, + }); + return { Authorization: `Bearer ${token}` }; + } } - const credentials = await readGoogleAuthorizedUserCredentials(credentialsPath); - if (!credentials) { - throw new Error("Google Vertex ADC fallback requires an authorized_user credentials file."); - } - const token = await refreshGoogleVertexAuthorizedUserAccessToken({ - credentialsPath, - credentials, - fetchImpl, - }); + // No file-based authorized_user ADC. Fall back to google-auth-library which + // handles GKE Workload Identity (metadata server), Workload Identity + // Federation (external_account), and service-account keys. + const token = await resolveGoogleVertexAccessTokenViaGoogleAuth(); return { Authorization: `Bearer ${token}` }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dae95587b6c..1b7ffbb4837 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -745,6 +745,9 @@ importers: '@google/genai': specifier: 2.5.0 version: 2.5.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + google-auth-library: + specifier: 10.6.2 + version: 10.6.2 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* diff --git a/src/agents/pi-embedded-runner/model.inline-provider.test.ts b/src/agents/pi-embedded-runner/model.inline-provider.test.ts index 282c565b991..615a7530f94 100644 --- a/src/agents/pi-embedded-runner/model.inline-provider.test.ts +++ b/src/agents/pi-embedded-runner/model.inline-provider.test.ts @@ -68,6 +68,24 @@ describe("buildInlineProviderModels", () => { ]); }); + it("preserves google-vertex api inherited from provider config", () => { + const providers: Parameters[0] = { + google: { + baseUrl: "https://us-central1-aiplatform.googleapis.com/v1", + api: "google-vertex", + models: [makeModel("gemini-2.5-pro")], + }, + }; + + const result = buildInlineProviderModels(providers); + + expect(result).toHaveLength(1); + expect(result[0].provider).toBe("google"); + expect(result[0].baseUrl).toBe("https://us-central1-aiplatform.googleapis.com/v1"); + expect(result[0].api).toBe("google-vertex"); + expect(result[0].id).toBe("gemini-2.5-pro"); + }); + it("model-level api takes precedence over provider-level api", () => { const providers: Parameters[0] = { custom: { diff --git a/src/agents/pi-embedded-runner/model.inline-provider.ts b/src/agents/pi-embedded-runner/model.inline-provider.ts index 7ae1587101a..4c361b14545 100644 --- a/src/agents/pi-embedded-runner/model.inline-provider.ts +++ b/src/agents/pi-embedded-runner/model.inline-provider.ts @@ -40,6 +40,7 @@ export function normalizeResolvedTransportApi( case "bedrock-converse-stream": case "github-copilot": case "google-generative-ai": + case "google-vertex": case "ollama": case "openai-codex-responses": case "openai-completions": diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 9029e18bb2f..a9ce8b27860 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -13,6 +13,7 @@ export const MODEL_APIS = [ "openai-codex-responses", "anthropic-messages", "google-generative-ai", + "google-vertex", "github-copilot", "bedrock-converse-stream", "ollama", diff --git a/src/config/zod-schema.models.test.ts b/src/config/zod-schema.models.test.ts new file mode 100644 index 00000000000..aad35819d92 --- /dev/null +++ b/src/config/zod-schema.models.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { ModelsConfigSchema } from "./zod-schema.core.js"; + +describe("ModelsConfigSchema", () => { + it("accepts google-vertex as a model API from MODEL_APIS", () => { + const result = ModelsConfigSchema.safeParse({ + providers: { + "google-vertex": { + baseUrl: "https://{location}-aiplatform.googleapis.com", + api: "google-vertex", + apiKey: "gcp-vertex-credentials", + models: [ + { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + api: "google-vertex", + }, + ], + }, + }, + }); + + expect(result.success).toBe(true); + }); +});