//! In-memory LLM response cache with TTL and LRU eviction. //! //! Wraps any [`LlmProvider`] and caches [`complete()`] responses keyed //! by a SHA-256 hash of the messages and model name. Tool-calling //! requests are never cached since they can trigger side effects. //! //! ```text //! ┌──────────────────────────────────────────────────┐ //! │ CachedProvider │ //! │ complete() ──► cache lookup ──► hit? return │ //! │ miss? call inner │ //! │ store response │ //! │ │ //! │ complete_with_tools() ──► always call inner │ //! └──────────────────────────────────────────────────┘ //! ``` use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use async_trait::async_trait; use rust_decimal::Decimal; use sha2::{Digest, Sha256}; use crate::llm::error::LlmError; use crate::llm::provider::{ CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest, ToolCompletionResponse, }; /// How often (in requests) to emit a cache statistics log line. const STATS_LOG_EVERY_N: u64 = 100; /// Configuration for the response cache. #[derive(Debug, Clone)] pub struct ResponseCacheConfig { /// Time-to-live for cache entries. pub ttl: Duration, /// Maximum number of cached entries before LRU eviction. pub max_entries: usize, } impl Default for ResponseCacheConfig { fn default() -> Self { Self { ttl: Duration::from_secs(3600), // 1 hour max_entries: 1000, } } } struct CacheEntry { response: CompletionResponse, created_at: Instant, last_accessed: Instant, hit_count: u64, } /// LLM provider wrapper that caches `complete()` responses. /// /// Tool completion requests are always forwarded without caching since /// tool calls can have side effects that should not be replayed. pub struct CachedProvider { inner: Arc, /// `std::sync::Mutex` (not tokio) — never held across an `.await` point, /// so blocking acquisition is safe and keeps `set_model()` synchronous. cache: Mutex>, config: ResponseCacheConfig, /// Total `complete()` calls (hits + misses) for periodic stats logging. request_count: AtomicU64, /// Running total of cache hits, independent of entry lifecycle. /// Never decremented on eviction, so `hit_rate_pct` in stats doesn't /// drift down as entries expire or are LRU-evicted. total_hit_count: AtomicU64, } impl CachedProvider { /// Wrap an existing provider with response caching. pub fn new(inner: Arc, config: ResponseCacheConfig) -> Self { Self { inner, cache: Mutex::new(HashMap::new()), config, request_count: AtomicU64::new(0), total_hit_count: AtomicU64::new(0), } } /// Number of entries currently in the cache. pub fn len(&self) -> usize { self.cache.lock().unwrap_or_else(|e| e.into_inner()).len() } /// Whether the cache is empty. pub fn is_empty(&self) -> bool { self.cache .lock() .unwrap_or_else(|e| e.into_inner()) .is_empty() } /// Total cache hits since this provider was created. /// /// Backed by an atomic counter that is never decremented on eviction, /// so the value is accurate even under high eviction pressure. pub fn total_hits(&self) -> u64 { self.total_hit_count.load(Ordering::Relaxed) } /// Clear all cached entries. pub fn clear(&self) { self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear(); } /// Emit a cache statistics log line if `req_no` is a multiple of /// [`STATS_LOG_EVERY_N`]. `total_hits` must come from the `total_hit_count` /// atomic so it accurately reflects hits that occurred on since-evicted /// entries. Must be called while holding the cache lock so that /// `entry_count` is consistent with the snapshot. fn maybe_log_stats(guard: &HashMap, req_no: u64, total_hits: u64) { if req_no.is_multiple_of(STATS_LOG_EVERY_N) { let hit_rate = total_hits as f64 / req_no as f64 * 100.0; tracing::info!( total_requests = req_no, total_hits, hit_rate_pct = format!("{hit_rate:.1}"), entry_count = guard.len(), "LLM response cache statistics" ); } } } /// Build a deterministic cache key from a completion request. /// /// Hashes the model name, messages, and response-affecting parameters /// (max_tokens, temperature, stop_sequences) via SHA-256. Two requests /// with identical content and parameters produce the same key. fn cache_key(model: &str, request: &CompletionRequest) -> String { let mut hasher = Sha256::new(); hasher.update(model.as_bytes()); hasher.update(b"|"); // Messages are Serialize, so we can deterministically hash them. // serde_json produces stable output for the same input structure. if let Ok(json) = serde_json::to_string(&request.messages) { hasher.update(json.as_bytes()); } // Include response-affecting parameters so different temperatures, // max_tokens, or stop sequences produce distinct cache keys. hasher.update(b"|"); if let Some(max_tokens) = request.max_tokens { hasher.update(max_tokens.to_le_bytes()); } hasher.update(b"|"); if let Some(temp) = request.temperature { hasher.update(temp.to_le_bytes()); } hasher.update(b"|"); if let Some(ref stops) = request.stop_sequences { for s in stops { hasher.update(s.as_bytes()); hasher.update(b"\x00"); } } format!("{:x}", hasher.finalize()) } #[async_trait] impl LlmProvider for CachedProvider { fn model_name(&self) -> &str { self.inner.model_name() } fn cost_per_token(&self) -> (Decimal, Decimal) { self.inner.cost_per_token() } fn cache_write_multiplier(&self) -> Decimal { self.inner.cache_write_multiplier() } fn cache_read_discount(&self) -> Decimal { self.inner.cache_read_discount() } async fn complete(&self, request: CompletionRequest) -> Result { let effective_model = self.inner.effective_model_name(request.model.as_deref()); let key = cache_key(&effective_model, &request); let now = Instant::now(); let req_no = self.request_count.fetch_add(1, Ordering::Relaxed) + 1; // Check cache — lock not held across the .await below. { let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); if let Some(entry) = guard.get_mut(&key) { if now.duration_since(entry.created_at) < self.config.ttl { entry.last_accessed = now; entry.hit_count += 1; let hit_count = entry.hit_count; // Clone now so we can release the mutable borrow before stats. let cached_response = entry.response.clone(); tracing::trace!(hits = hit_count, "response cache hit"); // Drop the mutable borrow of `entry` before reading `guard` immutably. let _ = entry; let total_hits = self.total_hit_count.fetch_add(1, Ordering::Relaxed) + 1; Self::maybe_log_stats(&guard, req_no, total_hits); return Ok(cached_response); } // Expired, remove it guard.remove(&key); } } // Cache miss — call the real provider. let result = self.inner.complete(request).await; // Store result and maybe log stats, all within one lock acquisition. // Stats are logged even on provider error so milestone intervals are // not silently skipped. { let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); let total_hits = self.total_hit_count.load(Ordering::Relaxed); let response = match result { Err(e) => { Self::maybe_log_stats(&guard, req_no, total_hits); return Err(e); } Ok(r) => r, }; // Evict expired entries guard.retain(|_, entry| now.duration_since(entry.created_at) < self.config.ttl); // LRU eviction if over capacity while guard.len() >= self.config.max_entries { let oldest_key = guard .iter() .min_by_key(|(_, entry)| entry.last_accessed) .map(|(k, _)| k.clone()); if let Some(k) = oldest_key { guard.remove(&k); } else { break; } } guard.insert( key, CacheEntry { response: response.clone(), created_at: now, last_accessed: now, hit_count: 0, }, ); Self::maybe_log_stats(&guard, req_no, total_hits); Ok(response) } } async fn complete_with_tools( &self, request: ToolCompletionRequest, ) -> Result { // Never cache tool calls; they can trigger side effects. self.inner.complete_with_tools(request).await } async fn list_models(&self) -> Result, LlmError> { self.inner.list_models().await } async fn model_metadata(&self) -> Result { self.inner.model_metadata().await } fn effective_model_name(&self, requested_model: Option<&str>) -> String { self.inner.effective_model_name(requested_model) } fn active_model_name(&self) -> String { self.inner.active_model_name() } fn set_model(&self, model: &str) -> Result<(), LlmError> { // Cache keys embed the active model name via `effective_model_name()`, so // requests to the new model automatically land in a separate cache slot. // Entries for the old model remain valid: if we switch back, they will be // hit again rather than wasted. Natural TTL / LRU eviction cleans them up. self.inner.set_model(model) } fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal { self.inner.calculate_cost(input_tokens, output_tokens) } } #[cfg(test)] mod tests { use std::sync::atomic::{AtomicU32, Ordering}; use rust_decimal::Decimal; use tracing_test::traced_test; use crate::llm::error::LlmError; use crate::llm::provider::{ ChatMessage, CompletionResponse, FinishReason, ToolCompletionRequest, ToolCompletionResponse, }; use crate::llm::response_cache::*; use crate::testing::StubLlm; /// Minimal provider stub that supports `set_model()` — used to test /// per-model cache key isolation. struct SwitchableStub { call_count: AtomicU32, active_model: std::sync::RwLock, } impl SwitchableStub { fn new() -> Self { Self { call_count: AtomicU32::new(0), active_model: std::sync::RwLock::new("stub-model".to_string()), } } } #[async_trait] impl LlmProvider for SwitchableStub { fn model_name(&self) -> &str { "stub-model" } fn active_model_name(&self) -> String { self.active_model.read().unwrap().clone() } fn cost_per_token(&self) -> (Decimal, Decimal) { (Decimal::ZERO, Decimal::ZERO) } fn set_model(&self, model: &str) -> Result<(), LlmError> { *self.active_model.write().unwrap() = model.to_string(); Ok(()) } async fn complete( &self, _request: CompletionRequest, ) -> Result { self.call_count.fetch_add(1, Ordering::Relaxed); Ok(CompletionResponse { content: "ok".into(), input_tokens: 1, output_tokens: 1, finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }) } async fn complete_with_tools( &self, _request: ToolCompletionRequest, ) -> Result { Ok(ToolCompletionResponse { content: Some("ok".into()), tool_calls: vec![], input_tokens: 1, output_tokens: 1, finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }) } } fn simple_request() -> CompletionRequest { CompletionRequest { messages: vec![ChatMessage::user("hello")], model: None, max_tokens: None, temperature: None, stop_sequences: None, metadata: Default::default(), } } fn different_request() -> CompletionRequest { CompletionRequest { messages: vec![ChatMessage::user("goodbye")], model: None, max_tokens: None, temperature: None, stop_sequences: None, metadata: Default::default(), } } #[test] fn cache_key_is_deterministic() { let req = simple_request(); let k1 = cache_key("model-a", &req); let k2 = cache_key("model-a", &req); assert_eq!(k1, k2); assert_eq!(k1.len(), 64); // SHA-256 hex } #[test] fn cache_key_varies_by_model() { let req = simple_request(); let k1 = cache_key("model-a", &req); let k2 = cache_key("model-b", &req); assert_ne!(k1, k2); } #[test] fn cache_key_varies_by_messages() { let k1 = cache_key("model-a", &simple_request()); let k2 = cache_key("model-a", &different_request()); assert_ne!(k1, k2); } #[test] fn cache_key_varies_by_temperature() { let mut req_a = simple_request(); req_a.temperature = Some(0.0); let mut req_b = simple_request(); req_b.temperature = Some(1.0); assert_ne!(cache_key("m", &req_a), cache_key("m", &req_b)); } #[test] fn cache_key_varies_by_max_tokens() { let mut req_a = simple_request(); req_a.max_tokens = Some(100); let mut req_b = simple_request(); req_b.max_tokens = Some(500); assert_ne!(cache_key("m", &req_a), cache_key("m", &req_b)); } #[tokio::test] async fn cache_hit_avoids_provider_call() { let stub = Arc::new(StubLlm::new("cached response")); let cached = CachedProvider::new( stub.clone(), ResponseCacheConfig { ttl: Duration::from_secs(60), max_entries: 100, }, ); // First call: cache miss let r1 = cached.complete(simple_request()).await.unwrap(); assert_eq!(stub.calls(), 1); assert_eq!(r1.content, "cached response"); // Second call: cache hit let r2 = cached.complete(simple_request()).await.unwrap(); assert_eq!(stub.calls(), 1); // still 1 assert_eq!(r2.content, "cached response"); assert_eq!(cached.total_hits(), 1); } #[tokio::test] async fn different_messages_get_different_entries() { let stub = Arc::new(StubLlm::new("cached response")); let cached = CachedProvider::new(stub.clone(), ResponseCacheConfig::default()); cached.complete(simple_request()).await.unwrap(); cached.complete(different_request()).await.unwrap(); assert_eq!(stub.calls(), 2); assert_eq!(cached.len(), 2); } #[tokio::test] async fn expired_entries_are_evicted() { let stub = Arc::new(StubLlm::new("cached response")); let cached = CachedProvider::new( stub.clone(), ResponseCacheConfig { ttl: Duration::from_millis(1), max_entries: 100, }, ); cached.complete(simple_request()).await.unwrap(); assert_eq!(stub.calls(), 1); // Wait for TTL to expire tokio::time::sleep(Duration::from_millis(10)).await; // Should be a cache miss now cached.complete(simple_request()).await.unwrap(); assert_eq!(stub.calls(), 2); } #[tokio::test] async fn lru_eviction_removes_oldest() { let stub = Arc::new(StubLlm::new("cached response")); let cached = CachedProvider::new( stub.clone(), ResponseCacheConfig { ttl: Duration::from_secs(60), max_entries: 2, }, ); // Fill cache with 2 entries cached.complete(simple_request()).await.unwrap(); cached.complete(different_request()).await.unwrap(); assert_eq!(cached.len(), 2); // Add a third: should evict the oldest let third = CompletionRequest { messages: vec![ChatMessage::user("third")], model: None, max_tokens: None, temperature: None, stop_sequences: None, metadata: Default::default(), }; cached.complete(third).await.unwrap(); assert_eq!(cached.len(), 2); assert_eq!(stub.calls(), 3); } #[tokio::test] async fn tool_calls_are_never_cached() { let stub = Arc::new(StubLlm::new("cached response")); let cached = CachedProvider::new(stub.clone(), ResponseCacheConfig::default()); let req = ToolCompletionRequest { messages: vec![ChatMessage::user("use tool")], tools: vec![], model: None, max_tokens: None, temperature: None, stop_sequences: None, tool_choice: None, metadata: Default::default(), }; cached.complete_with_tools(req.clone()).await.unwrap(); cached.complete_with_tools(req).await.unwrap(); // Both should have called through assert_eq!(stub.calls(), 2); assert!(cached.is_empty()); } #[tokio::test] async fn provider_errors_are_not_cached() { let stub = Arc::new(StubLlm::new("cached response")); let cached = CachedProvider::new( stub.clone(), ResponseCacheConfig { ttl: Duration::from_secs(60), max_entries: 100, }, ); stub.set_failing(true); let result = cached.complete(simple_request()).await; assert!(result.is_err()); assert!(cached.is_empty()); // After fixing the provider, should succeed and cache stub.set_failing(false); cached.complete(simple_request()).await.unwrap(); assert_eq!(cached.len(), 1); } #[tokio::test] async fn clear_empties_cache() { let stub = Arc::new(StubLlm::new("cached response")); let cached = CachedProvider::new(stub.clone(), ResponseCacheConfig::default()); cached.complete(simple_request()).await.unwrap(); assert_eq!(cached.len(), 1); cached.clear(); assert!(cached.is_empty()); } #[tokio::test] async fn model_override_gets_distinct_cache_entries() { let stub = Arc::new(StubLlm::new("cached response")); let cached = CachedProvider::new(stub.clone(), ResponseCacheConfig::default()); let mut req_a = simple_request(); req_a.model = Some("model-a".to_string()); let mut req_b = simple_request(); req_b.model = Some("model-b".to_string()); cached.complete(req_a).await.unwrap(); cached.complete(req_b).await.unwrap(); assert_eq!(stub.calls(), 2); assert_eq!(cached.len(), 2); } #[test] fn default_config_is_reasonable() { let cfg = ResponseCacheConfig::default(); assert_eq!(cfg.ttl, Duration::from_secs(3600)); assert_eq!(cfg.max_entries, 1000); } #[tokio::test] async fn delegates_model_name() { let stub = Arc::new(StubLlm::new("cached response")); let cached = CachedProvider::new(stub.clone(), ResponseCacheConfig::default()); assert_eq!(cached.model_name(), "stub-model"); } /// Switching models preserves existing cached entries and routes subsequent /// requests to a separate cache slot. Switching back replays the old slot. #[tokio::test] async fn set_model_isolates_per_model_via_key() { let stub = Arc::new(SwitchableStub::new()); let cached = CachedProvider::new(stub.clone(), ResponseCacheConfig::default()); // Populate cache under the initial model ("stub-model"). cached.complete(simple_request()).await.unwrap(); assert_eq!(stub.call_count.load(Ordering::Relaxed), 1); assert_eq!(cached.len(), 1, "one entry cached for stub-model"); // Switch to a different model — old entries must survive. cached.set_model("model-b").unwrap(); assert_eq!(cached.len(), 1, "old entries preserved after model switch"); // Same request under model-b is a cache miss (different key). cached.complete(simple_request()).await.unwrap(); assert_eq!( stub.call_count.load(Ordering::Relaxed), 2, "cache miss for model-b" ); assert_eq!(cached.len(), 2, "separate slots for stub-model and model-b"); // Switch back — original slot is still valid (cache hit, no extra call). cached.set_model("stub-model").unwrap(); cached.complete(simple_request()).await.unwrap(); assert_eq!( stub.call_count.load(Ordering::Relaxed), 2, "cache hit when switching back to stub-model" ); } /// When `set_model()` fails the error is propagated and the cache is unaffected. #[tokio::test] async fn set_model_error_leaves_cache_intact() { // StubLlm does not override set_model() — returns an error by default. let stub = Arc::new(StubLlm::default()); let cached = CachedProvider::new(stub, ResponseCacheConfig::default()); cached.complete(simple_request()).await.unwrap(); assert_eq!(cached.len(), 1); let result = cached.set_model("new-model"); assert!(result.is_err()); assert_eq!(cached.len(), 1, "cache unaffected by failed set_model"); } /// `hit_rate_pct` stays accurate even after entries are evicted. /// The `total_hit_count` atomic is never decremented on eviction. #[tokio::test] async fn total_hits_survives_eviction() { let stub = Arc::new(StubLlm::new("response")); // max_entries = 1 so the first entry is LRU-evicted when a second arrives. let cached = CachedProvider::new( stub.clone(), ResponseCacheConfig { ttl: Duration::from_secs(60), max_entries: 1, }, ); // Populate the cache and score a hit. cached.complete(simple_request()).await.unwrap(); cached.complete(simple_request()).await.unwrap(); assert_eq!(cached.total_hits(), 1); // Add a different request — LRU evicts the first entry. cached.complete(different_request()).await.unwrap(); assert_eq!(cached.len(), 1, "first entry was evicted"); // The hit from the evicted entry must still be counted. assert_eq!(cached.total_hits(), 1, "hit count survives eviction"); } /// A stats line is emitted exactly at the 100th request. #[tokio::test] #[traced_test] async fn stats_logged_at_request_100() { let stub = Arc::new(StubLlm::new("response")); let cached = CachedProvider::new( stub.clone(), ResponseCacheConfig { ttl: Duration::from_secs(60), max_entries: 2000, }, ); // 99 distinct requests — no stats line yet. for i in 0..99u32 { let req = CompletionRequest { messages: vec![ChatMessage::user(format!("request {i}"))], model: None, max_tokens: None, temperature: None, stop_sequences: None, metadata: Default::default(), }; cached.complete(req).await.unwrap(); } assert!( !logs_contain("LLM response cache statistics"), "no stats before request 100" ); // 100th request triggers the first stats line. let req = CompletionRequest { messages: vec![ChatMessage::user("request 99")], model: None, max_tokens: None, temperature: None, stop_sequences: None, metadata: Default::default(), }; cached.complete(req).await.unwrap(); assert!( logs_contain("LLM response cache statistics"), "stats emitted at request 100" ); } /// Stats are emitted even when the inner provider returns an error. #[tokio::test] #[traced_test] async fn stats_logged_on_provider_error_at_interval() { let stub = Arc::new(StubLlm::new("response")); let cached = CachedProvider::new( stub.clone(), ResponseCacheConfig { ttl: Duration::from_secs(60), max_entries: 2000, }, ); // 99 successful requests. for i in 0..99u32 { let req = CompletionRequest { messages: vec![ChatMessage::user(format!("req {i}"))], model: None, max_tokens: None, temperature: None, stop_sequences: None, metadata: Default::default(), }; cached.complete(req).await.unwrap(); } // 100th request fails — stats must still be logged. stub.set_failing(true); let req = CompletionRequest { messages: vec![ChatMessage::user("req 99")], model: None, max_tokens: None, temperature: None, stop_sequences: None, metadata: Default::default(), }; let result = cached.complete(req).await; assert!(result.is_err()); assert!( logs_contain("LLM response cache statistics"), "stats emitted even when provider errors on request 100" ); } }