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.
@@ -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
+