mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-10 22:43:37 +00:00
feat(llm): add Vertex AI Claude adapter
This commit is contained in:
parent
ba27bcdd57
commit
af846e1a8c
24 changed files with 1518 additions and 211 deletions
290
Cargo.lock
generated
290
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
605
lib/crates/fabro-llm/src/providers/vertex.rs
Normal file
605
lib/crates/fabro-llm/src/providers/vertex.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
147
lib/crates/fabro-model/src/catalog/providers/vertex.toml
Normal file
147
lib/crates/fabro-model/src/catalog/providers/vertex.toml
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue