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 c4b7cad4df
- 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>
This commit is contained in:
Damian Finol
2026-05-24 21:37:52 -03:00
committed by GitHub
parent fa3ff4d503
commit f09b4ebe31
11 changed files with 313 additions and 36 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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:*"

View File

@@ -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;

View File

@@ -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<typeof streamFn>[1],
{
apiKey: "gcp-vertex-credentials",
fetch: tokenFetchMock,
} as Parameters<typeof streamFn>[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");

View File

@@ -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<string | null | undefined>;
}>;
}
| 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<string> {
// 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<Record<string, string>> {
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}` };
}

3
pnpm-lock.yaml generated
View File

@@ -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:*

View File

@@ -68,6 +68,24 @@ describe("buildInlineProviderModels", () => {
]);
});
it("preserves google-vertex api inherited from provider config", () => {
const providers: Parameters<typeof buildInlineProviderModels>[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<typeof buildInlineProviderModels>[0] = {
custom: {

View File

@@ -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":

View File

@@ -13,6 +13,7 @@ export const MODEL_APIS = [
"openai-codex-responses",
"anthropic-messages",
"google-generative-ai",
"google-vertex",
"github-copilot",
"bedrock-converse-stream",
"ollama",

View File

@@ -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);
});
});