1use axum::{Json, http::StatusCode, response::IntoResponse};
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37
38use crate::core::protocol::strip_trailing_savings_footer;
39use crate::core::tokens::{TokenizerFamily, count_tokens_for, detect_tokenizer};
40
41use super::compress::compress_tool_result_gateway_for;
42
43#[derive(Debug, Deserialize)]
44pub struct CompressRequest {
45 pub messages: Vec<Value>,
46 #[serde(default)]
49 pub model: Option<String>,
50}
51
52#[derive(Debug, Serialize)]
53pub struct CompressStats {
54 pub original_tokens: usize,
55 pub compressed_tokens: usize,
56 pub saved_tokens: usize,
57 pub saved_pct: f64,
59 pub tokenizer: String,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub model: Option<String>,
62}
63
64#[derive(Debug, Serialize)]
65pub struct CompressResponse {
66 pub messages: Vec<Value>,
67 pub stats: CompressStats,
68 pub tokens_before: usize,
73 pub tokens_after: usize,
74 pub compression_ratio: f64,
77}
78
79#[derive(Default)]
80struct Totals {
81 original: usize,
82 compressed: usize,
83}
84
85pub async fn handler(Json(req): Json<CompressRequest>) -> impl IntoResponse {
87 (StatusCode::OK, Json(compress_messages(req)))
88}
89
90pub fn compress_messages(req: CompressRequest) -> CompressResponse {
93 let family = req
94 .model
95 .as_deref()
96 .map(detect_tokenizer)
97 .unwrap_or_default();
98 let mut messages = req.messages;
99 let mut totals = Totals::default();
100 for msg in &mut messages {
101 compress_message(msg, &mut totals, family);
102 }
103
104 let saved = totals.original.saturating_sub(totals.compressed);
105 let saved_pct = if totals.original > 0 {
106 ((saved as f64 / totals.original as f64) * 1000.0).round() / 10.0
107 } else {
108 0.0
109 };
110 let compression_ratio = if totals.original > 0 {
111 ((totals.compressed as f64 / totals.original as f64) * 100.0).round() / 100.0
112 } else {
113 1.0
114 };
115
116 CompressResponse {
117 messages,
118 stats: CompressStats {
119 original_tokens: totals.original,
120 compressed_tokens: totals.compressed,
121 saved_tokens: saved,
122 saved_pct,
123 tokenizer: family.to_string(),
124 model: req.model,
125 },
126 tokens_before: totals.original,
127 tokens_after: totals.compressed,
128 compression_ratio,
129 }
130}
131
132fn compress_message(msg: &mut Value, totals: &mut Totals, family: TokenizerFamily) {
133 let name = msg.get("name").and_then(Value::as_str).map(str::to_string);
136 if let Some(content) = msg.get_mut("content") {
137 compress_content(content, name.as_deref(), totals, family);
138 }
139}
140
141fn compress_content(
142 content: &mut Value,
143 name: Option<&str>,
144 totals: &mut Totals,
145 family: TokenizerFamily,
146) {
147 match content {
148 Value::String(s) => squeeze_in_place(s, name, totals, family),
149 Value::Array(blocks) => {
150 for block in blocks.iter_mut() {
151 compress_block(block, name, totals, family);
152 }
153 }
154 _ => {}
155 }
156}
157
158fn compress_block(
159 block: &mut Value,
160 name: Option<&str>,
161 totals: &mut Totals,
162 family: TokenizerFamily,
163) {
164 let Some(obj) = block.as_object_mut() else {
165 return;
166 };
167 match obj.get("type").and_then(Value::as_str) {
168 Some("text") => {
170 if let Some(Value::String(s)) = obj.get_mut("text") {
171 squeeze_in_place(s, name, totals, family);
172 }
173 }
174 Some("tool_result") => {
177 if let Some(inner) = obj.get_mut("content") {
178 compress_content(inner, name, totals, family);
179 }
180 }
181 _ => {}
183 }
184}
185
186fn squeeze_in_place(
187 s: &mut String,
188 name: Option<&str>,
189 totals: &mut Totals,
190 family: TokenizerFamily,
191) {
192 let before = count_tokens_for(s, family);
193 let compressed = compress_tool_result_gateway_for(s, name, family);
197 let clean = strip_trailing_savings_footer(&compressed);
198 let after = count_tokens_for(clean, family);
199 totals.original += before;
200 totals.compressed += after;
201 if clean != s {
202 *s = clean.to_string();
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use serde_json::json;
210
211 fn dedupable_prose() -> String {
214 let para = "Rust is a multi-paradigm systems programming language that \
215 emphasizes performance, type safety, and fearless concurrency, \
216 achieving memory safety without a garbage collector at runtime.";
217 format!("{}\n", [para; 8].join("\n\n"))
218 }
219
220 fn run(messages: Vec<Value>, model: Option<&str>) -> CompressResponse {
221 compress_messages(CompressRequest {
222 messages,
223 model: model.map(str::to_string),
224 })
225 }
226
227 #[test]
228 fn string_content_is_compressed_and_stats_reported() {
229 let _lock = crate::core::data_dir::test_env_lock();
230 let resp = run(
231 vec![json!({"role": "user", "content": dedupable_prose()})],
232 Some("claude-sonnet-4"),
233 );
234 let out = resp.messages[0]["content"].as_str().unwrap();
235 assert_eq!(
236 out.matches("fearless concurrency").count(),
237 1,
238 "duplicate paragraphs must be deduped"
239 );
240 assert!(resp.stats.saved_tokens > 0, "stats must reflect savings");
241 assert!(resp.stats.compressed_tokens < resp.stats.original_tokens);
242 assert_eq!(resp.stats.tokenizer, "cl100k_base");
243 assert_eq!(resp.stats.model.as_deref(), Some("claude-sonnet-4"));
244 }
245
246 #[test]
247 fn model_selects_tokenizer_family_for_gateway_compression() {
248 let resp = run(
249 vec![json!({"role": "user", "content": dedupable_prose()})],
250 Some("claude-sonnet-4"),
251 );
252
253 assert_eq!(resp.stats.model.as_deref(), Some("claude-sonnet-4"));
254 assert_eq!(resp.stats.tokenizer, "cl100k_base");
255 }
256
257 #[test]
258 fn litellm_guardrail_fields_present_and_consistent() {
259 let _lock = crate::core::data_dir::test_env_lock();
260 let resp = run(
264 vec![json!({"role": "user", "content": dedupable_prose()})],
265 None,
266 );
267 assert_eq!(resp.tokens_before, resp.stats.original_tokens);
268 assert_eq!(resp.tokens_after, resp.stats.compressed_tokens);
269 assert!(resp.compression_ratio > 0.0 && resp.compression_ratio < 1.0);
270
271 let wire = serde_json::to_value(&resp).unwrap();
272 assert!(wire["tokens_before"].is_u64());
273 assert!(wire["tokens_after"].is_u64());
274 assert!(wire["compression_ratio"].is_f64());
275
276 let empty = run(vec![json!({"role": "user", "content": "hi"})], None);
278 assert_eq!(empty.compression_ratio, 1.0);
279 }
280
281 #[test]
282 fn message_bodies_stay_footer_free() {
283 let _lock = crate::core::data_dir::test_env_lock();
284 let resp = run(
285 vec![json!({"role": "user", "content": dedupable_prose()})],
286 None,
287 );
288 let out = resp.messages[0]["content"].as_str().unwrap();
289 assert!(!out.contains('\u{2500}'), "no box-drawing footer in body");
290 assert!(!out.contains("[lean-ctx:"), "no savings footer in body");
291 assert!(resp.stats.model.is_none());
292 }
293
294 #[test]
299 fn lossy_rewrite_carries_litellm_retrieval_marker() {
300 let _lock = crate::core::data_dir::test_env_lock();
301 let original = dedupable_prose();
302 let resp = run(
303 vec![json!({"role": "user", "content": original.clone()})],
304 None,
305 );
306 let out = resp.messages[0]["content"].as_str().unwrap();
307
308 let litellm_regex = regex::Regex::new(r"hash=([a-f0-9]{24})").unwrap();
309 let hash = litellm_regex
310 .captures(out)
311 .unwrap_or_else(|| panic!("lossy body must carry the hash= marker: {out}"))
312 .get(1)
313 .unwrap()
314 .as_str();
315 let recovered = super::super::ccr::retrieve_litellm(hash)
316 .expect("marker hash must resolve via /v1/retrieve");
317 assert!(
318 recovered.contains("fearless concurrency"),
319 "retrieve returns the verbatim pre-compression original"
320 );
321 assert_eq!(
322 recovered.matches("fearless concurrency").count(),
323 8,
324 "all deduped paragraphs are recoverable"
325 );
326 }
327
328 #[test]
329 fn output_is_deterministic() {
330 let _lock = crate::core::data_dir::test_env_lock();
331 let msgs = vec![
332 json!({"role": "system", "content": "You are a helpful assistant."}),
333 json!({"role": "user", "content": dedupable_prose()}),
334 ];
335 let a = serde_json::to_string(&run(msgs.clone(), Some("gpt-4o"))).unwrap();
336 let b = serde_json::to_string(&run(msgs, Some("gpt-4o"))).unwrap();
337 assert_eq!(a, b, "same input must yield byte-identical output");
338 }
339
340 #[test]
341 fn short_content_is_untouched() {
342 let resp = run(vec![json!({"role": "user", "content": "hi there"})], None);
343 assert_eq!(resp.messages[0]["content"], "hi there");
344 assert_eq!(resp.stats.saved_tokens, 0);
345 assert_eq!(resp.stats.saved_pct, 0.0);
346 }
347
348 #[test]
349 fn anthropic_blocks_text_compressed_image_passthrough() {
350 let _lock = crate::core::data_dir::test_env_lock();
351 let resp = run(
352 vec![json!({
353 "role": "user",
354 "content": [
355 {"type": "text", "text": dedupable_prose()},
356 {"type": "image", "source": {"type": "base64", "data": "AAAA"}},
357 ],
358 })],
359 None,
360 );
361 let blocks = resp.messages[0]["content"].as_array().unwrap();
362 assert_eq!(
363 blocks[0]["text"]
364 .as_str()
365 .unwrap()
366 .matches("fearless concurrency")
367 .count(),
368 1
369 );
370 assert_eq!(blocks[1]["source"]["data"], "AAAA");
372 }
373
374 #[test]
375 fn anthropic_tool_result_block_is_compressed() {
376 let _lock = crate::core::data_dir::test_env_lock();
377 let resp = run(
378 vec![json!({
379 "role": "user",
380 "content": [{
381 "type": "tool_result",
382 "tool_use_id": "toolu_123",
383 "content": dedupable_prose(),
384 }],
385 })],
386 None,
387 );
388 let block = &resp.messages[0]["content"][0];
389 assert_eq!(block["tool_use_id"], "toolu_123", "ids preserved");
390 assert_eq!(
391 block["content"]
392 .as_str()
393 .unwrap()
394 .matches("fearless concurrency")
395 .count(),
396 1
397 );
398 assert!(resp.stats.saved_tokens > 0);
399 }
400
401 #[test]
402 fn lean_ctx_tool_output_passes_through_verbatim() {
403 let prose = dedupable_prose();
405 let resp = run(
406 vec![json!({"role": "tool", "name": "ctx_read", "content": prose.clone()})],
407 None,
408 );
409 assert_eq!(resp.messages[0]["content"].as_str().unwrap(), prose);
410 assert_eq!(
411 resp.stats.saved_tokens, 0,
412 "ctx_* output is not re-compressed"
413 );
414 }
415
416 #[test]
417 fn non_string_content_is_ignored() {
418 let resp = run(vec![json!({"role": "assistant", "tool_calls": []})], None);
420 assert_eq!(resp.stats.original_tokens, 0);
421 assert_eq!(resp.messages.len(), 1);
422 }
423
424 #[test]
429 fn determinism_regression_full_conversation_498() {
430 let _lock = crate::core::data_dir::test_env_lock();
431 let conversation = || {
432 vec![
433 json!({"role": "system", "content": "You are a helpful assistant."}),
434 json!({"role": "user", "content": dedupable_prose()}),
435 json!({
436 "role": "user",
437 "content": [
438 {"type": "text", "text": dedupable_prose()},
439 {"type": "image", "source": {"type": "base64", "data": "AAAA"}},
440 {"type": "tool_result", "tool_use_id": "toolu_1", "content": dedupable_prose()},
441 ],
442 }),
443 json!({"role": "tool", "name": "ctx_read", "content": dedupable_prose()}),
444 ]
445 };
446
447 let baseline =
448 serde_json::to_string(&run(conversation(), Some("claude-sonnet-4"))).unwrap();
449 for _ in 0..4 {
450 let again =
451 serde_json::to_string(&run(conversation(), Some("claude-sonnet-4"))).unwrap();
452 assert_eq!(again, baseline, "/v1/compress output must be byte-stable");
453 }
454
455 assert!(!baseline.contains("[lean-ctx:"));
457 assert!(!baseline.contains('\u{2500}'));
458 }
459
460 #[test]
465 #[ignore = "benchmark; run explicitly with --ignored --nocapture"]
466 fn bench_real_corpus_o200k() {
467 use std::path::Path;
468 use std::time::Instant;
469
470 let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../docs/reference");
471 let mut messages = Vec::new();
472 if let Ok(entries) = std::fs::read_dir(&corpus) {
473 let mut paths: Vec<_> = entries
474 .flatten()
475 .map(|e| e.path())
476 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("md"))
477 .collect();
478 paths.sort();
479 for path in paths {
480 if let Ok(text) = std::fs::read_to_string(&path) {
481 messages.push(json!({"role": "user", "content": text}));
482 }
483 }
484 }
485 assert!(!messages.is_empty(), "no corpus files found at {corpus:?}");
486
487 let files = messages.len();
488 let started = Instant::now();
489 let resp = run(messages, Some("gpt-4o"));
490 let latency_ms = started.elapsed().as_secs_f64() * 1000.0;
491
492 let report = json!({
493 "corpus": corpus.to_string_lossy(),
494 "files": files,
495 "tokenizer": resp.stats.tokenizer,
496 "original_tokens": resp.stats.original_tokens,
497 "compressed_tokens": resp.stats.compressed_tokens,
498 "tokens_saved": resp.stats.saved_tokens,
499 "saved_pct": resp.stats.saved_pct,
500 "latency_ms": (latency_ms * 100.0).round() / 100.0,
501 });
502 println!("{}", serde_json::to_string_pretty(&report).unwrap());
503 }
504}