feat: add anthropic-vertex provider for Claude via GCP Vertex AI (#43356)

Reuse pi-ai's Anthropic client injection seam for streaming, and add
the OpenClaw-side provider discovery, auth, model catalog, and tests
needed to expose anthropic-vertex cleanly.

Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
Sally O'Malley 2026-03-20 18:48:42 -04:00 committed by GitHub
parent 42ca447189
commit 6e20c4baa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1023 additions and 133 deletions

View File

@ -0,0 +1,65 @@
import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-models";
import { resolveAnthropicVertexRegion } from "openclaw/plugin-sdk/provider-models";
export const ANTHROPIC_VERTEX_DEFAULT_MODEL_ID = "claude-sonnet-4-6";
const ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW = 1_000_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;
}): 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 buildAnthropicVertexCatalog(): ModelDefinitionConfig[] {
return [
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: 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,
}),
];
}
export function buildAnthropicVertexProvider(params?: {
env?: NodeJS.ProcessEnv;
}): ModelProviderConfig {
const region = resolveAnthropicVertexRegion(params?.env);
const baseUrl =
region.toLowerCase() === "global"
? "https://aiplatform.googleapis.com"
: `https://${region}-aiplatform.googleapis.com`;
return {
baseUrl,
api: "anthropic-messages",
apiKey: GCP_VERTEX_CREDENTIALS_MARKER,
models: buildAnthropicVertexCatalog(),
};
}

View File

@ -577,6 +577,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@aws-sdk/client-bedrock": "^3.1011.0",
"@clack/prompts": "^1.1.0",
"@homebridge/ciao": "^1.3.5",

208
pnpm-lock.yaml generated
View File

@ -29,6 +29,9 @@ importers:
'@agentclientprotocol/sdk':
specifier: 0.16.1
version: 0.16.1(zod@4.3.6)
'@anthropic-ai/vertex-sdk':
specifier: ^0.14.4
version: 0.14.4(zod@4.3.6)
'@aws-sdk/client-bedrock':
specifier: ^3.1011.0
version: 3.1011.0
@ -688,6 +691,9 @@ packages:
zod:
optional: true
'@anthropic-ai/vertex-sdk@0.14.4':
resolution: {integrity: sha512-BZUPRWghZxfSFtAxU563wH+jfWBPoedAwsVxG35FhmNsjeV8tyfN+lFriWhCpcZApxA4NdT6Soov+PzfnxxD5g==}
'@asamuzakjp/css-color@5.0.1':
resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@ -1480,10 +1486,6 @@ packages:
cpu: [x64]
os: [win32]
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
@ -2619,10 +2621,6 @@ packages:
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@ -4125,9 +4123,6 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
@ -4140,9 +4135,6 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
empathic@2.0.0:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'}
@ -4359,10 +4351,6 @@ packages:
debug:
optional: true
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@2.5.4:
resolution: {integrity: sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==}
engines: {node: '>= 0.12'}
@ -4409,14 +4397,18 @@ packages:
engines: {node: '>=10'}
deprecated: This package is no longer supported.
gaxios@7.1.3:
resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==}
engines: {node: '>=18'}
gaxios@6.7.1:
resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==}
engines: {node: '>=14'}
gaxios@7.1.4:
resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==}
engines: {node: '>=18'}
gcp-metadata@6.1.1:
resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==}
engines: {node: '>=14'}
gcp-metadata@8.1.2:
resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
engines: {node: '>=18'}
@ -4459,11 +4451,6 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
@ -4472,14 +4459,18 @@ packages:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
google-auth-library@10.6.1:
resolution: {integrity: sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==}
engines: {node: '>=18'}
google-auth-library@10.6.2:
resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==}
engines: {node: '>=18'}
google-auth-library@9.15.1:
resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==}
engines: {node: '>=14'}
google-logging-utils@0.0.2:
resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
engines: {node: '>=14'}
google-logging-utils@1.1.3:
resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==}
engines: {node: '>=14'}
@ -4495,6 +4486,10 @@ packages:
resolution: {integrity: sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==}
engines: {node: ^12.20.0 || >=14.13.1}
gtoken@7.1.0:
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
engines: {node: '>=14.0.0'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@ -4721,9 +4716,6 @@ packages:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jimp@1.6.0:
resolution: {integrity: sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==}
engines: {node: '>=18'}
@ -4993,9 +4985,6 @@ packages:
resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==}
engines: {node: '>=18'}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
@ -5423,9 +5412,6 @@ packages:
resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
engines: {node: '>= 14'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@ -5483,10 +5469,6 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
@ -5794,10 +5776,6 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rimraf@5.0.10:
resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
hasBin: true
rolldown-plugin-dts@0.22.5:
resolution: {integrity: sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw==}
engines: {node: '>=20.19.0'}
@ -6089,10 +6067,6 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
@ -6402,6 +6376,10 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
validate-npm-package-name@7.0.2:
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==}
engines: {node: ^20.17.0 || >=22.9.0}
@ -6557,10 +6535,6 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@ -6668,6 +6642,15 @@ snapshots:
optionalDependencies:
zod: 4.3.6
'@anthropic-ai/vertex-sdk@0.14.4(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
google-auth-library: 9.15.1
transitivePeerDependencies:
- encoding
- supports-color
- zod
'@asamuzakjp/css-color@5.0.1':
dependencies:
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
@ -7804,7 +7787,7 @@ snapshots:
'@google/genai@1.44.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))':
dependencies:
google-auth-library: 10.6.1
google-auth-library: 10.6.2
p-retry: 4.6.2
protobufjs: 7.5.4
ws: 8.19.0
@ -7969,15 +7952,6 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.2.0
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.3
@ -9320,9 +9294,6 @@ snapshots:
'@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
'@polka/url@1.0.0-next.29': {}
'@protobufjs/aspromise@1.1.2': {}
@ -11012,8 +10983,6 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
@ -11024,8 +10993,6 @@ snapshots:
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
empathic@2.0.0: {}
encodeurl@2.0.0: {}
@ -11278,11 +11245,6 @@ snapshots:
follow-redirects@1.15.11: {}
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data@2.5.4:
dependencies:
asynckit: 0.4.0
@ -11336,13 +11298,15 @@ snapshots:
wide-align: 1.1.5
optional: true
gaxios@7.1.3:
gaxios@6.7.1:
dependencies:
extend: 3.0.2
https-proxy-agent: 7.0.6
node-fetch: 3.3.2
rimraf: 5.0.10
is-stream: 2.0.1
node-fetch: 2.7.0
uuid: 9.0.1
transitivePeerDependencies:
- encoding
- supports-color
gaxios@7.1.4:
@ -11353,6 +11317,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
gcp-metadata@6.1.1:
dependencies:
gaxios: 6.7.1
google-logging-utils: 0.0.2
json-bigint: 1.0.0
transitivePeerDependencies:
- encoding
- supports-color
gcp-metadata@8.1.2:
dependencies:
gaxios: 7.1.4
@ -11411,15 +11384,6 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 10.2.4
minipass: 7.1.3
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
glob@13.0.6:
dependencies:
minimatch: 10.2.4
@ -11436,17 +11400,6 @@ snapshots:
path-is-absolute: 1.0.1
optional: true
google-auth-library@10.6.1:
dependencies:
base64-js: 1.5.1
ecdsa-sig-formatter: 1.0.11
gaxios: 7.1.3
gcp-metadata: 8.1.2
google-logging-utils: 1.1.3
jws: 4.0.1
transitivePeerDependencies:
- supports-color
google-auth-library@10.6.2:
dependencies:
base64-js: 1.5.1
@ -11458,6 +11411,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
google-auth-library@9.15.1:
dependencies:
base64-js: 1.5.1
ecdsa-sig-formatter: 1.0.11
gaxios: 6.7.1
gcp-metadata: 6.1.1
gtoken: 7.1.0
jws: 4.0.1
transitivePeerDependencies:
- encoding
- supports-color
google-logging-utils@0.0.2: {}
google-logging-utils@1.1.3: {}
gopd@1.2.0: {}
@ -11474,6 +11441,14 @@ snapshots:
- encoding
- supports-color
gtoken@7.1.0:
dependencies:
gaxios: 6.7.1
jws: 4.0.1
transitivePeerDependencies:
- encoding
- supports-color
has-flag@4.0.0: {}
has-own@1.0.1: {}
@ -11725,12 +11700,6 @@ snapshots:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
jimp@1.6.0:
dependencies:
'@jimp/core': 1.6.0
@ -12037,8 +12006,6 @@ snapshots:
dependencies:
steno: 4.0.2
lru-cache@10.4.3: {}
lru-cache@11.2.7: {}
lru-cache@6.0.0:
@ -12634,8 +12601,6 @@ snapshots:
degenerator: 5.0.1
netmask: 2.0.2
package-json-from-dist@1.0.1: {}
pako@1.0.11: {}
pako@2.1.0: {}
@ -12681,11 +12646,6 @@ snapshots:
path-parse@1.0.7: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.3
path-scurry@2.0.2:
dependencies:
lru-cache: 11.2.7
@ -13036,10 +12996,6 @@ snapshots:
glob: 7.2.3
optional: true
rimraf@5.0.10:
dependencies:
glob: 10.5.0
rolldown-plugin-dts@0.22.5(@typescript/native-preview@7.0.0-dev.20260317.1)(rolldown@1.0.0-rc.9)(typescript@5.9.3):
dependencies:
'@babel/generator': 8.0.0-rc.2
@ -13394,12 +13350,6 @@ snapshots:
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@5.1.2:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.2.0
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
@ -13687,6 +13637,8 @@ snapshots:
uuid@8.3.2: {}
uuid@9.0.1: {}
validate-npm-package-name@7.0.2: {}
vary@1.1.2: {}
@ -13809,12 +13761,6 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.2.0
wrappy@1.0.2: {}
ws@8.19.0: {}

View File

@ -0,0 +1,124 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
const GCLOUD_DEFAULT_ADC_PATH = join(
homedir(),
".config",
"gcloud",
"application_default_credentials.json",
);
type AdcProjectFile = {
project_id?: unknown;
quota_project_id?: unknown;
};
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)
);
}
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;
}
export function resolveAnthropicVertexRegionFromBaseUrl(baseUrl?: string): string | undefined {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return undefined;
}
try {
const host = new URL(trimmed).hostname.toLowerCase();
if (host === "aiplatform.googleapis.com") {
return "global";
}
const match = /^([a-z0-9-]+)-aiplatform\.googleapis\.com$/.exec(host);
return match?.[1];
} catch {
return undefined;
}
}
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" || explicitMetadataOptIn?.toLowerCase() === "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 resolveAnthropicVertexAdcCredentialsPath(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const explicitCredentialsPath = normalizeOptionalSecretInput(env.GOOGLE_APPLICATION_CREDENTIALS);
if (explicitCredentialsPath) {
return existsSync(explicitCredentialsPath) ? explicitCredentialsPath : undefined;
}
const defaultAdcPath = resolveAnthropicVertexDefaultAdcPath(env);
return existsSync(defaultAdcPath) ? defaultAdcPath : undefined;
}
function resolveAnthropicVertexProjectIdFromAdc(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const credentialsPath = resolveAnthropicVertexAdcCredentialsPath(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;
}
}
export function hasAnthropicVertexCredentials(env: NodeJS.ProcessEnv = process.env): boolean {
return (
hasAnthropicVertexMetadataServerAdc(env) ||
resolveAnthropicVertexAdcCredentialsPath(env) !== undefined
);
}
export function hasAnthropicVertexAvailableAuth(env: NodeJS.ProcessEnv = process.env): boolean {
return hasAnthropicVertexCredentials(env);
}

View File

@ -0,0 +1,221 @@
import type { Model } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => {
const streamAnthropicMock = vi.fn<(model: unknown, context: unknown, options: unknown) => symbol>(
() => Symbol("anthropic-vertex-stream"),
);
const anthropicVertexCtorMock = vi.fn();
return {
streamAnthropicMock,
anthropicVertexCtorMock,
};
});
vi.mock("@mariozechner/pi-ai", () => {
return {
streamAnthropic: (model: unknown, context: unknown, options: unknown) =>
hoisted.streamAnthropicMock(model, context, options),
};
});
vi.mock("@anthropic-ai/vertex-sdk", () => ({
AnthropicVertex: vi.fn(function MockAnthropicVertex(options: unknown) {
hoisted.anthropicVertexCtorMock(options);
return { options };
}),
}));
import {
resolveAnthropicVertexRegion,
resolveAnthropicVertexRegionFromBaseUrl,
} from "./anthropic-vertex-provider.js";
import {
createAnthropicVertexStreamFn,
createAnthropicVertexStreamFnForModel,
} from "./anthropic-vertex-stream.js";
function makeModel(params: { id: string; maxTokens?: number }): Model<"anthropic-messages"> {
return {
id: params.id,
api: "anthropic-messages",
provider: "anthropic-vertex",
...(params.maxTokens !== undefined ? { maxTokens: params.maxTokens } : {}),
} as Model<"anthropic-messages">;
}
describe("createAnthropicVertexStreamFn", () => {
beforeEach(() => {
hoisted.streamAnthropicMock.mockClear();
hoisted.anthropicVertexCtorMock.mockClear();
});
it("omits projectId when ADC credentials are used without an explicit project", () => {
const streamFn = createAnthropicVertexStreamFn(undefined, "global");
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
region: "global",
});
});
it("passes an explicit baseURL through to the Vertex client", () => {
const streamFn = createAnthropicVertexStreamFn(
"vertex-project",
"us-east5",
"https://proxy.example.test/vertex/v1",
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
projectId: "vertex-project",
region: "us-east5",
baseURL: "https://proxy.example.test/vertex/v1",
});
});
it("defaults maxTokens to the model limit instead of the old 32000 cap", () => {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
const model = makeModel({ id: "claude-opus-4-6", maxTokens: 128000 });
void streamFn(model, { messages: [] }, {});
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.objectContaining({
maxTokens: 128000,
}),
);
});
it("clamps explicit maxTokens to the selected model limit", () => {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { maxTokens: 999999 });
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.objectContaining({
maxTokens: 128000,
}),
);
});
it("maps xhigh reasoning to max effort for adaptive Opus models", () => {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
const model = makeModel({ id: "claude-opus-4-6", maxTokens: 64000 });
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.objectContaining({
thinkingEnabled: true,
effort: "max",
}),
);
});
it("omits maxTokens when neither the model nor request provide a finite limit", () => {
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
const model = makeModel({ id: "claude-sonnet-4-6" });
void streamFn(model, { messages: [] }, { maxTokens: Number.NaN });
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.not.objectContaining({
maxTokens: expect.anything(),
}),
);
});
});
describe("resolveAnthropicVertexRegionFromBaseUrl", () => {
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",
);
});
});
describe("createAnthropicVertexStreamFnForModel", () => {
beforeEach(() => {
hoisted.anthropicVertexCtorMock.mockClear();
});
it("derives project and region from the model and env", () => {
const streamFn = createAnthropicVertexStreamFnForModel(
{ baseUrl: "https://europe-west4-aiplatform.googleapis.com" },
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
projectId: "vertex-project",
region: "europe-west4",
baseURL: "https://europe-west4-aiplatform.googleapis.com/v1",
});
});
it("preserves explicit custom provider base URLs", () => {
const streamFn = createAnthropicVertexStreamFnForModel(
{ baseUrl: "https://proxy.example.test/custom-root/v1" },
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
expect(hoisted.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 streamFn = createAnthropicVertexStreamFnForModel(
{ baseUrl: "https://proxy.example.test/custom-root" },
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
);
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
projectId: "vertex-project",
region: "global",
baseURL: "https://proxy.example.test/custom-root/v1",
});
});
});

View File

@ -0,0 +1,137 @@
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamAnthropic, type AnthropicOptions, type Model } from "@mariozechner/pi-ai";
import {
resolveAnthropicVertexClientRegion,
resolveAnthropicVertexProjectId,
} from "./anthropic-vertex-provider.js";
type AnthropicVertexEffort = NonNullable<AnthropicOptions["effort"]>;
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 pi-ai's `streamAnthropic` with an
* injected `AnthropicVertex` client. All streaming, message conversion, and
* event handling is handled by pi-ai we only supply the GCP-authenticated
* client and map SimpleStreamOptions AnthropicOptions.
*/
export function createAnthropicVertexStreamFn(
projectId: string | undefined,
region: string,
baseURL?: string,
): StreamFn {
const client = new AnthropicVertex({
region,
...(baseURL ? { baseURL } : {}),
...(projectId ? { projectId } : {}),
});
return (model, context, options) => {
const maxTokens = resolveAnthropicVertexMaxTokens({
modelMaxTokens: model.maxTokens,
requestedMaxTokens: options?.maxTokens,
});
const opts: AnthropicOptions = {
client: client as unknown as AnthropicOptions["client"],
temperature: options?.temperature,
...(maxTokens !== undefined ? { maxTokens } : {}),
signal: options?.signal,
cacheRetention: options?.cacheRetention,
sessionId: options?.sessionId,
headers: options?.headers,
onPayload: options?.onPayload,
maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata,
};
if (options?.reasoning) {
const isAdaptive =
model.id.includes("opus-4-6") ||
model.id.includes("opus-4.6") ||
model.id.includes("sonnet-4-6") ||
model.id.includes("sonnet-4.6");
if (isAdaptive) {
opts.thinkingEnabled = true;
const effortMap: Record<string, AnthropicVertexEffort> = {
minimal: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: model.id.includes("opus-4-6") || model.id.includes("opus-4.6") ? "max" : "high",
};
opts.effort = effortMap[options.reasoning] ?? "high";
} else {
opts.thinkingEnabled = true;
const budgets = options.thinkingBudgets;
opts.thinkingBudgetTokens =
(budgets && options.reasoning in budgets
? budgets[options.reasoning as keyof typeof budgets]
: undefined) ?? 10000;
}
} else {
opts.thinkingEnabled = false;
}
return streamAnthropic(model as Model<"anthropic-messages">, 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;
}
}
export function createAnthropicVertexStreamFnForModel(
model: { baseUrl?: string },
env: NodeJS.ProcessEnv = process.env,
): StreamFn {
return createAnthropicVertexStreamFn(
resolveAnthropicVertexProjectId(env),
resolveAnthropicVertexClientRegion({
baseUrl: model.baseUrl,
env,
}),
resolveAnthropicVertexSdkBaseUrl(model.baseUrl),
);
}

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js";
import {
GCP_VERTEX_CREDENTIALS_MARKER,
isKnownEnvApiKeyMarker,
isNonSecretApiKeyMarker,
NON_ENV_SECRETREF_MARKER,
@ -13,6 +14,7 @@ describe("model auth markers", () => {
expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(true);
expect(isNonSecretApiKeyMarker(resolveOAuthApiKeyMarker("chutes"))).toBe(true);
expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true);
expect(isNonSecretApiKeyMarker(GCP_VERTEX_CREDENTIALS_MARKER)).toBe(true);
});
it("recognizes known env marker names but not arbitrary all-caps keys", () => {

View File

@ -6,6 +6,7 @@ export const OAUTH_API_KEY_MARKER_PREFIX = "oauth:";
export const QWEN_OAUTH_MARKER = "qwen-oauth";
export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local";
export const CUSTOM_LOCAL_AUTH_MARKER = "custom-local";
export const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; // pragma: allowlist secret
export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret
@ -83,6 +84,7 @@ export function isNonSecretApiKeyMarker(
isOAuthApiKeyMarker(trimmed) ||
trimmed === OLLAMA_LOCAL_AUTH_MARKER ||
trimmed === CUSTOM_LOCAL_AUTH_MARKER ||
trimmed === GCP_VERTEX_CREDENTIALS_MARKER ||
trimmed === NON_ENV_SECRETREF_MARKER ||
isAwsSdkAuthMarker(trimmed);
if (isKnownMarker) {

View File

@ -506,4 +506,55 @@ describe("getApiKeyForModel", () => {
},
);
});
it("resolveEnvApiKey('anthropic-vertex') uses the provided env snapshot", async () => {
const resolved = resolveEnvApiKey("anthropic-vertex", {
GOOGLE_CLOUD_PROJECT_ID: "vertex-project",
} as NodeJS.ProcessEnv);
expect(resolved).toBeNull();
});
it("resolveEnvApiKey('anthropic-vertex') accepts GOOGLE_APPLICATION_CREDENTIALS with project_id", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-"));
const credentialsPath = path.join(tempDir, "adc.json");
await fs.writeFile(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
try {
const resolved = resolveEnvApiKey("anthropic-vertex", {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
} as NodeJS.ProcessEnv);
expect(resolved?.apiKey).toBe("gcp-vertex-credentials");
expect(resolved?.source).toBe("gcloud adc");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("resolveEnvApiKey('anthropic-vertex') accepts GOOGLE_APPLICATION_CREDENTIALS without a local project field", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-"));
const credentialsPath = path.join(tempDir, "adc.json");
await fs.writeFile(credentialsPath, "{}", "utf8");
try {
const resolved = resolveEnvApiKey("anthropic-vertex", {
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
} as NodeJS.ProcessEnv);
expect(resolved?.apiKey).toBe("gcp-vertex-credentials");
expect(resolved?.source).toBe("gcloud adc");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("resolveEnvApiKey('anthropic-vertex') accepts explicit metadata auth opt-in", async () => {
const resolved = resolveEnvApiKey("anthropic-vertex", {
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
} as NodeJS.ProcessEnv);
expect(resolved?.apiKey).toBe("gcp-vertex-credentials");
expect(resolved?.source).toBe("gcloud adc");
});
});

View File

@ -2,7 +2,11 @@ import { streamSimpleOpenAICompletions, type Model } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import type { AuthProfileStore } from "./auth-profiles.js";
import { CUSTOM_LOCAL_AUTH_MARKER, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
import {
CUSTOM_LOCAL_AUTH_MARKER,
GCP_VERTEX_CREDENTIALS_MARKER,
NON_ENV_SECRETREF_MARKER,
} from "./model-auth-markers.js";
import {
applyLocalNoAuthHeaderOverride,
hasUsableCustomProviderApiKey,
@ -169,6 +173,24 @@ describe("resolveUsableCustomProviderApiKey", () => {
expect(resolved).toBeNull();
});
it("does not treat the Vertex ADC marker as a usable models.json credential", () => {
const resolved = resolveUsableCustomProviderApiKey({
cfg: {
models: {
providers: {
"anthropic-vertex": {
baseUrl: "https://us-central1-aiplatform.googleapis.com",
apiKey: GCP_VERTEX_CREDENTIALS_MARKER,
models: [],
},
},
},
},
provider: "anthropic-vertex",
});
expect(resolved).toBeNull();
});
it("resolves known env marker names from process env for custom providers", () => {
const previous = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = "sk-from-env"; // pragma: allowlist secret

View File

@ -10,6 +10,7 @@ import {
normalizeOptionalSecretInput,
normalizeSecretInput,
} from "../utils/normalize-secret-input.js";
import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js";
import {
type AuthProfileStore,
ensureAuthProfileStore,
@ -21,6 +22,7 @@ import {
import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js";
import {
CUSTOM_LOCAL_AUTH_MARKER,
GCP_VERTEX_CREDENTIALS_MARKER,
isKnownEnvApiKeyMarker,
isNonSecretApiKeyMarker,
OLLAMA_LOCAL_AUTH_MARKER,
@ -428,6 +430,16 @@ export function resolveEnvApiKey(
}
return { apiKey: envKey, source: "gcloud adc" };
}
if (normalized === "anthropic-vertex") {
// Vertex AI uses GCP credentials (SA JSON or ADC), not API keys.
// Return a sentinel so the model resolver considers this provider available.
if (hasAnthropicVertexAvailableAuth(env)) {
return { apiKey: GCP_VERTEX_CREDENTIALS_MARKER, source: "gcloud adc" };
}
return null;
}
return null;
}

View File

@ -112,9 +112,15 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
"KIMI_API_KEY",
"KIMICODE_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_APPLICATION_CREDENTIALS",
"GOOGLE_CLOUD_LOCATION",
"GOOGLE_CLOUD_PROJECT",
"GOOGLE_CLOUD_PROJECT_ID",
"VENICE_API_KEY",
"VLLM_API_KEY",
"XIAOMI_API_KEY",
"ANTHROPIC_VERTEX_PROJECT_ID",
"CLOUD_ML_REGION",
// Avoid ambient AWS creds unintentionally enabling Bedrock discovery.
"AWS_ACCESS_KEY_ID",
"AWS_CONFIG_FILE",

View File

@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
@ -333,6 +334,53 @@ describe("models-config", () => {
});
});
});
it("fills anthropic-vertex apiKey with the ADC sentinel when models exist", async () => {
await withTempHome(async () => {
const adcDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-"));
const credentialsPath = path.join(adcDir, "application_default_credentials.json");
await fs.writeFile(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
const previousCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS;
try {
process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
await ensureOpenClawModelsJson({
models: {
providers: {
"anthropic-vertex": {
baseUrl: "https://us-central1-aiplatform.googleapis.com",
api: "anthropic-messages",
models: [
{
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 },
contextWindow: 200000,
maxTokens: 64000,
},
],
},
},
},
});
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string }>;
}>();
expect(parsed.providers["anthropic-vertex"]?.apiKey).toBe("gcp-vertex-credentials");
} finally {
if (previousCredentials === undefined) {
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
} else {
process.env.GOOGLE_APPLICATION_CREDENTIALS = previousCredentials;
}
await fs.rm(adcDir, { recursive: true, force: true });
}
});
});
it("merges providers by default", async () => {
await withTempHome(async () => {
await writeAgentModelsJson({

View File

@ -0,0 +1,190 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { captureEnv } from "../test-utils/env.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
describe("anthropic-vertex implicit provider", () => {
it("offers Claude models when GOOGLE_CLOUD_PROJECT_ID is set", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["GOOGLE_CLOUD_PROJECT_ID"]);
process.env.GOOGLE_CLOUD_PROJECT_ID = "vertex-project";
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["anthropic-vertex"]).toBeUndefined();
} finally {
envSnapshot.restore();
}
});
it("accepts ADC credentials when the file includes a project_id", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
const credentialsPath = join(adcDir, "application_default_credentials.json");
writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
process.env.GOOGLE_CLOUD_LOCATION = "us-east1";
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
"https://us-east1-aiplatform.googleapis.com",
);
expect(providers?.["anthropic-vertex"]?.models).toMatchObject([
{ id: "claude-opus-4-6", maxTokens: 128000, contextWindow: 1_000_000 },
{ id: "claude-sonnet-4-6", maxTokens: 128000, contextWindow: 1_000_000 },
]);
} finally {
rmSync(adcDir, { recursive: true, force: true });
envSnapshot.restore();
}
});
it("accepts ADC credentials when the file only includes a quota_project_id", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
const credentialsPath = join(adcDir, "application_default_credentials.json");
writeFileSync(credentialsPath, JSON.stringify({ quota_project_id: "vertex-quota" }), "utf8");
process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
process.env.GOOGLE_CLOUD_LOCATION = "us-east5";
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
"https://us-east5-aiplatform.googleapis.com",
);
} finally {
rmSync(adcDir, { recursive: true, force: true });
envSnapshot.restore();
}
});
it("accepts ADC credentials when project_id is resolved at runtime", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
const credentialsPath = join(adcDir, "application_default_credentials.json");
writeFileSync(credentialsPath, "{}", "utf8");
process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
process.env.GOOGLE_CLOUD_LOCATION = "europe-west4";
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
"https://europe-west4-aiplatform.googleapis.com",
);
} finally {
rmSync(adcDir, { recursive: true, force: true });
envSnapshot.restore();
}
});
it("falls back to the default region when GOOGLE_CLOUD_LOCATION is invalid", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
const credentialsPath = join(adcDir, "application_default_credentials.json");
writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
process.env.GOOGLE_CLOUD_LOCATION = "us-central1.attacker.example";
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe("https://aiplatform.googleapis.com");
} finally {
rmSync(adcDir, { recursive: true, force: true });
envSnapshot.restore();
}
});
it("uses the Vertex global endpoint when GOOGLE_CLOUD_LOCATION=global", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
const credentialsPath = join(adcDir, "application_default_credentials.json");
writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
process.env.GOOGLE_CLOUD_LOCATION = "global";
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe("https://aiplatform.googleapis.com");
} finally {
rmSync(adcDir, { recursive: true, force: true });
envSnapshot.restore();
}
});
it("accepts explicit metadata auth opt-in without local credential files", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["ANTHROPIC_VERTEX_USE_GCP_METADATA", "GOOGLE_CLOUD_LOCATION"]);
process.env.ANTHROPIC_VERTEX_USE_GCP_METADATA = "true";
process.env.GOOGLE_CLOUD_LOCATION = "us-east5";
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
"https://us-east5-aiplatform.googleapis.com",
);
} finally {
envSnapshot.restore();
}
});
it("merges the bundled catalog into explicit anthropic-vertex provider overrides", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
const credentialsPath = join(adcDir, "application_default_credentials.json");
writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
process.env.GOOGLE_CLOUD_LOCATION = "us-east5";
try {
const providers = await resolveImplicitProvidersForTest({
agentDir,
config: {
models: {
providers: {
"anthropic-vertex": {
baseUrl: "https://europe-west4-aiplatform.googleapis.com",
headers: { "x-test-header": "1" },
},
},
},
} as unknown as OpenClawConfig,
});
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
"https://europe-west4-aiplatform.googleapis.com",
);
expect(providers?.["anthropic-vertex"]?.headers).toEqual({ "x-test-header": "1" });
expect(providers?.["anthropic-vertex"]?.models?.map((model) => model.id)).toEqual([
"claude-opus-4-6",
"claude-sonnet-4-6",
]);
} finally {
rmSync(adcDir, { recursive: true, force: true });
envSnapshot.restore();
}
});
it("does not accept generic Kubernetes env without a GCP ADC signal", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KUBERNETES_SERVICE_HOST", "GOOGLE_CLOUD_LOCATION"]);
process.env.KUBERNETES_SERVICE_HOST = "10.0.0.1";
process.env.GOOGLE_CLOUD_LOCATION = "us-east5";
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["anthropic-vertex"]).toBeUndefined();
} finally {
envSnapshot.restore();
}
});
});

View File

@ -1,3 +1,7 @@
export {
ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
buildAnthropicVertexProvider,
} from "../../extensions/anthropic-vertex/provider-catalog.js";
export {
buildBytePlusCodingProvider,
buildBytePlusProvider,

View File

@ -1,3 +1,4 @@
import { buildAnthropicVertexProvider } from "../../extensions/anthropic-vertex/provider-catalog.js";
import {
QIANFAN_BASE_URL,
QIANFAN_DEFAULT_MODEL_ID,
@ -7,6 +8,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js";
import { isRecord } from "../utils.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js";
@ -552,7 +554,10 @@ export function normalizeProviders(params: {
mutated = true;
normalizedProvider = { ...normalizedProvider, apiKey };
} else {
const fromEnv = resolveEnvApiKeyVarName(normalizedKey, env);
const fromEnv =
normalizedKey === "anthropic-vertex"
? resolveEnvApiKey(normalizedKey, env)?.apiKey
: resolveEnvApiKeyVarName(normalizedKey, env);
const apiKey = fromEnv ?? profileApiKey?.apiKey;
if (apiKey?.trim()) {
if (profileApiKey && profileApiKey.source !== "plaintext") {
@ -812,9 +817,34 @@ export async function resolveImplicitProviders(
: implicitBedrock;
}
const implicitAnthropicVertex = resolveImplicitAnthropicVertexProvider({ env });
if (implicitAnthropicVertex) {
const existing = providers["anthropic-vertex"];
providers["anthropic-vertex"] = existing
? {
...implicitAnthropicVertex,
...existing,
models:
Array.isArray(existing.models) && existing.models.length > 0
? existing.models
: implicitAnthropicVertex.models,
}
: implicitAnthropicVertex;
}
return providers;
}
export function resolveImplicitAnthropicVertexProvider(params: {
env?: NodeJS.ProcessEnv;
}): ProviderConfig | null {
const env = params.env ?? process.env;
if (!hasAnthropicVertexAvailableAuth(env)) {
return null;
}
return buildAnthropicVertexProvider({ env });
}
export async function resolveImplicitBedrockProvider(params: {
agentDir: string;
config?: OpenClawConfig;

View File

@ -36,6 +36,7 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { resolveOpenClawAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js";
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
import { createAnthropicVertexStreamFnForModel } from "../../anthropic-vertex-stream.js";
import {
analyzeBootstrapBudget,
buildBootstrapPromptWarning,
@ -2196,6 +2197,10 @@ export async function runEmbeddedAttempt(
log.warn(`[ws-stream] no API key for provider=${params.provider}; using HTTP transport`);
activeSession.agent.streamFn = streamSimple;
}
} else if (params.model.provider === "anthropic-vertex") {
// Anthropic Vertex AI: inject AnthropicVertex client into pi-ai's
// streamAnthropic for GCP IAM auth instead of Anthropic API keys.
activeSession.agent.streamFn = createAnthropicVertexStreamFnForModel(params.model);
} else {
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
activeSession.agent.streamFn = streamSimple;

View File

@ -69,6 +69,18 @@ describe("resolveProviderCapabilities", () => {
geminiThoughtSignatureModelHints: [],
dropThinkingBlockModelHints: ["claude"],
});
expect(resolveProviderCapabilities("anthropic-vertex")).toEqual({
anthropicToolSchemaMode: "native",
anthropicToolChoiceMode: "native",
providerFamily: "anthropic",
preserveAnthropicThinkingSignatures: true,
openAiCompatTurnValidation: true,
geminiThoughtSignatureSanitization: false,
transcriptToolCallIdMode: "default",
transcriptToolCallIdModelHints: [],
geminiThoughtSignatureModelHints: [],
dropThinkingBlockModelHints: ["claude"],
});
expect(resolveProviderCapabilities("amazon-bedrock")).toEqual({
anthropicToolSchemaMode: "native",
anthropicToolChoiceMode: "native",
@ -136,6 +148,7 @@ describe("resolveProviderCapabilities", () => {
it("tracks provider families and model-specific transcript quirks in the registry", () => {
expect(isOpenAiProviderFamily("openai")).toBe(true);
expect(isAnthropicProviderFamily("anthropic-vertex")).toBe(true);
expect(isAnthropicProviderFamily("amazon-bedrock")).toBe(true);
expect(
shouldDropThinkingBlocksForModel({
@ -143,6 +156,12 @@ describe("resolveProviderCapabilities", () => {
modelId: "claude-opus-4-6",
}),
).toBe(true);
expect(
shouldDropThinkingBlocksForModel({
provider: "anthropic-vertex",
modelId: "claude-sonnet-4-6",
}),
).toBe(true);
expect(
shouldDropThinkingBlocksForModel({
provider: "amazon-bedrock",

View File

@ -35,6 +35,10 @@ const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = {
};
const CORE_PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
"anthropic-vertex": {
providerFamily: "anthropic",
dropThinkingBlockModelHints: ["claude"],
},
"amazon-bedrock": {
providerFamily: "anthropic",
dropThinkingBlockModelHints: ["claude"],

View File

@ -41,6 +41,7 @@ export {
CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
resolveCloudflareAiGatewayBaseUrl,
} from "../agents/cloudflare-ai-gateway.js";
export { resolveAnthropicVertexRegion } from "../agents/anthropic-vertex-provider.js";
export {
discoverHuggingfaceModels,
HUGGINGFACE_BASE_URL,