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.
Files changed (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. 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
+ )