Skip to main content

llm_kernel/llm/
cache.rs

1//! Response cache wrapper for [`LLMClient`] backed by a [`KvStore`].
2//!
3//! [`CacheClient`] is a dedicated [`LLMClient`] wrapper — not an
4//! observation-only middleware — because serving a cached response must
5//! *short-circuit* the upstream call, which the [`LLMClientMiddleware`]
6//! hooks (immutable, observe-only) deliberately cannot do. It composes like
7//! the other wrappers: `CacheClient<RetryClient<OpenAIClient>>`.
8//!
9//! Only [`LLMClient::complete`] is cached; [`LLMClient::stream_complete`] is
10//! always pass-through, since a streamed response cannot be replayed from a
11//! stored buffer without changing its semantics.
12//!
13//! The cache key incorporates the **client identity** (`model_name`) so that
14//! two different providers sharing one [`KvStore`] never cross-contaminate
15//! an identical request. An optional TTL ([`CacheClient::with_ttl`]) expires
16//! stale entries; without it entries live until removed.
17//!
18//! [`LLMClientMiddleware`]: crate::llm::LLMClientMiddleware
19//! [`KvStore`]: crate::store::KvStore
20
21use 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
34/// Cache-key schema version. Bump to invalidate every cached entry at once
35/// when the key derivation or stored format changes.
36const KEY_VERSION: u8 = 2;
37/// Namespace prefix so a shared [`KvStore`] can host several caches.
38const KEY_PREFIX: &str = "llm-resp";
39
40/// A cached response paired with the wall-clock second it was stored.
41#[derive(Serialize, Deserialize)]
42struct CachedResponse {
43    /// Unix-epoch seconds at which the entry was stored.
44    stored_at_secs: u64,
45    /// The cached response.
46    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
56/// Derive a stable cache key for a request under a given client identity.
57///
58/// The payload is the client's `model_name` (so two providers sharing a store
59/// can't collide on an identical request) followed by the canonical JSON of
60/// the whole [`LLMRequest`] (every field influences the response: messages,
61/// temperature, max_tokens, response_format, tools). `serde_json` emits struct
62/// fields in a fixed order, so identical requests hash identically.
63///
64/// The hash is best-effort: a Rust-version drift in [`DefaultHasher`] or a hash
65/// collision can only ever cause a *cache miss* (recomputed response), never a
66/// wrong hit.
67fn 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
76/// An [`LLMClient`] wrapper that caches `complete` responses in a [`KvStore`].
77///
78/// On `complete`, the cache is checked first; a fresh (non-expired) hit returns
79/// the stored response without calling the inner client. On a miss the inner
80/// client is called and a successful response is stored. Cache read/write
81/// errors are non-fatal — a failed read falls through to the inner client, a
82/// failed write is dropped, and the response is still returned.
83///
84/// `stream_complete` is always delegated to the inner client (never cached).
85pub struct CacheClient<C> {
86    inner: C,
87    store: Arc<dyn KvStore>,
88    ttl: Option<Duration>,
89}
90
91impl<C> CacheClient<C> {
92    /// Wrap `inner` with a response cache backed by `store` (no expiry).
93    pub fn new(inner: C, store: Arc<dyn KvStore>) -> Self {
94        Self {
95            inner,
96            store,
97            ttl: None,
98        }
99    }
100
101    /// Wrap `inner` with a response cache whose entries expire after `ttl`.
102    ///
103    /// Expired entries are lazily evicted on read (treated as a miss). A TTL
104    /// bounds staleness; to bound total size, wrap the [`KvStore`] with an
105    /// evicting implementation — `CacheClient` does not enforce a size cap.
106    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    /// Access the underlying (uncached) client.
115    pub fn inner(&self) -> &C {
116        &self.inner
117    }
118}
119
120/// Look up a non-expired cached response for `key`, if any.
121///
122/// Free function (rather than a method) so it can run inside
123/// [`tokio::task::spawn_blocking`] with only an owned `Arc<dyn KvStore>`
124/// captured — see [`CacheClient::complete`].
125fn 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
137/// Store a response under `key` (best-effort; failures are dropped).
138fn 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        // The `KvStore` API is synchronous. `CacheClient` wraps an arbitrary
154        // `Arc<dyn KvStore>` on the hot path of every completion, so offload the
155        // read/write to the blocking pool: a slow (or remote) store, or a
156        // single-threaded runtime, must not stall the async reactor.
157        {
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            // Best-effort write; join errors (task panic) are ignored, matching
176            // the store's own dropped-error semantics.
177            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    /// A mock client that counts upstream calls and returns a fixed response.
202    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    /// P1: two clients with different `model_name` sharing one store must not
269    /// cross-contaminate an identical request.
270    #[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        // Different clients → different keys → both upstream, no contamination.
297        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    /// P2b: an entry older than the TTL is treated as a miss.
304    #[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        // Seed a stale entry under the exact key the client will compute.
310        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, // ancient → definitely older than any TTL
318            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        // Stale entry ignored → upstream called → fresh response.
333        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}