fix(google): preserve Vertex ADC catalog auth (#90609)

* fix: preserve Google Vertex ADC catalog auth

* fix: register Google Vertex ADC config marker

* fix: fill Vertex ADC static catalog auth
This commit is contained in:
Yzx
2026-06-06 06:16:34 +08:00
committed by GitHub
parent 6da3b1f6a3
commit a4f7e4cbb9
8 changed files with 210 additions and 3 deletions

View File

@@ -1,4 +1,7 @@
// Google tests cover index plugin behavior.
import { mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Context, Model } from "openclaw/plugin-sdk/llm";
import type {
ProviderReplaySessionEntry,
@@ -14,6 +17,7 @@ import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-v
import { describe, expect, it, vi } from "vitest";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import googlePlugin from "./index.js";
import googleProviderDiscovery from "./provider-discovery.js";
import { registerGoogleProvider } from "./provider-registration.js";
const googleProviderPlugin = {
@@ -163,6 +167,59 @@ describe("google provider plugin hooks", () => {
).toBe("native");
});
it("resolves Google Vertex ADC auth evidence to the config marker", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-config-key-"));
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
await writeFile(
credentialsPath,
JSON.stringify({
type: "authorized_user",
client_id: "client-id",
client_secret: "client-secret",
refresh_token: "refresh-token",
}),
"utf8",
);
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const provider = requireRegisteredProvider(providers, "google-vertex");
expect(
provider.resolveConfigApiKey?.({
provider: "google-vertex",
env: {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
},
}),
).toBe("gcp-vertex-credentials");
expect(
provider.resolveConfigApiKey?.({
provider: "google-vertex",
env: {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "",
GCLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
},
}),
).toBe("gcp-vertex-credentials");
expect(
googleProviderDiscovery.resolveConfigApiKey?.({
provider: "google-vertex",
env: {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
},
}),
).toBe("gcp-vertex-credentials");
});
it("owns Gemini tool schema normalization for direct and CLI providers", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,

View File

@@ -4,12 +4,15 @@ import {
buildGoogleStaticCatalogProvider,
buildGoogleVertexStaticCatalogProvider,
} from "./provider-catalog.js";
import { resolveGoogleVertexConfigApiKey } from "./vertex-adc.js";
const googleProviderDiscovery: ProviderPlugin = {
id: "google",
label: "Google AI Studio",
docsPath: "/providers/models",
auth: [],
resolveConfigApiKey: ({ provider, env }) =>
provider === "google-vertex" ? resolveGoogleVertexConfigApiKey(env) : undefined,
staticCatalog: {
order: "simple",
run: async () => ({

View File

@@ -22,6 +22,7 @@ import {
createGoogleGenerativeAiTransportStreamFn,
createGoogleVertexTransportStreamFn,
} from "./transport-stream.js";
import { resolveGoogleVertexConfigApiKey } from "./vertex-adc.js";
function resolveGoogleReasoningOutputMode(
ctx: ProviderReasoningOutputModeContext,
@@ -68,6 +69,8 @@ export function buildGoogleProvider(): ProviderPlugin {
resolveGoogleGenerativeAiTransport({ provider, api, baseUrl }),
normalizeConfig: ({ provider, providerConfig }) =>
normalizeGoogleProviderConfig(provider, providerConfig),
resolveConfigApiKey: ({ provider, env }) =>
provider === "google-vertex" ? resolveGoogleVertexConfigApiKey(env) : undefined,
staticCatalog: {
order: "simple",
run: async () => ({

View File

@@ -93,6 +93,17 @@ export function isGoogleVertexCredentialsMarker(
return apiKey === undefined || apiKey === GCP_VERTEX_CREDENTIALS_MARKER;
}
function hasGoogleVertexProjectEnv(env: NodeJS.ProcessEnv): boolean {
return Boolean(
normalizeOptionalString(env.GOOGLE_CLOUD_PROJECT) ||
normalizeOptionalString(env.GCLOUD_PROJECT),
);
}
function hasGoogleVertexLocationEnv(env: NodeJS.ProcessEnv): boolean {
return Boolean(normalizeOptionalString(env.GOOGLE_CLOUD_LOCATION));
}
function resolveGoogleApplicationCredentialsPath(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
@@ -183,6 +194,16 @@ export function hasGoogleVertexAuthorizedUserAdcSync(
return false;
}
export function resolveGoogleVertexConfigApiKey(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
return hasGoogleVertexProjectEnv(env) &&
hasGoogleVertexLocationEnv(env) &&
hasGoogleVertexAuthorizedUserAdcSync(env)
? GCP_VERTEX_CREDENTIALS_MARKER
: undefined;
}
async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
credentialsPath: string;
credentials: GoogleAuthorizedUserCredentials;

View File

@@ -495,6 +495,60 @@ describe("models-config", () => {
}
});
it("keeps google-vertex static catalog rows when ADC auth evidence supplies the marker", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-models-"));
const credentialsPath = path.join(agentDir, "application_default_credentials.json");
await fs.writeFile(credentialsPath, JSON.stringify({ type: "authorized_user" }), "utf8");
try {
const plan = await planOpenClawModelsJsonWithDeps(
{
cfg: {
agents: {
defaults: {
models: {
"google-vertex/gemini-2.5-pro": {},
},
model: { primary: "google-vertex/gemini-2.5-pro" },
},
},
models: { providers: {} },
},
agentDir,
env: {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
} as NodeJS.ProcessEnv,
existingRaw: "",
existingParsed: null,
},
{
resolveImplicitProviders: async () => ({
"google-vertex": createImplicitGoogleVertexProvider(),
}),
},
);
expect(plan.action).toBe("write");
if (plan.action !== "write") {
throw new Error("Expected models.json write plan");
}
const parsed = JSON.parse(plan.contents) as {
providers?: Record<
string,
{ apiKey?: string; api?: string; models?: Array<{ id?: string }> }
>;
};
expect(parsed.providers?.["google-vertex"]?.api).toBe("google-vertex");
expect(parsed.providers?.["google-vertex"]?.apiKey).toBe("gcp-vertex-credentials");
expect(parsed.providers?.["google-vertex"]?.models?.map((model) => model.id)).toEqual([
"gemini-2.5-pro",
]);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("uses config env.vars entries for implicit provider discovery without mutating process.env", async () => {
await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => {
unsetEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR]);

View File

@@ -1,4 +1,7 @@
// Exercises startup provider discovery scoping without loading real plugin manifests.
import { mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginMetadataSnapshotOwnerMaps } from "../plugins/plugin-metadata-snapshot.js";
import type { ProviderPlugin } from "../plugins/types.js";
@@ -205,6 +208,38 @@ describe("resolveImplicitProviders startup discovery scope", () => {
expect(mocks.runProviderCatalog).not.toHaveBeenCalled();
});
it("fills missing static catalog apiKey from Google Vertex ADC auth evidence", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-"));
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
await writeFile(credentialsPath, JSON.stringify({ type: "authorized_user" }));
mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([
createStaticOnlyProvider("google"),
]);
mocks.runProviderStaticCatalog.mockResolvedValue({
providers: {
"google-vertex": {
baseUrl: "https://aiplatform.googleapis.com",
api: "google-vertex" as const,
models: [createTextModel("gemini-3.1-pro-preview", "Gemini 3.1 Pro Preview")],
},
},
});
const providers = await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
config: {},
env: {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
} as NodeJS.ProcessEnv,
explicitProviders: {},
providerDiscoveryEntriesOnly: true,
});
expect(providers?.["google-vertex"]?.apiKey).toBe("gcp-vertex-credentials");
});
it("falls back to static provider catalogs when runtime discovery has no rows", async () => {
mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([
createProviderWithStaticCatalog("minimax"),

View File

@@ -38,6 +38,7 @@ import type {
import {
createProviderApiKeyResolver,
createProviderAuthResolver,
resolveMissingProviderApiKey,
} from "./models-config.providers.secrets.js";
const log = createSubsystemLogger("agents/model-providers");
@@ -293,6 +294,19 @@ function mergeImplicitProviderConfig(params: {
};
}
function resolveImplicitProviderAuthMarker(params: {
ctx: ImplicitProviderContext;
providerId: string;
provider: ProviderConfig;
}): ProviderConfig {
return resolveMissingProviderApiKey({
providerKey: params.providerId,
provider: params.provider,
env: params.ctx.env,
profileApiKey: undefined,
});
}
function resolveConfiguredImplicitProvider(params: {
configuredProviders?: Record<string, ProviderConfig> | null;
providerIds: readonly string[];
@@ -430,7 +444,7 @@ async function resolvePluginImplicitProviders(
result,
});
for (const [providerId, implicitProvider] of Object.entries(normalizedResult)) {
discovered[providerId] = mergeImplicitProviderConfig({
const mergedProvider = mergeImplicitProviderConfig({
providerId,
existing:
discovered[providerId] ??
@@ -449,6 +463,11 @@ async function resolvePluginImplicitProviders(
providerId,
}),
});
discovered[providerId] = resolveImplicitProviderAuthMarker({
ctx,
providerId,
provider: mergedProvider,
});
}
}
return Object.keys(discovered).length > 0 ? discovered : undefined;

View File

@@ -98,6 +98,18 @@ export function resolveAwsSdkApiKeyVarName(
return resolveAwsSdkEnvVarName(env);
}
function resolveEnvAuthEvidenceApiKeyMarker(
provider: string,
env: NodeJS.ProcessEnv,
): string | undefined {
const resolved = resolveEnvApiKey(provider, env);
const apiKey = resolved?.apiKey?.trim();
if (!apiKey || !isNonSecretApiKeyMarker(apiKey, { includeEnvVarName: false })) {
return undefined;
}
return apiKey;
}
/** Rewrites secret-backed provider headers to stable marker values. */
export function normalizeHeaderValues(params: {
headers: ProviderConfig["headers"] | undefined;
@@ -334,11 +346,14 @@ export function resolveMissingProviderApiKey(params: {
}
const fromEnv = resolveEnvApiKeyVarName(params.providerKey, params.env);
const apiKey = fromEnv ?? params.profileApiKey?.apiKey;
const fromAuthEvidence = fromEnv
? undefined
: resolveEnvAuthEvidenceApiKeyMarker(params.providerKey, params.env);
const apiKey = fromEnv ?? fromAuthEvidence ?? params.profileApiKey?.apiKey;
if (!apiKey?.trim()) {
return params.provider;
}
if (params.profileApiKey && params.profileApiKey.source !== "plaintext") {
if (fromAuthEvidence || (params.profileApiKey && params.profileApiKey.source !== "plaintext")) {
params.secretRefManagedProviders?.add(params.providerKey);
}
return {