longcache 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,872 @@
1
+ """
2
+ LongCache — Main orchestrator class.
3
+
4
+ Provides the primary API for semantic similarity caching:
5
+ - get/set for manual cache control
6
+ - @cached decorator for transparent caching
7
+ - invalidate/clear for cache management
8
+ - stats for monitoring hit rates and estimated savings
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ import asyncio
15
+ import inspect
16
+ import logging
17
+ import hashlib
18
+ import json
19
+ import threading as _threading
20
+ from typing import Any, Callable, Optional, AsyncGenerator, Generator
21
+
22
+ _log = logging.getLogger(__name__)
23
+
24
+ from longcache.config import CacheConfig
25
+ from longcache.core.embedder import LocalEmbedder
26
+ from longcache.core.index import SimilarityIndex
27
+ from longcache.core.entry import CacheEntry
28
+ from longcache.backends.sqlite import SQLiteCacheBackend
29
+ from longcache.backends.memory import InMemoryCacheBackend
30
+
31
+
32
+ class LongCache:
33
+ """
34
+ Semantic similarity cache for LLM/RAG pipelines.
35
+
36
+ Intercepts queries before they reach expensive LLM calls,
37
+ embeds the query locally (sub-5ms with MiniLM), and checks
38
+ cosine similarity against a rolling FAISS/SQLite cache.
39
+
40
+ On HIT (similarity > threshold): returns cached response instantly.
41
+ On MISS: runs the full chain, caches the result, returns to user.
42
+
43
+ Usage:
44
+ # Simple decorator usage
45
+ cache = LongCache()
46
+
47
+ @cache.cached
48
+ def my_chain(query: str) -> str:
49
+ return expensive_llm_call(query)
50
+
51
+ # Manual get/set usage
52
+ cached = cache.get("What is the refund policy?")
53
+ if cached is None:
54
+ result = expensive_llm_call("What is the refund policy?")
55
+ cache.set("What is the refund policy?", result)
56
+ """
57
+
58
+ def __init__(self, config: Optional[CacheConfig] = None):
59
+ """
60
+ Initialize LongCache with optional configuration.
61
+
62
+ Args:
63
+ config: CacheConfig instance. Uses sensible defaults if not provided.
64
+ """
65
+ self.config = config or CacheConfig()
66
+ self.embedder = LocalEmbedder(self.config.embedder_model)
67
+ self.backend = self._init_backend()
68
+ self.index = SimilarityIndex(self.backend, self.config)
69
+ self._stats = {
70
+ "hits": 0,
71
+ "misses": 0,
72
+ "total_saved_ms": 0.0,
73
+ }
74
+ self._stats_lock = _threading.Lock()
75
+
76
+ # Load persisted stats from backend if available
77
+ if hasattr(self.backend, "load_stats"):
78
+ persisted = self.backend.load_stats()
79
+ with self._stats_lock:
80
+ self._stats.update(persisted)
81
+
82
+ # Rebuild FAISS index from existing backend entries
83
+ self._rebuild_index_from_backend()
84
+
85
+ self._sweep_interval = 300 # seconds between TTL sweeps (5 minutes)
86
+ self._start_sweep_thread()
87
+
88
+ def __del__(self):
89
+ """Signal the sweeper thread to stop on garbage collection."""
90
+ if hasattr(self, "_stop_event"):
91
+ self._stop_event.set()
92
+
93
+ def _start_sweep_thread(self) -> None:
94
+ """Start background thread that periodically deletes expired entries."""
95
+ if self.config.ttl_seconds == 0:
96
+ return # No TTL configured, no need to sweep
97
+
98
+ def sweeper():
99
+ while not self._stop_event.is_set():
100
+ self._stop_event.wait(timeout=self._sweep_interval)
101
+ if self._stop_event.is_set():
102
+ break
103
+ try:
104
+ if hasattr(self.backend, "sweep_expired"):
105
+ deleted = self.backend.sweep_expired()
106
+ if deleted > 0:
107
+ _log.debug("TTL sweep: deleted %d expired entries", deleted)
108
+ self._rebuild_index_from_backend()
109
+ except Exception as e:
110
+ _log.warning("TTL sweep error: %s", e)
111
+
112
+ self._stop_event = _threading.Event()
113
+ t = _threading.Thread(target=sweeper, name="longcache-sweeper", daemon=True)
114
+ t.start()
115
+
116
+ def _init_backend(self):
117
+ """
118
+ Initialize the cache storage backend based on configuration.
119
+
120
+ Returns:
121
+ An instance of the appropriate AbstractCacheBackend subclass.
122
+ """
123
+ if self.config.backend == "sqlite":
124
+ return SQLiteCacheBackend(
125
+ db_path=self.config.db_path,
126
+ max_entries=self.config.max_entries,
127
+ )
128
+ elif self.config.backend == "redis":
129
+ try:
130
+ from longcache.backends.redis import RedisCacheBackend
131
+ except ImportError:
132
+ raise ImportError(
133
+ "Redis backend requires the 'redis' extra. "
134
+ "Install with: uv pip install longcache[redis]"
135
+ )
136
+ return RedisCacheBackend(
137
+ redis_url=self.config.redis_url,
138
+ max_entries=self.config.max_entries,
139
+ )
140
+ elif self.config.backend == "async_redis":
141
+ try:
142
+ from longcache.backends.async_redis import AsyncRedisCacheBackend
143
+ except ImportError:
144
+ raise ImportError(
145
+ "Async Redis backend requires the 'redis' extra. "
146
+ "Install with: uv pip install longcache[redis]"
147
+ )
148
+ return AsyncRedisCacheBackend(
149
+ redis_url=self.config.redis_url,
150
+ max_entries=self.config.max_entries,
151
+ )
152
+ elif self.config.backend == "memory":
153
+ return InMemoryCacheBackend(
154
+ max_entries=self.config.max_entries,
155
+ )
156
+ else:
157
+ raise ValueError(f"Unknown backend: {self.config.backend}")
158
+
159
+ def _rebuild_index_from_backend(self) -> None:
160
+ """
161
+ Rebuild the FAISS index from entries already stored in the backend.
162
+
163
+ Called on startup, after sweeping expired entries, or after evictions
164
+ so that cached entries are cleanly re-indexed without duplicate vector accumulation.
165
+ """
166
+ try:
167
+ entries = self.backend.list_entries(self.config.namespace)
168
+ self.index.rebuild(entries or [])
169
+ except Exception as e:
170
+ _log.warning("Index rebuild failed: %s — starting with empty index.", e)
171
+
172
+ def _build_context_hash(
173
+ self,
174
+ model: Optional[str] = None,
175
+ system_prompt: Optional[str] = None,
176
+ temperature: Optional[float] = None,
177
+ ) -> str:
178
+ """
179
+ Build a deterministic hash from model, system_prompt, and temperature.
180
+ Returns empty string if all context parameters are default/empty.
181
+ """
182
+ m = model if model is not None else self.config.model
183
+ sp = system_prompt if system_prompt is not None else self.config.system_prompt
184
+ t = temperature if temperature is not None else self.config.temperature
185
+
186
+ if not m and not sp and t < 0.0:
187
+ return ""
188
+
189
+ context: dict[str, Any] = {
190
+ "model": m,
191
+ "system_prompt": sp,
192
+ }
193
+ if t >= 0.0:
194
+ context["temperature"] = t
195
+
196
+ serialized = json.dumps(context, sort_keys=True)
197
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()[:12]
198
+
199
+ def _effective_namespace(
200
+ self,
201
+ namespace: Optional[str] = None,
202
+ model: Optional[str] = None,
203
+ system_prompt: Optional[str] = None,
204
+ temperature: Optional[float] = None,
205
+ ) -> str:
206
+ """
207
+ Returns the namespace scoped to tenant and execution context (model/prompt/temp).
208
+ """
209
+ base = namespace or self.config.namespace
210
+ ctx = self._build_context_hash(model, system_prompt, temperature)
211
+ if not ctx:
212
+ return base
213
+ return f"{base}:{ctx}"
214
+
215
+ def get(
216
+ self,
217
+ query: str,
218
+ namespace: Optional[str] = None,
219
+ model: Optional[str] = None,
220
+ system_prompt: Optional[str] = None,
221
+ temperature: Optional[float] = None,
222
+ track_miss: bool = True,
223
+ ) -> Optional[Any]:
224
+ """
225
+ Check the cache for a semantically similar query.
226
+
227
+ Embeds the query locally, searches the FAISS index, and returns
228
+ the cached response if a sufficiently similar entry exists.
229
+
230
+ Args:
231
+ query: The input query string.
232
+ namespace: Override the default namespace for this lookup.
233
+ model: Optional model identifier override.
234
+ system_prompt: Optional system prompt override.
235
+ temperature: Optional temperature override.
236
+ track_miss: Whether to record a miss if no match is found.
237
+
238
+ Returns:
239
+ The cached response if a HIT is found, None on MISS.
240
+ """
241
+ ns = self._effective_namespace(namespace, model, system_prompt, temperature)
242
+ vector = self.embedder.embed(query)
243
+
244
+ entry = self.index.find_similar(
245
+ vector,
246
+ threshold=self.config.similarity_threshold,
247
+ namespace=ns,
248
+ )
249
+
250
+ if entry is not None and not entry.is_expired():
251
+ if self.config.track_stats:
252
+ with self._stats_lock:
253
+ self._stats["hits"] += 1
254
+ if hasattr(self.backend, "increment_hits"):
255
+ self.backend.increment_hits()
256
+ _log.debug("Cache HIT ns=%s query=%r", ns, query[:60])
257
+ return entry.response
258
+
259
+ if track_miss and self.config.track_stats:
260
+ with self._stats_lock:
261
+ self._stats["misses"] += 1
262
+ if hasattr(self.backend, "increment_misses"):
263
+ self.backend.increment_misses(0.0)
264
+
265
+ _log.debug("Cache MISS ns=%s query=%r", ns, query[:60])
266
+ return None
267
+
268
+ async def aget(
269
+ self,
270
+ query: str,
271
+ namespace: Optional[str] = None,
272
+ model: Optional[str] = None,
273
+ system_prompt: Optional[str] = None,
274
+ temperature: Optional[float] = None,
275
+ track_miss: bool = True,
276
+ ) -> Optional[Any]:
277
+ """Async version of get(). Use in async contexts to avoid blocking."""
278
+ loop = asyncio.get_event_loop()
279
+ ns = self._effective_namespace(namespace, model, system_prompt, temperature)
280
+ vector = await loop.run_in_executor(None, self.embedder.embed, query)
281
+ entry = self.index.find_similar(
282
+ vector, threshold=self.config.similarity_threshold, namespace=ns
283
+ )
284
+ if entry is not None and not entry.is_expired():
285
+ if self.config.track_stats:
286
+ with self._stats_lock:
287
+ self._stats["hits"] += 1
288
+ if hasattr(self.backend, "increment_hits"):
289
+ if asyncio.iscoroutinefunction(getattr(self.backend, "increment_hits")):
290
+ await self.backend.increment_hits()
291
+ else:
292
+ self.backend.increment_hits()
293
+ _log.debug("Cache HIT ns=%s query=%r", ns, query[:60])
294
+ return entry.response
295
+
296
+ if track_miss and self.config.track_stats:
297
+ with self._stats_lock:
298
+ self._stats["misses"] += 1
299
+ if hasattr(self.backend, "increment_misses"):
300
+ if asyncio.iscoroutinefunction(getattr(self.backend, "increment_misses")):
301
+ await self.backend.increment_misses(0.0)
302
+ else:
303
+ self.backend.increment_misses(0.0)
304
+
305
+ _log.debug("Cache MISS ns=%s query=%r", ns, query[:60])
306
+ return None
307
+
308
+ def set(
309
+ self,
310
+ query: str,
311
+ response: Any,
312
+ namespace: Optional[str] = None,
313
+ ttl: Optional[int] = None,
314
+ tags: Optional[list[str]] = None,
315
+ model: Optional[str] = None,
316
+ system_prompt: Optional[str] = None,
317
+ temperature: Optional[float] = None,
318
+ ) -> None:
319
+ """
320
+ Store a query-response pair in the cache.
321
+
322
+ Embeds the query locally, creates a CacheEntry, stores it in
323
+ the backend, and adds the vector to the FAISS index.
324
+
325
+ Args:
326
+ query: The query string to cache.
327
+ response: The LLM response to cache. Must be serializable by the backend.
328
+ namespace: Override the default namespace for this entry.
329
+ ttl: Override the default TTL (in seconds) for this entry.
330
+ tags: Optional list of tags to associate with this entry.
331
+ model: Optional model identifier override.
332
+ system_prompt: Optional system prompt override.
333
+ temperature: Optional temperature override.
334
+ """
335
+ ns = self._effective_namespace(namespace, model, system_prompt, temperature)
336
+ effective_ttl = ttl if ttl is not None else self.config.ttl_seconds
337
+
338
+ vector = self.embedder.embed(query)
339
+
340
+ now = time.time()
341
+ expires_at = (now + effective_ttl) if effective_ttl > 0 else None
342
+
343
+ entry = CacheEntry(
344
+ namespace=ns,
345
+ query=query,
346
+ vector=vector,
347
+ response=response,
348
+ created_at=now,
349
+ expires_at=expires_at,
350
+ hit_count=0,
351
+ model=model if model is not None else self.config.model,
352
+ system_prompt=system_prompt if system_prompt is not None else self.config.system_prompt,
353
+ temperature=temperature if temperature is not None else self.config.temperature,
354
+ tags=list(tags or []),
355
+ )
356
+
357
+ # Store in backend (assigns entry.id)
358
+ entry_id = self.backend.store(entry)
359
+ entry.id = entry_id
360
+
361
+ # Add to FAISS index
362
+ self.index.add(vector, entry_id)
363
+
364
+ # Enforce max_entries eviction
365
+ self._evict_if_needed(ns)
366
+
367
+ _log.debug("Cache SET ns=%s ttl=%s query=%r", ns, effective_ttl, query[:60])
368
+
369
+ async def aset(
370
+ self,
371
+ query: str,
372
+ response: Any,
373
+ namespace: Optional[str] = None,
374
+ ttl: Optional[int] = None,
375
+ tags: Optional[list[str]] = None,
376
+ model: Optional[str] = None,
377
+ system_prompt: Optional[str] = None,
378
+ temperature: Optional[float] = None,
379
+ ) -> None:
380
+ """Async version of set(). Use in async contexts to avoid blocking."""
381
+ loop = asyncio.get_event_loop()
382
+ ns = self._effective_namespace(namespace, model, system_prompt, temperature)
383
+ effective_ttl = ttl if ttl is not None else self.config.ttl_seconds
384
+ vector = await loop.run_in_executor(None, self.embedder.embed, query)
385
+ now = time.time()
386
+ expires_at = (now + effective_ttl) if effective_ttl > 0 else None
387
+ entry = CacheEntry(
388
+ namespace=ns,
389
+ query=query,
390
+ vector=vector,
391
+ response=response,
392
+ created_at=now,
393
+ expires_at=expires_at,
394
+ hit_count=0,
395
+ model=model if model is not None else self.config.model,
396
+ system_prompt=system_prompt if system_prompt is not None else self.config.system_prompt,
397
+ temperature=temperature if temperature is not None else self.config.temperature,
398
+ tags=list(tags or []),
399
+ )
400
+ # Handle async_redis case where store is a coroutine
401
+ if asyncio.iscoroutinefunction(self.backend.store):
402
+ entry_id = await self.backend.store(entry)
403
+ else:
404
+ entry_id = self.backend.store(entry)
405
+
406
+ entry.id = entry_id
407
+ await self.index.async_add(vector, entry_id)
408
+ self._evict_if_needed(ns)
409
+
410
+ _log.debug("Cache ASET ns=%s ttl=%s query=%r", ns, effective_ttl, query[:60])
411
+
412
+ def cached(
413
+ self,
414
+ func: Optional[Callable] = None,
415
+ *,
416
+ namespace: Optional[str] = None,
417
+ ttl: Optional[int] = None,
418
+ tags: Optional[list[str]] = None,
419
+ model: Optional[str] = None,
420
+ system_prompt: Optional[str] = None,
421
+ temperature: Optional[float] = None,
422
+ ) -> Callable:
423
+ """
424
+ Decorator for caching any function that takes a query string as first argument.
425
+
426
+ Can be used with or without arguments:
427
+ @cache.cached
428
+ def my_chain(query): ...
429
+
430
+ @cache.cached(namespace="faq", ttl=7200, model="gpt-4o", tags=["billing"])
431
+ def my_chain(query): ...
432
+ """
433
+
434
+ def decorator(fn: Callable) -> Callable:
435
+ if asyncio.iscoroutinefunction(fn):
436
+
437
+ async def async_wrapper(query: str, *args, **kwargs):
438
+ cached_result = await self.aget(
439
+ query,
440
+ namespace=namespace,
441
+ model=model,
442
+ system_prompt=system_prompt,
443
+ temperature=temperature,
444
+ track_miss=False,
445
+ )
446
+ if cached_result is not None:
447
+ return cached_result
448
+
449
+ t0 = time.time()
450
+ result = await fn(query, *args, **kwargs)
451
+ elapsed_ms = (time.time() - t0) * 1000
452
+
453
+ await self.aset(
454
+ query,
455
+ result,
456
+ namespace=namespace,
457
+ ttl=ttl,
458
+ tags=tags,
459
+ model=model,
460
+ system_prompt=system_prompt,
461
+ temperature=temperature,
462
+ )
463
+
464
+ if self.config.track_stats:
465
+ with self._stats_lock:
466
+ self._stats["misses"] += 1
467
+ self._stats["total_saved_ms"] += elapsed_ms
468
+ if hasattr(self.backend, "increment_misses"):
469
+ if asyncio.iscoroutinefunction(
470
+ getattr(self.backend, "increment_misses")
471
+ ):
472
+ await self.backend.increment_misses(elapsed_ms)
473
+ else:
474
+ self.backend.increment_misses(elapsed_ms)
475
+ return result
476
+
477
+ async_wrapper.__name__ = fn.__name__
478
+ async_wrapper.__doc__ = fn.__doc__
479
+ async_wrapper.__wrapped__ = fn
480
+ return async_wrapper
481
+ else:
482
+
483
+ def wrapper(query: str, *args, **kwargs):
484
+ cached_result = self.get(
485
+ query,
486
+ namespace=namespace,
487
+ model=model,
488
+ system_prompt=system_prompt,
489
+ temperature=temperature,
490
+ track_miss=False,
491
+ )
492
+ if cached_result is not None:
493
+ return cached_result
494
+
495
+ t0 = time.time()
496
+ result = fn(query, *args, **kwargs)
497
+ elapsed_ms = (time.time() - t0) * 1000
498
+
499
+ self.set(
500
+ query,
501
+ result,
502
+ namespace=namespace,
503
+ ttl=ttl,
504
+ tags=tags,
505
+ model=model,
506
+ system_prompt=system_prompt,
507
+ temperature=temperature,
508
+ )
509
+
510
+ if self.config.track_stats:
511
+ with self._stats_lock:
512
+ self._stats["misses"] += 1
513
+ self._stats["total_saved_ms"] += elapsed_ms
514
+ if hasattr(self.backend, "increment_misses"):
515
+ self.backend.increment_misses(elapsed_ms)
516
+
517
+ return result
518
+
519
+ wrapper.__name__ = fn.__name__
520
+ wrapper.__doc__ = fn.__doc__
521
+ wrapper.__wrapped__ = fn
522
+ return wrapper
523
+
524
+ if func is not None:
525
+ return decorator(func)
526
+ return decorator
527
+
528
+ def cached_stream(
529
+ self,
530
+ func: Optional[Callable] = None,
531
+ *,
532
+ namespace: Optional[str] = None,
533
+ ttl: Optional[int] = None,
534
+ tags: Optional[list[str]] = None,
535
+ model: Optional[str] = None,
536
+ system_prompt: Optional[str] = None,
537
+ temperature: Optional[float] = None,
538
+ ) -> Callable:
539
+ """
540
+ Decorator for caching async or sync streaming generator functions.
541
+
542
+ On MISS: yields stream chunks live while collecting full text, then saves in cache.
543
+ On HIT: re-yields cached text in chunks to emulate the streaming interface.
544
+ """
545
+
546
+ def decorator(fn: Callable) -> Callable:
547
+ if inspect.isasyncgenfunction(fn):
548
+
549
+ async def async_stream_wrapper(query: str, *args, **kwargs):
550
+ cached_result = await self.aget(
551
+ query,
552
+ namespace=namespace,
553
+ model=model,
554
+ system_prompt=system_prompt,
555
+ temperature=temperature,
556
+ track_miss=False,
557
+ )
558
+ if cached_result is not None:
559
+ if isinstance(cached_result, str):
560
+ chunk_size = max(1, len(cached_result) // 20 or 1)
561
+ for i in range(0, len(cached_result), chunk_size):
562
+ yield cached_result[i : i + chunk_size]
563
+ else:
564
+ yield cached_result
565
+ return
566
+
567
+ t0 = time.time()
568
+ collected_tokens = []
569
+ async for token in fn(query, *args, **kwargs):
570
+ collected_tokens.append(token)
571
+ yield token
572
+
573
+ elapsed_ms = (time.time() - t0) * 1000
574
+ full_response = (
575
+ "".join(collected_tokens)
576
+ if all(isinstance(c, str) for c in collected_tokens)
577
+ else collected_tokens
578
+ )
579
+ await self.aset(
580
+ query,
581
+ full_response,
582
+ namespace=namespace,
583
+ ttl=ttl,
584
+ tags=tags,
585
+ model=model,
586
+ system_prompt=system_prompt,
587
+ temperature=temperature,
588
+ )
589
+
590
+ if self.config.track_stats:
591
+ with self._stats_lock:
592
+ self._stats["misses"] += 1
593
+ self._stats["total_saved_ms"] += elapsed_ms
594
+ if hasattr(self.backend, "increment_misses"):
595
+ if asyncio.iscoroutinefunction(
596
+ getattr(self.backend, "increment_misses")
597
+ ):
598
+ await self.backend.increment_misses(elapsed_ms)
599
+ else:
600
+ self.backend.increment_misses(elapsed_ms)
601
+
602
+ async_stream_wrapper.__name__ = fn.__name__
603
+ async_stream_wrapper.__doc__ = fn.__doc__
604
+ async_stream_wrapper.__wrapped__ = fn
605
+ return async_stream_wrapper
606
+ else:
607
+
608
+ def sync_stream_wrapper(query: str, *args, **kwargs):
609
+ cached_result = self.get(
610
+ query,
611
+ namespace=namespace,
612
+ model=model,
613
+ system_prompt=system_prompt,
614
+ temperature=temperature,
615
+ track_miss=False,
616
+ )
617
+ if cached_result is not None:
618
+ if isinstance(cached_result, str):
619
+ chunk_size = max(1, len(cached_result) // 20 or 1)
620
+ for i in range(0, len(cached_result), chunk_size):
621
+ yield cached_result[i : i + chunk_size]
622
+ else:
623
+ yield cached_result
624
+ return
625
+
626
+ t0 = time.time()
627
+ collected_tokens = []
628
+ for token in fn(query, *args, **kwargs):
629
+ collected_tokens.append(token)
630
+ yield token
631
+
632
+ elapsed_ms = (time.time() - t0) * 1000
633
+ full_response = (
634
+ "".join(collected_tokens)
635
+ if all(isinstance(c, str) for c in collected_tokens)
636
+ else collected_tokens
637
+ )
638
+ self.set(
639
+ query,
640
+ full_response,
641
+ namespace=namespace,
642
+ ttl=ttl,
643
+ tags=tags,
644
+ model=model,
645
+ system_prompt=system_prompt,
646
+ temperature=temperature,
647
+ )
648
+
649
+ if self.config.track_stats:
650
+ with self._stats_lock:
651
+ self._stats["misses"] += 1
652
+ self._stats["total_saved_ms"] += elapsed_ms
653
+ if hasattr(self.backend, "increment_misses"):
654
+ self.backend.increment_misses(elapsed_ms)
655
+
656
+ sync_stream_wrapper.__name__ = fn.__name__
657
+ sync_stream_wrapper.__doc__ = fn.__doc__
658
+ sync_stream_wrapper.__wrapped__ = fn
659
+ return sync_stream_wrapper
660
+
661
+ if func is not None:
662
+ return decorator(func)
663
+ return decorator
664
+
665
+ def invalidate(
666
+ self,
667
+ namespace: Optional[str] = None,
668
+ model: Optional[str] = None,
669
+ system_prompt: Optional[str] = None,
670
+ temperature: Optional[float] = None,
671
+ ) -> int:
672
+ """
673
+ Invalidate all entries in a namespace (optionally scoped to model/prompt/temperature).
674
+
675
+ Removes all cache entries and their vectors from both the
676
+ backend storage and the FAISS index.
677
+
678
+ Args:
679
+ namespace: The namespace to clear. Defaults to the configured namespace.
680
+ model: Optional model scope.
681
+ system_prompt: Optional system prompt scope.
682
+ temperature: Optional temperature scope.
683
+
684
+ Returns:
685
+ The number of entries deleted.
686
+ """
687
+ ns = self._effective_namespace(namespace, model, system_prompt, temperature)
688
+ count = self.backend.delete_namespace(ns)
689
+
690
+ # Rebuild index after namespace deletion
691
+ self._rebuild_index_from_backend()
692
+
693
+ return count
694
+
695
+ def invalidate_by_tag(
696
+ self,
697
+ tag: str,
698
+ namespace: Optional[str] = None,
699
+ model: Optional[str] = None,
700
+ system_prompt: Optional[str] = None,
701
+ temperature: Optional[float] = None,
702
+ ) -> int:
703
+ """
704
+ Invalidate all cache entries carrying the given tag.
705
+
706
+ Args:
707
+ tag: The tag label to match.
708
+ namespace: Scope the invalidation. Defaults to config namespace.
709
+ model: Optional model context.
710
+ system_prompt: Optional system prompt context.
711
+ temperature: Optional temperature context.
712
+
713
+ Returns:
714
+ The number of entries deleted.
715
+ """
716
+ ns = self._effective_namespace(namespace, model, system_prompt, temperature)
717
+ count = self.backend.delete_by_tag(tag, ns)
718
+ if count > 0:
719
+ self._rebuild_index_from_backend()
720
+ return count
721
+
722
+ def clear(self) -> int:
723
+ """
724
+ Clear the entire cache across all namespaces.
725
+
726
+ Removes all entries from backend storage and resets the FAISS index.
727
+
728
+ Returns:
729
+ The total number of entries deleted.
730
+ """
731
+ count = self.backend.clear()
732
+
733
+ # Reset FAISS index
734
+ self.index.rebuild([])
735
+
736
+ # Reset stats
737
+ if self.config.track_stats:
738
+ with self._stats_lock:
739
+ self._stats = {"hits": 0, "misses": 0, "total_saved_ms": 0.0}
740
+
741
+ return count
742
+
743
+ def record_hit(self) -> None:
744
+ """Record a cache hit in a thread-safe manner."""
745
+ if self.config.track_stats:
746
+ with self._stats_lock:
747
+ self._stats["hits"] += 1
748
+ if hasattr(self.backend, "increment_hits"):
749
+ self.backend.increment_hits()
750
+
751
+ def record_miss(self, elapsed_ms: float = 0.0) -> None:
752
+ """Record a cache miss and elapsed generation time in a thread-safe manner."""
753
+ if self.config.track_stats:
754
+ with self._stats_lock:
755
+ self._stats["misses"] += 1
756
+ self._stats["total_saved_ms"] += elapsed_ms
757
+ if hasattr(self.backend, "increment_misses"):
758
+ self.backend.increment_misses(elapsed_ms)
759
+
760
+ def stats(self) -> dict:
761
+ """
762
+ Get cache statistics including hit rate and estimated savings.
763
+
764
+ Returns:
765
+ Dictionary with:
766
+ - hits: Number of cache hits
767
+ - misses: Number of cache misses
768
+ - hit_rate: Hit rate as a float between 0 and 1
769
+ - total_saved_ms: Total milliseconds saved across all misses/hits
770
+ - estimated_tokens_saved: Rough estimate of tokens saved by caching
771
+ - estimated_cost_saved_usd: Estimated USD saved (at GPT-4 pricing)
772
+ - total_entries: Total number of entries in the cache
773
+ """
774
+ with self._stats_lock:
775
+ hits = self._stats.get("hits", 0)
776
+ misses = self._stats.get("misses", 0)
777
+ total_saved_ms = self._stats.get("total_saved_ms", 0.0)
778
+
779
+ total = hits + misses
780
+ avg_tokens_per_hit = 1500 # rough estimate for typical RAG responses
781
+ gpt4_cost_per_1k_tokens = 0.03 # approximate blended rate
782
+
783
+ estimated_tokens_saved = hits * avg_tokens_per_hit
784
+ estimated_cost_saved = (estimated_tokens_saved / 1000) * gpt4_cost_per_1k_tokens
785
+
786
+ return {
787
+ "hits": hits,
788
+ "misses": misses,
789
+ "hit_rate": hits / total if total > 0 else 0.0,
790
+ "total_saved_ms": total_saved_ms,
791
+ "estimated_tokens_saved": estimated_tokens_saved,
792
+ "estimated_cost_saved_usd": round(estimated_cost_saved, 4),
793
+ "total_entries": self.backend.count(self.config.namespace),
794
+ }
795
+
796
+ def inspect(
797
+ self,
798
+ query: str,
799
+ namespace: Optional[str] = None,
800
+ model: Optional[str] = None,
801
+ system_prompt: Optional[str] = None,
802
+ temperature: Optional[float] = None,
803
+ ) -> Optional[dict]:
804
+ """
805
+ Inspect the nearest cached entry for a query without returning the cached response.
806
+
807
+ Useful for debugging and understanding what would be cached for a given query.
808
+
809
+ Args:
810
+ query: The query to inspect.
811
+ namespace: Override namespace for the lookup.
812
+ model: Optional model context.
813
+ system_prompt: Optional system prompt context.
814
+ temperature: Optional temperature context.
815
+
816
+ Returns:
817
+ Dictionary with entry metadata and similarity info, or None if no match.
818
+ """
819
+ ns = self._effective_namespace(namespace, model, system_prompt, temperature)
820
+ vector = self.embedder.embed(query)
821
+
822
+ entry = self.index.find_similar(
823
+ vector,
824
+ threshold=0.0, # Find the nearest regardless of threshold
825
+ namespace=ns,
826
+ )
827
+
828
+ if entry is None:
829
+ return None
830
+
831
+ return {
832
+ "id": entry.id,
833
+ "query": entry.query,
834
+ "namespace": entry.namespace,
835
+ "hit_count": entry.hit_count,
836
+ "created_at": entry.created_at,
837
+ "expires_at": entry.expires_at,
838
+ "is_expired": entry.is_expired(),
839
+ "model": entry.model,
840
+ "system_prompt": entry.system_prompt,
841
+ "temperature": entry.temperature,
842
+ "tags": list(entry.tags or []),
843
+ }
844
+
845
+ def _evict_if_needed(self, namespace: str) -> None:
846
+ """
847
+ Evict oldest entries if the namespace exceeds max_entries.
848
+
849
+ Uses LRU-style eviction: entries with the oldest created_at
850
+ timestamps are removed first.
851
+
852
+ Args:
853
+ namespace: The namespace to check for eviction.
854
+ """
855
+ count = self.backend.count(namespace)
856
+ if count <= self.config.max_entries:
857
+ return
858
+
859
+ # Evict the excess entries (oldest first)
860
+ excess = count - self.config.max_entries
861
+ evicted = self.backend.evict_oldest(namespace, excess)
862
+
863
+ # Rebuild index to keep it consistent
864
+ if evicted > 0:
865
+ self._rebuild_index_from_backend()
866
+
867
+ def __repr__(self) -> str:
868
+ return (
869
+ f"LongCache(backend={self.config.backend}, "
870
+ f"namespace={self.config.namespace!r}, "
871
+ f"threshold={self.config.similarity_threshold})"
872
+ )