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,756 @@
1
+ # pyright: reportAttributeAccessIssue=false, reportArgumentType=false
2
+ # Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
3
+ """Mistral Chat Completions streaming (``mistral-conversations`` API).
4
+
5
+ Port of pi's ``api/mistral-conversations.ts``. Like pi, this talks to
6
+ ``/v1/chat/completions`` directly: the payload is built in the Mistral SDK's
7
+ camelCase shape (what ``on_payload`` sees) and remapped to the snake_case wire
8
+ format before sending.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import http
15
+ import math
16
+ import re
17
+ from collections.abc import AsyncIterator, Mapping
18
+ from typing import Any, Literal, TypedDict
19
+
20
+ import httpx
21
+
22
+ from ..abort import abort_scope
23
+ from ..event_stream import AssistantMessageEventStream
24
+ from ..models import calculate_cost, clamp_thinking_level
25
+ from ..types import (
26
+ AssistantMessage,
27
+ DoneEvent,
28
+ ErrorEvent,
29
+ Message,
30
+ Model,
31
+ ProviderResponse,
32
+ StartEvent,
33
+ StopReason,
34
+ StreamOptions,
35
+ TextContent,
36
+ TextDeltaEvent,
37
+ TextEndEvent,
38
+ TextStartEvent,
39
+ ThinkingContent,
40
+ ThinkingDeltaEvent,
41
+ ThinkingEndEvent,
42
+ ThinkingStartEvent,
43
+ Tool,
44
+ ToolCall,
45
+ ToolCallDeltaEvent,
46
+ ToolCallEndEvent,
47
+ ToolCallStartEvent,
48
+ TranscriptContext,
49
+ Usage,
50
+ )
51
+ from ..utils import fastjson
52
+ from ..utils.callbacks import maybe_await
53
+ from ..utils.error_body import truncate_error_text
54
+ from ..utils.http import ProviderHttpError, build_headers, iter_sse, send_request
55
+ from ..utils.json_parse import StreamingJsonParser, parse_tool_call_arguments
56
+ from ..utils.misc import get_pi_user_agent, headers_to_record, sanitize_surrogates, short_hash
57
+ from ..utils.text import append_text as append_block_text
58
+ from ..utils.text import append_thinking as append_block_thinking
59
+ from ..utils.text import get_system_message_text, render_system_message_update
60
+ from ..utils.transcript import get_current_tools, resolve_transcript
61
+ from .constrained_sampling import get_json_schema_tool_parameters, resolve_json_schema_strict_sampling
62
+ from .simple_options import build_base_options
63
+ from .transform_messages import transform_messages
64
+
65
+ __all__ = ["MistralOptions", "MistralReasoningEffort", "MistralToolChoice", "stream", "stream_simple"]
66
+
67
+ _MISTRAL_TOOL_CALL_ID_LENGTH = 9
68
+ _MAX_MISTRAL_ERROR_BODY_CHARS = 4000
69
+ _DEFAULT_HEADER_TIMEOUT_MS = 60_000
70
+
71
+ MistralReasoningEffort = Literal["none", "low", "medium", "high", "max"]
72
+
73
+
74
+ class _FunctionName(TypedDict):
75
+ name: str
76
+
77
+
78
+ class _FunctionToolChoice(TypedDict):
79
+ type: Literal["function"]
80
+ function: _FunctionName
81
+
82
+
83
+ MistralToolChoice = Literal["auto", "none", "any", "required"] | _FunctionToolChoice
84
+
85
+
86
+ class MistralOptions(StreamOptions, total=False):
87
+ """Provider-specific options for the Mistral API."""
88
+
89
+ tool_choice: MistralToolChoice
90
+ prompt_mode: Literal["reasoning"]
91
+ reasoning_effort: MistralReasoningEffort
92
+
93
+
94
+ def stream(
95
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
96
+ ) -> AssistantMessageEventStream:
97
+ """Stream responses from the native Mistral Chat Completions endpoint."""
98
+ opts: dict[str, Any] = dict(options or {})
99
+ out = AssistantMessageEventStream()
100
+ normalized = resolve_transcript(context, (model.compat or {}).get("supportsMidConvoSystemMessages"))
101
+ out.start(_run(model, normalized, opts, out))
102
+ return out
103
+
104
+
105
+ def stream_simple(
106
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
107
+ ) -> AssistantMessageEventStream:
108
+ """Map provider-agnostic simple options to Mistral options.
109
+
110
+ Raises ``ValueError`` synchronously when no API key is given.
111
+ """
112
+ options = options or {}
113
+ api_key = options.get("api_key")
114
+ if not api_key:
115
+ raise ValueError(f"No API key for provider: {model.provider}")
116
+
117
+ base: dict[str, Any] = {**build_base_options(model, context, options, api_key)}
118
+ if options.get("tool_choice") is not None:
119
+ base["tool_choice"] = options["tool_choice"]
120
+ requested = options.get("reasoning")
121
+ clamped = clamp_thinking_level(model, requested) if requested else None
122
+ reasoning = None if clamped == "off" else clamped
123
+ # Models with a thinking level map use `reasoning_effort`; other reasoning models use `prompt_mode`.
124
+ effort_map = model.thinking_level_map if model.reasoning else None
125
+ reasoning_effort: str | None = None
126
+ if effort_map is not None:
127
+ mapped = effort_map.get(reasoning) if reasoning else effort_map.get("off")
128
+ reasoning_effort = "high" if reasoning and mapped is None else mapped
129
+ if model.reasoning and effort_map is None and reasoning:
130
+ base["prompt_mode"] = "reasoning"
131
+ if reasoning_effort:
132
+ base["reasoning_effort"] = reasoning_effort
133
+ return stream(model, context, base)
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Producer
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ async def _run(
142
+ model: Model, context: TranscriptContext, options: dict[str, Any], out: AssistantMessageEventStream
143
+ ) -> None:
144
+ output = AssistantMessage(
145
+ [], api=model.api, provider=model.provider, model=model.id, usage=Usage(), stop_reason="pending"
146
+ )
147
+ signal = options.get("signal")
148
+ try:
149
+ api_key = options.get("api_key")
150
+ if not api_key:
151
+ raise RuntimeError(f"No API key for provider: {model.provider}")
152
+
153
+ normalize_id = _create_mistral_tool_call_id_normalizer()
154
+ messages = transform_messages(context.messages, model, lambda id_, _model, _message: normalize_id(id_))
155
+ payload: Any = _build_chat_payload(model, context, messages, options)
156
+ on_payload = options.get("on_payload")
157
+ if on_payload is not None:
158
+ replaced = await maybe_await(on_payload(payload, model))
159
+ if replaced is not None:
160
+ payload = replaced
161
+
162
+ async with abort_scope(signal):
163
+ response = await _request_mistral_stream(model, payload, api_key, options)
164
+ try:
165
+ out.push(StartEvent(output))
166
+ await _consume_chat_stream(model, output, out, response, options.get("on_provider_stream_event"))
167
+ finally:
168
+ await response.aclose()
169
+
170
+ if signal is not None and signal.aborted:
171
+ raise RuntimeError("Request was aborted")
172
+ if output.stop_reason == "pending":
173
+ raise RuntimeError("Mistral stream ended without a finish reason")
174
+ if output.stop_reason in ("aborted", "error"):
175
+ raise RuntimeError(output.error_message or "An unknown error occurred")
176
+
177
+ out.push(DoneEvent(output.stop_reason, output)) # type: ignore[arg-type]
178
+ out.end()
179
+ except Exception as error:
180
+ output.stop_reason = "aborted" if signal is not None and signal.aborted else "error"
181
+ output.error_message = _format_mistral_error(error)
182
+ out.push(ErrorEvent(output.stop_reason, output))
183
+ out.end()
184
+
185
+
186
+ def _create_mistral_tool_call_id_normalizer() -> Any:
187
+ id_map: dict[str, str] = {}
188
+ reverse_map: dict[str, str] = {}
189
+
190
+ def normalize(id_: str) -> str:
191
+ existing = id_map.get(id_)
192
+ if existing:
193
+ return existing
194
+ attempt = 0
195
+ while True:
196
+ candidate = _derive_mistral_tool_call_id(id_, attempt)
197
+ owner = reverse_map.get(candidate)
198
+ if not owner or owner == id_:
199
+ id_map[id_] = candidate
200
+ reverse_map[candidate] = id_
201
+ return candidate
202
+ attempt += 1
203
+
204
+ return normalize
205
+
206
+
207
+ _NON_ALPHANUMERIC = re.compile(r"[^a-zA-Z0-9]")
208
+
209
+
210
+ def _derive_mistral_tool_call_id(id_: str, attempt: int) -> str:
211
+ normalized = _NON_ALPHANUMERIC.sub("", id_)
212
+ if attempt == 0 and len(normalized) == _MISTRAL_TOOL_CALL_ID_LENGTH:
213
+ return normalized
214
+ seed_base = normalized or id_
215
+ seed = seed_base if attempt == 0 else f"{seed_base}:{attempt}"
216
+ return _NON_ALPHANUMERIC.sub("", short_hash(seed))[:_MISTRAL_TOOL_CALL_ID_LENGTH]
217
+
218
+
219
+ def _format_mistral_error(error: BaseException) -> str:
220
+ if isinstance(error, ProviderHttpError) and error.status is not None:
221
+ body = (error.raw_body or "").strip()
222
+ if body:
223
+ return f"Mistral API error ({error.status}): {truncate_error_text(body, _MAX_MISTRAL_ERROR_BODY_CHARS)}"
224
+ return f"Mistral API error ({error.status}): {_status_text(error.status)}"
225
+ return str(error) or type(error).__name__
226
+
227
+
228
+ def _status_text(status: int) -> str:
229
+ try:
230
+ return http.HTTPStatus(status).phrase
231
+ except ValueError:
232
+ return f"Request failed with status {status}"
233
+
234
+
235
+ def _format_ms(value: float) -> str:
236
+ return str(int(value)) if float(value).is_integer() else str(value)
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # HTTP
241
+ # ---------------------------------------------------------------------------
242
+
243
+
244
+ def _chat_completions_url(base_url: str) -> str:
245
+ url = httpx.URL(base_url)
246
+ path = url.path.rstrip("/") + "/v1/chat/completions"
247
+ return str(url.copy_with(path=path, query=None, fragment=None))
248
+
249
+
250
+ async def _request_mistral_stream(
251
+ model: Model, payload: Any, api_key: str, options: Mapping[str, Any]
252
+ ) -> httpx.Response:
253
+ url = _chat_completions_url(model.base_url)
254
+ headers = _build_mistral_headers(model, api_key, options)
255
+ # The timeout covers only the wait for response headers. Long streams (e.g. extended thinking)
256
+ # must not be cut off by a fixed deadline; body stalls are left to the HTTP client idle timeout.
257
+ timeout_ms = options.get("timeout_ms")
258
+ if timeout_ms is None:
259
+ timeout_ms = _DEFAULT_HEADER_TIMEOUT_MS
260
+ request_options = {key: value for key, value in options.items() if key != "timeout_ms"}
261
+ try:
262
+ async with asyncio.timeout(timeout_ms / 1000):
263
+ response = await send_request(
264
+ "POST",
265
+ url,
266
+ headers=headers,
267
+ content=fastjson.dumps_bytes(_to_mistral_wire_payload(payload)),
268
+ options=request_options,
269
+ error_style="raw",
270
+ )
271
+ except TimeoutError as error:
272
+ raise RuntimeError(f"Mistral response headers timed out after {_format_ms(timeout_ms)}ms") from error
273
+ except ProviderHttpError as error:
274
+ on_response = options.get("on_response")
275
+ if on_response is not None and error.status is not None:
276
+ await maybe_await(
277
+ on_response(ProviderResponse(error.status, headers_to_record(error.headers or {})), model)
278
+ )
279
+ raise
280
+
281
+ on_response = options.get("on_response")
282
+ if on_response is not None:
283
+ try:
284
+ await maybe_await(
285
+ on_response(ProviderResponse(response.status_code, headers_to_record(response.headers)), model)
286
+ )
287
+ except BaseException:
288
+ await response.aclose()
289
+ raise
290
+ return response
291
+
292
+
293
+ def _build_mistral_headers(model: Model, api_key: str, options: Mapping[str, Any]) -> dict[str, str]:
294
+ option_headers = options.get("headers")
295
+ headers = build_headers(
296
+ {
297
+ "User-Agent": get_pi_user_agent(),
298
+ "accept": "text/event-stream",
299
+ "authorization": f"Bearer {api_key}",
300
+ "content-type": "application/json",
301
+ },
302
+ model.headers,
303
+ option_headers,
304
+ )
305
+ has_explicit_affinity = _has_header_override(model.headers, "x-affinity") or _has_header_override(
306
+ option_headers, "x-affinity"
307
+ )
308
+ if _should_use_prompt_caching(options) and not has_explicit_affinity:
309
+ headers["x-affinity"] = options["session_id"]
310
+ return headers
311
+
312
+
313
+ def _has_header_override(overrides: Mapping[str, Any] | None, target: str) -> bool:
314
+ return bool(overrides) and any(name.lower() == target for name in overrides) # type: ignore[union-attr]
315
+
316
+
317
+ _WIRE_PAYLOAD_KEYS = (
318
+ ("topP", "top_p"),
319
+ ("maxTokens", "max_tokens"),
320
+ ("randomSeed", "random_seed"),
321
+ ("responseFormat", "response_format"),
322
+ ("toolChoice", "tool_choice"),
323
+ ("presencePenalty", "presence_penalty"),
324
+ ("frequencyPenalty", "frequency_penalty"),
325
+ ("parallelToolCalls", "parallel_tool_calls"),
326
+ ("reasoningEffort", "reasoning_effort"),
327
+ ("promptMode", "prompt_mode"),
328
+ ("promptCacheKey", "prompt_cache_key"),
329
+ ("safePrompt", "safe_prompt"),
330
+ )
331
+ _WIRE_CHUNK_KEYS = (
332
+ ("imageUrl", "image_url"),
333
+ ("documentUrl", "document_url"),
334
+ ("documentName", "document_name"),
335
+ ("fileId", "file_id"),
336
+ ("referenceIds", "reference_ids"),
337
+ ("inputAudio", "input_audio"),
338
+ )
339
+
340
+
341
+ def _remap(record: dict[str, Any], source: str, target: str) -> None:
342
+ if source in record:
343
+ record[target] = record.pop(source)
344
+
345
+
346
+ def _to_mistral_wire_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
347
+ wire = dict(payload)
348
+ for source, target in _WIRE_PAYLOAD_KEYS:
349
+ _remap(wire, source, target)
350
+ messages = payload.get("messages")
351
+ if isinstance(messages, list):
352
+ wire["messages"] = [_to_mistral_wire_message(message) for message in messages]
353
+ response_format = wire.get("response_format")
354
+ if isinstance(response_format, Mapping):
355
+ wire_format = dict(response_format)
356
+ _remap(wire_format, "jsonSchema", "json_schema")
357
+ json_schema = wire_format.get("json_schema")
358
+ if isinstance(json_schema, Mapping):
359
+ wire_schema = dict(json_schema)
360
+ _remap(wire_schema, "schemaDefinition", "schema")
361
+ wire_format["json_schema"] = wire_schema
362
+ wire["response_format"] = wire_format
363
+ return wire
364
+
365
+
366
+ def _to_mistral_wire_message(message: Any) -> Any:
367
+ if not isinstance(message, Mapping):
368
+ return message
369
+ wire = dict(message)
370
+ _remap(wire, "toolCalls", "tool_calls")
371
+ _remap(wire, "toolCallId", "tool_call_id")
372
+ content = message.get("content")
373
+ if isinstance(content, list):
374
+ wire["content"] = [_to_mistral_wire_chunk(chunk) for chunk in content]
375
+ return wire
376
+
377
+
378
+ def _to_mistral_wire_chunk(chunk: Any) -> Any:
379
+ if not isinstance(chunk, Mapping):
380
+ return chunk
381
+ wire = dict(chunk)
382
+ for source, target in _WIRE_CHUNK_KEYS:
383
+ _remap(wire, source, target)
384
+ return wire
385
+
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # Payload
389
+ # ---------------------------------------------------------------------------
390
+
391
+
392
+ def _build_chat_payload(
393
+ model: Model, context: TranscriptContext, messages: list[Message], options: Mapping[str, Any]
394
+ ) -> dict[str, Any]:
395
+ payload: dict[str, Any] = {
396
+ "model": model.id,
397
+ "stream": True,
398
+ "messages": _to_chat_messages(messages, "image" in model.input),
399
+ }
400
+ current_tools = get_current_tools(context.messages)
401
+ if current_tools:
402
+ payload["tools"] = _to_function_tools(current_tools)
403
+ if options.get("temperature") is not None:
404
+ payload["temperature"] = options["temperature"]
405
+ if options.get("max_tokens") is not None:
406
+ payload["maxTokens"] = options["max_tokens"]
407
+ tool_choice = _map_tool_choice(options.get("tool_choice"))
408
+ if tool_choice is not None:
409
+ payload["toolChoice"] = tool_choice
410
+ if options.get("prompt_mode"):
411
+ payload["promptMode"] = options["prompt_mode"]
412
+ if options.get("reasoning_effort"):
413
+ payload["reasoningEffort"] = options["reasoning_effort"]
414
+ if _should_use_prompt_caching(options):
415
+ payload["promptCacheKey"] = options["session_id"]
416
+ return payload
417
+
418
+
419
+ def _should_use_prompt_caching(options: Mapping[str, Any]) -> bool:
420
+ return options.get("cache_retention") != "none" and bool(options.get("session_id"))
421
+
422
+
423
+ def _to_function_tools(tools: list[Tool]) -> list[dict[str, Any]]:
424
+ result: list[dict[str, Any]] = []
425
+ for tool in tools:
426
+ strict = resolve_json_schema_strict_sampling(tool, True)
427
+ result.append(
428
+ {
429
+ "type": "function",
430
+ "function": {
431
+ "name": tool.name,
432
+ "description": tool.description,
433
+ "parameters": _plain_json(get_json_schema_tool_parameters(tool, strict)),
434
+ "strict": strict if strict is not None else False,
435
+ },
436
+ }
437
+ )
438
+ return result
439
+
440
+
441
+ def _plain_json(value: Any) -> Any:
442
+ """Deep copy into plain dicts/lists (pi strips TypeBox symbol keys at this point)."""
443
+ if isinstance(value, Mapping):
444
+ return {key: _plain_json(entry) for key, entry in value.items()}
445
+ if isinstance(value, (list, tuple)):
446
+ return [_plain_json(item) for item in value]
447
+ return value
448
+
449
+
450
+ def _image_url(block: Any) -> str:
451
+ return f"data:{block.mime_type};base64,{block.data}"
452
+
453
+
454
+ def _to_chat_messages(messages: list[Message], supports_images: bool) -> list[dict[str, Any]]:
455
+ result: list[dict[str, Any]] = []
456
+ for index, message in enumerate(messages):
457
+ role = message.role
458
+ if role == "system":
459
+ text = get_system_message_text(message) if index == 0 else render_system_message_update(message) # type: ignore[arg-type]
460
+ if text:
461
+ result.append({"role": "system", "content": sanitize_surrogates(text)})
462
+ continue
463
+
464
+ if role == "user":
465
+ content = message.content # type: ignore[union-attr]
466
+ if isinstance(content, str):
467
+ result.append({"role": "user", "content": sanitize_surrogates(content)})
468
+ continue
469
+ had_images = any(item.type == "image" for item in content)
470
+ chunks: list[dict[str, Any]] = []
471
+ for item in content:
472
+ if item.type == "text":
473
+ chunks.append({"type": "text", "text": sanitize_surrogates(item.text)})
474
+ elif supports_images and item.type == "image":
475
+ chunks.append({"type": "image_url", "imageUrl": _image_url(item)})
476
+ if chunks:
477
+ result.append({"role": "user", "content": chunks})
478
+ continue
479
+ if had_images and not supports_images:
480
+ result.append({"role": "user", "content": "(image omitted: model does not support images)"})
481
+ continue
482
+
483
+ if role == "assistant":
484
+ parts: list[dict[str, Any]] = []
485
+ tool_calls: list[dict[str, Any]] = []
486
+ for block in message.content: # type: ignore[union-attr]
487
+ kind = block.type
488
+ if kind == "text":
489
+ if block.text.strip():
490
+ parts.append({"type": "text", "text": sanitize_surrogates(block.text)})
491
+ continue
492
+ if kind == "thinking":
493
+ if block.thinking.strip():
494
+ parts.append(
495
+ {
496
+ "type": "thinking",
497
+ "thinking": [{"type": "text", "text": sanitize_surrogates(block.thinking)}],
498
+ }
499
+ )
500
+ continue
501
+ tool_calls.append(
502
+ {
503
+ "id": block.id,
504
+ "type": "function",
505
+ "function": {"name": block.name, "arguments": fastjson.dumps(block.arguments or {})},
506
+ "index": 0,
507
+ }
508
+ )
509
+ assistant: dict[str, Any] = {"role": "assistant", "prefix": False}
510
+ if parts:
511
+ assistant["content"] = parts
512
+ if tool_calls:
513
+ assistant["toolCalls"] = tool_calls
514
+ if parts or tool_calls:
515
+ result.append(assistant)
516
+ continue
517
+
518
+ # toolResult
519
+ tool_content = message.content # type: ignore[union-attr]
520
+ text_result = "\n".join(sanitize_surrogates(part.text) for part in tool_content if part.type == "text")
521
+ has_images = any(part.type == "image" for part in tool_content)
522
+ chunks = [
523
+ {
524
+ "type": "text",
525
+ "text": _build_tool_result_text(text_result, has_images, supports_images, message.is_error), # type: ignore[union-attr]
526
+ }
527
+ ]
528
+ if supports_images:
529
+ chunks.extend(
530
+ {"type": "image_url", "imageUrl": _image_url(part)} for part in tool_content if part.type == "image"
531
+ )
532
+ result.append(
533
+ {
534
+ "role": "tool",
535
+ "toolCallId": message.tool_call_id, # type: ignore[union-attr]
536
+ "name": message.tool_name, # type: ignore[union-attr]
537
+ "content": chunks,
538
+ }
539
+ )
540
+ return result
541
+
542
+
543
+ def _build_tool_result_text(text: str, has_images: bool, supports_images: bool, is_error: bool) -> str:
544
+ trimmed = text.strip()
545
+ error_prefix = "[tool error] " if is_error else ""
546
+ if trimmed:
547
+ image_suffix = (
548
+ "\n[tool image omitted: model does not support images]" if has_images and not supports_images else ""
549
+ )
550
+ return f"{error_prefix}{trimmed}{image_suffix}"
551
+ if has_images:
552
+ if supports_images:
553
+ return "[tool error] (see attached image)" if is_error else "(see attached image)"
554
+ return (
555
+ "[tool error] (image omitted: model does not support images)"
556
+ if is_error
557
+ else "(image omitted: model does not support images)"
558
+ )
559
+ return "[tool error] (no tool output)" if is_error else "(no tool output)"
560
+
561
+
562
+ def _map_tool_choice(choice: Any) -> Any:
563
+ if not choice:
564
+ return None
565
+ if choice in ("auto", "none", "any", "required"):
566
+ return choice
567
+ return {"type": "function", "function": {"name": choice["function"]["name"]}}
568
+
569
+
570
+ # ---------------------------------------------------------------------------
571
+ # Stream consumption
572
+ # ---------------------------------------------------------------------------
573
+
574
+
575
+ def _map_chat_stop_reason(reason: str | None) -> tuple[StopReason, str | None]:
576
+ if reason is None or reason == "stop":
577
+ return "stop", None
578
+ if reason in ("length", "model_length"):
579
+ return "length", None
580
+ if reason == "tool_calls":
581
+ return "toolUse", None
582
+ if reason == "error":
583
+ # Mistral reports transient server failures this way; "server error" makes the message retryable.
584
+ return "error", "Provider stopped with: error (server error)"
585
+ return "error", f"Provider stopped with: {reason}"
586
+
587
+
588
+ def _cached_prompt_tokens(usage: Mapping[str, Any], prompt_tokens: int) -> int:
589
+ raw: Any = None
590
+ for details_key, cached_key in (
591
+ ("promptTokensDetails", "cachedTokens"),
592
+ ("prompt_tokens_details", "cached_tokens"),
593
+ ("promptTokenDetails", "cachedTokens"),
594
+ ("prompt_token_details", "cached_tokens"),
595
+ ):
596
+ details = usage.get(details_key)
597
+ if isinstance(details, Mapping) and details.get(cached_key) is not None:
598
+ raw = details[cached_key]
599
+ break
600
+ if raw is None:
601
+ raw = usage.get("numCachedTokens")
602
+ if raw is None:
603
+ raw = usage.get("num_cached_tokens")
604
+ cached = raw if isinstance(raw, (int, float)) and not isinstance(raw, bool) and math.isfinite(raw) else 0
605
+ return int(min(prompt_tokens, max(0, cached)))
606
+
607
+
608
+ async def _iter_mistral_events(response: httpx.Response) -> AsyncIterator[dict[str, Any]]:
609
+ async for event in iter_sse(response):
610
+ data = event.data.strip()
611
+ if not data:
612
+ continue
613
+ if data == "[DONE]":
614
+ return
615
+ parsed = fastjson.loads(data)
616
+ if not isinstance(parsed, dict) or not isinstance(parsed.get("choices"), list):
617
+ raise RuntimeError("Invalid Mistral streaming event")
618
+ yield parsed
619
+
620
+
621
+ async def _consume_chat_stream(
622
+ model: Model,
623
+ output: AssistantMessage,
624
+ out: AssistantMessageEventStream,
625
+ response: httpx.Response,
626
+ on_provider_stream_event: Any,
627
+ ) -> None:
628
+ blocks = output.content
629
+ current: TextContent | ThinkingContent | None = None
630
+ tool_blocks_by_key: dict[Any, int] = {}
631
+ parsers: dict[int, StreamingJsonParser] = {}
632
+
633
+ def finish_current() -> None:
634
+ if current is None:
635
+ return
636
+ index = len(blocks) - 1
637
+ if current.type == "text":
638
+ out.push(TextEndEvent(index, current.text, output))
639
+ else:
640
+ out.push(ThinkingEndEvent(index, current.thinking, output))
641
+
642
+ def append_text(text: str) -> None:
643
+ nonlocal current
644
+ if current is None or current.type != "text":
645
+ finish_current()
646
+ current = TextContent("")
647
+ blocks.append(current)
648
+ out.push(TextStartEvent(len(blocks) - 1, output))
649
+ append_block_text(current, text)
650
+ out.push(TextDeltaEvent(len(blocks) - 1, text, output))
651
+
652
+ def append_thinking(text: str) -> None:
653
+ nonlocal current
654
+ if current is None or current.type != "thinking":
655
+ finish_current()
656
+ current = ThinkingContent("")
657
+ blocks.append(current)
658
+ out.push(ThinkingStartEvent(len(blocks) - 1, output))
659
+ append_block_thinking(current, text)
660
+ out.push(ThinkingDeltaEvent(len(blocks) - 1, text, output))
661
+
662
+ async for chunk in _iter_mistral_events(response):
663
+ if on_provider_stream_event is not None:
664
+ await maybe_await(on_provider_stream_event(chunk, model))
665
+ # Mistral's streamed CompletionChunk carries an id field. Keep the first non-empty one,
666
+ # mirroring how OpenAI-style streaming exposes a stable response identifier per stream.
667
+ if not output.response_id and chunk.get("id"):
668
+ output.response_id = chunk["id"]
669
+
670
+ usage = chunk.get("usage")
671
+ if isinstance(usage, Mapping):
672
+ prompt_tokens = usage.get("prompt_tokens") or 0
673
+ cached_prompt_tokens = _cached_prompt_tokens(usage, prompt_tokens)
674
+ output.usage.input = max(0, prompt_tokens - cached_prompt_tokens)
675
+ output.usage.output = usage.get("completion_tokens") or 0
676
+ output.usage.cache_read = cached_prompt_tokens
677
+ output.usage.cache_write = 0
678
+ output.usage.total_tokens = usage.get("total_tokens") or (
679
+ output.usage.input + output.usage.output + output.usage.cache_read + output.usage.cache_write
680
+ )
681
+ calculate_cost(model, output.usage)
682
+
683
+ choices = chunk["choices"]
684
+ choice = choices[0] if choices else None
685
+ if not choice:
686
+ continue
687
+
688
+ finish_reason = choice.get("finish_reason")
689
+ if finish_reason:
690
+ output.raw_stop_reason = finish_reason
691
+ stop_reason, error_message = _map_chat_stop_reason(finish_reason)
692
+ output.stop_reason = stop_reason
693
+ if error_message:
694
+ output.error_message = error_message
695
+
696
+ delta = choice.get("delta") or {}
697
+ content = delta.get("content")
698
+ if content is not None:
699
+ for item in [content] if isinstance(content, str) else content:
700
+ if isinstance(item, str):
701
+ text = sanitize_surrogates(item)
702
+ # GLM models on Mistral send empty content deltas around thinking and tool calls.
703
+ # Opening a block for them splits thinking into multiple blocks, which Mistral rejects on replay.
704
+ if text:
705
+ append_text(text)
706
+ continue
707
+ kind = item.get("type")
708
+ if kind == "thinking":
709
+ parts = item.get("thinking") or []
710
+ thinking = sanitize_surrogates("".join(part.get("text") or "" for part in parts))
711
+ if thinking:
712
+ append_thinking(thinking)
713
+ elif kind == "text":
714
+ text = sanitize_surrogates(item.get("text") or "")
715
+ if text:
716
+ append_text(text)
717
+
718
+ for tool_call in delta.get("tool_calls") or []:
719
+ if current is not None:
720
+ finish_current()
721
+ current = None
722
+ raw_id = tool_call.get("id")
723
+ call_index = tool_call.get("index")
724
+ call_id = (
725
+ raw_id
726
+ if raw_id and raw_id != "null"
727
+ else _derive_mistral_tool_call_id(f"toolcall:{call_index if call_index is not None else 0}", 0)
728
+ )
729
+ key = call_index if call_index is not None else call_id
730
+ function = tool_call.get("function") or {}
731
+ existing_index = tool_blocks_by_key.get(key)
732
+ if existing_index is None or blocks[existing_index].type != "toolCall":
733
+ block = ToolCall(call_id, function.get("name") or "", {})
734
+ blocks.append(block)
735
+ existing_index = len(blocks) - 1
736
+ tool_blocks_by_key[key] = existing_index
737
+ parsers[existing_index] = StreamingJsonParser()
738
+ out.push(ToolCallStartEvent(existing_index, output))
739
+ block = blocks[existing_index]
740
+ raw_arguments = function.get("arguments")
741
+ args_delta = raw_arguments if isinstance(raw_arguments, str) else fastjson.dumps(raw_arguments or {})
742
+ parser = parsers[existing_index]
743
+ parser.feed(args_delta)
744
+ block.arguments = parser.value() # type: ignore[union-attr]
745
+ out.push(ToolCallDeltaEvent(existing_index, args_delta, output))
746
+
747
+ finish_current()
748
+ for index in tool_blocks_by_key.values():
749
+ block = blocks[index]
750
+ if block.type != "toolCall":
751
+ continue
752
+ arguments, arguments_error = parse_tool_call_arguments(parsers[index].text)
753
+ block.arguments = arguments # type: ignore[union-attr]
754
+ if arguments_error is not None:
755
+ block.arguments_error = arguments_error # type: ignore[union-attr]
756
+ out.push(ToolCallEndEvent(index, block, output)) # type: ignore[arg-type]