minimalcode 1.5.2__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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- minimalcode-1.5.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
"""OpenAI Responses API payloads, streams, and response parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx2
|
|
10
|
+
|
|
11
|
+
from ..errors import ProviderError
|
|
12
|
+
from .common import (
|
|
13
|
+
content_text,
|
|
14
|
+
decode_json_body,
|
|
15
|
+
is_event_stream,
|
|
16
|
+
normalize_usage,
|
|
17
|
+
strip_sse_bom,
|
|
18
|
+
tool_arguments,
|
|
19
|
+
)
|
|
20
|
+
from .types import ProviderReply, ToolCall
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ResponsesProtocolMixin:
|
|
24
|
+
"""Responses API wire format for the provider client."""
|
|
25
|
+
|
|
26
|
+
# Attributes supplied by OpenAICompatibleProvider.
|
|
27
|
+
model: str
|
|
28
|
+
thinking: str
|
|
29
|
+
stream_enabled: bool
|
|
30
|
+
send_reasoning_effort: bool
|
|
31
|
+
config: dict[str, Any]
|
|
32
|
+
|
|
33
|
+
def _open(self, payload: dict[str, Any]) -> httpx2.Response:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
def _responses_input(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
37
|
+
"""Convert stored Chat-style messages into Responses input items."""
|
|
38
|
+
items: list[dict[str, Any]] = []
|
|
39
|
+
for message in messages:
|
|
40
|
+
if not isinstance(message, dict):
|
|
41
|
+
continue
|
|
42
|
+
role = message.get("role")
|
|
43
|
+
if not isinstance(role, str):
|
|
44
|
+
# Session history is user-editable; a non-string role cannot be
|
|
45
|
+
# sent as an input item and must not raise AttributeError.
|
|
46
|
+
continue
|
|
47
|
+
content = message.get("content")
|
|
48
|
+
if role == "tool":
|
|
49
|
+
items.append(
|
|
50
|
+
{
|
|
51
|
+
"type": "function_call_output",
|
|
52
|
+
"call_id": str(message.get("tool_call_id") or ""),
|
|
53
|
+
"output": content if isinstance(content, str) else "",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
continue
|
|
57
|
+
if role == "assistant" and isinstance(message.get("tool_calls"), list):
|
|
58
|
+
if content:
|
|
59
|
+
items.append({"role": "assistant", "content": content})
|
|
60
|
+
for call in message["tool_calls"]:
|
|
61
|
+
if not isinstance(call, dict):
|
|
62
|
+
continue
|
|
63
|
+
function = call.get("function") or {}
|
|
64
|
+
if not isinstance(function, dict):
|
|
65
|
+
continue
|
|
66
|
+
items.append(
|
|
67
|
+
{
|
|
68
|
+
"type": "function_call",
|
|
69
|
+
"call_id": str(call.get("id") or ""),
|
|
70
|
+
"name": str(function.get("name") or ""),
|
|
71
|
+
"arguments": tool_arguments(function.get("arguments")),
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
if content is None or content == "":
|
|
76
|
+
# Transport items with an empty payload are rejected by strict
|
|
77
|
+
# Responses-compatible servers; there is nothing to send.
|
|
78
|
+
continue
|
|
79
|
+
items.append({"role": role, "content": content})
|
|
80
|
+
return items
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _responses_tools(
|
|
84
|
+
tools: list[dict[str, Any]] | None,
|
|
85
|
+
) -> list[dict[str, Any]] | None:
|
|
86
|
+
"""Flatten Chat-style function schemas into Responses tool objects."""
|
|
87
|
+
if not tools:
|
|
88
|
+
return None
|
|
89
|
+
result: list[dict[str, Any]] = []
|
|
90
|
+
for tool in tools:
|
|
91
|
+
function = tool.get("function") if isinstance(tool, dict) else None
|
|
92
|
+
if not isinstance(function, dict):
|
|
93
|
+
continue
|
|
94
|
+
result.append(
|
|
95
|
+
{
|
|
96
|
+
"type": "function",
|
|
97
|
+
"name": function.get("name", ""),
|
|
98
|
+
"description": function.get("description", ""),
|
|
99
|
+
"parameters": function.get("parameters", {"type": "object"}),
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
def _responses_payload(
|
|
105
|
+
self,
|
|
106
|
+
messages: list[dict[str, Any]],
|
|
107
|
+
tools: list[dict[str, Any]] | None,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Build the Responses request body."""
|
|
110
|
+
payload: dict[str, Any] = {
|
|
111
|
+
"model": self.model,
|
|
112
|
+
"input": self._responses_input(messages),
|
|
113
|
+
"stream": self.stream_enabled,
|
|
114
|
+
}
|
|
115
|
+
response_tools = self._responses_tools(tools)
|
|
116
|
+
if response_tools:
|
|
117
|
+
payload["tools"] = response_tools
|
|
118
|
+
payload["tool_choice"] = "auto"
|
|
119
|
+
if self.send_reasoning_effort:
|
|
120
|
+
effort = self.thinking.strip().lower()
|
|
121
|
+
if effort:
|
|
122
|
+
payload["reasoning"] = {"effort": effort}
|
|
123
|
+
extra = self.config.get("extra_body")
|
|
124
|
+
if isinstance(extra, dict):
|
|
125
|
+
# Never let extra_body override protocol-critical fields.
|
|
126
|
+
reserved = {"model", "input", "stream", "tools", "tool_choice"}
|
|
127
|
+
payload.update(
|
|
128
|
+
{key: value for key, value in extra.items() if key not in reserved}
|
|
129
|
+
)
|
|
130
|
+
return payload
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _parse_responses_output(
|
|
134
|
+
output: Any,
|
|
135
|
+
) -> tuple[str, list[ToolCall]]:
|
|
136
|
+
"""Extract text and function calls from a Responses ``output`` list."""
|
|
137
|
+
if not isinstance(output, list):
|
|
138
|
+
return "", []
|
|
139
|
+
content_parts: list[str] = []
|
|
140
|
+
calls: list[ToolCall] = []
|
|
141
|
+
for item in output:
|
|
142
|
+
if not isinstance(item, dict):
|
|
143
|
+
continue
|
|
144
|
+
item_type = item.get("type")
|
|
145
|
+
if not isinstance(item_type, str):
|
|
146
|
+
# Providers and gateways do send non-string types. Comparing one
|
|
147
|
+
# against a set raises TypeError and would abort the whole turn
|
|
148
|
+
# instead of ignoring the unusable item, which is what an
|
|
149
|
+
# unknown type already does.
|
|
150
|
+
continue
|
|
151
|
+
if item_type == "message":
|
|
152
|
+
text = content_text(item.get("content"))
|
|
153
|
+
if text:
|
|
154
|
+
content_parts.append(text)
|
|
155
|
+
elif item_type in {"output_text", "text"}:
|
|
156
|
+
text = content_text(item)
|
|
157
|
+
if text:
|
|
158
|
+
content_parts.append(text)
|
|
159
|
+
elif item_type == "function_call":
|
|
160
|
+
name = str(item.get("name") or "")
|
|
161
|
+
if name:
|
|
162
|
+
calls.append(
|
|
163
|
+
ToolCall(
|
|
164
|
+
id=str(item.get("call_id") or item.get("id") or ""),
|
|
165
|
+
name=name,
|
|
166
|
+
arguments=tool_arguments(item.get("arguments")),
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
for index, call in enumerate(calls):
|
|
170
|
+
if not call.id:
|
|
171
|
+
call.id = f"call_{index}"
|
|
172
|
+
return "".join(content_parts), calls
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _response_finish(response: dict[str, Any], has_calls: bool) -> str:
|
|
176
|
+
"""Map a Responses status object to a finish reason."""
|
|
177
|
+
status = str(response.get("status") or "")
|
|
178
|
+
if status == "incomplete":
|
|
179
|
+
details = response.get("incomplete_details")
|
|
180
|
+
if isinstance(details, dict) and details.get("reason"):
|
|
181
|
+
return str(details["reason"])
|
|
182
|
+
return "incomplete"
|
|
183
|
+
if status == "failed":
|
|
184
|
+
return "failed"
|
|
185
|
+
if status and status != "completed":
|
|
186
|
+
return status
|
|
187
|
+
return "tool_calls" if has_calls else "stop"
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _response_error(event: dict[str, Any]) -> ProviderError:
|
|
191
|
+
"""Build a ProviderError from a Responses failure event."""
|
|
192
|
+
response = event.get("response")
|
|
193
|
+
if isinstance(response, dict) and response.get("error"):
|
|
194
|
+
error = response["error"]
|
|
195
|
+
else:
|
|
196
|
+
error = event.get("error") or event.get("message") or event
|
|
197
|
+
return ProviderError(f"provider response failed: {error}")
|
|
198
|
+
|
|
199
|
+
def _responses_stream_request(
|
|
200
|
+
self,
|
|
201
|
+
payload: dict[str, Any],
|
|
202
|
+
*,
|
|
203
|
+
on_text: Callable[[str], None] | None,
|
|
204
|
+
on_thinking: Callable[[str], None] | None,
|
|
205
|
+
) -> ProviderReply:
|
|
206
|
+
"""Consume Responses SSE events and assemble the final reply."""
|
|
207
|
+
content_parts: list[str] = []
|
|
208
|
+
reasoning_parts: list[str] = []
|
|
209
|
+
fallback_text: list[str] = []
|
|
210
|
+
call_states: dict[str, dict[str, str]] = {}
|
|
211
|
+
# Events address an item either by `item_id` or by `output_index`,
|
|
212
|
+
# depending on the implementation; this alias map lets both spellings
|
|
213
|
+
# resolve to the same accumulated state.
|
|
214
|
+
output_keys: dict[str, str] = {}
|
|
215
|
+
final_response: dict[str, Any] = {}
|
|
216
|
+
usage: dict[str, int] = {}
|
|
217
|
+
|
|
218
|
+
def state_for(key: str) -> dict[str, str]:
|
|
219
|
+
"""Return mutable call state for an item id or output index."""
|
|
220
|
+
return call_states.setdefault(
|
|
221
|
+
key,
|
|
222
|
+
{"id": "", "name": "", "arguments": "", "index": str(len(call_states))},
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def merge_arguments(state: dict[str, str], arguments: Any) -> None:
|
|
226
|
+
"""Apply a whole arguments value on top of accumulated deltas."""
|
|
227
|
+
if isinstance(arguments, str):
|
|
228
|
+
# Whole-string events (`output_item.*`, `arguments.done`) carry the
|
|
229
|
+
# finished JSON string, so they replace streamed deltas; an empty
|
|
230
|
+
# string only stands in when nothing was streamed at all.
|
|
231
|
+
if arguments or not state["arguments"]:
|
|
232
|
+
state["arguments"] = arguments
|
|
233
|
+
elif isinstance(arguments, dict):
|
|
234
|
+
state["arguments"] = tool_arguments(arguments)
|
|
235
|
+
|
|
236
|
+
def update_call(item: dict[str, Any], key: str) -> None:
|
|
237
|
+
"""Merge a function-call item into its accumulated state."""
|
|
238
|
+
state = state_for(key)
|
|
239
|
+
if item.get("call_id"):
|
|
240
|
+
state["id"] = str(item["call_id"])
|
|
241
|
+
elif item.get("id"):
|
|
242
|
+
state["id"] = str(item["id"])
|
|
243
|
+
if item.get("name"):
|
|
244
|
+
state["name"] = str(item["name"])
|
|
245
|
+
merge_arguments(state, item.get("arguments"))
|
|
246
|
+
|
|
247
|
+
def add_fallback_text(text: str) -> None:
|
|
248
|
+
"""Remember completed text for servers that omit delta events."""
|
|
249
|
+
# Several implementations stream only `output_text.done` and then
|
|
250
|
+
# repeat the same text in the completed message item; dedupe so the
|
|
251
|
+
# fallback path cannot print the answer twice.
|
|
252
|
+
if text and (not fallback_text or fallback_text[-1] != text):
|
|
253
|
+
fallback_text.append(text)
|
|
254
|
+
|
|
255
|
+
response = self._open(payload)
|
|
256
|
+
try:
|
|
257
|
+
if not is_event_stream(response):
|
|
258
|
+
body = self._read_body(response)
|
|
259
|
+
parsed, data = decode_json_body(body)
|
|
260
|
+
if parsed:
|
|
261
|
+
return self._parse_responses_payload(
|
|
262
|
+
data, on_text=on_text, on_thinking=on_thinking
|
|
263
|
+
)
|
|
264
|
+
text = body.decode("utf-8", errors="replace")
|
|
265
|
+
if "data:" not in text:
|
|
266
|
+
raise ProviderError("provider returned an invalid response")
|
|
267
|
+
lines: Iterable[str] = text.splitlines()
|
|
268
|
+
else:
|
|
269
|
+
lines = response.iter_lines()
|
|
270
|
+
try:
|
|
271
|
+
for data in self._sse_events(lines):
|
|
272
|
+
# The assembler strips a line-leading BOM; a payload that
|
|
273
|
+
# carries one after `data: ` is stripped here.
|
|
274
|
+
data = strip_sse_bom(data).strip()
|
|
275
|
+
if not data or data == "[DONE]":
|
|
276
|
+
continue
|
|
277
|
+
try:
|
|
278
|
+
event = json.loads(data)
|
|
279
|
+
except json.JSONDecodeError:
|
|
280
|
+
continue
|
|
281
|
+
if not isinstance(event, dict):
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
event_type = str(event.get("type") or "")
|
|
285
|
+
if not event_type and event.get("error"):
|
|
286
|
+
raise self._response_error(event)
|
|
287
|
+
if event_type == "response.output_text.delta":
|
|
288
|
+
text = content_text(event.get("delta"))
|
|
289
|
+
if text:
|
|
290
|
+
content_parts.append(text)
|
|
291
|
+
if on_text is not None:
|
|
292
|
+
on_text(text)
|
|
293
|
+
continue
|
|
294
|
+
if event_type == "response.output_text.done":
|
|
295
|
+
add_fallback_text(content_text(event.get("text")))
|
|
296
|
+
continue
|
|
297
|
+
if event_type in {
|
|
298
|
+
"response.reasoning_summary_text.delta",
|
|
299
|
+
"response.reasoning_text.delta",
|
|
300
|
+
}:
|
|
301
|
+
# Reasoning summaries arrive as their own items; they are
|
|
302
|
+
# display-only and never part of the assistant content.
|
|
303
|
+
text = content_text(event.get("delta"))
|
|
304
|
+
if text:
|
|
305
|
+
reasoning_parts.append(text)
|
|
306
|
+
if on_thinking is not None:
|
|
307
|
+
on_thinking(text)
|
|
308
|
+
continue
|
|
309
|
+
if event_type == "response.output_item.added":
|
|
310
|
+
item = event.get("item")
|
|
311
|
+
if (
|
|
312
|
+
isinstance(item, dict)
|
|
313
|
+
and item.get("type") == "function_call"
|
|
314
|
+
):
|
|
315
|
+
key = str(item.get("id") or event.get("output_index", "0"))
|
|
316
|
+
if "output_index" in event:
|
|
317
|
+
output_keys[str(event["output_index"])] = key
|
|
318
|
+
update_call(item, key)
|
|
319
|
+
continue
|
|
320
|
+
if event_type == "response.function_call_arguments.delta":
|
|
321
|
+
raw_key = str(
|
|
322
|
+
event.get("item_id") or event.get("output_index", "0")
|
|
323
|
+
)
|
|
324
|
+
key = output_keys.get(raw_key, raw_key)
|
|
325
|
+
state = state_for(key)
|
|
326
|
+
delta = event.get("delta")
|
|
327
|
+
if isinstance(delta, str):
|
|
328
|
+
state["arguments"] += delta
|
|
329
|
+
elif delta is not None:
|
|
330
|
+
state["arguments"] = tool_arguments(delta)
|
|
331
|
+
continue
|
|
332
|
+
if event_type == "response.function_call_arguments.done":
|
|
333
|
+
raw_key = str(
|
|
334
|
+
event.get("item_id") or event.get("output_index", "0")
|
|
335
|
+
)
|
|
336
|
+
key = output_keys.get(raw_key, raw_key)
|
|
337
|
+
state = state_for(key)
|
|
338
|
+
# `merge_arguments` handles both forms: a finished JSON
|
|
339
|
+
# string replaces the deltas, and an object payload is
|
|
340
|
+
# serialized the same way an item's arguments are.
|
|
341
|
+
merge_arguments(state, event.get("arguments"))
|
|
342
|
+
continue
|
|
343
|
+
if event_type == "response.output_item.done":
|
|
344
|
+
item = event.get("item")
|
|
345
|
+
if not isinstance(item, dict):
|
|
346
|
+
continue
|
|
347
|
+
raw_key = str(item.get("id") or event.get("output_index", "0"))
|
|
348
|
+
# Resolve through `output_keys`: an item that repeats the
|
|
349
|
+
# call without an `id` must update the state its deltas
|
|
350
|
+
# were accumulated into instead of opening a second one.
|
|
351
|
+
key = output_keys.get(raw_key, raw_key)
|
|
352
|
+
if "output_index" in event:
|
|
353
|
+
output_keys[str(event["output_index"])] = key
|
|
354
|
+
if item.get("type") == "function_call":
|
|
355
|
+
update_call(item, key)
|
|
356
|
+
elif item.get("type") == "message":
|
|
357
|
+
add_fallback_text(content_text(item.get("content")))
|
|
358
|
+
elif item.get("type") == "reasoning" and not reasoning_parts:
|
|
359
|
+
# Servers that stream no reasoning delta still repeat
|
|
360
|
+
# the finished summary here; emit it exactly once.
|
|
361
|
+
text = content_text(item.get("summary")) or content_text(
|
|
362
|
+
item.get("content")
|
|
363
|
+
)
|
|
364
|
+
if text and on_thinking is not None:
|
|
365
|
+
reasoning_parts.append(text)
|
|
366
|
+
on_thinking(text)
|
|
367
|
+
continue
|
|
368
|
+
if event_type in {"response.completed", "response.incomplete"}:
|
|
369
|
+
completed = event.get("response")
|
|
370
|
+
if isinstance(completed, dict):
|
|
371
|
+
final_response = completed
|
|
372
|
+
parsed_usage = normalize_usage(completed.get("usage"))
|
|
373
|
+
if parsed_usage:
|
|
374
|
+
usage = parsed_usage
|
|
375
|
+
continue
|
|
376
|
+
if event_type in {"response.failed", "error"}:
|
|
377
|
+
raise self._response_error(event)
|
|
378
|
+
except httpx2.HTTPError as exc:
|
|
379
|
+
raise ProviderError(f"provider stream interrupted: {exc}") from exc
|
|
380
|
+
finally:
|
|
381
|
+
response.close()
|
|
382
|
+
|
|
383
|
+
output_text, output_calls = self._parse_responses_output(
|
|
384
|
+
final_response.get("output")
|
|
385
|
+
)
|
|
386
|
+
if output_text and not content_parts:
|
|
387
|
+
content_parts.append(output_text)
|
|
388
|
+
if on_text is not None:
|
|
389
|
+
on_text(output_text)
|
|
390
|
+
elif not content_parts and fallback_text:
|
|
391
|
+
text = "".join(fallback_text)
|
|
392
|
+
content_parts.append(text)
|
|
393
|
+
if on_text is not None:
|
|
394
|
+
on_text(text)
|
|
395
|
+
|
|
396
|
+
states = [
|
|
397
|
+
state
|
|
398
|
+
for state in call_states.values()
|
|
399
|
+
if state["name"] and state["arguments"]
|
|
400
|
+
]
|
|
401
|
+
tool_calls: list[ToolCall] = []
|
|
402
|
+
for call in output_calls:
|
|
403
|
+
if call.arguments in {"", "{}"}:
|
|
404
|
+
# The completed output may repeat a call whose arguments were
|
|
405
|
+
# already streamed, sometimes with an empty string. Falling
|
|
406
|
+
# back to the assembled state keeps the tool call usable.
|
|
407
|
+
match = next(
|
|
408
|
+
(state for state in states if state["id"] == call.id),
|
|
409
|
+
None,
|
|
410
|
+
)
|
|
411
|
+
if match is None:
|
|
412
|
+
by_name = [state for state in states if state["name"] == call.name]
|
|
413
|
+
if len(by_name) == 1:
|
|
414
|
+
match = by_name[0]
|
|
415
|
+
if match is not None:
|
|
416
|
+
call.arguments = match["arguments"]
|
|
417
|
+
tool_calls.append(call)
|
|
418
|
+
if not tool_calls:
|
|
419
|
+
for index, state in enumerate(call_states.values()):
|
|
420
|
+
if not state["name"]:
|
|
421
|
+
continue
|
|
422
|
+
tool_calls.append(
|
|
423
|
+
ToolCall(
|
|
424
|
+
id=state["id"] or f"call_{index}",
|
|
425
|
+
name=state["name"],
|
|
426
|
+
arguments=state["arguments"] or "{}",
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if final_response:
|
|
431
|
+
finish_reason = self._response_finish(final_response, bool(tool_calls))
|
|
432
|
+
else:
|
|
433
|
+
# Neither `response.completed` nor `response.incomplete` arrived, so
|
|
434
|
+
# the stream was cut short; reporting a clean stop here would let a
|
|
435
|
+
# truncated answer or half-received tool call through, as with Chat.
|
|
436
|
+
finish_reason = "incomplete"
|
|
437
|
+
return ProviderReply(
|
|
438
|
+
content="".join(content_parts),
|
|
439
|
+
tool_calls=tool_calls,
|
|
440
|
+
finish_reason=finish_reason,
|
|
441
|
+
usage=usage,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def _parse_responses_payload(
|
|
445
|
+
self,
|
|
446
|
+
data: Any,
|
|
447
|
+
*,
|
|
448
|
+
on_text: Callable[[str], None] | None,
|
|
449
|
+
on_thinking: Callable[[str], None] | None,
|
|
450
|
+
) -> ProviderReply:
|
|
451
|
+
"""Parse one non-streaming Responses payload."""
|
|
452
|
+
if not isinstance(data, dict):
|
|
453
|
+
raise ProviderError("provider response must be an object")
|
|
454
|
+
if data.get("status") == "failed" or data.get("error"):
|
|
455
|
+
detail = (
|
|
456
|
+
data.get("error") or data.get("incomplete_details") or "unknown error"
|
|
457
|
+
)
|
|
458
|
+
raise ProviderError(f"provider response failed: {detail}")
|
|
459
|
+
output = data.get("output")
|
|
460
|
+
if not isinstance(output, list) and not isinstance(
|
|
461
|
+
data.get("output_text"), str
|
|
462
|
+
):
|
|
463
|
+
raise ProviderError("provider response has no output")
|
|
464
|
+
content, calls = self._parse_responses_output(output)
|
|
465
|
+
if not content and isinstance(data.get("output_text"), str):
|
|
466
|
+
content = data["output_text"]
|
|
467
|
+
if content and on_text is not None:
|
|
468
|
+
on_text(content)
|
|
469
|
+
if on_thinking is not None and isinstance(output, list):
|
|
470
|
+
for item in output:
|
|
471
|
+
if not isinstance(item, dict) or item.get("type") != "reasoning":
|
|
472
|
+
continue
|
|
473
|
+
reasoning = content_text(item.get("summary")) or content_text(
|
|
474
|
+
item.get("content")
|
|
475
|
+
)
|
|
476
|
+
if reasoning:
|
|
477
|
+
on_thinking(reasoning)
|
|
478
|
+
return ProviderReply(
|
|
479
|
+
content=content,
|
|
480
|
+
tool_calls=calls,
|
|
481
|
+
finish_reason=self._response_finish(data, bool(calls)),
|
|
482
|
+
usage=normalize_usage(data.get("usage")),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def _parse_responses_sse_body(
|
|
486
|
+
self,
|
|
487
|
+
response: httpx2.Response,
|
|
488
|
+
*,
|
|
489
|
+
on_text: Callable[[str], None] | None,
|
|
490
|
+
on_thinking: Callable[[str], None] | None,
|
|
491
|
+
) -> ProviderReply:
|
|
492
|
+
"""Parse an SSE body received for a non-streaming request."""
|
|
493
|
+
body = self._read_body(response)
|
|
494
|
+
text = body.decode("utf-8", errors="replace")
|
|
495
|
+
if "data:" not in text:
|
|
496
|
+
raise ProviderError("provider returned an invalid response")
|
|
497
|
+
content_parts: list[str] = []
|
|
498
|
+
fallback_text: list[str] = []
|
|
499
|
+
call_states: dict[str, dict[str, str]] = {}
|
|
500
|
+
output_keys: dict[str, str] = {}
|
|
501
|
+
final_response: dict[str, Any] = {}
|
|
502
|
+
usage: dict[str, int] = {}
|
|
503
|
+
|
|
504
|
+
def state_for(key: str) -> dict[str, str]:
|
|
505
|
+
return call_states.setdefault(key, {"id": "", "name": "", "arguments": ""})
|
|
506
|
+
|
|
507
|
+
def update_call(item: dict[str, Any], key: str) -> None:
|
|
508
|
+
state = state_for(key)
|
|
509
|
+
if item.get("call_id"):
|
|
510
|
+
state["id"] = str(item["call_id"])
|
|
511
|
+
elif item.get("id"):
|
|
512
|
+
state["id"] = str(item["id"])
|
|
513
|
+
if item.get("name"):
|
|
514
|
+
state["name"] = str(item["name"])
|
|
515
|
+
arguments = item.get("arguments")
|
|
516
|
+
if isinstance(arguments, str):
|
|
517
|
+
if arguments or not state["arguments"]:
|
|
518
|
+
state["arguments"] = arguments
|
|
519
|
+
elif isinstance(arguments, dict):
|
|
520
|
+
state["arguments"] = tool_arguments(arguments)
|
|
521
|
+
|
|
522
|
+
def add_fallback(text: str) -> None:
|
|
523
|
+
if text and (not fallback_text or fallback_text[-1] != text):
|
|
524
|
+
fallback_text.append(text)
|
|
525
|
+
|
|
526
|
+
for data in self._sse_events(text.splitlines()):
|
|
527
|
+
data = strip_sse_bom(data).strip()
|
|
528
|
+
if not data or data == "[DONE]":
|
|
529
|
+
continue
|
|
530
|
+
try:
|
|
531
|
+
event = json.loads(data)
|
|
532
|
+
except json.JSONDecodeError:
|
|
533
|
+
continue
|
|
534
|
+
if not isinstance(event, dict):
|
|
535
|
+
continue
|
|
536
|
+
event_type = str(event.get("type") or "")
|
|
537
|
+
if not event_type and event.get("error"):
|
|
538
|
+
raise self._response_error(event)
|
|
539
|
+
if event_type == "response.output_text.delta":
|
|
540
|
+
delta = content_text(event.get("delta"))
|
|
541
|
+
if delta:
|
|
542
|
+
content_parts.append(delta)
|
|
543
|
+
if on_text is not None:
|
|
544
|
+
on_text(delta)
|
|
545
|
+
elif event_type == "response.output_text.done":
|
|
546
|
+
add_fallback(content_text(event.get("text")))
|
|
547
|
+
elif event_type in {
|
|
548
|
+
"response.output_item.added",
|
|
549
|
+
"response.output_item.done",
|
|
550
|
+
}:
|
|
551
|
+
item = event.get("item")
|
|
552
|
+
if isinstance(item, dict) and item.get("type") == "function_call":
|
|
553
|
+
key = str(item.get("id") or event.get("output_index", "0"))
|
|
554
|
+
if "output_index" in event:
|
|
555
|
+
output_keys[str(event["output_index"])] = key
|
|
556
|
+
update_call(item, key)
|
|
557
|
+
elif isinstance(item, dict) and item.get("type") == "message":
|
|
558
|
+
add_fallback(content_text(item.get("content")))
|
|
559
|
+
elif event_type == "response.function_call_arguments.delta":
|
|
560
|
+
raw_key = str(event.get("item_id") or event.get("output_index", "0"))
|
|
561
|
+
state = state_for(output_keys.get(raw_key, raw_key))
|
|
562
|
+
delta = event.get("delta")
|
|
563
|
+
if isinstance(delta, str):
|
|
564
|
+
state["arguments"] += delta
|
|
565
|
+
elif delta is not None:
|
|
566
|
+
state["arguments"] = tool_arguments(delta)
|
|
567
|
+
elif event_type == "response.function_call_arguments.done":
|
|
568
|
+
raw_key = str(event.get("item_id") or event.get("output_index", "0"))
|
|
569
|
+
state = state_for(output_keys.get(raw_key, raw_key))
|
|
570
|
+
arguments = event.get("arguments")
|
|
571
|
+
if isinstance(arguments, str) and (arguments or not state["arguments"]):
|
|
572
|
+
state["arguments"] = arguments
|
|
573
|
+
elif event_type in {"response.completed", "response.incomplete"}:
|
|
574
|
+
completed = event.get("response")
|
|
575
|
+
if isinstance(completed, dict):
|
|
576
|
+
final_response = completed
|
|
577
|
+
parsed_usage = normalize_usage(completed.get("usage"))
|
|
578
|
+
if parsed_usage:
|
|
579
|
+
usage = parsed_usage
|
|
580
|
+
elif event_type in {"response.failed", "error"}:
|
|
581
|
+
raise self._response_error(event)
|
|
582
|
+
|
|
583
|
+
stream_calls: list[ToolCall] = []
|
|
584
|
+
for index, state in enumerate(call_states.values()):
|
|
585
|
+
if not state["name"]:
|
|
586
|
+
continue
|
|
587
|
+
stream_calls.append(
|
|
588
|
+
ToolCall(
|
|
589
|
+
id=state["id"] or f"call_{index}",
|
|
590
|
+
name=state["name"],
|
|
591
|
+
arguments=state["arguments"] or "{}",
|
|
592
|
+
)
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def finish_content(reply_content: str = "") -> str:
|
|
596
|
+
content = "".join(content_parts)
|
|
597
|
+
if content:
|
|
598
|
+
return content
|
|
599
|
+
if fallback_text:
|
|
600
|
+
content = "".join(fallback_text)
|
|
601
|
+
elif reply_content:
|
|
602
|
+
content = reply_content
|
|
603
|
+
if content and on_text is not None and not content_parts:
|
|
604
|
+
on_text(content)
|
|
605
|
+
return content
|
|
606
|
+
|
|
607
|
+
if final_response:
|
|
608
|
+
has_output = isinstance(final_response.get("output"), list) or isinstance(
|
|
609
|
+
final_response.get("output_text"), str
|
|
610
|
+
)
|
|
611
|
+
reply = (
|
|
612
|
+
self._parse_responses_payload(
|
|
613
|
+
final_response, on_text=None, on_thinking=on_thinking
|
|
614
|
+
)
|
|
615
|
+
if has_output
|
|
616
|
+
else None
|
|
617
|
+
)
|
|
618
|
+
if reply is not None:
|
|
619
|
+
tool_calls = list(reply.tool_calls)
|
|
620
|
+
if tool_calls:
|
|
621
|
+
for call in tool_calls:
|
|
622
|
+
if call.arguments not in {"", "{}"}:
|
|
623
|
+
continue
|
|
624
|
+
match = next(
|
|
625
|
+
(item for item in stream_calls if item.id == call.id),
|
|
626
|
+
None,
|
|
627
|
+
)
|
|
628
|
+
if match is None:
|
|
629
|
+
by_name = [
|
|
630
|
+
item for item in stream_calls if item.name == call.name
|
|
631
|
+
]
|
|
632
|
+
if len(by_name) == 1:
|
|
633
|
+
match = by_name[0]
|
|
634
|
+
if match is not None:
|
|
635
|
+
call.arguments = match.arguments
|
|
636
|
+
else:
|
|
637
|
+
tool_calls = stream_calls
|
|
638
|
+
finish_reason = reply.finish_reason
|
|
639
|
+
if tool_calls and not reply.tool_calls:
|
|
640
|
+
# The tool call came from SSE events rather than `output`, so
|
|
641
|
+
# the payload parser did not know to report `tool_calls`.
|
|
642
|
+
finish_reason = self._response_finish(final_response, True)
|
|
643
|
+
return ProviderReply(
|
|
644
|
+
content=finish_content(reply.content),
|
|
645
|
+
tool_calls=tool_calls,
|
|
646
|
+
finish_reason=finish_reason,
|
|
647
|
+
usage=reply.usage,
|
|
648
|
+
)
|
|
649
|
+
content = finish_content()
|
|
650
|
+
if content or stream_calls:
|
|
651
|
+
return ProviderReply(
|
|
652
|
+
content=content,
|
|
653
|
+
tool_calls=stream_calls,
|
|
654
|
+
finish_reason=self._response_finish(
|
|
655
|
+
final_response, bool(stream_calls)
|
|
656
|
+
),
|
|
657
|
+
usage=usage,
|
|
658
|
+
)
|
|
659
|
+
raise ProviderError("provider response has no output")
|
|
660
|
+
content = finish_content()
|
|
661
|
+
if not content and not stream_calls:
|
|
662
|
+
raise ProviderError("provider returned an empty stream")
|
|
663
|
+
return ProviderReply(
|
|
664
|
+
content=content,
|
|
665
|
+
tool_calls=stream_calls,
|
|
666
|
+
finish_reason="incomplete",
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
def _responses_regular_request(
|
|
670
|
+
self,
|
|
671
|
+
payload: dict[str, Any],
|
|
672
|
+
*,
|
|
673
|
+
on_text: Callable[[str], None] | None,
|
|
674
|
+
on_thinking: Callable[[str], None] | None,
|
|
675
|
+
) -> ProviderReply:
|
|
676
|
+
"""Request a non-streaming Responses reply."""
|
|
677
|
+
response = self._open(payload)
|
|
678
|
+
try:
|
|
679
|
+
# Some gateways ignore `stream: false` and answer with SSE anyway,
|
|
680
|
+
# so the content type, not the request flag, selects the parser.
|
|
681
|
+
if is_event_stream(response):
|
|
682
|
+
return self._parse_responses_sse_body(
|
|
683
|
+
response, on_text=on_text, on_thinking=on_thinking
|
|
684
|
+
)
|
|
685
|
+
parsed, data = decode_json_body(self._read_body(response))
|
|
686
|
+
if not parsed:
|
|
687
|
+
raise ProviderError("provider returned invalid JSON")
|
|
688
|
+
finally:
|
|
689
|
+
response.close()
|
|
690
|
+
return self._parse_responses_payload(
|
|
691
|
+
data, on_text=on_text, on_thinking=on_thinking
|
|
692
|
+
)
|