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.
@@ -0,0 +1,436 @@
1
+ # Author: Maurizio Verde — LastReload
2
+ """Ingresso OpenAI Responses (Codex) -> formato interno Chat Completions -> cuore invariato -> uscita riconvertita
3
+ (anche in streaming).
4
+
5
+ POST /v1/responses (stream o no)
6
+
7
+ Codex 0.160 accetta solo `wire_api = "responses"` (`"chat"` è rifiutato: «`wire_api = "chat"` is no longer
8
+ supported»), quindi per usarlo davanti a un motore Chat Completions serve questa conversione.
9
+ Scelte:
10
+ - `instructions` -> messaggio system; messaggi `developer`/`system` -> system (il primo) o user con prefisso.
11
+ - `function_call` consecutivi (e il testo/ragionamento che li precede) -> un solo messaggio assistant con più
12
+ tool_calls; `function_call_output` -> messaggio tool. `call_id` resta identico in entrata e in uscita.
13
+ - `reasoning` (content `reasoning_text` o, in mancanza, `summary_text`) -> reasoning_content dell'assistant
14
+ che segue. `encrypted_content` ignorato. In uscita il ragionamento è un item `reasoning` con `content`
15
+ reasoning_text (e lo stesso testo come summary, perché Codex mostra i summary).
16
+ - strumenti `custom` (testo libero, es. apply_patch di Codex) -> funzione con un parametro `input`; la chiamata
17
+ torna al client come `custom_tool_call`. Strumenti lato server (web_search, local_shell…) scartati.
18
+ - Immagini in un messaggio utente: errore 400 chiaro; dentro un output di strumento: nota testuale.
19
+ - Niente stato lato server: `previous_response_id` non è supportato (Codex rimanda tutta la storia, `store=false`).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import threading
25
+ import time
26
+ import uuid
27
+
28
+ from .api_anthropic import IMAGE_NOTE, BadRequest, _err_message
29
+
30
+ CUSTOM_PARAMS = {"type": "object", "properties": {"input": {"type": "string",
31
+ "description": "testo libero passato allo strumento"}},
32
+ "required": ["input"]}
33
+
34
+
35
+ def _parts_text(content, where: str, allow_images: bool) -> str:
36
+ if content is None:
37
+ return ""
38
+ if isinstance(content, str):
39
+ return content
40
+ out = []
41
+ for p in content:
42
+ if isinstance(p, str):
43
+ out.append(p)
44
+ continue
45
+ t = p.get("type")
46
+ if t in ("input_text", "output_text", "text", "summary_text", "reasoning_text", "refusal"):
47
+ out.append(p.get("text") or p.get("refusal") or "")
48
+ elif t in ("input_image", "input_file", "image_url"):
49
+ if not allow_images:
50
+ raise BadRequest("%s: '%s' content is not supported by this proxy (the engine receives text only)"
51
+ % (where, t))
52
+ out.append(IMAGE_NOTE)
53
+ else:
54
+ raise BadRequest("%s: unsupported content type: %r" % (where, t))
55
+ return "".join(out)
56
+
57
+
58
+ def to_chat_messages(instructions, items, custom_names=()) -> list[dict]:
59
+ out: list[dict] = []
60
+ if instructions:
61
+ out.append({"role": "system", "content": instructions})
62
+ if isinstance(items, str):
63
+ items = [{"type": "message", "role": "user", "content": items}]
64
+ items_l: list[dict] = list(items or [])
65
+ pend: dict | None = None # assistant in costruzione
66
+ think: list[str] = [] # ragionamento in attesa del suo assistant
67
+
68
+ def flush():
69
+ nonlocal pend, think
70
+ if pend is None and think:
71
+ pend = {"role": "assistant", "content": ""}
72
+ if pend is not None:
73
+ if think:
74
+ pend["reasoning_content"] = "".join(think)
75
+ out.append(pend)
76
+ pend, think = None, []
77
+
78
+ def assistant():
79
+ nonlocal pend
80
+ if pend is None:
81
+ pend = {"role": "assistant", "content": ""}
82
+ return pend
83
+
84
+ for k, it in enumerate(items_l):
85
+ where = "input[%d]" % k
86
+ t = it.get("type") or ("message" if it.get("role") else None)
87
+ if t == "message":
88
+ role = it.get("role")
89
+ if role == "assistant":
90
+ a = assistant()
91
+ if a.get("tool_calls"): # testo dopo chiamate: nuovo turno assistant
92
+ flush()
93
+ a = assistant()
94
+ a["content"] += _parts_text(it.get("content"), where, False)
95
+ continue
96
+ flush()
97
+ text = _parts_text(it.get("content"), where, False)
98
+ if role in ("system", "developer"):
99
+ if all(m["role"] == "system" for m in out): # ancora in testa: si unisce al system
100
+ if out:
101
+ out[-1] = {"role": "system", "content": out[-1]["content"] + "\n\n" + text}
102
+ else:
103
+ out.append({"role": "system", "content": text})
104
+ else:
105
+ out.append({"role": "user", "content": "[%s]\n%s" % (role, text)})
106
+ elif role == "user":
107
+ out.append({"role": "user", "content": text})
108
+ else:
109
+ raise BadRequest("%s: ruolo non supportato: %r" % (where, role))
110
+ elif t == "reasoning":
111
+ if pend is not None and (pend.get("tool_calls") or pend.get("content")):
112
+ flush()
113
+ txt = _parts_text(it.get("content"), where, False) if it.get("content") else \
114
+ "\n".join((s.get("text") or "") for s in it.get("summary") or [])
115
+ if txt:
116
+ think.append(txt)
117
+ elif t in ("function_call", "custom_tool_call"):
118
+ a = assistant()
119
+ args = it.get("arguments") if t == "function_call" else json.dumps({"input": it.get("input") or ""},
120
+ ensure_ascii=False)
121
+ a.setdefault("tool_calls", []).append({"id": it.get("call_id"), "type": "function",
122
+ "function": {"name": it.get("name"), "arguments": args or ""}})
123
+ elif t in ("function_call_output", "custom_tool_call_output"):
124
+ flush()
125
+ o = it.get("output")
126
+ txt = o if isinstance(o, str) else (_parts_text(o, where, True) if isinstance(o, list)
127
+ else json.dumps(o, ensure_ascii=False))
128
+ out.append({"role": "tool", "tool_call_id": it.get("call_id"), "content": txt})
129
+ elif t in ("item_reference",):
130
+ raise BadRequest("%s: item_reference not supported (the proxy keeps no state: resend the history)"
131
+ % where)
132
+ else:
133
+ # web_search_call, local_shell_call, compaction… : non rappresentabili per il motore, ignorati
134
+ continue
135
+ flush()
136
+ return out
137
+
138
+
139
+ def to_chat_tools(tools) -> tuple[list | None, set]:
140
+ out, custom = [], set()
141
+ for t in tools or []:
142
+ ty = t.get("type")
143
+ if ty == "function":
144
+ out.append({"type": "function", "function": {"name": t["name"], "description": t.get("description") or "",
145
+ "parameters": t.get("parameters") or {"type": "object"}}})
146
+ elif ty == "custom":
147
+ custom.add(t["name"])
148
+ desc = t.get("description") or ""
149
+ fmt = t.get("format") or {}
150
+ if fmt.get("type") == "grammar" and fmt.get("definition"):
151
+ desc += "\n\nIl parametro `input` deve rispettare questa grammatica (%s):\n%s" % (
152
+ fmt.get("syntax") or "", fmt["definition"])
153
+ out.append({"type": "function", "function": {"name": t["name"], "description": desc,
154
+ "parameters": CUSTOM_PARAMS}})
155
+ return (out or None), custom
156
+
157
+
158
+ def to_chat_request(r: dict) -> tuple[dict, set]:
159
+ if r.get("previous_response_id"):
160
+ raise BadRequest("previous_response_id not supported: the proxy keeps no state, resend the whole history")
161
+ if r.get("input") is None:
162
+ raise BadRequest("input: campo obbligatorio")
163
+ tools, custom = to_chat_tools(r.get("tools"))
164
+ req = {"model": r.get("model"), "messages": to_chat_messages(r.get("instructions"), r["input"], custom)}
165
+ if tools:
166
+ req["tools"] = tools
167
+ tc = r.get("tool_choice")
168
+ if tc in ("auto", "none", "required"):
169
+ req["tool_choice"] = tc
170
+ elif isinstance(tc, dict) and tc.get("name"):
171
+ req["tool_choice"] = {"type": "function", "function": {"name": tc["name"]}}
172
+ if r.get("parallel_tool_calls") is False:
173
+ req["parallel_tool_calls"] = False
174
+ if r.get("max_output_tokens"):
175
+ req["max_tokens"] = int(r["max_output_tokens"])
176
+ for k in ("temperature", "top_p"):
177
+ if r.get(k) is not None:
178
+ req[k] = r[k]
179
+ if isinstance(r.get("reasoning"), dict) and r["reasoning"].get("effort"):
180
+ req["reasoning_effort"] = r["reasoning"]["effort"]
181
+ if r.get("stream"):
182
+ req["stream"] = True
183
+ return req, custom
184
+
185
+
186
+ # ---------------------------------------------------------------- risposta
187
+ def _usage(u: dict | None) -> dict:
188
+ u = u or {}
189
+ pt, ct = int(u.get("prompt_tokens") or 0), int(u.get("completion_tokens") or 0)
190
+ return {"input_tokens": pt,
191
+ "input_tokens_details": {"cached_tokens": int((u.get("prompt_tokens_details") or {})
192
+ .get("cached_tokens") or 0)},
193
+ "output_tokens": ct,
194
+ "output_tokens_details": {"reasoning_tokens": int((u.get("completion_tokens_details") or {})
195
+ .get("reasoning_tokens") or 0)},
196
+ "total_tokens": pt + ct}
197
+
198
+
199
+ def _rid(p):
200
+ return p + "_" + uuid.uuid4().hex[:24]
201
+
202
+
203
+ def reasoning_item(text, iid=None, status="completed"):
204
+ return {"type": "reasoning", "id": iid or _rid("rs"), "status": status,
205
+ "summary": [{"type": "summary_text", "text": text}] if text else [],
206
+ "content": [{"type": "reasoning_text", "text": text}] if text else []}
207
+
208
+
209
+ def message_item(text, iid=None, status="completed"):
210
+ return {"type": "message", "id": iid or _rid("msg"), "status": status, "role": "assistant",
211
+ "content": [{"type": "output_text", "text": text, "annotations": []}] if text is not None else []}
212
+
213
+
214
+ def call_item(cid, name, args, custom, iid=None, status="completed"):
215
+ if name in custom:
216
+ try:
217
+ inp = (json.loads(args) or {}).get("input", "") if args else ""
218
+ except (ValueError, AttributeError):
219
+ inp = args
220
+ return {"type": "custom_tool_call", "id": iid or _rid("ctc"), "status": status, "call_id": cid,
221
+ "name": name, "input": inp if isinstance(inp, str) else json.dumps(inp, ensure_ascii=False)}
222
+ return {"type": "function_call", "id": iid or _rid("fc"), "status": status, "call_id": cid, "name": name,
223
+ "arguments": args}
224
+
225
+
226
+ def response_obj(rid, model, output, usage, status="completed", created=None, extra=None):
227
+ o = {"id": rid, "object": "response", "created_at": created or int(time.time()), "status": status,
228
+ "model": model, "output": output, "usage": usage, "error": None, "incomplete_details": None,
229
+ "parallel_tool_calls": True, "tool_choice": "auto", "tools": []}
230
+ if status == "incomplete":
231
+ o["incomplete_details"] = {"reason": "max_output_tokens"}
232
+ o.update(extra or {})
233
+ return o
234
+
235
+
236
+ def from_chat_response(resp: dict, model, custom) -> dict:
237
+ ch = (resp.get("choices") or [{}])[0]
238
+ msg = ch.get("message") or {}
239
+ out = []
240
+ if msg.get("reasoning_content"):
241
+ out.append(reasoning_item(msg["reasoning_content"]))
242
+ if msg.get("content"):
243
+ out.append(message_item(msg["content"]))
244
+ for c in msg.get("tool_calls") or []:
245
+ f = c.get("function") or {}
246
+ out.append(call_item(c.get("id") or _rid("call"), f.get("name"), f.get("arguments") or "", custom))
247
+ extra = {"strata_context": resp["strata_context"]} if resp.get("strata_context") else None
248
+ return response_obj(_rid("resp"), model or resp.get("model"), out, _usage(resp.get("usage")),
249
+ "incomplete" if ch.get("finish_reason") == "length" else "completed", extra=extra)
250
+
251
+
252
+ def error_body(status: int, message: str) -> dict:
253
+ return {"error": {"message": message, "type": "invalid_request_error" if status == 400 else "server_error",
254
+ "param": None, "code": None}}
255
+
256
+
257
+ class StreamWriter:
258
+ """Chunk OpenAI Chat (dal cuore) -> eventi SSE Responses. feed(None) = keep-alive (commento SSE)."""
259
+
260
+ def __init__(self, write, model, custom):
261
+ self.write, self.model, self.custom = write, model, custom
262
+ self.rid = _rid("resp")
263
+ self.created = int(time.time())
264
+ self.seq = 0
265
+ self.output: list[dict] = []
266
+ self.cur = None # {"kind", "oi", "item", "buf"}
267
+ self.calls: dict[int, dict] = {}
268
+ self.started = False
269
+ self.done = False
270
+
271
+ def event(self, name, data):
272
+ d = {"type": name, "sequence_number": self.seq, **data}
273
+ self.seq += 1
274
+ self.write(("event: %s\ndata: %s\n\n" % (name, json.dumps(d, ensure_ascii=False))).encode("utf-8"))
275
+
276
+ def start(self):
277
+ if self.started:
278
+ return
279
+ self.started = True
280
+ r = response_obj(self.rid, self.model, [], None, "in_progress", self.created)
281
+ self.event("response.created", {"response": r})
282
+ self.event("response.in_progress", {"response": r})
283
+
284
+ # -- apertura/chiusura degli item
285
+ def _close(self):
286
+ c = self.cur
287
+ if c is None:
288
+ return
289
+ self.cur = None
290
+ oi, it, text = c["oi"], c["item"], "".join(c["buf"])
291
+ if c["kind"] == "reasoning":
292
+ self.event("response.reasoning_text.done", {"item_id": it["id"], "output_index": oi,
293
+ "content_index": 0, "text": text})
294
+ done = reasoning_item(text, it["id"])
295
+ elif c["kind"] == "message":
296
+ part = {"type": "output_text", "text": text, "annotations": []}
297
+ self.event("response.output_text.done", {"item_id": it["id"], "output_index": oi, "content_index": 0,
298
+ "text": text})
299
+ self.event("response.content_part.done", {"item_id": it["id"], "output_index": oi,
300
+ "content_index": 0, "part": part})
301
+ done = message_item(text, it["id"])
302
+ else:
303
+ if it["type"] == "function_call":
304
+ self.event("response.function_call_arguments.done", {"item_id": it["id"], "output_index": oi,
305
+ "arguments": text})
306
+ done = call_item(it["call_id"], it["name"], text, self.custom, it["id"])
307
+ self.output[oi] = done
308
+ self.event("response.output_item.done", {"output_index": oi, "item": done})
309
+
310
+ def _open(self, kind, item):
311
+ self._close()
312
+ oi = len(self.output)
313
+ self.output.append(item)
314
+ self.cur = {"kind": kind, "oi": oi, "item": item, "buf": []}
315
+ self.event("response.output_item.added", {"output_index": oi, "item": item})
316
+ if kind == "message":
317
+ self.event("response.content_part.added", {"item_id": item["id"], "output_index": oi,
318
+ "content_index": 0,
319
+ "part": {"type": "output_text", "text": "", "annotations": []}})
320
+ return self.cur
321
+
322
+ def feed(self, ch):
323
+ self.start()
324
+ if ch is None:
325
+ self.write(b": keep-alive\n\n")
326
+ return
327
+ if ch.get("error"):
328
+ self.error(_err_message(ch))
329
+ return
330
+ choice = (ch.get("choices") or [None])[0]
331
+ if not choice:
332
+ return
333
+ d = choice.get("delta") or {}
334
+ if d.get("reasoning_content"):
335
+ c = self.cur if self.cur and self.cur["kind"] == "reasoning" else \
336
+ self._open("reasoning", {"type": "reasoning", "id": _rid("rs"), "summary": [], "content": []})
337
+ c["buf"].append(d["reasoning_content"])
338
+ self.event("response.reasoning_text.delta", {"item_id": c["item"]["id"], "output_index": c["oi"],
339
+ "content_index": 0, "delta": d["reasoning_content"]})
340
+ if d.get("content"):
341
+ c = self.cur if self.cur and self.cur["kind"] == "message" else \
342
+ self._open("message", {"type": "message", "id": _rid("msg"), "status": "in_progress",
343
+ "role": "assistant", "content": []})
344
+ c["buf"].append(d["content"])
345
+ self.event("response.output_text.delta", {"item_id": c["item"]["id"], "output_index": c["oi"],
346
+ "content_index": 0, "delta": d["content"]})
347
+ for tc in d.get("tool_calls") or []:
348
+ i = tc.get("index", 0)
349
+ f = tc.get("function") or {}
350
+ c = self.calls.get(i)
351
+ if c is None:
352
+ name = f.get("name") or ""
353
+ cid = tc.get("id") or _rid("call")
354
+ if name in self.custom:
355
+ item = {"type": "custom_tool_call", "id": _rid("ctc"), "status": "in_progress", "call_id": cid,
356
+ "name": name, "input": ""}
357
+ else:
358
+ item = {"type": "function_call", "id": _rid("fc"), "status": "in_progress", "call_id": cid,
359
+ "name": name, "arguments": ""}
360
+ c = self.calls[i] = self._open("call", item)
361
+ if f.get("arguments"):
362
+ if c is not self.cur:
363
+ continue # argomenti intercalati (non accade col cuore): scartati
364
+ c["buf"].append(f["arguments"])
365
+ if c["item"]["type"] == "function_call":
366
+ self.event("response.function_call_arguments.delta", {
367
+ "item_id": c["item"]["id"], "output_index": c["oi"], "delta": f["arguments"]})
368
+ if choice.get("finish_reason"):
369
+ self._close()
370
+ st = "incomplete" if choice["finish_reason"] == "length" else "completed"
371
+ extra = {"strata_context": ch["strata_context"]} if ch.get("strata_context") else None
372
+ r = response_obj(self.rid, self.model, self.output, _usage(ch.get("usage")), st, self.created, extra)
373
+ self.event("response.completed" if st == "completed" else "response.incomplete", {"response": r})
374
+ self.done = True
375
+
376
+ def error(self, message, code="server_error"):
377
+ if self.done:
378
+ return
379
+ self._close()
380
+ r = response_obj(self.rid, self.model, self.output, None, "failed", self.created)
381
+ r["error"] = {"code": code, "message": message}
382
+ self.event("response.failed", {"response": r})
383
+ self.done = True
384
+
385
+
386
+ # ---------------------------------------------------------------- HTTP
387
+ def handle(h, proxy, path: str, raw: bytes):
388
+ try:
389
+ r = json.loads(raw or b"{}")
390
+ req, custom = to_chat_request(r)
391
+ except (ValueError, TypeError, KeyError, AttributeError) as e:
392
+ return h._send(400, error_body(400, str(e)))
393
+ hint = h.headers.get("X-Strata-Conversation") or r.get("prompt_cache_key")
394
+ model = r.get("model")
395
+ if not req.get("stream"):
396
+ try:
397
+ st, resp, hdr = proxy.chat(req, hint)
398
+ except Exception as e: # noqa: BLE001
399
+ proxy.journal.log("proxy_error", api="responses", error=repr(e)[:500])
400
+ return h._send(500, error_body(500, "proxy: %r" % e))
401
+ if st != 200:
402
+ return h._send(st, error_body(st, _err_message(resp)))
403
+ return h._send(200, from_chat_response(resp, model, custom), hdr)
404
+ from .server import ClientGone
405
+ h.send_response(200)
406
+ h.send_header("Content-Type", "text/event-stream")
407
+ h.send_header("Cache-Control", "no-cache")
408
+ h.send_header("Connection", "close")
409
+ h.end_headers()
410
+ h.close_connection = True
411
+ wlock = threading.Lock()
412
+
413
+ def write(data: bytes):
414
+ try:
415
+ with wlock:
416
+ h.wfile.write(data)
417
+ h.wfile.flush()
418
+ except (BrokenPipeError, ConnectionResetError, OSError):
419
+ raise ClientGone()
420
+
421
+ w = StreamWriter(write, model, custom)
422
+ try:
423
+ w.start()
424
+ st, resp, _ = proxy.chat(req, hint, emit=w.feed)
425
+ if st != 200:
426
+ w.error(_err_message(resp))
427
+ elif not w.done:
428
+ w.feed({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": resp.get("usage")})
429
+ except ClientGone:
430
+ pass
431
+ except Exception as e: # noqa: BLE001
432
+ proxy.journal.log("proxy_error", api="responses", error=repr(e)[:500])
433
+ try:
434
+ w.error("proxy: %r" % e)
435
+ except ClientGone:
436
+ pass