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.
- cachellm/__init__.py +13 -0
- cachellm/__main__.py +6 -0
- cachellm/api/__init__.py +5 -0
- cachellm/api/app.py +143 -0
- cachellm/api/auth.py +34 -0
- cachellm/api/deps.py +106 -0
- cachellm/api/routes_admin.py +265 -0
- cachellm/api/routes_chat.py +385 -0
- cachellm/api/sse.py +98 -0
- cachellm/cache/__init__.py +3 -0
- cachellm/cache/analytics.py +150 -0
- cachellm/cache/coalesce.py +63 -0
- cachellm/cache/entry.py +92 -0
- cachellm/cache/exact_store.py +33 -0
- cachellm/cache/keys.py +124 -0
- cachellm/cache/policy.py +134 -0
- cachellm/cache/redis_client.py +22 -0
- cachellm/cache/service.py +332 -0
- cachellm/cache/vector_store.py +217 -0
- cachellm/cli.py +122 -0
- cachellm/embeddings/__init__.py +19 -0
- cachellm/embeddings/base.py +38 -0
- cachellm/embeddings/fastembed_backend.py +75 -0
- cachellm/embeddings/hash_backend.py +42 -0
- cachellm/errors.py +72 -0
- cachellm/logging_setup.py +56 -0
- cachellm/models.py +181 -0
- cachellm/observability/__init__.py +6 -0
- cachellm/observability/metrics.py +147 -0
- cachellm/observability/tracing.py +107 -0
- cachellm/pricing.py +108 -0
- cachellm/providers/__init__.py +7 -0
- cachellm/providers/base.py +84 -0
- cachellm/providers/bedrock.py +238 -0
- cachellm/providers/fake.py +56 -0
- cachellm/providers/openai_compat.py +131 -0
- cachellm/providers/registry.py +96 -0
- cachellm/py.typed +0 -0
- cachellm/settings.py +230 -0
- cachellm_proxy-0.1.0.dist-info/METADATA +550 -0
- cachellm_proxy-0.1.0.dist-info/RECORD +43 -0
- cachellm_proxy-0.1.0.dist-info/WHEEL +4 -0
- 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,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)
|