Skip to main content

theway_core/agent/
context_cache.rs

1//! Client-side prefix cache hit estimation.
2//!
3//! The provider reports `cache_read_tokens` for real prompt-cache reads, but
4//! there is no provider-agnostic signal for how much of the *final* context
5//! prefix actually overlapped with the previous request. This module implements
6//! a lightweight, tokenizer-free estimate:
7//!
8//! 1. Serialize the final provider `Context` into a canonical byte sequence.
9//! 2. Split the bytes into fixed-size chunks and hash each chunk.
10//! 3. Compare the current chunk list with the previous request's list from
11//!    index 0 (longest common prefix).
12//! 4. Convert overlapping bytes to tokens using the provider-reported total
13//!    input token count as the byte-to-token calibration.
14//!
15//! The estimate is intentionally approximate and only intended to explain cache
16//! trends, not to replace provider-reported cache accounting.
17
18use std::collections::HashMap;
19
20use serde_json::{Value, json};
21use theway_llm_provider::{
22    ContentBlock, Context as PiContext, Message, UserContent, UserContentBlock,
23};
24
25/// Chunk size for the prefix-overlap comparison.
26pub const CONTEXT_CHUNK_SIZE: usize = 256;
27
28/// Result of comparing the current context against the previous baseline.
29#[derive(Clone, Debug, Default, PartialEq, Eq)]
30pub struct PrefixHitEstimate {
31    /// Number of bytes in the longest common prefix between the current and
32    /// previous canonical context.
33    pub overlap_bytes: usize,
34    /// Total canonical bytes of the current context.
35    pub total_bytes: usize,
36}
37
38/// Final prefix-hit metrics after provider usage is available.
39#[derive(Clone, Debug, Default, PartialEq)]
40pub struct PrefixHitResult {
41    /// Estimated number of input tokens served from the context prefix.
42    pub prefix_hit_tokens: u64,
43    /// `prefix_hit_tokens / total_input_tokens`; `None` when there is no
44    /// provider-reported total input to calibrate against.
45    pub prefix_cache_hit_rate: Option<f64>,
46}
47
48#[derive(Clone, Debug, Default)]
49struct ContextCacheEntry {
50    chunk_hashes: Vec<u64>,
51    bytes: Vec<u8>,
52}
53
54/// Per-session, per-model prefix overlap tracker.
55///
56/// The baseline is keyed by `(session_id, provider, model)`. Changing the
57/// provider or model clears that key's previous baseline so the first request
58/// after a switch reports a low (zero) prefix hit rate.
59#[derive(Clone, Debug, Default)]
60pub struct ContextCacheTracker {
61    entries: HashMap<String, ContextCacheEntry>,
62    /// Last key used per session, used to reset a key when the provider/model
63    /// changes away and back.
64    last_keys: HashMap<String, String>,
65}
66
67impl ContextCacheTracker {
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Compare `context` against the stored baseline for the active key, then
73    /// store this context as the new baseline.
74    ///
75    /// Call this immediately before sending the request, after all context
76    /// transforms have been applied.
77    pub fn estimate(
78        &mut self,
79        session_id: Option<&str>,
80        provider: &str,
81        model: &str,
82        context: &PiContext,
83    ) -> PrefixHitEstimate {
84        let session = session_id.unwrap_or("");
85        let key = format!("{session}\0{provider}\0{model}");
86
87        if let Some(previous_key) = self.last_keys.get(session) {
88            if previous_key != &key {
89                // Model/provider switch: start the new key's baseline from
90                // scratch even if it was seen earlier in the session.
91                self.entries.remove(&key);
92            }
93        }
94        self.last_keys.insert(session.to_string(), key.clone());
95
96        let bytes = canonical_context_bytes(context);
97        let total_bytes = bytes.len();
98        let chunk_hashes = chunk_hashes(&bytes);
99        let overlap_bytes = self
100            .entries
101            .get(&key)
102            .map(|entry| longest_common_prefix_bytes(entry, &bytes))
103            .unwrap_or(0);
104
105        self.entries.insert(
106            key,
107            ContextCacheEntry {
108                chunk_hashes,
109                bytes,
110            },
111        );
112
113        PrefixHitEstimate {
114            overlap_bytes,
115            total_bytes,
116        }
117    }
118
119    /// Compute the token-level prefix estimate once the provider reports total
120    /// input tokens for the request.
121    pub fn finalize(
122        &self,
123        estimate: &PrefixHitEstimate,
124        total_input_tokens: u64,
125    ) -> PrefixHitResult {
126        if estimate.total_bytes == 0 || total_input_tokens == 0 {
127            return PrefixHitResult {
128                prefix_hit_tokens: 0,
129                prefix_cache_hit_rate: None,
130            };
131        }
132
133        let prefix_hit_tokens = ((estimate.overlap_bytes as u128) * (total_input_tokens as u128)
134            / (estimate.total_bytes as u128)) as u64;
135        let rate = prefix_hit_tokens as f64 / total_input_tokens as f64;
136        PrefixHitResult {
137            prefix_hit_tokens,
138            prefix_cache_hit_rate: Some(rate),
139        }
140    }
141
142    /// Drop all baselines for a session (e.g. session reset/clear).
143    pub fn clear_session(&mut self, session_id: Option<&str>) {
144        let session = session_id.unwrap_or("");
145        self.last_keys.remove(session);
146        self.entries
147            .retain(|key, _| !key.starts_with(&format!("{session}\0")));
148    }
149}
150
151/// Canonical, deterministic byte representation of the final provider context.
152///
153/// Only fields that affect the provider request body are included. Transient
154/// bookkeeping such as usage counters, costs, response ids, diagnostics, and
155/// timestamps is excluded so unchanged conversation prefixes produce stable
156/// hashes across turns. `serde_json::Value` object keys are sorted recursively
157/// so the same logical context hashes identically regardless of map insertion
158/// order.
159pub fn canonical_context_bytes(context: &PiContext) -> Vec<u8> {
160    let messages = context
161        .messages
162        .iter()
163        .map(canonical_message)
164        .collect::<Vec<_>>();
165    let tools = context.tools.as_ref().map(|tools| {
166        tools
167            .iter()
168            .map(|tool| {
169                json!({
170                    "name": tool.name,
171                    "description": tool.description,
172                    "parameters": tool.parameters,
173                })
174            })
175            .collect::<Vec<_>>()
176    });
177    let mut value = json!({
178        "system_prompt": context.system_prompt,
179        "messages": messages,
180        "tools": tools,
181    });
182    canonicalize_structural(&mut value);
183    serde_json::to_vec(&value).unwrap_or_default()
184}
185
186fn canonical_message(message: &Message) -> Value {
187    match message {
188        Message::User(message) => json!({
189            "role": "user",
190            "content": canonical_user_content(&message.content),
191        }),
192        Message::Assistant(message) => json!({
193            "role": "assistant",
194            "content": message
195                .content
196                .iter()
197                .map(canonical_content_block)
198                .collect::<Vec<_>>(),
199        }),
200        Message::ToolResult(message) => json!({
201            "role": "tool_result",
202            "tool_call_id": message.tool_call_id,
203            "tool_name": message.tool_name,
204            "content": message
205                .content
206                .iter()
207                .map(canonical_user_content_block)
208                .collect::<Vec<_>>(),
209            "is_error": message.is_error,
210        }),
211    }
212}
213
214fn canonical_user_content(content: &UserContent) -> Value {
215    match content {
216        UserContent::Text(text) => Value::String(text.clone()),
217        UserContent::Blocks(blocks) => {
218            Value::Array(blocks.iter().map(canonical_user_content_block).collect())
219        }
220    }
221}
222
223fn canonical_content_block(block: &ContentBlock) -> Value {
224    match block {
225        ContentBlock::Text(text) => json!({
226            "type": "text",
227            "text": text.text,
228            "text_signature": text.text_signature,
229        }),
230        ContentBlock::Thinking(thinking) => json!({
231            "type": "thinking",
232            "thinking": thinking.thinking,
233            "thinking_signature": thinking.thinking_signature,
234            "redacted": thinking.redacted,
235        }),
236        ContentBlock::Image(image) => json!({
237            "type": "image",
238            "mime_type": image.mime_type,
239            "data": image.data,
240        }),
241        ContentBlock::ToolCall(call) => json!({
242            "type": "tool_call",
243            "id": call.id,
244            "name": call.name,
245            "arguments": call.arguments,
246            "thought_signature": call.thought_signature,
247        }),
248    }
249}
250
251fn canonical_user_content_block(block: &UserContentBlock) -> Value {
252    match block {
253        UserContentBlock::Text(text) => json!({
254            "type": "text",
255            "text": text.text,
256            "text_signature": text.text_signature,
257        }),
258        UserContentBlock::Image(image) => json!({
259            "type": "image",
260            "mime_type": image.mime_type,
261            "data": image.data,
262        }),
263    }
264}
265
266/// Canonicalize the structural wrapper without reordering top-level fields.
267/// The logical provider context order (system prompt, messages, tools) is
268/// significant for prefix matching, so only free-form JSON payloads (tool
269/// parameters, tool-call arguments) have their object keys sorted.
270fn canonicalize_structural(value: &mut Value) {
271    match value {
272        Value::Object(map) => {
273            for (key, child) in map.iter_mut() {
274                if key == "parameters" || key == "arguments" {
275                    canonicalize_freeform(child);
276                } else {
277                    canonicalize_structural(child);
278                }
279            }
280        }
281        Value::Array(items) => {
282            for item in items {
283                canonicalize_structural(item);
284            }
285        }
286        _ => {}
287    }
288}
289
290/// Recursively sort object keys in a free-form JSON value.
291fn canonicalize_freeform(value: &mut Value) {
292    match value {
293        Value::Object(map) => {
294            for child in map.values_mut() {
295                canonicalize_freeform(child);
296            }
297            map.sort_keys();
298        }
299        Value::Array(items) => {
300            for item in items {
301                canonicalize_freeform(item);
302            }
303        }
304        _ => {}
305    }
306}
307
308fn chunk_hashes(bytes: &[u8]) -> Vec<u64> {
309    bytes.chunks(CONTEXT_CHUNK_SIZE).map(fnv1a).collect()
310}
311
312fn fnv1a(bytes: &[u8]) -> u64 {
313    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
314    for &byte in bytes {
315        hash ^= u64::from(byte);
316        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
317    }
318    hash
319}
320
321fn longest_common_prefix_bytes(previous: &ContextCacheEntry, current: &[u8]) -> usize {
322    let current_hashes = chunk_hashes(current);
323    let mut matched_chunks = 0usize;
324    for (prev_hash, curr_hash) in previous.chunk_hashes.iter().zip(&current_hashes) {
325        if prev_hash == curr_hash {
326            matched_chunks += 1;
327        } else {
328            break;
329        }
330    }
331
332    let offset = matched_chunks
333        .saturating_mul(CONTEXT_CHUNK_SIZE)
334        .min(previous.bytes.len())
335        .min(current.len());
336
337    if matched_chunks < previous.chunk_hashes.len().min(current_hashes.len()) {
338        // The next chunk differs; count the common byte prefix inside it.
339        let start = offset;
340        let prev_slice =
341            &previous.bytes[start..previous.bytes.len().min(start + CONTEXT_CHUNK_SIZE)];
342        let curr_slice = &current[start..current.len().min(start + CONTEXT_CHUNK_SIZE)];
343        return offset.saturating_add(
344            prev_slice
345                .iter()
346                .zip(curr_slice)
347                .take_while(|(a, b)| a == b)
348                .count(),
349        );
350    }
351
352    // All common chunks matched; include the tail of a partial final chunk.
353    offset.saturating_add(
354        previous.bytes[offset..]
355            .iter()
356            .zip(&current[offset..])
357            .take_while(|(a, b)| a == b)
358            .count(),
359    )
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use theway_llm_provider::{
366        AssistantMessage, AssistantRole, ContentBlock, Message, StopReason, Tool,
367        ToolResultMessage, ToolResultRole, Usage, UserContent, UserContentBlock, UserMessage,
368        UserRole,
369    };
370
371    fn context_with(extra: Option<&str>) -> PiContext {
372        let mut messages = vec![Message::User(UserMessage {
373            role: UserRole::User,
374            content: UserContent::Text("hello".into()),
375            timestamp: 1,
376        })];
377        if let Some(text) = extra {
378            messages.push(Message::Assistant(AssistantMessage {
379                role: AssistantRole::Assistant,
380                content: vec![ContentBlock::Text(theway_llm_provider::TextContent {
381                    text: text.into(),
382                    text_signature: None,
383                })],
384                api: theway_llm_provider::Api::from("faux"),
385                provider: theway_llm_provider::Provider::from("faux"),
386                model: "m".into(),
387                response_model: None,
388                response_id: None,
389                diagnostics: None,
390                usage: Usage::default(),
391                stop_reason: StopReason::Stop,
392                error_message: None,
393                timestamp: 2,
394            }));
395        }
396        PiContext {
397            system_prompt: Some("system".into()),
398            messages,
399            tools: Some(vec![Tool {
400                name: "t".into(),
401                description: "d".into(),
402                parameters: serde_json::json!({ "type": "object" }),
403            }]),
404        }
405    }
406
407    #[test]
408    fn append_only_context_has_high_prefix_overlap() {
409        let mut tracker = ContextCacheTracker::new();
410        let first = context_with(None);
411        let estimate = tracker.estimate(Some("s1"), "p", "m", &first);
412        assert_eq!(estimate.overlap_bytes, 0);
413        assert!(estimate.total_bytes > 0);
414
415        let second = context_with(Some("next turn"));
416        let estimate = tracker.estimate(Some("s1"), "p", "m", &second);
417        assert!(estimate.overlap_bytes > 0);
418        assert!(estimate.overlap_bytes < estimate.total_bytes);
419
420        let result = tracker.finalize(&estimate, 100);
421        assert!(result.prefix_hit_tokens > 0);
422        assert!(result.prefix_cache_hit_rate.unwrap() > 0.0);
423    }
424
425    #[test]
426    fn mid_insertion_lowers_prefix_overlap() {
427        let mut tracker = ContextCacheTracker::new();
428        let base = context_with(Some("same tail"));
429        tracker.estimate(Some("s1"), "p", "m", &base);
430
431        // Changing the system prompt (the very start) drops the prefix to a
432        // tiny JSON-envelope remainder instead of a large content overlap.
433        let mut changed = base.clone();
434        changed.system_prompt = Some("different system".into());
435        let estimate = tracker.estimate(Some("s1"), "p", "m", &changed);
436        assert!(
437            estimate.overlap_bytes < 64,
438            "overlap: {}",
439            estimate.overlap_bytes
440        );
441    }
442
443    #[test]
444    fn model_switch_resets_baseline() {
445        let mut tracker = ContextCacheTracker::new();
446        let context = context_with(None);
447        tracker.estimate(Some("s1"), "p", "model-a", &context);
448        let estimate = tracker.estimate(Some("s1"), "p", "model-b", &context);
449        assert_eq!(estimate.overlap_bytes, 0);
450
451        // Switching back to model-a also starts fresh.
452        let estimate = tracker.estimate(Some("s1"), "p", "model-a", &context);
453        assert_eq!(estimate.overlap_bytes, 0);
454    }
455
456    #[test]
457    fn missing_total_input_returns_unknown_rate() {
458        let tracker = ContextCacheTracker::new();
459        let estimate = PrefixHitEstimate {
460            overlap_bytes: 10,
461            total_bytes: 100,
462        };
463        let result = tracker.finalize(&estimate, 0);
464        assert_eq!(result.prefix_hit_tokens, 0);
465        assert_eq!(result.prefix_cache_hit_rate, None);
466    }
467
468    #[test]
469    fn compaction_drop_of_suffix_lowers_prefix_overlap() {
470        let mut tracker = ContextCacheTracker::new();
471        tracker.estimate(Some("s1"), "p", "m", &context_with(Some("long tail")));
472        let compacted = context_with(None);
473        let estimate = tracker.estimate(Some("s1"), "p", "m", &compacted);
474        assert!(
475            estimate.overlap_bytes < estimate.total_bytes / 2,
476            "compaction should break prefix: {} / {}",
477            estimate.overlap_bytes,
478            estimate.total_bytes
479        );
480    }
481
482    #[test]
483    fn virtualization_keeps_earlier_prefix_stable() {
484        let mut tracker = ContextCacheTracker::new();
485        let full = context_with_tool_result("actual large tool output line");
486        tracker.estimate(Some("s1"), "p", "m", &full);
487        let virtualized =
488            context_with_tool_result("[tool_result bash call_1: 100 bytes / 10 lines; tail: ...]");
489        let estimate = tracker.estimate(Some("s1"), "p", "m", &virtualized);
490        assert!(
491            estimate.overlap_bytes > estimate.total_bytes / 2,
492            "virtualization should keep the earlier prefix: {} / {}",
493            estimate.overlap_bytes,
494            estimate.total_bytes
495        );
496    }
497
498    fn context_with_tool_result(result_text: &str) -> PiContext {
499        let mut context = context_with(Some("assistant text"));
500        context
501            .messages
502            .push(Message::ToolResult(ToolResultMessage {
503                role: ToolResultRole::ToolResult,
504                tool_call_id: "call_1".into(),
505                tool_name: "bash".into(),
506                content: vec![UserContentBlock::text(result_text)],
507                details: None,
508                is_error: false,
509                timestamp: 3,
510            }));
511        context
512    }
513
514    #[test]
515    fn canonicalization_sorts_freeform_object_keys() {
516        let mut a = serde_json::json!({ "z": 1, "a": { "y": 2, "b": 3 } });
517        let mut b = serde_json::json!({ "a": { "b": 3, "y": 2 }, "z": 1 });
518        canonicalize_freeform(&mut a);
519        canonicalize_freeform(&mut b);
520        assert_eq!(a, b);
521        assert_eq!(
522            serde_json::to_vec(&a).unwrap(),
523            serde_json::to_vec(&b).unwrap()
524        );
525    }
526}
527
528#[cfg(test)]
529mod coverage_gap {
530    use super::*;
531    use theway_llm_provider::{Message, UserContent, UserMessage, UserRole};
532
533    fn context_with_body(body: &str) -> PiContext {
534        PiContext {
535            system_prompt: Some("system".into()),
536            messages: vec![Message::User(UserMessage {
537                role: UserRole::User,
538                content: UserContent::Text(body.to_string()),
539                timestamp: 0,
540            })],
541            tools: None,
542        }
543    }
544
545    #[test]
546    fn finalize_zero_total_bytes_returns_unknown_rate() {
547        let tracker = ContextCacheTracker::new();
548        let estimate = PrefixHitEstimate {
549            overlap_bytes: 0,
550            total_bytes: 0,
551        };
552
553        let result = tracker.finalize(&estimate, 100);
554
555        assert_eq!(result.prefix_hit_tokens, 0);
556        assert_eq!(result.prefix_cache_hit_rate, None);
557    }
558
559    #[test]
560    fn identical_long_contexts_match_every_chunk() {
561        let mut tracker = ContextCacheTracker::new();
562        let context = context_with_body(&"x".repeat(1_000));
563
564        tracker.estimate(Some("s"), "p", "m", &context);
565        let estimate = tracker.estimate(Some("s"), "p", "m", &context);
566
567        assert!(estimate.overlap_bytes >= 256);
568        assert_eq!(estimate.overlap_bytes, estimate.total_bytes);
569    }
570}