Skip to main content

litellm_rs/core/cache/
llm_cache.rs

1//! LLM-specific caching layer
2//!
3//! This module provides high-level caching functionality specifically designed
4//! for LLM requests and responses, including chat completions and embeddings.
5
6use super::dual::DualCache;
7use super::key_generator::{
8    generate_chat_key, generate_chat_key_with_user, generate_embedding_key_with_user,
9};
10use super::types::{
11    CacheKey, CacheStatsSnapshot, CacheWriteIdentity, DualCacheConfig, serialize_write_identity,
12};
13use crate::core::models::openai::{
14    ChatCompletionRequest, ChatCompletionResponse, EmbeddingRequest, EmbeddingResponse,
15};
16use crate::storage::redis::RedisPool;
17use crate::utils::error::gateway_error::Result;
18use serde::{Deserialize, Deserializer, Serialize, Serializer};
19use std::sync::Arc;
20use std::time::Duration;
21use tracing::{info, trace};
22
23/// LLM-specific cache wrapping DualCache
24///
25/// Provides convenient methods for caching LLM requests and responses
26/// with automatic key generation and serialization.
27pub struct LLMCache {
28    /// Chat completion cache
29    chat_cache: DualCache<CachedChatResponse>,
30    /// Embedding cache
31    embedding_cache: DualCache<CachedEmbeddingResponse>,
32    /// Configuration
33    config: LLMCacheConfig,
34}
35
36/// Configuration for LLM cache
37#[derive(Debug, Clone)]
38pub struct LLMCacheConfig {
39    /// Base cache configuration
40    pub cache_config: DualCacheConfig,
41    /// TTL for chat completions
42    pub chat_ttl: Duration,
43    /// TTL for embeddings
44    pub embedding_ttl: Duration,
45    /// Enable user-specific caching
46    pub user_specific: bool,
47}
48
49impl Default for LLMCacheConfig {
50    fn default() -> Self {
51        Self {
52            cache_config: DualCacheConfig::default(),
53            chat_ttl: Duration::from_secs(3600),       // 1 hour
54            embedding_ttl: Duration::from_secs(86400), // 24 hours (embeddings are deterministic)
55            user_specific: false,
56        }
57    }
58}
59
60impl LLMCacheConfig {
61    /// Create a memory-only configuration
62    pub fn memory_only() -> Self {
63        Self {
64            cache_config: DualCacheConfig::memory_only(),
65            ..Default::default()
66        }
67    }
68
69    /// Set the chat TTL
70    pub fn with_chat_ttl(mut self, ttl: Duration) -> Self {
71        self.chat_ttl = ttl;
72        self
73    }
74
75    /// Set the embedding TTL
76    pub fn with_embedding_ttl(mut self, ttl: Duration) -> Self {
77        self.embedding_ttl = ttl;
78        self
79    }
80
81    /// Enable user-specific caching
82    pub fn with_user_specific(mut self) -> Self {
83        self.user_specific = true;
84        self
85    }
86}
87
88fn serialize_chat_response_arc<S>(
89    response: &Arc<ChatCompletionResponse>,
90    serializer: S,
91) -> std::result::Result<S::Ok, S::Error>
92where
93    S: Serializer,
94{
95    response.as_ref().serialize(serializer)
96}
97
98fn deserialize_chat_response_arc<'de, D>(
99    deserializer: D,
100) -> std::result::Result<Arc<ChatCompletionResponse>, D::Error>
101where
102    D: Deserializer<'de>,
103{
104    ChatCompletionResponse::deserialize(deserializer).map(Arc::new)
105}
106
107fn serialize_embedding_response_arc<S>(
108    response: &Arc<EmbeddingResponse>,
109    serializer: S,
110) -> std::result::Result<S::Ok, S::Error>
111where
112    S: Serializer,
113{
114    response.as_ref().serialize(serializer)
115}
116
117fn deserialize_embedding_response_arc<'de, D>(
118    deserializer: D,
119) -> std::result::Result<Arc<EmbeddingResponse>, D::Error>
120where
121    D: Deserializer<'de>,
122{
123    EmbeddingResponse::deserialize(deserializer).map(Arc::new)
124}
125
126/// Cached chat response wrapper
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct CachedChatResponse {
129    /// The original response
130    #[serde(
131        serialize_with = "serialize_chat_response_arc",
132        deserialize_with = "deserialize_chat_response_arc"
133    )]
134    pub response: Arc<ChatCompletionResponse>,
135    /// Model used for the request
136    pub model: String,
137    /// Whether this was a cached response
138    pub cached: bool,
139    /// Cache timestamp
140    pub cached_at: u64,
141}
142
143impl CachedChatResponse {
144    /// Create a new cached response
145    pub fn new(response: ChatCompletionResponse, model: String) -> Self {
146        Self::from_arc_response(Arc::new(response), model)
147    }
148
149    /// Create a new cached response from a shared payload
150    pub fn from_arc_response(response: Arc<ChatCompletionResponse>, model: String) -> Self {
151        Self {
152            response,
153            model,
154            cached: true,
155            cached_at: std::time::SystemTime::now()
156                .duration_since(std::time::UNIX_EPOCH)
157                .unwrap_or_default()
158                .as_secs(),
159        }
160    }
161
162    /// Clone the shared response payload
163    pub fn response_arc(&self) -> Arc<ChatCompletionResponse> {
164        Arc::clone(&self.response)
165    }
166
167    /// Get the shared response payload
168    pub fn into_response_arc(self) -> Arc<ChatCompletionResponse> {
169        self.response
170    }
171
172    /// Get the underlying response
173    pub fn into_response(self) -> ChatCompletionResponse {
174        Arc::try_unwrap(self.response).unwrap_or_else(|response| (*response).clone())
175    }
176}
177
178impl CacheWriteIdentity for CachedChatResponse {
179    fn cache_write_identity(&self) -> Option<u64> {
180        // Match invalidate comparison: ignore wrapper `cached_at` metadata.
181        serialize_write_identity(self.response.as_ref())
182    }
183}
184
185/// Cached embedding response wrapper
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct CachedEmbeddingResponse {
188    /// The original response
189    #[serde(
190        serialize_with = "serialize_embedding_response_arc",
191        deserialize_with = "deserialize_embedding_response_arc"
192    )]
193    pub response: Arc<EmbeddingResponse>,
194    /// Model used for the request
195    pub model: String,
196    /// Whether this was a cached response
197    pub cached: bool,
198    /// Cache timestamp
199    pub cached_at: u64,
200}
201
202impl CachedEmbeddingResponse {
203    /// Create a new cached response
204    pub fn new(response: EmbeddingResponse, model: String) -> Self {
205        Self::from_arc_response(Arc::new(response), model)
206    }
207
208    /// Create a new cached response from a shared payload
209    pub fn from_arc_response(response: Arc<EmbeddingResponse>, model: String) -> Self {
210        Self {
211            response,
212            model,
213            cached: true,
214            cached_at: std::time::SystemTime::now()
215                .duration_since(std::time::UNIX_EPOCH)
216                .unwrap_or_default()
217                .as_secs(),
218        }
219    }
220
221    /// Clone the shared response payload
222    pub fn response_arc(&self) -> Arc<EmbeddingResponse> {
223        Arc::clone(&self.response)
224    }
225
226    /// Get the shared response payload
227    pub fn into_response_arc(self) -> Arc<EmbeddingResponse> {
228        self.response
229    }
230
231    /// Get the underlying response
232    pub fn into_response(self) -> EmbeddingResponse {
233        Arc::try_unwrap(self.response).unwrap_or_else(|response| (*response).clone())
234    }
235}
236
237impl CacheWriteIdentity for CachedEmbeddingResponse {
238    fn cache_write_identity(&self) -> Option<u64> {
239        serialize_write_identity(self.response.as_ref())
240    }
241}
242
243impl LLMCache {
244    /// Create a new LLM cache with the given configuration
245    pub fn new(config: LLMCacheConfig, redis_pool: Option<Arc<RedisPool>>) -> Self {
246        // Provide payload-only identity for wrappers that embed `cached_at`, so
247        // Dual stale-write barriers stay off the general DualCache API bound.
248        let chat_cache = DualCache::with_write_identity(
249            config.cache_config.clone(),
250            redis_pool.clone(),
251            <CachedChatResponse as CacheWriteIdentity>::cache_write_identity,
252        );
253        let embedding_cache = DualCache::with_write_identity(
254            config.cache_config.clone(),
255            redis_pool,
256            <CachedEmbeddingResponse as CacheWriteIdentity>::cache_write_identity,
257        );
258
259        Self {
260            chat_cache,
261            embedding_cache,
262            config,
263        }
264    }
265
266    /// Create a memory-only LLM cache
267    pub fn memory_only() -> Self {
268        Self::new(LLMCacheConfig::memory_only(), None)
269    }
270
271    /// Create with default configuration
272    pub fn with_defaults() -> Self {
273        Self::new(LLMCacheConfig::default(), None)
274    }
275
276    /// Start background cleanup tasks
277    pub fn start_cleanup_tasks(&self) {
278        self.chat_cache.start_cleanup_task();
279        self.embedding_cache.start_cleanup_task();
280    }
281
282    // ==================== Chat Completion Methods ====================
283
284    /// Get a cached chat completion response
285    pub async fn get_chat_response(
286        &self,
287        request: &ChatCompletionRequest,
288    ) -> Result<Option<Arc<ChatCompletionResponse>>> {
289        self.get_chat_response_with_user(request, None).await
290    }
291
292    /// Get a cached chat completion response with user ID
293    pub async fn get_chat_response_with_user(
294        &self,
295        request: &ChatCompletionRequest,
296        user_id: Option<&str>,
297    ) -> Result<Option<Arc<ChatCompletionResponse>>> {
298        // Don't cache streaming requests
299        if request.stream.unwrap_or(false) {
300            return Ok(None);
301        }
302
303        let key = if self.config.user_specific {
304            generate_chat_key_with_user(request, user_id)
305        } else {
306            generate_chat_key(request)
307        };
308
309        if let Some(cached) = self.chat_cache.get(&key).await? {
310            trace!(
311                model = %cached.model,
312                key = %key,
313                "Chat cache hit"
314            );
315            return Ok(Some(cached.response_arc()));
316        }
317
318        Ok(None)
319    }
320
321    /// Cache a chat completion response
322    pub async fn cache_chat_response(
323        &self,
324        request: &ChatCompletionRequest,
325        response: ChatCompletionResponse,
326    ) -> Result<()> {
327        self.cache_chat_response_with_user(request, response, None)
328            .await
329    }
330
331    /// Cache a chat completion response with user ID
332    pub async fn cache_chat_response_with_user(
333        &self,
334        request: &ChatCompletionRequest,
335        response: ChatCompletionResponse,
336        user_id: Option<&str>,
337    ) -> Result<()> {
338        // Don't cache streaming requests
339        if request.stream.unwrap_or(false) {
340            return Ok(());
341        }
342
343        let key = if self.config.user_specific {
344            generate_chat_key_with_user(request, user_id)
345        } else {
346            generate_chat_key(request)
347        };
348
349        let cached = CachedChatResponse::new(response, request.model.clone());
350        self.chat_cache
351            .set_with_ttl(key.clone(), cached, self.config.chat_ttl)
352            .await?;
353
354        trace!(
355            model = %request.model,
356            key = %key,
357            ttl_secs = self.config.chat_ttl.as_secs(),
358            "Chat response cached"
359        );
360
361        Ok(())
362    }
363
364    /// Invalidate a cached chat response
365    pub async fn invalidate_chat(&self, request: &ChatCompletionRequest) -> Result<bool> {
366        self.invalidate_chat_with_user(request, None).await
367    }
368
369    /// Invalidate a cached chat response, honoring `config.user_specific`
370    ///
371    /// When user-specific caching is enabled, the cache is keyed by user id.
372    /// Invalidation must use the same key, or the per-user entry stays live
373    /// until its TTL expires — leaking stale responses across users.
374    pub async fn invalidate_chat_with_user(
375        &self,
376        request: &ChatCompletionRequest,
377        user_id: Option<&str>,
378    ) -> Result<bool> {
379        let key = if self.config.user_specific {
380            generate_chat_key_with_user(request, user_id)
381        } else {
382            generate_chat_key(request)
383        };
384        self.chat_cache.delete(&key).await
385    }
386
387    /// Invalidate a cached chat response only while it still matches `expected`.
388    ///
389    /// Key-only deletes can remove a concurrent replacement that another request
390    /// already stored after invalidating the same poisoned entry. Matching delete
391    /// is atomic per cache layer (DashMap `remove_if` / Redis Lua CAS) so a
392    /// replacement under the same key is not removed after a stale match.
393    /// Dual-mode matching also purges a divergent L2 value when L1 matched, so a
394    /// prior best-effort Redis write failure cannot leave a second poisoned
395    /// payload to warm back into L1. Only the predicate-matched L1 identity is
396    /// barriered; divergent L2 payloads are deleted without a write barrier.
397    pub async fn invalidate_chat_with_user_matching(
398        &self,
399        request: &ChatCompletionRequest,
400        user_id: Option<&str>,
401        expected: &ChatCompletionResponse,
402    ) -> Result<bool> {
403        let key = if self.config.user_specific {
404            generate_chat_key_with_user(request, user_id)
405        } else {
406            generate_chat_key(request)
407        };
408
409        self.chat_cache
410            .delete_if(&key, |current| {
411                chat_response_matches_cached(current.response.as_ref(), expected)
412            })
413            .await
414    }
415
416    // ==================== Embedding Methods ====================
417
418    fn embedding_key(
419        &self,
420        request: &EmbeddingRequest,
421        user_id: Option<&str>,
422        deployment_id: &str,
423    ) -> CacheKey {
424        generate_embedding_key_with_user(
425            request,
426            if self.config.user_specific {
427                user_id
428            } else {
429                None
430            },
431            Some(deployment_id),
432        )
433    }
434
435    /// Get a cached embedding response for the selected deployment and trusted caller.
436    pub async fn get_embedding_response(
437        &self,
438        request: &EmbeddingRequest,
439        user_id: Option<&str>,
440        deployment_id: &str,
441    ) -> Result<Option<Arc<EmbeddingResponse>>> {
442        let key = self.embedding_key(request, user_id, deployment_id);
443
444        if let Some(cached) = self.embedding_cache.get(&key).await? {
445            trace!(
446                model = %cached.model,
447                key = %key,
448                "Embedding cache hit"
449            );
450            return Ok(Some(cached.response_arc()));
451        }
452
453        Ok(None)
454    }
455
456    /// Cache an embedding response
457    pub async fn cache_embedding_response(
458        &self,
459        request: &EmbeddingRequest,
460        response: EmbeddingResponse,
461        user_id: Option<&str>,
462        deployment_id: &str,
463    ) -> Result<()> {
464        let key = self.embedding_key(request, user_id, deployment_id);
465        let cached = CachedEmbeddingResponse::new(response, request.model.clone());
466
467        self.embedding_cache
468            .set_with_ttl(key.clone(), cached, self.config.embedding_ttl)
469            .await?;
470
471        trace!(
472            model = %request.model,
473            key = %key,
474            ttl_secs = self.config.embedding_ttl.as_secs(),
475            "Embedding response cached"
476        );
477
478        Ok(())
479    }
480
481    /// Invalidate a cached embedding response
482    pub async fn invalidate_embedding(
483        &self,
484        request: &EmbeddingRequest,
485        user_id: Option<&str>,
486        deployment_id: &str,
487    ) -> Result<bool> {
488        let key = self.embedding_key(request, user_id, deployment_id);
489        self.embedding_cache.delete(&key).await
490    }
491
492    // ==================== Generic Methods ====================
493
494    /// Get a value by key directly
495    pub async fn get<T>(&self, _key: &CacheKey) -> Result<Option<T>>
496    where
497        T: serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
498    {
499        // Use a generic cache for arbitrary types
500        // For now, this is a placeholder - in production you'd want a separate cache
501        Ok(None)
502    }
503
504    /// Set a value by key directly
505    pub async fn set<T>(&self, _key: CacheKey, _value: T, _ttl: Duration) -> Result<()>
506    where
507        T: serde::Serialize + Clone + Send + Sync + 'static,
508    {
509        // Placeholder for generic cache operations
510        Ok(())
511    }
512
513    // ==================== Statistics and Management ====================
514
515    /// Get chat cache statistics
516    pub fn chat_stats(&self) -> CacheStatsSnapshot {
517        self.chat_cache.stats()
518    }
519
520    /// Get embedding cache statistics
521    pub fn embedding_stats(&self) -> CacheStatsSnapshot {
522        self.embedding_cache.stats()
523    }
524
525    /// Get combined statistics
526    pub fn combined_stats(&self) -> CombinedCacheStats {
527        CombinedCacheStats {
528            chat: self.chat_cache.stats(),
529            embedding: self.embedding_cache.stats(),
530        }
531    }
532
533    /// Clear all caches
534    pub async fn clear(&self) -> Result<()> {
535        self.chat_cache.clear().await?;
536        self.embedding_cache.clear().await?;
537        info!("LLM caches cleared");
538        Ok(())
539    }
540
541    /// Clear chat cache only
542    pub async fn clear_chat(&self) -> Result<()> {
543        self.chat_cache.clear().await
544    }
545
546    /// Clear embedding cache only
547    pub async fn clear_embedding(&self) -> Result<()> {
548        self.embedding_cache.clear().await
549    }
550
551    /// Check if Redis is available
552    pub async fn is_redis_available(&self) -> bool {
553        self.chat_cache.is_redis_available().await
554    }
555
556    /// Get the configuration
557    pub fn config(&self) -> &LLMCacheConfig {
558        &self.config
559    }
560
561    /// Shutdown the cache
562    pub fn shutdown(&self) {
563        self.chat_cache.shutdown();
564        self.embedding_cache.shutdown();
565    }
566}
567
568/// True when `current` is still the same completion that `expected` rejected.
569///
570/// Compares the full serialized payload. Metadata-only checks (`id`/`created`/
571/// `model`) collide when OpenAI-compatible upstreams (notably Azure) default a
572/// missing `id` to `""` and `created` to second resolution — two distinct
573/// completions for the same model in the same second then look identical.
574fn chat_response_matches_cached(
575    current: &ChatCompletionResponse,
576    expected: &ChatCompletionResponse,
577) -> bool {
578    match (serde_json::to_vec(current), serde_json::to_vec(expected)) {
579        (Ok(left), Ok(right)) => left == right,
580        // Serialization failure is unexpected; refuse the match so we do not
581        // delete a possibly-distinct replacement.
582        _ => false,
583    }
584}
585
586/// Combined cache statistics
587#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct CombinedCacheStats {
589    /// Chat cache statistics
590    pub chat: CacheStatsSnapshot,
591    /// Embedding cache statistics
592    pub embedding: CacheStatsSnapshot,
593}
594
595impl CombinedCacheStats {
596    /// Get total hits across all caches
597    pub fn total_hits(&self) -> u64 {
598        self.chat.total_hits() + self.embedding.total_hits()
599    }
600
601    /// Get total misses across all caches
602    pub fn total_misses(&self) -> u64 {
603        self.chat.total_misses() + self.embedding.total_misses()
604    }
605
606    /// Get combined hit rate
607    pub fn hit_rate(&self) -> f64 {
608        let total = self.total_hits() + self.total_misses();
609        if total == 0 {
610            0.0
611        } else {
612            self.total_hits() as f64 / total as f64
613        }
614    }
615}
616
617#[cfg(test)]
618#[path = "llm_cache_tests.rs"]
619mod tests;