//! AWS Bedrock LLM provider using the native Converse API. //! //! Uses `aws-sdk-bedrockruntime` to call `client.converse()` directly, //! bypassing the OpenAI-compatible layer. Supports standard AWS auth methods: //! IAM credentials, SSO profiles, and instance roles — all handled //! transparently by the AWS SDK credential chain. use std::collections::HashMap; use std::sync::RwLock; use async_trait::async_trait; use aws_config::{BehaviorVersion, Region}; use aws_sdk_bedrockruntime::Client; use aws_sdk_bedrockruntime::operation::converse::ConverseError; use aws_sdk_bedrockruntime::types::{ AnyToolChoice, AutoToolChoice, ContentBlock, ConversationRole, InferenceConfiguration, Message, StopReason, SystemContentBlock, Tool, ToolChoice, ToolConfiguration, ToolInputSchema, ToolResultBlock, ToolResultContentBlock, ToolResultStatus, ToolSpecification, ToolUseBlock, }; use aws_smithy_types::Document; use rust_decimal::Decimal; use crate::llm::config::BedrockConfig; use crate::llm::error::LlmError; use crate::llm::provider::{ CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, }; /// AWS Bedrock provider using the native Converse API. pub struct BedrockProvider { client: Client, /// Base model ID for display purposes (without prefix). display_model: String, /// Cross-region prefix (e.g. "us.", "global.") or empty. cross_region_prefix: String, /// Active model ID (with cross-region prefix), switchable at runtime via `set_model()`. active_model: RwLock, } impl BedrockProvider { /// Create a new Bedrock provider from configuration. /// /// Async because the AWS SDK config loader requires an async context /// to resolve credentials from SSO profiles, IMDS, etc. pub async fn new(config: &BedrockConfig) -> Result { let cross_region_prefix = config .cross_region .as_ref() .map(|prefix| format!("{}.", prefix)) .unwrap_or_default(); let model_id = format!("{}{}", cross_region_prefix, config.model); let mut builder = aws_config::defaults(BehaviorVersion::latest()) .region(Region::new(config.region.clone())); if let Some(ref profile) = config.profile { builder = builder.profile_name(profile); } let sdk_config = builder.load().await; let client = Client::new(&sdk_config); Ok(Self { client, display_model: config.model.clone(), cross_region_prefix, active_model: RwLock::new(model_id), }) } /// Get the currently active model ID (with cross-region prefix). fn current_model_id(&self) -> String { match self.active_model.read() { Ok(guard) => guard.clone(), Err(poisoned) => { tracing::warn!("active_model lock poisoned while reading; continuing"); poisoned.into_inner().clone() } } } } #[async_trait] impl LlmProvider for BedrockProvider { fn model_name(&self) -> &str { &self.display_model } fn cost_per_token(&self) -> (Decimal, Decimal) { // Bedrock billing is on the AWS bill, not trackable per-token here. (Decimal::ZERO, Decimal::ZERO) } async fn complete(&self, request: CompletionRequest) -> Result { let model_id = self.current_model_id(); let mut messages = request.messages; crate::llm::provider::sanitize_tool_messages(&mut messages); let (system_blocks, bedrock_messages) = convert_messages(&messages)?; if bedrock_messages.is_empty() { return Err(LlmError::RequestFailed { provider: "bedrock".to_string(), reason: "Bedrock requires at least one user or assistant message".to_string(), }); } let mut builder = self .client .converse() .model_id(&model_id) .set_system(if system_blocks.is_empty() { None } else { Some(system_blocks) }) .set_messages(Some(bedrock_messages)); if let Some(config) = build_inference_config( request.temperature, request.max_tokens, request.stop_sequences.as_deref(), ) { builder = builder.inference_config(config); } let response = builder.send().await.map_err(|e| map_sdk_error(&e))?; let (text, _tool_calls) = extract_content_blocks(response.output())?; let (input_tokens, output_tokens) = extract_token_usage(response.usage()); Ok(CompletionResponse { content: text, input_tokens, output_tokens, finish_reason: map_stop_reason(response.stop_reason()), cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }) } async fn complete_with_tools( &self, request: ToolCompletionRequest, ) -> Result { let model_id = self.current_model_id(); let mut messages = request.messages; crate::llm::provider::sanitize_tool_messages(&mut messages); let (system_blocks, bedrock_messages) = convert_messages(&messages)?; if bedrock_messages.is_empty() { return Err(LlmError::RequestFailed { provider: "bedrock".to_string(), reason: "Bedrock requires at least one user or assistant message".to_string(), }); } let tool_config = build_tool_config(&request.tools, request.tool_choice.as_deref())?; let mut builder = self .client .converse() .model_id(&model_id) .set_system(if system_blocks.is_empty() { None } else { Some(system_blocks) }) .set_messages(Some(bedrock_messages)); if let Some(tc) = tool_config { builder = builder.tool_config(tc); } if let Some(config) = build_inference_config( request.temperature, request.max_tokens, request.stop_sequences.as_deref(), ) { builder = builder.inference_config(config); } let response = builder.send().await.map_err(|e| map_sdk_error(&e))?; let (text, tool_calls) = extract_content_blocks(response.output())?; let (input_tokens, output_tokens) = extract_token_usage(response.usage()); Ok(ToolCompletionResponse { content: if text.is_empty() { None } else { Some(text) }, tool_calls, input_tokens, output_tokens, finish_reason: map_stop_reason(response.stop_reason()), cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }) } async fn model_metadata(&self) -> Result { Ok(ModelMetadata { id: self.current_model_id(), context_length: None, }) } fn active_model_name(&self) -> String { self.current_model_id() } fn effective_model_name(&self, _requested_model: Option<&str>) -> String { // Bedrock doesn't support per-request model overrides in Converse API; // the model is part of the request builder, not the message body. self.active_model_name() } fn set_model(&self, model: &str) -> Result<(), LlmError> { let new_id = format!("{}{}", self.cross_region_prefix, model); match self.active_model.write() { Ok(mut guard) => { *guard = new_id; } Err(poisoned) => { tracing::warn!("active_model lock poisoned while writing; continuing"); *poisoned.into_inner() = new_id; } } Ok(()) } } // --------------------------------------------------------------------------- // Inference configuration // --------------------------------------------------------------------------- /// Build an `InferenceConfiguration` from optional temperature and max_tokens. /// Returns `None` if neither is set. fn build_inference_config( temperature: Option, max_tokens: Option, stop_sequences: Option<&[String]>, ) -> Option { let mut builder = InferenceConfiguration::builder(); let mut needs_config = false; if let Some(temp) = temperature { builder = builder.temperature(temp); needs_config = true; } if let Some(tokens) = max_tokens { builder = builder.max_tokens(i32::try_from(tokens).unwrap_or(i32::MAX)); needs_config = true; } if let Some(seqs) = stop_sequences && !seqs.is_empty() { builder = builder.set_stop_sequences(Some(seqs.to_vec())); needs_config = true; } if needs_config { Some(builder.build()) } else { None } } // --------------------------------------------------------------------------- // Message conversion // --------------------------------------------------------------------------- /// Convert IronClaw `ChatMessage` list into Bedrock system blocks + messages. /// /// Key differences from OpenAI/Anthropic protocol: /// 1. System messages are extracted and passed separately. /// 2. Tool results (role=Tool) become `ContentBlock::ToolResult` inside User messages. /// 3. Consecutive tool results are merged into a single User message. /// 4. Bedrock requires strict user/assistant alternation. fn convert_messages( messages: &[crate::llm::provider::ChatMessage], ) -> Result<(Vec, Vec), LlmError> { use crate::llm::provider::Role; let mut system_blocks = Vec::new(); let mut bedrock_messages: Vec = Vec::new(); let mut pending_tool_results: Vec = Vec::new(); for msg in messages { match msg.role { Role::System => { if !msg.content.is_empty() { system_blocks.push(SystemContentBlock::Text(msg.content.clone())); } } Role::User => { // Flush any pending tool results as a User message first flush_tool_results(&mut pending_tool_results, &mut bedrock_messages)?; let content = vec![ContentBlock::Text(msg.content.clone())]; push_message(&mut bedrock_messages, ConversationRole::User, content)?; } Role::Assistant => { // Flush any pending tool results before an assistant message flush_tool_results(&mut pending_tool_results, &mut bedrock_messages)?; let mut content = Vec::new(); // Add text content if non-empty if !msg.content.is_empty() { content.push(ContentBlock::Text(msg.content.clone())); } // Add tool use blocks if present if let Some(ref tool_calls) = msg.tool_calls { for tc in tool_calls { let input_doc = json_to_document(&tc.arguments); let tool_use = ToolUseBlock::builder() .tool_use_id(&tc.id) .name(&tc.name) .input(input_doc) .build() .map_err(|e| LlmError::RequestFailed { provider: "bedrock".to_string(), reason: format!("Failed to build ToolUseBlock: {}", e), })?; content.push(ContentBlock::ToolUse(tool_use)); } } if !content.is_empty() { push_message(&mut bedrock_messages, ConversationRole::Assistant, content)?; } } Role::Tool => { // Accumulate tool results — they'll be flushed as a User message let tool_call_id = msg.tool_call_id.as_deref().unwrap_or("unknown"); let status = if let Ok(json) = serde_json::from_str::(&msg.content) { if json .get("is_error") .and_then(|v| v.as_bool()) .unwrap_or(false) { Some(ToolResultStatus::Error) } else { Some(ToolResultStatus::Success) } } else { Some(ToolResultStatus::Success) }; let tool_result = ToolResultBlock::builder() .tool_use_id(tool_call_id) .content(ToolResultContentBlock::Text(msg.content.clone())) .set_status(status) .build() .map_err(|e| LlmError::RequestFailed { provider: "bedrock".to_string(), reason: format!("Failed to build ToolResultBlock: {}", e), })?; pending_tool_results.push(ContentBlock::ToolResult(tool_result)); } } } // Flush any remaining tool results flush_tool_results(&mut pending_tool_results, &mut bedrock_messages)?; Ok((system_blocks, bedrock_messages)) } /// Flush accumulated tool result blocks as a single User message. fn flush_tool_results( pending: &mut Vec, messages: &mut Vec, ) -> Result<(), LlmError> { if pending.is_empty() { return Ok(()); } let content: Vec = std::mem::take(pending); push_message(messages, ConversationRole::User, content)?; Ok(()) } /// Push a message, enforcing Bedrock's alternation requirement. /// /// If the last message has the same role, merge the content blocks into it /// rather than creating a consecutive same-role message. fn push_message( messages: &mut Vec, role: ConversationRole, content: Vec, ) -> Result<(), LlmError> { if content.is_empty() { return Ok(()); } // Check if we need to merge with the previous message of the same role if let Some(last) = messages.last() && *last.role() == role { // Remove the last message, merge content, and re-push let prev = messages.pop().ok_or_else(|| LlmError::RequestFailed { provider: "bedrock".to_string(), reason: "Unexpected empty message list during merge".to_string(), })?; let mut merged = prev.content().to_vec(); merged.extend(content); let msg = Message::builder() .role(role) .set_content(Some(merged)) .build() .map_err(|e| LlmError::RequestFailed { provider: "bedrock".to_string(), reason: format!("Failed to build merged Message: {}", e), })?; messages.push(msg); return Ok(()); } let msg = Message::builder() .role(role) .set_content(Some(content)) .build() .map_err(|e| LlmError::RequestFailed { provider: "bedrock".to_string(), reason: format!("Failed to build Message: {}", e), })?; messages.push(msg); Ok(()) } // --------------------------------------------------------------------------- // Tool configuration // --------------------------------------------------------------------------- /// Build Bedrock `ToolConfiguration` from IronClaw tool definitions. fn build_tool_config( tools: &[ToolDefinition], tool_choice: Option<&str>, ) -> Result, LlmError> { if tools.is_empty() { return Ok(None); } let bedrock_tools: Vec = tools .iter() .map(|td| { let input_schema = ToolInputSchema::Json(json_to_document(&td.parameters)); let spec = ToolSpecification::builder() .name(&td.name) .description(&td.description) .input_schema(input_schema) .build() .map_err(|e| LlmError::RequestFailed { provider: "bedrock".to_string(), reason: format!("Failed to build ToolSpecification: {}", e), })?; Ok(Tool::ToolSpec(spec)) }) .collect::, LlmError>>()?; let choice = match tool_choice { Some("none") => { // If tool_choice is "none", don't send tool config at all return Ok(None); } Some("required") => Some(ToolChoice::Any(AnyToolChoice::builder().build())), // "auto" or anything else _ => Some(ToolChoice::Auto(AutoToolChoice::builder().build())), }; let mut builder = ToolConfiguration::builder().set_tools(Some(bedrock_tools)); if let Some(c) = choice { builder = builder.tool_choice(c); } let config = builder.build().map_err(|e| LlmError::RequestFailed { provider: "bedrock".to_string(), reason: format!("Failed to build ToolConfiguration: {}", e), })?; Ok(Some(config)) } // --------------------------------------------------------------------------- // Response extraction // --------------------------------------------------------------------------- /// Extract text content and tool calls from the Converse response output. fn extract_content_blocks( output: Option<&aws_sdk_bedrockruntime::types::ConverseOutput>, ) -> Result<(String, Vec), LlmError> { let output = output.ok_or_else(|| LlmError::RequestFailed { provider: "bedrock".to_string(), reason: "Converse response has no output".to_string(), })?; let message = output.as_message().map_err(|_| LlmError::RequestFailed { provider: "bedrock".to_string(), reason: "Converse output is not a message".to_string(), })?; let mut text_parts = Vec::new(); let mut tool_calls = Vec::new(); for block in message.content() { match block { ContentBlock::Text(t) => { text_parts.push(t.clone()); } ContentBlock::ToolUse(tu) => { tool_calls.push(ToolCall { id: tu.tool_use_id().to_string(), name: tu.name().to_string(), arguments: document_to_json(tu.input()), reasoning: None, }); } // Ignore reasoning, citations, images, etc. _ => {} } } Ok((text_parts.join(""), tool_calls)) } /// Extract token usage from the response, converting i32 → u32 safely. fn extract_token_usage(usage: Option<&aws_sdk_bedrockruntime::types::TokenUsage>) -> (u32, u32) { match usage { Some(u) => ( u32::try_from(u.input_tokens()).unwrap_or(0), u32::try_from(u.output_tokens()).unwrap_or(0), ), None => (0, 0), } } /// Map Bedrock `StopReason` to IronClaw `FinishReason`. fn map_stop_reason(reason: &StopReason) -> FinishReason { match reason { StopReason::EndTurn | StopReason::StopSequence => FinishReason::Stop, StopReason::ToolUse => FinishReason::ToolUse, StopReason::MaxTokens | StopReason::ModelContextWindowExceeded => FinishReason::Length, StopReason::ContentFiltered | StopReason::GuardrailIntervened => { FinishReason::ContentFilter } _ => FinishReason::Unknown, } } // --------------------------------------------------------------------------- // Error mapping // --------------------------------------------------------------------------- /// Map AWS SDK errors to `LlmError`. fn map_sdk_error( error: &aws_sdk_bedrockruntime::error::SdkError, ) -> LlmError { use aws_sdk_bedrockruntime::error::SdkError; match error { SdkError::ServiceError(service_err) => { let msg = match service_err.err() { ConverseError::ModelTimeoutException(e) => { format!("Model timeout: {}", e.message().unwrap_or("unknown")) } ConverseError::ModelNotReadyException(e) => { format!("Model not ready: {}", e.message().unwrap_or("unknown")) } ConverseError::ThrottlingException(e) => { format!("Throttled: {}", e.message().unwrap_or("unknown")) } ConverseError::ValidationException(e) => { format!("Validation error: {}", e.message().unwrap_or("unknown")) } ConverseError::AccessDeniedException(e) => { format!("Access denied: {}", e.message().unwrap_or("unknown")) } ConverseError::ResourceNotFoundException(e) => { format!("Resource not found: {}", e.message().unwrap_or("unknown")) } ConverseError::ModelErrorException(e) => { format!("Model error: {}", e.message().unwrap_or("unknown")) } ConverseError::InternalServerException(e) => { format!( "Internal server error: {}", e.message().unwrap_or("unknown") ) } ConverseError::ServiceUnavailableException(e) => { format!("Service unavailable: {}", e.message().unwrap_or("unknown")) } _ => format!("Bedrock service error: {}", service_err.err()), }; LlmError::RequestFailed { provider: "bedrock".to_string(), reason: msg, } } SdkError::TimeoutError(_) => LlmError::RequestFailed { provider: "bedrock".to_string(), reason: "Request timed out".to_string(), }, SdkError::DispatchFailure(e) => LlmError::RequestFailed { provider: "bedrock".to_string(), reason: format!("Connection error: {:?}", e), }, _ => LlmError::RequestFailed { provider: "bedrock".to_string(), reason: format!("AWS SDK error: {}", error), }, } } // --------------------------------------------------------------------------- // Document ↔ serde_json::Value conversion // --------------------------------------------------------------------------- /// Convert `serde_json::Value` to `aws_smithy_types::Document`. pub(crate) fn json_to_document(value: &serde_json::Value) -> Document { match value { serde_json::Value::Null => Document::Null, serde_json::Value::Bool(b) => Document::Bool(*b), serde_json::Value::Number(n) => { if let Some(u) = n.as_u64() { Document::Number(aws_smithy_types::Number::PosInt(u)) } else if let Some(i) = n.as_i64() { Document::Number(aws_smithy_types::Number::NegInt(i)) } else if let Some(f) = n.as_f64() { Document::Number(aws_smithy_types::Number::Float(f)) } else { Document::Null } } serde_json::Value::String(s) => Document::String(s.clone()), serde_json::Value::Array(arr) => { Document::Array(arr.iter().map(json_to_document).collect()) } serde_json::Value::Object(obj) => { let map: HashMap = obj .iter() .map(|(k, v)| (k.clone(), json_to_document(v))) .collect(); Document::Object(map) } } } /// Convert `aws_smithy_types::Document` to `serde_json::Value`. pub(crate) fn document_to_json(doc: &Document) -> serde_json::Value { match doc { Document::Null => serde_json::Value::Null, Document::Bool(b) => serde_json::Value::Bool(*b), Document::Number(n) => match n { aws_smithy_types::Number::PosInt(u) => { serde_json::Value::Number(serde_json::Number::from(*u)) } aws_smithy_types::Number::NegInt(i) => { serde_json::Value::Number(serde_json::Number::from(*i)) } aws_smithy_types::Number::Float(f) => serde_json::Number::from_f64(*f) .map(serde_json::Value::Number) .unwrap_or(serde_json::Value::Null), }, Document::String(s) => serde_json::Value::String(s.clone()), Document::Array(arr) => { serde_json::Value::Array(arr.iter().map(document_to_json).collect()) } Document::Object(obj) => { let map: serde_json::Map = obj .iter() .map(|(k, v)| (k.clone(), document_to_json(v))) .collect(); serde_json::Value::Object(map) } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::llm::provider::{ChatMessage, Role}; #[test] fn test_json_to_document_round_trip() { let json = serde_json::json!({ "name": "test", "count": 42, "negative": -7, "ratio": 3.125, "active": true, "nothing": null, "tags": ["a", "b"], "nested": {"x": 1} }); let doc = json_to_document(&json); let back = document_to_json(&doc); assert_eq!(json, back); } #[test] fn test_json_to_document_empty_object() { let json = serde_json::json!({}); let doc = json_to_document(&json); let back = document_to_json(&doc); assert_eq!(json, back); } #[test] fn test_convert_messages_system_extraction() { let messages = vec![ ChatMessage::system("You are helpful."), ChatMessage::system("Be concise."), ChatMessage::user("Hello"), ]; let (system, msgs) = convert_messages(&messages).unwrap(); assert_eq!(system.len(), 2); assert_eq!(msgs.len(), 1); assert_eq!(*msgs[0].role(), ConversationRole::User); } #[test] fn test_convert_messages_basic_conversation() { let messages = vec![ ChatMessage::user("Hi"), ChatMessage::assistant("Hello!"), ChatMessage::user("How are you?"), ]; let (system, msgs) = convert_messages(&messages).unwrap(); assert!(system.is_empty()); assert_eq!(msgs.len(), 3); assert_eq!(*msgs[0].role(), ConversationRole::User); assert_eq!(*msgs[1].role(), ConversationRole::Assistant); assert_eq!(*msgs[2].role(), ConversationRole::User); } #[test] fn test_convert_messages_tool_results_merge_into_user() { let tc = crate::llm::provider::ToolCall { id: "call_1".to_string(), name: "echo".to_string(), arguments: serde_json::json!({"text": "hi"}), reasoning: None, }; let tc2 = crate::llm::provider::ToolCall { id: "call_2".to_string(), name: "time".to_string(), arguments: serde_json::json!({}), reasoning: None, }; let messages = vec![ ChatMessage::user("Do things"), ChatMessage::assistant_with_tool_calls(None, vec![tc, tc2]), ChatMessage::tool_result("call_1", "echo", "hi back"), ChatMessage::tool_result("call_2", "time", "12:00"), ]; let (_, msgs) = convert_messages(&messages).unwrap(); // user, assistant (with tool_use), user (with merged tool_results) assert_eq!(msgs.len(), 3); assert_eq!(*msgs[2].role(), ConversationRole::User); // The merged user message should have 2 content blocks (both ToolResult) assert_eq!(msgs[2].content().len(), 2); assert!(msgs[2].content()[0].is_tool_result()); assert!(msgs[2].content()[1].is_tool_result()); } #[test] fn test_convert_messages_consecutive_users_merge() { let messages = vec![ChatMessage::user("First"), ChatMessage::user("Second")]; let (_, msgs) = convert_messages(&messages).unwrap(); // Should merge into a single User message with 2 text blocks assert_eq!(msgs.len(), 1); assert_eq!(*msgs[0].role(), ConversationRole::User); assert_eq!(msgs[0].content().len(), 2); } #[test] fn test_convert_messages_assistant_with_tool_calls() { let tc = crate::llm::provider::ToolCall { id: "call_1".to_string(), name: "search".to_string(), arguments: serde_json::json!({"query": "test"}), reasoning: None, }; let messages = vec![ ChatMessage::user("Search for test"), ChatMessage::assistant_with_tool_calls(Some("Let me search.".to_string()), vec![tc]), ]; let (_, msgs) = convert_messages(&messages).unwrap(); assert_eq!(msgs.len(), 2); assert_eq!(*msgs[1].role(), ConversationRole::Assistant); // Should have text + tool_use assert_eq!(msgs[1].content().len(), 2); assert!(msgs[1].content()[0].is_text()); assert!(msgs[1].content()[1].is_tool_use()); } #[test] fn test_convert_messages_empty_assistant_content_with_tool_calls() { let tc = crate::llm::provider::ToolCall { id: "call_1".to_string(), name: "echo".to_string(), arguments: serde_json::json!({}), reasoning: None, }; let messages = vec![ ChatMessage::user("Go"), ChatMessage::assistant_with_tool_calls(None, vec![tc]), ]; let (_, msgs) = convert_messages(&messages).unwrap(); assert_eq!(msgs.len(), 2); // Empty text should not add a Text block let assistant_content = msgs[1].content(); assert_eq!(assistant_content.len(), 1); assert!(assistant_content[0].is_tool_use()); } #[test] fn test_build_tool_config_empty_tools() { let result = build_tool_config(&[], None).unwrap(); assert!(result.is_none()); } #[test] fn test_build_tool_config_none_choice() { let result = build_tool_config(&[], Some("none")).unwrap(); assert!(result.is_none()); } #[test] fn test_build_tool_config_with_tools() { let tools = vec![ToolDefinition { name: "echo".to_string(), description: "Echoes input".to_string(), parameters: serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} } }), }]; let result = build_tool_config(&tools, Some("auto")).unwrap(); assert!(result.is_some()); } #[test] fn test_map_stop_reason() { assert_eq!(map_stop_reason(&StopReason::EndTurn), FinishReason::Stop); assert_eq!( map_stop_reason(&StopReason::StopSequence), FinishReason::Stop ); assert_eq!(map_stop_reason(&StopReason::ToolUse), FinishReason::ToolUse); assert_eq!( map_stop_reason(&StopReason::MaxTokens), FinishReason::Length ); assert_eq!( map_stop_reason(&StopReason::ContentFiltered), FinishReason::ContentFilter ); } #[test] fn test_model_id_with_cross_region() { // Simulate what the constructor does let prefix = "us."; let model = "anthropic.claude-opus-4-6-v1"; let model_id = format!("{}{}", prefix, model); assert_eq!(model_id, "us.anthropic.claude-opus-4-6-v1"); } #[test] fn test_model_id_without_cross_region() { let prefix = ""; let model = "anthropic.claude-opus-4-6-v1"; let model_id = format!("{}{}", prefix, model); assert_eq!(model_id, "anthropic.claude-opus-4-6-v1"); } #[test] fn test_convert_messages_tool_result_after_regular_user() { // Edge case: tool result appears after a user message (from sanitize_tool_messages rewrite) // This shouldn't happen normally but we should handle it gracefully let messages = vec![ ChatMessage::user("Hello"), ChatMessage { role: Role::Tool, content: "result".to_string(), tool_call_id: Some("call_1".to_string()), name: Some("echo".to_string()), tool_calls: None, content_parts: Vec::new(), }, ]; let (_, msgs) = convert_messages(&messages).unwrap(); // User + tool result (as user) = should merge into one User message assert_eq!(msgs.len(), 1); assert_eq!(*msgs[0].role(), ConversationRole::User); } #[test] fn test_extract_token_usage_present() { let usage = aws_sdk_bedrockruntime::types::TokenUsage::builder() .input_tokens(150) .output_tokens(42) .total_tokens(192) .build() .unwrap(); let (input, output) = extract_token_usage(Some(&usage)); assert_eq!(input, 150); assert_eq!(output, 42); } #[test] fn test_extract_token_usage_none() { let (input, output) = extract_token_usage(None); assert_eq!(input, 0); assert_eq!(output, 0); } #[test] fn test_extract_token_usage_negative_clamps_to_zero() { // Bedrock uses i32; negative values should not panic let usage = aws_sdk_bedrockruntime::types::TokenUsage::builder() .input_tokens(-1) .output_tokens(-5) .total_tokens(0) .build() .unwrap(); let (input, output) = extract_token_usage(Some(&usage)); assert_eq!(input, 0); assert_eq!(output, 0); } #[test] fn test_json_to_document_nested_arrays() { let json = serde_json::json!([[1, 2], [3, 4]]); let doc = json_to_document(&json); let back = document_to_json(&doc); assert_eq!(json, back); } #[test] fn test_json_to_document_large_numbers() { let json = serde_json::json!({ "big_pos": u64::MAX, "big_neg": i64::MIN, }); let doc = json_to_document(&json); let back = document_to_json(&doc); assert_eq!(json, back); } #[test] fn test_full_tool_round_trip_conversation() { // Simulate a complete tool-use conversation: // system → user → assistant(tool_calls) → tool_results → user follow-up let tc1 = crate::llm::provider::ToolCall { id: "call_abc".to_string(), name: "get_weather".to_string(), arguments: serde_json::json!({"city": "NYC"}), reasoning: None, }; let tc2 = crate::llm::provider::ToolCall { id: "call_def".to_string(), name: "get_time".to_string(), arguments: serde_json::json!({"tz": "EST"}), reasoning: None, }; let messages = vec![ ChatMessage::system("You are a helpful assistant."), ChatMessage::user("What's the weather and time in NYC?"), ChatMessage::assistant_with_tool_calls( Some("Let me check both.".to_string()), vec![tc1, tc2], ), ChatMessage::tool_result("call_abc", "get_weather", "72°F and sunny"), ChatMessage::tool_result("call_def", "get_time", "3:45 PM EST"), ChatMessage::user("Thanks! What about tomorrow?"), ]; let (system, msgs) = convert_messages(&messages).unwrap(); // 1 system block assert_eq!(system.len(), 1); // Messages: user, assistant(text+2 tool_use), user(2 tool_results + follow-up text merged) // The follow-up user message "Thanks!" merges into the tool_results User message // because Bedrock requires strict user/assistant alternation. assert_eq!(msgs.len(), 3); // msg[0]: user "What's the weather..." assert_eq!(*msgs[0].role(), ConversationRole::User); assert_eq!(msgs[0].content().len(), 1); assert!(msgs[0].content()[0].is_text()); // msg[1]: assistant with text + 2 tool_use blocks assert_eq!(*msgs[1].role(), ConversationRole::Assistant); assert_eq!(msgs[1].content().len(), 3); // text + 2 tool_use assert!(msgs[1].content()[0].is_text()); assert!(msgs[1].content()[1].is_tool_use()); assert!(msgs[1].content()[2].is_tool_use()); // Verify tool_use IDs and arguments survived conversion let tu1 = msgs[1].content()[1].as_tool_use().unwrap(); assert_eq!(tu1.tool_use_id(), "call_abc"); assert_eq!(tu1.name(), "get_weather"); let args1 = document_to_json(tu1.input()); assert_eq!(args1, serde_json::json!({"city": "NYC"})); let tu2 = msgs[1].content()[2].as_tool_use().unwrap(); assert_eq!(tu2.tool_use_id(), "call_def"); assert_eq!(tu2.name(), "get_time"); // msg[2]: user with 2 tool_result blocks + merged follow-up text // Tool results are User-role, and "Thanks!" is also User-role, so they merge. assert_eq!(*msgs[2].role(), ConversationRole::User); assert_eq!(msgs[2].content().len(), 3); // 2 tool_results + 1 text assert!(msgs[2].content()[0].is_tool_result()); assert!(msgs[2].content()[1].is_tool_result()); assert!(msgs[2].content()[2].is_text()); // Verify tool_result IDs and content let tr1 = msgs[2].content()[0].as_tool_result().unwrap(); assert_eq!(tr1.tool_use_id(), "call_abc"); assert_eq!(tr1.content().len(), 1); let tr2 = msgs[2].content()[1].as_tool_result().unwrap(); assert_eq!(tr2.tool_use_id(), "call_def"); } #[test] fn test_convert_messages_empty_input() { let (system, msgs) = convert_messages(&[]).unwrap(); assert!(system.is_empty()); assert!(msgs.is_empty()); } #[test] fn test_convert_messages_system_only() { let messages = vec![ChatMessage::system("You are helpful.")]; let (system, msgs) = convert_messages(&messages).unwrap(); assert_eq!(system.len(), 1); assert!(msgs.is_empty()); } #[test] fn test_build_tool_config_required_choice() { let tools = vec![ToolDefinition { name: "echo".to_string(), description: "Echoes".to_string(), parameters: serde_json::json!({"type": "object"}), }]; let result = build_tool_config(&tools, Some("required")).unwrap(); assert!(result.is_some()); } #[test] fn test_map_stop_reason_all_variants() { assert_eq!( map_stop_reason(&StopReason::GuardrailIntervened), FinishReason::ContentFilter ); assert_eq!( map_stop_reason(&StopReason::ModelContextWindowExceeded), FinishReason::Length ); } #[test] fn test_build_inference_config_none_none() { assert!(build_inference_config(None, None, None).is_none()); } #[test] fn test_build_inference_config_temperature_only() { let config = build_inference_config(Some(0.7), None, None); assert!(config.is_some()); } #[test] fn test_build_inference_config_max_tokens_only() { let config = build_inference_config(None, Some(1024), None); assert!(config.is_some()); } #[test] fn test_build_inference_config_both() { let config = build_inference_config(Some(0.5), Some(2048), None); assert!(config.is_some()); } #[test] fn test_build_inference_config_max_tokens_overflow() { // u32::MAX exceeds i32::MAX, should clamp to i32::MAX not wrap let config = build_inference_config(None, Some(u32::MAX), None).unwrap(); // Just verify it builds without panic — the clamped value is inside the opaque struct let _ = config; } #[test] fn test_build_inference_config_stop_sequences() { let seqs = vec!["STOP".to_string(), "END".to_string()]; let config = build_inference_config(None, None, Some(&seqs)); assert!(config.is_some()); } #[test] fn test_build_inference_config_empty_stop_sequences_ignored() { let seqs: Vec = vec![]; let config = build_inference_config(None, None, Some(&seqs)); assert!(config.is_none()); } #[test] fn test_empty_messages_returns_error() { let messages = vec![ChatMessage::system("System only, no user messages")]; let (_, bedrock_msgs) = convert_messages(&messages).unwrap(); assert!(bedrock_msgs.is_empty()); } }