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,1594 @@
1
+ # pyright: reportAttributeAccessIssue=false, reportArgumentType=false
2
+ # Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
3
+ """OpenAI Chat Completions API (``openai-completions``).
4
+
5
+ Port of pi's ``api/openai-completions.ts``. Streams ``chat.completions.create`` through
6
+ the official ``openai`` SDK (``AsyncOpenAI``) and covers the many OpenAI-compatible
7
+ servers through ``model.compat`` (auto-detected from the provider id and base URL
8
+ when unset).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from collections.abc import Mapping, Sequence
16
+ from typing import Any, Literal, TypeAlias, TypedDict
17
+
18
+ from ..abort import AbortError, abort_scope
19
+ from ..event_stream import AssistantMessageEventStream
20
+ from ..models import calculate_cost, clamp_thinking_level
21
+ from ..types import (
22
+ AssistantMessage,
23
+ DoneEvent,
24
+ ErrorEvent,
25
+ ImageContent,
26
+ Message,
27
+ Model,
28
+ ProviderResponse,
29
+ StartEvent,
30
+ StreamOptions,
31
+ TextContent,
32
+ TextDeltaEvent,
33
+ TextEndEvent,
34
+ TextStartEvent,
35
+ ThinkingBudgets,
36
+ ThinkingContent,
37
+ ThinkingDeltaEvent,
38
+ ThinkingEndEvent,
39
+ ThinkingStartEvent,
40
+ Tool,
41
+ ToolCall,
42
+ ToolCallDeltaEvent,
43
+ ToolCallEndEvent,
44
+ ToolCallStartEvent,
45
+ ToolResultMessage,
46
+ TranscriptContext,
47
+ Usage,
48
+ )
49
+ from ..utils import fastjson
50
+ from ..utils.callbacks import maybe_await
51
+ from ..utils.error_body import format_provider_error, normalize_provider_error
52
+ from ..utils.json_parse import StreamingJsonParser, parse_tool_call_arguments
53
+ from ..utils.misc import get_pi_user_agent, get_provider_env_value, headers_to_record, sanitize_surrogates, short_hash
54
+ from ..utils.text import append_text, append_thinking, get_system_message_text, render_system_message_update
55
+ from ..utils.transcript import get_declared_tools, resolve_transcript, resolve_transcript_tools
56
+ from .constrained_sampling import (
57
+ GrammarToolInputJsonBuffer,
58
+ append_grammar_tool_input_json_delta,
59
+ create_grammar_tool_input_properties,
60
+ get_grammar_tool_input,
61
+ get_json_schema_tool_parameters,
62
+ resolve_grammar_constrained_sampling,
63
+ resolve_json_schema_strict_sampling,
64
+ )
65
+ from .github_copilot_headers import build_copilot_dynamic_headers, has_copilot_vision_input
66
+ from .openai_prompt_cache import clamp_openai_prompt_cache_key
67
+ from .openai_sdk import (
68
+ create_openai_client,
69
+ iter_sdk_stream,
70
+ merge_sdk_headers,
71
+ open_sdk_stream,
72
+ openai_request,
73
+ sdk_body_arguments,
74
+ )
75
+ from .simple_options import (
76
+ build_base_options,
77
+ clamp_thinking_budget_to_answer_room,
78
+ resolve_sampling_params,
79
+ thinking_budget_for_level,
80
+ )
81
+ from .transform_messages import transform_messages
82
+
83
+ __all__ = [
84
+ "OpenAICompletionsOptions",
85
+ "ConvertCompletionsMessagesOptions",
86
+ "ResolvedOpenAICompletionsCompat",
87
+ "stream",
88
+ "stream_simple",
89
+ "build_params",
90
+ "convert_messages",
91
+ "convert_tools",
92
+ "detect_compat",
93
+ "get_compat",
94
+ ]
95
+
96
+
97
+ class OpenAICompletionsOptions(StreamOptions, total=False):
98
+ """Options for :func:`stream` (pi's ``OpenAICompletionsOptions``)."""
99
+
100
+ #: ``"auto"``, ``"none"``, ``"required"`` or a ``{"type": "function", ...}`` object.
101
+ tool_choice: str | dict[str, Any]
102
+ reasoning_effort: Literal["minimal", "low", "medium", "high", "xhigh", "max"]
103
+ #: Token budgets per thinking level. Used when ``compat.thinkingTokenBudgetField`` or
104
+ #: ``compat.supportsThinkingTokenBudget`` is set, or by ``{"$var": "thinking.budget"}``.
105
+ thinking_budgets: ThinkingBudgets
106
+
107
+
108
+ class ConvertCompletionsMessagesOptions(TypedDict, total=False):
109
+ #: Grammar tool name -> its single string input property.
110
+ grammar_tool_input_properties: Mapping[str, str]
111
+
112
+
113
+ #: Fully resolved ``OpenAICompletionsCompat`` (camelCase keys, as in pi).
114
+ ResolvedOpenAICompletionsCompat: TypeAlias = dict[str, Any]
115
+
116
+ _REASONING_FIELDS = ("reasoning_content", "reasoning", "reasoning_text")
117
+ _REPLAY_REASONING_FIELDS = frozenset(("reasoning", "reasoning_content", "reasoning_text"))
118
+ # Characters removed by JavaScript's String.prototype.trim().
119
+ _JS_WHITESPACE = (
120
+ "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005"
121
+ "\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
122
+ )
123
+
124
+
125
+ class _Undefined:
126
+ """JavaScript ``undefined``: distinct from JSON ``null`` (``None``)."""
127
+
128
+ __slots__ = ()
129
+
130
+ def __repr__(self) -> str:
131
+ return "undefined"
132
+
133
+
134
+ _UNDEFINED: Any = _Undefined()
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Small JavaScript-semantics helpers
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ def _truthy(value: Any) -> bool:
143
+ """JavaScript truthiness (empty objects and arrays are truthy)."""
144
+ if value is None or value is False or value is _UNDEFINED:
145
+ return False
146
+ if isinstance(value, str):
147
+ return value != ""
148
+ if isinstance(value, (int, float)):
149
+ return value != 0 and value == value
150
+ return True
151
+
152
+
153
+ def _is_number(value: Any) -> bool:
154
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
155
+
156
+
157
+ def _prop(obj: Any, key: str) -> Any:
158
+ """``obj?.[key]`` for parsed JSON (``None`` for missing keys and non-objects)."""
159
+ return obj.get(key) if isinstance(obj, dict) else None
160
+
161
+
162
+ def _js_trim(text: str) -> str:
163
+ return text.strip(_JS_WHITESPACE)
164
+
165
+
166
+ def _js_string(value: Any) -> str:
167
+ """JavaScript ``String(value)`` for parsed JSON values."""
168
+ if isinstance(value, str):
169
+ return value
170
+ if value is None:
171
+ return "null"
172
+ if isinstance(value, bool):
173
+ return "true" if value else "false"
174
+ if isinstance(value, float) and value.is_integer():
175
+ return str(int(value))
176
+ if isinstance(value, (int, float)):
177
+ return str(value)
178
+ if isinstance(value, list):
179
+ return ",".join("" if item is None else _js_string(item) for item in value)
180
+ return "[object Object]"
181
+
182
+
183
+ def _json_compact(value: Any) -> str:
184
+ """``JSON.stringify(value)``."""
185
+ try:
186
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
187
+ except (TypeError, ValueError):
188
+ return str(value)
189
+
190
+
191
+ def _level_value(model: Model, level: str | None) -> Any:
192
+ """``model.thinkingLevelMap?.[level]``: ``_UNDEFINED`` when absent, ``None`` for an explicit null."""
193
+ level_map = model.thinking_level_map
194
+ if level_map is None or level is None or level not in level_map:
195
+ return _UNDEFINED
196
+ return level_map[level]
197
+
198
+
199
+ def _level_value_or(model: Model, level: str | None, fallback: Any) -> Any:
200
+ """``model.thinkingLevelMap?.[level] ?? fallback``."""
201
+ value = _level_value(model, level)
202
+ return fallback if value is None or value is _UNDEFINED else value
203
+
204
+
205
+ def _has_nonblank_header(headers: Mapping[str, Any] | None, name: str) -> bool:
206
+ if not headers:
207
+ return False
208
+ expected = name.lower()
209
+ return any(
210
+ key.lower() == expected and isinstance(value, str) and bool(_js_trim(value)) for key, value in headers.items()
211
+ )
212
+
213
+
214
+ def _get_client_api_key(provider: str, api_key: str | None, headers: Mapping[str, Any] | None) -> str:
215
+ if api_key:
216
+ return api_key
217
+ if _has_nonblank_header(headers, "authorization") or _has_nonblank_header(headers, "cf-aig-authorization"):
218
+ return "unused"
219
+ raise RuntimeError(f"No API key for provider: {provider}")
220
+
221
+
222
+ def _has_tool_history(messages: Sequence[Message]) -> bool:
223
+ """Whether the transcript contains tool calls or tool results.
224
+
225
+ Anthropic (via a LiteLLM-style proxy) requires the ``tools`` param when the
226
+ conversation includes tool calls or tool role messages.
227
+ """
228
+ for message in messages:
229
+ role = message.role
230
+ if role == "toolResult":
231
+ return True
232
+ if role == "assistant" and any(block.type == "toolCall" for block in message.content):
233
+ return True
234
+ return False
235
+
236
+
237
+ def _resolve_cache_retention(cache_retention: str | None, env: Mapping[str, str] | None) -> str:
238
+ if cache_retention:
239
+ return cache_retention
240
+ if get_provider_env_value("PI_CACHE_RETENTION", env) == "long":
241
+ return "long"
242
+ return "short"
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # reasoning_details (OpenRouter-style structured reasoning replay data)
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ def _is_reasoning_detail(detail: Any) -> bool:
251
+ if not isinstance(detail, dict):
252
+ return False
253
+ if "id" in detail and detail["id"] is not None and not isinstance(detail["id"], str):
254
+ return False
255
+ if "format" in detail and not isinstance(detail["format"], str):
256
+ return False
257
+ if "index" in detail and not _is_number(detail["index"]):
258
+ return False
259
+ kind = detail.get("type")
260
+ if kind == "reasoning.summary":
261
+ return isinstance(detail.get("summary"), str)
262
+ if kind == "reasoning.encrypted":
263
+ return isinstance(detail.get("data"), str)
264
+ if kind == "reasoning.text":
265
+ signature = detail.get("signature")
266
+ return isinstance(detail.get("text"), str) and (signature is None or isinstance(signature, str))
267
+ return False
268
+
269
+
270
+ def _parse_reasoning_details(signature: str | None) -> list[dict[str, Any]] | None:
271
+ if not signature:
272
+ return None
273
+ try:
274
+ parsed = json.loads(signature)
275
+ except (TypeError, ValueError):
276
+ return None
277
+ if isinstance(parsed, list) and parsed and all(_is_reasoning_detail(detail) for detail in parsed):
278
+ return parsed
279
+ return None
280
+
281
+
282
+ def _parse_legacy_encrypted_reasoning_detail(signature: str | None) -> dict[str, Any] | None:
283
+ if not signature:
284
+ return None
285
+ try:
286
+ parsed = json.loads(signature)
287
+ except (TypeError, ValueError):
288
+ return None
289
+ if (
290
+ _is_reasoning_detail(parsed)
291
+ and parsed["type"] == "reasoning.encrypted"
292
+ and isinstance(parsed.get("id"), str)
293
+ and len(parsed["id"]) > 0
294
+ and len(parsed["data"]) > 0
295
+ ):
296
+ return parsed
297
+ return None
298
+
299
+
300
+ def _assign_from(target: dict[str, Any], key: str, source: Mapping[str, Any]) -> None:
301
+ # ``target[key] = source[key]`` where a missing source key assigns ``undefined``.
302
+ target[key] = source.get(key, _UNDEFINED)
303
+
304
+
305
+ def _nullish(value: Any) -> bool:
306
+ return value is None or value is _UNDEFINED
307
+
308
+
309
+ def _fill_missing_common_fields(target: dict[str, Any], source: Mapping[str, Any]) -> None:
310
+ if _nullish(target.get("id")):
311
+ _assign_from(target, "id", source)
312
+ if not _truthy(target.get("format")):
313
+ _assign_from(target, "format", source)
314
+ if _nullish(target.get("index")):
315
+ _assign_from(target, "index", source)
316
+
317
+
318
+ def _append_reasoning_detail(details: list[dict[str, Any]], detail: dict[str, Any]) -> None:
319
+ last = details[-1] if details else None
320
+ kind = detail["type"]
321
+ if kind == "reasoning.text" and last is not None and last.get("type") == "reasoning.text":
322
+ last["text"] += detail["text"]
323
+ if not _truthy(last.get("signature")):
324
+ _assign_from(last, "signature", detail)
325
+ _fill_missing_common_fields(last, detail)
326
+ return
327
+ if kind == "reasoning.summary" and last is not None and last.get("type") == "reasoning.summary":
328
+ last["summary"] += detail["summary"]
329
+ _fill_missing_common_fields(last, detail)
330
+ return
331
+ details.append(dict(detail))
332
+
333
+
334
+ def _serialize_reasoning_details(details: list[dict[str, Any]]) -> str:
335
+ # JSON.stringify drops ``undefined`` members but keeps the key order of the rest.
336
+ return fastjson.dumps([{key: value for key, value in d.items() if value is not _UNDEFINED} for d in details])
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Public entry points
341
+ # ---------------------------------------------------------------------------
342
+
343
+
344
+ def stream(
345
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
346
+ ) -> AssistantMessageEventStream:
347
+ """Stream a Chat Completions response as pi assistant message events."""
348
+ options = dict(options or {})
349
+ out = AssistantMessageEventStream()
350
+ normalized = resolve_transcript(context, get_compat(model).get("supportsMidConvoSystemMessages"))
351
+ out.start(_run(model, normalized, options, out))
352
+ return out
353
+
354
+
355
+ def stream_simple(
356
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
357
+ ) -> AssistantMessageEventStream:
358
+ """Stream with provider-neutral options (``reasoning``, ``tool_choice``, ``thinking_budgets``).
359
+
360
+ Raises ``RuntimeError`` synchronously when no API key (or authorization header) is given.
361
+ """
362
+ options = options or {}
363
+ _get_client_api_key(model.provider, options.get("api_key"), options.get("headers"))
364
+ base = build_base_options(model, context, options, options.get("api_key"))
365
+ tool_choice = options.get("tool_choice")
366
+ if tool_choice is not None:
367
+ base["tool_choice"] = tool_choice
368
+ reasoning = options.get("reasoning")
369
+ clamped = clamp_thinking_level(model, reasoning) if reasoning else None
370
+ if clamped and clamped != "off":
371
+ base["reasoning_effort"] = clamped
372
+ thinking_budgets = options.get("thinking_budgets")
373
+ if thinking_budgets is not None:
374
+ base["thinking_budgets"] = thinking_budgets
375
+ return stream(model, context, base)
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Streaming
380
+ # ---------------------------------------------------------------------------
381
+
382
+
383
+ def _build_request_headers(
384
+ model: Model,
385
+ context: TranscriptContext,
386
+ options_headers: Mapping[str, str | None] | None,
387
+ session_id: str | None,
388
+ compat: ResolvedOpenAICompletionsCompat,
389
+ ) -> dict[str, str | None]:
390
+ """The client's ``default_headers``: ``None`` removes a header, SDK defaults included."""
391
+ copilot_headers: dict[str, str] | None = None
392
+ if model.provider == "github-copilot":
393
+ messages = context.messages
394
+ copilot_headers = build_copilot_dynamic_headers(
395
+ messages=messages, has_images=has_copilot_vision_input(messages)
396
+ )
397
+ session_headers: dict[str, str] = {}
398
+ if session_id and compat.get("sendSessionAffinityHeaders"):
399
+ if compat.get("sessionAffinityFormat") == "openrouter":
400
+ session_headers["x-session-id"] = session_id
401
+ else:
402
+ if compat.get("sessionAffinityFormat") == "openai":
403
+ session_headers["session_id"] = session_id
404
+ session_headers["x-client-request-id"] = session_id
405
+ session_headers["x-session-affinity"] = session_id
406
+ # Options headers last so they can override defaults.
407
+ return merge_sdk_headers(
408
+ {"User-Agent": get_pi_user_agent()}, model.headers, copilot_headers, session_headers, options_headers
409
+ )
410
+
411
+
412
+ class _CustomInput:
413
+ __slots__ = ("buffer", "property")
414
+
415
+ def __init__(self, property: str) -> None:
416
+ self.property = property
417
+ self.buffer = GrammarToolInputJsonBuffer()
418
+
419
+
420
+ class _StreamProcessor:
421
+ """Normalizes Chat Completions chunks into ``output`` and pushes pi events.
422
+
423
+ Streaming scratch state (argument parsers, grammar input buffers, stream
424
+ indexes) is keyed by block identity so the content blocks stay clean.
425
+ """
426
+
427
+ __slots__ = (
428
+ "custom_inputs",
429
+ "grammar_properties",
430
+ "has_finish_reason",
431
+ "index_of",
432
+ "model",
433
+ "out",
434
+ "output",
435
+ "parsers",
436
+ "reasoning_details",
437
+ "stream_indexes",
438
+ "text_block",
439
+ "thinking_block",
440
+ "tool_by_id",
441
+ "tool_by_index",
442
+ )
443
+
444
+ def __init__(
445
+ self,
446
+ model: Model,
447
+ out: AssistantMessageEventStream,
448
+ output: AssistantMessage,
449
+ grammar_properties: Mapping[str, str],
450
+ ) -> None:
451
+ self.model = model
452
+ self.out = out
453
+ self.output = output
454
+ self.grammar_properties = grammar_properties
455
+ self.text_block: TextContent | None = None
456
+ self.thinking_block: ThinkingContent | None = None
457
+ self.has_finish_reason = False
458
+ self.tool_by_index: dict[Any, ToolCall] = {}
459
+ self.tool_by_id: dict[Any, ToolCall] = {}
460
+ self.index_of: dict[int, int] = {}
461
+ self.parsers: dict[int, StreamingJsonParser] = {}
462
+ self.custom_inputs: dict[int, _CustomInput] = {}
463
+ self.stream_indexes: dict[int, Any] = {}
464
+ # ``reasoning_details`` are replay metadata, not user-visible stream deltas. Keep
465
+ # them in memory during streaming and serialize once when the block is finalized.
466
+ self.reasoning_details: list[dict[str, Any]] | None = None
467
+
468
+ # -- blocks ---------------------------------------------------------------
469
+
470
+ def _add_block(self, block: Any) -> int:
471
+ content = self.output.content
472
+ index = len(content)
473
+ content.append(block)
474
+ self.index_of[id(block)] = index
475
+ return index
476
+
477
+ def apply_reasoning_details(self, block: ThinkingContent) -> None:
478
+ if self.reasoning_details is not None:
479
+ block.thinking_signature = _serialize_reasoning_details(self.reasoning_details)
480
+
481
+ def ensure_text_block(self) -> tuple[TextContent, int]:
482
+ block = self.text_block
483
+ if block is None:
484
+ block = TextContent("")
485
+ self.text_block = block
486
+ index = self._add_block(block)
487
+ self.out.push(TextStartEvent(index, self.output))
488
+ return block, index
489
+ return block, self.index_of[id(block)]
490
+
491
+ def ensure_thinking_block(self, signature: str) -> tuple[ThinkingContent, int]:
492
+ block = self.thinking_block
493
+ if block is None:
494
+ block = ThinkingContent("", thinking_signature=signature)
495
+ self.thinking_block = block
496
+ index = self._add_block(block)
497
+ self.out.push(ThinkingStartEvent(index, self.output))
498
+ return block, index
499
+ return block, self.index_of[id(block)]
500
+
501
+ def _custom_input_value(self, block: ToolCall) -> str:
502
+ custom = self.custom_inputs.get(id(block))
503
+ if custom is None:
504
+ return ""
505
+ value = block.arguments.get(custom.property) if isinstance(block.arguments, dict) else None
506
+ return value if isinstance(value, str) else ""
507
+
508
+ def _append_custom_input(self, block: ToolCall, next_input: str, close: bool) -> str | None:
509
+ custom = self.custom_inputs.get(id(block))
510
+ if custom is None:
511
+ return None
512
+ delta = append_grammar_tool_input_json_delta(custom.buffer, custom.property, next_input, close)
513
+ block.arguments = {custom.property: next_input}
514
+ return delta
515
+
516
+ def ensure_tool_call_block(self, tool_call: dict[str, Any]) -> ToolCall:
517
+ index = tool_call.get("index")
518
+ stream_index = index if _is_number(index) else None
519
+ function = tool_call.get("function")
520
+ custom = tool_call.get("custom")
521
+ name = _prop(function, "name")
522
+ if name is None:
523
+ name = _prop(custom, "name")
524
+ if name is None:
525
+ name = ""
526
+ tool_id = tool_call.get("id")
527
+ is_custom = _truthy(custom) and not _truthy(function)
528
+
529
+ block = self.tool_by_index.get(stream_index) if stream_index is not None else None
530
+ if block is None and _truthy(tool_id):
531
+ block = self.tool_by_id.get(tool_id)
532
+ if block is None:
533
+ # Note: the "input" fallback should not be taken. In case the LLM makes up a
534
+ # tool we don't know about, we at least have a place to stash its input.
535
+ custom_property = self.grammar_properties.get(name, "input") if is_custom else None
536
+ block = ToolCall(tool_id if _truthy(tool_id) else "", name, {custom_property: ""} if is_custom else {})
537
+ key = id(block)
538
+ if custom_property is not None:
539
+ self.custom_inputs[key] = _CustomInput(custom_property)
540
+ else:
541
+ self.parsers[key] = StreamingJsonParser()
542
+ if stream_index is not None:
543
+ self.stream_indexes[key] = stream_index
544
+ self.tool_by_index[stream_index] = block
545
+ if _truthy(tool_id):
546
+ self.tool_by_id[tool_id] = block
547
+ content_index = self._add_block(block)
548
+ self.out.push(ToolCallStartEvent(content_index, self.output))
549
+ key = id(block)
550
+ if stream_index is not None and key not in self.stream_indexes:
551
+ self.stream_indexes[key] = stream_index
552
+ self.tool_by_index[stream_index] = block
553
+ if _truthy(tool_id):
554
+ self.tool_by_id[tool_id] = block
555
+ if not block.name and name:
556
+ block.name = name
557
+ if is_custom and key not in self.custom_inputs:
558
+ custom_property = self.grammar_properties.get(block.name, "input")
559
+ block.arguments = {custom_property: ""}
560
+ self.custom_inputs[key] = _CustomInput(custom_property)
561
+ self.parsers.pop(key, None)
562
+ return block
563
+
564
+ def finish_block(self, block: Any) -> None:
565
+ content_index = self.index_of.get(id(block))
566
+ if content_index is None:
567
+ return
568
+ out = self.out
569
+ output = self.output
570
+ kind = block.type
571
+ if kind == "text":
572
+ out.push(TextEndEvent(content_index, block.text, output))
573
+ elif kind == "thinking":
574
+ self.apply_reasoning_details(block)
575
+ out.push(ThinkingEndEvent(content_index, block.thinking, output))
576
+ elif kind == "toolCall":
577
+ key = id(block)
578
+ if key in self.custom_inputs:
579
+ delta = self._append_custom_input(block, self._custom_input_value(block), True)
580
+ if delta is not None:
581
+ out.push(ToolCallDeltaEvent(content_index, delta, output))
582
+ else:
583
+ parser = self.parsers.get(key)
584
+ arguments, arguments_error = parse_tool_call_arguments(parser.text if parser is not None else None)
585
+ block.arguments = arguments
586
+ if arguments_error is not None:
587
+ block.arguments_error = arguments_error
588
+ # Finalize and drop the scratch buffers so replay only carries parsed arguments.
589
+ self.parsers.pop(key, None)
590
+ self.custom_inputs.pop(key, None)
591
+ self.stream_indexes.pop(key, None)
592
+ out.push(ToolCallEndEvent(content_index, block, output))
593
+
594
+ def finish(self) -> None:
595
+ for block in list(self.output.content):
596
+ self.finish_block(block)
597
+
598
+ # -- chunks ---------------------------------------------------------------
599
+
600
+ def handle_chunk(self, chunk: Any) -> None:
601
+ if not isinstance(chunk, dict):
602
+ return
603
+ output = self.output
604
+ model = self.model
605
+ out = self.out
606
+
607
+ # OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
608
+ # and each chunk in a streamed completion carries the same id.
609
+ if not output.response_id:
610
+ output.response_id = chunk.get("id")
611
+ chunk_model = chunk.get("model")
612
+ if isinstance(chunk_model, str) and chunk_model and chunk_model != model.id and not output.response_model:
613
+ output.response_model = chunk_model
614
+ usage = chunk.get("usage")
615
+ if _truthy(usage):
616
+ output.usage = _parse_chunk_usage(usage, model)
617
+
618
+ choices = chunk.get("choices")
619
+ choice = choices[0] if isinstance(choices, list) and choices else None
620
+ if not isinstance(choice, dict):
621
+ return
622
+
623
+ # Fallback: some providers (e.g., Moonshot) return usage in choice.usage
624
+ # instead of the standard chunk.usage.
625
+ if not _truthy(usage):
626
+ choice_usage = choice.get("usage")
627
+ if _truthy(choice_usage):
628
+ output.usage = _parse_chunk_usage(choice_usage, model)
629
+
630
+ finish_reason = choice.get("finish_reason")
631
+ if _truthy(finish_reason):
632
+ output.raw_stop_reason = finish_reason
633
+ stop_reason, error_message = _map_stop_reason(finish_reason)
634
+ output.stop_reason = stop_reason # type: ignore[assignment]
635
+ if error_message:
636
+ output.error_message = error_message
637
+ self.has_finish_reason = True
638
+
639
+ delta = choice.get("delta")
640
+ if not _truthy(delta) or not isinstance(delta, dict):
641
+ return
642
+
643
+ # Handle reasoning before content: some proxies (e.g. LiteLLM) aggregate reasoning and
644
+ # answer into one delta, and the thinking block must precede the text block.
645
+ # Some endpoints return reasoning in reasoning_content (llama.cpp), or reasoning (other
646
+ # OpenAI-compatible endpoints). Use the first non-empty reasoning field to avoid
647
+ # duplication (e.g., chutes.ai returns both reasoning_content and reasoning).
648
+ for field in _REASONING_FIELDS:
649
+ reasoning = delta.get(field)
650
+ if isinstance(reasoning, str) and reasoning:
651
+ signature = "reasoning_content" if model.provider == "opencode-go" and field == "reasoning" else field
652
+ thinking_block, thinking_index = self.ensure_thinking_block(signature)
653
+ append_thinking(thinking_block, reasoning)
654
+ out.push(ThinkingDeltaEvent(thinking_index, reasoning, output))
655
+ break
656
+
657
+ reasoning_details = delta.get("reasoning_details")
658
+ if isinstance(reasoning_details, list):
659
+ for detail in reasoning_details:
660
+ if not _is_reasoning_detail(detail):
661
+ continue
662
+ self.ensure_thinking_block("")
663
+ if self.reasoning_details is None:
664
+ self.reasoning_details = []
665
+ # Keep provider replay data in the existing signature slot. OpenRouter streams
666
+ # reasoning_details as deltas: consecutive text/summary deltas are merged into
667
+ # logical entries, while encrypted entries remain opaque and discrete.
668
+ _append_reasoning_detail(self.reasoning_details, detail)
669
+
670
+ content = delta.get("content")
671
+ if isinstance(content, str) and content:
672
+ text_block, text_index = self.ensure_text_block()
673
+ append_text(text_block, content)
674
+ out.push(TextDeltaEvent(text_index, content, output))
675
+
676
+ tool_calls = delta.get("tool_calls")
677
+ if isinstance(tool_calls, list):
678
+ for tool_call in tool_calls:
679
+ if not isinstance(tool_call, dict):
680
+ continue
681
+ self._handle_tool_call_delta(tool_call)
682
+
683
+ def _handle_tool_call_delta(self, tool_call: dict[str, Any]) -> None:
684
+ block = self.ensure_tool_call_block(tool_call)
685
+ tool_id = tool_call.get("id")
686
+ if not block.id and _truthy(tool_id):
687
+ block.id = tool_id
688
+ self.tool_by_id[tool_id] = block
689
+ function = tool_call.get("function")
690
+ custom = tool_call.get("custom")
691
+ name = _prop(function, "name")
692
+ if name is None:
693
+ name = _prop(custom, "name")
694
+ if not block.name and _truthy(name):
695
+ block.name = name
696
+
697
+ key = id(block)
698
+ delta = ""
699
+ arguments = _prop(function, "arguments")
700
+ if _truthy(arguments):
701
+ delta = arguments if isinstance(arguments, str) else _js_string(arguments)
702
+ parser = self.parsers.get(key)
703
+ if parser is None:
704
+ parser = StreamingJsonParser()
705
+ self.parsers[key] = parser
706
+ parser.feed(delta)
707
+ block.arguments = parser.value()
708
+ else:
709
+ custom_input = _prop(custom, "input")
710
+ if _truthy(custom_input):
711
+ next_input = self._custom_input_value(block) + _js_string(custom_input)
712
+ custom_delta = self._append_custom_input(block, next_input, False)
713
+ delta = custom_delta if custom_delta is not None else ""
714
+ self.out.push(ToolCallDeltaEvent(self.index_of[key], delta, self.output))
715
+
716
+
717
+ async def _run(
718
+ model: Model,
719
+ context: TranscriptContext,
720
+ options: dict[str, Any],
721
+ out: AssistantMessageEventStream,
722
+ ) -> None:
723
+ output = AssistantMessage(
724
+ [], api=model.api, provider=model.provider, model=model.id, usage=Usage(), stop_reason="pending"
725
+ )
726
+ signal = options.get("signal")
727
+ processor: _StreamProcessor | None = None
728
+ try:
729
+ api_key = _get_client_api_key(model.provider, options.get("api_key"), options.get("headers"))
730
+ compat = get_compat(model)
731
+ grammar_properties = create_grammar_tool_input_properties(
732
+ get_declared_tools(context.messages), bool(compat.get("supportsOpenAIGrammarTools"))
733
+ )
734
+ cache_retention = _resolve_cache_retention(options.get("cache_retention"), options.get("env"))
735
+ cache_session_id = None if cache_retention == "none" else options.get("session_id")
736
+ headers = _build_request_headers(model, context, options.get("headers"), cache_session_id, compat)
737
+ client = create_openai_client(api_key=api_key, base_url=model.base_url, headers=headers, options=options)
738
+ params: Any = build_params(model, context, options, compat, cache_retention, grammar_properties)
739
+ on_payload = options.get("on_payload")
740
+ if on_payload is not None:
741
+ next_params = await maybe_await(on_payload(params, model))
742
+ if next_params is not None:
743
+ params = next_params
744
+
745
+ create = client.chat.completions.with_raw_response.create
746
+ body_arguments = sdk_body_arguments(params, "model", "messages", headers=headers)
747
+ try:
748
+ async with abort_scope(signal):
749
+ raw = await openai_request(lambda: create(stream=True, **body_arguments), options)
750
+ except AbortError as error:
751
+ if signal is not None and signal.aborted:
752
+ raise AbortError("Request aborted") from error
753
+ raise
754
+
755
+ processor = _StreamProcessor(model, out, output, grammar_properties)
756
+ sdk_stream = open_sdk_stream(raw)
757
+ try:
758
+ on_response = options.get("on_response")
759
+ if on_response is not None:
760
+ await maybe_await(on_response(ProviderResponse(raw.status_code, headers_to_record(raw.headers)), model))
761
+ out.push(StartEvent(output))
762
+ on_event = options.get("on_provider_stream_event")
763
+ try:
764
+ async with abort_scope(signal):
765
+ async for chunk in iter_sdk_stream(sdk_stream):
766
+ if on_event is not None:
767
+ await maybe_await(on_event(chunk, model))
768
+ processor.handle_chunk(chunk)
769
+ except AbortError:
770
+ # The OpenAI SDK ends the chunk iteration silently when the request is aborted.
771
+ if signal is None or not signal.aborted:
772
+ raise
773
+ finally:
774
+ await sdk_stream.close()
775
+
776
+ processor.finish()
777
+ if signal is not None and signal.aborted:
778
+ raise RuntimeError("Request was aborted")
779
+ if output.stop_reason == "aborted":
780
+ raise RuntimeError("Request was aborted")
781
+ supports_finish_reason = compat.get("supportsFinishReason")
782
+ if not processor.has_finish_reason and not supports_finish_reason:
783
+ output.stop_reason = "toolUse" if any(block.type == "toolCall" for block in output.content) else "stop"
784
+ if output.stop_reason == "error":
785
+ raise RuntimeError(output.error_message or "Provider returned an error stop reason")
786
+ if (supports_finish_reason and not processor.has_finish_reason) or output.stop_reason == "pending":
787
+ raise RuntimeError("Stream ended without finish_reason")
788
+
789
+ out.push(DoneEvent(output.stop_reason, output)) # type: ignore[arg-type]
790
+ out.end()
791
+ except Exception as error:
792
+ if processor is not None and processor.thinking_block is not None:
793
+ processor.apply_reasoning_details(processor.thinking_block)
794
+ output.stop_reason = "aborted" if signal is not None and signal.aborted else "error"
795
+ message = format_provider_error(normalize_provider_error(error))
796
+ # Some providers via OpenRouter give additional information in this field.
797
+ # normalize_provider_error already stringifies the parsed body (error.error) into the
798
+ # message, so only append the raw metadata when it is not already present.
799
+ raw_metadata = _prop(_prop(getattr(error, "error", None), "metadata"), "raw")
800
+ if _truthy(raw_metadata):
801
+ raw_text = _js_string(raw_metadata)
802
+ if raw_text not in message:
803
+ message += f"\n{raw_text}"
804
+ output.error_message = message
805
+ out.push(ErrorEvent(output.stop_reason, output))
806
+ out.end()
807
+
808
+
809
+ # ---------------------------------------------------------------------------
810
+ # Request parameters
811
+ # ---------------------------------------------------------------------------
812
+
813
+
814
+ def build_params(
815
+ model: Model,
816
+ context: TranscriptContext,
817
+ options: Mapping[str, Any] | None = None,
818
+ compat: ResolvedOpenAICompletionsCompat | None = None,
819
+ cache_retention: str | None = None,
820
+ grammar_tool_input_properties: Mapping[str, str] | None = None,
821
+ ) -> dict[str, Any]:
822
+ """Build the ``chat.completions.create`` request body (``stream: true``)."""
823
+ options = options or {}
824
+ if compat is None:
825
+ compat = get_compat(model)
826
+ if cache_retention is None:
827
+ cache_retention = _resolve_cache_retention(options.get("cache_retention"), options.get("env"))
828
+ if grammar_tool_input_properties is None:
829
+ grammar_tool_input_properties = create_grammar_tool_input_properties(
830
+ get_declared_tools(context.messages), bool(compat.get("supportsOpenAIGrammarTools"))
831
+ )
832
+ transcript_tools = resolve_transcript_tools(
833
+ context.messages,
834
+ compat.get("supportsMidConvoSystemMessages") is True and compat.get("supportsMidConvoToolAdditions") is True,
835
+ )
836
+ messages = convert_messages(
837
+ model, context, compat, {"grammar_tool_input_properties": grammar_tool_input_properties}
838
+ )
839
+ cache_control = _get_compat_cache_control(compat, cache_retention)
840
+ long_retention = cache_retention == "long" and _truthy(compat.get("supportsLongCacheRetention"))
841
+
842
+ params: dict[str, Any] = {"model": model.id, "messages": messages, "stream": True}
843
+ if ("api.openai.com" in model.base_url and cache_retention != "none") or long_retention:
844
+ prompt_cache_key = clamp_openai_prompt_cache_key(options.get("session_id"))
845
+ if prompt_cache_key is not None:
846
+ params["prompt_cache_key"] = prompt_cache_key
847
+ if long_retention:
848
+ params["prompt_cache_retention"] = "24h"
849
+
850
+ if compat.get("supportsUsageInStreaming") is not False:
851
+ params["stream_options"] = {"include_usage": True}
852
+
853
+ if _truthy(compat.get("supportsStore")):
854
+ params["store"] = False
855
+
856
+ max_tokens = options.get("max_tokens")
857
+ if _truthy(max_tokens):
858
+ if compat.get("maxTokensField") == "max_tokens":
859
+ # Deprecated by OpenAI, but some OpenAI-compatible providers only accept max_tokens.
860
+ params["max_tokens"] = max_tokens
861
+ else:
862
+ params["max_completion_tokens"] = max_tokens
863
+
864
+ temperature = options.get("temperature")
865
+ if temperature is not None:
866
+ params["temperature"] = temperature
867
+
868
+ if transcript_tools.request_tools:
869
+ params["tools"] = convert_tools(transcript_tools.request_tools, compat)
870
+ if _truthy(compat.get("zaiToolStream")):
871
+ params["tool_stream"] = True
872
+ elif _has_tool_history(context.messages):
873
+ # Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results.
874
+ params["tools"] = []
875
+
876
+ if cache_control is not None:
877
+ _apply_anthropic_cache_control(messages, params.get("tools"), cache_control)
878
+
879
+ tool_choice = options.get("tool_choice")
880
+ if _truthy(tool_choice):
881
+ params["tool_choice"] = tool_choice
882
+
883
+ vllm_priority = compat.get("vllmPriority")
884
+ if vllm_priority is not None:
885
+ params["priority"] = vllm_priority
886
+
887
+ thinking_budget_field = _resolve_thinking_token_budget_field(compat)
888
+ thinking_budget = _resolve_clamped_thinking_budget(model, options, params)
889
+ _apply_thinking_params(model, options, compat, params, thinking_budget)
890
+
891
+ # Cap reasoning with a top-level budget field. Independent of thinkingFormat: the same
892
+ # server can serve zai, qwen or chat-template models. Reasoning and the answer share
893
+ # max_tokens here, so an uncapped reasoning phase can consume the whole response and
894
+ # leave no answer and no tool call.
895
+ if thinking_budget_field and thinking_budget is not None:
896
+ params[thinking_budget_field] = thinking_budget
897
+
898
+ model_compat = model.compat or {}
899
+ # OpenRouter provider routing preferences
900
+ open_router_routing = model_compat.get("openRouterRouting")
901
+ if _truthy(open_router_routing):
902
+ params["provider"] = open_router_routing
903
+
904
+ # Vercel AI Gateway provider routing preferences
905
+ vercel_routing = model_compat.get("vercelGatewayRouting")
906
+ if _truthy(vercel_routing):
907
+ only = _prop(vercel_routing, "only")
908
+ order = _prop(vercel_routing, "order")
909
+ if _truthy(only) or _truthy(order):
910
+ gateway_options: dict[str, Any] = {}
911
+ if _truthy(only):
912
+ gateway_options["only"] = only
913
+ if _truthy(order):
914
+ gateway_options["order"] = order
915
+ params["providerOptions"] = {"gateway": gateway_options}
916
+
917
+ # Last so model and request sampling parameters override named request fields.
918
+ sampling_params = resolve_sampling_params(
919
+ model, options.get("reasoning_effort") or "off", options.get("sampling_params")
920
+ )
921
+ if sampling_params:
922
+ params.update(sampling_params)
923
+
924
+ return params
925
+
926
+
927
+ def _apply_thinking_params(
928
+ model: Model,
929
+ options: Mapping[str, Any],
930
+ compat: ResolvedOpenAICompletionsCompat,
931
+ params: dict[str, Any],
932
+ thinking_budget: int | None,
933
+ ) -> None:
934
+ effort: str | None = options.get("reasoning_effort") or None
935
+ thinking_format = compat.get("thinkingFormat")
936
+ reasoning = model.reasoning
937
+ supports_effort = _truthy(compat.get("supportsReasoningEffort"))
938
+ level_map = model.thinking_level_map
939
+
940
+ if thinking_format == "zai" and reasoning:
941
+ params["thinking"] = {"type": "enabled", "clear_thinking": False} if effort else {"type": "disabled"}
942
+ if effort and supports_effort:
943
+ mapped = _level_value(model, effort)
944
+ value = effort if mapped is _UNDEFINED else mapped
945
+ if isinstance(value, str):
946
+ params["reasoning_effort"] = value
947
+ elif thinking_format == "qwen" and reasoning:
948
+ params["enable_thinking"] = bool(effort)
949
+ if effort and supports_effort:
950
+ value = _level_value_or(model, effort, effort)
951
+ if isinstance(value, str):
952
+ params["reasoning_effort"] = value
953
+ elif thinking_format == "qwen-chat-template" and reasoning:
954
+ params["chat_template_kwargs"] = {"enable_thinking": bool(effort), "preserve_thinking": True}
955
+ elif thinking_format == "chat-template" and reasoning:
956
+ kwargs = _build_chat_template_values(model, options, compat.get("chatTemplateKwargs"), thinking_budget)
957
+ if kwargs is not None:
958
+ params["chat_template_kwargs"] = kwargs
959
+ elif thinking_format == "baseten" and reasoning:
960
+ args = _build_chat_template_values(model, options, compat.get("chatTemplateArgs"), thinking_budget)
961
+ if args is not None:
962
+ params["chat_template_args"] = args
963
+ if supports_effort:
964
+ mapped = _level_value(model, effort) if effort else _level_value(model, "off")
965
+ value = effort if mapped is _UNDEFINED else mapped
966
+ if isinstance(value, str):
967
+ params["reasoning_effort"] = value
968
+ elif thinking_format == "deepseek" and reasoning:
969
+ if effort:
970
+ params["thinking"] = {"type": "enabled"}
971
+ elif _level_value(model, "off") is not None:
972
+ params["thinking"] = {"type": "disabled"}
973
+ if effort and supports_effort:
974
+ params["reasoning_effort"] = _level_value_or(model, effort, effort)
975
+ elif thinking_format == "openrouter" and reasoning:
976
+ # OpenRouter normalizes reasoning across providers via a nested reasoning object.
977
+ if effort:
978
+ params["reasoning"] = {"effort": _level_value_or(model, effort, effort)}
979
+ elif _level_value(model, "off") is not None:
980
+ params["reasoning"] = {"effort": _level_value_or(model, "off", "none")}
981
+ elif thinking_format == "ant-ling" and reasoning and effort:
982
+ value = _level_value(model, effort)
983
+ if isinstance(value, str):
984
+ params["reasoning"] = {"effort": value}
985
+ elif thinking_format == "together" and reasoning:
986
+ params["reasoning"] = {"enabled": bool(effort)}
987
+ if effort and supports_effort:
988
+ params["reasoning_effort"] = _level_value_or(model, effort, effort)
989
+ elif thinking_format == "string-thinking" and reasoning:
990
+ if effort:
991
+ params["thinking"] = _level_value_or(model, effort, effort)
992
+ elif _level_value(model, "off") is not None:
993
+ params["thinking"] = _level_value_or(model, "off", "none")
994
+ elif effort and reasoning and supports_effort:
995
+ # OpenAI-style reasoning_effort
996
+ params["reasoning_effort"] = _level_value_or(model, effort, effort)
997
+ elif not effort and reasoning and supports_effort:
998
+ off_value = level_map.get("off") if level_map else None
999
+ if isinstance(off_value, str):
1000
+ params["reasoning_effort"] = off_value
1001
+
1002
+
1003
+ def _resolve_thinking_token_budget_field(compat: Mapping[str, Any]) -> str | None:
1004
+ field = compat.get("thinkingTokenBudgetField")
1005
+ if _truthy(field):
1006
+ return field
1007
+ if _truthy(compat.get("supportsThinkingTokenBudget")):
1008
+ return "thinking_token_budget"
1009
+ return None
1010
+
1011
+
1012
+ def _resolve_clamped_thinking_budget(model: Model, options: Mapping[str, Any], params: Mapping[str, Any]) -> int | None:
1013
+ effort = options.get("reasoning_effort")
1014
+ if not effort or not model.reasoning:
1015
+ return None
1016
+ ceiling = params.get("max_tokens")
1017
+ if ceiling is None:
1018
+ ceiling = params.get("max_completion_tokens")
1019
+ if ceiling is None:
1020
+ ceiling = model.max_tokens
1021
+ try:
1022
+ budget = clamp_thinking_budget_to_answer_room(
1023
+ thinking_budget_for_level(effort, options.get("thinking_budgets")), ceiling
1024
+ )
1025
+ except (KeyError, TypeError):
1026
+ # Unknown levels or budgets yield NaN upstream, which never counts as a budget.
1027
+ return None
1028
+ return budget if budget > 0 else None
1029
+
1030
+
1031
+ def _build_chat_template_values(
1032
+ model: Model, options: Mapping[str, Any], values: Any, thinking_budget: int | None
1033
+ ) -> dict[str, Any] | None:
1034
+ if not isinstance(values, Mapping):
1035
+ return None
1036
+ resolved: dict[str, Any] = {}
1037
+ for key, value in values.items():
1038
+ result = _resolve_chat_template_value(model, options, value, thinking_budget)
1039
+ if result is not _UNDEFINED:
1040
+ resolved[key] = result
1041
+ return resolved or None
1042
+
1043
+
1044
+ def _resolve_chat_template_value(
1045
+ model: Model, options: Mapping[str, Any], value: Any, thinking_budget: int | None
1046
+ ) -> Any:
1047
+ if not isinstance(value, dict):
1048
+ return value
1049
+ effort = options.get("reasoning_effort")
1050
+ if not effort and _truthy(value.get("omitWhenOff")):
1051
+ return _UNDEFINED
1052
+ variable = value.get("$var")
1053
+ if variable == "thinking.enabled":
1054
+ return bool(effort)
1055
+ if variable == "thinking.budget":
1056
+ return thinking_budget if thinking_budget is not None else _UNDEFINED
1057
+ mapped = _level_value(model, effort) if effort else _level_value(model, "off")
1058
+ if mapped is _UNDEFINED:
1059
+ return effort if effort else _UNDEFINED
1060
+ return mapped if isinstance(mapped, str) else _UNDEFINED
1061
+
1062
+
1063
+ # ---------------------------------------------------------------------------
1064
+ # Anthropic-style cache_control markers
1065
+ # ---------------------------------------------------------------------------
1066
+
1067
+
1068
+ def _get_compat_cache_control(compat: Mapping[str, Any], cache_retention: str) -> dict[str, Any] | None:
1069
+ if compat.get("cacheControlFormat") != "anthropic" or cache_retention == "none":
1070
+ return None
1071
+ cache_control: dict[str, Any] = {"type": "ephemeral"}
1072
+ if cache_retention == "long" and _truthy(compat.get("supportsLongCacheRetention")):
1073
+ cache_control["ttl"] = "1h"
1074
+ return cache_control
1075
+
1076
+
1077
+ def _apply_anthropic_cache_control(
1078
+ messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, cache_control: dict[str, Any]
1079
+ ) -> None:
1080
+ # System prompt
1081
+ for message in messages:
1082
+ if message.get("role") in ("system", "developer"):
1083
+ _add_cache_control_to_text_content(message, cache_control)
1084
+ break
1085
+ # Last tool definition
1086
+ if tools:
1087
+ tools[-1]["cache_control"] = cache_control
1088
+ # Last user, assistant, or tool-result text content
1089
+ for message in reversed(messages):
1090
+ if message.get("role") in ("user", "assistant", "tool") and _add_cache_control_to_text_content(
1091
+ message, cache_control
1092
+ ):
1093
+ return
1094
+
1095
+
1096
+ def _add_cache_control_to_text_content(message: dict[str, Any], cache_control: dict[str, Any]) -> bool:
1097
+ content = message.get("content")
1098
+ if isinstance(content, str):
1099
+ if not content:
1100
+ return False
1101
+ message["content"] = [{"type": "text", "text": content, "cache_control": cache_control}]
1102
+ return True
1103
+ if not isinstance(content, list):
1104
+ return False
1105
+ for part in reversed(content):
1106
+ if isinstance(part, dict) and part.get("type") == "text":
1107
+ part["cache_control"] = cache_control
1108
+ return True
1109
+ return False
1110
+
1111
+
1112
+ # ---------------------------------------------------------------------------
1113
+ # Messages and tools
1114
+ # ---------------------------------------------------------------------------
1115
+
1116
+ _INVALID_ID_CHAR = re.compile(r"[^a-zA-Z0-9_-]")
1117
+
1118
+
1119
+ def _replace_invalid_id_char(match: re.Match[str]) -> str:
1120
+ # JavaScript regexes without the u flag replace each UTF-16 code unit.
1121
+ return "__" if ord(match.group()) > 0xFFFF else "_"
1122
+
1123
+
1124
+ def _sanitize_id_part(text: str) -> str:
1125
+ return _INVALID_ID_CHAR.sub(_replace_invalid_id_char, text)
1126
+
1127
+
1128
+ def _image_url(block: ImageContent) -> dict[str, Any]:
1129
+ return {"type": "image_url", "image_url": {"url": f"data:{block.mime_type};base64,{block.data}"}}
1130
+
1131
+
1132
+ def convert_messages(
1133
+ model: Model,
1134
+ context: TranscriptContext,
1135
+ compat: Mapping[str, Any],
1136
+ options: ConvertCompletionsMessagesOptions | Mapping[str, Any] | None = None,
1137
+ ) -> list[dict[str, Any]]:
1138
+ """Convert a pi transcript into Chat Completions ``messages``."""
1139
+ normalized = resolve_transcript(context, compat.get("supportsMidConvoSystemMessages"))
1140
+ grammar_properties: Mapping[str, str] | None = (options or {}).get("grammar_tool_input_properties")
1141
+ params: list[dict[str, Any]] = []
1142
+ provider = model.provider
1143
+
1144
+ def normalize_tool_call_id(tool_call_id: str, _model: Model, _source: AssistantMessage) -> str:
1145
+ # Handle pipe-separated IDs from the OpenAI Responses API: {call_id}|{id}, where {id}
1146
+ # can be 400+ chars with special chars (+, /, =). They come from providers like
1147
+ # github-copilot, openai-codex and opencode. Multiple tool calls in the same turn can
1148
+ # share call_id but differ by item_id, so preserve item-level uniqueness when
1149
+ # replaying into Chat Completions, which requires distinct tool call ids.
1150
+ if "|" in tool_call_id:
1151
+ # Sanitize to allowed chars and truncate to 40 chars (OpenAI limit).
1152
+ separator = tool_call_id.index("|")
1153
+ call_id = _sanitize_id_part(tool_call_id[:separator])
1154
+ item_id = _sanitize_id_part(tool_call_id[separator + 1 :])
1155
+ combined = f"{call_id}_{item_id}" if item_id else call_id
1156
+ if len(combined) <= 40:
1157
+ return combined
1158
+ digest = short_hash(tool_call_id)[:8]
1159
+ prefix = call_id[: max(1, 40 - len(digest) - 1)]
1160
+ return f"{prefix}_{digest}"
1161
+ if provider == "openai":
1162
+ return tool_call_id[:40] if len(tool_call_id) > 40 else tool_call_id
1163
+ return tool_call_id
1164
+
1165
+ transformed = transform_messages(normalized.messages, model, normalize_tool_call_id)
1166
+ transcript_tools = resolve_transcript_tools(
1167
+ normalized.messages,
1168
+ compat.get("supportsMidConvoSystemMessages") is True and compat.get("supportsMidConvoToolAdditions") is True,
1169
+ )
1170
+ instruction_role = "developer" if model.reasoning and _truthy(compat.get("supportsDeveloperRole")) else "system"
1171
+ requires_assistant_after_tool_result = _truthy(compat.get("requiresAssistantAfterToolResult"))
1172
+ supports_images = "image" in model.input
1173
+
1174
+ last_role: str | None = None
1175
+ i = 0
1176
+ count = len(transformed)
1177
+ while i < count:
1178
+ msg: Any = transformed[i]
1179
+ role = msg.role
1180
+ # Some providers don't allow user messages directly after tool results.
1181
+ # Insert a synthetic assistant message to bridge the gap.
1182
+ if requires_assistant_after_tool_result and last_role == "toolResult" and role == "user":
1183
+ params.append({"role": "assistant", "content": "I have processed the tool results."})
1184
+
1185
+ if role == "system":
1186
+ added_tools = (msg.tools_added or []) if i > 0 and transcript_tools.anchors_additions else []
1187
+ if added_tools:
1188
+ params.append({"role": "system", "tools": convert_tools(added_tools, compat)})
1189
+ text = get_system_message_text(msg) if i == 0 else render_system_message_update(msg)
1190
+ if text:
1191
+ params.append({"role": instruction_role, "content": sanitize_surrogates(text)})
1192
+ elif role == "user":
1193
+ if isinstance(msg.content, str):
1194
+ params.append({"role": "user", "content": sanitize_surrogates(msg.content)})
1195
+ else:
1196
+ parts: list[dict[str, Any]] = []
1197
+ for item in msg.content:
1198
+ if item.type == "text":
1199
+ if item.text:
1200
+ parts.append({"type": "text", "text": sanitize_surrogates(item.text)})
1201
+ else:
1202
+ parts.append(_image_url(item))
1203
+ if not parts:
1204
+ i += 1
1205
+ continue
1206
+ params.append({"role": "user", "content": parts})
1207
+ elif role == "assistant":
1208
+ if not _append_assistant_message(params, msg, model, compat, grammar_properties):
1209
+ i += 1
1210
+ continue
1211
+ elif role == "toolResult":
1212
+ image_blocks: list[dict[str, Any]] = []
1213
+ j = i
1214
+ while j < count and transformed[j].role == "toolResult":
1215
+ tool_msg: ToolResultMessage = transformed[j] # type: ignore[assignment]
1216
+ text_result = "\n".join(block.text for block in tool_msg.content if block.type == "text")
1217
+ has_images = any(block.type == "image" for block in tool_msg.content)
1218
+ # Always send tool result with text (or placeholder if only images).
1219
+ if _js_trim(text_result):
1220
+ tool_result_text = text_result
1221
+ else:
1222
+ tool_result_text = "(see attached image)" if has_images else "(no tool output)"
1223
+ tool_result: dict[str, Any] = {
1224
+ "role": "tool",
1225
+ "content": sanitize_surrogates(tool_result_text),
1226
+ "tool_call_id": tool_msg.tool_call_id,
1227
+ }
1228
+ # Some providers require the 'name' field in tool results.
1229
+ if _truthy(compat.get("requiresToolResultName")) and tool_msg.tool_name:
1230
+ tool_result["name"] = tool_msg.tool_name
1231
+ params.append(tool_result)
1232
+ if has_images and supports_images:
1233
+ image_blocks.extend(_image_url(block) for block in tool_msg.content if block.type == "image")
1234
+ j += 1
1235
+ i = j
1236
+ if image_blocks:
1237
+ if requires_assistant_after_tool_result:
1238
+ params.append({"role": "assistant", "content": "I have processed the tool results."})
1239
+ params.append(
1240
+ {
1241
+ "role": "user",
1242
+ "content": [{"type": "text", "text": "Attached image(s) from tool result:"}, *image_blocks],
1243
+ }
1244
+ )
1245
+ last_role = "user"
1246
+ else:
1247
+ last_role = "toolResult"
1248
+ continue
1249
+
1250
+ last_role = role
1251
+ i += 1
1252
+
1253
+ return params
1254
+
1255
+
1256
+ def _append_assistant_message(
1257
+ params: list[dict[str, Any]],
1258
+ msg: AssistantMessage,
1259
+ model: Model,
1260
+ compat: Mapping[str, Any],
1261
+ grammar_properties: Mapping[str, str] | None,
1262
+ ) -> bool:
1263
+ # Some providers don't accept null content, use empty string instead.
1264
+ assistant: dict[str, Any] = {
1265
+ "role": "assistant",
1266
+ "content": "" if _truthy(compat.get("requiresAssistantAfterToolResult")) else None,
1267
+ }
1268
+ text_parts = [
1269
+ {"type": "text", "text": sanitize_surrogates(block.text)}
1270
+ for block in msg.content
1271
+ if block.type == "text" and _js_trim(block.text)
1272
+ ]
1273
+ assistant_text = "".join(part["text"] for part in text_parts)
1274
+
1275
+ thinking_blocks: list[ThinkingContent] = [block for block in msg.content if block.type == "thinking"] # type: ignore[misc]
1276
+ tool_calls: list[ToolCall] = [block for block in msg.content if block.type == "toolCall"] # type: ignore[misc]
1277
+ signed_details = None
1278
+ for block in thinking_blocks:
1279
+ signed_details = _parse_reasoning_details(block.thinking_signature)
1280
+ if signed_details is not None:
1281
+ break
1282
+ legacy_details = [
1283
+ detail
1284
+ for detail in (_parse_legacy_encrypted_reasoning_detail(call.thought_signature) for call in tool_calls)
1285
+ if detail is not None
1286
+ ]
1287
+ preserved_details = signed_details if signed_details is not None else (legacy_details or None)
1288
+
1289
+ non_empty_thinking = [block for block in thinking_blocks if _js_trim(block.thinking)]
1290
+ if non_empty_thinking:
1291
+ if _truthy(compat.get("requiresThinkingAsText")):
1292
+ # Convert thinking blocks to plain text (no tags to avoid the model mimicking them).
1293
+ thinking_text = "\n\n".join(sanitize_surrogates(block.thinking) for block in non_empty_thinking)
1294
+ assistant["content"] = [{"type": "text", "text": thinking_text}, *text_parts]
1295
+ else:
1296
+ # Always send assistant content as a plain string (OpenAI Chat Completions API
1297
+ # standard format). Sending an array of {type:"text", text:"..."} objects is
1298
+ # non-standard and causes some models (e.g. DeepSeek V3.2 via NVIDIA NIM) to mirror
1299
+ # the content-block structure literally in their output.
1300
+ if assistant_text:
1301
+ assistant["content"] = assistant_text
1302
+ # reasoning_details is the structured alternative to a raw reasoning field.
1303
+ if preserved_details is None:
1304
+ # Use the signature from the first thinking block if available (llama.cpp server + gpt-oss).
1305
+ signature = non_empty_thinking[0].thinking_signature
1306
+ if model.provider == "opencode-go" and signature == "reasoning":
1307
+ signature = "reasoning_content"
1308
+ if signature and signature in _REPLAY_REASONING_FIELDS:
1309
+ assistant[signature] = "\n".join(block.thinking for block in non_empty_thinking)
1310
+ elif assistant_text:
1311
+ assistant["content"] = assistant_text
1312
+
1313
+ if tool_calls:
1314
+ converted: list[dict[str, Any]] = []
1315
+ for call in tool_calls:
1316
+ custom_property = grammar_properties.get(call.name) if grammar_properties else None
1317
+ if custom_property is not None:
1318
+ converted.append(
1319
+ {
1320
+ "id": call.id,
1321
+ "type": "custom",
1322
+ "custom": {
1323
+ "name": call.name,
1324
+ "input": sanitize_surrogates(
1325
+ get_grammar_tool_input(call.name, call.arguments, custom_property)
1326
+ ),
1327
+ },
1328
+ }
1329
+ )
1330
+ else:
1331
+ converted.append(
1332
+ {
1333
+ "id": call.id,
1334
+ "type": "function",
1335
+ "function": {"name": call.name, "arguments": fastjson.dumps(call.arguments)},
1336
+ }
1337
+ )
1338
+ assistant["tool_calls"] = converted
1339
+ if preserved_details is not None:
1340
+ assistant["reasoning_details"] = preserved_details
1341
+ if (
1342
+ _truthy(compat.get("requiresReasoningContentOnAssistantMessages"))
1343
+ and model.reasoning
1344
+ and "reasoning_content" not in assistant
1345
+ ):
1346
+ assistant["reasoning_content"] = ""
1347
+
1348
+ # Skip assistant messages that have no content and no tool calls. Some providers require
1349
+ # "either content or tool_calls, but not none", and others reject empty assistant messages.
1350
+ # This handles aborted assistant responses that got no content.
1351
+ content = assistant["content"]
1352
+ if not content and "tool_calls" not in assistant:
1353
+ return False
1354
+ params.append(assistant)
1355
+ return True
1356
+
1357
+
1358
+ def convert_tools(tools: Sequence[Tool], compat: Mapping[str, Any]) -> list[dict[str, Any]]:
1359
+ """Convert pi tools to Chat Completions tool definitions (function or grammar ``custom`` tools)."""
1360
+ supports_strict = compat.get("supportsStrictMode") is not False
1361
+ supports_grammar = _truthy(compat.get("supportsOpenAIGrammarTools"))
1362
+ result: list[dict[str, Any]] = []
1363
+ for tool in tools:
1364
+ grammar = resolve_grammar_constrained_sampling(tool, supports_grammar)
1365
+ if grammar is not None:
1366
+ result.append(
1367
+ {
1368
+ "type": "custom",
1369
+ "custom": {
1370
+ "name": tool.name,
1371
+ "description": tool.description,
1372
+ "format": {
1373
+ "type": "grammar",
1374
+ "grammar": {"syntax": grammar.format, "definition": grammar.definition},
1375
+ },
1376
+ },
1377
+ }
1378
+ )
1379
+ continue
1380
+ strict = resolve_json_schema_strict_sampling(tool, supports_strict)
1381
+ function: dict[str, Any] = {
1382
+ "name": tool.name,
1383
+ "description": tool.description,
1384
+ "parameters": get_json_schema_tool_parameters(tool, strict),
1385
+ }
1386
+ # Only include strict if the provider supports it. Some reject unknown fields.
1387
+ if supports_strict:
1388
+ function["strict"] = strict if strict is not None else False
1389
+ result.append({"type": "function", "function": function})
1390
+ return result
1391
+
1392
+
1393
+ # ---------------------------------------------------------------------------
1394
+ # Usage and stop reasons
1395
+ # ---------------------------------------------------------------------------
1396
+
1397
+
1398
+ def _parse_chunk_usage(raw_usage: Any, model: Model) -> Usage:
1399
+ raw = raw_usage if isinstance(raw_usage, dict) else {}
1400
+ prompt_tokens = raw.get("prompt_tokens") or 0
1401
+ prompt_details = raw.get("prompt_tokens_details")
1402
+ cache_read = _prop(prompt_details, "cached_tokens")
1403
+ if cache_read is None:
1404
+ cache_read = raw.get("prompt_cache_hit_tokens")
1405
+ if cache_read is None:
1406
+ cache_read = raw.get("cached_tokens")
1407
+ if cache_read is None:
1408
+ cache_read = 0
1409
+ cache_write = _prop(prompt_details, "cache_write_tokens") or 0
1410
+
1411
+ # Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read tokens (hits).
1412
+ # Providers disagree on placement: OpenAI/OpenRouter use prompt_tokens_details.cached_tokens,
1413
+ # DeepSeek uses prompt_cache_hit_tokens, and Kimi documents top-level usage.cached_tokens on
1414
+ # the final usage chunk. OpenAI does not document or emit cache_write_tokens, but
1415
+ # OpenRouter-compatible providers can include it as a separate write count
1416
+ # (https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409). Do not subtract writes from
1417
+ # cached_tokens, otherwise spec-compliant providers are under-reported
1418
+ # (DS4 mirrors this contract too: https://github.com/antirez/ds4/pull/29).
1419
+ input_tokens = max(0, prompt_tokens - cache_read - cache_write)
1420
+ # OpenAI completion_tokens already includes reasoning_tokens.
1421
+ output_tokens = raw.get("completion_tokens") or 0
1422
+ usage = Usage(
1423
+ input=input_tokens,
1424
+ output=output_tokens,
1425
+ cache_read=cache_read,
1426
+ cache_write=cache_write,
1427
+ total_tokens=input_tokens + output_tokens + cache_read + cache_write,
1428
+ reasoning=_prop(raw.get("completion_tokens_details"), "reasoning_tokens") or 0,
1429
+ )
1430
+ calculate_cost(model, usage)
1431
+ return usage
1432
+
1433
+
1434
+ def _map_stop_reason(reason: Any) -> tuple[str, str | None]:
1435
+ if reason is None:
1436
+ return "stop", None
1437
+ if reason in ("stop", "end"):
1438
+ return "stop", None
1439
+ if reason == "length":
1440
+ return "length", None
1441
+ if reason in ("function_call", "tool_calls"):
1442
+ return "toolUse", None
1443
+ return "error", f"Provider finish_reason: {_js_string(reason)}"
1444
+
1445
+
1446
+ # ---------------------------------------------------------------------------
1447
+ # Compatibility detection
1448
+ # ---------------------------------------------------------------------------
1449
+
1450
+
1451
+ def detect_compat(model: Model) -> ResolvedOpenAICompletionsCompat:
1452
+ """Auto-detect compatibility settings from the provider id and base URL.
1453
+
1454
+ Used as the base when ``model.compat`` is not set; explicit ``model.compat``
1455
+ entries override these detected values (see :func:`get_compat`).
1456
+ """
1457
+ provider = model.provider
1458
+ base_url = model.base_url
1459
+
1460
+ is_zai = provider in ("zai", "zai-coding-cn") or "api.z.ai" in base_url or "open.bigmodel.cn" in base_url
1461
+ is_together = provider == "together" or "api.together.ai" in base_url or "api.together.xyz" in base_url
1462
+ is_moonshot = provider in ("moonshotai", "moonshotai-cn") or "api.moonshot." in base_url
1463
+ is_open_router = provider == "openrouter" or "openrouter.ai" in base_url
1464
+ is_cloudflare_workers_ai = provider == "cloudflare-workers-ai" or "api.cloudflare.com" in base_url
1465
+ is_cloudflare_ai_gateway = provider == "cloudflare-ai-gateway" or "gateway.ai.cloudflare.com" in base_url
1466
+ is_nvidia = provider == "nvidia" or "integrate.api.nvidia.com" in base_url
1467
+ is_ant_ling = provider == "ant-ling" or "api.ant-ling.com" in base_url
1468
+ is_cerebras = provider == "cerebras" or "cerebras.ai" in base_url
1469
+ is_deepseek = provider == "deepseek" or "deepseek.com" in base_url.lower()
1470
+ is_chutes = "chutes.ai" in base_url
1471
+ is_grok = provider == "xai" or "api.x.ai" in base_url
1472
+
1473
+ is_non_standard = (
1474
+ is_nvidia
1475
+ or is_cerebras
1476
+ or is_grok
1477
+ or is_together
1478
+ or is_chutes
1479
+ or is_deepseek
1480
+ or is_zai
1481
+ or is_moonshot
1482
+ or provider == "opencode"
1483
+ or "opencode.ai" in base_url
1484
+ or is_cloudflare_workers_ai
1485
+ or is_cloudflare_ai_gateway
1486
+ or is_ant_ling
1487
+ )
1488
+ use_max_tokens = (
1489
+ is_chutes
1490
+ or is_deepseek
1491
+ or is_moonshot
1492
+ or is_cloudflare_ai_gateway
1493
+ or is_together
1494
+ or is_nvidia
1495
+ or is_ant_ling
1496
+ or is_zai
1497
+ )
1498
+ is_open_router_developer_role_model = is_open_router and (
1499
+ model.id.startswith("anthropic/") or model.id.startswith("openai/")
1500
+ )
1501
+ cache_control_format = "anthropic" if provider == "openrouter" and model.id.startswith("anthropic/") else None
1502
+
1503
+ if is_deepseek:
1504
+ thinking_format = "deepseek"
1505
+ elif is_zai:
1506
+ thinking_format = "zai"
1507
+ elif is_together:
1508
+ thinking_format = "together"
1509
+ elif is_ant_ling:
1510
+ thinking_format = "ant-ling"
1511
+ elif is_open_router:
1512
+ thinking_format = "openrouter"
1513
+ else:
1514
+ thinking_format = "openai"
1515
+
1516
+ return {
1517
+ "supportsStore": not is_non_standard,
1518
+ "supportsDeveloperRole": is_open_router_developer_role_model or (not is_non_standard and not is_open_router),
1519
+ "supportsReasoningEffort": not (
1520
+ is_grok or is_zai or is_moonshot or is_together or is_cloudflare_ai_gateway or is_nvidia or is_ant_ling
1521
+ ),
1522
+ "supportsUsageInStreaming": True,
1523
+ "supportsFinishReason": True,
1524
+ "maxTokensField": "max_tokens" if use_max_tokens else "max_completion_tokens",
1525
+ "requiresToolResultName": False,
1526
+ "requiresAssistantAfterToolResult": False,
1527
+ "requiresThinkingAsText": False,
1528
+ "requiresReasoningContentOnAssistantMessages": is_deepseek,
1529
+ "thinkingFormat": thinking_format,
1530
+ "openRouterRouting": {},
1531
+ "vercelGatewayRouting": {},
1532
+ "chatTemplateKwargs": {},
1533
+ "chatTemplateArgs": {},
1534
+ "zaiToolStream": False,
1535
+ "supportsThinkingTokenBudget": False,
1536
+ "thinkingTokenBudgetField": None,
1537
+ # OpenAI compatibility alone does not imply strict JSON-schema tool support.
1538
+ "supportsStrictMode": False,
1539
+ "supportsOpenAIGrammarTools": False,
1540
+ "supportsMidConvoSystemMessages": False,
1541
+ "supportsMidConvoToolAdditions": False,
1542
+ "cacheControlFormat": cache_control_format,
1543
+ "sendSessionAffinityHeaders": is_open_router,
1544
+ "sessionAffinityFormat": "openrouter" if is_open_router else "openai",
1545
+ "supportsLongCacheRetention": not (
1546
+ is_together or is_cloudflare_workers_ai or is_cloudflare_ai_gateway or is_nvidia or is_ant_ling
1547
+ ),
1548
+ }
1549
+
1550
+
1551
+ _COMPAT_KEYS = (
1552
+ "supportsStore",
1553
+ "supportsDeveloperRole",
1554
+ "supportsReasoningEffort",
1555
+ "supportsUsageInStreaming",
1556
+ "supportsFinishReason",
1557
+ "maxTokensField",
1558
+ "requiresToolResultName",
1559
+ "requiresAssistantAfterToolResult",
1560
+ "requiresThinkingAsText",
1561
+ "requiresReasoningContentOnAssistantMessages",
1562
+ "thinkingFormat",
1563
+ "openRouterRouting",
1564
+ "vercelGatewayRouting",
1565
+ "chatTemplateKwargs",
1566
+ "chatTemplateArgs",
1567
+ "zaiToolStream",
1568
+ "supportsThinkingTokenBudget",
1569
+ "thinkingTokenBudgetField",
1570
+ "supportsStrictMode",
1571
+ "supportsOpenAIGrammarTools",
1572
+ "supportsMidConvoSystemMessages",
1573
+ "supportsMidConvoToolAdditions",
1574
+ "cacheControlFormat",
1575
+ "sendSessionAffinityHeaders",
1576
+ "sessionAffinityFormat",
1577
+ "supportsLongCacheRetention",
1578
+ )
1579
+
1580
+
1581
+ def get_compat(model: Model) -> ResolvedOpenAICompletionsCompat:
1582
+ """Resolved compatibility settings: auto-detection overridden by explicit ``model.compat``."""
1583
+ detected = detect_compat(model)
1584
+ model_compat = model.compat
1585
+ if not model_compat:
1586
+ return detected
1587
+ resolved: ResolvedOpenAICompletionsCompat = {}
1588
+ for key in _COMPAT_KEYS:
1589
+ value = model_compat.get(key)
1590
+ resolved[key] = value if value is not None else detected[key]
1591
+ open_router_routing = model_compat.get("openRouterRouting")
1592
+ resolved["openRouterRouting"] = open_router_routing if open_router_routing is not None else {}
1593
+ resolved["vllmPriority"] = model_compat.get("vllmPriority")
1594
+ return resolved