Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
293 changes: 251 additions & 42 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions crates/buzz-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
224 changes: 224 additions & 0 deletions crates/buzz-agent/src/catalog_bedrock.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<ModelEntry>, 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", &region)
.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<Vec<ModelEntry>, AgentError> {
let Some(summaries) = json.get("modelSummaries").and_then(|v| v.as_array()) else {
return Ok(Vec::new());
};

let entries: Vec<ModelEntry> = 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());
}
}
21 changes: 21 additions & 0 deletions crates/buzz-agent/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
)),
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down
Loading