use std::borrow::Cow;
use crate::error::{LiterLlmError, Result};
use crate::provider::{Provider, StreamFormat};
mod embed;
mod request;
mod response;
mod stream;
use embed::{transform_bedrock_embed_request, transform_bedrock_embed_response};
pub(crate) use stream::parse_bedrock_stream_event;
const DEFAULT_REGION: &str = "us-east-1";
fn reasoning_effort_to_budget_tokens(effort: &str) -> u64 {
match effort {
"low" => 1024,
"medium" => 4096,
"high" => 16384,
_ => 4096,
}
}
fn format_from_media_type(media_type: &str) -> &str {
media_type.split('/').nth(1).unwrap_or("pdf")
}
fn convert_content_to_bedrock_blocks(content: Option<&serde_json::Value>) -> Vec<serde_json::Value> {
use serde_json::json;
if let Some(text) = content.and_then(|c| c.as_str()) {
vec![json!({"text": text})]
} else if let Some(array) = content.and_then(|c| c.as_array()) {
array
.iter()
.filter_map(|part| {
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
match part_type {
"text" => {
let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
Some(json!({"text": text}))
}
"image_url" => {
let url = part.pointer("/image_url/url").and_then(|u| u.as_str()).unwrap_or("");
if let Some(data_part) = url.strip_prefix("data:") {
let mut iter = data_part.splitn(2, ';');
let media_type = iter.next().unwrap_or("image/jpeg");
let b64 = iter.next().and_then(|s| s.strip_prefix("base64,")).unwrap_or("");
Some(json!({
"image": {
"format": media_type.split('/').nth(1).unwrap_or("jpeg"),
"source": {"bytes": b64}
}
}))
} else {
Some(json!({"text": url}))
}
}
"document" => {
let data = part.pointer("/document/data").and_then(|d| d.as_str()).unwrap_or("");
let media_type = part
.pointer("/document/media_type")
.and_then(|m| m.as_str())
.unwrap_or("application/pdf");
let format = format_from_media_type(media_type);
Some(json!({
"document": {
"name": "doc",
"format": format,
"source": {"bytes": data}
}
}))
}
_ => None,
}
})
.collect()
} else {
vec![json!({"text": ""})]
}
}
fn dns_suffix_for_region(region: &str) -> &'static str {
if region.starts_with("eusc-") {
"amazonaws.eu"
} else if region.starts_with("cn-") {
"amazonaws.com.cn"
} else {
"amazonaws.com"
}
}
fn percent_encode_model(model: &str) -> String {
let mut encoded = 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'~' => {
encoded.push(byte as char);
}
other => {
encoded.push('%');
let hi = char::from_digit(u32::from(other >> 4), 16).unwrap_or('0');
let lo = char::from_digit(u32::from(other & 0xf), 16).unwrap_or('0');
encoded.push(hi.to_ascii_uppercase());
encoded.push(lo.to_ascii_uppercase());
}
}
}
encoded
}
pub struct BedrockProvider {
region: String,
base_url: String,
cross_region_prefix: Option<String>,
access_key_id: Option<String>,
secret_access_key: Option<String>,
session_token: Option<String>,
dynamic_credentials: bool,
}
impl BedrockProvider {
#[must_use]
pub fn new(region: impl Into<String>) -> Self {
let region = region.into();
let custom_base_url = std::env::var("BEDROCK_BASE_URL")
.ok()
.filter(|v| !v.is_empty())
.map(|v| v.trim_end_matches('/').to_string());
let base_url = custom_base_url.clone().unwrap_or_else(|| {
let dns_suffix = dns_suffix_for_region(®ion);
format!("https://bedrock-runtime.{region}.{dns_suffix}")
});
let cross_region_prefix = if custom_base_url.is_some() {
None
} else {
std::env::var("BEDROCK_CROSS_REGION")
.ok()
.filter(|v| !v.is_empty())
.map(|v| format!("{v}."))
};
Self {
region,
base_url,
cross_region_prefix,
access_key_id: None,
secret_access_key: None,
session_token: None,
dynamic_credentials: false,
}
}
#[must_use]
pub fn from_env() -> Self {
let region = std::env::var("AWS_DEFAULT_REGION")
.or_else(|_| std::env::var("AWS_REGION"))
.unwrap_or_else(|_| DEFAULT_REGION.to_owned());
Self::new(region)
}
#[must_use]
pub fn from_config(
region: Option<String>,
cross_region_prefix: Option<String>,
access_key_id: Option<String>,
secret_access_key: Option<String>,
session_token: Option<String>,
) -> Self {
let region = region
.or_else(|| std::env::var("AWS_DEFAULT_REGION").ok())
.or_else(|| std::env::var("AWS_REGION").ok())
.unwrap_or_else(|| DEFAULT_REGION.to_owned());
Self::new(region)
.with_cross_region_prefix(cross_region_prefix)
.with_credentials(access_key_id, secret_access_key, session_token)
}
pub(crate) fn with_dynamic_credentials(mut self, enabled: bool) -> Self {
self.dynamic_credentials = enabled;
self
}
#[must_use]
pub fn with_cross_region_prefix(mut self, prefix: Option<String>) -> Self {
if let Some(prefix) = prefix {
let prefix = if prefix.ends_with('.') {
prefix
} else {
format!("{prefix}.")
};
self.cross_region_prefix = Some(prefix);
}
self
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into().trim_end_matches('/').to_owned();
self.cross_region_prefix = None;
self
}
#[must_use]
pub fn with_credentials(
mut self,
access_key_id: Option<String>,
secret_access_key: Option<String>,
session_token: Option<String>,
) -> Self {
self.access_key_id = access_key_id;
self.secret_access_key = secret_access_key;
self.session_token = session_token;
self
}
#[must_use]
#[allow(dead_code)]
pub fn region(&self) -> &str {
&self.region
}
fn bearer_token(&self) -> Option<String> {
#[cfg(feature = "bedrock")]
{
if self.has_explicit_credential() {
return None;
}
}
std::env::var("AWS_BEARER_TOKEN_BEDROCK")
.ok()
.filter(|token| !token.is_empty())
}
fn has_explicit_credential(&self) -> bool {
[&self.access_key_id, &self.secret_access_key, &self.session_token]
.into_iter()
.any(|field| field.as_deref().is_some_and(|value| !value.is_empty()))
}
}
impl Provider for BedrockProvider {
fn name(&self) -> &str {
"bedrock"
}
fn base_url(&self) -> &str {
&self.base_url
}
fn auth_header<'a>(&'a self, _api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
None
}
fn matches_model(&self, model: &str) -> bool {
model.starts_with("bedrock/")
}
fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
model.strip_prefix("bedrock/").unwrap_or(model)
}
fn validate(&self) -> Result<()> {
if self.dynamic_credentials {
return Ok(());
}
#[cfg(not(feature = "bedrock"))]
{
if self.has_explicit_credential() && self.bearer_token().is_none() {
return Err(LiterLlmError::Authentication {
message: "AWS Bedrock access keys need the `bedrock` feature to sign requests. \
Enable it, or set a Bedrock API key in AWS_BEARER_TOKEN_BEDROCK."
.into(),
status: 401,
});
}
}
#[cfg(feature = "bedrock")]
{
if self.bearer_token().is_some() {
return Ok(());
}
let has_access_key = self.access_key_id.is_some() || std::env::var("AWS_ACCESS_KEY_ID").is_ok();
let has_secret_key = self.secret_access_key.is_some() || std::env::var("AWS_SECRET_ACCESS_KEY").is_ok();
if !has_access_key || !has_secret_key {
return Err(LiterLlmError::Authentication {
message: "AWS Bedrock requires credentials. \
Set a Bedrock API key in AWS_BEARER_TOKEN_BEDROCK, or supply an \
access-key pair explicitly via config or as AWS_ACCESS_KEY_ID and \
AWS_SECRET_ACCESS_KEY (and optionally AWS_SESSION_TOKEN) in the \
environment."
.into(),
status: 401,
});
}
}
Ok(())
}
fn stream_format(&self) -> StreamFormat {
StreamFormat::AwsEventStream
}
fn build_url(&self, endpoint_path: &str, model: &str) -> String {
let base = self.base_url();
let effective_model = self.apply_cross_region_prefix(model);
let encoded_model = percent_encode_model(&effective_model);
if endpoint_path.contains("chat/completions") {
format!("{base}/model/{encoded_model}/converse")
} else if endpoint_path.contains("embeddings") {
format!("{base}/model/{encoded_model}/invoke")
} else {
format!("{base}{endpoint_path}")
}
}
fn build_stream_url(&self, endpoint_path: &str, model: &str) -> String {
let base = self.base_url();
let effective_model = self.apply_cross_region_prefix(model);
let encoded_model = percent_encode_model(&effective_model);
if endpoint_path.contains("chat/completions") {
format!("{base}/model/{encoded_model}/converse-stream")
} else {
self.build_url(endpoint_path, model)
}
}
fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
self.validate()?;
if body.get("input").is_some() && body.get("messages").is_none() {
return transform_bedrock_embed_request(body);
}
request::transform_converse_request(body)
}
fn transform_response(&self, body: &mut serde_json::Value) -> Result<()> {
if body.get("embedding").is_some() || body.get("embeddings").is_some() {
return transform_bedrock_embed_response(body);
}
response::transform_converse_response(body)
}
fn signing_headers(&self, method: &str, url: &str, body: &[u8]) -> Result<Vec<(String, String)>> {
if let Some(token) = self.bearer_token() {
return Ok(vec![("authorization".to_owned(), format!("Bearer {token}"))]);
}
#[cfg(feature = "bedrock")]
{
sigv4_sign(
method,
url,
body,
&self.region,
SigV4Credentials {
access_key_id: self.access_key_id.as_deref(),
secret_access_key: self.secret_access_key.as_deref(),
session_token: self.session_token.as_deref(),
use_environment: true,
},
)
.map_err(|error| {
tracing::error!(
error = %error,
%method,
"Bedrock SigV4 signing failed after credential precheck passed"
);
error
})
}
#[cfg(not(feature = "bedrock"))]
{
let _ = (method, url, body);
Ok(vec![])
}
}
fn signing_headers_with_credential(
&self,
method: &str,
url: &str,
body: &[u8],
credential: Option<&crate::auth::Credential>,
) -> Result<Vec<(String, String)>> {
let Some(credential) = credential else {
return self.signing_headers(method, url, body);
};
validate_resolved_credential(credential)?;
match credential {
crate::auth::Credential::BearerToken(_) => Ok(Vec::new()),
crate::auth::Credential::AwsCredentials {
access_key_id,
secret_access_key,
session_token,
} => {
#[cfg(feature = "bedrock")]
{
use secrecy::ExposeSecret;
sigv4_sign(
method,
url,
body,
&self.region,
SigV4Credentials {
access_key_id: Some(access_key_id.expose_secret()),
secret_access_key: Some(secret_access_key.expose_secret()),
session_token: session_token.as_ref().map(ExposeSecret::expose_secret),
use_environment: false,
},
)
}
#[cfg(not(feature = "bedrock"))]
{
let _ = (access_key_id, secret_access_key, session_token);
Err(LiterLlmError::Authentication {
message: "resolved AWS credentials require the `bedrock` feature to sign requests".into(),
status: 401,
})
}
}
}
}
}
fn validate_resolved_credential(credential: &crate::auth::Credential) -> Result<()> {
use secrecy::ExposeSecret;
let is_blank = match credential {
crate::auth::Credential::BearerToken(token) => token.expose_secret().trim().is_empty(),
crate::auth::Credential::AwsCredentials {
access_key_id,
secret_access_key,
..
} => access_key_id.expose_secret().trim().is_empty() || secret_access_key.expose_secret().trim().is_empty(),
};
if is_blank {
return Err(LiterLlmError::Authentication {
message: "resolved Bedrock credentials require a non-blank bearer token or access-key pair".into(),
status: 401,
});
}
Ok(())
}
fn converse_finish_reason(stop_reason: &str) -> &'static str {
match stop_reason {
"end_turn" => "stop",
"tool_use" => "tool_calls",
"max_tokens" => "length",
"stop_sequence" => "stop",
"content_filtered" | "guardrail_intervened" => "content_filter",
_ => "stop",
}
}
impl BedrockProvider {
fn apply_cross_region_prefix(&self, model: &str) -> String {
match &self.cross_region_prefix {
Some(prefix) => {
if model.starts_with(prefix.as_str()) {
model.to_owned()
} else {
format!("{prefix}{model}")
}
}
None => model.to_owned(),
}
}
}
#[cfg(test)]
fn apply_cross_region_prefix(model: &str) -> String {
match std::env::var("BEDROCK_CROSS_REGION") {
Ok(region) if !region.is_empty() => {
let prefix = format!("{region}.");
if model.starts_with(&prefix) {
model.to_owned()
} else {
format!("{prefix}{model}")
}
}
_ => model.to_owned(),
}
}
#[cfg(feature = "bedrock")]
struct SigV4Credentials<'a> {
access_key_id: Option<&'a str>,
secret_access_key: Option<&'a str>,
session_token: Option<&'a str>,
use_environment: bool,
}
#[cfg(feature = "bedrock")]
impl SigV4Credentials<'_> {
fn resolve(&self, explicit: Option<&str>, env_var: &str) -> Option<String> {
explicit.map(str::to_owned).or_else(|| {
if self.use_environment {
std::env::var(env_var).ok()
} else {
None
}
})
}
}
#[cfg(feature = "bedrock")]
fn sigv4_sign(
method: &str,
url: &str,
body: &[u8],
region: &str,
credentials: SigV4Credentials<'_>,
) -> Result<Vec<(String, String)>> {
use aws_credential_types::Credentials;
use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign};
use aws_sigv4::sign::v4::SigningParams;
let access_key = credentials
.resolve(credentials.access_key_id, "AWS_ACCESS_KEY_ID")
.ok_or_else(|| LiterLlmError::BadRequest {
message: "AWS access key ID is required for Bedrock requests: set it explicitly via config or \
the AWS_ACCESS_KEY_ID environment variable"
.into(),
status: 400,
})?;
let secret_key = credentials
.resolve(credentials.secret_access_key, "AWS_SECRET_ACCESS_KEY")
.ok_or_else(|| LiterLlmError::BadRequest {
message: "AWS secret access key is required for Bedrock requests: set it explicitly via config or \
the AWS_SECRET_ACCESS_KEY environment variable"
.into(),
status: 400,
})?;
let session_token = credentials.resolve(credentials.session_token, "AWS_SESSION_TOKEN");
let credentials = Credentials::new(access_key, secret_key, session_token, None, "env");
let identity = credentials.into();
let signing_settings = SigningSettings::default();
let now = std::time::SystemTime::now();
let params = SigningParams::builder()
.identity(&identity)
.region(region)
.name("bedrock")
.time(now)
.settings(signing_settings)
.build()
.map_err(|e| LiterLlmError::BadRequest {
message: format!("failed to build SigV4 signing params: {e}"),
status: 400,
})?;
let signable = SignableRequest::new(
method,
url,
std::iter::empty::<(&str, &str)>(),
SignableBody::Bytes(body),
)
.map_err(|e| LiterLlmError::BadRequest {
message: format!("failed to create signable request: {e}"),
status: 400,
})?;
let signing_output = sign(signable, ¶ms.into()).map_err(|e| LiterLlmError::BadRequest {
message: format!("SigV4 signing failed: {e}"),
status: 400,
})?;
let instructions = signing_output.output();
let signed_headers: Vec<(String, String)> = instructions
.headers()
.map(|(name, value)| (name.to_owned(), value.to_owned()))
.collect();
Ok(signed_headers)
}
#[cfg(test)]
mod provider_tests;
#[cfg(test)]
mod tests;