python-codex 0.2.7__py3-none-any.whl → 0.3.1__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 (85) hide show
  1. pycodex/__init__.py +14 -14
  2. pycodex/agent.py +465 -499
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -510
  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 +324 -253
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +76 -45
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +321 -883
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -176
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +1 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +58 -25
  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 +7 -5
  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 +41 -72
  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/{image_utils.py → utils/image_utils.py} +8 -11
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +217 -163
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.1.dist-info/METADATA +743 -0
  56. python_codex-0.3.1.dist-info/RECORD +91 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +34 -33
  60. responses_server/config.py +23 -23
  61. responses_server/messages_api.py +51 -53
  62. responses_server/payload_processors.py +25 -20
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +209 -98
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +36 -14
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +465 -377
  71. workspace_server/owned_process.py +27 -0
  72. workspace_server/workspace.html +854 -228
  73. workspace_server/workspaces.html +94 -95
  74. workspace_server/workspaces.py +141 -84
  75. pycodex/collaboration.py +0 -20
  76. pycodex/interactive_session.py +0 -415
  77. pycodex/prompts/collaboration_default.md +0 -11
  78. pycodex/prompts/collaboration_plan.md +0 -128
  79. pycodex/utils/toolcall_visualize.py +0 -713
  80. pycodex/utils/visualize.py +0 -560
  81. python_codex-0.2.7.dist-info/METADATA +0 -455
  82. python_codex-0.2.7.dist-info/RECORD +0 -93
  83. {python_codex-0.2.7.dist-info → python_codex-0.3.1.dist-info}/WHEEL +0 -0
  84. {python_codex-0.2.7.dist-info → python_codex-0.3.1.dist-info}/entry_points.txt +0 -0
  85. {python_codex-0.2.7.dist-info → python_codex-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,25 +1,22 @@
1
-
2
- import json
3
1
  import http.client
2
+ import json
4
3
  import ssl
4
+ import time
5
+ import typing
5
6
  import urllib.error
6
7
  import urllib.request
8
+ from contextlib import contextmanager
7
9
 
8
10
  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
- )
11
+ from .messages_api import MessagesAPIAdapterError, build_messages_request
12
+ from .messages_api import iter_chat_chunks as iter_chat_chunks_from_messages
13
+ from .messages_api import saw_message_stop as messages_saw_message_stop
15
14
  from .session_store import StoredResponse
16
15
  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
- )
16
+ from .tools.custom_adapter import CustomToolAdapterError
17
+ from .tools.custom_adapter import build_output_item as build_custom_output_item
18
+ from .tools.custom_adapter import build_tool_call as build_custom_tool_call
19
+ from .tools.custom_adapter import build_tool_definition as build_custom_tool_definition
23
20
  from .tools.web_search import (
24
21
  build_followup_request,
25
22
  build_output_items,
@@ -28,7 +25,6 @@ from .tools.web_search import (
28
25
  partition_tool_calls,
29
26
  )
30
27
  from .trajectory_dump import TrajectoryDumpWriter
31
- import typing
32
28
 
33
29
 
34
30
  class UnsupportedIncommingFeature(ValueError):
@@ -38,23 +34,23 @@ class UnsupportedIncommingFeature(ValueError):
38
34
  class OutcommingChatError(RuntimeError):
39
35
  def __init__(
40
36
  self,
41
- message: 'str',
42
- error_type: 'typing.Union[str, None]' = None,
43
- ) -> 'None':
37
+ message: "str",
38
+ error_type: "typing.Union[str, None]" = None,
39
+ ) -> "None":
44
40
  super().__init__(message)
45
41
  self.error_type = error_type
46
42
 
47
43
 
48
44
  class StreamRouter:
49
- def __init__(self, config: 'CompatServerConfig') -> 'None':
45
+ def __init__(self, config: "CompatServerConfig") -> "None":
50
46
  self._config = config
51
47
  self._mock_web_search = WebSearchTool()
52
48
 
53
49
  def _provider_capability(
54
50
  self,
55
- explicit_support: 'typing.Dict[str, bool]',
56
- default: 'typing.Union[bool, None]' = None,
57
- ) -> 'bool':
51
+ explicit_support: "typing.Dict[str, bool]",
52
+ default: "typing.Union[bool, None]" = None,
53
+ ) -> "bool":
58
54
  provider_name = str(self._config.model_provider or "").strip().lower()
59
55
  if provider_name in explicit_support:
60
56
  return explicit_support[provider_name]
@@ -64,7 +60,7 @@ class StreamRouter:
64
60
  return default
65
61
  raise KeyError("provider capability map is missing `vllm` fallback")
66
62
 
67
- def _supports_chat_reasoning(self) -> 'bool':
63
+ def _supports_chat_reasoning(self) -> "bool":
68
64
  # Unknown providers inherit the vLLM compatibility behavior unless a
69
65
  # provider is explicitly declared otherwise.
70
66
  return self._provider_capability(
@@ -74,7 +70,7 @@ class StreamRouter:
74
70
  }
75
71
  )
76
72
 
77
- def _supports_stream_usage(self) -> 'bool':
73
+ def _supports_stream_usage(self) -> "bool":
78
74
  return self._provider_capability(
79
75
  {
80
76
  "vllm": True,
@@ -84,8 +80,8 @@ class StreamRouter:
84
80
 
85
81
  def validate_incomming_request(
86
82
  self,
87
- incomming_request: 'typing.Dict[str, object]',
88
- ) -> 'None':
83
+ incomming_request: "typing.Dict[str, object]",
84
+ ) -> "None":
89
85
  model = str(incomming_request.get("model", "")).strip()
90
86
  if not model:
91
87
  raise UnsupportedIncommingFeature("incomming request is missing `model`")
@@ -104,11 +100,11 @@ class StreamRouter:
104
100
 
105
101
  def collect_custom_tool_names(
106
102
  self,
107
- incomming_request: 'typing.Dict[str, object]',
108
- ) -> 'typing.Set[str]':
103
+ incomming_request: "typing.Dict[str, object]",
104
+ ) -> "typing.Set[str]":
109
105
  return collect_custom_tool_names(incomming_request.get("tools") or [])
110
106
 
111
- def list_models(self) -> 'typing.Dict[str, object]':
107
+ def list_models(self) -> "typing.Dict[str, object]":
112
108
  request = urllib.request.Request(
113
109
  self._config.outcomming_models_url(),
114
110
  headers=self._build_headers(accept="application/json"),
@@ -118,8 +114,8 @@ class StreamRouter:
118
114
 
119
115
  def build_outcomming_request(
120
116
  self,
121
- incomming_request: 'typing.Dict[str, object]',
122
- ) -> 'typing.Dict[str, object]':
117
+ incomming_request: "typing.Dict[str, object]",
118
+ ) -> "typing.Dict[str, object]":
123
119
  model = str(incomming_request.get("model", "")).strip()
124
120
  if not model:
125
121
  raise UnsupportedIncommingFeature("incomming request is missing `model`")
@@ -135,7 +131,7 @@ class StreamRouter:
135
131
  if not isinstance(input_items, list):
136
132
  raise UnsupportedIncommingFeature("incomming `input` must be a list")
137
133
 
138
- payload: 'typing.Dict[str, object]' = {
134
+ payload: "typing.Dict[str, object]" = {
139
135
  "model": model,
140
136
  "messages": self._responses_input_to_chat_messages(
141
137
  instructions,
@@ -180,7 +176,7 @@ class StreamRouter:
180
176
 
181
177
  return payload
182
178
 
183
- def open_outcomming_stream(self, outcomming_request: 'typing.Dict[str, object]'):
179
+ def open_outcomming_stream(self, outcomming_request: "typing.Dict[str, object]"):
184
180
  outcomming_api = self._config.normalized_outcomming_api()
185
181
  if outcomming_api == "messages":
186
182
  return self._open_outcomming_messages_stream(outcomming_request)
@@ -192,7 +188,7 @@ class StreamRouter:
192
188
 
193
189
  def _open_outcomming_chat_stream(
194
190
  self,
195
- outcomming_request: 'typing.Dict[str, object]',
191
+ outcomming_request: "typing.Dict[str, object]",
196
192
  ):
197
193
  request = urllib.request.Request(
198
194
  self._config.outcomming_chat_completions_url(),
@@ -200,12 +196,19 @@ class StreamRouter:
200
196
  headers=self._build_headers(accept="text/event-stream"),
201
197
  method="POST",
202
198
  )
199
+ with self._request_diagnostics(request) as progress:
200
+ yield from self._read_outcomming_chat_stream(request, progress)
201
+
202
+ def _read_outcomming_chat_stream(self, request, progress):
203
203
  try:
204
204
  with urllib.request.urlopen(
205
205
  request,
206
206
  context=ssl.create_default_context(),
207
207
  timeout=self._config.timeout_seconds,
208
208
  ) as response:
209
+ progress["headers_at"] = time.monotonic()
210
+ progress["status"] = response.status
211
+ progress["phase"] = "waiting_first_chunk"
209
212
  try:
210
213
  saw_done = False
211
214
  for _event_name, data in self._iter_sse_events(response):
@@ -214,7 +217,14 @@ class StreamRouter:
214
217
  if data == "[DONE]":
215
218
  saw_done = True
216
219
  break
217
- yield json.loads(data)
220
+ payload = json.loads(data)
221
+ self._received_chunk(progress)
222
+ if isinstance(payload, dict) and payload.get("error") is not None:
223
+ raise OutcommingChatError(
224
+ "outcomming chat stream returned an error: "
225
+ + json.dumps(payload["error"], ensure_ascii=False)[:500]
226
+ )
227
+ yield payload
218
228
  if not saw_done:
219
229
  raise OutcommingChatError(
220
230
  "outcomming chat stream ended before [DONE]"
@@ -231,6 +241,9 @@ class StreamRouter:
231
241
  f"{exc}"
232
242
  ) from exc
233
243
  except urllib.error.HTTPError as exc:
244
+ progress["headers_at"] = time.monotonic()
245
+ progress["status"] = exc.code
246
+ progress["phase"] = "reading_error_body"
234
247
  body = exc.read().decode("utf-8", errors="replace")
235
248
  raise OutcommingChatError(
236
249
  f"outcomming chat request failed with status {exc.code}: {body[:500]}"
@@ -244,10 +257,15 @@ class StreamRouter:
244
257
  raise OutcommingChatError(
245
258
  f"outcomming chat request failed: {exc.reason}"
246
259
  ) from exc
260
+ except (OSError, EOFError, http.client.HTTPException) as exc:
261
+ raise OutcommingChatError(
262
+ "outcomming chat request failed while opening response: "
263
+ f"{type(exc).__name__}: {exc}"
264
+ ) from exc
247
265
 
248
266
  def _open_outcomming_messages_stream(
249
267
  self,
250
- outcomming_request: 'typing.Dict[str, object]',
268
+ outcomming_request: "typing.Dict[str, object]",
251
269
  ):
252
270
  try:
253
271
  messages_request = build_messages_request(outcomming_request)
@@ -260,20 +278,28 @@ class StreamRouter:
260
278
  headers=self._build_headers(accept="text/event-stream"),
261
279
  method="POST",
262
280
  )
281
+ with self._request_diagnostics(request) as progress:
282
+ yield from self._read_outcomming_messages_stream(request, progress)
283
+
284
+ def _read_outcomming_messages_stream(self, request, progress):
263
285
  try:
264
286
  with urllib.request.urlopen(
265
287
  request,
266
288
  context=ssl.create_default_context(),
267
289
  timeout=self._config.timeout_seconds,
268
290
  ) as response:
291
+ progress["headers_at"] = time.monotonic()
292
+ progress["status"] = response.status
293
+ progress["phase"] = "waiting_first_chunk"
269
294
  try:
270
- stream_state: 'typing.Dict[str, object]' = {}
295
+ stream_state: "typing.Dict[str, object]" = {}
271
296
  for event_name, data in self._iter_sse_events(response):
272
297
  if not data:
273
298
  continue
274
299
  payload = json.loads(data)
275
300
  if not isinstance(payload, dict):
276
301
  continue
302
+ self._received_chunk(progress)
277
303
  for chunk in iter_chat_chunks_from_messages(
278
304
  event_name,
279
305
  payload,
@@ -297,6 +323,9 @@ class StreamRouter:
297
323
  f"{exc}"
298
324
  ) from exc
299
325
  except urllib.error.HTTPError as exc:
326
+ progress["headers_at"] = time.monotonic()
327
+ progress["status"] = exc.code
328
+ progress["phase"] = "reading_error_body"
300
329
  body = exc.read().decode("utf-8", errors="replace")
301
330
  raise OutcommingChatError(
302
331
  f"outcomming messages request failed with status {exc.code}: {body[:500]}"
@@ -310,13 +339,18 @@ class StreamRouter:
310
339
  raise OutcommingChatError(
311
340
  f"outcomming messages request failed: {exc.reason}"
312
341
  ) from exc
342
+ except (OSError, EOFError, http.client.HTTPException) as exc:
343
+ raise OutcommingChatError(
344
+ "outcomming messages request failed while opening response: "
345
+ f"{type(exc).__name__}: {exc}"
346
+ ) from exc
313
347
 
314
348
  def route_stream(
315
349
  self,
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,
350
+ stored_response: "StoredResponse",
351
+ outcomming_request: "typing.Dict[str, object]",
352
+ custom_tool_names: "typing.Union[typing.Set[str], None]" = None,
353
+ trajectory_dump: "typing.Union[TrajectoryDumpWriter, None]" = None,
320
354
  ):
321
355
  yield (
322
356
  "response.created",
@@ -331,9 +365,9 @@ class StreamRouter:
331
365
  },
332
366
  )
333
367
 
334
- text_parts: 'typing.List[str]' = []
335
- reasoning_parts: 'typing.List[str]' = []
336
- latest_usage: 'typing.Dict[str, object]' = {}
368
+ text_parts: "typing.List[str]" = []
369
+ reasoning_parts: "typing.List[str]" = []
370
+ latest_usage: "typing.Dict[str, object]" = {}
337
371
  current_request = json.loads(json.dumps(outcomming_request))
338
372
  current_stream = self._open_tracked_outcomming_stream(
339
373
  current_request,
@@ -342,9 +376,9 @@ class StreamRouter:
342
376
  retried_reasoning_only_output = False
343
377
 
344
378
  while True:
345
- tool_calls: 'typing.Dict[int, typing.Dict[str, object]]' = {}
346
- finish_reasons: 'typing.List[str]' = []
347
- current_usage: 'typing.Dict[str, object]' = {}
379
+ tool_calls: "typing.Dict[int, typing.Dict[str, object]]" = {}
380
+ finish_reasons: "typing.List[str]" = []
381
+ current_usage: "typing.Dict[str, object]" = {}
348
382
  reasoning_start = len(reasoning_parts)
349
383
  text_start = len(text_parts)
350
384
  for chunk in current_stream:
@@ -458,8 +492,8 @@ class StreamRouter:
458
492
 
459
493
  def _open_tracked_outcomming_stream(
460
494
  self,
461
- outcomming_request: 'typing.Dict[str, object]',
462
- trajectory_dump: 'typing.Union[TrajectoryDumpWriter, None]' = None,
495
+ outcomming_request: "typing.Dict[str, object]",
496
+ trajectory_dump: "typing.Union[TrajectoryDumpWriter, None]" = None,
463
497
  ):
464
498
  outcomming_stream = self.open_outcomming_stream(outcomming_request)
465
499
  if trajectory_dump is None:
@@ -469,19 +503,71 @@ class StreamRouter:
469
503
  outcomming_request,
470
504
  )
471
505
 
506
+ @contextmanager
507
+ def _request_diagnostics(self, request):
508
+ started = time.monotonic()
509
+ progress = {
510
+ "phase": "waiting_response_headers",
511
+ "headers_at": None,
512
+ "first_chunk_at": None,
513
+ "last_chunk_at": None,
514
+ "chunks": 0,
515
+ "status": None,
516
+ }
517
+ try:
518
+ yield progress
519
+ except OutcommingChatError as exc:
520
+ now = time.monotonic()
521
+ cause = exc.__cause__ or exc
522
+ if isinstance(cause, urllib.error.URLError) and isinstance(
523
+ cause.reason, BaseException
524
+ ):
525
+ cause = cause.reason
526
+
527
+ def timing(received_at):
528
+ if received_at is None:
529
+ return "not_received"
530
+ return f"{received_at - started:.2f}s"
531
+
532
+ idle = (
533
+ f"{now - progress['last_chunk_at']:.2f}s"
534
+ if progress["last_chunk_at"] is not None
535
+ else "not_started"
536
+ )
537
+ details = (
538
+ f"phase={progress['phase']}, elapsed={now - started:.2f}s, "
539
+ f"timeout={self._config.timeout_seconds:g}s\n"
540
+ f"headers={timing(progress['headers_at'])}, "
541
+ f"first_chunk={timing(progress['first_chunk_at'])}, "
542
+ f"chunks={progress['chunks']}, idle={idle}\n"
543
+ f"status={progress['status']}, "
544
+ f"request_bytes={len(request.data or b'')}, "
545
+ f"exception={type(cause).__name__}"
546
+ )
547
+ exc.args = (f"{exc}\n{details}",)
548
+ raise
549
+
550
+ def _received_chunk(self, progress):
551
+ now = time.monotonic()
552
+ if progress["chunks"] == 0:
553
+ progress["first_chunk_at"] = now
554
+ progress["last_chunk_at"] = now
555
+ progress["chunks"] += 1
556
+ progress["phase"] = "reading_stream"
557
+
472
558
  def _responses_input_to_chat_messages(
473
559
  self,
474
- instructions: 'str',
475
- input_items: 'typing.List[object]',
476
- ) -> 'typing.List[typing.Dict[str, object]]':
477
- messages: 'typing.List[typing.Dict[str, object]]' = []
560
+ instructions: "str",
561
+ input_items: "typing.List[object]",
562
+ ) -> "typing.List[typing.Dict[str, object]]":
563
+ messages: "typing.List[typing.Dict[str, object]]" = []
478
564
  if instructions:
479
565
  messages.append({"role": "developer", "content": instructions})
480
566
 
481
- pending_assistant: 'typing.Union[typing.Dict[str, object], None]' = None
482
- pending_tool_images: 'typing.List[typing.Dict[str, object]]' = []
567
+ pending_assistant: "typing.Union[typing.Dict[str, object], None]" = None
568
+ pending_tool_images: "typing.List[typing.Dict[str, object]]" = []
483
569
 
484
- def flush_pending_assistant() -> 'None':
570
+ def flush_pending_assistant() -> "None":
485
571
  nonlocal pending_assistant
486
572
  if pending_assistant is None:
487
573
  return
@@ -495,7 +581,7 @@ class StreamRouter:
495
581
  messages.append(pending_assistant)
496
582
  pending_assistant = None
497
583
 
498
- def flush_pending_tool_images() -> 'None':
584
+ def flush_pending_tool_images() -> "None":
499
585
  if not pending_tool_images:
500
586
  return
501
587
  messages.append({"role": "user", "content": list(pending_tool_images)})
@@ -531,9 +617,9 @@ class StreamRouter:
531
617
  continue
532
618
  flush_pending_assistant()
533
619
  if image_parts:
534
- content: 'object' = (
535
- ([{"type": "text", "text": text}] if text else []) + image_parts
536
- )
620
+ content: "object" = (
621
+ [{"type": "text", "text": text}] if text else []
622
+ ) + image_parts
537
623
  else:
538
624
  content = text
539
625
  messages.append({"role": role, "content": content})
@@ -625,7 +711,7 @@ class StreamRouter:
625
711
  flush_pending_tool_images()
626
712
  return messages
627
713
 
628
- def _coerce_positive_int(self, raw_value: 'object') -> 'typing.Union[int, None]':
714
+ def _coerce_positive_int(self, raw_value: "object") -> "typing.Union[int, None]":
629
715
  if isinstance(raw_value, bool):
630
716
  return None
631
717
  if isinstance(raw_value, int) and raw_value > 0:
@@ -634,8 +720,8 @@ class StreamRouter:
634
720
 
635
721
  def _split_content_parts(
636
722
  self,
637
- raw_content: 'object',
638
- ) -> 'typing.Tuple[str, typing.List[typing.Dict[str, object]]]':
723
+ raw_content: "object",
724
+ ) -> "typing.Tuple[str, typing.List[typing.Dict[str, object]]]":
639
725
  if raw_content is None:
640
726
  return "", []
641
727
  if isinstance(raw_content, str):
@@ -645,8 +731,8 @@ class StreamRouter:
645
731
  "message `content` must be a list or string"
646
732
  )
647
733
 
648
- text_parts: 'typing.List[str]' = []
649
- image_parts: 'typing.List[typing.Dict[str, object]]' = []
734
+ text_parts: "typing.List[str]" = []
735
+ image_parts: "typing.List[typing.Dict[str, object]]" = []
650
736
  for part in raw_content:
651
737
  if not isinstance(part, dict):
652
738
  raise UnsupportedIncommingFeature(
@@ -666,14 +752,14 @@ class StreamRouter:
666
752
 
667
753
  def _build_chat_image_part(
668
754
  self,
669
- part: 'typing.Dict[str, object]',
670
- ) -> 'typing.Dict[str, object]':
755
+ part: "typing.Dict[str, object]",
756
+ ) -> "typing.Dict[str, object]":
671
757
  image_url = str(part.get("image_url", "") or "").strip()
672
758
  if not image_url:
673
759
  raise UnsupportedIncommingFeature(
674
760
  "`input_image` content parts must carry a non-empty `image_url`"
675
761
  )
676
- image_payload: 'typing.Dict[str, object]' = {"url": image_url}
762
+ image_payload: "typing.Dict[str, object]" = {"url": image_url}
677
763
  detail = part.get("detail")
678
764
  if isinstance(detail, str) and detail in {"auto", "low", "high"}:
679
765
  image_payload["detail"] = detail
@@ -681,18 +767,18 @@ class StreamRouter:
681
767
 
682
768
  def _split_tool_output_parts(
683
769
  self,
684
- raw_output: 'object',
685
- ) -> 'typing.Tuple[str, typing.List[typing.Dict[str, object]]]':
770
+ raw_output: "object",
771
+ ) -> "typing.Tuple[str, typing.List[typing.Dict[str, object]]]":
686
772
  if isinstance(raw_output, str):
687
773
  return raw_output, []
688
774
  if isinstance(raw_output, list):
689
775
  return self._split_content_parts(raw_output)
690
776
  return json.dumps(raw_output, ensure_ascii=False), []
691
777
 
692
- def _coalesce_reasoning_text(self, raw_item: 'typing.Dict[str, object]') -> 'str':
778
+ def _coalesce_reasoning_text(self, raw_item: "typing.Dict[str, object]") -> "str":
693
779
  content = raw_item.get("content")
694
780
  if isinstance(content, list):
695
- text_parts: 'typing.List[str]' = []
781
+ text_parts: "typing.List[str]" = []
696
782
  for part in content:
697
783
  if not isinstance(part, dict):
698
784
  continue
@@ -719,8 +805,10 @@ class StreamRouter:
719
805
  return value
720
806
  return ""
721
807
 
722
- def _translate_tools(self, incomming_tools: 'typing.List[object]') -> 'typing.List[typing.Dict[str, object]]':
723
- translated: 'typing.List[typing.Dict[str, object]]' = []
808
+ def _translate_tools(
809
+ self, incomming_tools: "typing.List[object]"
810
+ ) -> "typing.List[typing.Dict[str, object]]":
811
+ translated: "typing.List[typing.Dict[str, object]]" = []
724
812
  for raw_tool in incomming_tools:
725
813
  if not isinstance(raw_tool, dict):
726
814
  raise UnsupportedIncommingFeature("tool definitions must be objects")
@@ -755,7 +843,7 @@ class StreamRouter:
755
843
  )
756
844
  return translated
757
845
 
758
- def _translate_tool_choice(self, raw_tool_choice: 'object') -> 'object':
846
+ def _translate_tool_choice(self, raw_tool_choice: "object") -> "object":
759
847
  if isinstance(raw_tool_choice, str):
760
848
  return raw_tool_choice
761
849
  if not isinstance(raw_tool_choice, dict):
@@ -778,14 +866,14 @@ class StreamRouter:
778
866
 
779
867
  def _consume_chat_chunk(
780
868
  self,
781
- payload: 'typing.Dict[str, object]',
782
- reasoning_parts: 'typing.List[str]',
783
- text_parts: 'typing.List[str]',
784
- tool_calls: 'typing.Dict[int, typing.Dict[str, object]]',
785
- current_usage: 'typing.Dict[str, object]',
786
- finish_reasons: 'typing.List[str]',
787
- ) -> 'typing.List[typing.Tuple[str, typing.Dict[str, object]]]':
788
- events: 'typing.List[typing.Tuple[str, typing.Dict[str, object]]]' = []
869
+ payload: "typing.Dict[str, object]",
870
+ reasoning_parts: "typing.List[str]",
871
+ text_parts: "typing.List[str]",
872
+ tool_calls: "typing.Dict[int, typing.Dict[str, object]]",
873
+ current_usage: "typing.Dict[str, object]",
874
+ finish_reasons: "typing.List[str]",
875
+ ) -> "typing.List[typing.Tuple[str, typing.Dict[str, object]]]":
876
+ events: "typing.List[typing.Tuple[str, typing.Dict[str, object]]]" = []
789
877
  usage = payload.get("usage")
790
878
  if isinstance(usage, dict):
791
879
  self._capture_usage_snapshot(current_usage, usage)
@@ -876,9 +964,9 @@ class StreamRouter:
876
964
 
877
965
  def _capture_usage_snapshot(
878
966
  self,
879
- current_usage: 'typing.Dict[str, object]',
880
- usage: 'typing.Dict[str, object]',
881
- ) -> 'None':
967
+ current_usage: "typing.Dict[str, object]",
968
+ usage: "typing.Dict[str, object]",
969
+ ) -> "None":
882
970
  scalar_mappings = (
883
971
  ("input_tokens", usage.get("input_tokens", usage.get("prompt_tokens"))),
884
972
  (
@@ -910,12 +998,12 @@ class StreamRouter:
910
998
 
911
999
  def _build_output_items(
912
1000
  self,
913
- reasoning_parts: 'typing.List[str]',
914
- text_parts: 'typing.List[str]',
915
- tool_calls: 'typing.Dict[int, typing.Dict[str, object]]',
916
- custom_tool_names: 'typing.Set[str]',
917
- ) -> 'typing.List[typing.Dict[str, object]]':
918
- items: 'typing.List[typing.Dict[str, object]]' = []
1001
+ reasoning_parts: "typing.List[str]",
1002
+ text_parts: "typing.List[str]",
1003
+ tool_calls: "typing.Dict[int, typing.Dict[str, object]]",
1004
+ custom_tool_names: "typing.Set[str]",
1005
+ ) -> "typing.List[typing.Dict[str, object]]":
1006
+ items: "typing.List[typing.Dict[str, object]]" = []
919
1007
  reasoning_text = "".join(reasoning_parts)
920
1008
  if reasoning_text:
921
1009
  items.append(
@@ -968,11 +1056,18 @@ class StreamRouter:
968
1056
  except CustomToolAdapterError as exc:
969
1057
  raise OutcommingChatError(str(exc)) from exc
970
1058
  continue
1059
+ try:
1060
+ json.loads(arguments)
1061
+ except json.JSONDecodeError as exc:
1062
+ raise OutcommingChatError(
1063
+ f"outcomming function call arguments for {name!r} "
1064
+ f"contain invalid JSON: {exc}",
1065
+ error_type="server_error",
1066
+ ) from exc
971
1067
  items.append(
972
1068
  {
973
1069
  "type": "function_call",
974
- "call_id": str(tool_call.get("id", "")).strip()
975
- or f"call_{index}",
1070
+ "call_id": str(tool_call.get("id", "")).strip() or f"call_{index}",
976
1071
  "name": name,
977
1072
  "arguments": arguments,
978
1073
  }
@@ -980,15 +1075,27 @@ class StreamRouter:
980
1075
 
981
1076
  return items
982
1077
 
983
- def _request_json(self, request: 'urllib.request.Request') -> 'typing.Dict[str, object]':
1078
+ def _request_json(
1079
+ self, request: "urllib.request.Request"
1080
+ ) -> "typing.Dict[str, object]":
1081
+ with self._request_diagnostics(request) as progress:
1082
+ return self._read_json_response(request, progress)
1083
+
1084
+ def _read_json_response(self, request, progress):
984
1085
  try:
985
1086
  with urllib.request.urlopen(
986
1087
  request,
987
1088
  context=ssl.create_default_context(),
988
1089
  timeout=self._config.timeout_seconds,
989
1090
  ) as response:
1091
+ progress["headers_at"] = time.monotonic()
1092
+ progress["status"] = response.status
1093
+ progress["phase"] = "reading_response_body"
990
1094
  return json.loads(response.read().decode("utf-8"))
991
1095
  except urllib.error.HTTPError as exc:
1096
+ progress["headers_at"] = time.monotonic()
1097
+ progress["status"] = exc.code
1098
+ progress["phase"] = "reading_error_body"
992
1099
  body = exc.read().decode("utf-8", errors="replace")
993
1100
  raise OutcommingChatError(
994
1101
  f"outcomming request failed with status {exc.code}: {body[:500]}"
@@ -1002,8 +1109,12 @@ class StreamRouter:
1002
1109
  raise OutcommingChatError(
1003
1110
  f"outcomming request failed: {exc.reason}"
1004
1111
  ) from exc
1112
+ except (OSError, EOFError, http.client.HTTPException) as exc:
1113
+ raise OutcommingChatError(
1114
+ f"outcomming request failed: {type(exc).__name__}: {exc}"
1115
+ ) from exc
1005
1116
 
1006
- def _build_headers(self, accept: 'str') -> 'typing.Dict[str, str]':
1117
+ def _build_headers(self, accept: "str") -> "typing.Dict[str, str]":
1007
1118
  headers = {
1008
1119
  "Accept": accept,
1009
1120
  "Content-Type": "application/json",
@@ -1014,8 +1125,8 @@ class StreamRouter:
1014
1125
  return headers
1015
1126
 
1016
1127
  def _iter_sse_events(self, response):
1017
- event_name: 'typing.Union[str, None]' = None
1018
- data_lines: 'typing.List[str]' = []
1128
+ event_name: "typing.Union[str, None]" = None
1129
+ data_lines: "typing.List[str]" = []
1019
1130
 
1020
1131
  for raw_line in response:
1021
1132
  line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")