use serde_json::{Map, Value, json};
use crate::inference::types::{ChatRequest, ToolChoice, ToolDefinition};
pub fn build_body(request: &ChatRequest, default_max_tokens: u32) -> Value {
let mut system_blocks: Vec<Value> = Vec::new();
let mut system_has_cache = false;
let mut turns: Vec<(&'static str, Vec<Value>)> = Vec::new();
for msg in &request.messages {
match msg.role.as_str() {
"system" => {
if let Some(text) = &msg.content {
let mut block = json!({ "type": "text", "text": text });
if msg.cache_control.is_some() {
system_has_cache = true;
block["cache_control"] = json!({ "type": "ephemeral" });
}
system_blocks.push(block);
}
}
"tool" => {
let mut block = json!({
"type": "tool_result",
"tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
"content": msg.content.clone().unwrap_or_default(),
});
if msg.cache_control.is_some() {
block["cache_control"] = json!({ "type": "ephemeral" });
}
push_turn(&mut turns, "user", vec![block]);
}
role => {
let is_assistant = role == "assistant";
let mut blocks: Vec<Value> = Vec::new();
if let Some(text) = &msg.content {
blocks.push(json!({ "type": "text", "text": text }));
}
if let Some(calls) = msg.tool_calls.as_ref().filter(|_| is_assistant) {
for call in calls {
let input: Value = serde_json::from_str(&call.function.arguments)
.unwrap_or_else(|_| json!({}));
blocks.push(json!({
"type": "tool_use",
"id": call.id,
"name": call.function.name,
"input": input,
}));
}
}
if blocks.is_empty() {
continue;
}
if let Some(last) = blocks.last_mut().filter(|_| msg.cache_control.is_some()) {
last["cache_control"] = json!({ "type": "ephemeral" });
}
let anth_role = if is_assistant { "assistant" } else { "user" };
push_turn(&mut turns, anth_role, blocks);
}
}
}
let messages: Vec<Value> = turns
.into_iter()
.map(|(role, blocks)| json!({ "role": role, "content": blocks }))
.collect();
let mut body = Map::new();
body.insert("model".into(), json!(strip_prefix(&request.model)));
body.insert(
"max_tokens".into(),
json!(request.max_tokens.unwrap_or(default_max_tokens)),
);
body.insert("messages".into(), Value::Array(messages));
if !system_blocks.is_empty() {
if system_has_cache {
body.insert("system".into(), Value::Array(system_blocks));
} else {
let joined = system_blocks
.iter()
.filter_map(|b| b["text"].as_str())
.collect::<Vec<_>>()
.join("\n\n");
body.insert("system".into(), json!(joined));
}
}
if let Some(t) = request.temperature {
body.insert("temperature".into(), json!(t));
}
if let Some(stop) = &request.stop {
body.insert("stop_sequences".into(), json!(stop));
}
if let Some(tools) = &request.tools {
body.insert("tools".into(), Value::Array(convert_tools(tools)));
}
if let Some(tc) = &request.tool_choice {
body.insert("tool_choice".into(), tc.clone());
}
Value::Object(body)
}
pub fn anthropic_tool_choice(choice: ToolChoice) -> Value {
match choice {
ToolChoice::None => json!({ "type": "none" }),
ToolChoice::Auto => json!({ "type": "auto" }),
ToolChoice::Required => json!({ "type": "any" }),
ToolChoice::Function(name) => json!({ "type": "tool", "name": name }),
}
}
fn push_turn(
turns: &mut Vec<(&'static str, Vec<Value>)>,
role: &'static str,
mut blocks: Vec<Value>,
) {
match turns.last_mut() {
Some(last) if last.0 == role => last.1.append(&mut blocks),
_ => turns.push((role, blocks)),
}
}
fn convert_tools(tools: &[ToolDefinition]) -> Vec<Value> {
tools
.iter()
.map(|t| {
let f = &t.function;
let mut tool = Map::new();
tool.insert("name".into(), json!(f.name));
if let Some(desc) = &f.description {
tool.insert("description".into(), json!(desc));
}
tool.insert(
"input_schema".into(),
f.parameters
.clone()
.unwrap_or_else(|| json!({ "type": "object" })),
);
if f.cache_control.is_some() {
tool.insert("cache_control".into(), json!({ "type": "ephemeral" }));
}
Value::Object(tool)
})
.collect()
}
fn strip_prefix(model: &str) -> &str {
model.strip_prefix("anthropic/").unwrap_or(model)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inference::types::CacheControl;
use crate::inference::types::{
ChatMessage, FunctionCall, FunctionDefinition, ToolCall, ToolDefinition,
};
#[test]
fn hoists_system_and_defaults_max_tokens() {
let req = ChatRequest::new(
"claude-sonnet-4-5",
vec![ChatMessage::system("be terse"), ChatMessage::user("hi")],
);
let body = build_body(&req, 4096);
assert_eq!(body["system"], json!("be terse"));
assert_eq!(body["max_tokens"], json!(4096));
assert_eq!(body["messages"].as_array().unwrap().len(), 1);
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"][0]["text"], "hi");
}
#[test]
fn builds_alternating_user_assistant_turns() {
let mut req = ChatRequest::new(
"claude-sonnet-4-5",
vec![
ChatMessage::user("q1"),
ChatMessage::assistant("a1"),
ChatMessage::user("q2"),
],
);
req.max_tokens = Some(256);
req.temperature = Some(0.2);
req.stop = Some(vec!["STOP".into()]);
let body = build_body(&req, 4096);
assert_eq!(body["max_tokens"], json!(256));
assert_eq!(body["temperature"], json!(0.2_f32));
assert_eq!(body["stop_sequences"], json!(["STOP"]));
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[0]["role"], "user");
assert_eq!(msgs[1]["role"], "assistant");
assert_eq!(msgs[1]["content"][0]["text"], "a1");
assert_eq!(msgs[2]["role"], "user");
}
#[test]
fn tool_result_becomes_user_turn() {
let assistant_call = ChatMessage {
role: "assistant".into(),
content: None,
tool_calls: Some(vec![ToolCall {
id: "toolu_1".into(),
kind: "function".into(),
function: FunctionCall {
name: "get_weather".into(),
arguments: r#"{"loc":"SEA"}"#.into(),
},
}]),
tool_call_id: None,
name: None,
cache_control: None,
};
let req = ChatRequest::new(
"claude-sonnet-4-5",
vec![
ChatMessage::user("weather?"),
assistant_call,
ChatMessage::tool_result("toolu_1", "get_weather", r#"{"t":72}"#),
ChatMessage::tool_result("toolu_2", "get_time", "noon"),
],
);
let body = build_body(&req, 4096);
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[1]["role"], "assistant");
assert_eq!(msgs[1]["content"][0]["type"], "tool_use");
assert_eq!(msgs[1]["content"][0]["id"], "toolu_1");
assert_eq!(msgs[1]["content"][0]["input"]["loc"], "SEA");
assert_eq!(msgs[2]["role"], "user");
let results = msgs[2]["content"].as_array().unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0]["type"], "tool_result");
assert_eq!(results[0]["tool_use_id"], "toolu_1");
assert_eq!(results[1]["tool_use_id"], "toolu_2");
}
#[test]
fn converts_tools_to_input_schema() {
let mut req = ChatRequest::new("claude-sonnet-4-5", vec![ChatMessage::user("hi")]);
req.tools = Some(vec![ToolDefinition::function(FunctionDefinition {
name: "get_weather".into(),
description: Some("look up weather".into()),
parameters: Some(json!({"type": "object", "properties": {}})),
cache_control: None,
})]);
req.tool_choice = Some(anthropic_tool_choice(ToolChoice::Auto));
let body = build_body(&req, 4096);
assert_eq!(body["tools"][0]["name"], "get_weather");
assert_eq!(body["tools"][0]["description"], "look up weather");
assert_eq!(body["tools"][0]["input_schema"]["type"], "object");
assert!(body["tools"][0].get("parameters").is_none());
assert!(body["tools"][0].get("function").is_none());
assert_eq!(body["tool_choice"], json!({"type": "auto"}));
}
#[test]
fn cache_control_marks_system_and_blocks() {
let mut sys = ChatMessage::system("stable prefix");
sys.cache_control = Some(CacheControl::ephemeral());
let mut user = ChatMessage::user("cache me too");
user.cache_control = Some(CacheControl::ephemeral());
let mut req = ChatRequest::new("claude-sonnet-4-5", vec![sys, user]);
req.tools = Some(vec![ToolDefinition::function(FunctionDefinition {
name: "t".into(),
description: None,
parameters: None,
cache_control: Some(CacheControl::ephemeral()),
})]);
let body = build_body(&req, 4096);
assert_eq!(
body["system"],
json!([{ "type": "text", "text": "stable prefix", "cache_control": {"type": "ephemeral"} }])
);
assert_eq!(
body["messages"][0]["content"][0]["cache_control"],
json!({"type": "ephemeral"})
);
assert_eq!(
body["tools"][0]["cache_control"],
json!({"type": "ephemeral"})
);
}
#[test]
fn strips_anthropic_prefix() {
let req = ChatRequest::new("anthropic/claude-sonnet-4-5", vec![ChatMessage::user("x")]);
let body = build_body(&req, 4096);
assert_eq!(body["model"], "claude-sonnet-4-5");
}
#[test]
fn anthropic_tool_choice_maps_every_variant() {
assert_eq!(
anthropic_tool_choice(ToolChoice::None),
json!({"type": "none"})
);
assert_eq!(
anthropic_tool_choice(ToolChoice::Auto),
json!({"type": "auto"})
);
assert_eq!(
anthropic_tool_choice(ToolChoice::Required),
json!({"type": "any"})
);
assert_eq!(
anthropic_tool_choice(ToolChoice::Function("get_weather".into())),
json!({"type": "tool", "name": "get_weather"})
);
}
}