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.
- pycodex/__init__.py +5 -1
- pycodex/agent.py +39 -41
- pycodex/cli.py +51 -43
- pycodex/collaboration.py +6 -7
- pycodex/compat.py +99 -0
- pycodex/context.py +87 -87
- pycodex/doctor.py +40 -40
- pycodex/model.py +69 -69
- pycodex/portable.py +33 -33
- pycodex/portable_server.py +22 -21
- pycodex/protocol.py +84 -86
- pycodex/runtime.py +36 -35
- pycodex/runtime_services.py +72 -69
- pycodex/tools/agent_tool_schemas.py +0 -2
- pycodex/tools/apply_patch_tool.py +43 -44
- pycodex/tools/base_tool.py +35 -36
- pycodex/tools/close_agent_tool.py +2 -4
- pycodex/tools/code_mode_manager.py +61 -61
- pycodex/tools/exec_command_tool.py +5 -6
- pycodex/tools/exec_runtime.js +3 -3
- pycodex/tools/exec_tool.py +3 -5
- pycodex/tools/grep_files_tool.py +10 -11
- pycodex/tools/list_dir_tool.py +8 -9
- pycodex/tools/read_file_tool.py +13 -14
- pycodex/tools/request_permissions_tool.py +2 -4
- pycodex/tools/request_user_input_tool.py +13 -14
- pycodex/tools/resume_agent_tool.py +2 -4
- pycodex/tools/send_input_tool.py +8 -9
- pycodex/tools/shell_command_tool.py +5 -6
- pycodex/tools/shell_tool.py +5 -6
- pycodex/tools/spawn_agent_tool.py +4 -5
- pycodex/tools/unified_exec_manager.py +79 -61
- pycodex/tools/update_plan_tool.py +4 -5
- pycodex/tools/view_image_tool.py +4 -5
- pycodex/tools/wait_agent_tool.py +2 -4
- pycodex/tools/wait_tool.py +4 -5
- pycodex/tools/web_search_tool.py +1 -3
- pycodex/tools/write_stdin_tool.py +4 -5
- pycodex/utils/dotenv.py +6 -6
- pycodex/utils/get_env.py +57 -34
- pycodex/utils/random_ids.py +1 -2
- pycodex/utils/visualize.py +79 -79
- {python_codex-0.1.1.dist-info → python_codex-0.1.3.dist-info}/METADATA +15 -9
- python_codex-0.1.3.dist-info/RECORD +74 -0
- {python_codex-0.1.1.dist-info → python_codex-0.1.3.dist-info}/WHEEL +1 -1
- responses_server/__init__.py +17 -0
- responses_server/__main__.py +5 -0
- responses_server/app.py +227 -0
- responses_server/config.py +63 -0
- responses_server/payload_processors.py +86 -0
- responses_server/server.py +63 -0
- responses_server/session_store.py +37 -0
- responses_server/stream_router.py +784 -0
- responses_server/tools/__init__.py +4 -0
- responses_server/tools/custom_adapter.py +235 -0
- responses_server/tools/web_search.py +263 -0
- python_codex-0.1.1.dist-info/RECORD +0 -62
- {python_codex-0.1.1.dist-info → python_codex-0.1.3.dist-info}/entry_points.txt +0 -0
- {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)
|