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,98 @@
|
|
|
1
|
+
"""Client upstream résilient (async) — corrige BUG-001.
|
|
2
|
+
|
|
3
|
+
Adapté du delta DELTA-002 Volet A en async pour matcher mcp_server.py (httpx.AsyncClient).
|
|
4
|
+
|
|
5
|
+
Invariants :
|
|
6
|
+
1. Toute réponse (succès OU erreur) est drainée avant retour au pool.
|
|
7
|
+
2. Toute erreur de transport → connexion jetée (recycle) + 1 retry.
|
|
8
|
+
3. Après un 4xx/5xx, l'upstream est marqué "suspect" TTL court ; on sonde
|
|
9
|
+
/v1/models avant de réutiliser le pool.
|
|
10
|
+
4. Le message d'erreur porte status + body (fini le message vide).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
import asyncio
|
|
14
|
+
import time
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UpstreamError(Exception):
|
|
19
|
+
def __init__(self, upstream: str, status: int, body: str, cause: BaseException | None = None):
|
|
20
|
+
self.upstream = upstream
|
|
21
|
+
self.status = status
|
|
22
|
+
self.body = body[:2000]
|
|
23
|
+
self.cause = cause
|
|
24
|
+
super().__init__(f"{upstream} returned {status}: {self.body[:200]!r} (cause={cause!r})")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ResilientUpstream:
|
|
28
|
+
"""Client async thread-safe-ish (un instance par upstream, usage single-task)."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, base_url: str, timeout_s: float = 60.0,
|
|
31
|
+
connect_s: float = 10.0, suspect_ttl_s: float = 5.0):
|
|
32
|
+
self._base = base_url.rstrip("/")
|
|
33
|
+
self._timeout = httpx.Timeout(timeout_s, connect=connect_s)
|
|
34
|
+
self._client: httpx.AsyncClient | None = None
|
|
35
|
+
self._suspect_until = 0.0
|
|
36
|
+
self._suspect_ttl = suspect_ttl_s
|
|
37
|
+
self._lock = asyncio.Lock()
|
|
38
|
+
|
|
39
|
+
async def _ensure_client(self) -> httpx.AsyncClient:
|
|
40
|
+
if self._client is None or self._client.is_closed:
|
|
41
|
+
self._client = httpx.AsyncClient(timeout=self._timeout)
|
|
42
|
+
return self._client
|
|
43
|
+
|
|
44
|
+
async def _recycle(self) -> None:
|
|
45
|
+
if self._client and not self._client.is_closed:
|
|
46
|
+
try:
|
|
47
|
+
await self._client.aclose()
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
self._client = httpx.AsyncClient(timeout=self._timeout)
|
|
51
|
+
|
|
52
|
+
async def _probe(self) -> bool:
|
|
53
|
+
try:
|
|
54
|
+
c = await self._ensure_client()
|
|
55
|
+
r = await c.get(f"{self._base}/v1/models", timeout=5.0)
|
|
56
|
+
await r.aread()
|
|
57
|
+
r.close()
|
|
58
|
+
return True
|
|
59
|
+
except Exception:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
async def _ensure_healthy(self) -> None:
|
|
63
|
+
if time.monotonic() < self._suspect_until:
|
|
64
|
+
if await self._probe():
|
|
65
|
+
self._suspect_until = 0.0
|
|
66
|
+
else:
|
|
67
|
+
await self._recycle()
|
|
68
|
+
|
|
69
|
+
def _mark_suspect(self) -> None:
|
|
70
|
+
self._suspect_until = time.monotonic() + self._suspect_ttl
|
|
71
|
+
|
|
72
|
+
async def post_json(self, path: str, json_body: dict, stream: bool = False,
|
|
73
|
+
headers: dict | None = None) -> httpx.Response:
|
|
74
|
+
async with self._lock:
|
|
75
|
+
await self._ensure_healthy()
|
|
76
|
+
url = f"{self._base}{path}"
|
|
77
|
+
client = await self._ensure_client()
|
|
78
|
+
try:
|
|
79
|
+
resp = await client.post(url, json=json_body, headers=headers or {})
|
|
80
|
+
except (httpx.RemoteProtocolError, httpx.ReadError,
|
|
81
|
+
httpx.ConnectError, httpx.WriteError) as e:
|
|
82
|
+
await self._recycle()
|
|
83
|
+
client = await self._ensure_client()
|
|
84
|
+
resp = await client.post(url, json=json_body, headers=headers or {})
|
|
85
|
+
|
|
86
|
+
if resp.status_code >= 400:
|
|
87
|
+
body = (await resp.aread()).decode("utf-8", "replace")
|
|
88
|
+
await resp.aclose()
|
|
89
|
+
self._mark_suspect()
|
|
90
|
+
raise UpstreamError(self._base, resp.status_code, body)
|
|
91
|
+
|
|
92
|
+
if not stream:
|
|
93
|
+
await resp.aread()
|
|
94
|
+
return resp
|
|
95
|
+
|
|
96
|
+
async def close(self) -> None:
|
|
97
|
+
if self._client and not self._client.is_closed:
|
|
98
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""IcyCache upstream helpers.
|
|
3
|
+
|
|
4
|
+
Extraits de prefix_proxy.py SANS effets de bord a l'import (pas de logging.basicConfig,
|
|
5
|
+
pas d'init DB, pas de client httpx). Ne stocke aucune cle API.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import hashlib
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _flag(name, default="1"):
|
|
13
|
+
return os.environ.get(name, default) == "1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
INJECT_ANTHROPIC = _flag("PREFIX_INJECT_ANTHROPIC") # ajoute cache_control racine
|
|
17
|
+
INJECT_OPENAI_KEY = _flag("PREFIX_INJECT_OPENAI_KEY") # ajoute prompt_cache_key (upstream "openai" seulement)
|
|
18
|
+
|
|
19
|
+
# Headers client transmis tels quels a l'upstream (aucune cle stockee cote IcyCache).
|
|
20
|
+
FWD_HEADERS = {"authorization", "x-api-key", "anthropic-version", "anthropic-beta", "content-type",
|
|
21
|
+
"openai-organization", "openai-project", "http-referer", "x-title", "user-agent"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def usage_from(kind, u):
|
|
25
|
+
"""Normalise le champ usage upstream. Valeurs absentes = None (jamais estimees)."""
|
|
26
|
+
u = u or {}
|
|
27
|
+
if kind == "anthropic":
|
|
28
|
+
i = u.get("input_tokens")
|
|
29
|
+
r = u.get("cache_read_input_tokens") or 0
|
|
30
|
+
w = u.get("cache_creation_input_tokens") or 0
|
|
31
|
+
total_in = (i or 0) + r + w if (i is not None or r or w) else None
|
|
32
|
+
return {"prompt_tokens": total_in, "completion_tokens": u.get("output_tokens"),
|
|
33
|
+
"cached_tokens": r, "cache_write_tokens": w}
|
|
34
|
+
d = u.get("prompt_tokens_details") or {}
|
|
35
|
+
return {"prompt_tokens": u.get("prompt_tokens"), "completion_tokens": u.get("completion_tokens"),
|
|
36
|
+
"cached_tokens": d.get("cached_tokens") or 0, "cache_write_tokens": d.get("cache_write_tokens") or 0}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _inject_anthropic_cache_control(body):
|
|
40
|
+
"""Pose `cache_control` sur les blocs stables UNIQUEMENT (system + tools), jamais sur le dernier content.
|
|
41
|
+
|
|
42
|
+
Anthropic n'accepte `cache_control` que sur un bloc de contenu : un
|
|
43
|
+
`cache_control` racine fait rejeter la requete (400 extra inputs).
|
|
44
|
+
On cible UNIQUEMENT le system et les tools (prefixes stables) — jamais le
|
|
45
|
+
dernier message user/assistant, qui peut etre reecrit par la compaction et
|
|
46
|
+
invalider tout le cache Anthropic (invalidation en cascade du prefixe)."""
|
|
47
|
+
system = body.get("system")
|
|
48
|
+
if isinstance(system, str) and system.strip():
|
|
49
|
+
body["system"] = [{"type": "text", "text": system,
|
|
50
|
+
"cache_control": {"type": "ephemeral"}}]
|
|
51
|
+
elif isinstance(system, list) and system:
|
|
52
|
+
last = system[-1]
|
|
53
|
+
if isinstance(last, dict) and "cache_control" not in last:
|
|
54
|
+
last["cache_control"] = {"type": "ephemeral"}
|
|
55
|
+
# Tools : posent aussi un breakpoint (prefixe stable, max 4 breakpoints Anthropic)
|
|
56
|
+
for t in body.get("tools") or []:
|
|
57
|
+
if isinstance(t, dict) and "cache_control" not in t:
|
|
58
|
+
t["cache_control"] = {"type": "ephemeral"}
|
|
59
|
+
return bool(system or body.get("tools"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_text(msg):
|
|
63
|
+
"""Extrait le texte d'un message : content str ou liste de blocs (multimodal)."""
|
|
64
|
+
c = msg.get("content")
|
|
65
|
+
if isinstance(c, str):
|
|
66
|
+
return c
|
|
67
|
+
if isinstance(c, list):
|
|
68
|
+
return " ".join(b.get("text", "") for b in c if isinstance(b, dict) and "text" in b)
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def mutate(kind, upstream, body):
|
|
73
|
+
"""Active le cache de prefixe cote fournisseur. Retourne le nom du champ injecte (ou None).
|
|
74
|
+
Anthropic : cache_control au niveau bloc (system/content). Tous les autres (famille
|
|
75
|
+
OpenAI-compatible : OpenAI, Mistral, DeepSeek, Groq, OpenRouter, omniroute, locaux) :
|
|
76
|
+
prompt_cache_key stable par session = hash(system + 1er message user + modele). Le 1er user
|
|
77
|
+
reste stable sur tous les tours d'une conversation, ce qui colle les tours au meme prefix
|
|
78
|
+
cache cote fournisseur (tokens caches factures ~10% au lieu de 100%)."""
|
|
79
|
+
if kind == "anthropic":
|
|
80
|
+
if INJECT_ANTHROPIC and "cache_control" not in json.dumps(body):
|
|
81
|
+
if _inject_anthropic_cache_control(body):
|
|
82
|
+
return "cache_control"
|
|
83
|
+
else:
|
|
84
|
+
if INJECT_OPENAI_KEY and "prompt_cache_key" not in body:
|
|
85
|
+
msgs = body.get("messages") or []
|
|
86
|
+
sys_txt = next((_extract_text(m) for m in msgs if m.get("role") in ("system", "developer")), "")
|
|
87
|
+
first_user = next((_extract_text(m) for m in msgs if m.get("role") == "user"), "")
|
|
88
|
+
if sys_txt or first_user:
|
|
89
|
+
model = body.get("model") or ""
|
|
90
|
+
src = json.dumps({"s": sys_txt, "u": first_user, "m": model},
|
|
91
|
+
sort_keys=True, ensure_ascii=False)
|
|
92
|
+
digest = hashlib.sha256(src.encode("utf-8")).hexdigest()[:16]
|
|
93
|
+
body["prompt_cache_key"] = "ic-" + digest
|
|
94
|
+
# session_id forward : si le client envoie un session_id (sticky routing OpenRouter/omniroute),
|
|
95
|
+
# on le laisse passer tel quel. Les providers qui ne le connaissent pas l'ignorent (sans danger).
|
|
96
|
+
return "prompt_cache_key"
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class StreamMeter:
|
|
101
|
+
"""Lit les evenements SSE au passage (sans les modifier) pour extraire usage + modele.
|
|
102
|
+
|
|
103
|
+
`events` = nombre d'evenements data recus (compteur de CHUNKS, pas de tokens).
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, kind):
|
|
107
|
+
self.kind, self.buf, self.usage, self.model, self.events = kind, b"", {}, None, 0
|
|
108
|
+
|
|
109
|
+
def feed(self, chunk):
|
|
110
|
+
self.buf += chunk
|
|
111
|
+
while b"\n" in self.buf:
|
|
112
|
+
line, self.buf = self.buf.split(b"\n", 1)
|
|
113
|
+
line = line.strip()
|
|
114
|
+
if not line.startswith(b"data:"):
|
|
115
|
+
continue
|
|
116
|
+
raw = line[5:].strip()
|
|
117
|
+
if raw == b"[DONE]":
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
ev = json.loads(raw)
|
|
121
|
+
except Exception:
|
|
122
|
+
continue
|
|
123
|
+
self.events += 1
|
|
124
|
+
if self.kind == "anthropic":
|
|
125
|
+
t = ev.get("type")
|
|
126
|
+
if t == "message_start":
|
|
127
|
+
m = ev.get("message") or {}
|
|
128
|
+
self.model = m.get("model") or self.model
|
|
129
|
+
self.usage.update({k: v for k, v in (m.get("usage") or {}).items() if v is not None})
|
|
130
|
+
elif t == "message_delta":
|
|
131
|
+
self.usage.update({k: v for k, v in (ev.get("usage") or {}).items() if v is not None})
|
|
132
|
+
else:
|
|
133
|
+
self.model = ev.get("model") or self.model
|
|
134
|
+
if ev.get("usage"):
|
|
135
|
+
self.usage = ev["usage"]
|