1use std::collections::HashMap;
19
20use serde_json::{Value, json};
21use theway_llm_provider::{
22 ContentBlock, Context as PiContext, Message, UserContent, UserContentBlock,
23};
24
25pub const CONTEXT_CHUNK_SIZE: usize = 256;
27
28#[derive(Clone, Debug, Default, PartialEq, Eq)]
30pub struct PrefixHitEstimate {
31 pub overlap_bytes: usize,
34 pub total_bytes: usize,
36}
37
38#[derive(Clone, Debug, Default, PartialEq)]
40pub struct PrefixHitResult {
41 pub prefix_hit_tokens: u64,
43 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#[derive(Clone, Debug, Default)]
60pub struct ContextCacheTracker {
61 entries: HashMap<String, ContextCacheEntry>,
62 last_keys: HashMap<String, String>,
65}
66
67impl ContextCacheTracker {
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 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 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 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 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
151pub 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
266fn 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
290fn 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(¤t_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 let start = offset;
340 let prev_slice =
341 &previous.bytes[start..previous.bytes.len().min(start + CONTEXT_CHUNK_SIZE)];
342 let curr_slice = ¤t[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 offset.saturating_add(
354 previous.bytes[offset..]
355 .iter()
356 .zip(¤t[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 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 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}