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`]), and returns the rewritten
7//! messages plus a structured token-savings summary.
8//!
9//! ## Contract
10//! Request:  `{ "messages": [ … ], "model": "…"? }`
11//! Response: `{ "messages": [ … ], "stats": { … } }`
12//!
13//! Both OpenAI (`content: "string"`) and Anthropic (`content: [ {type:"text"…},
14//! {type:"tool_result"…} ]`) message shapes are accepted. Only text payloads are
15//! compressed; images, `tool_use` blocks, ids and every other field pass through
16//! untouched. lean-ctx's own `ctx_*` tool results are left verbatim (#479).
17//!
18//! ## Determinism (#498)
19//! Output is a pure function of `(messages, model)`. Compression runs footer-free
20//! — savings are reported in `stats`, never injected into message bodies — so the
21//! result stays byte-stable for provider prompt caching.
22
23use 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
32/// Default tokenizer behind [`count_tokens`]; surfaced so SDK clients can label
33/// the savings figures correctly.
34const TOKENIZER: &str = "o200k_base";
35
36#[derive(Debug, Deserialize)]
37pub struct CompressRequest {
38    pub messages: Vec<Value>,
39    /// Optional, echoed into `stats.model`. Routing/pricing hint for SDK clients;
40    /// the deterministic funnel itself is model-agnostic.
41    #[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    /// Percentage saved over the compressible text payloads, one decimal place.
51    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
69/// Axum handler. Malformed bodies are rejected by the `Json` extractor (400).
70pub async fn handler(Json(req): Json<CompressRequest>) -> impl IntoResponse {
71    (StatusCode::OK, Json(compress_messages(req)))
72}
73
74/// Pure, deterministic core: rewrites every text payload in `messages` and
75/// reports aggregate token savings. Same input → same output bytes (#498).
76pub 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    // OpenAI `tool`/`function` messages carry the tool name; pass it to the funnel
105    // so it can honour the #479 pass-through for lean-ctx's own `ctx_*` results.
106    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        // OpenAI + Anthropic text parts.
130        Some("text") => {
131            if let Some(Value::String(s)) = obj.get_mut("text") {
132                squeeze_in_place(s, name, totals);
133            }
134        }
135        // Anthropic tool_result: nested string or array of content blocks — the
136        // single biggest compressible payload in an agent transcript.
137        Some("tool_result") => {
138            if let Some(inner) = obj.get_mut("content") {
139                compress_content(inner, name, totals);
140            }
141        }
142        // image, tool_use, input_audio, document, … pass through untouched.
143        _ => {}
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    /// A prose blob well over the funnel's 600-char floor: eight identical
165    /// paragraphs the prose squeeze deduplicates down to one.
166    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        // Image block is preserved verbatim.
251        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        // A ctx_* result is already compressed at the tool boundary (#479).
283        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        // A malformed/absent content field must not panic.
298        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    /// #498 regression: a full, mixed-shape conversation must serialise to
304    /// byte-identical output across repeated calls. Provider prompt caching keys
305    /// on the exact bytes, so any non-determinism (ordering, footer leakage,
306    /// counter/timestamp) would silently destroy the cache discount.
307    #[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        // The byte-stable bodies must also be footer-free (savings live in stats).
334        assert!(!baseline.contains("[lean-ctx:"));
335        assert!(!baseline.contains('\u{2500}'));
336    }
337
338    /// Daemon-free, o200k_base benchmark over a real on-disk corpus. Prints a
339    /// JSON report (ratio + latency) and is `#[ignore]`d so it stays out of CI.
340    /// Reproduce: `cargo test -p lean-ctx --lib \
341    /// proxy::compress_api::tests::bench_real_corpus_o200k -- --ignored --nocapture`.
342    #[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}