icyloom 1.0.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.
- icyloom/__init__.py +10 -0
- icyloom/__main__.py +8 -0
- icyloom/admin.py +225 -0
- icyloom/breaker.py +157 -0
- icyloom/cache.py +237 -0
- icyloom/cli.py +399 -0
- icyloom/clientcheck.py +225 -0
- icyloom/config.py +233 -0
- icyloom/context/__init__.py +12 -0
- icyloom/context/anchors.py +149 -0
- icyloom/context/audit.py +92 -0
- icyloom/context/compress.py +119 -0
- icyloom/context/drawer.py +300 -0
- icyloom/context/ratelimit.py +81 -0
- icyloom/context/redact.py +82 -0
- icyloom/context/sandbox.py +184 -0
- icyloom/context/sessions.py +166 -0
- icyloom/context/store.py +341 -0
- icyloom/context/verify.py +56 -0
- icyloom/dashboard.py +710 -0
- icyloom/data/normalization_rules.json +5832 -0
- icyloom/data/tools_registry.json +10 -0
- icyloom/db.py +416 -0
- icyloom/gateway.py +796 -0
- icyloom/ledger.py +166 -0
- icyloom/logsetup.py +48 -0
- icyloom/mcpserver.py +462 -0
- icyloom/migrate.py +131 -0
- icyloom/normalize.py +278 -0
- icyloom/paths.py +200 -0
- icyloom/pool.py +103 -0
- icyloom/py.typed +0 -0
- icyloom/runner.py +76 -0
- icyloom/static/dashboard.html +2462 -0
- icyloom/static/logo.svg +68 -0
- icyloom/tokenizer.py +75 -0
- icyloom/toolproxy.py +788 -0
- icyloom/upstream.py +141 -0
- icyloom-1.0.0.dist-info/METADATA +580 -0
- icyloom-1.0.0.dist-info/RECORD +44 -0
- icyloom-1.0.0.dist-info/WHEEL +5 -0
- icyloom-1.0.0.dist-info/entry_points.txt +2 -0
- icyloom-1.0.0.dist-info/licenses/LICENSE +21 -0
- icyloom-1.0.0.dist-info/top_level.txt +1 -0
icyloom/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""IcyLoom — passerelle locale unifiée (cache LLM + tiroir de contexte).
|
|
2
|
+
|
|
3
|
+
Projet autonome : aucun import, aucun lien, aucun chemin vers un autre projet.
|
|
4
|
+
Tout est piloté par un unique fichier de configuration YAML.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__all__ = ["__version__", "PROJECT_NAME"]
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|
|
10
|
+
PROJECT_NAME = "icyloom"
|
icyloom/__main__.py
ADDED
icyloom/admin.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""API d'administration et métriques (8153).
|
|
2
|
+
|
|
3
|
+
Séparée de la passerelle LLM : elle ne relaie aucun trafic de modèle, elle
|
|
4
|
+
expose l'état du système (cache, tiroir, ledger, relais d'outils).
|
|
5
|
+
|
|
6
|
+
Accès distant : si `server.remote_access_token` est défini, les routes
|
|
7
|
+
protégées exigent l'en-tête `Authorization: Bearer <token>`. Sinon l'API
|
|
8
|
+
reste ouverte sur la boucle locale.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
19
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
20
|
+
|
|
21
|
+
from .cache import CacheStore
|
|
22
|
+
from .config import Config
|
|
23
|
+
from .context.store import ContextStore
|
|
24
|
+
from .dashboard import SnapshotCache, dashboard_html, snapshot
|
|
25
|
+
from .db import Database
|
|
26
|
+
from .ledger import Ledger
|
|
27
|
+
from .toolproxy import ToolProxy
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("icyloom.admin")
|
|
30
|
+
|
|
31
|
+
#: Routes qui exigent un jeton quand un jeton est configuré.
|
|
32
|
+
def create_app(config: Config) -> FastAPI:
|
|
33
|
+
"""Construit l'API d'administration."""
|
|
34
|
+
db = Database(config)
|
|
35
|
+
db.ensure_schema()
|
|
36
|
+
cache = CacheStore(db, ttl_seconds=config.cache.ttl_seconds,
|
|
37
|
+
max_entries=config.cache.max_entries)
|
|
38
|
+
context = ContextStore(config)
|
|
39
|
+
tools = ToolProxy(config)
|
|
40
|
+
ledger = Ledger(db, enabled=config.ledger.enabled)
|
|
41
|
+
|
|
42
|
+
# L'instantané est recalculé en tâche de fond : l'API répond en
|
|
43
|
+
# millisecondes même quand la base est très chargée.
|
|
44
|
+
snapshots = SnapshotCache(config, hours=24, days=14, interval=2.0)
|
|
45
|
+
|
|
46
|
+
@asynccontextmanager
|
|
47
|
+
async def lifespan(_app: FastAPI):
|
|
48
|
+
snapshots.start()
|
|
49
|
+
try:
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
snapshots.stop()
|
|
53
|
+
|
|
54
|
+
app = FastAPI(title="IcyLoom Admin", version="1.0.0", lifespan=lifespan)
|
|
55
|
+
app.state.config = config
|
|
56
|
+
app.state.snapshots = snapshots
|
|
57
|
+
|
|
58
|
+
@app.middleware("http")
|
|
59
|
+
async def _auth(request: Request, call_next):
|
|
60
|
+
token = config.server.remote_access_token
|
|
61
|
+
if token and request.url.path != "/health":
|
|
62
|
+
header = request.headers.get("authorization", "")
|
|
63
|
+
if header != f"Bearer {token}":
|
|
64
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
65
|
+
return await call_next(request)
|
|
66
|
+
|
|
67
|
+
@app.get("/health")
|
|
68
|
+
async def health():
|
|
69
|
+
return {"status": "healthy", "service": "icyloom-admin"}
|
|
70
|
+
|
|
71
|
+
# -- dashboard --------------------------------------------------------
|
|
72
|
+
@app.get("/", response_class=HTMLResponse)
|
|
73
|
+
@app.get("/dashboard", response_class=HTMLResponse)
|
|
74
|
+
async def dashboard():
|
|
75
|
+
"""Console HTML : toutes les métriques et les graphiques."""
|
|
76
|
+
return HTMLResponse(dashboard_html())
|
|
77
|
+
|
|
78
|
+
@app.get("/assets/logo.svg")
|
|
79
|
+
async def logo():
|
|
80
|
+
"""Logo officiel, servi tel quel depuis le paquet.
|
|
81
|
+
|
|
82
|
+
Le même fichier est versionné dans le dépôt : la console, le README et
|
|
83
|
+
GitHub partagent donc une seule et même marque.
|
|
84
|
+
"""
|
|
85
|
+
path = Path(__file__).resolve().parent / "static" / "logo.svg"
|
|
86
|
+
if not path.exists():
|
|
87
|
+
raise HTTPException(status_code=404, detail="logo absent")
|
|
88
|
+
return FileResponse(path, media_type="image/svg+xml")
|
|
89
|
+
|
|
90
|
+
@app.get("/api/dashboard")
|
|
91
|
+
async def dashboard_data(hours: int = 24, days: int = 14):
|
|
92
|
+
"""Instantané unique consommé par la console (lecture seule).
|
|
93
|
+
|
|
94
|
+
La fenêtre par défaut vient du cache partagé (recalculé en continu
|
|
95
|
+
en tâche de fond) : la réponse est instantanée. Les autres fenêtres
|
|
96
|
+
sont calculées à la demande.
|
|
97
|
+
"""
|
|
98
|
+
if (hours, days) == (snapshots.hours, snapshots.days):
|
|
99
|
+
return snapshots.get()
|
|
100
|
+
return snapshot(config, hours=hours, days=days)
|
|
101
|
+
|
|
102
|
+
@app.get("/api/gateway")
|
|
103
|
+
async def gateway_state():
|
|
104
|
+
"""État living de la passerelle (circuits, amonts) : relais vers 8150.
|
|
105
|
+
|
|
106
|
+
Le dashboard reste sur une seule origine : la page ne parle jamais
|
|
107
|
+
directement à un autre port.
|
|
108
|
+
"""
|
|
109
|
+
url = f"http://{config.server.host}:{config.ports.gateway_http}/api/status"
|
|
110
|
+
try:
|
|
111
|
+
import httpx
|
|
112
|
+
|
|
113
|
+
async with httpx.AsyncClient(timeout=2.0) as client:
|
|
114
|
+
response = await client.get(url)
|
|
115
|
+
return {"ok": response.status_code == 200,
|
|
116
|
+
"status_code": response.status_code,
|
|
117
|
+
"state": response.json() if response.status_code == 200 else None}
|
|
118
|
+
except Exception as exc: # pragma: no cover - passerelle absente
|
|
119
|
+
return {"ok": False, "error": str(exc), "state": None}
|
|
120
|
+
|
|
121
|
+
@app.get("/api/status")
|
|
122
|
+
async def status():
|
|
123
|
+
return {
|
|
124
|
+
"service": "icyloom",
|
|
125
|
+
"ports": config.ports.model_dump(),
|
|
126
|
+
"cache": cache.stats(),
|
|
127
|
+
"context": context.stats(),
|
|
128
|
+
"tools": tools.stats(),
|
|
129
|
+
"ledger": ledger.summary(),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@app.get("/api/ledger")
|
|
133
|
+
async def ledger_endpoint():
|
|
134
|
+
return ledger.summary()
|
|
135
|
+
|
|
136
|
+
@app.get("/api/ledger/daily")
|
|
137
|
+
async def ledger_daily(days: int = 14):
|
|
138
|
+
return {"series": ledger.daily(days)}
|
|
139
|
+
|
|
140
|
+
@app.get("/api/metrics")
|
|
141
|
+
async def metrics():
|
|
142
|
+
with db.cursor(commit=False) as cur:
|
|
143
|
+
row = cur.execute(
|
|
144
|
+
"SELECT COUNT(*) AS calls,"
|
|
145
|
+
" SUM(CASE WHEN kind='hit' THEN 1 ELSE 0 END) AS hits,"
|
|
146
|
+
" COALESCE(SUM(prompt_tokens + completion_tokens),0) AS real_tokens,"
|
|
147
|
+
" COALESCE(SUM(saved_prompt + saved_completion),0) AS saved_tokens"
|
|
148
|
+
" FROM llm_events"
|
|
149
|
+
).fetchone()
|
|
150
|
+
calls = int(row["calls"])
|
|
151
|
+
hits = int(row["hits"] or 0)
|
|
152
|
+
real = int(row["real_tokens"])
|
|
153
|
+
saved = int(row["saved_tokens"])
|
|
154
|
+
return {
|
|
155
|
+
"calls": calls,
|
|
156
|
+
"hits": hits,
|
|
157
|
+
"hit_rate_pct": round(hits / calls * 100.0, 2) if calls else 0.0,
|
|
158
|
+
"tokens_real": real,
|
|
159
|
+
"tokens_saved": saved,
|
|
160
|
+
"saved_pct": round(saved / (saved + real) * 100.0, 2) if (saved + real) else 0.0,
|
|
161
|
+
"note": "tokens réels (usage amont). Aucune estimation.",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@app.get("/api/cache/stats")
|
|
165
|
+
async def cache_stats():
|
|
166
|
+
return cache.stats()
|
|
167
|
+
|
|
168
|
+
@app.post("/api/cache-clear")
|
|
169
|
+
async def cache_clear(request: Request):
|
|
170
|
+
"""Purge du cache LLM. Exige un en-tête de confirmation explicite."""
|
|
171
|
+
if request.headers.get("X-IcyLoom-Confirm", "").lower() not in ("1", "true", "yes"):
|
|
172
|
+
raise HTTPException(status_code=400, detail={
|
|
173
|
+
"error": "confirmation_required",
|
|
174
|
+
"message": "Envoyer X-IcyLoom-Confirm: 1 pour confirmer.",
|
|
175
|
+
})
|
|
176
|
+
payload = await request.json() if await request.body() else {}
|
|
177
|
+
removed = cache.clear(payload.get("key") if isinstance(payload, dict) else None)
|
|
178
|
+
return {"removed": removed}
|
|
179
|
+
|
|
180
|
+
@app.get("/api/context/stats")
|
|
181
|
+
async def context_stats():
|
|
182
|
+
return context.stats()
|
|
183
|
+
|
|
184
|
+
@app.get("/api/sessions")
|
|
185
|
+
async def sessions():
|
|
186
|
+
return {"sessions": [s.__dict__ for s in context.sessions.list()]}
|
|
187
|
+
|
|
188
|
+
@app.post("/api/sessions/purge")
|
|
189
|
+
async def sessions_purge(request: Request):
|
|
190
|
+
"""Purge d'une session. Exige un en-tête de confirmation explicite."""
|
|
191
|
+
if request.headers.get("X-IcyLoom-Confirm", "").lower() not in ("1", "true", "yes"):
|
|
192
|
+
raise HTTPException(status_code=400, detail={
|
|
193
|
+
"error": "confirmation_required",
|
|
194
|
+
"message": "Envoyer X-IcyLoom-Confirm: 1 pour confirmer.",
|
|
195
|
+
})
|
|
196
|
+
payload = await request.json() if await request.body() else {}
|
|
197
|
+
session_id = payload.get("session_id") if isinstance(payload, dict) else None
|
|
198
|
+
if not session_id:
|
|
199
|
+
raise HTTPException(status_code=400, detail={"error": "session_id_required"})
|
|
200
|
+
return context.sessions.purge(str(session_id))
|
|
201
|
+
|
|
202
|
+
@app.get("/api/tools")
|
|
203
|
+
async def tools_list():
|
|
204
|
+
return {"servers": tools.server_names(), "stats": tools.stats()}
|
|
205
|
+
|
|
206
|
+
@app.post("/api/tools/clear")
|
|
207
|
+
async def tools_clear(request: Request):
|
|
208
|
+
if request.headers.get("X-IcyLoom-Confirm", "").lower() not in ("1", "true", "yes"):
|
|
209
|
+
raise HTTPException(status_code=400, detail={
|
|
210
|
+
"error": "confirmation_required",
|
|
211
|
+
"message": "Envoyer X-IcyLoom-Confirm: 1 pour confirmer.",
|
|
212
|
+
})
|
|
213
|
+
payload = await request.json() if await request.body() else {}
|
|
214
|
+
server = payload.get("server") if isinstance(payload, dict) else None
|
|
215
|
+
return tools.clear_cache(server)
|
|
216
|
+
|
|
217
|
+
return app
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def run_admin(config: Config, host: str | None = None, port: int | None = None) -> None:
|
|
221
|
+
"""Lance l'API d'administration (bloquant)."""
|
|
222
|
+
import uvicorn
|
|
223
|
+
|
|
224
|
+
uvicorn.run(create_app(config), host=host or config.server.host,
|
|
225
|
+
port=port or config.ports.admin, log_level="warning")
|
icyloom/breaker.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Circuit breaker par amont.
|
|
2
|
+
|
|
3
|
+
Trois états : *closed* (tout passe), *open* (tout est refusé), *half-open*
|
|
4
|
+
(une sonde est autorisée pour vérifier le retour à la normale).
|
|
5
|
+
|
|
6
|
+
Le point important : le **réarmement est réel**. Passé le délai
|
|
7
|
+
``reset_seconds``, l'état *open* devient *half-open* et une seule sonde est
|
|
8
|
+
autorisée. Si la sonde réussit, le breaker revient *closed* et le compteur
|
|
9
|
+
d'échecs est remis à zéro. Sans ce réarmement effectif, un amont recoveries
|
|
10
|
+
resterait bloqué indéfiniment.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BreakerState(str, Enum):
|
|
22
|
+
CLOSED = "closed"
|
|
23
|
+
OPEN = "open"
|
|
24
|
+
HALF_OPEN = "half_open"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class BreakerStatus:
|
|
29
|
+
"""Photographie de l'état d'un breaker."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
state: BreakerState
|
|
33
|
+
failures: int
|
|
34
|
+
opened_at: float | None
|
|
35
|
+
seconds_until_reset: float
|
|
36
|
+
probes_in_flight: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CircuitBreaker:
|
|
41
|
+
"""Disjoncteur par amont."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
failure_threshold: int = 3
|
|
45
|
+
reset_seconds: float = 30.0
|
|
46
|
+
half_open_probes: int = 1
|
|
47
|
+
_failures: int = field(default=0, init=False)
|
|
48
|
+
_state: BreakerState = field(default=BreakerState.CLOSED, init=False)
|
|
49
|
+
_opened_at: float | None = field(default=None, init=False)
|
|
50
|
+
_probes: int = field(default=0, init=False)
|
|
51
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, init=False)
|
|
52
|
+
|
|
53
|
+
# -- état -------------------------------------------------------------
|
|
54
|
+
@property
|
|
55
|
+
def state(self) -> BreakerState:
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._refresh()
|
|
58
|
+
return self._state
|
|
59
|
+
|
|
60
|
+
def _refresh(self) -> None:
|
|
61
|
+
"""Fait expirer l'état *open* une fois le délai écoulé (appel verrouillé)."""
|
|
62
|
+
if self._state is BreakerState.OPEN and self._opened_at is not None:
|
|
63
|
+
if time.monotonic() - self._opened_at >= self.reset_seconds:
|
|
64
|
+
self._state = BreakerState.HALF_OPEN
|
|
65
|
+
self._probes = 0
|
|
66
|
+
|
|
67
|
+
def allows(self) -> bool:
|
|
68
|
+
"""Le breaker autorise-t-il une requête maintenant ?"""
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._refresh()
|
|
71
|
+
if self._state is BreakerState.CLOSED:
|
|
72
|
+
return True
|
|
73
|
+
if self._state is BreakerState.HALF_OPEN:
|
|
74
|
+
if self._probes < self.half_open_probes:
|
|
75
|
+
self._probes += 1
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
# -- feedback ---------------------------------------------------------
|
|
81
|
+
def record_success(self) -> None:
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._failures = 0
|
|
84
|
+
self._state = BreakerState.CLOSED
|
|
85
|
+
self._opened_at = None
|
|
86
|
+
self._probes = 0
|
|
87
|
+
|
|
88
|
+
def record_failure(self) -> None:
|
|
89
|
+
with self._lock:
|
|
90
|
+
self._failures += 1
|
|
91
|
+
if self._state is BreakerState.HALF_OPEN:
|
|
92
|
+
# La sonde a échoué : on rouvre immédiatement.
|
|
93
|
+
self._state = BreakerState.OPEN
|
|
94
|
+
self._opened_at = time.monotonic()
|
|
95
|
+
self._probes = 0
|
|
96
|
+
elif self._failures >= self.failure_threshold:
|
|
97
|
+
self._state = BreakerState.OPEN
|
|
98
|
+
self._opened_at = time.monotonic()
|
|
99
|
+
|
|
100
|
+
def reset(self) -> None:
|
|
101
|
+
"""Remise à zéro manuelle."""
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._failures = 0
|
|
104
|
+
self._state = BreakerState.CLOSED
|
|
105
|
+
self._opened_at = None
|
|
106
|
+
self._probes = 0
|
|
107
|
+
|
|
108
|
+
def status(self) -> BreakerStatus:
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._refresh()
|
|
111
|
+
remaining = 0.0
|
|
112
|
+
if self._state is BreakerState.OPEN and self._opened_at is not None:
|
|
113
|
+
remaining = max(0.0, self.reset_seconds - (time.monotonic() - self._opened_at))
|
|
114
|
+
return BreakerStatus(
|
|
115
|
+
name=self.name,
|
|
116
|
+
state=self._state,
|
|
117
|
+
failures=self._failures,
|
|
118
|
+
opened_at=self._opened_at,
|
|
119
|
+
seconds_until_reset=remaining,
|
|
120
|
+
probes_in_flight=self._probes,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class BreakerRegistry:
|
|
125
|
+
"""Un breaker par amont."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, failure_threshold: int = 3, reset_seconds: float = 30.0,
|
|
128
|
+
half_open_probes: int = 1) -> None:
|
|
129
|
+
self._breakers: dict[str, CircuitBreaker] = {}
|
|
130
|
+
self._failure_threshold = failure_threshold
|
|
131
|
+
self._reset_seconds = reset_seconds
|
|
132
|
+
self._half_open_probes = half_open_probes
|
|
133
|
+
self._lock = threading.Lock()
|
|
134
|
+
|
|
135
|
+
def for_name(self, name: str) -> CircuitBreaker:
|
|
136
|
+
with self._lock:
|
|
137
|
+
breaker = self._breakers.get(name)
|
|
138
|
+
if breaker is None:
|
|
139
|
+
breaker = CircuitBreaker(
|
|
140
|
+
name=name,
|
|
141
|
+
failure_threshold=self._failure_threshold,
|
|
142
|
+
reset_seconds=self._reset_seconds,
|
|
143
|
+
half_open_probes=self._half_open_probes,
|
|
144
|
+
)
|
|
145
|
+
self._breakers[name] = breaker
|
|
146
|
+
return breaker
|
|
147
|
+
|
|
148
|
+
def all_status(self) -> list[BreakerStatus]:
|
|
149
|
+
with self._lock:
|
|
150
|
+
breakers = list(self._breakers.values())
|
|
151
|
+
return [b.status() for b in breakers]
|
|
152
|
+
|
|
153
|
+
def reset_all(self) -> None:
|
|
154
|
+
with self._lock:
|
|
155
|
+
breakers = list(self._breakers.values())
|
|
156
|
+
for breaker in breakers:
|
|
157
|
+
breaker.reset()
|
icyloom/cache.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Cache LLM : stockage SQLite, expiration (TTL), éviction LRU.
|
|
2
|
+
|
|
3
|
+
Le cache est **une seule table** de la base unifiée. Les entrées sont
|
|
4
|
+
adressées par la clé canonique calculée par le moteur de normalisation :
|
|
5
|
+
deux requêtes équivalentes partagent donc la même entrée.
|
|
6
|
+
|
|
7
|
+
Politique d'éviction : le plafond d'entrées est respecté en retirant les
|
|
8
|
+
entrées les moins récemment utilisées (``last_accessed``). Une entrée
|
|
9
|
+
expirée est ignorée à la lecture et purgée par ``purge_expired``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sqlite3
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .db import Database
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class CacheEntry:
|
|
25
|
+
"""Une entrée de cache lue depuis la base."""
|
|
26
|
+
|
|
27
|
+
key: str
|
|
28
|
+
normalized: str
|
|
29
|
+
original: str | None
|
|
30
|
+
response: str
|
|
31
|
+
is_stream: bool
|
|
32
|
+
provider: str | None
|
|
33
|
+
model: str | None
|
|
34
|
+
created_at: str
|
|
35
|
+
last_accessed: str
|
|
36
|
+
access_count: int
|
|
37
|
+
prompt_tokens: int = 0
|
|
38
|
+
completion_tokens: int = 0
|
|
39
|
+
|
|
40
|
+
def payload(self) -> Any:
|
|
41
|
+
"""Réponse décodée (JSON si possible, sinon texte brut)."""
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(self.response)
|
|
44
|
+
except (json.JSONDecodeError, TypeError):
|
|
45
|
+
return self.response
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CacheStore:
|
|
49
|
+
"""Interface du cache LLM sur la base unifiée."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, database: Database, ttl_seconds: int = 604800,
|
|
52
|
+
max_entries: int = 50000) -> None:
|
|
53
|
+
self.db = database
|
|
54
|
+
self.ttl_seconds = ttl_seconds
|
|
55
|
+
self.max_entries = max_entries
|
|
56
|
+
|
|
57
|
+
# -- lecture ----------------------------------------------------------
|
|
58
|
+
def get(self, key: str, bump: bool = True) -> CacheEntry | None:
|
|
59
|
+
"""Entrée non expirée pour `key` (None si absente ou expirée).
|
|
60
|
+
|
|
61
|
+
`bump` incrémente le compteur d'accès et l'horodatage de dernier
|
|
62
|
+
usage. L'appelant qui possède déjà une transaction (la passerelle)
|
|
63
|
+
passe `bump=False` et appelle `bump_access` dans *sa* transaction :
|
|
64
|
+
une seule écriture WAL par requête.
|
|
65
|
+
"""
|
|
66
|
+
with self.db.cursor(commit=False) as cur:
|
|
67
|
+
row = cur.execute(
|
|
68
|
+
"SELECT key, normalized, original, response, is_stream, provider, model,"
|
|
69
|
+
" created_at, last_accessed, access_count, prompt_tokens, completion_tokens"
|
|
70
|
+
" FROM cache_entries WHERE key = ?",
|
|
71
|
+
(key,),
|
|
72
|
+
).fetchone()
|
|
73
|
+
if row is None:
|
|
74
|
+
return None
|
|
75
|
+
if self._expired(str(row["created_at"])):
|
|
76
|
+
return None
|
|
77
|
+
if bump:
|
|
78
|
+
self.touch(key)
|
|
79
|
+
return CacheEntry(
|
|
80
|
+
key=row["key"],
|
|
81
|
+
normalized=row["normalized"],
|
|
82
|
+
original=row["original"],
|
|
83
|
+
response=row["response"],
|
|
84
|
+
is_stream=bool(row["is_stream"]),
|
|
85
|
+
provider=row["provider"],
|
|
86
|
+
model=row["model"],
|
|
87
|
+
created_at=row["created_at"],
|
|
88
|
+
last_accessed=row["last_accessed"],
|
|
89
|
+
access_count=int(row["access_count"]),
|
|
90
|
+
prompt_tokens=int(row["prompt_tokens"] or 0),
|
|
91
|
+
completion_tokens=int(row["completion_tokens"] or 0),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def touch(self, key: str) -> None:
|
|
95
|
+
"""Compteur d'accès + horodatage, dans une transaction dédiée."""
|
|
96
|
+
with self.db.transaction() as cur:
|
|
97
|
+
self.bump_access(cur, key)
|
|
98
|
+
|
|
99
|
+
def bump_access(self, cur: Any, key: str) -> None:
|
|
100
|
+
"""Incrémente le compteur d'accès sur un curseur déjà ouvert.
|
|
101
|
+
|
|
102
|
+
Utilisé par la passerelle : le journal, le ledger et ce compteur
|
|
103
|
+
partagent alors **une seule** transaction.
|
|
104
|
+
"""
|
|
105
|
+
cur.execute(
|
|
106
|
+
"UPDATE cache_entries SET access_count = access_count + 1,"
|
|
107
|
+
" last_accessed = datetime('now') WHERE key = ?",
|
|
108
|
+
(key,),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def search(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
|
|
112
|
+
"""Recherche d'entrées par fragment du texte normalisé."""
|
|
113
|
+
limit = max(1, min(int(limit), 100))
|
|
114
|
+
with self.db.cursor(commit=False) as cur:
|
|
115
|
+
rows = cur.execute(
|
|
116
|
+
"SELECT key, normalized, provider, model, created_at, access_count"
|
|
117
|
+
" FROM cache_entries WHERE normalized LIKE ?"
|
|
118
|
+
" ORDER BY access_count DESC, last_accessed DESC LIMIT ?",
|
|
119
|
+
(f"%{query}%", limit),
|
|
120
|
+
).fetchall()
|
|
121
|
+
return [
|
|
122
|
+
{
|
|
123
|
+
"key": r["key"],
|
|
124
|
+
"normalized": r["normalized"],
|
|
125
|
+
"provider": r["provider"],
|
|
126
|
+
"model": r["model"],
|
|
127
|
+
"created_at": r["created_at"],
|
|
128
|
+
"access_count": r["access_count"],
|
|
129
|
+
}
|
|
130
|
+
for r in rows
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
# -- écriture ---------------------------------------------------------
|
|
134
|
+
def set(
|
|
135
|
+
self,
|
|
136
|
+
key: str,
|
|
137
|
+
normalized: str,
|
|
138
|
+
response: str,
|
|
139
|
+
original: str | None = None,
|
|
140
|
+
is_stream: bool = False,
|
|
141
|
+
provider: str | None = None,
|
|
142
|
+
model: str | None = None,
|
|
143
|
+
raw_hash: str | None = None,
|
|
144
|
+
prompt_tokens: int = 0,
|
|
145
|
+
completion_tokens: int = 0,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Enregistre une réponse et applique la politique d'éviction."""
|
|
148
|
+
with self.db.transaction() as cur:
|
|
149
|
+
cur.execute(
|
|
150
|
+
"INSERT INTO cache_entries"
|
|
151
|
+
" (key, normalized, original, response, is_stream, provider, model, raw_hash,"
|
|
152
|
+
" prompt_tokens, completion_tokens)"
|
|
153
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?)"
|
|
154
|
+
" ON CONFLICT(key) DO UPDATE SET"
|
|
155
|
+
" normalized=excluded.normalized, original=excluded.original,"
|
|
156
|
+
" response=excluded.response, is_stream=excluded.is_stream,"
|
|
157
|
+
" provider=excluded.provider, model=excluded.model,"
|
|
158
|
+
" prompt_tokens=excluded.prompt_tokens,"
|
|
159
|
+
" completion_tokens=excluded.completion_tokens,"
|
|
160
|
+
" created_at=datetime('now'), last_accessed=datetime('now'),"
|
|
161
|
+
" access_count=cache_entries.access_count + 1",
|
|
162
|
+
(key, normalized, original, response, int(bool(is_stream)), provider, model,
|
|
163
|
+
raw_hash, int(prompt_tokens), int(completion_tokens)),
|
|
164
|
+
)
|
|
165
|
+
total = int(cur.execute("SELECT COUNT(*) FROM cache_entries").fetchone()[0])
|
|
166
|
+
if total > self.max_entries:
|
|
167
|
+
excess = total - self.max_entries
|
|
168
|
+
cur.execute(
|
|
169
|
+
"DELETE FROM cache_entries WHERE key IN ("
|
|
170
|
+
" SELECT key FROM cache_entries ORDER BY last_accessed ASC LIMIT ?)",
|
|
171
|
+
(excess,),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# -- maintenance ------------------------------------------------------
|
|
175
|
+
def clear(self, key: str | None = None) -> int:
|
|
176
|
+
"""Vide tout le cache ou une entrée précise. Renvoie le nombre supprimé."""
|
|
177
|
+
with self.db.transaction() as cur:
|
|
178
|
+
if key:
|
|
179
|
+
cur.execute("DELETE FROM cache_entries WHERE key = ?", (key,))
|
|
180
|
+
return int(cur.rowcount or 0)
|
|
181
|
+
cur.execute("DELETE FROM cache_entries")
|
|
182
|
+
return int(cur.rowcount or 0)
|
|
183
|
+
|
|
184
|
+
def purge_expired(self) -> int:
|
|
185
|
+
"""Supprime les entrées plus anciennes que le TTL."""
|
|
186
|
+
with self.db.transaction() as cur:
|
|
187
|
+
cur.execute(
|
|
188
|
+
"DELETE FROM cache_entries WHERE created_at < datetime('now', ?)",
|
|
189
|
+
(f"-{int(self.ttl_seconds)} seconds",),
|
|
190
|
+
)
|
|
191
|
+
return int(cur.rowcount or 0)
|
|
192
|
+
|
|
193
|
+
def stats(self) -> dict[str, Any]:
|
|
194
|
+
"""Compteurs d'usage (taux de succès = entrées réellement servies)."""
|
|
195
|
+
with self.db.cursor(commit=False) as cur:
|
|
196
|
+
total = int(cur.execute("SELECT COUNT(*) FROM cache_entries").fetchone()[0])
|
|
197
|
+
hits = int(cur.execute(
|
|
198
|
+
"SELECT COALESCE(SUM(access_count - 1), 0) FROM cache_entries"
|
|
199
|
+
).fetchone()[0] or 0)
|
|
200
|
+
by_provider = {
|
|
201
|
+
r[0]: r[1]
|
|
202
|
+
for r in cur.execute(
|
|
203
|
+
"SELECT provider, COUNT(*) FROM cache_entries GROUP BY provider"
|
|
204
|
+
).fetchall()
|
|
205
|
+
}
|
|
206
|
+
by_model = {
|
|
207
|
+
r[0]: r[1]
|
|
208
|
+
for r in cur.execute(
|
|
209
|
+
"SELECT model, COUNT(*) FROM cache_entries GROUP BY model"
|
|
210
|
+
" ORDER BY COUNT(*) DESC LIMIT 20"
|
|
211
|
+
).fetchall()
|
|
212
|
+
}
|
|
213
|
+
oldest = cur.execute(
|
|
214
|
+
"SELECT MIN(created_at) FROM cache_entries"
|
|
215
|
+
).fetchone()[0]
|
|
216
|
+
return {
|
|
217
|
+
"entries": total,
|
|
218
|
+
"reuse_count": hits,
|
|
219
|
+
"by_provider": by_provider,
|
|
220
|
+
"by_model": by_model,
|
|
221
|
+
"oldest_entry": oldest,
|
|
222
|
+
"ttl_seconds": self.ttl_seconds,
|
|
223
|
+
"max_entries": self.max_entries,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# -- interne ----------------------------------------------------------
|
|
227
|
+
def _expired(self, created_at: str) -> bool:
|
|
228
|
+
try:
|
|
229
|
+
with self.db.cursor(commit=False) as cur:
|
|
230
|
+
row = cur.execute(
|
|
231
|
+
"SELECT (julianday('now') - julianday(?)) * 86400 AS age FROM ?",
|
|
232
|
+
(created_at, created_at),
|
|
233
|
+
).fetchone()
|
|
234
|
+
age = float(row["age"]) if row and row["age"] is not None else 0.0
|
|
235
|
+
except sqlite3.Error:
|
|
236
|
+
return False
|
|
237
|
+
return age > self.ttl_seconds
|