Skip to main content

lean_ctx_proxy/
prefix_replay.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Append-only delta detection and forwarded-byte replay for provider prefix
3//! cache stability.
4//!
5//! When the proxy compresses and forwards a request, the serialised prefix
6//! bytes become the provider's cache key. Re-serialising an identical `Value`
7//! can produce subtly different bytes (JSON key order, float precision, Unicode
8//! escaping), causing a cache miss even though the content has not changed.
9//!
10//! This module caches the **exact forwarded bytes** and replays them verbatim
11//! on subsequent turns when the new request is an append-only extension.
12//! Only the delta (new messages) is freshly serialised.
13//!
14//! Replay is active only in `ProxyMode::Cache`: only that mode stores the
15//! forwarded bytes. Every mode records what each forwarded request carried,
16//! message by message ([`SentPrefix`]), which the determinism guard checks a
17//! later turn against: the provider cached what the proxy sent, not what the
18//! client sent.
19
20use std::collections::HashMap;
21use std::sync::{Mutex, OnceLock};
22
23use serde_json::Value;
24
25const MAX_TRACKED: usize = 2048;
26
27#[derive(Clone)]
28struct ConversationPrefix {
29    /// Empty outside `ProxyMode::Cache`: nothing to replay.
30    forwarded_bytes: Vec<u8>,
31    original_hashes: Vec<u64>,
32    count: usize,
33    sent: SentPrefix,
34}
35
36/// What one forwarded request carried, per cache-relevant message (`system`
37/// first, then the messages), as content hashes without `cache_control`
38/// markers (`determinism_guard::message_hashes`).
39#[derive(Clone, Debug, Default, PartialEq, Eq)]
40pub struct SentPrefix {
41    /// The client's messages as they reached the proxy.
42    pub client: Vec<u64>,
43    /// The same messages as the proxy forwarded them.
44    pub forwarded: Vec<u64>,
45}
46
47pub struct AppendDelta {
48    pub prefix_bytes: Vec<u8>,
49    pub delta_start: usize,
50}
51
52fn store() -> &'static Mutex<HashMap<u64, ConversationPrefix>> {
53    static STORE: OnceLock<Mutex<HashMap<u64, ConversationPrefix>>> = OnceLock::new();
54    STORE.get_or_init(|| Mutex::new(HashMap::new()))
55}
56
57/// Clear process-local replay state between isolated proxy lifecycles/tests.
58pub fn clear() {
59    if let Ok(mut guard) = store().lock() {
60        guard.clear();
61    }
62}
63
64fn message_hash(msg: &Value) -> u64 {
65    use std::hash::{Hash, Hasher};
66    let canonical = serde_json::to_string(msg).unwrap_or_default();
67    let mut hasher = std::collections::hash_map::DefaultHasher::new();
68    canonical.hash(&mut hasher);
69    hasher.finish()
70}
71
72/// Conversation identity from system prompt + first user message.
73pub fn conversation_id(system: Option<&Value>, messages: &[Value]) -> u64 {
74    use std::hash::{Hash, Hasher};
75    let mut hasher = std::collections::hash_map::DefaultHasher::new();
76    if let Some(sys) = system {
77        serde_json::to_string(sys)
78            .unwrap_or_default()
79            .hash(&mut hasher);
80    }
81    if let Some(first) = messages.first() {
82        serde_json::to_string(first)
83            .unwrap_or_default()
84            .hash(&mut hasher);
85    }
86    hasher.finish()
87}
88
89/// [`conversation_id`] of a request body with a `messages` array.
90pub fn request_conversation_id(request: &Value) -> Option<u64> {
91    let messages = request.get("messages")?.as_array()?;
92    Some(conversation_id(request.get("system"), messages))
93}
94
95/// Detect whether current messages are an append-only extension of the previous
96/// turn. Returns cached prefix bytes and delta start index if so.
97pub fn detect_append_only(conv_id: u64, messages: &[Value]) -> Option<AppendDelta> {
98    let guard = store().lock().ok()?;
99    let prev = guard.get(&conv_id)?;
100
101    if prev.forwarded_bytes.is_empty() || messages.len() <= prev.count {
102        return None;
103    }
104
105    let current_hashes: Vec<u64> = messages.iter().map(message_hash).collect();
106    for (i, prev_hash) in prev.original_hashes.iter().enumerate() {
107        if current_hashes.get(i) != Some(prev_hash) {
108            return None;
109        }
110    }
111
112    Some(AppendDelta {
113        prefix_bytes: prev.forwarded_bytes.clone(),
114        delta_start: prev.count,
115    })
116}
117
118/// What the proxy last forwarded for the first of `conv_ids` it has seen.
119///
120/// The guard passes the id of the outgoing request first and the client's
121/// own id second: a rewrite of `system` or the first message changes the
122/// outgoing id, and the client's id still finds the earlier turn forwarded
123/// with those two untouched.
124pub fn sent_prefix(conv_ids: &[u64]) -> Option<SentPrefix> {
125    let guard = store().lock().ok()?;
126    conv_ids
127        .iter()
128        .find_map(|id| guard.get(id))
129        .map(|prev| prev.sent.clone())
130}
131
132/// Record the forwarded prefix after a successful upstream send. `forwarded`
133/// is empty when the request may not be replayed (outside `ProxyMode::Cache`).
134pub fn record_forwarded(
135    conv_id: u64,
136    forwarded: Vec<u8>,
137    originals: &[Value],
138    msg_count: usize,
139    sent: SentPrefix,
140) {
141    let hashes: Vec<u64> = originals.iter().take(msg_count).map(message_hash).collect();
142    let entry = ConversationPrefix {
143        forwarded_bytes: forwarded,
144        original_hashes: hashes,
145        count: msg_count,
146        sent,
147    };
148
149    if let Ok(mut guard) = store().lock() {
150        if guard.len() >= MAX_TRACKED
151            && !guard.contains_key(&conv_id)
152            && let Some(&oldest) = guard.keys().next()
153        {
154            guard.remove(&oldest);
155        }
156        guard.insert(conv_id, entry);
157    }
158}
159
160/// Whether the previously forwarded request and the current one are the same
161/// request around their messages: every top-level member other than
162/// `messages` (model, limits, tools, system, …) is present in both and equal.
163///
164/// The conversation id only hashes the system prompt and the first message,
165/// and the store is shared by every route. Replaying the forwarded bytes of a
166/// request with another frame would send that request's model and parameters
167/// — possibly another provider's — in place of the caller's.
168pub fn same_request_frame(prefix_bytes: &[u8], current: &Value) -> bool {
169    let Ok(Value::Object(previous)) = serde_json::from_slice::<Value>(prefix_bytes) else {
170        return false;
171    };
172    let Some(current) = current.as_object() else {
173        return false;
174    };
175    previous.len() == current.len()
176        && previous
177            .iter()
178            .all(|(key, value)| key == "messages" || current.get(key) == Some(value))
179}
180
181/// Overlay cached prefix bytes with fresh delta bytes, producing a valid JSON
182/// array where the prefix portion is byte-identical to the previous forward.
183pub fn overlay_prefix(prefix_bytes: &[u8], delta_messages: &[Value]) -> Option<Vec<u8>> {
184    if delta_messages.is_empty() {
185        return Some(prefix_bytes.to_vec());
186    }
187
188    // Forwarded bytes are a complete provider request object, not a bare
189    // messages array. Parse the object, append only the fresh delta, then
190    // serialize deterministically; never splice bytes at the wrong bracket.
191    let mut request: Value = serde_json::from_slice(prefix_bytes).ok()?;
192    let messages = match &mut request {
193        Value::Object(_) => request.get_mut("messages")?.as_array_mut()?,
194        Value::Array(messages) => messages,
195        _ => return None,
196    };
197    messages.extend(delta_messages.iter().cloned());
198    serde_json::to_vec(&request).ok()
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use serde_json::json;
205
206    fn unique_messages(tag: &str) -> Vec<Value> {
207        vec![
208            json!({"role": "user", "content": format!("hello-{tag}")}),
209            json!({"role": "assistant", "content": format!("hi-{tag}")}),
210        ]
211    }
212
213    /// A forwarded request may only be replayed into a turn of the same
214    /// request frame: another model, another route's parameters or a dropped
215    /// limit must never be carried over from the stored bytes.
216    #[test]
217    fn replay_is_limited_to_the_same_request_frame() {
218        let hello = json!({"role": "user", "content": "hello"});
219        let forwarded =
220            serde_json::to_vec(&json!({"model": "gpt-test", "messages": [hello]})).unwrap();
221        let next = json!({"role": "user", "content": "continue"});
222        assert!(same_request_frame(
223            &forwarded,
224            &json!({"model": "gpt-test", "messages": [hello, next]})
225        ));
226        for other_frame in [
227            json!({"model": "claude-test", "max_tokens": 16, "messages": [hello, next]}),
228            json!({"model": "gpt-test", "max_tokens": 16, "messages": [hello, next]}),
229            json!({"model": "claude-test", "messages": [hello, next]}),
230        ] {
231            assert!(
232                !same_request_frame(&forwarded, &other_frame),
233                "{other_frame}"
234            );
235        }
236    }
237
238    #[test]
239    fn append_only_detection_works() {
240        let msgs = unique_messages("append");
241        let conv = conversation_id(None, &msgs);
242        let forwarded = serde_json::to_vec(&msgs).unwrap();
243
244        record_forwarded(
245            conv,
246            forwarded.clone(),
247            &msgs,
248            msgs.len(),
249            SentPrefix::default(),
250        );
251
252        let mut extended = msgs.clone();
253        extended.push(json!({"role": "user", "content": "what's up?"}));
254
255        let delta = detect_append_only(conv, &extended).expect("should detect append-only");
256        assert_eq!(delta.delta_start, 2);
257        assert_eq!(delta.prefix_bytes, forwarded);
258    }
259
260    #[test]
261    fn detection_fails_on_modified_prefix() {
262        let msgs = unique_messages("modified");
263        let conv = conversation_id(None, &msgs);
264        let forwarded = serde_json::to_vec(&msgs).unwrap();
265        record_forwarded(conv, forwarded, &msgs, msgs.len(), SentPrefix::default());
266
267        let mut modified = msgs;
268        modified[0] = json!({"role": "user", "content": "different"});
269        modified.push(json!({"role": "user", "content": "extra"}));
270        assert!(detect_append_only(conv, &modified).is_none());
271    }
272
273    #[test]
274    fn prefix_replay_is_byte_identical_across_turns() {
275        let msgs = unique_messages("replay");
276        let conv = conversation_id(None, &msgs);
277        let forwarded = serde_json::to_vec(&msgs).unwrap();
278        record_forwarded(
279            conv,
280            forwarded.clone(),
281            &msgs,
282            msgs.len(),
283            SentPrefix::default(),
284        );
285
286        let mut turn2 = msgs.clone();
287        turn2.push(json!({"role": "user", "content": "next"}));
288
289        let delta = detect_append_only(conv, &turn2).unwrap();
290        let result = overlay_prefix(&delta.prefix_bytes, &turn2[delta.delta_start..]).unwrap();
291        let result_str = String::from_utf8(result).unwrap();
292        let parsed: Vec<Value> = serde_json::from_str(&result_str).unwrap();
293        assert_eq!(parsed.len(), 3);
294
295        let prefix_portion = &result_str[..forwarded.len() - 1];
296        let original_prefix = std::str::from_utf8(&forwarded[..forwarded.len() - 1]).unwrap();
297        assert_eq!(
298            prefix_portion, original_prefix,
299            "prefix bytes must be identical"
300        );
301    }
302
303    #[test]
304    fn overlay_with_empty_delta_returns_prefix() {
305        let msgs = unique_messages("overlay");
306        let bytes = serde_json::to_vec(&msgs).unwrap();
307        let result = overlay_prefix(&bytes, &[]).unwrap();
308        assert_eq!(result, bytes);
309    }
310
311    #[test]
312    fn overlay_preserves_complete_request_object() {
313        let request = json!({"model":"m","messages":unique_messages("object")});
314        let prefix = serde_json::to_vec(&request).unwrap();
315        let out = overlay_prefix(&prefix, &[json!({"role":"user","content":"new"})]).unwrap();
316        let decoded: Value = serde_json::from_slice(&out).unwrap();
317        assert_eq!(decoded["model"], "m");
318        assert_eq!(decoded["messages"].as_array().unwrap().len(), 3);
319    }
320
321    #[test]
322    fn conversation_id_is_deterministic() {
323        let sys = json!("You are helpful");
324        let msgs = unique_messages("deterministic");
325        assert_eq!(
326            conversation_id(Some(&sys), &msgs),
327            conversation_id(Some(&sys), &msgs)
328        );
329    }
330
331    #[test]
332    fn max_tracked_evicts_oldest() {
333        for i in 0..MAX_TRACKED + 10 {
334            let msgs = vec![json!({"role": "user", "content": format!("msg {i}")})];
335            let conv = conversation_id(None, &msgs);
336            record_forwarded(
337                conv,
338                serde_json::to_vec(&msgs).unwrap(),
339                &msgs,
340                1,
341                SentPrefix::default(),
342            );
343        }
344        assert!(store().lock().unwrap().len() <= MAX_TRACKED);
345    }
346}