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/__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
|