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,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"total": 100,
|
|
4
|
+
"verified_anchors": [
|
|
5
|
+
{"name": "ha-mcp", "version": "8.7.0", "transport": "stdio"},
|
|
6
|
+
{"name": "serena-agent", "version": "2.1.0", "cmd": "serena start-mcp-server", "transport": "stdio"},
|
|
7
|
+
{"name": "browser-use", "version": "0.13.11", "flag": "--mcp", "transport": "stdio"}
|
|
8
|
+
],
|
|
9
|
+
"groups": {"A": 15, "B": 22, "C1": 3, "C2": 60}
|
|
10
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""IcyCache - store de cache LLM (compatible avec la table `cache` de mcp_server.py).
|
|
3
|
+
|
|
4
|
+
La table `cache` existe deja en base (creee par mcp_server.py). Ce module y accede
|
|
5
|
+
sans la recreer ni l'alterer, a part une colonne additive `raw_hash` qui permet
|
|
6
|
+
de savoir si un hit vient d'un payload identique ou d'un payload normalise
|
|
7
|
+
equivalent (transparence pour le dashboard).
|
|
8
|
+
|
|
9
|
+
Format de stockage de `response` (v3) :
|
|
10
|
+
{"_v": 3, "kind": "openai", "message": {...}, "usage": {...}, "model": "...",
|
|
11
|
+
"finish_reason": "stop", "sse": "data: ...\\n\\n", "gen_time_ms": 123}
|
|
12
|
+
|
|
13
|
+
Les entrees v2 (mcp_server.py) restent lisibles.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sqlite3
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
__all__ = ["CacheStore", "decode_entry", "encode_entry"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def encode_entry(kind: str, message: Any, usage: Dict[str, Any], model: str,
|
|
28
|
+
finish_reason: str, sse: str, gen_time_ms: int) -> str:
|
|
29
|
+
return json.dumps(
|
|
30
|
+
{
|
|
31
|
+
"_v": 3,
|
|
32
|
+
"kind": kind,
|
|
33
|
+
"message": message,
|
|
34
|
+
"usage": usage or {},
|
|
35
|
+
"model": model,
|
|
36
|
+
"finish_reason": finish_reason or "stop",
|
|
37
|
+
"sse": sse,
|
|
38
|
+
"gen_time_ms": gen_time_ms,
|
|
39
|
+
},
|
|
40
|
+
ensure_ascii=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def decode_entry(raw: str) -> Dict[str, Any]:
|
|
45
|
+
"""Decode une entree cache (v3 ou v2 ou texte brut) en structure exploitable."""
|
|
46
|
+
out: Dict[str, Any] = {"message": None, "usage": {}, "model": None, "finish_reason": "stop",
|
|
47
|
+
"sse": None, "gen_time_ms": None, "version": 0}
|
|
48
|
+
if not raw:
|
|
49
|
+
return out
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(raw)
|
|
52
|
+
except (json.JSONDecodeError, TypeError):
|
|
53
|
+
out["message"] = {"role": "assistant", "content": raw}
|
|
54
|
+
return out
|
|
55
|
+
if not isinstance(data, dict):
|
|
56
|
+
out["message"] = data
|
|
57
|
+
return out
|
|
58
|
+
v = data.get("_v")
|
|
59
|
+
if v == 3:
|
|
60
|
+
out.update({
|
|
61
|
+
"message": data.get("message"),
|
|
62
|
+
"usage": data.get("usage") or {},
|
|
63
|
+
"model": data.get("model"),
|
|
64
|
+
"finish_reason": data.get("finish_reason") or "stop",
|
|
65
|
+
"sse": data.get("sse"),
|
|
66
|
+
"gen_time_ms": data.get("gen_time_ms"),
|
|
67
|
+
"version": 3,
|
|
68
|
+
})
|
|
69
|
+
elif v == 2:
|
|
70
|
+
out.update({
|
|
71
|
+
"message": data.get("message"),
|
|
72
|
+
"usage": data.get("usage") or {},
|
|
73
|
+
"model": data.get("model"),
|
|
74
|
+
"finish_reason": data.get("finish_reason") or "stop",
|
|
75
|
+
"sse": None,
|
|
76
|
+
"gen_time_ms": data.get("gen_time_ms"),
|
|
77
|
+
"version": 2,
|
|
78
|
+
})
|
|
79
|
+
else:
|
|
80
|
+
out["message"] = data
|
|
81
|
+
out["version"] = 1
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CacheStore:
|
|
86
|
+
"""Acces thread-safe (un lock par operation) au cache LLM."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, db_path: Path, ttl_seconds: int = 86400, max_entries: int = 10000):
|
|
89
|
+
self.db_path = Path(db_path)
|
|
90
|
+
self.ttl_seconds = int(ttl_seconds)
|
|
91
|
+
self.max_entries = int(max_entries)
|
|
92
|
+
self._lock = threading.Lock()
|
|
93
|
+
self._migrate()
|
|
94
|
+
|
|
95
|
+
def _connect(self) -> sqlite3.Connection:
|
|
96
|
+
conn = sqlite3.connect(self.db_path, timeout=10)
|
|
97
|
+
conn.execute("PRAGMA busy_timeout=10000")
|
|
98
|
+
return conn
|
|
99
|
+
|
|
100
|
+
def _migrate(self) -> None:
|
|
101
|
+
"""Colonnes additives uniquement (jamais de DROP / recreation)."""
|
|
102
|
+
with self._lock:
|
|
103
|
+
conn = self._connect()
|
|
104
|
+
try:
|
|
105
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(cache)")}
|
|
106
|
+
if "raw_hash" not in cols:
|
|
107
|
+
conn.execute("ALTER TABLE cache ADD COLUMN raw_hash TEXT")
|
|
108
|
+
conn.commit()
|
|
109
|
+
except sqlite3.OperationalError:
|
|
110
|
+
pass
|
|
111
|
+
finally:
|
|
112
|
+
conn.close()
|
|
113
|
+
|
|
114
|
+
def get(self, prompt_hash: str) -> Optional[Dict[str, Any]]:
|
|
115
|
+
with self._lock:
|
|
116
|
+
conn = self._connect()
|
|
117
|
+
try:
|
|
118
|
+
row = conn.execute(
|
|
119
|
+
"SELECT normalized_prompt, original_prompt, response, is_stream, provider, model, "
|
|
120
|
+
"created_at, access_count, raw_hash FROM cache "
|
|
121
|
+
"WHERE prompt_hash = ? AND created_at >= datetime('now', '-' || ? || ' seconds')",
|
|
122
|
+
(prompt_hash, self.ttl_seconds),
|
|
123
|
+
).fetchone()
|
|
124
|
+
if not row:
|
|
125
|
+
return None
|
|
126
|
+
conn.execute(
|
|
127
|
+
"UPDATE cache SET access_count = access_count + 1, last_accessed = CURRENT_TIMESTAMP "
|
|
128
|
+
"WHERE prompt_hash = ?", (prompt_hash,))
|
|
129
|
+
conn.commit()
|
|
130
|
+
return {
|
|
131
|
+
"prompt_hash": prompt_hash,
|
|
132
|
+
"normalized_prompt": row[0],
|
|
133
|
+
"original_prompt": row[1],
|
|
134
|
+
"response": row[2],
|
|
135
|
+
"is_stream": bool(row[3]),
|
|
136
|
+
"provider": row[4],
|
|
137
|
+
"model": row[5],
|
|
138
|
+
"created_at": row[6],
|
|
139
|
+
"access_count": (row[7] or 1) + 1,
|
|
140
|
+
"raw_hash": row[8],
|
|
141
|
+
}
|
|
142
|
+
finally:
|
|
143
|
+
conn.close()
|
|
144
|
+
|
|
145
|
+
def set(self, prompt_hash: str, normalized: str, original: str, response: str,
|
|
146
|
+
is_stream: bool, provider: str = None, model: str = None, raw_hash: str = None) -> None:
|
|
147
|
+
with self._lock:
|
|
148
|
+
conn = self._connect()
|
|
149
|
+
try:
|
|
150
|
+
conn.execute(
|
|
151
|
+
"INSERT OR REPLACE INTO cache "
|
|
152
|
+
"(prompt_hash, normalized_prompt, original_prompt, response, is_stream, provider, model, "
|
|
153
|
+
" created_at, last_accessed, access_count, raw_hash) "
|
|
154
|
+
"VALUES (?,?,?,?,?,?,?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1, ?)",
|
|
155
|
+
(prompt_hash, normalized, original, response, int(bool(is_stream)), provider, model, raw_hash),
|
|
156
|
+
)
|
|
157
|
+
conn.execute("DELETE FROM cache WHERE created_at < datetime('now', '-' || ? || ' seconds')",
|
|
158
|
+
(self.ttl_seconds,))
|
|
159
|
+
count = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
|
|
160
|
+
if count >= self.max_entries:
|
|
161
|
+
excess = count - self.max_entries + 1
|
|
162
|
+
conn.execute(
|
|
163
|
+
"DELETE FROM cache WHERE prompt_hash IN "
|
|
164
|
+
"(SELECT prompt_hash FROM cache ORDER BY last_accessed ASC LIMIT ?)", (excess,))
|
|
165
|
+
conn.commit()
|
|
166
|
+
finally:
|
|
167
|
+
conn.close()
|
|
168
|
+
|
|
169
|
+
def stats(self) -> Dict[str, Any]:
|
|
170
|
+
with self._lock:
|
|
171
|
+
conn = self._connect()
|
|
172
|
+
try:
|
|
173
|
+
total = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
|
|
174
|
+
hits = conn.execute("SELECT COALESCE(SUM(access_count - 1), 0) FROM cache").fetchone()[0] or 0
|
|
175
|
+
by_provider = dict(conn.execute(
|
|
176
|
+
"SELECT provider, COUNT(*) FROM cache GROUP BY provider").fetchall())
|
|
177
|
+
by_model = dict(conn.execute(
|
|
178
|
+
"SELECT model, COUNT(*) FROM cache GROUP BY model ORDER BY 2 DESC LIMIT 20").fetchall())
|
|
179
|
+
return {
|
|
180
|
+
"total_entries": total,
|
|
181
|
+
"total_hits": hits,
|
|
182
|
+
"by_provider": by_provider,
|
|
183
|
+
"by_model": by_model,
|
|
184
|
+
"db_path": str(self.db_path),
|
|
185
|
+
}
|
|
186
|
+
finally:
|
|
187
|
+
conn.close()
|
|
188
|
+
|
|
189
|
+
def clear(self, prompt_hash: Optional[str] = None) -> int:
|
|
190
|
+
with self._lock:
|
|
191
|
+
conn = self._connect()
|
|
192
|
+
try:
|
|
193
|
+
if prompt_hash:
|
|
194
|
+
n = conn.execute("DELETE FROM cache WHERE prompt_hash = ?", (prompt_hash,)).rowcount
|
|
195
|
+
else:
|
|
196
|
+
n = conn.execute("DELETE FROM cache").rowcount
|
|
197
|
+
conn.commit()
|
|
198
|
+
return n
|
|
199
|
+
finally:
|
|
200
|
+
conn.close()
|
|
201
|
+
|
|
202
|
+
def search(self, query: str, limit: int = 10) -> list:
|
|
203
|
+
limit = max(1, min(int(limit), 200))
|
|
204
|
+
with self._lock:
|
|
205
|
+
conn = self._connect()
|
|
206
|
+
try:
|
|
207
|
+
rows = conn.execute(
|
|
208
|
+
"SELECT prompt_hash, normalized_prompt, provider, model, created_at, access_count "
|
|
209
|
+
"FROM cache WHERE normalized_prompt LIKE ? ORDER BY access_count DESC LIMIT ?",
|
|
210
|
+
("%" + query + "%", limit)).fetchall()
|
|
211
|
+
return [
|
|
212
|
+
{"hash": r[0][:8] + "...", "normalized": r[1], "provider": r[2],
|
|
213
|
+
"model": r[3], "created_at": r[4], "access_count": r[5]}
|
|
214
|
+
for r in rows
|
|
215
|
+
]
|
|
216
|
+
finally:
|
|
217
|
+
conn.close()
|
icycache/src/icy_core.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""IcyCache core: config, DB (events / agent_events / tool_cache), hub WebSocket, loop guard.
|
|
3
|
+
|
|
4
|
+
Module partage par server_new.py (gateway HTTP) et mcp_gateway.py (gateway MCP).
|
|
5
|
+
Aucune dependance a FastAPI. Les evenements "agent" (tools, boucles) vont dans une table
|
|
6
|
+
SEPARÉE (agent_events) pour ne pas fausser les compteurs de la table events (/api/metrics).
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import sqlite3
|
|
12
|
+
import asyncio
|
|
13
|
+
import hashlib
|
|
14
|
+
import logging
|
|
15
|
+
import threading
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from collections import deque
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("IcyCache.core")
|
|
21
|
+
|
|
22
|
+
from icy_paths import PROJECT_ROOT, resolve_data_path # noqa: E402
|
|
23
|
+
|
|
24
|
+
CONFIG_FILE = Path(os.environ.get("ICYCACHE_CONFIG") or (PROJECT_ROOT / "config" / "icycache.json"))
|
|
25
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as _f:
|
|
26
|
+
CONFIG = json.load(_f)
|
|
27
|
+
# Priorite: variable d'env > chemin declare dans la config > chemin par defaut.
|
|
28
|
+
# (mcp_gateway/server_new lisaient "cache.db_path" ; on aligne icy_core sur la
|
|
29
|
+
# meme regle pour que tous les processus pointent sur la meme base.)
|
|
30
|
+
# Un chemin relatif est resolu contre DATA_DIR (racine du depot en mode source,
|
|
31
|
+
# repertoire utilisateur en mode installe) -> portable d'une machine a l'autre.
|
|
32
|
+
DB_FILE = resolve_data_path(
|
|
33
|
+
os.environ.get("ICYCACHE_DB")
|
|
34
|
+
or os.environ.get("CACHEFORGE_DB")
|
|
35
|
+
or CONFIG.get("cache", {}).get("db_path"),
|
|
36
|
+
"db/cache.db",
|
|
37
|
+
)
|
|
38
|
+
DB_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def utc_iso():
|
|
42
|
+
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def canonical(obj):
|
|
46
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def short_hash(s, n=16):
|
|
50
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:n]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _db():
|
|
54
|
+
return sqlite3.connect(DB_FILE, timeout=10)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# === HUB WEBSOCKET (thread-safe publish, fan-out dans la boucle asyncio du proxy) ===
|
|
58
|
+
class EventHub:
|
|
59
|
+
def __init__(self):
|
|
60
|
+
self.loop = None
|
|
61
|
+
self.subs = set()
|
|
62
|
+
|
|
63
|
+
def bind(self, loop):
|
|
64
|
+
self.loop = loop
|
|
65
|
+
|
|
66
|
+
def subscribe(self):
|
|
67
|
+
q = asyncio.Queue(maxsize=2000)
|
|
68
|
+
self.subs.add(q)
|
|
69
|
+
return q
|
|
70
|
+
|
|
71
|
+
def unsubscribe(self, q):
|
|
72
|
+
self.subs.discard(q)
|
|
73
|
+
|
|
74
|
+
def publish(self, ev):
|
|
75
|
+
loop = self.loop
|
|
76
|
+
if loop is None or not self.subs:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
def _fan():
|
|
80
|
+
for q in list(self.subs):
|
|
81
|
+
try:
|
|
82
|
+
q.put_nowait(ev)
|
|
83
|
+
except asyncio.QueueFull:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
loop.call_soon_threadsafe(_fan)
|
|
88
|
+
except RuntimeError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
hub = EventHub()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# === DB ===
|
|
96
|
+
EVENT_NEW_COLS = {"upstream": "TEXT", "client": "TEXT", "cached_tokens": "INTEGER", "cache_write_tokens": "INTEGER"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def init_db():
|
|
100
|
+
conn = _db()
|
|
101
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS cache (
|
|
102
|
+
prompt_hash TEXT PRIMARY KEY, normalized_prompt TEXT, original_prompt TEXT, response TEXT,
|
|
103
|
+
is_stream INTEGER, provider TEXT, model TEXT,
|
|
104
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP, last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
105
|
+
access_count INTEGER DEFAULT 1)""")
|
|
106
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS events (
|
|
107
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
108
|
+
kind TEXT NOT NULL, hash TEXT, model_req TEXT, model_real TEXT, stream INTEGER,
|
|
109
|
+
prompt_tokens INTEGER, completion_tokens INTEGER,
|
|
110
|
+
saved_prompt INTEGER DEFAULT 0, saved_completion INTEGER DEFAULT 0,
|
|
111
|
+
latency_ms INTEGER, ttft_ms INTEGER)""")
|
|
112
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(events)")}
|
|
113
|
+
for c, t in EVENT_NEW_COLS.items():
|
|
114
|
+
if c not in cols:
|
|
115
|
+
conn.execute("ALTER TABLE events ADD COLUMN %s %s" % (c, t))
|
|
116
|
+
try:
|
|
117
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
118
|
+
except sqlite3.OperationalError:
|
|
119
|
+
logger.warning("WAL non active (base verrouillee par un autre process)")
|
|
120
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_cache_created ON cache(created_at)")
|
|
121
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_cache_accessed ON cache(last_accessed)")
|
|
122
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts)")
|
|
123
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_hash ON events(hash, kind)")
|
|
124
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS agent_events (
|
|
125
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
126
|
+
kind TEXT NOT NULL, server TEXT, tool TEXT, client TEXT, args_hash TEXT,
|
|
127
|
+
bytes INTEGER, latency_ms INTEGER, saved_latency_ms INTEGER, detail TEXT)""")
|
|
128
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS tool_cache (
|
|
129
|
+
key TEXT PRIMARY KEY, server TEXT, tool TEXT, result_json TEXT, bytes INTEGER,
|
|
130
|
+
latency_ms INTEGER, ttl INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
131
|
+
last_accessed TEXT DEFAULT CURRENT_TIMESTAMP, hits INTEGER DEFAULT 0)""")
|
|
132
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_tool_cache_server ON tool_cache(server)")
|
|
133
|
+
conn.commit()
|
|
134
|
+
conn.close()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
init_db()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def log_event(kind, h, model_req, model_real, stream, pt=None, ct=None, sp=0, sc=0,
|
|
141
|
+
latency_ms=None, ttft_ms=None, upstream=None, client=None,
|
|
142
|
+
cached_tokens=None, cache_write_tokens=None, req_id=None):
|
|
143
|
+
"""Ligne dans events (tokens = valeurs upstream reelles ou NULL, jamais estimees) + push WS."""
|
|
144
|
+
try:
|
|
145
|
+
conn = _db()
|
|
146
|
+
conn.execute(
|
|
147
|
+
"INSERT INTO events (kind,hash,model_req,model_real,stream,prompt_tokens,completion_tokens,"
|
|
148
|
+
"saved_prompt,saved_completion,latency_ms,ttft_ms,upstream,client,cached_tokens,cache_write_tokens) "
|
|
149
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
150
|
+
(kind, h, model_req, model_real, int(bool(stream)), pt, ct, sp, sc, latency_ms, ttft_ms,
|
|
151
|
+
upstream, client, cached_tokens, cache_write_tokens))
|
|
152
|
+
conn.commit()
|
|
153
|
+
conn.close()
|
|
154
|
+
except Exception:
|
|
155
|
+
logger.exception("events insert failed")
|
|
156
|
+
hub.publish({"type": "final", "id": req_id, "ts": utc_iso(), "kind": kind, "upstream": upstream,
|
|
157
|
+
"client": client, "model_req": model_req, "model": model_real, "stream": bool(stream),
|
|
158
|
+
"prompt_tokens": pt, "completion_tokens": ct, "cached_tokens": cached_tokens,
|
|
159
|
+
"cache_write_tokens": cache_write_tokens, "saved_prompt": sp, "saved_completion": sc,
|
|
160
|
+
"latency_ms": latency_ms, "ttft_ms": ttft_ms, "hash": (h or "")[:8],
|
|
161
|
+
"tokens_source": "upstream" if (pt is not None or ct is not None) else "n/a"})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def log_agent_event(kind, server=None, tool=None, client=None, args_hash=None, nbytes=None,
|
|
165
|
+
latency_ms=None, saved_latency_ms=None, detail=None):
|
|
166
|
+
try:
|
|
167
|
+
conn = _db()
|
|
168
|
+
conn.execute(
|
|
169
|
+
"INSERT INTO agent_events (kind,server,tool,client,args_hash,bytes,latency_ms,saved_latency_ms,detail) "
|
|
170
|
+
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
171
|
+
(kind, server, tool, client, args_hash, nbytes, latency_ms, saved_latency_ms,
|
|
172
|
+
canonical(detail) if detail is not None else None))
|
|
173
|
+
conn.commit()
|
|
174
|
+
conn.close()
|
|
175
|
+
except Exception:
|
|
176
|
+
logger.exception("agent_events insert failed")
|
|
177
|
+
hub.publish({"type": "agent", "ts": utc_iso(), "kind": kind, "server": server, "tool": tool,
|
|
178
|
+
"client": client, "bytes": nbytes, "latency_ms": latency_ms,
|
|
179
|
+
"saved_latency_ms": saved_latency_ms, "detail": detail})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# === TOOL CACHE (stockage) ===
|
|
183
|
+
def tc_get(key):
|
|
184
|
+
conn = _db()
|
|
185
|
+
row = conn.execute(
|
|
186
|
+
"SELECT result_json, latency_ms, bytes FROM tool_cache WHERE key=? "
|
|
187
|
+
"AND datetime(created_at, '+' || ttl || ' seconds') >= datetime('now')", (key,)).fetchone()
|
|
188
|
+
if row:
|
|
189
|
+
conn.execute("UPDATE tool_cache SET hits=hits+1, last_accessed=CURRENT_TIMESTAMP WHERE key=?", (key,))
|
|
190
|
+
conn.commit()
|
|
191
|
+
conn.close()
|
|
192
|
+
return row
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def tc_set(key, server, tool, result_json, nbytes, latency_ms, ttl):
|
|
196
|
+
conn = _db()
|
|
197
|
+
conn.execute("INSERT OR REPLACE INTO tool_cache (key,server,tool,result_json,bytes,latency_ms,ttl,hits) "
|
|
198
|
+
"VALUES (?,?,?,?,?,?,?,0)", (key, server, tool, result_json, nbytes, latency_ms, int(ttl)))
|
|
199
|
+
conn.execute("DELETE FROM tool_cache WHERE datetime(created_at, '+' || ttl || ' seconds') < datetime('now')")
|
|
200
|
+
conn.commit()
|
|
201
|
+
conn.close()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def tc_flush(server):
|
|
205
|
+
conn = _db()
|
|
206
|
+
n = conn.execute("DELETE FROM tool_cache WHERE server=?", (server,)).rowcount
|
|
207
|
+
conn.commit()
|
|
208
|
+
conn.close()
|
|
209
|
+
return n
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def tc_clear(server=None):
|
|
213
|
+
conn = _db()
|
|
214
|
+
if server:
|
|
215
|
+
n = conn.execute("DELETE FROM tool_cache WHERE server=?", (server,)).rowcount
|
|
216
|
+
else:
|
|
217
|
+
n = conn.execute("DELETE FROM tool_cache").rowcount
|
|
218
|
+
conn.commit()
|
|
219
|
+
conn.close()
|
|
220
|
+
return n
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def tc_stats():
|
|
224
|
+
conn = _db()
|
|
225
|
+
entries = conn.execute("SELECT server, tool, COUNT(*), COALESCE(SUM(hits),0), COALESCE(SUM(bytes),0) "
|
|
226
|
+
"FROM tool_cache GROUP BY server, tool").fetchall()
|
|
227
|
+
ev = conn.execute("SELECT kind, COUNT(*), COALESCE(SUM(saved_latency_ms),0), COALESCE(SUM(bytes),0) "
|
|
228
|
+
"FROM agent_events GROUP BY kind").fetchall()
|
|
229
|
+
conn.close()
|
|
230
|
+
return {"entries": [{"server": e[0], "tool": e[1], "entries": e[2], "hits": e[3], "bytes": e[4]} for e in entries],
|
|
231
|
+
"events": {k: {"count": c, "saved_latency_ms": s, "bytes": b} for k, c, s, b in ev},
|
|
232
|
+
"note": "tokens economises par tool-cache = n/a (non mesurables sans estimation); latence economisee mesuree."}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def agent_events_recent(limit=50, kinds=None):
|
|
236
|
+
limit = max(1, min(int(limit), 1000))
|
|
237
|
+
conn = _db()
|
|
238
|
+
conn.row_factory = sqlite3.Row
|
|
239
|
+
q = "SELECT id, ts, kind, server, tool, client, args_hash, bytes, latency_ms, saved_latency_ms, detail FROM agent_events"
|
|
240
|
+
args = []
|
|
241
|
+
if kinds:
|
|
242
|
+
q += " WHERE kind IN (%s)" % ",".join("?" * len(kinds))
|
|
243
|
+
args += list(kinds)
|
|
244
|
+
q += " ORDER BY id DESC LIMIT ?"
|
|
245
|
+
args.append(limit)
|
|
246
|
+
rows = [dict(r) for r in conn.execute(q, args).fetchall()]
|
|
247
|
+
conn.close()
|
|
248
|
+
return rows
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# === LOOP GUARD ===
|
|
252
|
+
def trailing_cycle(seq, max_cycle=3):
|
|
253
|
+
"""Motif repete en fin de sequence. Retourne (cycle_len, reps) avec reps>=2 maximal, sinon None."""
|
|
254
|
+
n = len(seq)
|
|
255
|
+
best = None
|
|
256
|
+
for c in range(1, max_cycle + 1):
|
|
257
|
+
if n < 2 * c:
|
|
258
|
+
continue
|
|
259
|
+
pat = seq[-c:]
|
|
260
|
+
reps, i = 1, n - 2 * c
|
|
261
|
+
while i >= 0 and seq[i:i + c] == pat:
|
|
262
|
+
reps += 1
|
|
263
|
+
i -= c
|
|
264
|
+
if reps >= 2 and (best is None or reps > best[1]):
|
|
265
|
+
best = (c, reps)
|
|
266
|
+
return best
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _args_hash(a):
|
|
270
|
+
if isinstance(a, str):
|
|
271
|
+
try:
|
|
272
|
+
a = json.loads(a)
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
return short_hash(canonical(a))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def llm_tool_sigs(messages):
|
|
279
|
+
"""Signatures (nom, args, resultat) des tool calls deja faits dans l'historique d'une requete LLM."""
|
|
280
|
+
results = {}
|
|
281
|
+
for m in messages:
|
|
282
|
+
if not isinstance(m, dict):
|
|
283
|
+
continue
|
|
284
|
+
if m.get("role") == "tool" and m.get("tool_call_id"):
|
|
285
|
+
results[m["tool_call_id"]] = short_hash(canonical(m.get("content")))
|
|
286
|
+
c = m.get("content")
|
|
287
|
+
if isinstance(c, list):
|
|
288
|
+
for b in c:
|
|
289
|
+
if isinstance(b, dict) and b.get("type") == "tool_result":
|
|
290
|
+
results[b.get("tool_use_id")] = short_hash(canonical(b.get("content")))
|
|
291
|
+
sigs = []
|
|
292
|
+
for m in messages:
|
|
293
|
+
if not isinstance(m, dict) or m.get("role") != "assistant":
|
|
294
|
+
continue
|
|
295
|
+
for tc in (m.get("tool_calls") or []):
|
|
296
|
+
fn = (tc or {}).get("function") or {}
|
|
297
|
+
sigs.append((fn.get("name"), _args_hash(fn.get("arguments")), results.get((tc or {}).get("id"))))
|
|
298
|
+
c = m.get("content")
|
|
299
|
+
if isinstance(c, list):
|
|
300
|
+
for b in c:
|
|
301
|
+
if isinstance(b, dict) and b.get("type") == "tool_use":
|
|
302
|
+
sigs.append((b.get("name"), _args_hash(b.get("input")), results.get(b.get("id"))))
|
|
303
|
+
return sigs
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def llm_loop_check(messages, warn_after=3, max_cycle=3):
|
|
307
|
+
"""Boucle d'agent vue dans l'historique: meme(s) tool call(s) + memes resultats, repetes en fin."""
|
|
308
|
+
if not isinstance(messages, list):
|
|
309
|
+
return None
|
|
310
|
+
sigs = llm_tool_sigs(messages)
|
|
311
|
+
cyc = trailing_cycle(sigs, max_cycle)
|
|
312
|
+
if cyc and cyc[1] >= warn_after:
|
|
313
|
+
tail = sigs[-cyc[0]:]
|
|
314
|
+
return {"cycle_len": cyc[0], "reps": cyc[1], "tools": [t[0] for t in tail]}
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class ToolLoopGuard:
|
|
319
|
+
"""Detecte un agent qui rappelle les memes tools avec les memes args ET les memes resultats."""
|
|
320
|
+
|
|
321
|
+
def __init__(self, cfg):
|
|
322
|
+
c = (cfg or {})
|
|
323
|
+
self.enabled = c.get("enabled", True)
|
|
324
|
+
self.window = c.get("window_seconds", 300)
|
|
325
|
+
t = c.get("tools", {})
|
|
326
|
+
self.warn_after = t.get("warn_after", 3)
|
|
327
|
+
self.block_after = t.get("block_after", 5)
|
|
328
|
+
self.max_cycle = t.get("max_cycle_len", 3)
|
|
329
|
+
self.sessions = {}
|
|
330
|
+
self.lock = threading.Lock()
|
|
331
|
+
|
|
332
|
+
def _s(self, key):
|
|
333
|
+
s = self.sessions.get(key)
|
|
334
|
+
if s is None:
|
|
335
|
+
s = {"hist": deque(maxlen=128), "blocked": None}
|
|
336
|
+
self.sessions[key] = s
|
|
337
|
+
return s
|
|
338
|
+
|
|
339
|
+
def check_before(self, key, tool, args_hash):
|
|
340
|
+
"""True si cet appel (meme tool + memes args) est bloque parce que la boucle persiste."""
|
|
341
|
+
if not self.enabled:
|
|
342
|
+
return False
|
|
343
|
+
with self.lock:
|
|
344
|
+
s = self._s(key)
|
|
345
|
+
b = s["blocked"]
|
|
346
|
+
if b is None:
|
|
347
|
+
return False
|
|
348
|
+
if b == (tool, args_hash):
|
|
349
|
+
return True
|
|
350
|
+
s["blocked"] = None # l'agent a change d'approche
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
def after(self, key, tool, args_hash, result_hash):
|
|
354
|
+
"""Enregistre l'appel. Retourne ('none'|'warn'|'block', cycle_len, reps)."""
|
|
355
|
+
if not self.enabled:
|
|
356
|
+
return ("none", 0, 0)
|
|
357
|
+
now = time.time()
|
|
358
|
+
with self.lock:
|
|
359
|
+
s = self._s(key)
|
|
360
|
+
hist = s["hist"]
|
|
361
|
+
hist.append((now, (tool, args_hash, result_hash)))
|
|
362
|
+
while hist and now - hist[0][0] > self.window:
|
|
363
|
+
hist.popleft()
|
|
364
|
+
seq = [h[1] for h in hist]
|
|
365
|
+
cyc = trailing_cycle(seq, self.max_cycle)
|
|
366
|
+
if not cyc:
|
|
367
|
+
return ("none", 0, 0)
|
|
368
|
+
c, reps = cyc
|
|
369
|
+
if reps >= self.block_after:
|
|
370
|
+
s["blocked"] = (tool, args_hash)
|
|
371
|
+
return ("block", c, reps)
|
|
372
|
+
if reps >= self.warn_after:
|
|
373
|
+
return ("warn", c, reps)
|
|
374
|
+
return ("none", c, reps)
|
|
375
|
+
|
|
376
|
+
def record_blocked(self, key, tool, args_hash):
|
|
377
|
+
"""Un appel bloque reste dans l'historique pour que le blocage tienne tant que l'agent insiste."""
|
|
378
|
+
with self.lock:
|
|
379
|
+
s = self._s(key)
|
|
380
|
+
s["hist"].append((time.time(), (tool, args_hash, "BLOCKED")))
|