use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use agent_framework_bedrock::sigv4::{self, SigV4Params};
use agent_framework_core::client::{ChatClient, ChatStream};
use agent_framework_core::error::{Error, Result};
use agent_framework_core::types::{
ChatOptions, ChatResponse, ChatResponseUpdate, Message, Role, UsageContent,
};
use futures::stream::{self, StreamExt};
use serde_json::Value;
use crate::convert;
pub const ANTHROPIC_BEDROCK_VERSION: &str = "bedrock-2023-05-31";
const DEFAULT_MAX_TOKENS: u32 = 1024;
const AWS_REGION_ENV: &str = "AWS_REGION";
const AWS_DEFAULT_REGION_ENV: &str = "AWS_DEFAULT_REGION";
const AWS_ACCESS_KEY_ID_ENV: &str = "AWS_ACCESS_KEY_ID";
const AWS_SECRET_ACCESS_KEY_ENV: &str = "AWS_SECRET_ACCESS_KEY";
const AWS_SESSION_TOKEN_ENV: &str = "AWS_SESSION_TOKEN";
const DEFAULT_REGION: &str = "us-east-1";
const SIGV4_SERVICE: &str = "bedrock";
fn bedrock_host(region: &str) -> String {
format!("bedrock-runtime.{region}.amazonaws.com")
}
fn uri_encode_model(model: &str) -> String {
let mut out = String::with_capacity(model.len());
for byte in model.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
fn invoke_path(model: &str) -> String {
format!("/model/{}/invoke", uri_encode_model(model))
}
fn classify_invoke_error(status: u16, message: impl Into<String>) -> Error {
let message = message.into();
match status {
401 | 403 => Error::service_invalid_auth(message),
400 => Error::service_invalid_request(message),
_ => Error::service_status(status, message, None),
}
}
#[derive(Clone)]
pub struct AnthropicBedrockClient {
inner: Arc<Inner>,
}
#[derive(Clone)]
struct Inner {
http: reqwest::Client,
region: String,
host: String,
model: String,
access_key_id: String,
secret_access_key: String,
session_token: Option<String>,
max_tokens: u32,
}
impl std::fmt::Debug for AnthropicBedrockClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnthropicBedrockClient")
.field("region", &self.inner.region)
.field("model", &self.inner.model)
.field("max_tokens", &self.inner.max_tokens)
.field("has_session_token", &self.inner.session_token.is_some())
.finish_non_exhaustive()
}
}
impl AnthropicBedrockClient {
pub fn new(
access_key_id: impl Into<String>,
secret_access_key: impl Into<String>,
region: impl Into<String>,
model: impl Into<String>,
) -> Self {
let region = region.into();
Self {
inner: Arc::new(Inner {
http: reqwest::Client::new(),
host: bedrock_host(®ion),
region,
model: model.into(),
access_key_id: access_key_id.into(),
secret_access_key: secret_access_key.into(),
session_token: None,
max_tokens: DEFAULT_MAX_TOKENS,
}),
}
}
pub fn from_env(model: impl Into<String>) -> Result<Self> {
let access_key_id = std::env::var(AWS_ACCESS_KEY_ID_ENV)
.map_err(|_| Error::Configuration(format!("{AWS_ACCESS_KEY_ID_ENV} is not set")))?;
let secret_access_key = std::env::var(AWS_SECRET_ACCESS_KEY_ENV)
.map_err(|_| Error::Configuration(format!("{AWS_SECRET_ACCESS_KEY_ENV} is not set")))?;
let region = std::env::var(AWS_REGION_ENV)
.or_else(|_| std::env::var(AWS_DEFAULT_REGION_ENV))
.unwrap_or_else(|_| DEFAULT_REGION.to_string());
let mut client = Self::new(access_key_id, secret_access_key, region, model);
if let Ok(token) = std::env::var(AWS_SESSION_TOKEN_ENV) {
if !token.trim().is_empty() {
client = client.with_session_token(token);
}
}
Ok(client)
}
pub fn with_session_token(mut self, session_token: impl Into<String>) -> Self {
Arc::make_mut(&mut self.inner).session_token = Some(session_token.into());
self
}
pub fn with_region(mut self, region: impl Into<String>) -> Self {
let region = region.into();
let inner = Arc::make_mut(&mut self.inner);
inner.host = bedrock_host(®ion);
inner.region = region;
self
}
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
Arc::make_mut(&mut self.inner).max_tokens = max_tokens;
self
}
pub fn model(&self) -> &str {
&self.inner.model
}
pub fn region(&self) -> &str {
&self.inner.region
}
fn effective_model(&self, options: &ChatOptions) -> String {
options
.model
.clone()
.unwrap_or_else(|| self.inner.model.clone())
}
async fn send(&self, model: &str, body: &Value) -> Result<reqwest::Response> {
let path = invoke_path(model);
let url = format!("https://{}{path}", self.inner.host);
let payload = serde_json::to_vec(body).map_err(|e| {
Error::Serialization(format!("failed to serialize Bedrock invoke request: {e}"))
})?;
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (amz_date, date_stamp) = sigv4::amz_dates_from_unix(secs);
let params = SigV4Params {
access_key: &self.inner.access_key_id,
secret_key: &self.inner.secret_access_key,
session_token: self.inner.session_token.as_deref(),
region: &self.inner.region,
service: SIGV4_SERVICE,
host: &self.inner.host,
method: "POST",
canonical_uri: &path,
canonical_query: "",
payload: &payload,
amz_date: &amz_date,
date_stamp: &date_stamp,
};
let (authorization, extra_headers) = sigv4::authorization_header(¶ms);
let mut request = self
.inner
.http
.post(&url)
.header(reqwest::header::AUTHORIZATION, authorization)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(payload);
for (name, value) in extra_headers {
request = request.header(name, value);
}
let resp = request
.send()
.await
.map_err(|e| Error::service(format!("request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(classify_invoke_error(
status.as_u16(),
format!("Bedrock InvokeModel error {status}: {text}"),
));
}
Ok(resp)
}
}
#[async_trait::async_trait]
impl ChatClient for AnthropicBedrockClient {
async fn get_response(
&self,
messages: Vec<Message>,
options: ChatOptions,
) -> Result<ChatResponse> {
let model = self.effective_model(&options);
let max_tokens = options.max_tokens.unwrap_or(self.inner.max_tokens);
let body = convert::build_cloud_request(
&messages,
&options,
max_tokens,
false,
ANTHROPIC_BEDROCK_VERSION,
);
let resp = self.send(&model, &body).await?;
let value: Value = resp
.json()
.await
.map_err(|e| Error::service(format!("invalid response json: {e}")))?;
Ok(convert::parse_response(&value))
}
async fn get_streaming_response(
&self,
messages: Vec<Message>,
options: ChatOptions,
) -> Result<ChatStream> {
let response = self.get_response(messages, options).await?;
let mut contents: Vec<_> = response
.messages
.iter()
.flat_map(|m| m.contents.iter().cloned())
.collect();
if let Some(usage) = response.usage_details.clone() {
contents.push(agent_framework_core::types::Content::Usage(UsageContent {
details: usage,
}));
}
let update = ChatResponseUpdate {
contents,
role: Some(Role::assistant()),
response_id: response.response_id.clone(),
model: response.model.clone(),
finish_reason: response.finish_reason.clone(),
..Default::default()
};
Ok(stream::once(async move { Ok(update) }).boxed())
}
fn model(&self) -> Option<&str> {
Some(&self.inner.model)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn client() -> AnthropicBedrockClient {
AnthropicBedrockClient::new(
"AKIDEXAMPLE",
"secret",
"us-east-1",
"anthropic.claude-3-5-sonnet-20241022-v2:0",
)
}
#[test]
fn invoke_path_encodes_colon_but_preserves_dots() {
assert_eq!(
invoke_path("anthropic.claude-3-5-sonnet-20241022-v2:0"),
"/model/anthropic.claude-3-5-sonnet-20241022-v2%3A0/invoke"
);
}
#[test]
fn new_derives_host_from_region() {
assert_eq!(
client().inner.host,
"bedrock-runtime.us-east-1.amazonaws.com"
);
}
#[test]
fn with_region_overrides_region_and_host() {
let c = client().with_region("eu-west-1");
assert_eq!(c.region(), "eu-west-1");
assert_eq!(c.inner.host, "bedrock-runtime.eu-west-1.amazonaws.com");
}
#[test]
fn model_and_region_accessors() {
let c = client();
assert_eq!(c.model(), "anthropic.claude-3-5-sonnet-20241022-v2:0");
assert_eq!(c.region(), "us-east-1");
assert_eq!(
ChatClient::model(&c),
Some("anthropic.claude-3-5-sonnet-20241022-v2:0")
);
}
#[test]
fn request_url_targets_invoke_endpoint() {
let c = client();
let path = invoke_path(&c.effective_model(&ChatOptions::new()));
let url = format!("https://{}{path}", c.inner.host);
assert_eq!(
url,
"https://bedrock-runtime.us-east-1.amazonaws.com/model/\
anthropic.claude-3-5-sonnet-20241022-v2%3A0/invoke"
);
}
#[test]
fn request_body_has_bedrock_anthropic_version_and_no_model_key() {
let body = convert::build_cloud_request(
&[Message::user("hi")],
&ChatOptions::new(),
1024,
false,
ANTHROPIC_BEDROCK_VERSION,
);
assert_eq!(
body["anthropic_version"],
serde_json::json!("bedrock-2023-05-31")
);
assert!(body.get("model").is_none());
}
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn clear_aws_env() {
unsafe {
std::env::remove_var(AWS_ACCESS_KEY_ID_ENV);
std::env::remove_var(AWS_SECRET_ACCESS_KEY_ENV);
std::env::remove_var(AWS_SESSION_TOKEN_ENV);
std::env::remove_var(AWS_REGION_ENV);
std::env::remove_var(AWS_DEFAULT_REGION_ENV);
}
}
#[test]
fn from_env_errors_without_credentials() {
let _guard = ENV_MUTEX.lock().unwrap();
clear_aws_env();
let result = AnthropicBedrockClient::from_env("anthropic.claude-3-haiku");
assert!(matches!(result, Err(Error::Configuration(_))));
clear_aws_env();
}
#[test]
fn from_env_reads_credentials_and_region() {
let _guard = ENV_MUTEX.lock().unwrap();
clear_aws_env();
unsafe {
std::env::set_var(AWS_ACCESS_KEY_ID_ENV, "AKIDEXAMPLE");
std::env::set_var(AWS_SECRET_ACCESS_KEY_ENV, "secret-123");
std::env::set_var(AWS_REGION_ENV, "eu-central-1");
}
let client = AnthropicBedrockClient::from_env("anthropic.claude-3-haiku").unwrap();
assert_eq!(client.inner.access_key_id, "AKIDEXAMPLE");
assert_eq!(client.region(), "eu-central-1");
clear_aws_env();
}
#[test]
fn from_env_defaults_region_when_unset() {
let _guard = ENV_MUTEX.lock().unwrap();
clear_aws_env();
unsafe {
std::env::set_var(AWS_ACCESS_KEY_ID_ENV, "AKIDEXAMPLE");
std::env::set_var(AWS_SECRET_ACCESS_KEY_ENV, "secret-123");
}
let client = AnthropicBedrockClient::from_env("anthropic.claude-3-haiku").unwrap();
assert_eq!(client.region(), DEFAULT_REGION);
clear_aws_env();
}
#[test]
fn classify_invoke_error_maps_auth_and_invalid_request() {
assert!(matches!(
classify_invoke_error(401, "denied"),
Error::ServiceInvalidAuth { .. }
));
assert!(matches!(
classify_invoke_error(403, "denied"),
Error::ServiceInvalidAuth { .. }
));
assert!(matches!(
classify_invoke_error(400, "bad request"),
Error::ServiceInvalidRequest { .. }
));
}
#[test]
fn classify_invoke_error_leaves_throttling_and_5xx_as_service_status() {
assert_eq!(classify_invoke_error(429, "throttled").status(), Some(429));
assert_eq!(classify_invoke_error(500, "boom").status(), Some(500));
}
}