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:
parent
42ca447189
commit
6e20c4baa0
65
extensions/anthropic-vertex/provider-catalog.ts
Normal file
65
extensions/anthropic-vertex/provider-catalog.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@ -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
208
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
124
src/agents/anthropic-vertex-provider.ts
Normal file
124
src/agents/anthropic-vertex-provider.ts
Normal 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);
|
||||
}
|
||||
221
src/agents/anthropic-vertex-stream.test.ts
Normal file
221
src/agents/anthropic-vertex-stream.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
137
src/agents/anthropic-vertex-stream.ts
Normal file
137
src/agents/anthropic-vertex-stream.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@ -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", () => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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({
|
||||
|
||||
190
src/agents/models-config.providers.anthropic-vertex.test.ts
Normal file
190
src/agents/models-config.providers.anthropic-vertex.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,3 +1,7 @@
|
||||
export {
|
||||
ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
|
||||
buildAnthropicVertexProvider,
|
||||
} from "../../extensions/anthropic-vertex/provider-catalog.js";
|
||||
export {
|
||||
buildBytePlusCodingProvider,
|
||||
buildBytePlusProvider,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user