Skip to main content

lean_ctx_proxy/
compress_api.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `POST /v1/compress` — deterministic messages-in / messages-out compression.
3//!
4//! Drop-in parity with library-style `compress(messages, model)` gateways: the
5//! caller sends a chat-style `messages` array, the proxy rewrites every text
6//! payload through the same deterministic funnel used on the wire
7//! ([`super::compress::compress_tool_result_gateway`]), and returns the
8//! rewritten messages plus a structured token-savings summary. A lossy rewrite
9//! embeds a `hash=<24hex>` retrieval marker (#702) that LiteLLM's headroom
10//! guardrail resolves through `GET /v1/retrieve/{hash}` — the CCR agentic loop
11//! (BerriAI/litellm#31681) works against lean-ctx unchanged.
12//!
13//! ## Contract
14//! Request:  `{ "messages": [ … ], "model": "…"? }`
15//! Response: `{ "messages": [ … ], "stats": { … } }`
16//!
17//! Both OpenAI (`content: "string"`) and Anthropic (`content: [ {type:"text"…},
18//! {type:"tool_result"…} ]`) message shapes are accepted. Only text payloads are
19//! compressed; images, `tool_use` blocks, ids and every other field pass through
20//! untouched. lean-ctx's own `ctx_*` tool results are left verbatim (#479).
21//!
22//! ## Gateway compatibility (#700)
23//! The response also carries `tokens_before` / `tokens_after` /
24//! `compression_ratio` at the top level — the field names LiteLLM's
25//! prompt-compression guardrail reads for its per-request savings log. That
26//! makes lean-ctx a drop-in `api_base` for `guardrail: headroom` deployments:
27//! LiteLLM only requires `messages` in the reply and treats the token fields
28//! as optional telemetry.
29//!
30//! ## Determinism (#498)
31//! Output is a pure function of `(messages, model)`. Compression runs footer-free
32//! — savings are reported in `stats`, never injected into message bodies — so the
33//! result stays byte-stable for provider prompt caching.
34
35#[cfg(feature = "http-server")]
36use axum::{
37    Json,
38    http::StatusCode,
39    response::{IntoResponse, Response},
40};
41use serde::{Deserialize, Serialize};
42use serde_json::Value;
43
44#[cfg(feature = "http-server")]
45use super::admitted::{self, GatewayRefusal, Payload};
46use super::live_zone;
47use lean_ctx::core::adaptive_policy::select_policy;
48#[cfg(feature = "http-server")]
49use lean_ctx::core::context_admission::egress::{EgressTarget, TrustedDestinationBinding};
50use lean_ctx::core::protocol::strip_trailing_savings_footer;
51use lean_ctx::core::tokens::{TokenizerFamily, count_tokens_for, detect_tokenizer};
52
53use super::compress::compress_tool_result_gateway_with_policy;
54
55#[derive(Debug, Deserialize)]
56pub struct CompressRequest {
57    pub messages: Vec<Value>,
58    /// Optional model name, echoed into `stats.model` and used to select the
59    /// tokenizer family for compression and token accounting.
60    #[serde(default)]
61    pub model: Option<String>,
62}
63
64#[derive(Debug, Serialize)]
65pub struct CompressStats {
66    pub original_tokens: usize,
67    pub compressed_tokens: usize,
68    pub saved_tokens: usize,
69    /// Percentage saved over the compressible text payloads, one decimal place.
70    pub saved_pct: f64,
71    pub tokenizer: String,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub model: Option<String>,
74}
75
76#[derive(Debug, Serialize)]
77pub struct CompressResponse {
78    pub messages: Vec<Value>,
79    pub stats: CompressStats,
80    /// LiteLLM-guardrail telemetry aliases (#700): duplicates of
81    /// `stats.original_tokens` / `stats.compressed_tokens` under the field
82    /// names the LiteLLM headroom guardrail logs (`tokens_before` →
83    /// `tokens_after`, ratio `after/before`).
84    pub tokens_before: usize,
85    pub tokens_after: usize,
86    /// `tokens_after / tokens_before`, rounded to 2 decimals; `1.0` when the
87    /// input had no compressible text.
88    pub compression_ratio: f64,
89}
90
91#[derive(Default)]
92struct Totals {
93    original: usize,
94    compressed: usize,
95}
96
97/// Destination label of the compress API in receipts. The reply goes back to
98/// a caller that forwards it to a model this proxy never sees, so the
99/// destination's locality is unknown — not provably local: restricted
100/// content is withheld, and configured placement rules cannot be satisfied.
101#[cfg(feature = "http-server")]
102const COMPRESS_DESTINATION: &str = "compress-api";
103
104/// Axum handler. Malformed bodies are rejected by the `Json` extractor (400).
105///
106/// The reply is a delivery and the retrieval copies compression keeps are a
107/// store: the messages are admitted by the gateway first, compression only
108/// ever sees the admitted form, and the reply bytes are bound to the receipt.
109/// A refusal is answered with the typed gateway reason.
110#[cfg(feature = "http-server")]
111pub async fn handler(Json(req): Json<CompressRequest>) -> Response {
112    match admitted_compress(req).await {
113        Ok(response) => response,
114        Err(refusal) => refusal.response("OpenAI"),
115    }
116}
117
118#[cfg(feature = "http-server")]
119async fn admitted_compress(req: CompressRequest) -> Result<Response, GatewayRefusal> {
120    let CompressRequest { messages, model } = req;
121    let mut document = Value::Array(messages);
122    let original_bytes = document.to_string().len();
123    let target = EgressTarget {
124        provider: COMPRESS_DESTINATION,
125        model: model.as_deref(),
126        upstream_base: "",
127    };
128    let mut admission = admitted::admit(
129        Payload::Document(&document),
130        &target,
131        TrustedDestinationBinding::default(),
132    )?;
133    if admission.is_refused() {
134        return Err(admitted::record_refusal(admission, original_bytes, None).await);
135    }
136    if let Some(admitted_document) = admission.take_rewritten() {
137        document = admitted_document;
138    }
139    let Value::Array(messages) = document else {
140        return Err(GatewayRefusal::unavailable("egress.encode_failed"));
141    };
142    // The reader of this reply can call this proxy's routes: under protection
143    // a block is compressed only with a recovery copy bound to its lineage,
144    // and its stub names `GET /v1/references/{id}`. What stays whole for want
145    // of such a copy is named, by code only, in the reply's reason-code
146    // header; the body keeps its contract.
147    let (compressed, kept_whole) =
148        super::compress::observing(super::compress::Reader::GatewayCaller, || {
149            compress_messages(CompressRequest { messages, model })
150        });
151    let reply = serde_json::to_vec(&compressed)
152        .map_err(|_| GatewayRefusal::unavailable("egress.encode_failed"))?;
153    // The receipt of this reply states the same codes, one note per block.
154    let admission = admission.note_kept_whole(&kept_whole);
155    admitted::bind_delivery(admission, &reply, original_bytes).await?;
156    let mut response = (
157        StatusCode::OK,
158        [(axum::http::header::CONTENT_TYPE, "application/json")],
159        reply,
160    )
161        .into_response();
162    if let Some(codes) = kept_whole.codes()
163        && let Ok(value) = axum::http::HeaderValue::from_str(&codes)
164    {
165        response
166            .headers_mut()
167            .insert(admitted::REASON_CODES_HEADER, value);
168    }
169    Ok(response)
170}
171
172/// Pure, deterministic core: rewrites every text payload in `messages` and
173/// reports aggregate token savings. Same input → same output bytes (#498).
174pub fn compress_messages(req: CompressRequest) -> CompressResponse {
175    let family = req
176        .model
177        .as_deref()
178        .map(detect_tokenizer)
179        .unwrap_or_default();
180    let mut messages = req.messages;
181    let mut totals = Totals::default();
182    let last_user_content = messages
183        .iter()
184        .rev()
185        .find(|message| message.get("role").and_then(Value::as_str) == Some("user"))
186        .and_then(|message| message.get("content").and_then(Value::as_str));
187    let task_class = super::pre_optimize::classify_task(last_user_content);
188
189    let policy = lean_ctx::core::adaptive_policy::best_policy_for(task_class);
190    let live_split = live_zone::detect_live_zone(&messages);
191    for msg in &mut messages[live_split.boundary_turn..] {
192        compress_message(msg, &mut totals, family, policy);
193    }
194    let _live_stats = live_zone::compress_live_only(&mut messages, family, select_policy("chat"));
195
196    let saved = totals.original.saturating_sub(totals.compressed);
197    let saved_pct = if totals.original > 0 {
198        ((saved as f64 / totals.original as f64) * 1000.0).round() / 10.0
199    } else {
200        0.0
201    };
202    let compression_ratio = if totals.original > 0 {
203        ((totals.compressed as f64 / totals.original as f64) * 100.0).round() / 100.0
204    } else {
205        1.0
206    };
207
208    CompressResponse {
209        messages,
210        stats: CompressStats {
211            original_tokens: totals.original,
212            compressed_tokens: totals.compressed,
213            saved_tokens: saved,
214            saved_pct,
215            tokenizer: family.to_string(),
216            model: req.model,
217        },
218        tokens_before: totals.original,
219        tokens_after: totals.compressed,
220        compression_ratio,
221    }
222}
223
224fn compress_message(
225    msg: &mut Value,
226    totals: &mut Totals,
227    family: TokenizerFamily,
228    policy: lean_ctx::core::adaptive_policy::CompressionPolicy,
229) {
230    // OpenAI `tool`/`function` messages carry the tool name; pass it to the funnel
231    // so it can honour the #479 pass-through for lean-ctx's own `ctx_*` results.
232    let name = msg.get("name").and_then(Value::as_str).map(str::to_string);
233    if let Some(content) = msg.get_mut("content") {
234        compress_content(content, name.as_deref(), totals, family, policy);
235    }
236}
237
238fn compress_content(
239    content: &mut Value,
240    name: Option<&str>,
241    totals: &mut Totals,
242    family: TokenizerFamily,
243    policy: lean_ctx::core::adaptive_policy::CompressionPolicy,
244) {
245    match content {
246        Value::String(s) => squeeze_in_place(s, name, totals, family, policy),
247        Value::Array(blocks) => {
248            for block in blocks.iter_mut() {
249                compress_block(block, name, totals, family, policy);
250            }
251        }
252        _ => {}
253    }
254}
255
256fn compress_block(
257    block: &mut Value,
258    name: Option<&str>,
259    totals: &mut Totals,
260    family: TokenizerFamily,
261    policy: lean_ctx::core::adaptive_policy::CompressionPolicy,
262) {
263    let Some(obj) = block.as_object_mut() else {
264        return;
265    };
266    match obj.get("type").and_then(Value::as_str) {
267        // OpenAI + Anthropic text parts.
268        Some("text") => {
269            if let Some(Value::String(s)) = obj.get_mut("text") {
270                squeeze_in_place(s, name, totals, family, policy);
271            }
272        }
273        // Anthropic tool_result: nested string or array of content blocks — the
274        // single biggest compressible payload in an agent transcript.
275        Some("tool_result") => {
276            if let Some(inner) = obj.get_mut("content") {
277                compress_content(inner, name, totals, family, policy);
278            }
279        }
280        // image, tool_use, input_audio, document, … pass through untouched.
281        _ => {}
282    }
283}
284
285fn squeeze_in_place(
286    s: &mut String,
287    name: Option<&str>,
288    totals: &mut Totals,
289    family: TokenizerFamily,
290    policy: lean_ctx::core::adaptive_policy::CompressionPolicy,
291) {
292    let before = count_tokens_for(s, family);
293    // Gateway audience (#702): a lossy rewrite carries the `hash=<24hex>`
294    // retrieval marker LiteLLM's CCR loop scans for; the savings footer is
295    // stripped inside the gateway funnel (stats carry the numbers instead).
296    let compressed = compress_tool_result_gateway_with_policy(s, name, family, policy);
297    let clean = strip_trailing_savings_footer(&compressed);
298    let after = count_tokens_for(clean, family);
299    totals.original += before;
300    totals.compressed += after;
301    if clean != s {
302        *s = clean.to_string();
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use serde_json::json;
310
311    /// A prose blob well over the funnel's 600-char floor: eight identical
312    /// paragraphs the prose squeeze deduplicates down to one.
313    fn dedupable_prose() -> String {
314        let para = "Rust is a multi-paradigm systems programming language that \
315                    emphasizes performance, type safety, and fearless concurrency, \
316                    achieving memory safety without a garbage collector at runtime.";
317        format!("{}\n", [para; 8].join("\n\n"))
318    }
319
320    fn run(messages: Vec<Value>, model: Option<&str>) -> CompressResponse {
321        compress_messages(CompressRequest {
322            messages,
323            model: model.map(str::to_string),
324        })
325    }
326
327    #[test]
328    fn string_content_is_compressed_and_stats_reported() {
329        let _lock = lean_ctx::core::data_dir::test_env_lock();
330        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
331        let resp = run(
332            vec![json!({"role": "user", "content": dedupable_prose()})],
333            Some("claude-sonnet-4"),
334        );
335        let out = resp.messages[0]["content"].as_str().unwrap();
336        assert_eq!(
337            out.matches("fearless concurrency").count(),
338            1,
339            "duplicate paragraphs must be deduped"
340        );
341        assert!(resp.stats.saved_tokens > 0, "stats must reflect savings");
342        assert!(resp.stats.compressed_tokens < resp.stats.original_tokens);
343        assert_eq!(resp.stats.tokenizer, "cl100k_base");
344        assert_eq!(resp.stats.model.as_deref(), Some("claude-sonnet-4"));
345    }
346
347    #[cfg(feature = "http-server")]
348    /// The reply of `/v1/compress` is a delivery to a destination this proxy
349    /// cannot see: a protected value is masked before compression or the
350    /// reply sees it, and restricted content is withheld in place (a notice
351    /// with its block id) instead of being handed back.
352    #[tokio::test]
353    async fn handler_admits_messages_before_compressing_or_replying() {
354        let _data = lean_ctx::core::data_dir::isolated_data_dir();
355        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
356        let key = concat!("AK", "IAIOSFODNN7EXAMPLE");
357        let request = |text: String| {
358            Json(CompressRequest {
359                messages: vec![json!({"role": "user", "content": text})],
360                model: None,
361            })
362        };
363
364        let response = handler(request(format!("deploy with {key} today"))).await;
365        assert_eq!(response.status(), StatusCode::OK);
366        let reply = axum::body::to_bytes(response.into_body(), usize::MAX)
367            .await
368            .expect("reply body");
369        let reply = String::from_utf8(reply.to_vec()).expect("JSON reply");
370        assert!(!reply.contains(key), "the reply carries the value: {reply}");
371        assert!(reply.contains("deploy with"), "{reply}");
372
373        // Restricted content is withheld in place: the reply carries the
374        // notice, never the content, and the rest of the messages.
375        let withheld = handler(request(
376            "Classification: RESTRICTED\nmerger terms for the board".to_owned(),
377        ))
378        .await;
379        assert_eq!(withheld.status(), StatusCode::OK);
380        let body = axum::body::to_bytes(withheld.into_body(), usize::MAX)
381            .await
382            .expect("reply body");
383        let body = String::from_utf8_lossy(&body);
384        assert!(!body.contains("merger terms"), "{body}");
385        assert!(body.contains("destination.remote_restricted"), "{body}");
386        assert!(body.contains("lean-ctx gateway why w-"), "{body}");
387    }
388
389    #[test]
390    fn model_selects_tokenizer_family_for_gateway_compression() {
391        let resp = run(
392            vec![json!({"role": "user", "content": dedupable_prose()})],
393            Some("claude-sonnet-4"),
394        );
395
396        assert_eq!(resp.stats.model.as_deref(), Some("claude-sonnet-4"));
397        assert_eq!(resp.stats.tokenizer, "cl100k_base");
398    }
399
400    #[test]
401    fn litellm_guardrail_fields_present_and_consistent() {
402        let _lock = lean_ctx::core::data_dir::test_env_lock();
403        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
404        // LiteLLM's headroom guardrail logs `tokens_before`/`tokens_after`/
405        // `compression_ratio` from the /v1/compress reply (#700). They must
406        // exist at the top level and agree with `stats`.
407        let resp = run(
408            vec![json!({"role": "user", "content": dedupable_prose()})],
409            None,
410        );
411        assert_eq!(resp.tokens_before, resp.stats.original_tokens);
412        assert_eq!(resp.tokens_after, resp.stats.compressed_tokens);
413        assert!(resp.compression_ratio > 0.0 && resp.compression_ratio < 1.0);
414
415        let wire = serde_json::to_value(&resp).unwrap();
416        assert!(wire["tokens_before"].is_u64());
417        assert!(wire["tokens_after"].is_u64());
418        assert!(wire["compression_ratio"].is_f64());
419
420        // No compressible text → ratio pins to 1.0, not 0/0.
421        let empty = run(vec![json!({"role": "user", "content": "hi"})], None);
422        assert_eq!(empty.compression_ratio, 1.0);
423    }
424
425    #[test]
426    fn message_bodies_stay_footer_free() {
427        let _lock = lean_ctx::core::data_dir::test_env_lock();
428        let resp = run(
429            vec![json!({"role": "user", "content": dedupable_prose()})],
430            None,
431        );
432        let out = resp.messages[0]["content"].as_str().unwrap();
433        assert!(!out.contains('\u{2500}'), "no box-drawing footer in body");
434        assert!(!out.contains("[lean-ctx:"), "no savings footer in body");
435        assert!(resp.stats.model.is_none());
436    }
437
438    #[cfg(feature = "http-server")]
439    /// #702: a lossy rewrite through the gateway contract must advertise its
440    /// retrieval hash in LiteLLM's regex-locked `hash=<24hex>` form, and the
441    /// hash must resolve back to the verbatim original — the wire half of the
442    /// guardrail's CCR agentic loop.
443    #[test]
444    fn lossy_rewrite_carries_litellm_retrieval_marker() {
445        let _lock = lean_ctx::core::data_dir::test_env_lock();
446        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
447        let original = dedupable_prose();
448        let resp = run(
449            vec![json!({"role": "user", "content": original.clone()})],
450            None,
451        );
452        let out = resp.messages[0]["content"].as_str().unwrap();
453
454        let litellm_regex = regex::Regex::new(r"hash=([a-f0-9]{24})").unwrap();
455        let hash = litellm_regex
456            .captures(out)
457            .unwrap_or_else(|| panic!("lossy body must carry the hash= marker: {out}"))
458            .get(1)
459            .unwrap()
460            .as_str();
461        let recovered = super::super::ccr::retrieve_litellm(hash)
462            .expect("marker hash must resolve via /v1/retrieve");
463        assert!(
464            recovered.contains("fearless concurrency"),
465            "retrieve returns the verbatim pre-compression original"
466        );
467        assert_eq!(
468            recovered.matches("fearless concurrency").count(),
469            8,
470            "all deduped paragraphs are recoverable"
471        );
472    }
473
474    #[test]
475    fn output_is_deterministic() {
476        let _lock = lean_ctx::core::data_dir::test_env_lock();
477        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
478        let msgs = vec![
479            json!({"role": "system", "content": "You are a helpful assistant."}),
480            json!({"role": "user", "content": dedupable_prose()}),
481        ];
482        let a = serde_json::to_string(&run(msgs.clone(), Some("gpt-4o"))).unwrap();
483        let b = serde_json::to_string(&run(msgs, Some("gpt-4o"))).unwrap();
484        assert_eq!(a, b, "same input must yield byte-identical output");
485    }
486
487    #[test]
488    fn short_content_is_untouched() {
489        let resp = run(vec![json!({"role": "user", "content": "hi there"})], None);
490        assert_eq!(resp.messages[0]["content"], "hi there");
491        assert_eq!(resp.stats.saved_tokens, 0);
492        assert_eq!(resp.stats.saved_pct, 0.0);
493    }
494
495    #[test]
496    fn anthropic_blocks_text_compressed_image_passthrough() {
497        let _lock = lean_ctx::core::data_dir::test_env_lock();
498        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
499        let resp = run(
500            vec![json!({
501                "role": "user",
502                "content": [
503                    {"type": "text", "text": dedupable_prose()},
504                    {"type": "image", "source": {"type": "base64", "data": "AAAA"}},
505                ],
506            })],
507            None,
508        );
509        let blocks = resp.messages[0]["content"].as_array().unwrap();
510        assert_eq!(
511            blocks[0]["text"]
512                .as_str()
513                .unwrap()
514                .matches("fearless concurrency")
515                .count(),
516            1
517        );
518        // Image block is preserved verbatim.
519        assert_eq!(blocks[1]["source"]["data"], "AAAA");
520    }
521
522    #[test]
523    fn anthropic_tool_result_block_is_compressed() {
524        let _lock = lean_ctx::core::data_dir::test_env_lock();
525        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
526        let resp = run(
527            vec![json!({
528                "role": "user",
529                "content": [{
530                    "type": "tool_result",
531                    "tool_use_id": "toolu_123",
532                    "content": dedupable_prose(),
533                }],
534            })],
535            None,
536        );
537        let block = &resp.messages[0]["content"][0];
538        assert_eq!(block["tool_use_id"], "toolu_123", "ids preserved");
539        assert_eq!(
540            block["content"]
541                .as_str()
542                .unwrap()
543                .matches("fearless concurrency")
544                .count(),
545            1
546        );
547        assert!(resp.stats.saved_tokens > 0);
548    }
549
550    #[test]
551    fn lean_ctx_tool_output_passes_through_verbatim() {
552        // A ctx_* result is already compressed at the tool boundary (#479).
553        let prose = dedupable_prose();
554        let resp = run(
555            vec![json!({"role": "tool", "name": "ctx_read", "content": prose.clone()})],
556            None,
557        );
558        assert_eq!(resp.messages[0]["content"].as_str().unwrap(), prose);
559        assert_eq!(
560            resp.stats.saved_tokens, 0,
561            "ctx_* output is not re-compressed"
562        );
563    }
564
565    #[test]
566    fn non_string_content_is_ignored() {
567        // A malformed/absent content field must not panic.
568        let resp = run(vec![json!({"role": "assistant", "tool_calls": []})], None);
569        assert_eq!(resp.stats.original_tokens, 0);
570        assert_eq!(resp.messages.len(), 1);
571    }
572
573    /// #498 regression: a full, mixed-shape conversation must serialise to
574    /// byte-identical output across repeated calls. Provider prompt caching keys
575    /// on the exact bytes, so any non-determinism (ordering, footer leakage,
576    /// counter/timestamp) would silently destroy the cache discount.
577    #[test]
578    fn determinism_regression_full_conversation_498() {
579        let _lock = lean_ctx::core::data_dir::test_env_lock();
580        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
581        let conversation = || {
582            vec![
583                json!({"role": "system", "content": "You are a helpful assistant."}),
584                json!({"role": "user", "content": dedupable_prose()}),
585                json!({
586                    "role": "user",
587                    "content": [
588                        {"type": "text", "text": dedupable_prose()},
589                        {"type": "image", "source": {"type": "base64", "data": "AAAA"}},
590                        {"type": "tool_result", "tool_use_id": "toolu_1", "content": dedupable_prose()},
591                    ],
592                }),
593                json!({"role": "tool", "name": "ctx_read", "content": dedupable_prose()}),
594            ]
595        };
596
597        let baseline =
598            serde_json::to_string(&run(conversation(), Some("claude-sonnet-4"))).unwrap();
599        for _ in 0..4 {
600            let again =
601                serde_json::to_string(&run(conversation(), Some("claude-sonnet-4"))).unwrap();
602            assert_eq!(again, baseline, "/v1/compress output must be byte-stable");
603        }
604
605        // The byte-stable bodies must also be footer-free (savings live in stats).
606        assert!(!baseline.contains("[lean-ctx:"));
607        assert!(!baseline.contains('\u{2500}'));
608    }
609
610    #[cfg(feature = "http-server")]
611    /// One tool result through the handler: the reply's reason codes (if
612    /// any) and the content that came back. The reply is never a refusal.
613    async fn reply_for(text: &str) -> (Option<String>, String) {
614        let response = handler(Json(CompressRequest {
615            messages: vec![json!({"role": "tool", "name": "bash", "content": text})],
616            model: None,
617        }))
618        .await;
619        assert_eq!(response.status(), StatusCode::OK);
620        assert!(
621            !response.headers().contains_key(admitted::REFUSAL_HEADER),
622            "a delivered reply is not a refusal"
623        );
624        let codes = response
625            .headers()
626            .get(admitted::REASON_CODES_HEADER)
627            .and_then(|value| value.to_str().ok())
628            .map(str::to_owned);
629        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
630            .await
631            .expect("reply body");
632        let body: Value = serde_json::from_slice(&body).expect("JSON reply");
633        let content = body["messages"][0]["content"]
634            .as_str()
635            .expect("string content")
636            .to_owned();
637        (codes, content)
638    }
639
640    #[cfg(feature = "http-server")]
641    /// [real register and store] Under protection `/v1/compress` follows the
642    /// rule of the forward path: a block the Engine delivered with a lineage
643    /// is compressed, with a recovery copy stored for the proxy's project and
644    /// a stub naming the route that resolves it to the exact original; a
645    /// block without a lineage comes back whole and the reply's reason-code
646    /// header says why, by code only. Before, every block came back whole
647    /// under protection and nothing said why.
648    #[tokio::test]
649    async fn protected_reply_compresses_only_with_a_bound_copy_and_names_why_not() {
650        let _data = lean_ctx::core::data_dir::isolated_data_dir();
651        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
652        let directory = tempfile::tempdir().expect("project");
653        let root = directory.path().canonicalize().expect("canonical root");
654        let config = lean_ctx::core::paths::config_dir_read_only().expect("config directory");
655        std::fs::create_dir_all(&config).expect("config directory");
656        std::fs::write(
657            config.join("config.toml"),
658            format!(
659                "[context_gateway]\nmode = \"governed\"\n\n[proxy]\nproject_root = '{}'\n",
660                root.display()
661            ),
662        )
663        .expect("gateway config");
664        let log = (1..=400)
665            .map(|i| format!("[info] processed item {i:04} ok"))
666            .collect::<Vec<_>>()
667            .join("\n");
668        let source = root.join("build.log");
669        std::fs::write(&source, &log).expect("source");
670        // The test plays the Engine: an admitted read, recorded as delivered.
671        let delivered_text = super::super::request_project::in_project(&root, || {
672            let path = source.to_str().expect("utf-8 path");
673            let admitted_text = lean_ctx::core::rooted_read::read_file_for_tool_rooted(
674                path,
675                root.to_str().expect("utf-8 root"),
676                "ctx_read",
677            )
678            .expect("admitted read");
679            let authority = lean_ctx::core::archive::authority::ArchiveAuthority::file(
680                &root,
681                path,
682                "ctx_read",
683                &admitted_text,
684            )
685            .expect("protection is active: the read is bound");
686            assert!(lean_ctx::core::archive::delivered::record_lineage(
687                &lean_ctx::core::archive::delivered::digest(&admitted_text),
688                &lean_ctx::core::archive::delivered::Lineage::of_files(authority)
689            ));
690            admitted_text
691        });
692
693        let (codes, sent) = reply_for(&delivered_text).await;
694        assert_eq!(codes, None, "nothing stayed whole");
695        assert!(sent.len() < delivered_text.len(), "compressed: {sent}");
696        let (_, rest) = sent
697            .rsplit_once("GET /v1/references/")
698            .expect("a stub naming the route");
699        let id: String = rest.chars().take(64).collect();
700        assert!(
701            sent.ends_with(&format!(
702                "[lean-ctx CCR: full original archived — GET /v1/references/{id} on this \
703                 gateway returns it while its sources are admitted]"
704            )),
705            "{sent}"
706        );
707        assert!(!sent.contains("hash="), "not a tee marker: {sent}");
708        assert_eq!(
709            super::super::compress::resolve_recovery(&id),
710            Some(Ok(delivered_text.clone())),
711            "the id resolves to the exact original"
712        );
713        let (_, again) = reply_for(&delivered_text).await;
714        assert_eq!(again, sent, "byte-stable");
715
716        let unknown = format!("{delivered_text}\n[info] a line the Engine never delivered");
717        let (codes, whole) = reply_for(&unknown).await;
718        assert_eq!(whole, unknown, "no lineage, no copy: the original");
719        assert_eq!(codes.as_deref(), Some("recovery.unknown_lineage"));
720
721        std::fs::remove_file(&source).expect("delete the source");
722        let (codes, whole) = reply_for(&delivered_text).await;
723        assert_eq!(whole, delivered_text);
724        assert_eq!(codes.as_deref(), Some("recovery.source_no_longer_admitted"));
725    }
726
727    /// Daemon-free, o200k_base benchmark over a real on-disk corpus. Prints a
728    /// JSON report (ratio + latency) and is `#[ignore]`d so it stays out of CI.
729    /// Reproduce: `cargo test -p lean-ctx-engine --lib \
730    /// proxy::compress_api::tests::bench_real_corpus_o200k -- --ignored --nocapture`.
731    #[test]
732    #[ignore = "benchmark; run explicitly with --ignored --nocapture"]
733    fn bench_real_corpus_o200k() {
734        use std::path::Path;
735        use std::time::Instant;
736
737        let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../docs/reference");
738        let mut messages = Vec::new();
739        if let Ok(entries) = std::fs::read_dir(&corpus) {
740            let mut paths: Vec<_> = entries
741                .flatten()
742                .map(|e| e.path())
743                .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("md"))
744                .collect();
745            paths.sort();
746            for path in paths {
747                if let Ok(text) = std::fs::read_to_string(&path) {
748                    messages.push(json!({"role": "user", "content": text}));
749                }
750            }
751        }
752        assert!(!messages.is_empty(), "no corpus files found at {corpus:?}");
753
754        let files = messages.len();
755        let started = Instant::now();
756        let resp = run(messages, Some("gpt-4o"));
757        let latency_ms = started.elapsed().as_secs_f64() * 1000.0;
758
759        let report = json!({
760            "corpus": corpus.to_string_lossy(),
761            "files": files,
762            "tokenizer": resp.stats.tokenizer,
763            "original_tokens": resp.stats.original_tokens,
764            "compressed_tokens": resp.stats.compressed_tokens,
765            "tokens_saved": resp.stats.saved_tokens,
766            "saved_pct": resp.stats.saved_pct,
767            "latency_ms": (latency_ms * 100.0).round() / 100.0,
768        });
769        println!("{}", serde_json::to_string_pretty(&report).unwrap());
770    }
771}