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::count_tokens;
40
41use super::compress::compress_tool_result_gateway;
42
43/// Default tokenizer behind [`count_tokens`]; surfaced so SDK clients can label
44/// the savings figures correctly.
45const TOKENIZER: &str = "o200k_base";
46
47#[derive(Debug, Deserialize)]
48pub struct CompressRequest {
49    pub messages: Vec<Value>,
50    /// Optional, echoed into `stats.model`. Routing/pricing hint for SDK clients;
51    /// the deterministic funnel itself is model-agnostic.
52    #[serde(default)]
53    pub model: Option<String>,
54}
55
56#[derive(Debug, Serialize)]
57pub struct CompressStats {
58    pub original_tokens: usize,
59    pub compressed_tokens: usize,
60    pub saved_tokens: usize,
61    /// Percentage saved over the compressible text payloads, one decimal place.
62    pub saved_pct: f64,
63    pub tokenizer: &'static str,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub model: Option<String>,
66}
67
68#[derive(Debug, Serialize)]
69pub struct CompressResponse {
70    pub messages: Vec<Value>,
71    pub stats: CompressStats,
72    /// LiteLLM-guardrail telemetry aliases (#700): duplicates of
73    /// `stats.original_tokens` / `stats.compressed_tokens` under the field
74    /// names the LiteLLM headroom guardrail logs (`tokens_before` →
75    /// `tokens_after`, ratio `after/before`).
76    pub tokens_before: usize,
77    pub tokens_after: usize,
78    /// `tokens_after / tokens_before`, rounded to 2 decimals; `1.0` when the
79    /// input had no compressible text.
80    pub compression_ratio: f64,
81}
82
83#[derive(Default)]
84struct Totals {
85    original: usize,
86    compressed: usize,
87}
88
89/// Axum handler. Malformed bodies are rejected by the `Json` extractor (400).
90pub async fn handler(Json(req): Json<CompressRequest>) -> impl IntoResponse {
91    (StatusCode::OK, Json(compress_messages(req)))
92}
93
94/// Pure, deterministic core: rewrites every text payload in `messages` and
95/// reports aggregate token savings. Same input → same output bytes (#498).
96pub fn compress_messages(req: CompressRequest) -> CompressResponse {
97    let mut messages = req.messages;
98    let mut totals = Totals::default();
99    for msg in &mut messages {
100        compress_message(msg, &mut totals);
101    }
102
103    let saved = totals.original.saturating_sub(totals.compressed);
104    let saved_pct = if totals.original > 0 {
105        ((saved as f64 / totals.original as f64) * 1000.0).round() / 10.0
106    } else {
107        0.0
108    };
109    let compression_ratio = if totals.original > 0 {
110        ((totals.compressed as f64 / totals.original as f64) * 100.0).round() / 100.0
111    } else {
112        1.0
113    };
114
115    CompressResponse {
116        messages,
117        stats: CompressStats {
118            original_tokens: totals.original,
119            compressed_tokens: totals.compressed,
120            saved_tokens: saved,
121            saved_pct,
122            tokenizer: TOKENIZER,
123            model: req.model,
124        },
125        tokens_before: totals.original,
126        tokens_after: totals.compressed,
127        compression_ratio,
128    }
129}
130
131fn compress_message(msg: &mut Value, totals: &mut Totals) {
132    // OpenAI `tool`/`function` messages carry the tool name; pass it to the funnel
133    // so it can honour the #479 pass-through for lean-ctx's own `ctx_*` results.
134    let name = msg.get("name").and_then(Value::as_str).map(str::to_string);
135    if let Some(content) = msg.get_mut("content") {
136        compress_content(content, name.as_deref(), totals);
137    }
138}
139
140fn compress_content(content: &mut Value, name: Option<&str>, totals: &mut Totals) {
141    match content {
142        Value::String(s) => squeeze_in_place(s, name, totals),
143        Value::Array(blocks) => {
144            for block in blocks.iter_mut() {
145                compress_block(block, name, totals);
146            }
147        }
148        _ => {}
149    }
150}
151
152fn compress_block(block: &mut Value, name: Option<&str>, totals: &mut Totals) {
153    let Some(obj) = block.as_object_mut() else {
154        return;
155    };
156    match obj.get("type").and_then(Value::as_str) {
157        // OpenAI + Anthropic text parts.
158        Some("text") => {
159            if let Some(Value::String(s)) = obj.get_mut("text") {
160                squeeze_in_place(s, name, totals);
161            }
162        }
163        // Anthropic tool_result: nested string or array of content blocks — the
164        // single biggest compressible payload in an agent transcript.
165        Some("tool_result") => {
166            if let Some(inner) = obj.get_mut("content") {
167                compress_content(inner, name, totals);
168            }
169        }
170        // image, tool_use, input_audio, document, … pass through untouched.
171        _ => {}
172    }
173}
174
175fn squeeze_in_place(s: &mut String, name: Option<&str>, totals: &mut Totals) {
176    let before = count_tokens(s);
177    // Gateway audience (#702): a lossy rewrite carries the `hash=<24hex>`
178    // retrieval marker LiteLLM's CCR loop scans for; the savings footer is
179    // stripped inside the gateway funnel (stats carry the numbers instead).
180    let compressed = compress_tool_result_gateway(s, name);
181    let clean = strip_trailing_savings_footer(&compressed);
182    let after = count_tokens(clean);
183    totals.original += before;
184    totals.compressed += after;
185    if clean != s {
186        *s = clean.to_string();
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use serde_json::json;
194
195    /// A prose blob well over the funnel's 600-char floor: eight identical
196    /// paragraphs the prose squeeze deduplicates down to one.
197    fn dedupable_prose() -> String {
198        let para = "Rust is a multi-paradigm systems programming language that \
199                    emphasizes performance, type safety, and fearless concurrency, \
200                    achieving memory safety without a garbage collector at runtime.";
201        format!("{}\n", [para; 8].join("\n\n"))
202    }
203
204    fn run(messages: Vec<Value>, model: Option<&str>) -> CompressResponse {
205        compress_messages(CompressRequest {
206            messages,
207            model: model.map(str::to_string),
208        })
209    }
210
211    #[test]
212    fn string_content_is_compressed_and_stats_reported() {
213        let _lock = crate::core::data_dir::test_env_lock();
214        let resp = run(
215            vec![json!({"role": "user", "content": dedupable_prose()})],
216            Some("claude-sonnet-4"),
217        );
218        let out = resp.messages[0]["content"].as_str().unwrap();
219        assert_eq!(
220            out.matches("fearless concurrency").count(),
221            1,
222            "duplicate paragraphs must be deduped"
223        );
224        assert!(resp.stats.saved_tokens > 0, "stats must reflect savings");
225        assert!(resp.stats.compressed_tokens < resp.stats.original_tokens);
226        assert_eq!(resp.stats.tokenizer, "o200k_base");
227        assert_eq!(resp.stats.model.as_deref(), Some("claude-sonnet-4"));
228    }
229
230    #[test]
231    fn litellm_guardrail_fields_present_and_consistent() {
232        let _lock = crate::core::data_dir::test_env_lock();
233        // LiteLLM's headroom guardrail logs `tokens_before`/`tokens_after`/
234        // `compression_ratio` from the /v1/compress reply (#700). They must
235        // exist at the top level and agree with `stats`.
236        let resp = run(
237            vec![json!({"role": "user", "content": dedupable_prose()})],
238            None,
239        );
240        assert_eq!(resp.tokens_before, resp.stats.original_tokens);
241        assert_eq!(resp.tokens_after, resp.stats.compressed_tokens);
242        assert!(resp.compression_ratio > 0.0 && resp.compression_ratio < 1.0);
243
244        let wire = serde_json::to_value(&resp).unwrap();
245        assert!(wire["tokens_before"].is_u64());
246        assert!(wire["tokens_after"].is_u64());
247        assert!(wire["compression_ratio"].is_f64());
248
249        // No compressible text → ratio pins to 1.0, not 0/0.
250        let empty = run(vec![json!({"role": "user", "content": "hi"})], None);
251        assert_eq!(empty.compression_ratio, 1.0);
252    }
253
254    #[test]
255    fn message_bodies_stay_footer_free() {
256        let _lock = crate::core::data_dir::test_env_lock();
257        let resp = run(
258            vec![json!({"role": "user", "content": dedupable_prose()})],
259            None,
260        );
261        let out = resp.messages[0]["content"].as_str().unwrap();
262        assert!(!out.contains('\u{2500}'), "no box-drawing footer in body");
263        assert!(!out.contains("[lean-ctx:"), "no savings footer in body");
264        assert!(resp.stats.model.is_none());
265    }
266
267    /// #702: a lossy rewrite through the gateway contract must advertise its
268    /// retrieval hash in LiteLLM's regex-locked `hash=<24hex>` form, and the
269    /// hash must resolve back to the verbatim original — the wire half of the
270    /// guardrail's CCR agentic loop.
271    #[test]
272    fn lossy_rewrite_carries_litellm_retrieval_marker() {
273        let _lock = crate::core::data_dir::test_env_lock();
274        let original = dedupable_prose();
275        let resp = run(
276            vec![json!({"role": "user", "content": original.clone()})],
277            None,
278        );
279        let out = resp.messages[0]["content"].as_str().unwrap();
280
281        let litellm_regex = regex::Regex::new(r"hash=([a-f0-9]{24})").unwrap();
282        let hash = litellm_regex
283            .captures(out)
284            .unwrap_or_else(|| panic!("lossy body must carry the hash= marker: {out}"))
285            .get(1)
286            .unwrap()
287            .as_str();
288        let recovered = super::super::ccr::retrieve_litellm(hash)
289            .expect("marker hash must resolve via /v1/retrieve");
290        assert!(
291            recovered.contains("fearless concurrency"),
292            "retrieve returns the verbatim pre-compression original"
293        );
294        assert_eq!(
295            recovered.matches("fearless concurrency").count(),
296            8,
297            "all deduped paragraphs are recoverable"
298        );
299    }
300
301    #[test]
302    fn output_is_deterministic() {
303        let _lock = crate::core::data_dir::test_env_lock();
304        let msgs = vec![
305            json!({"role": "system", "content": "You are a helpful assistant."}),
306            json!({"role": "user", "content": dedupable_prose()}),
307        ];
308        let a = serde_json::to_string(&run(msgs.clone(), Some("gpt-4o"))).unwrap();
309        let b = serde_json::to_string(&run(msgs, Some("gpt-4o"))).unwrap();
310        assert_eq!(a, b, "same input must yield byte-identical output");
311    }
312
313    #[test]
314    fn short_content_is_untouched() {
315        let resp = run(vec![json!({"role": "user", "content": "hi there"})], None);
316        assert_eq!(resp.messages[0]["content"], "hi there");
317        assert_eq!(resp.stats.saved_tokens, 0);
318        assert_eq!(resp.stats.saved_pct, 0.0);
319    }
320
321    #[test]
322    fn anthropic_blocks_text_compressed_image_passthrough() {
323        let _lock = crate::core::data_dir::test_env_lock();
324        let resp = run(
325            vec![json!({
326                "role": "user",
327                "content": [
328                    {"type": "text", "text": dedupable_prose()},
329                    {"type": "image", "source": {"type": "base64", "data": "AAAA"}},
330                ],
331            })],
332            None,
333        );
334        let blocks = resp.messages[0]["content"].as_array().unwrap();
335        assert_eq!(
336            blocks[0]["text"]
337                .as_str()
338                .unwrap()
339                .matches("fearless concurrency")
340                .count(),
341            1
342        );
343        // Image block is preserved verbatim.
344        assert_eq!(blocks[1]["source"]["data"], "AAAA");
345    }
346
347    #[test]
348    fn anthropic_tool_result_block_is_compressed() {
349        let _lock = crate::core::data_dir::test_env_lock();
350        let resp = run(
351            vec![json!({
352                "role": "user",
353                "content": [{
354                    "type": "tool_result",
355                    "tool_use_id": "toolu_123",
356                    "content": dedupable_prose(),
357                }],
358            })],
359            None,
360        );
361        let block = &resp.messages[0]["content"][0];
362        assert_eq!(block["tool_use_id"], "toolu_123", "ids preserved");
363        assert_eq!(
364            block["content"]
365                .as_str()
366                .unwrap()
367                .matches("fearless concurrency")
368                .count(),
369            1
370        );
371        assert!(resp.stats.saved_tokens > 0);
372    }
373
374    #[test]
375    fn lean_ctx_tool_output_passes_through_verbatim() {
376        // A ctx_* result is already compressed at the tool boundary (#479).
377        let prose = dedupable_prose();
378        let resp = run(
379            vec![json!({"role": "tool", "name": "ctx_read", "content": prose.clone()})],
380            None,
381        );
382        assert_eq!(resp.messages[0]["content"].as_str().unwrap(), prose);
383        assert_eq!(
384            resp.stats.saved_tokens, 0,
385            "ctx_* output is not re-compressed"
386        );
387    }
388
389    #[test]
390    fn non_string_content_is_ignored() {
391        // A malformed/absent content field must not panic.
392        let resp = run(vec![json!({"role": "assistant", "tool_calls": []})], None);
393        assert_eq!(resp.stats.original_tokens, 0);
394        assert_eq!(resp.messages.len(), 1);
395    }
396
397    /// #498 regression: a full, mixed-shape conversation must serialise to
398    /// byte-identical output across repeated calls. Provider prompt caching keys
399    /// on the exact bytes, so any non-determinism (ordering, footer leakage,
400    /// counter/timestamp) would silently destroy the cache discount.
401    #[test]
402    fn determinism_regression_full_conversation_498() {
403        let _lock = crate::core::data_dir::test_env_lock();
404        let conversation = || {
405            vec![
406                json!({"role": "system", "content": "You are a helpful assistant."}),
407                json!({"role": "user", "content": dedupable_prose()}),
408                json!({
409                    "role": "user",
410                    "content": [
411                        {"type": "text", "text": dedupable_prose()},
412                        {"type": "image", "source": {"type": "base64", "data": "AAAA"}},
413                        {"type": "tool_result", "tool_use_id": "toolu_1", "content": dedupable_prose()},
414                    ],
415                }),
416                json!({"role": "tool", "name": "ctx_read", "content": dedupable_prose()}),
417            ]
418        };
419
420        let baseline =
421            serde_json::to_string(&run(conversation(), Some("claude-sonnet-4"))).unwrap();
422        for _ in 0..4 {
423            let again =
424                serde_json::to_string(&run(conversation(), Some("claude-sonnet-4"))).unwrap();
425            assert_eq!(again, baseline, "/v1/compress output must be byte-stable");
426        }
427
428        // The byte-stable bodies must also be footer-free (savings live in stats).
429        assert!(!baseline.contains("[lean-ctx:"));
430        assert!(!baseline.contains('\u{2500}'));
431    }
432
433    /// Daemon-free, o200k_base benchmark over a real on-disk corpus. Prints a
434    /// JSON report (ratio + latency) and is `#[ignore]`d so it stays out of CI.
435    /// Reproduce: `cargo test -p lean-ctx --lib \
436    /// proxy::compress_api::tests::bench_real_corpus_o200k -- --ignored --nocapture`.
437    #[test]
438    #[ignore = "benchmark; run explicitly with --ignored --nocapture"]
439    fn bench_real_corpus_o200k() {
440        use std::path::Path;
441        use std::time::Instant;
442
443        let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../docs/reference");
444        let mut messages = Vec::new();
445        if let Ok(entries) = std::fs::read_dir(&corpus) {
446            let mut paths: Vec<_> = entries
447                .flatten()
448                .map(|e| e.path())
449                .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("md"))
450                .collect();
451            paths.sort();
452            for path in paths {
453                if let Ok(text) = std::fs::read_to_string(&path) {
454                    messages.push(json!({"role": "user", "content": text}));
455                }
456            }
457        }
458        assert!(!messages.is_empty(), "no corpus files found at {corpus:?}");
459
460        let files = messages.len();
461        let started = Instant::now();
462        let resp = run(messages, Some("gpt-4o"));
463        let latency_ms = started.elapsed().as_secs_f64() * 1000.0;
464
465        let report = json!({
466            "corpus": corpus.to_string_lossy(),
467            "files": files,
468            "tokenizer": resp.stats.tokenizer,
469            "original_tokens": resp.stats.original_tokens,
470            "compressed_tokens": resp.stats.compressed_tokens,
471            "tokens_saved": resp.stats.saved_tokens,
472            "saved_pct": resp.stats.saved_pct,
473            "latency_ms": (latency_ms * 100.0).round() / 100.0,
474        });
475        println!("{}", serde_json::to_string_pretty(&report).unwrap());
476    }
477}