coderouter-cli 2.17.0__py3-none-any.whl → 2.19.0__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,686 @@
1
+ """OpenAI Responses API ⇄ Anthropic Messages translation (v2.19.0).
2
+
3
+ The ``POST /v1/responses`` ingress exists for the OpenAI Codex CLI, which
4
+ since 2026-02 speaks only the Responses API. CodeRouter keeps Anthropic
5
+ Messages as its internal pivot, so a Responses request is translated to an
6
+ :class:`AnthropicRequest`, run through the fallback engine (tool-call
7
+ repair, guards, fallback all apply), and the engine's Anthropic events are
8
+ translated back into Responses SSE events.
9
+
10
+ The wire contract below was taken from openai/codex ``970111f``
11
+ (2026-10-23); see ``docs/start/quickstart.md`` Pattern B for the client
12
+ config. Key points:
13
+
14
+ - Codex sends no ``instructions`` field; the system prompt is the first
15
+ ``input`` item, a ``developer`` message. Every ``developer`` / ``system``
16
+ message becomes Anthropic ``system`` text, in order.
17
+ - Tool kinds: ``function`` (answer: ``function_call`` with a JSON-string
18
+ ``arguments``), ``custom`` (freeform grammar tools such as
19
+ ``apply_patch``; answer: ``custom_tool_call`` with a raw ``input``
20
+ string — a ``function_call`` is rejected), and ``namespace`` (MCP; answer
21
+ must carry ``namespace`` plus the inner ``name``). Hosted tools
22
+ (``web_search``, …) are dropped: only OpenAI runs them.
23
+ - Codex acts on ``response.output_item.done``; a text delta needs an open
24
+ item; ``response.completed`` must carry ``response.id``; usage detail
25
+ objects must be complete or absent.
26
+ - In-stream failure is ``response.failed`` followed by end of stream, and
27
+ ``context_length_exceeded`` there is the only context-overflow signal
28
+ Codex recognizes.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import json
35
+ import re
36
+ import uuid
37
+ from dataclasses import dataclass, field
38
+ from typing import Any
39
+
40
+ from coderouter.translation.anthropic import AnthropicRequest, AnthropicStreamEvent
41
+
42
+ #: Default ``max_tokens`` when the Responses request has no
43
+ #: ``max_output_tokens`` (Codex never sends one).
44
+ DEFAULT_MAX_OUTPUT_TOKENS = 16384
45
+
46
+ _NAME_SAFE = re.compile(r"[^a-zA-Z0-9_-]")
47
+ _HOSTED_TOOL_TYPES = frozenset(
48
+ {"web_search", "web_search_preview", "file_search", "image_generation", "code_interpreter",
49
+ "computer_use_preview", "local_shell", "mcp", "tool_search"}
50
+ )
51
+
52
+
53
+ _MAX_TOOL_NAME = 64
54
+
55
+
56
+ def _safe_name(raw: str) -> str:
57
+ name = _NAME_SAFE.sub("_", raw) or "tool"
58
+ if len(name) <= _MAX_TOOL_NAME:
59
+ return name
60
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8]
61
+ return f"{name[: _MAX_TOOL_NAME - 9]}_{digest}"
62
+
63
+
64
+ def _new_id(prefix: str) -> str:
65
+ return f"{prefix}_{uuid.uuid4().hex[:24]}"
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Tool mapping
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class ToolRef:
75
+ """How an Anthropic tool name maps back to a Responses tool."""
76
+
77
+ kind: str # "function" | "custom"
78
+ name: str
79
+ namespace: str | None = None
80
+
81
+
82
+ @dataclass
83
+ class ToolMap:
84
+ """Reversible per-request mapping between flattened Anthropic tool
85
+ names and Responses tools (namespaces are flattened)."""
86
+
87
+ by_flat: dict[str, ToolRef] = field(default_factory=dict)
88
+
89
+ def flat_name(self, name: str, namespace: str | None) -> str:
90
+ """Provider-safe name for ``(namespace, name)``; unique per request.
91
+
92
+ Capped at 64 chars (OpenAI-compatible servers reject longer function
93
+ names) with a short hash suffix so long ``mcp__server__tool`` names
94
+ stay distinct.
95
+ """
96
+ base = _safe_name(f"{namespace}__{name}" if namespace else name)
97
+ flat, i = base, 2
98
+ while flat in self.by_flat and (
99
+ self.by_flat[flat].name,
100
+ self.by_flat[flat].namespace,
101
+ ) != (name, namespace):
102
+ flat = f"{base[:56]}_{i}"
103
+ i += 1
104
+ return flat
105
+
106
+ def add(self, kind: str, name: str, namespace: str | None) -> str:
107
+ flat = self.flat_name(name, namespace)
108
+ self.by_flat[flat] = ToolRef(kind=kind, name=name, namespace=namespace)
109
+ return flat
110
+
111
+ def lookup(self, flat: str) -> ToolRef:
112
+ return self.by_flat.get(flat, ToolRef(kind="function", name=flat))
113
+
114
+ def flat_for(self, name: str, namespace: str | None) -> str:
115
+ for flat, ref in self.by_flat.items():
116
+ if ref.name == name and ref.namespace == namespace:
117
+ return flat
118
+ return _safe_name(f"{namespace}__{name}" if namespace else name)
119
+
120
+
121
+ def _custom_tool_schema(tool: dict[str, Any]) -> dict[str, Any]:
122
+ fmt = tool.get("format") or {}
123
+ hint = ""
124
+ if isinstance(fmt, dict) and fmt.get("definition"):
125
+ hint = (
126
+ f" The text must follow this {fmt.get('syntax', 'grammar')} grammar:\n"
127
+ f"{fmt['definition']}"
128
+ )
129
+ return {
130
+ "type": "object",
131
+ "properties": {
132
+ "input": {
133
+ "type": "string",
134
+ "description": "The raw text input for this tool, passed verbatim." + hint,
135
+ }
136
+ },
137
+ "required": ["input"],
138
+ }
139
+
140
+
141
+ def _convert_tools(tools: list[Any] | None, tool_map: ToolMap) -> list[dict[str, Any]]:
142
+ out: list[dict[str, Any]] = []
143
+ for tool in tools or []:
144
+ if not isinstance(tool, dict):
145
+ continue
146
+ kind = tool.get("type")
147
+ if kind == "namespace":
148
+ ns = tool.get("name")
149
+ for inner in tool.get("tools") or []:
150
+ out.extend(_convert_one_tool(inner, tool_map, namespace=ns))
151
+ continue
152
+ out.extend(_convert_one_tool(tool, tool_map, namespace=None))
153
+ return out
154
+
155
+
156
+ def _convert_one_tool(
157
+ tool: dict[str, Any], tool_map: ToolMap, *, namespace: str | None
158
+ ) -> list[dict[str, Any]]:
159
+ kind = tool.get("type")
160
+ name = tool.get("name")
161
+ if not isinstance(name, str) or not name:
162
+ return []
163
+ if kind == "function":
164
+ flat = tool_map.add("function", name, namespace)
165
+ return [
166
+ {
167
+ "name": flat,
168
+ "description": tool.get("description") or "",
169
+ "input_schema": tool.get("parameters") or {"type": "object", "properties": {}},
170
+ }
171
+ ]
172
+ if kind == "custom":
173
+ flat = tool_map.add("custom", name, namespace)
174
+ return [
175
+ {
176
+ "name": flat,
177
+ "description": tool.get("description") or "",
178
+ "input_schema": _custom_tool_schema(tool),
179
+ }
180
+ ]
181
+ # Hosted / unknown tool types only run on OpenAI's side.
182
+ return []
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Input → Anthropic messages
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ def _text_of(content: Any) -> str:
191
+ if isinstance(content, str):
192
+ return content
193
+ parts: list[str] = []
194
+ for part in content or []:
195
+ if isinstance(part, dict) and isinstance(part.get("text"), str):
196
+ parts.append(part["text"])
197
+ return "\n".join(parts)
198
+
199
+
200
+ def _image_block(part: dict[str, Any]) -> dict[str, Any] | None:
201
+ url = part.get("image_url")
202
+ if not isinstance(url, str) or not url:
203
+ return None
204
+ m = re.match(r"data:([^;]+);base64,(.*)", url, re.DOTALL)
205
+ if m:
206
+ return {"type": "image", "source": {"type": "base64", "media_type": m.group(1), "data": m.group(2)}}
207
+ return {"type": "image", "source": {"type": "url", "url": url}}
208
+
209
+
210
+ def _user_blocks(content: Any) -> list[dict[str, Any]]:
211
+ if isinstance(content, str):
212
+ return [{"type": "text", "text": content}] if content else []
213
+ blocks: list[dict[str, Any]] = []
214
+ for part in content or []:
215
+ if not isinstance(part, dict):
216
+ continue
217
+ ptype = part.get("type")
218
+ if ptype in ("input_text", "output_text", "text") and part.get("text"):
219
+ blocks.append({"type": "text", "text": part["text"]})
220
+ elif ptype == "input_image":
221
+ img = _image_block(part)
222
+ if img is not None:
223
+ blocks.append(img)
224
+ return blocks
225
+
226
+
227
+ def _tool_output_content(output: Any) -> str | list[dict[str, Any]]:
228
+ """``*_call_output.output`` → Anthropic ``tool_result`` content.
229
+
230
+ Strings pass through. Arrays keep their text parts as text blocks and
231
+ their images as image blocks (Codex's ``view_image`` and MCP image
232
+ results), instead of dumping base64 into the prompt as text. Backends
233
+ that cannot take images get a placeholder from the translation layer.
234
+ """
235
+ if isinstance(output, str):
236
+ return output
237
+ if not isinstance(output, list):
238
+ return json.dumps(output, ensure_ascii=False)
239
+ blocks: list[dict[str, Any]] = []
240
+ for part in output:
241
+ if not isinstance(part, dict):
242
+ continue
243
+ ptype = part.get("type")
244
+ if ptype in ("input_text", "output_text", "text") and isinstance(part.get("text"), str):
245
+ blocks.append({"type": "text", "text": part["text"]})
246
+ elif ptype == "input_image":
247
+ img = _image_block(part)
248
+ if img is not None:
249
+ blocks.append(img)
250
+ return blocks or ""
251
+
252
+
253
+ def _append(messages: list[dict[str, Any]], role: str, blocks: list[dict[str, Any]]) -> None:
254
+ if not blocks:
255
+ return
256
+ if messages and messages[-1]["role"] == role:
257
+ messages[-1]["content"].extend(blocks)
258
+ else:
259
+ messages.append({"role": role, "content": list(blocks)})
260
+
261
+
262
+ def _repair_tool_pairing(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
263
+ """Make tool_use / tool_result pairing valid for Anthropic.
264
+
265
+ - a tool_result whose tool_use is unknown is dropped;
266
+ - a tool_use with no tool_result in the next user turn gets a
267
+ synthetic ``"aborted"`` result (Codex does the same normalization,
268
+ but not necessarily before every request).
269
+ """
270
+ seen_ids: set[str] = set()
271
+ out: list[dict[str, Any]] = []
272
+ for i, msg in enumerate(messages):
273
+ if msg["role"] == "assistant":
274
+ ids = [b["id"] for b in msg["content"] if b.get("type") == "tool_use"]
275
+ seen_ids.update(ids)
276
+ out.append(msg)
277
+ nxt = messages[i + 1] if i + 1 < len(messages) else None
278
+ answered = {
279
+ b.get("tool_use_id")
280
+ for b in (nxt["content"] if nxt and nxt["role"] == "user" else [])
281
+ if b.get("type") == "tool_result"
282
+ }
283
+ missing = [tid for tid in ids if tid not in answered]
284
+ if missing and nxt is not None:
285
+ synth = [
286
+ {"type": "tool_result", "tool_use_id": tid, "content": "aborted"} for tid in missing
287
+ ]
288
+ if nxt["role"] == "user":
289
+ nxt["content"][:0] = synth
290
+ else:
291
+ out.append({"role": "user", "content": synth})
292
+ continue
293
+ msg = {
294
+ "role": msg["role"],
295
+ "content": [
296
+ b
297
+ for b in msg["content"]
298
+ if b.get("type") != "tool_result" or b.get("tool_use_id") in seen_ids
299
+ ],
300
+ }
301
+ if msg["content"]:
302
+ # tool_results must come first in a user turn.
303
+ msg["content"].sort(key=lambda b: 0 if b.get("type") == "tool_result" else 1)
304
+ if out and out[-1]["role"] == "user":
305
+ out[-1]["content"].extend(msg["content"])
306
+ else:
307
+ out.append(msg)
308
+ return out
309
+
310
+
311
+ def responses_to_anthropic(body: dict[str, Any]) -> tuple[AnthropicRequest, ToolMap]:
312
+ """Translate a Responses request body into an :class:`AnthropicRequest`."""
313
+ tool_map = ToolMap()
314
+ tools = _convert_tools(body.get("tools"), tool_map)
315
+
316
+ system_parts: list[str] = []
317
+ instructions = body.get("instructions")
318
+ if isinstance(instructions, str) and instructions.strip():
319
+ system_parts.append(instructions)
320
+
321
+ messages: list[dict[str, Any]] = []
322
+ raw_input = body.get("input")
323
+ items: list[Any] = (
324
+ [{"type": "message", "role": "user", "content": raw_input}]
325
+ if isinstance(raw_input, str)
326
+ else list(raw_input or [])
327
+ )
328
+ for item in items:
329
+ if not isinstance(item, dict):
330
+ continue
331
+ itype = item.get("type", "message" if "role" in item else None)
332
+ if itype == "message":
333
+ role = item.get("role")
334
+ if role in ("developer", "system"):
335
+ text = _text_of(item.get("content"))
336
+ if text.strip():
337
+ system_parts.append(text)
338
+ elif role == "assistant":
339
+ text = _text_of(item.get("content"))
340
+ if text:
341
+ _append(messages, "assistant", [{"type": "text", "text": text}])
342
+ else:
343
+ _append(messages, "user", _user_blocks(item.get("content")))
344
+ elif itype in ("function_call", "custom_tool_call"):
345
+ call_id = item.get("call_id")
346
+ name = item.get("name")
347
+ if not call_id or not name:
348
+ continue
349
+ ns = item.get("namespace")
350
+ if itype == "function_call":
351
+ try:
352
+ args = json.loads(item.get("arguments") or "{}")
353
+ except (TypeError, ValueError):
354
+ args = {"_raw_arguments": item.get("arguments")}
355
+ if not isinstance(args, dict):
356
+ args = {"value": args}
357
+ else:
358
+ args = {"input": item.get("input") or ""}
359
+ _append(
360
+ messages,
361
+ "assistant",
362
+ [{"type": "tool_use", "id": call_id, "name": tool_map.flat_for(name, ns), "input": args}],
363
+ )
364
+ elif itype in ("function_call_output", "custom_tool_call_output"):
365
+ call_id = item.get("call_id")
366
+ if not call_id:
367
+ continue
368
+ _append(
369
+ messages,
370
+ "user",
371
+ [{"type": "tool_result", "tool_use_id": call_id, "content": _tool_output_content(item.get("output"))}],
372
+ )
373
+ # reasoning / hosted-tool / compaction items: not representable for a
374
+ # non-OpenAI backend — dropped.
375
+
376
+ messages = _repair_tool_pairing(messages)
377
+ if not messages or messages[0]["role"] != "user":
378
+ messages.insert(0, {"role": "user", "content": [{"type": "text", "text": "(continue)"}]})
379
+
380
+ payload: dict[str, Any] = {
381
+ "model": body.get("model"),
382
+ "max_tokens": int(body.get("max_output_tokens") or DEFAULT_MAX_OUTPUT_TOKENS),
383
+ "messages": messages,
384
+ "stream": bool(body.get("stream", False)),
385
+ }
386
+ if system_parts:
387
+ payload["system"] = "\n\n".join(system_parts)
388
+ if tools:
389
+ payload["tools"] = tools
390
+ for key in ("temperature", "top_p"):
391
+ if body.get(key) is not None:
392
+ payload[key] = body[key]
393
+ if isinstance(body.get("profile"), str):
394
+ payload["profile"] = body["profile"]
395
+ return AnthropicRequest.model_validate(payload), tool_map
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # Anthropic output → Responses items
400
+ # ---------------------------------------------------------------------------
401
+
402
+
403
+ def _tool_item(block_id: str, flat_name: str, args: Any, tool_map: ToolMap) -> dict[str, Any]:
404
+ ref = tool_map.lookup(flat_name)
405
+ if ref.kind == "custom":
406
+ raw: str | None = None
407
+ if isinstance(args, dict):
408
+ if isinstance(args.get("input"), str):
409
+ raw = args["input"]
410
+ else:
411
+ # A repaired / local call may name the field differently
412
+ # ({"patch": "..."}); a lone string value is the input.
413
+ strings = [v for v in args.values() if isinstance(v, str)]
414
+ if len(args) == 1 and len(strings) == 1:
415
+ raw = strings[0]
416
+ elif isinstance(args, str):
417
+ raw = args
418
+ item: dict[str, Any] = {
419
+ "type": "custom_tool_call",
420
+ "id": _new_id("ctc"),
421
+ "call_id": block_id,
422
+ "name": ref.name,
423
+ "input": raw if raw is not None else json.dumps(args, ensure_ascii=False),
424
+ "status": "completed",
425
+ }
426
+ else:
427
+ item = {
428
+ "type": "function_call",
429
+ "id": _new_id("fc"),
430
+ "call_id": block_id,
431
+ "name": ref.name,
432
+ "arguments": json.dumps(args if args is not None else {}, ensure_ascii=False),
433
+ "status": "completed",
434
+ }
435
+ if ref.namespace:
436
+ item["namespace"] = ref.namespace
437
+ return item
438
+
439
+
440
+ def _usage(
441
+ input_tokens: int, output_tokens: int, cached: int, cache_write: int = 0
442
+ ) -> dict[str, Any]:
443
+ """OpenAI-style usage from Anthropic counts.
444
+
445
+ Anthropic's ``input_tokens`` excludes cache reads and writes; OpenAI's
446
+ includes them, and Codex uses ``total_tokens`` as context-window usage
447
+ to decide when to compact. So the cache counts are added back in.
448
+ """
449
+ input_tokens = input_tokens + cached + cache_write
450
+ details: dict[str, Any] = {"cached_tokens": cached}
451
+ if cache_write:
452
+ details["cache_write_tokens"] = cache_write
453
+ return {
454
+ "input_tokens": input_tokens,
455
+ "input_tokens_details": details,
456
+ "output_tokens": output_tokens,
457
+ "output_tokens_details": {"reasoning_tokens": 0},
458
+ "total_tokens": input_tokens + output_tokens,
459
+ }
460
+
461
+
462
+ def _response_obj(
463
+ resp_id: str, model: str, status: str, output: list[dict[str, Any]], usage: dict[str, Any] | None
464
+ ) -> dict[str, Any]:
465
+ obj: dict[str, Any] = {
466
+ "id": resp_id,
467
+ "object": "response",
468
+ "created_at": 0,
469
+ "status": status,
470
+ "model": model,
471
+ "output": output,
472
+ }
473
+ if usage is not None:
474
+ obj["usage"] = usage
475
+ return obj
476
+
477
+
478
+ def anthropic_response_to_responses(
479
+ resp: dict[str, Any], tool_map: ToolMap, *, model: str
480
+ ) -> dict[str, Any]:
481
+ """Non-streaming: an Anthropic response dict → a Responses ``response``."""
482
+ output: list[dict[str, Any]] = []
483
+ for block in resp.get("content") or []:
484
+ btype = block.get("type")
485
+ if btype == "text" and block.get("text"):
486
+ output.append(
487
+ {
488
+ "type": "message",
489
+ "id": _new_id("msg"),
490
+ "role": "assistant",
491
+ "status": "completed",
492
+ "content": [{"type": "output_text", "text": block["text"], "annotations": []}],
493
+ }
494
+ )
495
+ elif btype == "tool_use":
496
+ output.append(_tool_item(block.get("id") or _new_id("call"), block.get("name", ""), block.get("input"), tool_map))
497
+ usage = resp.get("usage") or {}
498
+ status = "incomplete" if resp.get("stop_reason") == "max_tokens" else "completed"
499
+ obj = _response_obj(
500
+ _new_id("resp"),
501
+ model,
502
+ status,
503
+ output,
504
+ _usage(
505
+ int(usage.get("input_tokens") or 0),
506
+ int(usage.get("output_tokens") or 0),
507
+ int(usage.get("cache_read_input_tokens") or 0),
508
+ int(usage.get("cache_creation_input_tokens") or 0),
509
+ ),
510
+ )
511
+ if status == "incomplete":
512
+ obj["incomplete_details"] = {"reason": "max_output_tokens"}
513
+ return obj
514
+
515
+
516
+ class ResponsesStreamTranslator:
517
+ """Anthropic stream events → Responses SSE events (stateful, one request)."""
518
+
519
+ def __init__(self, tool_map: ToolMap, *, model: str) -> None:
520
+ self.tool_map = tool_map
521
+ self.model = model
522
+ self.resp_id = _new_id("resp")
523
+ self.output: list[dict[str, Any]] = []
524
+ self._blocks: dict[int, dict[str, Any]] = {}
525
+ self._seq = 0
526
+ self._input_tokens = 0
527
+ self._output_tokens = 0
528
+ self._cached = 0
529
+ self._cache_write = 0
530
+ self._stop_reason: str | None = None
531
+ self._stopped = False
532
+ self.started = False
533
+
534
+ def _ev(self, etype: str, **data: Any) -> dict[str, Any]:
535
+ self._seq += 1
536
+ return {"type": etype, "sequence_number": self._seq, **data}
537
+
538
+ def start(self) -> list[dict[str, Any]]:
539
+ self.started = True
540
+ return [
541
+ self._ev(
542
+ "response.created",
543
+ response=_response_obj(self.resp_id, self.model, "in_progress", [], None),
544
+ )
545
+ ]
546
+
547
+ def feed(self, ev: AnthropicStreamEvent) -> list[dict[str, Any]]:
548
+ data = ev.data or {}
549
+ etype = ev.type
550
+ out: list[dict[str, Any]] = []
551
+ if etype == "message_start":
552
+ usage = (data.get("message") or {}).get("usage") or {}
553
+ self._input_tokens = int(usage.get("input_tokens") or 0)
554
+ self._cached = int(usage.get("cache_read_input_tokens") or 0)
555
+ self._cache_write = int(usage.get("cache_creation_input_tokens") or 0)
556
+ elif etype == "content_block_start":
557
+ idx = int(data.get("index", 0))
558
+ cb = data.get("content_block") or {}
559
+ ctype = cb.get("type")
560
+ if ctype == "text":
561
+ item = {
562
+ "type": "message",
563
+ "id": _new_id("msg"),
564
+ "role": "assistant",
565
+ "status": "in_progress",
566
+ "content": [],
567
+ }
568
+ self._blocks[idx] = {"kind": "text", "item": item, "text": "", "out_index": len(self.output)}
569
+ self.output.append(item)
570
+ out.append(self._ev("response.output_item.added", output_index=self._blocks[idx]["out_index"], item=item))
571
+ out.append(
572
+ self._ev(
573
+ "response.content_part.added",
574
+ item_id=item["id"],
575
+ output_index=self._blocks[idx]["out_index"],
576
+ content_index=0,
577
+ part={"type": "output_text", "text": "", "annotations": []},
578
+ )
579
+ )
580
+ if cb.get("text"):
581
+ out.extend(self._text_delta(idx, cb["text"]))
582
+ elif ctype == "tool_use":
583
+ self._blocks[idx] = {
584
+ "kind": "tool",
585
+ "id": cb.get("id") or _new_id("call"),
586
+ "name": cb.get("name", ""),
587
+ "json": "",
588
+ "input": cb.get("input") if cb.get("input") else None,
589
+ }
590
+ else:
591
+ self._blocks[idx] = {"kind": "skip"}
592
+ elif etype == "content_block_delta":
593
+ idx = int(data.get("index", 0))
594
+ delta = data.get("delta") or {}
595
+ block = self._blocks.get(idx)
596
+ if block is None:
597
+ return out
598
+ if block["kind"] == "text" and delta.get("type") == "text_delta":
599
+ out.extend(self._text_delta(idx, delta.get("text", "")))
600
+ elif block["kind"] == "tool" and delta.get("type") == "input_json_delta":
601
+ block["json"] += delta.get("partial_json", "")
602
+ elif etype == "content_block_stop":
603
+ idx = int(data.get("index", 0))
604
+ block = self._blocks.pop(idx, None)
605
+ if block is None:
606
+ return out
607
+ if block["kind"] == "text":
608
+ item = block["item"]
609
+ item["status"] = "completed"
610
+ item["content"] = [{"type": "output_text", "text": block["text"], "annotations": []}]
611
+ out.append(
612
+ self._ev(
613
+ "response.output_text.done",
614
+ item_id=item["id"],
615
+ output_index=block["out_index"],
616
+ content_index=0,
617
+ text=block["text"],
618
+ )
619
+ )
620
+ out.append(self._ev("response.output_item.done", output_index=block["out_index"], item=item))
621
+ elif block["kind"] == "tool":
622
+ if block["json"]:
623
+ try:
624
+ args = json.loads(block["json"])
625
+ except ValueError:
626
+ args = {"_raw_arguments": block["json"]}
627
+ else:
628
+ args = block["input"] or {}
629
+ item = _tool_item(block["id"], block["name"], args, self.tool_map)
630
+ out_index = len(self.output)
631
+ self.output.append(item)
632
+ out.append(self._ev("response.output_item.added", output_index=out_index, item=item))
633
+ out.append(self._ev("response.output_item.done", output_index=out_index, item=item))
634
+ elif etype == "message_delta":
635
+ usage = data.get("usage") or {}
636
+ if usage.get("output_tokens") is not None:
637
+ self._output_tokens = int(usage["output_tokens"])
638
+ if usage.get("input_tokens"):
639
+ self._input_tokens = int(usage["input_tokens"])
640
+ stop = (data.get("delta") or {}).get("stop_reason")
641
+ if stop:
642
+ self._stop_reason = stop
643
+ elif etype == "message_stop":
644
+ self._stopped = True
645
+ return out
646
+
647
+ def _text_delta(self, idx: int, text: str) -> list[dict[str, Any]]:
648
+ if not text:
649
+ return []
650
+ block = self._blocks[idx]
651
+ block["text"] += text
652
+ return [
653
+ self._ev(
654
+ "response.output_text.delta",
655
+ item_id=block["item"]["id"],
656
+ output_index=block["out_index"],
657
+ content_index=0,
658
+ delta=text,
659
+ )
660
+ ]
661
+
662
+ def finish(self) -> list[dict[str, Any]]:
663
+ # A stream that ended without ``message_stop``, or with a block
664
+ # still open, was cut off. Reporting it as completed would make
665
+ # Codex drop the partial item and end the turn as a success; a
666
+ # ``response.failed`` (retryable ``server_error``) makes it retry.
667
+ if not self._stopped or self._blocks:
668
+ return self.failed(
669
+ "server_error", "upstream stream ended before the message was complete"
670
+ )
671
+ usage = _usage(self._input_tokens, self._output_tokens, self._cached, self._cache_write)
672
+ if self._stop_reason == "max_tokens":
673
+ obj = _response_obj(self.resp_id, self.model, "incomplete", self.output, usage)
674
+ obj["incomplete_details"] = {"reason": "max_output_tokens"}
675
+ return [self._ev("response.incomplete", response=obj)]
676
+ obj = _response_obj(self.resp_id, self.model, "completed", self.output, usage)
677
+ return [self._ev("response.completed", response=obj)]
678
+
679
+ def failed(self, code: str, message: str) -> list[dict[str, Any]]:
680
+ obj = _response_obj(self.resp_id, self.model, "failed", self.output, None)
681
+ obj["error"] = {"code": code, "message": message}
682
+ return [self._ev("response.failed", response=obj)]
683
+
684
+
685
+ def format_sse(event: dict[str, Any]) -> str:
686
+ return f"event: {event['type']}\ndata: {json.dumps(event, ensure_ascii=False)}\n\n"