use crate::tokens::TokenEstimation;
pub fn trim_for_summary(
messages: &[serde_json::Value],
head: usize,
tail: usize,
) -> Vec<serde_json::Value> {
if messages.len() <= head + tail {
return messages.to_vec();
}
let dropped = messages.len() - head - tail;
let mut result = Vec::with_capacity(head + 1 + tail);
result.extend_from_slice(&messages[..head]);
result.push(serde_json::json!({
"role": "user",
"content": format!(
"[{dropped} earlier tool-call messages omitted to keep context within model limits]"
),
}));
result.extend_from_slice(&messages[messages.len() - tail..]);
repair_orphaned_tool_calls(&mut result);
result
}
pub(crate) fn estimate_value_tokens(v: &serde_json::Value, est: TokenEstimation) -> usize {
est.tokens_for_chars(v.to_string().chars().count())
}
pub(crate) fn estimate_tokens(messages: &[serde_json::Value], est: TokenEstimation) -> usize {
messages.iter().map(|m| estimate_value_tokens(m, est)).sum()
}
pub(crate) fn estimate_request_tokens(
messages: &[serde_json::Value],
tools: Option<&serde_json::Value>,
est: TokenEstimation,
) -> usize {
estimate_tokens(messages, est) + tools.map(|t| estimate_value_tokens(t, est)).unwrap_or(0)
}
pub(crate) struct PromptTracker {
anchor: Option<(u32, usize)>,
}
impl PromptTracker {
pub(crate) fn new() -> Self {
Self { anchor: None }
}
pub(crate) fn record(&mut self, prompt_tokens: u32, message_count: usize) {
self.anchor = Some((prompt_tokens, message_count));
}
pub(crate) fn invalidate(&mut self) {
self.anchor = None;
}
pub(crate) fn current(
&self,
messages: &[serde_json::Value],
tools: Option<&serde_json::Value>,
ratio: f32,
est: TokenEstimation,
) -> usize {
match self.anchor {
Some((tokens, count)) if count <= messages.len() => {
tokens as usize
+ super::calibrate_up(estimate_tokens(&messages[count..], est), ratio)
}
_ => super::calibrate_up(estimate_request_tokens(messages, tools, est), ratio),
}
}
}
pub(crate) fn repair_orphaned_tool_calls(messages: &mut Vec<serde_json::Value>) {
let result_ids: std::collections::HashSet<String> = messages
.iter()
.filter(|m| m["role"].as_str() == Some("tool"))
.filter_map(|m| m["tool_call_id"].as_str().map(|s| s.to_string()))
.collect();
let roles: Vec<Option<String>> = messages
.iter()
.map(|m| m["role"].as_str().map(|s| s.to_string()))
.collect();
let strip_indices: std::collections::HashSet<usize> = messages
.iter()
.enumerate()
.filter_map(|(i, msg)| {
if msg["role"].as_str() != Some("assistant") {
return None;
}
let tool_calls = msg["tool_calls"].as_array()?;
if tool_calls.is_empty() {
return None;
}
let ids: Vec<String> = tool_calls
.iter()
.filter_map(|tc| tc["id"].as_str().map(|s| s.to_string()))
.collect();
let should_strip = if ids.is_empty() {
roles.get(i + 1).and_then(|r| r.as_deref()) != Some("tool")
} else {
!ids.iter().all(|id| result_ids.contains(id))
};
should_strip.then_some(i)
})
.collect();
for i in strip_indices {
if let Some(obj) = messages[i].as_object_mut() {
obj.remove("tool_calls");
obj.entry("content")
.or_insert_with(|| serde_json::json!("[tool calls omitted]"));
}
}
let live_call_ids: std::collections::HashSet<String> = messages
.iter()
.filter(|m| m["role"].as_str() == Some("assistant"))
.filter_map(|m| m["tool_calls"].as_array())
.flat_map(|tc| tc.iter())
.filter_map(|tc| tc["id"].as_str().map(|s| s.to_string()))
.collect();
messages.retain(|m| {
if m["role"].as_str() != Some("tool") {
return true;
}
match m["tool_call_id"].as_str() {
Some(id) if !id.is_empty() => live_call_ids.contains(id),
_ => true,
}
});
}
pub(crate) fn merge_round_usage(
acc: Option<crate::TokenUsage>,
new: Option<crate::TokenUsage>,
) -> Option<crate::TokenUsage> {
match (acc, new) {
(Some(a), Some(b)) => Some(crate::TokenUsage {
input_tokens: a.input_tokens.max(b.input_tokens),
output_tokens: a.output_tokens.saturating_add(b.output_tokens),
}),
(Some(a), None) | (None, Some(a)) => Some(a),
(None, None) => None,
}
}
pub(crate) fn ollama_usage(json: &serde_json::Value) -> Option<crate::TokenUsage> {
let input = json["prompt_eval_count"].as_u64()? as u32;
let output = json["eval_count"].as_u64()? as u32;
Some(crate::TokenUsage {
input_tokens: input,
output_tokens: output,
})
}
pub(crate) fn openai_usage(usage: &serde_json::Value) -> Option<crate::TokenUsage> {
let input = usage["prompt_tokens"].as_u64()? as u32;
let output = usage["completion_tokens"].as_u64()? as u32;
Some(crate::TokenUsage {
input_tokens: input,
output_tokens: output,
})
}
#[cfg(test)]
mod tests {
use super::*;
const EST: TokenEstimation = TokenEstimation { chars_per_token: 4 };
use serde_json::json;
#[test]
fn trim_for_summary_drops_middle_and_inserts_placeholder() {
let msgs: Vec<serde_json::Value> = (0..10)
.map(|i| serde_json::json!({"role": "user", "content": format!("msg {i}")}))
.collect();
let trimmed = trim_for_summary(&msgs, 2, 3);
assert_eq!(
trimmed.len(),
6,
"expected 6 messages, got {}",
trimmed.len()
);
assert_eq!(trimmed[0]["content"], "msg 0");
assert_eq!(trimmed[1]["content"], "msg 1");
let placeholder = trimmed[2]["content"].as_str().unwrap();
assert!(
placeholder.contains("omitted"),
"placeholder must mention omitted messages: {placeholder}"
);
assert_eq!(trimmed[3]["content"], "msg 7");
assert_eq!(trimmed[4]["content"], "msg 8");
assert_eq!(trimmed[5]["content"], "msg 9");
}
#[test]
fn trim_for_summary_passthrough_when_short_enough() {
let msgs: Vec<serde_json::Value> = (0..4)
.map(|i| serde_json::json!({"role": "user", "content": format!("msg {i}")}))
.collect();
let trimmed = trim_for_summary(&msgs, 2, 3);
assert_eq!(trimmed.len(), 4);
}
#[test]
fn estimate_tokens_scales_with_content_size() {
let small = vec![serde_json::json!({"role": "user", "content": "hi"})];
let big = vec![serde_json::json!({"role": "user", "content": "x".repeat(4000)})];
let s = estimate_tokens(&small, EST);
let b = estimate_tokens(&big, EST);
assert!(
b >= 900,
"big message should estimate ~1000 tokens, got {b}"
);
assert!(b > s * 10, "big must dwarf small ({b} vs {s})");
}
#[test]
fn repair_leaves_matched_tool_calls_intact() {
let mut msgs = vec![
serde_json::json!({"role": "user", "content": "do it"}),
serde_json::json!({
"role": "assistant",
"content": "",
"tool_calls": [{"function": {"name": "list_dir", "arguments": {}}}]
}),
serde_json::json!({"role": "tool", "content": "file.rs"}),
];
repair_orphaned_tool_calls(&mut msgs);
assert!(
msgs[1]["tool_calls"].as_array().is_some(),
"matched tool_calls must be preserved"
);
}
#[test]
fn repair_strips_orphaned_tool_calls() {
let mut msgs = vec![
serde_json::json!({"role": "user", "content": "first"}),
serde_json::json!({
"role": "assistant",
"content": "",
"tool_calls": [{"function": {"name": "list_dir", "arguments": {}}}]
}),
serde_json::json!({"role": "user", "content": "[context omitted]"}),
serde_json::json!({"role": "assistant", "content": "done"}),
];
repair_orphaned_tool_calls(&mut msgs);
assert!(
msgs[1].get("tool_calls").is_none(),
"orphaned tool_calls must be stripped"
);
assert!(
msgs[1]["content"].as_str().is_some(),
"assistant message must still have content after stripping tool_calls"
);
}
#[test]
fn trim_then_repair_produces_no_orphans() {
let mut msgs = vec![serde_json::json!({"role": "user", "content": "task"})];
for i in 0..5u32 {
msgs.push(serde_json::json!({
"role": "assistant",
"content": "",
"tool_calls": [{"id": format!("call_{i}"), "function": {"name": "list_dir", "arguments": {}}}]
}));
msgs.push(serde_json::json!({"role": "tool", "tool_call_id": format!("call_{i}"), "content": "result"}));
}
let trimmed = trim_for_summary(&msgs, 1, 2);
let result_ids: std::collections::HashSet<String> = trimmed
.iter()
.filter(|m| m["role"].as_str() == Some("tool"))
.filter_map(|m| m["tool_call_id"].as_str().map(|s| s.to_string()))
.collect();
for msg in &trimmed {
if msg["role"].as_str() == Some("assistant") {
if let Some(tc) = msg["tool_calls"].as_array() {
for call in tc {
let id = call["id"].as_str().unwrap_or("");
assert!(
result_ids.contains(id),
"after trim+repair, tool_call id={id:?} has no matching tool result"
);
}
}
}
}
}
#[test]
fn repair_strips_partial_tool_call_results() {
let mut msgs = vec![
serde_json::json!({"role": "user", "content": "task"}),
serde_json::json!({
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_a", "function": {"name": "read_file", "arguments": {}}},
{"id": "tc_b", "function": {"name": "list_dir", "arguments": {}}}
]
}),
serde_json::json!({"role": "tool", "tool_call_id": "tc_a", "content": "file content"}),
serde_json::json!({"role": "assistant", "content": "done"}),
];
repair_orphaned_tool_calls(&mut msgs);
assert!(
msgs[1].get("tool_calls").is_none(),
"partial tool_calls (tc_b missing) must be stripped"
);
let has_orphaned_result = msgs.iter().any(|m| {
m["role"].as_str() == Some("tool") && m["tool_call_id"].as_str() == Some("tc_a")
});
assert!(
!has_orphaned_result,
"tool_result for stripped tool_call must be removed"
);
}
#[test]
fn repair_removes_orphaned_tool_result() {
let mut msgs = vec![
serde_json::json!({"role": "user", "content": "task"}),
serde_json::json!({"role": "user", "content": "[N messages omitted]"}),
serde_json::json!({"role": "tool", "tool_call_id": "tc_old", "content": "stale"}),
serde_json::json!({"role": "assistant", "content": "done"}),
];
repair_orphaned_tool_calls(&mut msgs);
let has_orphan = msgs.iter().any(|m| {
m["role"].as_str() == Some("tool") && m["tool_call_id"].as_str() == Some("tc_old")
});
assert!(
!has_orphan,
"orphaned tool_result with no matching assistant must be removed"
);
}
#[test]
fn merge_round_usage_takes_max_input_and_sums_output() {
let a = crate::TokenUsage {
input_tokens: 10,
output_tokens: 2,
};
let b = crate::TokenUsage {
input_tokens: 5,
output_tokens: 1,
};
let merged = merge_round_usage(Some(a), Some(b)).unwrap();
assert_eq!(merged.input_tokens, 10, "max of (10, 5), NOT the sum 15");
assert_eq!(merged.output_tokens, 3, "outputs sum: 2 + 1");
assert_eq!(merge_round_usage(Some(a), None).unwrap().input_tokens, 10);
assert_eq!(merge_round_usage(None, Some(b)).unwrap().output_tokens, 1);
assert!(merge_round_usage(None, None).is_none());
}
#[test]
fn merge_round_usage_does_not_inflate_like_b3_baseline() {
let rounds = [2_582u32, 3_765, 3_849, 3_983, 4_136, 4_136];
let mut acc = None;
for input in rounds {
acc = merge_round_usage(
acc,
Some(crate::TokenUsage {
input_tokens: input,
output_tokens: 10,
}),
);
}
let u = acc.unwrap();
assert_eq!(
u.input_tokens, 4_136,
"turn input = largest single prompt, not the 22,451 sum"
);
assert_eq!(u.output_tokens, 60);
}
#[test]
fn estimate_value_tokens_ceiling_divides() {
assert_eq!(estimate_value_tokens(&serde_json::json!("x"), EST), 1);
assert_eq!(estimate_value_tokens(&serde_json::json!("xxxxx"), EST), 2);
assert_eq!(estimate_value_tokens(&serde_json::json!("xx"), EST), 1);
}
#[test]
fn estimate_request_tokens_counts_tool_schemas() {
let msgs = vec![serde_json::json!({"role": "user", "content": "hi"})];
let tools = serde_json::json!([{
"type": "function",
"function": {"name": "list_dir", "description": "x".repeat(400)}
}]);
let without = estimate_request_tokens(&msgs, None, EST);
let with = estimate_request_tokens(&msgs, Some(&tools), EST);
assert_eq!(without, estimate_tokens(&msgs, EST));
assert_eq!(with, without + estimate_value_tokens(&tools, EST));
assert!(
with - without >= 100,
"the ~400-char schema must add ~100 tokens, got {}",
with - without
);
}
fn fixture_tools() -> serde_json::Value {
serde_json::json!([{
"type": "function",
"function": {"name": "list_dir", "description": "d".repeat(1_000)}
}])
}
#[test]
fn prompt_tracker_falls_back_to_request_estimate_without_anchor() {
let msgs = vec![serde_json::json!({"role": "user", "content": "hello"})];
let tools = fixture_tools();
let tracker = PromptTracker::new();
assert_eq!(
tracker.current(&msgs, Some(&tools), 1.0, EST),
estimate_request_tokens(&msgs, Some(&tools), EST),
"no report yet → chars/4 of messages + tool-schema tokens"
);
}
#[test]
fn prompt_tracker_anchors_on_reported_prompt_plus_appended_delta() {
let mut msgs = vec![
serde_json::json!({"role": "system", "content": "sys"}),
serde_json::json!({"role": "user", "content": "task"}),
];
let tools = fixture_tools();
let mut tracker = PromptTracker::new();
tracker.record(1_000, msgs.len());
assert_eq!(
tracker.current(&msgs, Some(&tools), 1.0, EST),
1_000,
"anchored: schema tokens NOT re-added — the report includes them"
);
msgs.push(serde_json::json!({"role": "assistant", "content": "a".repeat(400)}));
msgs.push(serde_json::json!({"role": "tool", "content": "b".repeat(400)}));
let appended = estimate_tokens(&msgs[2..], EST);
assert_eq!(
tracker.current(&msgs, Some(&tools), 1.0, EST),
1_000 + appended
);
}
#[test]
fn prompt_tracker_calibrates_estimate_legs_with_ratio() {
let mut msgs = vec![
serde_json::json!({"role": "system", "content": "sys"}),
serde_json::json!({"role": "user", "content": "task"}),
];
let tools = fixture_tools();
let ratio = 1.3_f32;
let tracker = PromptTracker::new();
assert_eq!(
tracker.current(&msgs, Some(&tools), ratio, EST),
super::super::calibrate_up(estimate_request_tokens(&msgs, Some(&tools), EST), ratio)
);
let mut tracker = PromptTracker::new();
tracker.record(1_000, msgs.len());
assert_eq!(
tracker.current(&msgs, Some(&tools), ratio, EST),
1_000,
"no appended messages → the real-token anchor alone, unscaled"
);
msgs.push(serde_json::json!({"role": "tool", "content": "b".repeat(400)}));
let appended = estimate_tokens(&msgs[2..], EST);
assert_eq!(
tracker.current(&msgs, Some(&tools), ratio, EST),
1_000 + super::super::calibrate_up(appended, ratio)
);
}
#[test]
fn prompt_tracker_invalidate_and_stale_anchor_fall_back() {
let msgs = vec![serde_json::json!({"role": "user", "content": "hello"})];
let mut tracker = PromptTracker::new();
tracker.record(1_000, 1);
tracker.invalidate();
assert_eq!(
tracker.current(&msgs, None, 1.0, EST),
estimate_tokens(&msgs, EST),
"invalidated anchor → fallback estimate"
);
tracker.record(1_000, 5);
assert_eq!(
tracker.current(&msgs, None, 1.0, EST),
estimate_tokens(&msgs, EST)
);
}
#[test]
fn ollama_usage_parses_or_none() {
let u = ollama_usage(&serde_json::json!({
"prompt_eval_count": 7, "eval_count": 3
}))
.unwrap();
assert_eq!(u.input_tokens, 7);
assert_eq!(u.output_tokens, 3);
assert!(ollama_usage(&serde_json::json!({"prompt_eval_count": 7})).is_none());
assert!(ollama_usage(&serde_json::json!({})).is_none());
}
#[test]
fn openai_usage_parses_or_none() {
let u = openai_usage(&json!({"prompt_tokens": 12, "completion_tokens": 34})).unwrap();
assert_eq!(u.input_tokens, 12);
assert_eq!(u.output_tokens, 34);
assert!(openai_usage(&json!({"prompt_tokens": 12})).is_none());
assert!(openai_usage(&json!({})).is_none());
}
}