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