use locode_protocol::{ContentBlock, ImageSource, ResultChunk, Role};
use super::config::{DeveloperRendering, ModelConfig};
use super::wire;
pub use crate::http::normalize_input_schema;
use crate::request::{CacheHint, ConversationRequest, ReasoningEffort};
fn effective_max_tokens(req: &ConversationRequest, cfg: &ModelConfig) -> u32 {
cfg.max_tokens_cap
.map_or(req.sampling_args.max_tokens, |cap| {
req.sampling_args.max_tokens.min(cap)
})
}
#[must_use]
pub fn build_request(req: &ConversationRequest, cfg: &ModelConfig) -> wire::MessagesRequest {
let (mut system_blocks, mut messages) = map_messages(req, cfg);
if req.cache_hint == CacheHint::Standard {
if let Some(last) = system_blocks.last_mut() {
last.cache_control = Some(wire::CacheControl::ephemeral());
}
if let Some(last_message) = messages.last_mut() {
mark_last_cache_capable(last_message);
}
}
let system = match system_blocks.len() {
0 => None,
1 if system_blocks[0].cache_control.is_none() => {
Some(wire::SystemParam::Text(system_blocks.remove(0).text))
}
_ => Some(wire::SystemParam::Blocks(system_blocks)),
};
let (thinking, output_config) = map_reasoning(req);
let temperature = None;
let tools: Vec<wire::ToolParam> = req
.tools
.iter()
.map(|spec| wire::ToolParam {
name: spec.name.clone(),
description: Some(spec.description.clone()),
input_schema: match &spec.input {
locode_protocol::ToolInputFormat::JsonSchema { parameters } => {
normalize_input_schema(parameters.clone())
}
locode_protocol::ToolInputFormat::Freeform { .. } => serde_json::json!({
"type": "object",
"properties": {"input": {"type": "string",
"description": "The entire raw text input."}},
"required": ["input"],
"additionalProperties": false
}),
},
})
.collect();
let built = wire::MessagesRequest {
model: cfg.model.clone(),
messages,
max_tokens: effective_max_tokens(req, cfg),
system,
tools: (!tools.is_empty()).then_some(tools),
tool_choice: None,
temperature,
top_p: req.sampling_args.top_p,
top_k: None,
stream: Some(false),
stop_sequences: None,
thinking: Some(thinking),
output_config,
metadata: None,
provider: cfg.effective_provider_prefs(),
};
let markers = count_cache_controls(&built);
assert!(
markers <= 4,
"cache_control marker count {markers} exceeds Anthropic's hard cap of 4"
);
built
}
fn map_messages(
req: &ConversationRequest,
cfg: &ModelConfig,
) -> (Vec<wire::TextBlock>, Vec<wire::Message>) {
let mut system_blocks: Vec<wire::TextBlock> = Vec::new();
let mut messages: Vec<wire::Message> = Vec::new();
for message in &req.messages {
match message.role {
Role::System => {
for block in &message.content {
if let ContentBlock::Text { text } = block {
system_blocks.push(wire::TextBlock {
r#type: "text".to_string(),
text: text.clone(),
cache_control: None,
});
}
}
}
Role::Developer => {
let text = concat_text(&message.content);
if !text.is_empty() {
messages.push(match cfg.developer_rendering {
DeveloperRendering::SystemReminder => wire::Message {
role: wire::MessageRole::User,
content: wire::MessageContent::Text(format!(
"<system-reminder>\n{text}\n</system-reminder>"
)),
},
DeveloperRendering::MidConversationSystemBeta => wire::Message {
role: wire::MessageRole::System,
content: wire::MessageContent::Text(text),
},
});
}
}
Role::User => {
let blocks = map_user_blocks(&message.content);
if !blocks.is_empty() {
messages.push(wire::Message {
role: wire::MessageRole::User,
content: wire::MessageContent::Blocks(blocks),
});
}
}
Role::Assistant => {
let blocks = map_assistant_blocks(&message.content);
if !blocks.is_empty() {
messages.push(wire::Message {
role: wire::MessageRole::Assistant,
content: wire::MessageContent::Blocks(blocks),
});
}
}
}
}
(system_blocks, messages)
}
fn concat_text(blocks: &[ContentBlock]) -> String {
let mut out = String::new();
for block in blocks {
if let ContentBlock::Text { text } = block {
if !out.is_empty() {
out.push('\n');
}
out.push_str(text);
}
}
out
}
fn is_blank(text: &str) -> bool {
text.trim().is_empty()
}
fn map_user_blocks(blocks: &[ContentBlock]) -> Vec<wire::ContentBlock> {
let mut out = Vec::with_capacity(blocks.len());
for block in blocks {
match block {
ContentBlock::Text { text } if is_blank(text) => {}
ContentBlock::Text { text } => out.push(wire::ContentBlock::Text {
text: text.clone(),
cache_control: None,
}),
ContentBlock::Image { source } => out.push(wire::ContentBlock::Image {
source: map_image_source(source),
}),
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => out.push(wire::ContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: map_tool_result_content(content),
is_error: *is_error,
cache_control: None,
}),
_ => {}
}
}
out
}
fn map_assistant_blocks(blocks: &[ContentBlock]) -> Vec<wire::ContentBlock> {
let mut out = Vec::with_capacity(blocks.len());
for block in blocks {
match block {
ContentBlock::Text { text } if is_blank(text) => {}
ContentBlock::Text { text } => out.push(wire::ContentBlock::Text {
text: text.clone(),
cache_control: None,
}),
ContentBlock::Reasoning {
format: locode_protocol::ReasoningFormat::Anthropic,
text,
signature: Some(signature),
..
} => {
if !text.is_empty() || !signature.is_empty() {
out.push(wire::ContentBlock::Thinking {
thinking: text.clone(),
signature: signature.clone(),
});
}
}
ContentBlock::Reasoning {
format: locode_protocol::ReasoningFormat::AnthropicRedacted,
payload: Some(payload),
..
} => {
if let Some(data) = payload.as_str() {
out.push(wire::ContentBlock::RedactedThinking {
data: data.to_owned(),
});
}
}
ContentBlock::ToolUse { id, name, input } => {
let input = if input.is_object() {
input.clone()
} else {
serde_json::json!({})
};
out.push(wire::ContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input,
});
}
_ => {}
}
}
out
}
fn map_tool_result_content(chunks: &[ResultChunk]) -> wire::ToolResultContent {
if let [ResultChunk::Text { text }] = chunks {
return wire::ToolResultContent::Text(text.clone());
}
let blocks = chunks
.iter()
.filter(|chunk| !matches!(chunk, ResultChunk::Text { text } if is_blank(text)))
.map(|chunk| match chunk {
ResultChunk::Text { text } => wire::ContentBlock::Text {
text: text.clone(),
cache_control: None,
},
ResultChunk::Image { source } => wire::ContentBlock::Image {
source: map_image_source(source),
},
})
.collect();
wire::ToolResultContent::Blocks(blocks)
}
fn map_image_source(source: &ImageSource) -> wire::ImageSource {
match source {
ImageSource::Base64 { media_type, data } => wire::ImageSource::Base64 {
media_type: media_type.clone(),
data: data.clone(),
},
ImageSource::Url { url } => wire::ImageSource::Url { url: url.clone() },
}
}
fn mark_last_cache_capable(message: &mut wire::Message) {
match &mut message.content {
wire::MessageContent::Text(_) => {}
wire::MessageContent::Blocks(blocks) => {
for block in blocks.iter_mut().rev() {
match block {
wire::ContentBlock::Text { cache_control, .. }
| wire::ContentBlock::ToolResult { cache_control, .. } => {
*cache_control = Some(wire::CacheControl::ephemeral());
return;
}
_ => {}
}
}
}
}
}
fn map_reasoning(req: &ConversationRequest) -> (wire::ThinkingConfig, Option<wire::OutputConfig>) {
let thinking = wire::ThinkingConfig::Adaptive {
display: Some(wire::ThinkingDisplay::Summarized),
};
let effort = match req.sampling_args.reasoning_effort.as_ref() {
None => return (thinking, None),
Some(ReasoningEffort::None | ReasoningEffort::Minimal | ReasoningEffort::Low) => "low",
Some(ReasoningEffort::Medium) => "medium",
Some(ReasoningEffort::High) => "high",
Some(ReasoningEffort::XHigh) => "xhigh",
Some(ReasoningEffort::Max) => "max",
Some(ReasoningEffort::Other(s)) => s.as_str(),
};
(
thinking,
Some(wire::OutputConfig {
effort: Some(effort.to_string()),
format: None,
}),
)
}
#[must_use]
pub fn count_cache_controls(req: &wire::MessagesRequest) -> usize {
let mut count = 0;
if let Some(wire::SystemParam::Blocks(blocks)) = &req.system {
count += blocks.iter().filter(|b| b.cache_control.is_some()).count();
}
for message in &req.messages {
if let wire::MessageContent::Blocks(blocks) = &message.content {
for block in blocks {
match block {
wire::ContentBlock::Text {
cache_control: Some(_),
..
}
| wire::ContentBlock::ToolResult {
cache_control: Some(_),
..
} => count += 1,
_ => {}
}
}
}
}
count
}
#[cfg(test)]
mod tests {
use super::*;
use locode_protocol::ContentBlock;
use serde_json::json;
#[test]
fn blank_text_blocks_are_never_sent() {
let blocks = map_assistant_blocks(&[
ContentBlock::Text {
text: String::new(),
},
ContentBlock::Text {
text: " \n ".to_string(),
},
ContentBlock::Text {
text: "real".to_string(),
},
]);
assert_eq!(blocks.len(), 1, "only the block with content survives");
assert!(matches!(&blocks[0], wire::ContentBlock::Text { text, .. } if text == "real"));
let user = map_user_blocks(&[ContentBlock::Text {
text: "\t".to_string(),
}]);
assert!(user.is_empty(), "whitespace-only user text is dropped too");
}
#[test]
fn dropping_blank_text_cannot_orphan_a_tool_use() {
let blocks = map_assistant_blocks(&[
ContentBlock::Text {
text: String::new(),
},
ContentBlock::ToolUse {
id: "toolu_1".to_string(),
name: "read".to_string(),
input: json!({}),
},
]);
assert_eq!(blocks.len(), 1);
assert!(
matches!(&blocks[0], wire::ContentBlock::ToolUse { id, .. } if id == "toolu_1"),
"the tool_use must survive so its tool_result still has a partner"
);
}
#[test]
fn assistant_tool_use_non_object_input_coerces_to_empty_object() {
let coerced = map_assistant_blocks(&[ContentBlock::ToolUse {
id: "t1".into(),
name: "grep".into(),
input: json!("{\"pattern\":\"x\",-A:3}"),
}]);
match &coerced[0] {
wire::ContentBlock::ToolUse { id, input, .. } => {
assert_eq!(id, "t1");
assert_eq!(input, &json!({}), "non-object input coerced to {{}}");
}
other => panic!("expected ToolUse, got {other:?}"),
}
let passthrough = map_assistant_blocks(&[ContentBlock::ToolUse {
id: "t2".into(),
name: "grep".into(),
input: json!({"pattern": "x"}),
}]);
match &passthrough[0] {
wire::ContentBlock::ToolUse { input, .. } => {
assert_eq!(input, &json!({"pattern": "x"}), "valid object unchanged");
}
other => panic!("expected ToolUse, got {other:?}"),
}
}
}