Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# OpenClaw Anthropic Vertex Provider
Official OpenClaw provider plugin for Claude models hosted through Google Vertex AI.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/anthropic-vertex-provider
```
Configure Google Cloud credentials and the target Vertex project/region in OpenClaw, then select Claude models with the Anthropic Vertex provider.

View File

@@ -0,0 +1,78 @@
// Anthropic Vertex tests cover api plugin behavior.
import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
function createStreamDeps(): {
deps: AnthropicVertexStreamDeps;
streamAnthropicMock: ReturnType<typeof vi.fn>;
anthropicVertexCtorMock: ReturnType<typeof vi.fn>;
} {
const streamAnthropicMock = vi.fn(
(..._args: Parameters<AnthropicVertexStreamDeps["streamAnthropic"]>) =>
createAssistantMessageEventStream(),
);
const anthropicVertexCtorMock = vi.fn();
const MockAnthropicVertex = function MockAnthropicVertex(options: unknown) {
anthropicVertexCtorMock(options);
} as unknown as AnthropicVertexStreamDeps["AnthropicVertex"];
return {
deps: {
AnthropicVertex: MockAnthropicVertex,
streamAnthropic: streamAnthropicMock,
},
streamAnthropicMock,
anthropicVertexCtorMock,
};
}
let createAnthropicVertexStreamFn: typeof import("./api.js").createAnthropicVertexStreamFn;
let createAnthropicVertexStreamFnForModel: typeof import("./api.js").createAnthropicVertexStreamFnForModel;
function makeModel(): Model<"anthropic-messages"> {
return {
id: "claude-sonnet-4-6",
api: "anthropic-messages",
provider: "anthropic-vertex",
maxTokens: 128000,
} as Model<"anthropic-messages">;
}
describe("Anthropic Vertex API stream factories", () => {
beforeAll(async () => {
({ createAnthropicVertexStreamFn, createAnthropicVertexStreamFnForModel } =
await import("./api.js"));
});
it("reuses the runtime stream factory across direct stream calls", async () => {
const { deps, streamAnthropicMock, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel();
await streamFn(model, { messages: [] }, {});
await streamFn(model, { messages: [] }, {});
expect(anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
expect(streamAnthropicMock).toHaveBeenCalledTimes(2);
});
it("reuses the runtime stream factory across model-derived stream calls", async () => {
const { deps, streamAnthropicMock, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFnForModel(
makeModel(),
{
ANTHROPIC_VERTEX_PROJECT_ID: "vertex-project",
GOOGLE_CLOUD_LOCATION: "us-east5",
} as NodeJS.ProcessEnv,
deps,
);
const model = makeModel();
await streamFn(model, { messages: [] }, {});
await streamFn(model, { messages: [] }, {});
expect(anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
expect(streamAnthropicMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,85 @@
/**
* Public Anthropic Vertex API barrel. It exposes lightweight discovery helpers
* and lazy stream factories without eagerly importing the Vertex SDK runtime.
*/
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
export {
ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
buildAnthropicVertexProvider,
} from "./provider-catalog.js";
export {
hasAnthropicVertexAvailableAuth,
hasAnthropicVertexCredentials,
resolveAnthropicVertexClientRegion,
resolveAnthropicVertexConfigApiKey,
resolveAnthropicVertexProjectId,
resolveAnthropicVertexRegion,
resolveAnthropicVertexRegionFromBaseUrl,
} from "./region.js";
import { buildAnthropicVertexProvider } from "./provider-catalog.js";
import { hasAnthropicVertexAvailableAuth } from "./region.js";
const loadStreamRuntimeModule = createLazyRuntimeModule(() => import("./stream-runtime.js"));
/** Merge an implicit Anthropic Vertex provider with explicit user config. */
export function mergeImplicitAnthropicVertexProvider(params: {
existing?: ReturnType<typeof buildAnthropicVertexProvider>;
implicit: ReturnType<typeof buildAnthropicVertexProvider>;
}) {
const { existing, implicit } = params;
if (!existing) {
return implicit;
}
return {
...implicit,
...existing,
models:
Array.isArray(existing.models) && existing.models.length > 0
? existing.models
: implicit.models,
};
}
/** Resolve an implicit Anthropic Vertex provider when ADC credentials are available. */
export function resolveImplicitAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }) {
const env = params?.env ?? process.env;
if (!hasAnthropicVertexAvailableAuth(env)) {
return null;
}
return buildAnthropicVertexProvider({ env });
}
/** Create a lazy Anthropic Vertex stream function for a known project/region/base URL. */
export function createAnthropicVertexStreamFn(
projectId: string | undefined,
region: string,
baseURL?: string,
deps?: AnthropicVertexStreamDeps,
): StreamFn {
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
runtime.createAnthropicVertexStreamFn(projectId, region, baseURL, deps),
);
return async (model, context, options) => {
const streamFn = await streamFnPromise;
return streamFn(model, context, options);
};
}
/** Create a lazy Anthropic Vertex stream function using model base URL and env hints. */
export function createAnthropicVertexStreamFnForModel(
model: { baseUrl?: string },
env: NodeJS.ProcessEnv = process.env,
deps?: AnthropicVertexStreamDeps,
): StreamFn {
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
runtime.createAnthropicVertexStreamFnForModel(model, env, deps),
);
return async (...args) => {
const streamFn = await streamFnPromise;
return streamFn(...args);
};
}

View File

@@ -0,0 +1,240 @@
// Anthropic Vertex tests cover index plugin behavior.
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { hasAnthropicVertexAvailableAuthMock } = vi.hoisted(() => ({
hasAnthropicVertexAvailableAuthMock: vi.fn(),
}));
vi.mock("./api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./api.js")>();
return {
...actual,
hasAnthropicVertexAvailableAuth: hasAnthropicVertexAvailableAuthMock,
};
});
import anthropicVertexPlugin from "./index.js";
describe("anthropic-vertex provider plugin", () => {
beforeEach(() => {
hasAnthropicVertexAvailableAuthMock.mockReturnValue(true);
});
afterEach(() => {
vi.clearAllMocks();
});
afterAll(() => {
vi.doUnmock("./api.js");
vi.resetModules();
});
it("resolves the ADC marker through the provider hook", async () => {
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
expect(
provider.resolveConfigApiKey?.({
provider: "anthropic-vertex",
env: {
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
} as NodeJS.ProcessEnv,
} as never),
).toBe("gcp-vertex-credentials");
});
it("merges the implicit Vertex catalog into explicit provider overrides", async () => {
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
const result = await provider.catalog?.run({
config: {
models: {
providers: {
"anthropic-vertex": {
baseUrl: "https://europe-west4-aiplatform.googleapis.com",
headers: { "x-test-header": "1" },
},
},
},
},
env: {
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
GOOGLE_CLOUD_LOCATION: "us-east5",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
} as never);
if (!result || !("provider" in result)) {
throw new Error("expected single provider catalog result");
}
expect(result.provider.api).toBe("anthropic-messages");
expect(result.provider.apiKey).toBe("gcp-vertex-credentials");
expect(result.provider.baseUrl).toBe("https://europe-west4-aiplatform.googleapis.com");
expect(result.provider.headers).toEqual({ "x-test-header": "1" });
expect(result.provider.models.map((model) => model.id)).toEqual([
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-6",
"claude-sonnet-4-6",
]);
expect(result.provider.models[0]?.thinkingLevelMap).toEqual({
off: "low",
minimal: "low",
xhigh: "xhigh",
max: "max",
});
expect(result.provider.models[2]?.thinkingLevelMap).toEqual({ xhigh: null, max: "max" });
expect(result.provider.models[3]?.thinkingLevelMap).toEqual({ xhigh: null, max: "max" });
});
it("owns Anthropic-style replay policy", async () => {
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
expect(
provider.buildReplayPolicy?.({
provider: "anthropic-vertex",
modelApi: "anthropic-messages",
modelId: "claude-sonnet-4-6",
} as never),
).toEqual({
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
preserveNativeAnthropicToolUseIds: true,
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
});
expect(
provider.buildReplayPolicy?.({
provider: "anthropic-vertex",
modelApi: "anthropic-messages",
modelId: "claude-fable-5",
} as never),
).not.toHaveProperty("dropThinkingBlocks");
});
it("owns Anthropic-style thinking policy", async () => {
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
const opus48Profile = provider.resolveThinkingProfile?.({
provider: "anthropic-vertex",
modelId: "claude-opus-4-8",
} as never);
expect(opus48Profile?.defaultLevel).toBe("off");
expect(opus48Profile?.levels.map((level) => level.id)).toContain("max");
const fableProfile = provider.resolveThinkingProfile?.({
provider: "anthropic-vertex",
modelId: "claude-fable-5",
} as never);
expect(fableProfile?.defaultLevel).toBe("high");
expect(fableProfile?.preserveWhenCatalogReasoningFalse).toBe(true);
const aliasProfile = provider.resolveThinkingProfile?.({
provider: "anthropic-vertex",
modelId: "production-claude",
params: { canonicalModelId: "claude-fable-5" },
} as never);
expect(aliasProfile?.defaultLevel).toBe("high");
});
it("restores Fable metadata for explicit Vertex catalog rows", async () => {
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
const normalized = provider.normalizeResolvedModel?.({
provider: "anthropic-vertex",
modelId: "claude-fable-5",
model: {
id: "claude-fable-5",
name: "Claude Fable 5",
api: "anthropic-messages",
provider: "anthropic-vertex",
baseUrl: "https://aiplatform.googleapis.com",
reasoning: false,
input: ["text"],
cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
contextWindow: 200_000,
maxTokens: 8192,
},
} as never);
expect(normalized).toMatchObject({
reasoning: true,
input: ["text", "image"],
contextWindow: 1_000_000,
contextTokens: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: {
off: "low",
minimal: "low",
xhigh: "xhigh",
max: "max",
},
});
const aliasNormalized = provider.normalizeResolvedModel?.({
provider: "anthropic-vertex",
modelId: "production-claude",
model: {
id: "production-claude",
name: "Production Claude",
api: "anthropic-messages",
provider: "anthropic-vertex",
baseUrl: "https://aiplatform.googleapis.com",
reasoning: false,
input: ["text"],
cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
contextWindow: 200_000,
maxTokens: 8192,
params: { canonicalModelId: "claude-fable-5" },
thinkingLevelMap: { max: null },
},
} as never);
expect(aliasNormalized).toMatchObject({
reasoning: true,
input: ["text", "image"],
contextWindow: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: null },
});
});
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();
});
});

View File

@@ -0,0 +1,73 @@
/**
* Anthropic Vertex provider plugin entry. It registers implicit ADC-backed
* catalog discovery, Anthropic replay policy, thinking profiles, and auth markers.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { readConfiguredProviderCatalogEntries } from "openclaw/plugin-sdk/provider-catalog-shared";
import {
NATIVE_ANTHROPIC_REPLAY_HOOKS,
resolveClaudeThinkingProfile,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
hasAnthropicVertexAvailableAuth,
mergeImplicitAnthropicVertexProvider,
resolveAnthropicVertexConfigApiKey,
resolveImplicitAnthropicVertexProvider,
} from "./api.js";
import { normalizeAnthropicVertexResolvedModel } from "./provider-catalog.js";
const PROVIDER_ID = "anthropic-vertex";
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
/** Provider entry for Anthropic Claude models served through Google Vertex AI. */
export default definePluginEntry({
id: PROVIDER_ID,
name: "Anthropic Vertex Provider",
description: "Bundled Anthropic Vertex provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "Anthropic Vertex",
docsPath: "/providers/models",
auth: [],
catalog: {
order: "simple",
run: async (ctx) => {
const implicit = resolveImplicitAnthropicVertexProvider({
env: ctx.env,
});
if (!implicit) {
return null;
}
return {
provider: mergeImplicitAnthropicVertexProvider({
existing: ctx.config.models?.providers?.[PROVIDER_ID],
implicit,
}),
};
},
},
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
...NATIVE_ANTHROPIC_REPLAY_HOOKS,
normalizeResolvedModel: ({ modelId, model }) =>
normalizeAnthropicVertexResolvedModel(modelId, model),
resolveThinkingProfile: ({ modelId, params }) =>
resolveClaudeThinkingProfile(modelId, params, { includeNativeMax: true }),
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,
}),
});
},
});

View File

@@ -0,0 +1,376 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.11",
"dependencies": {
"@anthropic-ai/vertex-sdk": "0.19.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.109.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.109.1.tgz",
"integrity": "sha512-q9OnEKLr5H9nxSuXdgDgJhxfYMiE+AaUEBze2Gk91UcaaLnsN+Lx5fbCYywiqurU/APLdwv23x03Wm6WN3EBsg==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@anthropic-ai/vertex-sdk": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.19.0.tgz",
"integrity": "sha512-Ja5NkDAmdCcvCJCvkY/6uJ+9krOiXFN56dzb+8n5apElHrYpUMlOCHCVRuLLsJCVWTsN8w60sgN9RSXZ+LPqNA==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": ">=0.50.3 <1",
"google-auth-library": "^10.2.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/gaxios": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz",
"integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-auth-library": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz",
"integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-domexception": {
"name": "@nolyfill/domexception",
"version": "1.0.28",
"resolved": "https://registry.npmjs.org/@nolyfill/domexception/-/domexception-1.0.28.tgz",
"integrity": "sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==",
"license": "MIT",
"engines": {
"node": ">=12.4.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
}
}
}

View File

@@ -0,0 +1,19 @@
{
"id": "anthropic-vertex",
"name": "Anthropic Vertex",
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
"icon": "https://cdn.simpleicons.org/anthropic",
"activation": {
"onStartup": false
},
"enabledByDefault": true,
"providers": ["anthropic-vertex"],
"providerCatalogEntry": "./provider-discovery.ts",
"syntheticAuthRefs": ["anthropic-vertex"],
"nonSecretAuthMarkers": ["gcp-vertex-credentials"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,37 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.11",
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@anthropic-ai/vertex-sdk": "0.19.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/anthropic-vertex-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,139 @@
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
/**
* Static Anthropic Vertex model catalog builder. It derives provider base URLs
* from region configuration and publishes Claude model metadata.
*/
import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import { resolveClaudeFable5ModelIdentity } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveAnthropicVertexRegion } from "./region.js";
/** Default Anthropic Vertex model used for implicit provider catalogs. */
export const ANTHROPIC_VERTEX_DEFAULT_MODEL_ID = "claude-sonnet-4-6";
const ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW = 1_000_000;
const ANTHROPIC_VERTEX_FABLE_MAX_TOKENS = 128_000;
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
function buildAnthropicVertexModel(params: {
id: string;
name: string;
reasoning: boolean;
input: ModelDefinitionConfig["input"];
cost: ModelDefinitionConfig["cost"];
maxTokens: number;
thinkingLevelMap?: ModelDefinitionConfig["thinkingLevelMap"];
}): 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,
...(params.thinkingLevelMap ? { thinkingLevelMap: params.thinkingLevelMap } : {}),
};
}
function buildAnthropicVertexCatalog(): ModelDefinitionConfig[] {
return [
buildAnthropicVertexModel({
id: "claude-fable-5",
name: "Claude Fable 5",
reasoning: true,
input: ["text", "image"],
cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
maxTokens: ANTHROPIC_VERTEX_FABLE_MAX_TOKENS,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
}),
buildAnthropicVertexModel({
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
reasoning: true,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
maxTokens: 128000,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
}),
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,
thinkingLevelMap: { xhigh: null, max: "max" },
}),
buildAnthropicVertexModel({
id: ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
name: "Claude Sonnet 4.6",
reasoning: true,
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
maxTokens: 128000,
thinkingLevelMap: { xhigh: null, max: "max" },
}),
];
}
/** Restore required Fable metadata after explicit catalog models replace the implicit row. */
export function normalizeAnthropicVertexResolvedModel(
modelId: string,
model: ProviderRuntimeModel,
): ProviderRuntimeModel | undefined {
if (!resolveClaudeFable5ModelIdentity({ id: modelId, params: model.params })) {
return undefined;
}
const input: ProviderRuntimeModel["input"] = model.input.includes("image")
? model.input
: [...model.input, "image"];
const thinkingLevelMap = {
off: "low",
minimal: "low",
xhigh: "xhigh",
max: "max",
...model.thinkingLevelMap,
};
if (
model.reasoning &&
input === model.input &&
model.contextWindow === ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW &&
model.contextTokens === ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW &&
(model.maxTokens ?? 0) >= ANTHROPIC_VERTEX_FABLE_MAX_TOKENS &&
model.thinkingLevelMap?.off === "low" &&
model.thinkingLevelMap.minimal === "low" &&
model.thinkingLevelMap.xhigh === "xhigh" &&
model.thinkingLevelMap.max === "max"
) {
return undefined;
}
return {
...model,
reasoning: true,
input,
contextWindow: ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW,
contextTokens: ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW,
maxTokens: Math.max(model.maxTokens ?? 0, ANTHROPIC_VERTEX_FABLE_MAX_TOKENS),
thinkingLevelMap,
};
}
/** Build the implicit Anthropic Vertex provider config for the current env. */
export 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: buildAnthropicVertexCatalog(),
};
}

View File

@@ -0,0 +1,11 @@
// Anthropic Vertex tests cover provider discovery.import guard plugin behavior.
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");
});
});

View File

@@ -0,0 +1,97 @@
/**
* Provider discovery descriptor for Anthropic Vertex. This variant is used by
* catalog surfaces that need the provider contract without full plugin entry setup.
*/
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
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;
label: string;
docsPath: string;
auth: [];
catalog: {
order: "simple";
run: (ctx: ProviderCatalogContext) => ReturnType<typeof runAnthropicVertexCatalog>;
};
resolveConfigApiKey: (params: { env: NodeJS.ProcessEnv }) => string | undefined;
resolveSyntheticAuth: () =>
| {
apiKey: string;
source: string;
mode: "api-key";
}
| undefined;
};
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,
}),
};
}
/** Anthropic Vertex provider discovery descriptor. */
export const anthropicVertexProviderDiscovery: AnthropicVertexProviderPlugin = {
id: PROVIDER_ID,
label: "Anthropic Vertex",
docsPath: "/providers/models",
auth: [],
catalog: {
order: "simple",
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;

View File

@@ -0,0 +1,62 @@
// Anthropic Vertex tests cover provider policy api plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveThinkingProfile } from "./provider-policy-api.js";
describe("anthropic-vertex provider-policy-api", () => {
it("leaves Claude Opus 4.8 thinking off by default with max effort support", () => {
const profile = resolveThinkingProfile({
provider: "anthropic-vertex",
modelId: "claude-opus-4-8",
});
expect(profile?.defaultLevel).toBe("off");
expect(profile?.levels.map((level) => level.id)).toContain("max");
});
it("keeps Claude Opus 4.7 thinking off by default", () => {
const profile = resolveThinkingProfile({
provider: "anthropic-vertex",
modelId: "claude-opus-4-7",
});
expect(profile?.defaultLevel).toBe("off");
});
it("exposes native max without xhigh for Claude Sonnet 4.6", () => {
const profile = resolveThinkingProfile({
provider: "anthropic-vertex",
modelId: "claude-sonnet-4-6",
});
expect(profile?.levels.map((level) => level.id)).toContain("max");
expect(profile?.levels.map((level) => level.id)).not.toContain("xhigh");
});
it("inherits Claude Fable 5's provider-agnostic thinking contract", () => {
const profile = resolveThinkingProfile({
provider: "anthropic-vertex",
modelId: "claude-fable-5",
});
expect(profile?.defaultLevel).toBe("high");
expect(profile?.preserveWhenCatalogReasoningFalse).toBe(true);
expect(profile?.levels.map((level) => level.id)).toContain("max");
});
it("resolves deployment aliases from canonical model metadata", () => {
const profile = resolveThinkingProfile({
provider: "anthropic-vertex",
modelId: "production-claude",
params: { canonicalModelId: "claude-fable-5" },
});
expect(profile?.defaultLevel).toBe("high");
expect(profile?.preserveWhenCatalogReasoningFalse).toBe(true);
});
it("ignores other providers", () => {
expect(resolveThinkingProfile({ provider: "anthropic", modelId: "claude-opus-4-8" })).toBe(
null,
);
});
});

View File

@@ -0,0 +1,19 @@
/**
* Provider-policy API for Anthropic Vertex. Core asks for thinking profiles
* without importing the provider entry or stream runtime.
*/
import { resolveClaudeThinkingProfile } from "openclaw/plugin-sdk/provider-model-shared";
/** Resolve Anthropic Vertex thinking profile for a provider/model pair. */
export function resolveThinkingProfile(params: {
provider: string;
modelId: string;
params?: Record<string, unknown>;
}) {
if (params.provider.trim().toLowerCase() !== "anthropic-vertex") {
return null;
}
return resolveClaudeThinkingProfile(params.modelId, params.params, {
includeNativeMax: true,
});
}

View File

@@ -0,0 +1,83 @@
// Anthropic Vertex tests cover region.adc plugin behavior.
import { platform } from "node:os";
import path from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
const { existsSyncMock, readFileSyncMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
readFileSyncMock: vi.fn(),
}));
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
existsSyncMock.mockImplementation((pathname) => actual.existsSync(pathname));
readFileSyncMock.mockImplementation((pathname, options) =>
String(pathname) === "/tmp/vertex-adc.json"
? '{"project_id":"vertex-project"}'
: actual.readFileSync(pathname, options as never),
);
return {
...actual,
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
default: {
...actual,
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
},
};
});
import { hasAnthropicVertexAvailableAuth, resolveAnthropicVertexProjectId } from "./region.js";
describe("anthropic-vertex ADC reads", () => {
afterEach(() => {
existsSyncMock.mockClear();
readFileSyncMock.mockClear();
});
afterAll(() => {
vi.doUnmock("node:fs");
vi.resetModules();
});
it("reads explicit ADC credentials without an existsSync preflight", () => {
const env = {
GOOGLE_APPLICATION_CREDENTIALS: "/tmp/vertex-adc.json",
} as NodeJS.ProcessEnv;
existsSyncMock.mockClear();
readFileSyncMock.mockClear();
expect(resolveAnthropicVertexProjectId(env)).toBe("vertex-project");
expect(hasAnthropicVertexAvailableAuth(env)).toBe(true);
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 homeDir = "/tmp/vertex-home";
const defaultAdcPath =
platform() === "win32"
? path.join(homeDir, "AppData", "Roaming", "gcloud", "application_default_credentials.json")
: path.join(homeDir, ".config", "gcloud", "application_default_credentials.json");
const env = {
HOME: homeDir,
} as NodeJS.ProcessEnv;
readFileSyncMock.mockImplementation((pathname, options) =>
String(pathname) === defaultAdcPath
? '{"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(defaultAdcPath, "utf8");
});
});

View File

@@ -0,0 +1,39 @@
// Anthropic Vertex tests cover region plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveAnthropicVertexRegion, resolveAnthropicVertexRegionFromBaseUrl } from "./api.js";
describe("anthropic vertex region helpers", () => {
it("accepts well-formed regional env values", () => {
expect(
resolveAnthropicVertexRegion({
GOOGLE_CLOUD_LOCATION: "us-east1",
} as NodeJS.ProcessEnv),
).toBe("us-east1");
});
it("falls back to the default region for malformed env values", () => {
expect(
resolveAnthropicVertexRegion({
GOOGLE_CLOUD_LOCATION: "us-central1.attacker.example",
} as NodeJS.ProcessEnv),
).toBe("global");
});
it("parses regional Vertex endpoints", () => {
expect(
resolveAnthropicVertexRegionFromBaseUrl("https://europe-west4-aiplatform.googleapis.com"),
).toBe("europe-west4");
});
it("treats the global Vertex endpoint as global", () => {
expect(resolveAnthropicVertexRegionFromBaseUrl("https://aiplatform.googleapis.com")).toBe(
"global",
);
});
it("does not infer a Vertex region from custom proxy hosts", () => {
expect(
resolveAnthropicVertexRegionFromBaseUrl("https://proxy.example.com/google/aiplatform"),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,156 @@
/**
* Anthropic Vertex region, project, and ADC auth detection helpers. They keep
* credential probing local to the provider plugin.
*/
import { readFileSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
type AdcProjectFile = {
project_id?: unknown;
quota_project_id?: unknown;
};
function normalizeOptionalSecretInput(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
/** Resolve the configured Vertex region, defaulting to global. */
export function resolveAnthropicVertexRegion(env: NodeJS.ProcessEnv = process.env): string {
const region =
normalizeOptionalSecretInput(env.GOOGLE_CLOUD_LOCATION) ||
normalizeOptionalSecretInput(env.CLOUD_ML_REGION);
return region && ANTHROPIC_VERTEX_REGION_RE.test(region)
? region
: ANTHROPIC_VERTEX_DEFAULT_REGION;
}
/** Resolve the Vertex project id from explicit env or ADC files. */
export function resolveAnthropicVertexProjectId(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
return (
normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_PROJECT_ID) ||
normalizeOptionalSecretInput(env.GOOGLE_CLOUD_PROJECT) ||
normalizeOptionalSecretInput(env.GOOGLE_CLOUD_PROJECT_ID) ||
resolveAnthropicVertexProjectIdFromAdc(env)
);
}
/** Extract a Vertex region from a provider base URL when possible. */
export function resolveAnthropicVertexRegionFromBaseUrl(baseUrl?: string): string | undefined {
const endpoint = resolveProviderEndpoint(baseUrl);
return endpoint.endpointClass === "google-vertex" ? endpoint.googleVertexRegion : undefined;
}
/** Resolve the client region from model base URL first, then env fallback. */
export function resolveAnthropicVertexClientRegion(params?: {
baseUrl?: string;
env?: NodeJS.ProcessEnv;
}): string {
return (
resolveAnthropicVertexRegionFromBaseUrl(params?.baseUrl) ||
resolveAnthropicVertexRegion(params?.env)
);
}
function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.env): boolean {
const explicitMetadataOptIn = normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_USE_GCP_METADATA);
return (
explicitMetadataOptIn === "1" ||
normalizeLowercaseStringOrEmpty(explicitMetadataOptIn) === "true"
);
}
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(
normalizeOptionalSecretInput(env.APPDATA) ??
join(resolveAnthropicVertexHomeDir(env), "AppData", "Roaming"),
"gcloud",
"application_default_credentials.json",
)
: join(
resolveAnthropicVertexHomeDir(env),
".config",
"gcloud",
"application_default_credentials.json",
);
}
function resolveAnthropicVertexAdcCredentialsPathCandidate(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const explicit = normalizeOptionalSecretInput(env.GOOGLE_APPLICATION_CREDENTIALS);
if (explicit) {
return explicit;
}
return resolveAnthropicVertexDefaultAdcPath(env);
}
function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolean {
const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env);
if (!credentialsPath) {
return false;
}
try {
readFileSync(credentialsPath, "utf8");
return true;
} catch {
return false;
}
}
function resolveAnthropicVertexProjectIdFromAdc(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env);
if (!credentialsPath) {
return undefined;
}
try {
const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as AdcProjectFile;
return (
normalizeOptionalSecretInput(parsed.project_id) ||
normalizeOptionalSecretInput(parsed.quota_project_id)
);
} catch {
return undefined;
}
}
/** Return whether ADC credentials or metadata-server auth are available. */
export function hasAnthropicVertexCredentials(env: NodeJS.ProcessEnv = process.env): boolean {
return hasAnthropicVertexMetadataServerAdc(env) || canReadAnthropicVertexAdc(env);
}
/** Return whether Anthropic Vertex has usable auth for implicit registration. */
export function hasAnthropicVertexAvailableAuth(env: NodeJS.ProcessEnv = process.env): boolean {
return hasAnthropicVertexCredentials(env);
}
/** Resolve the synthetic config API key marker for Anthropic Vertex auth. */
export function resolveAnthropicVertexConfigApiKey(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
return hasAnthropicVertexAvailableAuth(env) ? GCP_VERTEX_CREDENTIALS_MARKER : undefined;
}

View File

@@ -0,0 +1,21 @@
/**
* Lightweight Anthropic Vertex setup entry. It exposes provider auth detection
* without importing the stream runtime or Vertex SDK.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { resolveAnthropicVertexConfigApiKey } from "./region.js";
/** Setup entry for Anthropic Vertex provider auth probing. */
export default definePluginEntry({
id: "anthropic-vertex",
name: "Anthropic Vertex Setup",
description: "Lightweight Anthropic Vertex setup hooks",
register(api) {
api.registerProvider({
id: "anthropic-vertex",
label: "Anthropic Vertex",
auth: [],
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
});
},
});

View File

@@ -0,0 +1,478 @@
// Anthropic Vertex tests cover stream runtime plugin behavior.
import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
function createStreamDeps(): {
deps: AnthropicVertexStreamDeps;
streamAnthropicMock: ReturnType<typeof vi.fn>;
anthropicVertexCtorMock: ReturnType<typeof vi.fn>;
} {
const streamAnthropicMock = vi.fn(
(..._args: Parameters<AnthropicVertexStreamDeps["streamAnthropic"]>) =>
createAssistantMessageEventStream(),
);
const anthropicVertexCtorMock = vi.fn();
const MockAnthropicVertex = function MockAnthropicVertex(options: unknown) {
anthropicVertexCtorMock(options);
} as unknown as AnthropicVertexStreamDeps["AnthropicVertex"];
return {
deps: {
AnthropicVertex: MockAnthropicVertex,
streamAnthropic: streamAnthropicMock,
},
streamAnthropicMock,
anthropicVertexCtorMock,
};
}
let createAnthropicVertexStreamFn: typeof import("./stream-runtime.js").createAnthropicVertexStreamFn;
let createAnthropicVertexStreamFnForModel: typeof import("./stream-runtime.js").createAnthropicVertexStreamFnForModel;
function makeModel(params: {
id: string;
maxTokens?: number;
params?: Record<string, unknown>;
reasoning?: boolean;
thinkingLevelMap?: Model<"anthropic-messages">["thinkingLevelMap"];
}): Model<"anthropic-messages"> {
return {
id: params.id,
api: "anthropic-messages",
provider: "anthropic-vertex",
reasoning: params.reasoning ?? true,
...(params.maxTokens !== undefined ? { maxTokens: params.maxTokens } : {}),
...(params.params ? { params: params.params } : {}),
...(params.thinkingLevelMap ? { thinkingLevelMap: params.thinkingLevelMap } : {}),
} as Model<"anthropic-messages">;
}
type PayloadHook = (payload: unknown, payloadModel: unknown) => Promise<unknown>;
function streamAnthropicCall(streamAnthropicMock: ReturnType<typeof vi.fn>): unknown[] {
const call = streamAnthropicMock.mock.calls[0];
if (!call) {
throw new Error("Expected streamAnthropic call");
}
return call;
}
function streamTransportOptions(
streamAnthropicMock: ReturnType<typeof vi.fn>,
): Record<string, unknown> {
const options = streamAnthropicCall(streamAnthropicMock)[2];
if (!options || typeof options !== "object") {
throw new Error("Expected streamAnthropic transport options");
}
return options as Record<string, unknown>;
}
function captureTransportPayloadHook(
onPayload: PayloadHook | undefined,
deps: AnthropicVertexStreamDeps,
streamAnthropicMock: ReturnType<typeof vi.fn>,
) {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 });
void streamFn(
model,
{ messages: [{ role: "user", content: "Hello" }] } as never,
{ cacheRetention: "short", ...(onPayload ? { onPayload } : {}) } as never,
);
const transportOptions = streamTransportOptions(streamAnthropicMock);
return { model, onPayload: transportOptions.onPayload as PayloadHook | undefined };
}
// Mirrors the shared anthropic-messages transport output: cache boundary already
// split (uncached dynamic suffix) and all four cache_control markers allocated.
function buildBudgetedTransportPayload() {
return {
system: [
{ type: "text", text: "Stable prefix", cache_control: { type: "ephemeral" } },
{ type: "text", text: "Dynamic suffix" },
],
tools: [
{ name: "exec", input_schema: { type: "object" }, cache_control: { type: "ephemeral" } },
],
messages: [
{
role: "user",
content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }],
},
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "exec", input: {} }] },
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "t1",
content: [],
cache_control: { type: "ephemeral" },
},
],
},
],
};
}
function countCacheControlMarkers(payload: unknown): number {
let count = 0;
const visit = (value: unknown) => {
if (Array.isArray(value)) {
value.forEach(visit);
return;
}
if (!value || typeof value !== "object") {
return;
}
const record = value as Record<string, unknown>;
if (record.cache_control !== undefined) {
count += 1;
}
visit(record.content);
};
const record = payload as Record<string, unknown>;
visit(record.system);
visit(record.tools);
visit(record.messages);
return count;
}
describe("createAnthropicVertexStreamFn", () => {
beforeAll(async () => {
({ createAnthropicVertexStreamFn, createAnthropicVertexStreamFnForModel } =
await import("./stream-runtime.js"));
});
it("omits projectId when ADC credentials are used without an explicit project", () => {
const { deps, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn(undefined, "global", undefined, deps);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
region: "global",
});
});
it("passes an explicit baseURL through to the Vertex client", () => {
const { deps, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn(
"vertex-project",
"us-east5",
"https://proxy.example.test/vertex/v1",
deps,
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
projectId: "vertex-project",
region: "us-east5",
baseURL: "https://proxy.example.test/vertex/v1",
});
});
it("restores the canonical API before calling the shared Anthropic transport", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = {
...makeModel({ id: "claude-fable-5", maxTokens: 128000 }),
api: "openclaw-anthropic-vertex-simple:default",
};
void streamFn(model as never, { messages: [] }, {});
expect(streamAnthropicCall(streamAnthropicMock)[0]).toMatchObject({
api: "anthropic-messages",
provider: "anthropic-vertex",
id: "claude-fable-5",
});
});
it("defaults maxTokens to the model limit instead of the old 32000 cap", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-opus-4-6", maxTokens: 128000 });
void streamFn(model, { messages: [] }, {});
expect(streamTransportOptions(streamAnthropicMock).maxTokens).toBe(128000);
});
it("clamps explicit maxTokens to the selected model limit", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { maxTokens: 999999 });
expect(streamTransportOptions(streamAnthropicMock).maxTokens).toBe(128000);
});
it.each(["claude-opus-4-8", "claude-opus-4-7", "claude-fable-5", "claude-mythos-5"])(
"omits unsupported temperature for %s",
(modelId) => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: modelId, maxTokens: 128000 });
void streamFn(model, { messages: [] }, { temperature: 0.7 });
const transportOptions = streamTransportOptions(streamAnthropicMock);
expect(Object.hasOwn(transportOptions, "temperature")).toBe(false);
},
);
it("preserves temperature for Vertex models that support custom sampling", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { temperature: 0.7 });
expect(streamTransportOptions(streamAnthropicMock).temperature).toBe(0.7);
});
it("uses Fable 5's always-adaptive Vertex contract", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-fable-5", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { temperature: 0.7 });
expect(streamTransportOptions(streamAnthropicMock)).toMatchObject({
thinkingEnabled: true,
effort: "high",
maxTokens: 128000,
});
expect(streamTransportOptions(streamAnthropicMock)).not.toHaveProperty("temperature");
});
it("uses Mythos 5's mandatory adaptive Vertex contract by default", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-mythos-5", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { temperature: 0.7 });
expect(streamTransportOptions(streamAnthropicMock)).toMatchObject({
thinkingEnabled: true,
effort: "high",
maxTokens: 128000,
});
expect(streamTransportOptions(streamAnthropicMock)).not.toHaveProperty("temperature");
});
it("uses canonical Claude policy for Vertex deployment aliases", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({
id: "production-claude",
maxTokens: 128000,
params: { canonicalModelId: "claude-opus-4-8" },
});
void streamFn(model, { messages: [] }, { reasoning: "xhigh", temperature: 0.7 });
expect(streamTransportOptions(streamAnthropicMock)).toMatchObject({
thinkingEnabled: true,
effort: "xhigh",
});
expect(streamTransportOptions(streamAnthropicMock)).not.toHaveProperty("temperature");
});
it("preserves Fable 5 low effort on Vertex", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-fable-5", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { reasoning: "low" });
expect(streamTransportOptions(streamAnthropicMock)).toMatchObject({
thinkingEnabled: true,
effort: "low",
});
});
it("preserves Fable 5 xhigh effort on Vertex", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-fable-5", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
expect(streamTransportOptions(streamAnthropicMock)).toMatchObject({
thinkingEnabled: true,
effort: "xhigh",
});
});
it("maps unsupported xhigh reasoning to high effort for Opus 4.6", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-opus-4-6", maxTokens: 64000 });
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
const transportOptions = streamTransportOptions(streamAnthropicMock);
expect(transportOptions.thinkingEnabled).toBe(true);
expect(transportOptions.effort).toBe("high");
});
it("maps xhigh reasoning to xhigh effort for Opus 4.8", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-opus-4-8", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
const transportOptions = streamTransportOptions(streamAnthropicMock);
expect(transportOptions.thinkingEnabled).toBe(true);
expect(transportOptions.effort).toBe("xhigh");
});
it("preserves max reasoning for Opus 4.8", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-opus-4-8", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { reasoning: "max" });
const transportOptions = streamTransportOptions(streamAnthropicMock);
expect(transportOptions.thinkingEnabled).toBe(true);
expect(transportOptions.effort).toBe("max");
});
it("preserves native max reasoning for Sonnet 4.6", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { reasoning: "max" });
const transportOptions = streamTransportOptions(streamAnthropicMock);
expect(transportOptions.thinkingEnabled).toBe(true);
expect(transportOptions.effort).toBe("max");
});
it("honors explicit max opt-outs for Vertex aliases", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({
id: "production-claude",
params: { canonicalModelId: "claude-sonnet-4-6" },
reasoning: false,
thinkingLevelMap: { xhigh: null, max: null },
});
void streamFn(model, { messages: [] }, { reasoning: "max", temperature: 0.2 });
const transportOptions = streamTransportOptions(streamAnthropicMock);
expect(transportOptions.effort).toBe("high");
expect(transportOptions).not.toHaveProperty("temperature");
});
it("keeps already-budgeted cache_control markers intact when forwarding payload hooks", async () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const onPayload = vi.fn(async (payload: unknown) => payload);
const { model, onPayload: transportPayloadHook } = captureTransportPayloadHook(
onPayload,
deps,
streamAnthropicMock,
);
const payload = buildBudgetedTransportPayload();
const nextPayload = await transportPayloadHook?.(payload, model);
expect(onPayload).toHaveBeenCalledWith(payload, model);
expect(countCacheControlMarkers(nextPayload)).toBe(4);
expect((nextPayload as ReturnType<typeof buildBudgetedTransportPayload>).system[1]).toEqual({
type: "text",
text: "Dynamic suffix",
});
});
it("omits the transport payload hook when the caller provides none", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const { onPayload: transportPayloadHook } = captureTransportPayloadHook(
undefined,
deps,
streamAnthropicMock,
);
expect(transportPayloadHook).toBeUndefined();
});
it("omits maxTokens when neither the model nor request provide a finite limit", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-sonnet-4-6" });
void streamFn(model, { messages: [] }, { maxTokens: Number.NaN });
expect(streamAnthropicMock).toHaveBeenCalledTimes(1);
const [calledModel, payload, transportOptions] = streamAnthropicCall(streamAnthropicMock);
expect(calledModel).toBe(model);
expect(payload).toEqual({ messages: [] });
expect(transportOptions).toBeTypeOf("object");
expect(Object.hasOwn(transportOptions as object, "maxTokens")).toBe(false);
});
});
describe("createAnthropicVertexStreamFnForModel", () => {
it("derives project and region from the model and env", () => {
const { deps, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFnForModel(
{ baseUrl: "https://europe-west4-aiplatform.googleapis.com" },
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
deps,
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
projectId: "vertex-project",
region: "europe-west4",
baseURL: "https://europe-west4-aiplatform.googleapis.com/v1",
});
});
it("preserves explicit custom provider base URLs", () => {
const { deps, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFnForModel(
{ baseUrl: "https://proxy.example.test/custom-root/v1" },
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
deps,
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
projectId: "vertex-project",
region: "global",
baseURL: "https://proxy.example.test/custom-root/v1",
});
});
it("adds /v1 for path-prefixed custom provider base URLs", () => {
const { deps, anthropicVertexCtorMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFnForModel(
{ baseUrl: "https://proxy.example.test/custom-root" },
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
deps,
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
projectId: "vertex-project",
region: "global",
baseURL: "https://proxy.example.test/custom-root/v1",
});
});
});

View File

@@ -0,0 +1,245 @@
/**
* Anthropic Vertex stream runtime. It constructs Vertex SDK clients and adapts
* OpenClaw stream options for the shared Anthropic Messages transport.
*/
import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import {
clampThinkingLevel,
stream as streamDefault,
type Model,
type ModelThinkingLevel,
type ProviderStreamOptions,
} from "openclaw/plugin-sdk/llm";
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
} from "openclaw/plugin-sdk/provider-model-shared";
import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } from "./region.js";
type AnthropicVertexTransportOptions = ProviderStreamOptions & {
client?: unknown;
thinkingEnabled?: boolean;
thinkingBudgetTokens?: number;
effort?: "low" | "medium" | "high" | "xhigh" | "max";
};
type AnthropicVertexEffort = NonNullable<AnthropicVertexTransportOptions["effort"]>;
type AnthropicVertexAdaptiveEffort = AnthropicVertexEffort | "xhigh";
type AnthropicVertexClientOptions = {
baseURL?: string;
projectId?: string;
region: string;
};
/** Injectable dependencies for Anthropic Vertex stream tests. */
export type AnthropicVertexStreamDeps = {
AnthropicVertex: new (options: AnthropicVertexClientOptions) => unknown;
streamAnthropic: typeof streamDefault;
};
const defaultAnthropicVertexStreamDeps: AnthropicVertexStreamDeps = {
AnthropicVertex: AnthropicVertexSdk as AnthropicVertexStreamDeps["AnthropicVertex"],
streamAnthropic: streamDefault,
};
function isClaudeOpus47OrNewerModel(modelId: string): boolean {
return supportsClaudeNativeXhighEffort({ id: modelId });
}
function isClaudeFable5Model(modelId: string): boolean {
return resolveClaudeFable5ModelIdentity({ id: modelId }) !== undefined;
}
function isClaudeMythos5Model(modelId: string): boolean {
return /(?:^|-)claude-mythos-5(?=$|[^a-z0-9])/.test(resolveClaudeModelIdentity({ id: modelId }));
}
function supportsAdaptiveThinking(modelId: string): boolean {
return supportsClaudeAdaptiveThinking({ id: modelId }) || isClaudeMythos5Model(modelId);
}
function mapAnthropicAdaptiveEffort(
reasoning: ModelThinkingLevel,
model: Model<"anthropic-messages">,
modelId: string,
): AnthropicVertexAdaptiveEffort {
const clampModel =
typeof model.params?.canonicalModelId === "string" ? { ...model, reasoning: true } : model;
const resolvedReasoning = clampThinkingLevel(clampModel, reasoning);
const mapped = model.thinkingLevelMap?.[resolvedReasoning];
if (typeof mapped === "string") {
return mapped as AnthropicVertexAdaptiveEffort;
}
const effortMap: Record<string, AnthropicVertexAdaptiveEffort> = {
off: "low",
minimal: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: isClaudeFable5Model(modelId)
? "xhigh"
: isClaudeOpus47OrNewerModel(modelId) || isClaudeMythos5Model(modelId)
? "xhigh"
: "high",
max:
supportsClaudeNativeMaxEffort({ id: modelId }) || isClaudeMythos5Model(modelId)
? "max"
: "high",
};
return effortMap[resolvedReasoning] ?? "high";
}
function resolveAnthropicVertexMaxTokens(params: {
modelMaxTokens: number | undefined;
requestedMaxTokens: number | undefined;
}): number | undefined {
const modelMax =
typeof params.modelMaxTokens === "number" &&
Number.isFinite(params.modelMaxTokens) &&
params.modelMaxTokens > 0
? Math.floor(params.modelMaxTokens)
: undefined;
const requested =
typeof params.requestedMaxTokens === "number" &&
Number.isFinite(params.requestedMaxTokens) &&
params.requestedMaxTokens > 0
? Math.floor(params.requestedMaxTokens)
: undefined;
if (modelMax !== undefined && requested !== undefined) {
return Math.min(requested, modelMax);
}
return requested ?? modelMax;
}
/**
* Create a StreamFn that routes through OpenClaw's generic model stream with an
* injected `AnthropicVertex` client. All streaming, message conversion, and
* event handling is handled by the shared model runtime - we only supply the GCP-authenticated
* client and provider transport options.
*/
export function createAnthropicVertexStreamFn(
projectId: string | undefined,
region: string,
baseURL?: string,
deps: AnthropicVertexStreamDeps = defaultAnthropicVertexStreamDeps,
): StreamFn {
const client = new deps.AnthropicVertex({
region,
...(baseURL ? { baseURL } : {}),
...(projectId ? { projectId } : {}),
});
return (model, context, options) => {
// Simple completions use a synthetic registry API to select this plugin.
// The shared Anthropic transport must receive its canonical API or it recurses.
const transportModel = (
model.api === "anthropic-messages" ? model : { ...model, api: "anthropic-messages" as const }
) as Model<"anthropic-messages"> & {
baseUrl?: string;
provider: string;
};
const maxTokens = resolveAnthropicVertexMaxTokens({
modelMaxTokens: transportModel.maxTokens,
requestedMaxTokens: options?.maxTokens,
});
const contractModelId = resolveClaudeModelIdentity(model);
const fable5 = isClaudeFable5Model(contractModelId);
const mandatoryAdaptiveThinking = fable5 || isClaudeMythos5Model(contractModelId);
const reasoning =
(options?.reasoning as ModelThinkingLevel | undefined) ??
(mandatoryAdaptiveThinking ? "high" : undefined);
const adaptiveThinking =
mandatoryAdaptiveThinking || Boolean(reasoning && supportsAdaptiveThinking(contractModelId));
const temperature =
adaptiveThinking ||
isClaudeOpus47OrNewerModel(contractModelId) ||
isClaudeMythos5Model(contractModelId)
? undefined
: options?.temperature;
const opts: AnthropicVertexTransportOptions = {
client,
...(temperature !== undefined ? { temperature } : {}),
...(maxTokens !== undefined ? { maxTokens } : {}),
signal: options?.signal,
cacheRetention: options?.cacheRetention,
sessionId: options?.sessionId,
headers: options?.headers,
// The shared anthropic-messages transport already splits the system prompt
// cache boundary and budgets all cache_control markers; re-applying the
// payload policy here marked the uncached suffix and breached the 4-marker cap.
onPayload: options?.onPayload,
maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata,
};
if (reasoning) {
if (supportsAdaptiveThinking(contractModelId)) {
opts.thinkingEnabled = true;
opts.effort = mapAnthropicAdaptiveEffort(
reasoning,
transportModel,
contractModelId,
) as AnthropicVertexEffort;
} else {
opts.thinkingEnabled = true;
const budgets = options?.thinkingBudgets;
opts.thinkingBudgetTokens =
(budgets && reasoning in budgets
? budgets[reasoning as keyof typeof budgets]
: undefined) ?? 10000;
}
} else if (fable5) {
opts.thinkingEnabled = true;
opts.effort = "high";
} else {
opts.thinkingEnabled = false;
}
return deps.streamAnthropic(transportModel, context, opts);
};
}
function resolveAnthropicVertexSdkBaseUrl(baseUrl?: string): string | undefined {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return undefined;
}
try {
const url = new URL(trimmed);
const normalizedPath = url.pathname.replace(/\/+$/, "");
if (!normalizedPath || normalizedPath === "") {
url.pathname = "/v1";
return url.toString().replace(/\/$/, "");
}
if (!normalizedPath.endsWith("/v1")) {
url.pathname = `${normalizedPath}/v1`;
return url.toString().replace(/\/$/, "");
}
return trimmed;
} catch {
return trimmed;
}
}
/** Create an Anthropic Vertex stream function from model metadata and env. */
export function createAnthropicVertexStreamFnForModel(
model: { baseUrl?: string },
env: NodeJS.ProcessEnv = process.env,
deps?: AnthropicVertexStreamDeps,
): StreamFn {
return createAnthropicVertexStreamFn(
resolveAnthropicVertexProjectId(env),
resolveAnthropicVertexClientRegion({
baseUrl: model.baseUrl,
env,
}),
resolveAnthropicVertexSdkBaseUrl(model.baseUrl),
deps,
);
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}