feat(llm): add Vertex AI Claude adapter

This commit is contained in:
Bryan Helmkamp 2026-05-15 16:52:48 -04:00
parent ba27bcdd57
commit af846e1a8c
No known key found for this signature in database
24 changed files with 1518 additions and 211 deletions

290
Cargo.lock generated
View file

@ -29,7 +29,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"generic-array",
]
@ -41,7 +41,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
@ -601,6 +601,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@ -737,6 +746,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.44"
@ -757,7 +777,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"inout",
]
@ -829,6 +849,12 @@ dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
[[package]]
name = "cmsketch"
version = "0.2.4"
@ -904,10 +930,10 @@ dependencies = [
"aes-gcm",
"base64",
"hkdf",
"hmac",
"hmac 0.12.1",
"percent-encoding",
"rand 0.8.6",
"sha2",
"sha2 0.10.9",
"subtle",
"time",
"version_check",
@ -984,6 +1010,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -1123,6 +1158,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
dependencies = [
"hybrid-array",
]
[[package]]
name = "csv"
version = "1.4.0"
@ -1153,6 +1197,15 @@ dependencies = [
"cipher",
]
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
name = "darling"
version = "0.14.4"
@ -1253,7 +1306,7 @@ name = "daytona-api-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738"
dependencies = [
"reqwest 0.13.2",
"reqwest 0.13.3",
"reqwest-middleware",
"serde",
"serde_json",
@ -1270,7 +1323,7 @@ dependencies = [
"daytona-api-client",
"daytona-toolbox-client",
"futures-util",
"reqwest 0.13.2",
"reqwest 0.13.3",
"reqwest-middleware",
"rustls",
"serde",
@ -1287,7 +1340,7 @@ name = "daytona-toolbox-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738"
dependencies = [
"reqwest 0.13.2",
"reqwest 0.13.3",
"reqwest-middleware",
"serde",
"serde_json",
@ -1387,11 +1440,22 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"crypto-common 0.2.1",
"ctutils",
]
[[package]]
name = "dirs"
version = "6.0.0"
@ -1652,7 +1716,7 @@ dependencies = [
"progenitor",
"progenitor-client",
"regress",
"reqwest 0.13.2",
"reqwest 0.13.3",
"serde",
"serde_json",
"serde_yaml",
@ -1781,7 +1845,7 @@ dependencies = [
"progenitor-client",
"rand 0.9.4",
"regex",
"reqwest 0.13.2",
"reqwest 0.13.3",
"ring",
"rustls",
"scopeguard",
@ -1789,7 +1853,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"sha2",
"sha2 0.10.9",
"shlex",
"temp-env",
"tempfile",
@ -1994,7 +2058,7 @@ version = "0.234.0-nightly.0"
dependencies = [
"fabro-static",
"http",
"reqwest 0.13.2",
"reqwest 0.13.3",
"thiserror 2.0.18",
]
@ -2045,6 +2109,7 @@ dependencies = [
"fabro-test",
"fabro-util",
"futures",
"google-cloud-auth",
"http",
"httpmock",
"insta",
@ -2160,7 +2225,7 @@ dependencies = [
"rand 0.9.4",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"tokio",
"tracing",
]
@ -2287,7 +2352,7 @@ dependencies = [
"globset",
"hex",
"hkdf",
"hmac",
"hmac 0.12.1",
"http-body-util",
"httpmock",
"ipnet",
@ -2303,7 +2368,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"sha2",
"sha2 0.10.9",
"strum",
"tempfile",
"thiserror 2.0.18",
@ -2470,7 +2535,7 @@ dependencies = [
"ipnet",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"strum",
"tempfile",
"toml 0.8.23",
@ -2573,7 +2638,7 @@ dependencies = [
"scopeguard",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"shlex",
"tempfile",
"thiserror 2.0.18",
@ -3064,6 +3129,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasip2",
"wasip3",
]
@ -3130,6 +3196,84 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "google-cloud-auth"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd4f8c914f230834828771125168eaa39bc6602e32cb0316ceeff2add10d449"
dependencies = [
"async-trait",
"aws-lc-rs",
"base64",
"bytes",
"chrono",
"google-cloud-gax",
"hex",
"hmac 0.13.0",
"http",
"jsonwebtoken",
"reqwest 0.13.3",
"rustc_version",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.18",
"time",
"tokio",
"url",
]
[[package]]
name = "google-cloud-gax"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d597e9e4758fc778a60d8c28a8677629675ae40d8652ec000ae5f53f5ae7ec"
dependencies = [
"base64",
"bytes",
"futures",
"google-cloud-rpc",
"google-cloud-wkt",
"http",
"pin-project",
"rand 0.10.1",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "google-cloud-rpc"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10b177796075b7bfc02bf2e405db665ee850a924fa44cedfc5282b473c5ab203"
dependencies = [
"bytes",
"google-cloud-wkt",
"serde",
"serde_json",
"serde_with",
]
[[package]]
name = "google-cloud-wkt"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5daa3084991800bcc5333d7e77bb19259a02b34ee35f35c27b49d602732306e"
dependencies = [
"base64",
"bytes",
"serde",
"serde_json",
"serde_with",
"thiserror 2.0.18",
"time",
"url",
]
[[package]]
name = "graphviz-sys"
version = "0.1.0"
@ -3248,7 +3392,7 @@ version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
"hmac 0.12.1",
]
[[package]]
@ -3257,7 +3401,16 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest 0.11.3",
]
[[package]]
@ -3378,6 +3531,15 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "hybrid-array"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.8.1"
@ -3929,9 +4091,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.182"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libflate"
@ -4178,7 +4340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
"digest 0.10.7",
]
[[package]]
@ -4290,9 +4452,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
@ -5120,7 +5282,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
@ -5279,7 +5441,7 @@ dependencies = [
"bytes",
"futures-core",
"percent-encoding",
"reqwest 0.13.2",
"reqwest 0.13.3",
"serde",
"serde_json",
"serde_urlencoded",
@ -5452,6 +5614,17 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.1",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
@ -5490,6 +5663,12 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xoshiro"
version = "0.6.0"
@ -5657,9 +5836,9 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.13.2"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
dependencies = [
"base64",
"bytes",
@ -5712,7 +5891,7 @@ dependencies = [
"anyhow",
"async-trait",
"http",
"reqwest 0.13.2",
"reqwest 0.13.3",
"serde",
"thiserror 2.0.18",
"tower-service",
@ -5752,7 +5931,7 @@ dependencies = [
"pastey",
"pin-project-lite",
"process-wrap",
"reqwest 0.13.2",
"reqwest 0.13.3",
"rmcp-macros",
"schemars 1.2.1",
"serde",
@ -5810,7 +5989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"globset",
"sha2",
"sha2 0.10.9",
"walkdir",
]
@ -5869,9 +6048,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"log",
@ -6343,8 +6522,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
@ -6354,8 +6533,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@ -6537,12 +6727,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -6964,9 +7154,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.49.0"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
@ -6981,9 +7171,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.6.0"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@ -7308,7 +7498,7 @@ dependencies = [
"chrono",
"fabro-http",
"jsonwebtoken",
"reqwest 0.13.2",
"reqwest 0.13.3",
"serde",
"serde_json",
"tempfile",
@ -7348,9 +7538,9 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "typify"
@ -7488,7 +7678,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"subtle",
]

View file

@ -211,7 +211,7 @@ fn summarizer_model_id(provider: Provider) -> ModelHandle {
model: match provider {
Provider::OpenAi | Provider::OpenAiCompatible => "gpt-4o-mini",
Provider::Gemini => "gemini-2.0-flash",
Provider::Anthropic => "claude-haiku-4-5",
Provider::Anthropic | Provider::Vertex => "claude-haiku-4-5",
Provider::Kimi => "kimi-k2.5",
Provider::Zai => "glm-4.7",
Provider::Minimax => "minimax-m2.5",
@ -250,9 +250,11 @@ fn build_profile(
Provider::Gemini => {
Box::new(GeminiProfile::with_summarizer(model, summarizer).with_catalog(catalog))
}
Provider::Anthropic => {
Box::new(AnthropicProfile::with_summarizer(model, summarizer).with_catalog(catalog))
}
Provider::Anthropic | Provider::Vertex => Box::new(
AnthropicProfile::with_summarizer(model, summarizer)
.with_provider(provider)
.with_catalog(catalog),
),
}
}

View file

@ -19,7 +19,7 @@ pub async fn discover_memory(
let directories = build_directory_walk(git_root, working_dir);
let candidate_filenames: Vec<&str> = match provider {
Provider::Anthropic => vec!["AGENTS.md", "CLAUDE.md"],
Provider::Anthropic | Provider::Vertex => vec!["AGENTS.md", "CLAUDE.md"],
Provider::OpenAi
| Provider::Kimi
| Provider::Zai

View file

@ -33,9 +33,11 @@ fn profile_context_window_matches_catalog_for_default_models() {
Provider::Gemini => {
Box::new(GeminiProfile::new(model).with_catalog(Arc::clone(&catalog)))
}
Provider::Anthropic => {
Box::new(AnthropicProfile::new(model).with_catalog(Arc::clone(&catalog)))
}
Provider::Anthropic | Provider::Vertex => Box::new(
AnthropicProfile::new(model)
.with_provider(provider)
.with_catalog(Arc::clone(&catalog)),
),
};
assert_eq!(

View file

@ -43,8 +43,8 @@ fn summarizer_model_id(provider: Provider) -> ModelHandle {
provider: Provider::Gemini.id(),
model: "gemini-3-flash-preview".to_string(),
},
Provider::Anthropic => ModelHandle::ByName {
provider: Provider::Anthropic.id(),
Provider::Anthropic | Provider::Vertex => ModelHandle::ByName {
provider: provider.id(),
model: "claude-haiku-4-5".to_string(),
},
}
@ -60,7 +60,9 @@ fn build_summarizer(provider: Provider, client: &Client) -> WebFetchSummarizer {
fn build_profile(provider: Provider, model: &str, client: &Client) -> Box<dyn AgentProfile> {
let summarizer = Some(build_summarizer(provider, client));
match provider {
Provider::Anthropic => Box::new(AnthropicProfile::with_summarizer(model, summarizer)),
Provider::Anthropic | Provider::Vertex => {
Box::new(AnthropicProfile::with_summarizer(model, summarizer).with_provider(provider))
}
Provider::OpenAi => Box::new(OpenAiProfile::with_summarizer(model, summarizer)),
Provider::Kimi
| Provider::Zai
@ -93,10 +95,10 @@ async fn make_session(
let sub_profile: Arc<dyn AgentProfile> = {
let summarizer = Some(build_summarizer(provider, &factory_client));
match provider {
Provider::Anthropic => Arc::new(AnthropicProfile::with_summarizer(
&factory_model,
summarizer,
)),
Provider::Anthropic | Provider::Vertex => Arc::new(
AnthropicProfile::with_summarizer(&factory_model, summarizer)
.with_provider(provider),
),
Provider::OpenAi => {
Arc::new(OpenAiProfile::with_summarizer(&factory_model, summarizer))
}

View file

@ -2,7 +2,9 @@ use std::sync::Arc;
use async_trait::async_trait;
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter};
use fabro_model::{
AdapterAuthStrategy, Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter,
};
use fabro_static::EnvVars;
use crate::credential_source::{CredentialSource, ResolvedCredentials};
@ -43,22 +45,39 @@ impl EnvCredentialSource {
self.lookup(name)
});
if key.is_none() && provider.credentials.is_empty() && provider.extra_headers.is_empty() {
let adapter_auth_strategy =
adapter::get(&provider.adapter).map(|adapter| adapter.auth_strategy);
let adapter_managed_auth = matches!(
adapter_auth_strategy,
Some(AdapterAuthStrategy::GoogleApplicationDefault)
);
let adapter_managed_configured =
adapter_managed_auth && self.adapter_managed_configured(provider);
if key.is_none()
&& provider.credentials.is_empty()
&& provider.extra_headers.is_empty()
&& !adapter_managed_configured
{
return Ok(None);
}
let extra_headers = self.resolved_extra_headers(provider)?;
if key.is_none() && (!provider.credentials.is_empty() || extra_headers.is_empty()) {
if key.is_none()
&& !adapter_managed_configured
&& (!provider.credentials.is_empty() || extra_headers.is_empty())
{
return Ok(None);
}
let auth_header = key.map(|key| {
let policy = adapter::get(&provider.adapter)
.map_or(fabro_model::ApiKeyHeaderPolicy::Bearer, |adapter| {
adapter.api_key_header
});
build_api_key_header(policy, key)
let auth_header = key.and_then(|key| {
let policy = adapter_auth_strategy.and_then(|strategy| match strategy {
AdapterAuthStrategy::ApiKey(policy) => Some(policy),
AdapterAuthStrategy::GoogleApplicationDefault => None,
});
policy.map(|policy| build_api_key_header(policy, key))
});
let mut cred = ApiCredential {
@ -84,13 +103,27 @@ impl EnvCredentialSource {
cred.extra_headers
.insert("originator".to_string(), "fabro".to_string());
}
} else if provider.id == Provider::Vertex.id() {
cred.project_id = self.vertex_project_id();
}
Ok(Some(cred))
}
fn adapter_managed_configured(&self, provider: &CatalogProvider) -> bool {
provider.id == Provider::Vertex.id() && self.vertex_project_id().is_some()
}
fn vertex_project_id(&self) -> Option<String> {
self.lookup(EnvVars::ANTHROPIC_VERTEX_PROJECT_ID)
.or_else(|| self.lookup(EnvVars::GOOGLE_CLOUD_PROJECT))
.or_else(|| self.lookup(EnvVars::GCLOUD_PROJECT))
.or_else(|| self.lookup(EnvVars::GCP_PROJECT))
}
fn env_base_url(&self, provider: &ProviderId) -> Option<String> {
match Provider::from_id(provider) {
Some(Provider::Anthropic) => self.lookup(EnvVars::ANTHROPIC_BASE_URL),
Some(Provider::Vertex) => self.lookup(EnvVars::ANTHROPIC_VERTEX_BASE_URL),
Some(Provider::OpenAi) => self.lookup(EnvVars::OPENAI_BASE_URL),
Some(Provider::Gemini) => self.lookup(EnvVars::GEMINI_BASE_URL),
Some(Provider::OpenAiCompatible) => self.lookup(EnvVars::OPENAI_COMPATIBLE_BASE_URL),
@ -164,6 +197,10 @@ impl CredentialSource for EnvCredentialSource {
.any(|credential_ref| {
matches!(credential_ref, CredentialRef::Env(name) if self.lookup(name).is_some())
})
|| matches!(
adapter::get(&provider.adapter).map(|adapter| adapter.auth_strategy),
Some(AdapterAuthStrategy::GoogleApplicationDefault)
) && self.adapter_managed_configured(provider)
|| (!provider.extra_headers.is_empty()
&& provider.credentials.is_empty()
&& self.resolved_extra_headers(provider).is_ok())
@ -356,6 +393,32 @@ effort = true
);
}
#[tokio::test]
async fn resolve_registers_vertex_without_api_key_material() {
let source = test_source(&[
("ANTHROPIC_VERTEX_PROJECT_ID", "vertex-project"),
(
"ANTHROPIC_VERTEX_BASE_URL",
"https://vertex.example.test/v1",
),
]);
let catalog = default_catalog();
let resolved = source.resolve(&catalog).await.unwrap();
let credential = resolved
.credentials
.iter()
.find(|credential| credential.provider == Provider::Vertex.id())
.expect("vertex should be adapter-managed");
assert!(credential.auth_header.is_none());
assert_eq!(
credential.base_url.as_deref(),
Some("https://vertex.example.test/v1")
);
assert_eq!(credential.project_id.as_deref(), Some("vertex-project"));
}
#[tokio::test]
async fn resolve_reports_missing_required_header() {
let catalog = catalog_with(

View file

@ -3,7 +3,8 @@ use std::sync::Arc;
use fabro_model::catalog::CatalogProvider;
use fabro_model::{
ApiKeyHeaderPolicy, Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter,
AdapterAuthStrategy, ApiKeyHeaderPolicy, Catalog, CredentialRef, HeaderValueRef, Provider,
ProviderId, adapter,
};
use fabro_static::EnvVars;
use fabro_vault::Vault;
@ -88,11 +89,25 @@ fn default_auth_header_for_provider(provider: &ProviderId, key: String) -> ApiKe
}
fn auth_header_for_catalog_provider(provider: &CatalogProvider, key: String) -> ApiKeyHeader {
let policy = adapter::get(&provider.adapter)
.map_or(ApiKeyHeaderPolicy::Bearer, |adapter| adapter.api_key_header);
let policy =
adapter::get(&provider.adapter).map_or(ApiKeyHeaderPolicy::Bearer, |adapter| match adapter
.auth_strategy
{
AdapterAuthStrategy::ApiKey(policy) => policy,
AdapterAuthStrategy::GoogleApplicationDefault => ApiKeyHeaderPolicy::Bearer,
});
build_api_key_header(policy, key)
}
fn adapter_manages_api_auth(provider: &CatalogProvider) -> bool {
adapter::get(&provider.adapter).is_some_and(|adapter| {
matches!(
adapter.auth_strategy,
AdapterAuthStrategy::GoogleApplicationDefault
)
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliCredential {
pub env_vars: HashMap<String, String>,
@ -166,6 +181,15 @@ impl CredentialResolver {
let Some(catalog_provider) = catalog.provider(&provider_id) else {
return Err(ResolveError::NotConfigured(provider_id));
};
if usage == CredentialUsage::ApiRequest && adapter_manages_api_auth(catalog_provider) {
let vault = self.vault.read().await;
if !self.adapter_managed_auth_configured(&vault, &provider_id) {
return Err(ResolveError::NotConfigured(provider_id));
}
return self
.adapter_managed_api_credential(&vault, catalog_provider, catalog)
.map(ResolvedCredential::Api);
}
let initial_credential = {
let vault = self.vault.read().await;
self.find_credential(&vault, catalog_provider, usage)?
@ -280,8 +304,13 @@ impl CredentialResolver {
&& self
.resolved_extra_headers_for_catalog(vault, &provider.id, catalog)
.is_ok();
let has_adapter_managed_auth = adapter_manages_api_auth(provider)
&& self.adapter_managed_auth_configured(vault, &provider.id);
has_declared_credential || has_provider_id_credential || has_header_only_credentials
has_declared_credential
|| has_provider_id_credential
|| has_header_only_credentials
|| has_adapter_managed_auth
}
fn credential_from_ref(
@ -316,6 +345,9 @@ impl CredentialResolver {
Some(Provider::Anthropic) => {
self.lookup_env_or_vault(vault, EnvVars::ANTHROPIC_BASE_URL)
}
Some(Provider::Vertex) => {
self.lookup_env_or_vault(vault, EnvVars::ANTHROPIC_VERTEX_BASE_URL)
}
Some(Provider::OpenAi) => self.lookup_env_or_vault(vault, EnvVars::OPENAI_BASE_URL),
Some(Provider::Gemini) => self.lookup_env_or_vault(vault, EnvVars::GEMINI_BASE_URL),
Some(Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception)
@ -407,6 +439,42 @@ impl CredentialResolver {
}
}
fn adapter_managed_api_credential(
&self,
vault: &Vault,
provider: &CatalogProvider,
catalog: &Catalog,
) -> Result<ApiCredential, ResolveError> {
let project_id = if provider.id == Provider::Vertex.id() {
self.lookup_env_or_vault(vault, EnvVars::ANTHROPIC_VERTEX_PROJECT_ID)
.or_else(|| self.lookup_env_or_vault(vault, EnvVars::GOOGLE_CLOUD_PROJECT))
.or_else(|| self.lookup_env_or_vault(vault, EnvVars::GCLOUD_PROJECT))
.or_else(|| self.lookup_env_or_vault(vault, EnvVars::GCP_PROJECT))
} else {
None
};
Ok(ApiCredential {
provider: provider.id.clone(),
auth_header: None,
extra_headers: self.resolved_extra_headers_for_catalog(vault, &provider.id, catalog)?,
base_url: self.provider_base_url_for_catalog(vault, &provider.id, catalog),
codex_mode: false,
org_id: None,
project_id,
})
}
fn adapter_managed_auth_configured(&self, vault: &Vault, provider: &ProviderId) -> bool {
provider == &Provider::Vertex.id()
&& self
.lookup_env_or_vault(vault, EnvVars::ANTHROPIC_VERTEX_PROJECT_ID)
.or_else(|| self.lookup_env_or_vault(vault, EnvVars::GOOGLE_CLOUD_PROJECT))
.or_else(|| self.lookup_env_or_vault(vault, EnvVars::GCLOUD_PROJECT))
.or_else(|| self.lookup_env_or_vault(vault, EnvVars::GCP_PROJECT))
.is_some()
}
pub async fn header_only_api_credential(
&self,
provider: &CatalogProvider,
@ -749,6 +817,37 @@ effort = false
);
}
#[tokio::test]
async fn vertex_resolves_as_adapter_managed_api_credential_without_secret_material() {
let dir = tempfile::tempdir().unwrap();
let vault = Vault::load(dir.path().join("secrets.json")).unwrap();
let resolver = test_resolver(
vault,
Arc::new(|name| match name {
"ANTHROPIC_VERTEX_PROJECT_ID" => Some("vertex-project".to_string()),
"ANTHROPIC_VERTEX_BASE_URL" => Some("https://vertex.example.test/v1".to_string()),
_ => None,
}),
);
let catalog = default_catalog();
let ResolvedCredential::Api(api) = resolver
.resolve(Provider::Vertex, CredentialUsage::ApiRequest, &catalog)
.await
.unwrap()
else {
panic!("expected api credential");
};
assert_eq!(api.provider, Provider::Vertex.id());
assert!(api.auth_header.is_none());
assert_eq!(api.project_id.as_deref(), Some("vertex-project"));
assert_eq!(
api.base_url.as_deref(),
Some("https://vertex.example.test/v1")
);
}
#[tokio::test]
async fn openai_codex_cli_credential_includes_login_command_and_account_id() {
let dir = tempfile::tempdir().unwrap();

View file

@ -34,6 +34,9 @@ use tokio::time::timeout;
pub(crate) fn provider_key_url(provider: Provider) -> &'static str {
match provider {
Provider::Anthropic => "https://console.anthropic.com/settings/keys",
Provider::Vertex => {
"https://cloud.google.com/docs/authentication/application-default-credentials"
}
Provider::OpenAi => "https://platform.openai.com/api-keys",
Provider::Gemini => "https://aistudio.google.com/apikey",
Provider::Kimi => "https://platform.moonshot.cn/console/api-keys",

View file

@ -38,6 +38,7 @@ fabro-model = { path = "../fabro-model" }
fabro-redact.workspace = true
fabro-static.workspace = true
fabro-util = { path = "../fabro-util" }
google-cloud-auth = "1.10.0"
[dev-dependencies]
http = "1"

View file

@ -97,6 +97,27 @@ fn build_anthropic(config: AdapterConfig) -> Arc<dyn ProviderAdapter> {
Arc::new(build_anthropic_adapter(config))
}
fn build_vertex_adapter(config: AdapterConfig) -> providers::VertexAdapter {
let mut adapter = providers::VertexAdapter::new().with_name(config.provider_id.clone());
if let Some(base_url) = config.base_url {
adapter = adapter.with_base_url(base_url);
}
if let Some(project_id) = config.project_id {
adapter = adapter.with_project_id(project_id);
}
if !config.extra_headers.is_empty() {
adapter = adapter.with_default_headers(config.extra_headers);
}
if let Some(catalog) = config.catalog {
adapter = adapter.with_catalog(catalog);
}
adapter
}
fn build_vertex(config: AdapterConfig) -> Arc<dyn ProviderAdapter> {
Arc::new(build_vertex_adapter(config))
}
fn build_openai_adapter(config: AdapterConfig) -> providers::OpenAiAdapter {
let mut adapter = providers::OpenAiAdapter::new_optional_auth(auth_value_optional(
config.auth_header.as_ref(),
@ -179,6 +200,7 @@ fn build_openai_compatible(config: AdapterConfig) -> Arc<dyn ProviderAdapter> {
/// `factory_for` and `registered_keys` derive from this table.
const FACTORIES: &[(&str, AdapterFactory)] = &[
(model_adapter::ANTHROPIC.key, build_anthropic),
(model_adapter::VERTEX.key, build_vertex),
(model_adapter::OPENAI.key, build_openai),
(model_adapter::GEMINI.key, build_gemini),
(

View file

@ -695,6 +695,7 @@ enum ContentBlockKind {
/// Accumulated state across SSE events during streaming.
struct StreamAccumulator {
provider_name: String,
id: String,
model: String,
content_parts: Vec<ContentPart>,
@ -713,8 +714,9 @@ struct StreamAccumulator {
}
impl StreamAccumulator {
fn new(rate_limit: Option<RateLimitInfo>) -> Self {
fn new(provider_name: impl Into<String>, rate_limit: Option<RateLimitInfo>) -> Self {
Self {
provider_name: provider_name.into(),
id: String::new(),
model: String::new(),
content_parts: Vec::new(),
@ -735,7 +737,7 @@ impl StreamAccumulator {
Response {
id: self.id.clone(),
model: self.model.clone(),
provider: "anthropic".to_string(),
provider: self.provider_name.clone(),
message: Message {
role: Role::Assistant,
content: content_parts,
@ -1018,13 +1020,15 @@ struct SseReaderState {
impl SseReaderState {
fn new(
http_resp: fabro_http::Response,
provider_name: impl Into<String>,
rate_limit: Option<RateLimitInfo>,
json_schema_mode: bool,
stream_read_timeout: Option<std::time::Duration>,
) -> Self {
let provider_name = provider_name.into();
Self {
line_reader: super::common::LineReader::new(http_resp, stream_read_timeout),
accumulator: StreamAccumulator::new(rate_limit),
accumulator: StreamAccumulator::new(provider_name, rate_limit),
pending_events: std::collections::VecDeque::new(),
json_schema_mode,
}
@ -1104,11 +1108,16 @@ fn merge_provider_options(
/// Build an Anthropic API request and HTTP request builder for the given
/// unified request.
async fn build_api_request(
adapter: &Adapter,
struct ApiRequestParts {
api_request: ApiRequest,
beta_header: Option<String>,
}
async fn build_api_request_parts(
catalog: Option<&Catalog>,
request: &Request,
stream: bool,
) -> (ApiRequest, fabro_http::RequestBuilder) {
) -> ApiRequestParts {
let (system, other_messages) = extract_system_prompt(&request.messages);
let mut api_messages = translate_messages(&other_messages).await;
@ -1128,7 +1137,7 @@ async fn build_api_request(
request.tools.as_ref().map(|t| translate_tools(t))
};
let model_info = common::catalog_model(adapter.catalog.as_deref(), &request.model);
let model_info = common::catalog_model(catalog, &request.model);
let supports_prompt_cache = model_info.is_some_and(|m| m.features.prompt_cache);
let auto_cache =
supports_prompt_cache && is_auto_cache_enabled(request.provider_options.as_ref());
@ -1216,7 +1225,7 @@ async fn build_api_request(
let is_fast = request.speed == Some(Speed::Fast);
let api_request = ApiRequest {
model: common::api_model_id(adapter.catalog.as_deref(), &request.model),
model: common::api_model_id(catalog, &request.model),
messages: api_messages,
max_tokens: resolved_max_tokens,
system: system_value,
@ -1236,6 +1245,26 @@ async fn build_api_request(
stream,
};
let include_1m_context = model_info.is_some_and(|m| m.context_window() >= 1_000_000);
let beta_header = build_beta_header(
request.provider_options.as_ref(),
auto_cache,
is_fast,
include_1m_context,
);
ApiRequestParts {
api_request,
beta_header,
}
}
async fn build_api_request(
adapter: &Adapter,
request: &Request,
stream: bool,
) -> (ApiRequest, fabro_http::RequestBuilder) {
let parts = build_api_request_parts(adapter.catalog.as_deref(), request, stream).await;
let url = adapter.messages_url();
let mut req_builder = adapter.http.client.post(&url);
// Apply default_headers first so adapter-specific headers can override
@ -1249,13 +1278,7 @@ async fn build_api_request(
}
req_builder = req_builder.header("anthropic-version", "2023-06-01");
let include_1m_context = model_info.is_some_and(|m| m.context_window() >= 1_000_000);
if let Some(beta_str) = build_beta_header(
request.provider_options.as_ref(),
auto_cache,
is_fast,
include_1m_context,
) {
if let Some(beta_str) = parts.beta_header.clone() {
req_builder = req_builder.header("anthropic-beta", beta_str);
}
} else if let Some(api_key) = &adapter.http.api_key {
@ -1263,10 +1286,129 @@ async fn build_api_request(
}
let req_builder = req_builder.json(&merge_provider_options(
&api_request,
&parts.api_request,
request.provider_options.as_ref(),
));
(api_request, req_builder)
(parts.api_request, req_builder)
}
pub(crate) async fn build_vertex_request_body(
catalog: Option<&Catalog>,
request: &Request,
stream: bool,
) -> (String, serde_json::Value) {
let parts = build_api_request_parts(catalog, request, stream).await;
let model = parts.api_request.model.clone();
let mut body = merge_provider_options(&parts.api_request, request.provider_options.as_ref());
if let Some(object) = body.as_object_mut() {
object.remove("model");
object.insert(
"anthropic_version".to_string(),
serde_json::Value::String("vertex-2023-10-16".to_string()),
);
}
(model, body)
}
pub(crate) fn parse_response_body(
body: &str,
headers: &fabro_http::HeaderMap,
request: &Request,
provider_name: &str,
) -> Result<Response, Error> {
let api_resp: ApiResponse = serde_json::from_str(body)
.map_err(|e| Error::network(format!("failed to parse {provider_name} response: {e}"), e))?;
let content_parts: Vec<ContentPart> = api_resp
.content
.iter()
.filter_map(parse_content_block)
.collect();
let content_parts = if uses_json_schema_format(request) {
convert_synthetic_tool_to_text(content_parts)
} else {
content_parts
};
let finish_reason = if uses_json_schema_format(request) {
FinishReason::Stop
} else {
map_finish_reason(api_resp.stop_reason.as_deref())
};
Ok(Response {
id: api_resp.id,
model: api_resp.model,
provider: provider_name.to_string(),
message: Message {
role: Role::Assistant,
content: content_parts,
name: None,
tool_call_id: None,
},
finish_reason,
usage: token_counts_from_api_usage(&api_resp.usage),
raw: serde_json::from_str(body).ok(),
warnings: vec![],
rate_limit: parse_rate_limit_headers(headers),
})
}
pub(crate) fn stream_events_from_response(
http_resp: fabro_http::Response,
request: &Request,
provider_name: impl Into<String>,
stream_read_timeout: Option<std::time::Duration>,
) -> StreamEventStream {
let rate_limit = parse_rate_limit_headers(http_resp.headers());
let json_schema_mode = uses_json_schema_format(request);
let stream = stream::unfold(
SseReaderState::new(
http_resp,
provider_name,
rate_limit,
json_schema_mode,
stream_read_timeout,
),
|mut state| async move {
loop {
if let Some(event) = state.pending_events.pop_front() {
let event = if state.json_schema_mode {
convert_stream_event_for_json_schema(event)
} else {
event
};
return Some((Ok(event), state));
}
match state.next_sse_event().await {
SseResult::Event { event_type, data } => {
let parsed: serde_json::Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(e) => {
return Some((
Err(Error::stream_error(
format!("failed to parse SSE data: {e}"),
e,
)),
state,
));
}
};
let events =
process_sse_event(&event_type, &parsed, &mut state.accumulator);
state.pending_events.extend(events);
}
SseResult::Done => return None,
SseResult::Error(err) => return Some((Err(err), state)),
}
}
},
);
Box::pin(stream)
}
#[async_trait::async_trait]
@ -1293,50 +1435,7 @@ impl ProviderAdapter for Adapter {
req = req.timeout(t);
}
let (body, headers) = send_and_read_response(req, &self.provider_name, "type").await?;
let api_resp: ApiResponse = serde_json::from_str(&body).map_err(|e| {
Error::network(
format!("failed to parse {} response: {e}", self.provider_name),
e,
)
})?;
let content_parts: Vec<ContentPart> = api_resp
.content
.iter()
.filter_map(parse_content_block)
.collect();
// If we used JsonSchema mode, convert the synthetic tool call back to text
let content_parts = if uses_json_schema_format(request) {
convert_synthetic_tool_to_text(content_parts)
} else {
content_parts
};
let finish_reason = if uses_json_schema_format(request) {
// The model was forced to call a tool, so stop_reason is "tool_use",
// but from the caller's perspective, the request completed normally.
FinishReason::Stop
} else {
map_finish_reason(api_resp.stop_reason.as_deref())
};
Ok(Response {
id: api_resp.id,
model: api_resp.model,
provider: self.provider_name.clone(),
message: Message {
role: Role::Assistant,
content: content_parts,
name: None,
tool_call_id: None,
},
finish_reason,
usage: token_counts_from_api_usage(&api_resp.usage),
raw: serde_json::from_str(&body).ok(),
warnings: vec![],
rate_limit: parse_rate_limit_headers(&headers),
})
parse_response_body(&body, &headers, request, &self.provider_name)
}
async fn stream(&self, request: &Request) -> Result<StreamEventStream, Error> {
@ -1368,52 +1467,12 @@ impl ProviderAdapter for Adapter {
));
}
let rate_limit = parse_rate_limit_headers(http_resp.headers());
let json_schema_mode = uses_json_schema_format(request);
let stream_read_timeout = self.http.stream_read_timeout;
let stream = stream::unfold(
SseReaderState::new(http_resp, rate_limit, json_schema_mode, stream_read_timeout),
|mut state| async move {
loop {
// Drain any buffered events first.
if let Some(event) = state.pending_events.pop_front() {
let event = if state.json_schema_mode {
convert_stream_event_for_json_schema(event)
} else {
event
};
return Some((Ok(event), state));
}
// Read more SSE data from the byte stream.
match state.next_sse_event().await {
SseResult::Event { event_type, data } => {
let parsed: serde_json::Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(e) => {
return Some((
Err(Error::stream_error(
format!("failed to parse SSE data: {e}"),
e,
)),
state,
));
}
};
let events =
process_sse_event(&event_type, &parsed, &mut state.accumulator);
state.pending_events.extend(events);
// Loop to drain from pending_events.
}
SseResult::Done => return None,
SseResult::Error(err) => return Some((Err(err), state)),
}
}
},
);
Ok(Box::pin(stream))
Ok(stream_events_from_response(
http_resp,
request,
self.provider_name.clone(),
self.http.stream_read_timeout,
))
}
fn supports_tool_choice(&self, mode: &str) -> bool {
@ -1551,7 +1610,7 @@ mod tests {
#[test]
fn stream_token_counts_leaves_reasoning_zero_and_output_full() {
let mut acc = StreamAccumulator::new(None);
let mut acc = StreamAccumulator::new("anthropic", None);
acc.content_parts.push(ContentPart::Thinking(ThinkingData {
text: "summary text".to_string(),
signature: Some(String::new()),

View file

@ -5,9 +5,11 @@ pub mod gemini;
pub mod http_api;
pub mod openai;
pub mod openai_compatible;
pub mod vertex;
pub use anthropic::Adapter as AnthropicAdapter;
pub use fabro_server::Adapter as FabroServerAdapter;
pub use gemini::Adapter as GeminiAdapter;
pub use openai::Adapter as OpenAiAdapter;
pub use openai_compatible::Adapter as OpenAiCompatibleAdapter;
pub use vertex::Adapter as VertexAdapter;

View file

@ -0,0 +1,605 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_model::Catalog;
use fabro_static::EnvVars;
#[cfg(test)]
use fabro_util::error::collect_chain;
use google_cloud_auth::credentials::{AccessTokenCredentials, Builder as GoogleCredentialsBuilder};
use crate::error::{Error, error_from_grpc_status, error_from_status_code};
use crate::provider::{ProviderAdapter, StreamEventStream, validate_tool_choice};
use crate::providers::anthropic;
use crate::providers::common::{parse_error_body, parse_retry_after};
use crate::providers::http_api::HttpApi;
use crate::types::{AdapterTimeout, Request, Response};
const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
#[async_trait]
pub trait VertexTokenProvider: std::fmt::Debug + Send + Sync {
async fn access_token(&self) -> Result<String, Error>;
}
#[derive(Debug, Default)]
pub struct GoogleCloudAuthTokenProvider {
credentials: Mutex<Option<AccessTokenCredentials>>,
}
impl GoogleCloudAuthTokenProvider {
fn credentials(&self) -> Result<AccessTokenCredentials, Error> {
let mut guard = self
.credentials
.lock()
.map_err(|err| Error::Configuration {
message: format!("failed to lock Google ADC token provider: {err}"),
source: None,
})?;
if let Some(credentials) = guard.as_ref() {
return Ok(credentials.clone());
}
let credentials = GoogleCredentialsBuilder::default()
.with_scopes([CLOUD_PLATFORM_SCOPE])
.build_access_token_credentials()
.map_err(|err| Error::configuration_error("failed to initialize Google ADC", err))?;
*guard = Some(credentials.clone());
Ok(credentials)
}
}
#[async_trait]
impl VertexTokenProvider for GoogleCloudAuthTokenProvider {
async fn access_token(&self) -> Result<String, Error> {
let credentials = self.credentials()?;
credentials
.access_token()
.await
.map(|token| token.token)
.map_err(|err| {
Error::configuration_error("failed to fetch Google ADC access token", err)
})
}
}
pub struct Adapter {
http: HttpApi,
provider_name: String,
catalog: Option<Arc<Catalog>>,
token_provider: Arc<dyn VertexTokenProvider>,
project_id: Option<String>,
region: Option<String>,
}
impl Adapter {
#[must_use]
pub fn new() -> Self {
Self {
http: HttpApi::new_optional(None, default_base_url_for_region("global")),
provider_name: "vertex".to_string(),
catalog: None,
token_provider: Arc::new(GoogleCloudAuthTokenProvider::default()),
project_id: None,
region: None,
}
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.provider_name = name.into();
self
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.http.base_url = base_url.into();
self
}
#[must_use]
pub fn with_default_headers(self, headers: std::collections::HashMap<String, String>) -> Self {
Self {
http: self.http.with_default_headers(headers),
..self
}
}
#[must_use]
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
self.catalog = Some(catalog);
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: AdapterTimeout) -> Self {
self.http = self.http.with_timeout(timeout);
self
}
#[must_use]
pub fn with_token_provider(mut self, token_provider: Arc<dyn VertexTokenProvider>) -> Self {
self.token_provider = token_provider;
self
}
#[must_use]
pub fn with_project_id(mut self, project_id: impl Into<String>) -> Self {
self.project_id = Some(project_id.into());
self
}
#[must_use]
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.region = Some(region.into());
self
}
fn region(&self) -> String {
self.region
.clone()
.or_else(env_region)
.unwrap_or_else(|| "global".to_string())
}
fn base_url(&self) -> String {
if self.http.base_url.is_empty() {
default_base_url_for_region(&self.region())
} else {
self.http.base_url.clone()
}
}
fn project_id(&self) -> Result<String, Error> {
self.project_id
.clone()
.or_else(env_project_id)
.or_else(adc_project_id)
.ok_or_else(|| Error::Configuration {
message: "Vertex provider requires a Google Cloud project ID; set ANTHROPIC_VERTEX_PROJECT_ID or GOOGLE_CLOUD_PROJECT".to_string(),
source: None,
})
}
fn endpoint_url(&self, model: &str, stream: bool) -> Result<String, Error> {
let method = if stream {
"streamRawPredict"
} else {
"rawPredict"
};
let base_url = self.base_url();
let project = self.project_id()?;
let region = self.region();
Ok(format!(
"{}/projects/{}/locations/{}/publishers/anthropic/models/{}:{}",
base_url.trim_end_matches('/'),
project,
region,
model,
method,
))
}
async fn request_builder(
&self,
request: &Request,
stream: bool,
) -> Result<fabro_http::RequestBuilder, Error> {
let (model, body) =
anthropic::build_vertex_request_body(self.catalog.as_deref(), request, stream).await;
let url = self.endpoint_url(&model, stream)?;
let token = self.token_provider.access_token().await?;
let mut builder = self.http.client.post(url);
for (key, value) in &self.http.default_headers {
builder = builder.header(key, value);
}
builder = builder.bearer_auth(token).json(&body);
if let Some(timeout) = self.http.request_timeout {
builder = builder.timeout(timeout);
}
Ok(builder)
}
}
impl Default for Adapter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ProviderAdapter for Adapter {
fn name(&self) -> &str {
&self.provider_name
}
async fn initialize(&self) -> Result<(), Error> {
self.project_id()?;
self.token_provider.access_token().await.map(|_| ())
}
async fn complete(&self, request: &Request) -> Result<Response, Error> {
if let Some(tc) = &request.tool_choice {
validate_tool_choice(self, tc)?;
}
let request_builder = self.request_builder(request, false).await?;
let (body, headers) =
send_and_read_response_with_google_errors(request_builder, &self.provider_name).await?;
anthropic::parse_response_body(&body, &headers, request, &self.provider_name)
}
async fn stream(&self, request: &Request) -> Result<StreamEventStream, Error> {
if let Some(tc) = &request.tool_choice {
validate_tool_choice(self, tc)?;
}
let response = self
.request_builder(request, true)
.await?
.send()
.await
.map_err(|err| Error::network(err.to_string(), err))?;
let status = response.status();
if !status.is_success() {
return Err(google_error_from_response(response, &self.provider_name).await);
}
Ok(anthropic::stream_events_from_response(
response,
request,
self.provider_name.clone(),
self.http.stream_read_timeout,
))
}
fn supports_tool_choice(&self, mode: &str) -> bool {
matches!(mode, "auto" | "none" | "required" | "named")
}
}
async fn send_and_read_response_with_google_errors(
request: fabro_http::RequestBuilder,
provider: &str,
) -> Result<(String, fabro_http::HeaderMap), Error> {
let response = request
.send()
.await
.map_err(|err| Error::network(err.to_string(), err))?;
let status = response.status();
if !status.is_success() {
return Err(google_error_from_response(response, provider).await);
}
let headers = response.headers().clone();
let body = response
.text()
.await
.map_err(|err| Error::network(err.to_string(), err))?;
Ok((body, headers))
}
async fn google_error_from_response(response: fabro_http::Response, provider: &str) -> Error {
let status = response.status();
let retry_after = parse_retry_after(response.headers());
let body = match response.text().await {
Ok(body) => body,
Err(err) => return Error::network(err.to_string(), err),
};
let (message, code, raw) = parse_error_body(&body, "status");
if let Some(code) = code.as_deref() {
return error_from_grpc_status(
code,
google_error_message(code, &message),
provider.to_string(),
Some(code.to_string()),
raw,
retry_after,
);
}
error_from_status_code(
status.as_u16(),
message,
provider.to_string(),
None,
raw,
retry_after,
)
}
fn google_error_message(code: &str, message: &str) -> String {
match code {
"PERMISSION_DENIED" => format!(
"{message}; verify Vertex AI permissions, publisher model access, and Marketplace enablement"
),
"NOT_FOUND" => format!("{message}; verify the Vertex model ID and location"),
"RESOURCE_EXHAUSTED" => {
format!("{message}; Vertex quota or regional capacity was exhausted")
}
"INVALID_ARGUMENT" => format!("{message}; verify the Vertex region and request body"),
_ => message.to_string(),
}
}
fn default_base_url_for_region(region: &str) -> String {
match region {
"global" => "https://aiplatform.googleapis.com/v1".to_string(),
"us" => "https://aiplatform.us.rep.googleapis.com/v1".to_string(),
"eu" => "https://aiplatform.eu.rep.googleapis.com/v1".to_string(),
other => format!("https://{other}-aiplatform.googleapis.com/v1"),
}
}
#[expect(
clippy::disallowed_methods,
reason = "Vertex adapter resolves provider configuration from documented process env vars."
)]
fn env_region() -> Option<String> {
std::env::var(EnvVars::CLOUD_ML_REGION).ok()
}
#[expect(
clippy::disallowed_methods,
reason = "Vertex adapter resolves provider configuration from documented process env vars."
)]
fn env_project_id() -> Option<String> {
[
EnvVars::ANTHROPIC_VERTEX_PROJECT_ID,
EnvVars::GOOGLE_CLOUD_PROJECT,
EnvVars::GCLOUD_PROJECT,
EnvVars::GCP_PROJECT,
]
.into_iter()
.find_map(|name| std::env::var(name).ok())
}
#[expect(
clippy::disallowed_methods,
reason = "ADC project fallback reads only standard Google ADC locations."
)]
fn adc_project_id() -> Option<String> {
let path = std::env::var(EnvVars::GOOGLE_APPLICATION_CREDENTIALS)
.ok()
.map(std::path::PathBuf::from)
.or_else(well_known_adc_path)?;
let contents = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&contents).ok()?;
json.get("project_id")
.or_else(|| json.get("quota_project_id"))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
}
#[expect(
clippy::disallowed_methods,
reason = "ADC project fallback reads only standard Google ADC locations."
)]
fn well_known_adc_path() -> Option<std::path::PathBuf> {
if cfg!(windows) {
std::env::var("APPDATA")
.ok()
.map(std::path::PathBuf::from)
.map(|path| path.join("gcloud/application_default_credentials.json"))
} else {
std::env::var(EnvVars::HOME)
.ok()
.map(std::path::PathBuf::from)
.map(|path| path.join(".config/gcloud/application_default_credentials.json"))
}
}
#[cfg(test)]
fn configuration_message_with_chain(err: &Error) -> String {
match err {
Error::Configuration { source, message } => source.as_ref().map_or_else(
|| message.clone(),
|source| {
collect_chain(source.as_ref()).into_iter().fold(
message.clone(),
|mut message, cause| {
message.push_str(": ");
message.push_str(&cause);
message
},
)
},
),
_ => err.to_string(),
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use futures::StreamExt;
use http::StatusCode;
use httpmock::Method::POST;
use httpmock::MockServer;
use super::*;
use crate::error::ProviderErrorKind;
use crate::types::{ContentPart, Message, Role, StreamEvent};
#[derive(Debug)]
struct FakeTokenProvider {
token: Result<String, Error>,
}
#[async_trait]
impl VertexTokenProvider for FakeTokenProvider {
async fn access_token(&self) -> Result<String, Error> {
self.token.clone()
}
}
fn fake_token_provider(token: &str) -> Arc<dyn VertexTokenProvider> {
Arc::new(FakeTokenProvider {
token: Ok(token.to_string()),
})
}
fn test_request(model: &str) -> Request {
Request {
model: model.to_string(),
messages: vec![Message {
role: Role::User,
content: vec![ContentPart::text("hello")],
name: None,
tool_call_id: None,
}],
provider: None,
tools: None,
tool_choice: None,
response_format: None,
temperature: None,
top_p: None,
max_tokens: Some(128),
stop_sequences: None,
reasoning_effort: None,
speed: None,
metadata: None,
provider_options: None,
}
}
fn adapter(server: &MockServer) -> Adapter {
Adapter::new()
.with_base_url(server.url(""))
.with_project_id("test-project")
.with_region("us-central1")
.with_token_provider(fake_token_provider("test-token"))
}
#[tokio::test]
async fn complete_posts_raw_predict_with_vertex_body_and_bearer_token() {
let server = MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(POST)
.path("/projects/test-project/locations/us-central1/publishers/anthropic/models/claude-sonnet-4-6:rawPredict")
.header("authorization", "Bearer test-token")
.is_true(|request| {
let body: serde_json::Value =
serde_json::from_slice(request.body_ref()).unwrap();
body["anthropic_version"] == "vertex-2023-10-16"
&& !body.as_object().unwrap().contains_key("model")
});
then.status(StatusCode::OK.as_u16()).json_body(serde_json::json!({
"id": "msg_1",
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": "hi"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 2, "output_tokens": 3}
}));
})
.await;
let response = adapter(&server)
.complete(&test_request("claude-sonnet-4-6"))
.await
.unwrap();
assert_eq!(response.provider, "vertex");
assert_eq!(response.model, "claude-sonnet-4-6");
mock.assert_async().await;
}
#[tokio::test]
async fn stream_posts_stream_raw_predict_and_reuses_anthropic_sse_parser() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(POST)
.path("/projects/test-project/locations/us-central1/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict")
.header("authorization", "Bearer test-token");
then.status(StatusCode::OK.as_u16())
.header("content-type", "text/event-stream")
.body(
"event: message_start\n\
data: {\"message\":{\"id\":\"msg_1\",\"model\":\"claude-sonnet-4-6\",\"usage\":{\"input_tokens\":2}}}\n\n\
event: content_block_start\n\
data: {\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\
event: content_block_delta\n\
data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n\
event: content_block_stop\n\
data: {\"index\":0}\n\n\
event: message_delta\n\
data: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}\n\n\
event: message_stop\n\
data: {}\n\n",
);
})
.await;
let mut stream = adapter(&server)
.stream(&test_request("claude-sonnet-4-6"))
.await
.unwrap();
let mut provider = None;
while let Some(event) = stream.next().await {
if let StreamEvent::Finish { response, .. } = event.unwrap() {
provider = Some(response.provider);
}
}
assert_eq!(provider.as_deref(), Some("vertex"));
}
#[tokio::test]
async fn google_errors_preserve_status_code_and_google_status() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(POST);
then.status(StatusCode::FORBIDDEN.as_u16())
.json_body(serde_json::json!({
"error": {
"code": 403,
"message": "model is not enabled",
"status": "PERMISSION_DENIED"
}
}));
})
.await;
let err = adapter(&server)
.complete(&test_request("claude-sonnet-4-6"))
.await
.unwrap_err();
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::AccessDenied));
assert_eq!(err.status_code(), None);
assert!(err.to_string().contains("Marketplace enablement"));
}
#[tokio::test]
async fn adc_failure_is_reported_as_configuration_error() {
let adapter = Adapter::new()
.with_project_id("test-project")
.with_token_provider(Arc::new(FakeTokenProvider {
token: Err(Error::Configuration {
message: "failed to fetch Google ADC access token".to_string(),
source: None,
}),
}));
let err = adapter.initialize().await.unwrap_err();
assert!(configuration_message_with_chain(&err).contains("Google ADC"));
}
#[test]
fn default_base_url_matches_vertex_region_defaults() {
assert_eq!(
default_base_url_for_region("global"),
"https://aiplatform.googleapis.com/v1"
);
assert_eq!(
default_base_url_for_region("us"),
"https://aiplatform.us.rep.googleapis.com/v1"
);
assert_eq!(
default_base_url_for_region("eu"),
"https://aiplatform.eu.rep.googleapis.com/v1"
);
assert_eq!(
default_base_url_for_region("us-central1"),
"https://us-central1-aiplatform.googleapis.com/v1"
);
}
}

View file

@ -40,6 +40,17 @@ pub enum ApiKeyHeaderPolicy {
Custom { name: &'static str },
}
/// How a provider adapter authenticates outbound API requests.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdapterAuthStrategy {
/// Fabro resolves an API key and converts it into the adapter's HTTP
/// authentication header.
ApiKey(ApiKeyHeaderPolicy),
/// The adapter owns token acquisition through Google Application Default
/// Credentials. No API key material is stored in Fabro.
GoogleApplicationDefault,
}
/// Native control values an adapter knows how to send through its provider
/// API.
#[derive(Debug, Clone, Copy)]
@ -62,8 +73,8 @@ pub struct AdapterMetadata {
pub key: &'static str,
/// Default agent profile dispatched for providers that use this adapter.
pub default_profile: AgentProfileKind,
/// How API keys for this adapter are converted into auth headers.
pub api_key_header: ApiKeyHeaderPolicy,
/// How this adapter authenticates API requests.
pub auth_strategy: AdapterAuthStrategy,
/// Native control values the adapter can transmit.
pub controls: AdapterControlCapabilities,
}
@ -78,7 +89,18 @@ const FAST_SPEEDS: &[Speed] = &[Speed::Fast];
pub const ANTHROPIC: AdapterMetadata = AdapterMetadata {
key: "anthropic",
default_profile: AgentProfileKind::Anthropic,
api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-api-key" },
auth_strategy: AdapterAuthStrategy::ApiKey(ApiKeyHeaderPolicy::Custom { name: "x-api-key" }),
controls: AdapterControlCapabilities {
native_reasoning_effort: FULL_REASONING_EFFORTS,
additional_speeds: FAST_SPEEDS,
},
};
/// Anthropic Claude through Vertex AI publisher endpoints — `vertex` adapter.
pub const VERTEX: AdapterMetadata = AdapterMetadata {
key: "vertex",
default_profile: AgentProfileKind::Anthropic,
auth_strategy: AdapterAuthStrategy::GoogleApplicationDefault,
controls: AdapterControlCapabilities {
native_reasoning_effort: FULL_REASONING_EFFORTS,
additional_speeds: FAST_SPEEDS,
@ -89,7 +111,7 @@ pub const ANTHROPIC: AdapterMetadata = AdapterMetadata {
pub const OPENAI: AdapterMetadata = AdapterMetadata {
key: "openai",
default_profile: AgentProfileKind::OpenAi,
api_key_header: ApiKeyHeaderPolicy::Bearer,
auth_strategy: AdapterAuthStrategy::ApiKey(ApiKeyHeaderPolicy::Bearer),
controls: AdapterControlCapabilities {
native_reasoning_effort: FULL_REASONING_EFFORTS,
additional_speeds: &[],
@ -100,9 +122,9 @@ pub const OPENAI: AdapterMetadata = AdapterMetadata {
pub const GEMINI: AdapterMetadata = AdapterMetadata {
key: "gemini",
default_profile: AgentProfileKind::Gemini,
api_key_header: ApiKeyHeaderPolicy::Custom {
auth_strategy: AdapterAuthStrategy::ApiKey(ApiKeyHeaderPolicy::Custom {
name: "x-goog-api-key",
},
}),
controls: AdapterControlCapabilities {
native_reasoning_effort: FULL_REASONING_EFFORTS,
additional_speeds: &[],
@ -115,7 +137,7 @@ pub const GEMINI: AdapterMetadata = AdapterMetadata {
pub const OPENAI_COMPATIBLE: AdapterMetadata = AdapterMetadata {
key: "openai_compatible",
default_profile: AgentProfileKind::OpenAi,
api_key_header: ApiKeyHeaderPolicy::Bearer,
auth_strategy: AdapterAuthStrategy::ApiKey(ApiKeyHeaderPolicy::Bearer),
controls: AdapterControlCapabilities {
// `openai_compatible` providers vary widely; the catalog requires
// models declaring `features.reasoning_effort = "levels"` to
@ -126,7 +148,8 @@ pub const OPENAI_COMPATIBLE: AdapterMetadata = AdapterMetadata {
};
/// All built-in adapter metadata, in stable iteration order.
pub const ALL_ADAPTERS: &[AdapterMetadata] = &[ANTHROPIC, OPENAI, GEMINI, OPENAI_COMPATIBLE];
pub const ALL_ADAPTERS: &[AdapterMetadata] =
&[ANTHROPIC, VERTEX, OPENAI, GEMINI, OPENAI_COMPATIBLE];
/// Look up adapter metadata by stable key.
#[must_use]
@ -145,6 +168,7 @@ pub fn keys() -> impl Iterator<Item = &'static str> {
pub fn default_for_provider_id(provider: &ProviderId) -> &'static str {
match Provider::from_id(provider) {
Some(Provider::Anthropic) => ANTHROPIC.key,
Some(Provider::Vertex) => VERTEX.key,
Some(Provider::OpenAi) => OPENAI.key,
Some(Provider::Gemini) => GEMINI.key,
Some(
@ -165,6 +189,7 @@ mod tests {
#[test]
fn lookup_by_known_key() {
assert_eq!(get("anthropic").unwrap().key, "anthropic");
assert_eq!(get("vertex").unwrap().key, "vertex");
assert_eq!(get("openai").unwrap().key, "openai");
assert_eq!(get("gemini").unwrap().key, "gemini");
assert_eq!(get("openai_compatible").unwrap().key, "openai_compatible");
@ -187,15 +212,29 @@ mod tests {
#[test]
fn anthropic_uses_custom_x_api_key_header() {
match ANTHROPIC.api_key_header {
ApiKeyHeaderPolicy::Custom { name } => assert_eq!(name, "x-api-key"),
ApiKeyHeaderPolicy::Bearer => panic!("expected custom header for anthropic"),
match ANTHROPIC.auth_strategy {
AdapterAuthStrategy::ApiKey(ApiKeyHeaderPolicy::Custom { name }) => {
assert_eq!(name, "x-api-key");
}
other => panic!("expected custom API-key header for anthropic, got {other:?}"),
}
}
#[test]
fn openai_uses_bearer_header() {
assert!(matches!(OPENAI.api_key_header, ApiKeyHeaderPolicy::Bearer));
assert!(matches!(
OPENAI.auth_strategy,
AdapterAuthStrategy::ApiKey(ApiKeyHeaderPolicy::Bearer)
));
}
#[test]
fn vertex_uses_google_application_default_credentials() {
assert!(matches!(
VERTEX.auth_strategy,
AdapterAuthStrategy::GoogleApplicationDefault
));
assert_eq!(VERTEX.default_profile, AgentProfileKind::Anthropic);
}
#[test]

View file

@ -324,7 +324,9 @@ impl ModelBillingFacts {
match provider {
Provider::OpenAi => Self::OpenAi(OpenAiBillingFacts::default()),
Provider::OpenAiCompatible => Self::OpenAiCompatible(OpenAiBillingFacts::default()),
Provider::Anthropic => Self::Anthropic(anthropic_billing_facts(tokens)),
Provider::Anthropic | Provider::Vertex => {
Self::Anthropic(anthropic_billing_facts(tokens))
}
Provider::Gemini => Self::Gemini(GeminiBillingFacts::default()),
Provider::Kimi => Self::Kimi(OpenAiBillingFacts::default()),
Provider::Zai => Self::Zai(OpenAiBillingFacts::default()),
@ -629,7 +631,9 @@ fn pricing_policy_for_builtin_provider(
cached_input,
output,
}),
Provider::Anthropic => anthropic_pricing_policy(input, output, cached_input),
Provider::Anthropic | Provider::Vertex => {
anthropic_pricing_policy(input, output, cached_input)
}
Provider::Gemini => ModelPricingPolicy::Gemini(GeminiModelPricing {
input,
output,

View file

@ -835,6 +835,7 @@ impl Catalog {
pub fn probe_for_provider(&self, p: Provider) -> Option<&Model> {
let override_id: Option<&str> = match p {
Provider::Anthropic => Some("claude-haiku-4-5"),
Provider::Vertex => Some("vertex-claude-haiku-4-5"),
Provider::OpenAi => Some("gpt-5.4-mini"),
_ => None,
};
@ -1845,6 +1846,38 @@ effort = false
);
}
#[test]
fn builtin_vertex_catalog_uses_vertex_adapter_and_api_ids() {
let catalog = Catalog::builtin();
let provider = catalog.provider(&ProviderId::vertex()).unwrap();
assert_eq!(provider.adapter, "vertex");
assert!(provider.credentials.is_empty());
assert_eq!(
catalog
.default_for_provider(&ProviderId::vertex())
.unwrap()
.id,
"vertex-claude-sonnet-4-6"
);
let expected_api_ids = [
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-opus-4-5",
"claude-opus-4-1",
"claude-haiku-4-5",
];
for api_id in expected_api_ids {
let model_id = format!("vertex-{api_id}");
let model = catalog.get(&model_id).unwrap();
assert_eq!(model.provider, ProviderId::vertex());
assert_eq!(catalog.model_settings(&model_id).unwrap().api_id, api_id);
}
}
#[test]
fn catalog_from_settings_rejects_unknown_adapter() {
let layer = minimal_settings(

View file

@ -0,0 +1,147 @@
[providers.vertex]
display_name = "Vertex AI"
adapter = "vertex"
priority = 90
[models."vertex-claude-opus-4-7"]
provider = "vertex"
api_id = "claude-opus-4-7"
display_name = "Claude Opus 4.7 on Vertex AI"
family = "claude-4"
training = "2025-08-01"
knowledge_cutoff = "May 2025"
estimated_output_tps = 25
[models."vertex-claude-opus-4-7".limits]
context_window = 1000000
max_output = 128000
[models."vertex-claude-opus-4-7".features]
tools = true
vision = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
[models."vertex-claude-opus-4-7".controls]
speed = ["fast"]
[models."vertex-claude-opus-4-6"]
provider = "vertex"
api_id = "claude-opus-4-6"
display_name = "Claude Opus 4.6 on Vertex AI"
family = "claude-4"
training = "2025-08-01"
knowledge_cutoff = "May 2025"
estimated_output_tps = 25
[models."vertex-claude-opus-4-6".limits]
context_window = 1000000
max_output = 128000
[models."vertex-claude-opus-4-6".features]
tools = true
vision = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
[models."vertex-claude-opus-4-6".controls]
speed = ["fast"]
[models."vertex-claude-sonnet-4-6"]
provider = "vertex"
api_id = "claude-sonnet-4-6"
display_name = "Claude Sonnet 4.6 on Vertex AI"
family = "claude-4"
training = "2025-08-01"
knowledge_cutoff = "May 2025"
default = true
estimated_output_tps = 50
[models."vertex-claude-sonnet-4-6".limits]
context_window = 200000
max_output = 64000
[models."vertex-claude-sonnet-4-6".features]
tools = true
vision = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
[models."vertex-claude-sonnet-4-5"]
provider = "vertex"
api_id = "claude-sonnet-4-5"
display_name = "Claude Sonnet 4.5 on Vertex AI"
family = "claude-4"
training = "2025-08-01"
knowledge_cutoff = "May 2025"
estimated_output_tps = 50
[models."vertex-claude-sonnet-4-5".limits]
context_window = 200000
max_output = 64000
[models."vertex-claude-sonnet-4-5".features]
tools = true
vision = true
reasoning = true
prompt_cache = true
[models."vertex-claude-opus-4-5"]
provider = "vertex"
api_id = "claude-opus-4-5"
display_name = "Claude Opus 4.5 on Vertex AI"
family = "claude-4"
training = "2025-08-01"
knowledge_cutoff = "May 2025"
estimated_output_tps = 25
[models."vertex-claude-opus-4-5".limits]
context_window = 200000
max_output = 64000
[models."vertex-claude-opus-4-5".features]
tools = true
vision = true
reasoning = true
prompt_cache = true
[models."vertex-claude-opus-4-1"]
provider = "vertex"
api_id = "claude-opus-4-1"
display_name = "Claude Opus 4.1 on Vertex AI"
family = "claude-4"
training = "2025-03-01"
knowledge_cutoff = "March 2025"
estimated_output_tps = 25
[models."vertex-claude-opus-4-1".limits]
context_window = 200000
max_output = 32000
[models."vertex-claude-opus-4-1".features]
tools = true
vision = true
reasoning = true
prompt_cache = true
[models."vertex-claude-haiku-4-5"]
provider = "vertex"
api_id = "claude-haiku-4-5"
display_name = "Claude Haiku 4.5 on Vertex AI"
family = "claude-4"
training = "2025-08-01"
knowledge_cutoff = "May 2025"
estimated_output_tps = 100
[models."vertex-claude-haiku-4-5".limits]
context_window = 200000
max_output = 8192
[models."vertex-claude-haiku-4-5".features]
tools = true
vision = true
reasoning = false
prompt_cache = true

View file

@ -19,6 +19,7 @@ pub struct ProviderId(String);
impl ProviderId {
pub const ANTHROPIC: &'static str = "anthropic";
pub const VERTEX: &'static str = "vertex";
pub const OPENAI: &'static str = "openai";
pub const GEMINI: &'static str = "gemini";
pub const KIMI: &'static str = "kimi";
@ -51,6 +52,11 @@ impl ProviderId {
Self::new(Self::ANTHROPIC)
}
#[must_use]
pub fn vertex() -> Self {
Self::new(Self::VERTEX)
}
#[must_use]
pub fn openai() -> Self {
Self::new(Self::OPENAI)

View file

@ -10,7 +10,8 @@ pub mod reasoning;
pub mod types;
pub use adapter::{
AdapterControlCapabilities, AdapterMetadata, AgentProfileKind, ApiKeyHeaderPolicy,
AdapterAuthStrategy, AdapterControlCapabilities, AdapterMetadata, AgentProfileKind,
ApiKeyHeaderPolicy,
};
pub use billing::{
AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts,

View file

@ -31,6 +31,7 @@ use crate::ids::ProviderId;
#[strum(serialize_all = "snake_case")]
pub enum Provider {
Anthropic,
Vertex,
#[serde(rename = "openai", alias = "open_ai")]
#[strum(to_string = "openai", serialize = "open_ai")]
OpenAi,
@ -59,6 +60,7 @@ impl Provider {
/// All known provider variants, for use in guardrail tests and iteration.
pub const ALL: &[Self] = &[
Self::Anthropic,
Self::Vertex,
Self::OpenAi,
Self::Gemini,
Self::Kimi,
@ -80,7 +82,7 @@ impl Provider {
Self::Zai => &[EnvVars::ZAI_API_KEY],
Self::Minimax => &[EnvVars::MINIMAX_API_KEY],
Self::Inception => &[EnvVars::INCEPTION_API_KEY],
Self::OpenAiCompatible => &[],
Self::Vertex | Self::OpenAiCompatible => &[],
}
}
@ -128,6 +130,7 @@ impl Provider {
pub fn display_name(self) -> &'static str {
match self {
Self::Anthropic => "Anthropic",
Self::Vertex => "Vertex AI",
Self::OpenAi => "OpenAI",
Self::Gemini => "Gemini",
Self::Kimi => "Kimi",
@ -165,6 +168,7 @@ mod tests {
#[test]
fn provider_id_preserves_canonical_builtin_strings() {
assert_eq!(Provider::Anthropic.id().as_str(), ProviderId::ANTHROPIC);
assert_eq!(Provider::Vertex.id().as_str(), ProviderId::VERTEX);
assert_eq!(Provider::OpenAi.id().as_str(), ProviderId::OPENAI);
assert_eq!(Provider::Gemini.id().as_str(), ProviderId::GEMINI);
assert_eq!(Provider::Kimi.id().as_str(), ProviderId::KIMI);
@ -289,6 +293,11 @@ mod tests {
]);
}
#[test]
fn api_key_env_vars_vertex_empty_because_adc_is_adapter_managed() {
assert!(Provider::Vertex.api_key_env_vars().is_empty());
}
#[test]
fn api_key_env_vars_openai() {
assert_eq!(Provider::OpenAi.api_key_env_vars(), &["OPENAI_API_KEY"]);
@ -324,11 +333,11 @@ mod tests {
}
#[test]
fn every_provider_has_at_least_one_env_var() {
fn every_api_key_provider_has_at_least_one_env_var() {
assert!(
Provider::ALL
.iter()
.all(|p| !p.api_key_env_vars().is_empty())
.all(|p| { *p == Provider::Vertex || !p.api_key_env_vars().is_empty() })
);
}
}

View file

@ -822,6 +822,7 @@ async fn put_install_llm(
fn unsupported_install_provider_error(provider: Provider) -> Option<&'static str> {
match provider {
Provider::Vertex => Some("vertex is not supported by install in v1; configure Google ADC"),
Provider::OpenAiCompatible => Some("openai_compatible is not supported by install in v1"),
_ => None,
}
@ -2045,7 +2046,8 @@ async fn validate_llm_provider(
Provider::Anthropic => ("x-api-key", input.api_key.clone()),
Provider::OpenAi => ("Authorization", format!("Bearer {}", input.api_key)),
Provider::Gemini => ("x-goog-api-key", input.api_key.clone()),
Provider::Kimi
Provider::Vertex
| Provider::Kimi
| Provider::Zai
| Provider::Minimax
| Provider::Inception
@ -2093,6 +2095,7 @@ fn provider_base_url(state: &InstallAppState, provider: Provider) -> String {
.cloned()
.or_else(|| match provider {
Provider::Anthropic => std::env::var(EnvVars::ANTHROPIC_BASE_URL).ok(),
Provider::Vertex => std::env::var(EnvVars::ANTHROPIC_VERTEX_BASE_URL).ok(),
Provider::OpenAi => std::env::var(EnvVars::OPENAI_BASE_URL).ok(),
Provider::Gemini => std::env::var(EnvVars::GEMINI_BASE_URL).ok(),
Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => None,
@ -2102,7 +2105,8 @@ fn provider_base_url(state: &InstallAppState, provider: Provider) -> String {
Provider::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
Provider::OpenAi => DEFAULT_OPENAI_BASE_URL.to_string(),
Provider::Gemini => DEFAULT_GEMINI_BASE_URL.to_string(),
Provider::Kimi
Provider::Vertex
| Provider::Kimi
| Provider::Zai
| Provider::Minimax
| Provider::Inception

View file

@ -41,11 +41,18 @@ impl EnvVars {
// LLM providers and tool integrations
pub const ANTHROPIC_API_KEY: &'static str = "ANTHROPIC_API_KEY";
pub const ANTHROPIC_BASE_URL: &'static str = "ANTHROPIC_BASE_URL";
pub const ANTHROPIC_VERTEX_BASE_URL: &'static str = "ANTHROPIC_VERTEX_BASE_URL";
pub const ANTHROPIC_VERTEX_PROJECT_ID: &'static str = "ANTHROPIC_VERTEX_PROJECT_ID";
pub const BRAVE_SEARCH_API_KEY: &'static str = "BRAVE_SEARCH_API_KEY";
pub const CHATGPT_ACCOUNT_ID: &'static str = "CHATGPT_ACCOUNT_ID";
pub const GEMINI_API_KEY: &'static str = "GEMINI_API_KEY";
pub const GEMINI_BASE_URL: &'static str = "GEMINI_BASE_URL";
pub const GOOGLE_API_KEY: &'static str = "GOOGLE_API_KEY";
pub const GOOGLE_CLOUD_PROJECT: &'static str = "GOOGLE_CLOUD_PROJECT";
pub const GOOGLE_APPLICATION_CREDENTIALS: &'static str = "GOOGLE_APPLICATION_CREDENTIALS";
pub const CLOUD_ML_REGION: &'static str = "CLOUD_ML_REGION";
pub const GCLOUD_PROJECT: &'static str = "GCLOUD_PROJECT";
pub const GCP_PROJECT: &'static str = "GCP_PROJECT";
pub const GOPATH: &'static str = "GOPATH";
pub const INCEPTION_API_KEY: &'static str = "INCEPTION_API_KEY";
pub const KIMI_API_KEY: &'static str = "KIMI_API_KEY";
@ -179,11 +186,18 @@ mod tests {
EnvVars::FABRO_WORKER_TOKEN,
EnvVars::ANTHROPIC_API_KEY,
EnvVars::ANTHROPIC_BASE_URL,
EnvVars::ANTHROPIC_VERTEX_BASE_URL,
EnvVars::ANTHROPIC_VERTEX_PROJECT_ID,
EnvVars::BRAVE_SEARCH_API_KEY,
EnvVars::CHATGPT_ACCOUNT_ID,
EnvVars::GEMINI_API_KEY,
EnvVars::GEMINI_BASE_URL,
EnvVars::GOOGLE_API_KEY,
EnvVars::GOOGLE_CLOUD_PROJECT,
EnvVars::GOOGLE_APPLICATION_CREDENTIALS,
EnvVars::CLOUD_ML_REGION,
EnvVars::GCLOUD_PROJECT,
EnvVars::GCP_PROJECT,
EnvVars::GOPATH,
EnvVars::INCEPTION_API_KEY,
EnvVars::KIMI_API_KEY,

View file

@ -198,7 +198,7 @@ fn build_profile(
fn default_profile_kind(provider: Provider) -> AgentProfileKind {
match provider {
Provider::Anthropic => AgentProfileKind::Anthropic,
Provider::Anthropic | Provider::Vertex => AgentProfileKind::Anthropic,
Provider::Gemini => AgentProfileKind::Gemini,
Provider::OpenAi
| Provider::Kimi

View file

@ -60,7 +60,7 @@ pub enum AgentCli {
impl AgentCli {
pub fn for_provider(provider: Provider) -> Self {
match provider {
Provider::Anthropic => Self::Claude,
Provider::Anthropic | Provider::Vertex => Self::Claude,
Provider::Gemini => Self::Gemini,
Provider::OpenAi
| Provider::Kimi
@ -142,7 +142,7 @@ pub fn cli_command_for_provider(provider: Provider, model: &str, prompt_file: &s
| Provider::OpenAiCompatible => {
format!(" -m {model}")
}
Provider::Anthropic => format!(" --model {model}"),
Provider::Anthropic | Provider::Vertex => format!(" --model {model}"),
}
};
// Use `cat | command` instead of `command < file` because the background
@ -163,7 +163,7 @@ pub fn cli_command_for_provider(provider: Provider, model: &str, prompt_file: &s
// --dangerously-skip-permissions: bypass all permission checks (required for
// non-interactive use). CLAUDECODE= unset to allow running inside a Claude Code
// session.
Provider::Anthropic => format!(
Provider::Anthropic | Provider::Vertex => format!(
"cat {prompt_file} | CLAUDECODE= claude -p --verbose --output-format stream-json --dangerously-skip-permissions{model_flag}"
),
}
@ -326,7 +326,7 @@ pub fn parse_cli_response(provider: Provider, output: &str) -> Option<CliRespons
| Provider::Inception
| Provider::OpenAiCompatible => parse_codex_ndjson(output),
Provider::Gemini => parse_gemini_json(output),
Provider::Anthropic => parse_claude_ndjson(output),
Provider::Anthropic | Provider::Vertex => parse_claude_ndjson(output),
}
}