From f0ea5bf393bb6b14ebd8db1498c68f9d7e55d55a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Apr 2026 01:43:32 +0100 Subject: [PATCH] plugins: add lightweight anthropic vertex discovery --- .../anthropic-vertex/openclaw.plugin.json | 1 + .../provider-discovery.import-guard.test.ts | 10 + .../anthropic-vertex/provider-discovery.ts | 215 ++++++++++++++++++ ...-config.providers.anthropic-vertex.test.ts | 17 +- ...ls-config.providers.normalize-keys.test.ts | 16 +- 5 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 extensions/anthropic-vertex/provider-discovery.import-guard.test.ts create mode 100644 extensions/anthropic-vertex/provider-discovery.ts diff --git a/extensions/anthropic-vertex/openclaw.plugin.json b/extensions/anthropic-vertex/openclaw.plugin.json index cb64af041f0..8c417d22806 100644 --- a/extensions/anthropic-vertex/openclaw.plugin.json +++ b/extensions/anthropic-vertex/openclaw.plugin.json @@ -2,6 +2,7 @@ "id": "anthropic-vertex", "enabledByDefault": true, "providers": ["anthropic-vertex"], + "providerDiscoveryEntry": "./provider-discovery.ts", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/anthropic-vertex/provider-discovery.import-guard.test.ts b/extensions/anthropic-vertex/provider-discovery.import-guard.test.ts new file mode 100644 index 00000000000..010c7810949 --- /dev/null +++ b/extensions/anthropic-vertex/provider-discovery.import-guard.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +describe("anthropic-vertex provider discovery entry", () => { + it("imports without loading the full plugin entry", async () => { + const module = await import("./provider-discovery.js"); + + expect(module.default.id).toBe("anthropic-vertex"); + expect(module.default.catalog.order).toBe("simple"); + }); +}); diff --git a/extensions/anthropic-vertex/provider-discovery.ts b/extensions/anthropic-vertex/provider-discovery.ts new file mode 100644 index 00000000000..cc7aa448ac4 --- /dev/null +++ b/extensions/anthropic-vertex/provider-discovery.ts @@ -0,0 +1,215 @@ +import { readFileSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; +import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared"; +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-shared"; + +const PROVIDER_ID = "anthropic-vertex"; +const ANTHROPIC_VERTEX_DEFAULT_REGION = "global"; +const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/; +const ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW = 1_000_000; +const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; +const GCLOUD_DEFAULT_ADC_PATH = join( + homedir(), + ".config", + "gcloud", + "application_default_credentials.json", +); + +type AnthropicVertexProviderPlugin = { + id: string; + label: string; + docsPath: string; + auth: []; + catalog: { + order: "simple"; + run: (ctx: ProviderCatalogContext) => ReturnType; + }; + resolveConfigApiKey: (params: { env: NodeJS.ProcessEnv }) => string | undefined; +}; + +type AdcProjectFile = { + project_id?: unknown; + quota_project_id?: unknown; +}; + +function normalizeOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return normalizeOptionalString(value)?.toLowerCase() ?? ""; +} + +function resolveAnthropicVertexRegion(env: NodeJS.ProcessEnv = process.env): string { + const region = + normalizeOptionalString(env.GOOGLE_CLOUD_LOCATION) || + normalizeOptionalString(env.CLOUD_ML_REGION); + + return region && ANTHROPIC_VERTEX_REGION_RE.test(region) + ? region + : ANTHROPIC_VERTEX_DEFAULT_REGION; +} + +function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.env): boolean { + const explicitMetadataOptIn = normalizeOptionalString(env.ANTHROPIC_VERTEX_USE_GCP_METADATA); + return ( + explicitMetadataOptIn === "1" || + normalizeLowercaseStringOrEmpty(explicitMetadataOptIn) === "true" + ); +} + +function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string { + return platform() === "win32" + ? join( + env.APPDATA ?? join(homedir(), "AppData", "Roaming"), + "gcloud", + "application_default_credentials.json", + ) + : GCLOUD_DEFAULT_ADC_PATH; +} + +function resolveAnthropicVertexAdcCredentialsPathCandidate( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const explicit = normalizeOptionalString(env.GOOGLE_APPLICATION_CREDENTIALS); + if (explicit) { + return explicit; + } + if (env !== process.env) { + return undefined; + } + return resolveAnthropicVertexDefaultAdcPath(env); +} + +function readAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): AdcProjectFile | null { + const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env); + if (!credentialsPath) { + return null; + } + try { + return JSON.parse(readFileSync(credentialsPath, "utf8")) as AdcProjectFile; + } catch { + return null; + } +} + +function hasAnthropicVertexAvailableAuth(env: NodeJS.ProcessEnv = process.env): boolean { + return hasAnthropicVertexMetadataServerAdc(env) || readAnthropicVertexAdc(env) !== null; +} + +function resolveAnthropicVertexConfigApiKey( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + return hasAnthropicVertexAvailableAuth(env) ? GCP_VERTEX_CREDENTIALS_MARKER : undefined; +} + +function buildAnthropicVertexModel(params: { + id: string; + name: string; + reasoning: boolean; + input: ModelDefinitionConfig["input"]; + cost: ModelDefinitionConfig["cost"]; + maxTokens: number; +}): ModelDefinitionConfig { + return { + id: params.id, + name: params.name, + reasoning: params.reasoning, + input: params.input, + cost: params.cost, + contextWindow: ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW, + maxTokens: params.maxTokens, + }; +} + +function buildAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }): ModelProviderConfig { + const region = resolveAnthropicVertexRegion(params?.env); + const baseUrl = + normalizeLowercaseStringOrEmpty(region) === "global" + ? "https://aiplatform.googleapis.com" + : `https://${region}-aiplatform.googleapis.com`; + + return { + baseUrl, + api: "anthropic-messages", + apiKey: GCP_VERTEX_CREDENTIALS_MARKER, + models: [ + buildAnthropicVertexModel({ + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + maxTokens: 128000, + }), + buildAnthropicVertexModel({ + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + maxTokens: 128000, + }), + ], + }; +} + +function mergeImplicitAnthropicVertexProvider(params: { + existing?: ModelProviderConfig; + implicit: ModelProviderConfig; +}) { + const { existing, implicit } = params; + if (!existing) { + return implicit; + } + return { + ...implicit, + ...existing, + models: + Array.isArray(existing.models) && existing.models.length > 0 + ? existing.models + : implicit.models, + }; +} + +function resolveImplicitAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }) { + const env = params?.env ?? process.env; + if (!hasAnthropicVertexAvailableAuth(env)) { + return null; + } + + return buildAnthropicVertexProvider({ env }); +} + +async function runAnthropicVertexCatalog(ctx: ProviderCatalogContext) { + const implicit = resolveImplicitAnthropicVertexProvider({ + env: ctx.env, + }); + if (!implicit) { + return null; + } + return { + provider: mergeImplicitAnthropicVertexProvider({ + existing: ctx.config.models?.providers?.[PROVIDER_ID], + implicit, + }), + }; +} + +export const anthropicVertexProviderDiscovery: AnthropicVertexProviderPlugin = { + id: PROVIDER_ID, + label: "Anthropic Vertex", + docsPath: "/providers/models", + auth: [], + catalog: { + order: "simple", + run: runAnthropicVertexCatalog, + }, + resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env), +}; + +export default anthropicVertexProviderDiscovery; diff --git a/src/agents/models-config.providers.anthropic-vertex.test.ts b/src/agents/models-config.providers.anthropic-vertex.test.ts index a62e9793bcf..e431ebf725b 100644 --- a/src/agents/models-config.providers.anthropic-vertex.test.ts +++ b/src/agents/models-config.providers.anthropic-vertex.test.ts @@ -4,12 +4,19 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; +const ANTHROPIC_VERTEX_DISCOVERY_ENV = { + OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS: "anthropic", +} satisfies NodeJS.ProcessEnv; + describe("anthropic-vertex implicit provider", () => { it("does not auto-enable from GOOGLE_CLOUD_PROJECT_ID alone", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const providers = await resolveImplicitProvidersForTest({ agentDir, - env: { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" }, + env: { + ...ANTHROPIC_VERTEX_DISCOVERY_ENV, + GOOGLE_CLOUD_PROJECT_ID: "vertex-project", + }, }); expect(providers?.["anthropic-vertex"]).toBeUndefined(); }); @@ -24,6 +31,7 @@ describe("anthropic-vertex implicit provider", () => { const providers = await resolveImplicitProvidersForTest({ agentDir, env: { + ...ANTHROPIC_VERTEX_DISCOVERY_ENV, GOOGLE_APPLICATION_CREDENTIALS: credentialsPath, GOOGLE_CLOUD_LOCATION: "us-east1", }, @@ -50,6 +58,7 @@ describe("anthropic-vertex implicit provider", () => { const providers = await resolveImplicitProvidersForTest({ agentDir, env: { + ...ANTHROPIC_VERTEX_DISCOVERY_ENV, GOOGLE_APPLICATION_CREDENTIALS: credentialsPath, GOOGLE_CLOUD_LOCATION: "us-east5", }, @@ -72,6 +81,7 @@ describe("anthropic-vertex implicit provider", () => { const providers = await resolveImplicitProvidersForTest({ agentDir, env: { + ...ANTHROPIC_VERTEX_DISCOVERY_ENV, GOOGLE_APPLICATION_CREDENTIALS: credentialsPath, GOOGLE_CLOUD_LOCATION: "europe-west4", }, @@ -94,6 +104,7 @@ describe("anthropic-vertex implicit provider", () => { const providers = await resolveImplicitProvidersForTest({ agentDir, env: { + ...ANTHROPIC_VERTEX_DISCOVERY_ENV, GOOGLE_APPLICATION_CREDENTIALS: credentialsPath, GOOGLE_CLOUD_LOCATION: "us-central1.attacker.example", }, @@ -114,6 +125,7 @@ describe("anthropic-vertex implicit provider", () => { const providers = await resolveImplicitProvidersForTest({ agentDir, env: { + ...ANTHROPIC_VERTEX_DISCOVERY_ENV, GOOGLE_APPLICATION_CREDENTIALS: credentialsPath, GOOGLE_CLOUD_LOCATION: "global", }, @@ -129,6 +141,7 @@ describe("anthropic-vertex implicit provider", () => { const providers = await resolveImplicitProvidersForTest({ agentDir, env: { + ...ANTHROPIC_VERTEX_DISCOVERY_ENV, ANTHROPIC_VERTEX_USE_GCP_METADATA: "true", GOOGLE_CLOUD_LOCATION: "us-east5", }, @@ -143,6 +156,7 @@ describe("anthropic-vertex implicit provider", () => { const providers = await resolveImplicitProvidersForTest({ agentDir, env: { + ...ANTHROPIC_VERTEX_DISCOVERY_ENV, ANTHROPIC_VERTEX_USE_GCP_METADATA: "true", GOOGLE_CLOUD_LOCATION: "us-east5", }, @@ -170,6 +184,7 @@ describe("anthropic-vertex implicit provider", () => { const providers = await resolveImplicitProvidersForTest({ agentDir, env: { + ...ANTHROPIC_VERTEX_DISCOVERY_ENV, KUBERNETES_SERVICE_HOST: "10.0.0.1", GOOGLE_CLOUD_LOCATION: "us-east5", }, diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts index 6e0e43a56a4..f1a65808550 100644 --- a/src/agents/models-config.providers.normalize-keys.test.ts +++ b/src/agents/models-config.providers.normalize-keys.test.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { ensureAuthProfileStore } from "./auth-profiles.js"; import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { normalizeProviders } from "./models-config.providers.normalize.js"; import { resolveApiKeyFromProfiles } from "./models-config.providers.secrets.js"; @@ -183,13 +182,18 @@ describe("normalizeProviders", () => { "utf8", ); - const store = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, - }); - const resolved = resolveApiKeyFromProfiles({ provider: "minimax", - store, + store: { + version: 1, + profiles: { + "minimax:default": { + type: "api_key", + provider: "minimax", + keyRef: { source: "env", provider: "default", id: "MINIMAX_API_KEY" }, + }, + }, + }, env: process.env, });