dot-inference 2.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dot_inference/README.md +83 -0
- dot_inference/__init__.py +86 -0
- dot_inference/_client.py +520 -0
- dot_inference/_cost_tracker.py +71 -0
- dot_inference/_llm_registry.py +302 -0
- dot_inference/_local_check.py +76 -0
- dot_inference/_mistral_compat.py +530 -0
- dot_inference/_openai_compat.py +570 -0
- dot_inference/_rate_limit.py +64 -0
- dot_inference/_retry.py +66 -0
- dot_inference/_sync.py +49 -0
- dot_inference/_types.py +229 -0
- dot_inference/mock.py +151 -0
- dot_inference/models.yaml +481 -0
- dot_inference/settings.py +138 -0
- dot_inference-2.0.2.dist-info/METADATA +351 -0
- dot_inference-2.0.2.dist-info/RECORD +19 -0
- dot_inference-2.0.2.dist-info/WHEEL +4 -0
- dot_inference-2.0.2.dist-info/licenses/LICENSE.md +660 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Kannon For Deep Tech
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Shared Mistral-native client.
|
|
5
|
+
|
|
6
|
+
Provides ``MistralClient`` — a class-based LLM client using the native
|
|
7
|
+
Mistral SDK.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import inspect
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import AsyncGenerator
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
from dot_inference._client import EmbeddingClient, LLMClient
|
|
23
|
+
from dot_inference._cost_tracker import compute_cost
|
|
24
|
+
from dot_inference._llm_registry import ResolvedModelConfig
|
|
25
|
+
from dot_inference._retry import compute_wait
|
|
26
|
+
from dot_inference._types import (
|
|
27
|
+
InputJsonDelta,
|
|
28
|
+
LLMResponse,
|
|
29
|
+
Message,
|
|
30
|
+
ReasoningDelta,
|
|
31
|
+
StreamDone,
|
|
32
|
+
StreamEvent,
|
|
33
|
+
StreamUsage,
|
|
34
|
+
TextDelta,
|
|
35
|
+
ToolCall,
|
|
36
|
+
ToolCallDone,
|
|
37
|
+
ToolDef,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
LOGGER = logging.getLogger(__name__)
|
|
41
|
+
# TODO: include the deepika logger
|
|
42
|
+
|
|
43
|
+
_MAX_ATTEMPTS = 2
|
|
44
|
+
_RETRY_BASE_WAIT = 3 # seconds
|
|
45
|
+
|
|
46
|
+
# HTTP status codes that should NOT be retried (client-side mistakes).
|
|
47
|
+
_NON_RETRYABLE_STATUS = {400, 401, 403, 404, 422}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_mistral_available() -> None:
|
|
51
|
+
"""Raise ``ImportError`` with install instructions if *mistralai* is missing."""
|
|
52
|
+
try:
|
|
53
|
+
import mistralai # noqa: F401
|
|
54
|
+
except ImportError:
|
|
55
|
+
raise ImportError(
|
|
56
|
+
"The mistralai package is required for native Mistral support. "
|
|
57
|
+
"Install it with: pip install dot-inference[mistral]"
|
|
58
|
+
) from None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _tools_to_mistral(tools: list[ToolDef]) -> list[dict[str, Any]]:
|
|
62
|
+
"""Convert ``ToolDef`` list to the Mistral tools format.
|
|
63
|
+
|
|
64
|
+
Mistral uses the same OpenAI-like structure:
|
|
65
|
+
``{"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}``
|
|
66
|
+
"""
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
"type": "function",
|
|
70
|
+
"function": {
|
|
71
|
+
"name": t.name,
|
|
72
|
+
"description": t.description,
|
|
73
|
+
"parameters": t.parameters,
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
for t in tools
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_mistral_content(content: Any) -> tuple[str, str]:
|
|
81
|
+
"""Split Mistral message content into ``(text, thinking)``.
|
|
82
|
+
|
|
83
|
+
Non-reasoning responses return ``content`` as a plain ``str`` → ``(content, "")``.
|
|
84
|
+
Reasoning responses (mistralai >= 2.4) return a ``list[ContentChunk]`` mixing
|
|
85
|
+
``TextChunk(text=..., type="text")`` (the answer) and
|
|
86
|
+
``ThinkChunk(thinking=[...], type="thinking")`` (the chain of thought).
|
|
87
|
+
Text chunks are concatenated into *text*, thinking chunks into *thinking*;
|
|
88
|
+
other chunk types (reference/image) carry no ``.text`` and are skipped.
|
|
89
|
+
"""
|
|
90
|
+
if content is None:
|
|
91
|
+
return "", ""
|
|
92
|
+
if isinstance(content, str):
|
|
93
|
+
return content, ""
|
|
94
|
+
|
|
95
|
+
text_parts: list[str] = []
|
|
96
|
+
thinking_parts: list[str] = []
|
|
97
|
+
for chunk in content:
|
|
98
|
+
if getattr(chunk, "type", None) == "thinking":
|
|
99
|
+
for item in getattr(chunk, "thinking", None) or []:
|
|
100
|
+
thinking_parts.append(getattr(item, "text", "") or "")
|
|
101
|
+
else:
|
|
102
|
+
text_parts.append(getattr(chunk, "text", "") or "")
|
|
103
|
+
return "".join(text_parts), "".join(thinking_parts)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class MistralClient(LLMClient):
|
|
107
|
+
"""LLM client using the native Mistral SDK.
|
|
108
|
+
|
|
109
|
+
The underlying ``Mistral`` SDK client is stored as ``self._client``
|
|
110
|
+
and created once at construction time.
|
|
111
|
+
|
|
112
|
+
Requires: ``pip install dot-inference[mistral]``
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
model: str,
|
|
118
|
+
api_key: str | None = None,
|
|
119
|
+
model_config: ResolvedModelConfig | None = None,
|
|
120
|
+
temperature: float | None = None,
|
|
121
|
+
reasoning_effort: str | None = None,
|
|
122
|
+
max_tokens: int | None = None,
|
|
123
|
+
timeout: float = 300,
|
|
124
|
+
) -> None:
|
|
125
|
+
_check_mistral_available()
|
|
126
|
+
|
|
127
|
+
from mistralai.client import Mistral
|
|
128
|
+
|
|
129
|
+
super().__init__()
|
|
130
|
+
self._client = Mistral(
|
|
131
|
+
api_key=api_key,
|
|
132
|
+
timeout_ms=int(timeout * 1000),
|
|
133
|
+
)
|
|
134
|
+
self._model = model
|
|
135
|
+
self._model_config = model_config
|
|
136
|
+
self._temperature = temperature
|
|
137
|
+
self._reasoning_effort = reasoning_effort
|
|
138
|
+
self._max_tokens = max_tokens
|
|
139
|
+
self._strict = model_config.supports_strict_json if model_config else True
|
|
140
|
+
|
|
141
|
+
def _build_create_kwargs(
|
|
142
|
+
self,
|
|
143
|
+
messages: list[Message],
|
|
144
|
+
response_format: type[BaseModel] | dict[str, Any] | None,
|
|
145
|
+
tools: list[ToolDef] | None,
|
|
146
|
+
temperature: float | None,
|
|
147
|
+
reasoning_effort: str | None,
|
|
148
|
+
tool_choice: str | None = None,
|
|
149
|
+
) -> tuple[dict[str, Any], type[BaseModel] | None]:
|
|
150
|
+
"""Build kwargs for Mistral chat completion.
|
|
151
|
+
|
|
152
|
+
Returns ``(create_kwargs, response_model)``.
|
|
153
|
+
"""
|
|
154
|
+
create_kwargs: dict[str, Any] = {
|
|
155
|
+
"model": self._model,
|
|
156
|
+
"messages": [m.model_dump(exclude_none=True) for m in messages],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
is_reasoning = self._model_config.supports_reasoning if self._model_config else False
|
|
160
|
+
if is_reasoning and reasoning_effort is not None:
|
|
161
|
+
# Mistral reasoning models (Medium 3.5, Small) take reasoning_effort
|
|
162
|
+
# natively on the chat-completion request — La Plateforme accepts
|
|
163
|
+
# "none" (minimal thinking, thinking chunk omitted) and "high"
|
|
164
|
+
# (full thinking chunk). Temperature is ignored for these.
|
|
165
|
+
create_kwargs["reasoning_effort"] = reasoning_effort
|
|
166
|
+
elif not is_reasoning and temperature is not None:
|
|
167
|
+
create_kwargs["temperature"] = temperature
|
|
168
|
+
|
|
169
|
+
response_model: type[BaseModel] | None = None
|
|
170
|
+
|
|
171
|
+
self._validate_params(response_format, tools)
|
|
172
|
+
|
|
173
|
+
if tools:
|
|
174
|
+
create_kwargs["tools"] = _tools_to_mistral(tools)
|
|
175
|
+
create_kwargs["tool_choice"] = tool_choice if tool_choice is not None else "auto"
|
|
176
|
+
elif response_format is not None:
|
|
177
|
+
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
|
|
178
|
+
response_model = response_format
|
|
179
|
+
schema = response_format.model_json_schema()
|
|
180
|
+
else:
|
|
181
|
+
schema = response_format
|
|
182
|
+
|
|
183
|
+
create_kwargs["response_format"] = {
|
|
184
|
+
"type": "json_schema",
|
|
185
|
+
"json_schema": {
|
|
186
|
+
"name": "Output",
|
|
187
|
+
"strict": self._strict,
|
|
188
|
+
"schema": schema,
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if self._max_tokens is not None:
|
|
193
|
+
create_kwargs["max_tokens"] = self._max_tokens
|
|
194
|
+
|
|
195
|
+
return create_kwargs, response_model
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _normalize_arguments(arguments: Any) -> str:
|
|
199
|
+
"""Mistral SDK may return arguments as dict instead of str."""
|
|
200
|
+
return arguments if isinstance(arguments, str) else json.dumps(arguments)
|
|
201
|
+
|
|
202
|
+
async def _complete_with_retry(self, create_kwargs: dict[str, Any]) -> Any:
|
|
203
|
+
"""Call ``chat.complete_async`` with transient-error retry."""
|
|
204
|
+
from mistralai.client.errors.mistralerror import MistralError
|
|
205
|
+
|
|
206
|
+
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
|
207
|
+
try:
|
|
208
|
+
return await self._client.chat.complete_async(**create_kwargs)
|
|
209
|
+
except (MistralError, httpx.TransportError) as exc:
|
|
210
|
+
status = getattr(exc, "status_code", None)
|
|
211
|
+
if status in _NON_RETRYABLE_STATUS or attempt == _MAX_ATTEMPTS:
|
|
212
|
+
raise
|
|
213
|
+
headers = getattr(exc, "headers", None)
|
|
214
|
+
retry_after = headers.get("retry-after") if headers is not None else None
|
|
215
|
+
wait = compute_wait(attempt, _RETRY_BASE_WAIT, retry_after)
|
|
216
|
+
LOGGER.warning(
|
|
217
|
+
"Transient Mistral API error (attempt %d/%d, status %s): %s: %s — retrying in %.1fs",
|
|
218
|
+
attempt,
|
|
219
|
+
_MAX_ATTEMPTS,
|
|
220
|
+
status,
|
|
221
|
+
type(exc).__name__,
|
|
222
|
+
exc,
|
|
223
|
+
wait,
|
|
224
|
+
)
|
|
225
|
+
if self.on_retry is not None:
|
|
226
|
+
self.on_retry(attempt, _MAX_ATTEMPTS, str(exc))
|
|
227
|
+
await asyncio.sleep(wait)
|
|
228
|
+
|
|
229
|
+
async def call(
|
|
230
|
+
self,
|
|
231
|
+
messages: list[Message],
|
|
232
|
+
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
|
233
|
+
tools: list[ToolDef] | None = None,
|
|
234
|
+
*,
|
|
235
|
+
temperature: float | None = None,
|
|
236
|
+
reasoning_effort: str | None = None,
|
|
237
|
+
tool_choice: str | None = None,
|
|
238
|
+
) -> LLMResponse:
|
|
239
|
+
temperature = self._resolve_temperature(temperature)
|
|
240
|
+
reasoning_effort = self._resolve_reasoning_effort(reasoning_effort)
|
|
241
|
+
|
|
242
|
+
create_kwargs, response_model = self._build_create_kwargs(
|
|
243
|
+
messages,
|
|
244
|
+
response_format,
|
|
245
|
+
tools,
|
|
246
|
+
temperature,
|
|
247
|
+
reasoning_effort,
|
|
248
|
+
tool_choice,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
response = await self._complete_with_retry(create_kwargs)
|
|
252
|
+
|
|
253
|
+
prompt_tokens = response.usage.prompt_tokens if response.usage else None
|
|
254
|
+
completion_tokens = response.usage.completion_tokens if response.usage else None
|
|
255
|
+
|
|
256
|
+
cost = None
|
|
257
|
+
if self._model_config:
|
|
258
|
+
cost = compute_cost(
|
|
259
|
+
response.usage,
|
|
260
|
+
self._model_config.input_token_cost,
|
|
261
|
+
self._model_config.output_token_cost,
|
|
262
|
+
)
|
|
263
|
+
if response.usage:
|
|
264
|
+
LOGGER.debug(
|
|
265
|
+
"tokens: %s in / %s out, cost=$%s",
|
|
266
|
+
prompt_tokens,
|
|
267
|
+
completion_tokens,
|
|
268
|
+
cost or "?",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
msg = response.choices[0].message
|
|
272
|
+
# Reasoning models return content as list[ContentChunk]; keep the
|
|
273
|
+
# answer text only (thinking is display-only and dropped here).
|
|
274
|
+
text_content, _thinking = _normalize_mistral_content(msg.content)
|
|
275
|
+
|
|
276
|
+
tool_calls = None
|
|
277
|
+
if msg.tool_calls:
|
|
278
|
+
tool_calls = [
|
|
279
|
+
ToolCall(
|
|
280
|
+
id=tc.id,
|
|
281
|
+
name=tc.function.name,
|
|
282
|
+
arguments=self._normalize_arguments(tc.function.arguments),
|
|
283
|
+
)
|
|
284
|
+
for tc in msg.tool_calls
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
structured_output = None
|
|
288
|
+
if response_model is not None:
|
|
289
|
+
if not text_content:
|
|
290
|
+
raise RuntimeError("LLM returned empty content with response_format set")
|
|
291
|
+
structured_output = response_model.model_validate_json(text_content)
|
|
292
|
+
|
|
293
|
+
response = LLMResponse(
|
|
294
|
+
content=text_content or None,
|
|
295
|
+
tool_calls=tool_calls,
|
|
296
|
+
structured_output=structured_output,
|
|
297
|
+
cost=cost,
|
|
298
|
+
prompt_tokens=prompt_tokens,
|
|
299
|
+
completion_tokens=completion_tokens,
|
|
300
|
+
)
|
|
301
|
+
self.cost_tracker.record(response)
|
|
302
|
+
return response
|
|
303
|
+
|
|
304
|
+
async def stream(
|
|
305
|
+
self,
|
|
306
|
+
messages: list[Message],
|
|
307
|
+
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
|
308
|
+
tools: list[ToolDef] | None = None,
|
|
309
|
+
*,
|
|
310
|
+
temperature: float | None = None,
|
|
311
|
+
reasoning_effort: str | None = None,
|
|
312
|
+
tool_choice: str | None = None,
|
|
313
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
314
|
+
temperature = self._resolve_temperature(temperature)
|
|
315
|
+
reasoning_effort = self._resolve_reasoning_effort(reasoning_effort)
|
|
316
|
+
|
|
317
|
+
create_kwargs, response_model = self._build_create_kwargs(
|
|
318
|
+
messages,
|
|
319
|
+
response_format,
|
|
320
|
+
tools,
|
|
321
|
+
temperature,
|
|
322
|
+
reasoning_effort,
|
|
323
|
+
tool_choice,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
from mistralai.client.errors.mistralerror import MistralError
|
|
327
|
+
|
|
328
|
+
content_parts: list[str] = []
|
|
329
|
+
tool_acc: dict[int, dict[str, str]] = {}
|
|
330
|
+
finished_tool_calls: list[ToolCall] = []
|
|
331
|
+
prompt_tokens: int | None = None
|
|
332
|
+
completion_tokens: int | None = None
|
|
333
|
+
last_usage: Any = None
|
|
334
|
+
|
|
335
|
+
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
|
336
|
+
has_yielded = False
|
|
337
|
+
reasoning_seen = False
|
|
338
|
+
content_parts.clear()
|
|
339
|
+
tool_acc.clear()
|
|
340
|
+
finished_tool_calls.clear()
|
|
341
|
+
prompt_tokens = None
|
|
342
|
+
completion_tokens = None
|
|
343
|
+
last_usage = None
|
|
344
|
+
response = None
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
response = await self._client.chat.stream_async(**create_kwargs)
|
|
348
|
+
|
|
349
|
+
async for event in response:
|
|
350
|
+
data = event.data
|
|
351
|
+
|
|
352
|
+
if data.usage:
|
|
353
|
+
prompt_tokens = data.usage.prompt_tokens
|
|
354
|
+
completion_tokens = data.usage.completion_tokens
|
|
355
|
+
last_usage = data.usage
|
|
356
|
+
|
|
357
|
+
if not data.choices:
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
choice = data.choices[0]
|
|
361
|
+
delta = choice.delta
|
|
362
|
+
|
|
363
|
+
if delta.content:
|
|
364
|
+
# Reasoning models stream content as list[ContentChunk]
|
|
365
|
+
# (TextChunk + ThinkChunk); split thinking from answer.
|
|
366
|
+
text, thinking = _normalize_mistral_content(delta.content)
|
|
367
|
+
if thinking:
|
|
368
|
+
if not reasoning_seen:
|
|
369
|
+
reasoning_seen = True
|
|
370
|
+
LOGGER.info(
|
|
371
|
+
"model '%s' streams reasoning — "
|
|
372
|
+
"emitting reasoning_delta events",
|
|
373
|
+
self._model,
|
|
374
|
+
)
|
|
375
|
+
has_yielded = True
|
|
376
|
+
yield ReasoningDelta(content=thinking)
|
|
377
|
+
if text:
|
|
378
|
+
content_parts.append(text)
|
|
379
|
+
has_yielded = True
|
|
380
|
+
yield TextDelta(content=text)
|
|
381
|
+
|
|
382
|
+
if delta.tool_calls:
|
|
383
|
+
for tc_delta in delta.tool_calls:
|
|
384
|
+
idx = tc_delta.index if hasattr(tc_delta, "index") else 0
|
|
385
|
+
if idx not in tool_acc:
|
|
386
|
+
tool_acc[idx] = {"id": "", "name": "", "arguments": ""}
|
|
387
|
+
|
|
388
|
+
acc = tool_acc[idx]
|
|
389
|
+
tc_id = getattr(tc_delta, "id", None)
|
|
390
|
+
tc_fn = getattr(tc_delta, "function", None)
|
|
391
|
+
if tc_id:
|
|
392
|
+
acc["id"] = tc_id
|
|
393
|
+
if tc_fn and getattr(tc_fn, "name", None):
|
|
394
|
+
acc["name"] = tc_fn.name
|
|
395
|
+
if tc_fn and getattr(tc_fn, "arguments", None):
|
|
396
|
+
arg_chunk = self._normalize_arguments(tc_fn.arguments)
|
|
397
|
+
acc["arguments"] += arg_chunk
|
|
398
|
+
has_yielded = True
|
|
399
|
+
yield InputJsonDelta(
|
|
400
|
+
tool_call_index=idx,
|
|
401
|
+
tool_name=acc["name"],
|
|
402
|
+
partial_json=arg_chunk,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
if choice.finish_reason in ("tool_calls", "stop") and tool_acc:
|
|
406
|
+
for idx in sorted(tool_acc):
|
|
407
|
+
acc = tool_acc[idx]
|
|
408
|
+
tc = ToolCall(
|
|
409
|
+
id=acc["id"], name=acc["name"], arguments=acc["arguments"]
|
|
410
|
+
)
|
|
411
|
+
finished_tool_calls.append(tc)
|
|
412
|
+
has_yielded = True
|
|
413
|
+
yield ToolCallDone(tool_call=tc)
|
|
414
|
+
tool_acc.clear()
|
|
415
|
+
|
|
416
|
+
break # Stream completed successfully
|
|
417
|
+
|
|
418
|
+
except (MistralError, httpx.TransportError) as exc:
|
|
419
|
+
status = getattr(exc, "status_code", None)
|
|
420
|
+
if status in _NON_RETRYABLE_STATUS or has_yielded or attempt == _MAX_ATTEMPTS:
|
|
421
|
+
raise
|
|
422
|
+
headers = getattr(exc, "headers", None)
|
|
423
|
+
retry_after = headers.get("retry-after") if headers is not None else None
|
|
424
|
+
wait = compute_wait(attempt, _RETRY_BASE_WAIT, retry_after)
|
|
425
|
+
LOGGER.warning(
|
|
426
|
+
"Transient Mistral API error during stream (attempt %d/%d, status %s): %s: %s — retrying in %.1fs",
|
|
427
|
+
attempt,
|
|
428
|
+
_MAX_ATTEMPTS,
|
|
429
|
+
status,
|
|
430
|
+
type(exc).__name__,
|
|
431
|
+
exc,
|
|
432
|
+
wait,
|
|
433
|
+
)
|
|
434
|
+
if self.on_retry is not None:
|
|
435
|
+
self.on_retry(attempt, _MAX_ATTEMPTS, str(exc))
|
|
436
|
+
await asyncio.sleep(wait)
|
|
437
|
+
|
|
438
|
+
finally:
|
|
439
|
+
if response is not None:
|
|
440
|
+
aclose = getattr(response, "aclose", None) or getattr(response, "close", None)
|
|
441
|
+
if aclose is not None:
|
|
442
|
+
result = aclose()
|
|
443
|
+
if inspect.isawaitable(result):
|
|
444
|
+
await result
|
|
445
|
+
|
|
446
|
+
cost = None
|
|
447
|
+
if self._model_config:
|
|
448
|
+
cost = compute_cost(
|
|
449
|
+
last_usage,
|
|
450
|
+
self._model_config.input_token_cost,
|
|
451
|
+
self._model_config.output_token_cost,
|
|
452
|
+
)
|
|
453
|
+
if prompt_tokens is not None:
|
|
454
|
+
LOGGER.debug(
|
|
455
|
+
"tokens: %s in / %s out, cost=$%s",
|
|
456
|
+
prompt_tokens,
|
|
457
|
+
completion_tokens,
|
|
458
|
+
cost or "?",
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
if prompt_tokens is not None or completion_tokens is not None:
|
|
462
|
+
yield StreamUsage(
|
|
463
|
+
prompt_tokens=prompt_tokens,
|
|
464
|
+
completion_tokens=completion_tokens,
|
|
465
|
+
cost=cost,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
content = "".join(content_parts) or None
|
|
469
|
+
structured_output = None
|
|
470
|
+
if response_model is not None:
|
|
471
|
+
if not content:
|
|
472
|
+
raise RuntimeError("LLM returned empty streamed content with response_format set")
|
|
473
|
+
structured_output = response_model.model_validate_json(content)
|
|
474
|
+
|
|
475
|
+
final_response = LLMResponse(
|
|
476
|
+
content=content,
|
|
477
|
+
tool_calls=finished_tool_calls or None,
|
|
478
|
+
structured_output=structured_output,
|
|
479
|
+
cost=cost,
|
|
480
|
+
prompt_tokens=prompt_tokens,
|
|
481
|
+
completion_tokens=completion_tokens,
|
|
482
|
+
)
|
|
483
|
+
self.cost_tracker.record(final_response)
|
|
484
|
+
yield StreamDone(response=final_response)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class MistralEmbeddingClient(EmbeddingClient):
|
|
488
|
+
"""Embedding client using the native Mistral SDK.
|
|
489
|
+
|
|
490
|
+
The underlying ``Mistral`` SDK client is stored as ``self._client``
|
|
491
|
+
and created once at construction time.
|
|
492
|
+
|
|
493
|
+
Requires: ``pip install dot-inference[mistral]``
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
def __init__(
|
|
497
|
+
self,
|
|
498
|
+
model: str,
|
|
499
|
+
api_key: str | None = None,
|
|
500
|
+
batch_size: int = 0,
|
|
501
|
+
timeout: float = 300,
|
|
502
|
+
) -> None:
|
|
503
|
+
_check_mistral_available()
|
|
504
|
+
|
|
505
|
+
from mistralai.client import Mistral
|
|
506
|
+
|
|
507
|
+
self._client = Mistral(
|
|
508
|
+
api_key=api_key,
|
|
509
|
+
timeout_ms=int(timeout * 1000),
|
|
510
|
+
)
|
|
511
|
+
self._model = model
|
|
512
|
+
self._batch_size = batch_size
|
|
513
|
+
|
|
514
|
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
515
|
+
if not texts:
|
|
516
|
+
return []
|
|
517
|
+
|
|
518
|
+
batches = (
|
|
519
|
+
[texts[i : i + self._batch_size] for i in range(0, len(texts), self._batch_size)]
|
|
520
|
+
if self._batch_size > 0
|
|
521
|
+
else [texts]
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
all_embeddings: list[list[float]] = []
|
|
525
|
+
for batch in batches:
|
|
526
|
+
response = await self._client.embeddings.create_async(model=self._model, inputs=batch)
|
|
527
|
+
items = sorted(response.data, key=lambda x: x.index)
|
|
528
|
+
all_embeddings.extend(item.embedding for item in items)
|
|
529
|
+
|
|
530
|
+
return all_embeddings
|