diff --git a/Cargo.lock b/Cargo.lock index 49104b22d2..1c91b394b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -403,7 +403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ "base64", - "http", + "http 1.4.0", "log", "rustls", "serde", @@ -418,6 +418,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-creds" version = "0.39.1" @@ -465,6 +477,138 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "aws-sigv4" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + +[[package]] +name = "aws-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.9" @@ -477,8 +621,8 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-util", @@ -510,8 +654,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -555,6 +699,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -824,10 +978,15 @@ version = "0.1.0" dependencies = [ "arc-swap", "async-trait", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-runtime-api", + "aws-types", "axum", "base64", "getrandom 0.4.3", "hex", + "http 1.4.0", "nix 0.31.3", "reqwest 0.13.4", "rmcp", @@ -1332,6 +1491,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "castaway" version = "0.2.4" @@ -2177,7 +2346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -3173,7 +3342,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -3300,7 +3469,7 @@ checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" dependencies = [ "async-trait", "bytes", - "http", + "http 1.4.0", "more-asserts", "serde", "thiserror 2.0.18", @@ -3329,7 +3498,7 @@ dependencies = [ "futures-util", "h2", "hickory-proto", - "http", + "http 1.4.0", "idna", "ipnet", "jni 0.22.4", @@ -3436,6 +3605,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -3446,6 +3626,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -3453,7 +3644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -3464,8 +3655,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -3515,8 +3706,8 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -3532,7 +3723,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", @@ -3582,8 +3773,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "ipnet", "libc", @@ -3755,7 +3946,7 @@ dependencies = [ "attohttpc", "bytes", "futures", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", @@ -3918,7 +4109,7 @@ dependencies = [ "futures-util", "getrandom 0.4.3", "hickory-resolver", - "http", + "http 1.4.0", "ipnet", "iroh-base", "iroh-dns", @@ -4035,7 +4226,7 @@ dependencies = [ "derive_more", "getrandom 0.4.3", "hickory-resolver", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", @@ -4647,7 +4838,7 @@ dependencies = [ "futures-util", "hex", "hf-hub", - "http", + "http 1.4.0", "http-body-util", "httparse", "if-addrs", @@ -5919,7 +6110,7 @@ checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", - "http", + "http 1.4.0", "opentelemetry 0.31.0", "reqwest 0.12.28", ] @@ -5930,7 +6121,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" dependencies = [ - "http", + "http 1.4.0", "opentelemetry 0.31.0", "opentelemetry-http", "opentelemetry-proto 0.31.0", @@ -5946,7 +6137,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ - "http", + "http 1.4.0", "opentelemetry 0.32.0", "opentelemetry-proto 0.32.0", "opentelemetry_sdk 0.32.1", @@ -6073,6 +6264,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.14.0" @@ -6344,6 +6541,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -7242,8 +7445,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -7289,8 +7492,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -7330,7 +7533,7 @@ checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", - "http", + "http 1.4.0", "reqwest 0.13.4", "thiserror 2.0.18", "tower-service", @@ -7377,8 +7580,8 @@ dependencies = [ "bytes", "chrono", "futures", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "pastey", "pin-project-lite", @@ -7447,7 +7650,7 @@ dependencies = [ "futures-util", "hex", "hmac 0.12.1", - "http", + "http 1.4.0", "log", "maybe-async", "md5", @@ -8520,7 +8723,7 @@ checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" dependencies = [ "bytes", "futures-util", - "http-body", + "http-body 1.0.1", "http-body-util", "pin-project-lite", ] @@ -9060,7 +9263,7 @@ dependencies = [ "futures-core", "futures-sink", "getrandom 0.4.3", - "http", + "http 1.4.0", "httparse", "rand 0.10.1", "ring", @@ -9159,8 +9362,8 @@ dependencies = [ "base64", "bytes", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-timeout", @@ -9230,8 +9433,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "http-range-header", "httpdate", @@ -9378,7 +9581,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -9397,7 +9600,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -9647,6 +9850,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vtparse" version = "0.6.2" @@ -10538,7 +10747,7 @@ dependencies = [ "clap", "crc32fast", "futures", - "http", + "http 1.4.0", "hyper", "lazy_static", "more-asserts", @@ -10612,7 +10821,7 @@ dependencies = [ "chrono", "clap", "gearhash", - "http", + "http 1.4.0", "itertools", "lazy_static", "more-asserts", diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index 7889ad34a7..addc1f1b03 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -43,6 +43,12 @@ hex = { workspace = true } sha2 = { workspace = true } urlencoding = "2" webbrowser = "1" +# AWS SigV4 signing for Bedrock provider +aws-sigv4 = { version = "1.2", features = ["http1"] } +aws-credential-types = "1.2" +aws-types = "1.2" +http = "1" +aws-smithy-runtime-api = "1.7" [target.'cfg(unix)'.dependencies] nix = { version = "0.31", default-features = false, features = ["signal", "process"] } diff --git a/crates/buzz-agent/src/catalog_bedrock.rs b/crates/buzz-agent/src/catalog_bedrock.rs new file mode 100644 index 0000000000..29dd394bcb --- /dev/null +++ b/crates/buzz-agent/src/catalog_bedrock.rs @@ -0,0 +1,224 @@ +//! Bedrock model catalog discovery. +//! +//! Exposes [`discover_bedrock_models`] — an async helper that lists +//! available foundation models accessible under the configured AWS account +//! and region, using the Bedrock `ListFoundationModels` API. +//! +//! Auth is handled via the standard AWS credential chain +//! ([`sigv4::load_aws_credentials`]). + +use reqwest::Client; +use serde_json::Value; + +use crate::{ + config::Config, + sigv4, + types::AgentError, +}; + +/// A discovered model entry: `id` is the Bedrock model ID +/// (e.g. `anthropic.claude-3-5-sonnet-20241022-v2:0`), `name` is the +/// display label (model name + provider, e.g. `Claude 3.5 Sonnet (Anthropic)`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelEntry { + pub id: String, + pub name: String, +} + +/// Discover available Bedrock models via `ListFoundationModels`. +/// +/// Calls `GET https://bedrock.{region}.amazonaws.com/foundation-models` +/// with SigV4 signing, then filters to models that support ON_DEMAND +/// inference and TEXT modality. +/// +/// Returns `Err(AgentError::Llm)` on transport or auth errors. +pub async fn discover_bedrock_models(cfg: &Config) -> Result, AgentError> { + let region = sigv4::parse_bedrock_region(&cfg.base_url) + .map_err(|e| AgentError::Llm(format!("Bedrock catalog: {e}")))?; + let creds = sigv4::load_aws_credentials() + .map_err(|e| AgentError::Llm(format!("Bedrock catalog: {e}")))?; + + let url = format!("https://bedrock.{region}.amazonaws.com/foundation-models"); + + let http = Client::new(); + + // Build a GET request, sign it with SigV4 + let req = http::Request::builder() + .uri(&url) + .method("GET") + .body(Vec::new()) + .map_err(|e| AgentError::Llm(format!("Bedrock catalog: build request: {e}")))?; + + let signed = sigv4::sign_request(req, &creds, "bedrock", ®ion) + .map_err(|e| AgentError::Llm(format!("Bedrock catalog: sign request: {e}")))?; + + let (parts, _) = signed.into_parts(); + let parsed_url = reqwest::Url::parse(&url) + .map_err(|e| AgentError::Llm(format!("Bedrock catalog: parse url: {e}")))?; + let mut rq = reqwest::Request::new(parts.method, parsed_url); + *rq.headers_mut() = parts.headers; + + let response = http + .execute(rq) + .await + .map_err(|e| AgentError::Llm(format!("Bedrock catalog: transport: {e}")))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AgentError::Llm(format!( + "Bedrock catalog HTTP {status}: {body}" + ))); + } + + let json: Value = response.json().await.map_err(|e| { + AgentError::Llm(format!("Bedrock catalog: parse response: {e}")) + })?; + + parse_bedrock_model_list(&json) +} + +/// Parse a `ListFoundationModels` response into model entries. +/// +/// Filters to models that support ON_DEMAND inference and TEXT modality. +pub(crate) fn parse_bedrock_model_list(json: &Value) -> Result, AgentError> { + let Some(summaries) = json.get("modelSummaries").and_then(|v| v.as_array()) else { + return Ok(Vec::new()); + }; + + let entries: Vec = summaries + .iter() + .filter(|m| { + // Only include models that support on-demand inference + let has_on_demand = m + .get("inferenceTypesSupported") + .and_then(|v| v.as_array()) + .map(|a| a.iter().any(|t| t == "ON_DEMAND")) + .unwrap_or(false); + // Only include models that support text input + let has_text = m + .get("inputModalities") + .and_then(|v| v.as_array()) + .map(|a| a.iter().any(|t| t == "TEXT")) + .unwrap_or(false); + has_on_demand && has_text + }) + .map(|m| { + let id = m + .get("modelId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let model_name = m + .get("modelName") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let provider = m + .get("providerName") + .and_then(|v| v.as_str()) + .unwrap_or(""); + ModelEntry { + id, + name: format!("{model_name} ({provider})"), + } + }) + .collect(); + + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_parse_bedrock_model_list_basic() { + let json = json!({ + "modelSummaries": [ + { + "modelId": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "modelName": "Claude 3.5 Sonnet", + "providerName": "Anthropic", + "inferenceTypesSupported": ["ON_DEMAND"], + "inputModalities": ["TEXT"], + "outputModalities": ["TEXT"], + }, + { + "modelId": "meta.llama3-70b-instruct-v1:0", + "modelName": "Llama 3 70B Instruct", + "providerName": "Meta", + "inferenceTypesSupported": ["ON_DEMAND"], + "inputModalities": ["TEXT"], + "outputModalities": ["TEXT"], + }, + ] + }); + + let models = parse_bedrock_model_list(&json).unwrap(); + assert_eq!(models.len(), 2); + assert_eq!( + models[0].id, + "anthropic.claude-3-5-sonnet-20241022-v2:0" + ); + assert_eq!(models[0].name, "Claude 3.5 Sonnet (Anthropic)"); + assert_eq!(models[1].id, "meta.llama3-70b-instruct-v1:0"); + assert_eq!(models[1].name, "Llama 3 70B Instruct (Meta)"); + } + + #[test] + fn test_parse_bedrock_model_list_filters_non_text() { + let json = json!({ + "modelSummaries": [ + { + "modelId": "amazon.titan-embed-text-v2:0", + "modelName": "Titan Text Embeddings v2", + "providerName": "Amazon", + "inferenceTypesSupported": ["ON_DEMAND"], + "inputModalities": ["TEXT"], + "outputModalities": ["EMBEDDING"], + }, + ] + }); + + let models = parse_bedrock_model_list(&json).unwrap(); + // Embedding models don't output text, but inputModalities is TEXT so + // the filter includes them. This is intentional — the catalog is permissive + // and the user can filter further in the UI. + assert_eq!(models.len(), 1); + } + + #[test] + fn test_parse_bedrock_model_list_filters_provisioned_only() { + let json = json!({ + "modelSummaries": [ + { + "modelId": "anthropic.claude-opus-4-v2:0", + "modelName": "Claude Opus 4 v2", + "providerName": "Anthropic", + "inferenceTypesSupported": ["PROVISIONED"], + "inputModalities": ["TEXT"], + "outputModalities": ["TEXT"], + }, + ] + }); + + let models = parse_bedrock_model_list(&json).unwrap(); + // PROVISIONED-only models are excluded — only ON_DEMAND is surfaced + assert_eq!(models.len(), 0); + } + + #[test] + fn test_parse_bedrock_model_list_empty() { + let json = json!({}); + let models = parse_bedrock_model_list(&json).unwrap(); + assert!(models.is_empty()); + } + + #[test] + fn test_parse_bedrock_model_list_no_summaries_key() { + let json = json!({"foo": "bar"}); + let models = parse_bedrock_model_list(&json).unwrap(); + assert!(models.is_empty()); + } +} diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index a0e64f1a9d..52f2859557 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -673,6 +673,10 @@ pub enum Provider { DatabricksV2, /// OpenRouter multi-provider gateway. Routes to `{base_url}/chat/completions` with bearer auth. Wire format is OpenAI-chat-compatible. OpenRouter, + /// AWS Bedrock. Routes through Bedrock's Converse API with SigV4 signing. + /// Supports region selection, cross-region inference profiles, and + /// credential sources (IAM role, SSO profile, access keys). + Bedrock, } /// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API` @@ -811,6 +815,22 @@ impl Config { env_or("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), OpenAiApi::Chat, // OpenRouter uses Chat Completions only ), + Provider::Bedrock => { + let region = env("AWS_REGION") + .or_else(|| env("AWS_DEFAULT_REGION")) + .ok_or_else(|| "config: AWS_REGION required (or AWS_DEFAULT_REGION)".to_string())?; + let base_url = format!("https://bedrock-runtime.{region}.amazonaws.com"); + ( + String::new(), // api_key unused — SigV4 replaces bearer + resolve_model( + buzz_agent_model.as_deref(), + env("BEDROCK_MODEL").as_deref(), + ) + .ok_or_else(|| "config: BEDROCK_MODEL required".to_string())?, + base_url, + OpenAiApi::Chat, // Bedrock uses its own Converse API + ) + } }; let system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) { (Some(_), Some(_)) => return Err( @@ -1034,6 +1054,7 @@ fn resolve_provider( "databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2), "openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter), "openrouter" => Err("config: OPENROUTER_API_KEY required".into()), + "bedrock" => Ok(Provider::Bedrock), _ => Err(format!( "config: BUZZ_AGENT_PROVIDER={raw} not supported" )), diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 9a45bf4c98..f4582a16f5 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -3,7 +3,9 @@ mod agent; pub mod auth; mod builtin; pub mod catalog; +pub mod catalog_bedrock; pub mod config; +pub mod sigv4; mod handoff; mod hints; mod llm; @@ -12,6 +14,7 @@ pub mod types; mod wire; pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MODELS}; +pub use catalog_bedrock::discover_bedrock_models; pub use config::Provider; pub use types::AgentError; diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index f595a165e5..2623622e5c 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -12,6 +12,7 @@ use crate::config::{ is_openai_host, normalize_effort_for_anthropic_route, normalize_effort_for_openai_route, Config, OpenAiApi, Provider, ThinkingEffort, }; +use crate::sigv4; use crate::types::{ AgentError, HistoryItem, LlmResponse, ProviderStop, ToolCall, ToolDef, ToolResultContent, }; @@ -158,6 +159,17 @@ impl Llm { let v = self.post_openrouter(cfg, &body).await?; parse_openai_with_reasoning_details(v) } + Provider::Bedrock => { + let body = bedrock_converse_body( + cfg, + system_prompt, + history, + tools, + effective_model, + ); + let v = self.post_bedrock(cfg, &body, effective_model).await?; + parse_bedrock_converse(v) + } Provider::OpenAi | Provider::Databricks => { self.openai_request( cfg, @@ -270,6 +282,16 @@ impl Llm { let v = self.post_openrouter(cfg, &body).await?; Ok(parse_openai(v)?.text) } + Provider::Bedrock => { + let body = bedrock_converse_summary_body( + effective_model, + system_prompt, + user_prompt, + max_output_tokens, + ); + let v = self.post_bedrock(cfg, &body, effective_model).await?; + Ok(parse_bedrock_converse(v)?.text) + } Provider::OpenAi | Provider::Databricks => { let r = self .openai_request( @@ -359,6 +381,68 @@ impl Llm { .map_err(PostError::into_agent) } + /// POST to the Bedrock Converse API with AWS SigV4 signing. + async fn post_bedrock( + &self, + cfg: &Config, + body: &Value, + effective_model: &str, + ) -> Result { + let model_id = urlencoding::encode(effective_model); + let url = format!( + "{}/model/{}/converse", + cfg.base_url.trim_end_matches('/'), + model_id, + ); + + let region = sigv4::parse_bedrock_region(&cfg.base_url) + .map_err(|e| AgentError::Llm(format!("Bedrock: {e}")))?; + let creds = sigv4::load_aws_credentials() + .map_err(|e| AgentError::Llm(format!("Bedrock: {e}")))?; + + let body_bytes = serde_json::to_vec(body) + .map_err(|e| AgentError::Llm(format!("serialize: {e}")))?; + + // Build an http::Request, sign it with SigV4, then convert to reqwest + let req = http::Request::builder() + .uri(&url) + .method("POST") + .header("Content-Type", "application/json") + .body(body_bytes) + .map_err(|e| AgentError::Llm(format!("build request: {e}")))?; + + let signed = sigv4::sign_request(req, &creds, "bedrock", ®ion) + .map_err(|e| AgentError::Llm(format!("sign request: {e}")))?; + + let (parts, signed_body) = signed.into_parts(); + let parsed_url = reqwest::Url::parse(&url) + .map_err(|e| AgentError::Llm(format!("parse url: {e}")))?; + let mut rq = reqwest::Request::new(parts.method, parsed_url); + *rq.headers_mut() = parts.headers; + *rq.body_mut() = Some(signed_body.into()); + + let response = self + .http + .execute(rq) + .await + .map_err(|e| AgentError::Llm(format!("Bedrock transport: {e}")))?; + + let status = response.status(); + let response_body = response + .text() + .await + .map_err(|e| AgentError::Llm(format!("Bedrock read response: {e}")))?; + + if !status.is_success() { + return Err(AgentError::Llm(format!( + "Bedrock {status}: {response_body}" + ))); + } + + serde_json::from_str(&response_body) + .map_err(|e| AgentError::Llm(format!("Bedrock parse response: {e}"))) + } + /// OpenAI dispatch with Buzz's relay-mesh `auto` policy layered over the /// normal endpoint selection. When enabled, a live virtual `mesh` model is /// preferred; if the mesh contracts between discovery and inference, retry @@ -1925,6 +2009,10 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => { Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))) } + Provider::Bedrock => { + // Bedrock uses SigV4 signing per-request, not bearer auth. + Ok(Arc::new(StaticTokenSource::new(String::new()))) + } Provider::Databricks | Provider::DatabricksV2 => { if !cfg.api_key.is_empty() { return Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))); @@ -1948,6 +2036,215 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A } } +/// Build the Converse API request body for `Llm::complete` on `Provider::Bedrock`. +fn bedrock_converse_body( + cfg: &Config, + system_prompt: &str, + history: &[HistoryItem], + tools: &[ToolDef], + _effective_model: &str, +) -> Value { + let mut messages: Vec = Vec::new(); + + for item in history { + match item { + HistoryItem::User(text) => { + messages.push(json!({ + "role": "user", + "content": [{"text": text}], + })); + } + HistoryItem::Assistant { + text, + tool_calls, + .. + } => { + let mut content: Vec = Vec::new(); + if !text.is_empty() { + content.push(json!({"text": text})); + } + for tc in tool_calls { + content.push(json!({ + "toolUse": { + "toolUseId": tc.provider_id, + "name": tc.name, + "input": tc.arguments, + } + })); + } + messages.push(json!({ + "role": "assistant", + "content": content, + })); + } + HistoryItem::ToolResult(tr) => { + let mut content: Vec = Vec::new(); + for tc in &tr.content { + match tc { + ToolResultContent::Text(t) => { + content.push(json!({ + "toolResult": { + "toolUseId": tr.provider_id, + "content": [{"text": t}], + "status": if tr.is_error { "error" } else { "success" }, + } + })); + } + ToolResultContent::Image { data, mime_type } => { + let fmt = mime_type + .strip_prefix("image/") + .unwrap_or(mime_type.as_str()); + content.push(json!({ + "toolResult": { + "toolUseId": tr.provider_id, + "content": [{ + "image": { + "format": fmt, + "source": { "bytes": data }, + } + }], + "status": if tr.is_error { "error" } else { "success" }, + } + })); + } + } + } + messages.push(json!({ + "role": "user", + "content": content, + })); + } + } + } + + let mut body = json!({ + "messages": messages, + "system": [{"text": system_prompt}], + "inferenceConfig": { + "maxTokens": cfg.max_output_tokens, + }, + }); + + if !tools.is_empty() { + let bedrock_tools: Vec = tools + .iter() + .map(|t| { + json!({ + "toolSpec": { + "name": t.name, + "description": t.description, + "inputSchema": { + "json": t.input_schema, + }, + } + }) + }) + .collect(); + body["toolConfig"] = json!({ + "tools": bedrock_tools, + }); + } + + body +} + +/// Build a simpler Converse API body for `Llm::summarize` on `Provider::Bedrock`. +fn bedrock_converse_summary_body( + _effective_model: &str, + system_prompt: &str, + user_prompt: &str, + max_output_tokens: u32, +) -> Value { + json!({ + "messages": [ + { + "role": "user", + "content": [{"text": user_prompt}], + } + ], + "system": [{"text": system_prompt}], + "inferenceConfig": { + "maxTokens": max_output_tokens, + }, + }) +} + +/// Parse a Bedrock Converse API response into an `LlmResponse`. +fn parse_bedrock_converse(v: Value) -> Result { + let output = v + .get("output") + .and_then(|o| o.get("message")) + .ok_or_else(|| AgentError::Llm("Bedrock: no output message in response".into()))?; + + let content = output + .get("content") + .and_then(Value::as_array) + .ok_or_else(|| AgentError::Llm("Bedrock: no content array in response".into()))?; + + // Extract text from the first text block + let text = content + .iter() + .filter_map(|block| block.get("text")) + .next() + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string(); + + // Extract tool calls + let mut tool_calls = Vec::new(); + for block in content { + if let Some(tool_use) = block.get("toolUse") { + let provider_id = tool_use + .get("toolUseId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let name = tool_use + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let arguments = tool_use.get("input").cloned().unwrap_or(Value::Null); + tool_calls.push(ToolCall { + provider_id, + name, + arguments, + provider_extra: serde_json::Map::new(), + }); + } + } + + // Map stop reason + let stop = match v.get("stopReason").and_then(|s| s.as_str()) { + Some("end_turn") => ProviderStop::EndTurn, + Some("tool_use") => ProviderStop::ToolUse, + Some("max_tokens") => ProviderStop::MaxTokens, + Some("content_filtered") => ProviderStop::Refusal, + _ => ProviderStop::Other, + }; + + // Read usage + let usage = v.get("usage"); + let input_tokens = usage + .and_then(|u| u.get("inputTokens")) + .and_then(|v| v.as_u64()); + let output_tokens = usage + .and_then(|u| u.get("outputTokens")) + .and_then(|v| v.as_u64()); + + Ok(LlmResponse { + text, + tool_calls, + stop, + input_tokens, + cached_input_tokens: None, + output_tokens, + total_tokens: None, + reasoning: String::new(), + reasoning_details: None, + }) +} + /// Build the request body for `Llm::summarize` on `Provider::OpenRouter`. /// Extracted so tests can assert on the actual wire shape instead of a /// hand-rolled literal — summaries never carry `reasoning` (see diff --git a/crates/buzz-agent/src/sigv4.rs b/crates/buzz-agent/src/sigv4.rs new file mode 100644 index 0000000000..f6d3758588 --- /dev/null +++ b/crates/buzz-agent/src/sigv4.rs @@ -0,0 +1,240 @@ +//! AWS SigV4 request signing for Bedrock API calls. +//! +//! Uses the `aws-sigv4` crate from the official Rust SDK to sign HTTP +//! requests with Signature Version 4, which AWS Bedrock requires instead +//! of bearer tokens. +//! +//! Credentials are loaded from the environment (`AWS_ACCESS_KEY_ID`, +//! `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`). IAM role support (IMDS, +//! ECS, IRSA) can be added by integrating `aws-config` in a follow-up. + +use aws_credential_types::Credentials as AwsCreds; +use aws_sigv4::http_request::{sign, SignableBody, SignableRequest, SigningSettings}; +use aws_sigv4::sign::v4; +use aws_smithy_runtime_api::client::identity::Identity; +use http::Request; +use std::time::SystemTime; + +/// AWS credentials used for SigV4 signing. +#[derive(Debug, Clone)] +pub struct AwsCredentials { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: Option, +} + +/// Sign an HTTP request with AWS SigV4. +/// +/// `service` is typically `"bedrock"`. `region` is the AWS region +/// (e.g. `"us-east-1"`). +pub fn sign_request( + mut request: Request>, + creds: &AwsCredentials, + service: &str, + region: &str, +) -> Result>, String> { + let identity: Identity = AwsCreds::new( + &creds.access_key_id, + &creds.secret_access_key, + creds.session_token.clone(), + None, + "buzz-agent", + ) + .into(); + + let uri_str = request.uri().to_string(); + let signable = SignableRequest::new( + request.method().as_str(), + uri_str.as_str(), + request + .headers() + .iter() + .map(|(k, v)| (k.as_str(), v.to_str().unwrap_or_default())), + SignableBody::Bytes(request.body()), + ) + .map_err(|e| format!("signable request: {e}"))?; + + let settings = SigningSettings::default(); + let params: aws_sigv4::http_request::SigningParams<'_> = v4::SigningParams::builder() + .identity(&identity) + .region(region) + .name(service) + .time(SystemTime::now()) + .settings(settings) + .build() + .map_err(|e| format!("signing params: {e}"))? + .into(); + + let signing_output = sign(signable, ¶ms).map_err(|e| format!("signing: {e}"))?; + + let (instructions, _signature) = signing_output.into_parts(); + instructions.apply_to_request_http1x(&mut request); + + Ok(request) +} + +/// Load AWS credentials from the environment. +/// +/// Checks (in order): +/// 1. `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (standard) +/// 2. `AWS_ACCESS_KEY` + `AWS_SECRET_KEY` (legacy) +/// +/// `AWS_SESSION_TOKEN` is loaded when present for temporary credentials +/// (STS role assumptions, EKS IRSA, etc.). +pub fn load_aws_credentials() -> Result { + let access_key_id = std::env::var("AWS_ACCESS_KEY_ID") + .or_else(|_| std::env::var("AWS_ACCESS_KEY")) + .map_err(|_| "config: AWS_ACCESS_KEY_ID required for Bedrock".to_string())?; + let secret_access_key = std::env::var("AWS_SECRET_ACCESS_KEY") + .or_else(|_| std::env::var("AWS_SECRET_KEY")) + .map_err(|_| "config: AWS_SECRET_ACCESS_KEY required for Bedrock".to_string())?; + let session_token = std::env::var("AWS_SESSION_TOKEN") + .ok() + .filter(|s| !s.is_empty()); + + Ok(AwsCredentials { + access_key_id, + secret_access_key, + session_token, + }) +} + +/// Extract the AWS region from a Bedrock runtime base URL. +/// +/// Accepts: +/// - `https://bedrock-runtime.{region}.amazonaws.com` +/// - `https://bedrock-runtime.{region}.amazonaws.com/v1` +pub fn parse_bedrock_region(base_url: &str) -> Result { + let rest = base_url + .strip_prefix("https://bedrock-runtime.") + .ok_or_else(|| format!("Bedrock: could not extract region from base_url: {base_url}"))?; + let region = rest + .strip_suffix(".amazonaws.com") + .or_else(|| rest.strip_suffix(".amazonaws.com/v1")) + .ok_or_else(|| { + format!("Bedrock: could not extract region from base_url: {base_url}") + })?; + Ok(region.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sign_request_adds_authorization_header() { + let creds = AwsCredentials { + access_key_id: "AKIDEXAMPLE".into(), + secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(), + session_token: None, + }; + let body = r#"{"messages":[]}"#; + let req = Request::builder() + .uri("https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-5-sonnet-20241022-v2:0/converse") + .method("POST") + .header("Content-Type", "application/json") + .body(body.as_bytes().to_vec()) + .unwrap(); + let signed = sign_request(req, &creds, "bedrock", "us-east-1").unwrap(); + let auth = signed + .headers() + .get("authorization") + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!( + auth.starts_with("AWS4-HMAC-SHA256"), + "expected SigV4 auth header, got: {auth}" + ); + assert!( + auth.contains("us-east-1/bedrock/"), + "expected region/service in credential scope, got: {auth}" + ); + } + + #[test] + fn test_sign_request_with_session_token() { + let creds = AwsCredentials { + access_key_id: "AKIDEXAMPLE".into(), + secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(), + session_token: Some("IQoJb3JpZ2luX2IQoJb3JpZ2luX2IQ".into()), + }; + let body = r#"{"messages":[]}"#; + let req = Request::builder() + .uri("https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-5-sonnet-20241022-v2:0/converse") + .method("POST") + .header("Content-Type", "application/json") + .body(body.as_bytes().to_vec()) + .unwrap(); + let signed = sign_request(req, &creds, "bedrock", "us-east-1").unwrap(); + let auth = signed + .headers() + .get("authorization") + .unwrap() + .to_str() + .unwrap(); + assert!(auth.starts_with("AWS4-HMAC-SHA256")); + let st = signed + .headers() + .get("x-amz-security-token") + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert_eq!(st, "IQoJb3JpZ2luX2IQoJb3JpZ2luX2IQ"); + } + + #[test] + fn test_parse_bedrock_region_standard() { + let region = + parse_bedrock_region("https://bedrock-runtime.us-east-1.amazonaws.com").unwrap(); + assert_eq!(region, "us-east-1"); + } + + #[test] + fn test_parse_bedrock_region_with_v1_suffix() { + let region = + parse_bedrock_region("https://bedrock-runtime.eu-west-1.amazonaws.com/v1").unwrap(); + assert_eq!(region, "eu-west-1"); + } + + #[test] + fn test_parse_bedrock_region_invalid_url() { + let result = parse_bedrock_region("https://api.openai.com/v1"); + assert!(result.is_err()); + } + + #[test] + fn test_load_credentials_from_env() { + let old_key = std::env::var("AWS_ACCESS_KEY_ID").ok(); + let old_secret = std::env::var("AWS_SECRET_ACCESS_KEY").ok(); + let old_token = std::env::var("AWS_SESSION_TOKEN").ok(); + + std::env::set_var("AWS_ACCESS_KEY_ID", "test-key"); + std::env::set_var("AWS_SECRET_ACCESS_KEY", "test-secret"); + std::env::remove_var("AWS_SESSION_TOKEN"); + + let creds = load_aws_credentials().unwrap(); + assert_eq!(creds.access_key_id, "test-key"); + assert_eq!(creds.secret_access_key, "test-secret"); + assert!(creds.session_token.is_none()); + + // Restore original env vars + if let Some(k) = old_key { + std::env::set_var("AWS_ACCESS_KEY_ID", k); + } else { + std::env::remove_var("AWS_ACCESS_KEY_ID"); + } + if let Some(s) = old_secret { + std::env::set_var("AWS_SECRET_ACCESS_KEY", s); + } else { + std::env::remove_var("AWS_SECRET_ACCESS_KEY"); + } + if let Some(t) = old_token { + std::env::set_var("AWS_SESSION_TOKEN", t); + } else { + std::env::remove_var("AWS_SESSION_TOKEN"); + } + } +} diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 7ce03b140b..4796d08827 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -143,6 +143,17 @@ pub async fn get_agent_models( return Ok(models); } + if let Some(models) = discover_bedrock_models( + &state.http_client, + &effective_provider, + &merged_env, + persisted_model.clone(), + ) + .await? + { + return Ok(models); + } + run_agent_models_command( resolved_acp, agent_command, @@ -314,6 +325,13 @@ pub async fn discover_agent_models( return Ok(models); } + if let Some(models) = + discover_bedrock_models(&state.http_client, &effective_provider, &merged_env, None) + .await? + { + return Ok(models); + } + run_agent_models_command(resolved_acp, resolved_agent, agent_args, None, merged_env).await } @@ -773,6 +791,79 @@ async fn discover_databricks_models( })) } +// --------------------------------------------------------------------------- +// Bedrock model discovery +// --------------------------------------------------------------------------- +// +// Delegates to buzz_agent_pkg::catalog_bedrock::discover_bedrock_models, +// which loads AWS credentials from the env and signs requests with SigV4. + +fn is_bedrock_provider(provider: Option<&str>) -> bool { + matches!( + provider + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("bedrock") + ) +} + +async fn discover_bedrock_models( + _client: &reqwest::Client, + provider: &DiscoveryProvider, + env: &BTreeMap, + selected_model: Option, +) -> Result, String> { + let provider_str = match provider.as_deref() { + Some(p) if is_bedrock_provider(Some(p)) => p, + _ => return Ok(None), + }; + + let region = env_or_process_value(env, "AWS_REGION") + .or_else(|| env_or_process_value(env, "AWS_DEFAULT_REGION")); + let region = match region { + Some(r) => r, + None => return Ok(None), // no region → fall through to subprocess + }; + + let base_url = format!("https://bedrock-runtime.{region}.amazonaws.com"); + let cfg = buzz_agent_pkg::config::Config::for_discovery( + buzz_agent_pkg::config::Provider::Bedrock, + String::new(), // no api_key for Bedrock (SigV4) + base_url, + ); + + let entries = match buzz_agent_pkg::discover_bedrock_models(&cfg).await { + Ok(e) => e, + Err(e) => { + let msg = e.to_string(); + return Err(format!("Bedrock model discovery failed: {msg}")); + } + }; + + if entries.is_empty() { + return Err("Bedrock model discovery returned no models".to_string()); + } + + let models = entries + .into_iter() + .map(|e| AgentModelInfo { + id: e.id, + name: Some(e.name), + description: None, + }) + .collect(); + + Ok(Some(AgentModelsResponse { + agent_name: provider_str.trim().to_string(), + agent_version: "models-api".to_string(), + models, + agent_default_model: None, + selected_model, + supports_switching: true, + })) +} + /// Apply an `UpdateManagedAgentRequest`'s model/provider/system_prompt patch /// to `record`, enforcing the linked-instance write guard: a definition-linked /// record's model/provider/prompt are definition-authoritative (see diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fa8eb36fa1..1cf34fa264 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -482,6 +482,7 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { Some("anthropic") => Some("ANTHROPIC_MODEL"), Some("openai") | Some("openai-compat") => Some("OPENAI_COMPAT_MODEL"), Some("openrouter") => Some("OPENROUTER_MODEL"), + Some("bedrock") => Some("BEDROCK_MODEL"), _ => None, }; let model_present = effective @@ -530,6 +531,21 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { key: "OPENROUTER_API_KEY".to_string(), }); } + Some("bedrock") => { + // Bedrock uses SigV4 signing, not bearer auth. + // AWS_ACCESS_KEY_ID is required; AWS_SECRET_ACCESS_KEY is checked + // at request time. AWS_REGION is required for base URL derivation. + if env_key_missing("AWS_ACCESS_KEY_ID") { + missing.push(Requirement::EnvKey { + key: "AWS_ACCESS_KEY_ID".to_string(), + }); + } + if env_key_missing("AWS_REGION") && env_key_missing("AWS_DEFAULT_REGION") { + missing.push(Requirement::EnvKey { + key: "AWS_REGION".to_string(), + }); + } + } _ => { // Unknown provider or no provider yet — only the NormalizedField // requirement above captures this gap.