cachellm-proxy 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.
Files changed (43) hide show
  1. cachellm/__init__.py +13 -0
  2. cachellm/__main__.py +6 -0
  3. cachellm/api/__init__.py +5 -0
  4. cachellm/api/app.py +143 -0
  5. cachellm/api/auth.py +34 -0
  6. cachellm/api/deps.py +106 -0
  7. cachellm/api/routes_admin.py +265 -0
  8. cachellm/api/routes_chat.py +385 -0
  9. cachellm/api/sse.py +98 -0
  10. cachellm/cache/__init__.py +3 -0
  11. cachellm/cache/analytics.py +150 -0
  12. cachellm/cache/coalesce.py +63 -0
  13. cachellm/cache/entry.py +92 -0
  14. cachellm/cache/exact_store.py +33 -0
  15. cachellm/cache/keys.py +124 -0
  16. cachellm/cache/policy.py +134 -0
  17. cachellm/cache/redis_client.py +22 -0
  18. cachellm/cache/service.py +332 -0
  19. cachellm/cache/vector_store.py +217 -0
  20. cachellm/cli.py +122 -0
  21. cachellm/embeddings/__init__.py +19 -0
  22. cachellm/embeddings/base.py +38 -0
  23. cachellm/embeddings/fastembed_backend.py +75 -0
  24. cachellm/embeddings/hash_backend.py +42 -0
  25. cachellm/errors.py +72 -0
  26. cachellm/logging_setup.py +56 -0
  27. cachellm/models.py +181 -0
  28. cachellm/observability/__init__.py +6 -0
  29. cachellm/observability/metrics.py +147 -0
  30. cachellm/observability/tracing.py +107 -0
  31. cachellm/pricing.py +108 -0
  32. cachellm/providers/__init__.py +7 -0
  33. cachellm/providers/base.py +84 -0
  34. cachellm/providers/bedrock.py +238 -0
  35. cachellm/providers/fake.py +56 -0
  36. cachellm/providers/openai_compat.py +131 -0
  37. cachellm/providers/registry.py +96 -0
  38. cachellm/py.typed +0 -0
  39. cachellm/settings.py +230 -0
  40. cachellm_proxy-0.1.0.dist-info/METADATA +550 -0
  41. cachellm_proxy-0.1.0.dist-info/RECORD +43 -0
  42. cachellm_proxy-0.1.0.dist-info/WHEEL +4 -0
  43. cachellm_proxy-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,385 @@
1
+ """The drop-in endpoint: POST /v1/chat/completions.
2
+
3
+ Same request shape as OpenAI, same response shape, same error envelope, plus a
4
+ set of ``X-Cache-*`` headers describing what happened. An application adopts
5
+ CacheLLM by changing one base URL and nothing else.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections.abc import AsyncIterator
12
+ from typing import Any
13
+
14
+ import structlog
15
+ from fastapi import APIRouter, Request
16
+ from fastapi.responses import JSONResponse, StreamingResponse
17
+
18
+ from cachellm.api import sse
19
+ from cachellm.api.auth import verify
20
+ from cachellm.api.deps import AppState, get_state
21
+ from cachellm.cache.entry import CacheEntry
22
+ from cachellm.cache.keys import prompt_fingerprint
23
+ from cachellm.cache.policy import PolicyDecision
24
+ from cachellm.cache.service import LookupResult
25
+ from cachellm.errors import CacheMissError, UpstreamError
26
+ from cachellm.models import (
27
+ ChatCompletionRequest,
28
+ ChatCompletionResponse,
29
+ ModelCard,
30
+ ModelList,
31
+ )
32
+ from cachellm.observability import span
33
+ from cachellm.pricing import estimate_cost
34
+ from cachellm.providers.base import Provider
35
+
36
+ log = structlog.get_logger(__name__)
37
+ router = APIRouter()
38
+
39
+ BYPASS_LOOKUP = LookupResult(
40
+ status="bypass",
41
+ decision=PolicyDecision(False, "default", 1.0, 0, "cache_unavailable"),
42
+ )
43
+
44
+
45
+ def cache_headers(
46
+ lookup: LookupResult, *, total_ms: float, saved_usd: float = 0.0, coalesced: bool = False
47
+ ) -> dict[str, str]:
48
+ status = {
49
+ "hit": "HIT",
50
+ "miss": "MISS",
51
+ "bypass": "BYPASS",
52
+ "shadow_hit": "SHADOW",
53
+ }[lookup.status]
54
+ headers = {
55
+ "X-Cache": status,
56
+ "X-Cache-Category": lookup.category,
57
+ "X-Cache-Lookup-Ms": f"{lookup.lookup_ms:.2f}",
58
+ "X-Cache-Latency-Ms": f"{total_ms:.2f}",
59
+ "X-Cache-Threshold": f"{lookup.decision.threshold:.3f}",
60
+ }
61
+ if lookup.namespace:
62
+ headers["X-Cache-Namespace"] = lookup.namespace
63
+ if lookup.tier:
64
+ headers["X-Cache-Tier"] = lookup.tier
65
+ if lookup.status in ("hit", "shadow_hit", "miss"):
66
+ headers["X-Cache-Similarity"] = f"{lookup.similarity:.4f}"
67
+ if lookup.entry is not None:
68
+ headers["X-Cache-Entry-Id"] = lookup.entry.entry_id
69
+ headers["X-Cache-Age-Seconds"] = f"{lookup.entry.age_seconds():.0f}"
70
+ if lookup.decision.bypass_reason:
71
+ headers["X-Cache-Bypass-Reason"] = lookup.decision.bypass_reason
72
+ if saved_usd:
73
+ headers["X-Cache-Saved-USD"] = f"{saved_usd:.8f}"
74
+ if coalesced:
75
+ headers["X-Cache-Coalesced"] = "true"
76
+ return headers
77
+
78
+
79
+ def _debug_block(lookup: LookupResult, saved_usd: float, coalesced: bool) -> dict[str, Any]:
80
+ """Non-standard extra field. SDKs ignore unknown keys; humans find it useful."""
81
+ return {
82
+ "status": lookup.status,
83
+ "tier": lookup.tier or None,
84
+ "similarity": round(lookup.similarity, 4)
85
+ if lookup.tier or lookup.status == "miss"
86
+ else None,
87
+ "threshold": lookup.decision.threshold,
88
+ "category": lookup.category,
89
+ "namespace": lookup.namespace or None,
90
+ "bypass_reason": lookup.decision.bypass_reason or None,
91
+ "saved_usd": round(saved_usd, 8) or None,
92
+ "coalesced": coalesced or None,
93
+ "lookup_ms": round(lookup.lookup_ms, 2),
94
+ }
95
+
96
+
97
+ async def _record_outcome(
98
+ state: AppState, lookup: LookupResult, model: str, provider: str, total_ms: float
99
+ ) -> None:
100
+ metrics = state.metrics
101
+ result = lookup.status
102
+ metrics.requests.labels(
103
+ result=result, category=lookup.category, provider=provider, model=model
104
+ ).inc()
105
+ metrics.request_duration.labels(result=result).observe(total_ms / 1000.0)
106
+ if lookup.tier or result == "miss":
107
+ metrics.lookup_duration.labels(tier=lookup.tier or "semantic").observe(
108
+ lookup.lookup_ms / 1000.0
109
+ )
110
+ metrics.similarity.labels(result=result).observe(max(0.0, min(1.0, lookup.similarity)))
111
+ if state.analytics is not None:
112
+ counters = {"requests": 1}
113
+ if lookup.decision.cacheable:
114
+ counters["cacheable_requests"] = 1
115
+ if result == "miss":
116
+ counters["misses"] = 1
117
+ elif result == "bypass":
118
+ counters["bypass"] = 1
119
+ elif result == "shadow_hit":
120
+ counters["shadow_hits"] = 1
121
+ await state.analytics.bulk(counters)
122
+ await state.analytics.record_latency("hit" if result == "hit" else "miss", total_ms)
123
+
124
+
125
+ @router.get("/models", response_model=ModelList)
126
+ async def list_models(request: Request) -> ModelList:
127
+ state = get_state(request)
128
+ verify(request, state.settings.client_keys)
129
+ return ModelList(data=[ModelCard(id=m) for m in state.providers.models()])
130
+
131
+
132
+ @router.post("/chat/completions")
133
+ async def chat_completions(request: Request, body: ChatCompletionRequest) -> Any:
134
+ state = get_state(request)
135
+ verify(request, state.settings.client_keys)
136
+ started = time.perf_counter()
137
+
138
+ cache_control = request.headers.get("x-cache-control", "")
139
+ only_if_cached = "only-if-cached" in cache_control.lower()
140
+ provider, provider_name = state.providers.resolve(body.model)
141
+
142
+ with span(
143
+ "cachellm.request",
144
+ **{
145
+ "gen_ai.system": provider_name,
146
+ "gen_ai.request.model": body.model,
147
+ "gen_ai.request.temperature": body.effective_temperature,
148
+ "cachellm.prompt_fingerprint": prompt_fingerprint(body.last_user_text()),
149
+ },
150
+ ) as current:
151
+ # ---------------------------------------------------------- lookup
152
+ if state.caching_on and state.cache is not None:
153
+ lookup = await state.cache.lookup(body, provider_name, cache_control=cache_control)
154
+ else:
155
+ lookup = BYPASS_LOOKUP
156
+
157
+ if current is not None:
158
+ current.set_attribute("cachellm.status", lookup.status)
159
+ current.set_attribute("cachellm.category", lookup.category)
160
+ current.set_attribute("cachellm.similarity", round(lookup.similarity, 4))
161
+
162
+ if lookup.status == "shadow_hit" and state.analytics is not None:
163
+ state.metrics.shadow_hits.labels(category=lookup.category).inc()
164
+ log.info(
165
+ "shadow_hit",
166
+ similarity=round(lookup.similarity, 4),
167
+ tier=lookup.tier,
168
+ category=lookup.category,
169
+ fingerprint=prompt_fingerprint(lookup.cache_text),
170
+ )
171
+
172
+ # ------------------------------------------------------- cache hit
173
+ if lookup.served_from_cache and state.cache is not None and lookup.entry is not None:
174
+ entry = lookup.entry
175
+ saved = await state.cache.register_hit(lookup, body.model)
176
+ state.metrics.observe_cost(body.model, saved, "saved")
177
+ state.metrics.observe_tokens(
178
+ body.model, entry.prompt_tokens, entry.completion_tokens, "saved"
179
+ )
180
+ total_ms = (time.perf_counter() - started) * 1000
181
+ await _record_outcome(state, lookup, body.model, provider_name, total_ms)
182
+ headers = cache_headers(lookup, total_ms=total_ms, saved_usd=saved)
183
+ if body.stream:
184
+ return _replay_stream(body, entry, headers)
185
+ payload = ChatCompletionResponse.from_text(
186
+ model=body.model,
187
+ text=entry.response_text,
188
+ prompt_tokens=entry.prompt_tokens,
189
+ completion_tokens=entry.completion_tokens,
190
+ finish_reason=entry.finish_reason,
191
+ ).model_dump()
192
+ payload["cachellm"] = _debug_block(lookup, saved, False)
193
+ return JSONResponse(payload, headers=headers)
194
+
195
+ if only_if_cached:
196
+ raise CacheMissError()
197
+
198
+ # ----------------------------------------------------------- miss
199
+ if body.stream:
200
+ return await _proxy_stream(state, body, lookup, provider, provider_name, started)
201
+ return await _proxy_once(state, body, lookup, provider, provider_name, started)
202
+
203
+
204
+ async def _proxy_once(
205
+ state: AppState,
206
+ body: ChatCompletionRequest,
207
+ lookup: LookupResult,
208
+ provider: Provider,
209
+ provider_name: str,
210
+ started: float,
211
+ ) -> JSONResponse:
212
+ """Non-streaming miss: one upstream call, shared by identical concurrent misses."""
213
+ coalesce_key = f"{lookup.namespace}:{lookup.exact}" if lookup.namespace else ""
214
+
215
+ async def call() -> Any:
216
+ with span(
217
+ "cachellm.provider",
218
+ **{"gen_ai.system": provider_name, "gen_ai.request.model": body.model},
219
+ ):
220
+ return await provider.complete(body)
221
+
222
+ try:
223
+ if coalesce_key and state.caching_on:
224
+ result, coalesced = await state.singleflight.do(coalesce_key, call)
225
+ else:
226
+ result, coalesced = await call(), False
227
+ except UpstreamError:
228
+ state.metrics.provider_errors.labels(provider=provider_name).inc()
229
+ if state.analytics is not None:
230
+ await state.analytics.incr("provider_errors")
231
+ raise
232
+
233
+ if coalesced:
234
+ state.metrics.coalesced.inc()
235
+ if state.analytics is not None:
236
+ await state.analytics.incr("coalesced")
237
+
238
+ if state.caching_on and state.cache is not None and not coalesced:
239
+ await state.cache.store(
240
+ lookup=lookup,
241
+ request=body,
242
+ provider_name=provider_name,
243
+ response_text=result.text,
244
+ prompt_tokens=result.prompt_tokens,
245
+ completion_tokens=result.completion_tokens,
246
+ finish_reason=result.finish_reason,
247
+ tool_calls=result.tool_calls,
248
+ )
249
+
250
+ spent = estimate_cost(body.model, result.prompt_tokens, result.completion_tokens)
251
+ state.metrics.observe_cost(body.model, spent, "spent")
252
+ state.metrics.observe_tokens(
253
+ body.model, result.prompt_tokens, result.completion_tokens, "spent"
254
+ )
255
+ if state.analytics is not None:
256
+ await state.analytics.incr_float("usd_spent", spent)
257
+
258
+ total_ms = (time.perf_counter() - started) * 1000
259
+ await _record_outcome(state, lookup, body.model, provider_name, total_ms)
260
+
261
+ payload = ChatCompletionResponse.from_text(
262
+ model=body.model,
263
+ text=result.text,
264
+ prompt_tokens=result.prompt_tokens,
265
+ completion_tokens=result.completion_tokens,
266
+ finish_reason=result.finish_reason,
267
+ ).model_dump()
268
+ payload["cachellm"] = _debug_block(lookup, 0.0, coalesced)
269
+ return JSONResponse(
270
+ payload, headers=cache_headers(lookup, total_ms=total_ms, coalesced=coalesced)
271
+ )
272
+
273
+
274
+ def _replay_stream(
275
+ body: ChatCompletionRequest, entry: CacheEntry, headers: dict[str, str]
276
+ ) -> StreamingResponse:
277
+ """Serve a cache hit to a client that asked for a stream."""
278
+ stream_id = sse.new_stream_id()
279
+ created = int(time.time())
280
+
281
+ async def generate() -> AsyncIterator[str]:
282
+ yield sse.role_chunk(stream_id, body.model, created)
283
+ for piece in sse.split_for_replay(entry.response_text):
284
+ yield sse.text_chunk(stream_id, body.model, created, piece)
285
+ yield sse.final_chunk(
286
+ stream_id,
287
+ body.model,
288
+ created,
289
+ entry.finish_reason,
290
+ {
291
+ "prompt_tokens": entry.prompt_tokens,
292
+ "completion_tokens": entry.completion_tokens,
293
+ "total_tokens": entry.prompt_tokens + entry.completion_tokens,
294
+ },
295
+ )
296
+ yield sse.DONE
297
+
298
+ return StreamingResponse(generate(), media_type="text/event-stream", headers=headers)
299
+
300
+
301
+ async def _proxy_stream(
302
+ state: AppState,
303
+ body: ChatCompletionRequest,
304
+ lookup: LookupResult,
305
+ provider: Provider,
306
+ provider_name: str,
307
+ started: float,
308
+ ) -> StreamingResponse:
309
+ """Streaming miss: forward chunks live while buffering for the cache.
310
+
311
+ The buffer is only committed once the upstream reports a clean finish. A
312
+ disconnect halfway through leaves nothing behind, which is exactly what you
313
+ want: a truncated answer served from cache forever would be a silent bug.
314
+ """
315
+ stream_id = sse.new_stream_id()
316
+ created = int(time.time())
317
+
318
+ async def generate() -> AsyncIterator[str]:
319
+ buffer: list[str] = []
320
+ finish_reason: str | None = None
321
+ prompt_tokens = 0
322
+ completion_tokens = 0
323
+ yield sse.role_chunk(stream_id, body.model, created)
324
+ try:
325
+ async for event in provider.stream(body):
326
+ if event.delta:
327
+ buffer.append(event.delta)
328
+ yield sse.text_chunk(stream_id, body.model, created, event.delta)
329
+ if event.finish_reason:
330
+ finish_reason = event.finish_reason
331
+ if event.prompt_tokens or event.completion_tokens:
332
+ prompt_tokens = event.prompt_tokens or prompt_tokens
333
+ completion_tokens = event.completion_tokens or completion_tokens
334
+ except UpstreamError as exc:
335
+ state.metrics.provider_errors.labels(provider=provider_name).inc()
336
+ if state.analytics is not None:
337
+ await state.analytics.incr("provider_errors")
338
+ log.warning("stream_failed", error=str(exc)[:200])
339
+ yield sse.final_chunk(stream_id, body.model, created, "error", None)
340
+ yield sse.DONE
341
+ return
342
+
343
+ text = "".join(buffer)
344
+ if not completion_tokens and text:
345
+ completion_tokens = provider.approx_tokens(text)
346
+ if not prompt_tokens:
347
+ prompt_tokens = provider.approx_tokens(body.last_user_text())
348
+
349
+ yield sse.final_chunk(
350
+ stream_id,
351
+ body.model,
352
+ created,
353
+ finish_reason or "stop",
354
+ {
355
+ "prompt_tokens": prompt_tokens,
356
+ "completion_tokens": completion_tokens,
357
+ "total_tokens": prompt_tokens + completion_tokens,
358
+ },
359
+ )
360
+ yield sse.DONE
361
+
362
+ if state.caching_on and state.cache is not None and state.settings.cache_streaming:
363
+ await state.cache.store(
364
+ lookup=lookup,
365
+ request=body,
366
+ provider_name=provider_name,
367
+ response_text=text,
368
+ prompt_tokens=prompt_tokens,
369
+ completion_tokens=completion_tokens,
370
+ finish_reason=finish_reason or "stop",
371
+ )
372
+ spent = estimate_cost(body.model, prompt_tokens, completion_tokens)
373
+ state.metrics.observe_cost(body.model, spent, "spent")
374
+ state.metrics.observe_tokens(body.model, prompt_tokens, completion_tokens, "spent")
375
+ if state.analytics is not None:
376
+ await state.analytics.incr_float("usd_spent", spent)
377
+ total_ms = (time.perf_counter() - started) * 1000
378
+ await _record_outcome(state, lookup, body.model, provider_name, total_ms)
379
+
380
+ total_ms = (time.perf_counter() - started) * 1000
381
+ return StreamingResponse(
382
+ generate(),
383
+ media_type="text/event-stream",
384
+ headers=cache_headers(lookup, total_ms=total_ms),
385
+ )
cachellm/api/sse.py ADDED
@@ -0,0 +1,98 @@
1
+ """Server-sent event helpers for streaming chat completions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import time
8
+ import uuid
9
+ from typing import Any
10
+
11
+ DONE = "data: [DONE]\n\n"
12
+
13
+ # Non-space run plus whatever whitespace follows it, so no separator is lost.
14
+ _TOKEN_WITH_TRAILING_SPACE = re.compile(r"\S+\s*|\s+")
15
+
16
+
17
+ def new_stream_id() -> str:
18
+ return f"chatcmpl-{uuid.uuid4().hex[:24]}"
19
+
20
+
21
+ def chunk(
22
+ *,
23
+ stream_id: str,
24
+ model: str,
25
+ delta: dict[str, Any] | None = None,
26
+ finish_reason: str | None = None,
27
+ usage: dict[str, int] | None = None,
28
+ created: int | None = None,
29
+ ) -> str:
30
+ payload: dict[str, Any] = {
31
+ "id": stream_id,
32
+ "object": "chat.completion.chunk",
33
+ "created": created or int(time.time()),
34
+ "model": model,
35
+ "choices": [
36
+ {
37
+ "index": 0,
38
+ "delta": delta if delta is not None else {},
39
+ "finish_reason": finish_reason,
40
+ }
41
+ ],
42
+ }
43
+ if usage is not None:
44
+ payload["usage"] = usage
45
+ return f"data: {json.dumps(payload, separators=(',', ':'))}\n\n"
46
+
47
+
48
+ def role_chunk(stream_id: str, model: str, created: int) -> str:
49
+ return chunk(
50
+ stream_id=stream_id,
51
+ model=model,
52
+ delta={"role": "assistant", "content": ""},
53
+ created=created,
54
+ )
55
+
56
+
57
+ def text_chunk(stream_id: str, model: str, created: int, text: str) -> str:
58
+ return chunk(stream_id=stream_id, model=model, delta={"content": text}, created=created)
59
+
60
+
61
+ def final_chunk(
62
+ stream_id: str, model: str, created: int, finish_reason: str, usage: dict[str, int] | None
63
+ ) -> str:
64
+ return chunk(
65
+ stream_id=stream_id,
66
+ model=model,
67
+ delta={},
68
+ finish_reason=finish_reason,
69
+ usage=usage,
70
+ created=created,
71
+ )
72
+
73
+
74
+ def split_for_replay(text: str, max_chars: int = 24) -> list[str]:
75
+ """Chop a cached answer into believable stream chunks.
76
+
77
+ A cached hit has the whole answer already, but a client that asked for a
78
+ stream still expects a stream. Replaying in small pieces keeps SDKs and UIs
79
+ working unchanged; it arrives in a couple of milliseconds either way.
80
+
81
+ The invariant that matters is ``"".join(pieces) == text``: an SSE client
82
+ concatenates deltas directly, it does not re-insert separators. Splitting
83
+ on spaces and dropping them silently eats a space at every chunk boundary,
84
+ which is invisible in unit tests that join with a space and obvious the
85
+ moment a real SDK reads the stream.
86
+ """
87
+ if not text:
88
+ return []
89
+ pieces: list[str] = []
90
+ buffer = ""
91
+ for token in _TOKEN_WITH_TRAILING_SPACE.findall(text):
92
+ buffer += token
93
+ if len(buffer) >= max_chars:
94
+ pieces.append(buffer)
95
+ buffer = ""
96
+ if buffer:
97
+ pieces.append(buffer)
98
+ return pieces
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,150 @@
1
+ """Durable counters and the near-miss log.
2
+
3
+ Prometheus holds the time series for dashboards, but it resets when the process
4
+ does and it cannot answer "what did the cache do last week". These Redis-backed
5
+ counters survive restarts and are shared across workers, which is what the
6
+ admin endpoints and the README numbers read from.
7
+
8
+ The near-miss log is the tuning instrument: every lookup that landed just below
9
+ the threshold is kept with its score, so you can see exactly what a slightly
10
+ looser threshold would have bought you, on your own traffic.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ from dataclasses import asdict, dataclass
18
+ from typing import Any
19
+
20
+ import redis.asyncio as aioredis
21
+
22
+ from cachellm.settings import Settings
23
+
24
+
25
+ @dataclass
26
+ class NearMiss:
27
+ prompt: str
28
+ matched_prompt: str
29
+ similarity: float
30
+ threshold: float
31
+ category: str
32
+ namespace: str
33
+ model: str
34
+ at: float
35
+
36
+
37
+ class Analytics:
38
+ def __init__(self, redis: aioredis.Redis, settings: Settings) -> None:
39
+ self._redis = redis
40
+ self._settings = settings
41
+ self._counters = f"{settings.stats_prefix}counters"
42
+ self._near = f"{settings.stats_prefix}near_misses"
43
+ self._latency = f"{settings.stats_prefix}latency"
44
+
45
+ # ------------------------------------------------------------------ counters
46
+ async def incr(self, field: str, amount: int = 1) -> None:
47
+ await self._redis.hincrby(self._counters, field, amount)
48
+
49
+ async def incr_float(self, field: str, amount: float) -> None:
50
+ if amount:
51
+ await self._redis.hincrbyfloat(self._counters, field, amount)
52
+
53
+ async def bulk(self, ints: dict[str, int], floats: dict[str, float] | None = None) -> None:
54
+ async with self._redis.pipeline(transaction=False) as pipe:
55
+ for field, count in ints.items():
56
+ if count:
57
+ pipe.hincrby(self._counters, field, count)
58
+ for field, value in (floats or {}).items():
59
+ if value:
60
+ pipe.hincrbyfloat(self._counters, field, value)
61
+ await pipe.execute()
62
+
63
+ async def counters(self) -> dict[str, float]:
64
+ raw = await self._redis.hgetall(self._counters)
65
+ out: dict[str, float] = {}
66
+ for key, value in raw.items():
67
+ name = key.decode() if isinstance(key, bytes) else str(key)
68
+ text = value.decode() if isinstance(value, bytes) else str(value)
69
+ try:
70
+ out[name] = float(text)
71
+ except ValueError:
72
+ continue
73
+ return out
74
+
75
+ async def reset(self) -> None:
76
+ await self._redis.delete(self._counters, self._near, self._latency)
77
+
78
+ # ---------------------------------------------------------------- latency
79
+ async def record_latency(self, result: str, ms: float) -> None:
80
+ """Keep a rolling sample per outcome so admin/stats can show percentiles."""
81
+ await self._redis.lpush(f"{self._latency}:{result}", f"{ms:.3f}")
82
+ await self._redis.ltrim(f"{self._latency}:{result}", 0, 4_999)
83
+
84
+ async def latency_percentiles(self, result: str) -> dict[str, float]:
85
+ raw = await self._redis.lrange(f"{self._latency}:{result}", 0, -1)
86
+ values = sorted(float(v) for v in raw)
87
+ if not values:
88
+ return {}
89
+
90
+ def pct(p: float) -> float:
91
+ idx = min(len(values) - 1, max(0, round((p / 100.0) * (len(values) - 1))))
92
+ return round(values[idx], 2)
93
+
94
+ return {
95
+ "count": len(values),
96
+ "p50": pct(50),
97
+ "p95": pct(95),
98
+ "p99": pct(99),
99
+ "min": round(values[0], 2),
100
+ "max": round(values[-1], 2),
101
+ }
102
+
103
+ # -------------------------------------------------------------- near misses
104
+ async def record_near_miss(self, miss: NearMiss) -> None:
105
+ payload = asdict(miss)
106
+ if not self._settings.log_prompts:
107
+ payload["prompt"] = payload["prompt"][:120]
108
+ payload["matched_prompt"] = payload["matched_prompt"][:120]
109
+ await self._redis.lpush(self._near, json.dumps(payload))
110
+ await self._redis.ltrim(self._near, 0, self._settings.near_miss_log_size - 1)
111
+
112
+ async def near_misses(self, limit: int = 50) -> list[dict[str, Any]]:
113
+ raw = await self._redis.lrange(self._near, 0, limit - 1)
114
+ out: list[dict[str, Any]] = []
115
+ for item in raw:
116
+ try:
117
+ out.append(json.loads(item))
118
+ except json.JSONDecodeError:
119
+ continue
120
+ return out
121
+
122
+ async def near_miss_histogram(self, buckets: int = 20) -> list[dict[str, Any]]:
123
+ """How many near misses sit in each similarity band.
124
+
125
+ Read this as: "if I dropped the threshold to X, this many more requests
126
+ would have been served from cache."
127
+ """
128
+ misses = await self.near_misses(limit=self._settings.near_miss_log_size)
129
+ hist = [0] * buckets
130
+ for miss in misses:
131
+ sim = float(miss.get("similarity", 0.0))
132
+ idx = min(buckets - 1, max(0, int(sim * buckets)))
133
+ hist[idx] += 1
134
+ width = 1.0 / buckets
135
+ cumulative = 0
136
+ rows: list[dict[str, Any]] = []
137
+ for i in range(buckets - 1, -1, -1):
138
+ cumulative += hist[i]
139
+ rows.append(
140
+ {
141
+ "similarity_at_least": round(i * width, 3),
142
+ "in_band": hist[i],
143
+ "would_hit_if_threshold_here": cumulative,
144
+ }
145
+ )
146
+ return rows
147
+
148
+ @staticmethod
149
+ def now() -> float:
150
+ return time.time()
@@ -0,0 +1,63 @@
1
+ """Single-flight: collapse identical in-flight misses into one upstream call.
2
+
3
+ Ten users asking the same brand-new question at the same moment is a cache
4
+ stampede: every one of them misses, and every one of them pays for a full
5
+ generation. The first caller here does the work and the rest await its result.
6
+ On a cold cache under real concurrency this is worth more than a few points of
7
+ similarity threshold.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import Any, TypeVar
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ class SingleFlight:
20
+ def __init__(self) -> None:
21
+ self._inflight: dict[str, asyncio.Future[Any]] = {}
22
+ self._lock = asyncio.Lock()
23
+ self.coalesced = 0
24
+
25
+ async def do(self, key: str, factory: Callable[[], Awaitable[T]]) -> tuple[T, bool]:
26
+ """Run ``factory`` for ``key``, or await the run already in progress.
27
+
28
+ Returns ``(result, was_coalesced)``.
29
+ """
30
+ async with self._lock:
31
+ existing = self._inflight.get(key)
32
+ if existing is not None:
33
+ self.coalesced += 1
34
+ waiter = existing
35
+ joined = True
36
+ else:
37
+ waiter = asyncio.get_running_loop().create_future()
38
+ self._inflight[key] = waiter
39
+ joined = False
40
+
41
+ if joined:
42
+ return await asyncio.shield(waiter), True
43
+
44
+ try:
45
+ result = await factory()
46
+ except BaseException as exc: # propagate to every waiter, then re-raise
47
+ async with self._lock:
48
+ self._inflight.pop(key, None)
49
+ if not waiter.done():
50
+ waiter.set_exception(exc)
51
+ # Keep the future's exception from being reported as unretrieved.
52
+ waiter.exception()
53
+ raise
54
+ else:
55
+ async with self._lock:
56
+ self._inflight.pop(key, None)
57
+ if not waiter.done():
58
+ waiter.set_result(result)
59
+ return result, False
60
+
61
+ @property
62
+ def in_flight(self) -> int:
63
+ return len(self._inflight)