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,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"]