pi-ai-python 1.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.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,840 @@
1
+ # pyright: reportAttributeAccessIssue=false, reportArgumentType=false
2
+ # Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
3
+ """Message/tool conversion and stream processing shared by the OpenAI Responses API family.
4
+
5
+ Used by ``openai-responses``, ``azure-openai-responses`` and ``openai-codex-responses``.
6
+ Request items and stream events are plain dicts in the OpenAI Responses wire format.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from collections.abc import AsyncIterable, Callable, Mapping, Sequence
14
+ from collections.abc import Set as AbstractSet
15
+ from typing import Any, Literal, TypedDict
16
+
17
+ from ..event_stream import AssistantMessageEventStream
18
+ from ..models import calculate_cost
19
+ from ..types import (
20
+ AssistantMessage,
21
+ ImageContent,
22
+ Model,
23
+ StopReason,
24
+ SystemMessage,
25
+ TextContent,
26
+ TextDeltaEvent,
27
+ TextEndEvent,
28
+ TextStartEvent,
29
+ ThinkingContent,
30
+ ThinkingDeltaEvent,
31
+ ThinkingEndEvent,
32
+ ThinkingStartEvent,
33
+ Tool,
34
+ ToolCall,
35
+ ToolCallDeltaEvent,
36
+ ToolCallEndEvent,
37
+ ToolCallStartEvent,
38
+ TranscriptContext,
39
+ Usage,
40
+ )
41
+ from ..utils import fastjson
42
+ from ..utils.callbacks import maybe_await
43
+ from ..utils.json_parse import StreamingJsonParser, parse_tool_call_arguments
44
+ from ..utils.misc import sanitize_surrogates, short_hash
45
+ from ..utils.text import append_text, append_thinking, get_system_message_text, render_system_message_update
46
+ from ..utils.transcript import resolve_transcript, resolve_transcript_tools
47
+ from .constrained_sampling import (
48
+ GrammarToolInputJsonBuffer,
49
+ append_grammar_tool_input_json_delta,
50
+ get_grammar_tool_input,
51
+ get_json_schema_tool_parameters,
52
+ resolve_grammar_constrained_sampling,
53
+ resolve_json_schema_strict_sampling,
54
+ )
55
+ from .transform_messages import transform_messages
56
+
57
+ __all__ = [
58
+ "OpenAIResponsesStreamOptions",
59
+ "ConvertResponsesMessagesOptions",
60
+ "ConvertResponsesToolsOptions",
61
+ "convert_responses_messages",
62
+ "convert_responses_tools",
63
+ "process_responses_stream",
64
+ ]
65
+
66
+ # =============================================================================
67
+ # Utilities
68
+ # =============================================================================
69
+
70
+
71
+ def _json_stringify(value: Any) -> str:
72
+ """Compact JSON like ``JSON.stringify`` (non-ASCII kept as is)."""
73
+ return fastjson.dumps(value)
74
+
75
+
76
+ def _encode_text_signature_v1(item_id: Any, phase: Any = None) -> str:
77
+ payload: dict[str, Any] = {"v": 1}
78
+ if item_id is not None:
79
+ payload["id"] = item_id
80
+ if phase:
81
+ payload["phase"] = phase
82
+ return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
83
+
84
+
85
+ def _parse_text_signature(signature: str | None) -> tuple[str, str | None] | None:
86
+ if not signature:
87
+ return None
88
+ if signature.startswith("{"):
89
+ try:
90
+ parsed = json.loads(signature)
91
+ except ValueError:
92
+ parsed = None # Fall through to legacy plain-string handling.
93
+ if isinstance(parsed, dict):
94
+ version = parsed.get("v")
95
+ if version == 1 and not isinstance(version, bool) and isinstance(parsed.get("id"), str):
96
+ phase = parsed.get("phase")
97
+ return parsed["id"], phase if phase in ("commentary", "final_answer") else None
98
+ return signature, None
99
+
100
+
101
+ def _convert_tool_result_output(model: Model, content: Sequence[TextContent | ImageContent]) -> str | list[Any]:
102
+ text_result = "\n".join(block.text for block in content if block.type == "text")
103
+ images = [block for block in content if block.type == "image"]
104
+ has_text = len(text_result.strip()) > 0
105
+
106
+ if not images or "image" not in model.input:
107
+ if has_text:
108
+ return sanitize_surrogates(text_result)
109
+ return "(see attached image)" if images else "(no tool output)"
110
+
111
+ output: list[Any] = []
112
+ if has_text:
113
+ output.append({"type": "input_text", "text": sanitize_surrogates(text_result)})
114
+ for image in images:
115
+ output.append(
116
+ {"type": "input_image", "detail": "auto", "image_url": f"data:{image.mime_type};base64,{image.data}"}
117
+ )
118
+ return output
119
+
120
+
121
+ class OpenAIResponsesStreamOptions(TypedDict, total=False):
122
+ on_provider_stream_event: Callable[[Any, Any], Any]
123
+ service_tier: str
124
+ #: Grammar (custom) tool name -> its single string input property.
125
+ grammar_tool_input_properties: Mapping[str, str]
126
+ #: ``(response_service_tier, request_service_tier) -> service tier`` used for pricing.
127
+ resolve_service_tier: Callable[[Any, Any], Any]
128
+ #: ``(usage, service_tier) -> None``; adjusts ``usage.cost`` in place.
129
+ apply_service_tier_pricing: Callable[[Usage, Any], None]
130
+
131
+
132
+ class ConvertResponsesToolsOptions(TypedDict, total=False):
133
+ #: Default ``strict`` value of function tools. Absent means ``False``; ``None`` sends ``null``.
134
+ strict: bool | None
135
+ supports_strict_mode: bool
136
+ supports_openai_grammar_tools: bool
137
+ tool_search_result: bool
138
+
139
+
140
+ class ConvertResponsesMessagesOptions(TypedDict, total=False):
141
+ include_system_prompt: bool
142
+ grammar_tool_input_properties: Mapping[str, str]
143
+ #: Whether later system messages are sent in place; otherwise they are folded into the leading prompt.
144
+ supports_mid_convo_system_messages: bool
145
+ supports_additional_tools: bool
146
+ supports_tool_search: bool
147
+ tool_options: ConvertResponsesToolsOptions
148
+ #: Effort for the reply being generated. When set, the thinking level is sent as
149
+ #: ``configuration_update`` items instead of a changing ``reasoning.effort``, which would invalidate
150
+ #: the prompt cache. Each item sets the effort from that point on: one goes before every earlier
151
+ #: reply with the level recorded on it (``provider_thinking_level``), and one with this effort goes
152
+ #: at the end. Earlier input therefore never changes. OpenAI documents the item before user
153
+ #: messages only. After tool results it was verified on gpt-6-luna (October 2026) to be accepted
154
+ #: and to apply to the next reply.
155
+ configuration_update_effort: str
156
+
157
+
158
+ # =============================================================================
159
+ # Message conversion
160
+ # =============================================================================
161
+
162
+ _ID_PART_INVALID = re.compile(r"[^a-zA-Z0-9_-]")
163
+ _TRAILING_UNDERSCORES = re.compile(r"_+$")
164
+
165
+
166
+ def _replace_invalid_id_char(match: re.Match[str]) -> str:
167
+ # JavaScript replaces per UTF-16 code unit, so astral characters become two underscores.
168
+ return "__" if ord(match.group()) > 0xFFFF else "_"
169
+
170
+
171
+ def _normalize_id_part(part: str) -> str:
172
+ sanitized = _ID_PART_INVALID.sub(_replace_invalid_id_char, part)
173
+ normalized = sanitized[:64] if len(sanitized) > 64 else sanitized
174
+ return _TRAILING_UNDERSCORES.sub("", normalized)
175
+
176
+
177
+ def _build_foreign_responses_item_id(item_id: str) -> str:
178
+ normalized = f"fc_{short_hash(item_id)}"
179
+ return normalized[:64] if len(normalized) > 64 else normalized
180
+
181
+
182
+ def convert_responses_messages(
183
+ model: Model,
184
+ context: TranscriptContext,
185
+ allowed_tool_call_providers: AbstractSet[str],
186
+ options: ConvertResponsesMessagesOptions | None = None,
187
+ ) -> list[dict[str, Any]]:
188
+ """Convert a transcript into OpenAI Responses ``input`` items."""
189
+ options = options or {}
190
+ normalized_context = resolve_transcript(context, options.get("supports_mid_convo_system_messages"))
191
+ messages: list[dict[str, Any]] = []
192
+
193
+ def normalize_tool_call_id(tool_call_id: str, _target_model: Model, source: AssistantMessage) -> str:
194
+ if model.provider not in allowed_tool_call_providers:
195
+ return _normalize_id_part(tool_call_id)
196
+ if "|" not in tool_call_id:
197
+ return _normalize_id_part(tool_call_id)
198
+ parts = tool_call_id.split("|")
199
+ call_id, item_id = parts[0], parts[1]
200
+ normalized_call_id = _normalize_id_part(call_id)
201
+ is_foreign_tool_call = source.provider != model.provider or source.api != model.api
202
+ normalized_item_id = (
203
+ _build_foreign_responses_item_id(item_id) if is_foreign_tool_call else _normalize_id_part(item_id)
204
+ )
205
+ # OpenAI Responses API requires item id to start with "fc"
206
+ if not normalized_item_id.startswith("fc_"):
207
+ normalized_item_id = _normalize_id_part(f"fc_{normalized_item_id}")
208
+ return f"{normalized_call_id}|{normalized_item_id}"
209
+
210
+ transformed_messages = transform_messages(normalized_context.messages, model, normalize_tool_call_id)
211
+ supports_additional_tools = bool(options.get("supports_additional_tools"))
212
+ supports_tool_search = bool(options.get("supports_tool_search"))
213
+ transcript_tools = resolve_transcript_tools(
214
+ normalized_context.messages, supports_additional_tools or supports_tool_search
215
+ )
216
+ tool_options = options.get("tool_options")
217
+ grammar_properties = options.get("grammar_tool_input_properties") or {}
218
+
219
+ def append_system_tool_additions(message: SystemMessage, seed: str) -> None:
220
+ tools = (message.tools_added or []) if transcript_tools.anchors_additions else []
221
+ if not tools:
222
+ return
223
+ if supports_additional_tools:
224
+ messages.append(
225
+ {"type": "additional_tools", "role": "developer", "tools": convert_responses_tools(tools, tool_options)}
226
+ )
227
+ return
228
+ if not supports_tool_search:
229
+ return
230
+ names = [tool.name for tool in tools]
231
+ call_id = f"pi_tool_load_{short_hash(seed + ':' + ','.join(names))}"
232
+ messages.append(
233
+ {
234
+ "type": "tool_search_call",
235
+ "call_id": call_id,
236
+ "execution": "client",
237
+ "status": "completed",
238
+ "arguments": {"query": " ".join(names), "limit": len(names)},
239
+ }
240
+ )
241
+ messages.append(
242
+ {
243
+ "type": "tool_search_output",
244
+ "call_id": call_id,
245
+ "execution": "client",
246
+ "status": "completed",
247
+ "tools": convert_responses_tools(tools, {**(tool_options or {}), "tool_search_result": True}),
248
+ }
249
+ )
250
+
251
+ include_initial_system_message = options.get("include_system_prompt")
252
+ if include_initial_system_message is None:
253
+ include_initial_system_message = True
254
+ configuration_update_effort = options.get("configuration_update_effort")
255
+
256
+ def push_configuration_update(effort: str) -> None:
257
+ messages.append({"type": "configuration_update", "reasoning": {"effort": effort}})
258
+
259
+ compat = model.compat or {}
260
+ instruction_role = "developer" if model.reasoning and compat.get("supportsDeveloperRole") is not False else "system"
261
+
262
+ msg_index = 0
263
+ source_index = 0
264
+ for msg in transformed_messages:
265
+ is_leading_system_message = source_index == 0 and msg.role == "system"
266
+ source_index += 1
267
+ role = msg.role
268
+ if role == "system":
269
+ if not is_leading_system_message:
270
+ append_system_tool_additions(msg, f"system:{msg_index}")
271
+ if not is_leading_system_message or include_initial_system_message:
272
+ text = get_system_message_text(msg) if is_leading_system_message else render_system_message_update(msg)
273
+ if text:
274
+ messages.append({"role": instruction_role, "content": sanitize_surrogates(text)})
275
+ elif role == "user":
276
+ if isinstance(msg.content, str):
277
+ messages.append(
278
+ {"role": "user", "content": [{"type": "input_text", "text": sanitize_surrogates(msg.content)}]}
279
+ )
280
+ else:
281
+ content: list[dict[str, Any]] = []
282
+ for item in msg.content:
283
+ if item.type == "text":
284
+ content.append({"type": "input_text", "text": sanitize_surrogates(item.text)})
285
+ else:
286
+ content.append(
287
+ {
288
+ "type": "input_image",
289
+ "detail": "auto",
290
+ "image_url": f"data:{item.mime_type};base64,{item.data}",
291
+ }
292
+ )
293
+ if not content:
294
+ continue
295
+ messages.append({"role": "user", "content": content})
296
+ elif role == "assistant":
297
+ output: list[dict[str, Any]] = []
298
+ is_same_provider_and_api = msg.provider == model.provider and msg.api == model.api
299
+ is_same_model = is_same_provider_and_api and msg.model == model.id
300
+ is_different_model = is_same_provider_and_api and msg.model != model.id
301
+ text_block_index = 0
302
+
303
+ for block in msg.content:
304
+ kind = block.type
305
+ if kind == "thinking":
306
+ if block.thinking_signature:
307
+ output.append(fastjson.loads(block.thinking_signature))
308
+ elif kind == "text":
309
+ parsed_signature = _parse_text_signature(block.text_signature)
310
+ fallback_message_id = (
311
+ f"msg_pi_{msg_index}" if text_block_index == 0 else f"msg_pi_{msg_index}_{text_block_index}"
312
+ )
313
+ text_block_index += 1
314
+ # OpenAI requires id to be max 64 characters
315
+ msg_id = parsed_signature[0] if parsed_signature else None
316
+ if not msg_id:
317
+ msg_id = fallback_message_id
318
+ elif len(msg_id) > 64:
319
+ msg_id = f"msg_{short_hash(msg_id)}"
320
+ message_item: dict[str, Any] = {
321
+ "type": "message",
322
+ "role": "assistant",
323
+ "content": [
324
+ {"type": "output_text", "text": sanitize_surrogates(block.text), "annotations": []}
325
+ ],
326
+ "status": "completed",
327
+ "id": msg_id,
328
+ }
329
+ phase = parsed_signature[1] if parsed_signature else None
330
+ if phase is not None:
331
+ message_item["phase"] = phase
332
+ output.append(message_item)
333
+ elif kind == "toolCall":
334
+ id_parts = block.id.split("|")
335
+ call_id = id_parts[0]
336
+ item_id: str | None = id_parts[1] if len(id_parts) > 1 else None
337
+ custom_input_property = grammar_properties.get(block.name)
338
+
339
+ # For different-model messages, set id to undefined to avoid pairing validation.
340
+ # OpenAI tracks which item IDs were paired with rs_xxx reasoning items.
341
+ # By omitting the id, we avoid triggering that validation (like cross-provider does).
342
+ # Also drop ids that do not match the replayed item type: function_call ids must be fc_*
343
+ # and custom_tool_call ids must be ctc_*. Foreign tool call ids are normalized to fc_*, and
344
+ # a call can switch between the two types when grammar tool support differs.
345
+ item_id_prefix = "fc_" if custom_input_property is None else "ctc_"
346
+ if is_different_model or not (item_id is not None and item_id.startswith(item_id_prefix)):
347
+ item_id = None
348
+
349
+ call_item: dict[str, Any]
350
+ if custom_input_property is not None:
351
+ call_item = {"type": "custom_tool_call"}
352
+ if item_id is not None:
353
+ call_item["id"] = item_id
354
+ call_item["call_id"] = call_id
355
+ call_item["name"] = block.name
356
+ call_item["input"] = sanitize_surrogates(
357
+ get_grammar_tool_input(block.name, block.arguments, custom_input_property)
358
+ )
359
+ else:
360
+ call_item = {"type": "function_call"}
361
+ if item_id is not None:
362
+ call_item["id"] = item_id
363
+ call_item["call_id"] = call_id
364
+ call_item["name"] = block.name
365
+ call_item["arguments"] = _json_stringify(block.arguments)
366
+ if is_same_model and block.namespace is not None:
367
+ call_item["namespace"] = block.namespace
368
+ output.append(call_item)
369
+ if not output:
370
+ continue
371
+ if configuration_update_effort is not None and is_same_model and msg.provider_thinking_level is not None:
372
+ push_configuration_update(msg.provider_thinking_level)
373
+ messages.extend(output)
374
+ elif role == "toolResult":
375
+ call_id = msg.tool_call_id.split("|")[0]
376
+ tool_output = _convert_tool_result_output(model, msg.content)
377
+ if msg.tool_name in grammar_properties:
378
+ messages.append({"type": "custom_tool_call_output", "call_id": call_id, "output": tool_output})
379
+ else:
380
+ messages.append({"type": "function_call_output", "call_id": call_id, "output": tool_output})
381
+ if not is_leading_system_message:
382
+ msg_index += 1
383
+ if configuration_update_effort is not None:
384
+ push_configuration_update(configuration_update_effort)
385
+
386
+ return messages
387
+
388
+
389
+ # =============================================================================
390
+ # Tool conversion
391
+ # =============================================================================
392
+
393
+
394
+ def convert_responses_tools(
395
+ tools: Sequence[Tool], options: ConvertResponsesToolsOptions | None = None
396
+ ) -> list[dict[str, Any]]:
397
+ """Convert pi tools to OpenAI Responses ``function``/``custom`` (grammar) tools."""
398
+ options = options or {}
399
+ default_strict = options.get("strict", False)
400
+ supports_strict_mode = options.get("supports_strict_mode")
401
+ if supports_strict_mode is None:
402
+ supports_strict_mode = True
403
+ supports_openai_grammar_tools = bool(options.get("supports_openai_grammar_tools"))
404
+ tool_search_result = bool(options.get("tool_search_result"))
405
+
406
+ result: list[dict[str, Any]] = []
407
+ for tool in tools:
408
+ grammar = resolve_grammar_constrained_sampling(tool, supports_openai_grammar_tools)
409
+ if grammar is not None:
410
+ custom_tool: dict[str, Any] = {
411
+ "type": "custom",
412
+ "name": tool.name,
413
+ "description": tool.description,
414
+ "format": {"type": "grammar", "syntax": grammar.format, "definition": grammar.definition},
415
+ }
416
+ if tool_search_result:
417
+ custom_tool["defer_loading"] = True
418
+ result.append(custom_tool)
419
+ continue
420
+
421
+ constrained_strict = resolve_json_schema_strict_sampling(tool, supports_strict_mode)
422
+ strict = constrained_strict if constrained_strict is not None else default_strict
423
+ function_tool: dict[str, Any] = {
424
+ "type": "function",
425
+ "name": tool.name,
426
+ "description": tool.description,
427
+ "parameters": get_json_schema_tool_parameters(tool, strict is True),
428
+ }
429
+ if tool_search_result:
430
+ function_tool["defer_loading"] = True
431
+ if supports_strict_mode:
432
+ function_tool["strict"] = strict
433
+ result.append(function_tool)
434
+ return result
435
+
436
+
437
+ # =============================================================================
438
+ # Stream processing
439
+ # =============================================================================
440
+
441
+ _SlotKind = Literal["thinking", "text", "toolCall"]
442
+
443
+
444
+ class _Slot:
445
+ """Streaming state of one output item (scratch data never stored on the content block)."""
446
+
447
+ __slots__ = ("block", "content_index", "custom_buffer", "custom_property", "kind", "parser")
448
+
449
+ def __init__(self, kind: _SlotKind, block: Any, content_index: int) -> None:
450
+ self.kind = kind
451
+ self.block = block
452
+ self.content_index = content_index
453
+ #: Function-call argument parser; ``None`` once the call finished (pi's ``partialJson``).
454
+ self.parser: StreamingJsonParser | None = None
455
+ #: Grammar tool input property and JSON delta buffer; ``None`` once finished (pi's ``customInput``).
456
+ self.custom_property: str | None = None
457
+ self.custom_buffer: GrammarToolInputJsonBuffer | None = None
458
+
459
+
460
+ def _custom_tool_call_input(slot: _Slot) -> str:
461
+ if slot.custom_property is None:
462
+ return ""
463
+ value = slot.block.arguments.get(slot.custom_property)
464
+ return value if isinstance(value, str) else ""
465
+
466
+
467
+ def _append_custom_tool_call_input(slot: _Slot, next_input: str, close: bool) -> str | None:
468
+ buffer = slot.custom_buffer
469
+ prop = slot.custom_property
470
+ if buffer is None or prop is None:
471
+ return None
472
+ delta = append_grammar_tool_input_json_delta(buffer, prop, next_input, close)
473
+ slot.block.arguments = {prop: next_input}
474
+ return delta
475
+
476
+
477
+ def _js_template(event: Mapping[str, Any], key: str) -> str:
478
+ """Render a field the way a JavaScript template literal would (missing -> ``undefined``)."""
479
+ if key not in event:
480
+ return "undefined"
481
+ value = event[key]
482
+ if value is None:
483
+ return "null"
484
+ if isinstance(value, bool):
485
+ return "true" if value else "false"
486
+ if isinstance(value, str):
487
+ return value
488
+ if isinstance(value, (int, float)):
489
+ return str(value)
490
+ return "[object Object]" if isinstance(value, dict) else ",".join(str(item) for item in value)
491
+
492
+
493
+ async def process_responses_stream(
494
+ events: AsyncIterable[Any],
495
+ output: AssistantMessage,
496
+ stream: AssistantMessageEventStream,
497
+ model: Model,
498
+ options: OpenAIResponsesStreamOptions | None = None,
499
+ ) -> None:
500
+ """Normalize OpenAI Responses stream events into ``output`` and push pi events to ``stream``.
501
+
502
+ Raises when the stream fails, ends before a terminal response event, or completes with an
503
+ unfinished tool call.
504
+ """
505
+ options = options or {}
506
+ on_provider_stream_event = options.get("on_provider_stream_event")
507
+ grammar_properties = options.get("grammar_tool_input_properties") or {}
508
+ saw_terminal_response_event = False
509
+ output_slots: dict[Any, _Slot] = {}
510
+ reasoning_blocks_by_id: dict[Any, ThinkingContent] = {}
511
+ #: Tool-call blocks (by identity) whose ``output_item.done`` has not arrived yet.
512
+ unfinished_tool_calls: set[int] = set()
513
+ content = output.content
514
+ push = stream.push
515
+
516
+ def apply_message_phase_stop_reason(item: Mapping[str, Any]) -> None:
517
+ if item.get("type") == "message" and item.get("phase") == "final_answer":
518
+ output.stop_reason = "stop"
519
+
520
+ def get_slot(output_index: Any, kind: _SlotKind) -> _Slot | None:
521
+ slot = output_slots.get(output_index)
522
+ return slot if slot is not None and slot.kind == kind else None
523
+
524
+ def push_tool_call_delta(slot: _Slot, delta: str | None) -> None:
525
+ if delta is None:
526
+ return
527
+ push(ToolCallDeltaEvent(slot.content_index, delta, output))
528
+
529
+ def create_slot(output_index: Any, item: Mapping[str, Any]) -> _Slot | None:
530
+ item_type = item.get("type")
531
+ if item_type == "reasoning":
532
+ block: Any = ThinkingContent("")
533
+ content.append(block)
534
+ slot = _Slot("thinking", block, len(content) - 1)
535
+ output_slots[output_index] = slot
536
+ push(ThinkingStartEvent(slot.content_index, output))
537
+ return slot
538
+ if item_type == "message":
539
+ apply_message_phase_stop_reason(item)
540
+ block = TextContent("")
541
+ content.append(block)
542
+ slot = _Slot("text", block, len(content) - 1)
543
+ output_slots[output_index] = slot
544
+ push(TextStartEvent(slot.content_index, output))
545
+ return slot
546
+ if item_type == "function_call":
547
+ block = ToolCall(
548
+ f"{_js_template(item, 'call_id')}|{_js_template(item, 'id')}",
549
+ item.get("name"), # type: ignore[arg-type]
550
+ {},
551
+ namespace=item.get("namespace"),
552
+ )
553
+ content.append(block)
554
+ slot = _Slot("toolCall", block, len(content) - 1)
555
+ parser = StreamingJsonParser()
556
+ parser.feed(item.get("arguments") or "")
557
+ slot.parser = parser
558
+ unfinished_tool_calls.add(id(block))
559
+ output_slots[output_index] = slot
560
+ push(ToolCallStartEvent(slot.content_index, output))
561
+ return slot
562
+ if item_type == "custom_tool_call":
563
+ input_property = grammar_properties.get(item.get("name"), "input") # type: ignore[arg-type]
564
+ block = ToolCall(
565
+ f"{_js_template(item, 'call_id')}|{_js_template(item, 'id')}",
566
+ item.get("name"), # type: ignore[arg-type]
567
+ {input_property: item.get("input") or ""},
568
+ namespace=item.get("namespace"),
569
+ )
570
+ content.append(block)
571
+ slot = _Slot("toolCall", block, len(content) - 1)
572
+ slot.custom_property = input_property
573
+ slot.custom_buffer = GrammarToolInputJsonBuffer()
574
+ unfinished_tool_calls.add(id(block))
575
+ output_slots[output_index] = slot
576
+ push(ToolCallStartEvent(slot.content_index, output))
577
+ return slot
578
+ return None
579
+
580
+ def backfill_reasoning_signatures(response_output: Any) -> None:
581
+ # Azure OpenAI can omit reasoning.encrypted_content from response.output_item.done
582
+ # and provide it only in response.completed.response.output. Backfill the
583
+ # persisted reasoning signature from the terminal response to keep store:false
584
+ # multi-turn replay stateless. See https://github.com/earendil-works/pi/issues/6409.
585
+ for item in response_output or ():
586
+ if not isinstance(item, dict) or item.get("type") != "reasoning" or not item.get("encrypted_content"):
587
+ continue
588
+ block = reasoning_blocks_by_id.get(item.get("id"))
589
+ if block is None or not block.thinking_signature:
590
+ continue
591
+ stored_item = json.loads(block.thinking_signature)
592
+ if stored_item.get("encrypted_content"):
593
+ continue
594
+ block.thinking_signature = _json_stringify({**stored_item, "encrypted_content": item["encrypted_content"]})
595
+
596
+ def finalize_response(response: Any) -> None:
597
+ nonlocal saw_terminal_response_event
598
+ saw_terminal_response_event = True
599
+ response = response if isinstance(response, dict) else {}
600
+ backfill_reasoning_signatures(response.get("output"))
601
+ if response.get("id"):
602
+ output.response_id = response["id"]
603
+ usage = response.get("usage")
604
+ if usage:
605
+ input_details = usage.get("input_tokens_details") or {}
606
+ cached_tokens = input_details.get("cached_tokens") or 0
607
+ cache_write_tokens = input_details.get("cache_write_tokens") or 0
608
+ output_details = usage.get("output_tokens_details") or {}
609
+ output.usage = Usage(
610
+ # OpenAI includes cached and cache-write tokens in input_tokens, so subtract both.
611
+ input=max(0, (usage.get("input_tokens") or 0) - cached_tokens - cache_write_tokens),
612
+ output=usage.get("output_tokens") or 0,
613
+ cache_read=cached_tokens,
614
+ cache_write=cache_write_tokens,
615
+ total_tokens=usage.get("total_tokens") or 0,
616
+ reasoning=output_details.get("reasoning_tokens") or 0,
617
+ )
618
+ calculate_cost(model, output.usage)
619
+ apply_service_tier_pricing = options.get("apply_service_tier_pricing")
620
+ if apply_service_tier_pricing is not None:
621
+ resolve_service_tier = options.get("resolve_service_tier")
622
+ response_tier = response.get("service_tier")
623
+ request_tier = options.get("service_tier")
624
+ if resolve_service_tier is not None:
625
+ service_tier = resolve_service_tier(response_tier, request_tier)
626
+ else:
627
+ service_tier = response_tier if response_tier is not None else request_tier
628
+ apply_service_tier_pricing(output.usage, service_tier)
629
+ # Map status to stop reason. For incomplete responses, retain the provider's
630
+ # specific reason so max-output truncation and content filtering stay distinct.
631
+ status = response.get("status")
632
+ incomplete_details = response.get("incomplete_details")
633
+ reason = incomplete_details.get("reason") if isinstance(incomplete_details, dict) else None
634
+ incomplete_reason = reason if isinstance(reason, str) else None
635
+ output.raw_stop_reason = f"{status}.{incomplete_reason}" if incomplete_reason else status
636
+ stop_reason, error_message = _map_stop_reason(status, incomplete_reason)
637
+ output.stop_reason = stop_reason
638
+ output.error_message = error_message
639
+ if output.stop_reason == "stop" and any(block.type == "toolCall" for block in content):
640
+ output.stop_reason = "toolUse"
641
+
642
+ async for event in events:
643
+ if on_provider_stream_event is not None:
644
+ await maybe_await(on_provider_stream_event(event, model))
645
+ if not isinstance(event, dict):
646
+ continue
647
+ event_type = event.get("type")
648
+ if event_type == "response.created":
649
+ response = event.get("response")
650
+ output.response_id = response.get("id") if isinstance(response, dict) else None
651
+ elif event_type == "response.output_item.added":
652
+ item = event.get("item")
653
+ if isinstance(item, dict):
654
+ create_slot(event.get("output_index"), item)
655
+ elif event_type in (
656
+ "response.reasoning_summary_text.delta",
657
+ "response.reasoning_text.delta",
658
+ ):
659
+ slot = get_slot(event.get("output_index"), "thinking")
660
+ if slot is None:
661
+ continue
662
+ delta = event.get("delta") or ""
663
+ append_thinking(slot.block, delta)
664
+ push(ThinkingDeltaEvent(slot.content_index, delta, output))
665
+ elif event_type == "response.reasoning_summary_part.done":
666
+ slot = get_slot(event.get("output_index"), "thinking")
667
+ if slot is None:
668
+ continue
669
+ append_thinking(slot.block, "\n\n")
670
+ push(ThinkingDeltaEvent(slot.content_index, "\n\n", output))
671
+ elif event_type in ("response.output_text.delta", "response.refusal.delta"):
672
+ slot = get_slot(event.get("output_index"), "text")
673
+ if slot is None:
674
+ continue
675
+ delta = event.get("delta") or ""
676
+ append_text(slot.block, delta)
677
+ push(TextDeltaEvent(slot.content_index, delta, output))
678
+ elif event_type == "response.function_call_arguments.delta":
679
+ slot = get_slot(event.get("output_index"), "toolCall")
680
+ if slot is None or slot.parser is None:
681
+ continue
682
+ delta = event.get("delta") or ""
683
+ slot.parser.feed(delta)
684
+ slot.block.arguments = slot.parser.value()
685
+ push_tool_call_delta(slot, delta)
686
+ elif event_type == "response.function_call_arguments.done":
687
+ slot = get_slot(event.get("output_index"), "toolCall")
688
+ if slot is None or slot.parser is None:
689
+ continue
690
+ final_arguments = event.get("arguments") or ""
691
+ previous_partial_json = slot.parser.text
692
+ if final_arguments.startswith(previous_partial_json):
693
+ delta = final_arguments[len(previous_partial_json) :]
694
+ slot.parser.feed(delta)
695
+ slot.block.arguments = slot.parser.value()
696
+ if delta:
697
+ push_tool_call_delta(slot, delta)
698
+ else:
699
+ parser = StreamingJsonParser()
700
+ parser.feed(final_arguments)
701
+ slot.parser = parser
702
+ slot.block.arguments = parser.value()
703
+ elif event_type == "response.custom_tool_call_input.delta":
704
+ slot = get_slot(event.get("output_index"), "toolCall")
705
+ if slot is None or slot.custom_buffer is None:
706
+ continue
707
+ push_tool_call_delta(
708
+ slot,
709
+ _append_custom_tool_call_input(slot, _custom_tool_call_input(slot) + (event.get("delta") or ""), False),
710
+ )
711
+ elif event_type == "response.custom_tool_call_input.done":
712
+ slot = get_slot(event.get("output_index"), "toolCall")
713
+ if slot is None or slot.custom_buffer is None:
714
+ continue
715
+ push_tool_call_delta(slot, _append_custom_tool_call_input(slot, event.get("input") or "", True))
716
+ elif event_type == "response.output_item.done":
717
+ item = event.get("item")
718
+ if not isinstance(item, dict):
719
+ continue
720
+ output_index = event.get("output_index")
721
+ apply_message_phase_stop_reason(item)
722
+ slot = output_slots.get(output_index) or create_slot(output_index, item)
723
+ item_type = item.get("type")
724
+
725
+ if item_type == "reasoning" and slot is not None and slot.kind == "thinking":
726
+ block = slot.block
727
+ summary_text = "\n\n".join(_text_of(part) for part in item.get("summary") or ())
728
+ content_text = "\n\n".join(_text_of(part) for part in item.get("content") or ())
729
+ block.thinking = summary_text or content_text or block.thinking
730
+ block.thinking_signature = _json_stringify(item)
731
+ reasoning_blocks_by_id[item.get("id")] = block
732
+ push(ThinkingEndEvent(slot.content_index, block.thinking, output))
733
+ del output_slots[output_index]
734
+ elif item_type == "message" and slot is not None and slot.kind == "text":
735
+ block = slot.block
736
+ block.text = "".join(
737
+ (part.get("text") if part.get("type") == "output_text" else part.get("refusal")) or ""
738
+ for part in item.get("content") or ()
739
+ if isinstance(part, dict)
740
+ )
741
+ block.text_signature = _encode_text_signature_v1(item.get("id"), item.get("phase"))
742
+ push(TextEndEvent(slot.content_index, block.text, output))
743
+ del output_slots[output_index]
744
+ elif (
745
+ item_type == "function_call"
746
+ and slot is not None
747
+ and slot.kind == "toolCall"
748
+ and slot.parser is not None
749
+ ):
750
+ block = slot.block
751
+ arguments, arguments_error = parse_tool_call_arguments(item.get("arguments") or slot.parser.text)
752
+ block.arguments = arguments
753
+ if arguments_error is not None:
754
+ block.arguments_error = arguments_error
755
+ if item.get("namespace") is not None:
756
+ block.namespace = item["namespace"]
757
+ # Finalize in-place and drop the scratch buffer so replay only carries parsed arguments.
758
+ slot.parser = None
759
+ unfinished_tool_calls.discard(id(block))
760
+ push(ToolCallEndEvent(slot.content_index, block, output))
761
+ del output_slots[output_index]
762
+ elif (
763
+ item_type == "custom_tool_call"
764
+ and slot is not None
765
+ and slot.kind == "toolCall"
766
+ and slot.custom_buffer is not None
767
+ ):
768
+ block = slot.block
769
+ final_input = item.get("input")
770
+ push_tool_call_delta(
771
+ slot,
772
+ _append_custom_tool_call_input(
773
+ slot, final_input if final_input is not None else _custom_tool_call_input(slot), True
774
+ ),
775
+ )
776
+ if item.get("namespace") is not None:
777
+ block.namespace = item["namespace"]
778
+ slot.custom_buffer = None
779
+ slot.custom_property = None
780
+ unfinished_tool_calls.discard(id(block))
781
+ push(ToolCallEndEvent(slot.content_index, block, output))
782
+ del output_slots[output_index]
783
+ elif event_type in ("response.completed", "response.incomplete"):
784
+ finalize_response(event.get("response"))
785
+ elif event_type == "error":
786
+ raise RuntimeError(f"Error Code {_js_template(event, 'code')}: {_js_template(event, 'message')}")
787
+ elif event_type == "response.failed":
788
+ saw_terminal_response_event = True
789
+ response = event.get("response")
790
+ response = response if isinstance(response, dict) else {}
791
+ output.raw_stop_reason = response.get("status")
792
+ error = response.get("error")
793
+ details = response.get("incomplete_details")
794
+ if error:
795
+ message = f"{error.get('code') or 'unknown'}: {error.get('message') or 'no message'}"
796
+ elif isinstance(details, dict) and details.get("reason"):
797
+ message = f"incomplete: {details['reason']}"
798
+ else:
799
+ message = "Unknown error (no error details in response)"
800
+ raise RuntimeError(message)
801
+
802
+ if not saw_terminal_response_event:
803
+ raise RuntimeError("OpenAI Responses stream ended before a terminal response event")
804
+ # The agent runs every tool call in the final message. Refuse to hand over calls whose
805
+ # output_item.done never arrived: their arguments may be cut off or mixed up, e.g. when a
806
+ # non-compliant server omits output_index. Finished calls have their scratch buffers removed.
807
+ if output.stop_reason == "toolUse":
808
+ for block in content:
809
+ if block.type == "toolCall" and id(block) in unfinished_tool_calls:
810
+ raise RuntimeError(
811
+ f"OpenAI Responses stream completed with an unfinished tool call: {block.name} ({block.id})"
812
+ )
813
+
814
+
815
+ def _text_of(part: Any) -> str:
816
+ if isinstance(part, dict):
817
+ text = part.get("text")
818
+ return text if isinstance(text, str) else ""
819
+ return ""
820
+
821
+
822
+ def _map_stop_reason(status: Any, incomplete_reason: str | None = None) -> tuple[StopReason, str | None]:
823
+ if not status:
824
+ return "stop", None
825
+ if status == "completed":
826
+ return "stop", None
827
+ if status == "incomplete":
828
+ if incomplete_reason == "max_output_tokens":
829
+ return "length", None
830
+ return "error", (
831
+ f"Response incomplete: {incomplete_reason}"
832
+ if incomplete_reason
833
+ else "Response incomplete without a provider reason"
834
+ )
835
+ if status in ("failed", "cancelled"):
836
+ return "error", None
837
+ # These two are wonky ...
838
+ if status in ("in_progress", "queued"):
839
+ return "stop", None
840
+ raise RuntimeError(f"Unhandled stop reason: {status}")