Skip to main content

lean_ctx_proxy/
live_zone.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Live-zone-aware request compression preserves provider-cached prefixes.
3//!
4//! The detector deliberately works on message values rather than serialized request bytes:
5//! frozen messages are moved through the pipeline unchanged, while only the live suffix may
6//! be rewritten.
7
8use serde_json::{Value, json};
9
10use lean_ctx::core::tokens::{TokenizerFamily, count_tokens_for};
11
12use super::compress::compress_tool_result_gateway_with_policy;
13use lean_ctx::core::adaptive_policy::CompressionPolicy;
14
15/// A conversation split at the first message safe to rewrite.
16#[derive(Debug, Clone, PartialEq)]
17pub struct LiveZoneSplit {
18    pub frozen_messages: Vec<Value>,
19    pub live_messages: Vec<Value>,
20    pub boundary_turn: usize,
21    pub frozen_tokens_estimate: u64,
22}
23
24/// Compression accounting for a single live-zone pass.
25#[derive(Debug, Clone, Copy, Default, PartialEq)]
26pub struct LiveZoneStats {
27    pub frozen_tokens: u64,
28    pub live_tokens_before: u64,
29    pub live_tokens_after: u64,
30    pub savings_pct: f64,
31}
32
33/// Locate the prefix already eligible for provider cache reuse.
34///
35/// An Anthropic cache marker freezes every preceding turn.  Absent a marker, retain the
36/// preceding conversation and expose only the final three messages for mutation. System
37/// messages are always frozen, so a system message that appears later advances the boundary.
38pub fn detect_live_zone(messages: &[Value]) -> LiveZoneSplit {
39    let marked_boundary = messages
40        .iter()
41        .enumerate()
42        .filter(|(_, message)| has_cache_control(message))
43        .map(|(index, _)| index + 1)
44        .max();
45    let fallback_boundary = messages.len().saturating_sub(3);
46    let mut boundary_turn = marked_boundary.unwrap_or(fallback_boundary);
47
48    // System prompts must never become a live rewrite target.
49    if let Some(last_system) = messages
50        .iter()
51        .enumerate()
52        .filter(|(_, message)| role(message) == Some("system"))
53        .map(|(index, _)| index)
54        .max()
55    {
56        boundary_turn = boundary_turn.max(last_system + 1);
57    }
58    boundary_turn = boundary_turn.min(messages.len());
59
60    let frozen_messages = messages[..boundary_turn].to_vec();
61    let live_messages = messages[boundary_turn..].to_vec();
62    let frozen_tokens_estimate = messages_tokens(&frozen_messages, TokenizerFamily::default());
63    LiveZoneSplit {
64        frozen_messages,
65        live_messages,
66        boundary_turn,
67        frozen_tokens_estimate,
68    }
69}
70
71/// Compress only the non-cached suffix in place.
72///
73/// This function reconstructs the original slice order without assigning to the frozen prefix.
74pub fn compress_live_only(
75    messages: &mut [Value],
76    family: TokenizerFamily,
77    policy: CompressionPolicy,
78) -> LiveZoneStats {
79    let split = detect_live_zone(messages);
80    let mut stats = LiveZoneStats {
81        frozen_tokens: messages_tokens(&split.frozen_messages, family),
82        live_tokens_before: messages_tokens(&split.live_messages, family),
83        ..LiveZoneStats::default()
84    };
85
86    for message in &mut messages[split.boundary_turn..] {
87        compress_message(message, family, policy);
88    }
89
90    stats.live_tokens_after = messages_tokens(&messages[split.boundary_turn..], family);
91    stats.savings_pct = percentage_saved(stats.live_tokens_before, stats.live_tokens_after);
92    stats
93}
94
95/// Add one Anthropic cache breakpoint at the frozen/live boundary.
96///
97/// Existing client cache markers are retained. The marker is attached to an existing text
98/// block so it does not alter provider message ordering.
99pub fn add_cache_markers(messages: &mut [Value]) {
100    let boundary = detect_live_zone(messages).boundary_turn;
101    let idx = if boundary > 0 && boundary <= messages.len() {
102        boundary - 1
103    } else {
104        messages.len().saturating_sub(1)
105    };
106    let Some(message) = messages.get_mut(idx) else {
107        return;
108    };
109
110    if has_cache_control(message) {
111        return;
112    }
113
114    let Some(object) = message.as_object_mut() else {
115        return;
116    };
117    match object.get_mut("content") {
118        Some(Value::Array(blocks)) => {
119            if let Some(block) = blocks.last_mut().and_then(Value::as_object_mut) {
120                block.insert("cache_control".into(), ephemeral());
121            }
122        }
123        Some(Value::String(text)) => {
124            let text = std::mem::take(text);
125            object.insert(
126                "content".into(),
127                Value::Array(vec![json!({
128                    "type": "text",
129                    "text": text,
130                    "cache_control": ephemeral(),
131                })]),
132            );
133        }
134        _ => {}
135    }
136}
137
138fn compress_message(message: &mut Value, family: TokenizerFamily, policy: CompressionPolicy) {
139    let tool_name = message
140        .get("name")
141        .and_then(Value::as_str)
142        .map(str::to_owned);
143    if let Some(content) = message.get_mut("content") {
144        compress_content(content, tool_name.as_deref(), family, policy);
145    }
146}
147
148fn compress_content(
149    content: &mut Value,
150    tool_name: Option<&str>,
151    family: TokenizerFamily,
152    policy: CompressionPolicy,
153) {
154    match content {
155        Value::String(text) => compress_text(text, tool_name, family, policy),
156        Value::Array(blocks) => {
157            for block in blocks {
158                let Some(object) = block.as_object_mut() else {
159                    continue;
160                };
161                let name = object
162                    .get("name")
163                    .and_then(Value::as_str)
164                    .map(str::to_owned);
165                let block_type = object
166                    .get("type")
167                    .and_then(Value::as_str)
168                    .map(str::to_owned);
169                match block_type.as_deref() {
170                    Some("text") => {
171                        if let Some(Value::String(text)) = object.get_mut("text") {
172                            compress_text(text, name.as_deref(), family, policy);
173                        }
174                    }
175                    Some("tool_result") => {
176                        if let Some(inner) = object.get_mut("content") {
177                            compress_content(inner, name.as_deref(), family, policy);
178                        }
179                    }
180                    _ => {}
181                }
182            }
183        }
184        _ => {}
185    }
186}
187
188fn compress_text(
189    text: &mut String,
190    tool_name: Option<&str>,
191    family: TokenizerFamily,
192    policy: CompressionPolicy,
193) {
194    let compressed = compress_tool_result_gateway_with_policy(text, tool_name, family, policy);
195    if compressed.len() < text.len() {
196        *text = compressed;
197    }
198}
199
200fn messages_tokens(messages: &[Value], family: TokenizerFamily) -> u64 {
201    messages
202        .iter()
203        .map(|message| count_tokens_for(&message.to_string(), family) as u64)
204        .sum()
205}
206
207fn percentage_saved(before: u64, after: u64) -> f64 {
208    if before == 0 {
209        0.0
210    } else {
211        ((before.saturating_sub(after)) as f64 / before as f64) * 100.0
212    }
213}
214
215fn role(message: &Value) -> Option<&str> {
216    message.get("role").and_then(Value::as_str)
217}
218
219fn has_cache_control(message: &Value) -> bool {
220    message.get("cache_control").is_some()
221        || message
222            .get("content")
223            .and_then(Value::as_array)
224            .is_some_and(|blocks| {
225                blocks
226                    .iter()
227                    .any(|block| block.get("cache_control").is_some())
228            })
229}
230
231fn ephemeral() -> Value {
232    json!({"type": "ephemeral"})
233}
234
235#[cfg(test)]
236mod tests {
237    use serde_json::json;
238
239    use super::*;
240
241    fn policy() -> CompressionPolicy {
242        lean_ctx::core::adaptive_policy::select_policy("chat")
243    }
244
245    fn verbose_text() -> String {
246        (0..80)
247            .map(|index| format!("log line {index}: repeated operational detail for compression"))
248            .collect::<Vec<_>>()
249            .join("\n")
250    }
251
252    #[test]
253    fn system_messages_always_frozen() {
254        let messages = vec![
255            json!({"role": "user", "content": "old"}),
256            json!({"role": "system", "content": "do not alter"}),
257            json!({"role": "user", "content": "new"}),
258        ];
259        let split = detect_live_zone(&messages);
260        assert_eq!(split.boundary_turn, 2);
261        assert_eq!(split.frozen_messages[1], messages[1]);
262    }
263
264    #[test]
265    fn cache_control_freezes_preceding_messages() {
266        let messages = vec![
267            json!({"role": "user", "content": "old"}),
268            json!({"role": "assistant", "content": [{"type":"text", "text":"cached", "cache_control":{"type":"ephemeral"}}]}),
269            json!({"role": "user", "content": "new"}),
270        ];
271        let split = detect_live_zone(&messages);
272        assert_eq!(split.boundary_turn, 2);
273        assert_eq!(split.frozen_messages, messages[..2]);
274        assert_eq!(split.live_messages, messages[2..]);
275    }
276
277    #[test]
278    fn live_messages_are_compressed() {
279        let _data = lean_ctx::core::data_dir::isolated_data_dir();
280        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
281        let mut messages = vec![
282            json!({"role": "user", "content": "old"}),
283            json!({"role": "assistant", "content": "cached"}),
284            json!({"role": "user", "content": "old too"}),
285            json!({"role": "tool", "name": "bash", "content": verbose_text()}),
286        ];
287        let before = messages[3].clone();
288        let stats = compress_live_only(&mut messages, TokenizerFamily::default(), policy());
289        assert!(stats.live_tokens_after < stats.live_tokens_before);
290        assert_ne!(messages[3], before);
291    }
292
293    #[test]
294    fn frozen_messages_remain_byte_identical() {
295        let mut messages = vec![
296            json!({"role": "system", "content": "stable system"}),
297            json!({"role": "user", "content": [{"type":"text", "text":"cached", "cache_control":{"type":"ephemeral"}}]}),
298            json!({"role": "assistant", "content": "answer"}),
299            json!({"role": "tool", "name": "bash", "content": verbose_text()}),
300        ];
301        let frozen = messages[..2]
302            .iter()
303            .map(Value::to_string)
304            .collect::<Vec<_>>();
305        compress_live_only(&mut messages, TokenizerFamily::default(), policy());
306        assert_eq!(
307            frozen,
308            messages[..2]
309                .iter()
310                .map(Value::to_string)
311                .collect::<Vec<_>>(),
312        );
313    }
314
315    #[test]
316    fn stats_report_frozen_and_live_token_counts() {
317        let mut messages = vec![
318            json!({"role": "system", "content": "stable"}),
319            json!({"role": "user", "content": "old"}),
320            json!({"role": "assistant", "content": "answer"}),
321            json!({"role": "tool", "name": "bash", "content": verbose_text()}),
322        ];
323        let stats = compress_live_only(&mut messages, TokenizerFamily::default(), policy());
324        assert!(stats.frozen_tokens > 0);
325        assert!(stats.live_tokens_before >= stats.live_tokens_after);
326        assert!(stats.savings_pct >= 0.0);
327    }
328
329    #[test]
330    fn cache_marker_is_added_at_boundary() {
331        let mut messages = vec![
332            json!({"role": "user", "content": "old"}),
333            json!({"role": "assistant", "content": "answer"}),
334            json!({"role": "user", "content": "new"}),
335            json!({"role": "user", "content": "latest"}),
336        ];
337        add_cache_markers(&mut messages);
338        assert!(has_cache_control(&messages[0]));
339    }
340}