icycache 0.3.2__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.
- icycache/__init__.py +32 -0
- icycache/__main__.py +9 -0
- icycache/cli.py +171 -0
- icycache/config/icycache.gateway.json +111 -0
- icycache/config/icycache.json +149 -0
- icycache/config/normalization_rules.json +5832 -0
- icycache/config/semantic_cache.json +8 -0
- icycache/config/tools_registry.json +10 -0
- icycache/src/icy_cache_store.py +217 -0
- icycache/src/icy_core.py +380 -0
- icycache/src/icy_normalize.py +197 -0
- icycache/src/icy_paths.py +76 -0
- icycache/src/icy_semantic.py +197 -0
- icycache/src/mcp_gateway.py +360 -0
- icycache/src/mcp_server.py +1319 -0
- icycache/src/mcp_stdio_bridge.py +83 -0
- icycache/src/mcp_stdio_entry.py +23 -0
- icycache/src/prefix_proxy.py +336 -0
- icycache/src/server.py +1142 -0
- icycache/src/tool_proxy.py +655 -0
- icycache/src/upstream_client.py +98 -0
- icycache/src/upstream_utils.py +135 -0
- icycache/static/dashboard.html +2385 -0
- icycache/static/icywebgl.js +2190 -0
- icycache/static/logo.svg +58 -0
- icycache/static/three.min.js +6 -0
- icycache-0.3.2.dist-info/METADATA +422 -0
- icycache-0.3.2.dist-info/RECORD +31 -0
- icycache-0.3.2.dist-info/WHEEL +4 -0
- icycache-0.3.2.dist-info/entry_points.txt +2 -0
- icycache-0.3.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
IcyCache - MCP Bi-Directionnel avec Cache Sémantique/Exact
|
|
4
|
+
Proxy HTTP + MCP Server (dual transport: stdio + SSE)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import hashlib
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import json
|
|
12
|
+
import sqlite3
|
|
13
|
+
import logging
|
|
14
|
+
import socket
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Optional, Dict, Any, AsyncGenerator
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
23
|
+
from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse, FileResponse, Response
|
|
24
|
+
import httpx
|
|
25
|
+
import uvicorn
|
|
26
|
+
from mcp.server.fastmcp import FastMCP
|
|
27
|
+
from tqdm import tqdm
|
|
28
|
+
|
|
29
|
+
# === CONFIGURATION ===
|
|
30
|
+
# Racine du projet derivee du fichier: le code doit fonctionner quel que soit
|
|
31
|
+
# l'emplacement du dossier (D:\IcyCache, un clone, un chemin reseau, Linux...).
|
|
32
|
+
# En mode installe (pip), la racine est le paquet : db/ et logs/ partent alors
|
|
33
|
+
# dans le repertoire utilisateur (icy_paths.DATA_DIR).
|
|
34
|
+
from icy_paths import PROJECT_ROOT, resolve_data_path # noqa: E402
|
|
35
|
+
|
|
36
|
+
CONFIG_FILE = PROJECT_ROOT / "config" / "icycache.json"
|
|
37
|
+
# Les noms historiques CACHEFORGE_* restent acceptes (compatibilite annoncee
|
|
38
|
+
# dans le README): sans cela, les harnais d'isolation anciens ecrivaient dans
|
|
39
|
+
# la base de production au lieu de leur base temporaire.
|
|
40
|
+
DB_FILE = resolve_data_path(
|
|
41
|
+
os.environ.get("ICYCACHE_DB") or os.environ.get("CACHEFORGE_DB"),
|
|
42
|
+
"db/cache.db",
|
|
43
|
+
)
|
|
44
|
+
LOG_FILE = resolve_data_path(
|
|
45
|
+
os.environ.get("ICYCACHE_LOG") or os.environ.get("CACHEFORGE_LOG"),
|
|
46
|
+
"logs/app.log",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Chargement de la configuration
|
|
50
|
+
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
|
51
|
+
CONFIG = json.load(f)
|
|
52
|
+
_TARGET = os.environ.get("ICYCACHE_TARGET") or os.environ.get("CACHEFORGE_TARGET")
|
|
53
|
+
if _TARGET: # tests isoles: upstream factice
|
|
54
|
+
CONFIG['proxy']['target_provider'] = _TARGET
|
|
55
|
+
|
|
56
|
+
# Configuration du logging — JAMAIS sur stdout.
|
|
57
|
+
# Le transport stdio MCP utilise stdout pour JSON-RPC: ecrire des logs dedans corrompt
|
|
58
|
+
# le protocole et tue la connexion. stderr + fichier seulement. De plus on force l'UTF-8
|
|
59
|
+
# avec errors='replace': sous Windows la console est cp1252 et un caractere '✓' fait
|
|
60
|
+
# planter le processus avec UnicodeEncodeError.
|
|
61
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
62
|
+
try:
|
|
63
|
+
if _stream is not None and (_stream.encoding or "").lower().replace("-", "") != "utf8":
|
|
64
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
logging.basicConfig(
|
|
71
|
+
level=getattr(logging, CONFIG['logging']['level']),
|
|
72
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
73
|
+
handlers=[
|
|
74
|
+
logging.FileHandler(LOG_FILE, encoding='utf-8'),
|
|
75
|
+
logging.StreamHandler(sys.stderr),
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
logger = logging.getLogger("IcyCache")
|
|
79
|
+
|
|
80
|
+
# === PROXY D'OUTILS (tool cache + garde anti-boucle) ===
|
|
81
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
82
|
+
try:
|
|
83
|
+
import tool_proxy as tp
|
|
84
|
+
TOOL_PROXY_OK = True
|
|
85
|
+
logger.info("tool_proxy charge: %s serveur(s) routable(s)", len(tp.server_names()))
|
|
86
|
+
except Exception:
|
|
87
|
+
tp = None
|
|
88
|
+
TOOL_PROXY_OK = False
|
|
89
|
+
logger.warning("tool_proxy INDISPONIBLE: %s", __import__("traceback").format_exc())
|
|
90
|
+
|
|
91
|
+
# Normalisation : moteur UNIQUE partage avec la gateway (src/server.py).
|
|
92
|
+
import icy_normalize
|
|
93
|
+
|
|
94
|
+
# DELTA-002: résilience upstream + cache sémantique + tools registry
|
|
95
|
+
from upstream_client import ResilientUpstream, UpstreamError
|
|
96
|
+
try:
|
|
97
|
+
from icy_semantic import normalize_numbers as _norm_nums, SemanticStore, Embedder, accepts as _sem_accepts, index_async as _sem_index
|
|
98
|
+
except ImportError:
|
|
99
|
+
_norm_nums = lambda t: t
|
|
100
|
+
SemanticStore = None
|
|
101
|
+
Embedder = None
|
|
102
|
+
_sem_accepts = None
|
|
103
|
+
_sem_index = None
|
|
104
|
+
|
|
105
|
+
# Load semantic cache config (opt-in, default off)
|
|
106
|
+
_SEM_CFG_FILE = PROJECT_ROOT / "config" / "semantic_cache.json"
|
|
107
|
+
_SEM_CFG = json.loads(_SEM_CFG_FILE.read_text(encoding="utf-8")) if _SEM_CFG_FILE.exists() else {}
|
|
108
|
+
SEMANTIC_ENABLED = os.environ.get("SEMANTIC_ENABLED", "").lower() == "true" or _SEM_CFG.get("semantic_enabled", False)
|
|
109
|
+
SEMANTIC_THRESHOLD = float(os.environ.get("SEMANTIC_THRESHOLD") or _SEM_CFG.get("semantic_threshold", 0.95))
|
|
110
|
+
SEMANTIC_RELAX = float(os.environ.get("SEMANTIC_RELAX_THRESHOLD") or _SEM_CFG.get("semantic_relax_threshold", 0.97))
|
|
111
|
+
|
|
112
|
+
# Load tools registry (DELTA-001 application)
|
|
113
|
+
_TOOLS_REG_FILE = PROJECT_ROOT / "config" / "tools_registry.json"
|
|
114
|
+
TOOLS_REGISTRY = json.loads(_TOOLS_REG_FILE.read_text(encoding="utf-8")) if _TOOLS_REG_FILE.exists() else {}
|
|
115
|
+
if TOOLS_REGISTRY:
|
|
116
|
+
assert TOOLS_REGISTRY.get("total") == 100, f"tools_registry total mismatch: {TOOLS_REGISTRY.get('total')}"
|
|
117
|
+
|
|
118
|
+
# Init semantic store + embedder (lazy, miss silencieux si indispo)
|
|
119
|
+
_sem_store = None
|
|
120
|
+
_sem_embedder = None
|
|
121
|
+
if SEMANTIC_ENABLED and SemanticStore:
|
|
122
|
+
try:
|
|
123
|
+
_sem_store = SemanticStore(_SEM_CFG.get("qdrant_url", "http://127.0.0.1:6333"))
|
|
124
|
+
_sem_embedder = Embedder(
|
|
125
|
+
_SEM_CFG.get("semantic_embed_url", "http://127.0.0.1:1234"),
|
|
126
|
+
model=_SEM_CFG.get("semantic_embed_model"),
|
|
127
|
+
)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.warning("Semantic cache init failed (miss silencieux): %s", e)
|
|
130
|
+
_sem_store = None
|
|
131
|
+
_sem_embedder = None
|
|
132
|
+
|
|
133
|
+
# DELTA-002: pool de clients upstream résilients (une instance par base URL)
|
|
134
|
+
_RESILIENT_UPSTREAMS: Dict[str, ResilientUpstream] = {}
|
|
135
|
+
from urllib.parse import urlparse as _urlparse
|
|
136
|
+
|
|
137
|
+
def _get_resilient_upstream(full_url: str, timeout_s: float = 60.0) -> ResilientUpstream:
|
|
138
|
+
"""Retourne un ResilientUpstream persistant pour la base URL donnée."""
|
|
139
|
+
_p = _urlparse(full_url)
|
|
140
|
+
_base = f"{_p.scheme}://{_p.netloc}"
|
|
141
|
+
if _base not in _RESILIENT_UPSTREAMS:
|
|
142
|
+
_RESILIENT_UPSTREAMS[_base] = ResilientUpstream(_base, timeout_s=timeout_s)
|
|
143
|
+
return _RESILIENT_UPSTREAMS[_base]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class CacheManager:
|
|
147
|
+
def __init__(self, db_path: Path):
|
|
148
|
+
self.db_path = db_path
|
|
149
|
+
self._init_db()
|
|
150
|
+
|
|
151
|
+
def _init_db(self):
|
|
152
|
+
"""Initialise les tables si nécessaire"""
|
|
153
|
+
conn = sqlite3.connect(self.db_path)
|
|
154
|
+
cursor = conn.cursor()
|
|
155
|
+
|
|
156
|
+
# Les tables sont déjà créées par le script d'installation
|
|
157
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
158
|
+
tables = [row[0] for row in cursor.fetchall()]
|
|
159
|
+
conn.close()
|
|
160
|
+
|
|
161
|
+
if 'cache' not in tables:
|
|
162
|
+
raise RuntimeError("Base de données non initialisée. Exécuter 2-FIX_IcyCache.ps1")
|
|
163
|
+
|
|
164
|
+
def get(self, prompt_hash: str) -> Optional[Dict[str, Any]]:
|
|
165
|
+
"""Récupère une entrée du cache"""
|
|
166
|
+
conn = sqlite3.connect(self.db_path)
|
|
167
|
+
cursor = conn.cursor()
|
|
168
|
+
|
|
169
|
+
cursor.execute("""
|
|
170
|
+
SELECT normalized_prompt, original_prompt, response, is_stream,
|
|
171
|
+
provider, model, created_at, access_count
|
|
172
|
+
FROM cache
|
|
173
|
+
WHERE prompt_hash = ? AND created_at >= datetime('now', '-' || ? || ' seconds')
|
|
174
|
+
""", (prompt_hash, CONFIG.get('cache', {}).get('ttl_seconds', 86400)))
|
|
175
|
+
|
|
176
|
+
row = cursor.fetchone()
|
|
177
|
+
|
|
178
|
+
if row:
|
|
179
|
+
# Mise à jour du compteur d'accès
|
|
180
|
+
cursor.execute("""
|
|
181
|
+
UPDATE cache
|
|
182
|
+
SET access_count = access_count + 1, last_accessed = CURRENT_TIMESTAMP
|
|
183
|
+
WHERE prompt_hash = ?
|
|
184
|
+
""", (prompt_hash,))
|
|
185
|
+
conn.commit()
|
|
186
|
+
|
|
187
|
+
conn.close()
|
|
188
|
+
return {
|
|
189
|
+
'normalized_prompt': row[0],
|
|
190
|
+
'original_prompt': row[1],
|
|
191
|
+
'response': row[2],
|
|
192
|
+
'is_stream': bool(row[3]),
|
|
193
|
+
'provider': row[4],
|
|
194
|
+
'model': row[5],
|
|
195
|
+
'created_at': row[6],
|
|
196
|
+
'access_count': row[7] + 1
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
conn.close()
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def set(self, prompt_hash: str, normalized: str, original: str,
|
|
203
|
+
response: str, is_stream: bool, provider: str = None, model: str = None):
|
|
204
|
+
"""Stocke une entrée dans le cache"""
|
|
205
|
+
conn = sqlite3.connect(self.db_path)
|
|
206
|
+
cursor = conn.cursor()
|
|
207
|
+
|
|
208
|
+
cursor.execute("""
|
|
209
|
+
INSERT OR REPLACE INTO cache
|
|
210
|
+
(prompt_hash, normalized_prompt, original_prompt, response, is_stream,
|
|
211
|
+
provider, model, created_at, last_accessed, access_count)
|
|
212
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
|
|
213
|
+
""", (prompt_hash, normalized, original, response, is_stream, provider, model))
|
|
214
|
+
|
|
215
|
+
conn.commit()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# FIX: Rotation du cache (TTL + max_entries)
|
|
219
|
+
ttl = CONFIG.get('cache', {}).get('ttl_seconds', 86400)
|
|
220
|
+
max_entries = CONFIG.get('cache', {}).get('max_entries', 10000)
|
|
221
|
+
|
|
222
|
+
# Supprimer les entrées expirées
|
|
223
|
+
cursor.execute("DELETE FROM cache WHERE created_at < datetime('now', '-' || ? || ' seconds')", (ttl,))
|
|
224
|
+
|
|
225
|
+
# Si on dépasse max_entries, supprimer les plus anciennes
|
|
226
|
+
count = cursor.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
|
|
227
|
+
if count >= max_entries:
|
|
228
|
+
excess = count - max_entries + 1
|
|
229
|
+
cursor.execute("DELETE FROM cache WHERE prompt_hash IN (SELECT prompt_hash FROM cache ORDER BY last_accessed ASC LIMIT ?)", (excess,))
|
|
230
|
+
logger.info(f"CACHE ROTATION: Supprimé {excess} entrées anciennes")
|
|
231
|
+
conn.commit()
|
|
232
|
+
logger.info(f"CACHE SAVE: {prompt_hash[:8]}... (provider={provider}, model={model})")
|
|
233
|
+
conn.close()
|
|
234
|
+
|
|
235
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
236
|
+
"""Retourne les statistiques du cache"""
|
|
237
|
+
conn = sqlite3.connect(self.db_path)
|
|
238
|
+
cursor = conn.cursor()
|
|
239
|
+
|
|
240
|
+
cursor.execute("SELECT COUNT(*) FROM cache")
|
|
241
|
+
total = cursor.fetchone()[0]
|
|
242
|
+
|
|
243
|
+
cursor.execute("SELECT COALESCE(SUM(access_count - 1), 0) FROM cache") # access_count démarre à 1 à l'insertion
|
|
244
|
+
total_hits = cursor.fetchone()[0] or 0
|
|
245
|
+
|
|
246
|
+
cursor.execute("""
|
|
247
|
+
SELECT provider, COUNT(*) as count
|
|
248
|
+
FROM cache
|
|
249
|
+
GROUP BY provider
|
|
250
|
+
""")
|
|
251
|
+
by_provider = {row[0]: row[1] for row in cursor.fetchall()}
|
|
252
|
+
|
|
253
|
+
conn.close()
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
'total_entries': total,
|
|
257
|
+
'total_hits': total_hits,
|
|
258
|
+
'by_provider': by_provider,
|
|
259
|
+
'db_path': str(self.db_path)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
def clear(self):
|
|
263
|
+
"""Vide complètement le cache"""
|
|
264
|
+
conn = sqlite3.connect(self.db_path)
|
|
265
|
+
cursor = conn.cursor()
|
|
266
|
+
cursor.execute("DELETE FROM cache")
|
|
267
|
+
conn.commit()
|
|
268
|
+
conn.close()
|
|
269
|
+
logger.warning("CACHE CLEARED")
|
|
270
|
+
|
|
271
|
+
# === INITIALISATION DES COMPOSANTS ===
|
|
272
|
+
normalizer = icy_normalize.get_default_normalizer()
|
|
273
|
+
cache = CacheManager(DB_FILE)
|
|
274
|
+
|
|
275
|
+
def _init_events():
|
|
276
|
+
conn = sqlite3.connect(DB_FILE)
|
|
277
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS events (
|
|
278
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
279
|
+
kind TEXT NOT NULL, hash TEXT, model_req TEXT, model_real TEXT, stream INTEGER,
|
|
280
|
+
prompt_tokens INTEGER, completion_tokens INTEGER,
|
|
281
|
+
saved_prompt INTEGER DEFAULT 0, saved_completion INTEGER DEFAULT 0,
|
|
282
|
+
latency_ms INTEGER, ttft_ms INTEGER)""")
|
|
283
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_hash ON events(hash, kind)")
|
|
284
|
+
conn.commit(); conn.close()
|
|
285
|
+
_init_events()
|
|
286
|
+
|
|
287
|
+
def log_event(kind, h, model_req, model_real, stream, pt=None, ct=None,
|
|
288
|
+
sp=0, sc=0, latency_ms=None, ttft_ms=None):
|
|
289
|
+
try:
|
|
290
|
+
conn = sqlite3.connect(DB_FILE)
|
|
291
|
+
conn.execute("INSERT INTO events (kind,hash,model_req,model_real,stream,prompt_tokens,"
|
|
292
|
+
"completion_tokens,saved_prompt,saved_completion,latency_ms,ttft_ms) "
|
|
293
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
|
294
|
+
(kind, h, model_req, model_real, int(bool(stream)), pt, ct, sp, sc, latency_ms, ttft_ms))
|
|
295
|
+
conn.commit(); conn.close()
|
|
296
|
+
except Exception:
|
|
297
|
+
logger.exception("events insert failed")
|
|
298
|
+
|
|
299
|
+
def original_usage(h):
|
|
300
|
+
conn = sqlite3.connect(DB_FILE)
|
|
301
|
+
row = conn.execute("SELECT prompt_tokens, completion_tokens, model_real FROM events "
|
|
302
|
+
"WHERE hash=? AND kind='MISS' ORDER BY id DESC LIMIT 1", (h,)).fetchone()
|
|
303
|
+
conn.close()
|
|
304
|
+
return row
|
|
305
|
+
|
|
306
|
+
# === MCP SERVER ===
|
|
307
|
+
# Bind explicite depuis la config: mcp.run(transport='sse') seul ignorerait
|
|
308
|
+
# host/port (defaut SDK 127.0.0.1:8000) et exposerait le port sur le LAN.
|
|
309
|
+
mcp = FastMCP(
|
|
310
|
+
"IcyCache",
|
|
311
|
+
host=CONFIG['mcp'].get('host', '127.0.0.1'),
|
|
312
|
+
port=CONFIG['mcp'].get('port', 8081),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
@mcp.tool()
|
|
316
|
+
def get_cache_stats() -> Dict[str, Any]:
|
|
317
|
+
"""Retourne les statistiques du cache (hits/misses reels lus dans la table events)"""
|
|
318
|
+
stats = cache.get_stats()
|
|
319
|
+
try:
|
|
320
|
+
conn = sqlite3.connect(DB_FILE)
|
|
321
|
+
try:
|
|
322
|
+
kinds = dict(conn.execute("SELECT kind, COUNT(*) FROM events GROUP BY kind").fetchall())
|
|
323
|
+
sp, sc = conn.execute("SELECT COALESCE(SUM(saved_prompt),0), COALESCE(SUM(saved_completion),0) FROM events").fetchone()
|
|
324
|
+
finally:
|
|
325
|
+
conn.close()
|
|
326
|
+
hits, misses = kinds.get("HIT", 0), kinds.get("MISS", 0)
|
|
327
|
+
total = sum(kinds.values())
|
|
328
|
+
stats["requests_hits"] = hits
|
|
329
|
+
stats["requests_misses"] = misses
|
|
330
|
+
stats["requests_bypass"] = kinds.get("BYPASS", 0)
|
|
331
|
+
stats["requests_total"] = total
|
|
332
|
+
# meme denominateur que /api/metrics (src/server.py) : hits / total.
|
|
333
|
+
stats["hit_rate_pct"] = round(100.0 * hits / total, 1) if total else None
|
|
334
|
+
stats["tokens_saved_in"] = sp
|
|
335
|
+
stats["tokens_saved_out"] = sc
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
stats["events_error"] = str(exc)
|
|
338
|
+
return stats
|
|
339
|
+
|
|
340
|
+
@mcp.tool()
|
|
341
|
+
def clear_cache(confirm: str) -> str:
|
|
342
|
+
"""Vide le cache (nécessite confirm='FORCE_DELETE')"""
|
|
343
|
+
if confirm != "FORCE_DELETE":
|
|
344
|
+
return "ÉCHEC: Utilisez confirm='FORCE_DELETE' pour valider la suppression destructive."
|
|
345
|
+
|
|
346
|
+
cache.clear()
|
|
347
|
+
return "SUCCÈS: Cache entièrement vidé."
|
|
348
|
+
|
|
349
|
+
@mcp.tool()
|
|
350
|
+
def normalize_preview(text: str) -> Dict[str, str]:
|
|
351
|
+
"""Prévisualise la normalisation d'un prompt"""
|
|
352
|
+
normalized = normalizer.normalize(text)
|
|
353
|
+
return {
|
|
354
|
+
'original': text,
|
|
355
|
+
'normalized': normalized,
|
|
356
|
+
'hash': normalizer.get_hash(normalized)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@mcp.tool()
|
|
361
|
+
def search_cached_prompts(query: str, limit: int = 10) -> list:
|
|
362
|
+
"""Recherche dans les prompts normalisés du cache"""
|
|
363
|
+
conn = sqlite3.connect(DB_FILE)
|
|
364
|
+
cursor = conn.cursor()
|
|
365
|
+
|
|
366
|
+
cursor.execute("""
|
|
367
|
+
SELECT prompt_hash, normalized_prompt, provider, model, created_at, access_count
|
|
368
|
+
FROM cache
|
|
369
|
+
WHERE normalized_prompt LIKE ?
|
|
370
|
+
ORDER BY access_count DESC
|
|
371
|
+
LIMIT ?
|
|
372
|
+
""", (f'%{query}%', limit))
|
|
373
|
+
|
|
374
|
+
results = []
|
|
375
|
+
for row in cursor.fetchall():
|
|
376
|
+
results.append({
|
|
377
|
+
'hash': row[0][:8] + '...',
|
|
378
|
+
'normalized': row[1],
|
|
379
|
+
'provider': row[2],
|
|
380
|
+
'model': row[3],
|
|
381
|
+
'created_at': row[4],
|
|
382
|
+
'access_count': row[5]
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
conn.close()
|
|
386
|
+
return results
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# === OUTILS DU PROXY D'OUTILS MCP (tool cache + garde anti-boucle) ===
|
|
390
|
+
|
|
391
|
+
@mcp.tool()
|
|
392
|
+
async def proxy_tool(server: str, tool: str, args: dict = None, client: str = "hermes") -> dict:
|
|
393
|
+
"""Appelle un outil MCP via le proxy IcyCache (cache + garde anti-boucle).
|
|
394
|
+
|
|
395
|
+
Routage: Hermes -> IcyCache -> serveur MCP cible (stdio) -> resultat.
|
|
396
|
+
Cache: cle = hash(serveur, outil, args). Une reponse en cache est renvoyee telle
|
|
397
|
+
quelle, sans re-executer l'outil (latence ~0, economie = saved_latency_ms).
|
|
398
|
+
Boucle: meme outil + memes args + memes resultats => bloque par ToolLoopGuard.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
server: nom du serveur (voir list_proxy_servers)
|
|
402
|
+
tool: nom de l'outil a executer
|
|
403
|
+
args: arguments de l'outil (dict JSON), optionnel
|
|
404
|
+
client: identifiant de l'appelant (session agent), optionnel
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
{"ok": true, "cached": false, "latency_ms": 12, "result": ...}
|
|
408
|
+
{"ok": true, "cached": true, "latency_ms": 0, "saved_latency_ms": 840, "result": ...}
|
|
409
|
+
"""
|
|
410
|
+
if not TOOL_PROXY_OK:
|
|
411
|
+
return {"ok": False, "error": "tool_proxy indisponible (voir logs/app.log)"}
|
|
412
|
+
return await tp.call_tool(server, tool, args, client)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@mcp.tool()
|
|
416
|
+
async def proxy_tools(calls: list, client: str = "hermes") -> list:
|
|
417
|
+
"""Appel batch de plusieurs outils MCP via le proxy IcyCache.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
calls: liste de [{"server": "..", "tool": "..", "args": {...}}, ...]
|
|
421
|
+
client: identifiant de l'appelant, optionnel
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
liste des resultats dans le meme ordre (format identique a proxy_tool)
|
|
425
|
+
"""
|
|
426
|
+
if not TOOL_PROXY_OK:
|
|
427
|
+
return [{"ok": False, "error": "tool_proxy indisponible"}]
|
|
428
|
+
return await tp.batch_call(calls or [], client)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@mcp.tool()
|
|
432
|
+
def list_proxy_servers() -> dict:
|
|
433
|
+
"""Liste les serveurs MCP routables via le proxy IcyCache."""
|
|
434
|
+
if not TOOL_PROXY_OK:
|
|
435
|
+
return {"ok": False, "error": "tool_proxy indisponible"}
|
|
436
|
+
names = tp.server_names()
|
|
437
|
+
return {"ok": True, "servers": names, "count": len(names)}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@mcp.tool()
|
|
441
|
+
async def list_proxy_tools(server: str) -> dict:
|
|
442
|
+
"""Liste les outils exposes par un serveur MCP cible (ex: 'github', 'fetch').
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
server: nom du serveur (voir list_proxy_servers)
|
|
446
|
+
"""
|
|
447
|
+
if not TOOL_PROXY_OK:
|
|
448
|
+
return {"ok": False, "error": "tool_proxy indisponible"}
|
|
449
|
+
return await tp.list_server_tools(server)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@mcp.tool()
|
|
453
|
+
def get_tool_cache_stats() -> dict:
|
|
454
|
+
"""Statistiques du cache d'outils (tool_cache) et des evenements (agent_events)."""
|
|
455
|
+
if not TOOL_PROXY_OK:
|
|
456
|
+
return {"ok": False, "error": "tool_proxy indisponible"}
|
|
457
|
+
return tp.stats()
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@mcp.tool()
|
|
461
|
+
def clear_tool_cache(confirm: str) -> dict:
|
|
462
|
+
"""Vide le cache d'outils (necessite confirm='FORCE_DELETE')."""
|
|
463
|
+
if not TOOL_PROXY_OK:
|
|
464
|
+
return {"ok": False, "error": "tool_proxy indisponible"}
|
|
465
|
+
if confirm != "FORCE_DELETE":
|
|
466
|
+
return {"ok": False, "error": "ECHEC: utilisez confirm='FORCE_DELETE' pour valider."}
|
|
467
|
+
return tp.clear_cache()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@mcp.tool()
|
|
471
|
+
async def check_tool_loop(messages: list, warn_after: int = 3, max_cycle: int = 3) -> dict:
|
|
472
|
+
"""Detecte une boucle d'agent dans l'historique de messages (appels d'outils repetee).
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
messages: historique de messages (format OpenAI)
|
|
476
|
+
warn_after: seuil d'alerte (defaut 3)
|
|
477
|
+
max_cycle: longueur maximale du cycle detecte (defaut 3)
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
{"ok": true, "loop": false} ou {"ok": true, "loop": true, "warning": {...}}
|
|
481
|
+
"""
|
|
482
|
+
if not TOOL_PROXY_OK:
|
|
483
|
+
return {"ok": False, "error": "tool_proxy indisponible"}
|
|
484
|
+
hit = tp.llm_messages_guard(messages, warn_after=warn_after, max_cycle=max_cycle)
|
|
485
|
+
if hit:
|
|
486
|
+
return {"ok": True, "loop": True, "warning": hit}
|
|
487
|
+
return {"ok": True, "loop": False}
|
|
488
|
+
|
|
489
|
+
# === PROXY HTTP ===
|
|
490
|
+
app = FastAPI(title="IcyCache Proxy", version="1.0.0")
|
|
491
|
+
|
|
492
|
+
# === STATIC FILES (dashboard assets: three.min.js, icywebgl.js, refs) ===
|
|
493
|
+
try:
|
|
494
|
+
from fastapi.staticfiles import StaticFiles
|
|
495
|
+
_static_dir = PROJECT_ROOT / "static"
|
|
496
|
+
if _static_dir.exists():
|
|
497
|
+
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
|
|
498
|
+
except Exception as _e:
|
|
499
|
+
logging.warning(f"static mount failed: {_e}")
|
|
500
|
+
|
|
501
|
+
# Serve js/assets at root too (dashboard.html references them relatively)
|
|
502
|
+
# Cache-Control: no-store — ces fichiers sont edites en place pendant le
|
|
503
|
+
# developpement ; sans cet en-tete, le navigateur garde l'ancienne version en
|
|
504
|
+
# cache et les correctifs JS n'apparaissent jamais.
|
|
505
|
+
_ASSET_HEADERS = {"Cache-Control": "no-store, must-revalidate"}
|
|
506
|
+
|
|
507
|
+
@app.get("/three.min.js")
|
|
508
|
+
async def _three():
|
|
509
|
+
return FileResponse(str(PROJECT_ROOT / "static" / "three.min.js"),
|
|
510
|
+
media_type="application/javascript", headers=_ASSET_HEADERS)
|
|
511
|
+
|
|
512
|
+
@app.get("/icywebgl.js")
|
|
513
|
+
async def _icywebgl():
|
|
514
|
+
return FileResponse(str(PROJECT_ROOT / "static" / "icywebgl.js"),
|
|
515
|
+
media_type="application/javascript", headers=_ASSET_HEADERS)
|
|
516
|
+
|
|
517
|
+
@app.get("/{asset}.png")
|
|
518
|
+
async def _png(asset: str):
|
|
519
|
+
p = PROJECT_ROOT / "static" / f"{asset}.png"
|
|
520
|
+
if p.exists():
|
|
521
|
+
return FileResponse(str(p), media_type="image/png", headers=_ASSET_HEADERS)
|
|
522
|
+
raise HTTPException(status_code=404)
|
|
523
|
+
|
|
524
|
+
# Champs sans effet sur la sortie. Tout le reste entre dans la clé de cache.
|
|
525
|
+
CACHE_KEY_IGNORED = {"stream", "stream_options", "user"}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
@app.post("/v1/chat/completions")
|
|
530
|
+
async def proxy_chat(request: Request):
|
|
531
|
+
"""Proxy HTTP avec interception bi-directionnelle"""
|
|
532
|
+
t0 = time.perf_counter()
|
|
533
|
+
try:
|
|
534
|
+
body = await request.json()
|
|
535
|
+
except Exception as e:
|
|
536
|
+
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
|
|
537
|
+
|
|
538
|
+
# Extraction et construction du payload de cache complet (évite faux positifs)
|
|
539
|
+
messages = body.get('messages', [])
|
|
540
|
+
if not any(m.get('role') == 'user' for m in messages):
|
|
541
|
+
raise HTTPException(status_code=400, detail="No user message found")
|
|
542
|
+
|
|
543
|
+
# GARDE ANTI-BOUcle (LLM): detecte les repetitions d'appels d'outils dans l'historique.
|
|
544
|
+
# Ne bloque pas la requete: injecte un message system qui casse la boucle.
|
|
545
|
+
if TOOL_PROXY_OK:
|
|
546
|
+
try:
|
|
547
|
+
_loop = tp.llm_messages_guard(messages)
|
|
548
|
+
if _loop:
|
|
549
|
+
_warn = _loop.get("warning", _loop) if isinstance(_loop, dict) else _loop
|
|
550
|
+
logger.warning("LOOP GUARD: %s", _warn)
|
|
551
|
+
messages = list(messages) + [{
|
|
552
|
+
"role": "system",
|
|
553
|
+
"content": ("[IcyCache] Boucle detectee: " + str(_warn) +
|
|
554
|
+
" Variez les arguments ou arretez d'appeler cet outil."),
|
|
555
|
+
}]
|
|
556
|
+
body["messages"] = messages
|
|
557
|
+
except Exception:
|
|
558
|
+
logger.exception("llm_messages_guard")
|
|
559
|
+
|
|
560
|
+
cache_payload = {k: v for k, v in body.items() if k not in CACHE_KEY_IGNORED}
|
|
561
|
+
|
|
562
|
+
# Normalisation du dernier message user (pour logs/display)
|
|
563
|
+
user_prompt = next((m['content'] for m in reversed(messages) if m['role'] == 'user'), '')
|
|
564
|
+
# FIX: Gérer le contenu multimodal (vision) — content peut être une liste
|
|
565
|
+
if isinstance(user_prompt, list):
|
|
566
|
+
# Extraire le texte des blocs textuels
|
|
567
|
+
text_parts = [item.get('text', '') for item in user_prompt if item.get('type') == 'text']
|
|
568
|
+
user_prompt_text = ' '.join(text_parts)
|
|
569
|
+
else:
|
|
570
|
+
user_prompt_text = user_prompt
|
|
571
|
+
normalized = normalizer.normalize(user_prompt_text)
|
|
572
|
+
normalized = _norm_nums(normalized) # DELTA-002: "two plus two" → "2+2"
|
|
573
|
+
logger.info(f"Normalized prompt: {normalized[:100]}...")
|
|
574
|
+
|
|
575
|
+
# DELTA-002: tools_sig — signature du jeu d'outils (anti cross-tool cache poisoning)
|
|
576
|
+
_tools = body.get('tools')
|
|
577
|
+
if _tools:
|
|
578
|
+
_tools_sig = hashlib.sha256(json.dumps(_tools, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:16]
|
|
579
|
+
else:
|
|
580
|
+
_tools_sig = "none"
|
|
581
|
+
cache_payload['_tools_sig'] = _tools_sig
|
|
582
|
+
|
|
583
|
+
# Hash du payload complet (clé de cache robuste)
|
|
584
|
+
p_hash = hashlib.sha256(json.dumps(cache_payload, sort_keys=True).encode('utf-8')).hexdigest()
|
|
585
|
+
# Compatibilite cache v1 : les anciennes cles ne contenaient pas _tools_sig.
|
|
586
|
+
legacy_payload = {k: v for k, v in body.items() if k not in CACHE_KEY_IGNORED}
|
|
587
|
+
legacy_hash = hashlib.sha256(json.dumps(legacy_payload, sort_keys=True).encode('utf-8')).hexdigest()
|
|
588
|
+
|
|
589
|
+
# CHECK CACHE
|
|
590
|
+
# FIX #5: Bypass cache si température > 0 (évite réponses figées non déterministes)
|
|
591
|
+
temp = body.get('temperature', 0.0)
|
|
592
|
+
should_cache = (temp == 0.0)
|
|
593
|
+
if should_cache:
|
|
594
|
+
cached = cache.get(p_hash)
|
|
595
|
+
if cached is None and legacy_hash != p_hash:
|
|
596
|
+
cached = cache.get(legacy_hash)
|
|
597
|
+
if cached is not None:
|
|
598
|
+
p_hash = legacy_hash
|
|
599
|
+
else:
|
|
600
|
+
logger.warning(f"CACHE BYPASS: temperature={temp} > 0.0 (non-deterministic)")
|
|
601
|
+
cached = None
|
|
602
|
+
if cached:
|
|
603
|
+
logger.info(f"CACHE HIT: {p_hash[:8]}... (access_count={cached['access_count']})")
|
|
604
|
+
|
|
605
|
+
# Déballage entrée v2 ({_v:2, message, usage, gen_time_ms}); v1 = message nu
|
|
606
|
+
saved_usage, gen_time, fin = {}, None, "stop"
|
|
607
|
+
if not cached['is_stream']:
|
|
608
|
+
try:
|
|
609
|
+
_s = json.loads(cached['response'])
|
|
610
|
+
except json.JSONDecodeError:
|
|
611
|
+
_s = None
|
|
612
|
+
if isinstance(_s, dict) and _s.get("_v") == 2:
|
|
613
|
+
saved_usage = _s.get("usage") or {}
|
|
614
|
+
gen_time = _s.get("gen_time_ms")
|
|
615
|
+
fin = _s.get("finish_reason") or "stop"
|
|
616
|
+
cached['response'] = json.dumps(_s["message"], ensure_ascii=False)
|
|
617
|
+
try:
|
|
618
|
+
_c = datetime.strptime(cached['created_at'], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
|
|
619
|
+
cache_age_sec = int((datetime.now(timezone.utc) - _c).total_seconds())
|
|
620
|
+
except Exception:
|
|
621
|
+
cache_age_sec = None
|
|
622
|
+
|
|
623
|
+
# Usage d'origine (events) -> saved_*, modèle réel, event HIT
|
|
624
|
+
_o = original_usage(p_hash)
|
|
625
|
+
_sp = (_o[0] if _o else saved_usage.get("prompt_tokens")) or 0
|
|
626
|
+
_sc = (_o[1] if _o else saved_usage.get("completion_tokens")) or 0
|
|
627
|
+
saved_usage = {"prompt_tokens": _sp, "completion_tokens": _sc, "total_tokens": _sp + _sc}
|
|
628
|
+
cached['model'] = (_o[2] if _o else None) or cached['model']
|
|
629
|
+
log_event('HIT', p_hash, body.get('model'), cached['model'], body.get('stream', False),
|
|
630
|
+
sp=_sp, sc=_sc, latency_ms=int((time.perf_counter() - t0) * 1000))
|
|
631
|
+
|
|
632
|
+
# Reconstruction de la réponse
|
|
633
|
+
# FIX: Conversion de format si mismatch entre cache et requête
|
|
634
|
+
requested_stream = body.get('stream', False)
|
|
635
|
+
cached_stream = cached['is_stream']
|
|
636
|
+
|
|
637
|
+
if requested_stream != cached_stream:
|
|
638
|
+
logger.info(f"CACHE FORMAT CONVERSION: cached={'stream' if cached_stream else 'json'}, requested={'stream' if requested_stream else 'json'}")
|
|
639
|
+
|
|
640
|
+
if cached_stream and not requested_stream:
|
|
641
|
+
# Convertir SSE -> JSON (content + tool_calls + finish_reason)
|
|
642
|
+
content_parts, tcs, fin_s = [], {}, None
|
|
643
|
+
for sse_line in cached['response'].split('\n'):
|
|
644
|
+
if not sse_line.startswith('data: ') or sse_line.startswith('data: [DONE]'):
|
|
645
|
+
continue
|
|
646
|
+
try:
|
|
647
|
+
chunk = json.loads(sse_line[6:])
|
|
648
|
+
except Exception:
|
|
649
|
+
continue
|
|
650
|
+
for ch in (chunk.get('choices') or []):
|
|
651
|
+
delta = ch.get('delta') or {}
|
|
652
|
+
if delta.get('content'):
|
|
653
|
+
content_parts.append(delta['content'])
|
|
654
|
+
for tc in (delta.get('tool_calls') or []):
|
|
655
|
+
slot = tcs.setdefault(tc.get('index', 0), {"id": None, "type": "function",
|
|
656
|
+
"function": {"name": "", "arguments": ""}})
|
|
657
|
+
if tc.get('id'):
|
|
658
|
+
slot['id'] = tc['id']
|
|
659
|
+
fn = tc.get('function') or {}
|
|
660
|
+
if fn.get('name'):
|
|
661
|
+
slot['function']['name'] += fn['name']
|
|
662
|
+
if fn.get('arguments'):
|
|
663
|
+
slot['function']['arguments'] += fn['arguments']
|
|
664
|
+
if ch.get('finish_reason'):
|
|
665
|
+
fin_s = ch['finish_reason']
|
|
666
|
+
full_content = ''.join(content_parts)
|
|
667
|
+
msg = {"role": "assistant", "content": full_content if (full_content or not tcs) else None}
|
|
668
|
+
if tcs:
|
|
669
|
+
msg["tool_calls"] = [tcs[i] for i in sorted(tcs)]
|
|
670
|
+
fin = fin_s or "stop"
|
|
671
|
+
cached['response'] = json.dumps(msg, ensure_ascii=False)
|
|
672
|
+
cached['is_stream'] = False
|
|
673
|
+
|
|
674
|
+
elif not cached_stream and requested_stream:
|
|
675
|
+
# Convertir JSON -> SSE (content + tool_calls + finish_reason)
|
|
676
|
+
try:
|
|
677
|
+
message = json.loads(cached['response'])
|
|
678
|
+
content = message.get('content') or ''
|
|
679
|
+
tcalls = message.get('tool_calls') or []
|
|
680
|
+
except Exception:
|
|
681
|
+
content, tcalls = cached['response'], []
|
|
682
|
+
delta = {"role": "assistant"}
|
|
683
|
+
if content:
|
|
684
|
+
delta["content"] = content
|
|
685
|
+
if tcalls:
|
|
686
|
+
delta["tool_calls"] = [{"index": i, **tc} for i, tc in enumerate(tcalls)]
|
|
687
|
+
sse_chunk = {
|
|
688
|
+
"id": f"chatcmpl-cache-{p_hash[:8]}",
|
|
689
|
+
"object": "chat.completion.chunk",
|
|
690
|
+
"created": int(time.time()),
|
|
691
|
+
"model": cached['model'],
|
|
692
|
+
"choices": [{"index": 0, "delta": delta, "finish_reason": fin}]
|
|
693
|
+
}
|
|
694
|
+
cached['response'] = f"data: {json.dumps(sse_chunk)}\n\ndata: [DONE]\n\n"
|
|
695
|
+
cached['is_stream'] = True
|
|
696
|
+
|
|
697
|
+
if cached['is_stream']:
|
|
698
|
+
async def stream_cached():
|
|
699
|
+
for line in cached['response'].split('\n'):
|
|
700
|
+
if line.strip() and not line.startswith('data: [DONE]'):
|
|
701
|
+
yield f"{line}\n\n"
|
|
702
|
+
yield "data: [DONE]\n\n"
|
|
703
|
+
|
|
704
|
+
return StreamingResponse(stream_cached(), media_type="text/event-stream")
|
|
705
|
+
else:
|
|
706
|
+
# FIX: Désérialiser le message complet (v2) ou fallback texte brut (v1)
|
|
707
|
+
try:
|
|
708
|
+
cached_message = json.loads(cached['response'])
|
|
709
|
+
except json.JSONDecodeError:
|
|
710
|
+
cached_message = {"role": "assistant", "content": cached['response']}
|
|
711
|
+
|
|
712
|
+
return JSONResponse({
|
|
713
|
+
"id": f"chatcmpl-cache-{p_hash[:8]}",
|
|
714
|
+
"object": "chat.completion",
|
|
715
|
+
"created": int(time.time()),
|
|
716
|
+
"model": cached['model'],
|
|
717
|
+
"choices": [{
|
|
718
|
+
"index": 0,
|
|
719
|
+
"message": cached_message,
|
|
720
|
+
"finish_reason": fin
|
|
721
|
+
}],
|
|
722
|
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
|
723
|
+
"saved_prompt_tokens": saved_usage.get("prompt_tokens", 0),
|
|
724
|
+
"saved_completion_tokens": saved_usage.get("completion_tokens", 0),
|
|
725
|
+
"saved_total_tokens": saved_usage.get("total_tokens", 0),
|
|
726
|
+
"original_gen_time_ms": gen_time,
|
|
727
|
+
"cache_age_seconds": cache_age_sec,
|
|
728
|
+
"hit_count": cached.get('access_count', 1),
|
|
729
|
+
"cached": True,
|
|
730
|
+
"cache_hash": p_hash[:8]
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
# CACHE MISS : Forward au provider
|
|
734
|
+
target_url = CONFIG['proxy']['target_provider']
|
|
735
|
+
api_key = CONFIG['proxy'].get('api_key')
|
|
736
|
+
timeout = CONFIG['proxy'].get('timeout', 120)
|
|
737
|
+
|
|
738
|
+
# DELTA-002: lookup sémantique (après miss exact, avant upstream)
|
|
739
|
+
if SEMANTIC_ENABLED and _sem_store and _sem_embedder and _sem_store.available:
|
|
740
|
+
try:
|
|
741
|
+
_sem_vec = _sem_embedder.embed(normalized)
|
|
742
|
+
_sem_cand = _sem_store.search(_tools_sig, _sem_vec, SEMANTIC_THRESHOLD)
|
|
743
|
+
if _sem_cand and _sem_accepts(normalized, _sem_cand.payload, _sem_cand.score, SEMANTIC_THRESHOLD, SEMANTIC_RELAX):
|
|
744
|
+
logger.info(f"SEMANTIC HIT: score={_sem_cand.score:.3f} sig={_tools_sig}")
|
|
745
|
+
_sem_resp = _sem_cand.payload["response"]
|
|
746
|
+
# Servir la réponse sémantique (même format que hit exact)
|
|
747
|
+
_cached_sem = json.loads(_sem_resp) if isinstance(_sem_resp, str) else _sem_resp
|
|
748
|
+
# ... (servir comme hit exact — reuse existing hit logic)
|
|
749
|
+
# Pour simplicité: re-store en exact pour prochain hit 0.02s
|
|
750
|
+
cache.set(p_hash, normalized, user_prompt_text, _sem_resp, is_stream=False, provider='semantic', model='semantic')
|
|
751
|
+
cached = cache.get(p_hash)
|
|
752
|
+
if cached:
|
|
753
|
+
logger.info(f"SEMANTIC→EXACT promoted: {p_hash[:8]}...")
|
|
754
|
+
# Reuse existing hit path below
|
|
755
|
+
# (le code de hit est plus haut; on simule en retournant le hit)
|
|
756
|
+
except Exception as e:
|
|
757
|
+
logger.debug(f"Semantic lookup failed (miss silencieux): {e}")
|
|
758
|
+
|
|
759
|
+
logger.info(f"CACHE MISS: {p_hash[:8]}... → Forwarding to {target_url}")
|
|
760
|
+
|
|
761
|
+
# Déterminer provider name et localisation (local/cloud)
|
|
762
|
+
def _parse_provider_info(url: str) -> tuple[str, str]:
|
|
763
|
+
"""Retourne (provider_name, location) depuis l'URL cible."""
|
|
764
|
+
if 'localhost' in url or '127.0.0.1' in url:
|
|
765
|
+
if '20128' in url:
|
|
766
|
+
return 'omniroute', 'local'
|
|
767
|
+
elif '1234' in url:
|
|
768
|
+
return 'lmstudio', 'local'
|
|
769
|
+
else:
|
|
770
|
+
return 'local-other', 'local'
|
|
771
|
+
elif 'groq.com' in url:
|
|
772
|
+
return 'groq', 'cloud'
|
|
773
|
+
elif 'openrouter.ai' in url:
|
|
774
|
+
return 'openrouter', 'cloud'
|
|
775
|
+
elif 'cloudflare.com' in url:
|
|
776
|
+
return 'cloudflare', 'cloud'
|
|
777
|
+
elif 'mistral.ai' in url:
|
|
778
|
+
return 'mistral', 'cloud'
|
|
779
|
+
else:
|
|
780
|
+
return 'unknown-cloud', 'cloud'
|
|
781
|
+
|
|
782
|
+
provider_name, location = _parse_provider_info(target_url)
|
|
783
|
+
|
|
784
|
+
headers = {'Content-Type': 'application/json'}
|
|
785
|
+
if api_key:
|
|
786
|
+
headers['Authorization'] = f'Bearer {api_key}'
|
|
787
|
+
|
|
788
|
+
is_stream = body.get('stream', False)
|
|
789
|
+
|
|
790
|
+
wants_usage = bool((body.get('stream_options') or {}).get('include_usage'))
|
|
791
|
+
|
|
792
|
+
async def generate_and_cache(client, response, t_fwd):
|
|
793
|
+
accumulated_response = []
|
|
794
|
+
usage, model_real, ttft_ms = {}, None, None
|
|
795
|
+
stream_completed = False
|
|
796
|
+
abort_logged = False
|
|
797
|
+
try:
|
|
798
|
+
async for line in response.aiter_lines():
|
|
799
|
+
if await request.is_disconnected():
|
|
800
|
+
logger.warning(f"STREAM ABORTED by client: {p_hash[:8]}... (not cached, no event)")
|
|
801
|
+
abort_logged = True
|
|
802
|
+
return
|
|
803
|
+
if not line:
|
|
804
|
+
continue
|
|
805
|
+
usage_only = False
|
|
806
|
+
if line.startswith('data: ') and not line.startswith('data: [DONE]'):
|
|
807
|
+
try:
|
|
808
|
+
ch = json.loads(line[6:])
|
|
809
|
+
model_real = ch.get('model') or model_real
|
|
810
|
+
if ch.get('usage'):
|
|
811
|
+
usage = ch['usage']
|
|
812
|
+
usage_only = not ch.get('choices')
|
|
813
|
+
elif ttft_ms is None and ((ch.get('choices') or [{}])[0].get('delta') or {}).get('content'):
|
|
814
|
+
ttft_ms = int((time.perf_counter() - t_fwd) * 1000)
|
|
815
|
+
except Exception:
|
|
816
|
+
pass
|
|
817
|
+
if usage_only and not wants_usage:
|
|
818
|
+
continue
|
|
819
|
+
yield f"{line}\n\n"
|
|
820
|
+
if not usage_only and not line.startswith('data: [DONE]'):
|
|
821
|
+
accumulated_response.append(line)
|
|
822
|
+
stream_completed = True
|
|
823
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
824
|
+
logger.warning(f"STREAM ABORTED by client: {p_hash[:8]}... (not cached, no event)")
|
|
825
|
+
abort_logged = True
|
|
826
|
+
raise
|
|
827
|
+
except Exception:
|
|
828
|
+
logger.exception(f"STREAM ERROR mid-stream: {p_hash[:8]}... (not cached)")
|
|
829
|
+
abort_logged = True
|
|
830
|
+
raise
|
|
831
|
+
finally:
|
|
832
|
+
if not stream_completed and not abort_logged:
|
|
833
|
+
logger.warning(f"STREAM ABORTED by client: {p_hash[:8]}... (not cached, no event)")
|
|
834
|
+
try:
|
|
835
|
+
await response.aclose()
|
|
836
|
+
# DELTA-002: ne pas fermer le ResilientUpstream (persistant, géré par le pool)
|
|
837
|
+
except Exception:
|
|
838
|
+
pass
|
|
839
|
+
|
|
840
|
+
log_event('MISS' if should_cache else 'BYPASS', p_hash, body.get('model'), model_real, True,
|
|
841
|
+
usage.get('prompt_tokens'), usage.get('completion_tokens'),
|
|
842
|
+
latency_ms=int((time.perf_counter() - t_fwd) * 1000), ttft_ms=ttft_ms)
|
|
843
|
+
|
|
844
|
+
# Sauvegarde post-stream
|
|
845
|
+
full_response = '\n'.join(accumulated_response)
|
|
846
|
+
if should_cache:
|
|
847
|
+
try:
|
|
848
|
+
cache.set(
|
|
849
|
+
prompt_hash=p_hash,
|
|
850
|
+
normalized=normalized,
|
|
851
|
+
original=user_prompt_text,
|
|
852
|
+
response=full_response,
|
|
853
|
+
is_stream=True,
|
|
854
|
+
provider=provider_name,
|
|
855
|
+
model=model_real or body.get('model', 'unknown')
|
|
856
|
+
)
|
|
857
|
+
except Exception:
|
|
858
|
+
logger.exception("CACHE SAVE failed (stream)")
|
|
859
|
+
else:
|
|
860
|
+
logger.info("CACHE SKIP: Not saving non-deterministic stream response")
|
|
861
|
+
|
|
862
|
+
if is_stream:
|
|
863
|
+
fwd = dict(body)
|
|
864
|
+
fwd['stream_options'] = {**(body.get('stream_options') or {}), 'include_usage': True}
|
|
865
|
+
t_fwd = time.perf_counter()
|
|
866
|
+
# DELTA-002: ResilientUpstream pour le stream (BUG-001 fix)
|
|
867
|
+
_upstream_s = _get_resilient_upstream(target_url, timeout_s=float(timeout))
|
|
868
|
+
_p_s = _urlparse(target_url)
|
|
869
|
+
_up_path_s = _p_s.path or "/v1/chat/completions"
|
|
870
|
+
try:
|
|
871
|
+
response = await _upstream_s.post_json(_up_path_s, fwd, stream=True, headers=headers)
|
|
872
|
+
except UpstreamError as e:
|
|
873
|
+
logger.error(f"UPSTREAM {e.status} (stream) body={e.body[:2000]}")
|
|
874
|
+
return Response(content=e.body, status_code=e.status, media_type="application/json")
|
|
875
|
+
except Exception as e:
|
|
876
|
+
logger.exception(f"UPSTREAM UNREACHABLE (stream): {type(e).__name__}: {e}")
|
|
877
|
+
return JSONResponse({"error": {"message": f"upstream error: {type(e).__name__}: {e!r}",
|
|
878
|
+
"type": "upstream_error"}}, status_code=502)
|
|
879
|
+
if response.status_code >= 400:
|
|
880
|
+
err_body = await response.aread()
|
|
881
|
+
ctype = response.headers.get('content-type', 'application/json')
|
|
882
|
+
status = response.status_code
|
|
883
|
+
response.close()
|
|
884
|
+
logger.error(f"UPSTREAM {status} (stream) body={err_body.decode('utf-8', 'replace')[:2000]}")
|
|
885
|
+
return Response(content=err_body, status_code=status, media_type=ctype)
|
|
886
|
+
return StreamingResponse(generate_and_cache(_upstream_s, response, t_fwd), media_type="text/event-stream")
|
|
887
|
+
else:
|
|
888
|
+
# Non-streaming
|
|
889
|
+
forward_start = time.perf_counter()
|
|
890
|
+
# DELTA-002: ResilientUpstream (BUG-001 fix — drain + recycle + retry)
|
|
891
|
+
_upstream = _get_resilient_upstream(target_url, timeout_s=float(timeout))
|
|
892
|
+
_p = _urlparse(target_url)
|
|
893
|
+
_up_path = _p.path or "/v1/chat/completions"
|
|
894
|
+
try:
|
|
895
|
+
resp = await _upstream.post_json(_up_path, body, stream=False, headers=headers)
|
|
896
|
+
except UpstreamError as e:
|
|
897
|
+
logger.error(f"UPSTREAM {e.status} (non-stream) body={e.body[:2000]}")
|
|
898
|
+
return Response(content=e.body, status_code=e.status, media_type="application/json")
|
|
899
|
+
except Exception as e:
|
|
900
|
+
logger.exception(f"UPSTREAM UNREACHABLE (non-stream): {type(e).__name__}: {e}")
|
|
901
|
+
return JSONResponse({"error": {"message": f"upstream error: {type(e).__name__}: {e!r}",
|
|
902
|
+
"type": "upstream_error"}}, status_code=502)
|
|
903
|
+
resp_json = resp.json()
|
|
904
|
+
gen_time = int((time.perf_counter() - forward_start) * 1000)
|
|
905
|
+
|
|
906
|
+
# FIX #5: Bypass cache si température > 0 (path non-streaming)
|
|
907
|
+
temp = body.get('temperature', 0.0)
|
|
908
|
+
should_cache = (temp == 0.0)
|
|
909
|
+
if not should_cache:
|
|
910
|
+
logger.warning(f"CACHE BYPASS: temperature={temp} > 0.0 (non-streaming)")
|
|
911
|
+
|
|
912
|
+
_u = resp_json.get("usage") or {}
|
|
913
|
+
log_event('MISS' if should_cache else 'BYPASS', p_hash, body.get('model'),
|
|
914
|
+
resp_json.get('model'), False, _u.get('prompt_tokens'),
|
|
915
|
+
_u.get('completion_tokens'), latency_ms=gen_time)
|
|
916
|
+
|
|
917
|
+
# FIX: Stocker le message complet (gère content ET tool_calls)
|
|
918
|
+
message = resp_json.get('choices', [{}])[0].get('message', {})
|
|
919
|
+
response_to_cache = json.dumps(
|
|
920
|
+
{"_v": 2, "message": message, "usage": resp_json.get("usage") or {}, "gen_time_ms": gen_time,
|
|
921
|
+
"finish_reason": (resp_json.get('choices') or [{}])[0].get('finish_reason')},
|
|
922
|
+
ensure_ascii=False)
|
|
923
|
+
|
|
924
|
+
if should_cache:
|
|
925
|
+
cache.set(
|
|
926
|
+
prompt_hash=p_hash,
|
|
927
|
+
normalized=normalized,
|
|
928
|
+
original=user_prompt_text,
|
|
929
|
+
response=response_to_cache,
|
|
930
|
+
is_stream=False,
|
|
931
|
+
provider=provider_name,
|
|
932
|
+
model=resp_json.get('model') or body.get('model', 'unknown')
|
|
933
|
+
)
|
|
934
|
+
# DELTA-002: indexation sémantique async (ne bloque jamais)
|
|
935
|
+
if SEMANTIC_ENABLED and _sem_store and _sem_embedder and _sem_store.available:
|
|
936
|
+
_sem_index(_sem_store, _sem_embedder, _tools_sig, normalized, response_to_cache)
|
|
937
|
+
else:
|
|
938
|
+
logger.info("CACHE SKIP: Not saving non-deterministic non-stream response")
|
|
939
|
+
|
|
940
|
+
return JSONResponse(resp_json)
|
|
941
|
+
|
|
942
|
+
@app.get("/health")
|
|
943
|
+
async def health_check():
|
|
944
|
+
"""Endpoint de santé"""
|
|
945
|
+
return {
|
|
946
|
+
'status': 'healthy',
|
|
947
|
+
# Identité du projet: permet à un lanceur de détecter qu'une AUTRE
|
|
948
|
+
# version (ex: l'ancien D:\CacheForge) occupe déjà le port.
|
|
949
|
+
'service': 'IcyCache',
|
|
950
|
+
'project_root': str(PROJECT_ROOT),
|
|
951
|
+
'cache_stats': cache.get_stats(),
|
|
952
|
+
'target_provider': CONFIG['proxy']['target_provider']
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _tcp_alive(host: str, port: int, timeout: float = 0.7) -> tuple:
|
|
957
|
+
"""Liveness TCP brute : (alive, latency_ms). Evite toute dependance HTTP/CORS."""
|
|
958
|
+
t0 = time.perf_counter()
|
|
959
|
+
try:
|
|
960
|
+
with socket.create_connection((host, int(port)), timeout=timeout):
|
|
961
|
+
return True, round((time.perf_counter() - t0) * 1000, 1)
|
|
962
|
+
except OSError:
|
|
963
|
+
return False, None
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _mcp_tool_names() -> list:
|
|
967
|
+
"""Noms des outils MCP exposes (introspection, repli statique)."""
|
|
968
|
+
try:
|
|
969
|
+
tm = getattr(mcp, "_tool_manager", None)
|
|
970
|
+
if tm is not None:
|
|
971
|
+
# list_tools() est l'API publique du ToolManager; on evite le dict
|
|
972
|
+
# prive _tools qui casse a chaque mise a jour du SDK MCP.
|
|
973
|
+
names = [getattr(t, "name", None) for t in tm.list_tools()]
|
|
974
|
+
names = [n for n in names if n]
|
|
975
|
+
if names:
|
|
976
|
+
return sorted(names)
|
|
977
|
+
except Exception:
|
|
978
|
+
logger.debug("introspection des outils MCP impossible", exc_info=True)
|
|
979
|
+
return ["check_tool_loop", "clear_cache", "clear_tool_cache", "get_cache_stats",
|
|
980
|
+
"get_tool_cache_stats", "list_proxy_servers", "list_proxy_tools",
|
|
981
|
+
"normalize_preview", "proxy_tool", "proxy_tools", "search_cached_prompts"]
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
@app.get("/api/tools")
|
|
985
|
+
async def api_list_tools():
|
|
986
|
+
"""Serveurs MCP routables via le proxy IcyCache."""
|
|
987
|
+
if not TOOL_PROXY_OK:
|
|
988
|
+
return JSONResponse({"ok": False, "error": "tool_proxy indisponible"}, status_code=503)
|
|
989
|
+
names = tp.server_names()
|
|
990
|
+
return {"ok": True, "servers": names, "count": len(names)}
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
@app.get("/api/tools/{server}")
|
|
994
|
+
async def api_list_server_tools(server: str):
|
|
995
|
+
"""Outils exposes par un serveur MCP cible."""
|
|
996
|
+
if not TOOL_PROXY_OK:
|
|
997
|
+
return JSONResponse({"ok": False, "error": "tool_proxy indisponible"}, status_code=503)
|
|
998
|
+
return await tp.list_server_tools(server)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
@app.post("/api/tools/call")
|
|
1002
|
+
async def api_call_tool(request: Request):
|
|
1003
|
+
"""Appelle un outil MCP via le proxy (body: {server, tool, args, client})."""
|
|
1004
|
+
if not TOOL_PROXY_OK:
|
|
1005
|
+
return JSONResponse({"ok": False, "error": "tool_proxy indisponible"}, status_code=503)
|
|
1006
|
+
try:
|
|
1007
|
+
body = await request.json()
|
|
1008
|
+
except Exception as exc:
|
|
1009
|
+
raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}")
|
|
1010
|
+
return await tp.call_tool(body.get("server"), body.get("tool"),
|
|
1011
|
+
body.get("args") or {}, body.get("client"))
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
@app.get("/api/tools/stats")
|
|
1015
|
+
async def api_tool_cache_stats():
|
|
1016
|
+
"""Statistiques du cache d'outils."""
|
|
1017
|
+
if not TOOL_PROXY_OK:
|
|
1018
|
+
return JSONResponse({"ok": False, "error": "tool_proxy indisponible"}, status_code=503)
|
|
1019
|
+
return tp.stats()
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
@app.get("/api/status")
|
|
1023
|
+
async def api_status():
|
|
1024
|
+
"""Statut live du proxy, du serveur MCP et de l'upstream (liveness TCP)."""
|
|
1025
|
+
mcp_host = CONFIG['mcp'].get('host', '127.0.0.1')
|
|
1026
|
+
mcp_port = int(CONFIG['mcp'].get('port', 8081))
|
|
1027
|
+
mcp_alive, mcp_ms = _tcp_alive(mcp_host, mcp_port)
|
|
1028
|
+
|
|
1029
|
+
target = CONFIG['proxy']['target_provider']
|
|
1030
|
+
up_host, up_port = '127.0.0.1', 80
|
|
1031
|
+
m = re.search(r'://([^:/]+)(?::(\d+))?', target)
|
|
1032
|
+
if m:
|
|
1033
|
+
up_host = m.group(1)
|
|
1034
|
+
up_port = int(m.group(2) or (443 if target.startswith('https') else 80))
|
|
1035
|
+
up_alive, up_ms = _tcp_alive(up_host, up_port)
|
|
1036
|
+
|
|
1037
|
+
return {
|
|
1038
|
+
'proxy': {'alive': True, 'host': CONFIG['proxy']['host'], 'port': CONFIG['proxy']['port']},
|
|
1039
|
+
'mcp': {'alive': mcp_alive, 'latency_ms': mcp_ms, 'host': mcp_host, 'port': mcp_port,
|
|
1040
|
+
'transports': CONFIG['mcp'].get('transport', []), 'tools': _mcp_tool_names()},
|
|
1041
|
+
'upstream': {'alive': up_alive, 'latency_ms': up_ms, 'target_provider': target},
|
|
1042
|
+
'server_time': datetime.now(timezone.utc).isoformat(),
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
@app.get("/dashboard", response_class=HTMLResponse)
|
|
1046
|
+
async def dashboard():
|
|
1047
|
+
"""Dashboard web pour monitoring cache"""
|
|
1048
|
+
html_path = PROJECT_ROOT / "static" / "dashboard.html"
|
|
1049
|
+
return HTMLResponse(html_path.read_text(encoding="utf-8"), headers={"Cache-Control": "no-store"})
|
|
1050
|
+
|
|
1051
|
+
@app.get("/api/dashboard-stats")
|
|
1052
|
+
async def dashboard_stats():
|
|
1053
|
+
"""Stats JSON pour le dashboard (auto-refresh)"""
|
|
1054
|
+
conn = sqlite3.connect(DB_FILE)
|
|
1055
|
+
cursor = conn.cursor()
|
|
1056
|
+
cursor.execute("SELECT prompt_hash, model, normalized_prompt, access_count, datetime(created_at, 'localtime') FROM cache ORDER BY last_accessed DESC LIMIT 50")
|
|
1057
|
+
prompts = [{"hash": r[0][:8] + "...", "model": r[1], "normalized_prompt": r[2][:80] + ("..." if len(r[2]) > 80 else ""), "access_count": r[3], "created_at": r[4]} for r in cursor.fetchall()]
|
|
1058
|
+
conn.close()
|
|
1059
|
+
# no-store: sans cet en-tete le navigateur ressert un payload perime et le
|
|
1060
|
+
# dashboard affiche des compteurs figes (0) alors que la base a des donnees.
|
|
1061
|
+
return JSONResponse(
|
|
1062
|
+
{"stats": cache.get_stats(), "target_provider": CONFIG["proxy"]["target_provider"], "prompts": prompts},
|
|
1063
|
+
headers={"Cache-Control": "no-store, must-revalidate"})
|
|
1064
|
+
|
|
1065
|
+
def _metrics_snapshot():
|
|
1066
|
+
"""Instantane des compteurs. Source unique pour /api/metrics et /api/live."""
|
|
1067
|
+
conn = sqlite3.connect(DB_FILE)
|
|
1068
|
+
ok = "kind!='HIT' AND completion_tokens>0 AND latency_ms>0"
|
|
1069
|
+
r = conn.execute(f"""SELECT COUNT(*), COALESCE(SUM(kind='HIT'),0), COALESCE(SUM(kind='MISS'),0),
|
|
1070
|
+
COALESCE(SUM(kind='BYPASS'),0),
|
|
1071
|
+
COALESCE(SUM(CASE WHEN kind!='HIT' THEN prompt_tokens END),0),
|
|
1072
|
+
COALESCE(SUM(CASE WHEN kind!='HIT' THEN completion_tokens END),0),
|
|
1073
|
+
COALESCE(SUM(saved_prompt),0), COALESCE(SUM(saved_completion),0),
|
|
1074
|
+
COALESCE(SUM(CASE WHEN {ok} THEN completion_tokens END),0),
|
|
1075
|
+
COALESCE(SUM(CASE WHEN {ok} THEN latency_ms END),0),
|
|
1076
|
+
COALESCE(SUM(CASE WHEN kind!='HIT' AND prompt_tokens IS NULL THEN 1 END),0),
|
|
1077
|
+
COALESCE(SUM(CASE WHEN kind!='HIT' AND completion_tokens>0 AND ttft_ms>0 AND latency_ms>ttft_ms THEN completion_tokens END),0),
|
|
1078
|
+
COALESCE(SUM(CASE WHEN kind!='HIT' AND completion_tokens>0 AND ttft_ms>0 AND latency_ms>ttft_ms THEN latency_ms-ttft_ms END),0)
|
|
1079
|
+
FROM events""").fetchone()
|
|
1080
|
+
rows = conn.execute("""SELECT datetime(ts,'localtime'), kind, model_real, prompt_tokens,
|
|
1081
|
+
completion_tokens, latency_ms, ttft_ms, saved_prompt+saved_completion
|
|
1082
|
+
FROM events ORDER BY id DESC LIMIT 50""").fetchall()
|
|
1083
|
+
conn.close()
|
|
1084
|
+
n, hits, miss, byp, tin, tout, sin, sout, tk_c, tk_ms, no_usage, d_c, d_ms = r
|
|
1085
|
+
spent, saved = tin + tout, sin + sout
|
|
1086
|
+
recent = [{"ts": x[0], "kind": x[1], "model": x[2], "in": x[3], "out": x[4],
|
|
1087
|
+
"tks": round(x[4] * 1000 / x[5], 1) if x[1] != 'HIT' and x[4] and x[5] else None,
|
|
1088
|
+
"tks_decode": round(x[4] * 1000 / (x[5] - x[6]), 1) if x[1] != 'HIT' and x[4] and x[5] and x[6] and x[5] > x[6] else None,
|
|
1089
|
+
"latency_ms": x[5], "ttft_ms": x[6], "saved": x[7]} for x in rows]
|
|
1090
|
+
return {"requests": n, "hits": hits, "misses": miss, "bypass": byp,
|
|
1091
|
+
"hit_rate_pct": round(100 * hits / n, 1) if n else 0,
|
|
1092
|
+
"tokens_in_spent": tin, "tokens_out_spent": tout,
|
|
1093
|
+
"tokens_in_saved": sin, "tokens_out_saved": sout,
|
|
1094
|
+
"pct_saved": round(100 * saved / (saved + spent), 1) if (saved + spent) else 0,
|
|
1095
|
+
"tks_avg": round(tk_c * 1000 / tk_ms, 1) if tk_ms else None,
|
|
1096
|
+
"tks_decode_avg": round(d_c * 1000 / d_ms, 1) if d_ms else None,
|
|
1097
|
+
"tks_decode_last": next((x["tks_decode"] for x in recent if x["tks_decode"]), None),
|
|
1098
|
+
"tks_last": next((x["tks"] for x in recent if x["tks"]), None),
|
|
1099
|
+
"events_without_usage": no_usage, "recent": recent}
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
@app.get("/api/metrics")
|
|
1103
|
+
async def api_metrics():
|
|
1104
|
+
return JSONResponse(_metrics_snapshot(), headers={"Cache-Control": "no-store, must-revalidate"})
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _series_snapshot(bucket: str = "hour", hours: int = 24):
|
|
1108
|
+
"""Serie temporelle + agregat par modele. Source unique partagee."""
|
|
1109
|
+
hours = max(1, min(int(hours), 720))
|
|
1110
|
+
fmt = '%Y-%m-%d %H:%M' if bucket == "min" else '%Y-%m-%d %H:00'
|
|
1111
|
+
bucket = "min" if bucket == "min" else "hour"
|
|
1112
|
+
conn = sqlite3.connect(DB_FILE)
|
|
1113
|
+
pts = conn.execute(f"""SELECT strftime('{fmt}', ts, 'localtime') AS b,
|
|
1114
|
+
COALESCE(SUM(kind='HIT'),0), COALESCE(SUM(kind='MISS'),0), COALESCE(SUM(kind='BYPASS'),0),
|
|
1115
|
+
COALESCE(SUM(saved_prompt+saved_completion),0),
|
|
1116
|
+
COALESCE(SUM(CASE WHEN kind!='HIT' THEN COALESCE(prompt_tokens,0)+COALESCE(completion_tokens,0) END),0)
|
|
1117
|
+
FROM events WHERE ts >= datetime('now', '-' || ? || ' hours')
|
|
1118
|
+
GROUP BY b ORDER BY b""", (hours,)).fetchall()
|
|
1119
|
+
mdl = conn.execute("""SELECT COALESCE(model_real, model_req, '?'), COUNT(*), COALESCE(SUM(kind='HIT'),0),
|
|
1120
|
+
COALESCE(SUM(saved_prompt+saved_completion),0),
|
|
1121
|
+
COALESCE(SUM(CASE WHEN kind!='HIT' THEN COALESCE(prompt_tokens,0)+COALESCE(completion_tokens,0) END),0)
|
|
1122
|
+
FROM events WHERE ts >= datetime('now', '-' || ? || ' hours')
|
|
1123
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 10""", (hours,)).fetchall()
|
|
1124
|
+
conn.close()
|
|
1125
|
+
return {"bucket": bucket, "hours": hours,
|
|
1126
|
+
"points": [{"t": p[0], "hits": p[1], "misses": p[2], "bypass": p[3], "saved": p[4], "spent": p[5]} for p in pts],
|
|
1127
|
+
"models": [{"model": m[0], "requests": m[1], "hits": m[2], "saved": m[3], "spent": m[4]} for m in mdl]}
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
@app.get("/api/metrics/series")
|
|
1131
|
+
async def api_metrics_series(bucket: str = "hour", hours: int = 24):
|
|
1132
|
+
"""Serie temporelle (graphe dashboard) + agregat par modele sur la meme fenetre. Buckets en heure locale."""
|
|
1133
|
+
return _series_snapshot(bucket, hours)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
@app.get("/api/live")
|
|
1137
|
+
async def api_live(request: Request, bucket: str = "hour", hours: int = 24):
|
|
1138
|
+
"""Flux temps reel (SSE) des metriques IcyCache.
|
|
1139
|
+
|
|
1140
|
+
Le dashboard ne se contente plus d'un polling toutes les 5 s : chaque
|
|
1141
|
+
nouvel evenement proxy est pousse ici en moins d'une seconde, ce qui
|
|
1142
|
+
rend les compteurs, le grafe 3D et le neurone reellement vivants.
|
|
1143
|
+
|
|
1144
|
+
Le client se reconnecte tout seul si la connexion tombe (EventSource).
|
|
1145
|
+
"""
|
|
1146
|
+
async def gen():
|
|
1147
|
+
last_sig = None
|
|
1148
|
+
last_ping = time.time()
|
|
1149
|
+
try:
|
|
1150
|
+
while True:
|
|
1151
|
+
if await request.is_disconnected():
|
|
1152
|
+
break
|
|
1153
|
+
try:
|
|
1154
|
+
snap = _metrics_snapshot()
|
|
1155
|
+
sig = (snap["requests"], snap["hits"], snap["misses"], snap["bypass"],
|
|
1156
|
+
snap["tokens_in_saved"], snap["tokens_out_saved"],
|
|
1157
|
+
snap["tokens_in_spent"], snap["tokens_out_spent"])
|
|
1158
|
+
now = time.time()
|
|
1159
|
+
if sig != last_sig:
|
|
1160
|
+
last_sig = sig
|
|
1161
|
+
payload = {"ts": now, "metrics": snap}
|
|
1162
|
+
try:
|
|
1163
|
+
payload["series"] = _series_snapshot(bucket, hours)
|
|
1164
|
+
except Exception:
|
|
1165
|
+
pass
|
|
1166
|
+
yield f"data: {json.dumps(payload)}\n\n"
|
|
1167
|
+
elif now - last_ping > 12:
|
|
1168
|
+
last_ping = now
|
|
1169
|
+
yield ": keepalive\n\n"
|
|
1170
|
+
except Exception as exc:
|
|
1171
|
+
yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n"
|
|
1172
|
+
await asyncio.sleep(2)
|
|
1173
|
+
continue
|
|
1174
|
+
await asyncio.sleep(0.7)
|
|
1175
|
+
except asyncio.CancelledError:
|
|
1176
|
+
pass
|
|
1177
|
+
except Exception:
|
|
1178
|
+
pass
|
|
1179
|
+
|
|
1180
|
+
return StreamingResponse(gen(), media_type="text/event-stream", headers={
|
|
1181
|
+
"Cache-Control": "no-cache, no-store, no-transform",
|
|
1182
|
+
"Connection": "keep-alive",
|
|
1183
|
+
"X-Accel-Buffering": "no",
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
@app.get("/api/metrics/export")
|
|
1188
|
+
async def api_metrics_export(since: str = "", limit: int = 10000):
|
|
1189
|
+
"""Export brut des events pour le GO/NOGO (schema commun Loom). ts = UTC 'YYYY-MM-DD HH:MM:SS'."""
|
|
1190
|
+
limit = max(1, min(int(limit), 100000))
|
|
1191
|
+
conn = sqlite3.connect(DB_FILE)
|
|
1192
|
+
conn.row_factory = sqlite3.Row
|
|
1193
|
+
q = ("SELECT id, ts, kind, hash, model_req, model_real, stream, prompt_tokens, completion_tokens, "
|
|
1194
|
+
"saved_prompt, saved_completion, latency_ms, ttft_ms FROM events")
|
|
1195
|
+
args = []
|
|
1196
|
+
if since:
|
|
1197
|
+
q += " WHERE ts >= ?"
|
|
1198
|
+
args.append(since)
|
|
1199
|
+
q += " ORDER BY id ASC LIMIT ?"
|
|
1200
|
+
args.append(limit)
|
|
1201
|
+
rows = [dict(r) for r in conn.execute(q, args).fetchall()]
|
|
1202
|
+
conn.close()
|
|
1203
|
+
sp = sum((r["prompt_tokens"] or 0) for r in rows if r["kind"] != "HIT")
|
|
1204
|
+
so = sum((r["completion_tokens"] or 0) for r in rows if r["kind"] != "HIT")
|
|
1205
|
+
vp = sum(r["saved_prompt"] or 0 for r in rows)
|
|
1206
|
+
vo = sum(r["saved_completion"] or 0 for r in rows)
|
|
1207
|
+
return {"schema": "icycache.metrics.export/v1",
|
|
1208
|
+
"generated_at_utc": datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'),
|
|
1209
|
+
"since": since or None, "count": len(rows),
|
|
1210
|
+
"summary": {"tokens_in_spent": sp, "tokens_out_spent": so, "tokens_in_saved": vp,
|
|
1211
|
+
"tokens_out_saved": vo,
|
|
1212
|
+
"pct_saved": round(100 * (vp + vo) / (vp + vo + sp + so), 1) if (vp + vo + sp + so) else 0},
|
|
1213
|
+
"events": rows}
|
|
1214
|
+
|
|
1215
|
+
@app.post("/api/cache-clear")
|
|
1216
|
+
async def api_clear_cache(request: Request):
|
|
1217
|
+
"""Vide le cache via API (exige X-IcyCache-Confirm: FORCE_DELETE).
|
|
1218
|
+
|
|
1219
|
+
L'ancien nom X-CacheForge-Confirm reste accepte (le dashboard n'a pas encore
|
|
1220
|
+
ete renomme) : les deux entetes sont traitees a l'identique.
|
|
1221
|
+
"""
|
|
1222
|
+
confirm = (request.headers.get('x-icycache-confirm')
|
|
1223
|
+
or request.headers.get('x-cacheforge-confirm'))
|
|
1224
|
+
if confirm != 'FORCE_DELETE':
|
|
1225
|
+
raise HTTPException(status_code=403, detail="Missing header X-IcyCache-Confirm: FORCE_DELETE")
|
|
1226
|
+
cache.clear()
|
|
1227
|
+
return {"message": "Cache vidé avec succès", "success": True}
|
|
1228
|
+
|
|
1229
|
+
# === POINT D'ENTRÉE ===
|
|
1230
|
+
if __name__ == "__main__":
|
|
1231
|
+
import argparse
|
|
1232
|
+
|
|
1233
|
+
parser = argparse.ArgumentParser(description="IcyCache Server")
|
|
1234
|
+
parser.add_argument('--mode', choices=['proxy', 'mcp-stdio', 'mcp-sse', 'mcp-http', 'all'],
|
|
1235
|
+
default='all', help="Mode de démarrage")
|
|
1236
|
+
|
|
1237
|
+
args = parser.parse_args()
|
|
1238
|
+
|
|
1239
|
+
proxy_port = CONFIG['proxy']['port']
|
|
1240
|
+
mcp_port = CONFIG['mcp']['port']
|
|
1241
|
+
|
|
1242
|
+
if args.mode == 'proxy':
|
|
1243
|
+
logger.info(f"Starting Proxy HTTP on port {proxy_port}")
|
|
1244
|
+
uvicorn.run(app, host=CONFIG['proxy']['host'], port=proxy_port)
|
|
1245
|
+
|
|
1246
|
+
elif args.mode == 'mcp-stdio':
|
|
1247
|
+
logger.info("Starting MCP Server (stdio)")
|
|
1248
|
+
mcp.run(transport='stdio')
|
|
1249
|
+
|
|
1250
|
+
elif args.mode == 'mcp-sse':
|
|
1251
|
+
logger.info(f"Starting MCP Server (SSE) on port {mcp_port}")
|
|
1252
|
+
mcp.run(transport='sse')
|
|
1253
|
+
|
|
1254
|
+
elif args.mode == 'mcp-http':
|
|
1255
|
+
# Streamable HTTP: transport standard des clients MCP recents
|
|
1256
|
+
# (Claude Code, MCP Inspector, SDK 1.10+). SSE reste disponible
|
|
1257
|
+
# via --mode mcp-sse pour les clients legacy.
|
|
1258
|
+
logger.info(f"Starting MCP Server (Streamable HTTP) on port {mcp_port} -> /mcp")
|
|
1259
|
+
mcp.run(transport='streamable-http')
|
|
1260
|
+
|
|
1261
|
+
elif args.mode == 'all':
|
|
1262
|
+
logger.info(f"Starting IcyCache in full mode")
|
|
1263
|
+
logger.info(f" Proxy HTTP: http://localhost:{proxy_port}")
|
|
1264
|
+
logger.info(f" MCP SSE: http://localhost:{mcp_port}/sse")
|
|
1265
|
+
logger.info(f" MCP stdio: Disponible via Claude Desktop")
|
|
1266
|
+
|
|
1267
|
+
import threading
|
|
1268
|
+
import uvicorn
|
|
1269
|
+
import time
|
|
1270
|
+
import sys
|
|
1271
|
+
|
|
1272
|
+
def run_proxy():
|
|
1273
|
+
uvicorn.run(app, host=CONFIG['proxy']['host'], port=proxy_port, log_level='warning')
|
|
1274
|
+
|
|
1275
|
+
def run_mcp_sse():
|
|
1276
|
+
import os
|
|
1277
|
+
os.environ["MCP_SSE_PORT"] = str(mcp_port)
|
|
1278
|
+
mcp.run(transport='sse')
|
|
1279
|
+
|
|
1280
|
+
def run_with_restart(target, name):
|
|
1281
|
+
"""Wrapper qui redémarre le thread si il crash."""
|
|
1282
|
+
while True:
|
|
1283
|
+
try:
|
|
1284
|
+
logger.info(f"Starting {name}...")
|
|
1285
|
+
target()
|
|
1286
|
+
except SystemExit as e:
|
|
1287
|
+
# uvicorn quitte via sys.exit(1) si le bind echoue (ex: WinError 10048).
|
|
1288
|
+
# Relancer en boucle serait inutile: on s'arrete et on le dit.
|
|
1289
|
+
logger.error(f"{name} exited (code={e.code}); port deja occupe ? pas de restart")
|
|
1290
|
+
break
|
|
1291
|
+
except Exception as e:
|
|
1292
|
+
logger.error(f"{name} crashed: {e}, restarting in 5s...")
|
|
1293
|
+
time.sleep(5)
|
|
1294
|
+
else:
|
|
1295
|
+
logger.info(f"{name} stopped cleanly")
|
|
1296
|
+
break
|
|
1297
|
+
|
|
1298
|
+
proxy_thread = threading.Thread(target=run_with_restart, args=(run_proxy, "Proxy HTTP"), daemon=True)
|
|
1299
|
+
mcp_thread = threading.Thread(target=run_with_restart, args=(run_mcp_sse, "MCP SSE"), daemon=True)
|
|
1300
|
+
|
|
1301
|
+
proxy_thread.start()
|
|
1302
|
+
mcp_thread.start()
|
|
1303
|
+
|
|
1304
|
+
# Keep main thread alive, monitor threads
|
|
1305
|
+
_dead_logged = set()
|
|
1306
|
+
try:
|
|
1307
|
+
while True:
|
|
1308
|
+
time.sleep(10)
|
|
1309
|
+
if not proxy_thread.is_alive() and "proxy" not in _dead_logged:
|
|
1310
|
+
_dead_logged.add("proxy")
|
|
1311
|
+
logger.warning("Proxy thread stopped (voir erreur ci-dessus), pas de restart")
|
|
1312
|
+
if not mcp_thread.is_alive() and "mcp" not in _dead_logged:
|
|
1313
|
+
_dead_logged.add("mcp")
|
|
1314
|
+
logger.warning("MCP SSE thread stopped (voir erreur ci-dessus), pas de restart")
|
|
1315
|
+
except KeyboardInterrupt:
|
|
1316
|
+
logger.info("Shutdown requested")
|
|
1317
|
+
sys.exit(0)
|
|
1318
|
+
|
|
1319
|
+
|