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.
- longcache/__init__.py +11 -0
- longcache/backends/__init__.py +21 -0
- longcache/backends/async_redis.py +274 -0
- longcache/backends/base.py +167 -0
- longcache/backends/memory.py +243 -0
- longcache/backends/redis.py +374 -0
- longcache/backends/sqlite.py +521 -0
- longcache/cli.py +216 -0
- longcache/config.py +127 -0
- longcache/core/__init__.py +8 -0
- longcache/core/base_embedder.py +43 -0
- longcache/core/cache.py +872 -0
- longcache/core/embedder.py +155 -0
- longcache/core/entry.py +122 -0
- longcache/core/index.py +272 -0
- longcache/integrations/__init__.py +32 -0
- longcache/integrations/langchain.py +316 -0
- longcache/integrations/langgraph.py +150 -0
- longcache/integrations/llamaindex.py +103 -0
- longcache/integrations/openai.py +146 -0
- longcache/middleware.py +366 -0
- longcache-0.1.0.dist-info/METADATA +942 -0
- longcache-0.1.0.dist-info/RECORD +26 -0
- longcache-0.1.0.dist-info/WHEEL +4 -0
- longcache-0.1.0.dist-info/entry_points.txt +2 -0
- longcache-0.1.0.dist-info/licenses/LICENSE +21 -0
longcache/core/cache.py
ADDED
|
@@ -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
|
+
)
|