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,83 @@
1
+ #!/usr/bin/env python3
2
+ """IcyCache - pont stdio pour le serveur MCP.
3
+
4
+ Probleme : en transport MCP stdio, stdout EST le canal JSON-RPC. Or le serveur
5
+ historique (src/mcp_server.py) ecrit des `print()` de demarrage ET configure son
6
+ logging sur sys.stdout. Chaque ligne non-JSON pollue le flux protocole :
7
+ les clients MCP stricts echouent, et les permissifs loguent des erreurs.
8
+
9
+ Le `print("✓ ...")` pose en plus un crash : l'encodage console Windows par
10
+ defaut (cp1252) ne sait pas encoder le caractere de coche.
11
+
12
+ Ce pont :
13
+ - garde stdout REEL pour le protocole JSON-RPC (via .buffer),
14
+ - redirige tout ce qui est ecrit en texte (prints, logs) vers stderr,
15
+ - force l'encodage UTF-8 sur les deux flux.
16
+
17
+ Usage (dans la config MCP d'un client) :
18
+ D:/IcyCache/venv/Scripts/python.exe D:/IcyCache/src/mcp_stdio_bridge.py --mode mcp-stdio
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import runpy
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ SRC_DIR = Path(__file__).resolve().parent
28
+ TARGET = SRC_DIR / "mcp_server.py"
29
+
30
+
31
+ class _StdoutToStderr:
32
+ """Expose .buffer (stdout reel, utilise par le SDK MCP) mais route le texte vers stderr."""
33
+
34
+ def __init__(self, real_stdout, sink):
35
+ self._real = real_stdout
36
+ self._sink = sink
37
+ self.buffer = real_stdout.buffer # <- le SDK MCP ecrit ici
38
+ self.encoding = "utf-8"
39
+
40
+ def write(self, data):
41
+ return self._sink.write(data)
42
+
43
+ def flush(self):
44
+ return self._sink.flush()
45
+
46
+ def isatty(self):
47
+ return False
48
+
49
+ def fileno(self):
50
+ return self._sink.fileno()
51
+
52
+ def writable(self):
53
+ return True
54
+
55
+ def __getattr__(self, item):
56
+ return getattr(self._sink, item)
57
+
58
+
59
+ def main() -> None:
60
+ # Encodage UTF-8 explicite : evite le crash cp1252 sur les caracteres accentues.
61
+ for stream_name in ("stdout", "stderr"):
62
+ stream = getattr(sys, stream_name, None)
63
+ if stream is not None and hasattr(stream, "reconfigure"):
64
+ try:
65
+ stream.reconfigure(encoding="utf-8", errors="replace")
66
+ except Exception:
67
+ pass
68
+
69
+ if not TARGET.exists():
70
+ print(f"[IcyCache] mcp_server.py introuvable : {TARGET}", file=sys.stderr)
71
+ sys.exit(2)
72
+
73
+ real_stdout = sys.stdout
74
+ sys.stdout = _StdoutToStderr(real_stdout, sys.stderr)
75
+
76
+ os.environ.setdefault("PYTHONIOENCODING", "utf-8")
77
+ sys.argv = [str(TARGET)] + sys.argv[1:]
78
+ sys.path.insert(0, str(SRC_DIR))
79
+ runpy.run_path(str(TARGET), run_name="__main__")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
@@ -0,0 +1,23 @@
1
+ """Entree MCP stdio pour la distribution installee.
2
+
3
+ `mcp_server.py` ecrit sur stdout pendant son import (banniere, logs d'init) :
4
+ sur le transport stdio, stdout appartient au JSON-RPC. On le detourne donc
5
+ vers stderr pendant l'import, puis on rend stdout au protocole.
6
+
7
+ Equivalent de `mcp_stdio.py` a la racine du depot, mais place dans `src/`
8
+ pour que le chemin du paquet installe soit correct.
9
+ """
10
+ import os
11
+ import sys
12
+
13
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+
15
+ _real_stdout = sys.stdout
16
+ sys.stdout = sys.stderr
17
+ try:
18
+ import mcp_server # noqa: E402
19
+ finally:
20
+ sys.stdout = _real_stdout
21
+
22
+ if __name__ == "__main__":
23
+ mcp_server.mcp.run(transport="stdio")
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env python3
2
+ """CacheForge PrefixProxy v1 - proxy ADDITIF (port 8090) pour le cache de PREFIXE des fournisseurs cloud.
3
+ Ne modifie ni mcp_server.py ni cache.db. Ne stocke AUCUNE cle API (transmises telles quelles au fournisseur).
4
+
5
+ Anthropic : ajoute `cache_control` au niveau BLOC (system / dernier content), jamais a la racine.
6
+ OpenAI : ajoute un `prompt_cache_key` stable (hash du prompt systeme) si absent.
7
+ Mesure : tokens lus / ecrits dans le cache du fournisseur -> GET /api/prefix-stats
8
+
9
+ Base URL cote client:
10
+ Anthropic SDK : http://127.0.0.1:8090/anthropic (POST /anthropic/v1/messages)
11
+ OpenAI SDK : http://127.0.0.1:8090/openai/v1 (POST /openai/v1/chat/completions)
12
+ Autres fournisseurs compatibles OpenAI (URLs NON TESTEES): /openrouter /groq /mistral /deepseek
13
+ Surcharge d'une URL amont: variable d'environnement PREFIX_URL_<NOM> (ex: PREFIX_URL_OPENAI).
14
+ """
15
+ import os, json, time, sqlite3, hashlib, logging, sys
16
+ from pathlib import Path
17
+ import httpx
18
+ from fastapi import FastAPI, Request
19
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ from icy_paths import resolve_data_path # noqa: E402
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+
26
+ DB_FILE = resolve_data_path(os.environ.get("PREFIX_DB"), "db/prefix.db")
27
+ LOG_DIR = resolve_data_path(None, "logs")
28
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
29
+ DB_FILE.parent.mkdir(parents=True, exist_ok=True)
30
+
31
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
32
+ handlers=[logging.FileHandler(LOG_DIR / "prefix_proxy.log", encoding="utf-8")])
33
+ log = logging.getLogger("PrefixProxy")
34
+
35
+
36
+ def _flag(name, default="1"):
37
+ return os.environ.get(name, default) == "1"
38
+
39
+
40
+ INJECT_ANTHROPIC = _flag("PREFIX_INJECT_ANTHROPIC") # ajoute cache_control racine
41
+ INJECT_OPENAI_KEY = _flag("PREFIX_INJECT_OPENAI_KEY") # ajoute prompt_cache_key
42
+ METER_OPENAI_STREAM = _flag("PREFIX_METER_OPENAI_STREAM") # ajoute stream_options.include_usage (OpenAI, stream)
43
+
44
+ UPSTREAMS = {
45
+ "anthropic": "https://api.anthropic.com/v1/messages",
46
+ "openai": "https://api.openai.com/v1/chat/completions",
47
+ "openrouter": "https://openrouter.ai/api/v1/chat/completions", # non teste
48
+ "groq": "https://api.groq.com/openai/v1/chat/completions", # non teste
49
+ "mistral": "https://api.mistral.ai/v1/chat/completions", # non teste
50
+ "deepseek": "https://api.deepseek.com/chat/completions", # non teste
51
+ }
52
+ for _k in list(UPSTREAMS):
53
+ UPSTREAMS[_k] = os.environ.get("PREFIX_URL_" + _k.upper(), UPSTREAMS[_k])
54
+
55
+ # Multiplicateurs de prix pour l'equivalent en tokens economises (pas des $).
56
+ # Anthropic lecture = 0.1x (verifie dans la doc officielle). Ecriture 5 min = 1.25x: NON VERIFIE (surchargeable).
57
+ # OpenAI: aucun multiplicateur configure -> pas d'equivalent calcule (tokens lus bruts seulement).
58
+ READ_MULT = {"anthropic": 0.1}
59
+ WRITE_MULT = {"anthropic": float(os.environ.get("PREFIX_ANTHROPIC_WRITE_MULT", "1.25"))}
60
+
61
+ FWD = {"authorization", "x-api-key", "anthropic-version", "anthropic-beta", "content-type",
62
+ "openai-organization", "openai-project", "http-referer", "x-title", "user-agent"}
63
+
64
+ client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=15.0))
65
+
66
+ async def _recycle_client():
67
+ """DELTA-002: recycle le client httpx sur erreur (BUG-001 fix)."""
68
+ global client
69
+ try:
70
+ await client.aclose()
71
+ except Exception:
72
+ pass
73
+ client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=15.0))
74
+ app = FastAPI(title="CacheForge PrefixProxy")
75
+
76
+
77
+ def init_db():
78
+ c = sqlite3.connect(DB_FILE)
79
+ c.execute("""CREATE TABLE IF NOT EXISTS events (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT DEFAULT CURRENT_TIMESTAMP,
81
+ provider TEXT, model TEXT, stream INTEGER, status INTEGER, injected TEXT,
82
+ total_in INTEGER, cache_read INTEGER, cache_write INTEGER, out_tokens INTEGER,
83
+ latency_ms INTEGER, measured INTEGER)""")
84
+ c.commit()
85
+ c.close()
86
+
87
+
88
+ init_db()
89
+
90
+
91
+ def _log_event(provider, model, stream, status, injected, usage, latency_ms):
92
+ u = usage or {}
93
+ try:
94
+ c = sqlite3.connect(DB_FILE)
95
+ c.execute("INSERT INTO events (provider,model,stream,status,injected,total_in,cache_read,cache_write,"
96
+ "out_tokens,latency_ms,measured) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
97
+ (provider, model, int(bool(stream)), status, injected, u.get("total_in", 0), u.get("read", 0),
98
+ u.get("write", 0), u.get("out", 0), latency_ms, 1 if usage else 0))
99
+ c.commit()
100
+ c.close()
101
+ except Exception:
102
+ log.exception("event log failed")
103
+ if usage:
104
+ log.info("%s %s status=%s in=%s cache_read=%s cache_write=%s out=%s injected=%s %sms", provider, model,
105
+ status, u.get("total_in"), u.get("read"), u.get("write"), u.get("out"), injected, latency_ms)
106
+ else:
107
+ log.info("%s %s status=%s (usage non mesure) injected=%s %sms", provider, model, status, injected, latency_ms)
108
+
109
+
110
+ def usage_from(kind, u):
111
+ u = u or {}
112
+ if kind == "anthropic":
113
+ i = u.get("input_tokens") or 0
114
+ r = u.get("cache_read_input_tokens") or 0
115
+ w = u.get("cache_creation_input_tokens") or 0
116
+ return {"total_in": i + r + w, "read": r, "write": w, "out": u.get("output_tokens") or 0}
117
+ d = u.get("prompt_tokens_details") or {}
118
+ return {"total_in": u.get("prompt_tokens") or 0, "read": d.get("cached_tokens") or 0,
119
+ "write": d.get("cache_write_tokens") or 0, "out": u.get("completion_tokens") or 0}
120
+
121
+
122
+ def _inject_anthropic_cache_control(body):
123
+ """Pose `cache_control` sur les blocs stables UNIQUEMENT (system + tools), jamais sur le dernier content.
124
+
125
+ Raison : le dernier message user peut etre compacte/reecrit par l'agent, ce qui casserait le
126
+ cache Anthropic (qui invalide TOUT ce qui suit un changement de prefixe). Le system et les tools
127
+ sont les prefixes les plus stables. On ne pose jamais cache_control sur un message user/assistant."""
128
+ targets = []
129
+ sysv = body.get("system")
130
+ if isinstance(sysv, list):
131
+ targets = sysv
132
+ elif isinstance(sysv, str) and sysv.strip():
133
+ blocks = [{"type": "text", "text": sysv}]
134
+ body["system"] = blocks
135
+ targets = blocks
136
+ if targets and isinstance(targets[-1], dict) and "cache_control" not in targets[-1]:
137
+ targets[-1]["cache_control"] = {"type": "ephemeral"}
138
+ # Tools : posent aussi un breakpoint (prefixe stable, max 4 breakpoints Anthropic)
139
+ for t in body.get("tools") or []:
140
+ if isinstance(t, dict) and "cache_control" not in t:
141
+ t["cache_control"] = {"type": "ephemeral"}
142
+ return bool(targets)
143
+
144
+
145
+ def _extract_text(msg):
146
+ """Extrait le texte d'un message : content str ou liste de blocs (multimodal)."""
147
+ c = msg.get("content")
148
+ if isinstance(c, str):
149
+ return c
150
+ if isinstance(c, list):
151
+ return " ".join(b.get("text", "") for b in c if isinstance(b, dict) and "text" in b)
152
+ return ""
153
+
154
+
155
+ def mutate(kind, provider, body):
156
+ """Active le cache de prefixe cote fournisseur. Retourne le nom du champ injecte (ou None).
157
+ Anthropic : cache_control au niveau bloc (system/content). Tous les autres (famille
158
+ OpenAI-compatible : OpenAI, Mistral, DeepSeek, Groq, OpenRouter, omniroute, locaux) :
159
+ prompt_cache_key stable par session = hash(system + 1er message user + modele). Le 1er user
160
+ reste stable sur tous les tours d'une conversation, ce qui colle les tours au meme prefix
161
+ cache cote fournisseur (tokens caches factures ~10% au lieu de 100%)."""
162
+ if kind == "anthropic":
163
+ if INJECT_ANTHROPIC and _inject_anthropic_cache_control(body):
164
+ return "cache_control"
165
+ else:
166
+ if INJECT_OPENAI_KEY and "prompt_cache_key" not in body:
167
+ msgs = body.get("messages") or []
168
+ sys_txt = next((_extract_text(m) for m in msgs if m.get("role") in ("system", "developer")), "")
169
+ first_user = next((_extract_text(m) for m in msgs if m.get("role") == "user"), "")
170
+ if sys_txt or first_user:
171
+ model = body.get("model") or ""
172
+ src = json.dumps({"s": sys_txt, "u": first_user, "m": model},
173
+ sort_keys=True, ensure_ascii=False)
174
+ digest = hashlib.sha256(src.encode("utf-8")).hexdigest()[:16]
175
+ body["prompt_cache_key"] = "ic-" + digest
176
+ # session_id forward : si le client envoie un session_id (sticky routing OpenRouter/omniroute),
177
+ # on le laisse passer tel quel. Les providers qui ne le connaissent pas l'ignorent (sans danger).
178
+ return "prompt_cache_key"
179
+ return None
180
+
181
+
182
+ class StreamMeter:
183
+ """Lit les evenements SSE au passage (sans les modifier) pour extraire l'usage."""
184
+
185
+ def __init__(self, kind):
186
+ self.kind, self.buf, self.usage, self.model = kind, b"", {}, None
187
+
188
+ def feed(self, chunk):
189
+ self.buf += chunk
190
+ while b"\n" in self.buf:
191
+ line, self.buf = self.buf.split(b"\n", 1)
192
+ line = line.strip()
193
+ if not line.startswith(b"data:"):
194
+ continue
195
+ raw = line[5:].strip()
196
+ if raw == b"[DONE]":
197
+ continue
198
+ try:
199
+ ev = json.loads(raw)
200
+ except Exception:
201
+ continue
202
+ if self.kind == "anthropic":
203
+ t = ev.get("type")
204
+ if t == "message_start":
205
+ m = ev.get("message") or {}
206
+ self.model = m.get("model") or self.model
207
+ self.usage.update({k: v for k, v in (m.get("usage") or {}).items() if v is not None})
208
+ elif t == "message_delta":
209
+ self.usage.update({k: v for k, v in (ev.get("usage") or {}).items() if v is not None})
210
+ else:
211
+ self.model = ev.get("model") or self.model
212
+ if ev.get("usage"):
213
+ self.usage = ev["usage"]
214
+
215
+
216
+ async def _send(url, headers, body):
217
+ req = client.build_request("POST", url, json=body, headers=headers)
218
+ return await client.send(req, stream=True)
219
+
220
+
221
+ @app.post("/{provider}/v1/{endpoint:path}")
222
+ async def proxy(provider: str, endpoint: str, request: Request):
223
+ url = UPSTREAMS.get(provider)
224
+ kind = "anthropic" if provider == "anthropic" else "openai"
225
+ expected = "messages" if kind == "anthropic" else "chat/completions"
226
+ if not url or endpoint != expected:
227
+ return JSONResponse({"error": {"message": "route inconnue: /%s/v1/%s" % (provider, endpoint),
228
+ "type": "not_found"}}, status_code=404)
229
+ try:
230
+ body = await request.json()
231
+ except Exception:
232
+ return JSONResponse({"error": {"message": "corps JSON invalide", "type": "invalid_request"}}, status_code=400)
233
+
234
+ is_stream = bool(body.get("stream"))
235
+ injected = mutate(kind, provider, body)
236
+ if provider == "openai" and is_stream and METER_OPENAI_STREAM:
237
+ body.setdefault("stream_options", {}).setdefault("include_usage", True)
238
+ headers = {k: v for k, v in request.headers.items() if k.lower() in FWD}
239
+ headers["accept-encoding"] = "identity"
240
+ model = body.get("model")
241
+ t0 = time.time()
242
+
243
+ def ms():
244
+ return int((time.time() - t0) * 1000)
245
+
246
+ try:
247
+ resp = await _send(url, headers, body)
248
+ if resp.status_code == 400 and injected:
249
+ err = await resp.aread()
250
+ ctype = resp.headers.get("content-type", "application/json")
251
+ await resp.aclose()
252
+ if injected.encode() in err:
253
+ log.warning("amont refuse '%s' -> retry sans injection", injected)
254
+ body.pop(injected, None)
255
+ injected = None
256
+ resp = await _send(url, headers, body)
257
+ else:
258
+ _log_event(provider, model, is_stream, 400, injected, None, ms())
259
+ return Response(content=err, status_code=400, headers={"content-type": ctype})
260
+ except httpx.HTTPError as e:
261
+ # DELTA-002: drain + recycle + retry (BUG-001 fix)
262
+ await _recycle_client()
263
+ log.error("UPSTREAM UNREACHABLE %s: %s: %s", provider, type(e).__name__, e)
264
+ # Retry une fois après recycle
265
+ try:
266
+ resp = await _send(url, headers, body)
267
+ resp.raise_for_status()
268
+ log.info("UPSTREAM RECOVERED after retry: %s", provider)
269
+ except Exception as e2:
270
+ log.error("UPSTREAM RETRY FAILED %s: %s: %s", provider, type(e2).__name__, e2)
271
+ return JSONResponse({"error": {"message": "upstream unreachable: %s: %s" % (type(e2).__name__, e2),
272
+ "type": "upstream_error"}}, status_code=502)
273
+
274
+ ctype = resp.headers.get("content-type", "application/json")
275
+
276
+ if is_stream and resp.status_code == 200:
277
+ async def gen():
278
+ meter = StreamMeter(kind)
279
+ try:
280
+ async for chunk in resp.aiter_raw():
281
+ meter.feed(chunk)
282
+ yield chunk
283
+ finally:
284
+ await resp.aclose()
285
+ _log_event(provider, meter.model or model, True, 200, injected,
286
+ usage_from(kind, meter.usage) if meter.usage else None, ms())
287
+ return StreamingResponse(gen(), status_code=200, media_type="text/event-stream")
288
+
289
+ content = await resp.aread()
290
+ await resp.aclose()
291
+ usage = None
292
+ if resp.status_code == 200:
293
+ try:
294
+ j = json.loads(content)
295
+ model = j.get("model") or model
296
+ if j.get("usage"):
297
+ usage = usage_from(kind, j["usage"])
298
+ except Exception:
299
+ pass
300
+ _log_event(provider, model, False, resp.status_code, injected, usage, ms())
301
+ return Response(content=content, status_code=resp.status_code, headers={"content-type": ctype})
302
+
303
+
304
+ @app.get("/health")
305
+ async def health():
306
+ return {"status": "ok", "providers": sorted(UPSTREAMS), "inject_anthropic": INJECT_ANTHROPIC,
307
+ "inject_openai_key": INJECT_OPENAI_KEY, "db": str(DB_FILE)}
308
+
309
+
310
+ @app.get("/api/prefix-stats")
311
+ async def prefix_stats():
312
+ c = sqlite3.connect(DB_FILE)
313
+ rows = c.execute("""SELECT provider, COUNT(*), COALESCE(SUM(measured),0), COALESCE(SUM(cache_read>0),0),
314
+ COALESCE(SUM(total_in),0), COALESCE(SUM(cache_read),0), COALESCE(SUM(cache_write),0),
315
+ COALESCE(SUM(out_tokens),0), COALESCE(SUM(injected IS NOT NULL),0)
316
+ FROM events WHERE status=200 GROUP BY provider""").fetchall()
317
+ c.close()
318
+ out = {}
319
+ for prov, n, meas, hits, tin, rd, wr, outt, inj in rows:
320
+ rm = READ_MULT.get(prov if prov == "anthropic" else "openai")
321
+ wm = WRITE_MULT.get(prov if prov == "anthropic" else "openai", 1.0)
322
+ equiv = None if rm is None else round(rd * (1 - rm) - wr * (wm - 1), 1)
323
+ out[prov] = {"requests": n, "measured": meas, "hits": hits, "input_tokens_total": tin,
324
+ "cache_read_tokens": rd, "cache_write_tokens": wr, "output_tokens": outt,
325
+ "injected_requests": inj, "prefix_hit_pct": round(100 * rd / tin, 1) if tin else 0,
326
+ "equiv_saved_input_tokens": equiv}
327
+ return {"providers": out,
328
+ "notes": ["equiv_saved_input_tokens = lecture*(1-mult_lecture) - ecriture*(mult_ecriture-1); "
329
+ "negatif = le cache a coute plus qu'il n'a economise.",
330
+ "Anthropic: lecture 0.1x verifie; ecriture 1.25x non verifie. OpenAI: pas de multiplicateur configure.",
331
+ "Ce sont des TOKENS equivalents, pas des $."]}
332
+
333
+
334
+ if __name__ == "__main__":
335
+ import uvicorn
336
+ uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PREFIX_PORT", "8090")), log_level="warning")