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.
- pycodex/__init__.py +18 -14
- pycodex/agent.py +468 -462
- pycodex/bootstrap.py +417 -0
- pycodex/cli.py +236 -436
- pycodex/compat.py +19 -5
- pycodex/context.py +222 -212
- pycodex/doctor.py +52 -48
- pycodex/events.py +857 -0
- pycodex/feishu_card.py +217 -163
- pycodex/feishu_link.py +43 -83
- pycodex/model.py +329 -252
- pycodex/model_metadata.py +19 -7
- pycodex/portable.py +90 -52
- pycodex/portable_server.py +32 -24
- pycodex/prompts/models.json +235 -803
- pycodex/protocol.py +177 -137
- pycodex/runtime.py +579 -174
- pycodex/runtime_services.py +204 -157
- pycodex/tools/__init__.py +4 -1
- pycodex/tools/apply_patch_tool.py +69 -48
- pycodex/tools/base_tool.py +89 -42
- pycodex/tools/clock_tool.py +201 -0
- pycodex/tools/close_agent_tool.py +2 -2
- pycodex/tools/code_mode_manager.py +77 -64
- pycodex/tools/exec_command_tool.py +26 -11
- pycodex/tools/exec_tool.py +4 -4
- pycodex/tools/grep_files_tool.py +12 -10
- pycodex/tools/ipython_tool.py +10 -13
- pycodex/tools/list_dir_tool.py +13 -9
- pycodex/tools/read_file_tool.py +29 -17
- pycodex/tools/request_permissions_tool.py +15 -5
- pycodex/tools/request_user_input_tool.py +13 -104
- pycodex/tools/resume_agent_tool.py +2 -2
- pycodex/tools/send_input_tool.py +11 -8
- pycodex/tools/shell_command_tool.py +7 -5
- pycodex/tools/shell_tool.py +7 -5
- pycodex/tools/spawn_agent_tool.py +7 -4
- pycodex/tools/unified_exec_manager.py +102 -69
- pycodex/tools/update_plan_tool.py +8 -5
- pycodex/tools/view_image_tool.py +13 -13
- pycodex/tools/wait_agent_tool.py +27 -4
- pycodex/tools/wait_tool.py +5 -4
- pycodex/tools/web_search_tool.py +4 -2
- pycodex/tools/write_stdin_tool.py +12 -11
- pycodex/utils/__init__.py +2 -17
- pycodex/utils/compactor.py +50 -66
- pycodex/utils/debug.py +2 -2
- pycodex/utils/dotenv.py +6 -7
- pycodex/utils/event_helpers.py +190 -0
- pycodex/utils/get_env.py +27 -70
- pycodex/utils/image_utils.py +76 -0
- pycodex/utils/random_ids.py +1 -2
- pycodex/utils/session_persist.py +263 -161
- pycodex/utils/truncation.py +21 -45
- python_codex-0.3.0.dist-info/METADATA +704 -0
- python_codex-0.3.0.dist-info/RECORD +90 -0
- responses_server/__init__.py +1 -5
- responses_server/__main__.py +0 -1
- responses_server/app.py +36 -31
- responses_server/config.py +25 -22
- responses_server/messages_api.py +96 -49
- responses_server/payload_processors.py +25 -19
- responses_server/server.py +11 -11
- responses_server/session_store.py +14 -11
- responses_server/stream_router.py +196 -107
- responses_server/tools/custom_adapter.py +17 -16
- responses_server/tools/web_search.py +39 -36
- responses_server/trajectory_dump.py +51 -13
- workspace_server/__main__.py +0 -1
- workspace_server/app.py +470 -384
- workspace_server/workspace.html +859 -232
- workspace_server/workspaces.html +94 -95
- workspace_server/workspaces.py +168 -100
- pycodex/collaboration.py +0 -20
- pycodex/interactive_session.py +0 -415
- pycodex/prompts/collaboration_default.md +0 -11
- pycodex/prompts/collaboration_plan.md +0 -128
- pycodex/utils/toolcall_visualize.py +0 -713
- pycodex/utils/visualize.py +0 -553
- python_codex-0.2.6.dist-info/METADATA +0 -441
- python_codex-0.2.6.dist-info/RECORD +0 -91
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
42
|
-
error_type:
|
|
43
|
-
) ->
|
|
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:
|
|
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:
|
|
56
|
-
default:
|
|
57
|
-
) ->
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
88
|
-
) ->
|
|
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:
|
|
108
|
-
) ->
|
|
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) ->
|
|
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:
|
|
122
|
-
) ->
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
296
|
-
outcomming_request:
|
|
297
|
-
custom_tool_names:
|
|
298
|
-
trajectory_dump:
|
|
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:
|
|
314
|
-
reasoning_parts:
|
|
315
|
-
latest_usage:
|
|
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:
|
|
325
|
-
finish_reasons:
|
|
326
|
-
current_usage:
|
|
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:
|
|
441
|
-
trajectory_dump:
|
|
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(
|
|
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:
|
|
451
|
-
input_items:
|
|
452
|
-
) ->
|
|
453
|
-
messages:
|
|
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:
|
|
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() ->
|
|
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.
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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:
|
|
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:
|
|
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(
|
|
650
|
-
|
|
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:
|
|
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:
|
|
709
|
-
reasoning_parts:
|
|
710
|
-
text_parts:
|
|
711
|
-
tool_calls:
|
|
712
|
-
current_usage:
|
|
713
|
-
finish_reasons:
|
|
714
|
-
) ->
|
|
715
|
-
events:
|
|
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
|
|
740
|
-
|
|
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:
|
|
799
|
-
usage:
|
|
800
|
-
) ->
|
|
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:
|
|
833
|
-
text_parts:
|
|
834
|
-
tool_calls:
|
|
835
|
-
custom_tool_names:
|
|
836
|
-
) ->
|
|
837
|
-
items:
|
|
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(
|
|
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:
|
|
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:
|
|
932
|
-
data_lines:
|
|
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")
|