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/fake_engine.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# Author: Maurizio Verde — LastReload
|
|
2
|
+
"""Motore finto HTTP (solo stdlib) per i test, la demo e `check` in CI: OpenAI /v1/chat/completions + /slots/0.
|
|
3
|
+
|
|
4
|
+
- Registra ogni richiesta ricevuta (self.requests) e il prompt "fisico" renderizzato in modo deterministico.
|
|
5
|
+
- Simula la cache a prefisso: cached_tokens = prefisso comune (in caratteri / 3.5) col prompt precedente;
|
|
6
|
+
timings.prompt_n = token letti davvero. Così il giornale del proxy riceve reused/prompt_read come da Strata.
|
|
7
|
+
- `policy(body) -> message` decide la risposta (default: testo fisso). Le richieste di note e di sigillo sono
|
|
8
|
+
riconosciute dal loro marcatore.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render(body: dict) -> str:
|
|
20
|
+
"""Rendering deterministico del prompt (stand-in del template chat): ciò che conta è che due liste di messaggi
|
|
21
|
+
uguali diano la stessa stringa e che un messaggio cambiato cambi la stringa da quel punto in poi."""
|
|
22
|
+
out = ["<tools>" + json.dumps(body.get("tools") or [], sort_keys=True, ensure_ascii=False) + "</tools>"]
|
|
23
|
+
for m in body.get("messages") or []:
|
|
24
|
+
out.append("<|im_start|>%s\n%s%s%s<|im_end|>\n" % (
|
|
25
|
+
m.get("role"), json.dumps(m.get("content"), ensure_ascii=False),
|
|
26
|
+
json.dumps(m.get("tool_calls"), sort_keys=True, ensure_ascii=False) if m.get("tool_calls") else "",
|
|
27
|
+
m.get("tool_call_id") or ""))
|
|
28
|
+
return "".join(out)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FakeEngine:
|
|
32
|
+
def __init__(self, policy=None, cpt: float = 3.5, flavor: str = "strata", slot_save: bool = True,
|
|
33
|
+
n_slots: int = 1, n_ctx: int = 131072):
|
|
34
|
+
"""flavor: strata (/v1/status) | llama (llama-server: /props, /slots, /tokenize, 400 per file mancante,
|
|
35
|
+
id_task che cresce di più di 1 per richiesta, 501 sulle azioni slot senza --slot-save-path) | ds4
|
|
36
|
+
(ds4-server: /v1/models con owned_by "ds4.c", niente slot, id `call_<hex>` generati dal motore, 400 su un
|
|
37
|
+
risultato di strumento con id sconosciuto, conteggio delle chiamate rimandate con id noto/sconosciuto) | openai
|
|
38
|
+
(solo /v1/chat/completions)."""
|
|
39
|
+
self.flavor, self.slot_save, self.n_slots, self.n_ctx = flavor, slot_save, n_slots, n_ctx
|
|
40
|
+
self.id_task = 0
|
|
41
|
+
self.policy = policy or (lambda body: {"role": "assistant", "content": "ok"})
|
|
42
|
+
self.requests: list[dict] = []
|
|
43
|
+
self.prompts: list[str] = []
|
|
44
|
+
self.slots: list[tuple] = []
|
|
45
|
+
self.files: dict[str, str] = {}
|
|
46
|
+
self.cancelled = 0
|
|
47
|
+
self.cpt = cpt
|
|
48
|
+
self.live = ""
|
|
49
|
+
self.srv = None
|
|
50
|
+
self.started = 1000
|
|
51
|
+
self.n_requests = 0
|
|
52
|
+
self.busy = False
|
|
53
|
+
self.ds4_memory: dict = {} # flavor ds4: id -> argomenti campionati (exact DSML tool replay)
|
|
54
|
+
self.ds4_live_ids: set = set()
|
|
55
|
+
self.ds4_replay = {"mem": 0, "canonical": 0, "missing_ids": []}
|
|
56
|
+
self.rejected: list = []
|
|
57
|
+
|
|
58
|
+
def restart(self):
|
|
59
|
+
"""Come un riavvio di Strata: la conversazione in memoria si perde, i file di sessione restano."""
|
|
60
|
+
self.live = ""
|
|
61
|
+
self.started += 1
|
|
62
|
+
self.n_requests = 0
|
|
63
|
+
self.id_task = 0
|
|
64
|
+
|
|
65
|
+
def slots_list(self) -> list:
|
|
66
|
+
return [{"id": i, "n_ctx": self.n_ctx // self.n_slots, "is_processing": bool(self.busy) and i == 0,
|
|
67
|
+
"id_task": self.id_task if i == 0 else -1, "n_prompt_tokens": int(len(self.live) / self.cpt)}
|
|
68
|
+
for i in range(self.n_slots)]
|
|
69
|
+
|
|
70
|
+
def status(self) -> dict:
|
|
71
|
+
return {"service": "strata", "loaded": True, "started": self.started,
|
|
72
|
+
"context": {"native": self.n_ctx, "max_positions": self.n_ctx},
|
|
73
|
+
"activity": {"requests": self.n_requests, "in_flight": int(self.busy)}}
|
|
74
|
+
|
|
75
|
+
# ---- ds4-server (flavor="ds4"): comportamento verificato su ds4_server.c ----
|
|
76
|
+
def ds4_check(self, body: dict) -> str | None:
|
|
77
|
+
"""Come anthropic_validate_tool_results / responses_validate_tool_outputs: un risultato di strumento con un
|
|
78
|
+
id che non è né nella storia rimandata (chiamata assistant precedente) né nello stato vivo -> errore
|
|
79
|
+
«replay full history». (Il vero ds4 lo fa su /v1/messages e /v1/responses; qui anche su chat, più severo.)"""
|
|
80
|
+
seen = set()
|
|
81
|
+
for m in body.get("messages") or []:
|
|
82
|
+
if m.get("role") == "assistant":
|
|
83
|
+
seen.update(c.get("id") for c in m.get("tool_calls") or [])
|
|
84
|
+
elif m.get("role") == "tool":
|
|
85
|
+
cid = m.get("tool_call_id")
|
|
86
|
+
if cid not in seen and cid not in self.ds4_live_ids:
|
|
87
|
+
return ("continuation state is not available for tool_call_id %s; retry by replaying the full "
|
|
88
|
+
"messages history" % cid)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def ds4_after(self, body: dict, msg: dict) -> dict:
|
|
92
|
+
"""Ripresa per id: per ogni tool call rimandata, id noto = testo campionato esatto (mem), id sconosciuto =
|
|
93
|
+
rendering canonico (il prefisso può cambiare). Poi id nuovi `call_<hex>` alle chiamate del modello."""
|
|
94
|
+
for m in body.get("messages") or []:
|
|
95
|
+
for c in (m.get("tool_calls") or []) if m.get("role") == "assistant" else []:
|
|
96
|
+
if c.get("id") in self.ds4_memory:
|
|
97
|
+
self.ds4_replay["mem"] += 1
|
|
98
|
+
else:
|
|
99
|
+
self.ds4_replay["canonical"] += 1
|
|
100
|
+
self.ds4_replay["missing_ids"].append(c.get("id"))
|
|
101
|
+
calls = []
|
|
102
|
+
for c in msg.get("tool_calls") or []:
|
|
103
|
+
c = dict(c)
|
|
104
|
+
if not c.get("id"):
|
|
105
|
+
c["id"] = "call_" + uuid.uuid4().hex
|
|
106
|
+
self.ds4_memory[c["id"]] = (c.get("function") or {}).get("arguments")
|
|
107
|
+
calls.append(c)
|
|
108
|
+
self.ds4_live_ids = {c["id"] for c in calls}
|
|
109
|
+
return {**msg, "tool_calls": calls} if calls else msg
|
|
110
|
+
|
|
111
|
+
def handle_chat(self, body: dict) -> dict:
|
|
112
|
+
p = render(body)
|
|
113
|
+
common = 0
|
|
114
|
+
for a, b in zip(self.live, p):
|
|
115
|
+
if a != b:
|
|
116
|
+
break
|
|
117
|
+
common += 1
|
|
118
|
+
self.requests.append(body)
|
|
119
|
+
self.prompts.append(p)
|
|
120
|
+
self.n_requests += 1
|
|
121
|
+
self.id_task += 3
|
|
122
|
+
msg = self.policy(body)
|
|
123
|
+
if self.flavor == "ds4":
|
|
124
|
+
msg = self.ds4_after(body, msg)
|
|
125
|
+
self.live = p
|
|
126
|
+
pt = int(len(p) / self.cpt)
|
|
127
|
+
cached = int(common / self.cpt)
|
|
128
|
+
finish = "tool_calls" if msg.get("tool_calls") else "stop"
|
|
129
|
+
ct = int(len(json.dumps(msg)) / self.cpt)
|
|
130
|
+
return {"id": "chatcmpl-" + uuid.uuid4().hex[:8], "object": "chat.completion", "created": int(time.time()),
|
|
131
|
+
"model": "fake", "choices": [{"index": 0, "message": msg, "finish_reason": finish}],
|
|
132
|
+
"usage": {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": pt + ct,
|
|
133
|
+
"prompt_tokens_details": {"cached_tokens": cached}},
|
|
134
|
+
"timings": {"cache_n": cached, "prompt_n": pt - cached, "prompt_ms": (pt - cached) * 0.4}}
|
|
135
|
+
|
|
136
|
+
def start(self, port: int = 0):
|
|
137
|
+
eng = self
|
|
138
|
+
|
|
139
|
+
class H(BaseHTTPRequestHandler):
|
|
140
|
+
protocol_version = "HTTP/1.1"
|
|
141
|
+
|
|
142
|
+
def log_message(self, *a):
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
def _send(self, st, obj):
|
|
146
|
+
d = json.dumps(obj).encode()
|
|
147
|
+
self.send_response(st)
|
|
148
|
+
self.send_header("Content-Type", "application/json")
|
|
149
|
+
self.send_header("Content-Length", str(len(d)))
|
|
150
|
+
self.end_headers()
|
|
151
|
+
self.wfile.write(d)
|
|
152
|
+
|
|
153
|
+
def do_GET(self):
|
|
154
|
+
if eng.flavor == "llama":
|
|
155
|
+
if self.path.startswith("/props"):
|
|
156
|
+
return self._send(200, {"default_generation_settings": {"n_ctx": eng.n_ctx // eng.n_slots},
|
|
157
|
+
"total_slots": eng.n_slots, "model_path": "/m/fake-0.6B.gguf"})
|
|
158
|
+
if self.path.startswith("/slots"):
|
|
159
|
+
return self._send(200, eng.slots_list())
|
|
160
|
+
if self.path.startswith("/health"):
|
|
161
|
+
return self._send(200, {"status": "ok"})
|
|
162
|
+
if self.path.startswith("/v1/models"):
|
|
163
|
+
return self._send(200, {"object": "list", "data": [{"id": "fake-0.6B.gguf"}]})
|
|
164
|
+
return self._send(404, {"error": {"code": 404, "message": "File Not Found"}})
|
|
165
|
+
if eng.flavor == "openai":
|
|
166
|
+
if self.path.startswith("/v1/models"):
|
|
167
|
+
return self._send(200, {"data": [{"id": "fake"}]})
|
|
168
|
+
return self._send(404, {"error": {"message": "not found"}})
|
|
169
|
+
if eng.flavor == "ds4":
|
|
170
|
+
if self.path == "/v1/models":
|
|
171
|
+
mk = lambda i: {"id": i, "object": "model", "created": 1767225600, "owned_by": "ds4.c", # noqa
|
|
172
|
+
"name": "Qwen3.8-Flash-Next", "context_length": eng.n_ctx,
|
|
173
|
+
"top_provider": {"context_length": eng.n_ctx}}
|
|
174
|
+
return self._send(200, {"object": "list", "data": [
|
|
175
|
+
mk("qwen3.8-flash-next"), mk("qwen3.8-flash-next-chat"),
|
|
176
|
+
mk("qwen3.8-flash-next-reasoner")]})
|
|
177
|
+
return self._send(404, {"error": {"message": "unknown endpoint"}})
|
|
178
|
+
if self.path.startswith("/v1/status"):
|
|
179
|
+
return self._send(200, eng.status())
|
|
180
|
+
self._send(200, {"status": "ok", "fake": True})
|
|
181
|
+
|
|
182
|
+
def do_POST(self):
|
|
183
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
184
|
+
body = json.loads(self.rfile.read(n) or b"{}")
|
|
185
|
+
if eng.flavor == "openai" and not self.path.startswith("/v1/chat"):
|
|
186
|
+
return self._send(404, {"error": {"message": "not found"}})
|
|
187
|
+
if eng.flavor == "ds4":
|
|
188
|
+
if not self.path.startswith("/v1/chat/completions"):
|
|
189
|
+
eng.slots.append(("unexpected", self.path, len(eng.live)))
|
|
190
|
+
return self._send(404, {"error": {"message": "unknown endpoint"}})
|
|
191
|
+
err = eng.ds4_check(body)
|
|
192
|
+
if err:
|
|
193
|
+
eng.rejected.append(err)
|
|
194
|
+
return self._send(400, {"error": {"message": err, "type": "invalid_request_error"}})
|
|
195
|
+
if eng.flavor == "llama" and self.path.startswith("/tokenize"):
|
|
196
|
+
return self._send(200, {"tokens": list(range(int(len(body.get("content") or "") / eng.cpt)))})
|
|
197
|
+
if eng.flavor == "llama" and self.path.startswith("/slots/"):
|
|
198
|
+
sid = int(self.path.split("/")[2].split("?")[0])
|
|
199
|
+
action = self.path.split("action=")[-1]
|
|
200
|
+
if not eng.slot_save:
|
|
201
|
+
return self._send(501, {"error": {"code": 501, "message": "This server does not support "
|
|
202
|
+
"slots action. Start it with `--slot-save-path`"}})
|
|
203
|
+
if action not in ("save", "restore", "erase"):
|
|
204
|
+
return self._send(400, {"error": {"code": 400, "message": "Invalid action"}})
|
|
205
|
+
if sid != 0:
|
|
206
|
+
return self._send(400, {"error": {"code": 400, "message": "Invalid slot ID"}})
|
|
207
|
+
fn = body.get("filename")
|
|
208
|
+
eng.slots.append((action, fn, len(eng.live)))
|
|
209
|
+
if action == "save":
|
|
210
|
+
eng.files[fn] = eng.live
|
|
211
|
+
return self._send(200, {"id_slot": sid, "filename": fn, "n_saved": int(len(eng.live) / eng.cpt),
|
|
212
|
+
"n_written": len(eng.live) * 10, "timings": {"save_ms": 1.0}})
|
|
213
|
+
if action == "restore":
|
|
214
|
+
if fn not in eng.files:
|
|
215
|
+
return self._send(400, {"error": {"code": 400, "message": "Unable to restore slot: No "
|
|
216
|
+
"available space in KV cache or invalid slot save file"}})
|
|
217
|
+
eng.live = eng.files[fn]
|
|
218
|
+
return self._send(200, {"id_slot": sid, "filename": fn,
|
|
219
|
+
"n_restored": int(len(eng.live) / eng.cpt),
|
|
220
|
+
"n_read": len(eng.live) * 10, "timings": {"restore_ms": 1.0}})
|
|
221
|
+
eng.live = ""
|
|
222
|
+
return self._send(200, {"id_slot": sid, "n_erased": 1})
|
|
223
|
+
if self.path.startswith("/slots/0"):
|
|
224
|
+
action = self.path.split("action=")[-1]
|
|
225
|
+
eng.slots.append((action, body.get("filename"), len(eng.live)))
|
|
226
|
+
if action == "save":
|
|
227
|
+
eng.files[body.get("filename")] = eng.live
|
|
228
|
+
elif action == "restore":
|
|
229
|
+
if body.get("filename") not in eng.files:
|
|
230
|
+
return self._send(404, {"error": {"message": "file non trovato"}})
|
|
231
|
+
eng.live = eng.files[body.get("filename")]
|
|
232
|
+
return self._send(200, {"id_slot": 0, "filename": body.get("filename"),
|
|
233
|
+
"n_saved": int(len(eng.live) / eng.cpt),
|
|
234
|
+
"n_restored": int(len(eng.live) / eng.cpt),
|
|
235
|
+
"n_written": len(eng.live) * 10, "timings": {"save_ms": 1.0}})
|
|
236
|
+
if body.get("stream"):
|
|
237
|
+
return self.do_POST_stream(body)
|
|
238
|
+
self._send(200, eng.handle_chat(body))
|
|
239
|
+
|
|
240
|
+
def do_POST_stream(self, body):
|
|
241
|
+
r = eng.handle_chat(body)
|
|
242
|
+
msg = dict(r["choices"][0]["message"])
|
|
243
|
+
slow = msg.pop("_slow", 0)
|
|
244
|
+
base = {k: r[k] for k in ("id", "object", "created", "model")}
|
|
245
|
+
base["object"] = "chat.completion.chunk"
|
|
246
|
+
|
|
247
|
+
def ch(delta, fin=None, **x):
|
|
248
|
+
return {**base, "choices": [{"index": 0, "delta": delta, "finish_reason": fin}], **x}
|
|
249
|
+
|
|
250
|
+
parts = [ch({"role": "assistant", "content": ""})]
|
|
251
|
+
for key in ("reasoning_content", "content"):
|
|
252
|
+
t = msg.get(key) or ""
|
|
253
|
+
for i in range(0, len(t), 10):
|
|
254
|
+
parts.append(ch({key: t[i:i + 10]}))
|
|
255
|
+
for i, c in enumerate(msg.get("tool_calls") or []):
|
|
256
|
+
parts.append(ch({"tool_calls": [{"index": i, "id": c["id"], "type": "function",
|
|
257
|
+
"function": {"name": c["function"]["name"], "arguments": ""}}]}))
|
|
258
|
+
a = c["function"]["arguments"]
|
|
259
|
+
for k in range(0, len(a), 7):
|
|
260
|
+
parts.append(ch({"tool_calls": [{"index": i, "function": {"arguments": a[k:k + 7]}}]}))
|
|
261
|
+
parts.append(ch({}, r["choices"][0]["finish_reason"], usage=r["usage"], timings=r["timings"]))
|
|
262
|
+
self.send_response(200)
|
|
263
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
264
|
+
self.send_header("Connection", "close")
|
|
265
|
+
self.end_headers()
|
|
266
|
+
try:
|
|
267
|
+
for p in parts:
|
|
268
|
+
self.wfile.write(b"data: " + json.dumps(p).encode() + b"\n\n")
|
|
269
|
+
self.wfile.flush()
|
|
270
|
+
if slow:
|
|
271
|
+
time.sleep(slow)
|
|
272
|
+
self.wfile.write(b"data: [DONE]\n\n")
|
|
273
|
+
self.wfile.flush()
|
|
274
|
+
except ConnectionError: # Windows: ConnectionAbortedError (WinError 10053) too
|
|
275
|
+
eng.cancelled += 1
|
|
276
|
+
self.close_connection = True
|
|
277
|
+
|
|
278
|
+
self.srv = ThreadingHTTPServer(("127.0.0.1", port), H)
|
|
279
|
+
threading.Thread(target=self.srv.serve_forever, daemon=True).start()
|
|
280
|
+
return "http://127.0.0.1:%d" % self.srv.server_address[1]
|
|
281
|
+
|
|
282
|
+
def stop(self):
|
|
283
|
+
if self.srv:
|
|
284
|
+
self.srv.shutdown()
|
|
285
|
+
self.srv.server_close()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
# Stand-alone fake engine for smoke tests: python3 -m ctxproxy.fake_engine --port 18095
|
|
290
|
+
import argparse
|
|
291
|
+
ap = argparse.ArgumentParser(description="fake OpenAI-compatible engine (Strata stand-in) for smoke tests")
|
|
292
|
+
ap.add_argument("--port", type=int, default=18095)
|
|
293
|
+
ap.add_argument("--flavor", choices=["strata", "llama", "ds4", "openai"], default="strata",
|
|
294
|
+
help="engine to imitate (llama = llama-server with --slot-save-path)")
|
|
295
|
+
ap.add_argument("--n-ctx", type=int, default=131072)
|
|
296
|
+
ap.add_argument("--no-slot-save", action="store_true", help="llama flavor: as if started without --slot-save-path")
|
|
297
|
+
a = ap.parse_args()
|
|
298
|
+
url = FakeEngine(flavor=a.flavor, n_ctx=a.n_ctx, slot_save=not a.no_slot_save).start(a.port)
|
|
299
|
+
print("[fake-engine] listening on %s" % url, flush=True)
|
|
300
|
+
try:
|
|
301
|
+
while True:
|
|
302
|
+
time.sleep(3600)
|
|
303
|
+
except KeyboardInterrupt:
|
|
304
|
+
pass
|