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.
@@ -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