python-codex 0.1.1__py3-none-any.whl → 0.1.3__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.
Files changed (59) hide show
  1. pycodex/__init__.py +5 -1
  2. pycodex/agent.py +39 -41
  3. pycodex/cli.py +51 -43
  4. pycodex/collaboration.py +6 -7
  5. pycodex/compat.py +99 -0
  6. pycodex/context.py +87 -87
  7. pycodex/doctor.py +40 -40
  8. pycodex/model.py +69 -69
  9. pycodex/portable.py +33 -33
  10. pycodex/portable_server.py +22 -21
  11. pycodex/protocol.py +84 -86
  12. pycodex/runtime.py +36 -35
  13. pycodex/runtime_services.py +72 -69
  14. pycodex/tools/agent_tool_schemas.py +0 -2
  15. pycodex/tools/apply_patch_tool.py +43 -44
  16. pycodex/tools/base_tool.py +35 -36
  17. pycodex/tools/close_agent_tool.py +2 -4
  18. pycodex/tools/code_mode_manager.py +61 -61
  19. pycodex/tools/exec_command_tool.py +5 -6
  20. pycodex/tools/exec_runtime.js +3 -3
  21. pycodex/tools/exec_tool.py +3 -5
  22. pycodex/tools/grep_files_tool.py +10 -11
  23. pycodex/tools/list_dir_tool.py +8 -9
  24. pycodex/tools/read_file_tool.py +13 -14
  25. pycodex/tools/request_permissions_tool.py +2 -4
  26. pycodex/tools/request_user_input_tool.py +13 -14
  27. pycodex/tools/resume_agent_tool.py +2 -4
  28. pycodex/tools/send_input_tool.py +8 -9
  29. pycodex/tools/shell_command_tool.py +5 -6
  30. pycodex/tools/shell_tool.py +5 -6
  31. pycodex/tools/spawn_agent_tool.py +4 -5
  32. pycodex/tools/unified_exec_manager.py +79 -61
  33. pycodex/tools/update_plan_tool.py +4 -5
  34. pycodex/tools/view_image_tool.py +4 -5
  35. pycodex/tools/wait_agent_tool.py +2 -4
  36. pycodex/tools/wait_tool.py +4 -5
  37. pycodex/tools/web_search_tool.py +1 -3
  38. pycodex/tools/write_stdin_tool.py +4 -5
  39. pycodex/utils/dotenv.py +6 -6
  40. pycodex/utils/get_env.py +57 -34
  41. pycodex/utils/random_ids.py +1 -2
  42. pycodex/utils/visualize.py +79 -79
  43. {python_codex-0.1.1.dist-info → python_codex-0.1.3.dist-info}/METADATA +15 -9
  44. python_codex-0.1.3.dist-info/RECORD +74 -0
  45. {python_codex-0.1.1.dist-info → python_codex-0.1.3.dist-info}/WHEEL +1 -1
  46. responses_server/__init__.py +17 -0
  47. responses_server/__main__.py +5 -0
  48. responses_server/app.py +227 -0
  49. responses_server/config.py +63 -0
  50. responses_server/payload_processors.py +86 -0
  51. responses_server/server.py +63 -0
  52. responses_server/session_store.py +37 -0
  53. responses_server/stream_router.py +784 -0
  54. responses_server/tools/__init__.py +4 -0
  55. responses_server/tools/custom_adapter.py +235 -0
  56. responses_server/tools/web_search.py +263 -0
  57. python_codex-0.1.1.dist-info/RECORD +0 -62
  58. {python_codex-0.1.1.dist-info → python_codex-0.1.3.dist-info}/entry_points.txt +0 -0
  59. {python_codex-0.1.1.dist-info → python_codex-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,784 @@
1
+
2
+ import json
3
+ import ssl
4
+ import urllib.error
5
+ import urllib.request
6
+
7
+ from .config import CompatServerConfig
8
+ from .session_store import StoredResponse
9
+ from .tools import WebSearchTool, collect_custom_tool_names
10
+ from .tools.custom_adapter import (
11
+ CustomToolAdapterError,
12
+ build_output_item as build_custom_output_item,
13
+ build_tool_call as build_custom_tool_call,
14
+ build_tool_definition as build_custom_tool_definition,
15
+ )
16
+ from .tools.web_search import (
17
+ build_followup_request,
18
+ build_output_items,
19
+ build_tool_definition,
20
+ hydrate_tool_call_names,
21
+ partition_tool_calls,
22
+ )
23
+ import typing
24
+
25
+
26
+ class UnsupportedIncommingFeature(ValueError):
27
+ pass
28
+
29
+
30
+ class OutcommingChatError(RuntimeError):
31
+ pass
32
+
33
+
34
+ class StreamRouter:
35
+ def __init__(self, config: 'CompatServerConfig') -> 'None':
36
+ self._config = config
37
+ self._mock_web_search = WebSearchTool()
38
+
39
+ def _provider_capability(
40
+ self,
41
+ explicit_support: 'typing.Dict[str, bool]',
42
+ default: 'typing.Union[bool, None]' = None,
43
+ ) -> 'bool':
44
+ provider_name = str(self._config.model_provider or "").strip().lower()
45
+ if provider_name in explicit_support:
46
+ return explicit_support[provider_name]
47
+ if "vllm" in explicit_support:
48
+ return explicit_support["vllm"]
49
+ if default is not None:
50
+ return default
51
+ raise KeyError("provider capability map is missing `vllm` fallback")
52
+
53
+ def _supports_chat_reasoning(self) -> 'bool':
54
+ # Unknown providers inherit the vLLM compatibility behavior unless a
55
+ # provider is explicitly declared otherwise.
56
+ return self._provider_capability(
57
+ {
58
+ "vllm": True,
59
+ "stepfun": True,
60
+ }
61
+ )
62
+
63
+ def _supports_stream_usage(self) -> 'bool':
64
+ return self._provider_capability(
65
+ {
66
+ "vllm": True,
67
+ "stepfun": True,
68
+ }
69
+ )
70
+
71
+ def validate_incomming_request(
72
+ self,
73
+ incomming_request: 'typing.Dict[str, object]',
74
+ ) -> 'None':
75
+ model = str(incomming_request.get("model", "")).strip()
76
+ if not model:
77
+ raise UnsupportedIncommingFeature("incomming request is missing `model`")
78
+
79
+ stream = incomming_request.get("stream", True)
80
+ if stream is not True:
81
+ raise UnsupportedIncommingFeature(
82
+ "only streaming incomming `/responses` requests are supported"
83
+ )
84
+
85
+ input_items = incomming_request.get("input") or []
86
+ if not isinstance(input_items, list):
87
+ raise UnsupportedIncommingFeature("incomming `input` must be a list")
88
+
89
+ self.build_outcomming_request(incomming_request)
90
+
91
+ def collect_custom_tool_names(
92
+ self,
93
+ incomming_request: 'typing.Dict[str, object]',
94
+ ) -> 'typing.Set[str]':
95
+ return collect_custom_tool_names(incomming_request.get("tools") or [])
96
+
97
+ def list_models(self) -> 'typing.Dict[str, object]':
98
+ request = urllib.request.Request(
99
+ self._config.outcomming_models_url(),
100
+ headers=self._build_headers(accept="application/json"),
101
+ method="GET",
102
+ )
103
+ return self._request_json(request)
104
+
105
+ def build_outcomming_request(
106
+ self,
107
+ incomming_request: 'typing.Dict[str, object]',
108
+ ) -> 'typing.Dict[str, object]':
109
+ model = str(incomming_request.get("model", "")).strip()
110
+ if not model:
111
+ raise UnsupportedIncommingFeature("incomming request is missing `model`")
112
+
113
+ stream = incomming_request.get("stream", True)
114
+ if stream is not True:
115
+ raise UnsupportedIncommingFeature(
116
+ "only streaming incomming `/responses` requests are supported"
117
+ )
118
+
119
+ instructions = str(incomming_request.get("instructions", "") or "")
120
+ input_items = incomming_request.get("input") or []
121
+ if not isinstance(input_items, list):
122
+ raise UnsupportedIncommingFeature("incomming `input` must be a list")
123
+
124
+ payload: 'typing.Dict[str, object]' = {
125
+ "model": model,
126
+ "messages": self._responses_input_to_chat_messages(
127
+ instructions,
128
+ input_items,
129
+ ),
130
+ "stream": True,
131
+ }
132
+ if self._supports_stream_usage():
133
+ payload["stream_options"] = {"include_usage": True}
134
+
135
+ tools = incomming_request.get("tools") or []
136
+ if tools:
137
+ if not isinstance(tools, list):
138
+ raise UnsupportedIncommingFeature("incomming `tools` must be a list")
139
+ payload["tools"] = self._translate_tools(tools)
140
+
141
+ tool_choice = incomming_request.get("tool_choice")
142
+ if tool_choice is not None:
143
+ payload["tool_choice"] = self._translate_tool_choice(tool_choice)
144
+
145
+ parallel_tool_calls = incomming_request.get("parallel_tool_calls")
146
+ if isinstance(parallel_tool_calls, bool):
147
+ payload["parallel_tool_calls"] = parallel_tool_calls
148
+
149
+ return payload
150
+
151
+ def open_outcomming_stream(self, outcomming_request: 'typing.Dict[str, object]'):
152
+ request = urllib.request.Request(
153
+ self._config.outcomming_chat_completions_url(),
154
+ data=json.dumps(outcomming_request).encode("utf-8"),
155
+ headers=self._build_headers(accept="text/event-stream"),
156
+ method="POST",
157
+ )
158
+ try:
159
+ with urllib.request.urlopen(
160
+ request,
161
+ context=ssl.create_default_context(),
162
+ timeout=self._config.timeout_seconds,
163
+ ) as response:
164
+ for _event_name, data in self._iter_sse_events(response):
165
+ if not data:
166
+ continue
167
+ if data == "[DONE]":
168
+ break
169
+ yield json.loads(data)
170
+ except urllib.error.HTTPError as exc:
171
+ body = exc.read().decode("utf-8", errors="replace")
172
+ raise OutcommingChatError(
173
+ f"outcomming chat request failed with status {exc.code}: {body[:500]}"
174
+ ) from exc
175
+ except urllib.error.URLError as exc:
176
+ raise OutcommingChatError(
177
+ f"outcomming chat request failed: {exc.reason}"
178
+ ) from exc
179
+
180
+ def route_stream(
181
+ self,
182
+ incomming_stream,
183
+ stored_response: 'StoredResponse',
184
+ outcomming_request: 'typing.Dict[str, object]',
185
+ custom_tool_names: 'typing.Union[typing.Set[str], None]' = None,
186
+ ):
187
+ yield (
188
+ "response.created",
189
+ {
190
+ "type": "response.created",
191
+ "response": {
192
+ "id": stored_response.response_id,
193
+ "object": "response",
194
+ "status": "in_progress",
195
+ "model": stored_response.model,
196
+ },
197
+ },
198
+ )
199
+
200
+ text_parts: 'typing.List[str]' = []
201
+ reasoning_parts: 'typing.List[str]' = []
202
+ latest_usage: 'typing.Dict[str, object]' = {}
203
+ current_request = json.loads(json.dumps(outcomming_request))
204
+ current_stream = incomming_stream
205
+
206
+ while True:
207
+ tool_calls: 'typing.Dict[int, typing.Dict[str, object]]' = {}
208
+ current_usage: 'typing.Dict[str, object]' = {}
209
+ for chunk in current_stream:
210
+ for event_name, payload in self._consume_chat_chunk(
211
+ chunk,
212
+ reasoning_parts,
213
+ text_parts,
214
+ tool_calls,
215
+ current_usage,
216
+ ):
217
+ yield event_name, payload
218
+ if current_usage:
219
+ latest_usage = json.loads(json.dumps(current_usage))
220
+
221
+ hydrate_tool_call_names(tool_calls, current_request)
222
+ mock_search_calls, ordinary_tool_calls = partition_tool_calls(
223
+ self._mock_web_search,
224
+ tool_calls,
225
+ current_request,
226
+ )
227
+ if mock_search_calls and not ordinary_tool_calls:
228
+ for item in build_output_items(mock_search_calls):
229
+ yield (
230
+ "response.output_item.done",
231
+ {
232
+ "type": "response.output_item.done",
233
+ "item": item,
234
+ },
235
+ )
236
+ try:
237
+ current_request = build_followup_request(
238
+ self._mock_web_search,
239
+ current_request,
240
+ mock_search_calls,
241
+ reasoning_text=(
242
+ "".join(reasoning_parts)
243
+ if self._supports_chat_reasoning()
244
+ else None
245
+ ),
246
+ )
247
+ except ValueError as exc:
248
+ raise OutcommingChatError(str(exc)) from exc
249
+ current_stream = self.open_outcomming_stream(current_request)
250
+ continue
251
+
252
+ for item in self._build_output_items(
253
+ reasoning_parts,
254
+ text_parts,
255
+ ordinary_tool_calls,
256
+ custom_tool_names or set(),
257
+ ):
258
+ yield (
259
+ "response.output_item.done",
260
+ {
261
+ "type": "response.output_item.done",
262
+ "item": item,
263
+ },
264
+ )
265
+ for item in build_output_items(mock_search_calls):
266
+ yield (
267
+ "response.output_item.done",
268
+ {
269
+ "type": "response.output_item.done",
270
+ "item": item,
271
+ },
272
+ )
273
+ break
274
+
275
+ yield (
276
+ "response.completed",
277
+ {
278
+ "type": "response.completed",
279
+ "response": {
280
+ "id": stored_response.response_id,
281
+ "output": [],
282
+ **(
283
+ {"usage": json.loads(json.dumps(latest_usage))}
284
+ if latest_usage
285
+ else {}
286
+ ),
287
+ },
288
+ },
289
+ )
290
+
291
+ def _responses_input_to_chat_messages(
292
+ self,
293
+ instructions: 'str',
294
+ input_items: 'typing.List[object]',
295
+ ) -> 'typing.List[typing.Dict[str, object]]':
296
+ messages: 'typing.List[typing.Dict[str, object]]' = []
297
+ if instructions:
298
+ messages.append({"role": "developer", "content": instructions})
299
+
300
+ pending_assistant: 'typing.Union[typing.Dict[str, object], None]' = None
301
+
302
+ def flush_pending_assistant() -> 'None':
303
+ nonlocal pending_assistant
304
+ if pending_assistant is None:
305
+ return
306
+ if (
307
+ "content" not in pending_assistant
308
+ and "reasoning" not in pending_assistant
309
+ and "tool_calls" not in pending_assistant
310
+ ):
311
+ pending_assistant = None
312
+ return
313
+ messages.append(pending_assistant)
314
+ pending_assistant = None
315
+
316
+ for raw_item in input_items:
317
+ if not isinstance(raw_item, dict):
318
+ raise UnsupportedIncommingFeature(
319
+ "all incomming `input` items must be objects"
320
+ )
321
+ item_type = raw_item.get("type")
322
+
323
+ if item_type == "message":
324
+ role = str(raw_item.get("role", "")).strip()
325
+ if role not in {"developer", "user", "assistant", "system"}:
326
+ raise UnsupportedIncommingFeature(
327
+ f"unsupported incomming message role: {role or '<empty>'}"
328
+ )
329
+ text = self._coalesce_content_text(raw_item.get("content"))
330
+ if role == "assistant":
331
+ if pending_assistant is None:
332
+ pending_assistant = {"role": "assistant"}
333
+ if text:
334
+ pending_assistant["content"] = (
335
+ str(pending_assistant.get("content", "")) + text
336
+ )
337
+ continue
338
+ flush_pending_assistant()
339
+ messages.append({"role": role, "content": text})
340
+ continue
341
+
342
+ if item_type == "reasoning":
343
+ if not self._supports_chat_reasoning():
344
+ raise UnsupportedIncommingFeature(
345
+ "incomming `reasoning` items are not supported by this chat backend"
346
+ )
347
+ if pending_assistant is None:
348
+ pending_assistant = {"role": "assistant"}
349
+ reasoning_text = self._coalesce_reasoning_text(raw_item)
350
+ if reasoning_text:
351
+ pending_assistant["reasoning"] = (
352
+ str(pending_assistant.get("reasoning", "")) + reasoning_text
353
+ )
354
+ continue
355
+
356
+ if item_type == "function_call":
357
+ if pending_assistant is None:
358
+ pending_assistant = {"role": "assistant"}
359
+ tool_calls = pending_assistant.setdefault("tool_calls", [])
360
+ if not isinstance(tool_calls, list):
361
+ raise UnsupportedIncommingFeature(
362
+ "assistant tool calls must be a list"
363
+ )
364
+ tool_calls.append(
365
+ {
366
+ "id": str(raw_item.get("call_id", "")).strip(),
367
+ "type": "function",
368
+ "function": {
369
+ "name": str(raw_item.get("name", "")).strip(),
370
+ "arguments": str(raw_item.get("arguments", "") or "{}"),
371
+ },
372
+ }
373
+ )
374
+ continue
375
+
376
+ if item_type == "function_call_output":
377
+ flush_pending_assistant()
378
+ messages.append(
379
+ {
380
+ "role": "tool",
381
+ "tool_call_id": str(raw_item.get("call_id", "")).strip(),
382
+ "content": self._coalesce_tool_output_text(
383
+ raw_item.get("output")
384
+ ),
385
+ }
386
+ )
387
+ continue
388
+
389
+ if item_type == "custom_tool_call":
390
+ if pending_assistant is None:
391
+ pending_assistant = {"role": "assistant"}
392
+ tool_calls = pending_assistant.setdefault("tool_calls", [])
393
+ if not isinstance(tool_calls, list):
394
+ raise UnsupportedIncommingFeature(
395
+ "assistant tool calls must be a list"
396
+ )
397
+ try:
398
+ tool_calls.append(build_custom_tool_call(raw_item))
399
+ except CustomToolAdapterError as exc:
400
+ raise UnsupportedIncommingFeature(str(exc)) from exc
401
+ continue
402
+
403
+ if item_type == "custom_tool_call_output":
404
+ flush_pending_assistant()
405
+ messages.append(
406
+ {
407
+ "role": "tool",
408
+ "tool_call_id": str(raw_item.get("call_id", "")).strip(),
409
+ "content": self._coalesce_tool_output_text(
410
+ raw_item.get("output")
411
+ ),
412
+ }
413
+ )
414
+ continue
415
+
416
+ raise UnsupportedIncommingFeature(
417
+ f"unsupported incomming input item type: {item_type!r}"
418
+ )
419
+
420
+ flush_pending_assistant()
421
+ return messages
422
+
423
+ def _coalesce_content_text(self, raw_content: 'object') -> 'str':
424
+ if raw_content is None:
425
+ return ""
426
+ if isinstance(raw_content, str):
427
+ return raw_content
428
+ if not isinstance(raw_content, list):
429
+ raise UnsupportedIncommingFeature(
430
+ "message `content` must be a list or string"
431
+ )
432
+
433
+ text_parts: 'typing.List[str]' = []
434
+ for part in raw_content:
435
+ if not isinstance(part, dict):
436
+ raise UnsupportedIncommingFeature(
437
+ "message content parts must be objects"
438
+ )
439
+ part_type = part.get("type")
440
+ if part_type in {"input_text", "output_text"}:
441
+ text_parts.append(str(part.get("text", "")))
442
+ continue
443
+ raise UnsupportedIncommingFeature(
444
+ f"content part type `{part_type}` is not yet supported by the chat backend"
445
+ )
446
+ return "".join(text_parts)
447
+
448
+ def _coalesce_tool_output_text(self, raw_output: 'object') -> 'str':
449
+ if isinstance(raw_output, str):
450
+ return raw_output
451
+ if isinstance(raw_output, list):
452
+ return self._coalesce_content_text(raw_output)
453
+ return json.dumps(raw_output, ensure_ascii=False)
454
+
455
+ def _coalesce_reasoning_text(self, raw_item: 'typing.Dict[str, object]') -> 'str':
456
+ content = raw_item.get("content")
457
+ if isinstance(content, list):
458
+ text_parts: 'typing.List[str]' = []
459
+ for part in content:
460
+ if not isinstance(part, dict):
461
+ continue
462
+ part_type = part.get("type")
463
+ if part_type in {"reasoning_text", "summary_text"}:
464
+ text_parts.append(str(part.get("text", "")))
465
+ if text_parts:
466
+ return "".join(text_parts)
467
+
468
+ summary = raw_item.get("summary")
469
+ if isinstance(summary, list):
470
+ text_parts = []
471
+ for part in summary:
472
+ if not isinstance(part, dict):
473
+ continue
474
+ if part.get("type") == "summary_text":
475
+ text_parts.append(str(part.get("text", "")))
476
+ if text_parts:
477
+ return "".join(text_parts)
478
+
479
+ for key in ("reasoning", "reasoning_content", "text"):
480
+ value = raw_item.get(key)
481
+ if isinstance(value, str) and value:
482
+ return value
483
+ return ""
484
+
485
+ def _translate_tools(self, incomming_tools: 'typing.List[object]') -> 'typing.List[typing.Dict[str, object]]':
486
+ translated: 'typing.List[typing.Dict[str, object]]' = []
487
+ for raw_tool in incomming_tools:
488
+ if not isinstance(raw_tool, dict):
489
+ raise UnsupportedIncommingFeature("tool definitions must be objects")
490
+ tool_type = raw_tool.get("type")
491
+ if tool_type == "function":
492
+ name = str(raw_tool.get("name", "")).strip()
493
+ translated.append(
494
+ {
495
+ "type": "function",
496
+ "name": name,
497
+ "function": {
498
+ "name": name,
499
+ "description": str(raw_tool.get("description", "") or ""),
500
+ "parameters": raw_tool.get("parameters")
501
+ or {"type": "object"},
502
+ "strict": bool(raw_tool.get("strict", False)),
503
+ },
504
+ }
505
+ )
506
+ continue
507
+ if tool_type == "web_search":
508
+ translated.append(build_tool_definition(self._mock_web_search))
509
+ continue
510
+ if tool_type == "custom":
511
+ try:
512
+ translated.append(build_custom_tool_definition(raw_tool))
513
+ except CustomToolAdapterError as exc:
514
+ raise UnsupportedIncommingFeature(str(exc)) from exc
515
+ continue
516
+ raise UnsupportedIncommingFeature(
517
+ f"unsupported incomming tool type: {tool_type!r}"
518
+ )
519
+ return translated
520
+
521
+ def _translate_tool_choice(self, raw_tool_choice: 'object') -> 'object':
522
+ if isinstance(raw_tool_choice, str):
523
+ return raw_tool_choice
524
+ if not isinstance(raw_tool_choice, dict):
525
+ raise UnsupportedIncommingFeature("unsupported `tool_choice` shape")
526
+
527
+ choice_type = raw_tool_choice.get("type")
528
+ if choice_type in {"function", "custom"}:
529
+ name = raw_tool_choice.get("name")
530
+ if not isinstance(name, str) or not name.strip():
531
+ raise UnsupportedIncommingFeature(
532
+ f"{choice_type} tool_choice is missing `name`"
533
+ )
534
+ return {
535
+ "type": "function",
536
+ "function": {"name": name},
537
+ }
538
+ raise UnsupportedIncommingFeature(
539
+ f"unsupported incomming tool_choice type: {choice_type!r}"
540
+ )
541
+
542
+ def _consume_chat_chunk(
543
+ self,
544
+ payload: 'typing.Dict[str, object]',
545
+ reasoning_parts: 'typing.List[str]',
546
+ text_parts: 'typing.List[str]',
547
+ tool_calls: 'typing.Dict[int, typing.Dict[str, object]]',
548
+ current_usage: 'typing.Dict[str, object]',
549
+ ) -> 'typing.List[typing.Tuple[str, typing.Dict[str, object]]]':
550
+ events: 'typing.List[typing.Tuple[str, typing.Dict[str, object]]]' = []
551
+ usage = payload.get("usage")
552
+ if isinstance(usage, dict):
553
+ self._capture_usage_snapshot(current_usage, usage)
554
+
555
+ choices = payload.get("choices") or []
556
+ if not isinstance(choices, list):
557
+ return events
558
+
559
+ for choice in choices:
560
+ if not isinstance(choice, dict):
561
+ continue
562
+ delta = choice.get("delta") or {}
563
+ if not isinstance(delta, dict):
564
+ continue
565
+
566
+ reasoning = delta.get("reasoning")
567
+ if isinstance(reasoning, str) and reasoning:
568
+ reasoning_parts.append(reasoning)
569
+
570
+ reasoning_content = delta.get("reasoning_content")
571
+ if isinstance(reasoning_content, str) and reasoning_content:
572
+ reasoning_parts.append(reasoning_content)
573
+
574
+ content = delta.get("content")
575
+ if isinstance(content, str) and content:
576
+ text_parts.append(content)
577
+ events.append(
578
+ (
579
+ "response.output_text.delta",
580
+ {
581
+ "type": "response.output_text.delta",
582
+ "delta": content,
583
+ },
584
+ )
585
+ )
586
+
587
+ raw_tool_calls = delta.get("tool_calls") or []
588
+ if not isinstance(raw_tool_calls, list):
589
+ continue
590
+ for raw_tool_call in raw_tool_calls:
591
+ if not isinstance(raw_tool_call, dict):
592
+ continue
593
+ index = int(raw_tool_call.get("index", len(tool_calls)))
594
+ state = tool_calls.setdefault(
595
+ index,
596
+ {
597
+ "id": "",
598
+ "type": "function",
599
+ "function": {
600
+ "name": "",
601
+ "arguments": "",
602
+ },
603
+ },
604
+ )
605
+ tool_call_id = raw_tool_call.get("id")
606
+ if isinstance(tool_call_id, str) and tool_call_id:
607
+ state["id"] = tool_call_id
608
+ tool_type = raw_tool_call.get("type")
609
+ if isinstance(tool_type, str) and tool_type:
610
+ state["type"] = tool_type
611
+ function = raw_tool_call.get("function") or {}
612
+ if not isinstance(function, dict):
613
+ continue
614
+ state_function = state["function"]
615
+ if not isinstance(state_function, dict):
616
+ continue
617
+ name = function.get("name")
618
+ if isinstance(name, str) and name:
619
+ state_function["name"] = name
620
+ arguments = function.get("arguments")
621
+ if isinstance(arguments, str) and arguments:
622
+ state_function["arguments"] = (
623
+ str(state_function.get("arguments", "")) + arguments
624
+ )
625
+
626
+ return events
627
+
628
+ def _capture_usage_snapshot(
629
+ self,
630
+ current_usage: 'typing.Dict[str, object]',
631
+ usage: 'typing.Dict[str, object]',
632
+ ) -> 'None':
633
+ scalar_mappings = (
634
+ ("input_tokens", usage.get("input_tokens", usage.get("prompt_tokens"))),
635
+ (
636
+ "output_tokens",
637
+ usage.get("output_tokens", usage.get("completion_tokens")),
638
+ ),
639
+ ("total_tokens", usage.get("total_tokens")),
640
+ )
641
+ for key, value in scalar_mappings:
642
+ if isinstance(value, int):
643
+ current_usage[key] = value
644
+
645
+ detail_mappings = (
646
+ (
647
+ "input_tokens_details",
648
+ usage.get("input_tokens_details", usage.get("prompt_tokens_details")),
649
+ ),
650
+ (
651
+ "output_tokens_details",
652
+ usage.get(
653
+ "output_tokens_details",
654
+ usage.get("completion_tokens_details"),
655
+ ),
656
+ ),
657
+ )
658
+ for key, value in detail_mappings:
659
+ if isinstance(value, dict):
660
+ current_usage[key] = json.loads(json.dumps(value))
661
+
662
+ def _build_output_items(
663
+ self,
664
+ reasoning_parts: 'typing.List[str]',
665
+ text_parts: 'typing.List[str]',
666
+ tool_calls: 'typing.Dict[int, typing.Dict[str, object]]',
667
+ custom_tool_names: 'typing.Set[str]',
668
+ ) -> 'typing.List[typing.Dict[str, object]]':
669
+ items: 'typing.List[typing.Dict[str, object]]' = []
670
+ reasoning_text = "".join(reasoning_parts)
671
+ if reasoning_text:
672
+ items.append(
673
+ {
674
+ "type": "reasoning",
675
+ "summary": [],
676
+ "content": [
677
+ {
678
+ "type": "reasoning_text",
679
+ "text": reasoning_text,
680
+ }
681
+ ],
682
+ }
683
+ )
684
+ text = "".join(text_parts)
685
+ if text:
686
+ items.append(
687
+ {
688
+ "type": "message",
689
+ "role": "assistant",
690
+ "content": [
691
+ {
692
+ "type": "output_text",
693
+ "text": text,
694
+ }
695
+ ],
696
+ }
697
+ )
698
+
699
+ for index in sorted(tool_calls):
700
+ tool_call = tool_calls[index]
701
+ if tool_call.get("type") != "function":
702
+ raise OutcommingChatError(
703
+ f"unsupported outcomming tool call type: {tool_call.get('type')!r}"
704
+ )
705
+ function = tool_call.get("function") or {}
706
+ if not isinstance(function, dict):
707
+ raise OutcommingChatError(
708
+ "outcomming tool call is missing function payload"
709
+ )
710
+ name = str(function.get("name", "")).strip()
711
+ if not name:
712
+ raise OutcommingChatError(
713
+ "outcomming function tool call is missing `name`"
714
+ )
715
+ arguments = str(function.get("arguments", "") or "{}")
716
+ if name in custom_tool_names:
717
+ try:
718
+ items.append(build_custom_output_item(tool_call, index))
719
+ except CustomToolAdapterError as exc:
720
+ raise OutcommingChatError(str(exc)) from exc
721
+ continue
722
+ items.append(
723
+ {
724
+ "type": "function_call",
725
+ "call_id": str(tool_call.get("id", "")).strip()
726
+ or f"call_{index}",
727
+ "name": name,
728
+ "arguments": arguments,
729
+ }
730
+ )
731
+
732
+ return items
733
+
734
+ def _request_json(self, request: 'urllib.request.Request') -> 'typing.Dict[str, object]':
735
+ try:
736
+ with urllib.request.urlopen(
737
+ request,
738
+ context=ssl.create_default_context(),
739
+ timeout=self._config.timeout_seconds,
740
+ ) as response:
741
+ return json.loads(response.read().decode("utf-8"))
742
+ except urllib.error.HTTPError as exc:
743
+ body = exc.read().decode("utf-8", errors="replace")
744
+ raise OutcommingChatError(
745
+ f"outcomming request failed with status {exc.code}: {body[:500]}"
746
+ ) from exc
747
+ except urllib.error.URLError as exc:
748
+ raise OutcommingChatError(
749
+ f"outcomming request failed: {exc.reason}"
750
+ ) from exc
751
+
752
+ def _build_headers(self, accept: 'str') -> 'typing.Dict[str, str]':
753
+ headers = {
754
+ "Accept": accept,
755
+ "Content-Type": "application/json",
756
+ }
757
+ api_key = self._config.outcomming_api_key()
758
+ if api_key is not None:
759
+ headers["Authorization"] = f"Bearer {api_key}"
760
+ return headers
761
+
762
+ def _iter_sse_events(self, response):
763
+ event_name: 'typing.Union[str, None]' = None
764
+ data_lines: 'typing.List[str]' = []
765
+
766
+ for raw_line in response:
767
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
768
+ if line == "":
769
+ if data_lines:
770
+ yield event_name or "message", "\n".join(data_lines)
771
+ event_name = None
772
+ data_lines = []
773
+ continue
774
+
775
+ if line.startswith(":"):
776
+ continue
777
+ if line.startswith("event:"):
778
+ event_name = line.split(":", 1)[1].lstrip()
779
+ continue
780
+ if line.startswith("data:"):
781
+ data_lines.append(line.split(":", 1)[1].lstrip())
782
+
783
+ if data_lines:
784
+ yield event_name or "message", "\n".join(data_lines)