1use axum::{Json, http::StatusCode, response::IntoResponse};
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26
27use crate::core::protocol::strip_trailing_savings_footer;
28use crate::core::tokens::count_tokens;
29
30use super::compress::compress_tool_result;
31
32const TOKENIZER: &str = "o200k_base";
35
36#[derive(Debug, Deserialize)]
37pub struct CompressRequest {
38 pub messages: Vec<Value>,
39 #[serde(default)]
42 pub model: Option<String>,
43}
44
45#[derive(Debug, Serialize)]
46pub struct CompressStats {
47 pub original_tokens: usize,
48 pub compressed_tokens: usize,
49 pub saved_tokens: usize,
50 pub saved_pct: f64,
52 pub tokenizer: &'static str,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub model: Option<String>,
55}
56
57#[derive(Debug, Serialize)]
58pub struct CompressResponse {
59 pub messages: Vec<Value>,
60 pub stats: CompressStats,
61}
62
63#[derive(Default)]
64struct Totals {
65 original: usize,
66 compressed: usize,
67}
68
69pub async fn handler(Json(req): Json<CompressRequest>) -> impl IntoResponse {
71 (StatusCode::OK, Json(compress_messages(req)))
72}
73
74pub fn compress_messages(req: CompressRequest) -> CompressResponse {
77 let mut messages = req.messages;
78 let mut totals = Totals::default();
79 for msg in &mut messages {
80 compress_message(msg, &mut totals);
81 }
82
83 let saved = totals.original.saturating_sub(totals.compressed);
84 let saved_pct = if totals.original > 0 {
85 ((saved as f64 / totals.original as f64) * 1000.0).round() / 10.0
86 } else {
87 0.0
88 };
89
90 CompressResponse {
91 messages,
92 stats: CompressStats {
93 original_tokens: totals.original,
94 compressed_tokens: totals.compressed,
95 saved_tokens: saved,
96 saved_pct,
97 tokenizer: TOKENIZER,
98 model: req.model,
99 },
100 }
101}
102
103fn compress_message(msg: &mut Value, totals: &mut Totals) {
104 let name = msg.get("name").and_then(Value::as_str).map(str::to_string);
107 if let Some(content) = msg.get_mut("content") {
108 compress_content(content, name.as_deref(), totals);
109 }
110}
111
112fn compress_content(content: &mut Value, name: Option<&str>, totals: &mut Totals) {
113 match content {
114 Value::String(s) => squeeze_in_place(s, name, totals),
115 Value::Array(blocks) => {
116 for block in blocks.iter_mut() {
117 compress_block(block, name, totals);
118 }
119 }
120 _ => {}
121 }
122}
123
124fn compress_block(block: &mut Value, name: Option<&str>, totals: &mut Totals) {
125 let Some(obj) = block.as_object_mut() else {
126 return;
127 };
128 match obj.get("type").and_then(Value::as_str) {
129 Some("text") => {
131 if let Some(Value::String(s)) = obj.get_mut("text") {
132 squeeze_in_place(s, name, totals);
133 }
134 }
135 Some("tool_result") => {
138 if let Some(inner) = obj.get_mut("content") {
139 compress_content(inner, name, totals);
140 }
141 }
142 _ => {}
144 }
145}
146
147fn squeeze_in_place(s: &mut String, name: Option<&str>, totals: &mut Totals) {
148 let before = count_tokens(s);
149 let compressed = compress_tool_result(s, name);
150 let clean = strip_trailing_savings_footer(&compressed);
151 let after = count_tokens(clean);
152 totals.original += before;
153 totals.compressed += after;
154 if clean != s {
155 *s = clean.to_string();
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use serde_json::json;
163
164 fn dedupable_prose() -> String {
167 let para = "Rust is a multi-paradigm systems programming language that \
168 emphasizes performance, type safety, and fearless concurrency, \
169 achieving memory safety without a garbage collector at runtime.";
170 format!("{}\n", [para; 8].join("\n\n"))
171 }
172
173 fn run(messages: Vec<Value>, model: Option<&str>) -> CompressResponse {
174 compress_messages(CompressRequest {
175 messages,
176 model: model.map(str::to_string),
177 })
178 }
179
180 #[test]
181 fn string_content_is_compressed_and_stats_reported() {
182 let resp = run(
183 vec![json!({"role": "user", "content": dedupable_prose()})],
184 Some("claude-sonnet-4"),
185 );
186 let out = resp.messages[0]["content"].as_str().unwrap();
187 assert_eq!(
188 out.matches("fearless concurrency").count(),
189 1,
190 "duplicate paragraphs must be deduped"
191 );
192 assert!(resp.stats.saved_tokens > 0, "stats must reflect savings");
193 assert!(resp.stats.compressed_tokens < resp.stats.original_tokens);
194 assert_eq!(resp.stats.tokenizer, "o200k_base");
195 assert_eq!(resp.stats.model.as_deref(), Some("claude-sonnet-4"));
196 }
197
198 #[test]
199 fn message_bodies_stay_footer_free() {
200 let resp = run(
201 vec![json!({"role": "user", "content": dedupable_prose()})],
202 None,
203 );
204 let out = resp.messages[0]["content"].as_str().unwrap();
205 assert!(!out.contains('\u{2500}'), "no box-drawing footer in body");
206 assert!(!out.contains("[lean-ctx:"), "no verbatim footer in body");
207 assert!(resp.stats.model.is_none());
208 }
209
210 #[test]
211 fn output_is_deterministic() {
212 let msgs = vec![
213 json!({"role": "system", "content": "You are a helpful assistant."}),
214 json!({"role": "user", "content": dedupable_prose()}),
215 ];
216 let a = serde_json::to_string(&run(msgs.clone(), Some("gpt-4o"))).unwrap();
217 let b = serde_json::to_string(&run(msgs, Some("gpt-4o"))).unwrap();
218 assert_eq!(a, b, "same input must yield byte-identical output");
219 }
220
221 #[test]
222 fn short_content_is_untouched() {
223 let resp = run(vec![json!({"role": "user", "content": "hi there"})], None);
224 assert_eq!(resp.messages[0]["content"], "hi there");
225 assert_eq!(resp.stats.saved_tokens, 0);
226 assert_eq!(resp.stats.saved_pct, 0.0);
227 }
228
229 #[test]
230 fn anthropic_blocks_text_compressed_image_passthrough() {
231 let resp = run(
232 vec![json!({
233 "role": "user",
234 "content": [
235 {"type": "text", "text": dedupable_prose()},
236 {"type": "image", "source": {"type": "base64", "data": "AAAA"}},
237 ],
238 })],
239 None,
240 );
241 let blocks = resp.messages[0]["content"].as_array().unwrap();
242 assert_eq!(
243 blocks[0]["text"]
244 .as_str()
245 .unwrap()
246 .matches("fearless concurrency")
247 .count(),
248 1
249 );
250 assert_eq!(blocks[1]["source"]["data"], "AAAA");
252 }
253
254 #[test]
255 fn anthropic_tool_result_block_is_compressed() {
256 let resp = run(
257 vec![json!({
258 "role": "user",
259 "content": [{
260 "type": "tool_result",
261 "tool_use_id": "toolu_123",
262 "content": dedupable_prose(),
263 }],
264 })],
265 None,
266 );
267 let block = &resp.messages[0]["content"][0];
268 assert_eq!(block["tool_use_id"], "toolu_123", "ids preserved");
269 assert_eq!(
270 block["content"]
271 .as_str()
272 .unwrap()
273 .matches("fearless concurrency")
274 .count(),
275 1
276 );
277 assert!(resp.stats.saved_tokens > 0);
278 }
279
280 #[test]
281 fn lean_ctx_tool_output_passes_through_verbatim() {
282 let prose = dedupable_prose();
284 let resp = run(
285 vec![json!({"role": "tool", "name": "ctx_read", "content": prose.clone()})],
286 None,
287 );
288 assert_eq!(resp.messages[0]["content"].as_str().unwrap(), prose);
289 assert_eq!(
290 resp.stats.saved_tokens, 0,
291 "ctx_* output is not re-compressed"
292 );
293 }
294
295 #[test]
296 fn non_string_content_is_ignored() {
297 let resp = run(vec![json!({"role": "assistant", "tool_calls": []})], None);
299 assert_eq!(resp.stats.original_tokens, 0);
300 assert_eq!(resp.messages.len(), 1);
301 }
302
303 #[test]
308 fn determinism_regression_full_conversation_498() {
309 let conversation = || {
310 vec![
311 json!({"role": "system", "content": "You are a helpful assistant."}),
312 json!({"role": "user", "content": dedupable_prose()}),
313 json!({
314 "role": "user",
315 "content": [
316 {"type": "text", "text": dedupable_prose()},
317 {"type": "image", "source": {"type": "base64", "data": "AAAA"}},
318 {"type": "tool_result", "tool_use_id": "toolu_1", "content": dedupable_prose()},
319 ],
320 }),
321 json!({"role": "tool", "name": "ctx_read", "content": dedupable_prose()}),
322 ]
323 };
324
325 let baseline =
326 serde_json::to_string(&run(conversation(), Some("claude-sonnet-4"))).unwrap();
327 for _ in 0..4 {
328 let again =
329 serde_json::to_string(&run(conversation(), Some("claude-sonnet-4"))).unwrap();
330 assert_eq!(again, baseline, "/v1/compress output must be byte-stable");
331 }
332
333 assert!(!baseline.contains("[lean-ctx:"));
335 assert!(!baseline.contains('\u{2500}'));
336 }
337
338 #[test]
343 #[ignore = "benchmark; run explicitly with --ignored --nocapture"]
344 fn bench_real_corpus_o200k() {
345 use std::path::Path;
346 use std::time::Instant;
347
348 let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../docs/reference");
349 let mut messages = Vec::new();
350 if let Ok(entries) = std::fs::read_dir(&corpus) {
351 let mut paths: Vec<_> = entries
352 .flatten()
353 .map(|e| e.path())
354 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("md"))
355 .collect();
356 paths.sort();
357 for path in paths {
358 if let Ok(text) = std::fs::read_to_string(&path) {
359 messages.push(json!({"role": "user", "content": text}));
360 }
361 }
362 }
363 assert!(!messages.is_empty(), "no corpus files found at {corpus:?}");
364
365 let files = messages.len();
366 let started = Instant::now();
367 let resp = run(messages, Some("gpt-4o"));
368 let latency_ms = started.elapsed().as_secs_f64() * 1000.0;
369
370 let report = json!({
371 "corpus": corpus.to_string_lossy(),
372 "files": files,
373 "tokenizer": resp.stats.tokenizer,
374 "original_tokens": resp.stats.original_tokens,
375 "compressed_tokens": resp.stats.compressed_tokens,
376 "tokens_saved": resp.stats.saved_tokens,
377 "saved_pct": resp.stats.saved_pct,
378 "latency_ms": (latency_ms * 100.0).round() / 100.0,
379 });
380 println!("{}", serde_json::to_string_pretty(&report).unwrap());
381 }
382}