lc_core/cache/
llm_cache.rs1use crate::language_models::LLMResult;
8use lc_schema::Message;
9use std::collections::HashMap;
10use std::time::{Duration, Instant};
11use tokio::sync::RwLock;
12
13#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum CacheError {
17 #[error("cache key serialization failed: {0}")]
19 Serialization(#[from] serde_json::Error),
20}
21
22#[derive(Debug, Clone)]
24pub struct CachedLLMResult {
25 pub result: LLMResult,
27 pub cached_at: Instant,
29}
30
31#[derive(Debug, Clone)]
33pub struct CacheConfig {
34 pub max_entries: usize,
36 pub ttl: Option<Duration>,
38 pub enabled: bool,
40}
41
42impl Default for CacheConfig {
43 fn default() -> Self {
44 Self {
45 max_entries: 1000,
46 ttl: Some(Duration::from_secs(3600)), enabled: true,
48 }
49 }
50}
51
52impl CacheConfig {
53 pub fn new() -> Self {
55 Self::default()
56 }
57
58 pub fn no_ttl(mut self) -> Self {
60 self.ttl = None;
61 self
62 }
63
64 pub fn with_ttl(mut self, ttl: Duration) -> Self {
66 self.ttl = Some(ttl);
67 self
68 }
69
70 pub fn with_max_entries(mut self, max: usize) -> Self {
72 self.max_entries = max;
73 self
74 }
75
76 pub fn disabled(mut self) -> Self {
78 self.enabled = false;
79 self
80 }
81}
82
83pub struct LLMCache {
99 config: CacheConfig,
100 store: RwLock<HashMap<String, CachedLLMResult>>,
101}
102
103impl LLMCache {
104 pub fn new() -> Self {
106 Self::with_config(CacheConfig::default())
107 }
108
109 pub fn with_config(config: CacheConfig) -> Self {
111 Self {
112 config,
113 store: RwLock::new(HashMap::new()),
114 }
115 }
116
117 pub fn build_key(messages: &[Message], model: &str) -> Result<String, CacheError> {
123 let serialized = serde_json::to_string(messages).map_err(CacheError::Serialization)?;
124 Ok(format!("{}:{}", model, serialized))
125 }
126
127 pub async fn get(&self, key: &str) -> Option<CachedLLMResult> {
132 if !self.config.enabled {
133 return None;
134 }
135
136 let store = self.store.read().await;
137 let entry = store.get(key)?;
138
139 if let Some(ttl) = self.config.ttl {
141 if entry.cached_at.elapsed() > ttl {
142 drop(store);
144 let mut store = self.store.write().await;
145 if let Some(entry) = store.get(key) {
147 if entry.cached_at.elapsed() > ttl {
148 store.remove(key);
149 }
150 }
151 return None;
152 }
153 }
154
155 let result = entry.clone();
156
157 drop(store);
159 let mut store = self.store.write().await;
160 if let Some(entry) = store.get_mut(key) {
161 entry.cached_at = Instant::now();
162 }
163
164 Some(result)
165 }
166
167 pub async fn put(&self, key: impl Into<String>, result: LLMResult) {
169 if !self.config.enabled {
170 return;
171 }
172
173 let mut store = self.store.write().await;
174
175 if self.config.max_entries > 0 && store.len() >= self.config.max_entries {
177 if let Some(oldest_key) = store
179 .iter()
180 .min_by_key(|(_, v)| v.cached_at)
181 .map(|(k, _)| k.clone())
182 {
183 store.remove(&oldest_key);
184 }
185 }
186
187 store.insert(
188 key.into(),
189 CachedLLMResult {
190 result,
191 cached_at: Instant::now(),
192 },
193 );
194 }
195
196 pub async fn clear(&self) {
198 let mut store = self.store.write().await;
199 store.clear();
200 }
201
202 pub async fn len(&self) -> usize {
204 let store = self.store.read().await;
205 store.len()
206 }
207
208 pub async fn is_empty(&self) -> bool {
210 self.len().await == 0
211 }
212
213 pub async fn evict_expired(&self) -> usize {
215 if let Some(ttl) = self.config.ttl {
216 let mut store = self.store.write().await;
217 let before = store.len();
218 store.retain(|_, v| v.cached_at.elapsed() <= ttl);
219 before - store.len()
220 } else {
221 0
222 }
223 }
224}
225
226impl Default for LLMCache {
227 fn default() -> Self {
228 Self::new()
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use crate::language_models::TokenUsage;
236
237 fn make_result(content: &str) -> LLMResult {
238 LLMResult {
239 content: content.to_string(),
240 model: "test-model".to_string(),
241 token_usage: Some(TokenUsage {
242 prompt_tokens: 10,
243 completion_tokens: 5,
244 total_tokens: 15,
245 }),
246 tool_calls: None,
247 thinking_content: None,
248 }
249 }
250
251 #[tokio::test]
252 async fn test_cache_put_and_get() {
253 let cache = LLMCache::new();
254 let key = "test-key";
255 let result = make_result("Hello, world!");
256
257 cache.put(key.to_string(), result.clone()).await;
258 let cached = cache.get(key).await;
259
260 assert!(cached.is_some());
261 assert_eq!(cached.unwrap().result.content, "Hello, world!");
262 }
263
264 #[tokio::test]
265 async fn test_cache_miss() {
266 let cache = LLMCache::new();
267 let cached = cache.get("non-existent").await;
268 assert!(cached.is_none());
269 }
270
271 #[tokio::test]
272 async fn test_cache_clear() {
273 let cache = LLMCache::new();
274 cache.put("k1".to_string(), make_result("r1")).await;
275 cache.put("k2".to_string(), make_result("r2")).await;
276 assert_eq!(cache.len().await, 2);
277
278 cache.clear().await;
279 assert_eq!(cache.len().await, 0);
280 }
281
282 #[tokio::test]
283 async fn test_cache_disabled() {
284 let config = CacheConfig::new().disabled();
285 let cache = LLMCache::with_config(config);
286
287 cache.put("key".to_string(), make_result("test")).await;
288 let cached = cache.get("key").await;
289 assert!(cached.is_none());
290 }
291
292 #[tokio::test]
293 async fn test_cache_ttl_expiry() {
294 let config = CacheConfig::new().with_ttl(Duration::from_millis(10));
295 let cache = LLMCache::with_config(config);
296
297 cache.put("key".to_string(), make_result("test")).await;
298 assert!(cache.get("key").await.is_some());
299
300 tokio::time::sleep(Duration::from_millis(20)).await;
302 assert!(cache.get("key").await.is_none());
303 }
304
305 #[tokio::test]
306 async fn test_cache_max_entries() {
307 let config = CacheConfig::new().with_max_entries(3).no_ttl();
308 let cache = LLMCache::with_config(config);
309
310 cache.put("a".to_string(), make_result("1")).await;
311 cache.put("b".to_string(), make_result("2")).await;
312 cache.put("c".to_string(), make_result("3")).await;
313 assert_eq!(cache.len().await, 3);
314
315 cache.put("d".to_string(), make_result("4")).await;
317 assert_eq!(cache.len().await, 3);
318 assert!(cache.get("a").await.is_none());
320 }
321
322 #[tokio::test]
323 async fn test_cache_get_refreshes_lru_order() {
324 let config = CacheConfig::new().with_max_entries(2).no_ttl();
327 let cache = LLMCache::with_config(config);
328
329 cache.put("a".to_string(), make_result("1")).await;
330 cache.put("b".to_string(), make_result("2")).await;
331
332 assert!(cache.get("a").await.is_some());
334
335 cache.put("c".to_string(), make_result("3")).await;
337 assert!(cache.get("a").await.is_some());
338 assert!(cache.get("b").await.is_none());
339 assert!(cache.get("c").await.is_some());
340 }
341
342 #[tokio::test]
343 async fn test_cache_no_ttl() {
344 let config = CacheConfig::new().no_ttl();
345 let cache = LLMCache::with_config(config);
346
347 cache.put("key".to_string(), make_result("persist")).await;
348
349 tokio::time::sleep(Duration::from_millis(10)).await;
351 assert!(cache.get("key").await.is_some());
352 }
353
354 #[tokio::test]
355 async fn test_cache_evict_expired() {
356 let config = CacheConfig::new().with_ttl(Duration::from_millis(0));
358 let cache = LLMCache::with_config(config);
359
360 cache.put("key".to_string(), make_result("test")).await;
361 tokio::time::sleep(Duration::from_millis(1)).await;
362
363 let evicted = cache.evict_expired().await;
364 assert_eq!(evicted, 1);
365 assert!(cache.is_empty().await);
366 }
367
368 #[tokio::test]
369 async fn test_cache_build_key() {
370 let messages = vec![Message::human("Hello"), Message::ai("Hi!")];
371 let key = LLMCache::build_key(&messages, "gpt-4").unwrap();
372 assert!(key.contains("gpt-4"));
373 assert!(key.contains("Hello"));
374 }
375
376 #[tokio::test]
377 async fn test_cache_is_empty() {
378 let cache = LLMCache::new();
379 assert!(cache.is_empty().await);
380
381 cache.put("key".to_string(), make_result("test")).await;
382 assert!(!cache.is_empty().await);
383 }
384}