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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- 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]
|