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, Clone)]
15pub struct CachedLLMResult {
16 pub result: LLMResult,
18 pub cached_at: Instant,
20}
21
22#[derive(Debug, Clone)]
24pub struct CacheConfig {
25 pub max_entries: usize,
27 pub ttl: Option<Duration>,
29 pub enabled: bool,
31}
32
33impl Default for CacheConfig {
34 fn default() -> Self {
35 Self {
36 max_entries: 1000,
37 ttl: Some(Duration::from_secs(3600)), enabled: true,
39 }
40 }
41}
42
43impl CacheConfig {
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 pub fn no_ttl(mut self) -> Self {
50 self.ttl = None;
51 self
52 }
53
54 pub fn with_ttl(mut self, ttl: Duration) -> Self {
56 self.ttl = Some(ttl);
57 self
58 }
59
60 pub fn with_max_entries(mut self, max: usize) -> Self {
62 self.max_entries = max;
63 self
64 }
65
66 pub fn disabled(mut self) -> Self {
68 self.enabled = false;
69 self
70 }
71}
72
73pub struct LLMCache {
89 config: CacheConfig,
90 store: RwLock<HashMap<String, CachedLLMResult>>,
91}
92
93impl LLMCache {
94 pub fn new() -> Self {
95 Self::with_config(CacheConfig::default())
96 }
97
98 pub fn with_config(config: CacheConfig) -> Self {
99 Self {
100 config,
101 store: RwLock::new(HashMap::new()),
102 }
103 }
104
105 pub fn build_key(messages: &[Message], model: &str) -> Result<String, String> {
111 let serialized = serde_json::to_string(messages)
112 .map_err(|e| format!("cache key serialization failed: {}", e))?;
113 Ok(format!("{}:{}", model, serialized))
114 }
115
116 pub async fn get(&self, key: &str) -> Option<CachedLLMResult> {
121 if !self.config.enabled {
122 return None;
123 }
124
125 let store = self.store.read().await;
126 let entry = store.get(key)?;
127
128 if let Some(ttl) = self.config.ttl {
130 if entry.cached_at.elapsed() > ttl {
131 drop(store);
133 let mut store = self.store.write().await;
134 if let Some(entry) = store.get(key) {
136 if entry.cached_at.elapsed() > ttl {
137 store.remove(key);
138 }
139 }
140 return None;
141 }
142 }
143
144 let result = entry.clone();
145
146 drop(store);
148 let mut store = self.store.write().await;
149 if let Some(entry) = store.get_mut(key) {
150 entry.cached_at = Instant::now();
151 }
152
153 Some(result)
154 }
155
156 pub async fn put(&self, key: String, result: LLMResult) {
158 if !self.config.enabled {
159 return;
160 }
161
162 let mut store = self.store.write().await;
163
164 if self.config.max_entries > 0 && store.len() >= self.config.max_entries {
166 if let Some(oldest_key) = store
168 .iter()
169 .min_by_key(|(_, v)| v.cached_at)
170 .map(|(k, _)| k.clone())
171 {
172 store.remove(&oldest_key);
173 }
174 }
175
176 store.insert(
177 key,
178 CachedLLMResult {
179 result,
180 cached_at: Instant::now(),
181 },
182 );
183 }
184
185 pub async fn clear(&self) {
187 let mut store = self.store.write().await;
188 store.clear();
189 }
190
191 pub async fn len(&self) -> usize {
193 let store = self.store.read().await;
194 store.len()
195 }
196
197 pub async fn is_empty(&self) -> bool {
199 self.len().await == 0
200 }
201
202 pub async fn evict_expired(&self) -> usize {
204 if let Some(ttl) = self.config.ttl {
205 let mut store = self.store.write().await;
206 let before = store.len();
207 store.retain(|_, v| v.cached_at.elapsed() <= ttl);
208 before - store.len()
209 } else {
210 0
211 }
212 }
213}
214
215impl Default for LLMCache {
216 fn default() -> Self {
217 Self::new()
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::language_models::TokenUsage;
225
226 fn make_result(content: &str) -> LLMResult {
227 LLMResult {
228 content: content.to_string(),
229 model: "test-model".to_string(),
230 token_usage: Some(TokenUsage {
231 prompt_tokens: 10,
232 completion_tokens: 5,
233 total_tokens: 15,
234 }),
235 tool_calls: None,
236 thinking_content: None,
237 }
238 }
239
240 #[tokio::test]
241 async fn test_cache_put_and_get() {
242 let cache = LLMCache::new();
243 let key = "test-key";
244 let result = make_result("Hello, world!");
245
246 cache.put(key.to_string(), result.clone()).await;
247 let cached = cache.get(key).await;
248
249 assert!(cached.is_some());
250 assert_eq!(cached.unwrap().result.content, "Hello, world!");
251 }
252
253 #[tokio::test]
254 async fn test_cache_miss() {
255 let cache = LLMCache::new();
256 let cached = cache.get("non-existent").await;
257 assert!(cached.is_none());
258 }
259
260 #[tokio::test]
261 async fn test_cache_clear() {
262 let cache = LLMCache::new();
263 cache.put("k1".to_string(), make_result("r1")).await;
264 cache.put("k2".to_string(), make_result("r2")).await;
265 assert_eq!(cache.len().await, 2);
266
267 cache.clear().await;
268 assert_eq!(cache.len().await, 0);
269 }
270
271 #[tokio::test]
272 async fn test_cache_disabled() {
273 let config = CacheConfig::new().disabled();
274 let cache = LLMCache::with_config(config);
275
276 cache.put("key".to_string(), make_result("test")).await;
277 let cached = cache.get("key").await;
278 assert!(cached.is_none());
279 }
280
281 #[tokio::test]
282 async fn test_cache_ttl_expiry() {
283 let config = CacheConfig::new().with_ttl(Duration::from_millis(10));
284 let cache = LLMCache::with_config(config);
285
286 cache.put("key".to_string(), make_result("test")).await;
287 assert!(cache.get("key").await.is_some());
288
289 tokio::time::sleep(Duration::from_millis(20)).await;
291 assert!(cache.get("key").await.is_none());
292 }
293
294 #[tokio::test]
295 async fn test_cache_max_entries() {
296 let config = CacheConfig::new().with_max_entries(3).no_ttl();
297 let cache = LLMCache::with_config(config);
298
299 cache.put("a".to_string(), make_result("1")).await;
300 cache.put("b".to_string(), make_result("2")).await;
301 cache.put("c".to_string(), make_result("3")).await;
302 assert_eq!(cache.len().await, 3);
303
304 cache.put("d".to_string(), make_result("4")).await;
306 assert_eq!(cache.len().await, 3);
307 assert!(cache.get("a").await.is_none());
309 }
310
311 #[tokio::test]
312 async fn test_cache_get_refreshes_lru_order() {
313 let config = CacheConfig::new().with_max_entries(2).no_ttl();
316 let cache = LLMCache::with_config(config);
317
318 cache.put("a".to_string(), make_result("1")).await;
319 cache.put("b".to_string(), make_result("2")).await;
320
321 assert!(cache.get("a").await.is_some());
323
324 cache.put("c".to_string(), make_result("3")).await;
326 assert!(cache.get("a").await.is_some());
327 assert!(cache.get("b").await.is_none());
328 assert!(cache.get("c").await.is_some());
329 }
330
331 #[tokio::test]
332 async fn test_cache_no_ttl() {
333 let config = CacheConfig::new().no_ttl();
334 let cache = LLMCache::with_config(config);
335
336 cache.put("key".to_string(), make_result("persist")).await;
337
338 tokio::time::sleep(Duration::from_millis(10)).await;
340 assert!(cache.get("key").await.is_some());
341 }
342
343 #[tokio::test]
344 async fn test_cache_evict_expired() {
345 let config = CacheConfig::new().with_ttl(Duration::from_millis(0));
347 let cache = LLMCache::with_config(config);
348
349 cache.put("key".to_string(), make_result("test")).await;
350 tokio::time::sleep(Duration::from_millis(1)).await;
351
352 let evicted = cache.evict_expired().await;
353 assert_eq!(evicted, 1);
354 assert!(cache.is_empty().await);
355 }
356
357 #[tokio::test]
358 async fn test_cache_build_key() {
359 let messages = vec![Message::human("Hello"), Message::ai("Hi!")];
360 let key = LLMCache::build_key(&messages, "gpt-4").unwrap();
361 assert!(key.contains("gpt-4"));
362 assert!(key.contains("Hello"));
363 }
364
365 #[tokio::test]
366 async fn test_cache_is_empty() {
367 let cache = LLMCache::new();
368 assert!(cache.is_empty().await);
369
370 cache.put("key".to_string(), make_result("test")).await;
371 assert!(!cache.is_empty().await);
372 }
373}