use std::collections::{HashMap, HashSet};
use aws_sdk_bedrockruntime::operation::converse::ConverseOutput as ConverseResponse;
use aws_sdk_bedrockruntime::types::{
AnyToolChoice, AutoToolChoice, ContentBlock, ConversationRole, Message, SpecificToolChoice,
SystemContentBlock, Tool, ToolChoice as SdkToolChoice, ToolConfiguration, ToolInputSchema,
ToolResultBlock, ToolResultContentBlock, ToolResultStatus, ToolSpecification, ToolUseBlock,
};
use aws_smithy_types::{Document, Number};
use serde_json::Value;
use super::cache;
use crate::inference::error::InferenceError;
use crate::inference::types::{
AssistantMessage, ChatChoice, ChatMessage, ChatRequest, ChatResponse, FunctionCall, ToolCall,
ToolDefinition, UsageBlock,
};
pub(super) fn build_converse_messages(
req: &ChatRequest,
) -> Result<(Vec<SystemContentBlock>, Vec<Message>), InferenceError> {
let mut system_blocks = Vec::new();
let mut messages = Vec::new();
let mut current_role: Option<ConversationRole> = None;
let mut current_blocks: Vec<ContentBlock> = Vec::new();
for msg in &req.messages {
if msg.role == "system" {
flush_message(&mut messages, &mut current_role, &mut current_blocks)?;
if let Some(text) = &msg.content {
system_blocks.push(SystemContentBlock::Text(text.clone()));
if msg.cache_control.is_some() && cache::system_cacheable(text) {
system_blocks.push(SystemContentBlock::CachePoint(cache::cache_point_block()));
}
}
continue;
}
let role = if msg.role == "assistant" {
ConversationRole::Assistant
} else {
ConversationRole::User
};
if current_role.as_ref() != Some(&role) {
flush_message(&mut messages, &mut current_role, &mut current_blocks)?;
current_role = Some(role);
}
append_content_blocks(msg, &mut current_blocks)?;
if msg.cache_control.is_some() {
current_blocks.push(ContentBlock::CachePoint(cache::cache_point_block()));
}
}
flush_message(&mut messages, &mut current_role, &mut current_blocks)?;
enforce_tool_pairing(&mut messages)?;
Ok((system_blocks, messages))
}
fn enforce_tool_pairing(messages: &mut Vec<Message>) -> Result<(), InferenceError> {
let mut introduced: HashSet<String> = HashSet::new();
for msg in messages.iter_mut() {
let role = msg.role().clone();
let mut kept = Vec::with_capacity(msg.content().len());
for block in msg.content() {
match block {
ContentBlock::ToolUse(tu) => {
introduced.insert(tu.tool_use_id().to_string());
kept.push(block.clone());
}
ContentBlock::ToolResult(tr) if !introduced.contains(tr.tool_use_id()) => {
}
other => kept.push(other.clone()),
}
}
*msg = Message::builder()
.role(role)
.set_content(Some(kept))
.build()
.map_err(|e| InferenceError::Provider(format!("rebuild Message: {e}")))?;
}
let mut missing_by_index: Vec<Vec<String>> = vec![Vec::new(); messages.len()];
for (i, msg) in messages.iter().enumerate() {
let own_ids: Vec<String> = msg
.content()
.iter()
.filter_map(|b| match b {
ContentBlock::ToolUse(tu) => Some(tu.tool_use_id().to_string()),
_ => None,
})
.collect();
if own_ids.is_empty() {
continue;
}
let answered: HashSet<&str> = messages
.get(i + 1)
.map(|next| {
next.content()
.iter()
.filter_map(|b| match b {
ContentBlock::ToolResult(tr) => Some(tr.tool_use_id()),
_ => None,
})
.collect()
})
.unwrap_or_default();
missing_by_index[i] = own_ids
.into_iter()
.filter(|id| !answered.contains(id.as_str()))
.collect();
}
for i in (0..messages.len()).rev() {
if missing_by_index[i].is_empty() {
continue;
}
let placeholders = missing_by_index[i]
.iter()
.map(|id| placeholder_tool_result(id))
.collect::<Result<Vec<_>, _>>()?;
let append_to_next = messages
.get(i + 1)
.is_some_and(|next| next.role() == &ConversationRole::User);
if append_to_next {
let mut content = messages[i + 1].content().to_vec();
content.extend(placeholders);
messages[i + 1] = Message::builder()
.role(ConversationRole::User)
.set_content(Some(content))
.build()
.map_err(|e| InferenceError::Provider(format!("rebuild Message: {e}")))?;
} else {
let new_msg = Message::builder()
.role(ConversationRole::User)
.set_content(Some(placeholders))
.build()
.map_err(|e| InferenceError::Provider(format!("build placeholder Message: {e}")))?;
messages.insert(i + 1, new_msg);
}
}
Ok(())
}
fn placeholder_tool_result(tool_use_id: &str) -> Result<ContentBlock, InferenceError> {
let block = ToolResultBlock::builder()
.tool_use_id(tool_use_id)
.content(ToolResultContentBlock::Text(
"[result unavailable — history was compacted]".to_string(),
))
.status(ToolResultStatus::Error)
.build()
.map_err(|e| InferenceError::Provider(format!("build placeholder ToolResultBlock: {e}")))?;
Ok(ContentBlock::ToolResult(block))
}
fn append_content_blocks(
msg: &ChatMessage,
blocks: &mut Vec<ContentBlock>,
) -> Result<(), InferenceError> {
match msg.role.as_str() {
"assistant" => {
if let Some(text) = &msg.content
&& !text.is_empty()
{
blocks.push(ContentBlock::Text(text.clone()));
}
if let Some(calls) = &msg.tool_calls {
for call in calls {
blocks.push(ContentBlock::ToolUse(tool_use_block(call)?));
}
}
}
"tool" => {
let tool_use_id = msg.tool_call_id.clone().unwrap_or_default();
let content_text = msg.content.clone().unwrap_or_default();
let result = ToolResultBlock::builder()
.tool_use_id(tool_use_id)
.content(ToolResultContentBlock::Text(content_text))
.build()
.map_err(|e| InferenceError::Provider(format!("build ToolResultBlock: {e}")))?;
blocks.push(ContentBlock::ToolResult(result));
}
_ => {
if let Some(text) = &msg.content {
blocks.push(ContentBlock::Text(text.clone()));
}
}
}
Ok(())
}
fn tool_use_block(call: &ToolCall) -> Result<ToolUseBlock, InferenceError> {
let parsed: Value = serde_json::from_str(&call.function.arguments)
.unwrap_or_else(|_| Value::Object(Default::default()));
let input = json_to_document(&parsed).unwrap_or_else(|| Document::Object(HashMap::new()));
ToolUseBlock::builder()
.tool_use_id(&call.id)
.name(&call.function.name)
.input(input)
.build()
.map_err(|e| InferenceError::Provider(format!("build ToolUseBlock: {e}")))
}
fn flush_message(
messages: &mut Vec<Message>,
current_role: &mut Option<ConversationRole>,
blocks: &mut Vec<ContentBlock>,
) -> Result<(), InferenceError> {
if blocks.is_empty() {
return Ok(());
}
let role = current_role.take().unwrap_or(ConversationRole::User);
let message = Message::builder()
.role(role)
.set_content(Some(std::mem::take(blocks)))
.build()
.map_err(|e| InferenceError::Provider(format!("build Message: {e}")))?;
messages.push(message);
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
enum ToolChoiceDecision {
Auto,
Any,
Named(String),
Suppress,
}
fn interpret_tool_choice(value: Option<&Value>) -> ToolChoiceDecision {
let Some(value) = value else {
return ToolChoiceDecision::Auto;
};
match value {
Value::String(s) => match s.as_str() {
"auto" => ToolChoiceDecision::Auto,
"required" | "any" => ToolChoiceDecision::Any,
"none" => ToolChoiceDecision::Suppress,
_ => ToolChoiceDecision::Auto,
},
Value::Object(map) => {
if let Some(name) = map
.get("tool")
.and_then(|t| t.get("name"))
.and_then(Value::as_str)
{
return ToolChoiceDecision::Named(name.to_string());
}
if let Some(name) = map
.get("function")
.and_then(|f| f.get("name"))
.and_then(Value::as_str)
{
return ToolChoiceDecision::Named(name.to_string());
}
if map.contains_key("any") {
return ToolChoiceDecision::Any;
}
ToolChoiceDecision::Auto
}
_ => ToolChoiceDecision::Auto,
}
}
pub(super) fn build_tool_config(
tools: &[ToolDefinition],
tool_choice: Option<&Value>,
) -> Result<Option<ToolConfiguration>, InferenceError> {
if tools.is_empty() {
return Ok(None);
}
let decision = interpret_tool_choice(tool_choice);
if decision == ToolChoiceDecision::Suppress {
return Ok(None);
}
let mut builder = ToolConfiguration::builder();
for def in tools {
let schema = def
.function
.parameters
.clone()
.unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}}));
let doc = json_to_document(&schema).ok_or_else(|| {
InferenceError::Provider(format!(
"tool {:?} parameters must be a JSON object",
def.function.name
))
})?;
let spec = ToolSpecification::builder()
.name(&def.function.name)
.set_description(def.function.description.clone())
.input_schema(ToolInputSchema::Json(doc))
.build()
.map_err(|e| InferenceError::Provider(format!("build ToolSpecification: {e}")))?;
builder = builder.tools(Tool::ToolSpec(spec));
}
if tools
.last()
.is_some_and(|def| def.function.cache_control.is_some())
&& cache::tools_cacheable(tools)
{
builder = builder.tools(Tool::CachePoint(cache::cache_point_block()));
}
let sdk_choice = match decision {
ToolChoiceDecision::Auto => SdkToolChoice::Auto(AutoToolChoice::builder().build()),
ToolChoiceDecision::Any => SdkToolChoice::Any(AnyToolChoice::builder().build()),
ToolChoiceDecision::Named(name) => SdkToolChoice::Tool(
SpecificToolChoice::builder()
.name(name)
.build()
.map_err(|e| InferenceError::Provider(format!("build SpecificToolChoice: {e}")))?,
),
ToolChoiceDecision::Suppress => unreachable!("handled above"),
};
builder = builder.tool_choice(sdk_choice);
builder
.build()
.map(Some)
.map_err(|e| InferenceError::Provider(format!("build ToolConfiguration: {e}")))
}
pub(super) fn converse_output_to_chat_response(
output: &ConverseResponse,
requested_model: &str,
) -> ChatResponse {
let mut text = String::new();
let mut tool_calls = Vec::new();
if let Some(msg) = output.output().and_then(|o| o.as_message().ok()) {
for block in msg.content() {
match block {
ContentBlock::Text(t) => {
if !text.is_empty() {
text.push('\n');
}
text.push_str(t);
}
ContentBlock::ToolUse(tu) => {
let arguments =
document_to_json_string(tu.input()).unwrap_or_else(|| "{}".to_string());
tool_calls.push(ToolCall {
id: tu.tool_use_id().to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: tu.name().to_string(),
arguments,
},
});
}
_ => {}
}
}
}
let content = if text.is_empty() { None } else { Some(text) };
let finish_reason = Some(output.stop_reason().as_str().to_ascii_lowercase());
let usage = output
.usage()
.map(|u| UsageBlock {
prompt_tokens: u.input_tokens().max(0) as u32,
completion_tokens: u.output_tokens().max(0) as u32,
total_tokens: u.total_tokens().max(0) as u32,
cache_read_input_tokens: u.cache_read_input_tokens().unwrap_or(0).max(0) as u32,
cache_creation_input_tokens: u.cache_write_input_tokens().unwrap_or(0).max(0) as u32,
prompt_tokens_details: None,
cost: None,
})
.unwrap_or_default();
ChatResponse {
id: String::new(),
model: requested_model.to_string(),
choices: vec![ChatChoice {
message: AssistantMessage {
content,
tool_calls,
},
finish_reason,
}],
usage,
}
}
pub(super) fn json_to_document(value: &Value) -> Option<Document> {
match value {
Value::Object(map) => Some(Document::Object(
map.iter()
.map(|(k, v)| (k.clone(), json_value_to_doc(v)))
.collect(),
)),
_ => None,
}
}
fn json_value_to_doc(v: &Value) -> Document {
match v {
Value::Null => Document::Null,
Value::Bool(b) => Document::Bool(*b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Document::Number(Number::NegInt(i))
} else if let Some(u) = n.as_u64() {
Document::Number(Number::PosInt(u))
} else {
Document::Number(Number::Float(n.as_f64().unwrap_or(0.0)))
}
}
Value::String(s) => Document::String(s.clone()),
Value::Array(arr) => Document::Array(arr.iter().map(json_value_to_doc).collect()),
Value::Object(map) => Document::Object(
map.iter()
.map(|(k, v)| (k.clone(), json_value_to_doc(v)))
.collect(),
),
}
}
pub(super) fn document_to_json_string(doc: &Document) -> Option<String> {
serde_json::to_string(&doc_to_json_value(doc)).ok()
}
fn doc_to_json_value(doc: &Document) -> Value {
match doc {
Document::Null => Value::Null,
Document::Bool(b) => Value::Bool(*b),
Document::Number(n) => match n {
Number::PosInt(u) => Value::Number((*u).into()),
Number::NegInt(i) => Value::Number((*i).into()),
Number::Float(f) => serde_json::Number::from_f64(*f)
.map(Value::Number)
.unwrap_or(Value::Null),
},
Document::String(s) => Value::String(s.clone()),
Document::Array(arr) => Value::Array(arr.iter().map(doc_to_json_value).collect()),
Document::Object(map) => Value::Object(
map.iter()
.map(|(k, v)| (k.clone(), doc_to_json_value(v)))
.collect(),
),
}
}