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,360 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ IcyCache - facade MCP de la gateway.
4
+
5
+ Le MCP est une FACADE : il observe et pilote, il n'est PAS dans le chemin des
6
+ requetes LLM (un client MCP appelle volontairement ses outils, on ne peut pas
7
+ intercepter un flux OpenAI/Anthropic avec lui). Le chemin des requetes passe
8
+ par la gateway HTTP (server_new.py).
9
+
10
+ Outils exposes :
11
+ get_cache_stats etat du cache LLM
12
+ clear_cache purge (confirmation explicite requise)
13
+ normalize_preview apercu de la normalisation (et de la cle de cache)
14
+ search_cached_prompts recherche dans le cache
15
+ get_realtime_tokens DERNIERS tokens REELS par upstream (jamais estimes)
16
+ list_upstreams upstreams disponibles dans la gateway
17
+ tool_cache_stats etat du cache d'outils
18
+
19
+ Surfaces :
20
+ MCP : stdio (--mode mcp-stdio) ou SSE (--mode mcp-sse)
21
+ REST : /api/tools, /api/tools/{nom}, /api/agent/events, /api/loop/check,
22
+ /api/toolcache/{stats,flush,clear}
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import json
28
+ import os
29
+ import sqlite3
30
+ import sys
31
+ import time
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Any, Dict, List, Optional
35
+
36
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
37
+
38
+ from icy_paths import PROJECT_ROOT, resolve_data_path # noqa: E402
39
+
40
+ _CONFIG_FILE = Path(os.environ.get("ICYCACHE_CONFIG") or (PROJECT_ROOT / "config" / "icycache.gateway.json"))
41
+ os.environ["ICYCACHE_CONFIG"] = str(_CONFIG_FILE)
42
+
43
+ from fastapi import FastAPI, HTTPException, Request # noqa: E402
44
+ from fastapi.responses import JSONResponse # noqa: E402
45
+ from mcp.server.fastmcp import FastMCP # noqa: E402
46
+ import uvicorn # noqa: E402
47
+
48
+ import icy_core # noqa: E402
49
+ from icy_core import log_agent_event, utc_iso # noqa: E402
50
+ from icy_cache_store import CacheStore # noqa: E402
51
+ import icy_normalize # noqa: E402
52
+
53
+ # Proxy d'outils: disponible si le module s'importe. IcyCache sert alors aussi
54
+ # de relais vers les serveurs MCP cibles (tools calling).
55
+ try:
56
+ import tool_proxy as tp
57
+ TOOL_PROXY_OK = True
58
+ except Exception: # pragma: no cover
59
+ tp = None
60
+ TOOL_PROXY_OK = False
61
+
62
+ CONFIG: Dict[str, Any] = json.loads(_CONFIG_FILE.read_text(encoding="utf-8"))
63
+ DB_FILE = resolve_data_path(
64
+ os.environ.get("ICYCACHE_DB") or CONFIG.get("cache", {}).get("db_path"),
65
+ "db/cache.db",
66
+ )
67
+ DB_FILE.parent.mkdir(parents=True, exist_ok=True)
68
+
69
+ UPSTREAMS: Dict[str, Any] = CONFIG.get("upstreams", {})
70
+ MCP_CFG = CONFIG.get("mcp", {})
71
+ MCP_GW_CFG = CONFIG.get("mcp_gateway", {"host": "127.0.0.1", "port": 8083})
72
+ GW_CFG = CONFIG.get("gateway", {})
73
+ LOOP_CFG = CONFIG.get("loop_guard", {})
74
+ TOOL_CACHE_CFG = CONFIG.get("tool_cache", {})
75
+
76
+ CACHE = CacheStore(DB_FILE,
77
+ ttl_seconds=int(CONFIG.get("cache", {}).get("ttl_seconds", 86400)),
78
+ max_entries=int(CONFIG.get("cache", {}).get("max_entries", 10000)))
79
+ LOOP_GUARD = icy_core.ToolLoopGuard(LOOP_CFG)
80
+
81
+
82
+ # =============================================================================
83
+ # OUTILS MCP
84
+ # =============================================================================
85
+ mcp = FastMCP("IcyCache Gateway",
86
+ host=str(MCP_GW_CFG.get("host", "127.0.0.1")),
87
+ port=int(MCP_GW_CFG.get("port", 8083)))
88
+
89
+
90
+ @mcp.tool()
91
+ def get_cache_stats() -> Dict[str, Any]:
92
+ """Etat du cache LLM (entrees, hits, repartition par upstream/modele)."""
93
+ return CACHE.stats()
94
+
95
+
96
+ @mcp.tool()
97
+ def clear_cache(confirm: str) -> str:
98
+ """Vide le cache. Necessite confirm='FORCE_DELETE'."""
99
+ if confirm != "FORCE_DELETE":
100
+ return "ECHEC: utilisez confirm='FORCE_DELETE' pour valider la suppression destructive."
101
+ n = CACHE.clear()
102
+ return f"SUCCES: cache vide ({n} entrees supprimees)."
103
+
104
+
105
+ @mcp.tool()
106
+ def normalize_preview(text: str) -> Dict[str, str]:
107
+ """Apercu de la normalisation d'un prompt et de sa cle de cache."""
108
+ norm = icy_normalize.get_default_normalizer()
109
+ normalized = norm.normalize(text)
110
+ return {"original": text, "normalized": normalized, "hash": norm.get_hash(normalized)}
111
+
112
+
113
+ @mcp.tool()
114
+ def search_cached_prompts(query: str, limit: int = 10) -> List[Dict[str, Any]]:
115
+ """Recherche dans les prompts normalises du cache."""
116
+ return CACHE.search(query, limit)
117
+
118
+
119
+ @mcp.tool()
120
+ def get_realtime_tokens(limit: int = 20, upstream: str = "") -> Dict[str, Any]:
121
+ """Derniers tokens REELS constatees en amont (jamais estimes).
122
+
123
+ Retourne pour chaque evenement : upstream, modele, prompt/completion/cached tokens,
124
+ latence, ttft et la source du token ('upstream' ou 'n/a' si l'upstream n'envoie pas d'usage).
125
+ """
126
+ limit = max(1, min(int(limit), 200))
127
+ conn = sqlite3.connect(DB_FILE)
128
+ conn.row_factory = sqlite3.Row
129
+ try:
130
+ q = ("SELECT id, ts, kind, upstream, client, model_req, model_real, stream, prompt_tokens, "
131
+ "completion_tokens, cached_tokens, cache_write_tokens, latency_ms, ttft_ms, saved_prompt, "
132
+ "saved_completion FROM events")
133
+ args: List[Any] = []
134
+ if upstream:
135
+ q += " WHERE upstream = ?"
136
+ args.append(upstream)
137
+ q += " ORDER BY id DESC LIMIT ?"
138
+ args.append(limit)
139
+ rows = [dict(r) for r in conn.execute(q, args).fetchall()]
140
+ finally:
141
+ conn.close()
142
+ events = []
143
+ for r in rows:
144
+ events.append({
145
+ "id": r["id"], "ts": r["ts"], "kind": r["kind"], "upstream": r["upstream"],
146
+ "client": r["client"], "model": r["model_real"] or r["model_req"], "stream": bool(r["stream"]),
147
+ "prompt_tokens": r["prompt_tokens"], "completion_tokens": r["completion_tokens"],
148
+ "cached_tokens": r["cached_tokens"], "cache_write_tokens": r["cache_write_tokens"],
149
+ "saved_tokens": (r["saved_prompt"] or 0) + (r["saved_completion"] or 0),
150
+ "latency_ms": r["latency_ms"], "ttft_ms": r["ttft_ms"],
151
+ "tokens_source": "upstream" if (r["prompt_tokens"] is not None or r["completion_tokens"] is not None) else "n/a",
152
+ })
153
+ return {
154
+ "events": events,
155
+ "note": ("prompt_tokens / completion_tokens proviennent du champ usage de la reponse upstream. "
156
+ "Valeur NULL = l'upstream n'a pas envoye d'usage : aucune estimation n'est faite."),
157
+ "websocket": f"ws://{GW_CFG.get('host', '127.0.0.1')}:{GW_CFG.get('port', 8082)}/ws/events",
158
+ "generated_at": utc_iso(),
159
+ }
160
+
161
+
162
+ @mcp.tool()
163
+ def list_upstreams() -> Dict[str, Any]:
164
+ """Upstreams configures dans la gateway (base_url, protocole, defaut)."""
165
+ return {
166
+ "upstreams": {k: {"base_url": v.get("base_url"), "kind": v.get("kind", "openai")}
167
+ for k, v in UPSTREAMS.items()},
168
+ "default_upstream": GW_CFG.get("default_upstream"),
169
+ "note": "Aucun defaut implicite : si default_upstream est absent, le client doit nommer son upstream.",
170
+ }
171
+
172
+
173
+ @mcp.tool()
174
+ def tool_cache_stats() -> Dict[str, Any]:
175
+ """Etat du cache d'outils (entrees, hits, octets) et evenements agent recents."""
176
+ return {"tool_cache": icy_core.tc_stats(), "recent_agent_events": icy_core.agent_events_recent(limit=20)}
177
+
178
+
179
+ # =============================================================================
180
+ # OUTILS DE RELAY MCP (tool calling vers les serveurs cibles)
181
+ # =============================================================================
182
+ @mcp.tool()
183
+ async def proxy_tool(server: str, tool: str, args: Optional[Dict[str, Any]] = None,
184
+ client: str = "gateway") -> Dict[str, Any]:
185
+ """Appelle un outil d'un serveur MCP cible via IcyCache (cache + garde anti-boucle).
186
+
187
+ Args:
188
+ server: nom du serveur cible (voir list_proxy_servers)
189
+ tool: nom de l'outil a executer
190
+ args: arguments JSON de l'outil, optionnel
191
+ client: identifiant de l'appelant (session agent), optionnel
192
+
193
+ Returns:
194
+ {"ok": true, "cached": false, "latency_ms": 12, "result": ...}
195
+ {"ok": false, "error": "..."} en cas d'erreur (jamais de faux succes)
196
+ """
197
+ if not TOOL_PROXY_OK:
198
+ return {"ok": False, "error": "tool_proxy indisponible"}
199
+ return await tp.call_tool(server, tool, args, client)
200
+
201
+
202
+ @mcp.tool()
203
+ async def proxy_tools(calls: List[Dict[str, Any]], client: str = "gateway") -> List[Dict[str, Any]]:
204
+ """Appel batch d'outils MCP cibles.
205
+
206
+ Args:
207
+ calls: liste de [{"server": "..", "tool": "..", "args": {...}}, ...]
208
+ client: identifiant de l'appelant, optionnel
209
+
210
+ Returns:
211
+ liste des resultats dans le meme ordre (format identique a proxy_tool)
212
+ """
213
+ if not TOOL_PROXY_OK:
214
+ return [{"ok": False, "error": "tool_proxy indisponible"}]
215
+ return await tp.batch_call(calls or [], client)
216
+
217
+
218
+ @mcp.tool()
219
+ def list_proxy_servers() -> Dict[str, Any]:
220
+ """Liste des serveurs MCP routables via IcyCache."""
221
+ if not TOOL_PROXY_OK:
222
+ return {"ok": False, "error": "tool_proxy indisponible"}
223
+ names = tp.server_names()
224
+ return {"ok": True, "servers": names, "count": len(names)}
225
+
226
+
227
+ @mcp.tool()
228
+ async def list_proxy_tools(server: str) -> Dict[str, Any]:
229
+ """Liste les outils exposes par un serveur MCP cible (ex: 'github', 'fetch')."""
230
+ if not TOOL_PROXY_OK:
231
+ return {"ok": False, "error": "tool_proxy indisponible"}
232
+ return await tp.list_server_tools(server)
233
+
234
+
235
+ # =============================================================================
236
+ # API REST (miroir des outils + introspection)
237
+ # =============================================================================
238
+ app = FastAPI(title="IcyCache Gateway MCP", version="2.0.0")
239
+
240
+ TOOL_IMPLS = {
241
+ "get_cache_stats": get_cache_stats,
242
+ "clear_cache": clear_cache,
243
+ "normalize_preview": normalize_preview,
244
+ "search_cached_prompts": search_cached_prompts,
245
+ "get_realtime_tokens": get_realtime_tokens,
246
+ "list_upstreams": list_upstreams,
247
+ "tool_cache_stats": tool_cache_stats,
248
+ "proxy_tool": proxy_tool,
249
+ "proxy_tools": proxy_tools,
250
+ "list_proxy_servers": list_proxy_servers,
251
+ "list_proxy_tools": list_proxy_tools,
252
+ }
253
+
254
+
255
+ @app.get("/health")
256
+ async def health():
257
+ return {"status": "healthy", "tools": sorted(TOOL_IMPLS), "upstreams": sorted(UPSTREAMS)}
258
+
259
+
260
+ @app.get("/api/tools")
261
+ async def list_tools():
262
+ return {"tools": sorted(TOOL_IMPLS), "protocol": "icycache.mcp/v1"}
263
+
264
+
265
+ @app.post("/api/tools/{name}")
266
+ async def call_tool(name: str, request: Request):
267
+ if name not in TOOL_IMPLS:
268
+ raise HTTPException(status_code=404, detail={"error": "unknown_tool", "tools": sorted(TOOL_IMPLS)})
269
+ try:
270
+ args = await request.json()
271
+ except Exception:
272
+ args = {}
273
+ if not isinstance(args, dict):
274
+ args = {}
275
+ func = TOOL_IMPLS[name]
276
+ t0 = time.perf_counter()
277
+ try:
278
+ if asyncio.iscoroutinefunction(func):
279
+ result = await func(**args)
280
+ else:
281
+ result = await asyncio.to_thread(func, **args)
282
+ except TypeError as exc:
283
+ raise HTTPException(status_code=400, detail={"error": "bad_args", "message": str(exc)})
284
+ latency = int((time.perf_counter() - t0) * 1000)
285
+ log_agent_event("tool_call", server="gateway", tool=name, client="rest",
286
+ args_hash=icy_core.short_hash(json.dumps(args, sort_keys=True, default=str)),
287
+ latency_ms=latency, detail={"args": sorted(args.keys())})
288
+ return {"tool": name, "result": result, "latency_ms": latency}
289
+
290
+
291
+ @app.get("/api/agent/events")
292
+ async def agent_events(limit: int = 50, kind: str = ""):
293
+ kinds = [k for k in (kind or "").split(",") if k] or None
294
+ return {"events": icy_core.agent_events_recent(limit=limit, kinds=kinds)}
295
+
296
+
297
+ @app.post("/api/loop/check")
298
+ async def loop_check(request: Request):
299
+ """Detecte une boucle d'agent dans l'historique d'une requete LLM.
300
+
301
+ Body : {"messages": [...], "client": "hermes"}
302
+ """
303
+ body = await request.json()
304
+ messages = body.get("messages") or []
305
+ client = body.get("client") or "unknown"
306
+ warn_after = int(LOOP_CFG.get("llm", {}).get("warn_after", 3))
307
+ verdict = icy_core.llm_loop_check(messages, warn_after=warn_after)
308
+ if verdict:
309
+ log_agent_event("loop_detected", server="gateway", client=client, detail=verdict)
310
+ return {"loop": verdict, "action": LOOP_CFG.get("llm", {}).get("action", "warn"),
311
+ "checked_at": utc_iso()}
312
+
313
+
314
+ @app.post("/api/toolcache/flush")
315
+ async def toolcache_flush(request: Request):
316
+ body = await request.json()
317
+ server = body.get("server")
318
+ n = icy_core.tc_flush(server) if server else icy_core.tc_clear()
319
+ return {"flushed": n, "server": server}
320
+
321
+
322
+ @app.get("/api/toolcache/stats")
323
+ async def toolcache_stats():
324
+ return icy_core.tc_stats()
325
+
326
+
327
+ @app.get("/api/realtime")
328
+ async def realtime(limit: int = 20, upstream: str = ""):
329
+ return get_realtime_tokens(limit=limit, upstream=upstream)
330
+
331
+
332
+ # =============================================================================
333
+ if __name__ == "__main__":
334
+ import argparse
335
+ import logging
336
+
337
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - IcyCache.mcp - %(levelname)s - %(message)s")
338
+ parser = argparse.ArgumentParser(description="IcyCache Gateway MCP")
339
+ parser.add_argument("--mode", choices=["api", "mcp-stdio", "mcp-sse", "mcp-http", "all"],
340
+ default="api")
341
+ parser.add_argument("--port", type=int, default=int(GW_CFG.get("port", 8082)))
342
+ parser.add_argument("--host", default=str(GW_CFG.get("host", "127.0.0.1")))
343
+ args = parser.parse_args()
344
+
345
+ if args.mode == "mcp-stdio":
346
+ mcp.run(transport="stdio")
347
+ elif args.mode == "mcp-sse":
348
+ mcp.run(transport="sse")
349
+ elif args.mode == "mcp-http":
350
+ # Streamable HTTP: transport standard des clients MCP recents.
351
+ mcp.run(transport="streamable-http")
352
+ elif args.mode == "all":
353
+ threading = __import__("threading")
354
+ t = threading.Thread(target=lambda: uvicorn.run(app, host=args.host, port=args.port,
355
+ log_level="warning"), daemon=True)
356
+ t.start()
357
+ mcp.run(transport="sse")
358
+ else:
359
+ print(f"IcyCache MCP API -> http://{args.host}:{args.port}")
360
+ uvicorn.run(app, host=args.host, port=args.port, log_level="warning")