python-codex 0.2.6__py3-none-any.whl → 0.3.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.
Files changed (84) hide show
  1. pycodex/__init__.py +18 -14
  2. pycodex/agent.py +468 -462
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -436
  5. pycodex/compat.py +19 -5
  6. pycodex/context.py +222 -212
  7. pycodex/doctor.py +52 -48
  8. pycodex/events.py +857 -0
  9. pycodex/feishu_card.py +217 -163
  10. pycodex/feishu_link.py +43 -83
  11. pycodex/model.py +329 -252
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +90 -52
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +235 -803
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -174
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +4 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +201 -0
  23. pycodex/tools/close_agent_tool.py +2 -2
  24. pycodex/tools/code_mode_manager.py +77 -64
  25. pycodex/tools/exec_command_tool.py +26 -11
  26. pycodex/tools/exec_tool.py +4 -4
  27. pycodex/tools/grep_files_tool.py +12 -10
  28. pycodex/tools/ipython_tool.py +10 -13
  29. pycodex/tools/list_dir_tool.py +13 -9
  30. pycodex/tools/read_file_tool.py +29 -17
  31. pycodex/tools/request_permissions_tool.py +15 -5
  32. pycodex/tools/request_user_input_tool.py +13 -104
  33. pycodex/tools/resume_agent_tool.py +2 -2
  34. pycodex/tools/send_input_tool.py +11 -8
  35. pycodex/tools/shell_command_tool.py +7 -5
  36. pycodex/tools/shell_tool.py +7 -5
  37. pycodex/tools/spawn_agent_tool.py +7 -4
  38. pycodex/tools/unified_exec_manager.py +102 -69
  39. pycodex/tools/update_plan_tool.py +8 -5
  40. pycodex/tools/view_image_tool.py +13 -13
  41. pycodex/tools/wait_agent_tool.py +27 -4
  42. pycodex/tools/wait_tool.py +5 -4
  43. pycodex/tools/web_search_tool.py +4 -2
  44. pycodex/tools/write_stdin_tool.py +12 -11
  45. pycodex/utils/__init__.py +2 -17
  46. pycodex/utils/compactor.py +50 -66
  47. pycodex/utils/debug.py +2 -2
  48. pycodex/utils/dotenv.py +6 -7
  49. pycodex/utils/event_helpers.py +190 -0
  50. pycodex/utils/get_env.py +27 -70
  51. pycodex/utils/image_utils.py +76 -0
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +263 -161
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.0.dist-info/METADATA +704 -0
  56. python_codex-0.3.0.dist-info/RECORD +90 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +36 -31
  60. responses_server/config.py +25 -22
  61. responses_server/messages_api.py +96 -49
  62. responses_server/payload_processors.py +25 -19
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +196 -107
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +51 -13
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +470 -384
  71. workspace_server/workspace.html +859 -232
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +168 -100
  74. pycodex/collaboration.py +0 -20
  75. pycodex/interactive_session.py +0 -415
  76. pycodex/prompts/collaboration_default.md +0 -11
  77. pycodex/prompts/collaboration_plan.md +0 -128
  78. pycodex/utils/toolcall_visualize.py +0 -713
  79. pycodex/utils/visualize.py +0 -553
  80. python_codex-0.2.6.dist-info/METADATA +0 -441
  81. python_codex-0.2.6.dist-info/RECORD +0 -91
  82. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,25 +1,20 @@
1
-
2
- import json
3
1
  import http.client
2
+ import json
4
3
  import ssl
4
+ import typing
5
5
  import urllib.error
6
6
  import urllib.request
7
7
 
8
8
  from .config import CompatServerConfig
9
- from .messages_api import (
10
- MessagesAPIAdapterError,
11
- build_messages_request,
12
- iter_chat_chunks as iter_chat_chunks_from_messages,
13
- saw_message_stop as messages_saw_message_stop,
14
- )
9
+ from .messages_api import MessagesAPIAdapterError, build_messages_request
10
+ from .messages_api import iter_chat_chunks as iter_chat_chunks_from_messages
11
+ from .messages_api import saw_message_stop as messages_saw_message_stop
15
12
  from .session_store import StoredResponse
16
13
  from .tools import WebSearchTool, collect_custom_tool_names
17
- from .tools.custom_adapter import (
18
- CustomToolAdapterError,
19
- build_output_item as build_custom_output_item,
20
- build_tool_call as build_custom_tool_call,
21
- build_tool_definition as build_custom_tool_definition,
22
- )
14
+ from .tools.custom_adapter import CustomToolAdapterError
15
+ from .tools.custom_adapter import build_output_item as build_custom_output_item
16
+ from .tools.custom_adapter import build_tool_call as build_custom_tool_call
17
+ from .tools.custom_adapter import build_tool_definition as build_custom_tool_definition
23
18
  from .tools.web_search import (
24
19
  build_followup_request,
25
20
  build_output_items,
@@ -28,7 +23,6 @@ from .tools.web_search import (
28
23
  partition_tool_calls,
29
24
  )
30
25
  from .trajectory_dump import TrajectoryDumpWriter
31
- import typing
32
26
 
33
27
 
34
28
  class UnsupportedIncommingFeature(ValueError):
@@ -38,23 +32,23 @@ class UnsupportedIncommingFeature(ValueError):
38
32
  class OutcommingChatError(RuntimeError):
39
33
  def __init__(
40
34
  self,
41
- message: 'str',
42
- error_type: 'typing.Union[str, None]' = None,
43
- ) -> 'None':
35
+ message: "str",
36
+ error_type: "typing.Union[str, None]" = None,
37
+ ) -> "None":
44
38
  super().__init__(message)
45
39
  self.error_type = error_type
46
40
 
47
41
 
48
42
  class StreamRouter:
49
- def __init__(self, config: 'CompatServerConfig') -> 'None':
43
+ def __init__(self, config: "CompatServerConfig") -> "None":
50
44
  self._config = config
51
45
  self._mock_web_search = WebSearchTool()
52
46
 
53
47
  def _provider_capability(
54
48
  self,
55
- explicit_support: 'typing.Dict[str, bool]',
56
- default: 'typing.Union[bool, None]' = None,
57
- ) -> 'bool':
49
+ explicit_support: "typing.Dict[str, bool]",
50
+ default: "typing.Union[bool, None]" = None,
51
+ ) -> "bool":
58
52
  provider_name = str(self._config.model_provider or "").strip().lower()
59
53
  if provider_name in explicit_support:
60
54
  return explicit_support[provider_name]
@@ -64,7 +58,7 @@ class StreamRouter:
64
58
  return default
65
59
  raise KeyError("provider capability map is missing `vllm` fallback")
66
60
 
67
- def _supports_chat_reasoning(self) -> 'bool':
61
+ def _supports_chat_reasoning(self) -> "bool":
68
62
  # Unknown providers inherit the vLLM compatibility behavior unless a
69
63
  # provider is explicitly declared otherwise.
70
64
  return self._provider_capability(
@@ -74,7 +68,7 @@ class StreamRouter:
74
68
  }
75
69
  )
76
70
 
77
- def _supports_stream_usage(self) -> 'bool':
71
+ def _supports_stream_usage(self) -> "bool":
78
72
  return self._provider_capability(
79
73
  {
80
74
  "vllm": True,
@@ -84,8 +78,8 @@ class StreamRouter:
84
78
 
85
79
  def validate_incomming_request(
86
80
  self,
87
- incomming_request: 'typing.Dict[str, object]',
88
- ) -> 'None':
81
+ incomming_request: "typing.Dict[str, object]",
82
+ ) -> "None":
89
83
  model = str(incomming_request.get("model", "")).strip()
90
84
  if not model:
91
85
  raise UnsupportedIncommingFeature("incomming request is missing `model`")
@@ -104,11 +98,11 @@ class StreamRouter:
104
98
 
105
99
  def collect_custom_tool_names(
106
100
  self,
107
- incomming_request: 'typing.Dict[str, object]',
108
- ) -> 'typing.Set[str]':
101
+ incomming_request: "typing.Dict[str, object]",
102
+ ) -> "typing.Set[str]":
109
103
  return collect_custom_tool_names(incomming_request.get("tools") or [])
110
104
 
111
- def list_models(self) -> 'typing.Dict[str, object]':
105
+ def list_models(self) -> "typing.Dict[str, object]":
112
106
  request = urllib.request.Request(
113
107
  self._config.outcomming_models_url(),
114
108
  headers=self._build_headers(accept="application/json"),
@@ -118,8 +112,8 @@ class StreamRouter:
118
112
 
119
113
  def build_outcomming_request(
120
114
  self,
121
- incomming_request: 'typing.Dict[str, object]',
122
- ) -> 'typing.Dict[str, object]':
115
+ incomming_request: "typing.Dict[str, object]",
116
+ ) -> "typing.Dict[str, object]":
123
117
  model = str(incomming_request.get("model", "")).strip()
124
118
  if not model:
125
119
  raise UnsupportedIncommingFeature("incomming request is missing `model`")
@@ -135,7 +129,7 @@ class StreamRouter:
135
129
  if not isinstance(input_items, list):
136
130
  raise UnsupportedIncommingFeature("incomming `input` must be a list")
137
131
 
138
- payload: 'typing.Dict[str, object]' = {
132
+ payload: "typing.Dict[str, object]" = {
139
133
  "model": model,
140
134
  "messages": self._responses_input_to_chat_messages(
141
135
  instructions,
@@ -143,6 +137,17 @@ class StreamRouter:
143
137
  ),
144
138
  "stream": True,
145
139
  }
140
+ reasoning = incomming_request.get("reasoning")
141
+ if isinstance(reasoning, dict):
142
+ reasoning_effort = reasoning.get("effort")
143
+ if isinstance(reasoning_effort, str) and reasoning_effort:
144
+ # vLLM's Responses renderer consumes effort as a template
145
+ # kwarg; keeping it out of the chat schema also preserves
146
+ # provider-specific values such as `max`.
147
+ payload["chat_template_kwargs"] = {
148
+ "reasoning_effort": reasoning_effort,
149
+ }
150
+
146
151
  max_tokens = self._coerce_positive_int(
147
152
  incomming_request.get("max_output_tokens")
148
153
  )
@@ -169,7 +174,7 @@ class StreamRouter:
169
174
 
170
175
  return payload
171
176
 
172
- def open_outcomming_stream(self, outcomming_request: 'typing.Dict[str, object]'):
177
+ def open_outcomming_stream(self, outcomming_request: "typing.Dict[str, object]"):
173
178
  outcomming_api = self._config.normalized_outcomming_api()
174
179
  if outcomming_api == "messages":
175
180
  return self._open_outcomming_messages_stream(outcomming_request)
@@ -181,7 +186,7 @@ class StreamRouter:
181
186
 
182
187
  def _open_outcomming_chat_stream(
183
188
  self,
184
- outcomming_request: 'typing.Dict[str, object]',
189
+ outcomming_request: "typing.Dict[str, object]",
185
190
  ):
186
191
  request = urllib.request.Request(
187
192
  self._config.outcomming_chat_completions_url(),
@@ -203,7 +208,13 @@ class StreamRouter:
203
208
  if data == "[DONE]":
204
209
  saw_done = True
205
210
  break
206
- yield json.loads(data)
211
+ payload = json.loads(data)
212
+ if isinstance(payload, dict) and payload.get("error") is not None:
213
+ raise OutcommingChatError(
214
+ "outcomming chat stream returned an error: "
215
+ + json.dumps(payload["error"], ensure_ascii=False)[:500]
216
+ )
217
+ yield payload
207
218
  if not saw_done:
208
219
  raise OutcommingChatError(
209
220
  "outcomming chat stream ended before [DONE]"
@@ -224,6 +235,11 @@ class StreamRouter:
224
235
  raise OutcommingChatError(
225
236
  f"outcomming chat request failed with status {exc.code}: {body[:500]}"
226
237
  ) from exc
238
+ except TimeoutError as exc:
239
+ raise OutcommingChatError(
240
+ "outcomming chat request timed out after "
241
+ f"{self._config.timeout_seconds:g}s"
242
+ ) from exc
227
243
  except urllib.error.URLError as exc:
228
244
  raise OutcommingChatError(
229
245
  f"outcomming chat request failed: {exc.reason}"
@@ -231,7 +247,7 @@ class StreamRouter:
231
247
 
232
248
  def _open_outcomming_messages_stream(
233
249
  self,
234
- outcomming_request: 'typing.Dict[str, object]',
250
+ outcomming_request: "typing.Dict[str, object]",
235
251
  ):
236
252
  try:
237
253
  messages_request = build_messages_request(outcomming_request)
@@ -251,7 +267,7 @@ class StreamRouter:
251
267
  timeout=self._config.timeout_seconds,
252
268
  ) as response:
253
269
  try:
254
- stream_state: 'typing.Dict[str, object]' = {}
270
+ stream_state: "typing.Dict[str, object]" = {}
255
271
  for event_name, data in self._iter_sse_events(response):
256
272
  if not data:
257
273
  continue
@@ -285,6 +301,11 @@ class StreamRouter:
285
301
  raise OutcommingChatError(
286
302
  f"outcomming messages request failed with status {exc.code}: {body[:500]}"
287
303
  ) from exc
304
+ except TimeoutError as exc:
305
+ raise OutcommingChatError(
306
+ "outcomming messages request timed out after "
307
+ f"{self._config.timeout_seconds:g}s"
308
+ ) from exc
288
309
  except urllib.error.URLError as exc:
289
310
  raise OutcommingChatError(
290
311
  f"outcomming messages request failed: {exc.reason}"
@@ -292,10 +313,10 @@ class StreamRouter:
292
313
 
293
314
  def route_stream(
294
315
  self,
295
- stored_response: 'StoredResponse',
296
- outcomming_request: 'typing.Dict[str, object]',
297
- custom_tool_names: 'typing.Union[typing.Set[str], None]' = None,
298
- trajectory_dump: 'typing.Union[TrajectoryDumpWriter, None]' = None,
316
+ stored_response: "StoredResponse",
317
+ outcomming_request: "typing.Dict[str, object]",
318
+ custom_tool_names: "typing.Union[typing.Set[str], None]" = None,
319
+ trajectory_dump: "typing.Union[TrajectoryDumpWriter, None]" = None,
299
320
  ):
300
321
  yield (
301
322
  "response.created",
@@ -310,9 +331,9 @@ class StreamRouter:
310
331
  },
311
332
  )
312
333
 
313
- text_parts: 'typing.List[str]' = []
314
- reasoning_parts: 'typing.List[str]' = []
315
- latest_usage: 'typing.Dict[str, object]' = {}
334
+ text_parts: "typing.List[str]" = []
335
+ reasoning_parts: "typing.List[str]" = []
336
+ latest_usage: "typing.Dict[str, object]" = {}
316
337
  current_request = json.loads(json.dumps(outcomming_request))
317
338
  current_stream = self._open_tracked_outcomming_stream(
318
339
  current_request,
@@ -321,9 +342,9 @@ class StreamRouter:
321
342
  retried_reasoning_only_output = False
322
343
 
323
344
  while True:
324
- tool_calls: 'typing.Dict[int, typing.Dict[str, object]]' = {}
325
- finish_reasons: 'typing.List[str]' = []
326
- current_usage: 'typing.Dict[str, object]' = {}
345
+ tool_calls: "typing.Dict[int, typing.Dict[str, object]]" = {}
346
+ finish_reasons: "typing.List[str]" = []
347
+ current_usage: "typing.Dict[str, object]" = {}
327
348
  reasoning_start = len(reasoning_parts)
328
349
  text_start = len(text_parts)
329
350
  for chunk in current_stream:
@@ -437,26 +458,30 @@ class StreamRouter:
437
458
 
438
459
  def _open_tracked_outcomming_stream(
439
460
  self,
440
- outcomming_request: 'typing.Dict[str, object]',
441
- trajectory_dump: 'typing.Union[TrajectoryDumpWriter, None]' = None,
461
+ outcomming_request: "typing.Dict[str, object]",
462
+ trajectory_dump: "typing.Union[TrajectoryDumpWriter, None]" = None,
442
463
  ):
443
464
  outcomming_stream = self.open_outcomming_stream(outcomming_request)
444
465
  if trajectory_dump is None:
445
466
  return outcomming_stream
446
- return trajectory_dump.wrap_stream(outcomming_stream)
467
+ return trajectory_dump.wrap_stream(
468
+ outcomming_stream,
469
+ outcomming_request,
470
+ )
447
471
 
448
472
  def _responses_input_to_chat_messages(
449
473
  self,
450
- instructions: 'str',
451
- input_items: 'typing.List[object]',
452
- ) -> 'typing.List[typing.Dict[str, object]]':
453
- messages: 'typing.List[typing.Dict[str, object]]' = []
474
+ instructions: "str",
475
+ input_items: "typing.List[object]",
476
+ ) -> "typing.List[typing.Dict[str, object]]":
477
+ messages: "typing.List[typing.Dict[str, object]]" = []
454
478
  if instructions:
455
479
  messages.append({"role": "developer", "content": instructions})
456
480
 
457
- pending_assistant: 'typing.Union[typing.Dict[str, object], None]' = None
481
+ pending_assistant: "typing.Union[typing.Dict[str, object], None]" = None
482
+ pending_tool_images: "typing.List[typing.Dict[str, object]]" = []
458
483
 
459
- def flush_pending_assistant() -> 'None':
484
+ def flush_pending_assistant() -> "None":
460
485
  nonlocal pending_assistant
461
486
  if pending_assistant is None:
462
487
  return
@@ -470,12 +495,20 @@ class StreamRouter:
470
495
  messages.append(pending_assistant)
471
496
  pending_assistant = None
472
497
 
498
+ def flush_pending_tool_images() -> "None":
499
+ if not pending_tool_images:
500
+ return
501
+ messages.append({"role": "user", "content": list(pending_tool_images)})
502
+ del pending_tool_images[:]
503
+
473
504
  for raw_item in input_items:
474
505
  if not isinstance(raw_item, dict):
475
506
  raise UnsupportedIncommingFeature(
476
507
  "all incomming `input` items must be objects"
477
508
  )
478
509
  item_type = raw_item.get("type")
510
+ if item_type not in {"function_call_output", "custom_tool_call_output"}:
511
+ flush_pending_tool_images()
479
512
 
480
513
  if item_type == "message":
481
514
  role = str(raw_item.get("role", "")).strip()
@@ -483,8 +516,12 @@ class StreamRouter:
483
516
  raise UnsupportedIncommingFeature(
484
517
  f"unsupported incomming message role: {role or '<empty>'}"
485
518
  )
486
- text = self._coalesce_content_text(raw_item.get("content"))
519
+ text, image_parts = self._split_content_parts(raw_item.get("content"))
487
520
  if role == "assistant":
521
+ if image_parts:
522
+ raise UnsupportedIncommingFeature(
523
+ "assistant messages cannot carry `input_image` content parts"
524
+ )
488
525
  if pending_assistant is None:
489
526
  pending_assistant = {"role": "assistant"}
490
527
  if text:
@@ -493,7 +530,13 @@ class StreamRouter:
493
530
  )
494
531
  continue
495
532
  flush_pending_assistant()
496
- messages.append({"role": role, "content": text})
533
+ if image_parts:
534
+ content: "object" = (
535
+ [{"type": "text", "text": text}] if text else []
536
+ ) + image_parts
537
+ else:
538
+ content = text
539
+ messages.append({"role": role, "content": content})
497
540
  continue
498
541
 
499
542
  if item_type == "reasoning":
@@ -532,15 +575,17 @@ class StreamRouter:
532
575
 
533
576
  if item_type == "function_call_output":
534
577
  flush_pending_assistant()
578
+ text, image_parts = self._split_tool_output_parts(
579
+ raw_item.get("output")
580
+ )
535
581
  messages.append(
536
582
  {
537
583
  "role": "tool",
538
584
  "tool_call_id": str(raw_item.get("call_id", "")).strip(),
539
- "content": self._coalesce_tool_output_text(
540
- raw_item.get("output")
541
- ),
585
+ "content": text,
542
586
  }
543
587
  )
588
+ pending_tool_images.extend(image_parts)
544
589
  continue
545
590
 
546
591
  if item_type == "custom_tool_call":
@@ -559,15 +604,17 @@ class StreamRouter:
559
604
 
560
605
  if item_type == "custom_tool_call_output":
561
606
  flush_pending_assistant()
607
+ text, image_parts = self._split_tool_output_parts(
608
+ raw_item.get("output")
609
+ )
562
610
  messages.append(
563
611
  {
564
612
  "role": "tool",
565
613
  "tool_call_id": str(raw_item.get("call_id", "")).strip(),
566
- "content": self._coalesce_tool_output_text(
567
- raw_item.get("output")
568
- ),
614
+ "content": text,
569
615
  }
570
616
  )
617
+ pending_tool_images.extend(image_parts)
571
618
  continue
572
619
 
573
620
  raise UnsupportedIncommingFeature(
@@ -575,26 +622,31 @@ class StreamRouter:
575
622
  )
576
623
 
577
624
  flush_pending_assistant()
625
+ flush_pending_tool_images()
578
626
  return messages
579
627
 
580
- def _coerce_positive_int(self, raw_value: 'object') -> 'typing.Union[int, None]':
628
+ def _coerce_positive_int(self, raw_value: "object") -> "typing.Union[int, None]":
581
629
  if isinstance(raw_value, bool):
582
630
  return None
583
631
  if isinstance(raw_value, int) and raw_value > 0:
584
632
  return raw_value
585
633
  return None
586
634
 
587
- def _coalesce_content_text(self, raw_content: 'object') -> 'str':
635
+ def _split_content_parts(
636
+ self,
637
+ raw_content: "object",
638
+ ) -> "typing.Tuple[str, typing.List[typing.Dict[str, object]]]":
588
639
  if raw_content is None:
589
- return ""
640
+ return "", []
590
641
  if isinstance(raw_content, str):
591
- return raw_content
642
+ return raw_content, []
592
643
  if not isinstance(raw_content, list):
593
644
  raise UnsupportedIncommingFeature(
594
645
  "message `content` must be a list or string"
595
646
  )
596
647
 
597
- text_parts: 'typing.List[str]' = []
648
+ text_parts: "typing.List[str]" = []
649
+ image_parts: "typing.List[typing.Dict[str, object]]" = []
598
650
  for part in raw_content:
599
651
  if not isinstance(part, dict):
600
652
  raise UnsupportedIncommingFeature(
@@ -604,22 +656,43 @@ class StreamRouter:
604
656
  if part_type in {"input_text", "output_text"}:
605
657
  text_parts.append(str(part.get("text", "")))
606
658
  continue
659
+ if part_type == "input_image":
660
+ image_parts.append(self._build_chat_image_part(part))
661
+ continue
607
662
  raise UnsupportedIncommingFeature(
608
663
  f"content part type `{part_type}` is not yet supported by the chat backend"
609
664
  )
610
- return "".join(text_parts)
665
+ return "".join(text_parts), image_parts
666
+
667
+ def _build_chat_image_part(
668
+ self,
669
+ part: "typing.Dict[str, object]",
670
+ ) -> "typing.Dict[str, object]":
671
+ image_url = str(part.get("image_url", "") or "").strip()
672
+ if not image_url:
673
+ raise UnsupportedIncommingFeature(
674
+ "`input_image` content parts must carry a non-empty `image_url`"
675
+ )
676
+ image_payload: "typing.Dict[str, object]" = {"url": image_url}
677
+ detail = part.get("detail")
678
+ if isinstance(detail, str) and detail in {"auto", "low", "high"}:
679
+ image_payload["detail"] = detail
680
+ return {"type": "image_url", "image_url": image_payload}
611
681
 
612
- def _coalesce_tool_output_text(self, raw_output: 'object') -> 'str':
682
+ def _split_tool_output_parts(
683
+ self,
684
+ raw_output: "object",
685
+ ) -> "typing.Tuple[str, typing.List[typing.Dict[str, object]]]":
613
686
  if isinstance(raw_output, str):
614
- return raw_output
687
+ return raw_output, []
615
688
  if isinstance(raw_output, list):
616
- return self._coalesce_content_text(raw_output)
617
- return json.dumps(raw_output, ensure_ascii=False)
689
+ return self._split_content_parts(raw_output)
690
+ return json.dumps(raw_output, ensure_ascii=False), []
618
691
 
619
- def _coalesce_reasoning_text(self, raw_item: 'typing.Dict[str, object]') -> 'str':
692
+ def _coalesce_reasoning_text(self, raw_item: "typing.Dict[str, object]") -> "str":
620
693
  content = raw_item.get("content")
621
694
  if isinstance(content, list):
622
- text_parts: 'typing.List[str]' = []
695
+ text_parts: "typing.List[str]" = []
623
696
  for part in content:
624
697
  if not isinstance(part, dict):
625
698
  continue
@@ -646,8 +719,10 @@ class StreamRouter:
646
719
  return value
647
720
  return ""
648
721
 
649
- def _translate_tools(self, incomming_tools: 'typing.List[object]') -> 'typing.List[typing.Dict[str, object]]':
650
- translated: 'typing.List[typing.Dict[str, object]]' = []
722
+ def _translate_tools(
723
+ self, incomming_tools: "typing.List[object]"
724
+ ) -> "typing.List[typing.Dict[str, object]]":
725
+ translated: "typing.List[typing.Dict[str, object]]" = []
651
726
  for raw_tool in incomming_tools:
652
727
  if not isinstance(raw_tool, dict):
653
728
  raise UnsupportedIncommingFeature("tool definitions must be objects")
@@ -682,7 +757,7 @@ class StreamRouter:
682
757
  )
683
758
  return translated
684
759
 
685
- def _translate_tool_choice(self, raw_tool_choice: 'object') -> 'object':
760
+ def _translate_tool_choice(self, raw_tool_choice: "object") -> "object":
686
761
  if isinstance(raw_tool_choice, str):
687
762
  return raw_tool_choice
688
763
  if not isinstance(raw_tool_choice, dict):
@@ -705,14 +780,14 @@ class StreamRouter:
705
780
 
706
781
  def _consume_chat_chunk(
707
782
  self,
708
- payload: 'typing.Dict[str, object]',
709
- reasoning_parts: 'typing.List[str]',
710
- text_parts: 'typing.List[str]',
711
- tool_calls: 'typing.Dict[int, typing.Dict[str, object]]',
712
- current_usage: 'typing.Dict[str, object]',
713
- finish_reasons: 'typing.List[str]',
714
- ) -> 'typing.List[typing.Tuple[str, typing.Dict[str, object]]]':
715
- events: 'typing.List[typing.Tuple[str, typing.Dict[str, object]]]' = []
783
+ payload: "typing.Dict[str, object]",
784
+ reasoning_parts: "typing.List[str]",
785
+ text_parts: "typing.List[str]",
786
+ tool_calls: "typing.Dict[int, typing.Dict[str, object]]",
787
+ current_usage: "typing.Dict[str, object]",
788
+ finish_reasons: "typing.List[str]",
789
+ ) -> "typing.List[typing.Tuple[str, typing.Dict[str, object]]]":
790
+ events: "typing.List[typing.Tuple[str, typing.Dict[str, object]]]" = []
716
791
  usage = payload.get("usage")
717
792
  if isinstance(usage, dict):
718
793
  self._capture_usage_snapshot(current_usage, usage)
@@ -732,12 +807,20 @@ class StreamRouter:
732
807
  continue
733
808
 
734
809
  reasoning = delta.get("reasoning")
735
- if isinstance(reasoning, str) and reasoning:
736
- reasoning_parts.append(reasoning)
737
-
738
810
  reasoning_content = delta.get("reasoning_content")
739
- if isinstance(reasoning_content, str) and reasoning_content:
740
- reasoning_parts.append(reasoning_content)
811
+ if (
812
+ isinstance(reasoning, str)
813
+ and reasoning
814
+ and isinstance(reasoning_content, str)
815
+ and reasoning == reasoning_content
816
+ ):
817
+ # Some chat providers expose the same delta under both aliases.
818
+ reasoning_parts.append(reasoning)
819
+ else:
820
+ if isinstance(reasoning, str) and reasoning:
821
+ reasoning_parts.append(reasoning)
822
+ if isinstance(reasoning_content, str) and reasoning_content:
823
+ reasoning_parts.append(reasoning_content)
741
824
 
742
825
  content = delta.get("content")
743
826
  if isinstance(content, str) and content:
@@ -795,9 +878,9 @@ class StreamRouter:
795
878
 
796
879
  def _capture_usage_snapshot(
797
880
  self,
798
- current_usage: 'typing.Dict[str, object]',
799
- usage: 'typing.Dict[str, object]',
800
- ) -> 'None':
881
+ current_usage: "typing.Dict[str, object]",
882
+ usage: "typing.Dict[str, object]",
883
+ ) -> "None":
801
884
  scalar_mappings = (
802
885
  ("input_tokens", usage.get("input_tokens", usage.get("prompt_tokens"))),
803
886
  (
@@ -829,12 +912,12 @@ class StreamRouter:
829
912
 
830
913
  def _build_output_items(
831
914
  self,
832
- reasoning_parts: 'typing.List[str]',
833
- text_parts: 'typing.List[str]',
834
- tool_calls: 'typing.Dict[int, typing.Dict[str, object]]',
835
- custom_tool_names: 'typing.Set[str]',
836
- ) -> 'typing.List[typing.Dict[str, object]]':
837
- items: 'typing.List[typing.Dict[str, object]]' = []
915
+ reasoning_parts: "typing.List[str]",
916
+ text_parts: "typing.List[str]",
917
+ tool_calls: "typing.Dict[int, typing.Dict[str, object]]",
918
+ custom_tool_names: "typing.Set[str]",
919
+ ) -> "typing.List[typing.Dict[str, object]]":
920
+ items: "typing.List[typing.Dict[str, object]]" = []
838
921
  reasoning_text = "".join(reasoning_parts)
839
922
  if reasoning_text:
840
923
  items.append(
@@ -890,8 +973,7 @@ class StreamRouter:
890
973
  items.append(
891
974
  {
892
975
  "type": "function_call",
893
- "call_id": str(tool_call.get("id", "")).strip()
894
- or f"call_{index}",
976
+ "call_id": str(tool_call.get("id", "")).strip() or f"call_{index}",
895
977
  "name": name,
896
978
  "arguments": arguments,
897
979
  }
@@ -899,7 +981,9 @@ class StreamRouter:
899
981
 
900
982
  return items
901
983
 
902
- def _request_json(self, request: 'urllib.request.Request') -> 'typing.Dict[str, object]':
984
+ def _request_json(
985
+ self, request: "urllib.request.Request"
986
+ ) -> "typing.Dict[str, object]":
903
987
  try:
904
988
  with urllib.request.urlopen(
905
989
  request,
@@ -912,12 +996,17 @@ class StreamRouter:
912
996
  raise OutcommingChatError(
913
997
  f"outcomming request failed with status {exc.code}: {body[:500]}"
914
998
  ) from exc
999
+ except TimeoutError as exc:
1000
+ raise OutcommingChatError(
1001
+ "outcomming request timed out after "
1002
+ f"{self._config.timeout_seconds:g}s"
1003
+ ) from exc
915
1004
  except urllib.error.URLError as exc:
916
1005
  raise OutcommingChatError(
917
1006
  f"outcomming request failed: {exc.reason}"
918
1007
  ) from exc
919
1008
 
920
- def _build_headers(self, accept: 'str') -> 'typing.Dict[str, str]':
1009
+ def _build_headers(self, accept: "str") -> "typing.Dict[str, str]":
921
1010
  headers = {
922
1011
  "Accept": accept,
923
1012
  "Content-Type": "application/json",
@@ -928,8 +1017,8 @@ class StreamRouter:
928
1017
  return headers
929
1018
 
930
1019
  def _iter_sse_events(self, response):
931
- event_name: 'typing.Union[str, None]' = None
932
- data_lines: 'typing.List[str]' = []
1020
+ event_name: "typing.Union[str, None]" = None
1021
+ data_lines: "typing.List[str]" = []
933
1022
 
934
1023
  for raw_line in response:
935
1024
  line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")