1use std::collections::hash_map::DefaultHasher;
22use std::hash::{Hash, Hasher};
23use std::sync::Arc;
24use std::time::{Duration, SystemTime, UNIX_EPOCH};
25
26use async_trait::async_trait;
27use serde::{Deserialize, Serialize};
28
29use crate::error::Result;
30use crate::llm::client::LLMClient;
31use crate::llm::types::{LLMRequest, LLMResponse, LLMStream};
32use crate::store::KvStore;
33
34const KEY_VERSION: u8 = 2;
37const KEY_PREFIX: &str = "llm-resp";
39
40#[derive(Serialize, Deserialize)]
42struct CachedResponse {
43 stored_at_secs: u64,
45 response: LLMResponse,
47}
48
49fn now_secs() -> u64 {
50 SystemTime::now()
51 .duration_since(UNIX_EPOCH)
52 .unwrap_or_default()
53 .as_secs()
54}
55
56fn cache_key(model_name: &str, request: &LLMRequest) -> String {
68 let mut hasher = DefaultHasher::new();
69 model_name.hash(&mut hasher);
70 serde_json::to_vec(request)
71 .unwrap_or_default()
72 .hash(&mut hasher);
73 format!("{KEY_PREFIX}:v{KEY_VERSION}:{:016x}", hasher.finish())
74}
75
76pub struct CacheClient<C> {
86 inner: C,
87 store: Arc<dyn KvStore>,
88 ttl: Option<Duration>,
89}
90
91impl<C> CacheClient<C> {
92 pub fn new(inner: C, store: Arc<dyn KvStore>) -> Self {
94 Self {
95 inner,
96 store,
97 ttl: None,
98 }
99 }
100
101 pub fn with_ttl(inner: C, store: Arc<dyn KvStore>, ttl: Duration) -> Self {
107 Self {
108 inner,
109 store,
110 ttl: Some(ttl),
111 }
112 }
113
114 pub fn inner(&self) -> &C {
116 &self.inner
117 }
118}
119
120fn lookup(store: &dyn KvStore, ttl: Option<Duration>, key: &str) -> Option<LLMResponse> {
126 let bytes = store.get(key).ok()??;
127 let entry: CachedResponse = serde_json::from_slice(&bytes).ok()?;
128 if let Some(ttl) = ttl {
129 let age = now_secs().saturating_sub(entry.stored_at_secs);
130 if age > ttl.as_secs() {
131 return None;
132 }
133 }
134 Some(entry.response)
135}
136
137fn store_entry(store: &dyn KvStore, key: &str, response: &LLMResponse) {
139 let entry = CachedResponse {
140 stored_at_secs: now_secs(),
141 response: response.clone(),
142 };
143 if let Ok(bytes) = serde_json::to_vec(&entry) {
144 let _ = store.put(key, &bytes);
145 }
146}
147
148#[async_trait]
149impl<C: LLMClient> LLMClient for CacheClient<C> {
150 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
151 let key = cache_key(self.inner.model_name(), &request);
152
153 {
158 let store = self.store.clone();
159 let ttl = self.ttl;
160 let key = key.clone();
161 let hit = tokio::task::spawn_blocking(move || lookup(store.as_ref(), ttl, &key))
162 .await
163 .unwrap_or(None);
164 if let Some(response) = hit {
165 return Ok(response);
166 }
167 }
168
169 let response = self.inner.complete(request).await?;
170
171 {
172 let store = self.store.clone();
173 let key = key.clone();
174 let to_store = response.clone();
175 let _ =
178 tokio::task::spawn_blocking(move || store_entry(store.as_ref(), &key, &to_store))
179 .await;
180 }
181
182 Ok(response)
183 }
184
185 fn model_name(&self) -> &str {
186 self.inner.model_name()
187 }
188
189 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
190 self.inner.stream_complete(request).await
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use crate::error::KernelError;
198 use std::sync::Arc;
199 use std::sync::atomic::{AtomicUsize, Ordering};
200
201 struct CountingClient {
203 calls: Arc<AtomicUsize>,
204 body: String,
205 model: &'static str,
206 }
207
208 #[async_trait]
209 impl LLMClient for CountingClient {
210 async fn complete(&self, _request: LLMRequest) -> Result<LLMResponse> {
211 self.calls.fetch_add(1, Ordering::SeqCst);
212 Ok(LLMResponse {
213 content: self.body.clone(),
214 model: self.model.to_string(),
215 ..Default::default()
216 })
217 }
218 fn model_name(&self) -> &str {
219 self.model
220 }
221 async fn stream_complete(&self, _request: LLMRequest) -> Result<LLMStream> {
222 Err(KernelError::LlmApi("not implemented".into()))
223 }
224 }
225
226 fn make_request(text: &str) -> LLMRequest {
227 LLMRequest::builder().user_message(text).build()
228 }
229
230 #[tokio::test]
231 async fn identical_request_served_from_cache_after_first_call() {
232 let calls = Arc::new(AtomicUsize::new(0));
233 let inner = CountingClient {
234 calls: calls.clone(),
235 body: "hello".into(),
236 model: "mock",
237 };
238 let store: Arc<dyn KvStore> =
239 Arc::new(crate::store::SqliteKvStore::open_in_memory().unwrap());
240 let client = CacheClient::new(inner, store);
241
242 let r1 = client.complete(make_request("ping")).await.unwrap();
243 let r2 = client.complete(make_request("ping")).await.unwrap();
244
245 assert_eq!(r1.content, "hello");
246 assert_eq!(r2.content, "hello");
247 assert_eq!(calls.load(Ordering::SeqCst), 1);
248 }
249
250 #[tokio::test]
251 async fn differing_request_misses_cache() {
252 let calls = Arc::new(AtomicUsize::new(0));
253 let inner = CountingClient {
254 calls: calls.clone(),
255 body: "x".into(),
256 model: "mock",
257 };
258 let store: Arc<dyn KvStore> =
259 Arc::new(crate::store::SqliteKvStore::open_in_memory().unwrap());
260 let client = CacheClient::new(inner, store);
261
262 let _ = client.complete(make_request("alpha")).await.unwrap();
263 let _ = client.complete(make_request("beta")).await.unwrap();
264
265 assert_eq!(calls.load(Ordering::SeqCst), 2);
266 }
267
268 #[tokio::test]
271 async fn distinct_clients_do_not_share_entries() {
272 let store: Arc<dyn KvStore> =
273 Arc::new(crate::store::SqliteKvStore::open_in_memory().unwrap());
274 let calls_a = Arc::new(AtomicUsize::new(0));
275 let calls_b = Arc::new(AtomicUsize::new(0));
276 let a = CacheClient::new(
277 CountingClient {
278 calls: calls_a.clone(),
279 body: "from-a".into(),
280 model: "openai",
281 },
282 store.clone(),
283 );
284 let b = CacheClient::new(
285 CountingClient {
286 calls: calls_b.clone(),
287 body: "from-b".into(),
288 model: "anthropic",
289 },
290 store,
291 );
292
293 let ra = a.complete(make_request("same")).await.unwrap();
294 let rb = b.complete(make_request("same")).await.unwrap();
295
296 assert_eq!(ra.content, "from-a");
298 assert_eq!(rb.content, "from-b");
299 assert_eq!(calls_a.load(Ordering::SeqCst), 1);
300 assert_eq!(calls_b.load(Ordering::SeqCst), 1);
301 }
302
303 #[tokio::test]
305 async fn expired_entry_is_a_miss() {
306 let store: Arc<dyn KvStore> =
307 Arc::new(crate::store::SqliteKvStore::open_in_memory().unwrap());
308
309 let inner = CountingClient {
311 calls: Arc::new(AtomicUsize::new(0)),
312 body: "fresh".into(),
313 model: "mock",
314 };
315 let stale_key = cache_key("mock", &make_request("hi"));
316 let stale = CachedResponse {
317 stored_at_secs: 0, response: LLMResponse {
319 content: "stale".into(),
320 model: "mock".into(),
321 ..Default::default()
322 },
323 };
324 store
325 .put(&stale_key, &serde_json::to_vec(&stale).unwrap())
326 .unwrap();
327
328 let calls = inner.calls.clone();
329 let client = CacheClient::with_ttl(inner, store, Duration::from_secs(60));
330 let r = client.complete(make_request("hi")).await.unwrap();
331
332 assert_eq!(r.content, "fresh");
334 assert_eq!(calls.load(Ordering::SeqCst), 1);
335 }
336
337 #[tokio::test]
338 async fn cache_key_reflects_temperature() {
339 let calls = Arc::new(AtomicUsize::new(0));
340 let inner = CountingClient {
341 calls: calls.clone(),
342 body: "x".into(),
343 model: "mock",
344 };
345 let store: Arc<dyn KvStore> =
346 Arc::new(crate::store::SqliteKvStore::open_in_memory().unwrap());
347 let client = CacheClient::new(inner, store);
348
349 let _ = client
350 .complete(
351 LLMRequest::builder()
352 .user_message("hi")
353 .temperature(0.0)
354 .build(),
355 )
356 .await
357 .unwrap();
358 let _ = client
359 .complete(
360 LLMRequest::builder()
361 .user_message("hi")
362 .temperature(0.7)
363 .build(),
364 )
365 .await
366 .unwrap();
367
368 assert_eq!(calls.load(Ordering::SeqCst), 2);
369 }
370
371 #[tokio::test]
372 async fn model_name_delegates_to_inner() {
373 let inner = CountingClient {
374 calls: Arc::new(AtomicUsize::new(0)),
375 body: "x".into(),
376 model: "mock",
377 };
378 let store: Arc<dyn KvStore> =
379 Arc::new(crate::store::SqliteKvStore::open_in_memory().unwrap());
380 let client = CacheClient::new(inner, store);
381 assert_eq!(client.model_name(), "mock");
382 }
383}