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 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
@@ -0,0 +1,8 @@
1
+ """Point d'entrée ``python -m icyloom``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
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