use std::time::{Duration, Instant};
use async_trait::async_trait;
use tracing::{debug, warn};
use trusty_common::inference::{
BedrockAdapter, ChatMessage as InferenceChatMessage, ChatRequest, InferenceAdapter,
};
use super::{LlmProvider, LlmRequest, LlmResponse, error::SmLlmError, pricing};
const INFERENCE_PROFILE_PREFIXES: &[&str] = &["us.", "eu.", "ap.", "jp.", "global."];
const MAX_RETRIES: u32 = 3;
fn validate_model_id(model_id: &str) -> Result<(), SmLlmError> {
if INFERENCE_PROFILE_PREFIXES
.iter()
.any(|p| model_id.starts_with(p))
{
return Ok(());
}
Err(SmLlmError::Validation(format!(
"Bedrock model id {model_id:?} must start with a cross-region inference-profile \
prefix (us., eu., ap., jp., or global.). Example: \"us.anthropic.claude-sonnet-4-6\"."
)))
}
#[derive(Debug)]
pub struct BedrockProvider {
inner: BedrockAdapter,
pub model: String,
}
impl BedrockProvider {
pub async fn new(model: impl Into<String>, region: Option<&str>) -> Result<Self, SmLlmError> {
let model = model.into();
validate_model_id(&model)?;
Ok(Self {
inner: BedrockAdapter::new(region),
model,
})
}
pub fn region(&self) -> &str {
self.inner.region()
}
async fn call_once(&self, req: &LlmRequest) -> Result<LlmResponse, SmLlmError> {
let start = Instant::now();
if req.messages.is_empty() {
return Err(SmLlmError::Validation(
"LlmRequest contains no user/assistant messages".to_string(),
));
}
let mut messages: Vec<InferenceChatMessage> = Vec::with_capacity(req.messages.len() + 1);
if !req.system.is_empty() {
messages.push(InferenceChatMessage::system(req.system.clone()));
}
for m in &req.messages {
let msg = if m.role == "assistant" {
InferenceChatMessage::assistant(m.content.clone())
} else {
InferenceChatMessage::user(m.content.clone())
};
messages.push(msg);
}
let mut chat_req = ChatRequest::new(req.model.clone(), messages);
chat_req.temperature = Some(req.temperature);
chat_req.max_tokens = Some(req.max_tokens);
let resp = self
.inner
.chat(&chat_req)
.await
.map_err(|e| map_sdk_error(e.to_string(), &req.model, self.region()))?;
let latency_ms = start.elapsed().as_millis() as u64;
let text = resp.first_text().unwrap_or_default();
let usage = resp.usage();
let (input_tokens, output_tokens) = (usage.prompt_tokens, usage.completion_tokens);
let cost_usd = pricing::estimate_cost_usd(&req.model, input_tokens, output_tokens);
Ok(LlmResponse {
text,
model: req.model.clone(),
input_tokens,
output_tokens,
latency_ms,
cost_usd,
})
}
}
fn map_sdk_error(msg: String, model: &str, region: &str) -> SmLlmError {
let lower = msg.to_lowercase();
if lower.contains("resourcenotfound") || lower.contains("no such model") {
SmLlmError::ModelNotFound(format!("model={model}: {msg}"))
} else if lower.contains("accessdenied")
|| lower.contains("unauthorized")
|| lower.contains("credential")
|| lower.contains("not authorized")
|| lower.contains("no credentials")
{
SmLlmError::AccessDenied(format!(
"AWS Bedrock access denied (model={model}, region={region}): {msg}"
))
} else if lower.contains("validationexception") || lower.contains("validation") {
SmLlmError::Validation(msg)
} else if lower.contains("throttling") || lower.contains("throttled") || lower.contains("rate")
{
SmLlmError::RateLimited
} else if lower.contains("serviceunavailable") || lower.contains("internalserver") {
SmLlmError::Upstream {
status: 503,
body: msg,
}
} else if lower.contains("modelnotready") || lower.contains("not in active") {
SmLlmError::ModelNotReady(msg)
} else {
SmLlmError::Transport(format!(
"Bedrock Converse SDK error (model={model}, region={region}): {msg}"
))
}
}
#[async_trait]
impl LlmProvider for BedrockProvider {
fn name(&self) -> &str {
"bedrock"
}
async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, SmLlmError> {
debug!(
provider = "bedrock",
model = %req.model,
region = %self.region(),
"sm bedrock complete request"
);
let mut attempt = 0u32;
loop {
match self.call_once(&req).await {
Ok(resp) => {
debug!(
provider = "bedrock",
model = %resp.model,
input_tokens = resp.input_tokens,
output_tokens = resp.output_tokens,
latency_ms = resp.latency_ms,
cost_usd = resp.cost_usd,
"sm bedrock complete response"
);
return Ok(resp);
}
Err(err) if err.is_retryable() && attempt < MAX_RETRIES => {
attempt += 1;
let backoff_ms = 500u64 * (1u64 << attempt.min(6));
warn!(attempt, backoff_ms, model = %req.model, "sm bedrock retry: {err}");
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
}
Err(err) => return Err(err),
}
}
}
}
#[cfg(test)]
#[path = "bedrock_tests.rs"]
mod tests;