Skip to content

Commit f09b4eb

Browse files
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 c4b7cad - 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 <damian@felixpago.com>
1 parent fa3ff4d commit f09b4eb

11 files changed

Lines changed: 313 additions & 36 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
99
### Fixes
1010

1111
- 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.
12+
- 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.
1213

1314
## 2026.5.25
1415

extensions/google/index.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,25 @@ describe("google provider plugin hooks", () => {
199199
runCase(cliProvider, "google-gemini-cli");
200200
});
201201

202+
it("wires Vertex transport before request-time metadata ADC detection", async () => {
203+
const { providers } = await registerProviderPlugin({
204+
plugin: googleProviderPlugin,
205+
id: "google",
206+
name: "Google Provider",
207+
});
208+
const provider = requireRegisteredProvider(providers, "google");
209+
210+
expect(
211+
provider.createStreamFn?.({
212+
model: {
213+
api: "google-vertex",
214+
provider: "google",
215+
id: "gemini-2.5-pro",
216+
},
217+
} as never),
218+
).toEqual(expect.any(Function));
219+
});
220+
202221
it("advertises adaptive thinking for Gemini dynamic thinking", async () => {
203222
const { providers } = await registerProviderPlugin({
204223
plugin: googleProviderPlugin,

extensions/google/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"type": "module",
77
"dependencies": {
88
"@earendil-works/pi-ai": "0.75.4",
9-
"@google/genai": "2.5.0"
9+
"@google/genai": "2.5.0",
10+
"google-auth-library": "10.6.2"
1011
},
1112
"devDependencies": {
1213
"@openclaw/plugin-sdk": "workspace:*"

extensions/google/provider-registration.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
createGoogleGenerativeAiTransportStreamFn,
1414
createGoogleVertexTransportStreamFn,
1515
} from "./transport-stream.js";
16-
import { hasGoogleVertexAuthorizedUserAdcSync } from "./vertex-adc.js";
1716

1817
export function buildGoogleProvider(): ProviderPlugin {
1918
return {
@@ -57,7 +56,7 @@ export function buildGoogleProvider(): ProviderPlugin {
5756
if (model.api === "google-generative-ai") {
5857
return createGoogleGenerativeAiTransportStreamFn();
5958
}
60-
if (model.api === "google-vertex" && hasGoogleVertexAuthorizedUserAdcSync()) {
59+
if (model.api === "google-vertex") {
6160
return createGoogleVertexTransportStreamFn();
6261
}
6362
return undefined;

extensions/google/transport-stream.test.ts

Lines changed: 114 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,40 @@ import path from "node:path";
44
import type { Model } from "@earendil-works/pi-ai";
55
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
66

7-
const { buildGuardedModelFetchMock, guardedFetchMock } = vi.hoisted(() => ({
8-
buildGuardedModelFetchMock: vi.fn(),
9-
guardedFetchMock: vi.fn(),
10-
}));
7+
const {
8+
buildGuardedModelFetchMock,
9+
guardedFetchMock,
10+
googleAuthGetAccessTokenMock,
11+
googleAuthMock,
12+
} = vi.hoisted(() => {
13+
const googleAuthGetAccessTokenMock = vi.fn();
14+
return {
15+
buildGuardedModelFetchMock: vi.fn(),
16+
guardedFetchMock: vi.fn(),
17+
googleAuthGetAccessTokenMock,
18+
googleAuthMock: vi.fn(function GoogleAuthMock() {
19+
return {
20+
getAccessToken: googleAuthGetAccessTokenMock,
21+
};
22+
}),
23+
};
24+
});
1125

1226
vi.mock("openclaw/plugin-sdk/provider-transport-runtime", async (importOriginal) => ({
1327
...(await importOriginal()),
1428
buildGuardedModelFetch: buildGuardedModelFetchMock,
1529
}));
1630

31+
vi.mock("google-auth-library", () => ({
32+
GoogleAuth: googleAuthMock,
33+
}));
34+
1735
let buildGoogleGenerativeAiParams: typeof import("./transport-stream.js").buildGoogleGenerativeAiParams;
1836
let buildGoogleGemini3FirstResponseRetryParams: typeof import("./transport-stream.js").buildGoogleGemini3FirstResponseRetryParams;
1937
let createGoogleGenerativeAiTransportStreamFn: typeof import("./transport-stream.js").createGoogleGenerativeAiTransportStreamFn;
2038
let createGoogleVertexTransportStreamFn: typeof import("./transport-stream.js").createGoogleVertexTransportStreamFn;
2139
let hasGoogleVertexAuthorizedUserAdcSync: typeof import("./vertex-adc.js").hasGoogleVertexAuthorizedUserAdcSync;
40+
let resolveGoogleVertexAuthorizedUserHeaders: typeof import("./vertex-adc.js").resolveGoogleVertexAuthorizedUserHeaders;
2241
let resetGoogleVertexAuthorizedUserTokenCacheForTest: typeof import("./vertex-adc.js").resetGoogleVertexAuthorizedUserTokenCacheForTest;
2342

2443
const MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL = Symbol.for(
@@ -254,13 +273,18 @@ describe("google transport stream", () => {
254273
createGoogleGenerativeAiTransportStreamFn,
255274
createGoogleVertexTransportStreamFn,
256275
} = await import("./transport-stream.js"));
257-
({ hasGoogleVertexAuthorizedUserAdcSync, resetGoogleVertexAuthorizedUserTokenCacheForTest } =
258-
await import("./vertex-adc.js"));
276+
({
277+
hasGoogleVertexAuthorizedUserAdcSync,
278+
resolveGoogleVertexAuthorizedUserHeaders,
279+
resetGoogleVertexAuthorizedUserTokenCacheForTest,
280+
} = await import("./vertex-adc.js"));
259281
});
260282

261283
beforeEach(() => {
262284
buildGuardedModelFetchMock.mockReset();
263285
guardedFetchMock.mockReset();
286+
googleAuthGetAccessTokenMock.mockReset();
287+
googleAuthMock.mockClear();
264288
buildGuardedModelFetchMock.mockReturnValue(guardedFetchMock);
265289
resetGoogleVertexAuthorizedUserTokenCacheForTest();
266290
});
@@ -271,6 +295,7 @@ describe("google transport stream", () => {
271295

272296
afterAll(() => {
273297
vi.doUnmock("openclaw/plugin-sdk/provider-transport-runtime");
298+
vi.doUnmock("google-auth-library");
274299
vi.resetModules();
275300
});
276301

@@ -695,6 +720,89 @@ describe("google transport stream", () => {
695720
});
696721
});
697722

723+
it("detects supported Vertex ADC sources synchronously", async () => {
724+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-detect-"));
725+
for (const type of ["authorized_user", "external_account", "service_account"]) {
726+
const credentialsPath = path.join(tempDir, `${type}.json`);
727+
await writeFile(credentialsPath, JSON.stringify({ type }), "utf8");
728+
729+
expect(
730+
hasGoogleVertexAuthorizedUserAdcSync({
731+
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
732+
}),
733+
).toBe(true);
734+
}
735+
736+
expect(
737+
hasGoogleVertexAuthorizedUserAdcSync({
738+
HOME: path.join(tempDir, "empty-home"),
739+
KUBERNETES_SERVICE_HOST: "10.0.0.1",
740+
}),
741+
).toBe(false);
742+
});
743+
744+
it("resolves non-file Vertex ADC through google-auth-library without OAuth refresh fetch", async () => {
745+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-authlib-"));
746+
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
747+
vi.stubEnv("HOME", path.join(tempDir, "home"));
748+
vi.stubEnv("APPDATA", "");
749+
googleAuthGetAccessTokenMock.mockResolvedValueOnce("ya29.google-auth-token");
750+
const tokenFetchMock = vi.fn();
751+
752+
await expect(resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock)).resolves.toEqual({
753+
Authorization: "Bearer ya29.google-auth-token",
754+
});
755+
756+
expect(googleAuthMock).toHaveBeenCalledWith({
757+
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
758+
});
759+
expect(googleAuthGetAccessTokenMock).toHaveBeenCalledTimes(1);
760+
expect(tokenFetchMock).not.toHaveBeenCalled();
761+
});
762+
763+
it("uses google-auth-library bearer auth for Google Vertex credential marker requests", async () => {
764+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-authlib-stream-"));
765+
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
766+
vi.stubEnv("HOME", path.join(tempDir, "home"));
767+
vi.stubEnv("APPDATA", "");
768+
vi.stubEnv("GOOGLE_CLOUD_PROJECT", "vertex-project");
769+
vi.stubEnv("GOOGLE_CLOUD_LOCATION", "us-central1");
770+
googleAuthGetAccessTokenMock.mockResolvedValueOnce("ya29.transport-token");
771+
const tokenFetchMock = vi.fn();
772+
guardedFetchMock.mockResolvedValueOnce(
773+
buildSseResponse([
774+
{
775+
candidates: [{ content: { parts: [{ text: "ok" }] }, finishReason: "STOP" }],
776+
},
777+
]),
778+
);
779+
780+
const streamFn = createGoogleVertexTransportStreamFn();
781+
const stream = await Promise.resolve(
782+
streamFn(
783+
buildGoogleVertexModel(),
784+
{
785+
messages: [{ role: "user", content: "hello", timestamp: 0 }],
786+
} as Parameters<typeof streamFn>[1],
787+
{
788+
apiKey: "gcp-vertex-credentials",
789+
fetch: tokenFetchMock,
790+
} as Parameters<typeof streamFn>[2],
791+
),
792+
);
793+
await stream.result();
794+
795+
expect(tokenFetchMock).not.toHaveBeenCalled();
796+
const guardedCall = requireMockCall(guardedFetchMock, 0, "guarded fetch");
797+
const guardedInit = requireRequestInit(guardedCall, "guarded fetch");
798+
expectHeaders(guardedInit, {
799+
Authorization: "Bearer ya29.transport-token",
800+
"Content-Type": "application/json",
801+
accept: "text/event-stream",
802+
});
803+
expect(new Headers(guardedInit.headers).has("x-goog-api-key")).toBe(false);
804+
});
805+
698806
it("refreshes authorized_user ADC before Google Vertex requests", async () => {
699807
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-"));
700808
const credentialsPath = path.join(tempDir, "application_default_credentials.json");

0 commit comments

Comments
 (0)