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 ADDED
@@ -0,0 +1,8 @@
1
+ # Author: Maurizio Verde — LastReload
2
+ """Mnemonic Proxy (package `ctxproxy`): transparent proxy that lets local coding agents work for hours.
3
+
4
+ Verbatim archive, structured recall, segments with handoff notes, saved engine state. In front of Strata, llama.cpp
5
+ (llama-server), ds4-server or any OpenAI-compatible engine; speaks Chat Completions, Anthropic Messages and OpenAI Responses.
6
+ """
7
+
8
+ __version__ = "0.3.2"
@@ -0,0 +1,426 @@
1
+ # Author: Maurizio Verde — LastReload
2
+ """Ingresso Anthropic Messages (Claude Code) -> formato interno Chat Completions -> cuore invariato -> uscita
3
+ riconvertita (anche in streaming).
4
+
5
+ POST /v1/messages (stream o no)
6
+ POST /v1/messages/count_tokens
7
+
8
+ Il cuore (Proxy.chat) non sa da che client arriva la richiesta: qui si converte solo il formato.
9
+ Scelte:
10
+ - id degli strumenti: passano INTATTI in entrambe le direzioni (l'id generato dal motore arriva al client e torna
11
+ al motore uguale: ds4-server lo usa per riprendere senza rileggere). Solo un id con caratteri non ammessi da
12
+ Anthropic viene ripulito in uscita («a.b» -> «toolu_a_b»); in ingresso nessuna conversione (la catena di hash
13
+ resta stabile da un turno all'altro).
14
+ - `thinking` <-> `reasoning_content`. La firma (`signature`) non è verificabile in locale: in uscita se ne genera
15
+ una fittizia (hash del testo), in ingresso è ignorata.
16
+ - `cache_control` ignorato. Immagini nei messaggi utente: errore 400 chiaro. Immagini dentro un `tool_result`:
17
+ sostituite da una nota testuale (rifiutarle bloccherebbe per sempre la sessione, che le rimanda a ogni turno).
18
+ - `tool_result` con `is_error`: il testo è preceduto da «Error: » (così anche le ricevute del cuore lo vedono
19
+ fallito), salvo che lo dica già.
20
+ - Blocchi di sistema «x-anthropic-billing-header: …» di Claude Code (cambiano a ogni richiesta) scartati: altrimenti
21
+ ogni richiesta sarebbe una conversazione nuova e la cache a prefisso del motore non servirebbe.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import re
28
+ import threading
29
+ import uuid
30
+
31
+ IMAGE_NOTE = "[immagine omessa: il proxy non inoltra immagini al motore]"
32
+ BILLING = re.compile(r"^\s*x-anthropic-billing-header\s*:", re.I)
33
+ _ID_BAD = re.compile(r"[^A-Za-z0-9_-]")
34
+
35
+
36
+ class BadRequest(ValueError):
37
+ pass
38
+
39
+
40
+ # ---------------------------------------------------------------- id
41
+ def out_tool_id(cid: str | None) -> str:
42
+ """id interno -> id Anthropic (solo [A-Za-z0-9_-]). Un id già valido resta INTATTO: motori come ds4-server
43
+ riconoscono la chiamata dal suo id (testo campionato esatto, ripresa senza rileggere il prefisso) e il client
44
+ lo rimanda così com'è. Solo un id con caratteri non ammessi viene ripulito (e marcato toolu_)."""
45
+ if not cid:
46
+ return "toolu_" + uuid.uuid4().hex[:24]
47
+ if not _ID_BAD.search(cid):
48
+ return cid
49
+ cid = _ID_BAD.sub("_", cid)
50
+ return cid if cid.startswith("toolu_") else "toolu_" + cid
51
+
52
+
53
+ def signature_for(thinking: str) -> str:
54
+ return "strata-" + hashlib.sha256((thinking or "").encode("utf-8")).hexdigest()[:40]
55
+
56
+
57
+ # ---------------------------------------------------------------- richiesta
58
+ def _text_of(blocks, where: str, allow_images: bool) -> str:
59
+ if blocks is None:
60
+ return ""
61
+ if isinstance(blocks, str):
62
+ return blocks
63
+ out = []
64
+ for b in blocks:
65
+ if isinstance(b, str):
66
+ out.append(b)
67
+ continue
68
+ t = b.get("type")
69
+ if t == "text":
70
+ out.append(b.get("text") or "")
71
+ elif t in ("image", "document"):
72
+ if not allow_images:
73
+ raise BadRequest("%s: '%s' blocks are not supported by this proxy (the engine receives text only); "
74
+ "remove the image/document from the message" % (where, t))
75
+ out.append(IMAGE_NOTE)
76
+ elif t in ("search_result",):
77
+ out.append(_text_of(b.get("content"), where, allow_images))
78
+ else:
79
+ raise BadRequest("%s: unsupported block type: %r" % (where, t))
80
+ return "".join(out)
81
+
82
+
83
+ def _system_text(system) -> str | None:
84
+ if system is None:
85
+ return None
86
+ if isinstance(system, str):
87
+ return system
88
+ parts = [b.get("text") or "" for b in system if isinstance(b, dict) and b.get("type") == "text"
89
+ and not BILLING.match(b.get("text") or "")]
90
+ return "\n\n".join(p for p in parts if p) if parts else None
91
+
92
+
93
+ def to_chat_messages(system, messages: list) -> list[dict]:
94
+ out: list[dict] = []
95
+ s = _system_text(system)
96
+ if s:
97
+ out.append({"role": "system", "content": s})
98
+ for k, m in enumerate(messages or []):
99
+ role, content = m.get("role"), m.get("content")
100
+ where = "messages[%d]" % k
101
+ if role == "user":
102
+ if isinstance(content, str):
103
+ out.append({"role": "user", "content": content})
104
+ continue
105
+ texts: list[str] = []
106
+ for b in content or []:
107
+ t = b.get("type") if isinstance(b, dict) else "text"
108
+ if t == "tool_result":
109
+ if texts: # testo prima dei risultati: messaggio utente a sé (raro)
110
+ out.append({"role": "user", "content": "".join(texts)})
111
+ texts = []
112
+ r = _text_of(b.get("content"), where + ".tool_result", True)
113
+ if b.get("is_error") and not r.lstrip().lower().startswith("error"):
114
+ r = "Error: " + r
115
+ out.append({"role": "tool", "tool_call_id": b.get("tool_use_id"), "content": r})
116
+ else:
117
+ texts.append(_text_of([b], where, False))
118
+ if texts:
119
+ out.append({"role": "user", "content": "".join(texts)})
120
+ elif role == "assistant":
121
+ if isinstance(content, str):
122
+ out.append({"role": "assistant", "content": content})
123
+ continue
124
+ text, think, calls = [], [], []
125
+ for b in content or []:
126
+ t = b.get("type")
127
+ if t == "text":
128
+ text.append(b.get("text") or "")
129
+ elif t == "thinking":
130
+ think.append(b.get("thinking") or "")
131
+ elif t == "redacted_thinking":
132
+ continue
133
+ elif t in ("tool_use", "server_tool_use"):
134
+ calls.append({"id": b.get("id"), "type": "function",
135
+ "function": {"name": b.get("name"),
136
+ "arguments": json.dumps(b.get("input") or {}, ensure_ascii=False)}})
137
+ else:
138
+ raise BadRequest("%s: unsupported assistant block type: %r" % (where, t))
139
+ am: dict = {"role": "assistant", "content": "".join(text)}
140
+ if think:
141
+ am["reasoning_content"] = "".join(think)
142
+ if calls:
143
+ am["tool_calls"] = calls
144
+ out.append(am)
145
+ elif role in ("system", "developer"):
146
+ # Claude Code recente manda anche messaggi system dentro messages: in testa si uniscono al system,
147
+ # dopo diventano un messaggio utente marcato (i template chat vogliono il system solo in testa)
148
+ text = _text_of([b for b in content if not (isinstance(b, dict) and BILLING.match(b.get("text") or ""))]
149
+ if isinstance(content, list) else content, where, False)
150
+ if not text:
151
+ continue
152
+ if all(x["role"] == "system" for x in out):
153
+ if out:
154
+ out[-1] = {"role": "system", "content": out[-1]["content"] + "\n\n" + text}
155
+ else:
156
+ out.append({"role": "system", "content": text})
157
+ else:
158
+ out.append({"role": "user", "content": "[system]\n" + text})
159
+ else:
160
+ raise BadRequest("%s: ruolo non supportato: %r" % (where, role))
161
+ return out
162
+
163
+
164
+ def to_chat_tools(tools) -> list | None:
165
+ out = []
166
+ for t in tools or []:
167
+ if t.get("type") not in (None, "custom"):
168
+ continue # strumenti lato server Anthropic (web_search, bash_2025…): il motore non li ha
169
+ out.append({"type": "function", "function": {"name": t["name"], "description": t.get("description") or "",
170
+ "parameters": t.get("input_schema") or {"type": "object"}}})
171
+ return out or None
172
+
173
+
174
+ def to_chat_request(a: dict) -> dict:
175
+ if not isinstance(a.get("messages"), list):
176
+ raise BadRequest("messages: campo obbligatorio")
177
+ if a.get("max_tokens") is None:
178
+ raise BadRequest("max_tokens: campo obbligatorio")
179
+ req = {"model": a.get("model"), "messages": to_chat_messages(a.get("system"), a["messages"]),
180
+ "max_tokens": int(a["max_tokens"])}
181
+ tools = to_chat_tools(a.get("tools"))
182
+ if tools:
183
+ req["tools"] = tools
184
+ tc = a.get("tool_choice")
185
+ if tools and isinstance(tc, dict):
186
+ ty = tc.get("type")
187
+ if ty == "any":
188
+ req["tool_choice"] = "required"
189
+ elif ty == "tool":
190
+ req["tool_choice"] = {"type": "function", "function": {"name": tc.get("name")}}
191
+ elif ty in ("none", "auto"):
192
+ req["tool_choice"] = ty
193
+ if tc.get("disable_parallel_tool_use"):
194
+ req["parallel_tool_calls"] = False
195
+ for k in ("temperature", "top_p", "top_k"):
196
+ if a.get(k) is not None:
197
+ req[k] = a[k]
198
+ if a.get("stop_sequences"):
199
+ req["stop"] = list(a["stop_sequences"])
200
+ if a.get("stream"):
201
+ req["stream"] = True
202
+ return req
203
+
204
+
205
+ # ---------------------------------------------------------------- risposta
206
+ STOP = {"tool_calls": "tool_use", "length": "max_tokens", "stop": "end_turn", "function_call": "tool_use",
207
+ "content_filter": "refusal"}
208
+
209
+
210
+ def _usage(u: dict | None) -> dict:
211
+ u = u or {}
212
+ pt = int(u.get("prompt_tokens") or 0)
213
+ cached = int((u.get("prompt_tokens_details") or {}).get("cached_tokens") or 0)
214
+ return {"input_tokens": max(pt - cached, 0), "cache_read_input_tokens": cached,
215
+ "cache_creation_input_tokens": 0, "output_tokens": int(u.get("completion_tokens") or 0)}
216
+
217
+
218
+ def _input(args: str):
219
+ if not args or not args.strip():
220
+ return {}
221
+ try:
222
+ v = json.loads(args)
223
+ return v if isinstance(v, dict) else {"value": v}
224
+ except ValueError:
225
+ return {"_raw_arguments": args}
226
+
227
+
228
+ def from_chat_response(resp: dict, model: str | None) -> dict:
229
+ ch = (resp.get("choices") or [{}])[0]
230
+ msg = ch.get("message") or {}
231
+ blocks = []
232
+ if msg.get("reasoning_content"):
233
+ blocks.append({"type": "thinking", "thinking": msg["reasoning_content"],
234
+ "signature": signature_for(msg["reasoning_content"])})
235
+ if msg.get("content"):
236
+ blocks.append({"type": "text", "text": msg["content"]})
237
+ for c in msg.get("tool_calls") or []:
238
+ f = c.get("function") or {}
239
+ blocks.append({"type": "tool_use", "id": out_tool_id(c.get("id")), "name": f.get("name"),
240
+ "input": _input(f.get("arguments") or "")})
241
+ stop = STOP.get(ch.get("finish_reason") or "stop", "end_turn")
242
+ if msg.get("tool_calls"):
243
+ stop = "tool_use"
244
+ out = {"id": "msg_" + uuid.uuid4().hex[:24], "type": "message", "role": "assistant",
245
+ "model": model or resp.get("model"), "content": blocks, "stop_reason": stop, "stop_sequence": None,
246
+ "usage": _usage(resp.get("usage"))}
247
+ if resp.get("strata_context"):
248
+ out["strata_context"] = resp["strata_context"]
249
+ return out
250
+
251
+
252
+ ERR_TYPE = {400: "invalid_request_error", 401: "authentication_error", 403: "permission_error",
253
+ 404: "not_found_error", 413: "request_too_large", 429: "rate_limit_error", 529: "overloaded_error"}
254
+
255
+
256
+ def error_body(status: int, message: str) -> dict:
257
+ return {"type": "error", "error": {"type": ERR_TYPE.get(status, "api_error"), "message": message}}
258
+
259
+
260
+ def _err_message(obj) -> str:
261
+ if isinstance(obj, dict):
262
+ e = obj.get("error")
263
+ if isinstance(e, dict):
264
+ return str(e.get("message") or e)
265
+ if e:
266
+ return str(e)
267
+ return json.dumps(obj, ensure_ascii=False)[:2000]
268
+
269
+
270
+ class StreamWriter:
271
+ """Chunk OpenAI (dal cuore) -> eventi SSE Anthropic. `feed(None)` = keep-alive (evento ping)."""
272
+
273
+ def __init__(self, write, model):
274
+ self.write, self.model = write, model
275
+ self.idx = -1
276
+ self.kind = None # "thinking" | "text" | "tool"
277
+ self.think = []
278
+ self.tools: dict[int, int] = {} # indice tool_call OpenAI -> indice blocco
279
+ self.any_tool = False
280
+ self.started = False
281
+ self.done = False
282
+
283
+ def event(self, name, data):
284
+ self.write(("event: %s\ndata: %s\n\n" % (name, json.dumps({"type": name, **data}, ensure_ascii=False)))
285
+ .encode("utf-8"))
286
+
287
+ def start(self):
288
+ if self.started:
289
+ return
290
+ self.started = True
291
+ self.event("message_start", {"message": {
292
+ "id": "msg_" + uuid.uuid4().hex[:24], "type": "message", "role": "assistant", "model": self.model,
293
+ "content": [], "stop_reason": None, "stop_sequence": None,
294
+ "usage": {"input_tokens": 0, "output_tokens": 0}}})
295
+
296
+ def _close(self):
297
+ if self.kind is None:
298
+ return
299
+ if self.kind == "thinking":
300
+ self.event("content_block_delta", {"index": self.idx, "delta": {
301
+ "type": "signature_delta", "signature": signature_for("".join(self.think))}})
302
+ self.event("content_block_stop", {"index": self.idx})
303
+ self.kind = None
304
+
305
+ def _open(self, kind, block):
306
+ self._close()
307
+ self.idx += 1
308
+ self.kind = kind
309
+ if kind == "thinking":
310
+ self.think = []
311
+ self.event("content_block_start", {"index": self.idx, "content_block": block})
312
+
313
+ def feed(self, ch):
314
+ self.start()
315
+ if ch is None:
316
+ self.event("ping", {})
317
+ return
318
+ if ch.get("error"):
319
+ self.error(500, _err_message(ch))
320
+ return
321
+ choice = (ch.get("choices") or [None])[0]
322
+ if not choice:
323
+ return
324
+ d = choice.get("delta") or {}
325
+ if d.get("reasoning_content"):
326
+ if self.kind != "thinking":
327
+ self._open("thinking", {"type": "thinking", "thinking": "", "signature": ""})
328
+ self.think.append(d["reasoning_content"])
329
+ self.event("content_block_delta", {"index": self.idx, "delta": {
330
+ "type": "thinking_delta", "thinking": d["reasoning_content"]}})
331
+ if d.get("content"):
332
+ if self.kind != "text":
333
+ self._open("text", {"type": "text", "text": ""})
334
+ self.event("content_block_delta", {"index": self.idx, "delta": {"type": "text_delta",
335
+ "text": d["content"]}})
336
+ for tc in d.get("tool_calls") or []:
337
+ i = tc.get("index", 0)
338
+ f = tc.get("function") or {}
339
+ if i not in self.tools:
340
+ self._open("tool", {"type": "tool_use", "id": out_tool_id(tc.get("id")), "name": f.get("name"),
341
+ "input": {}})
342
+ self.tools[i] = self.idx
343
+ self.any_tool = True
344
+ if f.get("arguments"):
345
+ if self.tools[i] != self.idx: # argomenti intercalati (raro): non rappresentabile, si scarta
346
+ continue
347
+ self.event("content_block_delta", {"index": self.idx, "delta": {
348
+ "type": "input_json_delta", "partial_json": f["arguments"]}})
349
+ fin = choice.get("finish_reason")
350
+ if fin:
351
+ self._close()
352
+ stop = "tool_use" if self.any_tool else STOP.get(fin, "end_turn")
353
+ u = _usage(ch.get("usage"))
354
+ delta = {"delta": {"stop_reason": stop, "stop_sequence": None}, "usage": u}
355
+ if ch.get("strata_context"):
356
+ delta["strata_context"] = ch["strata_context"]
357
+ self.event("message_delta", delta)
358
+ self.event("message_stop", {})
359
+ self.done = True
360
+
361
+ def error(self, status, message):
362
+ if self.done:
363
+ return
364
+ self.event("error", {"error": error_body(status, message)["error"]})
365
+ self.done = True
366
+
367
+
368
+ # ---------------------------------------------------------------- HTTP
369
+ def handle(h, proxy, path: str, raw: bytes):
370
+ """Chiamato da server.make_handler per /v1/messages e /v1/messages/count_tokens."""
371
+ try:
372
+ a = json.loads(raw or b"{}")
373
+ req = to_chat_request(a)
374
+ except (ValueError, TypeError, KeyError, AttributeError) as e:
375
+ return h._send(400, error_body(400, str(e)))
376
+ if path.endswith("/count_tokens"):
377
+ n, _ = proxy.mgr.estimate(req["messages"], req.get("tools"))
378
+ return h._send(200, {"input_tokens": int(n)})
379
+ hint = h.headers.get("X-Strata-Conversation")
380
+ model = a.get("model")
381
+ if not req.get("stream"):
382
+ try:
383
+ st, resp, hdr = proxy.chat(req, hint)
384
+ except Exception as e: # noqa: BLE001
385
+ proxy.journal.log("proxy_error", api="anthropic", error=repr(e)[:500])
386
+ return h._send(500, error_body(500, "proxy: %r" % e))
387
+ if st != 200:
388
+ return h._send(st, error_body(st, _err_message(resp)))
389
+ return h._send(200, from_chat_response(resp, model), hdr)
390
+ return _stream(h, proxy, req, hint, model)
391
+
392
+
393
+ def _stream(h, proxy, req, hint, model):
394
+ from .server import ClientGone
395
+ h.send_response(200)
396
+ h.send_header("Content-Type", "text/event-stream")
397
+ h.send_header("Cache-Control", "no-cache")
398
+ h.send_header("Connection", "close")
399
+ h.end_headers()
400
+ h.close_connection = True
401
+ wlock = threading.Lock()
402
+
403
+ def write(data: bytes):
404
+ try:
405
+ with wlock:
406
+ h.wfile.write(data)
407
+ h.wfile.flush()
408
+ except (BrokenPipeError, ConnectionResetError, OSError):
409
+ raise ClientGone()
410
+
411
+ w = StreamWriter(write, model)
412
+ try:
413
+ w.start()
414
+ st, resp, _ = proxy.chat(req, hint, emit=w.feed)
415
+ if st != 200:
416
+ w.error(st, _err_message(resp))
417
+ elif not w.done: # il cuore chiude sempre con un chunk finale; per sicurezza
418
+ w.feed({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": resp.get("usage")})
419
+ except ClientGone:
420
+ pass
421
+ except Exception as e: # noqa: BLE001
422
+ proxy.journal.log("proxy_error", api="anthropic", error=repr(e)[:500])
423
+ try:
424
+ w.error(500, "proxy: %r" % e)
425
+ except ClientGone:
426
+ pass