#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
#[cfg(feature = "static-bundled")]
mod cli;
mod usage;
#[cfg(test)]
use bcode_model::TokenUsage;
use aws_config::{BehaviorVersion, Region};
use aws_credential_types::Credentials;
use aws_sdk_bedrock as bedrock;
use aws_sdk_bedrockruntime::Client;
use aws_sdk_bedrockruntime::operation::RequestId;
use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamError;
use aws_sdk_bedrockruntime::operation::invoke_model_with_response_stream::InvokeModelWithResponseStreamError;
use aws_sdk_bedrockruntime::types::{
AnyToolChoice, AutoToolChoice, CachePointBlock, CachePointType,
ContentBlock as BedrockContentBlock, ContentBlockDelta, ContentBlockStart, ConversationRole,
ConverseStreamOutput, ImageBlock, ImageFormat, ImageSource, InferenceConfiguration,
JsonSchemaDefinition, Message as BedrockMessage, OutputConfig, OutputFormat,
OutputFormatStructure, OutputFormatType, ReasoningContentBlockDelta, ResponseStream,
SpecificToolChoice, StopReason as BedrockStopReason, SystemContentBlock, Tool,
ToolChoice as BedrockToolChoice, ToolConfiguration, ToolInputSchema, ToolResultBlock,
ToolResultContentBlock, ToolResultStatus, ToolSpecification, ToolUseBlock,
};
use aws_smithy_types::Blob;
use aws_smithy_types::error::metadata::ProvideErrorMetadata;
use aws_smithy_types::{Document, Number};
use base64::Engine as _;
use bcode_model::{
AckResponse, CancelTurnRequest, ContentBlock, FinishTurnRequest, MODEL_PROVIDER_INTERFACE_ID,
MODEL_PROVIDER_INTERFACE_ID_V2, MODEL_PROVIDER_INTERFACE_ID_V3, MessageRole, ModelCapability,
ModelCatalogHints, ModelInfo, ModelList, ModelListRequest, ModelMessage, ModelTurnRequest,
OP_CANCEL_TURN, OP_CAPABILITIES, OP_FINISH_TURN, OP_MODELS, OP_POLL_TURN_EVENTS, OP_RUN_TURN,
OP_START_TURN, OP_VALIDATE_CONFIG, PollTurnEventsRequest, PollTurnEventsResponse,
ProviderCapabilities, ProviderCapability, ProviderError, ProviderErrorCategory,
ProviderErrorSource, ProviderRequestContext, ProviderRequestProjection, ProviderTurnEvent,
RunTurnResponse, StartTurnResponse, StopReason, ToolCall, ToolChoice, ToolDefinition,
ValidateConfigResponse,
};
use bcode_model_provider_runtime::{
ProviderRuntime, StreamOutcome, SyntheticStructuredOutput, TurnState, TurnStore,
provider_error, retry_hint_from_body, sanitize_provider_diagnostic,
};
use bcode_plugin_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const PROVIDER_ID: &str = "bcode.bedrock";
const CATALOG_PROVIDER_ID: &str = "bedrock";
const DEFAULT_REGION: &str = "us-east-1";
const DEFAULT_MANTLE_BASE_URL_PREFIX: &str = "https://bedrock-mantle.";
const MODEL_DISCOVERY_TTL: Duration = Duration::from_mins(10);
const COMPATIBILITY_CACHE_VERSION: u8 = 1;
const COMPATIBILITY_CACHE_TTL_SECONDS: u64 = 7 * 24 * 60 * 60;
const STREAMING_TOOL_UNSUPPORTED_REASON: &str = "streaming_tool_use_unsupported";
#[cfg(test)]
const PROMPT_CACHE_UNSUPPORTED_REASON: &str = "prompt_cache_unsupported";
pub struct BedrockProviderPlugin {
turns: Mutex<TurnStore>,
discovery: Arc<Mutex<DiscoveryCache>>,
runtime: Result<ProviderRuntime, String>,
turn_executor: Arc<dyn BedrockTurnExecutor>,
}
struct PushTurnCleanup<'a> {
store: &'a Mutex<TurnStore>,
id: String,
turn: TurnState,
transferred: bool,
}
impl Drop for PushTurnCleanup<'_> {
fn drop(&mut self) {
if self.transferred {
return;
}
self.turn.cancel();
self.store
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.finish(&self.id);
}
}
impl Default for BedrockProviderPlugin {
fn default() -> Self {
Self {
turns: Mutex::default(),
discovery: Arc::default(),
runtime: ProviderRuntime::new().map_err(|error| error.to_string()),
turn_executor: Arc::new(AwsBedrockTurnExecutor),
}
}
}
#[derive(Debug)]
struct AwsBedrockTurnExecutor;
trait BedrockTurnExecutor: Send + Sync {
fn start(
&self,
runtime: &ProviderRuntime,
request: ModelTurnRequest,
turn: TurnState,
discovery: Arc<Mutex<DiscoveryCache>>,
);
}
struct BedrockWorkerCompletion {
turn: TurnState,
completed: bool,
}
impl Drop for BedrockWorkerCompletion {
fn drop(&mut self) {
if !self.completed && self.turn.is_cancelled() && !std::thread::panicking() {
self.turn.push(ProviderTurnEvent::Cancelled);
self.turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::Cancelled,
});
} else if !self.completed {
push_runtime_error(
&self.turn,
"provider worker ended before completing the turn",
);
}
}
}
impl BedrockTurnExecutor for AwsBedrockTurnExecutor {
fn start(
&self,
runtime: &ProviderRuntime,
request: ModelTurnRequest,
turn: TurnState,
discovery: Arc<Mutex<DiscoveryCache>>,
) {
let mut completion = BedrockWorkerCompletion {
turn: turn.clone(),
completed: false,
};
if let Err(error) = runtime.try_spawn(async move {
stream_bedrock_turn(&request, &completion.turn, discovery).await;
completion.completed = true;
drop(completion);
}) {
push_runtime_error(&turn, &error.to_string());
}
}
}
impl ConcurrentRustPlugin for BedrockProviderPlugin {
fn deactivate_concurrent(&self) -> Result<(), PluginError> {
if let Ok(runtime) = &self.runtime {
runtime.request_shutdown();
}
self.turns
.lock()
.map_err(|_| PluginError::failed("provider turn state is unavailable"))?
.cancel_all();
if let Ok(runtime) = &self.runtime {
runtime
.shutdown(Duration::from_secs(5))
.map_err(|error| PluginError::failed(error.to_string()))?;
}
self.turns
.lock()
.map_err(|_| PluginError::failed("provider turn state is unavailable"))?
.finish_all();
Ok(())
}
fn activate_concurrent(&self) -> Result<(), PluginError> {
self.activate_provider();
Ok(())
}
fn invoke_service_concurrent(&self, context: NativeServiceContext) -> ServiceResponse {
self.invoke_provider_service(&context)
}
}
impl RustPlugin for BedrockProviderPlugin {
fn deactivate(&mut self) -> Result<(), PluginError> {
self.deactivate_concurrent()
}
fn activate(&mut self) -> Result<(), PluginError> {
self.activate_provider();
Ok(())
}
fn invoke_service(&mut self, context: NativeServiceContext) -> ServiceResponse {
self.invoke_provider_service(&context)
}
}
impl BedrockProviderPlugin {
fn activate_provider(&self) {
match load_compatibility_cache() {
Ok(compatibility) => {
if let Ok(mut discovery) = self.discovery.lock() {
discovery.compatibility = compatibility;
}
}
Err(error) => {
tracing::warn!(
target: "bcode_bedrock::compatibility",
error = %error.message,
"failed to load Bedrock compatibility cache"
);
}
}
if let Ok(runtime) = &self.runtime {
warm_discovery_cache(runtime, self.discovery.clone(), Settings::resolve(None));
}
}
fn invoke_provider_service(&self, context: &NativeServiceContext) -> ServiceResponse {
if !matches!(
context.request.interface_id.as_str(),
MODEL_PROVIDER_INTERFACE_ID
| MODEL_PROVIDER_INTERFACE_ID_V2
| MODEL_PROVIDER_INTERFACE_ID_V3
) {
return ServiceResponse::error(
"unsupported_interface",
"unsupported model provider service interface",
);
}
match context.request.operation.as_str() {
OP_CAPABILITIES => Self::capabilities_response(&context.request),
bcode_model::OP_NORMALIZE_USAGE => {
bcode_model_provider_runtime::normalize_usage_service(
PROVIDER_ID,
usage_decoders(),
&context.request,
)
}
OP_MODELS => self.models_response(&context.request),
OP_VALIDATE_CONFIG => self.validate_config_response(&context.request),
OP_START_TURN => self.start_turn(
&context.request,
context.request.interface_id == MODEL_PROVIDER_INTERFACE_ID_V2,
),
OP_RUN_TURN => self.run_turn(context),
OP_POLL_TURN_EVENTS => self.poll_turn_events(&context.request),
OP_CANCEL_TURN => self.cancel_turn(&context.request),
OP_FINISH_TURN => self.finish_turn(&context.request),
_ => ServiceResponse::error(
"unsupported_operation",
"unsupported model provider operation",
),
}
}
fn capabilities_response(request: &ServiceRequest) -> ServiceResponse {
let request = match request.payload_json::<bcode_model::ProviderCapabilitiesRequest>() {
Ok(request) => request,
Err(error) => return invalid_request(&error),
};
json_response(&capabilities_for_context(&request.provider_context))
}
fn models_response(&self, request: &ServiceRequest) -> ServiceResponse {
json_response(&self.models(&model_list_request(request)))
}
fn start_turn(&self, request: &ServiceRequest, positioned_output: bool) -> ServiceResponse {
let request = match request.payload_json::<ModelTurnRequest>() {
Ok(request) => request,
Err(error) => return invalid_request(&error),
};
let (provider_turn_id, turn) = self
.turns
.lock()
.expect("bedrock turn store lock should not be poisoned")
.insert_started("bedrock-turn");
let mut cleanup = PushTurnCleanup {
store: &self.turns,
id: provider_turn_id.clone(),
turn: turn.clone(),
transferred: false,
};
if request
.metadata
.get(bcode_model::CAPTURE_ORIGINAL_USAGE_METADATA_KEY)
.is_some_and(|value| value == "true")
{
turn.enable_original_usage();
}
if positioned_output {
turn.enable_positioned_output();
}
if let Ok(route) = resolve_bedrock_route(&request, &Settings::resolve(Some(&request))) {
turn.push(ProviderTurnEvent::RequestProjection {
projection: bedrock_request_projection(&request, route),
});
}
match &self.runtime {
Ok(runtime) => {
self.turn_executor
.start(runtime, request, turn, Arc::clone(&self.discovery));
}
Err(error) => push_runtime_error(&turn, error),
}
let response = json_response(&StartTurnResponse { provider_turn_id });
cleanup.transferred = response.error.is_none();
drop(cleanup);
response
}
fn run_turn(&self, context: &NativeServiceContext) -> ServiceResponse {
let request = match context.request.payload_json::<ModelTurnRequest>() {
Ok(request) => request,
Err(error) => return invalid_request(&error),
};
let (provider_turn_id, turn) = self
.turns
.lock()
.expect("bedrock turn store lock should not be poisoned")
.insert_started("bedrock-turn");
let cleanup = PushTurnCleanup {
store: &self.turns,
id: provider_turn_id,
turn: turn.clone(),
transferred: false,
};
if request
.metadata
.get(bcode_model::CAPTURE_ORIGINAL_USAGE_METADATA_KEY)
.is_some_and(|value| value == "true")
{
turn.enable_original_usage();
}
turn.enable_positioned_output();
if let Ok(route) = resolve_bedrock_route(&request, &Settings::resolve(Some(&request))) {
turn.push(ProviderTurnEvent::RequestProjection {
projection: bedrock_request_projection(&request, route),
});
}
match &self.runtime {
Ok(runtime) => {
self.turn_executor.start(
runtime,
request,
turn.clone(),
Arc::clone(&self.discovery),
);
}
Err(error) => push_runtime_error(&turn, error),
}
let outcome = stream_turn_events(&turn, context);
drop(cleanup);
outcome
}
fn poll_turn_events(&self, request: &ServiceRequest) -> ServiceResponse {
let request = match request.payload_json::<PollTurnEventsRequest>() {
Ok(request) => request,
Err(error) => return invalid_request(&error),
};
let events = self
.turns
.lock()
.expect("bedrock turn store lock should not be poisoned")
.drain(&request.provider_turn_id);
json_response(&PollTurnEventsResponse { events })
}
fn cancel_turn(&self, request: &ServiceRequest) -> ServiceResponse {
let request = match request.payload_json::<CancelTurnRequest>() {
Ok(request) => request,
Err(error) => return invalid_request(&error),
};
self.turns
.lock()
.expect("bedrock turn store lock should not be poisoned")
.cancel(&request.provider_turn_id);
json_response(&AckResponse::default())
}
fn finish_turn(&self, request: &ServiceRequest) -> ServiceResponse {
let request = match request.payload_json::<FinishTurnRequest>() {
Ok(request) => request,
Err(error) => return invalid_request(&error),
};
self.turns
.lock()
.expect("bedrock turn store lock should not be poisoned")
.finish(&request.provider_turn_id);
json_response(&AckResponse::default())
}
}
fn push_runtime_error(turn: &TurnState, error: &str) {
turn.push(ProviderTurnEvent::Error {
error: provider_error(
"runtime_unavailable",
ProviderErrorCategory::ProviderInternal,
error.to_string(),
),
});
turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::Error,
});
}
const TURN_DRAIN_INTERVAL: Duration = Duration::from_millis(1);
fn stream_turn_events(turn: &TurnState, context: &NativeServiceContext) -> ServiceResponse {
loop {
if context.cancellation.is_cancelled() {
turn.cancel();
let stop_reason = forward_turn_events(turn, |event| {
emit_provider_turn_event(context, event);
})
.unwrap_or(StopReason::Cancelled);
return json_response(&RunTurnResponse {
stop_reason: Some(stop_reason),
});
}
let terminal = forward_turn_events(turn, |event| {
emit_provider_turn_event(context, event);
});
if let Some(stop_reason) = terminal {
return json_response(&RunTurnResponse {
stop_reason: Some(stop_reason),
});
}
let _cancelled = context.cancellation.wait_cancelled(TURN_DRAIN_INTERVAL);
}
}
fn forward_turn_events(
turn: &TurnState,
mut emit: impl FnMut(&ProviderTurnEvent),
) -> Option<StopReason> {
let mut terminal = None;
for event in turn.drain() {
emit(&event);
match event {
ProviderTurnEvent::TurnFinished { stop_reason } => terminal = Some(stop_reason),
ProviderTurnEvent::Cancelled => terminal = Some(StopReason::Cancelled),
_ => {}
}
}
terminal
}
fn emit_provider_turn_event(context: &NativeServiceContext, event: &ProviderTurnEvent) {
if let Ok(payload) = serde_json::to_vec(event) {
context.events.emit(&payload);
}
}
async fn stream_bedrock_turn(
request: &ModelTurnRequest,
turn: &TurnState,
discovery: Arc<Mutex<DiscoveryCache>>,
) {
match stream_bedrock_turn_inner(request, turn, discovery).await {
Ok(StreamOutcome::Finished) => turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::EndTurn,
}),
Ok(StreamOutcome::ToolCall) => turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::ToolCall,
}),
Ok(StreamOutcome::MaxTokens) => turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::MaxTokens,
}),
Ok(StreamOutcome::Refusal) => turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::Refusal,
}),
Ok(StreamOutcome::Cancelled) => {
turn.push(ProviderTurnEvent::Cancelled);
turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::Cancelled,
});
}
Err(error) => {
turn.push(ProviderTurnEvent::Error { error });
turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::Error,
});
}
}
}
fn validate_bedrock_request(
request: &ModelTurnRequest,
responses_surface: bool,
) -> Result<(), ProviderError> {
if request.parameters.reasoning_summary.is_some() && !responses_surface {
return Err(provider_error(
"bedrock_reasoning_summary_unsupported",
ProviderErrorCategory::UnsupportedFeature,
"Bedrock Anthropic extended thinking does not support provider-visible reasoning summaries",
));
}
if request.tool_call_policy.parallel == Some(true) && !responses_surface {
return Err(provider_error(
"bedrock_parallel_tool_policy_unsupported",
ProviderErrorCategory::UnsupportedFeature,
"Bedrock model metadata does not guarantee parallel tool-call generation",
));
}
if request.conversation_reuse.mode.is_enabled()
|| request
.conversation_reuse
.previous_provider_response_id
.is_some()
|| request.conversation_reuse.provider_state.is_some()
{
return Err(provider_error(
"bedrock_conversation_reuse_unsupported",
ProviderErrorCategory::UnsupportedFeature,
"Bedrock provider-native conversation reuse is not supported by this adapter",
));
}
if !request.provider_context.request.is_empty() {
return Err(provider_error(
"bedrock_provider_options_unsupported",
ProviderErrorCategory::UnsupportedFeature,
"Bedrock provider-native request options are not supported by this adapter",
));
}
if request
.parameters
.max_output_tokens
.is_some_and(|tokens| i32::try_from(tokens).is_err())
{
return Err(provider_error(
"bedrock_max_output_tokens_out_of_range",
ProviderErrorCategory::InvalidRequest,
"Bedrock max_output_tokens must fit in a signed 32-bit integer",
));
}
validate_bedrock_cache_ttl(request, responses_surface)?;
validate_fable_5_1_request(request)?;
validate_declared_bedrock_features(request, responses_surface)?;
validate_tool_choice_registration(request)
}
fn validate_declared_bedrock_features(
request: &ModelTurnRequest,
responses_surface: bool,
) -> Result<(), ProviderError> {
let mut surface_support = bedrock_feature_support_for(responses_surface);
if !request.prompt_cache.supported_ttl_seconds.is_empty() {
surface_support.prompt_cache.insert(
bcode_model::PromptCacheFeature::Ttl,
bcode_model::CapabilitySupport::supported(
bcode_model::CapabilitySource::BundledCatalog,
),
);
}
let unsupported = request
.explicitly_unsupported_features(&surface_support)
.into_iter()
.filter(|feature| {
!matches!(
feature,
bcode_model::RequestedModelFeature::MediaInput(
bcode_model::MediaInputFeature::ImageReference
)
)
})
.collect::<Vec<_>>();
unsupported.first().map_or(Ok(()), |feature| {
Err(provider_error(
"bedrock_feature_unsupported",
ProviderErrorCategory::UnsupportedFeature,
format!("Bedrock does not support requested feature {feature:?}"),
))
})
}
fn validate_bedrock_cache_ttl(
request: &ModelTurnRequest,
responses_surface: bool,
) -> Result<(), ProviderError> {
let ttl_values = request
.messages
.iter()
.flat_map(|message| &message.content)
.filter_map(|block| match block {
ContentBlock::CachePoint { hint } => hint.ttl_seconds,
_ => None,
})
.chain(request.prompt_cache.ttl_seconds)
.collect::<BTreeSet<_>>();
if ttl_values.is_empty() {
return Ok(());
}
if !ttl_values.is_subset(&request.prompt_cache.supported_ttl_seconds) {
return Err(provider_error(
"bedrock_cache_ttl_unsupported",
ProviderErrorCategory::UnsupportedFeature,
"the requested cache TTL is not present in the host-resolved model capabilities",
));
}
if responses_surface || ttl_values.iter().all(|ttl| matches!(ttl, 300 | 3_600)) {
return Ok(());
}
Err(provider_error(
"bedrock_cache_ttl_unsupported",
ProviderErrorCategory::UnsupportedFeature,
"the selected Bedrock model does not support the requested portable cache TTL",
))
}
fn is_claude_fable_5_1(model_id: &str) -> bool {
let normalized = model_id.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
"anthropic.claude-fable-5-1"
| "us.anthropic.claude-fable-5-1"
| "global.anthropic.claude-fable-5-1"
)
}
fn validate_fable_5_1_request(request: &ModelTurnRequest) -> Result<(), ProviderError> {
if !is_claude_fable_5_1(&request.model_id) {
return Ok(());
}
if matches!(
request.tool_call_policy.choice,
ToolChoice::Required | ToolChoice::Tool { .. }
) {
return Err(provider_error(
"bedrock_fable_5_1_forced_tool_choice_unsupported",
ProviderErrorCategory::UnsupportedFeature,
"Claude Fable 5.1 supports only auto or none tool choice",
));
}
if request
.parameters
.temperature
.is_some_and(|value| (value - 1.0).abs() > f32::EPSILON)
{
return Err(provider_error(
"bedrock_fable_5_1_temperature_invalid",
ProviderErrorCategory::InvalidRequest,
"Claude Fable 5.1 temperature must be 1.0 or omitted",
));
}
if request
.parameters
.top_p
.is_some_and(|value| (value - 0.99).abs() > f32::EPSILON)
{
return Err(provider_error(
"bedrock_fable_5_1_top_p_invalid",
ProviderErrorCategory::InvalidRequest,
"Claude Fable 5.1 top_p must be 0.99 or omitted",
));
}
if request.parameters.temperature.is_some() && request.parameters.top_p.is_some() {
return Err(provider_error(
"bedrock_fable_5_1_sampling_parameters_conflict",
ProviderErrorCategory::InvalidRequest,
"Claude Fable 5.1 temperature and top_p cannot be specified together",
));
}
Ok(())
}
fn validate_tool_choice_registration(request: &ModelTurnRequest) -> Result<(), ProviderError> {
match &request.tool_call_policy.choice {
ToolChoice::Required if request.tools.is_empty() => Err(provider_error(
"tool_choice_without_tools",
ProviderErrorCategory::InvalidRequest,
"required tool choice needs at least one registered tool",
)),
ToolChoice::Tool { name } if !request.tools.iter().any(|tool| tool.name == *name) => {
Err(provider_error(
"unknown_required_tool",
ProviderErrorCategory::InvalidRequest,
format!("required tool '{name}' is not registered"),
))
}
ToolChoice::Auto | ToolChoice::None | ToolChoice::Required | ToolChoice::Tool { .. } => {
Ok(())
}
}
}
async fn stream_bedrock_turn_inner(
request: &ModelTurnRequest,
turn: &TurnState,
discovery: Arc<Mutex<DiscoveryCache>>,
) -> Result<StreamOutcome, ProviderError> {
tokio::select! {
biased;
() = turn.cancelled() => Ok(StreamOutcome::Cancelled),
outcome = stream_bedrock_turn_attempt(request, turn, discovery) => outcome,
}
}
async fn stream_bedrock_turn_attempt(
request: &ModelTurnRequest,
turn: &TurnState,
discovery: Arc<Mutex<DiscoveryCache>>,
) -> Result<StreamOutcome, ProviderError> {
let settings = Settings::resolve(Some(request));
let route = resolve_bedrock_route(request, &settings)?;
let responses_surface = route == EffectiveBedrockRoute::OpenAiResponses;
validate_bedrock_request(request, responses_surface)?;
let selection = resolve_turn_model_selection(request, &settings, turn, &discovery).await?;
let name_map = bedrock_tool_name_map(&request.tools);
if responses_surface {
return stream_mantle_openai_turn(request, &settings, &selection, turn, name_map).await;
}
if route == EffectiveBedrockRoute::AnthropicMessages {
return stream_mantle_anthropic_turn(request, &settings, &selection, turn, name_map).await;
}
let client = bedrock_client(&settings).await;
if request.provider_context.api_surface == Some(bcode_model::ModelApiSurface::Messages) {
return stream_bedrock_messages_turn(
request,
&client,
&selection,
settings.region.as_deref(),
turn,
name_map,
)
.await;
}
stream_bedrock_converse_turn(
request, &client, &settings, &selection, turn, &discovery, name_map,
)
.await
}
async fn stream_bedrock_converse_turn(
request: &ModelTurnRequest,
client: &Client,
settings: &Settings,
selection: &ModelSelection,
turn: &TurnState,
discovery: &Arc<Mutex<DiscoveryCache>>,
name_map: BTreeMap<String, String>,
) -> Result<StreamOutcome, ProviderError> {
let mut last_error = None;
for model_id in &selection.model_ids {
let mut effective_request;
let needs_cache_disabled =
prompt_cache_known_unsupported(discovery, selection.cache_key.as_ref(), model_id);
let request_for_model = if needs_cache_disabled {
effective_request = request.clone();
disable_prompt_cache(&mut effective_request);
&effective_request
} else {
request
};
let bedrock_request = build_converse_request(
request_for_model,
model_id.clone(),
settings.region.as_deref(),
)?;
let mut builder = client
.converse_stream()
.model_id(bedrock_request.model_id)
.set_messages(Some(bedrock_request.messages));
if !bedrock_request.system.is_empty() {
builder = builder.set_system(Some(bedrock_request.system));
}
if let Some(tool_config) = bedrock_request.tool_config {
builder = builder.tool_config(tool_config);
}
if let Some(inference_config) = bedrock_request.inference_config {
builder = builder.inference_config(inference_config);
}
if let Some(output_config) = bedrock_request.output_config {
builder = builder.output_config(output_config);
}
if let Some(additional_fields) = bedrock_request.additional_model_request_fields {
builder = builder.additional_model_request_fields(additional_fields);
}
match builder.send().await {
Ok(response) => {
return read_bedrock_stream(response.stream, turn, name_map.clone()).await;
}
Err(error) => {
let error = bedrock_sdk_error(&error);
match handle_bedrock_turn_error(
error,
request,
model_id,
selection,
settings.region.as_deref(),
turn,
client,
discovery,
name_map.clone(),
)
.await
{
TurnAttempt::Completed(outcome) => return outcome,
TurnAttempt::TryNextModel(error) => {
last_error = Some(error);
}
}
}
}
}
Err(last_error.unwrap_or_else(|| {
provider_error(
"bedrock_model_discovery_empty",
ProviderErrorCategory::Config,
"Bedrock model discovery returned no usable streaming tool-use models; set BCODE_BEDROCK_MODEL or configure a Bedrock model profile",
)
.with_failure(bedrock_failure_context(
bcode_model::ProviderFailureSourceKind::ModelProfile,
"BCODE_BEDROCK_MODEL or model profile",
bcode_model::ProviderFailureCapability::ModelDiscovery,
"set BCODE_BEDROCK_MODEL or configure an accessible streaming Bedrock model",
))
}))
}
async fn stream_mantle_anthropic_turn(
request: &ModelTurnRequest,
settings: &Settings,
selection: &ModelSelection,
turn: &TurnState,
name_map: BTreeMap<String, String>,
) -> Result<StreamOutcome, ProviderError> {
let token = client_context_bearer_token(settings).ok_or_else(|| {
provider_error(
"bedrock_mantle_missing_bearer_token",
ProviderErrorCategory::Auth,
"Bedrock Mantle requires AWS_BEARER_TOKEN_BEDROCK or a mapped bearer_token credential",
)
})?;
let endpoint = mantle_anthropic_messages_endpoint(settings)?;
let client = if settings.force_http1 {
reqwest::Client::builder().http1_only().build()
} else {
reqwest::Client::builder().build()
}
.map_err(|error| mantle_network_error("client_build_failed", &error))?;
let mut request_builder = client
.post(endpoint.clone())
.header("x-api-key", &token)
.header("anthropic-version", "2023-06-01")
.header("accept", "text/event-stream")
.header("user-agent", "bcode/0.0.1");
if settings.mantle_auth_header {
request_builder = request_builder.bearer_auth(&token);
}
let (mut provider_request, synthetic) = if request.structured_output.is_some() {
let (fallback, synthetic) = bedrock_messages_structured_fallback_request(request)?;
(fallback, Some(synthetic))
} else {
(request.clone(), None)
};
bound_anthropic_messages_cache_points(&mut provider_request);
let mut last_error = None;
for model_id in &selection.model_ids {
let response = request_builder
.try_clone()
.ok_or_else(|| {
provider_error(
"bedrock_mantle_request_clone_failed",
ProviderErrorCategory::ProviderInternal,
"failed to prepare Bedrock Mantle request",
)
})?
.json(&build_mantle_anthropic_request(
&provider_request,
model_id,
settings.region.as_deref(),
)?)
.send()
.await
.map_err(|error| mantle_network_error("request_failed", &error))?;
if !response.status().is_success() {
let error = mantle_status_error(response).await;
let is_last = selection.model_ids.last().map(String::as_str) == Some(model_id);
if selection.explicit || is_last {
return Err(error);
}
last_error = Some(error);
continue;
}
return read_mantle_anthropic_stream(
response,
turn,
name_map.clone(),
synthetic.clone(),
provider_request.prompt_cache.ttl_seconds,
model_id,
)
.await;
}
Err(last_error.unwrap_or_else(|| {
provider_error(
"bedrock_mantle_model_unavailable",
ProviderErrorCategory::Config,
"no usable Bedrock Mantle Anthropic model was available for the turn",
)
}))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EffectiveBedrockRoute {
Converse,
AnthropicMessages,
OpenAiResponses,
}
fn resolve_bedrock_route(
request: &ModelTurnRequest,
settings: &Settings,
) -> Result<EffectiveBedrockRoute, ProviderError> {
let catalog_surface = request.provider_context.api_surface;
let transport = settings.transport.clone()?;
if settings.transport_explicit {
return match transport {
BedrockTransport::MantleOpenAi => Ok(EffectiveBedrockRoute::OpenAiResponses),
BedrockTransport::MantleAnthropic => Ok(EffectiveBedrockRoute::AnthropicMessages),
BedrockTransport::Runtime
if catalog_surface == Some(bcode_model::ModelApiSurface::Responses) =>
{
Err(provider_error(
"bedrock_runtime_surface_unsupported",
ProviderErrorCategory::UnsupportedFeature,
"the selected model requires the OpenAI Responses surface and cannot be forced through Bedrock Runtime",
))
}
BedrockTransport::Runtime => Ok(EffectiveBedrockRoute::Converse),
};
}
Ok(match catalog_surface {
Some(bcode_model::ModelApiSurface::Responses) => EffectiveBedrockRoute::OpenAiResponses,
Some(bcode_model::ModelApiSurface::Messages) => EffectiveBedrockRoute::AnthropicMessages,
_ => EffectiveBedrockRoute::Converse,
})
}
fn mantle_openai_support_hint() -> bcode_model::ModelCatalogSupportHint {
bcode_model::ModelCatalogSupportHint {
provider: CATALOG_PROVIDER_ID.to_string(),
auth_mode: "bearer_token".to_string(),
api_surface: "responses".to_string(),
integration: Some("bcode".to_string()),
}
}
fn mantle_anthropic_support_hint() -> bcode_model::ModelCatalogSupportHint {
bcode_model::ModelCatalogSupportHint {
provider: CATALOG_PROVIDER_ID.to_string(),
auth_mode: "bearer_token".to_string(),
api_surface: "messages".to_string(),
integration: Some("bcode".to_string()),
}
}
fn build_mantle_openai_request(
request: &ModelTurnRequest,
model_id: &str,
) -> Result<serde_json::Value, ProviderError> {
let explicit_prompt_cache = mantle_openai_explicit_prompt_cache_enabled(request);
let mut input = bcode_openai_responses::model_messages_to_responses_input_with_cache_boundaries(
&request.messages,
0,
&|name| bedrock_tool_name(name),
explicit_prompt_cache,
);
let mut instructions = bcode_openai_responses::response_instruction_bundle(
request.system_prompt.as_deref(),
&request.messages,
);
if explicit_prompt_cache && let Some(stable_instructions) = instructions.take() {
input.insert(
0,
bcode_openai_responses::ResponsesInputItem::Message {
role: "developer".to_string(),
content: vec![bcode_openai_responses::ResponsesContent::InputText {
text: stable_instructions,
prompt_cache_breakpoint: Some(
bcode_openai_responses::ResponsesPromptCacheBreakpoint {
mode: "explicit".to_string(),
},
),
}],
},
);
}
let tools = request
.tools
.iter()
.map(|tool| bcode_openai_responses::ResponsesTool {
r#type: "function",
name: bedrock_tool_name(&tool.name),
description: tool.description.clone(),
parameters: tool.input_schema.clone(),
strict: Some(false),
})
.collect::<Vec<_>>();
let typed = bcode_openai_responses::ResponsesRequest {
model: model_id.to_string(),
instructions,
input,
stream: true,
store: false,
previous_response_id: None,
context_management: Vec::new(),
tools,
tool_choice: mantle_openai_tool_choice(request)?,
parallel_tool_calls: (!request.tools.is_empty())
.then_some(request.tool_call_policy.parallel)
.flatten(),
text: request.structured_output.as_ref().map(|structured| {
bcode_openai_responses::ResponsesTextOptions {
format: Some(bcode_openai_responses::ResponsesTextFormat {
r#type: "json_schema",
name: structured.name.clone(),
schema: structured.schema.clone(),
strict: structured.strict,
}),
verbosity: "low",
}
}),
reasoning: mantle_openai_reasoning(request),
include: Vec::new(),
prompt_cache_key: mantle_openai_explicit_prompt_cache_enabled(request).then(|| {
request
.prompt_cache
.key
.clone()
.or_else(|| request.metadata.get("prompt_cache_key").cloned())
.unwrap_or_else(|| mantle_openai_stable_prefix_key(request))
}),
prompt_cache_options: mantle_openai_explicit_prompt_cache_enabled(request).then_some(
bcode_openai_responses::ResponsesPromptCacheOptions {
mode: "explicit",
ttl: (request.prompt_cache.ttl_seconds == Some(30 * 60)).then_some("30m"),
},
),
temperature: request.parameters.temperature,
max_output_tokens: request.parameters.max_output_tokens,
top_p: request.parameters.top_p,
};
let mut value = serde_json::to_value(typed).map_err(|error| {
provider_error(
"bedrock_mantle_openai_request_encode_failed",
ProviderErrorCategory::InvalidRequest,
format!("failed to encode Bedrock Mantle OpenAI request: {error}"),
)
})?;
if mantle_openai_explicit_prompt_cache_enabled(request) {
project_mantle_openai_prompt_cache(&value)?;
}
if let Some(service_tier) = request.metadata.get("service_tier") {
value["service_tier"] = serde_json::Value::String(service_tier.clone());
}
Ok(value)
}
fn requested_mantle_cache_retention(request: &ModelTurnRequest) -> Option<&str> {
match request.prompt_cache.ttl_seconds {
Some(1_800) => Some("30m"),
Some(3_600) => Some("1h"),
_ => request
.metadata
.get("prompt_cache_retention")
.map(String::as_str),
}
}
fn mantle_openai_explicit_prompt_cache_enabled(request: &ModelTurnRequest) -> bool {
request.prompt_cache.mode.is_enabled()
&& request
.metadata
.get("model_cache_capabilities")
.is_some_and(|capabilities| {
capabilities
.split(',')
.any(|capability| capability == "explicit_cache_points")
})
}
fn mantle_openai_stable_prefix_key(request: &ModelTurnRequest) -> String {
let prompt_hash = request
.metadata
.get("stable_prompt_hash")
.map_or("unknown", String::as_str);
let tools_hash = request
.metadata
.get("tools_hash")
.map_or("unknown", String::as_str);
format!("bcode:{}:{prompt_hash}:{tools_hash}", request.model_id)
}
fn project_mantle_openai_prompt_cache(value: &serde_json::Value) -> Result<(), ProviderError> {
const MAX_BREAKPOINTS: usize = 4;
let breakpoint_count = value
.get("input")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|item| item.get("content").and_then(serde_json::Value::as_array))
.flatten()
.filter(|content| content.get("prompt_cache_breakpoint").is_some())
.count();
if !(1..=MAX_BREAKPOINTS).contains(&breakpoint_count) {
return Err(provider_error(
"bedrock_mantle_openai_cache_projection_failed",
ProviderErrorCategory::InvalidRequest,
format!(
"expected one to {MAX_BREAKPOINTS} explicit prompt cache breakpoints, found {breakpoint_count}"
),
));
}
Ok(())
}
fn mantle_openai_reasoning(
request: &ModelTurnRequest,
) -> Option<bcode_openai_responses::ResponsesReasoningOptions> {
let effort = request
.parameters
.reasoning_effort_value
.clone()
.or_else(|| {
request
.parameters
.reasoning_effort
.map(|effort| match effort {
bcode_model::ReasoningEffort::Low => "low",
bcode_model::ReasoningEffort::Medium => "medium",
bcode_model::ReasoningEffort::High => "high",
})
.map(ToString::to_string)
});
let summary = request.parameters.reasoning_summary.clone();
(effort.is_some() || summary.is_some()).then_some(
bcode_openai_responses::ResponsesReasoningOptions {
effort,
summary,
context: None,
},
)
}
fn mantle_openai_tool_choice(
request: &ModelTurnRequest,
) -> Result<Option<serde_json::Value>, ProviderError> {
if request.tools.is_empty() {
return match &request.tool_call_policy.choice {
ToolChoice::Auto | ToolChoice::None => Ok(None),
ToolChoice::Required | ToolChoice::Tool { .. } => Err(provider_error(
"tool_choice_without_tools",
ProviderErrorCategory::InvalidRequest,
"Bedrock tool choice requires at least one registered tool",
)),
};
}
Ok(match &request.tool_call_policy.choice {
ToolChoice::Auto => Some(serde_json::json!("auto")),
ToolChoice::None => Some(serde_json::json!("none")),
ToolChoice::Required => Some(serde_json::json!("required")),
ToolChoice::Tool { name } => Some(serde_json::json!({
"type": "function",
"name": bedrock_tool_name(name),
})),
})
}
#[allow(clippy::too_many_lines)]
async fn stream_mantle_openai_turn(
request: &ModelTurnRequest,
settings: &Settings,
selection: &ModelSelection,
turn: &TurnState,
name_map: BTreeMap<String, String>,
) -> Result<StreamOutcome, ProviderError> {
let token = client_context_bearer_token(settings).ok_or_else(|| {
provider_error(
"bedrock_mantle_missing_bearer_token",
ProviderErrorCategory::Auth,
"Bedrock Mantle requires AWS_BEARER_TOKEN_BEDROCK or a mapped bearer_token credential",
)
})?;
let client = if settings.force_http1 {
reqwest::Client::builder().http1_only().build()
} else {
reqwest::Client::builder().build()
}
.map_err(|error| mantle_network_error("client_build_failed", &error))?;
let endpoint = mantle_endpoint(settings, MantleFlavor::OpenAi)?;
let request_builder = client
.post(&endpoint)
.bearer_auth(&token)
.header("accept", "text/event-stream")
.header("user-agent", "bcode/0.0.1");
let mut last_error = None;
for model_id in &selection.model_ids {
let response = request_builder
.try_clone()
.ok_or_else(|| {
provider_error(
"bedrock_mantle_request_clone_failed",
ProviderErrorCategory::ProviderInternal,
"failed to prepare Bedrock Mantle request",
)
})?
.json(&build_mantle_openai_request(request, model_id)?)
.send()
.await
.map_err(|error| mantle_network_error("request_failed", &error))?;
if response.status().is_success() {
return read_mantle_openai_stream(
response,
turn,
&name_map,
request.metadata.get("service_tier").map(String::as_str),
requested_mantle_cache_retention(request),
)
.await;
}
let error = mantle_status_error(response).await;
let is_last = selection.model_ids.last().map(String::as_str) == Some(model_id);
if selection.explicit || is_last {
return Err(error);
}
last_error = Some(error);
}
Err(last_error.unwrap_or_else(|| {
provider_error(
"bedrock_mantle_openai_model_unavailable",
ProviderErrorCategory::Config,
"no usable Bedrock Mantle OpenAI model was available for the turn",
)
}))
}
async fn read_mantle_openai_stream(
mut response: reqwest::Response,
turn: &TurnState,
name_map: &BTreeMap<String, String>,
requested_service_tier: Option<&str>,
requested_cache_retention: Option<&str>,
) -> Result<StreamOutcome, ProviderError> {
let sink = MantleTurnEventSink(turn);
let mut buffer = String::new();
let mut state = MantleOpenAiStreamState::default();
let mut requested = BTreeMap::new();
if let Some(tier) = requested_service_tier {
requested.insert("service_tier".into(), tier.into());
}
if let Some(ttl) = requested_cache_retention {
requested.insert("prompt_cache_retention".into(), ttl.into());
}
let turn_sink = turn.clone();
state.usage = Some(
bcode_model_provider_runtime::UsageRecorder::new(
PROVIDER_ID,
&bcode_openai_responses::ResponsesUsageDialect::Bedrock,
requested,
turn.original_usage_enabled(),
)
.scoped(
Box::new(move |event| turn_sink.push(event)) as Box<dyn Fn(ProviderTurnEvent) + Send>
),
);
loop {
if turn.is_cancelled() {
return Ok(StreamOutcome::Cancelled);
}
tokio::select! {
chunk = response.chunk() => {
let Some(chunk) = chunk
.map_err(|error| mantle_network_error("stream_failed", &error))?
else {
return Err(provider_error(
"bedrock_mantle_openai_stream_incomplete",
ProviderErrorCategory::ProviderInternal,
"Bedrock Mantle OpenAI stream ended before reporting completion",
));
};
buffer.push_str(&String::from_utf8_lossy(&chunk));
for line in bcode_openai_responses::drain_complete_stream_lines(&mut buffer) {
if let Some(outcome) =
process_mantle_openai_line(
&line,
&sink,
&mut state,
name_map,
requested_service_tier,
requested_cache_retention,
)?
{
return Ok(outcome);
}
}
}
() = turn.cancelled() => return Ok(StreamOutcome::Cancelled),
}
}
}
#[derive(Debug, Clone, Copy)]
struct MantleTurnEventSink<'a>(&'a TurnState);
impl bcode_openai_responses::ResponsesEventSink for MantleTurnEventSink<'_> {
fn push(&self, event: ProviderTurnEvent) {
self.0.push(event);
}
}
type MantleUsageRecorder<'a> = bcode_model_provider_runtime::ScopedUsageRecorder<
'static,
Box<dyn Fn(ProviderTurnEvent) + Send + 'a>,
>;
#[derive(Default)]
struct MantleOpenAiStreamState<'a> {
tool_calls: BTreeMap<u32, bcode_openai_responses::ToolCallAccumulator>,
reasoning_items: BTreeMap<u32, bcode_openai_responses::ReasoningItemAccumulator>,
saw_tool_call: bool,
usage: Option<MantleUsageRecorder<'a>>,
saw_assistant_text: bool,
}
#[allow(clippy::too_many_lines)]
fn process_mantle_openai_line<'a>(
line: &str,
sink: &'a (impl bcode_openai_responses::ResponsesEventSink + Sync),
state: &mut MantleOpenAiStreamState<'a>,
name_map: &BTreeMap<String, String>,
requested_service_tier: Option<&str>,
requested_cache_retention: Option<&str>,
) -> Result<Option<StreamOutcome>, ProviderError> {
use bcode_openai_responses as responses;
let recorder = state.usage.get_or_insert_with(|| {
let mut requested = BTreeMap::new();
if let Some(tier) = requested_service_tier {
requested.insert("service_tier".into(), tier.into());
}
if let Some(ttl) = requested_cache_retention {
requested.insert("prompt_cache_retention".into(), ttl.into());
}
let recorder = bcode_model_provider_runtime::UsageRecorder::new(
PROVIDER_ID,
&bcode_openai_responses::ResponsesUsageDialect::Bedrock,
requested,
true,
);
recorder
.scoped(Box::new(move |event| sink.push(event))
as Box<dyn Fn(ProviderTurnEvent) + Send + 'a>)
});
if let Some(data) = line.strip_prefix("data:")
&& data.trim() != "[DONE]"
{
recorder.observe_json(data.trim());
}
if recorder.is_complete()
&& let Some(usage) = recorder.finish(true, |event| sink.push(event))
&& let Some(tokens) = usage.input_tokens
{
sink.push(ProviderTurnEvent::ExactRequestInputTokens {
tokens: bcode_model::ExactRequestInputTokens::new(u64::from(tokens)),
});
}
let event = match responses::classify_responses_stream_line(line)? {
responses::ResponsesStreamLine::Ignored => return Ok(None),
responses::ResponsesStreamLine::Done => {
if state.saw_tool_call {
finish_mantle_openai_tool_calls(sink, &state.tool_calls, name_map)?;
return Ok(Some(StreamOutcome::ToolCall));
}
return Ok(Some(StreamOutcome::Finished));
}
responses::ResponsesStreamLine::Event(event) => event,
};
let event_type = responses::responses_event_type(&event);
if matches!(
event_type,
"response.completed" | "response.incomplete" | "response.failed"
) {
recorder.finish(true, |event| sink.push(event));
}
match event_type {
"response.output_text.delta" | "response.refusal.delta" => {
if let Some(delta) = responses::responses_text_delta(&event) {
sink.push(ProviderTurnEvent::TextDelta {
text: delta.to_string(),
});
state.saw_assistant_text = true;
}
}
"response.reasoning_summary_text.delta" => responses::process_responses_reasoning_delta(
&event,
sink,
&mut state.reasoning_items,
bcode_session_models::ReasoningContentKind::Summary,
),
"response.reasoning_text.delta" => responses::process_responses_reasoning_delta(
&event,
sink,
&mut state.reasoning_items,
bcode_session_models::ReasoningContentKind::Raw,
),
"response.reasoning_summary_text.done" => responses::process_responses_reasoning_done(
&event,
sink,
&mut state.reasoning_items,
bcode_session_models::ReasoningContentKind::Summary,
),
"response.reasoning_text.done" => responses::process_responses_reasoning_done(
&event,
sink,
&mut state.reasoning_items,
bcode_session_models::ReasoningContentKind::Raw,
),
"response.output_item.added" => {
responses::process_responses_output_item(
&event,
sink,
&mut state.tool_calls,
&mut state.saw_tool_call,
&|name| original_tool_name(name, name_map),
);
responses::process_responses_reasoning_output_item(
&event,
sink,
&mut state.reasoning_items,
false,
);
}
"response.output_item.done" => {
responses::process_responses_output_item(
&event,
sink,
&mut state.tool_calls,
&mut state.saw_tool_call,
&|name| original_tool_name(name, name_map),
);
if !state.saw_assistant_text
&& let Some(text) = responses::responses_output_item_text(&event)
{
sink.push(ProviderTurnEvent::TextDelta { text });
state.saw_assistant_text = true;
}
responses::process_responses_reasoning_output_item(
&event,
sink,
&mut state.reasoning_items,
true,
);
}
"response.function_call_arguments.delta" => {
responses::process_responses_function_arguments_delta(
&event,
sink,
&mut state.tool_calls,
);
}
"response.function_call_arguments.done" => {
responses::process_responses_function_arguments_done(&event, &mut state.tool_calls);
}
"response.completed" => {
if state.saw_tool_call {
finish_mantle_openai_tool_calls(sink, &state.tool_calls, name_map)?;
return Ok(Some(StreamOutcome::ToolCall));
}
return Ok(Some(StreamOutcome::Finished));
}
"response.incomplete" => {
let reason = responses::responses_incomplete_reason(&event);
if reason == "max_output_tokens" {
if state.saw_tool_call {
finish_mantle_openai_tool_calls(sink, &state.tool_calls, name_map)?;
}
return Ok(Some(StreamOutcome::MaxTokens));
}
return Err(provider_error(
"bedrock_mantle_openai_response_incomplete",
ProviderErrorCategory::ProviderInternal,
format!("Bedrock Mantle OpenAI response was incomplete: {reason}"),
));
}
"response.failed" | "error" => return Err(mantle_openai_failed_error(&event)),
_ => {}
}
Ok(None)
}
fn finish_mantle_openai_tool_calls(
sink: &impl bcode_openai_responses::ResponsesEventSink,
tool_calls: &BTreeMap<u32, bcode_openai_responses::ToolCallAccumulator>,
name_map: &BTreeMap<String, String>,
) -> Result<(), ProviderError> {
for accumulator in tool_calls.values() {
let id = accumulator.id.clone().ok_or_else(|| {
provider_error(
"bedrock_mantle_openai_missing_tool_call_id",
ProviderErrorCategory::ProviderInternal,
"Bedrock Mantle OpenAI emitted a tool call without an id",
)
})?;
let provider_name = accumulator.name.clone().ok_or_else(|| {
provider_error(
"bedrock_mantle_openai_missing_tool_call_name",
ProviderErrorCategory::ProviderInternal,
"Bedrock Mantle OpenAI emitted a tool call without a function name",
)
})?;
let name = original_tool_name(&provider_name, name_map);
let arguments =
bcode_openai_responses::parse_tool_arguments(&accumulator.arguments, &id, &name)?;
sink.push(ProviderTurnEvent::ToolCallFinished {
call: ToolCall {
id,
name,
arguments,
},
});
}
Ok(())
}
#[cfg(test)]
fn mantle_openai_usage(
event: &serde_json::Value,
requested_service_tier: Option<&str>,
requested_cache_retention: Option<&str>,
) -> Option<TokenUsage> {
let mut requested = BTreeMap::new();
if let Some(tier) = requested_service_tier {
requested.insert("service_tier".into(), tier.into());
}
if let Some(ttl) = requested_cache_retention {
requested.insert("prompt_cache_retention".into(), ttl.into());
}
let mut recorder = bcode_model_provider_runtime::UsageRecorder::new(
PROVIDER_ID,
&bcode_openai_responses::ResponsesUsageDialect::Bedrock,
requested,
true,
);
recorder.observe_json(&event.to_string());
recorder.finish(true, |_| {})
}
fn mantle_openai_failed_error(event: &serde_json::Value) -> ProviderError {
let response = event.get("response");
let detail = event
.get("error")
.or_else(|| response.and_then(|response| response.get("error")));
let message = detail
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.or_else(|| event.get("message").and_then(serde_json::Value::as_str))
.unwrap_or("Bedrock Mantle OpenAI stream failed");
let code = detail
.and_then(|error| error.get("code").or_else(|| error.get("type")))
.and_then(serde_json::Value::as_str)
.unwrap_or("bedrock_mantle_openai_stream_failed");
let mut error = provider_error(
code,
ProviderErrorCategory::ProviderInternal,
message.to_string(),
);
error.request_id = response
.and_then(|response| response.get("id"))
.and_then(serde_json::Value::as_str)
.map(Into::into);
error
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MantleFlavor {
Anthropic,
OpenAi,
}
impl MantleFlavor {
const fn base_url_suffix(self) -> &'static str {
match self {
Self::Anthropic => ".api.aws/anthropic",
Self::OpenAi => ".api.aws/openai/v1",
}
}
const fn request_path(self) -> &'static str {
match self {
Self::Anthropic => "/v1/messages",
Self::OpenAi => "/responses",
}
}
const fn gateway_request_path(self) -> &'static str {
match self {
Self::Anthropic => "/v1/messages",
Self::OpenAi => "/v1/responses",
}
}
}
fn mantle_endpoint(settings: &Settings, flavor: MantleFlavor) -> Result<String, ProviderError> {
let region = settings.region.as_deref().unwrap_or(DEFAULT_REGION);
let base_url = settings.mantle_base_url.clone().unwrap_or_else(|| {
format!(
"{DEFAULT_MANTLE_BASE_URL_PREFIX}{region}{suffix}",
suffix = flavor.base_url_suffix()
)
});
let mut url = reqwest::Url::parse(base_url.trim()).map_err(|error| {
provider_error(
"bedrock_mantle_base_url_invalid",
ProviderErrorCategory::Config,
format!("invalid Bedrock Mantle base URL: {error}"),
)
})?;
if url.scheme() != "https"
&& !url
.host_str()
.is_some_and(|host| matches!(host, "127.0.0.1" | "localhost"))
{
return Err(provider_error(
"bedrock_mantle_base_url_insecure",
ProviderErrorCategory::Config,
"Bedrock Mantle base URL must use HTTPS",
));
}
let request_path = if settings.mantle_base_url.is_some() {
flavor.gateway_request_path()
} else {
flavor.request_path()
};
let path = format!("{}{request_path}", url.path().trim_end_matches('/'));
url.set_path(&path);
Ok(url.to_string())
}
fn mantle_anthropic_messages_endpoint(settings: &Settings) -> Result<String, ProviderError> {
mantle_endpoint(settings, MantleFlavor::Anthropic)
}
async fn mantle_status_error(response: reqwest::Response) -> ProviderError {
let status = response.status();
let request_id = response
.headers()
.get("x-amzn-requestid")
.or_else(|| response.headers().get("request-id"))
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let body = response.text().await.unwrap_or_default();
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|value| {
value
.pointer("/error/message")
.or_else(|| value.get("message"))
.and_then(serde_json::Value::as_str)
.map(sanitize_provider_diagnostic)
})
.unwrap_or_else(|| format!("Bedrock Mantle request failed with HTTP {status}"));
let category = match status.as_u16() {
401 | 403 => ProviderErrorCategory::Auth,
400 | 404 | 409 | 422 => ProviderErrorCategory::InvalidRequest,
408 | 425 | 429 | 500..=599 => ProviderErrorCategory::Network,
_ => ProviderErrorCategory::ProviderInternal,
};
let mut error = provider_error("bedrock_mantle_http_error", category, message);
error.request_id = request_id.map(String::into_boxed_str);
error.retry = retry_hint_from_body(&body).map(Box::new);
error
}
fn mantle_network_error(code: &str, error: &reqwest::Error) -> ProviderError {
provider_error(
format!("bedrock_mantle_{code}"),
ProviderErrorCategory::Network,
format!("Bedrock Mantle transport failed: {error}"),
)
}
#[derive(Default)]
struct MantleSseDecoder {
buffer: Vec<u8>,
data: Vec<String>,
}
impl MantleSseDecoder {
fn push(&mut self, bytes: &[u8]) -> Result<Vec<serde_json::Value>, ProviderError> {
self.buffer.extend_from_slice(bytes);
let mut events = Vec::new();
while let Some(position) = self.buffer.iter().position(|byte| *byte == b'\n') {
let mut line = self.buffer.drain(..=position).collect::<Vec<_>>();
line.pop();
if line.last() == Some(&b'\r') {
line.pop();
}
let line = String::from_utf8(line).map_err(|error| {
provider_error(
"bedrock_mantle_stream_decode_failed",
ProviderErrorCategory::ProviderInternal,
format!("Bedrock Mantle stream was not valid UTF-8: {error}"),
)
})?;
self.process_line(&line, &mut events)?;
}
Ok(events)
}
fn finish(&mut self) -> Result<Vec<serde_json::Value>, ProviderError> {
let mut events = Vec::new();
if !self.buffer.is_empty() {
let line = String::from_utf8(std::mem::take(&mut self.buffer)).map_err(|error| {
provider_error(
"bedrock_mantle_stream_decode_failed",
ProviderErrorCategory::ProviderInternal,
format!("Bedrock Mantle stream was not valid UTF-8: {error}"),
)
})?;
self.process_line(line.trim_end_matches('\r'), &mut events)?;
}
self.flush(&mut events)?;
Ok(events)
}
fn process_line(
&mut self,
line: &str,
events: &mut Vec<serde_json::Value>,
) -> Result<(), ProviderError> {
if line.is_empty() {
return self.flush(events);
}
if line.starts_with(':') {
return Ok(());
}
if let Some(data) = line.strip_prefix("data:") {
self.data
.push(data.strip_prefix(' ').unwrap_or(data).to_string());
}
Ok(())
}
fn flush(&mut self, events: &mut Vec<serde_json::Value>) -> Result<(), ProviderError> {
if self.data.is_empty() {
return Ok(());
}
let data = self.data.join("\n");
self.data.clear();
if data == "[DONE]" {
return Ok(());
}
let _: Box<serde_json::value::RawValue> = serde_json::from_str(&data).map_err(|_| {
provider_error(
"bedrock_mantle_stream_decode_failed",
ProviderErrorCategory::ProviderInternal,
"invalid Mantle event JSON",
)
})?;
events.push(serde_json::Value::String(data));
Ok(())
}
}
fn bedrock_incomplete_stream_error(surface: &str, saw_output: bool) -> ProviderError {
let mut error = provider_error(
format!("bedrock_{surface}_stream_incomplete"),
ProviderErrorCategory::Network,
"Bedrock stream ended before reporting a terminal stop reason",
);
error
.diagnostic_context
.insert("api_surface".to_string(), surface.to_string());
error
.diagnostic_context
.insert("saw_output".to_string(), saw_output.to_string());
error.sources.push(ProviderErrorSource {
source: "bedrock_stream".to_string(),
code: Some("unexpected_eof".to_string()),
message: None,
});
error
}
async fn read_mantle_anthropic_stream(
mut response: reqwest::Response,
turn: &TurnState,
name_map: BTreeMap<String, String>,
synthetic: Option<SyntheticStructuredOutput>,
cache_ttl_seconds: Option<u64>,
model_id: &str,
) -> Result<StreamOutcome, ProviderError> {
let mut decoder = MantleSseDecoder::default();
let mut accumulator = AnthropicMessagesAccumulator::new_with_pricing_context(
name_map,
synthetic,
cache_ttl_seconds,
Some(bcode_model::model_billing_scope_from_effective_id(model_id)),
);
loop {
if turn.is_cancelled() {
return Ok(StreamOutcome::Cancelled);
}
tokio::select! {
chunk = response.chunk() => {
if let Some(chunk) = chunk.map_err(|error| mantle_network_error("stream_failed", &error))? {
for event in decoder.push(&chunk)? {
if let Some(outcome) = accumulator.process_json(event.as_str().ok_or_else(|| provider_error("bedrock_decode",ProviderErrorCategory::ProviderInternal,"expected Messages JSON"))?, turn)? {
return Ok(outcome);
}
}
} else {
for event in decoder.finish()? {
if let Some(outcome) = accumulator.process_json(event.as_str().ok_or_else(|| provider_error("bedrock_decode",ProviderErrorCategory::ProviderInternal,"expected Messages JSON"))?, turn)? {
return Ok(outcome);
}
}
if !accumulator.saw_terminal_stop_reason() {
return Err(bedrock_incomplete_stream_error(
"mantle_anthropic",
accumulator.saw_output(),
));
}
accumulator.emit_usage(turn);
return Ok(accumulator.finish());
}
}
() = turn.cancelled() => return Ok(StreamOutcome::Cancelled),
}
}
}
async fn stream_bedrock_messages_turn(
request: &ModelTurnRequest,
client: &Client,
selection: &ModelSelection,
region: Option<&str>,
turn: &TurnState,
name_map: BTreeMap<String, String>,
) -> Result<StreamOutcome, ProviderError> {
let mut last_error = None;
for model_id in &selection.model_ids {
match stream_bedrock_messages_model(
request,
client,
model_id,
region,
turn,
name_map.clone(),
)
.await
{
Ok(outcome) => return Ok(outcome),
Err(error) => {
let is_last = selection.model_ids.last().map(String::as_str) == Some(model_id);
if selection.explicit || is_last {
return Err(error);
}
last_error = Some(error);
}
}
}
Err(last_error.unwrap_or_else(|| {
provider_error(
"bedrock_messages_model_unavailable",
ProviderErrorCategory::Config,
"no usable Bedrock Messages model was available for the turn",
)
}))
}
fn bedrock_messages_structured_fallback_request(
request: &ModelTurnRequest,
) -> Result<(ModelTurnRequest, SyntheticStructuredOutput), ProviderError> {
let structured = request.structured_output.as_ref().ok_or_else(|| {
provider_error(
"structured_output_fallback_missing_schema",
ProviderErrorCategory::InvalidRequest,
"structured-output fallback requires a schema",
)
})?;
let synthetic = SyntheticStructuredOutput::new(structured, !request.tools.is_empty())?;
let mut fallback = request.clone();
fallback.structured_output = None;
fallback.tools = vec![synthetic.tool()];
fallback.tool_schema_mode = None;
fallback.tool_call_policy.choice = ToolChoice::Required;
fallback.tool_call_policy.parallel = Some(false);
Ok((fallback, synthetic))
}
async fn stream_bedrock_messages_model(
request: &ModelTurnRequest,
client: &Client,
model_id: &str,
region: Option<&str>,
turn: &TurnState,
name_map: BTreeMap<String, String>,
) -> Result<StreamOutcome, ProviderError> {
match send_bedrock_messages_request(
request,
client,
model_id,
region,
turn,
name_map.clone(),
None,
)
.await
{
Ok(outcome) => Ok(outcome),
Err(error)
if request.structured_output.is_some()
&& !is_claude_fable_5_1(&request.model_id)
&& bedrock_messages_native_structured_output_rejected(&error) =>
{
let (fallback, synthetic) = bedrock_messages_structured_fallback_request(request)?;
turn.push(ProviderTurnEvent::Warning {
message: "Bedrock model rejected native structured output; retrying with adapter-mediated schema enforcement"
.to_string(),
});
send_bedrock_messages_request(
&fallback,
client,
model_id,
region,
turn,
name_map,
Some(synthetic),
)
.await
}
Err(error) => Err(error),
}
}
async fn send_bedrock_messages_request(
request: &ModelTurnRequest,
client: &Client,
model_id: &str,
region: Option<&str>,
turn: &TurnState,
name_map: BTreeMap<String, String>,
synthetic: Option<SyntheticStructuredOutput>,
) -> Result<StreamOutcome, ProviderError> {
let body = build_anthropic_messages_request(request, model_id, region)?;
let response = client
.invoke_model_with_response_stream()
.model_id(model_id)
.content_type("application/json")
.accept("application/json")
.body(Blob::new(body))
.send()
.await
.map_err(|error| bedrock_messages_sdk_error(&error))?;
read_anthropic_messages_stream(
response.body,
turn,
name_map,
synthetic,
request.prompt_cache.ttl_seconds,
model_id,
)
.await
}
fn bedrock_messages_native_structured_output_rejected(error: &ProviderError) -> bool {
error.category == ProviderErrorCategory::InvalidRequest
&& error
.provider_message
.as_deref()
.is_some_and(|message| message.contains("output_config.format"))
}
enum TurnAttempt {
Completed(Result<StreamOutcome, ProviderError>),
TryNextModel(ProviderError),
}
#[allow(clippy::too_many_arguments)]
async fn handle_bedrock_turn_error(
error: ProviderError,
request: &ModelTurnRequest,
model_id: &str,
selection: &ModelSelection,
region: Option<&str>,
turn: &TurnState,
client: &Client,
discovery: &Arc<Mutex<DiscoveryCache>>,
name_map: BTreeMap<String, String>,
) -> TurnAttempt {
let is_last = selection.model_ids.last().map(String::as_str) == Some(model_id);
if !selection.explicit && streaming_tool_use_unsupported(&error) && !is_last {
mark_streaming_tool_unsupported(
discovery,
selection.cache_key.as_ref(),
model_id,
&error.message,
);
turn.push(ProviderTurnEvent::Warning {
message: format!(
"Bedrock model {model_id} does not support streaming tool use; retrying another discovered model"
),
});
return TurnAttempt::TryNextModel(error);
}
if model_unusable_via_converse(&error) {
turn.push(ProviderTurnEvent::Warning {
message: format!(
"Bedrock model {model_id} requires the Anthropic Messages API; retrying through InvokeModelWithResponseStream"
),
});
return TurnAttempt::Completed(
stream_bedrock_messages_model(request, client, model_id, region, turn, name_map).await,
);
}
TurnAttempt::Completed(Err(error))
}
fn disable_prompt_cache(request: &mut ModelTurnRequest) {
request.prompt_cache = bcode_model::PromptCacheHints::default();
for message in &mut request.messages {
message
.content
.retain(|block| !matches!(block, ContentBlock::CachePoint { .. }));
}
}
async fn bedrock_client(settings: &Settings) -> Client {
let config = bedrock_sdk_config(settings).await;
client_context_bearer_token(settings).map_or_else(
|| Client::new(&config),
|token| {
let config = aws_sdk_bedrockruntime::config::Builder::from(&config)
.bearer_token(aws_sdk_bedrockruntime::config::Token::new(token, None))
.auth_scheme_preference([
aws_smithy_runtime_api::client::auth::http::HTTP_BEARER_AUTH_SCHEME_ID,
])
.build();
Client::from_conf(config)
},
)
}
async fn bedrock_sdk_config(settings: &Settings) -> aws_config::SdkConfig {
let request_scoped = settings.config_source.starts_with("request/");
let initial_region = settings
.region
.clone()
.or_else(|| request_scoped.then(|| DEFAULT_REGION.to_string()));
let mut config = bedrock_sdk_config_with_region(settings, initial_region).await;
if config.region().is_none() {
tracing::debug!(
target: "bcode_bedrock::config",
fallback_region = DEFAULT_REGION,
"AWS SDK region chain did not resolve a region; using Bedrock fallback region"
);
config = bedrock_sdk_config_with_region(settings, Some(DEFAULT_REGION.to_string())).await;
}
config
}
async fn bedrock_sdk_config_with_region(
settings: &Settings,
region: Option<String>,
) -> aws_config::SdkConfig {
let mut loader = aws_config::defaults(BehaviorVersion::latest());
if let Some(region) = region {
loader = loader.region(Region::new(region));
}
if let Some(profile) = &settings.aws_profile {
loader = loader.profile_name(profile.clone());
}
if let Some(endpoint_url) = &settings.endpoint_url {
loader = loader.endpoint_url(endpoint_url.clone());
}
if let Some(credentials) = client_context_credentials(settings) {
loader = loader.credentials_provider(credentials);
} else if settings.config_source.starts_with("request/") && settings.aws_profile.is_none() {
loader = loader.no_credentials();
}
loader.load().await
}
fn client_context_bearer_token(settings: &Settings) -> Option<String> {
settings
.auth_credentials
.get("bearer_token")
.or_else(|| settings.env.get("AWS_BEARER_TOKEN_BEDROCK"))
.filter(|value| !value.trim().is_empty())
.cloned()
}
fn client_context_credentials(settings: &Settings) -> Option<Credentials> {
let access_key = settings
.auth_credentials
.get("access_key_id")
.or_else(|| settings.env.get("AWS_ACCESS_KEY_ID"))
.filter(|value| !value.trim().is_empty())?;
let secret_key = settings
.auth_credentials
.get("secret_access_key")
.or_else(|| settings.env.get("AWS_SECRET_ACCESS_KEY"))
.filter(|value| !value.trim().is_empty())?;
let session_token = settings
.auth_credentials
.get("session_token")
.or_else(|| settings.env.get("AWS_SESSION_TOKEN"))
.filter(|value| !value.trim().is_empty())
.cloned();
Some(Credentials::new(
access_key.clone(),
secret_key.clone(),
session_token,
None,
"bcode-client-context",
))
}
async fn read_anthropic_messages_stream(
mut stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver<
ResponseStream,
aws_sdk_bedrockruntime::types::error::ResponseStreamError,
>,
turn: &TurnState,
name_map: BTreeMap<String, String>,
synthetic: Option<SyntheticStructuredOutput>,
cache_ttl_seconds: Option<u64>,
model_id: &str,
) -> Result<StreamOutcome, ProviderError> {
let mut accumulator = AnthropicMessagesAccumulator::new_with_pricing_context(
name_map,
synthetic,
cache_ttl_seconds,
Some(bcode_model::model_billing_scope_from_effective_id(model_id)),
);
loop {
if turn.is_cancelled() {
return Ok(StreamOutcome::Cancelled);
}
tokio::select! {
event = stream.recv() => {
let Some(event) = event.map_err(|error| bedrock_messages_stream_error(&error))? else {
if !accumulator.saw_terminal_stop_reason() {
return Err(bedrock_incomplete_stream_error(
"messages",
accumulator.saw_output(),
));
}
accumulator.emit_usage(turn);
return Ok(accumulator.finish());
};
if let ResponseStream::Chunk(chunk) = event
&& let Some(bytes) = chunk.bytes()
{
let json = std::str::from_utf8(bytes.as_ref()).map_err(|_| provider_error("bedrock_decode",ProviderErrorCategory::ProviderInternal,"invalid Messages encoding"))?;
if let Some(outcome) = accumulator.process_json(json, turn)? {
return Ok(outcome);
}
}
}
() = turn.cancelled() => return Ok(StreamOutcome::Cancelled),
}
}
}
fn usage_decoders() -> &'static [&'static dyn bcode_model::UsageDecoder] {
&[
&usage::MessagesUsage,
&usage::ConverseUsage,
&bcode_openai_responses::ResponsesUsageDialect::Bedrock,
]
}
#[cfg(test)]
fn normalize_original_usage(
original: &bcode_session_models::OriginalUsage,
) -> Result<TokenUsage, String> {
bcode_model_provider_runtime::normalize_registered_usage(
PROVIDER_ID,
usage_decoders(),
original,
)
}
fn anthropic_messages_event_error(event: &serde_json::Value) -> ProviderError {
let error_type = event
.pointer("/error/type")
.and_then(serde_json::Value::as_str)
.unwrap_or("stream_error");
let message = event
.pointer("/error/message")
.and_then(serde_json::Value::as_str)
.map_or_else(
|| "Bedrock Anthropic Messages stream returned an error".to_string(),
sanitize_provider_diagnostic,
);
let category = match error_type {
"authentication_error" | "permission_error" => ProviderErrorCategory::Auth,
"invalid_request_error" | "not_found_error" => ProviderErrorCategory::InvalidRequest,
"rate_limit_error" | "overloaded_error" | "api_error" => ProviderErrorCategory::Network,
_ => ProviderErrorCategory::ProviderInternal,
};
provider_error(format!("bedrock_anthropic_{error_type}"), category, message)
}
struct AnthropicMessagesAccumulator {
tool_calls: BTreeMap<u32, ToolCallAccumulator>,
reasoning_blocks: BTreeMap<u32, String>,
redacted_reasoning_blocks: BTreeSet<u32>,
saw_tool_call: bool,
synthetic_output_emitted: bool,
synthetic_call_indexes: BTreeSet<u32>,
synthetic: Option<SyntheticStructuredOutput>,
stop_reason: Option<StopReason>,
usage_sink: Option<TurnState>,
usage: bcode_model_provider_runtime::UsageRecorder<'static>,
exact_input_emitted: bool,
name_map: BTreeMap<String, String>,
}
impl Drop for AnthropicMessagesAccumulator {
fn drop(&mut self) {
let complete = self.saw_terminal_stop_reason();
if let Some(turn) = self.usage_sink.take() {
self.usage.finish(complete, |event| turn.push(event));
}
}
}
impl AnthropicMessagesAccumulator {
#[cfg(test)]
fn new(
name_map: BTreeMap<String, String>,
synthetic: Option<SyntheticStructuredOutput>,
) -> Self {
Self::new_with_pricing_context(name_map, synthetic, None, None)
}
fn new_with_pricing_context(
name_map: BTreeMap<String, String>,
synthetic: Option<SyntheticStructuredOutput>,
cache_ttl_seconds: Option<u64>,
billing_scope: Option<String>,
) -> Self {
let mut requested = BTreeMap::new();
if let Some(ttl) = cache_ttl_seconds {
requested.insert("cache_ttl_seconds".into(), ttl.to_string());
}
if let Some(scope) = billing_scope {
requested.insert("billing_scope".into(), scope);
}
Self {
tool_calls: BTreeMap::new(),
reasoning_blocks: BTreeMap::new(),
redacted_reasoning_blocks: BTreeSet::new(),
saw_tool_call: false,
synthetic_output_emitted: false,
synthetic_call_indexes: BTreeSet::new(),
synthetic,
stop_reason: None,
usage_sink: None,
usage: bcode_model_provider_runtime::UsageRecorder::new(
PROVIDER_ID,
&usage::MessagesUsage,
requested,
true,
),
exact_input_emitted: false,
name_map,
}
}
fn process_json(
&mut self,
json: &str,
turn: &TurnState,
) -> Result<Option<StreamOutcome>, ProviderError> {
self.usage
.set_capture_enabled(turn.original_usage_enabled());
self.usage_sink = Some(turn.clone());
self.usage.observe_json(json);
let event: serde_json::Value = serde_json::from_str(json).map_err(|_| {
provider_error(
"bedrock_messages_stream_decode_failed",
ProviderErrorCategory::ProviderInternal,
"invalid Messages event",
)
})?;
self.process_event(&event, turn)
}
#[cfg(test)]
fn process(
&mut self,
event: &serde_json::Value,
turn: &TurnState,
) -> Result<Option<StreamOutcome>, ProviderError> {
self.process_json(&event.to_string(), turn)
}
fn process_event(
&mut self,
event: &serde_json::Value,
turn: &TurnState,
) -> Result<Option<StreamOutcome>, ProviderError> {
match event.get("type").and_then(serde_json::Value::as_str) {
Some("message_start") => {
self.emit_exact_input(turn);
}
Some("error") => return Err(anthropic_messages_event_error(event)),
Some("content_block_start") => self.start_content_block(event, turn)?,
Some("content_block_delta") => self.content_block_delta(event, turn),
Some("content_block_stop") => self.finish_content_block(event, turn)?,
Some("message_delta") => {
self.record_message_delta_stop_reason(event);
}
Some("message_stop") => {
self.emit_usage(turn);
return Ok(Some(self.finish()));
}
_ => {}
}
Ok(None)
}
fn start_content_block(
&mut self,
event: &serde_json::Value,
turn: &TurnState,
) -> Result<(), ProviderError> {
let index = event_index(event)?;
let block = event
.get("content_block")
.unwrap_or(&serde_json::Value::Null);
match block.get("type").and_then(serde_json::Value::as_str) {
Some("tool_use") => {
let id = required_event_string(block, "id")?;
let name = required_event_string(block, "name")?;
let synthetic = self
.synthetic
.as_ref()
.is_some_and(|constraint| constraint.matches_tool_name(&name));
if synthetic {
self.synthetic_call_indexes.insert(index);
} else {
self.saw_tool_call = true;
turn.push(ProviderTurnEvent::ToolCallStarted {
call_id: id.clone(),
name: original_tool_name(&name, &self.name_map),
});
}
self.tool_calls.insert(
index,
ToolCallAccumulator {
id: Some(id),
name: Some(name),
arguments: String::new(),
},
);
}
Some(block_type @ ("thinking" | "redacted_thinking")) => {
self.reasoning_blocks.insert(index, String::new());
let activity_id = format!("bedrock-messages-reasoning-{index}");
turn.push(ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::Started {
activity_id: activity_id.clone(),
order: index,
},
});
if block_type == "redacted_thinking" {
self.redacted_reasoning_blocks.insert(index);
turn.push(ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::OpaqueObserved {
activity_id,
activity_order: index,
},
});
}
}
_ => {}
}
Ok(())
}
fn content_block_delta(&mut self, event: &serde_json::Value, turn: &TurnState) {
let Some(index) = event
.get("index")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok())
else {
return;
};
let delta = event.get("delta").unwrap_or(&serde_json::Value::Null);
match delta.get("type").and_then(serde_json::Value::as_str) {
Some("text_delta") => {
if let Some(text) = delta
.get("text")
.and_then(serde_json::Value::as_str)
.filter(|text| !text.is_empty())
{
turn.push(ProviderTurnEvent::TextDelta {
text: text.to_string(),
});
}
}
Some("input_json_delta") => {
if let Some(json) = delta
.get("partial_json")
.and_then(serde_json::Value::as_str)
&& let Some(tool) = self.tool_calls.get_mut(&index)
{
tool.arguments.push_str(json);
if !json.is_empty()
&& !self.synthetic_call_indexes.contains(&index)
&& let Some(call_id) = &tool.id
{
turn.push(ProviderTurnEvent::ToolCallDelta {
call_id: call_id.clone(),
delta: json.to_string(),
});
}
}
}
Some("thinking_delta") => {
if let Some(text) = delta
.get("thinking")
.and_then(serde_json::Value::as_str)
.filter(|text| !text.is_empty())
{
self.reasoning_blocks
.entry(index)
.or_default()
.push_str(text);
turn.push(ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::PartDelta {
activity_id: format!("bedrock-messages-reasoning-{index}"),
activity_order: index,
part_id: format!("raw-{index}"),
kind: bcode_session_models::ReasoningContentKind::Raw,
role: bcode_session_models::ReasoningContentRole::Detail,
part_order: index,
text: text.to_string(),
},
});
}
}
_ => {}
}
}
fn finish_content_block(
&mut self,
event: &serde_json::Value,
turn: &TurnState,
) -> Result<(), ProviderError> {
let index = event_index(event)?;
if let Some(tool) = self.tool_calls.get(&index) {
let id = tool.id.clone().ok_or_else(|| {
provider_error(
"missing_tool_call_id",
ProviderErrorCategory::ProviderInternal,
"Bedrock Messages emitted a tool call without an id",
)
})?;
let name = tool.name.clone().ok_or_else(|| {
provider_error(
"missing_tool_call_name",
ProviderErrorCategory::ProviderInternal,
"Bedrock Messages emitted a tool call without a name",
)
})?;
let arguments = if tool.arguments.trim().is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(&tool.arguments).map_err(|error| {
provider_error(
"tool_arguments_decode_failed",
ProviderErrorCategory::ProviderInternal,
format!("failed to decode arguments for tool call {id} ({name}): {error}"),
)
})?
};
let call = ToolCall {
id,
name: original_tool_name(&name, &self.name_map),
arguments,
};
if self.synthetic_call_indexes.remove(&index) {
let constraint = self.synthetic.as_ref().ok_or_else(|| {
provider_error(
"missing_structured_output_constraint",
ProviderErrorCategory::ProviderInternal,
"synthetic structured output call had no matching constraint",
)
})?;
let text = constraint.completed_text(&call)?;
turn.push(ProviderTurnEvent::TextDelta { text });
self.synthetic_output_emitted = true;
} else {
turn.push(ProviderTurnEvent::ToolCallFinished { call });
}
}
if let Some(text) = self.reasoning_blocks.remove(&index) {
let activity_id = format!("bedrock-messages-reasoning-{index}");
let redacted = self.redacted_reasoning_blocks.remove(&index);
if !redacted && !text.is_empty() {
turn.push(ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::PartCompleted {
activity_id: activity_id.clone(),
activity_order: index,
part_id: format!("raw-{index}"),
kind: bcode_session_models::ReasoningContentKind::Raw,
role: bcode_session_models::ReasoningContentRole::Detail,
part_order: index,
text,
},
});
}
turn.push(ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::Finished {
activity_id,
activity_order: index,
status: bcode_session_models::ReasoningActivityStatus::Completed,
},
});
}
Ok(())
}
fn record_message_delta_stop_reason(&mut self, event: &serde_json::Value) {
if let Some(stop_reason) = event
.get("delta")
.and_then(|delta| delta.get("stop_reason"))
.and_then(serde_json::Value::as_str)
.and_then(map_anthropic_stop_reason)
{
self.stop_reason = Some(stop_reason);
}
}
fn emit_exact_input(&mut self, turn: &TurnState) {
if self.exact_input_emitted {
return;
}
if let Some(tokens) = self
.usage
.normalized()
.ok()
.and_then(|usage| usage.pricing_context.request_input_tokens)
{
turn.push(ProviderTurnEvent::ExactRequestInputTokens {
tokens: bcode_model::ExactRequestInputTokens::new(tokens),
});
self.exact_input_emitted = true;
}
}
fn emit_usage(&mut self, turn: &TurnState) {
self.emit_exact_input(turn);
self.usage.finish(true, |event| turn.push(event));
}
const fn finish(&self) -> StreamOutcome {
if matches!(self.stop_reason, Some(StopReason::MaxTokens)) {
return StreamOutcome::MaxTokens;
}
if matches!(self.stop_reason, Some(StopReason::Refusal)) {
return StreamOutcome::Refusal;
}
if self.synthetic_output_emitted {
StreamOutcome::Finished
} else if self.saw_tool_call {
StreamOutcome::ToolCall
} else {
StreamOutcome::Finished
}
}
const fn saw_terminal_stop_reason(&self) -> bool {
self.stop_reason.is_some()
}
const fn saw_output(&self) -> bool {
self.saw_tool_call || self.synthetic_output_emitted
}
}
fn event_index(event: &serde_json::Value) -> Result<u32, ProviderError> {
event.get("index").and_then(json_u32).ok_or_else(|| {
provider_error(
"bedrock_messages_stream_invalid_event",
ProviderErrorCategory::ProviderInternal,
"Bedrock Messages stream event omitted a valid content block index",
)
})
}
fn required_event_string(value: &serde_json::Value, key: &str) -> Result<String, ProviderError> {
value
.get(key)
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.ok_or_else(|| {
provider_error(
"bedrock_messages_stream_invalid_event",
ProviderErrorCategory::ProviderInternal,
format!("Bedrock Messages stream content block omitted {key}"),
)
})
}
fn json_u32(value: &serde_json::Value) -> Option<u32> {
value.as_u64().and_then(|value| u32::try_from(value).ok())
}
fn converse_sdk_report(
usage: &aws_sdk_bedrockruntime::types::TokenUsage,
) -> bcode_session_models::OriginalUsageReport {
bcode_session_models::OriginalUsageReport {source:"metadata".into(),confirmed:BTreeMap::new(),
usage_json:serde_json::json!({"inputTokens":usage.input_tokens(),"outputTokens":usage.output_tokens(),"totalTokens":usage.total_tokens(),"cacheReadInputTokens":usage.cache_read_input_tokens(),"cacheWriteInputTokens":usage.cache_write_input_tokens()}).to_string()}
}
#[cfg(test)]
fn capture_converse_sdk_usage(
usage: &aws_sdk_bedrockruntime::types::TokenUsage,
) -> bcode_session_models::OriginalUsage {
let mut recorder = bcode_model_provider_runtime::UsageRecorder::new(
PROVIDER_ID,
&usage::ConverseUsage,
BTreeMap::new(),
true,
);
recorder.observe_sdk(converse_sdk_report(usage));
let mut original = None;
recorder.finish(true, |event| {
if let ProviderTurnEvent::OriginalUsage { original: value } = event {
original = Some(*value);
}
});
original.unwrap()
}
async fn read_bedrock_stream(
mut stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver<
ConverseStreamOutput,
aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError,
>,
turn: &TurnState,
name_map: BTreeMap<String, String>,
) -> Result<StreamOutcome, ProviderError> {
let mut accumulator = StreamAccumulator::new(name_map);
loop {
if turn.is_cancelled() {
return Ok(StreamOutcome::Cancelled);
}
tokio::select! {
event = stream.recv() => {
let Some(event) = event.map_err(|error| bedrock_stream_error(&error))? else {
if !accumulator.saw_terminal_stop_reason() {
return Err(bedrock_incomplete_stream_error(
"converse",
accumulator.saw_output(),
));
}
return Ok(accumulator.finish_outcome());
};
if let Some(outcome) = accumulator.process_event(event, turn)? {
return Ok(outcome);
}
}
() = turn.cancelled() => return Ok(StreamOutcome::Cancelled),
}
}
}
#[derive(Debug)]
struct StreamAccumulator {
tool_calls: BTreeMap<i32, ToolCallAccumulator>,
saw_tool_call: bool,
stop_reason: Option<StopReason>,
reasoning_blocks: BTreeMap<i32, String>,
name_map: BTreeMap<String, String>,
saw_message_stop: bool,
usage: bcode_model_provider_runtime::UsageRecorder<'static>,
}
impl StreamAccumulator {
fn new(name_map: BTreeMap<String, String>) -> Self {
Self {
tool_calls: BTreeMap::new(),
saw_tool_call: false,
stop_reason: None,
reasoning_blocks: BTreeMap::new(),
name_map,
saw_message_stop: false,
usage: bcode_model_provider_runtime::UsageRecorder::new(
PROVIDER_ID,
&usage::ConverseUsage,
BTreeMap::new(),
false,
),
}
}
fn process_event(
&mut self,
event: ConverseStreamOutput,
turn: &TurnState,
) -> Result<Option<StreamOutcome>, ProviderError> {
match event {
ConverseStreamOutput::ContentBlockStart(event) => {
if let Some(ContentBlockStart::ToolUse(tool_use)) = event.start() {
let entry = self
.tool_calls
.entry(event.content_block_index())
.or_default();
entry.id = Some(tool_use.tool_use_id().to_string());
entry.name = Some(tool_use.name().to_string());
self.saw_tool_call = true;
turn.push(ProviderTurnEvent::ToolCallStarted {
call_id: tool_use.tool_use_id().to_string(),
name: original_tool_name(tool_use.name(), &self.name_map),
});
}
}
ConverseStreamOutput::ContentBlockDelta(event) => match event.delta() {
Some(ContentBlockDelta::Text(text)) if !text.is_empty() => {
turn.push(ProviderTurnEvent::TextDelta { text: text.clone() });
}
Some(ContentBlockDelta::ToolUse(delta)) => {
self.process_tool_use_delta(event.content_block_index(), delta.input(), turn);
}
Some(ContentBlockDelta::ReasoningContent(delta)) => {
self.process_reasoning_delta(event.content_block_index(), delta, turn);
}
_ => {}
},
ConverseStreamOutput::Metadata(event) => {
if let Some(usage) = event.usage() {
self.usage
.set_capture_enabled(turn.original_usage_enabled());
self.usage.observe_sdk(converse_sdk_report(usage));
let input_tokens = self
.usage
.finish(true, |event| turn.push(event))
.and_then(|usage| usage.input_tokens);
if let Some(input_tokens) = input_tokens {
turn.push(ProviderTurnEvent::ExactRequestInputTokens {
tokens: bcode_model::ExactRequestInputTokens::new(u64::from(
input_tokens,
)),
});
}
}
if self.saw_message_stop {
return Ok(Some(self.finish_outcome()));
}
}
ConverseStreamOutput::MessageStop(event) => {
self.stop_reason = Some(map_stop_reason(event.stop_reason()));
self.finish_reasoning(turn);
if self.saw_tool_call {
self.finish_tool_calls(turn)?;
}
self.saw_message_stop = true;
}
_ => {}
}
Ok(None)
}
fn process_reasoning_delta(
&mut self,
content_block_index: i32,
delta: &ReasoningContentBlockDelta,
turn: &TurnState,
) {
let part_order = u32::try_from(content_block_index).unwrap_or_default();
let activity_id = format!("bedrock-reasoning-{content_block_index}");
let part_id = format!("raw-{part_order}");
let started = self.reasoning_blocks.contains_key(&content_block_index);
if !started {
self.reasoning_blocks
.insert(content_block_index, String::new());
turn.push(ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::Started {
activity_id: activity_id.clone(),
order: part_order,
},
});
}
match delta {
ReasoningContentBlockDelta::Text(text) if !text.is_empty() => {
self.reasoning_blocks
.entry(content_block_index)
.or_default()
.push_str(text);
turn.push(ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::PartDelta {
activity_id,
activity_order: part_order,
part_id,
kind: bcode_session_models::ReasoningContentKind::Raw,
role: bcode_session_models::ReasoningContentRole::Detail,
part_order,
text: text.clone(),
},
});
}
ReasoningContentBlockDelta::RedactedContent(_) => {
turn.push(ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::OpaqueObserved {
activity_id,
activity_order: part_order,
},
});
}
_ => {}
}
}
fn finish_reasoning(&mut self, turn: &TurnState) {
for (content_block_index, text) in std::mem::take(&mut self.reasoning_blocks) {
let part_order = u32::try_from(content_block_index).unwrap_or_default();
let activity_id = format!("bedrock-reasoning-{content_block_index}");
if !text.is_empty() {
turn.push(ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::PartCompleted {
activity_id: activity_id.clone(),
activity_order: part_order,
part_id: format!("raw-{part_order}"),
kind: bcode_session_models::ReasoningContentKind::Raw,
role: bcode_session_models::ReasoningContentRole::Detail,
part_order,
text,
},
});
}
turn.push(ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::Finished {
activity_id,
activity_order: part_order,
status: bcode_session_models::ReasoningActivityStatus::Completed,
},
});
}
}
fn process_tool_use_delta(&mut self, content_block_index: i32, input: &str, turn: &TurnState) {
let entry = self.tool_calls.entry(content_block_index).or_default();
entry.arguments.push_str(input);
if !input.is_empty()
&& let Some(call_id) = &entry.id
{
turn.push(ProviderTurnEvent::ToolCallDelta {
call_id: call_id.clone(),
delta: input.to_string(),
});
}
}
fn finish_tool_calls(&self, turn: &TurnState) -> Result<(), ProviderError> {
for accumulator in self.tool_calls.values() {
let id = accumulator.id.clone().ok_or_else(|| {
provider_error(
"missing_tool_call_id",
ProviderErrorCategory::ProviderInternal,
"Bedrock emitted a tool call without an id",
)
})?;
let name = accumulator.name.clone().ok_or_else(|| {
provider_error(
"missing_tool_call_name",
ProviderErrorCategory::ProviderInternal,
"Bedrock emitted a tool call without a name",
)
})?;
let arguments = if accumulator.arguments.trim().is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(&accumulator.arguments).map_err(|error| {
let mut error = provider_error(
"tool_arguments_decode_failed",
ProviderErrorCategory::ProviderInternal,
format!(
"failed to decode arguments for tool call {id} ({name}): {error}; received {} bytes",
accumulator.arguments.len()
),
);
error.retryable = false;
error
})?
};
turn.push(ProviderTurnEvent::ToolCallFinished {
call: ToolCall {
id,
name: original_tool_name(&name, &self.name_map),
arguments,
},
});
}
Ok(())
}
const fn finish_outcome(&self) -> StreamOutcome {
if matches!(self.stop_reason, Some(StopReason::MaxTokens)) {
return StreamOutcome::MaxTokens;
}
if self.saw_tool_call {
StreamOutcome::ToolCall
} else {
StreamOutcome::Finished
}
}
const fn saw_terminal_stop_reason(&self) -> bool {
self.saw_message_stop
}
const fn saw_output(&self) -> bool {
self.saw_tool_call
}
}
#[derive(Debug, Default)]
struct ToolCallAccumulator {
id: Option<String>,
name: Option<String>,
arguments: String,
}
struct BedrockConverseRequest {
model_id: String,
messages: Vec<BedrockMessage>,
system: Vec<SystemContentBlock>,
tool_config: Option<ToolConfiguration>,
inference_config: Option<InferenceConfiguration>,
output_config: Option<OutputConfig>,
additional_model_request_fields: Option<Document>,
}
fn bedrock_tool_input_schema(tool: &ToolDefinition) -> Result<&serde_json::Value, ProviderError> {
let Some(schema) = tool.input_schema.as_object() else {
return Err(provider_error(
"bedrock_tool_schema_invalid",
ProviderErrorCategory::InvalidRequest,
format!(
"Bedrock tool '{}' input schema must be an object",
tool.name
),
));
};
if let Some(keyword) = ["oneOf", "allOf", "anyOf"]
.into_iter()
.find(|keyword| schema.contains_key(*keyword))
{
return Err(provider_error(
"bedrock_tool_schema_unsupported",
ProviderErrorCategory::InvalidRequest,
format!(
"Bedrock tool '{}' input schema uses unsupported top-level {keyword}",
tool.name
),
));
}
Ok(&tool.input_schema)
}
fn make_tool_schema_strict(schema: &serde_json::Value) -> serde_json::Value {
match schema {
serde_json::Value::Object(obj) => {
let mut new_obj = serde_json::Map::new();
if obj.get("type").and_then(|v| v.as_str()) == Some("object") {
let original_required = obj
.get("required")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect::<std::collections::HashSet<_>>()
})
.unwrap_or_default();
if let Some(serde_json::Value::Object(properties)) = obj.get("properties") {
let mut new_properties = serde_json::Map::new();
for (key, value) in properties {
let strict_value = make_tool_schema_strict(value);
if !original_required.contains(key.as_str())
&& !schema_allows_null(&strict_value)
{
new_properties.insert(
key.clone(),
serde_json::json!({
"anyOf": [strict_value, {"type": "null"}]
}),
);
} else {
new_properties.insert(key.clone(), strict_value);
}
}
new_obj.insert(
"properties".to_string(),
serde_json::Value::Object(new_properties.clone()),
);
let required_keys: Vec<_> = new_properties
.keys()
.map(|k| serde_json::Value::String(k.clone()))
.collect();
new_obj.insert(
"required".to_string(),
serde_json::Value::Array(required_keys),
);
}
if !obj.contains_key("additionalProperties") {
new_obj.insert(
"additionalProperties".to_string(),
serde_json::Value::Bool(false),
);
}
for (key, value) in obj {
if key != "properties" && key != "required" && key != "additionalProperties" {
new_obj.insert(key.clone(), make_tool_schema_strict(value));
}
}
} else {
for (key, value) in obj {
new_obj.insert(key.clone(), make_tool_schema_strict(value));
}
}
serde_json::Value::Object(new_obj)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(make_tool_schema_strict).collect())
}
other => other.clone(),
}
}
fn schema_allows_null(schema: &serde_json::Value) -> bool {
let Some(obj) = schema.as_object() else {
return false;
};
if let Some(type_val) = obj.get("type") {
if type_val.as_str() == Some("null") {
return true;
}
if let Some(types) = type_val.as_array()
&& types.iter().any(|v| v.as_str() == Some("null"))
{
return true;
}
}
if obj.get("const").is_some_and(serde_json::Value::is_null) {
return true;
}
if let Some(enum_arr) = obj.get("enum").and_then(|v| v.as_array())
&& enum_arr.iter().any(serde_json::Value::is_null)
{
return true;
}
if let Some(any_of) = obj.get("anyOf").and_then(|v| v.as_array())
&& any_of.iter().any(schema_allows_null)
{
return true;
}
false
}
fn bedrock_tool_input_schema_for_model(
tool: &ToolDefinition,
model_id: &str,
) -> Result<serde_json::Value, ProviderError> {
let schema = bedrock_tool_input_schema(tool)?;
if model_id.contains(".openai.") {
Ok(make_tool_schema_strict(schema))
} else {
Ok(schema.clone())
}
}
fn anthropic_messages_max_tokens(request: &ModelTurnRequest) -> Result<u32, ProviderError> {
request.parameters.max_output_tokens.ok_or_else(|| {
provider_error(
"bedrock_messages_max_output_tokens_unresolved",
ProviderErrorCategory::InvalidRequest,
"Bedrock Anthropic Messages requires a resolved max_output_tokens for the selected \
model; refusing to substitute a provider-local default that would truncate output",
)
})
}
const ANTHROPIC_MESSAGES_MAX_CACHE_POINTS: usize = 4;
fn bound_anthropic_messages_cache_points(request: &mut ModelTurnRequest) {
let reserved = usize::from(
request.prompt_cache.cache_system_prompt
&& request
.system_prompt
.as_ref()
.is_some_and(|prompt| !prompt.trim().is_empty()),
) + usize::from(request.prompt_cache.cache_tools && !request.tools.is_empty());
let keep = ANTHROPIC_MESSAGES_MAX_CACHE_POINTS.saturating_sub(reserved);
let mut remaining = prompt_cache_point_count(request).saturating_sub(keep);
if remaining == 0 {
return;
}
for message in &mut request.messages {
message.content.retain(|block| {
if remaining > 0 && matches!(block, ContentBlock::CachePoint { .. }) {
remaining -= 1;
false
} else {
true
}
});
}
}
fn build_anthropic_messages_request_value(
request: &ModelTurnRequest,
model_id: &str,
region: Option<&str>,
) -> Result<serde_json::Map<String, serde_json::Value>, ProviderError> {
let mut body = serde_json::Map::new();
body.insert(
"anthropic_version".to_string(),
serde_json::json!("bedrock-2023-05-31"),
);
body.insert(
"max_tokens".to_string(),
serde_json::json!(anthropic_messages_max_tokens(request)?),
);
if let Some(system) = anthropic_system_content(request) {
body.insert("system".to_string(), system);
}
body.insert(
"messages".to_string(),
serde_json::Value::Array(
request
.messages
.iter()
.filter_map(|message| {
anthropic_message(message, request.prompt_cache.ttl_seconds).transpose()
})
.collect::<Result<Vec<_>, _>>()?,
),
);
if !request.tools.is_empty() && !matches!(request.tool_call_policy.choice, ToolChoice::None) {
body.insert(
"tools".to_string(),
serde_json::Value::Array(
request
.tools
.iter()
.map(|tool| {
let projected = serde_json::json!({
"name": bedrock_tool_name(&tool.name),
"description": tool.description,
"input_schema": bedrock_tool_input_schema(tool)?,
});
Ok(projected)
})
.collect::<Result<Vec<_>, ProviderError>>()?,
),
);
body.insert("tool_choice".to_string(), anthropic_tool_choice(request)?);
}
if request.prompt_cache.cache_tools
&& let Some(last_tool) = body
.get_mut("tools")
.and_then(serde_json::Value::as_array_mut)
.and_then(|tools| tools.last_mut())
.and_then(serde_json::Value::as_object_mut)
{
last_tool.insert(
"cache_control".to_string(),
anthropic_cache_control(request.prompt_cache.ttl_seconds),
);
}
if let Some(temperature) = request.parameters.temperature {
body.insert("temperature".to_string(), serde_json::json!(temperature));
}
if let Some(top_p) = request.parameters.top_p {
body.insert("top_p".to_string(), serde_json::json!(top_p));
}
if !request.parameters.stop_sequences.is_empty() {
body.insert(
"stop_sequences".to_string(),
serde_json::json!(request.parameters.stop_sequences),
);
}
if let Some(structured) = &request.structured_output {
let schema = bcode_model_schema::normalize(&structured.schema, &bedrock_schema_dialect())
.map_err(|error| {
provider_error(
"invalid_structured_output_schema",
ProviderErrorCategory::InvalidRequest,
error.to_string(),
)
})?;
body.insert(
"output_config".to_string(),
serde_json::json!({
"format": {
"type": "json_schema",
"schema": schema
}
}),
);
}
apply_anthropic_thinking_fields(
&mut body,
&request.parameters,
anthropic_thinking_display(region, model_id),
);
Ok(body)
}
fn build_anthropic_messages_request(
request: &ModelTurnRequest,
model_id: &str,
region: Option<&str>,
) -> Result<Vec<u8>, ProviderError> {
serde_json::to_vec(&build_anthropic_messages_request_value(
request, model_id, region,
)?)
.map_err(|error| build_error(&error))
}
fn build_mantle_anthropic_request(
request: &ModelTurnRequest,
model_id: &str,
region: Option<&str>,
) -> Result<serde_json::Value, ProviderError> {
let mut body = build_anthropic_messages_request_value(request, model_id, region)?;
body.remove("anthropic_version");
body.insert("model".to_string(), serde_json::json!(model_id));
body.insert("stream".to_string(), serde_json::Value::Bool(true));
Ok(serde_json::Value::Object(body))
}
fn anthropic_system_content(request: &ModelTurnRequest) -> Option<serde_json::Value> {
let mut blocks = Vec::new();
if let Some(prompt) = request
.system_prompt
.as_deref()
.filter(|prompt| !prompt.trim().is_empty())
{
let mut block = serde_json::json!({"type": "text", "text": prompt});
if request.prompt_cache.cache_system_prompt {
block
.as_object_mut()
.expect("text block is an object")
.insert(
"cache_control".to_string(),
anthropic_cache_control(request.prompt_cache.ttl_seconds),
);
}
blocks.push(block);
}
for message in &request.messages {
if message.role == MessageRole::System {
let text = joined_text_content(message);
if !text.is_empty() {
blocks.push(serde_json::json!({"type": "text", "text": text}));
}
}
}
(!blocks.is_empty()).then_some(serde_json::Value::Array(blocks))
}
fn anthropic_message(
message: &ModelMessage,
default_cache_ttl_seconds: Option<u64>,
) -> Result<Option<serde_json::Value>, ProviderError> {
let role = match message.role {
MessageRole::System => return Ok(None),
MessageRole::User | MessageRole::Tool => "user",
MessageRole::Assistant => "assistant",
};
let content = anthropic_content_blocks(message, default_cache_ttl_seconds)?;
Ok((!content.is_empty()).then(|| serde_json::json!({"role": role, "content": content})))
}
fn anthropic_cache_control(ttl_seconds: Option<u64>) -> serde_json::Value {
let mut control = serde_json::json!({"type": "ephemeral"});
if let Some(ttl) = ttl_seconds.and_then(|ttl| match ttl {
300 => Some("5m"),
3_600 => Some("1h"),
_ => None,
}) {
control["ttl"] = serde_json::json!(ttl);
}
control
}
const EMPTY_ERROR_TOOL_RESULT_PLACEHOLDER: &str = "Tool call failed without producing any output.";
fn anthropic_content_blocks(
message: &ModelMessage,
default_cache_ttl_seconds: Option<u64>,
) -> Result<Vec<serde_json::Value>, ProviderError> {
let mut blocks = Vec::new();
for block in &message.content {
match block {
ContentBlock::Text { text } if !text.is_empty() => {
blocks.push(serde_json::json!({"type": "text", "text": text}));
}
ContentBlock::Image { image } => {
base64::engine::general_purpose::STANDARD.decode(&image.data_base64).map_err(|error| {
provider_error(
"bedrock_invalid_image_data",
ProviderErrorCategory::InvalidRequest,
format!("invalid image data: {error}"),
)
})?;
blocks.push(serde_json::json!({
"type": "image",
"source": {"type": "base64", "media_type": image.mime_type, "data": image.data_base64}
}));
}
ContentBlock::ToolCall { call } => blocks.push(serde_json::json!({
"type": "tool_use", "id": call.id, "name": bedrock_tool_name(&call.name), "input": call.arguments,
})),
ContentBlock::ToolResult { result } => {
let mut content = Vec::with_capacity(result.content.len() + 1);
if !result.output.is_empty() {
content.push(serde_json::json!({"type": "text", "text": result.output}));
}
content.extend(result.content.iter().map(|item| match item {
bcode_model::ToolResultContent::Text { text } => serde_json::json!({"type": "text", "text": text}),
bcode_model::ToolResultContent::Image { image } => serde_json::json!({
"type": "image",
"source": {"type": "base64", "media_type": image.mime_type, "data": image.data_base64}
}),
bcode_model::ToolResultContent::ImageRef { image } => serde_json::json!({
"type": "text",
"text": format!("[image reference: {} {}]", image.path, image.mime_type),
}),
}));
if content.is_empty() && result.is_error {
content.push(serde_json::json!({
"type": "text", "text": EMPTY_ERROR_TOOL_RESULT_PLACEHOLDER,
}));
}
blocks.push(serde_json::json!({
"type": "tool_result", "tool_use_id": result.call_id,
"content": content, "is_error": result.is_error,
}));
}
ContentBlock::CachePoint { hint } => {
if let Some(previous) = blocks
.last_mut()
.and_then(serde_json::Value::as_object_mut)
{
previous.insert(
"cache_control".to_string(),
anthropic_cache_control(hint.ttl_seconds.or(default_cache_ttl_seconds)),
);
}
}
ContentBlock::ProviderExtension { .. } | ContentBlock::Text { .. } => {}
}
}
Ok(blocks)
}
fn anthropic_tool_choice(request: &ModelTurnRequest) -> Result<serde_json::Value, ProviderError> {
match &request.tool_call_policy.choice {
ToolChoice::Auto => Ok(serde_json::json!({"type": "auto"})),
ToolChoice::Required => Ok(serde_json::json!({"type": "any"})),
ToolChoice::Tool { name } => {
let tool = request
.tools
.iter()
.find(|tool| tool.name == *name)
.ok_or_else(|| {
provider_error(
"unknown_required_tool",
ProviderErrorCategory::InvalidRequest,
format!("required Bedrock tool '{name}' is not registered"),
)
})?;
Ok(serde_json::json!({"type": "tool", "name": bedrock_tool_name(&tool.name)}))
}
ToolChoice::None => Ok(serde_json::Value::Null),
}
}
const ANTHROPIC_THINKING_DISPLAY_SUMMARIZED: &str = "summarized";
fn govcloud_bedrock_target(region: Option<&str>, model_id: &str) -> bool {
if region.is_some_and(|region| {
region
.trim()
.to_ascii_lowercase()
.starts_with(GOVCLOUD_REGION_PREFIX)
}) {
return true;
}
let model_id = model_id.trim().to_ascii_lowercase();
model_id.starts_with(GOVCLOUD_MODEL_ID_PREFIX)
|| model_id.starts_with(GOVCLOUD_MODEL_ARN_PREFIX)
}
const GOVCLOUD_REGION_PREFIX: &str = "us-gov-";
const GOVCLOUD_MODEL_ID_PREFIX: &str = "us-gov.";
const GOVCLOUD_MODEL_ARN_PREFIX: &str = "arn:aws-us-gov:";
fn anthropic_thinking_display(region: Option<&str>, model_id: &str) -> Option<&'static str> {
(!govcloud_bedrock_target(region, model_id)).then_some(ANTHROPIC_THINKING_DISPLAY_SUMMARIZED)
}
fn apply_anthropic_thinking_fields(
body: &mut serde_json::Map<String, serde_json::Value>,
params: &bcode_model::ModelParameters,
display: Option<&str>,
) {
match params.reasoning_control {
Some(bcode_model::ReasoningControl::Adaptive) => {
let mut thinking = serde_json::Map::new();
thinking.insert("type".to_string(), serde_json::json!("adaptive"));
if let Some(display) = display {
thinking.insert("display".to_string(), serde_json::json!(display));
}
body.insert("thinking".to_string(), serde_json::Value::Object(thinking));
if let Some(effort) = adaptive_reasoning_effort(params) {
body.insert(
"output_config".to_string(),
serde_json::json!({"effort": effort}),
);
}
}
Some(bcode_model::ReasoningControl::Budget) | None => {
if let Some(budget) = resolve_reasoning_budget_tokens(params) {
let mut thinking = serde_json::Map::new();
thinking.insert("type".to_string(), serde_json::json!("enabled"));
thinking.insert("budget_tokens".to_string(), serde_json::json!(budget));
body.insert("thinking".to_string(), serde_json::Value::Object(thinking));
}
}
}
}
fn bedrock_request_projection(
request: &ModelTurnRequest,
route: EffectiveBedrockRoute,
) -> ProviderRequestProjection {
let total_cache_points = bedrock_emitted_cache_point_count(request);
let (emitted_cache_points, dropped_cache_points) = match route {
EffectiveBedrockRoute::OpenAiResponses => (prompt_cache_point_count(request), 0),
EffectiveBedrockRoute::AnthropicMessages => (
total_cache_points.min(ANTHROPIC_MESSAGES_MAX_CACHE_POINTS),
total_cache_points.saturating_sub(ANTHROPIC_MESSAGES_MAX_CACHE_POINTS),
),
EffectiveBedrockRoute::Converse => (total_cache_points, 0),
};
let sent_messages = request
.messages
.iter()
.filter(|message| message.role != MessageRole::System)
.count();
ProviderRequestProjection {
context_representation: None,
provider: Some("bcode.bedrock".to_string()),
api_shape: Some(
match route {
EffectiveBedrockRoute::Converse => "bedrock_converse",
EffectiveBedrockRoute::AnthropicMessages => "bedrock_anthropic_messages",
EffectiveBedrockRoute::OpenAiResponses => "bedrock_mantle_openai_responses",
}
.to_string(),
),
message_count: Some(sent_messages),
original_message_count: Some(request.messages.len()),
sent_message_count: Some(sent_messages),
omitted_message_count: Some(request.messages.len().saturating_sub(sent_messages)),
cache_point_count: Some(prompt_cache_point_count(request)),
emitted_cache_point_count: Some(emitted_cache_points),
dropped_cache_point_count: Some(dropped_cache_points),
used_previous_response_id: false,
..ProviderRequestProjection::default()
}
}
fn bedrock_emitted_cache_point_count(request: &ModelTurnRequest) -> usize {
let system_prompt_cache_point = usize::from(
request.prompt_cache.cache_system_prompt
&& request
.system_prompt
.as_ref()
.is_some_and(|prompt| !prompt.trim().is_empty()),
);
let tool_cache_point =
usize::from(request.prompt_cache.cache_tools && !request.tools.is_empty());
system_prompt_cache_point + tool_cache_point + prompt_cache_point_count(request)
}
fn prompt_cache_point_count(request: &ModelTurnRequest) -> usize {
request
.messages
.iter()
.flat_map(|message| &message.content)
.filter(|block| matches!(block, ContentBlock::CachePoint { .. }))
.count()
}
fn bedrock_schema_dialect() -> bcode_model_schema::SchemaDialect {
use bcode_model_schema::{ObjectPropertyPolicy, SchemaDialect, UnsupportedKeywordPolicy};
SchemaDialect {
object_properties: ObjectPropertyPolicy::RequireAllAndClose,
unsupported_keywords: std::collections::BTreeMap::from([
("minimum".to_string(), UnsupportedKeywordPolicy::Remove),
("maximum".to_string(), UnsupportedKeywordPolicy::Remove),
("multipleOf".to_string(), UnsupportedKeywordPolicy::Remove),
("minLength".to_string(), UnsupportedKeywordPolicy::Remove),
("maxLength".to_string(), UnsupportedKeywordPolicy::Remove),
]),
accepted_min_items: std::collections::BTreeSet::from([0, 1]),
..SchemaDialect::default()
}
}
fn bedrock_output_config(
request: &ModelTurnRequest,
) -> Result<Option<OutputConfig>, ProviderError> {
let Some(structured) = &request.structured_output else {
return Ok(None);
};
let schema = bcode_model_schema::normalize(&structured.schema, &bedrock_schema_dialect())
.map_err(|error| {
provider_error(
"invalid_structured_output_schema",
ProviderErrorCategory::InvalidRequest,
error.to_string(),
)
})?;
let definition = JsonSchemaDefinition::builder()
.schema(schema.to_string())
.name(structured.name.clone())
.build()
.map_err(|error| {
provider_error(
"invalid_structured_output_schema",
ProviderErrorCategory::InvalidRequest,
error.to_string(),
)
})?;
let format = OutputFormat::builder()
.r#type(OutputFormatType::JsonSchema)
.structure(OutputFormatStructure::JsonSchema(definition))
.build()
.map_err(|error| {
provider_error(
"invalid_structured_output_schema",
ProviderErrorCategory::InvalidRequest,
error.to_string(),
)
})?;
Ok(Some(OutputConfig::builder().text_format(format).build()))
}
fn build_converse_request(
request: &ModelTurnRequest,
model_id: String,
region: Option<&str>,
) -> Result<BedrockConverseRequest, ProviderError> {
let thinking_display = anthropic_thinking_display(region, &model_id);
Ok(BedrockConverseRequest {
messages: model_messages_to_bedrock_messages(request)?,
system: system_blocks(request),
tool_config: model_tools_to_bedrock_tool_config(request, &model_id)?,
inference_config: model_parameters_to_inference_config(request),
output_config: bedrock_output_config(request)?,
additional_model_request_fields: bedrock_thinking_fields(
&request.parameters,
thinking_display,
),
model_id,
})
}
const REASONING_EFFORT_LOW_BUDGET: u32 = 1_024;
const REASONING_EFFORT_MEDIUM_BUDGET: u32 = 4_096;
const REASONING_EFFORT_HIGH_BUDGET: u32 = 16_384;
fn resolve_reasoning_budget_tokens(params: &bcode_model::ModelParameters) -> Option<u32> {
if let Some(budget) = params.reasoning_budget_tokens
&& budget > 0
{
return Some(budget);
}
let effort = params.reasoning_effort.map(|effort| match effort {
bcode_model::ReasoningEffort::Low => REASONING_EFFORT_LOW_BUDGET,
bcode_model::ReasoningEffort::Medium => REASONING_EFFORT_MEDIUM_BUDGET,
bcode_model::ReasoningEffort::High => REASONING_EFFORT_HIGH_BUDGET,
});
if let Some(budget) = effort {
return Some(budget);
}
match params.reasoning_effort_value.as_deref() {
Some("low" | "minimal") => Some(REASONING_EFFORT_LOW_BUDGET),
Some("medium") => Some(REASONING_EFFORT_MEDIUM_BUDGET),
Some("high" | "xhigh" | "max") => Some(REASONING_EFFORT_HIGH_BUDGET),
_ => None,
}
}
fn bedrock_thinking_fields(
params: &bcode_model::ModelParameters,
display: Option<&str>,
) -> Option<Document> {
if params.reasoning_control == Some(bcode_model::ReasoningControl::Adaptive) {
return Some(bedrock_adaptive_thinking_fields(params, display));
}
let budget = resolve_reasoning_budget_tokens(params)?;
let mut thinking = HashMap::new();
thinking.insert("type".to_string(), Document::String("enabled".to_string()));
thinking.insert(
"budget_tokens".to_string(),
Document::Number(Number::PosInt(u64::from(budget))),
);
let mut fields = HashMap::new();
fields.insert("thinking".to_string(), Document::Object(thinking));
Some(Document::Object(fields))
}
fn bedrock_adaptive_thinking_fields(
params: &bcode_model::ModelParameters,
display: Option<&str>,
) -> Document {
let mut thinking = HashMap::new();
thinking.insert("type".to_string(), Document::String("adaptive".to_string()));
if let Some(display) = display {
thinking.insert("display".to_string(), Document::String(display.to_string()));
}
let mut fields = HashMap::new();
fields.insert("thinking".to_string(), Document::Object(thinking));
if let Some(effort) = adaptive_reasoning_effort(params) {
let mut output_config = HashMap::new();
output_config.insert("effort".to_string(), Document::String(effort));
fields.insert("output_config".to_string(), Document::Object(output_config));
}
Document::Object(fields)
}
fn adaptive_reasoning_effort(params: &bcode_model::ModelParameters) -> Option<String> {
if let Some(effort) = params
.reasoning_effort_value
.as_deref()
.map(str::trim)
.filter(|effort| !effort.is_empty())
{
return Some(effort.to_owned());
}
params.reasoning_effort.map(|effort| {
match effort {
bcode_model::ReasoningEffort::Low => "low",
bcode_model::ReasoningEffort::Medium => "medium",
bcode_model::ReasoningEffort::High => "high",
}
.to_owned()
})
}
fn system_blocks(request: &ModelTurnRequest) -> Vec<SystemContentBlock> {
let mut system = request
.system_prompt
.as_ref()
.filter(|prompt| !prompt.trim().is_empty())
.map(|prompt| vec![SystemContentBlock::Text(prompt.clone())])
.unwrap_or_default();
if request.prompt_cache.cache_system_prompt && !system.is_empty() {
system.push(SystemContentBlock::CachePoint(default_cache_point()));
}
for message in &request.messages {
if message.role == MessageRole::System {
let text = joined_text_content(message);
if !text.is_empty() {
system.push(SystemContentBlock::Text(text));
}
}
}
system
}
fn model_messages_to_bedrock_messages(
request: &ModelTurnRequest,
) -> Result<Vec<BedrockMessage>, ProviderError> {
request
.messages
.iter()
.filter(|message| message.role != MessageRole::System)
.filter_map(model_message_to_bedrock_message)
.collect()
}
fn model_message_to_bedrock_message(
message: &ModelMessage,
) -> Option<Result<BedrockMessage, ProviderError>> {
let role = match message.role {
MessageRole::System => return None,
MessageRole::User | MessageRole::Tool => ConversationRole::User,
MessageRole::Assistant => ConversationRole::Assistant,
};
let content = match bedrock_content_blocks(message) {
Ok(content) if content.is_empty() => return None,
Ok(content) => content,
Err(error) => return Some(Err(error)),
};
Some(
BedrockMessage::builder()
.role(role)
.set_content(Some(content))
.build()
.map_err(|error| build_error(&error)),
)
}
fn bedrock_content_blocks(
message: &ModelMessage,
) -> Result<Vec<BedrockContentBlock>, ProviderError> {
let mut blocks = Vec::new();
let text = joined_text_content(message);
if !text.is_empty() {
blocks.push(BedrockContentBlock::Text(text));
}
for image in message_image_blocks(message) {
blocks.push(BedrockContentBlock::Image(bedrock_image_block(image)?));
}
for block in &message.content {
match block {
ContentBlock::ToolCall { call } => {
blocks.push(BedrockContentBlock::ToolUse(
ToolUseBlock::builder()
.tool_use_id(call.id.clone())
.name(bedrock_tool_name(&call.name))
.input(json_value_to_document(&call.arguments))
.build()
.map_err(|error| build_error(&error))?,
));
}
ContentBlock::ToolResult { result } => {
let mut builder = ToolResultBlock::builder()
.tool_use_id(result.call_id.clone())
.content(ToolResultContentBlock::Text(result.output.clone()));
for content in &result.content {
match content {
bcode_model::ToolResultContent::Image { image } => {
builder = builder.content(ToolResultContentBlock::Image(
bedrock_image_block(image)?,
));
}
bcode_model::ToolResultContent::ImageRef { image } => {
builder = builder.content(ToolResultContentBlock::Text(format!(
"[image reference: {} {}{}{}]",
image.path,
image.mime_type,
image
.metadata
.width
.zip(image.metadata.height)
.map_or_else(String::new, |(width, height)| format!(
" {width}x{height}"
)),
image
.metadata
.byte_len
.map_or_else(String::new, |byte_len| format!(
" {byte_len} bytes"
))
)));
}
bcode_model::ToolResultContent::Text { text } => {
builder = builder.content(ToolResultContentBlock::Text(text.clone()));
}
}
}
if result.is_error {
builder = builder.status(ToolResultStatus::Error);
}
blocks.push(BedrockContentBlock::ToolResult(
builder.build().map_err(|error| build_error(&error))?,
));
}
ContentBlock::CachePoint { .. } => {
blocks.push(BedrockContentBlock::CachePoint(default_cache_point()));
}
ContentBlock::Text { .. }
| ContentBlock::Image { .. }
| ContentBlock::ProviderExtension { .. } => {}
}
}
Ok(blocks)
}
fn message_image_blocks(
message: &ModelMessage,
) -> impl Iterator<Item = &bcode_model::ImageContent> {
message.content.iter().filter_map(|block| match block {
ContentBlock::Image { image } => Some(image),
_ => None,
})
}
fn bedrock_image_block(image: &bcode_model::ImageContent) -> Result<ImageBlock, ProviderError> {
let format = bedrock_image_format(&image.mime_type).ok_or_else(|| {
provider_error(
"bedrock_unsupported_image_mime_type",
ProviderErrorCategory::UnsupportedFeature,
format!("unsupported Bedrock image MIME type: {}", image.mime_type),
)
})?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&image.data_base64)
.map_err(|error| {
provider_error(
"bedrock_invalid_image_data",
ProviderErrorCategory::InvalidRequest,
format!("invalid image data: {error}"),
)
})?;
ImageBlock::builder()
.format(format)
.source(ImageSource::Bytes(Blob::new(bytes)))
.build()
.map_err(|error| build_error(&error))
}
fn bedrock_image_format(mime_type: &str) -> Option<ImageFormat> {
match mime_type {
"image/png" => Some(ImageFormat::Png),
"image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg),
"image/gif" => Some(ImageFormat::Gif),
"image/webp" => Some(ImageFormat::Webp),
_ => None,
}
}
fn joined_text_content(message: &ModelMessage) -> String {
message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
fn model_tools_to_bedrock_tool_config(
request: &ModelTurnRequest,
model_id: &str,
) -> Result<Option<ToolConfiguration>, ProviderError> {
if matches!(request.tool_call_policy.choice, ToolChoice::None) {
return Ok(None);
}
if request.tools.is_empty() {
return match &request.tool_call_policy.choice {
ToolChoice::Auto | ToolChoice::None => Ok(None),
ToolChoice::Required | ToolChoice::Tool { .. } => Err(provider_error(
"tool_choice_without_tools",
ProviderErrorCategory::InvalidRequest,
"Bedrock tool choice requires at least one registered tool",
)),
};
}
let tool_choice = bedrock_tool_choice(request)?;
let mut tools = request
.tools
.iter()
.map(|tool| {
let schema = bedrock_tool_input_schema_for_model(tool, model_id)?;
let mut specification = ToolSpecification::builder()
.name(bedrock_tool_name(&tool.name))
.description(tool.description.clone())
.input_schema(ToolInputSchema::Json(json_value_to_document(&schema)));
if request.tool_schema_mode == Some(bcode_model::ToolSchemaMode::Strict) {
specification = specification.strict(true);
}
specification
.build()
.map(Tool::ToolSpec)
.map_err(|error| build_error(&error))
})
.collect::<Result<Vec<_>, _>>()?;
if request.prompt_cache.cache_tools {
tools.push(Tool::CachePoint(default_cache_point()));
}
ToolConfiguration::builder()
.set_tools(Some(tools))
.set_tool_choice(tool_choice)
.build()
.map(Some)
.map_err(|error| build_error(&error))
}
fn bedrock_tool_choice(
request: &ModelTurnRequest,
) -> Result<Option<BedrockToolChoice>, ProviderError> {
match &request.tool_call_policy.choice {
ToolChoice::Auto => Ok(Some(BedrockToolChoice::Auto(
AutoToolChoice::builder().build(),
))),
ToolChoice::None => Ok(None),
ToolChoice::Required => Ok(Some(BedrockToolChoice::Any(
AnyToolChoice::builder().build(),
))),
ToolChoice::Tool { name } => {
let tool = request
.tools
.iter()
.find(|tool| tool.name == *name)
.ok_or_else(|| {
provider_error(
"unknown_required_tool",
ProviderErrorCategory::InvalidRequest,
format!("required Bedrock tool '{name}' is not registered"),
)
})?;
SpecificToolChoice::builder()
.name(bedrock_tool_name(&tool.name))
.build()
.map(BedrockToolChoice::Tool)
.map(Some)
.map_err(|error| build_error(&error))
}
}
}
fn default_cache_point() -> CachePointBlock {
CachePointBlock::builder()
.r#type(CachePointType::Default)
.build()
.expect("default cache point should build")
}
fn bedrock_tool_name_map(tools: &[ToolDefinition]) -> BTreeMap<String, String> {
tools
.iter()
.map(|tool| (bedrock_tool_name(&tool.name), tool.name.clone()))
.collect()
}
fn original_tool_name(name: &str, name_map: &BTreeMap<String, String>) -> String {
name_map
.get(name)
.cloned()
.unwrap_or_else(|| name.to_string())
}
fn bedrock_tool_name(name: &str) -> String {
name.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '_' || character == '-' {
character
} else {
'_'
}
})
.collect()
}
fn model_parameters_to_inference_config(
request: &ModelTurnRequest,
) -> Option<InferenceConfiguration> {
let params = &request.parameters;
if params.temperature.is_none()
&& params.top_p.is_none()
&& params.max_output_tokens.is_none()
&& params.stop_sequences.is_empty()
{
return None;
}
let mut builder = InferenceConfiguration::builder()
.set_temperature(params.temperature)
.set_top_p(params.top_p)
.set_stop_sequences(
(!params.stop_sequences.is_empty()).then(|| params.stop_sequences.clone()),
);
if let Some(max_tokens) = params
.max_output_tokens
.and_then(|tokens| i32::try_from(tokens).ok())
{
builder = builder.max_tokens(max_tokens);
}
Some(builder.build())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BedrockTransport {
Runtime,
MantleAnthropic,
MantleOpenAi,
}
impl BedrockTransport {
fn parse(value: Option<&str>) -> Result<Self, ProviderError> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
None | Some("bedrock_runtime" | "runtime") => Ok(Self::Runtime),
Some("mantle_anthropic" | "mantle") => Ok(Self::MantleAnthropic),
Some("mantle_openai") => Ok(Self::MantleOpenAi),
Some(value) => Err(provider_error(
"bedrock_transport_invalid",
ProviderErrorCategory::Config,
format!(
"unsupported Bedrock transport '{value}'; expected 'bedrock_runtime', 'mantle_anthropic', or 'mantle_openai'"
),
)),
}
}
const fn as_str(self) -> &'static str {
match self {
Self::Runtime => "bedrock_runtime",
Self::MantleAnthropic => "mantle_anthropic",
Self::MantleOpenAi => "mantle_openai",
}
}
const fn is_mantle(self) -> bool {
matches!(self, Self::MantleAnthropic | Self::MantleOpenAi)
}
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
struct Settings {
transport: Result<BedrockTransport, ProviderError>,
transport_explicit: bool,
mantle_base_url: Option<String>,
mantle_auth_header: bool,
force_http1: bool,
default_model: Option<String>,
model_ids: Vec<String>,
model_ids_are_explicit: bool,
region: Option<String>,
region_source: RegionSource,
aws_profile: Option<String>,
endpoint_url: Option<String>,
auth_credentials: BTreeMap<String, String>,
env: BTreeMap<String, String>,
config_source: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RegionSource {
BcodeEnv,
AwsEnv,
Profile,
AwsSdkDefaultChain,
Fallback,
}
impl RegionSource {
const fn as_str(self) -> &'static str {
match self {
Self::BcodeEnv => "bcode_env",
Self::AwsEnv => "aws_env",
Self::Profile => "profile",
Self::AwsSdkDefaultChain => "aws_sdk_default_chain",
Self::Fallback => "fallback",
}
}
}
impl Settings {
fn mantle_flavor(&self) -> Result<MantleFlavor, ProviderError> {
match self.transport.clone()? {
BedrockTransport::MantleAnthropic => Ok(MantleFlavor::Anthropic),
BedrockTransport::MantleOpenAi => Ok(MantleFlavor::OpenAi),
BedrockTransport::Runtime => Err(provider_error(
"bedrock_mantle_transport_required",
ProviderErrorCategory::Config,
"Bedrock Runtime transport does not use a Mantle endpoint",
)),
}
}
fn resolve_from_context(context: &ProviderRequestContext) -> Self {
Self::resolve_context(Some(context))
}
fn resolve(request: Option<&ModelTurnRequest>) -> Self {
Self::resolve_context(request.map(|request| &request.provider_context))
}
#[allow(clippy::too_many_lines)]
fn resolve_context(request_context: Option<&ProviderRequestContext>) -> Self {
let config = request_context
.is_none()
.then(bcode_config::load_config)
.and_then(Result::ok);
let resolved = config
.as_ref()
.map(bcode_config::BcodeConfig::resolved_model_selection);
let request_settings = request_context
.map(|context| context.settings.clone())
.unwrap_or_default();
let request_env = request_context
.map(|context| context.env.clone())
.unwrap_or_default();
let request_auth = request_context.and_then(|context| context.auth.as_ref());
let request_auth_attributes = request_auth
.map(|auth| auth.attributes.clone())
.unwrap_or_default();
let vault_backed_auth = request_auth
.and_then(|auth| auth.backend.as_deref())
.or_else(|| {
config.as_ref().and_then(|config| {
resolved
.as_ref()
.and_then(|selection| selection.auth_profile.as_ref())
.and_then(|auth_profile| config.auth.profiles.get(auth_profile))
.map(|auth| auth.backend.as_str())
})
})
.is_some_and(|backend| backend == "sshenv");
let request_auth_credentials = request_auth
.map(|auth| {
auth.credentials
.iter()
.map(|(key, credential)| (key.clone(), credential.value.clone()))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default();
let profile_settings = resolved
.as_ref()
.map(|selection| selection.settings.clone())
.unwrap_or_default();
let auth_settings = config
.as_ref()
.and_then(|config| {
resolved
.as_ref()
.and_then(|selection| selection.auth_profile.as_ref())
.and_then(|auth_profile| config.auth.profiles.get(auth_profile))
})
.map(|auth| auth.settings.clone())
.unwrap_or_default();
let value = |keys: &[&str]| {
first_nonempty(
keys.iter()
.filter_map(|key| request_settings.get(*key).cloned()),
)
.or_else(|| {
first_nonempty(
keys.iter()
.filter_map(|key| request_auth_attributes.get(*key).cloned()),
)
})
.or_else(|| {
first_nonempty(
keys.iter()
.filter_map(|key| profile_settings.get(*key).cloned()),
)
})
.or_else(|| {
first_nonempty(
keys.iter()
.filter_map(|key| auth_settings.get(*key).cloned()),
)
})
};
let first_context_env = |keys: &[&str]| {
let request_value =
first_nonempty(keys.iter().filter_map(|key| request_env.get(*key).cloned()));
if request_context.is_some() {
request_value
} else {
request_value.or_else(|| {
first_nonempty(keys.iter().filter_map(|key| std::env::var(key).ok()))
})
}
};
let default_model = first_context_env(&["BCODE_BEDROCK_MODEL", "BEDROCK_MODEL"])
.or_else(|| value(&["model", "model_id"]))
.or_else(|| resolved.and_then(|selection| selection.model_id));
let model_ids_value = first_context_env(&["BCODE_BEDROCK_MODELS", "BEDROCK_MODELS"])
.or_else(|| value(&["models", "model_ids"]));
let mut model_ids = model_ids_value
.as_deref()
.map_or_else(Vec::new, parse_model_list);
if let Some(default_model) = &default_model
&& !model_ids.contains(default_model)
{
model_ids.insert(0, default_model.clone());
}
let (region, region_source) = resolve_configured_region(&value, &first_context_env);
let transport_value =
first_context_env(&["BCODE_BEDROCK_TRANSPORT"]).or_else(|| value(&["transport"]));
let transport_explicit = transport_value.is_some();
let transport = BedrockTransport::parse(transport_value.as_deref());
let endpoint_url = first_context_env(&[
"AWS_ENDPOINT_URL_BEDROCK", "BCODE_BEDROCK_ENDPOINT_URL", "BEDROCK_ENDPOINT_URL", ])
.or_else(|| value(&["endpoint_url"]));
let mantle_base_url = first_context_env(&["BCODE_BEDROCK_MANTLE_BASE_URL"])
.or_else(|| value(&["mantle_base_url"]))
.or_else(|| endpoint_url.clone());
let mantle_auth_header = first_context_env(&["BCODE_BEDROCK_MANTLE_AUTH_HEADER"])
.or_else(|| value(&["mantle_auth_header"]))
.is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"));
let force_http1 =
first_context_env(&["BCODE_BEDROCK_FORCE_HTTP1", "AWS_BEDROCK_FORCE_HTTP1"])
.is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"));
Self {
transport,
transport_explicit,
mantle_base_url,
mantle_auth_header,
force_http1,
default_model,
model_ids,
model_ids_are_explicit: model_ids_value.is_some(),
region,
region_source,
aws_profile: first_context_env(&["BCODE_BEDROCK_AWS_PROFILE", "AWS_PROFILE"]).or_else(
|| {
if vault_backed_auth {
value(&["aws_profile"])
} else {
value(&["profile", "aws_profile"])
}
},
),
endpoint_url,
auth_credentials: request_auth_credentials,
env: request_env,
config_source: if request_context.is_some() {
"request/config/environment".to_string()
} else {
"config/environment".to_string()
},
}
}
}
fn resolve_configured_region(
value: &impl Fn(&[&str]) -> Option<String>,
first_context_env: &impl Fn(&[&str]) -> Option<String>,
) -> (Option<String>, RegionSource) {
if let Some(region) = first_context_env(&["BCODE_BEDROCK_REGION"]) {
return (Some(region), RegionSource::BcodeEnv);
}
if let Some(region) = first_context_env(&["AWS_REGION", "AWS_DEFAULT_REGION"]) {
return (Some(region), RegionSource::AwsEnv);
}
if let Some(region) = value(&["region"]) {
return (Some(region), RegionSource::Profile);
}
(None, RegionSource::AwsSdkDefaultChain)
}
struct BedrockSurfaceCapabilities {
reasoning_summary: bcode_model::CapabilitySupport,
structured_output: bcode_model::CapabilitySupport,
parallel_tool_calls: bcode_model::CapabilitySupport,
}
impl BedrockSurfaceCapabilities {
fn for_surface(responses_surface: bool) -> Self {
use bcode_model::{CapabilitySource, CapabilitySupport};
let supported = || CapabilitySupport::supported(CapabilitySource::BundledCatalog);
let unsupported = |reason: &str| CapabilitySupport::Unsupported {
source: CapabilitySource::BundledCatalog,
reason: reason.to_string(),
};
if responses_surface {
return Self {
reasoning_summary: supported(),
structured_output: supported(),
parallel_tool_calls: supported(),
};
}
Self {
reasoning_summary: unsupported(
"Bedrock Anthropic extended thinking has no provider-visible reasoning summary",
),
structured_output: supported(),
parallel_tool_calls: unsupported(
"Bedrock model-specific parallel support is not guaranteed",
),
}
}
}
fn bedrock_feature_support_for(responses_surface: bool) -> bcode_model::ModelFeatureSupport {
bedrock_feature_support_for_surface(responses_surface, false, false)
}
fn bedrock_structured_output_support(
responses_surface: bool,
messages_surface: bool,
unsupported: bool,
) -> bcode_model::CapabilitySupport {
use bcode_model::{
CapabilityFidelity, CapabilityMechanism, CapabilitySource, CapabilitySupport,
};
if unsupported {
CapabilitySupport::Unsupported {
source: CapabilitySource::BundledCatalog,
reason: "the selected Bedrock surface does not support provider-native JSON Schema enforcement"
.to_string(),
}
} else if responses_surface {
CapabilitySupport::supported(CapabilitySource::BundledCatalog)
} else {
CapabilitySupport::Supported {
source: CapabilitySource::BundledCatalog,
mechanism: if messages_surface {
CapabilityMechanism::AdapterMediated
} else {
CapabilityMechanism::Native
},
fidelity: CapabilityFidelity::Reduced,
execution: if messages_surface {
bcode_model::CapabilityExecution::ToolFreeProviderRound
} else {
bcode_model::CapabilityExecution::Direct
},
}
}
}
fn bedrock_strict_tool_schema_support(
responses_surface: bool,
messages_surface: bool,
) -> bcode_model::CapabilitySupport {
use bcode_model::{
CapabilityFidelity, CapabilityMechanism, CapabilitySource, CapabilitySupport,
};
if messages_surface {
CapabilitySupport::Unsupported {
source: CapabilitySource::BundledCatalog,
reason: "the Bedrock Anthropic Messages tool schema does not accept strict enforcement"
.to_string(),
}
} else if responses_surface {
CapabilitySupport::supported(CapabilitySource::BundledCatalog)
} else {
CapabilitySupport::Supported {
source: CapabilitySource::BundledCatalog,
mechanism: CapabilityMechanism::Native,
fidelity: CapabilityFidelity::Reduced,
execution: bcode_model::CapabilityExecution::Direct,
}
}
}
fn bedrock_prompt_cache_support(
responses_surface: bool,
supported: impl Fn() -> bcode_model::CapabilitySupport,
) -> std::collections::BTreeMap<bcode_model::PromptCacheFeature, bcode_model::CapabilitySupport> {
use bcode_model::{CapabilitySource, CapabilitySupport, PromptCacheFeature};
[
PromptCacheFeature::ConversationPrefix,
PromptCacheFeature::ExplicitSystem,
PromptCacheFeature::ExplicitTools,
PromptCacheFeature::ExplicitMessage,
]
.into_iter()
.map(|feature| (feature, supported()))
.chain(std::iter::once((
PromptCacheFeature::Ttl,
if responses_surface {
supported()
} else {
CapabilitySupport::Unsupported {
source: CapabilitySource::BundledCatalog,
reason: "most Bedrock models do not accept a portable TTL; model-level capabilities may narrow this"
.to_string(),
}
},
)))
.collect()
}
fn bedrock_feature_support_for_surface(
responses_surface: bool,
messages_surface: bool,
structured_output_unsupported: bool,
) -> bcode_model::ModelFeatureSupport {
use bcode_model::{
CapabilitySource, CapabilitySupport, MediaInputFeature, ModelFeatureSupport,
ModelParameterKey, StructuredOutputMode, ToolChoiceMode, ToolSchemaMode,
};
let surface = BedrockSurfaceCapabilities::for_surface(responses_surface);
let supported = || CapabilitySupport::supported(CapabilitySource::BundledCatalog);
let structured_output = bedrock_structured_output_support(
responses_surface,
messages_surface,
structured_output_unsupported,
);
let unsupported = |reason: &str| CapabilitySupport::Unsupported {
source: CapabilitySource::BundledCatalog,
reason: reason.to_string(),
};
ModelFeatureSupport {
parameters: [
ModelParameterKey::Temperature,
ModelParameterKey::MaxOutputTokens,
ModelParameterKey::TopP,
ModelParameterKey::StopSequences,
ModelParameterKey::ReasoningBudgetTokens,
ModelParameterKey::ReasoningEffort,
ModelParameterKey::ReasoningEffortValue,
]
.into_iter()
.map(|key| (key, supported()))
.chain(std::iter::once((
ModelParameterKey::ReasoningSummary,
surface.reasoning_summary,
)))
.collect(),
structured_output: [
StructuredOutputMode::JsonSchema,
StructuredOutputMode::StrictJsonSchema,
]
.into_iter()
.map(|mode| {
let support = if surface.structured_output.is_guaranteed() {
structured_output.clone()
} else {
surface.structured_output.clone()
};
(mode, support)
})
.collect(),
tool_choice: [
(ToolChoiceMode::Auto, supported()),
(ToolChoiceMode::None, supported()),
(ToolChoiceMode::Required, supported()),
(ToolChoiceMode::Named, supported()),
(ToolChoiceMode::Parallel, surface.parallel_tool_calls),
]
.into_iter()
.collect(),
tool_schema: [
(ToolSchemaMode::Permissive, supported()),
(
ToolSchemaMode::Strict,
bedrock_strict_tool_schema_support(responses_surface, messages_surface),
),
]
.into_iter()
.collect(),
prompt_cache: bedrock_prompt_cache_support(responses_surface, supported),
media_input: [
(MediaInputFeature::UserImage, supported()),
(
MediaInputFeature::SystemImage,
unsupported("Bedrock Converse system content does not accept images"),
),
(
MediaInputFeature::AssistantImage,
unsupported("Bedrock Converse assistant messages do not accept images"),
),
(
MediaInputFeature::ToolMessageImage,
unsupported("use structured tool-result image content instead"),
),
(MediaInputFeature::ToolResultImage, supported()),
(
MediaInputFeature::ImageReference,
unsupported("Bedrock requires inline image bytes"),
),
]
.into_iter()
.collect(),
}
}
fn capabilities_for_context(context: &bcode_model::ProviderRequestContext) -> ProviderCapabilities {
let settings = Settings::resolve_from_context(context);
let transport = settings.transport.as_ref().copied().ok();
let responses_surface = transport == Some(BedrockTransport::MantleOpenAi)
|| context.api_surface == Some(bcode_model::ModelApiSurface::Responses);
let messages_surface =
!responses_surface && context.api_surface == Some(bcode_model::ModelApiSurface::Messages);
let structured_output_unsupported = false;
ProviderCapabilities {
provider_id: PROVIDER_ID.to_string(),
display_name: "Amazon Bedrock".to_string(),
capabilities: [
ProviderCapability::Streaming,
ProviderCapability::Cancellation,
ProviderCapability::Tools,
ProviderCapability::ParallelToolCalls,
ProviderCapability::PromptCaching,
]
.into_iter()
.collect(),
feature_support: bedrock_feature_support_for_surface(
responses_surface,
messages_surface,
structured_output_unsupported,
),
auth_schemes: [
"aws_default_chain".to_string(),
"aws_credentials".to_string(),
]
.into_iter()
.collect(),
retry_rules: Vec::new(),
metadata: diagnostics_metadata(&settings),
}
}
impl BedrockProviderPlugin {
fn models(&self, request: &ModelListRequest) -> ModelList {
let settings = Settings::resolve_from_context(&request.provider_context);
if settings
.transport
.as_ref()
.is_ok_and(|transport| transport.is_mantle())
{
let model_ids = if settings.model_ids.is_empty() {
settings.default_model.iter().cloned().collect::<Vec<_>>()
} else {
settings.model_ids.clone()
};
let models = model_infos_from_ids(&model_ids, settings.default_model.as_deref());
let policy = match settings.transport.as_ref() {
Ok(BedrockTransport::MantleOpenAi) => {
bcode_model::ModelCatalogPolicy::ExpandSupported {
provider_id: CATALOG_PROVIDER_ID.to_string(),
target: mantle_openai_support_hint(),
authority: bcode_model::ModelListAuthority::Authoritative,
}
}
Ok(BedrockTransport::MantleAnthropic) => {
bcode_model::ModelCatalogPolicy::ExpandSupported {
provider_id: CATALOG_PROVIDER_ID.to_string(),
target: mantle_anthropic_support_hint(),
authority: bcode_model::ModelListAuthority::Authoritative,
}
}
Ok(BedrockTransport::Runtime) | Err(_) => {
bcode_model::ModelCatalogPolicy::EnrichOnly {
provider_id: CATALOG_PROVIDER_ID.to_string(),
target: None,
authority: bcode_model::ModelListAuthority::Explicit,
}
}
};
return ModelList {
models,
catalog: ModelCatalogHints {
policy,
..ModelCatalogHints::default()
},
};
}
if settings.model_ids_are_explicit {
let mut models =
model_infos_from_ids(&settings.model_ids, settings.default_model.as_deref());
apply_cached_pricing(&mut models, &self.discovery, &settings);
return ModelList {
models,
catalog: ModelCatalogHints {
policy: bcode_model::ModelCatalogPolicy::EnrichOnly {
provider_id: CATALOG_PROVIDER_ID.to_string(),
target: None,
authority: bcode_model::ModelListAuthority::Explicit,
},
..ModelCatalogHints::default()
},
};
}
let discovered = match self.runtime.as_ref() {
Ok(runtime) => {
let discovered =
discovery_for_picker_nonblocking(runtime, &self.discovery, &settings);
if discovered.models.is_empty() {
self.report_empty_picker_discovery(runtime, &settings);
}
discovered
}
Err(error) => {
tracing::warn!(
target: "bcode_bedrock::discovery",
error = %error,
"Bedrock model discovery runtime unavailable"
);
ModelDiscovery::default()
}
};
let mut models = discovered.models;
apply_default_model_to_list(&mut models, settings.default_model.as_deref());
ModelList {
models,
catalog: ModelCatalogHints {
policy: bcode_model::ModelCatalogPolicy::ExpandSupported {
provider_id: CATALOG_PROVIDER_ID.to_string(),
target: mantle_openai_support_hint(),
authority: bcode_model::ModelListAuthority::Authoritative,
},
..ModelCatalogHints::default()
},
}
}
fn validate_config_response(&self, request: &ServiceRequest) -> ServiceResponse {
let request = match request.payload_json::<bcode_model::ValidateConfigRequest>() {
Ok(request) => request,
Err(error) => return invalid_request(&error),
};
json_response(&self.validate_config(&request.provider_context))
}
fn report_empty_picker_discovery(&self, runtime: &ProviderRuntime, settings: &Settings) {
let settings = settings.clone();
let Ok(key) = runtime.block_on(async move { discovery_cache_key(&settings).await }) else {
return;
};
if let Some(failure) = last_discovery_failure(&self.discovery, &key) {
tracing::warn!(
target: "bcode_bedrock::discovery",
region = %key.region,
bearer_auth = key.bearer_auth,
code = %failure.code,
category = ?failure.category,
error = %failure.message,
age_secs = failure.failed_at.elapsed().as_secs(),
"Bedrock model picker has no live models because discovery failed; showing catalog fallback models only"
);
} else {
tracing::info!(
target: "bcode_bedrock::discovery",
region = %key.region,
bearer_auth = key.bearer_auth,
"Bedrock model discovery has not completed yet; the model picker will fill in on the next open"
);
}
}
fn validate_config(&self, provider_context: &ProviderRequestContext) -> ValidateConfigResponse {
let settings = Settings::resolve_from_context(provider_context);
let mut validation = settings.transport.clone().map(|_| ());
if validation.is_ok()
&& settings
.transport
.as_ref()
.is_ok_and(|transport| transport.is_mantle())
{
validation = validate_mantle_settings(&settings);
}
let mut metadata = diagnostics_metadata(&settings);
let transport = settings.transport.as_ref().copied();
let effective_region = validation.as_ref().ok().and_then(|()| {
(transport == Ok(BedrockTransport::Runtime))
.then(|| {
self.runtime
.as_ref()
.ok()
.and_then(|runtime| resolved_sdk_region(runtime, &settings))
})
.flatten()
});
if let Some((region, source)) = &effective_region {
metadata.insert("effective_region".to_string(), region.clone());
metadata.insert(
"effective_region_source".to_string(),
source.as_str().to_string(),
);
}
let discovery_matters = validation.is_ok()
&& settings
.transport
.as_ref()
.is_ok_and(|transport| *transport == BedrockTransport::Runtime)
&& !settings.model_ids_are_explicit;
let discovery_required = discovery_matters && settings.default_model.is_none();
if discovery_matters {
match self
.runtime
.as_ref()
.map_err(|error| {
provider_error(
"runtime_unavailable",
ProviderErrorCategory::ProviderInternal,
error.clone(),
)
})
.and_then(|runtime| {
get_or_refresh_discovery_sync(runtime, &self.discovery, &settings)
}) {
Ok(discovery) => {
metadata.insert(
"discovered_model_count".to_string(),
discovery.models.len().to_string(),
);
if let Some(model_id) = discovery.default_model_id {
metadata.insert("discovered_default_model".to_string(), model_id);
}
}
Err(error) => {
metadata.insert("model_discovery_error".to_string(), error.message.clone());
metadata.insert("model_discovery_error_code".to_string(), error.code.clone());
if discovery_required
&& matches!(
error.category,
ProviderErrorCategory::Auth | ProviderErrorCategory::Config
)
{
validation = Err(error);
}
}
}
}
let failures = validation
.as_ref()
.err()
.and_then(|error| error.failure.as_deref())
.cloned()
.into_iter()
.collect();
ValidateConfigResponse {
valid: validation.is_ok(),
message: Some(match &validation {
Ok(()) => effective_region.map_or_else(
|| format!(
"Bedrock configuration is usable; region will fall back to '{DEFAULT_REGION}' if the AWS SDK chain is empty and credentials will be resolved at request time"
),
|(region, source)| format!(
"Bedrock configuration is usable; region '{region}' resolved from {} and credentials will be resolved at request time",
source.as_str()
),
),
Err(error) => format!("Bedrock configuration is not usable: {}", error.message),
}),
failures,
metadata,
}
}
}
fn model_list_request(request: &ServiceRequest) -> ModelListRequest {
request
.payload_json::<ModelListRequest>()
.unwrap_or_default()
}
fn bedrock_model_capabilities() -> BTreeSet<ModelCapability> {
[
ModelCapability::StreamingText,
ModelCapability::ToolCalls,
ModelCapability::PromptCaching,
]
.into_iter()
.collect()
}
fn apply_cached_pricing(
models: &mut [ModelInfo],
cache: &Arc<Mutex<DiscoveryCache>>,
settings: &Settings,
) {
let Ok(cache) = cache.lock() else {
return;
};
for (_, cached) in cache.entries.iter().filter(|(key, _)| {
settings
.region
.as_ref()
.is_none_or(|region| key.region == *region)
}) {
for model in models.iter_mut() {
if model.pricing.is_none()
&& let Some(pricing) = cached
.discovery
.models
.iter()
.find(|candidate| candidate.model_id == model.model_id)
.and_then(|candidate| candidate.pricing.clone())
{
model.pricing = Some(pricing);
}
}
}
}
fn model_infos_from_ids(model_ids: &[String], default_model: Option<&str>) -> Vec<ModelInfo> {
model_ids
.iter()
.map(|model_id| ModelInfo {
model_id: model_id.clone(),
display_name: model_id.clone(),
is_default: default_model == Some(model_id.as_str()),
context_window: None,
max_output_tokens: None,
max_image_input_base64_bytes: None,
capabilities: bedrock_model_capabilities(),
feature_support: bcode_model::ModelFeatureSupport::default(),
reasoning: None,
cache: bcode_model::ModelCacheInfo::default(),
metadata_source: None,
pricing: None,
api_surface: None,
visibility: bcode_model::ModelVisibility::Visible,
})
.collect::<Vec<_>>()
}
fn apply_default_model_to_list(models: &mut Vec<ModelInfo>, default_model: Option<&str>) {
let Some(default_model) = default_model.map(str::trim).filter(|id| !id.is_empty()) else {
return;
};
if !models.iter().any(|model| model.model_id == default_model) {
models.insert(
0,
model_infos_from_ids(&[default_model.to_string()], Some(default_model)).remove(0),
);
}
for model in models.iter_mut() {
model.is_default = model.model_id == default_model;
}
}
#[derive(Debug, Clone)]
struct ModelSelection {
model_ids: Vec<String>,
explicit: bool,
cache_key: Option<DiscoveryCacheKey>,
}
async fn resolve_turn_model_selection(
request: &ModelTurnRequest,
settings: &Settings,
turn: &TurnState,
cache: &Arc<Mutex<DiscoveryCache>>,
) -> Result<ModelSelection, ProviderError> {
if !request.model_id.trim().is_empty() {
return Ok(ModelSelection {
model_ids: vec![request.model_id.clone()],
explicit: true,
cache_key: None,
});
}
if let Some(model_id) = &settings.default_model
&& !model_id.trim().is_empty()
{
return Ok(ModelSelection {
model_ids: vec![model_id.clone()],
explicit: true,
cache_key: None,
});
}
if settings
.transport
.as_ref()
.is_ok_and(|transport| transport.is_mantle())
{
return Err(provider_error(
"bedrock_mantle_model_required",
ProviderErrorCategory::Config,
"Bedrock Mantle requires a configured model",
));
}
let key = discovery_cache_key(settings).await;
let discovery = if let Some(discovery) = cached_discovery(cache, &key) {
discovery
} else {
turn.push(ProviderTurnEvent::Warning {
message: "discovering available Bedrock models".to_string(),
});
let discovery = discover_models(settings).await?;
store_discovery(cache, key.clone(), discovery.clone());
discovery
};
let model_ids = discovery
.models
.iter()
.map(|model| model.model_id.clone())
.collect::<Vec<_>>();
if model_ids.is_empty() {
return Err(provider_error(
"bedrock_model_discovery_empty",
ProviderErrorCategory::Config,
"Bedrock model discovery returned no usable text/streaming models; set BCODE_BEDROCK_MODEL or configure a Bedrock model profile",
)
.with_failure(bedrock_failure_context(
bcode_model::ProviderFailureSourceKind::ModelProfile,
"BCODE_BEDROCK_MODEL or model profile",
bcode_model::ProviderFailureCapability::ModelDiscovery,
"set BCODE_BEDROCK_MODEL or configure an accessible streaming Bedrock model",
)));
}
Ok(ModelSelection {
model_ids,
explicit: false,
cache_key: Some(key),
})
}
#[derive(Debug, Clone, Default)]
struct ModelDiscovery {
models: Vec<ModelInfo>,
default_model_id: Option<String>,
}
#[derive(Debug, Default)]
struct DiscoveryCache {
entries: BTreeMap<DiscoveryCacheKey, CachedDiscovery>,
compatibility: PersistedCompatibilityCache,
failures: BTreeMap<DiscoveryCacheKey, DiscoveryFailure>,
inflight: BTreeMap<DiscoveryCacheKey, Arc<tokio::sync::Semaphore>>,
}
struct DiscoveryReservation<'a> {
cache: &'a Mutex<DiscoveryCache>,
key: DiscoveryCacheKey,
done: Arc<tokio::sync::Semaphore>,
}
impl Drop for DiscoveryReservation<'_> {
fn drop(&mut self) {
let mut cache = self
.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if cache
.inflight
.get(&self.key)
.is_some_and(|done| Arc::ptr_eq(done, &self.done))
{
cache.inflight.remove(&self.key);
}
drop(cache);
self.done.close();
}
}
#[derive(Debug, Clone)]
struct DiscoveryFailure {
code: String,
category: ProviderErrorCategory,
message: String,
failed_at: Instant,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
struct DiscoveryCacheKey {
region: String,
aws_profile: Option<String>,
endpoint_url: Option<String>,
#[serde(default)]
bearer_auth: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedCompatibilityCache {
version: u8,
entries: Vec<PersistedCompatibilityEntry>,
}
impl Default for PersistedCompatibilityCache {
fn default() -> Self {
Self {
version: COMPATIBILITY_CACHE_VERSION,
entries: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedCompatibilityEntry {
key: DiscoveryCacheKey,
#[serde(default)]
unsupported_streaming_tool_models: BTreeMap<String, PersistedModelIncompatibility>,
#[serde(default)]
unsupported_prompt_cache_models: BTreeMap<String, PersistedModelIncompatibility>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedModelIncompatibility {
reason: String,
message: String,
first_seen_unix_seconds: u64,
last_seen_unix_seconds: u64,
}
#[derive(Debug, Clone)]
struct CachedDiscovery {
discovered_at: Instant,
discovery: ModelDiscovery,
unsupported_streaming_tool_models: BTreeSet<String>,
unsupported_prompt_cache_models: BTreeSet<String>,
}
#[derive(Debug, Clone)]
struct CandidateModel {
model_id: String,
display_name: String,
priority: i32,
date_key: i64,
visibility: bcode_model::ModelVisibility,
}
fn warm_discovery_cache(
runtime: &ProviderRuntime,
cache: Arc<Mutex<DiscoveryCache>>,
settings: Settings,
) {
if settings
.transport
.as_ref()
.is_ok_and(|transport| transport.is_mantle())
{
return;
}
runtime.spawn(async move {
let key = discovery_cache_key(&settings).await;
if cached_discovery(&cache, &key).is_some() {
return;
}
refresh_discovery_shared(&cache, &settings, key).await;
});
}
fn get_or_refresh_discovery_sync(
runtime: &ProviderRuntime,
cache: &Arc<Mutex<DiscoveryCache>>,
settings: &Settings,
) -> Result<ModelDiscovery, ProviderError> {
let cache = Arc::clone(cache);
let settings = settings.clone();
runtime
.block_on(async move { get_or_refresh_discovery(&cache, &settings).await })
.map_err(|error| {
provider_error(
"runtime_unavailable",
ProviderErrorCategory::ProviderInternal,
error.to_string(),
)
})?
}
const PICKER_DISCOVERY_WAIT: Duration = Duration::from_millis(1500);
const DISCOVERY_FAILURE_RETRY_COOLDOWN: Duration = Duration::from_mins(1);
fn discovery_for_picker_nonblocking(
runtime: &ProviderRuntime,
cache: &Arc<Mutex<DiscoveryCache>>,
settings: &Settings,
) -> ModelDiscovery {
let cache = Arc::clone(cache);
let settings = settings.clone();
let key = runtime.block_on({
let settings = settings.clone();
async move { discovery_cache_key(&settings).await }
});
let Ok(key) = key else {
return ModelDiscovery::default();
};
if let Some(discovery) = cached_discovery(&cache, &key) {
return discovery;
}
if let Some(stale) = stale_cached_discovery(&cache, &key) {
spawn_discovery_refresh(runtime, cache, settings, key);
return stale;
}
if discovery_failure_is_recent(&cache, &key) {
return ModelDiscovery::default();
}
let task = runtime.spawn({
let cache = Arc::clone(&cache);
let key = key.clone();
async move { refresh_discovery_shared(&cache, &settings, key).await }
});
let waited = runtime.block_on({
let cache = Arc::clone(&cache);
async move {
drop(tokio::time::timeout(PICKER_DISCOVERY_WAIT, task).await);
cached_discovery(&cache, &key)
}
});
waited.ok().flatten().unwrap_or_default()
}
fn spawn_discovery_refresh(
runtime: &ProviderRuntime,
cache: Arc<Mutex<DiscoveryCache>>,
settings: Settings,
key: DiscoveryCacheKey,
) {
runtime.spawn(async move {
refresh_discovery_shared(&cache, &settings, key).await;
});
}
async fn refresh_discovery_shared(
cache: &Arc<Mutex<DiscoveryCache>>,
settings: &Settings,
key: DiscoveryCacheKey,
) {
let acquisition = {
let Ok(mut guard) = cache.lock() else {
return;
};
match guard.inflight.entry(key.clone()) {
std::collections::btree_map::Entry::Occupied(entry) => Err(Arc::clone(entry.get())),
std::collections::btree_map::Entry::Vacant(entry) => {
let done = Arc::new(tokio::sync::Semaphore::new(0));
entry.insert(Arc::clone(&done));
Ok(DiscoveryReservation {
cache,
key: key.clone(),
done,
})
}
}
};
let reservation = match acquisition {
Ok(reservation) => reservation,
Err(done) => {
drop(done.acquire().await);
return;
}
};
let outcome = discover_models(settings).await;
match outcome {
Ok(discovery) => {
tracing::info!(
target: "bcode_bedrock::discovery",
region = %key.region,
bearer_auth = key.bearer_auth,
model_count = discovery.models.len(),
"Bedrock model discovery completed"
);
store_discovery(cache, key.clone(), discovery);
}
Err(error) => {
tracing::warn!(
target: "bcode_bedrock::discovery",
region = %key.region,
bearer_auth = key.bearer_auth,
code = %error.code,
category = ?error.category,
error = %error.message,
"Bedrock model discovery failed; the model picker will show catalog fallback models only"
);
if let Ok(mut guard) = cache.lock() {
guard.failures.insert(
key.clone(),
DiscoveryFailure {
code: error.code,
category: error.category,
message: error.message,
failed_at: Instant::now(),
},
);
}
}
}
drop(reservation);
}
fn last_discovery_failure(
cache: &Arc<Mutex<DiscoveryCache>>,
key: &DiscoveryCacheKey,
) -> Option<DiscoveryFailure> {
cache.lock().ok()?.failures.get(key).cloned()
}
fn discovery_failure_is_recent(
cache: &Arc<Mutex<DiscoveryCache>>,
key: &DiscoveryCacheKey,
) -> bool {
last_discovery_failure(cache, key)
.is_some_and(|failure| failure.failed_at.elapsed() < DISCOVERY_FAILURE_RETRY_COOLDOWN)
}
fn stale_cached_discovery(
cache: &Arc<Mutex<DiscoveryCache>>,
key: &DiscoveryCacheKey,
) -> Option<ModelDiscovery> {
let cached = cache.lock().ok()?.entries.get(key).cloned()?;
Some(filtered_discovery(
&cached.discovery,
&cached.unsupported_streaming_tool_models,
&cached.unsupported_prompt_cache_models,
))
}
async fn get_or_refresh_discovery(
cache: &Arc<Mutex<DiscoveryCache>>,
settings: &Settings,
) -> Result<ModelDiscovery, ProviderError> {
let key = discovery_cache_key(settings).await;
if let Some(discovery) = cached_discovery(cache, &key) {
return Ok(discovery);
}
let discovery = discover_models(settings).await.inspect_err(|error| {
if let Ok(mut guard) = cache.lock() {
guard.failures.insert(
key.clone(),
DiscoveryFailure {
code: error.code.clone(),
category: error.category,
message: error.message.clone(),
failed_at: Instant::now(),
},
);
}
})?;
store_discovery(cache, key.clone(), discovery.clone());
Ok(cached_discovery(cache, &key).unwrap_or(discovery))
}
fn cached_discovery(
cache: &Arc<Mutex<DiscoveryCache>>,
key: &DiscoveryCacheKey,
) -> Option<ModelDiscovery> {
let cached = cache.lock().ok()?.entries.get(key).cloned()?;
(cached.discovered_at.elapsed() < MODEL_DISCOVERY_TTL).then(|| {
filtered_discovery(
&cached.discovery,
&cached.unsupported_streaming_tool_models,
&cached.unsupported_prompt_cache_models,
)
})
}
fn filtered_discovery(
discovery: &ModelDiscovery,
unsupported_streaming_tool_models: &BTreeSet<String>,
unsupported_prompt_cache_models: &BTreeSet<String>,
) -> ModelDiscovery {
let models = discovery
.models
.iter()
.filter(|model| !unsupported_streaming_tool_models.contains(&model.model_id))
.cloned()
.map(|mut model| {
if unsupported_prompt_cache_models.contains(&model.model_id) {
model.capabilities.remove(&ModelCapability::PromptCaching);
model.cache.capabilities.clear();
for feature in [
bcode_model::PromptCacheFeature::ConversationPrefix,
bcode_model::PromptCacheFeature::ExplicitSystem,
bcode_model::PromptCacheFeature::ExplicitTools,
bcode_model::PromptCacheFeature::ExplicitMessage,
bcode_model::PromptCacheFeature::Ttl,
] {
model.feature_support.prompt_cache.insert(
feature,
bcode_model::CapabilitySupport::Unsupported {
source: bcode_model::CapabilitySource::Probe,
reason: "Bedrock previously rejected prompt caching for this model"
.to_string(),
},
);
}
}
model
})
.collect::<Vec<_>>();
let default_model_id = models
.iter()
.find(|model| model.visibility == bcode_model::ModelVisibility::Visible)
.or_else(|| models.first())
.map(|model| model.model_id.clone());
ModelDiscovery {
models,
default_model_id,
}
}
fn store_discovery(
cache: &Arc<Mutex<DiscoveryCache>>,
key: DiscoveryCacheKey,
discovery: ModelDiscovery,
) {
if let Ok(mut cache) = cache.lock() {
let mut unsupported_streaming_tool_models = cache
.entries
.get(&key)
.map(|cached| cached.unsupported_streaming_tool_models.clone())
.unwrap_or_default();
let mut unsupported_prompt_cache_models = cache
.entries
.get(&key)
.map(|cached| cached.unsupported_prompt_cache_models.clone())
.unwrap_or_default();
unsupported_streaming_tool_models
.extend(cache.compatibility.unsupported_streaming_for(&key));
unsupported_prompt_cache_models
.extend(cache.compatibility.unsupported_prompt_cache_for(&key));
cache.failures.remove(&key);
cache.entries.insert(
key,
CachedDiscovery {
discovered_at: Instant::now(),
discovery,
unsupported_streaming_tool_models,
unsupported_prompt_cache_models,
},
);
}
}
fn mark_streaming_tool_unsupported(
cache: &Arc<Mutex<DiscoveryCache>>,
key: Option<&DiscoveryCacheKey>,
model_id: &str,
message: &str,
) {
let Some(key) = key else {
return;
};
let compatibility = cache.lock().ok().map(|mut cache| {
if let Some(cached) = cache.entries.get_mut(key) {
cached
.unsupported_streaming_tool_models
.insert(model_id.to_string());
}
cache.compatibility.mark_streaming_tool_unsupported(
key,
model_id,
message,
now_unix_seconds(),
);
cache.compatibility.clone()
});
if let Some(compatibility) = compatibility
&& let Err(error) = save_compatibility_cache(&compatibility)
{
tracing::warn!(
target: "bcode_bedrock::compatibility",
error = %error.message,
"failed to save Bedrock compatibility cache"
);
}
}
fn streaming_tool_use_unsupported(error: &ProviderError) -> bool {
error.category == ProviderErrorCategory::InvalidRequest
&& error
.message
.contains("doesn't support tool use in streaming mode")
}
fn model_unusable_via_converse(error: &ProviderError) -> bool {
error.category == ProviderErrorCategory::InvalidRequest
&& error
.message
.to_ascii_lowercase()
.contains("data retention mode")
}
fn prompt_cache_known_unsupported(
cache: &Arc<Mutex<DiscoveryCache>>,
key: Option<&DiscoveryCacheKey>,
model_id: &str,
) -> bool {
let Some(key) = key else {
return false;
};
cache
.lock()
.ok()
.and_then(|cache| cache.entries.get(key).cloned())
.is_some_and(|entry| entry.unsupported_prompt_cache_models.contains(model_id))
}
impl PersistedCompatibilityCache {
fn unsupported_streaming_for(&self, key: &DiscoveryCacheKey) -> BTreeSet<String> {
self.entries
.iter()
.find(|entry| &entry.key == key)
.map(|entry| {
entry
.unsupported_streaming_tool_models
.keys()
.cloned()
.collect()
})
.unwrap_or_default()
}
fn unsupported_prompt_cache_for(&self, key: &DiscoveryCacheKey) -> BTreeSet<String> {
self.entries
.iter()
.find(|entry| &entry.key == key)
.map(|entry| {
entry
.unsupported_prompt_cache_models
.keys()
.cloned()
.collect()
})
.unwrap_or_default()
}
fn mark_streaming_tool_unsupported(
&mut self,
key: &DiscoveryCacheKey,
model_id: &str,
message: &str,
now: u64,
) {
self.mark_unsupported(
key,
model_id,
STREAMING_TOOL_UNSUPPORTED_REASON,
message,
now,
true,
);
}
#[cfg(test)]
fn mark_prompt_cache_unsupported(
&mut self,
key: &DiscoveryCacheKey,
model_id: &str,
message: &str,
now: u64,
) {
self.mark_unsupported(
key,
model_id,
PROMPT_CACHE_UNSUPPORTED_REASON,
message,
now,
false,
);
}
fn mark_unsupported(
&mut self,
key: &DiscoveryCacheKey,
model_id: &str,
reason: &str,
message: &str,
now: u64,
streaming_tool: bool,
) {
let entry = self.entry_mut(key.clone());
let models = if streaming_tool {
&mut entry.unsupported_streaming_tool_models
} else {
&mut entry.unsupported_prompt_cache_models
};
models
.entry(model_id.to_string())
.and_modify(|model| {
model.message = message.to_string();
model.last_seen_unix_seconds = now;
})
.or_insert_with(|| PersistedModelIncompatibility {
reason: reason.to_string(),
message: message.to_string(),
first_seen_unix_seconds: now,
last_seen_unix_seconds: now,
});
}
fn prune_expired(&mut self, now: u64) {
for entry in &mut self.entries {
entry.unsupported_streaming_tool_models.retain(|_, model| {
now.saturating_sub(model.last_seen_unix_seconds) <= COMPATIBILITY_CACHE_TTL_SECONDS
});
entry.unsupported_prompt_cache_models.retain(|_, model| {
now.saturating_sub(model.last_seen_unix_seconds) <= COMPATIBILITY_CACHE_TTL_SECONDS
});
}
self.entries.retain(|entry| {
!entry.unsupported_streaming_tool_models.is_empty()
|| !entry.unsupported_prompt_cache_models.is_empty()
});
}
fn entry_mut(&mut self, key: DiscoveryCacheKey) -> &mut PersistedCompatibilityEntry {
if let Some(index) = self.entries.iter().position(|entry| entry.key == key) {
return &mut self.entries[index];
}
self.entries.push(PersistedCompatibilityEntry {
key,
unsupported_streaming_tool_models: BTreeMap::new(),
unsupported_prompt_cache_models: BTreeMap::new(),
});
self.entries.last_mut().expect("entry was just inserted")
}
}
fn load_compatibility_cache() -> Result<PersistedCompatibilityCache, ProviderError> {
load_compatibility_cache_from_path(&compatibility_cache_path())
}
fn load_compatibility_cache_from_path(
path: &Path,
) -> Result<PersistedCompatibilityCache, ProviderError> {
if !path.exists() {
return Ok(PersistedCompatibilityCache::default());
}
let contents = std::fs::read_to_string(path).map_err(|error| {
provider_error(
"bedrock_compatibility_cache_read_failed",
ProviderErrorCategory::ProviderInternal,
error.to_string(),
)
})?;
let mut cache =
serde_json::from_str::<PersistedCompatibilityCache>(&contents).map_err(|error| {
provider_error(
"bedrock_compatibility_cache_decode_failed",
ProviderErrorCategory::ProviderInternal,
error.to_string(),
)
})?;
if cache.version != COMPATIBILITY_CACHE_VERSION {
return Ok(PersistedCompatibilityCache::default());
}
cache.prune_expired(now_unix_seconds());
Ok(cache)
}
fn save_compatibility_cache(cache: &PersistedCompatibilityCache) -> Result<(), ProviderError> {
save_compatibility_cache_to_path(&compatibility_cache_path(), cache)
}
fn save_compatibility_cache_to_path(
path: &Path,
cache: &PersistedCompatibilityCache,
) -> Result<(), ProviderError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
provider_error(
"bedrock_compatibility_cache_dir_failed",
ProviderErrorCategory::ProviderInternal,
error.to_string(),
)
})?;
}
let temp_path = path.with_extension("json.tmp");
let contents = serde_json::to_vec_pretty(cache).map_err(|error| {
provider_error(
"bedrock_compatibility_cache_encode_failed",
ProviderErrorCategory::ProviderInternal,
error.to_string(),
)
})?;
std::fs::write(&temp_path, contents).map_err(|error| {
provider_error(
"bedrock_compatibility_cache_write_failed",
ProviderErrorCategory::ProviderInternal,
error.to_string(),
)
})?;
std::fs::rename(&temp_path, path).map_err(|error| {
provider_error(
"bedrock_compatibility_cache_rename_failed",
ProviderErrorCategory::ProviderInternal,
error.to_string(),
)
})?;
Ok(())
}
fn compatibility_cache_path() -> PathBuf {
bcode_config::default_state_dir()
.join("providers")
.join("bedrock")
.join("compatibility-cache-v1.json")
}
fn now_unix_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs())
}
async fn discovery_cache_key(settings: &Settings) -> DiscoveryCacheKey {
DiscoveryCacheKey {
region: resolved_discovery_region(settings).await,
aws_profile: settings.aws_profile.clone(),
endpoint_url: settings.endpoint_url.clone(),
bearer_auth: client_context_bearer_token(settings).is_some(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct ResolvedRegionKey {
region: Option<String>,
aws_profile: Option<String>,
endpoint_url: Option<String>,
request_scoped: bool,
explicit_credentials: bool,
}
const RESOLVED_REGION_TTL: Duration = Duration::from_mins(10);
fn resolved_region_cache() -> &'static Mutex<BTreeMap<ResolvedRegionKey, (String, Instant)>> {
static CACHE: OnceLock<Mutex<BTreeMap<ResolvedRegionKey, (String, Instant)>>> = OnceLock::new();
CACHE.get_or_init(Mutex::default)
}
async fn resolved_discovery_region(settings: &Settings) -> String {
if let Some(region) = settings
.region
.as_deref()
.filter(|region| !region.is_empty())
{
return region.to_owned();
}
let key = ResolvedRegionKey {
region: settings.region.clone(),
aws_profile: settings.aws_profile.clone(),
endpoint_url: settings.endpoint_url.clone(),
request_scoped: settings.config_source.starts_with("request/"),
explicit_credentials: client_context_credentials(settings).is_some(),
};
if let Some(region) = resolved_region_cache()
.lock()
.ok()
.and_then(|cache| cache.get(&key).cloned())
.filter(|(_, resolved_at)| resolved_at.elapsed() < RESOLVED_REGION_TTL)
.map(|(region, _)| region)
{
return region;
}
let config = bedrock_sdk_config(settings).await;
let region = config
.region()
.map_or_else(|| DEFAULT_REGION.to_string(), ToString::to_string);
if let Ok(mut cache) = resolved_region_cache().lock() {
cache.insert(key, (region.clone(), Instant::now()));
}
region
}
async fn discover_models(settings: &Settings) -> Result<ModelDiscovery, ProviderError> {
let client = bedrock_control_client(settings).await;
let mut candidates = BTreeMap::<String, CandidateModel>::new();
for profile in discover_inference_profiles(&client).await? {
candidates
.entry(profile.model_id.clone())
.or_insert(profile);
}
for model in discover_foundation_models(&client).await? {
candidates.entry(model.model_id.clone()).or_insert(model);
}
let mut candidates = candidates.into_values().collect::<Vec<_>>();
candidates.sort_by(|left, right| {
right
.priority
.cmp(&left.priority)
.then_with(|| right.date_key.cmp(&left.date_key))
.then_with(|| left.model_id.cmp(&right.model_id))
});
let default_model_id = candidates
.iter()
.find(|candidate| candidate.visibility == bcode_model::ModelVisibility::Visible)
.map(|candidate| candidate.model_id.clone());
let models: Vec<ModelInfo> = candidates
.into_iter()
.map(|candidate| ModelInfo {
is_default: default_model_id.as_deref() == Some(candidate.model_id.as_str()),
model_id: candidate.model_id,
display_name: candidate.display_name,
context_window: None,
max_output_tokens: None,
max_image_input_base64_bytes: None,
capabilities: bedrock_model_capabilities(),
feature_support: bcode_model::ModelFeatureSupport::default(),
reasoning: None,
cache: bcode_model::ModelCacheInfo::default(),
metadata_source: None,
pricing: None,
api_surface: None,
visibility: candidate.visibility,
})
.collect();
Ok(ModelDiscovery {
models,
default_model_id,
})
}
async fn bedrock_control_client(settings: &Settings) -> bedrock::Client {
let config = bedrock_sdk_config(settings).await;
client_context_bearer_token(settings).map_or_else(
|| bedrock::Client::new(&config),
|token| {
let config = bedrock::config::Builder::from(&config)
.bearer_token(bedrock::config::Token::new(token, None))
.auth_scheme_preference([
aws_smithy_runtime_api::client::auth::http::HTTP_BEARER_AUTH_SCHEME_ID,
])
.build();
bedrock::Client::from_conf(config)
},
)
}
async fn discover_inference_profiles(
client: &bedrock::Client,
) -> Result<Vec<CandidateModel>, ProviderError> {
let mut next_token = None;
let mut candidates = Vec::new();
loop {
let response = client
.list_inference_profiles()
.set_next_token(next_token)
.send()
.await
.map_err(|error| bedrock_discovery_error(&error))?;
for profile in response.inference_profile_summaries() {
if profile.status().as_str() != "ACTIVE" {
continue;
}
let model_id = profile.inference_profile_id().to_string();
let display_name = profile.inference_profile_name().to_string();
let date_key = profile
.updated_at()
.or_else(|| profile.created_at())
.map_or(0, aws_smithy_types::DateTime::secs);
candidates.push(CandidateModel {
model_id,
display_name,
priority: 2,
date_key,
visibility: bcode_model::ModelVisibility::Visible,
});
}
next_token = response.next_token().map(ToString::to_string);
if next_token.is_none() {
break;
}
}
Ok(candidates)
}
async fn discover_foundation_models(
client: &bedrock::Client,
) -> Result<Vec<CandidateModel>, ProviderError> {
let response = client
.list_foundation_models()
.send()
.await
.map_err(|error| bedrock_discovery_error(&error))?;
Ok(response
.model_summaries()
.iter()
.filter_map(foundation_model_candidate)
.collect())
}
fn foundation_model_candidate(
model: &bedrock::types::FoundationModelSummary,
) -> Option<CandidateModel> {
let supports_text_output = model
.output_modalities()
.iter()
.any(|modality| modality.as_str() == "TEXT");
if !supports_text_output || model.response_streaming_supported() == Some(false) {
return None;
}
let legacy = model
.model_lifecycle()
.is_some_and(|lifecycle| lifecycle.status().as_str() == "LEGACY");
if legacy {
return None;
}
let inference_types = model.inference_types_supported();
let directly_invocable = inference_types.is_empty()
|| inference_types
.iter()
.any(|kind| matches!(kind.as_str(), "ON_DEMAND" | "PROVISIONED"));
let model_id = model.model_id().to_string();
let display_name = model
.model_name()
.map_or_else(|| model_id.clone(), ToString::to_string);
let date_key = model
.model_lifecycle()
.and_then(|lifecycle| lifecycle.start_of_life_time())
.map_or(0, aws_smithy_types::DateTime::secs);
Some(CandidateModel {
model_id,
display_name,
priority: 1,
date_key,
visibility: if directly_invocable {
bcode_model::ModelVisibility::Visible
} else {
bcode_model::ModelVisibility::Unsupported {
reason: "Bedrock serves this model only through an inference profile; select the \
regional (`us.`/`eu.`/`apac.`) or `global.` profile ID instead"
.to_string(),
}
},
})
}
fn resolved_sdk_region(
runtime: &ProviderRuntime,
settings: &Settings,
) -> Option<(String, RegionSource)> {
let settings_for_config = settings.clone();
let config = runtime
.block_on(async move { bedrock_sdk_config(&settings_for_config).await })
.ok()?;
let region = config.region().map(ToString::to_string)?;
let source = if settings.region.is_some() {
settings.region_source
} else if region == DEFAULT_REGION {
RegionSource::Fallback
} else {
RegionSource::AwsSdkDefaultChain
};
Some((region, source))
}
fn validate_mantle_settings(settings: &Settings) -> Result<(), ProviderError> {
let model_configured = settings
.default_model
.as_ref()
.is_some_and(|model| !model.trim().is_empty())
|| settings
.model_ids
.iter()
.any(|model| !model.trim().is_empty());
if !model_configured {
return Err(provider_error(
"bedrock_mantle_model_required",
ProviderErrorCategory::Config,
"Bedrock Mantle requires a configured model because control-plane discovery is unavailable",
));
}
if client_context_bearer_token(settings).is_none() {
return Err(provider_error(
"bedrock_mantle_missing_bearer_token",
ProviderErrorCategory::Auth,
"Bedrock Mantle requires AWS_BEARER_TOKEN_BEDROCK or a mapped bearer_token credential",
));
}
mantle_endpoint(settings, settings.mantle_flavor()?).map(|_| ())
}
fn diagnostics_metadata(settings: &Settings) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::new();
metadata.insert("provider".to_string(), PROVIDER_ID.to_string());
metadata.insert(
"transport".to_string(),
settings
.transport
.as_ref()
.map_or("invalid", |transport| transport.as_str())
.to_string(),
);
metadata.insert("force_http1".to_string(), settings.force_http1.to_string());
metadata.insert(
"mantle_auth_header".to_string(),
settings.mantle_auth_header.to_string(),
);
if let Some(base_url) = &settings.mantle_base_url {
metadata.insert("mantle_base_url".to_string(), base_url.clone());
}
metadata.insert(
"default_model".to_string(),
settings
.default_model
.clone()
.unwrap_or_else(|| "<bedrock-discovery>".to_string()),
);
metadata.insert(
"model_list_source".to_string(),
if settings.model_ids_are_explicit {
"explicit".to_string()
} else {
"default".to_string()
},
);
metadata.insert(
"configured_region".to_string(),
settings
.region
.clone()
.unwrap_or_else(|| "<aws-sdk-default-chain-or-fallback>".to_string()),
);
metadata.insert(
"configured_region_source".to_string(),
settings.region_source.as_str().to_string(),
);
metadata.insert("fallback_region".to_string(), DEFAULT_REGION.to_string());
if let Some(profile) = &settings.aws_profile {
metadata.insert("aws_profile".to_string(), profile.clone());
}
if let Some(endpoint_url) = &settings.endpoint_url {
metadata.insert("endpoint_url".to_string(), endpoint_url.clone());
}
if client_context_bearer_token(settings).is_some()
|| std::env::var("AWS_BEARER_TOKEN_BEDROCK").is_ok_and(|value| !value.trim().is_empty())
{
metadata.insert(
"bearer_token_source".to_string(),
if settings.auth_credentials.contains_key("bearer_token") {
"provider_auth_context"
} else if settings.env.contains_key("AWS_BEARER_TOKEN_BEDROCK") {
"request_environment"
} else {
"AWS_BEARER_TOKEN_BEDROCK"
}
.to_string(),
);
}
metadata.insert("config_source".to_string(), settings.config_source.clone());
metadata
}
fn parse_model_list(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|model| !model.is_empty())
.map(ToString::to_string)
.collect()
}
fn first_nonempty(values: impl IntoIterator<Item = String>) -> Option<String> {
values.into_iter().find(|value| !value.trim().is_empty())
}
fn json_value_to_document(value: &serde_json::Value) -> Document {
match value {
serde_json::Value::Null => Document::Null,
serde_json::Value::Bool(value) => Document::Bool(*value),
serde_json::Value::Number(value) => value
.as_u64()
.map(Number::PosInt)
.or_else(|| value.as_i64().map(Number::NegInt))
.or_else(|| value.as_f64().map(Number::Float))
.map_or(Document::Null, Document::Number),
serde_json::Value::String(value) => Document::String(value.clone()),
serde_json::Value::Array(values) => {
Document::Array(values.iter().map(json_value_to_document).collect())
}
serde_json::Value::Object(values) => Document::Object(
values
.iter()
.map(|(key, value)| (key.clone(), json_value_to_document(value)))
.collect::<HashMap<_, _>>(),
),
}
}
#[cfg(feature = "static-bundled")]
#[must_use]
pub fn static_plugin() -> bcode_plugin_sdk::StaticPluginVtable {
let mut vtable = bcode_plugin_sdk::static_concurrent_plugin_vtable!(
BedrockProviderPlugin,
include_str!("../bcode-plugin.toml")
);
vtable.cli_registration = Some(cli::registration);
vtable
}
#[cfg(not(feature = "static-bundled"))]
bcode_plugin_sdk::export_concurrent_plugin!(
BedrockProviderPlugin,
include_str!("../bcode-plugin.toml")
);
#[cfg(test)]
fn document_to_json_value(document: &Document) -> serde_json::Value {
match document {
Document::Object(values) => serde_json::Value::Object(
values
.iter()
.map(|(key, value)| (key.clone(), document_to_json_value(value)))
.collect(),
),
Document::Array(values) => {
serde_json::Value::Array(values.iter().map(document_to_json_value).collect())
}
Document::Number(number) => match number {
Number::PosInt(value) => serde_json::Value::Number((*value).into()),
Number::NegInt(value) => serde_json::Value::Number((*value).into()),
Number::Float(value) => serde_json::Number::from_f64(*value)
.map_or(serde_json::Value::Null, serde_json::Value::Number),
},
Document::String(value) => serde_json::Value::String(value.clone()),
Document::Bool(value) => serde_json::Value::Bool(*value),
Document::Null => serde_json::Value::Null,
}
}
fn map_anthropic_stop_reason(reason: &str) -> Option<StopReason> {
match reason {
"end_turn" => Some(StopReason::EndTurn),
"tool_use" => Some(StopReason::ToolCall),
"max_tokens" => Some(StopReason::MaxTokens),
"stop_sequence" => Some(StopReason::StopSequence),
"refusal" => Some(StopReason::Refusal),
_ => None,
}
}
const fn map_stop_reason(reason: &BedrockStopReason) -> StopReason {
match reason {
BedrockStopReason::ToolUse => StopReason::ToolCall,
BedrockStopReason::MaxTokens => StopReason::MaxTokens,
BedrockStopReason::StopSequence => StopReason::StopSequence,
_ => StopReason::EndTurn,
}
}
fn converse_pricing_details(
ordinary_input: Option<u32>,
output: Option<u32>,
cache_read: Option<u32>,
cache_write: Option<u32>,
) -> Box<[bcode_model::ModelTokenUsageDetail]> {
use bcode_model::{ModelPricingBucket, ModelTokenModality, ModelTokenUsageDetail};
[
(ModelPricingBucket::Input, ordinary_input),
(ModelPricingBucket::CacheReadInput, cache_read),
(ModelPricingBucket::CacheWriteInput, cache_write),
(ModelPricingBucket::Output, output),
]
.into_iter()
.filter_map(|(bucket, tokens)| {
tokens
.filter(|tokens| *tokens > 0)
.map(|tokens| ModelTokenUsageDetail {
bucket,
modality: ModelTokenModality::Text,
tokens,
cache_ttl_seconds: None,
})
})
.collect::<Vec<_>>()
.into_boxed_slice()
}
fn anthropic_messages_pricing_details(
input: Option<u32>,
output: Option<u32>,
cached: Option<u32>,
cache_write: Option<u32>,
cache_ttl_seconds: Option<u64>,
) -> Box<[bcode_model::ModelTokenUsageDetail]> {
let Some(input) = input else {
return Box::default();
};
let cached = cached.unwrap_or_default();
let cache_write = cache_write.unwrap_or_default();
let ordinary = input.saturating_sub(cached).saturating_sub(cache_write);
[
(bcode_model::ModelPricingBucket::Input, ordinary, None),
(
bcode_model::ModelPricingBucket::CacheReadInput,
cached,
None,
),
(
bcode_model::ModelPricingBucket::CacheWriteInput,
cache_write,
cache_ttl_seconds,
),
(
bcode_model::ModelPricingBucket::Output,
output.unwrap_or_default(),
None,
),
]
.into_iter()
.filter_map(|(bucket, tokens, ttl)| {
(tokens > 0).then_some(bcode_model::ModelTokenUsageDetail {
bucket,
modality: bcode_model::ModelTokenModality::Text,
tokens,
cache_ttl_seconds: ttl,
})
})
.collect::<Vec<_>>()
.into_boxed_slice()
}
fn complete_request_input_tokens(
input_tokens: u32,
cache_read_input_tokens: Option<u32>,
cache_write_input_tokens: Option<u32>,
) -> u64 {
u64::from(input_tokens)
.saturating_add(u64::from(cache_read_input_tokens.unwrap_or_default()))
.saturating_add(u64::from(cache_write_input_tokens.unwrap_or_default()))
}
fn build_error(error: &(impl ToString + ?Sized)) -> ProviderError {
provider_error(
"bedrock_request_build_failed",
ProviderErrorCategory::InvalidRequest,
error.to_string(),
)
}
fn bedrock_messages_sdk_error(
error_source: &aws_sdk_bedrockruntime::error::SdkError<InvokeModelWithResponseStreamError>,
) -> ProviderError {
let Some(service_error) = error_source.as_service_error() else {
let (kind, category) = match error_source {
aws_sdk_bedrockruntime::error::SdkError::TimeoutError(_) => {
("timeout", ProviderErrorCategory::Timeout)
}
aws_sdk_bedrockruntime::error::SdkError::DispatchFailure(dispatch) => {
return bedrock_dispatch_error(
"bedrock_messages_request_failed",
"Bedrock Messages runtime",
dispatch,
bcode_model::ProviderFailureCapability::ModelInvocation,
);
}
aws_sdk_bedrockruntime::error::SdkError::ResponseError(_) => {
("response", ProviderErrorCategory::Network)
}
aws_sdk_bedrockruntime::error::SdkError::ConstructionFailure(_) => {
("construction", ProviderErrorCategory::Config)
}
_ => ("unknown", ProviderErrorCategory::ProviderInternal),
};
return bedrock_runtime_transport_error(kind, category);
};
let metadata = service_error.meta();
let provider_message = metadata.message().map(sanitize_provider_diagnostic);
let category = if service_error.is_access_denied_exception() {
ProviderErrorCategory::Auth
} else if service_error.is_throttling_exception() {
ProviderErrorCategory::RateLimit
} else if service_error.is_service_unavailable_exception() {
ProviderErrorCategory::Overloaded
} else if service_error.is_model_timeout_exception() {
ProviderErrorCategory::Timeout
} else if service_error.is_resource_not_found_exception() {
ProviderErrorCategory::ModelNotFound
} else if service_error.is_validation_exception() {
bedrock_validation_exception_category(provider_message.as_deref())
} else if provider_message
.as_deref()
.is_some_and(is_context_length_error)
{
ProviderErrorCategory::ContextLength
} else {
ProviderErrorCategory::ProviderInternal
};
let mut normalized = provider_error(
"bedrock_messages_request_failed",
category,
provider_message
.as_deref()
.unwrap_or("Bedrock Messages request failed"),
);
normalized.request_id = metadata
.request_id()
.map(str::to_string)
.map(String::into_boxed_str);
normalized.provider_message = provider_message.clone().map(String::into_boxed_str);
normalized.sources.push(ProviderErrorSource {
source: "aws_bedrock_messages".to_string(),
code: metadata.code().map(str::to_string),
message: provider_message,
});
normalized
}
fn bedrock_sdk_error(
error_source: &aws_sdk_bedrockruntime::error::SdkError<ConverseStreamError>,
) -> ProviderError {
let Some(service_error) = error_source.as_service_error() else {
let (kind, category) = match error_source {
aws_sdk_bedrockruntime::error::SdkError::TimeoutError(_) => {
("timeout", ProviderErrorCategory::Timeout)
}
aws_sdk_bedrockruntime::error::SdkError::DispatchFailure(dispatch) => {
return bedrock_dispatch_error(
"bedrock_request_failed",
"Bedrock runtime",
dispatch,
bcode_model::ProviderFailureCapability::ModelInvocation,
);
}
aws_sdk_bedrockruntime::error::SdkError::ResponseError(_) => {
("response", ProviderErrorCategory::Network)
}
aws_sdk_bedrockruntime::error::SdkError::ConstructionFailure(_) => {
("construction", ProviderErrorCategory::Config)
}
_ => ("unknown", ProviderErrorCategory::ProviderInternal),
};
return bedrock_runtime_transport_error(kind, category);
};
let category = bedrock_converse_stream_error_category(service_error);
let provider_message = service_error
.meta()
.message()
.map(sanitize_provider_diagnostic);
let mut normalized = provider_error(
"bedrock_request_failed",
category,
provider_message
.as_deref()
.unwrap_or("Bedrock runtime service request failed"),
);
if matches!(category, ProviderErrorCategory::Auth) {
normalized.failure = Some(Box::new(bedrock_failure_context(
bcode_model::ProviderFailureSourceKind::ProviderResponse,
service_error
.meta()
.code()
.unwrap_or("aws_bedrock_runtime_auth"),
bcode_model::ProviderFailureCapability::ModelInvocation,
"verify the AWS credential/profile source, region, and Bedrock model access",
)));
}
normalized.request_id = service_error
.meta()
.request_id()
.map(str::to_string)
.map(String::into_boxed_str);
normalized.provider_message = provider_message.clone().map(String::into_boxed_str);
normalized.sources.push(ProviderErrorSource {
source: "aws_bedrock_runtime".to_string(),
code: service_error.meta().code().map(str::to_string),
message: provider_message.clone(),
});
if category == ProviderErrorCategory::RateLimit || category == ProviderErrorCategory::Overloaded
{
normalized.retry = provider_message
.as_deref()
.and_then(retry_hint_from_body)
.map(Box::new);
}
normalized
}
fn bedrock_failure_context(
source_kind: bcode_model::ProviderFailureSourceKind,
source: impl Into<String>,
capability: bcode_model::ProviderFailureCapability,
remediation: impl Into<String>,
) -> bcode_model::ProviderFailureContext {
bcode_model::ProviderFailureContext {
provider_id: PROVIDER_ID.to_string(),
source_kind,
source: source.into(),
capability,
remediation: remediation.into(),
}
}
const MAX_BEDROCK_ERROR_CHAIN_DEPTH: usize = 16;
const MAX_BEDROCK_SOURCE_MESSAGE_CHARS: usize = 512;
#[allow(clippy::too_many_lines)]
fn bedrock_dispatch_error(
code: &str,
operation: &str,
dispatch: &aws_smithy_runtime_api::client::result::DispatchFailure,
capability: bcode_model::ProviderFailureCapability,
) -> ProviderError {
let connector = dispatch
.as_connector_error()
.expect("AWS dispatch failures contain a connector error");
let connector_kind = if connector.is_timeout() {
"timeout"
} else if connector.is_io() {
"io"
} else if connector.is_user() {
"user"
} else if connector.is_other() {
"other"
} else {
"unknown"
};
let chain = bedrock_error_chain(connector);
let credential_chain_failure = bedrock_credential_chain_failure(&chain.sources);
let category = if credential_chain_failure {
ProviderErrorCategory::Auth
} else if connector.is_timeout() {
ProviderErrorCategory::Timeout
} else if connector.is_user() {
ProviderErrorCategory::Config
} else {
ProviderErrorCategory::Network
};
let mut normalized = provider_error(
code,
category,
if credential_chain_failure {
"AWS credentials could not be resolved for Bedrock".to_string()
} else {
format!("{operation} dispatch failure")
},
);
normalized.retryable = !credential_chain_failure
&& matches!(
category,
ProviderErrorCategory::Network | ProviderErrorCategory::Timeout
);
normalized
.diagnostic_context
.insert("transport_kind".to_string(), "dispatch".to_string());
normalized.diagnostic_context.insert(
"connector_error_kind".to_string(),
connector_kind.to_string(),
);
if let Some(retry_kind) = connector.as_other() {
normalized.diagnostic_context.insert(
"connector_retry_kind".to_string(),
retry_kind.to_string().replace(' ', "_"),
);
}
normalized.diagnostic_context.insert(
"connection_established".to_string(),
connector.connection_metadata().is_some().to_string(),
);
if credential_chain_failure {
normalized.diagnostic_context.insert(
"auth_failure_kind".to_string(),
"credential_chain_exhausted".to_string(),
);
}
normalized.diagnostic_context.insert(
"error_chain_depth".to_string(),
chain.sources.len().to_string(),
);
if chain.truncated {
normalized
.diagnostic_context
.insert("error_chain_truncated".to_string(), "true".to_string());
}
if let Some(io_error) = preferred_bedrock_io_error(&chain.sources) {
normalized.diagnostic_context.insert(
"io_error_kind".to_string(),
bedrock_io_error_kind(io_error.kind()).to_string(),
);
if let Some(os_error) = io_error.raw_os_error() {
normalized
.diagnostic_context
.insert("os_error_code".to_string(), os_error.to_string());
}
}
if let Some(tls_error) = chain
.sources
.iter()
.find_map(|source| source.downcast_ref::<rustls::Error>())
{
normalized.diagnostic_context.insert(
"tls_error_kind".to_string(),
bedrock_rustls_error_kind(tls_error).to_string(),
);
}
if let Some(root) = chain.sources.last() {
let (root_source, root_code) = bedrock_error_source_identity(
*root,
chain.sources.len().saturating_sub(1),
chain.sources.len(),
);
normalized
.diagnostic_context
.insert("root_error_source".to_string(), root_source);
normalized
.diagnostic_context
.insert("root_error_code".to_string(), root_code);
normalized.diagnostic_context.insert(
"root_error_message".to_string(),
safe_bedrock_source_message(&root.to_string()),
);
}
normalized.sources.push(ProviderErrorSource {
source: "aws_sdk".to_string(),
code: Some("dispatch".to_string()),
message: None,
});
normalized
.sources
.extend(chain.sources.iter().enumerate().map(|(index, source)| {
let (source_name, source_code) =
bedrock_error_source_identity(*source, index, chain.sources.len());
ProviderErrorSource {
source: source_name,
code: Some(source_code),
message: Some(safe_bedrock_source_message(&source.to_string())),
}
}));
if matches!(
category,
ProviderErrorCategory::Auth | ProviderErrorCategory::Config
) {
normalized.failure = Some(Box::new(bedrock_failure_context(
bcode_model::ProviderFailureSourceKind::Runtime,
if credential_chain_failure {
"aws_sdk_credential_chain"
} else {
"aws_sdk_connector"
},
capability,
if credential_chain_failure {
"configure AWS credentials through the selected Bedrock auth profile, AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, AWS_PROFILE, web identity, ECS task credentials, or EC2 instance credentials"
} else {
"verify the AWS region, endpoint, credentials, proxy, TLS trust roots, and network policy"
},
)));
}
normalized
}
fn bedrock_credential_chain_failure(chain: &[&(dyn std::error::Error + 'static)]) -> bool {
chain.iter().any(|source| {
let message = source.to_string().to_ascii_lowercase();
message.contains("no credentials found in chain")
|| message.contains("credential provider was not enabled")
|| message.contains("credentials could not be loaded")
})
}
struct BedrockErrorChain<'a> {
sources: Vec<&'a (dyn std::error::Error + 'static)>,
truncated: bool,
}
fn bedrock_error_chain<'a>(error: &'a (dyn std::error::Error + 'static)) -> BedrockErrorChain<'a> {
let mut sources = Vec::new();
let mut source = bedrock_nested_error_source(error);
while let Some(current) = source
&& sources.len() < MAX_BEDROCK_ERROR_CHAIN_DEPTH
{
sources.push(current);
source = bedrock_nested_error_source(current);
}
BedrockErrorChain {
sources,
truncated: source.is_some(),
}
}
fn bedrock_nested_error_source<'a>(
error: &'a (dyn std::error::Error + 'static),
) -> Option<&'a (dyn std::error::Error + 'static)> {
error.source().or_else(|| {
error.downcast_ref::<std::io::Error>().and_then(|error| {
error
.get_ref()
.map(|source| source as &(dyn std::error::Error + 'static))
})
})
}
fn preferred_bedrock_io_error<'a>(
chain: &[&'a (dyn std::error::Error + 'static)],
) -> Option<&'a std::io::Error> {
let errors = chain
.iter()
.filter_map(|source| source.downcast_ref::<std::io::Error>())
.collect::<Vec<_>>();
errors
.iter()
.rev()
.copied()
.find(|error| error.kind() != std::io::ErrorKind::Other)
.or_else(|| errors.last().copied())
}
fn bedrock_error_source_identity(
source: &(dyn std::error::Error + 'static),
index: usize,
chain_len: usize,
) -> (String, String) {
if let Some(io_error) = source.downcast_ref::<std::io::Error>() {
return (
if index + 1 == chain_len {
"io_root"
} else {
"io"
}
.to_string(),
bedrock_io_error_kind(io_error.kind()).to_string(),
);
}
if let Some(tls_error) = source.downcast_ref::<rustls::Error>() {
return (
if index + 1 == chain_len {
"rustls_root"
} else {
"rustls"
}
.to_string(),
bedrock_rustls_error_kind(tls_error).to_string(),
);
}
let message = source.to_string().to_ascii_lowercase();
let code = if message.contains("dns") {
"dns"
} else if message.contains("proxy") || message.contains("tunnel") {
"proxy"
} else if message.contains("certificate") || message.contains("tls") {
"tls"
} else if message.contains("connect") || message.contains("tcp") {
"connect"
} else {
"unknown"
};
(
if index + 1 == chain_len {
"transport_root"
} else {
"transport"
}
.to_string(),
code.to_string(),
)
}
fn safe_bedrock_source_message(message: &str) -> String {
let sanitized = sanitize_provider_diagnostic(message);
let normalized = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
let mut chars = normalized.chars();
let prefix = chars
.by_ref()
.take(MAX_BEDROCK_SOURCE_MESSAGE_CHARS)
.collect::<String>();
if chars.next().is_some() {
format!("{prefix}…[TRUNCATED]")
} else {
prefix
}
}
const fn bedrock_io_error_kind(kind: std::io::ErrorKind) -> &'static str {
match kind {
std::io::ErrorKind::PermissionDenied => "permission_denied",
std::io::ErrorKind::ConnectionRefused => "connection_refused",
std::io::ErrorKind::ConnectionReset => "connection_reset",
std::io::ErrorKind::HostUnreachable => "host_unreachable",
std::io::ErrorKind::NetworkUnreachable => "network_unreachable",
std::io::ErrorKind::ConnectionAborted => "connection_aborted",
std::io::ErrorKind::NotConnected => "not_connected",
std::io::ErrorKind::AddrNotAvailable => "address_not_available",
std::io::ErrorKind::NetworkDown => "network_down",
std::io::ErrorKind::BrokenPipe => "broken_pipe",
std::io::ErrorKind::TimedOut => "timed_out",
std::io::ErrorKind::Interrupted => "interrupted",
std::io::ErrorKind::UnexpectedEof => "unexpected_eof",
std::io::ErrorKind::InvalidData => "invalid_data",
_ => "other",
}
}
const fn bedrock_rustls_error_kind(error: &rustls::Error) -> &'static str {
match error {
rustls::Error::InvalidCertificate(certificate_error) => {
bedrock_certificate_error_kind(certificate_error)
}
rustls::Error::NoCertificatesPresented => "no_certificates_presented",
rustls::Error::PeerIncompatible(_) => "peer_incompatible",
rustls::Error::PeerMisbehaved(_) => "peer_misbehaved",
rustls::Error::NoApplicationProtocol => "no_application_protocol",
rustls::Error::AlertReceived(_) => "alert_received",
_ => "tls_protocol_error",
}
}
const fn bedrock_certificate_error_kind(error: &rustls::CertificateError) -> &'static str {
match error {
rustls::CertificateError::BadEncoding => "certificate_bad_encoding",
rustls::CertificateError::Expired | rustls::CertificateError::ExpiredContext { .. } => {
"certificate_expired"
}
rustls::CertificateError::NotValidYet
| rustls::CertificateError::NotValidYetContext { .. } => "certificate_not_valid_yet",
rustls::CertificateError::Revoked => "certificate_revoked",
rustls::CertificateError::UnknownIssuer => "certificate_unknown_issuer",
rustls::CertificateError::BadSignature => "certificate_bad_signature",
rustls::CertificateError::NotValidForName
| rustls::CertificateError::NotValidForNameContext { .. } => {
"certificate_not_valid_for_name"
}
_ => "certificate_error",
}
}
fn bedrock_runtime_transport_error(kind: &str, category: ProviderErrorCategory) -> ProviderError {
let mut normalized = provider_error(
"bedrock_request_failed",
category,
format!("Bedrock runtime {kind} failure"),
);
normalized
.diagnostic_context
.insert("transport_kind".to_string(), kind.to_string());
normalized.sources.push(ProviderErrorSource {
source: "aws_sdk".to_string(),
code: Some(kind.to_string()),
message: None,
});
if matches!(
category,
ProviderErrorCategory::Auth | ProviderErrorCategory::Config
) {
normalized.failure = Some(Box::new(bedrock_failure_context(
bcode_model::ProviderFailureSourceKind::Runtime,
"aws_sdk_credential_and_region_chain",
bcode_model::ProviderFailureCapability::ModelInvocation,
"verify the AWS credential/profile source, region, and Bedrock model access",
)));
}
normalized
}
fn bedrock_converse_stream_error_category(error: &ConverseStreamError) -> ProviderErrorCategory {
if error.is_service_unavailable_exception() {
ProviderErrorCategory::Overloaded
} else if error.is_throttling_exception() {
ProviderErrorCategory::RateLimit
} else if error.is_access_denied_exception() {
ProviderErrorCategory::Auth
} else if is_context_length_error(error.to_string().as_str()) {
ProviderErrorCategory::ContextLength
} else if error.is_validation_exception() {
ProviderErrorCategory::InvalidRequest
} else if error.is_resource_not_found_exception() {
ProviderErrorCategory::ModelNotFound
} else if error.is_model_timeout_exception() {
ProviderErrorCategory::Timeout
} else {
ProviderErrorCategory::ProviderInternal
}
}
fn bedrock_error_category_from_message(message: &str) -> ProviderErrorCategory {
if message.contains("ServiceUnavailableException")
|| message.contains("ServiceUnavailable")
|| message.contains("status code: 503")
|| message.contains("status: 503")
{
ProviderErrorCategory::Overloaded
} else if message.contains("UnrecognizedClient")
|| message.contains("AccessDenied")
|| message.contains("ExpiredToken")
|| message.contains("credentials")
{
ProviderErrorCategory::Auth
} else if message.contains("Throttl") || message.contains("TooManyRequests") {
ProviderErrorCategory::RateLimit
} else if is_context_length_error(message) {
ProviderErrorCategory::ContextLength
} else if message.contains("ValidationException") {
ProviderErrorCategory::InvalidRequest
} else if message.contains("ResourceNotFound") || message.contains("not found") {
ProviderErrorCategory::ModelNotFound
} else {
ProviderErrorCategory::ProviderInternal
}
}
fn bedrock_discovery_error<E, R>(error: &bedrock::error::SdkError<E, R>) -> ProviderError
where
E: ProvideErrorMetadata,
{
match error {
bedrock::error::SdkError::ServiceError(service) => {
let metadata = service.err().meta();
let provider_message = metadata.message().map(sanitize_provider_diagnostic);
let category = metadata
.code()
.or_else(|| metadata.message())
.map_or(ProviderErrorCategory::ProviderInternal, |detail| {
bedrock_error_category_from_message(detail)
});
let mut normalized = provider_error(
"bedrock_model_discovery_failed",
category,
provider_message
.as_deref()
.unwrap_or("Bedrock model discovery service request failed"),
);
normalized.request_id = metadata
.request_id()
.map(str::to_string)
.map(String::into_boxed_str);
normalized.provider_message = provider_message.clone().map(String::into_boxed_str);
normalized.sources.push(ProviderErrorSource {
source: "aws_bedrock_control".to_string(),
code: metadata.code().map(str::to_string),
message: provider_message,
});
if matches!(category, ProviderErrorCategory::Auth) {
normalized.failure = Some(Box::new(bedrock_failure_context(
bcode_model::ProviderFailureSourceKind::ProviderResponse,
metadata.code().unwrap_or("aws_bedrock_control_auth"),
bcode_model::ProviderFailureCapability::ModelDiscovery,
"verify the AWS credential/profile source, region, and Bedrock model access",
)));
}
normalized
}
bedrock::error::SdkError::TimeoutError(_) => {
bedrock_transport_error("timeout", ProviderErrorCategory::Timeout)
}
bedrock::error::SdkError::DispatchFailure(dispatch) => bedrock_dispatch_error(
"bedrock_model_discovery_failed",
"Bedrock model discovery",
dispatch,
bcode_model::ProviderFailureCapability::ModelDiscovery,
),
bedrock::error::SdkError::ResponseError(_) => {
bedrock_transport_error("response", ProviderErrorCategory::Network)
}
bedrock::error::SdkError::ConstructionFailure(_) => {
bedrock_transport_error("construction", ProviderErrorCategory::Config)
}
_ => bedrock_transport_error("unknown", ProviderErrorCategory::ProviderInternal),
}
}
fn bedrock_transport_error(kind: &str, category: ProviderErrorCategory) -> ProviderError {
let mut normalized = provider_error(
"bedrock_model_discovery_failed",
category,
format!("Bedrock model discovery {kind} failure"),
);
normalized
.diagnostic_context
.insert("transport_kind".to_string(), kind.to_string());
normalized.sources.push(ProviderErrorSource {
source: "aws_sdk".to_string(),
code: Some(kind.to_string()),
message: None,
});
if matches!(
category,
ProviderErrorCategory::Auth | ProviderErrorCategory::Config
) {
normalized.failure = Some(Box::new(bedrock_failure_context(
bcode_model::ProviderFailureSourceKind::Runtime,
"aws_sdk_credential_and_region_chain",
bcode_model::ProviderFailureCapability::ModelDiscovery,
"verify the AWS credential/profile source, region, and Bedrock model access",
)));
}
normalized
}
fn bedrock_messages_stream_error<R>(
error: &aws_sdk_bedrockruntime::error::SdkError<
aws_sdk_bedrockruntime::types::error::ResponseStreamError,
R,
>,
) -> ProviderError {
let Some(service_error) = error.as_service_error() else {
return provider_error(
"bedrock_messages_stream_failed",
ProviderErrorCategory::Network,
"Bedrock Messages response stream failed",
);
};
let metadata = service_error.meta();
let provider_message = metadata.message().map(sanitize_provider_diagnostic);
let category = if service_error.is_service_unavailable_exception() {
ProviderErrorCategory::Overloaded
} else if service_error.is_throttling_exception() {
ProviderErrorCategory::RateLimit
} else if service_error.is_validation_exception() {
bedrock_validation_exception_category(provider_message.as_deref())
} else if service_error.is_model_timeout_exception() {
ProviderErrorCategory::Timeout
} else if provider_message
.as_deref()
.is_some_and(is_context_length_error)
{
ProviderErrorCategory::ContextLength
} else {
ProviderErrorCategory::ProviderInternal
};
let mut normalized = provider_error(
"bedrock_messages_stream_failed",
category,
provider_message
.as_deref()
.unwrap_or("Bedrock Messages response stream failed"),
);
normalized.request_id = metadata
.request_id()
.map(str::to_string)
.map(String::into_boxed_str);
normalized.provider_message = provider_message.clone().map(String::into_boxed_str);
normalized.sources.push(ProviderErrorSource {
source: "aws_bedrock_messages_stream".to_string(),
code: metadata.code().map(str::to_string),
message: provider_message,
});
normalized
}
fn bedrock_stream_error<R>(
error: &aws_sdk_bedrockruntime::error::SdkError<
aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError,
R,
>,
) -> ProviderError {
let Some(service_error) = error.as_service_error() else {
let (kind, category) = match error {
aws_sdk_bedrockruntime::error::SdkError::TimeoutError(_) => {
("timeout", ProviderErrorCategory::Timeout)
}
aws_sdk_bedrockruntime::error::SdkError::DispatchFailure(dispatch) => {
return bedrock_dispatch_error(
"bedrock_stream_failed",
"Bedrock stream",
dispatch,
bcode_model::ProviderFailureCapability::ModelInvocation,
);
}
aws_sdk_bedrockruntime::error::SdkError::ResponseError(_) => {
("response", ProviderErrorCategory::Network)
}
aws_sdk_bedrockruntime::error::SdkError::ConstructionFailure(_) => {
("construction", ProviderErrorCategory::ProviderInternal)
}
_ => ("unknown", ProviderErrorCategory::ProviderInternal),
};
let mut normalized = provider_error(
"bedrock_stream_failed",
category,
format!("Bedrock stream {kind} failure"),
);
normalized
.diagnostic_context
.insert("transport_kind".to_string(), kind.to_string());
normalized.sources.push(ProviderErrorSource {
source: "aws_sdk".to_string(),
code: Some(kind.to_string()),
message: None,
});
return normalized;
};
let metadata = service_error.meta();
let provider_message = metadata.message().map(sanitize_provider_diagnostic);
let category = if service_error.is_service_unavailable_exception() {
ProviderErrorCategory::Overloaded
} else if service_error.is_throttling_exception() {
ProviderErrorCategory::RateLimit
} else if service_error.is_validation_exception() {
bedrock_validation_exception_category(provider_message.as_deref())
} else if provider_message
.as_deref()
.is_some_and(is_context_length_error)
{
ProviderErrorCategory::ContextLength
} else {
ProviderErrorCategory::ProviderInternal
};
let mut normalized = provider_error(
"bedrock_stream_failed",
category,
provider_message
.as_deref()
.unwrap_or("Bedrock response stream failed"),
);
normalized.request_id = metadata
.request_id()
.map(str::to_string)
.map(String::into_boxed_str);
normalized.provider_message = provider_message.clone().map(String::into_boxed_str);
normalized.sources.push(ProviderErrorSource {
source: "aws_bedrock_stream".to_string(),
code: metadata.code().map(str::to_string),
message: provider_message,
});
normalized
}
fn bedrock_validation_exception_category(provider_message: Option<&str>) -> ProviderErrorCategory {
if provider_message.is_some_and(is_context_length_error) {
ProviderErrorCategory::ContextLength
} else {
ProviderErrorCategory::InvalidRequest
}
}
fn is_context_length_error(message: &str) -> bool {
let message = message.to_ascii_lowercase();
message.contains("maximum context length")
|| message.contains("prompt is too long")
|| message.contains("input is too long")
|| message.contains("too many tokens")
|| (message.contains("context length")
&& (message.contains("exceed") || message.contains("too long")))
|| (message.contains("context window")
&& (message.contains("exceed")
|| message.contains("too long")
|| message.contains("overflow")))
}
fn json_response<T: Serialize>(value: &T) -> ServiceResponse {
match serde_json::to_vec(value) {
Ok(payload) => ServiceResponse::ok(payload),
Err(error) => ServiceResponse::error("serialization_failed", error.to_string()),
}
}
fn invalid_request(error: &serde_json::Error) -> ServiceResponse {
ServiceResponse::error("invalid_request", error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn worker_abort_and_panic_publish_terminal_outcomes() {
let runtime = ProviderRuntime::new().unwrap();
for cancelled in [false, true] {
let turn = TurnState::default();
if cancelled {
turn.cancel();
}
let completion = BedrockWorkerCompletion {
turn: turn.clone(),
completed: false,
};
let (started, start) = tokio::sync::oneshot::channel();
let mut task = runtime.spawn(async move {
let _completion = completion;
started.send(()).unwrap();
std::future::pending::<()>().await;
});
start.await.unwrap();
task.cancel_and_wait().await.unwrap();
let events = turn.drain();
let expected = if cancelled {
StopReason::Cancelled
} else {
StopReason::Error
};
assert!(
matches!(events.last(), Some(ProviderTurnEvent::TurnFinished { stop_reason }) if *stop_reason == expected)
);
assert_eq!(events.len(), 2);
}
for cancelled in [false, true] {
let turn = TurnState::default();
if cancelled {
turn.cancel();
}
let completion = BedrockWorkerCompletion {
turn: turn.clone(),
completed: false,
};
let task = runtime.spawn(async move {
let _completion = completion;
panic!("private worker panic");
});
assert!(task.await.is_err());
assert!(matches!(
turn.drain().as_slice(),
[
ProviderTurnEvent::Error { .. },
ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::Error
}
]
));
}
runtime.shutdown_wait().await.unwrap();
}
#[test]
fn interrupted_worker_publishes_terminal_failure_without_overwriting_completion() {
let turn = TurnState::default();
drop(BedrockWorkerCompletion {
turn: turn.clone(),
completed: false,
});
assert!(matches!(
turn.drain().as_slice(),
[
ProviderTurnEvent::Error { .. },
ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::Error
}
]
));
let turn = TurnState::default();
turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::EndTurn,
});
drop(BedrockWorkerCompletion {
turn: turn.clone(),
completed: false,
});
assert!(matches!(
turn.drain().as_slice(),
[ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::EndTurn
}]
));
}
#[test]
fn push_response_preserves_terminal_outcome_under_late_host_cancellation() {
for queued in [
None,
Some(StopReason::EndTurn),
Some(StopReason::ToolCall),
Some(StopReason::Error),
] {
let turn = TurnState::default();
if let Some(stop_reason) = queued {
turn.push(ProviderTurnEvent::TurnFinished { stop_reason });
}
let context = NativeServiceContext {
plugin_id: PROVIDER_ID.to_string(),
request: ServiceRequest {
interface_id: MODEL_PROVIDER_INTERFACE_ID_V2.to_string(),
operation: OP_RUN_TURN.to_string(),
payload: Vec::new(),
},
config: PluginConfigContext::default(),
events: ServiceEventEmitter::default(),
cancellation: ServiceCancellation::default(),
bridge: ServiceBridge::default(),
transient_progress_limits: bcode_plugin_sdk::TransientProgressLimits::default(),
};
context.cancellation.cancel();
let response = stream_turn_events(&turn, &context);
assert!(response.error.is_none());
let response: RunTurnResponse = serde_json::from_slice(&response.payload).unwrap();
assert_eq!(
response.stop_reason,
Some(queued.unwrap_or(StopReason::Cancelled))
);
assert!(turn.is_cancelled());
assert!(turn.drain().is_empty());
}
}
#[test]
fn forwarding_preserves_completed_turn_when_cancellation_arrives_late() {
for reason in [StopReason::EndTurn, StopReason::ToolCall, StopReason::Error] {
let turn = TurnState::default();
turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: reason,
});
turn.cancel();
let mut emitted = Vec::new();
assert_eq!(
forward_turn_events(&turn, |event| emitted.push(event.clone())),
Some(reason)
);
assert!(
matches!(emitted.as_slice(), [ProviderTurnEvent::TurnFinished { stop_reason }] if *stop_reason == reason)
);
}
let turn = TurnState::default();
turn.cancel();
assert_eq!(
forward_turn_events(&turn, |_| panic!("no queued events")),
None
);
}
#[tokio::test]
async fn cancelled_turn_skips_discovery_and_request_setup() {
let request = test_model_turn_request();
let turn = TurnState::default();
turn.cancel();
let discovery = Arc::new(Mutex::new(DiscoveryCache::default()));
assert!(matches!(
stream_bedrock_turn_inner(&request, &turn, discovery).await,
Ok(StreamOutcome::Cancelled)
));
assert!(turn.drain().is_empty());
}
#[test]
fn transferred_turn_remains_owned_by_polling_caller() {
let store = Mutex::new(TurnStore::default());
let (id, turn) = store.lock().unwrap().insert_started("test");
drop(PushTurnCleanup {
store: &store,
id: id.clone(),
turn: turn.clone(),
transferred: true,
});
assert!(!turn.is_cancelled());
assert!(!store.lock().unwrap().drain(&id).is_empty());
store.lock().unwrap().finish(&id);
assert!(turn.is_cancelled());
}
#[test]
fn push_turn_cleanup_releases_registry_on_unwind() {
let store = Mutex::new(TurnStore::default());
let (id, turn) = store.lock().unwrap().insert_started("test");
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _cleanup = PushTurnCleanup {
store: &store,
id: id.clone(),
turn: turn.clone(),
transferred: false,
};
panic!("failed invocation");
}));
assert!(outcome.is_err());
assert!(turn.is_cancelled());
assert!(store.lock().unwrap().drain(&id).is_empty());
}
#[test]
fn stopped_runtime_reports_turn_start_failure() {
let runtime = ProviderRuntime::new().unwrap();
runtime.shutdown(Duration::from_secs(5)).unwrap();
let turn = TurnState::default();
AwsBedrockTurnExecutor.start(
&runtime,
test_model_turn_request(),
turn.clone(),
Arc::default(),
);
assert!(
turn.drain()
.iter()
.any(|event| matches!(event, ProviderTurnEvent::Error { .. }))
);
}
#[test]
fn deactivation_stops_provider_runtime() {
let mut plugin = BedrockProviderPlugin::default();
let runtime = plugin.runtime.as_ref().expect("runtime");
let task = runtime.spawn(std::future::pending::<()>());
let (turn_id, turn) = plugin.turns.lock().unwrap().insert_started("test");
plugin.deactivate_concurrent().expect("shutdown");
assert!(turn.is_cancelled());
assert!(plugin.turns.lock().unwrap().drain(&turn_id).is_empty());
assert!(task.is_finished());
assert!(runtime.block_on(async {}).is_err());
plugin.deactivate().expect("idempotent shutdown");
}
use bcode_model_provider_runtime::{
BlockingModelProviderInvoker, ProviderConformanceOptions, ProviderConformanceOutcome,
run_provider_conformance_suite,
};
use bcode_plugin_sdk::{PluginConfigContext, ServiceCancellation};
#[test]
fn converse_structured_output_normalizes_schema_and_builds_output_config() {
let mut request = test_model_turn_request();
request.structured_output = Some(bcode_model::StructuredOutputRequest {
name: "LoopWorkflowIteration".to_string(),
schema: serde_json::json!({
"type": "object",
"properties": {
"iteration": {"type": "integer", "minimum": 0},
"summary": {"type": "string"}
}
}),
strict: true,
});
let config = bedrock_output_config(&request)
.expect("schema should normalize")
.expect("structured request should build output config");
let format = config.text_format().expect("text format");
assert_eq!(format.r#type(), &OutputFormatType::JsonSchema);
let definition = format
.structure()
.expect("structure")
.as_json_schema()
.expect("json schema");
assert_eq!(definition.name(), Some("LoopWorkflowIteration"));
let schema: serde_json::Value =
serde_json::from_str(definition.schema()).expect("normalized schema JSON");
assert!(schema.pointer("/properties/iteration/minimum").is_none());
assert_eq!(schema["additionalProperties"], false);
assert_eq!(
schema["required"],
serde_json::json!(["iteration", "summary"])
);
let built = build_converse_request(&request, "model".to_string(), Some(DEFAULT_REGION))
.expect("Converse request should build");
assert!(built.output_config.is_some());
}
#[test]
fn mantle_anthropic_structured_output_uses_tool_free_synthetic_fallback() {
let mut request = test_model_turn_request();
request.structured_output = Some(bcode_model::StructuredOutputRequest {
name: "answer".to_string(),
schema: serde_json::json!({"type": "object"}),
strict: true,
});
request
.provider_context
.settings
.insert("transport".to_string(), "mantle_anthropic".to_string());
validate_bedrock_request(&request, false)
.expect("Mantle Anthropic adapter should support structured output");
let (fallback, synthetic) = bedrock_messages_structured_fallback_request(&request)
.expect("tool-free synthetic fallback");
assert!(fallback.structured_output.is_none());
assert_eq!(fallback.tools, [synthetic.tool()]);
assert_eq!(fallback.tool_call_policy.choice, ToolChoice::Required);
assert_eq!(fallback.tool_call_policy.parallel, Some(false));
}
#[test]
fn anthropic_messages_structured_output_uses_native_output_config() {
let mut request = test_model_turn_request();
request.provider_context.api_surface = Some(bcode_model::ModelApiSurface::Messages);
request.structured_output = Some(bcode_model::StructuredOutputRequest {
name: "answer".to_string(),
schema: serde_json::json!({
"type": "object",
"properties": {"ok": {"type": "boolean"}}
}),
strict: true,
});
validate_bedrock_request(&request, false)
.expect("Bedrock Runtime Messages should support structured output");
let body = build_anthropic_messages_request_value(&request, "model", Some(DEFAULT_REGION))
.expect("Messages request should build");
assert_eq!(body["output_config"]["format"]["type"], "json_schema");
assert_eq!(
body["output_config"]["format"]["schema"]["additionalProperties"],
false
);
}
#[test]
fn anthropic_messages_adapter_fallback_forces_synthetic_tool() {
let structured = bcode_model::StructuredOutputRequest {
name: "answer".to_string(),
schema: serde_json::json!({"type": "object"}),
strict: true,
};
let mut request = test_model_turn_request();
request.structured_output = Some(structured);
request.tool_schema_mode = Some(bcode_model::ToolSchemaMode::Strict);
request.tool_call_policy.parallel = Some(true);
let (request, _synthetic) = bedrock_messages_structured_fallback_request(&request)
.expect("tool-free fallback request should build");
let body = build_anthropic_messages_request_value(&request, "model", Some(DEFAULT_REGION))
.expect("fallback Messages request should build");
assert_eq!(request.tools.len(), 1);
assert!(request.structured_output.is_none());
assert_eq!(request.tool_schema_mode, None);
assert_eq!(request.tool_call_policy.parallel, Some(false));
assert_eq!(request.tool_call_policy.choice, ToolChoice::Required);
assert!(
body["tools"]
.as_array()
.is_some_and(|tools| !tools.is_empty())
);
assert!(
body["tools"]
.as_array()
.expect("tools")
.iter()
.all(|tool| tool.get("strict").is_none())
);
assert_eq!(body["tool_choice"]["type"], "any");
assert!(body.get("output_config").is_none());
}
#[test]
fn anthropic_messages_adapter_fallback_rejects_host_tools() {
let mut request = test_model_turn_request();
request.structured_output = Some(bcode_model::StructuredOutputRequest {
name: "answer".to_string(),
schema: serde_json::json!({"type": "object"}),
strict: true,
});
request.tools.push(bcode_model::ToolDefinition {
name: "real_tool".to_string(),
description: "host tool".to_string(),
input_schema: serde_json::json!({"type": "object"}),
});
let error = bedrock_messages_structured_fallback_request(&request)
.expect_err("fallback must reject mixed host and synthetic tools");
assert_eq!(
error.code,
"structured_output_emulation_requires_tool_free_round"
);
}
#[test]
fn bedrock_messages_native_structured_output_rejection_is_narrow() {
let mut error = provider_error(
"bedrock_messages_request_failed",
ProviderErrorCategory::InvalidRequest,
"output_config.format: Extra inputs are not permitted",
);
error.provider_message = Some(
"output_config.format: Extra inputs are not permitted"
.to_string()
.into_boxed_str(),
);
assert!(bedrock_messages_native_structured_output_rejected(&error));
error.provider_message = Some("invalid max_tokens".to_string().into_boxed_str());
assert!(!bedrock_messages_native_structured_output_rejected(&error));
error.category = ProviderErrorCategory::Network;
error.provider_message = Some(
"output_config.format: Extra inputs are not permitted"
.to_string()
.into_boxed_str(),
);
assert!(!bedrock_messages_native_structured_output_rejected(&error));
}
#[test]
fn structured_finalization_failure_diagnostics_are_secret_safe() {
let secret = "secret-finalization-token";
let upstream = format!(
"Bedrock finalization failed: authorization: Bearer {secret}; aws_secret_access_key={secret}"
);
let sanitized = sanitize_provider_diagnostic(&upstream);
let mut error = provider_error(
"bedrock_messages_request_failed",
ProviderErrorCategory::InvalidRequest,
&sanitized,
);
error.provider_message = Some(sanitized.clone().into_boxed_str());
error.sources.push(ProviderErrorSource {
source: "aws_bedrock_messages".to_string(),
code: Some("ValidationException".to_string()),
message: Some(sanitized),
});
let public = serde_json::to_string(&error).expect("provider error should serialize");
assert!(!public.contains(secret));
assert!(public.contains("[REDACTED]"));
}
#[test]
fn anthropic_messages_synthetic_output_is_intercepted_as_text() {
let structured = bcode_model::StructuredOutputRequest {
name: "answer".to_string(),
schema: serde_json::json!({"type": "object"}),
strict: true,
};
let synthetic = SyntheticStructuredOutput::new(&structured, false)
.expect("tool-free synthetic output should be supported");
let tool = synthetic.tool();
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), Some(synthetic));
let turn = TurnState::default();
accumulator
.process(
&serde_json::json!({
"type": "content_block_start",
"index": 0,
"content_block": {"type": "tool_use", "id": "synthetic", "name": tool.name}
}),
&turn,
)
.expect("synthetic tool starts");
accumulator
.process(
&serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": "{\"ok\":true}"}
}),
&turn,
)
.expect("synthetic arguments stream");
accumulator
.process(
&serde_json::json!({"type": "content_block_stop", "index": 0}),
&turn,
)
.expect("synthetic tool finishes");
let events = turn.drain();
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::TextDelta { text } if text == "{\"ok\":true}"
)));
assert!(!events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ToolCallStarted { .. }
| ProviderTurnEvent::ToolCallDelta { .. }
| ProviderTurnEvent::ToolCallFinished { .. }
)));
assert_eq!(accumulator.finish(), StreamOutcome::Finished);
}
#[test]
fn anthropic_messages_error_event_is_normalized() {
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
let turn = TurnState::default();
let event = serde_json::json!({
"type": "error",
"error": {
"type": "authentication_error",
"message": "invalid API key"
}
});
let error = accumulator
.process(&event, &turn)
.expect_err("error event must fail the stream");
assert_eq!(error.category, ProviderErrorCategory::Auth);
assert_eq!(error.code, "bedrock_anthropic_authentication_error");
}
#[test]
fn mantle_anthropic_request_uses_direct_messages_shape() {
let mut request = test_model_turn_request();
request.parameters.reasoning_control = Some(bcode_model::ReasoningControl::Adaptive);
request.parameters.reasoning_effort_value = Some("high".to_string());
let value = build_mantle_anthropic_request(
&request,
"anthropic.claude-opus-5",
Some(DEFAULT_REGION),
)
.expect("Mantle request should serialize");
assert_eq!(value["model"], "anthropic.claude-opus-5");
assert_eq!(value["stream"], true);
assert!(value.get("anthropic_version").is_none());
assert_eq!(value["thinking"]["type"], "adaptive");
assert_eq!(value["output_config"]["effort"], "high");
}
#[test]
fn mantle_config_validation_checks_the_endpoint_for_the_configured_flavor() {
for (transport, flavor) in [
(BedrockTransport::MantleAnthropic, MantleFlavor::Anthropic),
(BedrockTransport::MantleOpenAi, MantleFlavor::OpenAi),
] {
let mut settings = test_settings();
settings.transport = Ok(transport);
assert_eq!(
settings.mantle_flavor().expect("flavor should resolve"),
flavor
);
settings.mantle_base_url = Some("http://mantle.example.com/v1".to_string());
settings
.auth_credentials
.insert("bearer_token".to_string(), "secret".to_string());
assert_eq!(
validate_mantle_settings(&settings).unwrap_err().code,
"bedrock_mantle_base_url_insecure"
);
}
let mut runtime = test_settings();
runtime.transport = Ok(BedrockTransport::Runtime);
assert_eq!(
runtime.mantle_flavor().unwrap_err().code,
"bedrock_mantle_transport_required"
);
}
#[test]
fn mantle_endpoint_defaults_from_region_and_accepts_local_tests() {
let settings = test_settings();
assert_eq!(
mantle_anthropic_messages_endpoint(&settings).expect("default endpoint"),
"https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages"
);
let mut custom = test_settings();
custom.mantle_base_url = Some("http://127.0.0.1:8080".to_string());
assert_eq!(
mantle_anthropic_messages_endpoint(&custom).expect("custom endpoint"),
"http://127.0.0.1:8080/v1/messages"
);
custom.mantle_base_url = Some("https://gateway.example.com/gw".to_string());
assert_eq!(
mantle_anthropic_messages_endpoint(&custom).expect("gateway endpoint"),
"https://gateway.example.com/gw/v1/messages"
);
assert_eq!(
mantle_endpoint(&custom, MantleFlavor::OpenAi).expect("custom openai endpoint"),
"https://gateway.example.com/gw/v1/responses"
);
}
#[test]
fn mantle_openai_request_uses_the_shared_responses_projection() {
let mut request = test_model_turn_request();
request.system_prompt = Some("be helpful".to_string());
request.messages = vec![ModelMessage {
role: MessageRole::User,
content: vec![ContentBlock::Text {
text: "hello".to_string(),
}],
}];
let value = build_mantle_openai_request(&request, "openai.test-model")
.expect("request should build");
assert_eq!(value["model"], "openai.test-model");
assert_eq!(value["stream"], true);
assert_eq!(value["store"], false);
assert!(value.get("previous_response_id").is_none());
assert_eq!(value["instructions"], "be helpful");
let input = value["input"].as_array().expect("input is an array");
assert_eq!(input.len(), 1);
assert_eq!(input[0]["type"], "message");
assert_eq!(input[0]["role"], "user");
assert_eq!(input[0]["content"][0]["type"], "input_text");
assert_eq!(input[0]["content"][0]["text"], "hello");
}
fn enable_explicit_prompt_cache(request: &mut ModelTurnRequest) {
request.prompt_cache.mode = bcode_model::PromptCacheMode::Aggressive;
request.prompt_cache.ttl_seconds = Some(30 * 60);
request.prompt_cache.supported_ttl_seconds = BTreeSet::from([30 * 60]);
request.metadata.insert(
"model_cache_capabilities".to_string(),
"prompt_cache_key,explicit_cache_points,cache_usage_reporting".to_string(),
);
request
.metadata
.insert("stable_prompt_hash".to_string(), "prompt-hash".to_string());
request
.metadata
.insert("tools_hash".to_string(), "tools-hash".to_string());
}
#[test]
fn mantle_openai_request_uses_stable_prefix_cache_key() {
let mut request = test_model_turn_request();
enable_explicit_prompt_cache(&mut request);
request.system_prompt = Some("stable instructions".to_string());
request.metadata.insert(
"prompt_cache_key".to_string(),
"stable-override".to_string(),
);
let overridden = build_mantle_openai_request(&request, "openai.test-model")
.expect("request should build");
assert_eq!(overridden["prompt_cache_key"], "stable-override");
request.metadata.remove("prompt_cache_key");
request.prompt_cache.key = Some("bcode:session-id".to_string());
let stable_prefix = build_mantle_openai_request(&request, "openai.test-model")
.expect("request should build");
assert_eq!(stable_prefix["prompt_cache_key"], "bcode:session-id");
request.session_id = "11111111-1111-1111-1111-111111111111"
.parse()
.expect("static UUID should parse");
request.messages = vec![ModelMessage {
role: MessageRole::User,
content: vec![ContentBlock::Text {
text: "different changing suffix".to_string(),
}],
}];
let same_prefix = build_mantle_openai_request(&request, "openai.test-model")
.expect("request should build");
assert_eq!(
same_prefix["prompt_cache_key"],
stable_prefix["prompt_cache_key"]
);
}
#[test]
fn mantle_openai_explicit_cache_uses_documented_typed_shape() {
let mut request = test_model_turn_request();
enable_explicit_prompt_cache(&mut request);
request.system_prompt = Some("stable instructions".to_string());
request.messages = vec![
ModelMessage {
role: MessageRole::User,
content: vec![
ContentBlock::Text {
text: "stable conversation prefix".to_string(),
},
ContentBlock::CachePoint {
hint: bcode_model::PromptCachePoint {
label: Some("conversation_prefix".to_string()),
ttl_seconds: Some(30 * 60),
},
},
],
},
ModelMessage {
role: MessageRole::User,
content: vec![ContentBlock::Text {
text: "changing tail".to_string(),
}],
},
];
let value = build_mantle_openai_request(&request, "test.explicit-cache-model")
.expect("request should build");
assert!(value.get("instructions").is_none());
assert_eq!(value["prompt_cache_options"]["mode"], "explicit");
assert_eq!(value["prompt_cache_options"]["ttl"], "30m");
assert_eq!(value["input"][0]["role"], "developer");
assert_eq!(
value["input"][0]["content"][0]["text"],
"stable instructions"
);
assert_eq!(
value["input"][0]["content"][0]["prompt_cache_breakpoint"]["mode"],
"explicit"
);
assert_eq!(
value["input"][1]["content"][0]["prompt_cache_breakpoint"]["mode"],
"explicit"
);
assert_eq!(value["input"][2]["content"][0]["text"], "changing tail");
assert!(
value["input"][2]["content"][0]
.get("prompt_cache_breakpoint")
.is_none()
);
}
#[test]
fn mantle_openai_explicit_cache_requires_declared_capability() {
let mut request = test_model_turn_request();
request.prompt_cache.mode = bcode_model::PromptCacheMode::Auto;
request.system_prompt = Some("stable instructions".to_string());
let value = build_mantle_openai_request(&request, "test.gpt-5.6-unit")
.expect("request should build");
assert_eq!(value["instructions"], "stable instructions");
assert!(value.get("prompt_cache_options").is_none());
assert!(value.get("prompt_cache_key").is_none());
}
#[test]
fn mantle_openai_auto_uses_explicit_cache_when_declared() {
let mut request = test_model_turn_request();
request.prompt_cache.mode = bcode_model::PromptCacheMode::Auto;
request.prompt_cache.key = Some("bcode:auto-session".to_string());
request.system_prompt = Some("stable instructions".to_string());
request.metadata.insert(
"model_cache_capabilities".to_string(),
"prompt_cache_key,explicit_cache_points,cache_usage_reporting".to_string(),
);
let value = build_mantle_openai_request(&request, "unrecognized-model-name")
.expect("request should build");
assert!(value.get("instructions").is_none());
assert_eq!(value["prompt_cache_options"]["mode"], "explicit");
assert_eq!(value["prompt_cache_key"], "bcode:auto-session");
assert_eq!(
value["input"][0]["content"][0]["prompt_cache_breakpoint"]["mode"],
"explicit"
);
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn custom_gateway_responses_route_reuses_stable_prefix() {
use std::io::{Read as _, Write as _};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
let model_id = "catalog-responses-model";
let listener = TcpListener::bind("127.0.0.1:0").expect("test gateway binds");
let address = listener.local_addr().expect("test gateway address");
let captured = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
let server_captured = Arc::clone(&captured);
let server = std::thread::spawn(move || {
for request_index in 0..2 {
let (mut stream, _) = listener.accept().expect("gateway request accepts");
let mut bytes = Vec::new();
let mut buffer = [0_u8; 4096];
let header_end = loop {
let count = stream.read(&mut buffer).expect("gateway request reads");
assert!(count > 0, "request ended before headers");
bytes.extend_from_slice(&buffer[..count]);
if let Some(position) =
bytes.windows(4).position(|window| window == b"\r\n\r\n")
{
break position + 4;
}
};
let headers = String::from_utf8_lossy(&bytes[..header_end]);
assert!(headers.starts_with("POST /gw/v1/responses HTTP/1.1"));
assert!(
headers
.to_ascii_lowercase()
.contains("authorization: bearer test-token")
);
let content_length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length: ")
.map(str::to_owned)
})
.and_then(|value| value.trim().parse::<usize>().ok())
.expect("content length");
while bytes.len() - header_end < content_length {
let count = stream.read(&mut buffer).expect("gateway body reads");
assert!(count > 0, "request ended before body");
bytes.extend_from_slice(&buffer[..count]);
}
let body: serde_json::Value =
serde_json::from_slice(&bytes[header_end..header_end + content_length])
.expect("Responses body decodes");
assert_eq!(body["model"], model_id);
assert_eq!(body["prompt_cache_options"]["mode"], "explicit");
assert!(
body["prompt_cache_key"]
.as_str()
.is_some_and(|key| !key.is_empty())
);
assert_eq!(body["store"], false);
server_captured.lock().expect("capture lock").push(body);
let details = if request_index == 0 {
serde_json::json!({"cached_tokens": 0, "cache_write_tokens": 9000})
} else {
serde_json::json!({"cached_tokens": 9000, "cache_write_tokens": 0})
};
let event = serde_json::json!({
"type": "response.completed",
"response": {
"usage": {
"input_tokens": 10000 + request_index,
"output_tokens": 10,
"total_tokens": 10010 + request_index,
"input_tokens_details": details
}
}
});
let payload = format!("data: {event}\n\n");
write!(
stream,
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
payload.len(),
payload
)
.expect("gateway response writes");
}
});
let mut request = test_model_turn_request();
request.model_id = model_id.to_string();
request.provider_context.api_surface = Some(bcode_model::ModelApiSurface::Responses);
request.provider_context.settings.insert(
"mantle_base_url".to_string(),
format!("http://{address}/gw"),
);
request.provider_context.auth = Some(bcode_model::ProviderAuthContext {
credentials: BTreeMap::from([(
"bearer_token".to_string(),
bcode_model::ProviderAuthCredential {
value: "test-token".to_string(),
source: Some("test".to_string()),
},
)]),
..bcode_model::ProviderAuthContext::default()
});
enable_explicit_prompt_cache(&mut request);
request.system_prompt = Some("stable prefix ".repeat(1_000));
let cache = Arc::new(Mutex::new(DiscoveryCache::default()));
let mut usages = Vec::new();
for suffix in ["first suffix", "second suffix"] {
request.messages = vec![ModelMessage {
role: MessageRole::User,
content: vec![ContentBlock::Text {
text: suffix.to_string(),
}],
}];
let turn = TurnState::default();
assert_eq!(
stream_bedrock_turn_inner(&request, &turn, Arc::clone(&cache))
.await
.expect("gateway Responses turn succeeds"),
StreamOutcome::Finished
);
let usage = turn
.drain()
.into_iter()
.find_map(|event| match event {
ProviderTurnEvent::Usage { usage } => Some(usage),
_ => None,
})
.expect("turn reports usage");
usages.push(usage);
}
server.join().expect("test gateway joins");
assert_eq!(usages[0].cache_write_input_tokens, Some(9000));
assert_eq!(usages[0].cached_input_tokens, Some(0));
assert_eq!(usages[1].cached_input_tokens, Some(9000));
assert_eq!(usages[1].cache_write_input_tokens, Some(0));
let (first_key, second_key, first_input, second_input) = {
let bodies = captured.lock().expect("capture lock");
(
bodies[0]["prompt_cache_key"].clone(),
bodies[1]["prompt_cache_key"].clone(),
bodies[0]["input"].clone(),
bodies[1]["input"].clone(),
)
};
assert_eq!(first_key, second_key);
assert_ne!(first_input, second_input);
}
#[tokio::test]
#[ignore = "requires live Bedrock Mantle credentials and incurs model usage"]
#[allow(clippy::too_many_lines)]
async fn live_mantle_openai_reuses_explicit_stable_prefix() {
fn ordinary_input(usage: &TokenUsage) -> u32 {
usage
.input_tokens
.unwrap_or_default()
.saturating_sub(usage.cached_input_tokens.unwrap_or_default())
.saturating_sub(usage.cache_write_input_tokens.unwrap_or_default())
}
fn breakpoint_count(value: &serde_json::Value) -> usize {
match value {
serde_json::Value::Array(values) => values.iter().map(breakpoint_count).sum(),
serde_json::Value::Object(values) => {
usize::from(values.contains_key("prompt_cache_breakpoint"))
+ values.values().map(breakpoint_count).sum::<usize>()
}
_ => 0,
}
}
fn percent(numerator: u32, denominator: u32) -> f64 {
if denominator == 0 {
0.0
} else {
f64::from(numerator) * 100.0 / f64::from(denominator)
}
}
async fn send(endpoint: &str, token: &str, body: serde_json::Value) -> TokenUsage {
let response = reqwest::Client::new()
.post(endpoint)
.bearer_auth(token)
.header("accept", "text/event-stream")
.json(&body)
.send()
.await
.expect("live cache request should send");
let status = response.status();
let text = response.text().await.expect("response body should read");
assert!(
status.is_success(),
"live cache request failed: {status}: {text}"
);
text.lines()
.filter_map(|line| line.strip_prefix("data: "))
.filter_map(|data| serde_json::from_str::<serde_json::Value>(data).ok())
.find_map(|event| mantle_openai_usage(&event, None, None))
.expect("terminal response should report usage")
}
let token = std::env::var("AWS_BEARER_TOKEN_BEDROCK")
.expect("AWS_BEARER_TOKEN_BEDROCK is required");
let base = std::env::var("AWS_ENDPOINT_URL_BEDROCK")
.expect("AWS_ENDPOINT_URL_BEDROCK is required");
let trimmed = base.trim_end_matches('/');
let endpoint = if trimmed.ends_with("/v1/responses") {
base
} else if trimmed.contains("bedrock-mantle.") && trimmed.ends_with("/openai/v1") {
format!("{trimmed}/responses")
} else {
format!("{trimmed}/v1/responses")
};
let model = std::env::var("AWS_BEDROCK_OPENAI_MODEL")
.expect("AWS_BEDROCK_OPENAI_MODEL is required");
let mut request = test_model_turn_request();
enable_explicit_prompt_cache(&mut request);
request.prompt_cache.cache_system_prompt = true;
let cache_run_id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock follows Unix epoch")
.as_nanos();
request.prompt_cache.key = Some(format!(
"bcode:live-cache-efficiency:{}:{cache_run_id}",
std::process::id()
));
request.model_id.clone_from(&model);
request.system_prompt = Some("stable cache acceptance instruction. ".repeat(1_500));
request.messages = vec![
ModelMessage {
role: MessageRole::User,
content: vec![
ContentBlock::Text {
text: "stable conversation prefix one. ".repeat(300),
},
ContentBlock::CachePoint {
hint: bcode_model::PromptCachePoint {
label: Some("stable-prefix-one".to_string()),
ttl_seconds: Some(30 * 60),
},
},
],
},
ModelMessage {
role: MessageRole::User,
content: vec![
ContentBlock::Text {
text: "stable conversation prefix two. ".repeat(300),
},
ContentBlock::CachePoint {
hint: bcode_model::PromptCachePoint {
label: Some("stable-prefix-two".to_string()),
ttl_seconds: Some(30 * 60),
},
},
],
},
ModelMessage {
role: MessageRole::User,
content: vec![ContentBlock::Text {
text: "first changing suffix".to_string(),
}],
},
];
let first_body =
build_mantle_openai_request(&request, &model).expect("first request should build");
assert_eq!(first_body["prompt_cache_options"]["mode"], "explicit");
assert_eq!(first_body["prompt_cache_options"]["ttl"], "30m");
assert_eq!(
breakpoint_count(&first_body),
3,
"developer and two rolling conversation breakpoints must reach the wire"
);
let first_cache_key = first_body["prompt_cache_key"].clone();
let first = send(&endpoint, &token, first_body).await;
assert!(
first.cache_write_input_tokens.unwrap_or_default() > 0,
"first request must write an eligible prefix: {first:?}"
);
request
.messages
.last_mut()
.expect("changing suffix message")
.content = vec![ContentBlock::Text {
text: "second changing suffix".to_string(),
}];
let second_body =
build_mantle_openai_request(&request, &model).expect("second request should build");
assert_eq!(breakpoint_count(&second_body), 3);
assert_eq!(
first_cache_key, second_body["prompt_cache_key"],
"same stable prefix must retain one cache identity"
);
let second = send(&endpoint, &token, second_body).await;
let first_written = first.cache_write_input_tokens.unwrap_or_default();
let first_read = first.cached_input_tokens.unwrap_or_default();
let first_total = first.input_tokens.unwrap_or_default();
let first_ordinary = ordinary_input(&first);
assert_eq!(
first_read, 0,
"unique initial cache identity must start cold: {first:?}"
);
assert!(
first_written >= first_total.saturating_mul(95) / 100,
"cold initial request must write at least 95% of its input: write={first_written}, total={first_total}, first={first:?}"
);
assert!(
first_ordinary <= first_total / 20,
"cold initial request ordinary remainder must be at most 5%: ordinary={first_ordinary}, total={first_total}, first={first:?}"
);
let second_read = second.cached_input_tokens.unwrap_or_default();
let second_written = second.cache_write_input_tokens.unwrap_or_default();
let second_total = second.input_tokens.unwrap_or_default();
let second_ordinary = ordinary_input(&second);
let write_reuse_floor = first_written.saturating_mul(95) / 100;
assert!(
second_read >= write_reuse_floor,
"same-prefix follow-up must reuse at least 95% of the initial cache write: first_write={first_written}, second_read={second_read}, first={first:?}, second={second:?}"
);
assert!(
second_written <= first_written / 20,
"same-prefix follow-up must not rewrite more than 5% of the initial cached prefix: first_write={first_written}, second_write={second_written}, second={second:?}"
);
assert!(
second_ordinary <= second_total / 20,
"same-prefix follow-up ordinary input must be at most 5% of total input: ordinary={second_ordinary}, total={second_total}, second={second:?}"
);
assert!(
percent(second_read, second_total) >= 90.0,
"same-prefix follow-up cache hit rate must be at least 90%: read={second_read}, total={second_total}, second={second:?}"
);
let input_rate = 4_400_000_u64;
let cache_read_rate = 440_000_u64;
let second_effective_input_cost = u64::from(second_ordinary)
.saturating_mul(input_rate)
.saturating_add(u64::from(second_read).saturating_mul(cache_read_rate))
.saturating_add(u64::from(second_written).saturating_mul(5_500_000));
let second_uncached_input_cost = u64::from(second_total).saturating_mul(input_rate);
assert!(
second_effective_input_cost * 2 < second_uncached_input_cost,
"cache reuse must reduce modeled follow-up input cost by more than 50%: effective={second_effective_input_cost}, uncached={second_uncached_input_cost}"
);
let base_read = second_read;
request.messages.insert(
request.messages.len() - 1,
ModelMessage {
role: MessageRole::Assistant,
content: vec![ContentBlock::ToolCall {
call: ToolCall {
id: "live-cache-tool-call".to_string(),
name: "filesystem.read".to_string(),
arguments: serde_json::json!({"path":"stable.rs"}),
},
}],
},
);
request.messages.insert(
request.messages.len() - 1,
ModelMessage {
role: MessageRole::Tool,
content: vec![
ContentBlock::ToolResult {
result: bcode_model::ToolResult {
call_id: "live-cache-tool-call".to_string(),
output: "stable completed tool output. ".repeat(500),
is_error: false,
content: Vec::new(),
},
},
ContentBlock::CachePoint {
hint: bcode_model::PromptCachePoint {
label: Some("completed-tool-prefix".to_string()),
ttl_seconds: Some(30 * 60),
},
},
],
},
);
let expanded_body = build_mantle_openai_request(&request, &model)
.expect("expanded tool-prefix request should build");
assert!(expanded_body["input"].as_array().is_some_and(|items| {
items.iter().any(|item| {
item["type"] == "message"
&& item["content"][0]["prompt_cache_breakpoint"]["mode"] == "explicit"
&& item["content"][0]["text"] == "Tool results received."
})
}));
let expanded = send(&endpoint, &token, expanded_body).await;
let expanded_write = expanded.cache_write_input_tokens.unwrap_or_default();
assert!(
expanded_write > 0,
"a newly completed tool prefix must extend the cache: {expanded:?}"
);
request.messages.last_mut().expect("mutable tail").content = vec![ContentBlock::Text {
text: "third changing suffix".to_string(),
}];
let expanded_follow_up = send(
&endpoint,
&token,
build_mantle_openai_request(&request, &model).expect("expanded follow-up should build"),
)
.await;
let expanded_read = expanded_follow_up.cached_input_tokens.unwrap_or_default();
assert!(
expanded_read >= base_read.saturating_add(expanded_write.saturating_mul(95) / 100),
"follow-up must read the expanded tool-loop prefix: base_read={base_read}, expanded_write={expanded_write}, expanded_read={expanded_read}, expanded={expanded:?}, follow_up={expanded_follow_up:?}"
);
println!(
"request,total_input,cache_read,cache_write,ordinary_input,hit_rate\ninitial,{},{},{},{},{:.2}%\nfollow_up,{},{},{},{},{:.2}%",
first_total,
first_read,
first_written,
first_ordinary,
percent(first_read, first_total),
second_total,
second_read,
second_written,
second_ordinary,
percent(second_read, second_total),
);
request.prompt_cache.key = Some(format!(
"bcode:live-cache-isolation:{}:{cache_run_id}",
std::process::id()
));
let isolated = send(
&endpoint,
&token,
build_mantle_openai_request(&request, &model)
.expect("isolated-key request should build"),
)
.await;
let isolated_total = isolated.input_tokens.unwrap_or_default();
let isolated_read = isolated.cached_input_tokens.unwrap_or_default();
let isolated_write = isolated.cache_write_input_tokens.unwrap_or_default();
assert_eq!(
isolated_read, 0,
"a changed cache identity must not read the prior prefix: {isolated:?}"
);
assert!(
isolated_write >= write_reuse_floor,
"a changed cache identity must rewrite at least 95% of the independently cacheable prefix: initial_write={first_written}, isolated_write={isolated_write}, isolated={isolated:?}"
);
println!(
"isolated,{isolated_total},{isolated_read},{isolated_write},{},{:.2}%",
ordinary_input(&isolated),
percent(isolated_read, isolated_total),
);
}
#[test]
fn mantle_openai_request_projects_tools_with_bedrock_names() {
let mut request = test_model_turn_request();
request.tools = vec![ToolDefinition {
name: "filesystem.read".to_string(),
description: "read a file".to_string(),
input_schema: serde_json::json!({"type": "object"}),
}];
let value =
build_mantle_openai_request(&request, "openai.test-model").expect("request builds");
let tools = value["tools"].as_array().expect("tools is an array");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0]["name"], "filesystem_read");
assert_eq!(tools[0]["type"], "function");
assert_eq!(value["tool_choice"], "auto");
}
#[test]
fn mantle_openai_request_rejects_tool_choice_without_tools() {
let mut request = test_model_turn_request();
request.tool_call_policy.choice = ToolChoice::Required;
assert_eq!(
build_mantle_openai_request(&request, "openai.test-model")
.unwrap_err()
.code,
"tool_choice_without_tools"
);
}
#[test]
fn mantle_openai_request_carries_reasoning_and_structured_output() {
let mut request = test_model_turn_request();
request.parameters.reasoning_effort_value = Some("xhigh".to_string());
request.parameters.reasoning_summary = Some("detailed".to_string());
request.structured_output = Some(bcode_model::StructuredOutputRequest {
name: "answer".to_string(),
schema: serde_json::json!({"type": "object"}),
strict: true,
});
let value =
build_mantle_openai_request(&request, "openai.test-model").expect("request builds");
assert_eq!(value["reasoning"]["effort"], "xhigh");
assert_eq!(value["reasoning"]["summary"], "detailed");
assert_eq!(value["text"]["format"]["type"], "json_schema");
assert_eq!(value["text"]["format"]["name"], "answer");
assert_eq!(value["text"]["format"]["strict"], true);
}
#[test]
fn responses_surface_permits_features_converse_cannot_serve() {
let mut request = test_model_turn_request();
request.parameters.reasoning_summary = Some("detailed".to_string());
request.structured_output = Some(bcode_model::StructuredOutputRequest {
name: "answer".to_string(),
schema: serde_json::json!({"type": "object"}),
strict: false,
});
request.tool_call_policy.parallel = Some(true);
validate_bedrock_request(&request, true)
.expect("the Responses surface supports these features");
for transport in [BedrockTransport::Runtime, BedrockTransport::MantleAnthropic] {
assert!(
validate_bedrock_request(&request, false).is_err(),
"{transport:?} must still reject Converse-unsupported features"
);
}
}
#[test]
fn mantle_openai_stream_reports_text_then_completion() {
let turn = TurnState::default();
let sink = MantleTurnEventSink(&turn);
let mut state = MantleOpenAiStreamState::default();
let name_map = BTreeMap::new();
assert!(
process_mantle_openai_line(
r#"data: {"type":"response.output_text.delta","delta":"hi"}"#,
&sink,
&mut state,
&name_map,
None,
None,
)
.expect("delta decodes")
.is_none()
);
let outcome = process_mantle_openai_line(
r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":11,"output_tokens":5,"total_tokens":16,"output_tokens_details":{"reasoning_tokens":3}}}}"#,
&sink,
&mut state,
&name_map,
None,
None,
)
.expect("completion decodes")
.expect("completion is terminal");
assert_eq!(outcome, StreamOutcome::Finished);
let events = turn.drain();
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::TextDelta { text } if text == "hi"
)));
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::Usage { usage }
if usage.input_tokens == Some(11)
&& usage.output_tokens == Some(5)
&& usage.reasoning_tokens == Some(3)
)));
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ExactRequestInputTokens { tokens } if tokens.get() == 11
)));
}
#[test]
fn mantle_openai_cached_usage_does_not_double_count_exact_input() {
let turn = TurnState::default();
let sink = MantleTurnEventSink(&turn);
let mut state = MantleOpenAiStreamState::default();
let outcome = process_mantle_openai_line(
r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":492,"output_tokens":5,"input_tokens_details":{"cached_tokens":400}}}}"#,
&sink,
&mut state,
&BTreeMap::new(),
None,
None,
)
.expect("completion decodes")
.expect("completion is terminal");
assert_eq!(outcome, StreamOutcome::Finished);
let events = turn.drain();
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::Usage { usage }
if usage.input_tokens == Some(492) && usage.cached_input_tokens == Some(400)
)));
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ExactRequestInputTokens { tokens } if tokens.get() == 492
)));
}
#[test]
fn mantle_openai_usage_splits_cache_billing_buckets() {
let assert_buckets =
|cached, cache_write, expected: &[(bcode_model::ModelPricingBucket, u32)]| {
let event = serde_json::json!({
"response": {
"usage": {
"input_tokens": 1_000,
"output_tokens": 25,
"input_tokens_details": {
"cached_tokens": cached,
"cache_write_tokens": cache_write
}
}
}
});
let usage = mantle_openai_usage(&event, None, Some("30m")).expect("usage decodes");
assert!(usage.has_valid_input_breakdown());
assert_eq!(usage.pricing_context.request_input_tokens, Some(1_000));
let actual = usage
.details
.iter()
.map(|detail| (detail.bucket, detail.tokens))
.collect::<Vec<_>>();
assert_eq!(actual, expected);
};
assert_buckets(
0,
900,
&[
(bcode_model::ModelPricingBucket::Input, 100),
(bcode_model::ModelPricingBucket::CacheWriteInput, 900),
(bcode_model::ModelPricingBucket::Output, 25),
],
);
assert_buckets(
900,
0,
&[
(bcode_model::ModelPricingBucket::Input, 100),
(bcode_model::ModelPricingBucket::CacheReadInput, 900),
(bcode_model::ModelPricingBucket::Output, 25),
],
);
assert_buckets(
600,
300,
&[
(bcode_model::ModelPricingBucket::Input, 100),
(bcode_model::ModelPricingBucket::CacheReadInput, 600),
(bcode_model::ModelPricingBucket::CacheWriteInput, 300),
(bcode_model::ModelPricingBucket::Output, 25),
],
);
let malformed = serde_json::json!({
"response": {
"usage": {
"input_tokens": 100,
"output_tokens": 1,
"input_tokens_details": {
"cached_tokens": 60,
"cache_write_tokens": 50
}
}
}
});
let usage = mantle_openai_usage(&malformed, None, None).expect("usage decodes");
assert!(!usage.has_valid_input_breakdown());
assert!(usage.details.is_empty());
assert_eq!(usage.pricing_context.request_input_tokens, None);
}
#[test]
fn mantle_openai_stream_finishes_tool_calls_with_original_names() {
let turn = TurnState::default();
let sink = MantleTurnEventSink(&turn);
let mut state = MantleOpenAiStreamState::default();
let name_map =
BTreeMap::from([("filesystem_read".to_string(), "filesystem.read".to_string())]);
for line in [
r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"filesystem_read"}}"#,
r#"data: {"type":"response.function_call_arguments.done","output_index":0,"arguments":"{\"path\":\"a.rs\"}"}"#,
] {
assert!(
process_mantle_openai_line(line, &sink, &mut state, &name_map, None, None)
.expect("line decodes")
.is_none()
);
}
let outcome = process_mantle_openai_line(
r#"data: {"type":"response.completed","response":{}}"#,
&sink,
&mut state,
&name_map,
None,
None,
)
.expect("completion decodes")
.expect("completion is terminal");
assert_eq!(outcome, StreamOutcome::ToolCall);
let events = turn.drain();
assert!(
events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ToolCallFinished { call }
if call.id == "call_1"
&& call.name == "filesystem.read"
&& call.arguments == serde_json::json!({"path": "a.rs"})
)),
"tool call must be reported with its original name and decoded arguments: {events:?}"
);
}
#[test]
fn mantle_openai_stream_maps_truncation_and_failure() {
let turn = TurnState::default();
let sink = MantleTurnEventSink(&turn);
let name_map = BTreeMap::new();
let mut truncated = MantleOpenAiStreamState::default();
let outcome = process_mantle_openai_line(
r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
&sink,
&mut truncated,
&name_map,
None,
None,
)
.expect("incomplete decodes")
.expect("incomplete is terminal");
assert_eq!(outcome, StreamOutcome::MaxTokens);
let mut filtered = MantleOpenAiStreamState::default();
let error = process_mantle_openai_line(
r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"content_filter"}}}"#,
&sink,
&mut filtered,
&name_map,
None,
None,
)
.expect_err("unexpected incomplete reasons must fail");
assert_eq!(error.code, "bedrock_mantle_openai_response_incomplete");
assert!(error.message.contains("content_filter"));
let mut failed = MantleOpenAiStreamState::default();
let error = process_mantle_openai_line(
r#"data: {"type":"response.failed","response":{"id":"resp_1","error":{"code":"server_error","message":"boom"}}}"#,
&sink,
&mut failed,
&name_map,
None,
None,
)
.expect_err("failures must propagate");
assert_eq!(error.code, "server_error");
assert_eq!(error.message, "boom");
assert_eq!(error.request_id.as_deref(), Some("resp_1"));
}
#[test]
fn mantle_openai_endpoint_uses_the_documented_responses_path() {
let mut settings = test_settings();
assert_eq!(
mantle_endpoint(&settings, MantleFlavor::OpenAi).expect("default endpoint"),
"https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses"
);
settings.region = Some("eu-west-1".to_string());
assert_eq!(
mantle_endpoint(&settings, MantleFlavor::OpenAi).expect("regional endpoint"),
"https://bedrock-mantle.eu-west-1.api.aws/openai/v1/responses"
);
let mut local = settings;
local.mantle_base_url = Some("http://localhost:8080".to_string());
assert_eq!(
mantle_endpoint(&local, MantleFlavor::OpenAi).expect("local endpoint"),
"http://localhost:8080/v1/responses"
);
}
#[test]
fn mantle_endpoints_reject_insecure_non_loopback_base_urls() {
for flavor in [MantleFlavor::Anthropic, MantleFlavor::OpenAi] {
let mut settings = test_settings();
settings.mantle_base_url = Some("http://mantle.example.com/openai/v1".to_string());
assert_eq!(
mantle_endpoint(&settings, flavor).unwrap_err().code,
"bedrock_mantle_base_url_insecure"
);
}
}
#[test]
fn transport_parsing_covers_every_supported_value() {
for (value, expected) in [
(None, BedrockTransport::Runtime),
(Some("runtime"), BedrockTransport::Runtime),
(Some("bedrock_runtime"), BedrockTransport::Runtime),
(Some("mantle"), BedrockTransport::MantleAnthropic),
(Some("mantle_anthropic"), BedrockTransport::MantleAnthropic),
(Some("mantle_openai"), BedrockTransport::MantleOpenAi),
] {
let parsed = BedrockTransport::parse(value).expect("transport should parse");
assert_eq!(parsed, expected, "value: {value:?}");
assert_eq!(
BedrockTransport::parse(Some(parsed.as_str())).expect("round-trip"),
expected
);
}
assert_eq!(
BedrockTransport::parse(Some("mantle_bedrock"))
.unwrap_err()
.code,
"bedrock_transport_invalid"
);
}
#[test]
fn every_mantle_transport_requires_an_explicitly_configured_model() {
for transport in [
BedrockTransport::MantleAnthropic,
BedrockTransport::MantleOpenAi,
] {
assert!(
transport.is_mantle(),
"{transport:?} must be a Mantle transport"
);
let mut settings = test_settings();
settings.transport = Ok(transport);
settings.default_model = None;
settings.model_ids.clear();
assert_eq!(
validate_mantle_settings(&settings).unwrap_err().code,
"bedrock_mantle_model_required"
);
}
assert!(!BedrockTransport::Runtime.is_mantle());
}
#[test]
fn mantle_sse_decoder_handles_fragmented_multiline_events() {
let mut decoder = MantleSseDecoder::default();
assert!(
decoder
.push(b"event: message_start\r\ndata: {\"type\":")
.unwrap()
.is_empty()
);
let events = decoder
.push(b"\"message_start\",\r\ndata: \"message\":{}}\r\n\r\n")
.expect("fragmented event should decode");
assert_eq!(events.len(), 1);
assert_eq!(
serde_json::from_str::<serde_json::Value>(events[0].as_str().unwrap()).unwrap()["type"],
"message_start"
);
}
#[test]
fn mantle_settings_require_model_and_bearer_token() {
let mut settings = test_settings();
settings.transport = Ok(BedrockTransport::MantleAnthropic);
settings.default_model = None;
settings.model_ids.clear();
assert_eq!(
validate_mantle_settings(&settings).unwrap_err().code,
"bedrock_mantle_model_required"
);
settings.default_model = Some("anthropic.claude-opus-5".to_string());
assert_eq!(
validate_mantle_settings(&settings).unwrap_err().code,
"bedrock_mantle_missing_bearer_token"
);
settings
.auth_credentials
.insert("bearer_token".to_string(), "secret".to_string());
validate_mantle_settings(&settings).expect("valid Mantle settings");
let diagnostics = diagnostics_metadata(&settings);
assert_eq!(
diagnostics.get("transport").map(String::as_str),
Some("mantle_anthropic")
);
assert!(!diagnostics.values().any(|value| value.contains("secret")));
}
fn test_settings() -> Settings {
Settings {
transport: Ok(BedrockTransport::Runtime),
transport_explicit: false,
mantle_base_url: None,
mantle_auth_header: false,
force_http1: false,
default_model: Some("anthropic.claude-opus-5".to_string()),
model_ids: vec!["anthropic.claude-opus-5".to_string()],
model_ids_are_explicit: false,
region: Some(DEFAULT_REGION.to_string()),
region_source: RegionSource::Profile,
aws_profile: None,
endpoint_url: None,
auth_credentials: BTreeMap::new(),
env: BTreeMap::new(),
config_source: "test".to_string(),
}
}
#[test]
fn anthropic_messages_rejects_unsupported_tool_schema_with_tool_name() {
let mut request = test_model_turn_request();
request.tools = vec![ToolDefinition {
name: "custom.choice".to_string(),
description: "choose".to_string(),
input_schema: serde_json::json!({
"type": "object",
"oneOf": [{"required": ["left"]}, {"required": ["right"]}]
}),
}];
let error = build_anthropic_messages_request(&request, "model", Some(DEFAULT_REGION))
.expect_err("unsupported root combinator must fail locally");
assert_eq!(error.code, "bedrock_tool_schema_unsupported");
assert!(error.message.contains("custom.choice"));
assert!(error.message.contains("oneOf"));
}
#[test]
fn converse_rejects_unsupported_tool_schema_with_tool_name() {
let mut request = test_model_turn_request();
request.tools = vec![ToolDefinition {
name: "custom.choice".to_string(),
description: "choose".to_string(),
input_schema: serde_json::json!({"anyOf": [{"type": "object"}]}),
}];
let error = model_tools_to_bedrock_tool_config(&request, "anthropic.claude-opus-5")
.expect_err("unsupported root combinator must fail locally");
assert_eq!(error.code, "bedrock_tool_schema_unsupported");
assert!(error.message.contains("custom.choice"));
assert!(error.message.contains("anyOf"));
}
#[test]
fn anthropic_messages_request_omits_unsupported_strict_tool_property() {
let mut request = test_model_turn_request();
request.provider_context.api_surface = Some(bcode_model::ModelApiSurface::Messages);
request.tools = vec![ToolDefinition {
name: "filesystem.read".to_string(),
description: "read a file".to_string(),
input_schema: serde_json::json!({"type": "object"}),
}];
request.tool_schema_mode = Some(bcode_model::ToolSchemaMode::Strict);
let body = build_anthropic_messages_request_value(&request, "model", Some(DEFAULT_REGION))
.expect("Messages request should serialize without strict tool metadata");
assert!(
body["tools"]
.as_array()
.expect("tools")
.iter()
.all(|tool| tool.get("strict").is_none())
);
}
#[test]
fn anthropic_messages_request_serializes_tools_and_adaptive_thinking() {
let mut request = test_model_turn_request();
request.messages = vec![ModelMessage {
role: MessageRole::User,
content: vec![ContentBlock::Text {
text: "inspect the repository".to_string(),
}],
}];
request.parameters.max_output_tokens = Some(8_192);
request.parameters.reasoning_control = Some(bcode_model::ReasoningControl::Adaptive);
request.parameters.reasoning_effort_value = Some("high".to_string());
request.system_prompt = Some("system instructions".to_string());
request.prompt_cache.cache_system_prompt = true;
request.prompt_cache.cache_tools = true;
request.tools = vec![ToolDefinition {
name: "shell.run".to_string(),
description: "Run a shell command".to_string(),
input_schema: serde_json::json!({"type": "object"}),
}];
let value: serde_json::Value = serde_json::from_slice(
&build_anthropic_messages_request(&request, "model", Some(DEFAULT_REGION))
.expect("request should serialize"),
)
.expect("request should be JSON");
assert_eq!(value["anthropic_version"], "bedrock-2023-05-31");
assert_eq!(value["max_tokens"], 8_192);
assert_eq!(
value["messages"][0]["content"][0]["text"],
"inspect the repository"
);
assert_eq!(value["tools"][0]["name"], "shell_run");
assert_eq!(value["system"][0]["cache_control"]["type"], "ephemeral");
assert_eq!(value["tools"][0]["cache_control"]["type"], "ephemeral");
assert_eq!(value["thinking"]["type"], "adaptive");
assert_eq!(value["output_config"]["effort"], "high");
}
#[test]
fn anthropic_messages_request_serializes_inline_tool_result_image() {
let mut request = test_model_turn_request();
request.messages = vec![ModelMessage {
role: MessageRole::Tool,
content: vec![ContentBlock::ToolResult {
result: bcode_model::ToolResult {
call_id: "toolu_image".to_owned(),
output: "image".to_owned(),
is_error: false,
content: vec![bcode_model::ToolResultContent::Image {
image: bcode_model::ImageContent {
mime_type: "image/png".to_owned(),
data_base64: "AQID".to_owned(),
metadata: bcode_model::ImageMetadata::default(),
},
}],
},
}],
}];
let value: serde_json::Value = serde_json::from_slice(
&build_anthropic_messages_request(&request, "model", Some(DEFAULT_REGION))
.expect("request should serialize"),
)
.expect("request should be JSON");
assert_eq!(
value["messages"][0]["content"][0]["content"][1]["type"],
"image"
);
assert_eq!(
value["messages"][0]["content"][0]["content"][1]["source"]["data"],
"AQID"
);
}
#[test]
fn converse_request_serializes_inline_tool_result_image() {
let mut request = test_model_turn_request();
request.messages = vec![ModelMessage {
role: MessageRole::Tool,
content: vec![ContentBlock::ToolResult {
result: bcode_model::ToolResult {
call_id: "toolu_image".to_owned(),
output: "image".to_owned(),
is_error: false,
content: vec![bcode_model::ToolResultContent::Image {
image: bcode_model::ImageContent {
mime_type: "image/png".to_owned(),
data_base64: "AQID".to_owned(),
metadata: bcode_model::ImageMetadata::default(),
},
}],
},
}],
}];
let messages = model_messages_to_bedrock_messages(&request)
.expect("Converse request should serialize");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].content().len(), 1);
let BedrockContentBlock::ToolResult(result) = &messages[0].content()[0] else {
panic!("tool result block");
};
assert!(
result
.content()
.iter()
.any(|content| matches!(content, ToolResultContentBlock::Image(_)))
);
}
#[test]
fn anthropic_messages_request_serializes_tool_result_output() {
let mut request = test_model_turn_request();
request.messages = vec![ModelMessage {
role: MessageRole::Tool,
content: vec![ContentBlock::ToolResult {
result: bcode_model::ToolResult {
call_id: "toolu_1".to_string(),
output: "useful tool output".to_string(),
is_error: false,
content: Vec::new(),
},
}],
}];
let value: serde_json::Value = serde_json::from_slice(
&build_anthropic_messages_request(&request, "model", Some(DEFAULT_REGION))
.expect("request should serialize"),
)
.expect("request should be JSON");
assert_eq!(value["messages"][0]["role"], "user");
assert_eq!(
value["messages"][0]["content"][0],
serde_json::json!({
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": [{"type": "text", "text": "useful tool output"}],
"is_error": false,
})
);
}
#[test]
fn anthropic_messages_request_substitutes_placeholder_for_empty_error_tool_result() {
let mut request = test_model_turn_request();
request.messages = vec![ModelMessage {
role: MessageRole::Tool,
content: vec![ContentBlock::ToolResult {
result: bcode_model::ToolResult {
call_id: "toolu_err".to_string(),
output: String::new(),
is_error: true,
content: Vec::new(),
},
}],
}];
let value: serde_json::Value = serde_json::from_slice(
&build_anthropic_messages_request(&request, "model", Some(DEFAULT_REGION))
.expect("request should serialize"),
)
.expect("request should be JSON");
assert_eq!(
value["messages"][0]["content"][0],
serde_json::json!({
"type": "tool_result",
"tool_use_id": "toolu_err",
"content": [{"type": "text", "text": EMPTY_ERROR_TOOL_RESULT_PLACEHOLDER}],
"is_error": true,
})
);
}
#[test]
fn anthropic_messages_request_keeps_empty_content_for_successful_tool_result() {
let mut request = test_model_turn_request();
request.messages = vec![ModelMessage {
role: MessageRole::Tool,
content: vec![ContentBlock::ToolResult {
result: bcode_model::ToolResult {
call_id: "toolu_ok".to_string(),
output: String::new(),
is_error: false,
content: Vec::new(),
},
}],
}];
let value: serde_json::Value = serde_json::from_slice(
&build_anthropic_messages_request(&request, "model", Some(DEFAULT_REGION))
.expect("request should serialize"),
)
.expect("request should be JSON");
assert_eq!(
value["messages"][0]["content"][0],
serde_json::json!({
"type": "tool_result",
"tool_use_id": "toolu_ok",
"content": [],
"is_error": false,
})
);
}
#[test]
fn anthropic_messages_accumulator_emits_text_tools_and_usage() {
let turn = TurnState::default();
let mut accumulator = AnthropicMessagesAccumulator::new(
BTreeMap::from([("shell_run".to_string(), "shell.run".to_string())]),
None,
);
accumulator
.process(
&serde_json::json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}),
&turn,
)
.expect("text delta should process");
accumulator
.process(
&serde_json::json!({"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"shell_run","input":{}}}),
&turn,
)
.expect("tool start should process");
accumulator
.process(
&serde_json::json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\"pwd\"}"}}),
&turn,
)
.expect("tool delta should process");
accumulator
.process(
&serde_json::json!({"type":"content_block_stop","index":1}),
&turn,
)
.expect("tool stop should process");
accumulator
.process(
&serde_json::json!({"type":"message_start","message":{"usage":{"input_tokens":12,"output_tokens":0}}}),
&turn,
)
.expect("usage should process");
accumulator
.process(&serde_json::json!({"type":"message_stop"}), &turn)
.expect("message stop should process");
let events = turn.drain();
assert!(events.iter().any(
|event| matches!(event, ProviderTurnEvent::TextDelta { text } if text == "hello")
));
assert!(events.iter().any(|event| matches!(event, ProviderTurnEvent::ToolCallFinished { call } if call.name == "shell.run" && call.arguments["command"] == "pwd")));
assert!(events.iter().any(|event| matches!(event, ProviderTurnEvent::ExactRequestInputTokens { tokens } if tokens.get() == 12)));
}
#[test]
fn anthropic_messages_skips_empty_tool_input_deltas() {
let turn = TurnState::default();
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
accumulator
.process(
&serde_json::json!({"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"shell_run","input":{}}}),
&turn,
)
.expect("tool start should process");
accumulator
.process(
&serde_json::json!({"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}),
&turn,
)
.expect("empty delta should process");
accumulator
.process(
&serde_json::json!({"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\"pwd\"}"}}),
&turn,
)
.expect("tool delta should process");
let deltas = turn
.drain()
.into_iter()
.filter_map(|event| match event {
ProviderTurnEvent::ToolCallDelta { delta, .. } => Some(delta),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(deltas, vec!["{\"command\":\"pwd\"}".to_string()]);
}
#[test]
fn anthropic_messages_exact_input_tokens_include_cache_reads_and_writes() {
let turn = TurnState::default();
let mut accumulator = AnthropicMessagesAccumulator::new_with_pricing_context(
BTreeMap::new(),
None,
Some(300),
Some("global".to_string()),
);
accumulator
.process(
&serde_json::json!({"type":"message_start","message":{"usage":{
"input_tokens": 12,
"output_tokens": 0,
"cache_read_input_tokens": 400,
"cache_creation_input_tokens": 80
}}}),
&turn,
)
.expect("usage should process");
accumulator
.process(&serde_json::json!({"type":"message_stop"}), &turn)
.expect("message stop should process");
let events = turn.drain();
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ExactRequestInputTokens { tokens } if tokens.get() == 492
)));
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::Usage { usage }
if usage.input_tokens == Some(492)
&& usage.uncached_input_tokens() == Some(12)
&& usage.cached_input_tokens == Some(400)
&& usage.cache_write_input_tokens == Some(80)
&& usage.pricing_context.request_input_tokens == Some(492)
&& usage.pricing_context.cache_ttl_seconds == Some(300)
&& usage.pricing_context.service_tier.as_deref() == Some("standard")
&& usage.details.iter().any(|detail|
detail.bucket == bcode_model::ModelPricingBucket::CacheWriteInput
&& detail.tokens == 80
&& detail.cache_ttl_seconds == Some(300))
)));
}
#[test]
fn converse_capture_is_explicitly_sdk_limited_and_replays_known_usage() {
let usage = aws_sdk_bedrockruntime::types::TokenUsage::builder()
.input_tokens(10)
.output_tokens(2)
.total_tokens(12)
.cache_read_input_tokens(20)
.cache_write_input_tokens(30)
.build()
.unwrap();
let original = capture_converse_sdk_usage(&usage);
assert_eq!(
original.capture_issue,
Some(bcode_session_models::UsageCaptureIssue::SdkFieldsOnly)
);
let normalized = normalize_original_usage(&original).unwrap();
assert_eq!(normalized.input_tokens, Some(60));
assert_eq!(normalized.cache_write_input_tokens, Some(30));
}
#[test]
fn messages_wire_capture_preserves_large_unknown_numbers() {
let turn = TurnState::default();
turn.enable_original_usage();
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
accumulator.process_json(r#"{"type":"message_start","message":{"usage":{"input_tokens":10,"output_tokens":0,"unknown_counter":123456789012345678901234567890,"fraction":1.2300}}}"#,&turn).unwrap();
accumulator
.process_json(r#"{"type":"message_stop"}"#, &turn)
.unwrap();
let events = turn.drain();
let original = events
.iter()
.find_map(|event| match event {
ProviderTurnEvent::OriginalUsage { original } => Some(original),
_ => None,
})
.unwrap();
assert!(
original.reports[0]
.usage_json
.contains("123456789012345678901234567890")
);
assert!(original.reports[0].usage_json.contains("1.2300"));
}
#[test]
fn original_messages_reports_replay_without_merging_away_unknown_fields() {
let turn = TurnState::default();
turn.enable_original_usage();
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
for event in [
serde_json::json!({"type":"message_start","message":{"usage":{"input_tokens":5,"output_tokens":0,"cache_read_input_tokens":20,"cache_creation_input_tokens":30,"future_count":17}}}),
serde_json::json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}),
serde_json::json!({"type":"message_stop"}),
] {
accumulator.process(&event, &turn).unwrap();
}
let events = turn.drain();
let original = events
.iter()
.find_map(|event| match event {
ProviderTurnEvent::OriginalUsage { original } => Some(original),
_ => None,
})
.unwrap();
let live = events
.iter()
.find_map(|event| match event {
ProviderTurnEvent::Usage { usage } => Some(usage),
_ => None,
})
.unwrap();
assert_eq!(original.reports.len(), 2);
assert!(original.reports[0].usage_json.contains("future_count"));
assert_eq!(&normalize_original_usage(original).unwrap(), live);
}
#[test]
fn interrupted_anthropic_usage_preserves_input_without_claiming_final_output() {
let turn = TurnState::default();
{
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
accumulator.process(&serde_json::json!({"type":"message_start", "message":{"usage":{
"input_tokens":12,"output_tokens":0,"cache_read_input_tokens":400,"cache_creation_input_tokens":80
}}}), &turn).unwrap();
}
let usages = turn
.drain()
.into_iter()
.filter_map(|event| match event {
ProviderTurnEvent::Usage { usage } => Some(usage),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].input_tokens, Some(492));
assert_eq!(usages[0].output_tokens, None);
}
#[test]
fn anthropic_usage_preserves_mixed_cache_write_ttls() {
let turn = TurnState::default();
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
accumulator.process(&serde_json::json!({"type":"message_start", "message":{"usage":{
"input_tokens":12,"output_tokens":0,"cache_read_input_tokens":400,"cache_creation_input_tokens":80,
"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":50}
}}}), &turn).unwrap();
accumulator.process(&serde_json::json!({"type":"message_delta", "delta":{"stop_reason":"end_turn"}, "usage":{"output_tokens":7}}), &turn).unwrap();
accumulator
.process(&serde_json::json!({"type":"message_stop"}), &turn)
.unwrap();
drop(accumulator);
let usages = turn
.drain()
.into_iter()
.filter_map(|event| match event {
ProviderTurnEvent::Usage { usage } => Some(usage),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(usages.len(), 1);
let writes = usages[0]
.details
.iter()
.filter(|detail| detail.bucket == bcode_model::ModelPricingBucket::CacheWriteInput)
.map(|detail| (detail.cache_ttl_seconds, detail.tokens))
.collect::<Vec<_>>();
assert_eq!(writes, vec![(Some(300), 30), (Some(3600), 50)]);
assert_eq!(usages[0].input_tokens, Some(492));
assert_eq!(usages[0].output_tokens, Some(7));
}
#[test]
fn anthropic_messages_omits_exact_input_tokens_without_input_count() {
let turn = TurnState::default();
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
accumulator
.process(
&serde_json::json!({"type":"message_start","message":{"usage":{
"cache_read_input_tokens": 400
}}}),
&turn,
)
.expect("usage should process");
assert!(
!turn
.drain()
.iter()
.any(|event| matches!(event, ProviderTurnEvent::ExactRequestInputTokens { .. }))
);
}
fn converse_metadata_event(
input_tokens: i32,
cache_read_input_tokens: Option<i32>,
cache_write_input_tokens: Option<i32>,
) -> ConverseStreamOutput {
let mut usage = aws_sdk_bedrockruntime::types::TokenUsage::builder()
.input_tokens(input_tokens)
.output_tokens(7)
.total_tokens(input_tokens.saturating_add(7));
if let Some(tokens) = cache_read_input_tokens {
usage = usage.cache_read_input_tokens(tokens);
}
if let Some(tokens) = cache_write_input_tokens {
usage = usage.cache_write_input_tokens(tokens);
}
ConverseStreamOutput::Metadata(
aws_sdk_bedrockruntime::types::ConverseStreamMetadataEvent::builder()
.usage(usage.build().expect("token usage should build"))
.build(),
)
}
fn converse_message_stop_event(stop_reason: BedrockStopReason) -> ConverseStreamOutput {
ConverseStreamOutput::MessageStop(
aws_sdk_bedrockruntime::types::MessageStopEvent::builder()
.stop_reason(stop_reason)
.build()
.expect("message stop event should build"),
)
}
#[test]
fn converse_metadata_after_message_stop_emits_usage_and_exact_input_tokens() {
let turn = TurnState::default();
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
assert!(
accumulator
.process_event(
converse_message_stop_event(BedrockStopReason::EndTurn),
&turn
)
.expect("message stop should process")
.is_none(),
"messageStop must not terminate the stream before the trailing metadata event"
);
let outcome = accumulator
.process_event(converse_metadata_event(47, None, None), &turn)
.expect("metadata should process")
.expect("metadata after messageStop should terminate the stream");
assert_eq!(outcome, StreamOutcome::Finished);
let events = turn.drain();
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::Usage { usage } if usage.input_tokens == Some(47)
)));
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ExactRequestInputTokens { tokens } if tokens.get() == 47
)));
}
#[test]
fn converse_metadata_uses_complete_input_for_pricing_thresholds() {
let turn = TurnState::default();
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
accumulator
.process_event(
converse_message_stop_event(BedrockStopReason::EndTurn),
&turn,
)
.expect("message stop should process");
accumulator
.process_event(converse_metadata_event(12, Some(400), Some(80)), &turn)
.expect("metadata should process");
assert!(turn.drain().iter().any(|event| matches!(
event,
ProviderTurnEvent::Usage { usage }
if usage.pricing_context.request_input_tokens == Some(492)
&& usage.pricing_context.service_tier.as_deref() == Some("standard")
)));
}
#[test]
fn anthropic_messages_truncated_tool_call_reports_max_tokens() {
let turn = TurnState::default();
let mut accumulator = AnthropicMessagesAccumulator::new(
BTreeMap::from([("shell_run".to_string(), "shell.run".to_string())]),
None,
);
accumulator
.process(
&serde_json::json!({
"type": "content_block_start",
"index": 0,
"content_block": {"type": "tool_use", "id": "toolu_trunc", "name": "shell_run"},
}),
&turn,
)
.expect("tool use start should process");
accumulator
.process(
&serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": "{\"command\": \"echo "},
}),
&turn,
)
.expect("partial arguments should process");
accumulator
.process(
&serde_json::json!({
"type": "message_delta",
"delta": {"stop_reason": "max_tokens"},
"usage": {"output_tokens": 4096},
}),
&turn,
)
.expect("message delta should process");
let outcome = accumulator
.process(&serde_json::json!({"type": "message_stop"}), &turn)
.expect("message stop should process")
.expect("message stop should terminate the stream");
assert_eq!(outcome, StreamOutcome::MaxTokens);
let events = turn.drain();
assert!(
!events
.iter()
.any(|event| matches!(event, ProviderTurnEvent::ToolCallFinished { .. })),
"a truncated tool call must not be reported as finished"
);
}
#[test]
fn anthropic_messages_completed_tool_call_still_reports_tool_call() {
let turn = TurnState::default();
let mut accumulator = AnthropicMessagesAccumulator::new(
BTreeMap::from([("shell_run".to_string(), "shell.run".to_string())]),
None,
);
accumulator
.process(
&serde_json::json!({
"type": "content_block_start",
"index": 0,
"content_block": {"type": "tool_use", "id": "toolu_ok", "name": "shell_run"},
}),
&turn,
)
.expect("tool use start should process");
accumulator
.process(
&serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": "{\"command\":\"ls\"}"},
}),
&turn,
)
.expect("arguments should process");
accumulator
.process(
&serde_json::json!({"type": "content_block_stop", "index": 0}),
&turn,
)
.expect("content block stop should process");
accumulator
.process(
&serde_json::json!({
"type": "message_delta",
"delta": {"stop_reason": "tool_use"},
}),
&turn,
)
.expect("message delta should process");
let outcome = accumulator
.process(&serde_json::json!({"type": "message_stop"}), &turn)
.expect("message stop should process")
.expect("message stop should terminate the stream");
assert_eq!(outcome, StreamOutcome::ToolCall);
let events = turn.drain();
assert_eq!(
events
.iter()
.filter(|event| matches!(event, ProviderTurnEvent::ToolCallFinished { .. }))
.count(),
1
);
}
#[test]
fn anthropic_messages_unknown_stop_reason_is_not_interpreted() {
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
accumulator.record_message_delta_stop_reason(&serde_json::json!({
"type": "message_delta",
"delta": {"stop_reason": "some_future_reason"},
}));
assert_eq!(accumulator.stop_reason, None);
assert_eq!(accumulator.finish(), StreamOutcome::Finished);
}
#[test]
fn anthropic_messages_stream_without_stop_reason_is_not_terminal() {
let turn = TurnState::default();
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
accumulator
.process(
&serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "partial answer"},
}),
&turn,
)
.expect("text delta should process");
assert!(
!accumulator.saw_terminal_stop_reason(),
"a stream with only text deltas has no terminal stop reason"
);
assert_eq!(accumulator.finish(), StreamOutcome::Finished);
accumulator.record_message_delta_stop_reason(&serde_json::json!({
"type": "message_delta",
"delta": {"stop_reason": "end_turn"},
}));
assert!(accumulator.saw_terminal_stop_reason());
}
#[test]
fn anthropic_messages_refusal_is_a_terminal_refusal() {
let turn = TurnState::default();
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
accumulator
.process(
&serde_json::json!({
"type": "message_delta",
"delta": {
"stop_reason": "refusal",
"stop_details": {"category": "reasoning_extraction"}
},
"usage": {"output_tokens": 0}
}),
&turn,
)
.expect("refusal delta should process");
assert!(accumulator.saw_terminal_stop_reason());
assert_eq!(accumulator.finish(), StreamOutcome::Refusal);
}
#[test]
fn converse_stream_without_message_stop_is_not_terminal() {
let turn = TurnState::default();
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
assert!(!accumulator.saw_terminal_stop_reason());
accumulator
.process_event(
converse_message_stop_event(BedrockStopReason::EndTurn),
&turn,
)
.expect("message stop should process");
assert!(
accumulator.saw_terminal_stop_reason(),
"messageStop is Converse's terminal marker"
);
}
#[test]
fn incomplete_stream_error_is_retryable_and_secret_safe() {
let error = bedrock_incomplete_stream_error("messages", true);
assert_eq!(error.code, "bedrock_messages_stream_incomplete");
assert_eq!(error.category, ProviderErrorCategory::Network);
assert!(
error.retryable,
"a cut-off stream must be retryable so the turn is recovered rather than truncated"
);
assert_eq!(
error
.diagnostic_context
.get("api_surface")
.map(String::as_str),
Some("messages")
);
assert_eq!(
error
.diagnostic_context
.get("saw_output")
.map(String::as_str),
Some("true")
);
assert!(error.provider_message.is_none());
}
#[test]
fn anthropic_messages_request_fails_closed_without_resolved_max_output_tokens() {
let mut request = test_model_turn_request();
request.provider_context.api_surface = Some(bcode_model::ModelApiSurface::Messages);
request.parameters.max_output_tokens = None;
let error = build_anthropic_messages_request_value(&request, "model", Some(DEFAULT_REGION))
.expect_err("an unresolved output limit must not fall back to a provider default");
assert_eq!(error.code, "bedrock_messages_max_output_tokens_unresolved");
assert_eq!(error.category, ProviderErrorCategory::InvalidRequest);
}
#[test]
fn anthropic_messages_request_sends_resolved_max_output_tokens() {
let mut request = test_model_turn_request();
request.provider_context.api_surface = Some(bcode_model::ModelApiSurface::Messages);
request.parameters.max_output_tokens = Some(128_000);
let body = build_anthropic_messages_request_value(&request, "model", Some(DEFAULT_REGION))
.expect("Messages request should build with a resolved limit");
assert_eq!(body["max_tokens"], 128_000);
}
#[test]
fn converse_truncated_tool_call_reports_max_tokens() {
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
accumulator.saw_tool_call = true;
accumulator.stop_reason = Some(StopReason::MaxTokens);
assert_eq!(accumulator.finish_outcome(), StreamOutcome::MaxTokens);
}
#[test]
fn converse_tool_call_turn_still_reports_usage_after_message_stop() {
let turn = TurnState::default();
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
accumulator.tool_calls.insert(
0,
ToolCallAccumulator {
id: Some("call-1".to_string()),
name: Some("shell_run".to_string()),
arguments: "{}".to_string(),
},
);
accumulator.saw_tool_call = true;
assert!(
accumulator
.process_event(
converse_message_stop_event(BedrockStopReason::ToolUse),
&turn
)
.expect("message stop should process")
.is_none()
);
let outcome = accumulator
.process_event(converse_metadata_event(31, None, None), &turn)
.expect("metadata should process")
.expect("metadata after messageStop should terminate the stream");
assert_eq!(outcome, StreamOutcome::ToolCall);
let events = turn.drain();
assert_eq!(
events
.iter()
.filter(|event| matches!(event, ProviderTurnEvent::ToolCallFinished { .. }))
.count(),
1
);
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ExactRequestInputTokens { tokens } if tokens.get() == 31
)));
}
#[test]
fn converse_duplicate_metadata_does_not_republish_usage() {
let turn = TurnState::default();
turn.enable_original_usage();
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
for _ in 0..2 {
accumulator
.process_event(converse_metadata_event(12, Some(400), Some(80)), &turn)
.unwrap();
}
let events = turn.drain();
assert_eq!(
events
.iter()
.filter(|event| matches!(event, ProviderTurnEvent::Usage { .. }))
.count(),
1
);
assert_eq!(
events
.iter()
.filter(|event| matches!(event, ProviderTurnEvent::OriginalUsage { .. }))
.count(),
1
);
}
#[test]
fn converse_exact_input_tokens_include_cache_reads_and_writes() {
let turn = TurnState::default();
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
accumulator
.process_event(converse_metadata_event(12, Some(400), Some(80)), &turn)
.expect("metadata should process");
let events = turn.drain();
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ExactRequestInputTokens { tokens } if tokens.get() == 492
)));
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::Usage { usage }
if usage.input_tokens == Some(492)
&& usage.cached_input_tokens == Some(400)
&& usage.cache_write_input_tokens == Some(80)
&& usage.pricing_context.request_input_tokens == Some(492)
&& usage.details.iter().any(|detail| {
detail.bucket == bcode_model::ModelPricingBucket::Input
&& detail.tokens == 12
})
)));
}
#[test]
fn converse_stream_truncated_after_message_stop_still_terminates() {
let turn = TurnState::default();
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
accumulator
.process_event(
converse_message_stop_event(BedrockStopReason::EndTurn),
&turn,
)
.expect("message stop should process");
assert_eq!(accumulator.finish_outcome(), StreamOutcome::Finished);
}
#[derive(Debug)]
struct DeterministicBedrockTurnExecutor;
impl BedrockTurnExecutor for DeterministicBedrockTurnExecutor {
fn start(
&self,
_runtime: &ProviderRuntime,
request: ModelTurnRequest,
turn: TurnState,
_discovery: Arc<Mutex<DiscoveryCache>>,
) {
if turn.is_cancelled() {
turn.push(ProviderTurnEvent::Cancelled);
turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::Cancelled,
});
return;
}
if matches!(request.tool_call_policy.choice, ToolChoice::Required) {
let tool_count = if request.tool_call_policy.parallel == Some(true) {
request.tools.len()
} else {
1
};
for (index, tool) in request.tools.iter().take(tool_count).enumerate() {
let call = ToolCall {
id: format!("bedrock-conformance-call-{index}"),
name: tool.name.clone(),
arguments: serde_json::json!({}),
};
turn.push(ProviderTurnEvent::ToolCallStarted {
call_id: call.id.clone(),
name: call.name.clone(),
});
turn.push(ProviderTurnEvent::ToolCallFinished { call });
}
turn.push(ProviderTurnEvent::Usage {
usage: TokenUsage::default(),
});
turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::ToolCall,
});
return;
}
turn.push(ProviderTurnEvent::TextDelta {
text: "bedrock conformance".to_string(),
});
turn.push(ProviderTurnEvent::Usage {
usage: TokenUsage {
input_tokens: Some(3),
output_tokens: Some(2),
total_tokens: Some(5),
..TokenUsage::default()
},
});
turn.push(ProviderTurnEvent::TurnFinished {
stop_reason: StopReason::EndTurn,
});
}
}
struct BedrockPluginInvoker {
plugin: BedrockProviderPlugin,
}
impl Default for BedrockPluginInvoker {
fn default() -> Self {
Self {
plugin: BedrockProviderPlugin {
turns: Mutex::default(),
discovery: Arc::default(),
runtime: ProviderRuntime::new().map_err(|error| error.to_string()),
turn_executor: Arc::new(DeterministicBedrockTurnExecutor),
},
}
}
}
impl BlockingModelProviderInvoker for BedrockPluginInvoker {
fn invoke_json<Q, R>(
&mut self,
_provider_plugin_id: Option<&str>,
operation: &'static str,
request: &Q,
) -> Result<R, String>
where
Q: serde::Serialize,
R: serde::de::DeserializeOwned,
{
let response = self.plugin.invoke_service_concurrent(NativeServiceContext {
plugin_id: PROVIDER_ID.to_string(),
request: ServiceRequest {
interface_id: MODEL_PROVIDER_INTERFACE_ID_V2.to_string(),
operation: operation.to_string(),
payload: serde_json::to_vec(request).map_err(|error| error.to_string())?,
},
config: PluginConfigContext::default(),
events: ServiceEventEmitter::default(),
cancellation: ServiceCancellation::default(),
bridge: ServiceBridge::default(),
transient_progress_limits: bcode_plugin_sdk::TransientProgressLimits::default(),
});
if let Some(error) = response.error {
return Err(format!("{}: {}", error.code, error.message));
}
serde_json::from_slice(&response.payload).map_err(|error| error.to_string())
}
}
#[test]
fn bedrock_adapter_passes_public_deterministic_conformance_suite() {
let options = ProviderConformanceOptions {
provider_context: ProviderRequestContext {
settings: BTreeMap::from([
("model".to_string(), "test-model".to_string()),
("models".to_string(), "test-model".to_string()),
]),
..ProviderRequestContext::default()
},
model_id: Some("test-model".to_string()),
turn_timeout: Duration::from_secs(2),
..ProviderConformanceOptions::default()
};
let report = run_provider_conformance_suite(&mut BedrockPluginInvoker::default(), &options)
.expect("Bedrock adapter should satisfy deterministic conformance");
assert_eq!(report.provider.provider_id, PROVIDER_ID);
assert!(report.cases.iter().all(|case| {
case.outcome == ProviderConformanceOutcome::Passed
|| matches!(case.outcome, ProviderConformanceOutcome::Skipped { .. })
}));
for required_case in [
"baseline turn",
"tool calling",
"prompt caching",
"cancellation",
] {
assert!(report.cases.iter().any(|case| {
case.name == required_case && case.outcome == ProviderConformanceOutcome::Passed
}));
}
}
#[test]
fn bedrock_service_unavailable_is_overload_from_sdk_error() {
let error = ConverseStreamError::ServiceUnavailableException(
aws_sdk_bedrockruntime::types::error::ServiceUnavailableException::builder()
.message("service unavailable")
.build(),
);
assert_eq!(
bedrock_converse_stream_error_category(&error),
ProviderErrorCategory::Overloaded
);
}
#[test]
fn bedrock_throttling_remains_rate_limit_from_sdk_error() {
let error = ConverseStreamError::ThrottlingException(
aws_sdk_bedrockruntime::types::error::ThrottlingException::builder()
.message("too many requests")
.build(),
);
assert_eq!(
bedrock_converse_stream_error_category(&error),
ProviderErrorCategory::RateLimit
);
}
#[test]
fn bedrock_validation_exception_prompt_overflow_is_context_length() {
assert_eq!(
bedrock_validation_exception_category(Some(
"prompt is too long: 1000646 tokens > 1000000 maximum"
)),
ProviderErrorCategory::ContextLength
);
}
#[test]
fn bedrock_validation_exception_without_overflow_stays_invalid_request() {
assert_eq!(
bedrock_validation_exception_category(Some(
"The model returned the following errors: malformed input"
)),
ProviderErrorCategory::InvalidRequest
);
assert_eq!(
bedrock_validation_exception_category(None),
ProviderErrorCategory::InvalidRequest
);
}
#[test]
fn bedrock_converse_stream_validation_overflow_is_context_length() {
let error = ConverseStreamError::ValidationException(
aws_sdk_bedrockruntime::types::error::ValidationException::builder()
.message("prompt is too long: 1000646 tokens > 1000000 maximum")
.build(),
);
assert_eq!(
bedrock_converse_stream_error_category(&error),
ProviderErrorCategory::ContextLength
);
}
#[test]
fn bedrock_context_length_error_detects_reported_overflow_shapes() {
assert!(is_context_length_error(
"prompt is too long: 1000646 tokens > 1000000 maximum"
));
assert!(is_context_length_error(
"Input is too long for requested model."
));
assert!(!is_context_length_error(
"data retention mode 'default' is not available for this model"
));
}
#[test]
fn bedrock_overload_message_fallback_is_conservative() {
assert_eq!(
bedrock_error_category_from_message("ServiceUnavailableException: service unavailable"),
ProviderErrorCategory::Overloaded
);
assert_eq!(
bedrock_error_category_from_message("ThrottlingException: too many requests"),
ProviderErrorCategory::RateLimit
);
}
#[test]
fn json_document_round_trip_preserves_objects() {
let value = serde_json::json!({"path":"/tmp/file", "count": 2, "ok": true});
let document = json_value_to_document(&value);
assert_eq!(document_to_json_value(&document), value);
}
#[test]
fn bedrock_provider_cancel_turn_signals_active_adapter_state() {
let plugin = BedrockProviderPlugin::default();
let (provider_turn_id, turn) = plugin
.turns
.lock()
.expect("turn store")
.insert_started("cancel-me");
let response = plugin.cancel_turn(&ServiceRequest {
interface_id: MODEL_PROVIDER_INTERFACE_ID.to_owned(),
operation: OP_CANCEL_TURN.to_owned(),
payload: serde_json::to_vec(&CancelTurnRequest { provider_turn_id })
.expect("cancel request"),
});
assert!(response.error.is_none());
assert!(turn.is_cancelled());
}
#[test]
fn bedrock_reasoning_text_and_redaction_use_neutral_activity_events() {
let turn = TurnState::default();
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
accumulator.process_reasoning_delta(
2,
&ReasoningContentBlockDelta::Text("raw detail".to_owned()),
&turn,
);
accumulator.process_reasoning_delta(
2,
&ReasoningContentBlockDelta::RedactedContent(Blob::new("secret")),
&turn,
);
accumulator.finish_reasoning(&turn);
let events = turn.drain();
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::PartDelta {
kind: bcode_session_models::ReasoningContentKind::Raw,
text,
..
}
} if text == "raw detail"
)));
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::OpaqueObserved { .. }
}
)));
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::Finished {
status: bcode_session_models::ReasoningActivityStatus::Completed,
..
}
}
)));
assert!(
events
.iter()
.all(|event| !format!("{event:?}").contains("secret"))
);
}
#[test]
fn messages_reasoning_text_completes_authoritative_part() {
let turn = TurnState::default();
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
accumulator
.process(
&serde_json::json!({
"type": "content_block_start",
"index": 0,
"content_block": {"type": "thinking"},
}),
&turn,
)
.expect("thinking start should process");
accumulator
.process(
&serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": "step one "},
}),
&turn,
)
.expect("thinking delta should process");
accumulator
.process(
&serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": "step two"},
}),
&turn,
)
.expect("thinking delta should process");
accumulator
.process(
&serde_json::json!({"type": "content_block_stop", "index": 0}),
&turn,
)
.expect("thinking stop should process");
let events = turn.drain();
let deltas = events
.iter()
.filter_map(|event| match event {
ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::PartDelta { text, .. },
} => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
deltas,
vec!["step one ".to_string(), "step two".to_string()]
);
let completed = events
.iter()
.filter_map(|event| match event {
ProviderTurnEvent::ReasoningActivity {
event:
bcode_session_models::ReasoningActivityEvent::PartCompleted {
part_id,
kind,
text,
..
},
} => Some((part_id.clone(), *kind, text.clone())),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
completed,
vec![(
"raw-0".to_string(),
bcode_session_models::ReasoningContentKind::Raw,
"step one step two".to_string()
)],
"completion must carry the full accumulated text under the streamed part identity"
);
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::Finished {
status: bcode_session_models::ReasoningActivityStatus::Completed,
..
}
}
)));
}
#[test]
fn messages_redacted_thinking_reports_opaque_evidence_only() {
let turn = TurnState::default();
let mut accumulator = AnthropicMessagesAccumulator::new(BTreeMap::new(), None);
accumulator
.process(
&serde_json::json!({
"type": "content_block_start",
"index": 0,
"content_block": {"type": "redacted_thinking", "data": "secret-opaque-payload"},
}),
&turn,
)
.expect("redacted thinking start should process");
accumulator
.process(
&serde_json::json!({"type": "content_block_stop", "index": 0}),
&turn,
)
.expect("redacted thinking stop should process");
let events = turn.drain();
assert!(
events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::OpaqueObserved { .. }
}
)),
"redacted thinking must record opaque evidence"
);
assert!(
!events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::PartDelta { .. }
| bcode_session_models::ReasoningActivityEvent::PartCompleted { .. }
}
)),
"redacted thinking has no readable content and must not fabricate a part"
);
assert!(events.iter().any(|event| matches!(
event,
ProviderTurnEvent::ReasoningActivity {
event: bcode_session_models::ReasoningActivityEvent::Finished {
status: bcode_session_models::ReasoningActivityStatus::Completed,
..
}
}
)));
assert!(
events
.iter()
.all(|event| !format!("{event:?}").contains("secret-opaque-payload")),
"opaque provider bytes must never enter neutral reasoning events"
);
}
#[test]
fn tool_use_delta_emits_progress_event_when_call_id_is_known() {
let turn = TurnState::default();
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
accumulator.tool_calls.insert(
0,
ToolCallAccumulator {
id: Some("call-1".to_string()),
name: Some("filesystem_write".to_string()),
arguments: String::new(),
},
);
accumulator.process_tool_use_delta(0, "{\"path\"", &turn);
assert!(turn.drain().iter().any(|event| matches!(
event,
ProviderTurnEvent::ToolCallDelta { call_id, delta }
if call_id == "call-1" && delta == "{\"path\""
)));
}
#[test]
fn completed_tool_calls_preserve_bedrock_order_and_exact_ids() {
let turn = TurnState::default();
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
accumulator.tool_calls.extend([
(
1,
ToolCallAccumulator {
id: Some("bedrock-call-second".to_owned()),
name: Some("second_tool".to_owned()),
arguments: r#"{"position":2}"#.to_owned(),
},
),
(
0,
ToolCallAccumulator {
id: Some("bedrock-call-first".to_owned()),
name: Some("first_tool".to_owned()),
arguments: r#"{"position":1}"#.to_owned(),
},
),
]);
accumulator
.finish_tool_calls(&turn)
.expect("Bedrock tool calls should finish");
let completed = turn
.drain()
.into_iter()
.filter_map(|event| match event {
ProviderTurnEvent::ToolCallFinished { call } => Some(call),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
completed
.iter()
.map(|call| call.id.as_str())
.collect::<Vec<_>>(),
["bedrock-call-first", "bedrock-call-second"]
);
assert_eq!(completed[0].arguments["position"], 1);
assert_eq!(completed[1].arguments["position"], 2);
}
#[test]
fn malformed_bedrock_tool_call_is_rejected_without_partial_completion() {
let turn = TurnState::default();
let mut accumulator = StreamAccumulator::new(BTreeMap::new());
accumulator.tool_calls.insert(
0,
ToolCallAccumulator {
id: Some("bedrock-call-malformed".to_owned()),
name: Some("broken_tool".to_owned()),
arguments: r#"{"unterminated""#.to_owned(),
},
);
let error = accumulator
.finish_tool_calls(&turn)
.expect_err("malformed Bedrock arguments must fail");
assert_eq!(error.code, "tool_arguments_decode_failed");
assert!(
!turn
.drain()
.iter()
.any(|event| matches!(event, ProviderTurnEvent::ToolCallFinished { .. }))
);
}
#[test]
fn bedrock_tool_names_are_sanitized() {
assert_eq!(bedrock_tool_name("filesystem.read"), "filesystem_read");
}
#[test]
fn explicit_bedrock_model_infos_are_raw_provider_candidates() {
let models = model_infos_from_ids(
&["anthropic.claude-3-5-sonnet-20241022-v2:0".to_string()],
None,
);
assert_eq!(models[0].context_window, None);
assert_eq!(models[0].max_output_tokens, None);
}
#[test]
fn unknown_bedrock_model_infos_are_raw_provider_candidates() {
let models = model_infos_from_ids(&["provider.future-model-v1:0".to_string()], None);
assert_eq!(models[0].context_window, None);
assert_eq!(models[0].max_output_tokens, None);
}
#[test]
fn historical_tool_use_names_are_sanitized_for_bedrock() {
let message = ModelMessage {
role: MessageRole::Assistant,
content: vec![ContentBlock::ToolCall {
call: ToolCall {
id: "tooluse_1".to_string(),
name: "shell.run".to_string(),
arguments: serde_json::json!({"command":"git status"}),
},
}],
};
let blocks = bedrock_content_blocks(&message).expect("tool call should convert");
let BedrockContentBlock::ToolUse(tool_use) = &blocks[0] else {
panic!("expected tool use block");
};
assert_eq!(tool_use.name(), "shell_run");
}
#[test]
fn cache_hints_emit_bedrock_cache_points() {
let request = ModelTurnRequest {
context_lineage: None,
session_id: "00000000-0000-0000-0000-000000000000"
.parse()
.expect("static nil UUID should parse"),
turn_id: "turn".to_string(),
model_id: "model".to_string(),
provider_context: bcode_model::ProviderRequestContext::default(),
system_prompt: Some("stable".to_string()),
messages: vec![ModelMessage {
role: MessageRole::User,
content: vec![
ContentBlock::Text {
text: "hello".to_string(),
},
ContentBlock::CachePoint {
hint: bcode_model::PromptCachePoint::default(),
},
],
}],
tools: vec![ToolDefinition {
name: "filesystem.read".to_string(),
description: "read".to_string(),
input_schema: serde_json::json!({"type":"object"}),
}],
tool_call_policy: bcode_model::ToolCallRequestPolicy::default(),
tool_schema_mode: None,
structured_output: None,
context_management: bcode_model::ContextManagementRequest::default(),
parameters: bcode_model::ModelParameters::default(),
prompt_cache: bcode_model::PromptCacheHints {
mode: bcode_model::PromptCacheMode::Auto,
key: None,
ttl_seconds: None,
supported_ttl_seconds: BTreeSet::new(),
cache_system_prompt: true,
cache_tools: true,
},
conversation_reuse: bcode_model::ConversationReuseHints::default(),
metadata: BTreeMap::default(),
};
let system = system_blocks(&request);
assert!(matches!(system[1], SystemContentBlock::CachePoint(_)));
let messages = model_messages_to_bedrock_messages(&request).expect("messages convert");
assert!(matches!(
messages[0].content().last(),
Some(BedrockContentBlock::CachePoint(_))
));
let tool_config = model_tools_to_bedrock_tool_config(&request, "anthropic.claude-opus-5")
.expect("tools convert")
.expect("tool config should exist");
assert!(matches!(
tool_config.tools().last(),
Some(Tool::CachePoint(_))
));
assert!(
tool_config
.tool_choice()
.is_some_and(BedrockToolChoice::is_auto)
);
}
#[test]
fn bedrock_rejects_unmapped_cache_ttl() {
let mut request = test_model_turn_request();
request.messages.push(ModelMessage {
role: MessageRole::User,
content: vec![ContentBlock::CachePoint {
hint: bcode_model::PromptCachePoint {
label: None,
ttl_seconds: Some(300),
},
}],
});
let error = validate_bedrock_request(&request, false).expect_err("TTL must be rejected");
assert_eq!(error.code, "bedrock_cache_ttl_unsupported");
}
#[test]
fn bedrock_granular_capabilities_distinguish_transport_and_unknown_model() {
use bcode_model::{
CapabilitySupport, ModelFeatureSupport, ModelParameterKey, RequestedModelFeature,
StructuredOutputMode,
};
let provider = bedrock_feature_support_for(false);
assert!(provider.has_complete_inventory());
assert!(
provider
.parameter(ModelParameterKey::Temperature)
.is_guaranteed()
);
assert!(
provider
.structured_output(StructuredOutputMode::StrictJsonSchema)
.is_guaranteed()
);
assert!(
provider
.parameter(ModelParameterKey::ReasoningBudgetTokens)
.is_guaranteed(),
"Bedrock now maps extended thinking to a reasoning budget"
);
assert!(
provider
.parameter(ModelParameterKey::ReasoningEffort)
.is_guaranteed()
);
assert!(matches!(
provider.parameter(ModelParameterKey::ReasoningSummary),
CapabilitySupport::Unsupported { .. }
));
assert!(matches!(
provider.negotiate(
&ModelFeatureSupport::default(),
RequestedModelFeature::Parameter(ModelParameterKey::Temperature)
),
bcode_model::NegotiatedFeatureSupport::Unknown {
scope: bcode_model::CapabilityScope::Model
}
));
}
#[test]
fn capabilities_follow_the_request_context_api_surface() {
use bcode_model::{CapabilityExecution, CapabilitySupport, StructuredOutputMode};
let messages_context = bcode_model::ProviderRequestContext {
api_surface: Some(bcode_model::ModelApiSurface::Messages),
..bcode_model::ProviderRequestContext::default()
};
let capabilities = capabilities_for_context(&messages_context);
assert!(matches!(
capabilities
.feature_support
.structured_output(StructuredOutputMode::StrictJsonSchema),
CapabilitySupport::Supported {
execution: CapabilityExecution::ToolFreeProviderRound,
..
}
));
let capabilities =
capabilities_for_context(&bcode_model::ProviderRequestContext::default());
assert!(matches!(
capabilities
.feature_support
.structured_output(StructuredOutputMode::StrictJsonSchema),
CapabilitySupport::Supported {
execution: CapabilityExecution::Direct,
..
}
));
}
#[test]
fn bedrock_structured_output_capabilities_report_surface_fidelity() {
use bcode_model::{
CapabilityExecution, CapabilityFidelity, CapabilityMechanism, CapabilitySupport,
StructuredOutputMode,
};
let converse = bedrock_feature_support_for_surface(false, false, false);
assert!(matches!(
converse.structured_output(StructuredOutputMode::StrictJsonSchema),
CapabilitySupport::Supported {
mechanism: CapabilityMechanism::Native,
fidelity: CapabilityFidelity::Reduced,
execution: CapabilityExecution::Direct,
..
}
));
let messages = bedrock_feature_support_for_surface(false, true, false);
assert!(matches!(
messages.structured_output(StructuredOutputMode::StrictJsonSchema),
CapabilitySupport::Supported {
mechanism: CapabilityMechanism::AdapterMediated,
fidelity: CapabilityFidelity::Reduced,
execution: CapabilityExecution::ToolFreeProviderRound,
..
}
));
assert!(matches!(
messages.tool_schema(bcode_model::ToolSchemaMode::Strict),
CapabilitySupport::Unsupported { .. }
));
let responses = bedrock_feature_support_for_surface(true, false, false);
assert!(matches!(
responses.structured_output(StructuredOutputMode::StrictJsonSchema),
CapabilitySupport::Supported {
mechanism: CapabilityMechanism::Native,
fidelity: CapabilityFidelity::Exact,
execution: CapabilityExecution::Direct,
..
}
));
let unsupported = bedrock_feature_support_for_surface(false, false, true);
assert!(matches!(
unsupported.structured_output(StructuredOutputMode::StrictJsonSchema),
CapabilitySupport::Unsupported { .. }
));
}
#[test]
fn bedrock_rejects_unsupported_correctness_sensitive_controls() {
let mut request = test_model_turn_request();
request.structured_output = Some(bcode_model::StructuredOutputRequest {
name: "Output".to_string(),
schema: serde_json::json!({"type": "object"}),
strict: true,
});
validate_bedrock_request(&request, false).expect("Converse schema mode must be supported");
request
.provider_context
.settings
.insert("transport".to_string(), "mantle_anthropic".to_string());
validate_bedrock_request(&request, false)
.expect("Mantle Anthropic schema mode uses adapter-mediated fallback");
request.provider_context.settings.clear();
request.structured_output = None;
request.parameters.reasoning_effort = Some(bcode_model::ReasoningEffort::High);
validate_bedrock_request(&request, false)
.expect("reasoning effort is now mapped to extended thinking");
request.parameters = bcode_model::ModelParameters::default();
request.parameters.reasoning_summary = Some("detailed".to_string());
let error =
validate_bedrock_request(&request, false).expect_err("reasoning summary must fail");
assert_eq!(error.code, "bedrock_reasoning_summary_unsupported");
request.parameters = bcode_model::ModelParameters::default();
request.tool_call_policy.parallel = Some(true);
let error =
validate_bedrock_request(&request, false).expect_err("parallel policy must fail");
assert_eq!(error.code, "bedrock_parallel_tool_policy_unsupported");
}
#[test]
fn bedrock_thinking_fields_from_reasoning_effort() {
let params = bcode_model::ModelParameters::default();
assert!(
bedrock_thinking_fields(¶ms, Some(ANTHROPIC_THINKING_DISPLAY_SUMMARIZED)).is_none()
);
let params = bcode_model::ModelParameters {
reasoning_effort: Some(bcode_model::ReasoningEffort::High),
..Default::default()
};
let fields = bedrock_thinking_fields(¶ms, Some(ANTHROPIC_THINKING_DISPLAY_SUMMARIZED))
.expect("thinking fields");
let Document::Object(root) = fields else {
panic!("thinking fields must be an object");
};
let Some(Document::Object(thinking)) = root.get("thinking") else {
panic!("thinking key must be an object");
};
assert_eq!(
thinking.get("type"),
Some(&Document::String("enabled".to_string()))
);
assert_eq!(
thinking.get("budget_tokens"),
Some(&Document::Number(Number::PosInt(u64::from(
REASONING_EFFORT_HIGH_BUDGET
))))
);
}
#[test]
fn bedrock_adaptive_thinking_sends_effort_in_output_config() {
let params = bcode_model::ModelParameters {
reasoning_control: Some(bcode_model::ReasoningControl::Adaptive),
reasoning_effort_value: Some("xhigh".to_string()),
..Default::default()
};
let Some(Document::Object(root)) =
bedrock_thinking_fields(¶ms, Some(ANTHROPIC_THINKING_DISPLAY_SUMMARIZED))
else {
panic!("adaptive thinking fields must be an object");
};
let Some(Document::Object(thinking)) = root.get("thinking") else {
panic!("thinking key must be an object");
};
assert_eq!(
thinking.get("type"),
Some(&Document::String("adaptive".to_string()))
);
assert!(
!thinking.contains_key("budget_tokens"),
"adaptive thinking must never send a token budget"
);
assert!(
!thinking.contains_key("effort"),
"effort nested inside thinking is rejected by Bedrock"
);
let Some(Document::Object(output_config)) = root.get("output_config") else {
panic!("output_config must be a sibling object");
};
assert_eq!(
output_config.get("effort"),
Some(&Document::String("xhigh".to_string()))
);
}
#[test]
fn bedrock_adaptive_thinking_ignores_budget_only_requests() {
let params = bcode_model::ModelParameters {
reasoning_control: Some(bcode_model::ReasoningControl::Adaptive),
reasoning_budget_tokens: Some(8_192),
..Default::default()
};
let Some(Document::Object(root)) =
bedrock_thinking_fields(¶ms, Some(ANTHROPIC_THINKING_DISPLAY_SUMMARIZED))
else {
panic!("adaptive models still request adaptive thinking");
};
let Some(Document::Object(thinking)) = root.get("thinking") else {
panic!("thinking key must be an object");
};
assert_eq!(
thinking.get("type"),
Some(&Document::String("adaptive".to_string()))
);
assert!(
!root.contains_key("output_config"),
"a token budget carries no adaptive effort name"
);
}
#[test]
fn converse_thinking_fields_request_readable_thinking_display() {
let adaptive = bcode_model::ModelParameters {
reasoning_control: Some(bcode_model::ReasoningControl::Adaptive),
reasoning_effort_value: Some("high".to_string()),
..Default::default()
};
let Some(Document::Object(root)) = bedrock_thinking_fields(
&adaptive,
anthropic_thinking_display(Some(DEFAULT_REGION), "anthropic.claude-opus-5"),
) else {
panic!("thinking fields must be an object");
};
let Some(Document::Object(thinking)) = root.get("thinking") else {
panic!("thinking key must be an object");
};
assert_eq!(
thinking.get("display"),
Some(&Document::String(
ANTHROPIC_THINKING_DISPLAY_SUMMARIZED.to_string()
)),
"adaptive thinking must request readable content explicitly"
);
let budget = bcode_model::ModelParameters {
reasoning_control: Some(bcode_model::ReasoningControl::Budget),
reasoning_effort: Some(bcode_model::ReasoningEffort::High),
..Default::default()
};
let Some(Document::Object(root)) = bedrock_thinking_fields(
&budget,
anthropic_thinking_display(Some(DEFAULT_REGION), "anthropic.claude-sonnet-4"),
) else {
panic!("thinking fields must be an object");
};
let Some(Document::Object(thinking)) = root.get("thinking") else {
panic!("thinking key must be an object");
};
assert!(
!thinking.contains_key("display"),
"budget thinking rejects thinking.enabled.display"
);
assert_eq!(
thinking.get("type"),
Some(&Document::String("enabled".to_string()))
);
}
#[test]
fn converse_thinking_fields_omit_display_on_govcloud() {
let params = bcode_model::ModelParameters {
reasoning_control: Some(bcode_model::ReasoningControl::Adaptive),
reasoning_effort_value: Some("high".to_string()),
..Default::default()
};
for (region, model_id) in [
(Some("us-gov-west-1"), "anthropic.claude-opus-4-7"),
(Some("US-GOV-WEST-1"), "anthropic.claude-opus-4-7"),
(Some(DEFAULT_REGION), "us-gov.anthropic.claude-opus-4-7"),
(
Some(DEFAULT_REGION),
"arn:aws-us-gov:bedrock:us-gov-west-1::foundation-model/anthropic.claude-opus-4-7",
),
] {
let Some(Document::Object(root)) =
bedrock_thinking_fields(¶ms, anthropic_thinking_display(region, model_id))
else {
panic!("thinking fields must still be sent on GovCloud");
};
let Some(Document::Object(thinking)) = root.get("thinking") else {
panic!("thinking key must be an object");
};
assert!(
!thinking.contains_key("display"),
"GovCloud rejects thinking.display ({region:?}, {model_id})"
);
assert_eq!(
thinking.get("type"),
Some(&Document::String("adaptive".to_string())),
"the adaptive shape must survive the GovCloud carve-out"
);
let Some(Document::Object(output_config)) = root.get("output_config") else {
panic!("adaptive effort must still be sent on GovCloud");
};
assert_eq!(
output_config.get("effort"),
Some(&Document::String("high".to_string()))
);
}
}
#[test]
fn messages_surface_requests_readable_thinking_display() {
let mut request = test_model_turn_request();
request.parameters.reasoning_control = Some(bcode_model::ReasoningControl::Adaptive);
request.parameters.reasoning_effort_value = Some("high".to_string());
let body = build_anthropic_messages_request_value(
&request,
"anthropic.claude-opus-4-7",
Some(DEFAULT_REGION),
)
.expect("request should build");
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(
body["thinking"]["display"],
ANTHROPIC_THINKING_DISPLAY_SUMMARIZED
);
assert_eq!(body["output_config"]["effort"], "high");
let mut budget = test_model_turn_request();
budget.parameters.reasoning_control = Some(bcode_model::ReasoningControl::Budget);
budget.parameters.reasoning_effort = Some(bcode_model::ReasoningEffort::High);
let body = build_anthropic_messages_request_value(
&budget,
"anthropic.claude-opus-4-1",
Some(DEFAULT_REGION),
)
.expect("request should build");
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(
body["thinking"]["budget_tokens"],
serde_json::json!(REASONING_EFFORT_HIGH_BUDGET)
);
assert!(
body["thinking"].get("display").is_none(),
"budget thinking must not carry a display field"
);
}
#[test]
fn messages_surface_omits_thinking_display_on_govcloud() {
let mut request = test_model_turn_request();
request.parameters.reasoning_control = Some(bcode_model::ReasoningControl::Adaptive);
request.parameters.reasoning_effort_value = Some("high".to_string());
let body = build_anthropic_messages_request_value(
&request,
"anthropic.claude-opus-4-7",
Some("us-gov-west-1"),
)
.expect("request should build");
assert_eq!(body["thinking"]["type"], "adaptive");
assert!(
body["thinking"].get("display").is_none(),
"GovCloud rejects thinking.display"
);
assert_eq!(body["output_config"]["effort"], "high");
}
#[test]
fn govcloud_target_detection_matches_region_and_model_identity() {
assert!(govcloud_bedrock_target(
Some("us-gov-east-1"),
"anthropic.claude-opus-5"
));
assert!(govcloud_bedrock_target(
Some(DEFAULT_REGION),
"us-gov.anthropic.claude-opus-5"
));
assert!(govcloud_bedrock_target(
None,
"arn:aws-us-gov:bedrock:us-gov-west-1::foundation-model/anthropic.claude-opus-5"
));
assert!(!govcloud_bedrock_target(
Some(DEFAULT_REGION),
"anthropic.claude-opus-5"
));
assert!(!govcloud_bedrock_target(None, "anthropic.claude-opus-5"));
assert!(!govcloud_bedrock_target(
Some("eu-central-1"),
"arn:aws:bedrock:eu-central-1::foundation-model/anthropic.claude-opus-5"
));
}
#[test]
fn bedrock_budget_control_keeps_enabled_thinking_shape() {
let params = bcode_model::ModelParameters {
reasoning_control: Some(bcode_model::ReasoningControl::Budget),
reasoning_effort_value: Some("high".to_string()),
..Default::default()
};
let Some(Document::Object(root)) =
bedrock_thinking_fields(¶ms, Some(ANTHROPIC_THINKING_DISPLAY_SUMMARIZED))
else {
panic!("budget thinking fields must be an object");
};
let Some(Document::Object(thinking)) = root.get("thinking") else {
panic!("thinking key must be an object");
};
assert_eq!(
thinking.get("type"),
Some(&Document::String("enabled".to_string()))
);
assert!(!root.contains_key("output_config"));
}
#[test]
fn bedrock_thinking_budget_prefers_explicit_tokens() {
let mut params = bcode_model::ModelParameters {
reasoning_effort: Some(bcode_model::ReasoningEffort::Low),
reasoning_budget_tokens: Some(8_192),
..Default::default()
};
assert_eq!(resolve_reasoning_budget_tokens(¶ms), Some(8_192));
params.reasoning_budget_tokens = Some(0);
assert_eq!(
resolve_reasoning_budget_tokens(¶ms),
Some(REASONING_EFFORT_LOW_BUDGET),
"a zero explicit budget falls back to the effort mapping"
);
}
#[test]
fn fable_5_1_rejects_forced_tools_and_invalid_sampling() {
let mut request = test_model_turn_request();
request.model_id = "global.anthropic.claude-fable-5-1".to_string();
request.tools = vec![ToolDefinition {
name: "filesystem.read".to_string(),
description: "read".to_string(),
input_schema: serde_json::json!({"type":"object"}),
}];
request.tool_call_policy.choice = ToolChoice::Required;
let error = validate_bedrock_request(&request, false).expect_err("forced tools must fail");
assert_eq!(
error.code,
"bedrock_fable_5_1_forced_tool_choice_unsupported"
);
request.tool_call_policy.choice = ToolChoice::Auto;
request.parameters.temperature = Some(0.5);
let error = validate_bedrock_request(&request, false).expect_err("temperature must fail");
assert_eq!(error.code, "bedrock_fable_5_1_temperature_invalid");
request.parameters.temperature = Some(1.0);
request.parameters.top_p = Some(0.99);
let error =
validate_bedrock_request(&request, false).expect_err("combined sampling must fail");
assert_eq!(error.code, "bedrock_fable_5_1_sampling_parameters_conflict");
}
#[test]
fn anthropic_messages_bounds_cache_points_and_keeps_newest_boundaries() {
let mut request = test_model_turn_request();
request.system_prompt = Some("stable system".to_string());
request.prompt_cache.cache_system_prompt = true;
request.prompt_cache.cache_tools = true;
request.tools.push(ToolDefinition {
name: "filesystem.read".to_string(),
description: "read".to_string(),
input_schema: serde_json::json!({"type":"object"}),
});
request.messages = (0..5)
.map(|index| ModelMessage {
role: MessageRole::User,
content: vec![
ContentBlock::Text {
text: format!("boundary {index}"),
},
ContentBlock::CachePoint {
hint: bcode_model::PromptCachePoint::default(),
},
],
})
.collect();
bound_anthropic_messages_cache_points(&mut request);
assert_eq!(bedrock_emitted_cache_point_count(&request), 4);
let retained = request
.messages
.iter()
.filter(|message| {
message
.content
.iter()
.any(|block| matches!(block, ContentBlock::CachePoint { .. }))
})
.map(|message| match &message.content[0] {
ContentBlock::Text { text } => text.as_str(),
_ => panic!("expected text boundary"),
})
.collect::<Vec<_>>();
assert_eq!(retained, vec!["boundary 3", "boundary 4"]);
let mut unbounded = request.clone();
unbounded.messages = (0..5)
.map(|index| ModelMessage {
role: MessageRole::User,
content: vec![
ContentBlock::Text {
text: format!("boundary {index}"),
},
ContentBlock::CachePoint {
hint: bcode_model::PromptCachePoint::default(),
},
],
})
.collect();
let projection =
bedrock_request_projection(&unbounded, EffectiveBedrockRoute::AnthropicMessages);
assert_eq!(projection.emitted_cache_point_count, Some(4));
assert_eq!(projection.dropped_cache_point_count, Some(3));
}
#[test]
fn fable_5_1_serializes_adaptive_max_effort() {
let mut request = test_model_turn_request();
request.model_id = "global.anthropic.claude-fable-5-1".to_string();
request.parameters.max_output_tokens = Some(128_000);
request.parameters.reasoning_control = Some(bcode_model::ReasoningControl::Adaptive);
request.parameters.reasoning_effort_value = Some("max".to_string());
let body =
build_mantle_anthropic_request(&request, &request.model_id, Some(DEFAULT_REGION))
.expect("Fable request should serialize");
assert_eq!(body["model"], request.model_id);
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["thinking"]["display"], "summarized");
assert_eq!(body["output_config"]["effort"], "max");
assert_eq!(body["max_tokens"], 128_000);
}
#[test]
fn fable_5_1_serializes_supported_cache_ttls() {
let mut request = test_model_turn_request();
request.model_id = "global.anthropic.claude-fable-5-1".to_string();
request.prompt_cache.cache_system_prompt = true;
request.prompt_cache.ttl_seconds = Some(3_600);
request.prompt_cache.supported_ttl_seconds = BTreeSet::from([300, 3_600]);
request.system_prompt = Some("stable system".to_string());
validate_bedrock_request(&request, false).expect("one-hour TTL is supported");
let body =
build_mantle_anthropic_request(&request, &request.model_id, Some(DEFAULT_REGION))
.expect("Fable request should serialize");
assert_eq!(body["system"][0]["cache_control"]["type"], "ephemeral");
assert_eq!(body["system"][0]["cache_control"]["ttl"], "1h");
request.prompt_cache.ttl_seconds = Some(300);
validate_bedrock_request(&request, false).expect("five-minute TTL is supported");
request.prompt_cache.supported_ttl_seconds.clear();
let error = validate_bedrock_request(&request, false)
.expect_err("TTL without host-resolved support must fail closed");
assert_eq!(error.code, "bedrock_cache_ttl_unsupported");
request.prompt_cache.supported_ttl_seconds = BTreeSet::from([300, 3_600]);
request.prompt_cache.ttl_seconds = Some(600);
let error = validate_bedrock_request(&request, false)
.expect_err("unadvertised TTL must fail closed");
assert_eq!(error.code, "bedrock_cache_ttl_unsupported");
request.prompt_cache.supported_ttl_seconds = BTreeSet::from([600]);
let error = validate_bedrock_request(&request, false)
.expect_err("unsupported wire TTL must fail closed");
assert_eq!(error.code, "bedrock_cache_ttl_unsupported");
}
#[test]
fn bedrock_rejects_unmapped_provider_options_and_reuse() {
let mut request = test_model_turn_request();
request.provider_context.request.insert(
"unsupported".to_string(),
bcode_model::ProviderRequestValue::Bool(true),
);
let error =
validate_bedrock_request(&request, false).expect_err("provider option must fail");
assert_eq!(error.code, "bedrock_provider_options_unsupported");
request.provider_context.request.clear();
request.conversation_reuse.mode = bcode_model::ConversationReuseMode::Auto;
let error = validate_bedrock_request(&request, false).expect_err("reuse must fail");
assert_eq!(error.code, "bedrock_conversation_reuse_unsupported");
}
#[test]
fn bedrock_tool_choice_none_omits_tools() {
let mut request = test_model_turn_request();
request.tools.push(ToolDefinition {
name: "filesystem.read".to_string(),
description: "read".to_string(),
input_schema: serde_json::json!({"type":"object"}),
});
request.tool_call_policy.choice = ToolChoice::None;
assert!(
model_tools_to_bedrock_tool_config(&request, "anthropic.claude-opus-5")
.expect("none choice should project")
.is_none()
);
}
#[test]
fn bedrock_required_and_specific_tool_choices_are_typed() {
let mut request = test_model_turn_request();
request.tools.push(ToolDefinition {
name: "filesystem.read".to_string(),
description: "read".to_string(),
input_schema: serde_json::json!({"type":"object"}),
});
request.tool_call_policy.choice = ToolChoice::Required;
let required = model_tools_to_bedrock_tool_config(&request, "anthropic.claude-opus-5")
.expect("required choice should project")
.expect("required choice needs tool config");
assert!(
required
.tool_choice()
.is_some_and(BedrockToolChoice::is_any)
);
request.tool_call_policy.choice = ToolChoice::Tool {
name: "filesystem.read".to_string(),
};
let specific = model_tools_to_bedrock_tool_config(&request, "anthropic.claude-opus-5")
.expect("specific choice should project")
.expect("specific choice needs tool config");
let selected = specific
.tool_choice()
.and_then(|choice| choice.as_tool().ok())
.expect("specific Bedrock choice");
assert_eq!(selected.name(), "filesystem_read");
}
#[test]
fn bedrock_rejects_unknown_specific_tool_choice() {
let mut request = test_model_turn_request();
request.tools.push(ToolDefinition {
name: "filesystem.read".to_string(),
description: "read".to_string(),
input_schema: serde_json::json!({"type":"object"}),
});
request.tool_call_policy.choice = ToolChoice::Tool {
name: "missing".to_string(),
};
let error = model_tools_to_bedrock_tool_config(&request, "anthropic.claude-opus-5")
.expect_err("unknown required tool must fail");
assert_eq!(error.code, "unknown_required_tool");
assert_eq!(error.category, ProviderErrorCategory::InvalidRequest);
}
#[test]
fn image_reference_tool_result_degrades_instead_of_failing_validation() {
let mut request = test_model_turn_request();
request.messages = vec![ModelMessage {
role: MessageRole::User,
content: vec![ContentBlock::ToolResult {
result: bcode_model::ToolResult {
call_id: "call-image".to_string(),
output: "image returned".to_string(),
is_error: false,
content: vec![bcode_model::ToolResultContent::ImageRef {
image: bcode_model::ImageRefContent {
path: "/workspace/image.png".to_string(),
mime_type: "image/png".to_string(),
artifact_id: None,
reference_key: None,
metadata: bcode_model::ImageMetadata::default(),
},
}],
},
}],
}];
validate_bedrock_request(&request, false)
.expect("image references should use the adapter's text fallback");
}
fn test_model_turn_request() -> ModelTurnRequest {
ModelTurnRequest {
context_lineage: None,
session_id: "00000000-0000-0000-0000-000000000000"
.parse()
.expect("static nil UUID should parse"),
turn_id: "turn".to_string(),
model_id: "model".to_string(),
provider_context: ProviderRequestContext::default(),
system_prompt: None,
messages: Vec::new(),
tools: Vec::new(),
tool_call_policy: bcode_model::ToolCallRequestPolicy::default(),
tool_schema_mode: None,
parameters: bcode_model::ModelParameters {
max_output_tokens: Some(64_000),
..bcode_model::ModelParameters::default()
},
structured_output: None,
context_management: bcode_model::ContextManagementRequest::default(),
prompt_cache: bcode_model::PromptCacheHints::default(),
conversation_reuse: bcode_model::ConversationReuseHints::default(),
metadata: BTreeMap::new(),
}
}
#[test]
fn persisted_compatibility_tracks_prompt_cache_unsupported() {
let key = DiscoveryCacheKey {
region: "us-east-1".to_string(),
aws_profile: None,
endpoint_url: None,
bearer_auth: false,
};
let mut compatibility = PersistedCompatibilityCache::default();
compatibility.mark_prompt_cache_unsupported(&key, "model", "no cache", 10);
assert!(
compatibility
.unsupported_prompt_cache_for(&key)
.contains("model")
);
assert!(compatibility.unsupported_streaming_for(&key).is_empty());
}
#[test]
fn responses_custom_gateway_preserves_explicit_cache_controls() {
let mut request = test_model_turn_request();
enable_explicit_prompt_cache(&mut request);
request.provider_context.api_surface = Some(bcode_model::ModelApiSurface::Responses);
request.system_prompt = Some("stable instructions".to_string());
request.messages = vec![ModelMessage {
role: MessageRole::User,
content: vec![
ContentBlock::Text {
text: "hello".to_string(),
},
ContentBlock::CachePoint {
hint: bcode_model::PromptCachePoint::default(),
},
],
}];
let mut settings = test_settings();
settings.endpoint_url = Some("https://gateway.example.com/gw".to_string());
settings.mantle_base_url = Some("https://gateway.example.com/gw".to_string());
let route = resolve_bedrock_route(&request, &settings).expect("gateway route resolves");
assert_eq!(route, EffectiveBedrockRoute::OpenAiResponses);
let projected = bedrock_request_projection(&request, route);
assert_eq!(
projected.api_shape.as_deref(),
Some("bedrock_mantle_openai_responses")
);
assert_eq!(projected.emitted_cache_point_count, Some(1));
assert_eq!(projected.dropped_cache_point_count, Some(0));
let wire = build_mantle_openai_request(&request, "catalog-responses-model")
.expect("gateway Responses request should build");
assert_eq!(wire["prompt_cache_options"]["mode"], "explicit");
assert!(wire["prompt_cache_key"].as_str().is_some());
assert_eq!(
wire["input"][1]["content"][0]["prompt_cache_breakpoint"]["mode"],
"explicit"
);
}
#[test]
fn responses_models_route_and_list_without_any_configuration() {
let default_settings = test_settings();
let mut responses_request = test_model_turn_request();
responses_request.model_id = "openai.gpt-5.6-sol".to_string();
responses_request
.metadata
.insert("bcode_request_kind".to_string(), "compaction".to_string());
responses_request.provider_context.api_surface =
Some(bcode_model::ModelApiSurface::Responses);
assert_eq!(
resolve_bedrock_route(&responses_request, &default_settings).expect("Responses route"),
EffectiveBedrockRoute::OpenAiResponses,
"a Responses-surface model must route to OpenAI Responses on the default AWS endpoint"
);
let mut gateway_settings = default_settings.clone();
gateway_settings.endpoint_url = Some("https://gateway.example.com/gw".to_string());
gateway_settings.mantle_base_url = Some("https://gateway.example.com/gw".to_string());
assert_eq!(
resolve_bedrock_route(&responses_request, &gateway_settings)
.expect("gateway Responses route"),
EffectiveBedrockRoute::OpenAiResponses,
"a custom endpoint must not change a catalog Responses model's wire surface"
);
let converse_request = test_model_turn_request();
assert_eq!(
resolve_bedrock_route(&converse_request, &default_settings).expect("Converse route"),
EffectiveBedrockRoute::Converse
);
let mut messages_request = test_model_turn_request();
messages_request.provider_context.api_surface =
Some(bcode_model::ModelApiSurface::Messages);
assert_eq!(
resolve_bedrock_route(&messages_request, &default_settings).expect("Messages route"),
EffectiveBedrockRoute::AnthropicMessages
);
let mut explicit_mantle = default_settings.clone();
explicit_mantle.transport = Ok(BedrockTransport::MantleOpenAi);
explicit_mantle.transport_explicit = true;
assert_eq!(
resolve_bedrock_route(&converse_request, &explicit_mantle)
.expect("explicit Responses route"),
EffectiveBedrockRoute::OpenAiResponses
);
let mut explicit_runtime = default_settings.clone();
explicit_runtime.transport_explicit = true;
let error = resolve_bedrock_route(&responses_request, &explicit_runtime)
.expect_err("Responses-only model must reject Runtime override");
assert_eq!(error.code, "bedrock_runtime_surface_unsupported");
let mut rich = test_model_turn_request();
rich.provider_context.api_surface = Some(bcode_model::ModelApiSurface::Responses);
rich.parameters.reasoning_summary = Some("detailed".to_string());
rich.tool_call_policy.parallel = Some(true);
let responses_surface = resolve_bedrock_route(&rich, &default_settings)
.expect("rich Responses route")
== EffectiveBedrockRoute::OpenAiResponses;
validate_bedrock_request(&rich, responses_surface)
.expect("Responses models accept these features on the default transport");
let plugin = BedrockProviderPlugin::default();
let list = plugin.models(&ModelListRequest::default());
match &list.catalog.policy {
bcode_model::ModelCatalogPolicy::ExpandSupported {
provider_id,
target,
..
} => {
assert_eq!(provider_id, "bedrock");
assert_eq!(target, &mantle_openai_support_hint());
}
other => panic!("the default picker must expand Responses models, got {other:?}"),
}
}
#[tokio::test]
async fn abandoned_discovery_releases_reservation_and_wakes_followers() {
let cache = Arc::new(Mutex::new(DiscoveryCache::default()));
let key = DiscoveryCacheKey {
region: "us-east-1".into(),
aws_profile: None,
endpoint_url: None,
bearer_auth: false,
};
let done = Arc::new(tokio::sync::Semaphore::new(0));
cache
.lock()
.unwrap()
.inflight
.insert(key.clone(), Arc::clone(&done));
let (started, ready) = tokio::sync::oneshot::channel();
let owned_cache = Arc::clone(&cache);
let owned_done = Arc::clone(&done);
let task = tokio::spawn(async move {
let reservation = DiscoveryReservation {
cache: &owned_cache,
key,
done: owned_done,
};
started.send(()).unwrap();
std::future::pending::<()>().await;
drop(reservation);
});
ready.await.unwrap();
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
assert!(cache.lock().unwrap().inflight.is_empty());
assert!(
tokio::time::timeout(Duration::from_secs(5), done.acquire())
.await
.unwrap()
.is_err()
);
}
#[test]
fn stale_discovery_cleanup_preserves_replacement() {
let cache = Mutex::new(DiscoveryCache::default());
let key = DiscoveryCacheKey {
region: "us-east-1".into(),
aws_profile: None,
endpoint_url: None,
bearer_auth: false,
};
let old = Arc::new(tokio::sync::Semaphore::new(0));
let replacement = Arc::new(tokio::sync::Semaphore::new(0));
let reservation = DiscoveryReservation {
cache: &cache,
key: key.clone(),
done: Arc::clone(&old),
};
cache
.lock()
.unwrap()
.inflight
.insert(key.clone(), Arc::clone(&replacement));
drop(reservation);
assert!(old.is_closed());
assert!(!replacement.is_closed());
assert!(Arc::ptr_eq(
&cache.lock().unwrap().inflight[&key],
&replacement
));
}
#[test]
fn recent_discovery_failure_short_circuits_picker_discovery() {
let cache = Arc::new(Mutex::new(DiscoveryCache::default()));
let key = DiscoveryCacheKey {
region: "us-east-1".to_string(),
aws_profile: None,
endpoint_url: None,
bearer_auth: true,
};
assert!(!discovery_failure_is_recent(&cache, &key));
cache.lock().expect("cache").failures.insert(
key.clone(),
DiscoveryFailure {
code: "bedrock_model_discovery_failed".to_string(),
category: ProviderErrorCategory::ProviderInternal,
message: "request failed".to_string(),
failed_at: Instant::now(),
},
);
assert!(discovery_failure_is_recent(&cache, &key));
cache
.lock()
.expect("cache")
.failures
.get_mut(&key)
.expect("recorded failure")
.failed_at = Instant::now()
.checked_sub(DISCOVERY_FAILURE_RETRY_COOLDOWN)
.expect("cooldown fits within process uptime");
assert!(!discovery_failure_is_recent(&cache, &key));
store_discovery(&cache, key.clone(), ModelDiscovery::default());
assert!(last_discovery_failure(&cache, &key).is_none());
}
#[test]
#[ignore = "release-mode `models` service-call profile; run explicitly with --ignored --nocapture"]
fn benchmark_models_service_call_with_warm_discovery_cache() {
let plugin = BedrockProviderPlugin::default();
let mut context = ProviderRequestContext::default();
context
.settings
.insert("region".to_string(), "us-east-1".to_string());
let request = ModelListRequest {
provider_context: context,
selected_model_id: None,
};
let settings = Settings::resolve_from_context(&request.provider_context);
let runtime = plugin.runtime.as_ref().expect("provider runtime");
let key = runtime
.block_on(async move { discovery_cache_key(&settings).await })
.expect("cache key");
store_discovery(
&plugin.discovery,
key,
ModelDiscovery {
models: model_infos_from_ids(
&["anthropic.claude-fable-5-1".to_string()],
Some("anthropic.claude-fable-5-1"),
),
default_model_id: Some("anthropic.claude-fable-5-1".to_string()),
},
);
let _ = plugin.models(&request);
let mut settings_us = Vec::new();
let mut key_us = Vec::new();
let mut models_us = Vec::new();
for _ in 0..50 {
let started = Instant::now();
let settings = Settings::resolve_from_context(&request.provider_context);
settings_us.push(started.elapsed().as_micros());
let started = Instant::now();
let _ = runtime.block_on(async move { discovery_cache_key(&settings).await });
key_us.push(started.elapsed().as_micros());
let started = Instant::now();
let list = plugin.models(&request);
models_us.push(started.elapsed().as_micros());
assert!(!list.models.is_empty());
}
settings_us.sort_unstable();
key_us.sort_unstable();
models_us.sort_unstable();
eprintln!(
"bedrock_models: settings_p50_us={} cache_key_p50_us={} models_p50_us={} models_max_us={}",
settings_us[25], key_us[25], models_us[25], models_us[49]
);
}
#[tokio::test]
async fn request_scoped_sdk_config_does_not_inherit_daemon_credentials_or_region() {
let settings = Settings {
transport: Ok(BedrockTransport::Runtime),
transport_explicit: false,
mantle_base_url: None,
mantle_auth_header: false,
force_http1: false,
default_model: None,
model_ids: Vec::new(),
model_ids_are_explicit: false,
region: None,
region_source: RegionSource::AwsSdkDefaultChain,
aws_profile: None,
endpoint_url: None,
auth_credentials: BTreeMap::new(),
env: BTreeMap::new(),
config_source: "request/config/environment".to_string(),
};
let config = bedrock_sdk_config(&settings).await;
assert_eq!(config.region().map(Region::as_ref), Some(DEFAULT_REGION));
assert!(config.credentials_provider().is_none());
}
#[test]
fn request_contexts_isolate_bedrock_routes_within_one_plugin_instance() {
let responses = ProviderRequestContext {
settings: BTreeMap::from([
(
"model".to_string(),
format!("{}{}", "openai.gpt-5", ".6-sol"),
),
("transport".to_string(), "mantle_openai".to_string()),
(
"mantle_base_url".to_string(),
"https://openai.example.test".to_string(),
),
]),
..ProviderRequestContext::default()
};
let messages = ProviderRequestContext {
settings: BTreeMap::from([
(
"model".to_string(),
format!("{}{}", "global.anthropic.claude-fable-5", "-1"),
),
("transport".to_string(), "mantle_anthropic".to_string()),
(
"mantle_base_url".to_string(),
"https://messages.example.test".to_string(),
),
]),
..ProviderRequestContext::default()
};
let responses = Settings::resolve_from_context(&responses);
let messages = Settings::resolve_from_context(&messages);
assert_eq!(
responses.default_model.as_deref(),
Some(concat!("openai.gpt-5", ".6-sol"))
);
assert_eq!(
responses.mantle_base_url.as_deref(),
Some("https://openai.example.test")
);
assert!(matches!(
responses.transport,
Ok(BedrockTransport::MantleOpenAi)
));
assert_eq!(
messages.default_model.as_deref(),
Some(concat!("global.anthropic.claude-fable-5", "-1"))
);
assert_eq!(
messages.mantle_base_url.as_deref(),
Some("https://messages.example.test")
);
assert!(matches!(
messages.transport,
Ok(BedrockTransport::MantleAnthropic)
));
}
#[test]
fn vault_backed_auth_profile_name_is_not_treated_as_an_aws_named_profile() {
let vault_context = ProviderRequestContext {
auth: Some(bcode_model::ProviderAuthContext {
backend: Some("sshenv".to_string()),
attributes: BTreeMap::from([
("provider".to_string(), "aws".to_string()),
("profile".to_string(), "bedrock".to_string()),
]),
..bcode_model::ProviderAuthContext::default()
}),
..ProviderRequestContext::default()
};
let settings = Settings::resolve_from_context(&vault_context);
assert_eq!(settings.aws_profile, None);
let mut explicit = vault_context;
explicit
.auth
.as_mut()
.expect("auth context")
.attributes
.insert("aws_profile".to_string(), "america-admin".to_string());
let settings = Settings::resolve_from_context(&explicit);
assert_eq!(settings.aws_profile.as_deref(), Some("america-admin"));
let chain_context = ProviderRequestContext {
auth: Some(bcode_model::ProviderAuthContext {
backend: Some("aws_default_chain".to_string()),
attributes: BTreeMap::from([
("provider".to_string(), "aws".to_string()),
("profile".to_string(), "bedrock".to_string()),
]),
..bcode_model::ProviderAuthContext::default()
}),
..ProviderRequestContext::default()
};
let settings = Settings::resolve_from_context(&chain_context);
assert_eq!(settings.aws_profile.as_deref(), Some("bedrock"));
}
#[test]
fn discovery_cache_key_separates_bearer_token_from_sigv4_principals() {
let runtime = ProviderRuntime::new().expect("runtime");
let sigv4 = ProviderRequestContext {
settings: BTreeMap::from([("region".to_string(), "us-east-1".to_string())]),
..ProviderRequestContext::default()
};
let mut bearer = sigv4.clone();
bearer.auth = Some(bcode_model::ProviderAuthContext {
credentials: BTreeMap::from([(
"bearer_token".to_string(),
bcode_model::ProviderAuthCredential {
value: "test-token".to_string(),
source: Some("test".to_string()),
},
)]),
..bcode_model::ProviderAuthContext::default()
});
let sigv4 = Settings::resolve_from_context(&sigv4);
let bearer = Settings::resolve_from_context(&bearer);
let (sigv4_key, bearer_key) = runtime
.block_on(async move {
(
discovery_cache_key(&sigv4).await,
discovery_cache_key(&bearer).await,
)
})
.expect("cache keys");
assert!(!sigv4_key.bearer_auth);
assert!(bearer_key.bearer_auth);
assert_ne!(sigv4_key, bearer_key);
}
#[test]
fn foundation_model_candidates_list_broadly_and_mark_profile_only_ids_unsupported() {
use bedrock::types::{
FoundationModelLifecycle, FoundationModelLifecycleStatus, FoundationModelSummary,
InferenceType, ModelModality,
};
let summary = |id: &str| {
FoundationModelSummary::builder()
.model_arn(format!("arn:aws:bedrock:us-east-1::foundation-model/{id}"))
.model_id(id)
.model_name(id)
.output_modalities(ModelModality::Text)
};
let nova = summary("amazon.nova-pro-v1:0")
.inference_types_supported(InferenceType::OnDemand)
.build()
.expect("summary");
let candidate = foundation_model_candidate(&nova).expect("listed");
assert_eq!(candidate.visibility, bcode_model::ModelVisibility::Visible);
let batch_only = summary("example.batch-only")
.inference_types_supported(InferenceType::OnDemand)
.response_streaming_supported(false)
.build()
.expect("summary");
assert!(foundation_model_candidate(&batch_only).is_none());
let legacy = summary("anthropic.claude-v2")
.inference_types_supported(InferenceType::OnDemand)
.model_lifecycle(
FoundationModelLifecycle::builder()
.status(FoundationModelLifecycleStatus::Legacy)
.build()
.expect("lifecycle"),
)
.build()
.expect("summary");
assert!(foundation_model_candidate(&legacy).is_none());
let image = FoundationModelSummary::builder()
.model_arn("arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-image")
.model_id("amazon.titan-image")
.output_modalities(ModelModality::Image)
.inference_types_supported(InferenceType::OnDemand)
.build()
.expect("summary");
assert!(foundation_model_candidate(&image).is_none());
let profile_only = summary("anthropic.claude-fable-5-1")
.inference_types_supported(InferenceType::from("INFERENCE_PROFILE"))
.response_streaming_supported(true)
.build()
.expect("summary");
let candidate = foundation_model_candidate(&profile_only).expect("listed");
assert!(matches!(
candidate.visibility,
bcode_model::ModelVisibility::Unsupported { .. }
));
let unknown = summary("example.unknown-inference")
.build()
.expect("summary");
let candidate = foundation_model_candidate(&unknown).expect("listed");
assert_eq!(candidate.visibility, bcode_model::ModelVisibility::Visible);
}
#[test]
fn filtered_discovery_defaults_to_the_first_visible_model() {
let model = |id: &str, visibility: bcode_model::ModelVisibility| ModelInfo {
model_id: id.to_string(),
display_name: id.to_string(),
is_default: false,
context_window: None,
max_output_tokens: None,
max_image_input_base64_bytes: None,
capabilities: bedrock_model_capabilities(),
feature_support: bcode_model::ModelFeatureSupport::default(),
reasoning: None,
cache: bcode_model::ModelCacheInfo::default(),
metadata_source: None,
pricing: None,
api_surface: None,
visibility,
};
let discovery = ModelDiscovery {
models: vec![
model(
"anthropic.claude-fable-5-1",
bcode_model::ModelVisibility::Unsupported {
reason: "profile only".to_string(),
},
),
model(
"us.anthropic.claude-fable-5-1",
bcode_model::ModelVisibility::Visible,
),
],
default_model_id: None,
};
let filtered = filtered_discovery(&discovery, &BTreeSet::new(), &BTreeSet::new());
assert_eq!(
filtered.default_model_id.as_deref(),
Some("us.anthropic.claude-fable-5-1")
);
}
#[test]
fn mantle_transports_request_surface_specific_catalog_expansion_for_the_picker() {
let hint = mantle_openai_support_hint();
assert_eq!(hint.provider, "bedrock");
assert_eq!(hint.auth_mode, "bearer_token");
assert_eq!(hint.api_surface, "responses");
assert_eq!(hint.integration.as_deref(), Some("bcode"));
let plugin = BedrockProviderPlugin::default();
let context = |transport: &str, model: &str| ProviderRequestContext {
settings: BTreeMap::from([
("transport".to_string(), transport.to_string()),
("model".to_string(), model.to_string()),
]),
env: BTreeMap::from([("BCODE_BEDROCK_TRANSPORT".to_string(), transport.to_string())]),
..ProviderRequestContext::default()
};
let list = plugin.models(&ModelListRequest {
provider_context: context("mantle_openai", "openai.test-model"),
selected_model_id: None,
});
match &list.catalog.policy {
bcode_model::ModelCatalogPolicy::ExpandSupported {
provider_id,
target,
authority,
} => {
assert_eq!(provider_id, "bedrock");
assert_eq!(target, &hint);
assert_eq!(authority, &bcode_model::ModelListAuthority::Authoritative);
}
other => panic!("mantle_openai must expand from the catalog, got {other:?}"),
}
let anthropic = plugin.models(&ModelListRequest {
provider_context: context("mantle_anthropic", "anthropic.claude-opus-5"),
selected_model_id: None,
});
let anthropic_hint = mantle_anthropic_support_hint();
match &anthropic.catalog.policy {
bcode_model::ModelCatalogPolicy::ExpandSupported {
provider_id,
target,
authority,
} => {
assert_eq!(provider_id, "bedrock");
assert_eq!(target, &anthropic_hint);
assert_eq!(authority, &bcode_model::ModelListAuthority::Authoritative);
}
other => panic!("mantle_anthropic must expand from the catalog, got {other:?}"),
}
}
#[test]
fn model_list_includes_default_first() {
let mut settings = Settings::resolve(None);
settings.default_model = Some("model-b".to_string());
settings.model_ids = vec!["model-b".to_string(), "model-a".to_string()];
let metadata = diagnostics_metadata(&settings);
assert_eq!(metadata.get("default_model"), Some(&"model-b".to_string()));
}
#[test]
fn apply_default_model_preserves_full_discovered_list() {
let mut models = model_infos_from_ids(
&[
"us.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(),
"us.anthropic.claude-opus-4-8-20250101-v1:0".to_string(),
"us.anthropic.claude-haiku-4-5-20250101-v1:0".to_string(),
],
None,
);
apply_default_model_to_list(
&mut models,
Some("us.anthropic.claude-opus-4-8-20250101-v1:0"),
);
assert_eq!(models.len(), 3, "the full list must be preserved");
let default = models
.iter()
.filter(|model| model.is_default)
.collect::<Vec<_>>();
assert_eq!(default.len(), 1);
assert_eq!(
default[0].model_id,
"us.anthropic.claude-opus-4-8-20250101-v1:0"
);
}
#[test]
fn apply_default_model_adds_missing_pinned_model() {
let mut models = model_infos_from_ids(
&["us.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string()],
None,
);
apply_default_model_to_list(
&mut models,
Some("anthropic.claude-3-5-sonnet-20241022-v2:0"),
);
assert_eq!(models.len(), 2);
assert_eq!(
models[0].model_id,
"anthropic.claude-3-5-sonnet-20241022-v2:0"
);
assert!(models[0].is_default);
}
#[test]
fn apply_default_model_none_leaves_list_untouched() {
let mut models = model_infos_from_ids(&["a".to_string(), "b".to_string()], Some("b"));
apply_default_model_to_list(&mut models, None);
assert_eq!(models.len(), 2);
assert!(
models
.iter()
.find(|m| m.model_id == "b")
.unwrap()
.is_default
);
}
#[test]
fn model_unusable_via_converse_detects_data_retention_error() {
let error = provider_error(
"bedrock_request_failed",
ProviderErrorCategory::InvalidRequest,
"The model returned the following errors: data retention mode 'default' is not available for this model",
);
assert!(model_unusable_via_converse(&error));
let unrelated = provider_error(
"bedrock_request_failed",
ProviderErrorCategory::InvalidRequest,
"some other validation problem",
);
assert!(!model_unusable_via_converse(&unrelated));
let wrong_category = provider_error(
"bedrock_request_failed",
ProviderErrorCategory::RateLimit,
"data retention mode 'default' is not available for this model",
);
assert!(!model_unusable_via_converse(&wrong_category));
}
#[test]
fn persisted_compatibility_filters_discovery() {
let key = DiscoveryCacheKey {
region: "us-east-1".to_string(),
aws_profile: Some("work".to_string()),
endpoint_url: None,
bearer_auth: false,
};
let mut compatibility = PersistedCompatibilityCache::default();
compatibility.mark_streaming_tool_unsupported(&key, "bad-model", "unsupported", 10);
let discovery = ModelDiscovery {
models: model_infos_from_ids(
&["bad-model".to_string(), "good-model".to_string()],
None,
),
default_model_id: Some("bad-model".to_string()),
};
let filtered = filtered_discovery(
&discovery,
&compatibility.unsupported_streaming_for(&key),
&BTreeSet::new(),
);
assert_eq!(filtered.default_model_id, Some("good-model".to_string()));
}
#[test]
fn persisted_prompt_cache_incompatibility_removes_cache_capabilities() {
let key = DiscoveryCacheKey {
region: "us-east-1".to_string(),
aws_profile: None,
endpoint_url: None,
bearer_auth: false,
};
let mut compatibility = PersistedCompatibilityCache::default();
compatibility.mark_prompt_cache_unsupported(&key, "no-cache", "unsupported", 10);
let discovery = ModelDiscovery {
models: model_infos_from_ids(&["no-cache".to_string(), "cache-ok".to_string()], None),
default_model_id: Some("no-cache".to_string()),
};
let filtered = filtered_discovery(
&discovery,
&BTreeSet::new(),
&compatibility.unsupported_prompt_cache_for(&key),
);
assert!(
!filtered.models[0]
.capabilities
.contains(&ModelCapability::PromptCaching)
);
assert!(filtered.models[0].cache.capabilities.is_empty());
assert!(
filtered.models[1]
.capabilities
.contains(&ModelCapability::PromptCaching)
);
}
#[test]
fn context_bearer_token_is_resolved_from_auth_credentials() {
let settings = Settings {
transport: Ok(BedrockTransport::Runtime),
transport_explicit: false,
mantle_base_url: None,
mantle_auth_header: false,
force_http1: false,
default_model: None,
model_ids: Vec::new(),
model_ids_are_explicit: false,
region: Some("us-east-1".to_string()),
region_source: RegionSource::Profile,
aws_profile: None,
endpoint_url: None,
auth_credentials: BTreeMap::from([(
"bearer_token".to_string(),
"bedrock-token".to_string(),
)]),
env: BTreeMap::new(),
config_source: "test".to_string(),
};
assert_eq!(
client_context_bearer_token(&settings).as_deref(),
Some("bedrock-token")
);
assert_eq!(
diagnostics_metadata(&settings)
.get("bearer_token_source")
.map(String::as_str),
Some("provider_auth_context")
);
}
#[test]
fn dispatch_error_preserves_connector_root_cause() {
let connector = aws_smithy_runtime_api::client::result::ConnectorError::io(Box::new(
std::io::Error::from(std::io::ErrorKind::NetworkUnreachable),
));
let dispatch = aws_smithy_runtime_api::client::result::DispatchFailure::builder()
.source(connector)
.build();
let error = bedrock_dispatch_error(
"bedrock_request_failed",
"Bedrock runtime",
&dispatch,
bcode_model::ProviderFailureCapability::ModelInvocation,
);
assert_eq!(error.category, ProviderErrorCategory::Network);
assert_eq!(
error
.diagnostic_context
.get("connector_error_kind")
.map(String::as_str),
Some("io")
);
assert_eq!(
error
.diagnostic_context
.get("io_error_kind")
.map(String::as_str),
Some("network_unreachable")
);
assert_eq!(
error
.diagnostic_context
.get("root_error_source")
.map(String::as_str),
Some("io_root")
);
assert_eq!(
error
.diagnostic_context
.get("root_error_message")
.map(String::as_str),
Some("network unreachable")
);
assert!(matches!(
error.sources.as_slice(),
[
ProviderErrorSource { source: aws, code: Some(dispatch), .. },
ProviderErrorSource { source: root, code: Some(root_code), .. }
] if aws == "aws_sdk"
&& dispatch == "dispatch"
&& root == "io_root"
&& root_code == "network_unreachable"
));
}
#[test]
fn dispatch_error_classifies_tls_unknown_issuer() {
let tls = rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer);
let connector = aws_smithy_runtime_api::client::result::ConnectorError::io(Box::new(
std::io::Error::new(std::io::ErrorKind::InvalidData, tls),
));
let dispatch = aws_smithy_runtime_api::client::result::DispatchFailure::builder()
.source(connector)
.build();
let error = bedrock_dispatch_error(
"bedrock_request_failed",
"Bedrock runtime",
&dispatch,
bcode_model::ProviderFailureCapability::ModelInvocation,
);
assert_eq!(
error
.diagnostic_context
.get("tls_error_kind")
.map(String::as_str),
Some("certificate_unknown_issuer")
);
assert_eq!(
error
.diagnostic_context
.get("root_error_source")
.map(String::as_str),
Some("rustls_root")
);
}
#[test]
fn dispatch_error_classifies_missing_credentials_as_non_retryable_auth() {
let connector = aws_smithy_runtime_api::client::result::ConnectorError::other(
Box::new(std::io::Error::other(
"no credentials found in chain. Attempted: Environment: the credential provider was not enabled",
)),
None,
);
let dispatch = aws_smithy_runtime_api::client::result::DispatchFailure::builder()
.source(connector)
.build();
let error = bedrock_dispatch_error(
"bedrock_request_failed",
"Bedrock runtime",
&dispatch,
bcode_model::ProviderFailureCapability::ModelInvocation,
);
assert_eq!(error.category, ProviderErrorCategory::Auth);
assert!(!error.retryable);
assert_eq!(
error.message,
"AWS credentials could not be resolved for Bedrock"
);
assert_eq!(
error
.diagnostic_context
.get("auth_failure_kind")
.map(String::as_str),
Some("credential_chain_exhausted")
);
let failure = error.failure.expect("actionable auth failure");
assert_eq!(failure.source, "aws_sdk_credential_chain");
assert!(failure.remediation.contains("AWS_PROFILE"));
}
#[test]
fn config_transport_failure_is_actionable_and_secret_safe() {
let error = bedrock_transport_error("construction", ProviderErrorCategory::Config);
let failure = error.failure.expect("config failure context");
assert_eq!(failure.provider_id, PROVIDER_ID);
assert_eq!(
failure.capability,
bcode_model::ProviderFailureCapability::ModelDiscovery
);
assert!(failure.is_actionable());
assert!(
!serde_json::to_string(&failure)
.expect("failure should encode")
.contains("secret_access_key")
);
}
#[test]
fn persisted_compatibility_updates_timestamps() {
let key = DiscoveryCacheKey {
region: "us-east-1".to_string(),
aws_profile: None,
endpoint_url: None,
bearer_auth: false,
};
let mut compatibility = PersistedCompatibilityCache::default();
compatibility.mark_streaming_tool_unsupported(&key, "model", "first", 10);
compatibility.mark_streaming_tool_unsupported(&key, "model", "second", 20);
let record = compatibility.entries[0]
.unsupported_streaming_tool_models
.get("model")
.expect("model should be recorded");
assert_eq!(record.first_seen_unix_seconds, 10);
assert_eq!(record.last_seen_unix_seconds, 20);
assert_eq!(record.message, "second");
}
#[test]
fn persisted_compatibility_prunes_expired_records() {
let key = DiscoveryCacheKey {
region: "us-east-1".to_string(),
aws_profile: None,
endpoint_url: None,
bearer_auth: false,
};
let mut compatibility = PersistedCompatibilityCache::default();
compatibility.mark_streaming_tool_unsupported(&key, "stale", "old", 1);
compatibility.mark_streaming_tool_unsupported(
&key,
"fresh",
"new",
COMPATIBILITY_CACHE_TTL_SECONDS + 1,
);
compatibility.prune_expired(COMPATIBILITY_CACHE_TTL_SECONDS + 2);
let unsupported = compatibility.unsupported_streaming_for(&key);
assert!(!unsupported.contains("stale"));
assert!(unsupported.contains("fresh"));
}
#[test]
fn persisted_compatibility_save_load_round_trip() {
let root = unique_temp_dir();
let path = root.join("compatibility-cache-v1.json");
let key = DiscoveryCacheKey {
region: "us-east-1".to_string(),
aws_profile: None,
endpoint_url: Some("https://example.com".to_string()),
bearer_auth: false,
};
let mut compatibility = PersistedCompatibilityCache::default();
compatibility.mark_streaming_tool_unsupported(&key, "model", "message", now_unix_seconds());
save_compatibility_cache_to_path(&path, &compatibility).expect("cache should save");
let loaded = load_compatibility_cache_from_path(&path).expect("cache should load");
assert!(loaded.unsupported_streaming_for(&key).contains("model"));
std::fs::remove_dir_all(root).expect("temp dir should clean up");
}
fn unique_temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("bcode-bedrock-test-{nanos}"))
}
}