From be4920f9bcaf73cbec2a0d18b7c7a8dab5400b25 Mon Sep 17 00:00:00 2001 From: Pengfei Ni Date: Thu, 23 Apr 2026 22:03:16 +0800 Subject: [PATCH] fix(anthropic-vertex): resolve model discovery and auth for GCP ADC (openclaw#65716) Verified: - pnpm test extensions/anthropic-vertex/index.test.ts extensions/anthropic-vertex/region.adc.test.ts src/plugins/manifest-registry.test.ts src/plugins/provider-runtime.synthetic-auth-discovery.test.ts Co-authored-by: feiskyer <676637+feiskyer@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/anthropic-vertex/index.test.ts | 53 ++++++++++++++++- extensions/anthropic-vertex/index.ts | 18 ++++++ .../anthropic-vertex/openclaw.plugin.json | 1 + .../anthropic-vertex/provider-discovery.ts | 18 ++++++ .../anthropic-vertex/region.adc.test.ts | 24 ++++++++ extensions/anthropic-vertex/region.ts | 27 +++++---- src/plugins/manifest-registry.test.ts | 21 +++++++ src/plugins/manifest-registry.ts | 22 ++++++- ...r-runtime.synthetic-auth-discovery.test.ts | 59 +++++++++++++++++++ src/plugins/provider-runtime.ts | 11 +++- 11 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 src/plugins/provider-runtime.synthetic-auth-discovery.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2a26ec9d2..f41dc18261f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Thinking defaults/status: raise the implicit default thinking level for reasoning-capable models from legacy `off`/`low` fallback behavior to a safe provider-supported `medium` equivalent when no explicit config default is set, preserve configured-model reasoning metadata when runtime catalog loading is empty, and make `/status` report the same resolved default as runtime. - Gateway/model pricing: fetch OpenRouter and LiteLLM pricing asynchronously at startup and extend catalog fetch timeouts to 30 seconds, reducing noisy timeout warnings during slow upstream responses. +- Providers/Anthropic Vertex: restore ADC-backed model discovery after the lightweight provider-discovery path by resolving emitted discovery entries, exposing synthetic auth on bootstrap discovery, and honoring copied env snapshots when probing the default GCP ADC path. Fixes #65715. (#65716) Thanks @feiskyer. - Plugins/install: add newly installed plugin ids to an existing `plugins.allow` list before enabling them, so allowlisted configs load installed plugins after restart. - Status: show `Fast` in `/status` when fast mode is enabled, including config/default-derived fast mode, and omit it when disabled. - OpenAI/image generation: detect Azure OpenAI-style image endpoints, use Azure `api-key` auth plus deployment-scoped image URLs, and honor `AZURE_OPENAI_API_VERSION` so image generation and edits work against Azure-hosted OpenAI resources. (#70570) Thanks @zhanggpcsu. diff --git a/extensions/anthropic-vertex/index.test.ts b/extensions/anthropic-vertex/index.test.ts index 257ed9c260b..d5cc97cc362 100644 --- a/extensions/anthropic-vertex/index.test.ts +++ b/extensions/anthropic-vertex/index.test.ts @@ -1,8 +1,29 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js"; + +const { hasAnthropicVertexAvailableAuthMock } = vi.hoisted(() => ({ + hasAnthropicVertexAvailableAuthMock: vi.fn(), +})); + +vi.mock("./api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hasAnthropicVertexAvailableAuth: hasAnthropicVertexAvailableAuthMock, + }; +}); + import anthropicVertexPlugin from "./index.js"; describe("anthropic-vertex provider plugin", () => { + beforeEach(() => { + hasAnthropicVertexAvailableAuthMock.mockReturnValue(true); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + it("resolves the ADC marker through the provider hook", async () => { const provider = await registerSingleProviderPlugin(anthropicVertexPlugin); @@ -77,4 +98,34 @@ describe("anthropic-vertex provider plugin", () => { allowSyntheticToolResults: true, }); }); + + it("resolves synthetic auth when ADC is available", async () => { + hasAnthropicVertexAvailableAuthMock.mockReturnValue(true); + const provider = await registerSingleProviderPlugin(anthropicVertexPlugin); + + const result = provider.resolveSyntheticAuth?.({ + provider: "anthropic-vertex", + config: undefined, + providerConfig: undefined, + } as never); + + expect(result).toEqual({ + apiKey: "gcp-vertex-credentials", + source: "gcp-vertex-credentials (ADC)", + mode: "api-key", + }); + }); + + it("returns undefined when ADC is not available", async () => { + hasAnthropicVertexAvailableAuthMock.mockReturnValue(false); + const provider = await registerSingleProviderPlugin(anthropicVertexPlugin); + + const result = provider.resolveSyntheticAuth?.({ + provider: "anthropic-vertex", + config: undefined, + providerConfig: undefined, + } as never); + + expect(result).toBeUndefined(); + }); }); diff --git a/extensions/anthropic-vertex/index.ts b/extensions/anthropic-vertex/index.ts index 4d7beabd0fc..5733e2ab061 100644 --- a/extensions/anthropic-vertex/index.ts +++ b/extensions/anthropic-vertex/index.ts @@ -1,12 +1,15 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { readConfiguredProviderCatalogEntries } from "openclaw/plugin-sdk/provider-catalog-shared"; import { NATIVE_ANTHROPIC_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared"; import { + hasAnthropicVertexAvailableAuth, mergeImplicitAnthropicVertexProvider, resolveAnthropicVertexConfigApiKey, resolveImplicitAnthropicVertexProvider, } from "./api.js"; const PROVIDER_ID = "anthropic-vertex"; +const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; export default definePluginEntry({ id: PROVIDER_ID, @@ -37,6 +40,21 @@ export default definePluginEntry({ }, resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env), ...NATIVE_ANTHROPIC_REPLAY_HOOKS, + resolveSyntheticAuth: () => { + if (!hasAnthropicVertexAvailableAuth()) { + return undefined; + } + return { + apiKey: GCP_VERTEX_CREDENTIALS_MARKER, + source: "gcp-vertex-credentials (ADC)", + mode: "api-key", + }; + }, + augmentModelCatalog: ({ config }) => + readConfiguredProviderCatalogEntries({ + config, + providerId: PROVIDER_ID, + }), }); }, }); diff --git a/extensions/anthropic-vertex/openclaw.plugin.json b/extensions/anthropic-vertex/openclaw.plugin.json index 54844144623..1542b4399a8 100644 --- a/extensions/anthropic-vertex/openclaw.plugin.json +++ b/extensions/anthropic-vertex/openclaw.plugin.json @@ -3,6 +3,7 @@ "enabledByDefault": true, "providers": ["anthropic-vertex"], "providerDiscoveryEntry": "./provider-discovery.ts", + "syntheticAuthRefs": ["anthropic-vertex"], "nonSecretAuthMarkers": ["gcp-vertex-credentials"], "configSchema": { "type": "object", diff --git a/extensions/anthropic-vertex/provider-discovery.ts b/extensions/anthropic-vertex/provider-discovery.ts index 981d4166783..bfbcce8d079 100644 --- a/extensions/anthropic-vertex/provider-discovery.ts +++ b/extensions/anthropic-vertex/provider-discovery.ts @@ -4,6 +4,7 @@ import { buildAnthropicVertexProvider } from "./provider-catalog.js"; import { hasAnthropicVertexAvailableAuth, resolveAnthropicVertexConfigApiKey } from "./region.js"; const PROVIDER_ID = "anthropic-vertex"; +const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; type AnthropicVertexProviderPlugin = { id: string; @@ -15,6 +16,13 @@ type AnthropicVertexProviderPlugin = { run: (ctx: ProviderCatalogContext) => ReturnType; }; resolveConfigApiKey: (params: { env: NodeJS.ProcessEnv }) => string | undefined; + resolveSyntheticAuth: () => + | { + apiKey: string; + source: string; + mode: "api-key"; + } + | undefined; }; function mergeImplicitAnthropicVertexProvider(params: { @@ -69,6 +77,16 @@ export const anthropicVertexProviderDiscovery: AnthropicVertexProviderPlugin = { run: runAnthropicVertexCatalog, }, resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env), + resolveSyntheticAuth: () => { + if (!hasAnthropicVertexAvailableAuth()) { + return undefined; + } + return { + apiKey: GCP_VERTEX_CREDENTIALS_MARKER, + source: "gcp-vertex-credentials (ADC)", + mode: "api-key", + }; + }, }; export default anthropicVertexProviderDiscovery; diff --git a/extensions/anthropic-vertex/region.adc.test.ts b/extensions/anthropic-vertex/region.adc.test.ts index 8c40149efef..8a9d44d8a3b 100644 --- a/extensions/anthropic-vertex/region.adc.test.ts +++ b/extensions/anthropic-vertex/region.adc.test.ts @@ -46,4 +46,28 @@ describe("anthropic-vertex ADC reads", () => { expect(existsSyncMock).not.toHaveBeenCalled(); expect(readFileSyncMock).toHaveBeenCalledWith("/tmp/vertex-adc.json", "utf8"); }); + + it("respects HOME when probing the default ADC path from a copied env snapshot", () => { + const env = { + HOME: "/tmp/vertex-home", + } as NodeJS.ProcessEnv; + + readFileSyncMock.mockImplementation((pathname, options) => + String(pathname) === "/tmp/vertex-home/.config/gcloud/application_default_credentials.json" + ? '{"project_id":"vertex-project"}' + : String(pathname) === "/tmp/vertex-adc.json" + ? '{"project_id":"vertex-project"}' + : (() => { + throw new Error(`unexpected readFileSync(${String(pathname)}, ${String(options)})`); + })(), + ); + + expect(resolveAnthropicVertexProjectId(env)).toBe("vertex-project"); + expect(hasAnthropicVertexAvailableAuth(env)).toBe(true); + expect(existsSyncMock).not.toHaveBeenCalled(); + expect(readFileSyncMock).toHaveBeenCalledWith( + "/tmp/vertex-home/.config/gcloud/application_default_credentials.json", + "utf8", + ); + }); }); diff --git a/extensions/anthropic-vertex/region.ts b/extensions/anthropic-vertex/region.ts index ca4e322093d..021159de2ad 100644 --- a/extensions/anthropic-vertex/region.ts +++ b/extensions/anthropic-vertex/region.ts @@ -7,12 +7,6 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim const ANTHROPIC_VERTEX_DEFAULT_REGION = "global"; const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/; const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; -const GCLOUD_DEFAULT_ADC_PATH = join( - homedir(), - ".config", - "gcloud", - "application_default_credentials.json", -); type AdcProjectFile = { project_id?: unknown; @@ -71,14 +65,28 @@ function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.en ); } +function resolveAnthropicVertexHomeDir(env: NodeJS.ProcessEnv = process.env): string { + return ( + normalizeOptionalSecretInput(env.HOME) || + normalizeOptionalSecretInput(env.USERPROFILE) || + homedir() + ); +} + function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string { return platform() === "win32" ? join( - env.APPDATA ?? join(homedir(), "AppData", "Roaming"), + normalizeOptionalSecretInput(env.APPDATA) ?? + join(resolveAnthropicVertexHomeDir(env), "AppData", "Roaming"), "gcloud", "application_default_credentials.json", ) - : GCLOUD_DEFAULT_ADC_PATH; + : join( + resolveAnthropicVertexHomeDir(env), + ".config", + "gcloud", + "application_default_credentials.json", + ); } function resolveAnthropicVertexAdcCredentialsPathCandidate( @@ -88,9 +96,6 @@ function resolveAnthropicVertexAdcCredentialsPathCandidate( if (explicit) { return explicit; } - if (env !== process.env) { - return undefined; - } return resolveAnthropicVertexDefaultAdcPath(env); } diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index bb0dfa07798..aab97ca5f0c 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -478,6 +478,27 @@ describe("loadPluginManifestRegistry", () => { ]); }); + it("falls back providerDiscoverySource from .ts to emitted .js files", () => { + const dir = makeTempDir(); + writeManifest(dir, { + id: "anthropic-vertex", + providers: ["anthropic-vertex"], + providerDiscoveryEntry: "./provider-discovery.ts", + configSchema: { type: "object" }, + }); + fs.writeFileSync(path.join(dir, "provider-discovery.js"), "export default {};\n", "utf8"); + + const registry = loadSingleCandidateRegistry({ + idHint: "anthropic-vertex", + rootDir: dir, + origin: "bundled", + }); + + expect(registry.plugins[0]?.providerDiscoverySource).toBe( + path.join(dir, "provider-discovery.js"), + ); + }); + it("preserves activation and setup descriptors from plugin manifests", () => { const dir = makeTempDir(); writeManifest(dir, { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 1101eec459d..142747c39c1 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -45,6 +45,24 @@ import type { PluginKind } from "./plugin-kind.types.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import { resolvePluginCacheInputs } from "./roots.js"; +/** + * Resolve a plugin source path, falling back from .ts to .js when the + * .ts file doesn't exist on disk (e.g. in dist builds where only .js + * is emitted but the manifest still references the .ts entry). + */ +function resolvePluginSourcePath(sourcePath: string): string { + if (fs.existsSync(sourcePath)) { + return sourcePath; + } + if (sourcePath.endsWith(".ts")) { + const jsPath = sourcePath.slice(0, -3) + ".js"; + if (fs.existsSync(jsPath)) { + return jsPath; + } + } + return sourcePath; +} + type PluginManifestContractListKey = | "speechProviders" | "externalAuthProviders" @@ -334,7 +352,9 @@ function buildRecord(params: { channels: params.manifest.channels ?? [], providers: params.manifest.providers ?? [], providerDiscoverySource: params.manifest.providerDiscoveryEntry - ? path.resolve(params.candidate.rootDir, params.manifest.providerDiscoveryEntry) + ? resolvePluginSourcePath( + path.resolve(params.candidate.rootDir, params.manifest.providerDiscoveryEntry), + ) : undefined, modelSupport: params.manifest.modelSupport, providerEndpoints: params.manifest.providerEndpoints, diff --git a/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts b/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts new file mode 100644 index 00000000000..afc493cff90 --- /dev/null +++ b/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; + +const resolveProviderRuntimePlugin = vi.hoisted(() => vi.fn(() => undefined)); +const resolvePluginDiscoveryProvidersRuntime = vi.hoisted(() => + vi.fn(() => [ + { + id: "anthropic-vertex", + label: "Anthropic Vertex", + auth: [], + resolveSyntheticAuth: () => ({ + apiKey: "gcp-vertex-credentials", + source: "gcp-vertex-credentials (ADC)", + mode: "api-key" as const, + }), + }, + ]), +); + +vi.mock("./provider-hook-runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + __testing: {}, + clearProviderRuntimeHookCache: vi.fn(), + prepareProviderExtraParams: vi.fn(), + resetProviderRuntimeHookCacheForTest: vi.fn(), + resolveProviderHookPlugin: vi.fn(), + resolveProviderPluginsForHooks: vi.fn(() => []), + resolveProviderRuntimePlugin, + wrapProviderStreamFn: vi.fn(), + }; +}); + +vi.mock("./provider-discovery.runtime.js", () => ({ + resolvePluginDiscoveryProvidersRuntime, +})); + +import { resolveProviderSyntheticAuthWithPlugin } from "./provider-runtime.js"; + +describe("resolveProviderSyntheticAuthWithPlugin", () => { + it("falls back to lightweight discovery providers when runtime hooks are unavailable", () => { + expect( + resolveProviderSyntheticAuthWithPlugin({ + provider: "anthropic-vertex", + context: { + config: undefined, + provider: "anthropic-vertex", + providerConfig: undefined, + }, + }), + ).toEqual({ + apiKey: "gcp-vertex-credentials", + source: "gcp-vertex-credentials (ADC)", + mode: "api-key", + }); + expect(resolveProviderRuntimePlugin).toHaveBeenCalled(); + expect(resolvePluginDiscoveryProvidersRuntime).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 5ba7a18bd08..75e78ae7524 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -20,6 +20,7 @@ import { resolveProviderRuntimePlugin, wrapProviderStreamFn, } from "./provider-hook-runtime.js"; +import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js"; import { resolveBundledProviderPolicySurface } from "./provider-public-artifacts.js"; import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js"; import type { ProviderThinkingProfile } from "./provider-thinking.types.js"; @@ -761,7 +762,15 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { env?: NodeJS.ProcessEnv; context: ProviderResolveSyntheticAuthContext; }) { - return resolveProviderRuntimePlugin(params)?.resolveSyntheticAuth?.(params.context) ?? undefined; + const runtimeResolved = resolveProviderRuntimePlugin(params)?.resolveSyntheticAuth?.(params.context); + if (runtimeResolved) { + return runtimeResolved; + } + return resolvePluginDiscoveryProvidersRuntime({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }).find((provider) => provider.id === params.provider)?.resolveSyntheticAuth?.(params.context); } export function resolveExternalAuthProfilesWithPlugins(params: {