use crate::api::LlmClient::{Anthropic, OpenAI};
use crate::api::{CreateMessageRequest, LlmClient, ReasoningEffort, ReasoningMode, Tool};
use crate::repl::conversation::ConversationHistory;
pub struct RequestBuilder<'a> {
client: &'a LlmClient,
model: &'a str,
max_tokens: u32,
conversation: &'a ConversationHistory,
tools: Vec<Tool>,
reasoning_effort: ReasoningEffort,
reasoning_mode: ReasoningMode,
session_id: &'a str,
}
impl<'a> RequestBuilder<'a> {
pub fn new(
client: &'a LlmClient,
model: &'a str,
max_tokens: u32,
conversation: &'a ConversationHistory,
tools: Vec<Tool>,
reasoning_effort: ReasoningEffort,
session_id: &'a str,
) -> Self {
Self {
client,
model,
max_tokens,
conversation,
tools,
reasoning_effort,
reasoning_mode: ReasoningMode::default(),
session_id,
}
}
pub fn with_reasoning_mode(mut self, mode: ReasoningMode) -> Self {
self.reasoning_mode = mode;
self
}
pub fn build(self) -> CreateMessageRequest {
let is_anthropic = matches!(self.client, Anthropic(_));
let adaptive =
is_anthropic && crate::api::anthropic::requires_adaptive_thinking(self.model);
let (thinking_config, output_config) = if is_anthropic && adaptive {
let effort = crate::api::anthropic::effort_label(self.reasoning_effort);
(
Some(crate::api::Thinking::adaptive()),
Some(crate::api::OutputConfig::with_effort(effort)),
)
} else if is_anthropic {
let budget = crate::api::anthropic::legacy_thinking_budget(self.reasoning_effort);
(Some(crate::api::Thinking::enabled(budget)), None)
} else {
(None, None)
};
let reasoning_config = if matches!(self.client, OpenAI(_)) {
let mut reasoning = match self.reasoning_effort {
ReasoningEffort::Low => crate::api::Reasoning::with_effort("low"),
ReasoningEffort::Medium => crate::api::Reasoning::with_effort("medium"),
ReasoningEffort::High => crate::api::Reasoning::with_effort("high"),
ReasoningEffort::XHigh => crate::api::Reasoning::with_effort("xhigh"),
ReasoningEffort::Max => crate::api::Reasoning::with_effort("max"),
};
reasoning.mode = self.reasoning_mode.wire_value().map(str::to_string);
Some(reasoning)
} else {
None
};
let system_prompt = Some(self.conversation.system_prompt().clone());
let context_management = if matches!(self.client, Anthropic(_)) {
let info = crate::api::model_info::lookup(self.model);
if info.supports_server_compaction {
Some(crate::api::ContextManagement {
edits: vec![crate::api::ContextEdit::Compact20260112 {
trigger: Some(crate::api::CompactionTrigger::InputTokens {
value: info
.auto_compact_at()
.max(crate::api::anthropic::COMPACTION_TRIGGER_FLOOR),
}),
instructions: None,
}],
})
} else {
None
}
} else {
None
};
let mut request = CreateMessageRequest {
model: self.model.to_string(),
max_tokens: self.max_tokens,
messages: self.conversation.messages().to_vec(),
system: system_prompt,
tools: Some(self.tools),
stream: None,
thinking: thinking_config,
output_config,
reasoning: reasoning_config,
prompt_cache_key: Some(self.session_id.to_string()),
context_management,
};
if matches!(self.client, Anthropic(_)) {
if let Some(tools) = request.tools.as_mut() {
let last_anthropic_tool = tools
.iter_mut()
.rev()
.find(|t| matches!(t, Tool::Regular { .. } | Tool::AnthropicWebSearch { .. }));
if let Some(tool) = last_anthropic_tool {
match tool {
Tool::Regular { cache_control, .. }
| Tool::AnthropicWebSearch { cache_control, .. } => {
*cache_control = Some(crate::api::CacheControl::ephemeral_one_hour());
}
Tool::OpenAIWebSearch { .. } => unreachable!(),
}
}
}
mark_rolling_cache_breakpoint(
&mut request.messages,
self.conversation.cache_anchor_message_idx(),
);
}
request
}
}
fn mark_rolling_cache_breakpoint(messages: &mut [crate::api::Message], anchor_idx: Option<usize>) {
if let Some(last_msg) = messages.last_mut() {
stamp_last_block(last_msg, crate::api::CacheControl::ephemeral(None));
}
if let Some(idx) = anchor_idx {
if idx + 1 < messages.len() {
stamp_last_block(
&mut messages[idx],
crate::api::CacheControl::ephemeral_one_hour(),
);
}
}
}
fn stamp_last_block(msg: &mut crate::api::Message, control: crate::api::CacheControl) {
use crate::api::{MessageContent, MessageContentBlock};
let MessageContent::Blocks { content } = &mut msg.content else {
return;
};
let Some(last_block) = content.last_mut() else {
return;
};
let cc = match last_block {
MessageContentBlock::Text { cache_control, .. }
| MessageContentBlock::Thinking { cache_control, .. }
| MessageContentBlock::Summary { cache_control, .. }
| MessageContentBlock::Compaction { cache_control, .. }
| MessageContentBlock::Reasoning { cache_control, .. }
| MessageContentBlock::ToolUse { cache_control, .. }
| MessageContentBlock::ToolResult { cache_control, .. }
| MessageContentBlock::ServerToolUse { cache_control, .. }
| MessageContentBlock::WebSearchToolResult { cache_control, .. }
| MessageContentBlock::Image { cache_control, .. } => cache_control,
};
*cc = Some(control);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::{AnthropicClient, Message, MessageContentBlock, OpenAIClient, Tool};
fn anthropic_client() -> LlmClient {
LlmClient::Anthropic(AnthropicClient::new("test-key".to_string()).unwrap())
}
fn openai_client() -> LlmClient {
LlmClient::OpenAI(OpenAIClient::new("test-key".to_string()).unwrap())
}
fn one_regular_tool() -> Vec<Tool> {
vec![Tool::Regular {
name: "read_file".to_string(),
description: "read a file".to_string(),
input_schema: serde_json::json!({"type": "object"}),
cache_control: None,
}]
}
#[test]
fn build_sets_prompt_cache_key_to_session_id() {
let conv = ConversationHistory::new();
let request = RequestBuilder::new(
&openai_client(),
crate::api::model_info::GPT_FLAGSHIP,
8192,
&conv,
one_regular_tool(),
ReasoningEffort::Medium,
"session-abc",
)
.build();
assert_eq!(request.prompt_cache_key.as_deref(), Some("session-abc"));
}
#[test]
fn openai_reasoning_effort_maps_straight_onto_wire_labels() {
let conv = ConversationHistory::new();
let effort_on_wire = |effort| {
RequestBuilder::new(
&openai_client(),
crate::api::model_info::GPT_SOL,
8192,
&conv,
one_regular_tool(),
effort,
"s1",
)
.build()
.reasoning
.expect("OpenAI requests always carry a reasoning config")
.effort
};
assert_eq!(effort_on_wire(ReasoningEffort::Low), "low");
assert_eq!(effort_on_wire(ReasoningEffort::Medium), "medium");
assert_eq!(effort_on_wire(ReasoningEffort::High), "high");
assert_eq!(effort_on_wire(ReasoningEffort::XHigh), "xhigh");
assert_eq!(effort_on_wire(ReasoningEffort::Max), "max");
}
#[test]
fn openai_pro_mode_sets_reasoning_mode_field() {
let conv = ConversationHistory::new();
let mode_on_wire = |mode| {
RequestBuilder::new(
&openai_client(),
crate::api::model_info::GPT_SOL,
8192,
&conv,
one_regular_tool(),
ReasoningEffort::Medium,
"s1",
)
.with_reasoning_mode(mode)
.build()
.reasoning
.expect("OpenAI requests always carry a reasoning config")
.mode
};
assert_eq!(
mode_on_wire(crate::api::ReasoningMode::Pro).as_deref(),
Some("pro")
);
assert_eq!(mode_on_wire(crate::api::ReasoningMode::Standard), None);
}
#[test]
fn build_marks_rolling_cache_breakpoint_on_anthropic_only() {
let mut conv = ConversationHistory::new();
conv.add_user_with_blocks(vec![MessageContentBlock::Text {
text: "hello".to_string(),
cache_control: None,
}]);
let anth = RequestBuilder::new(
&anthropic_client(),
crate::api::model_info::CLAUDE_SONNET,
8192,
&conv,
one_regular_tool(),
ReasoningEffort::Medium,
"s1",
)
.build();
let last_block = match &anth.messages.last().unwrap().content {
crate::api::MessageContent::Blocks { content } => content.last().unwrap(),
_ => panic!("expected Blocks content"),
};
let cc = match last_block {
MessageContentBlock::Text { cache_control, .. } => cache_control.as_ref(),
_ => panic!("expected Text block"),
};
assert!(cc.is_some(), "Anthropic should stamp rolling breakpoint");
let oai = RequestBuilder::new(
&openai_client(),
crate::api::model_info::GPT_FLAGSHIP,
8192,
&conv,
one_regular_tool(),
ReasoningEffort::Medium,
"s1",
)
.build();
let last_block = match &oai.messages.last().unwrap().content {
crate::api::MessageContent::Blocks { content } => content.last().unwrap(),
_ => panic!("expected Blocks content"),
};
let cc = match last_block {
MessageContentBlock::Text { cache_control, .. } => cache_control.as_ref(),
_ => panic!("expected Text block"),
};
assert!(cc.is_none(), "OpenAI must not stamp Anthropic markers");
}
#[test]
fn rolling_breakpoint_is_noop_on_plain_text_user_message() {
let mut messages = vec![Message::user("just text")];
mark_rolling_cache_breakpoint(&mut messages, None);
assert!(matches!(
&messages[0].content,
crate::api::MessageContent::Text { .. }
));
}
#[test]
fn rolling_breakpoint_is_noop_on_empty_messages() {
let mut messages: Vec<Message> = Vec::new();
mark_rolling_cache_breakpoint(&mut messages, None);
assert!(messages.is_empty());
}
#[test]
fn rolling_breakpoint_targets_only_the_final_block() {
let mut messages = vec![Message::user_with_blocks(vec![
MessageContentBlock::Text {
text: "first".to_string(),
cache_control: None,
},
MessageContentBlock::Text {
text: "second".to_string(),
cache_control: None,
},
])];
mark_rolling_cache_breakpoint(&mut messages, None);
let blocks = match &messages[0].content {
crate::api::MessageContent::Blocks { content } => content,
_ => panic!("expected Blocks"),
};
assert!(matches!(
&blocks[0],
MessageContentBlock::Text {
cache_control: None,
..
}
));
assert!(matches!(
&blocks[1],
MessageContentBlock::Text {
cache_control: Some(_),
..
}
));
}
fn one_text_block_user(text: &str) -> Message {
Message::user_with_blocks(vec![MessageContentBlock::Text {
text: text.to_string(),
cache_control: None,
}])
}
fn block_cache_control(msg: &Message) -> Option<&crate::api::CacheControl> {
match &msg.content {
crate::api::MessageContent::Blocks { content } => match content.last() {
Some(MessageContentBlock::Text { cache_control, .. }) => cache_control.as_ref(),
_ => None,
},
_ => None,
}
}
#[test]
fn anchor_breakpoint_stamps_when_index_provided() {
let mut messages = vec![
one_text_block_user("a"),
one_text_block_user("b"),
one_text_block_user("c"),
];
mark_rolling_cache_breakpoint(&mut messages, Some(0));
assert!(
block_cache_control(&messages[0]).is_some(),
"anchor stamped"
);
assert!(
block_cache_control(&messages[1]).is_none(),
"middle untouched"
);
assert!(
block_cache_control(&messages[2]).is_some(),
"rolling stamped"
);
}
#[test]
fn anchor_breakpoint_skipped_when_index_is_rolling() {
let mut messages = vec![one_text_block_user("a"), one_text_block_user("b")];
mark_rolling_cache_breakpoint(&mut messages, Some(1));
assert!(block_cache_control(&messages[0]).is_none());
assert!(
block_cache_control(&messages[1]).is_some(),
"rolling still stamped"
);
}
#[test]
fn anchor_breakpoint_skipped_when_index_out_of_bounds() {
let mut messages = vec![one_text_block_user("a"), one_text_block_user("b")];
mark_rolling_cache_breakpoint(&mut messages, Some(99));
assert!(block_cache_control(&messages[1]).is_some());
}
#[test]
fn anthropic_build_stamps_both_anchor_and_rolling_when_conversation_has_anchor() {
let mut conv = ConversationHistory::new();
for i in 0..16 {
conv.add_user_with_blocks(vec![MessageContentBlock::Text {
text: format!("msg-{}", i),
cache_control: None,
}]);
}
let anchor_idx = conv
.cache_anchor_message_idx()
.expect("anchor must be set with 16 blocks of history");
let req = RequestBuilder::new(
&anthropic_client(),
crate::api::model_info::CLAUDE_SONNET,
8192,
&conv,
one_regular_tool(),
ReasoningEffort::Medium,
"s1",
)
.build();
let last_cc = block_cache_control(req.messages.last().unwrap());
assert!(last_cc.is_some(), "rolling breakpoint missing");
let anchor_cc = block_cache_control(&req.messages[anchor_idx]);
assert!(
anchor_cc.is_some(),
"anchor breakpoint missing at idx {}",
anchor_idx
);
for (i, msg) in req.messages.iter().enumerate() {
if i == anchor_idx || i == req.messages.len() - 1 {
continue;
}
assert!(
block_cache_control(msg).is_none(),
"unexpected cache_control at idx {} (only rolling + anchor should be stamped)",
i
);
}
}
#[test]
fn anthropic_1m_models_get_server_compaction_config() {
let conv = ConversationHistory::new();
let req = RequestBuilder::new(
&anthropic_client(),
crate::api::model_info::CLAUDE_OPUS,
8192,
&conv,
one_regular_tool(),
ReasoningEffort::Medium,
"s1",
)
.build();
let cm = req
.context_management
.expect("this model should carry context_management");
let json = serde_json::to_value(&cm).unwrap();
assert_eq!(json["edits"][0]["type"], "compact_20260112");
assert_eq!(json["edits"][0]["trigger"]["type"], "input_tokens");
let trigger_value = json["edits"][0]["trigger"]["value"].as_u64().unwrap();
assert!(
trigger_value >= 50_000,
"Anthropic rejects triggers below 50K"
);
}
#[test]
fn legacy_anthropic_thinking_budget_scales_with_effort() {
let conv = ConversationHistory::new();
let budget_for = |effort| {
let req = RequestBuilder::new(
&anthropic_client(),
crate::api::model_info::CLAUDE_HAIKU,
65_536,
&conv,
one_regular_tool(),
effort,
"s1",
)
.build();
let thinking = req.thinking.expect("legacy Anthropic enables thinking");
thinking
.budget_tokens
.expect("legacy thinking carries budget_tokens")
};
let low = budget_for(ReasoningEffort::Low);
let medium = budget_for(ReasoningEffort::Medium);
let high = budget_for(ReasoningEffort::High);
assert!(low < medium, "Medium ({medium}) must exceed Low ({low})");
assert!(medium < high, "High ({high}) must exceed Medium ({medium})");
}
#[test]
fn unsupported_models_skip_server_compaction() {
let conv = ConversationHistory::new();
let haiku_req = RequestBuilder::new(
&anthropic_client(),
crate::api::model_info::CLAUDE_HAIKU,
8192,
&conv,
one_regular_tool(),
ReasoningEffort::Medium,
"s1",
)
.build();
assert!(
haiku_req.context_management.is_none(),
"this model isn't on Anthropic's compaction-supported list"
);
let openai_req = RequestBuilder::new(
&openai_client(),
crate::api::model_info::GPT_FLAGSHIP,
8192,
&conv,
one_regular_tool(),
ReasoningEffort::Medium,
"s1",
)
.build();
assert!(
openai_req.context_management.is_none(),
"OpenAI never receives Anthropic's context_management"
);
}
}