pub(super) mod capabilities;
pub(super) mod responses_wire;
#[cfg(test)]
mod tests;
use std::{pin::Pin, sync::Arc, time::Duration};
use async_trait::async_trait;
use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider};
use aws_sigv4::{
http_request::{SignableBody, SignableRequest, SigningSettings, sign},
sign::v4,
};
use aws_smithy_runtime_api::client::identity::Identity;
use capabilities::MODEL_CAPABILITIES;
use futures::{Stream, stream::StreamExt};
use moka::future::Cache;
use responses_wire::{
ResponsesResponse, build_responses_request, convert_responses_sse_event,
parse_responses_response,
};
use serde::Deserialize;
use crate::{
anthropic_wire::{
AnthropicResponse, build_request as build_anthropic_request, convert_anthropic_sse_event,
map_anthropic_error, parse_anthropic_response,
},
capabilities::ModelCapabilities,
config::{LanguageModelConfig, PromptCaching},
error::LanguageModelError,
identifiers::ModelId,
message::Message,
openai_wire::{
OpenAiResponse, OpenAiStreamOptions, build_request as build_openai_request,
convert_openai_sse_event, parse_openai_response,
},
provider::{ChatModelInfo, GenerateRequest, LanguageModelProvider, ResponseFormatKind},
response::{LanguageModelResponse, StreamDelta},
retry::{RetryConfig, with_retry},
sse::parse_sse_stream,
};
const SIGV4_SERVICE_NAME: &str = "bedrock";
const LIST_MODELS_TTL_SECS: u64 = 3600;
const ANTHROPIC_HEADERS: &[(&str, &str)] = &[("anthropic-version", "2023-06-01")];
struct SignedRequest<'a> {
method: reqwest::Method,
url: &'a str,
region: &'a str,
body: Vec<u8>,
extra_headers: &'a [(&'a str, &'a str)],
}
pub struct BedrockMantleProvider {
client: Arc<reqwest::Client>,
auth: BedrockMantleAuth,
config: BedrockMantleProviderConfig,
list_models_cache: Cache<(), Vec<ChatModelInfo>>,
}
pub enum BedrockMantleAuth {
Sigv4 {
credentials_provider: SharedCredentialsProvider,
},
ApiKey(String),
}
pub struct BedrockMantleProviderDeps {
pub client: Arc<reqwest::Client>,
pub auth: BedrockMantleAuth,
}
pub struct BedrockMantleProviderConfig {
pub default_region: String,
pub openai_gpt5_region: String,
pub anthropic_region: String,
pub retry_config: RetryConfig,
}
impl BedrockMantleProviderConfig {
pub const DEFAULT_REGION: &'static str = "us-west-2";
pub const DEFAULT_OPENAI_GPT5_REGION: &'static str = "us-east-2";
pub const DEFAULT_ANTHROPIC_REGION: &'static str = "us-east-1";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Route {
ChatCompletions,
Responses,
Messages,
}
impl Route {
pub(crate) fn from_model_id(model: &str) -> Self {
if model.starts_with("openai.gpt-5.") {
Self::Responses
} else if model.starts_with("anthropic.") {
Self::Messages
} else {
Self::ChatCompletions
}
}
}
impl BedrockMantleProvider {
#[must_use]
pub fn new(deps: BedrockMantleProviderDeps, config: BedrockMantleProviderConfig) -> Self {
Self {
client: deps.client,
auth: deps.auth,
config,
list_models_cache: Cache::builder()
.time_to_live(Duration::from_secs(LIST_MODELS_TTL_SECS))
.max_capacity(1)
.build(),
}
}
fn endpoint_base(region: &str) -> String {
format!("https://bedrock-mantle.{region}.api.aws")
}
fn region_for(&self, route: Route) -> &str {
match route {
Route::Responses => &self.config.openai_gpt5_region,
Route::Messages => &self.config.anthropic_region,
Route::ChatCompletions => &self.config.default_region,
}
}
fn unique_regions(&self) -> Vec<&str> {
let mut out: Vec<&str> = Vec::with_capacity(3);
for r in [
self.config.default_region.as_str(),
self.config.openai_gpt5_region.as_str(),
self.config.anthropic_region.as_str(),
] {
if !out.contains(&r) {
out.push(r);
}
}
out
}
async fn chat_completions_generate(
&self,
model: &str,
messages: &[Message],
config: &LanguageModelConfig,
region: &str,
) -> Result<LanguageModelResponse, LanguageModelError> {
let request_body = build_openai_request(
model,
messages,
config,
mantle_uses_completion_tokens(model),
);
let body_bytes = serde_json::to_vec(&request_body).map_err(|e| {
LanguageModelError::provider(format!("failed to serialize chat-completions body: {e}"))
})?;
let url = format!("{}/v1/chat/completions", Self::endpoint_base(region));
let response = self
.send_signed(SignedRequest {
method: reqwest::Method::POST,
url: &url,
region,
body: body_bytes,
extra_headers: &[],
})
.await?;
let status = response.status();
if !status.is_success() {
let body = crate::http_error::read_error_body_or_warn(
response,
"bedrock-mantle",
status.as_u16(),
)
.await;
return Err(map_mantle_error(status.as_u16(), &body));
}
let api_response: OpenAiResponse = response.json().await.map_err(|e| {
LanguageModelError::provider(format!("failed to parse chat-completions response: {e}"))
})?;
parse_openai_response(api_response)
}
fn build_messages_body(
model: &str,
messages: &[Message],
config: &LanguageModelConfig,
) -> Result<Vec<u8>, LanguageModelError> {
let mut mantle_config = config.clone();
mantle_config.prompt_caching = PromptCaching::Off;
let (request, _needs_files_beta) = build_anthropic_request(model, messages, &mantle_config);
serde_json::to_vec(&request).map_err(|e| {
LanguageModelError::provider(format!("failed to serialize messages body: {e}"))
})
}
async fn messages_generate(
&self,
model: &str,
messages: &[Message],
config: &LanguageModelConfig,
region: &str,
) -> Result<LanguageModelResponse, LanguageModelError> {
let body_bytes = Self::build_messages_body(model, messages, config)?;
let url = format!("{}/anthropic/v1/messages", Self::endpoint_base(region));
let response = self
.send_signed(SignedRequest {
method: reqwest::Method::POST,
url: &url,
region,
body: body_bytes,
extra_headers: ANTHROPIC_HEADERS,
})
.await?;
let status = response.status();
if !status.is_success() {
let headers = response.headers().clone();
let body = crate::http_error::read_error_body_or_warn(
response,
"bedrock-mantle",
status.as_u16(),
)
.await;
return Err(map_anthropic_error(status.as_u16(), &body, &headers));
}
let api_response: AnthropicResponse = response.json().await.map_err(|e| {
LanguageModelError::provider(format!("failed to parse messages response: {e}"))
})?;
parse_anthropic_response(api_response)
}
async fn messages_stream(
&self,
model: &str,
messages: &[Message],
config: &LanguageModelConfig,
region: &str,
) -> Result<
Pin<Box<dyn Stream<Item = Result<StreamDelta, LanguageModelError>> + Send>>,
LanguageModelError,
> {
let mut body_value: serde_json::Value = serde_json::from_slice(&Self::build_messages_body(
model, messages, config,
)?)
.map_err(|e| {
LanguageModelError::provider(format!("failed to round-trip messages body: {e}"))
})?;
body_value["stream"] = serde_json::Value::Bool(true);
let body_bytes = serde_json::to_vec(&body_value).map_err(|e| {
LanguageModelError::provider(format!(
"failed to serialize streaming messages body: {e}"
))
})?;
let url = format!("{}/anthropic/v1/messages", Self::endpoint_base(region));
let response = self
.send_signed(SignedRequest {
method: reqwest::Method::POST,
url: &url,
region,
body: body_bytes,
extra_headers: ANTHROPIC_HEADERS,
})
.await?;
let status = response.status();
if !status.is_success() {
let headers = response.headers().clone();
let body = crate::http_error::read_error_body_or_warn(
response,
"bedrock-mantle",
status.as_u16(),
)
.await;
return Err(map_anthropic_error(status.as_u16(), &body, &headers));
}
let byte_stream = response.bytes_stream();
let sse_stream = parse_sse_stream(byte_stream);
let delta_stream = sse_stream
.filter_map(|event_result| async move { convert_anthropic_sse_event(event_result) });
Ok(Box::pin(delta_stream))
}
async fn responses_generate(
&self,
model: &str,
messages: &[Message],
config: &LanguageModelConfig,
region: &str,
) -> Result<LanguageModelResponse, LanguageModelError> {
let request_body = build_responses_request(model, messages, config, false);
let body_bytes = serde_json::to_vec(&request_body).map_err(|e| {
LanguageModelError::provider(format!("failed to serialize responses body: {e}"))
})?;
let url = format!("{}/openai/v1/responses", Self::endpoint_base(region));
let response = self
.send_signed(SignedRequest {
method: reqwest::Method::POST,
url: &url,
region,
body: body_bytes,
extra_headers: &[],
})
.await?;
let status = response.status();
if !status.is_success() {
let body = crate::http_error::read_error_body_or_warn(
response,
"bedrock-mantle",
status.as_u16(),
)
.await;
return Err(map_mantle_error(status.as_u16(), &body));
}
let api_response: ResponsesResponse = response.json().await.map_err(|e| {
LanguageModelError::provider(format!("failed to parse responses response: {e}"))
})?;
parse_responses_response(api_response)
}
async fn responses_stream(
&self,
model: &str,
messages: &[Message],
config: &LanguageModelConfig,
region: &str,
) -> Result<
Pin<Box<dyn Stream<Item = Result<StreamDelta, LanguageModelError>> + Send>>,
LanguageModelError,
> {
let request_body = build_responses_request(model, messages, config, true);
let body_bytes = serde_json::to_vec(&request_body).map_err(|e| {
LanguageModelError::provider(format!(
"failed to serialize streaming responses body: {e}"
))
})?;
let url = format!("{}/openai/v1/responses", Self::endpoint_base(region));
let response = self
.send_signed(SignedRequest {
method: reqwest::Method::POST,
url: &url,
region,
body: body_bytes,
extra_headers: &[],
})
.await?;
let status = response.status();
if !status.is_success() {
let body = crate::http_error::read_error_body_or_warn(
response,
"bedrock-mantle",
status.as_u16(),
)
.await;
return Err(map_mantle_error(status.as_u16(), &body));
}
let byte_stream = response.bytes_stream();
let sse_stream = parse_sse_stream(byte_stream);
let delta_stream = sse_stream
.filter_map(|event_result| async move { convert_responses_sse_event(event_result) });
Ok(Box::pin(delta_stream))
}
async fn chat_completions_stream(
&self,
model: &str,
messages: &[Message],
config: &LanguageModelConfig,
region: &str,
) -> Result<
Pin<Box<dyn Stream<Item = Result<StreamDelta, LanguageModelError>> + Send>>,
LanguageModelError,
> {
let mut request_body = build_openai_request(
model,
messages,
config,
mantle_uses_completion_tokens(model),
);
request_body.stream = Some(true);
request_body.stream_options = Some(OpenAiStreamOptions {
include_usage: true,
});
let body_bytes = serde_json::to_vec(&request_body).map_err(|e| {
LanguageModelError::provider(format!(
"failed to serialize streaming chat-completions body: {e}"
))
})?;
let url = format!("{}/v1/chat/completions", Self::endpoint_base(region));
let response = self
.send_signed(SignedRequest {
method: reqwest::Method::POST,
url: &url,
region,
body: body_bytes,
extra_headers: &[],
})
.await?;
let status = response.status();
if !status.is_success() {
let body = crate::http_error::read_error_body_or_warn(
response,
"bedrock-mantle",
status.as_u16(),
)
.await;
return Err(map_mantle_error(status.as_u16(), &body));
}
let byte_stream = response.bytes_stream();
let sse_stream = parse_sse_stream(byte_stream);
let delta_stream = sse_stream
.filter_map(|event_result| async move { convert_openai_sse_event(event_result) });
Ok(Box::pin(delta_stream))
}
async fn send_signed(
&self,
req: SignedRequest<'_>,
) -> Result<reqwest::Response, LanguageModelError> {
let body_bytes = bytes::Bytes::from(req.body);
let builder = match &self.auth {
BedrockMantleAuth::ApiKey(key) => {
let mut b = self
.client
.request(req.method, req.url)
.header("authorization", format!("Bearer {key}"))
.header("content-type", "application/json");
for (k, v) in req.extra_headers {
b = b.header(*k, *v);
}
b.body(body_bytes)
}
BedrockMantleAuth::Sigv4 {
credentials_provider,
} => {
let creds = credentials_provider
.provide_credentials()
.await
.map_err(|e| {
LanguageModelError::authentication(format!("aws credentials: {e}"))
})?;
let identity: Identity = creds.into();
let signing_params = v4::SigningParams::builder()
.identity(&identity)
.region(req.region)
.name(SIGV4_SERVICE_NAME)
.time(std::time::SystemTime::now())
.settings(SigningSettings::default())
.build()
.map_err(|e| LanguageModelError::provider(format!("sigv4 params: {e}")))?
.into();
let mut signable_headers: Vec<(&str, &str)> =
Vec::with_capacity(1 + req.extra_headers.len());
signable_headers.push(("content-type", "application/json"));
signable_headers.extend(req.extra_headers.iter().copied());
let signable = SignableRequest::new(
req.method.as_str(),
req.url,
signable_headers.iter().copied(),
SignableBody::Bytes(&body_bytes),
)
.map_err(|e| LanguageModelError::provider(format!("signable request: {e}")))?;
let signing_output = sign(signable, &signing_params)
.map_err(|e| LanguageModelError::provider(format!("sign: {e}")))?;
let (instructions, _signature) = signing_output.into_parts();
let mut stub_builder = http::Request::builder()
.method(req.method.as_str())
.uri(req.url)
.header("content-type", "application/json");
for (k, v) in req.extra_headers {
stub_builder = stub_builder.header(*k, *v);
}
let mut stub: http::Request<()> = stub_builder.body(()).map_err(|e| {
LanguageModelError::provider(format!("build stub http req: {e}"))
})?;
instructions.apply_to_request_http1x(&mut stub);
let mut signed = self.client.request(req.method, req.url).body(body_bytes);
for (k, v) in stub.headers() {
signed = signed.header(k.as_str(), v.as_bytes());
}
signed
}
};
builder
.send()
.await
.map_err(|e| LanguageModelError::provider(e.to_string()))
}
async fn fetch_models_in_region(
&self,
region: &str,
) -> Result<Vec<MantleModelEntry>, LanguageModelError> {
let url = format!("{}/v1/models", Self::endpoint_base(region));
let response = self
.send_signed(SignedRequest {
method: reqwest::Method::GET,
url: &url,
region,
body: Vec::new(),
extra_headers: &[],
})
.await?;
let status = response.status();
if !status.is_success() {
let body = crate::http_error::read_error_body_or_warn(
response,
"bedrock-mantle",
status.as_u16(),
)
.await;
return Err(map_mantle_error(status.as_u16(), &body));
}
let parsed: MantleListModelsResponse = response.json().await.map_err(|e| {
LanguageModelError::provider(format!("failed to parse mantle models: {e}"))
})?;
Ok(parsed.data)
}
}
fn mantle_uses_completion_tokens(model: &str) -> bool {
MODEL_CAPABILITIES
.get(model)
.is_some_and(|c| c.reasoning.is_some())
}
fn map_mantle_error(status: u16, body: &str) -> LanguageModelError {
match status {
401 | 403 => LanguageModelError::authentication(body.to_owned()),
429 => LanguageModelError::rate_limited(body.to_owned()),
_ => LanguageModelError::provider(format!("HTTP {status}: {body}")),
}
}
#[async_trait]
impl LanguageModelProvider for BedrockMantleProvider {
fn name(&self) -> &'static str {
"bedrock-mantle"
}
fn capabilities(&self, model: &ModelId) -> Option<ModelCapabilities> {
let caps = MODEL_CAPABILITIES.get(model.as_str())?;
Some(ModelCapabilities {
model_id: model.as_str().to_owned(),
media_support: caps.media_support.clone(),
reasoning: caps.reasoning.clone(),
latency_optimized_supported: false,
extended_cache_ttl_supported: false,
})
}
#[tracing::instrument(
skip(self),
fields(
provider = "bedrock-mantle",
regions = tracing::field::Empty,
model_count = tracing::field::Empty,
),
err(Display),
)]
async fn list_models(&self) -> Result<Vec<ChatModelInfo>, LanguageModelError> {
let result = self
.list_models_cache
.try_get_with((), async {
let regions = self.unique_regions();
tracing::Span::current().record("regions", regions.join(","));
let per_region = futures::future::try_join_all(
regions
.iter()
.map(|region| self.fetch_models_in_region(region)),
)
.await?;
let mut by_id: std::collections::BTreeMap<String, MantleModelEntry> =
std::collections::BTreeMap::new();
for batch in per_region {
for entry in batch {
by_id.entry(entry.id.clone()).or_insert(entry);
}
}
let merged: Vec<ChatModelInfo> = by_id
.into_values()
.map(|m| {
let caps = MODEL_CAPABILITIES.get(m.id.as_str()).cloned();
if caps.is_none() {
tracing::warn!(
provider = "bedrock-mantle",
model_id = %m.id,
"model returned by upstream but no local capability metadata; \
ChatModelInfo will have minimal fields. Update MODEL_CAPABILITIES \
when this model is ready for first-class support."
);
}
let mut formats = vec![ResponseFormatKind::Text];
if caps.as_ref().is_some_and(|c| c.supports_json_schema) {
formats.push(ResponseFormatKind::JsonObject);
formats.push(ResponseFormatKind::JsonSchema);
}
ChatModelInfo {
id: ModelId::new(m.id),
display_name: None,
context_window: caps.as_ref().map(|c| c.context_window),
supports_streaming: caps.as_ref().is_some_and(|c| c.supports_streaming),
supported_response_formats: formats,
media_support: caps
.as_ref()
.map(|c| c.media_support.clone())
.unwrap_or_default(),
reasoning: caps.as_ref().and_then(|c| c.reasoning.clone()),
}
})
.collect();
Ok::<_, LanguageModelError>(merged)
})
.await
.map_err(|arc_err| (*arc_err).clone());
if let Ok(ref v) = result {
tracing::Span::current().record("model_count", v.len());
}
result
}
#[tracing::instrument(
skip(self, request),
fields(
provider = "bedrock-mantle",
model = %request.model,
messages = request.messages.len(),
surface = tracing::field::Empty,
prompt_tokens = tracing::field::Empty,
completion_tokens = tracing::field::Empty,
total_tokens = tracing::field::Empty,
cache_creation_input_tokens = tracing::field::Empty,
cache_read_input_tokens = tracing::field::Empty,
),
err(Display),
)]
async fn generate(
&self,
request: GenerateRequest<'_>,
) -> Result<LanguageModelResponse, LanguageModelError> {
self.validate_request(&request)?;
let model = request.model.as_str();
let route = Route::from_model_id(model);
let region = self.region_for(route).to_owned();
let span = tracing::Span::current();
span.record(
"surface",
tracing::field::display(format_args!("{route:?}")),
);
let response = match route {
Route::ChatCompletions => {
with_retry(&self.config.retry_config, || {
self.chat_completions_generate(model, request.messages, request.config, ®ion)
})
.await?
}
Route::Responses => {
with_retry(&self.config.retry_config, || {
self.responses_generate(model, request.messages, request.config, ®ion)
})
.await?
}
Route::Messages => {
with_retry(&self.config.retry_config, || {
self.messages_generate(model, request.messages, request.config, ®ion)
})
.await?
}
};
if let Some(usage) = &response.usage {
span.record("prompt_tokens", usage.input_tokens);
span.record("completion_tokens", usage.output_tokens);
span.record("total_tokens", usage.input_tokens + usage.output_tokens);
span.record(
"cache_creation_input_tokens",
usage.cache_creation_input_tokens,
);
span.record("cache_read_input_tokens", usage.cache_read_input_tokens);
}
Ok(response)
}
#[tracing::instrument(
skip(self, request),
fields(
provider = "bedrock-mantle",
model = %request.model,
messages = request.messages.len(),
surface = tracing::field::Empty,
first_token_ms = tracing::field::Empty,
prompt_tokens = tracing::field::Empty,
completion_tokens = tracing::field::Empty,
total_tokens = tracing::field::Empty,
cache_creation_input_tokens = tracing::field::Empty,
cache_read_input_tokens = tracing::field::Empty,
),
err(Display),
)]
async fn generate_stream(
&self,
request: GenerateRequest<'_>,
) -> Result<
Pin<Box<dyn Stream<Item = Result<StreamDelta, LanguageModelError>> + Send>>,
LanguageModelError,
> {
let started_at = std::time::Instant::now();
self.validate_request(&request)?;
let model = request.model.as_str();
let route = Route::from_model_id(model);
let region = self.region_for(route).to_owned();
tracing::Span::current().record(
"surface",
tracing::field::display(format_args!("{route:?}")),
);
let inner = match route {
Route::ChatCompletions => {
self.chat_completions_stream(model, request.messages, request.config, ®ion)
.await?
}
Route::Responses => {
self.responses_stream(model, request.messages, request.config, ®ion)
.await?
}
Route::Messages => {
self.messages_stream(model, request.messages, request.config, ®ion)
.await?
}
};
let wrapped =
crate::streaming_timing::instrument_stream(tracing::Span::current(), started_at, inner);
Ok(Box::pin(wrapped))
}
}
#[derive(Deserialize)]
struct MantleListModelsResponse {
data: Vec<MantleModelEntry>,
}
#[derive(Deserialize)]
struct MantleModelEntry {
id: String,
}