diff --git a/Cargo.lock b/Cargo.lock index 0e389e20f..de2596ff0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 46f335529..f51595185 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -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), + ), } } diff --git a/lib/crates/fabro-agent/src/memory.rs b/lib/crates/fabro-agent/src/memory.rs index 071435c30..10b201aaf 100644 --- a/lib/crates/fabro-agent/src/memory.rs +++ b/lib/crates/fabro-agent/src/memory.rs @@ -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 diff --git a/lib/crates/fabro-agent/tests/it/guardrails.rs b/lib/crates/fabro-agent/tests/it/guardrails.rs index 985cb7bc4..1d810d45b 100644 --- a/lib/crates/fabro-agent/tests/it/guardrails.rs +++ b/lib/crates/fabro-agent/tests/it/guardrails.rs @@ -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!( diff --git a/lib/crates/fabro-agent/tests/it/parity_matrix.rs b/lib/crates/fabro-agent/tests/it/parity_matrix.rs index 8a795ac24..2915b92dc 100644 --- a/lib/crates/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/crates/fabro-agent/tests/it/parity_matrix.rs @@ -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 { 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 = { 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)) } diff --git a/lib/crates/fabro-auth/src/env_source.rs b/lib/crates/fabro-auth/src/env_source.rs index dc755767f..56a9daadb 100644 --- a/lib/crates/fabro-auth/src/env_source.rs +++ b/lib/crates/fabro-auth/src/env_source.rs @@ -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 { + 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 { 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( diff --git a/lib/crates/fabro-auth/src/resolve.rs b/lib/crates/fabro-auth/src/resolve.rs index 28f5232c1..043ac44f8 100644 --- a/lib/crates/fabro-auth/src/resolve.rs +++ b/lib/crates/fabro-auth/src/resolve.rs @@ -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, @@ -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 { + 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(); diff --git a/lib/crates/fabro-cli/src/shared/provider_auth.rs b/lib/crates/fabro-cli/src/shared/provider_auth.rs index 444f1d402..221cdc166 100644 --- a/lib/crates/fabro-cli/src/shared/provider_auth.rs +++ b/lib/crates/fabro-cli/src/shared/provider_auth.rs @@ -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", diff --git a/lib/crates/fabro-llm/Cargo.toml b/lib/crates/fabro-llm/Cargo.toml index e2e04924c..1fa493a34 100644 --- a/lib/crates/fabro-llm/Cargo.toml +++ b/lib/crates/fabro-llm/Cargo.toml @@ -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" diff --git a/lib/crates/fabro-llm/src/adapter_registry.rs b/lib/crates/fabro-llm/src/adapter_registry.rs index 37eb5f6f0..ca7cd0f9c 100644 --- a/lib/crates/fabro-llm/src/adapter_registry.rs +++ b/lib/crates/fabro-llm/src/adapter_registry.rs @@ -97,6 +97,27 @@ fn build_anthropic(config: AdapterConfig) -> Arc { 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 { + 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 { /// `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), ( diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index d7469a3f2..02cb91437 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/fabro-llm/src/providers/anthropic.rs @@ -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, @@ -713,8 +714,9 @@ struct StreamAccumulator { } impl StreamAccumulator { - fn new(rate_limit: Option) -> Self { + fn new(provider_name: impl Into, rate_limit: Option) -> 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, rate_limit: Option, json_schema_mode: bool, stream_read_timeout: Option, ) -> 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, +} + +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 { + 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 = 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, + stream_read_timeout: Option, +) -> 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 = 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 { @@ -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()), diff --git a/lib/crates/fabro-llm/src/providers/mod.rs b/lib/crates/fabro-llm/src/providers/mod.rs index 13d105b2d..52f8da301 100644 --- a/lib/crates/fabro-llm/src/providers/mod.rs +++ b/lib/crates/fabro-llm/src/providers/mod.rs @@ -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; diff --git a/lib/crates/fabro-llm/src/providers/vertex.rs b/lib/crates/fabro-llm/src/providers/vertex.rs new file mode 100644 index 000000000..43a2d2e0c --- /dev/null +++ b/lib/crates/fabro-llm/src/providers/vertex.rs @@ -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; +} + +#[derive(Debug, Default)] +pub struct GoogleCloudAuthTokenProvider { + credentials: Mutex>, +} + +impl GoogleCloudAuthTokenProvider { + fn credentials(&self) -> Result { + 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 { + 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>, + token_provider: Arc, + project_id: Option, + region: Option, +} + +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) -> Self { + self.provider_name = name.into(); + self + } + + #[must_use] + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.http.base_url = base_url.into(); + self + } + + #[must_use] + pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { + Self { + http: self.http.with_default_headers(headers), + ..self + } + } + + #[must_use] + pub fn with_catalog(mut self, catalog: Arc) -> 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) -> Self { + self.token_provider = token_provider; + self + } + + #[must_use] + pub fn with_project_id(mut self, project_id: impl Into) -> Self { + self.project_id = Some(project_id.into()); + self + } + + #[must_use] + pub fn with_region(mut self, region: impl Into) -> 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + [ + 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 { + 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 { + 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, + } + + #[async_trait] + impl VertexTokenProvider for FakeTokenProvider { + async fn access_token(&self) -> Result { + self.token.clone() + } + } + + fn fake_token_provider(token: &str) -> Arc { + 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" + ); + } +} diff --git a/lib/crates/fabro-model/src/adapter.rs b/lib/crates/fabro-model/src/adapter.rs index d9b3ddd80..e91dfad52 100644 --- a/lib/crates/fabro-model/src/adapter.rs +++ b/lib/crates/fabro-model/src/adapter.rs @@ -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 { 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] diff --git a/lib/crates/fabro-model/src/billing.rs b/lib/crates/fabro-model/src/billing.rs index a2ea21cef..3cc0299b4 100644 --- a/lib/crates/fabro-model/src/billing.rs +++ b/lib/crates/fabro-model/src/billing.rs @@ -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, diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 06dd0f054..68aefaab8 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -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( diff --git a/lib/crates/fabro-model/src/catalog/providers/vertex.toml b/lib/crates/fabro-model/src/catalog/providers/vertex.toml new file mode 100644 index 000000000..90946cd44 --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/vertex.toml @@ -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 diff --git a/lib/crates/fabro-model/src/ids.rs b/lib/crates/fabro-model/src/ids.rs index 9f9fcabaa..758ca6b69 100644 --- a/lib/crates/fabro-model/src/ids.rs +++ b/lib/crates/fabro-model/src/ids.rs @@ -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) diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/crates/fabro-model/src/lib.rs index a9adada4a..f31a525b8 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/fabro-model/src/lib.rs @@ -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, diff --git a/lib/crates/fabro-model/src/provider.rs b/lib/crates/fabro-model/src/provider.rs index 24fb360fe..49c04c791 100644 --- a/lib/crates/fabro-model/src/provider.rs +++ b/lib/crates/fabro-model/src/provider.rs @@ -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() }) ); } } diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index 32e7a5e9d..d75019c73 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -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 diff --git a/lib/crates/fabro-static/src/env_vars.rs b/lib/crates/fabro-static/src/env_vars.rs index f1c5937d7..b2af0492f 100644 --- a/lib/crates/fabro-static/src/env_vars.rs +++ b/lib/crates/fabro-static/src/env_vars.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 5f38a56e5..641b94920 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -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 diff --git a/lib/crates/fabro-workflow/src/handler/llm/cli.rs b/lib/crates/fabro-workflow/src/handler/llm/cli.rs index 72d0b6bfb..c012afb00 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/cli.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/cli.rs @@ -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 parse_codex_ndjson(output), Provider::Gemini => parse_gemini_json(output), - Provider::Anthropic => parse_claude_ndjson(output), + Provider::Anthropic | Provider::Vertex => parse_claude_ndjson(output), } }