mnemonic-proxy 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.
- ctxproxy/__init__.py +8 -0
- ctxproxy/api_anthropic.py +426 -0
- ctxproxy/api_responses.py +436 -0
- ctxproxy/autosave.py +350 -0
- ctxproxy/check.py +356 -0
- ctxproxy/compat.py +91 -0
- ctxproxy/core.py +2218 -0
- ctxproxy/demo.py +193 -0
- ctxproxy/engines.py +314 -0
- ctxproxy/fake_engine.py +304 -0
- ctxproxy/kvarchive.py +855 -0
- ctxproxy/lang.py +57 -0
- ctxproxy/mcp_server.py +381 -0
- ctxproxy/packtok.py +29 -0
- ctxproxy/paging.py +508 -0
- ctxproxy/recall2.py +1003 -0
- ctxproxy/render.py +274 -0
- ctxproxy/server.py +797 -0
- ctxproxy/tooldefs.py +111 -0
- ctxproxy/upstream.py +106 -0
- mnemonic_proxy-0.3.2.dist-info/METADATA +841 -0
- mnemonic_proxy-0.3.2.dist-info/RECORD +26 -0
- mnemonic_proxy-0.3.2.dist-info/WHEEL +5 -0
- mnemonic_proxy-0.3.2.dist-info/entry_points.txt +4 -0
- mnemonic_proxy-0.3.2.dist-info/licenses/LICENSE +21 -0
- mnemonic_proxy-0.3.2.dist-info/top_level.txt +1 -0
ctxproxy/autosave.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# Author: Maurizio Verde — LastReload
|
|
2
|
+
"""Salvataggio / ripristino automatico del segmento attivo.
|
|
3
|
+
|
|
4
|
+
Strata tiene UNA conversazione (più pochi checkpoint) e la perde al riavvio o quando un'altra conversazione la
|
|
5
|
+
sostituisce. Rileggere 70–100K token costa 30–40 s; ripristinare un file di sessione ~1–2 s.
|
|
6
|
+
|
|
7
|
+
- `Tracker` avvolge l'Upstream e sa che cosa Strata ha in memoria: dopo ogni chiamata registra l'impronta del prompt
|
|
8
|
+
(testo del template SENZA il prompt di generazione: è esattamente il checkpoint di fine turno che Strata riusa) e
|
|
9
|
+
il contatore `requests` di /v1/status. Prima di ogni chiamata: se Strata è ripartito (started diverso, o non
|
|
10
|
+
caricato), se qualcun altro l'ha usato (requests diverso) o se tiene un'altra conversazione, e c'è un autosalvataggio
|
|
11
|
+
il cui testo è prefisso ESATTO del nuovo prompt e vale almeno `autorestore_min_gain` token in più, fa `restore`.
|
|
12
|
+
- `AutoSaver` (thread): quando la conversazione che occupa Strata è ferma da `autosave_idle_s`, nessuna richiesta è
|
|
13
|
+
in corso (lock del proxy libero, /v1/status senza in_flight) e lo stato è ancora il nostro, `save` in un file
|
|
14
|
+
`<conv>-seg<N>-auto-<impronta>.bin`; registra file/token/byte/impronta nella tabella `autosaves`. Pulizia: un file
|
|
15
|
+
il cui testo è prefisso esatto di quello appena salvato è superato e si cancella; poi al massimo `autosave_keep`
|
|
16
|
+
file e `autosave_max_gb`. Testo e archivio SQLite restano sempre.
|
|
17
|
+
- Lo stato del Tracker è salvato nella tabella `kv`: dopo un riavvio del PROXY con Strata ancora vivo e intatto
|
|
18
|
+
(stesso started/requests) non serve alcun restore.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
from .core import H
|
|
29
|
+
from .render import render, template_kwargs
|
|
30
|
+
from .upstream import UpstreamError
|
|
31
|
+
|
|
32
|
+
STATE_KEY = "strata_state"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def prompt_text(body: dict) -> str | None:
|
|
36
|
+
"""Testo del prompt fino all'ultimo confine di turno (dove Strata mette il checkpoint riusabile)."""
|
|
37
|
+
try:
|
|
38
|
+
return render(body.get("messages") or [], body.get("tools") or None, template_kwargs(body),
|
|
39
|
+
add_generation_prompt=False)
|
|
40
|
+
except (ValueError, TypeError, KeyError, AttributeError):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _TrackedStream:
|
|
45
|
+
def __init__(self, call, on_done, on_abort):
|
|
46
|
+
self.call, self.on_done, self.on_abort = call, on_done, on_abort
|
|
47
|
+
self.finished = False
|
|
48
|
+
|
|
49
|
+
def __iter__(self):
|
|
50
|
+
usage = None
|
|
51
|
+
for ch in self.call:
|
|
52
|
+
if ch.get("usage"):
|
|
53
|
+
usage = ch["usage"]
|
|
54
|
+
yield ch
|
|
55
|
+
self.finished = True
|
|
56
|
+
self.on_done(usage)
|
|
57
|
+
|
|
58
|
+
def close(self):
|
|
59
|
+
self.call.close()
|
|
60
|
+
if not self.finished:
|
|
61
|
+
self.finished = True
|
|
62
|
+
self.on_abort()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Tracker:
|
|
66
|
+
"""Upstream con memoria di ciò che Strata contiene. Stessa interfaccia di Upstream (chat, chat_stream, slot, raw)."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, up, store, journal, cfg):
|
|
69
|
+
self.up, self.store, self.journal, self.cfg = up, store, journal, cfg
|
|
70
|
+
self.ctx: tuple[str | None, int] = (None, 0) # (conv, seg) della richiesta in corso (lo imposta il proxy)
|
|
71
|
+
self.saved: dict[str, dict] = {} # file salvati da questo processo -> stato
|
|
72
|
+
self.skip_restore = False
|
|
73
|
+
self.text = None # testo dell'ultimo prompt letto da Strata (questo processo)
|
|
74
|
+
self.mu = threading.RLock()
|
|
75
|
+
self.state = self._load()
|
|
76
|
+
|
|
77
|
+
# ---------- passthrough ----------
|
|
78
|
+
def __getattr__(self, k):
|
|
79
|
+
return getattr(self.up, k)
|
|
80
|
+
|
|
81
|
+
def raw(self, *a, **kw):
|
|
82
|
+
return self.up.raw(*a, **kw)
|
|
83
|
+
|
|
84
|
+
# ---------- stato persistente ----------
|
|
85
|
+
def _load(self):
|
|
86
|
+
try:
|
|
87
|
+
v = self.store.kv_get(STATE_KEY)
|
|
88
|
+
return json.loads(v) if v else None
|
|
89
|
+
except (ValueError, TypeError):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def _set(self, st):
|
|
93
|
+
with self.mu:
|
|
94
|
+
self.state = st
|
|
95
|
+
try:
|
|
96
|
+
self.store.kv_set(STATE_KEY, json.dumps(st) if st else "")
|
|
97
|
+
except Exception: # noqa: BLE001
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def status(self) -> dict | None:
|
|
101
|
+
"""Stato del motore nella forma di /v1/status di Strata (llama-server: ricavato da /slots)."""
|
|
102
|
+
from .engines import normalize_status
|
|
103
|
+
try:
|
|
104
|
+
return normalize_status(getattr(self.up, "engine", None), self.up)
|
|
105
|
+
except Exception: # noqa: BLE001
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def check(self, s: dict | None = None, where: str = "") -> dict | None:
|
|
109
|
+
"""Lo stato registrato vale ancora? Altrimenti lo azzera (e lo annota). -> stato valido o None."""
|
|
110
|
+
st = self.state
|
|
111
|
+
if st is None:
|
|
112
|
+
return None
|
|
113
|
+
s = s if s is not None else self.status()
|
|
114
|
+
why = None
|
|
115
|
+
if s is None:
|
|
116
|
+
why = "status_unavailable"
|
|
117
|
+
elif not s.get("loaded", True):
|
|
118
|
+
why = "unloaded"
|
|
119
|
+
elif st.get("started") is not None and s.get("started") != st.get("started"):
|
|
120
|
+
why = "strata_restart"
|
|
121
|
+
elif st.get("requests") is not None and \
|
|
122
|
+
(s.get("activity") or {}).get("requests") not in (st["requests"], st["requests"] - 1):
|
|
123
|
+
# -1: il totale di Strata può aggiornarsi un attimo dopo la fine della risposta
|
|
124
|
+
why = "foreign_request"
|
|
125
|
+
if why:
|
|
126
|
+
self.journal.log("strata_state_lost", reason=why, conv=st.get("conv"), seg=st.get("seg"),
|
|
127
|
+
tokens=st.get("tokens"), where=where)
|
|
128
|
+
self._set(None)
|
|
129
|
+
return None
|
|
130
|
+
return st
|
|
131
|
+
|
|
132
|
+
# ---------- chiamate ----------
|
|
133
|
+
def _before(self, body: dict):
|
|
134
|
+
"""-> (testo del prompt, stato /v1/status prima della chiamata). Eventuale restore automatico."""
|
|
135
|
+
text = prompt_text(body)
|
|
136
|
+
s = self.status()
|
|
137
|
+
st = self.check(s, "before_call")
|
|
138
|
+
if self.skip_restore:
|
|
139
|
+
self.skip_restore = False
|
|
140
|
+
elif text is not None and self.cfg.autorestore:
|
|
141
|
+
self._maybe_restore(text, st, s)
|
|
142
|
+
s = self.status()
|
|
143
|
+
return text, s
|
|
144
|
+
|
|
145
|
+
def _after(self, text, s, usage, ok=True):
|
|
146
|
+
if not ok or text is None or s is None:
|
|
147
|
+
self._set(None)
|
|
148
|
+
return
|
|
149
|
+
conv, seg = self.ctx
|
|
150
|
+
self.text = text
|
|
151
|
+
req = ((s.get("activity") or {}).get("requests") or 0) + 1
|
|
152
|
+
eng = getattr(self.up, "engine", None)
|
|
153
|
+
if eng is not None and eng.counter_after_call:
|
|
154
|
+
# llama-server: il contatore è l'id dell'ultimo compito dello slot (non cresce di 1): lo si rilegge
|
|
155
|
+
s2 = self.status()
|
|
156
|
+
req = (s2.get("activity") or {}).get("requests") if s2 else None
|
|
157
|
+
self._set({"phash": H(text), "plen": len(text), "tokens": int((usage or {}).get("prompt_tokens") or 0),
|
|
158
|
+
"conv": conv, "seg": seg, "started": s.get("started"),
|
|
159
|
+
"requests": req, "t_end": time.time()})
|
|
160
|
+
|
|
161
|
+
def chat(self, body: dict) -> dict:
|
|
162
|
+
text, s = self._before(body)
|
|
163
|
+
try:
|
|
164
|
+
r = self.up.chat(body)
|
|
165
|
+
except Exception:
|
|
166
|
+
self._set(None)
|
|
167
|
+
raise
|
|
168
|
+
self._after(text, s, r.get("usage"))
|
|
169
|
+
return r
|
|
170
|
+
|
|
171
|
+
def chat_stream(self, body: dict):
|
|
172
|
+
text, s = self._before(body)
|
|
173
|
+
try:
|
|
174
|
+
call = self.up.chat_stream(body)
|
|
175
|
+
except Exception:
|
|
176
|
+
self._set(None)
|
|
177
|
+
raise
|
|
178
|
+
return _TrackedStream(call, lambda u: self._after(text, s, u), lambda: self._set(None))
|
|
179
|
+
|
|
180
|
+
def slot(self, action: str, filename: str) -> dict:
|
|
181
|
+
r = self.up.slot(action, filename)
|
|
182
|
+
if action == "save" and self.state:
|
|
183
|
+
self.saved[filename] = dict(self.state)
|
|
184
|
+
elif action == "restore":
|
|
185
|
+
known = self.saved.get(filename) or self.store.autosave_state(filename)
|
|
186
|
+
s = self.status()
|
|
187
|
+
if known and s:
|
|
188
|
+
self._set({**known, "started": s.get("started"),
|
|
189
|
+
"requests": (s.get("activity") or {}).get("requests"), "t_end": time.time()})
|
|
190
|
+
else:
|
|
191
|
+
self._set(None)
|
|
192
|
+
self.skip_restore = True # restore deciso dal Manager (ancora): non sovrascriverlo
|
|
193
|
+
return r
|
|
194
|
+
|
|
195
|
+
# ---------- ripristino automatico ----------
|
|
196
|
+
def _maybe_restore(self, text: str, st: dict | None, s: dict | None):
|
|
197
|
+
if s is None:
|
|
198
|
+
return
|
|
199
|
+
have = 0
|
|
200
|
+
if st and st["plen"] <= len(text) and H(text[:st["plen"]]) == st["phash"]:
|
|
201
|
+
have = st["tokens"]
|
|
202
|
+
best = None
|
|
203
|
+
for row in self.store.autosaves(active_only=True):
|
|
204
|
+
if row["plen"] <= len(text) and (best is None or row["tokens"] > best["tokens"]) \
|
|
205
|
+
and H(text[:row["plen"]]) == row["phash"]:
|
|
206
|
+
best = row
|
|
207
|
+
if best is None or best["tokens"] - have < self.cfg.autorestore_min_gain:
|
|
208
|
+
return
|
|
209
|
+
t0 = time.time()
|
|
210
|
+
reason = "strata_restart" if st is None else "other_state"
|
|
211
|
+
if not s.get("loaded", True):
|
|
212
|
+
# motore spento (scaricato o morto): il restore vuole il modello caricato, lo si carica ora
|
|
213
|
+
try:
|
|
214
|
+
self.up.raw("POST", "/load", b"{}")
|
|
215
|
+
except Exception: # noqa: BLE001
|
|
216
|
+
pass
|
|
217
|
+
try:
|
|
218
|
+
r = self.up.slot("restore", best["file"])
|
|
219
|
+
except UpstreamError as e:
|
|
220
|
+
self.journal.log("autorestore_error", conv=best["conv"], file=best["file"], status=e.status,
|
|
221
|
+
error=str(e)[:300])
|
|
222
|
+
if e.status == 404:
|
|
223
|
+
self.store.autosave_deleted(best["file"])
|
|
224
|
+
self._set(None)
|
|
225
|
+
return
|
|
226
|
+
ms = round((time.time() - t0) * 1000)
|
|
227
|
+
self.store.autosave_used(best["file"])
|
|
228
|
+
s2 = self.status() or {}
|
|
229
|
+
self._set({**{k: best[k] for k in ("phash", "plen", "tokens", "conv", "seg")},
|
|
230
|
+
"started": s2.get("started"), "requests": (s2.get("activity") or {}).get("requests"),
|
|
231
|
+
"t_end": time.time()})
|
|
232
|
+
self.journal.log("autorestore", conv=best["conv"], seg=best["seg"], file=best["file"], tokens=best["tokens"],
|
|
233
|
+
had_tokens=have, gain_tokens=best["tokens"] - have, reason=reason, ms=ms,
|
|
234
|
+
restore_ms=(r.get("timings") or {}).get("restore_ms"), n_restored=r.get("n_restored"))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class AutoSaver:
|
|
238
|
+
def __init__(self, proxy):
|
|
239
|
+
self.proxy = proxy
|
|
240
|
+
self.cfg, self.store, self.journal = proxy.cfg, proxy.store, proxy.journal
|
|
241
|
+
self.tr: Tracker = proxy.up
|
|
242
|
+
self.stop_ev = threading.Event()
|
|
243
|
+
self.th = None
|
|
244
|
+
self._skip_logged = None
|
|
245
|
+
|
|
246
|
+
def start(self):
|
|
247
|
+
self.th = threading.Thread(target=self._loop, daemon=True, name="autosave")
|
|
248
|
+
self.th.start()
|
|
249
|
+
return self
|
|
250
|
+
|
|
251
|
+
def stop(self):
|
|
252
|
+
self.stop_ev.set()
|
|
253
|
+
|
|
254
|
+
def _loop(self):
|
|
255
|
+
while not self.stop_ev.wait(self.cfg.autosave_poll_s):
|
|
256
|
+
try:
|
|
257
|
+
self.tick()
|
|
258
|
+
except Exception as e: # noqa: BLE001
|
|
259
|
+
self.journal.log("autosave_error", step="tick", error=repr(e)[:300])
|
|
260
|
+
|
|
261
|
+
def _skip(self, reason, **kw):
|
|
262
|
+
key = (reason, (self.tr.state or {}).get("phash"))
|
|
263
|
+
if key != self._skip_logged:
|
|
264
|
+
self._skip_logged = key
|
|
265
|
+
self.journal.log("autosave_skip", reason=reason, **kw)
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
def tick(self, now: float | None = None):
|
|
269
|
+
"""Un controllo; -> evento autosave o None."""
|
|
270
|
+
cfg, tr = self.cfg, self.tr
|
|
271
|
+
now = now or time.time()
|
|
272
|
+
st = tr.state
|
|
273
|
+
if not st or not st.get("conv"):
|
|
274
|
+
return None
|
|
275
|
+
if now - st.get("t_end", now) < cfg.autosave_idle_s:
|
|
276
|
+
return None
|
|
277
|
+
if st.get("tokens", 0) < cfg.autosave_min_tokens:
|
|
278
|
+
return None
|
|
279
|
+
if self.store.autosave_with_phash(st["phash"]):
|
|
280
|
+
return None # già salvato
|
|
281
|
+
if not self.proxy.lock.acquire(blocking=False):
|
|
282
|
+
return None # una richiesta è in corso: mai salvare adesso
|
|
283
|
+
try:
|
|
284
|
+
s = tr.status()
|
|
285
|
+
if s is None or (s.get("activity") or {}).get("in_flight"):
|
|
286
|
+
return None
|
|
287
|
+
st = tr.check(s, "autosave")
|
|
288
|
+
if st is None:
|
|
289
|
+
return None
|
|
290
|
+
if cfg.slot_dir:
|
|
291
|
+
free = shutil.disk_usage(cfg.slot_dir).free
|
|
292
|
+
need = cfg.autosave_min_free_gb * 2 ** 30 + st["tokens"] * 20000
|
|
293
|
+
if free < need:
|
|
294
|
+
return self._skip("disk_low", free_gb=round(free / 2 ** 30, 1))
|
|
295
|
+
fn = "%s-seg%d-auto-%s.bin" % (st["conv"], st.get("seg") or 0, st["phash"][:10])
|
|
296
|
+
t0 = time.time()
|
|
297
|
+
try:
|
|
298
|
+
r = tr.up.slot("save", fn)
|
|
299
|
+
except Exception as e: # noqa: BLE001
|
|
300
|
+
self.journal.log("autosave_error", conv=st["conv"], file=fn, error=str(e)[:300])
|
|
301
|
+
return None
|
|
302
|
+
ms = round((time.time() - t0) * 1000)
|
|
303
|
+
nbytes = int(r.get("n_written") or 0)
|
|
304
|
+
self.store.add_autosave(fn, st["conv"], st.get("seg") or 0, st["phash"], st["plen"], st["tokens"],
|
|
305
|
+
nbytes, int(r.get("n_saved") or 0))
|
|
306
|
+
ev = self.journal.log("autosave", conv=st["conv"], seg=st.get("seg") or 0, file=fn, tokens=st["tokens"],
|
|
307
|
+
n_saved=r.get("n_saved"), bytes=nbytes, ms=ms, idle_s=round(now - st["t_end"]),
|
|
308
|
+
phash=st["phash"][:16])
|
|
309
|
+
# lo stato di Strata non cambia con un save (requests non conta i save): resta valido
|
|
310
|
+
self.prune(keep_file=fn, text=getattr(tr, "text", None) if H(getattr(tr, "text", "") or "") == st["phash"]
|
|
311
|
+
else None)
|
|
312
|
+
return ev
|
|
313
|
+
finally:
|
|
314
|
+
self.proxy.lock.release()
|
|
315
|
+
|
|
316
|
+
def prune(self, keep_file: str | None = None, text: str | None = None):
|
|
317
|
+
"""Superato = un salvataggio più vecchio il cui testo è prefisso esatto di `text` (quello appena salvato):
|
|
318
|
+
la stessa conversazione più avanti lo rende inutile. Poi tetto di numero e di disco (più recenti tenuti)."""
|
|
319
|
+
cfg = self.cfg
|
|
320
|
+
rows = self.store.autosaves(active_only=True) # più recenti prima
|
|
321
|
+
drop, kept, total = [], [], 0
|
|
322
|
+
for r in rows:
|
|
323
|
+
if r["file"] != keep_file and text is not None and r["plen"] <= len(text) \
|
|
324
|
+
and H(text[:r["plen"]]) == r["phash"]:
|
|
325
|
+
drop.append((r, "superseded"))
|
|
326
|
+
continue
|
|
327
|
+
if r["file"] != keep_file and (len(kept) >= cfg.autosave_keep or
|
|
328
|
+
total + r["bytes"] > cfg.autosave_max_gb * 2 ** 30):
|
|
329
|
+
drop.append((r, "limit"))
|
|
330
|
+
continue
|
|
331
|
+
kept.append(r)
|
|
332
|
+
total += r["bytes"]
|
|
333
|
+
for r, why in drop:
|
|
334
|
+
ok = True
|
|
335
|
+
if cfg.slot_dir:
|
|
336
|
+
try:
|
|
337
|
+
os.remove(os.path.join(cfg.slot_dir, r["file"]))
|
|
338
|
+
except FileNotFoundError:
|
|
339
|
+
pass
|
|
340
|
+
except OSError as e:
|
|
341
|
+
ok = False
|
|
342
|
+
self.journal.log("autosave_error", step="prune", file=r["file"], error=str(e)[:200])
|
|
343
|
+
if ok:
|
|
344
|
+
if cfg.kv_archive:
|
|
345
|
+
from .kvarchive import discard
|
|
346
|
+
discard(cfg, r["file"], self.journal)
|
|
347
|
+
self.store.autosave_deleted(r["file"])
|
|
348
|
+
self.journal.log("autosave_prune", conv=r["conv"], seg=r["seg"], file=r["file"], reason=why,
|
|
349
|
+
bytes=r["bytes"])
|
|
350
|
+
return drop
|