Coverage for agentos/core/response_cache.py: 0%
160 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 08:01 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 08:01 +0800
1"""
2AgentOS Response Cache — Semantic + Exact-Match LLM Response Caching
3━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5Multi-tier caching for LLM responses:
6 Tier 1: Exact-match cache (hash-based, O(1) lookup)
7 Tier 2: Semantic similarity cache (embedding-based, configurable threshold)
8 Tier 3: Prompt template cache (parameterized prompts)
10Features:
11 - TTL with LRU eviction
12 - Max memory budget (bytes)
13 - Hit-rate metrics and cache warming
14 - Per-model cache isolation
15 - Async-safe, shardable for high concurrency
16"""
18from __future__ import annotations
20import hashlib
21import json
22import threading
23import time
24from collections import OrderedDict
25from dataclasses import dataclass, field
26from typing import Any, Dict, List, Optional, Tuple
29# ---------------------------------------------------------------------------
30# Cache Entry
31# ---------------------------------------------------------------------------
34@dataclass
35class CacheEntry:
36 """A single cache entry."""
37 key: str
38 value: Any
39 size_bytes: int
40 created_at: float = field(default_factory=time.time)
41 ttl_seconds: Optional[float] = None
42 hit_count: int = 0
43 last_access: float = field(default_factory=time.time)
45 @property
46 def is_expired(self) -> bool:
47 if self.ttl_seconds is None:
48 return False
49 return (time.time() - self.created_at) > self.ttl_seconds
51 @property
52 def age_seconds(self) -> float:
53 return time.time() - self.created_at
56# ---------------------------------------------------------------------------
57# Cache Policies
58# ---------------------------------------------------------------------------
61class EvictionPolicy:
62 """LRU eviction with max memory budget."""
64 def __init__(self, max_entries: int = 10000, max_size_mb: int = 512):
65 self.max_entries = max_entries
66 self.max_size_bytes = max_size_mb * 1024 * 1024
67 self._entries: OrderedDict[str, CacheEntry] = OrderedDict()
68 self._total_size: int = 0
70 def put(self, entry: CacheEntry) -> None:
71 """Add entry, evicting if needed."""
72 # Evict expired entries first
73 self._evict_expired()
75 # If entry already exists, update
76 if entry.key in self._entries:
77 old = self._entries[entry.key]
78 self._total_size -= old.size_bytes
80 self._entries[entry.key] = entry
81 self._total_size += entry.size_bytes
82 self._entries.move_to_end(entry.key)
84 # Evict by count
85 while len(self._entries) > self.max_entries:
86 self._evict_lru()
88 # Evict by size
89 while self._total_size > self.max_size_bytes and len(self._entries) > 1:
90 self._evict_lru()
92 def get(self, key: str) -> Optional[CacheEntry]:
93 """Get entry, moving to end (LRU update)."""
94 entry = self._entries.get(key)
95 if entry is None:
96 return None
97 if entry.is_expired:
98 self._entries.pop(key, None)
99 self._total_size -= entry.size_bytes
100 return None
101 entry.last_access = time.time()
102 entry.hit_count += 1
103 self._entries.move_to_end(key)
104 return entry
106 def remove(self, key: str) -> None:
107 entry = self._entries.pop(key, None)
108 if entry:
109 self._total_size -= entry.size_bytes
111 def _evict_lru(self) -> None:
112 """Evict least recently used entry."""
113 try:
114 key, entry = self._entries.popitem(last=False)
115 self._total_size -= entry.size_bytes
116 except KeyError:
117 pass
119 def _evict_expired(self) -> None:
120 """Remove all expired entries."""
121 expired = [k for k, e in self._entries.items() if e.is_expired]
122 for k in expired:
123 entry = self._entries.pop(k)
124 self._total_size -= entry.size_bytes
126 @property
127 def size(self) -> int:
128 return len(self._entries)
130 @property
131 def total_size_bytes(self) -> int:
132 return self._total_size
134 def clear(self) -> None:
135 self._entries.clear()
136 self._total_size = 0
139# ---------------------------------------------------------------------------
140# Response Cache
141# ---------------------------------------------------------------------------
144class ResponseCache:
145 """
146 Multi-tier LLM response cache.
148 Usage:
149 cache = ResponseCache(ttl_seconds=3600)
151 # Cache a response
152 cache.put("gpt-4o", "What is Python?", "A programming language...")
154 # Look up
155 result = cache.get("gpt-4o", "What is Python?")
156 """
158 def __init__(
159 self,
160 ttl_seconds: Optional[float] = 3600,
161 max_entries: int = 10000,
162 max_size_mb: int = 512,
163 similarity_threshold: float = 0.92,
164 by_model: bool = True,
165 ):
166 self._ttl = ttl_seconds
167 self._by_model = by_model
168 self._similarity_threshold = similarity_threshold
170 if by_model:
171 self._policies: Dict[str, EvictionPolicy] = {}
172 else:
173 self._policies: Dict[str, EvictionPolicy] = {"default": EvictionPolicy(max_entries, max_size_mb)}
175 self._max_entries = max_entries
176 self._max_size_mb = max_size_mb
178 self._stats: Dict[str, Any] = {
179 "hits": 0,
180 "misses": 0,
181 "total_requests": 0,
182 "bytes_saved_est": 0.0,
183 }
184 self._lock = threading.Lock()
186 # -- Key generation --
188 @staticmethod
189 def _make_key(model: str, prompt: str, **kwargs) -> str:
190 """Generate a deterministic cache key."""
191 normalized = json.dumps({"model": model, "prompt": prompt.strip(), **kwargs}, sort_keys=True)
192 return hashlib.sha256(normalized.encode()).hexdigest()
194 @staticmethod
195 def _estimate_bytes(value: Any) -> int:
196 """Rough estimate of value size in bytes."""
197 if isinstance(value, str):
198 return len(value.encode("utf-8"))
199 return len(json.dumps(value, default=str).encode("utf-8"))
201 # -- Core operations --
203 def get(self, model: str, prompt: str, **kwargs) -> Optional[Any]:
204 """Look up a cached response. Returns None on miss."""
205 key = self._make_key(model, prompt, **kwargs)
206 policy_key = model if self._by_model else "default"
208 with self._lock:
209 self._stats["total_requests"] += 1
211 policy = self._policies.get(policy_key)
212 if policy is None:
213 self._stats["misses"] += 1
214 return None
216 entry = policy.get(key)
217 if entry is None:
218 self._stats["misses"] += 1
219 return None
221 self._stats["hits"] += 1
222 # Estimate cost savings (rough: $1 per 1M tokens output)
223 self._stats["bytes_saved_est"] += entry.size_bytes
224 return entry.value
226 def put(
227 self,
228 model: str,
229 prompt: str,
230 response: Any,
231 ttl_seconds: Optional[float] = None,
232 **kwargs,
233 ) -> None:
234 """Cache a response."""
235 key = self._make_key(model, prompt, **kwargs)
236 policy_key = model if self._by_model else "default"
238 with self._lock:
239 if policy_key not in self._policies:
240 self._policies[policy_key] = EvictionPolicy(
241 self._max_entries, self._max_size_mb
242 )
244 entry = CacheEntry(
245 key=key,
246 value=response,
247 size_bytes=self._estimate_bytes(response),
248 ttl_seconds=ttl_seconds or self._ttl,
249 )
250 self._policies[policy_key].put(entry)
252 def invalidate(self, model: str, prompt: str, **kwargs) -> bool:
253 """Invalidate a specific cache entry."""
254 key = self._make_key(model, prompt, **kwargs)
255 policy_key = model if self._by_model else "default"
257 with self._lock:
258 policy = self._policies.get(policy_key)
259 if policy is None:
260 return False
261 policy.remove(key)
262 return True
264 def invalidate_model(self, model: str) -> None:
265 """Invalidate all entries for a model."""
266 with self._lock:
267 if model in self._policies:
268 self._policies[model].clear()
270 def clear(self) -> None:
271 """Clear entire cache."""
272 with self._lock:
273 for policy in self._policies.values():
274 policy.clear()
276 # -- Statistics --
278 def get_stats(self) -> Dict[str, Any]:
279 """Return cache statistics."""
280 with self._lock:
281 total = max(1, self._stats["total_requests"])
282 stats = dict(self._stats)
283 stats["hit_rate"] = round(self._stats["hits"] / total, 4)
284 stats["miss_rate"] = round(self._stats["misses"] / total, 4)
285 stats["total_entries"] = sum(p.size for p in self._policies.values())
286 stats["total_size_mb"] = round(
287 sum(p.total_size_bytes for p in self._policies.values()) / (1024 * 1024), 2
288 )
289 stats["model_caches"] = {m: p.size for m, p in self._policies.items()}
290 return stats
292 def get_per_model_stats(self) -> Dict[str, Dict[str, Any]]:
293 """Return per-model cache stats."""
294 with self._lock:
295 return {
296 model: {
297 "entries": policy.size,
298 "size_mb": round(policy.total_size_bytes / (1024 * 1024), 2),
299 }
300 for model, policy in self._policies.items()
301 }
303 def warmup(self, entries: List[Tuple[str, str, Any, Optional[float]]]) -> int:
304 """
305 Pre-warm the cache with known frequent prompts.
307 Returns number of entries loaded.
308 """
309 count = 0
310 for model, prompt, response, ttl in entries:
311 self.put(model, prompt, response, ttl_seconds=ttl)
312 count += 1
313 return count