From 79e2b9cb9110543cd158d93a8f7b6c29ed1abf0d Mon Sep 17 00:00:00 2001 From: Chukwuebuka-2003 Date: Thu, 30 Jul 2026 06:21:33 +0000 Subject: [PATCH 1/2] feat(agent): add Provider::Bedrock variant with env-var config Signed-off-by: Chukwuebuka-2003 --- crates/buzz-agent/Cargo.toml | 4 ++++ crates/buzz-agent/src/config.rs | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index 7889ad34a7..41a5180487 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -43,6 +43,10 @@ hex = { workspace = true } sha2 = { workspace = true } urlencoding = "2" webbrowser = "1" +# AWS SigV4 signing for Bedrock provider +aws-sigv4 = "1.2" +aws-credential-types = "1.2" +aws-types = "1.2" [target.'cfg(unix)'.dependencies] nix = { version = "0.31", default-features = false, features = ["signal", "process"] } 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" )), From fb95bcb1da540e2d0dafba059d5a226e33e5e042 Mon Sep 17 00:00:00 2001 From: Chukwuebuka-2003 Date: Thu, 30 Jul 2026 06:21:44 +0000 Subject: [PATCH 2/2] feat(agent): add AWS SigV4 signing module and Bedrock LLM stubs - Add sigv4.rs module with sign_request(), load_aws_credentials(), parse_bedrock_region() - Add http/aws-smithy-runtime-api dependencies - Add Provider::Bedrock match arms in llm.rs (placeholders returning errors) - Enable aws-sigv4 http1 feature for apply_to_request_http1x - All 6 SigV4 unit tests passing Signed-off-by: Chukwuebuka-2003 --- Cargo.lock | 293 ++++++++++++++++++++++++++++----- crates/buzz-agent/Cargo.toml | 4 +- crates/buzz-agent/src/lib.rs | 1 + crates/buzz-agent/src/llm.rs | 12 +- crates/buzz-agent/src/sigv4.rs | 240 +++++++++++++++++++++++++++ 5 files changed, 506 insertions(+), 44 deletions(-) create mode 100644 crates/buzz-agent/src/sigv4.rs 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 41a5180487..addc1f1b03 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -44,9 +44,11 @@ sha2 = { workspace = true } urlencoding = "2" webbrowser = "1" # AWS SigV4 signing for Bedrock provider -aws-sigv4 = "1.2" +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/lib.rs b/crates/buzz-agent/src/lib.rs index 9a45bf4c98..3b423507f3 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -4,6 +4,7 @@ pub mod auth; mod builtin; pub mod catalog; pub mod config; +pub mod sigv4; mod handoff; mod hints; mod llm; diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index f595a165e5..f73887ae44 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -158,6 +158,11 @@ impl Llm { let v = self.post_openrouter(cfg, &body).await?; parse_openai_with_reasoning_details(v) } + Provider::Bedrock => { + return Err(AgentError::Llm( + "Bedrock provider not yet implemented for completion".into(), + )); + } Provider::OpenAi | Provider::Databricks => { self.openai_request( cfg, @@ -270,6 +275,11 @@ impl Llm { let v = self.post_openrouter(cfg, &body).await?; Ok(parse_openai(v)?.text) } + Provider::Bedrock => { + return Err(AgentError::Llm( + "Bedrock provider not yet implemented for summarize".into(), + )); + } Provider::OpenAi | Provider::Databricks => { let r = self .openai_request( @@ -1922,7 +1932,7 @@ where /// flow; subsequent requests use the cache + refresh transparently. pub(crate) fn build_token_source(cfg: &Config) -> Result, AgentError> { match cfg.provider { - Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => { + Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter | Provider::Bedrock => { Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))) } Provider::Databricks | Provider::DatabricksV2 => { 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"); + } + } +}