diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 55f2a0282d7..9f9e8aee916 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -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, diff --git a/extensions/google/provider-discovery.ts b/extensions/google/provider-discovery.ts index 8de97f097fe..2fb355dc83e 100644 --- a/extensions/google/provider-discovery.ts +++ b/extensions/google/provider-discovery.ts @@ -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 () => ({ diff --git a/extensions/google/provider-registration.ts b/extensions/google/provider-registration.ts index a52dc7a22d3..81098da7bf1 100644 --- a/extensions/google/provider-registration.ts +++ b/extensions/google/provider-registration.ts @@ -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 () => ({ diff --git a/extensions/google/vertex-adc.ts b/extensions/google/vertex-adc.ts index 9017b2ca066..c30678ab00c 100644 --- a/extensions/google/vertex-adc.ts +++ b/extensions/google/vertex-adc.ts @@ -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; diff --git a/src/agents/models-config.applies-config-env-vars.test.ts b/src/agents/models-config.applies-config-env-vars.test.ts index 7167f97e9c4..166d926cc4a 100644 --- a/src/agents/models-config.applies-config-env-vars.test.ts +++ b/src/agents/models-config.applies-config-env-vars.test.ts @@ -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]); diff --git a/src/agents/models-config.providers.implicit.discovery-scope.test.ts b/src/agents/models-config.providers.implicit.discovery-scope.test.ts index 5cd669af358..22f06548d9c 100644 --- a/src/agents/models-config.providers.implicit.discovery-scope.test.ts +++ b/src/agents/models-config.providers.implicit.discovery-scope.test.ts @@ -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"), diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index f4c404f8c7e..ffb3e5dd71d 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -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 | 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; diff --git a/src/agents/models-config.providers.secret-helpers.ts b/src/agents/models-config.providers.secret-helpers.ts index f4ae4028904..a7aec4f08b6 100644 --- a/src/agents/models-config.providers.secret-helpers.ts +++ b/src/agents/models-config.providers.secret-helpers.ts @@ -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 {