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
cachellm/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""CacheLLM: a drop-in semantic cache for OpenAI-compatible LLM APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
__all__ = ["__version__", "main"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None: # pragma: no cover - thin console-script shim
|
|
11
|
+
from cachellm.cli import app
|
|
12
|
+
|
|
13
|
+
app()
|
cachellm/__main__.py
ADDED
cachellm/api/__init__.py
ADDED
cachellm/api/app.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""FastAPI application assembly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
from fastapi import FastAPI, Request
|
|
11
|
+
from fastapi.exceptions import RequestValidationError
|
|
12
|
+
from fastapi.responses import JSONResponse, Response
|
|
13
|
+
|
|
14
|
+
from cachellm import __version__
|
|
15
|
+
from cachellm.api import routes_admin, routes_chat
|
|
16
|
+
from cachellm.api.deps import AppState, build_state, shutdown_state
|
|
17
|
+
from cachellm.errors import CacheLLMError
|
|
18
|
+
from cachellm.logging_setup import configure_logging
|
|
19
|
+
from cachellm.observability.tracing import setup_tracing
|
|
20
|
+
from cachellm.settings import Settings, get_settings
|
|
21
|
+
|
|
22
|
+
log = structlog.get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
DESCRIPTION = """
|
|
25
|
+
A drop-in semantic cache for OpenAI-compatible LLM APIs.
|
|
26
|
+
|
|
27
|
+
Point your client's `base_url` at this service and repeated or reworded
|
|
28
|
+
questions are served from cache instead of the model. Every response carries
|
|
29
|
+
`X-Cache` headers explaining what happened.
|
|
30
|
+
""".strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_app(settings: Settings | None = None, state: AppState | None = None) -> FastAPI:
|
|
34
|
+
settings = settings or get_settings()
|
|
35
|
+
configure_logging(settings)
|
|
36
|
+
|
|
37
|
+
@contextlib.asynccontextmanager
|
|
38
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
39
|
+
app.state.cachellm = state or await build_state(settings)
|
|
40
|
+
current: AppState = app.state.cachellm
|
|
41
|
+
if not current.settings.auth_enabled:
|
|
42
|
+
log.warning("auth_disabled", hint="set CACHELLM_API_KEYS before exposing this proxy")
|
|
43
|
+
if current.settings.shadow_mode:
|
|
44
|
+
log.warning("shadow_mode_on", hint="cache hits are logged, never served")
|
|
45
|
+
if not current.settings.is_calibrated and current.settings.threshold_default <= 0:
|
|
46
|
+
log.warning(
|
|
47
|
+
"threshold_not_calibrated",
|
|
48
|
+
embedding_model=current.settings.embedding_model,
|
|
49
|
+
using=current.settings.calibrated_threshold,
|
|
50
|
+
hint="measured safe thresholds span 0.89-0.98 across models; run "
|
|
51
|
+
"`python -m bench.compare_models` on your own data before trusting this",
|
|
52
|
+
)
|
|
53
|
+
log.info(
|
|
54
|
+
"cachellm_started",
|
|
55
|
+
version=__version__,
|
|
56
|
+
cache_available=current.cache_available,
|
|
57
|
+
embedding_model=current.embedder.name,
|
|
58
|
+
default_provider=current.settings.default_provider,
|
|
59
|
+
)
|
|
60
|
+
try:
|
|
61
|
+
yield
|
|
62
|
+
finally:
|
|
63
|
+
if state is None:
|
|
64
|
+
await shutdown_state(current)
|
|
65
|
+
|
|
66
|
+
app = FastAPI(
|
|
67
|
+
title="CacheLLM",
|
|
68
|
+
version=__version__,
|
|
69
|
+
description=DESCRIPTION,
|
|
70
|
+
lifespan=lifespan,
|
|
71
|
+
docs_url="/docs",
|
|
72
|
+
openapi_url="/openapi.json",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
setup_tracing(settings, app)
|
|
76
|
+
|
|
77
|
+
app.include_router(routes_chat.router, prefix="/v1", tags=["openai"])
|
|
78
|
+
app.include_router(routes_admin.router, prefix="/admin", tags=["admin"])
|
|
79
|
+
|
|
80
|
+
# --------------------------------------------------------------- errors
|
|
81
|
+
@app.exception_handler(CacheLLMError)
|
|
82
|
+
async def _cachellm_error(_request: Request, exc: CacheLLMError) -> JSONResponse:
|
|
83
|
+
return JSONResponse(status_code=exc.status_code, content=exc.envelope())
|
|
84
|
+
|
|
85
|
+
@app.exception_handler(RequestValidationError)
|
|
86
|
+
async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
87
|
+
first = exc.errors()[0] if exc.errors() else {}
|
|
88
|
+
location = ".".join(str(p) for p in first.get("loc", ())[1:]) or None
|
|
89
|
+
return JSONResponse(
|
|
90
|
+
status_code=400,
|
|
91
|
+
content={
|
|
92
|
+
"error": {
|
|
93
|
+
"message": first.get("msg", "Invalid request."),
|
|
94
|
+
"type": "invalid_request_error",
|
|
95
|
+
"param": location,
|
|
96
|
+
"code": None,
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# --------------------------------------------------------------- health
|
|
102
|
+
@app.get("/healthz", tags=["ops"])
|
|
103
|
+
async def healthz() -> dict[str, Any]:
|
|
104
|
+
return {"status": "ok", "version": __version__}
|
|
105
|
+
|
|
106
|
+
@app.get("/readyz", tags=["ops"])
|
|
107
|
+
async def readyz(request: Request) -> JSONResponse:
|
|
108
|
+
current: AppState = request.app.state.cachellm
|
|
109
|
+
ready = current.cache_available
|
|
110
|
+
body = {
|
|
111
|
+
"status": "ready" if ready else "degraded",
|
|
112
|
+
"cache_available": current.cache_available,
|
|
113
|
+
"degraded_reason": current.degraded_reason or None,
|
|
114
|
+
"embedding_model": current.embedder.name,
|
|
115
|
+
}
|
|
116
|
+
# Degraded still returns 200: the proxy is serving, just without cache.
|
|
117
|
+
return JSONResponse(body, status_code=200)
|
|
118
|
+
|
|
119
|
+
@app.get("/metrics", tags=["ops"])
|
|
120
|
+
async def metrics(request: Request) -> Response:
|
|
121
|
+
current: AppState = request.app.state.cachellm
|
|
122
|
+
if not current.settings.metrics_enabled:
|
|
123
|
+
return Response(status_code=404)
|
|
124
|
+
if current.vectors is not None:
|
|
125
|
+
with contextlib.suppress(Exception):
|
|
126
|
+
current.metrics.entries.set(await current.vectors.count())
|
|
127
|
+
body, content_type = current.metrics.render()
|
|
128
|
+
return Response(content=body, media_type=content_type)
|
|
129
|
+
|
|
130
|
+
@app.get("/", include_in_schema=False)
|
|
131
|
+
async def root() -> dict[str, Any]:
|
|
132
|
+
return {
|
|
133
|
+
"name": "CacheLLM",
|
|
134
|
+
"version": __version__,
|
|
135
|
+
"docs": "/docs",
|
|
136
|
+
"openai_base_url": "/v1",
|
|
137
|
+
"endpoints": ["/v1/chat/completions", "/v1/models", "/admin/stats", "/metrics"],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return app
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
app = create_app # uvicorn factory entry point
|
cachellm/api/auth.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Client authentication.
|
|
2
|
+
|
|
3
|
+
The proxy holds provider credentials, so an unauthenticated proxy on a public
|
|
4
|
+
address is an open wallet. Keys are compared in constant time and never logged.
|
|
5
|
+
Auth is off only when no keys are configured, which is the local-dev case and
|
|
6
|
+
is reported loudly at startup.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hmac
|
|
12
|
+
|
|
13
|
+
from fastapi import Request
|
|
14
|
+
|
|
15
|
+
from cachellm.errors import AuthError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def extract_key(request: Request) -> str:
|
|
19
|
+
header = request.headers.get("authorization", "")
|
|
20
|
+
if header.lower().startswith("bearer "):
|
|
21
|
+
return header[7:].strip()
|
|
22
|
+
return request.headers.get("api-key", "").strip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verify(request: Request, keys: set[str]) -> None:
|
|
26
|
+
if not keys:
|
|
27
|
+
return
|
|
28
|
+
presented = extract_key(request)
|
|
29
|
+
if not presented:
|
|
30
|
+
raise AuthError("Missing API key. Pass it as `Authorization: Bearer <key>`.")
|
|
31
|
+
for known in keys:
|
|
32
|
+
if hmac.compare_digest(presented, known):
|
|
33
|
+
return
|
|
34
|
+
raise AuthError()
|
cachellm/api/deps.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Application state and its lifecycle.
|
|
2
|
+
|
|
3
|
+
One deliberate design choice here: the cache **fails open**. If Redis is
|
|
4
|
+
unreachable or the index cannot be built, the proxy keeps serving by forwarding
|
|
5
|
+
every request upstream and reports itself degraded. A cache that takes an
|
|
6
|
+
application down when it breaks is worse than no cache, and this is the first
|
|
7
|
+
thing a reviewer looks for.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
from fastapi import Request
|
|
17
|
+
|
|
18
|
+
from cachellm.cache.analytics import Analytics
|
|
19
|
+
from cachellm.cache.coalesce import SingleFlight
|
|
20
|
+
from cachellm.cache.exact_store import ExactStore
|
|
21
|
+
from cachellm.cache.redis_client import build_redis
|
|
22
|
+
from cachellm.cache.service import CacheService
|
|
23
|
+
from cachellm.cache.vector_store import VectorStore
|
|
24
|
+
from cachellm.embeddings import build_embedder
|
|
25
|
+
from cachellm.embeddings.base import Embedder
|
|
26
|
+
from cachellm.observability.metrics import Metrics, get_metrics
|
|
27
|
+
from cachellm.providers.registry import ProviderRegistry
|
|
28
|
+
from cachellm.settings import Settings
|
|
29
|
+
|
|
30
|
+
log = structlog.get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AppState:
|
|
35
|
+
settings: Settings
|
|
36
|
+
metrics: Metrics
|
|
37
|
+
providers: ProviderRegistry
|
|
38
|
+
embedder: Embedder
|
|
39
|
+
singleflight: SingleFlight = field(default_factory=SingleFlight)
|
|
40
|
+
redis: Any = None
|
|
41
|
+
vectors: VectorStore | None = None
|
|
42
|
+
exact: ExactStore | None = None
|
|
43
|
+
analytics: Analytics | None = None
|
|
44
|
+
cache: CacheService | None = None
|
|
45
|
+
cache_available: bool = False
|
|
46
|
+
degraded_reason: str = ""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def caching_on(self) -> bool:
|
|
50
|
+
return self.cache_available and self.cache is not None and self.settings.enabled
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def build_state(
|
|
54
|
+
settings: Settings,
|
|
55
|
+
*,
|
|
56
|
+
embedder: Embedder | None = None,
|
|
57
|
+
providers: ProviderRegistry | None = None,
|
|
58
|
+
) -> AppState:
|
|
59
|
+
state = AppState(
|
|
60
|
+
settings=settings,
|
|
61
|
+
metrics=get_metrics(),
|
|
62
|
+
providers=providers or ProviderRegistry(settings),
|
|
63
|
+
embedder=embedder or build_embedder(settings),
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
redis = build_redis(settings)
|
|
67
|
+
await redis.ping()
|
|
68
|
+
vectors = VectorStore(redis, settings)
|
|
69
|
+
await vectors.connect()
|
|
70
|
+
exact = ExactStore(redis, settings)
|
|
71
|
+
analytics = Analytics(redis, settings)
|
|
72
|
+
state.redis = redis
|
|
73
|
+
state.vectors = vectors
|
|
74
|
+
state.exact = exact
|
|
75
|
+
state.analytics = analytics
|
|
76
|
+
state.cache = CacheService(
|
|
77
|
+
settings=settings,
|
|
78
|
+
embedder=state.embedder,
|
|
79
|
+
vectors=vectors,
|
|
80
|
+
exact=exact,
|
|
81
|
+
analytics=analytics,
|
|
82
|
+
)
|
|
83
|
+
state.cache_available = True
|
|
84
|
+
except Exception as exc: # degrade, never crash the proxy
|
|
85
|
+
state.cache_available = False
|
|
86
|
+
state.degraded_reason = f"{type(exc).__name__}: {exc}"
|
|
87
|
+
log.error("cache_unavailable_failing_open", error=state.degraded_reason)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
await state.embedder.warmup()
|
|
91
|
+
except Exception as exc: # embeddings are optional for pass-through
|
|
92
|
+
log.error("embedder_warmup_failed", error=str(exc)[:300])
|
|
93
|
+
state.cache_available = False
|
|
94
|
+
state.degraded_reason = f"embedder unavailable: {exc}"
|
|
95
|
+
|
|
96
|
+
return state
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def shutdown_state(state: AppState) -> None:
|
|
100
|
+
await state.providers.close()
|
|
101
|
+
if state.redis is not None:
|
|
102
|
+
await state.redis.aclose()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_state(request: Request) -> AppState:
|
|
106
|
+
return request.app.state.cachellm # type: ignore[no-any-return]
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Operator endpoints: stats, invalidation, near-miss analysis, threshold tuning.
|
|
2
|
+
|
|
3
|
+
These are what turn the cache from a black box into something a team is willing
|
|
4
|
+
to leave switched on. Every one of them answers a question an operator actually
|
|
5
|
+
asks: what is it doing, what would a different threshold do, and how do I make
|
|
6
|
+
it forget something right now.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import structlog
|
|
15
|
+
from fastapi import APIRouter, Body, Query, Request
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from cachellm.api.auth import verify
|
|
19
|
+
from cachellm.api.deps import get_state
|
|
20
|
+
from cachellm.errors import CacheLLMError
|
|
21
|
+
|
|
22
|
+
log = structlog.get_logger(__name__)
|
|
23
|
+
router = APIRouter()
|
|
24
|
+
|
|
25
|
+
DEFAULT_SWEEP = [0.80, 0.84, 0.86, 0.88, 0.90, 0.92, 0.94, 0.96, 0.98, 0.99]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidateRequest(BaseModel):
|
|
29
|
+
namespace: str | None = None
|
|
30
|
+
model: str | None = None
|
|
31
|
+
all: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LabelledPair(BaseModel):
|
|
35
|
+
a: str
|
|
36
|
+
b: str
|
|
37
|
+
duplicate: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SweepRequest(BaseModel):
|
|
41
|
+
pairs: list[LabelledPair] = Field(min_length=1)
|
|
42
|
+
thresholds: list[float] | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _require_cache(request: Request) -> Any:
|
|
46
|
+
state = get_state(request)
|
|
47
|
+
if not state.cache_available or state.cache is None:
|
|
48
|
+
raise CacheLLMError(
|
|
49
|
+
503,
|
|
50
|
+
f"Cache backend unavailable: {state.degraded_reason or 'not connected'}",
|
|
51
|
+
"api_error",
|
|
52
|
+
code="cache_unavailable",
|
|
53
|
+
)
|
|
54
|
+
return state
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("/stats")
|
|
58
|
+
async def stats(request: Request) -> dict[str, Any]:
|
|
59
|
+
state = get_state(request)
|
|
60
|
+
if state.settings.require_auth_for_admin:
|
|
61
|
+
verify(request, state.settings.client_keys)
|
|
62
|
+
if not state.cache_available or state.cache is None:
|
|
63
|
+
return {
|
|
64
|
+
"cache_available": False,
|
|
65
|
+
"caching_enabled": state.settings.enabled,
|
|
66
|
+
"degraded_reason": state.degraded_reason or "backend not connected",
|
|
67
|
+
}
|
|
68
|
+
data = await state.cache.stats()
|
|
69
|
+
data["cache_available"] = True
|
|
70
|
+
data["caching_enabled"] = state.settings.enabled
|
|
71
|
+
data["shadow_mode"] = state.settings.shadow_mode
|
|
72
|
+
data["in_flight"] = state.singleflight.in_flight
|
|
73
|
+
state.metrics.entries.set(data.get("entries", 0))
|
|
74
|
+
return data
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/config")
|
|
78
|
+
async def config(request: Request) -> dict[str, Any]:
|
|
79
|
+
state = get_state(request)
|
|
80
|
+
if state.settings.require_auth_for_admin:
|
|
81
|
+
verify(request, state.settings.client_keys)
|
|
82
|
+
s = state.settings
|
|
83
|
+
return {
|
|
84
|
+
"enabled": s.enabled,
|
|
85
|
+
"shadow_mode": s.shadow_mode,
|
|
86
|
+
"embedding": {
|
|
87
|
+
"backend": s.embedding_backend,
|
|
88
|
+
"model": s.embedding_model,
|
|
89
|
+
"dim": s.expected_dim(),
|
|
90
|
+
},
|
|
91
|
+
"thresholds": {
|
|
92
|
+
c: s.threshold_for(c)
|
|
93
|
+
for c in (
|
|
94
|
+
"factual",
|
|
95
|
+
"classification",
|
|
96
|
+
"creative",
|
|
97
|
+
"volatile",
|
|
98
|
+
"conversational",
|
|
99
|
+
"default",
|
|
100
|
+
)
|
|
101
|
+
},
|
|
102
|
+
"ttl_seconds": {
|
|
103
|
+
c: s.ttl_for(c)
|
|
104
|
+
for c in (
|
|
105
|
+
"factual",
|
|
106
|
+
"classification",
|
|
107
|
+
"creative",
|
|
108
|
+
"volatile",
|
|
109
|
+
"conversational",
|
|
110
|
+
"default",
|
|
111
|
+
)
|
|
112
|
+
},
|
|
113
|
+
"rules": {
|
|
114
|
+
"max_cacheable_temperature": s.max_cacheable_temperature,
|
|
115
|
+
"cache_multi_turn": s.cache_multi_turn,
|
|
116
|
+
"cache_json_mode": s.cache_json_mode,
|
|
117
|
+
"cache_tool_calls": s.cache_tool_calls,
|
|
118
|
+
"pii_guard": s.pii_guard,
|
|
119
|
+
"strip_filler_words": s.strip_filler_words,
|
|
120
|
+
"max_prompt_chars": s.max_prompt_chars,
|
|
121
|
+
},
|
|
122
|
+
"default_provider": s.default_provider,
|
|
123
|
+
"auth_enabled": s.auth_enabled,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@router.post("/invalidate")
|
|
128
|
+
async def invalidate(request: Request, body: InvalidateRequest) -> dict[str, Any]:
|
|
129
|
+
state = _require_cache(request)
|
|
130
|
+
if state.settings.require_auth_for_admin:
|
|
131
|
+
verify(request, state.settings.client_keys)
|
|
132
|
+
if not (body.namespace or body.model or body.all):
|
|
133
|
+
raise CacheLLMError(400, "Pass one of `namespace`, `model` or `all`.", param="namespace")
|
|
134
|
+
removed = await state.cache.invalidate(
|
|
135
|
+
namespace=body.namespace, model=body.model, drop_all=body.all
|
|
136
|
+
)
|
|
137
|
+
return {
|
|
138
|
+
"removed_keys": removed,
|
|
139
|
+
"namespace": body.namespace,
|
|
140
|
+
"model": body.model,
|
|
141
|
+
"all": body.all,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.get("/near-misses")
|
|
146
|
+
async def near_misses(request: Request, limit: int = Query(50, ge=1, le=500)) -> dict[str, Any]:
|
|
147
|
+
state = _require_cache(request)
|
|
148
|
+
if state.settings.require_auth_for_admin:
|
|
149
|
+
verify(request, state.settings.client_keys)
|
|
150
|
+
rows = await state.analytics.near_misses(limit=limit)
|
|
151
|
+
return {"count": len(rows), "near_misses": rows}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@router.get("/near-miss-histogram")
|
|
155
|
+
async def near_miss_histogram(
|
|
156
|
+
request: Request, buckets: int = Query(20, ge=5, le=100)
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Cumulative view: how many more requests each lower threshold would serve."""
|
|
159
|
+
state = _require_cache(request)
|
|
160
|
+
if state.settings.require_auth_for_admin:
|
|
161
|
+
verify(request, state.settings.client_keys)
|
|
162
|
+
return {"histogram": await state.analytics.near_miss_histogram(buckets=buckets)}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@router.get("/entries")
|
|
166
|
+
async def entries(request: Request, limit: int = Query(20, ge=1, le=200)) -> dict[str, Any]:
|
|
167
|
+
state = _require_cache(request)
|
|
168
|
+
if state.settings.require_auth_for_admin:
|
|
169
|
+
verify(request, state.settings.client_keys)
|
|
170
|
+
keys = await state.vectors.scan_keys()
|
|
171
|
+
rows = []
|
|
172
|
+
for key in keys[:limit]:
|
|
173
|
+
entry_id = key.split(state.settings.entry_prefix, 1)[-1]
|
|
174
|
+
entry = await state.vectors.get(entry_id)
|
|
175
|
+
if entry is None:
|
|
176
|
+
continue
|
|
177
|
+
summary = entry.summary()
|
|
178
|
+
if not state.settings.log_prompts:
|
|
179
|
+
summary["prompt"] = summary["prompt"][:120]
|
|
180
|
+
summary["response_text"] = summary["response_text"][:160]
|
|
181
|
+
rows.append(summary)
|
|
182
|
+
return {"total_keys": len(keys), "returned": len(rows), "entries": rows}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@router.post("/reset-stats")
|
|
186
|
+
async def reset_stats(request: Request) -> dict[str, str]:
|
|
187
|
+
state = _require_cache(request)
|
|
188
|
+
if state.settings.require_auth_for_admin:
|
|
189
|
+
verify(request, state.settings.client_keys)
|
|
190
|
+
await state.analytics.reset()
|
|
191
|
+
return {"status": "counters reset"}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@router.post("/threshold-sweep")
|
|
195
|
+
async def threshold_sweep(request: Request, body: SweepRequest = Body(...)) -> dict[str, Any]:
|
|
196
|
+
"""Score labelled prompt pairs at several thresholds.
|
|
197
|
+
|
|
198
|
+
This is the answer to the only question that really matters when tuning a
|
|
199
|
+
semantic cache: at threshold X, how many extra requests do I serve, and how
|
|
200
|
+
many of those are actually different questions wearing similar words?
|
|
201
|
+
|
|
202
|
+
``precision`` is the share of would-be cache hits that were genuine
|
|
203
|
+
duplicates. ``false_positive_rate`` is the share of *non*-duplicate pairs
|
|
204
|
+
that would wrongly hit. Read them together: hit rate you can only spend if
|
|
205
|
+
precision holds.
|
|
206
|
+
"""
|
|
207
|
+
state = get_state(request)
|
|
208
|
+
if state.settings.require_auth_for_admin:
|
|
209
|
+
verify(request, state.settings.client_keys)
|
|
210
|
+
|
|
211
|
+
thresholds = sorted(body.thresholds or DEFAULT_SWEEP)
|
|
212
|
+
texts_a = [p.a for p in body.pairs]
|
|
213
|
+
texts_b = [p.b for p in body.pairs]
|
|
214
|
+
labels = np.array([p.duplicate for p in body.pairs], dtype=bool)
|
|
215
|
+
|
|
216
|
+
vectors_a = await state.embedder.embed_batch(texts_a)
|
|
217
|
+
vectors_b = await state.embedder.embed_batch(texts_b)
|
|
218
|
+
sims = np.array(
|
|
219
|
+
[float(np.dot(a, b)) for a, b in zip(vectors_a, vectors_b, strict=True)],
|
|
220
|
+
dtype=np.float32,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
positives = int(labels.sum())
|
|
224
|
+
negatives = int((~labels).sum())
|
|
225
|
+
rows: list[dict[str, Any]] = []
|
|
226
|
+
for threshold in thresholds:
|
|
227
|
+
predicted = sims >= threshold
|
|
228
|
+
tp = int((predicted & labels).sum())
|
|
229
|
+
fp = int((predicted & ~labels).sum())
|
|
230
|
+
fn = int((~predicted & labels).sum())
|
|
231
|
+
precision = tp / (tp + fp) if (tp + fp) else 1.0
|
|
232
|
+
recall = tp / positives if positives else 0.0
|
|
233
|
+
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
|
|
234
|
+
rows.append(
|
|
235
|
+
{
|
|
236
|
+
"threshold": round(threshold, 4),
|
|
237
|
+
"would_hit": tp + fp,
|
|
238
|
+
"hit_rate": round((tp + fp) / len(body.pairs), 4),
|
|
239
|
+
"true_hits": tp,
|
|
240
|
+
"false_hits": fp,
|
|
241
|
+
"missed_duplicates": fn,
|
|
242
|
+
"precision": round(precision, 4),
|
|
243
|
+
"recall": round(recall, 4),
|
|
244
|
+
"f1": round(f1, 4),
|
|
245
|
+
"false_positive_rate": round(fp / negatives, 4) if negatives else 0.0,
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
best = max(rows, key=lambda r: r["f1"]) if rows else None
|
|
250
|
+
safe = [r for r in rows if r["false_positive_rate"] <= 0.01]
|
|
251
|
+
return {
|
|
252
|
+
"pairs": len(body.pairs),
|
|
253
|
+
"duplicates": positives,
|
|
254
|
+
"non_duplicates": negatives,
|
|
255
|
+
"embedding_model": state.embedder.name,
|
|
256
|
+
"similarity": {
|
|
257
|
+
"duplicates_mean": round(float(sims[labels].mean()), 4) if positives else None,
|
|
258
|
+
"non_duplicates_mean": round(float(sims[~labels].mean()), 4) if negatives else None,
|
|
259
|
+
},
|
|
260
|
+
"sweep": rows,
|
|
261
|
+
"best_f1": best,
|
|
262
|
+
"lowest_threshold_under_1pct_false_positives": (
|
|
263
|
+
min(safe, key=lambda r: r["threshold"]) if safe else None
|
|
264
|
+
),
|
|
265
|
+
}
|