Skip to main content

lean_ctx/proxy/
compress_api.rs

1//! `POST /v1/compress` — deterministic messages-in / messages-out compression.
2//!
3//! Drop-in parity with library-style `compress(messages, model)` gateways: the
4//! caller sends a chat-style `messages` array, the proxy rewrites every text
5//! payload through the same deterministic funnel used on the wire
6//! ([`super::compress::compress_tool_result_gateway`]), and returns the
7//! rewritten messages plus a structured token-savings summary. A lossy rewrite
8//! embeds a `hash=<24hex>` retrieval marker (#702) that LiteLLM's headroom
9//! guardrail resolves through `GET /v1/retrieve/{hash}` — the CCR agentic loop
10//! (BerriAI/litellm#31681) works against lean-ctx unchanged.
11//!
12//! ## Contract
13//! Request:  `{ "messages": [ … ], "model": "…"? }`
14//! Response: `{ "messages": [ … ], "stats": { … } }`
15//!
16//! Both OpenAI (`content: "string"`) and Anthropic (`content: [ {type:"text"…},
17//! {type:"tool_result"…} ]`) message shapes are accepted. Only text payloads are
18//! compressed; images, `tool_use` blocks, ids and every other field pass through
19//! untouched. lean-ctx's own `ctx_*` tool results are left verbatim (#479).
20//!
21//! ## Gateway compatibility (#700)
22//! The response also carries `tokens_before` / `tokens_after` /
23//! `compression_ratio` at the top level — the field names LiteLLM's
24//! prompt-compression guardrail reads for its per-request savings log. That
25//! makes lean-ctx a drop-in `api_base` for `guardrail: headroom` deployments:
26//! LiteLLM only requires `messages` in the reply and treats the token fields
27//! as optional telemetry.
28//!
29//! ## Determinism (#498)
30//! Output is a pure function of `(messages, model)`. Compression runs footer-free
31//! — savings are reported in `stats`, never injected into message bodies — so the
32//! result stays byte-stable for provider prompt caching.
33
34use 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    /// Optional model name, echoed into `stats.model` and used to select the
47    /// tokenizer family for compression and token accounting.
48    #[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    /// Percentage saved over the compressible text payloads, one decimal place.
58    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    /// LiteLLM-guardrail telemetry aliases (#700): duplicates of
69    /// `stats.original_tokens` / `stats.compressed_tokens` under the field
70    /// names the LiteLLM headroom guardrail logs (`tokens_before` →
71    /// `tokens_after`, ratio `after/before`).
72    pub tokens_before: usize,
73    pub tokens_after: usize,
74    /// `tokens_after / tokens_before`, rounded to 2 decimals; `1.0` when the
75    /// input had no compressible text.
76    pub compression_ratio: f64,
77}
78
79#[derive(Default)]
80struct Totals {
81    original: usize,
82    compressed: usize,
83}
84
85/// Axum handler. Malformed bodies are rejected by the `Json` extractor (400).
86pub async fn handler(Json(req): Json<CompressRequest>) -> impl IntoResponse {
87    (StatusCode::OK, Json(compress_messages(req)))
88}
89
90/// Pure, deterministic core: rewrites every text payload in `messages` and
91/// reports aggregate token savings. Same input → same output bytes (#498).
92pub 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    // OpenAI `tool`/`function` messages carry the tool name; pass it to the funnel
134    // so it can honour the #479 pass-through for lean-ctx's own `ctx_*` results.
135    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        // OpenAI + Anthropic text parts.
169        Some("text") => {
170            if let Some(Value::String(s)) = obj.get_mut("text") {
171                squeeze_in_place(s, name, totals, family);
172            }
173        }
174        // Anthropic tool_result: nested string or array of content blocks — the
175        // single biggest compressible payload in an agent transcript.
176        Some("tool_result") => {
177            if let Some(inner) = obj.get_mut("content") {
178                compress_content(inner, name, totals, family);
179            }
180        }
181        // image, tool_use, input_audio, document, … pass through untouched.
182        _ => {}
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    // Gateway audience (#702): a lossy rewrite carries the `hash=<24hex>`
194    // retrieval marker LiteLLM's CCR loop scans for; the savings footer is
195    // stripped inside the gateway funnel (stats carry the numbers instead).
196    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    /// A prose blob well over the funnel's 600-char floor: eight identical
212    /// paragraphs the prose squeeze deduplicates down to one.
213    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        // LiteLLM's headroom guardrail logs `tokens_before`/`tokens_after`/
261        // `compression_ratio` from the /v1/compress reply (#700). They must
262        // exist at the top level and agree with `stats`.
263        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        // No compressible text → ratio pins to 1.0, not 0/0.
277        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    /// #702: a lossy rewrite through the gateway contract must advertise its
295    /// retrieval hash in LiteLLM's regex-locked `hash=<24hex>` form, and the
296    /// hash must resolve back to the verbatim original — the wire half of the
297    /// guardrail's CCR agentic loop.
298    #[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        // Image block is preserved verbatim.
371        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        // A ctx_* result is already compressed at the tool boundary (#479).
404        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        // A malformed/absent content field must not panic.
419        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    /// #498 regression: a full, mixed-shape conversation must serialise to
425    /// byte-identical output across repeated calls. Provider prompt caching keys
426    /// on the exact bytes, so any non-determinism (ordering, footer leakage,
427    /// counter/timestamp) would silently destroy the cache discount.
428    #[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        // The byte-stable bodies must also be footer-free (savings live in stats).
456        assert!(!baseline.contains("[lean-ctx:"));
457        assert!(!baseline.contains('\u{2500}'));
458    }
459
460    /// Daemon-free, o200k_base benchmark over a real on-disk corpus. Prints a
461    /// JSON report (ratio + latency) and is `#[ignore]`d so it stays out of CI.
462    /// Reproduce: `cargo test -p lean-ctx --lib \
463    /// proxy::compress_api::tests::bench_real_corpus_o200k -- --ignored --nocapture`.
464    #[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}