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
dot_inference/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# dot_inference
|
|
2
|
+
|
|
3
|
+
A thin, async-first abstraction over multiple LLM providers. It exposes class-based clients for generation, streaming, and embedding behind a unified interface, so application code never couples to a specific SDK.
|
|
4
|
+
|
|
5
|
+
## Core concepts
|
|
6
|
+
|
|
7
|
+
### Client classes (`_client.py`)
|
|
8
|
+
|
|
9
|
+
The package defines two abstract base classes that every provider must implement:
|
|
10
|
+
|
|
11
|
+
| Class | Key methods | Purpose |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| `LLMClient` | `call()`, `stream()`, `stream_text()`, `call_sync()` | Chat completion with optional structured output or tool calling |
|
|
14
|
+
| `EmbeddingClient` | `embed()`, `embed_sync()` | Batched text embeddings |
|
|
15
|
+
|
|
16
|
+
`LLMResponse` carries `content`, `tool_calls`, `structured_output`, `cost`, and token counts.
|
|
17
|
+
|
|
18
|
+
### Type aliases (`_types.py`)
|
|
19
|
+
|
|
20
|
+
Functional type aliases are also available for interoperability:
|
|
21
|
+
|
|
22
|
+
| Type | Signature | Purpose |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `LLMCallable` | `(messages, json_schema?, tools?) -> LLMResponse` | Async chat completion |
|
|
25
|
+
| `LLMStreamCallable` | `(messages, json_schema?, tools?) -> AsyncGenerator[StreamEvent]` | Streaming events (text, tool calls, usage, done) |
|
|
26
|
+
| `EmbeddingCallable` | `(texts) -> list[list[float]]` | Batched text embeddings |
|
|
27
|
+
|
|
28
|
+
### Settings (`settings.py`)
|
|
29
|
+
|
|
30
|
+
Configuration is driven by `pydantic-settings` and environment variables prefixed with `DOTI_`:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
DOTI_GENERATION__PROVIDER=openrouter
|
|
34
|
+
DOTI_GENERATION__OPENROUTER__MODEL_NAME=openai/gpt-5-mini
|
|
35
|
+
DOTI_GENERATION__OPENROUTER__API_KEY=sk-or-...
|
|
36
|
+
DOTI_GENERATION__MISTRAL__MODEL_NAME=mistral-small-latest
|
|
37
|
+
DOTI_GENERATION__MISTRAL__API_KEY=...
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Each provider has its own nested section for `model_name`, `api_key`, and `base_url`. Switching providers only requires changing the `PROVIDER` value — all credentials and model names are pre-configured per provider.
|
|
41
|
+
|
|
42
|
+
### Factories (`_client.py`)
|
|
43
|
+
|
|
44
|
+
`get_llm_client()` and `get_embedding_client()` read the active provider from an `InferenceSettings` instance and return the appropriate concrete client. This is the recommended entry point for application code.
|
|
45
|
+
|
|
46
|
+
### Provider implementations
|
|
47
|
+
|
|
48
|
+
| Module | Purpose |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `_openai_compat.py` | OpenAI-compatible client used by OpenAI, OpenRouter, and local providers |
|
|
51
|
+
| `_mistral_compat.py` | Native Mistral SDK client |
|
|
52
|
+
|
|
53
|
+
## Other modules
|
|
54
|
+
|
|
55
|
+
| Module | Purpose |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `_rate_limit.py` | Sliding-window rate limiter wrapping any `LLMClient.call()` |
|
|
58
|
+
| `_cost_tracker.py` | Accumulates token usage and cost across multiple calls |
|
|
59
|
+
| `_llm_registry.py` | Loads model metadata (pricing, capabilities, routing) from `models.yaml` |
|
|
60
|
+
| `mock.py` | Mock clients (`MockLLMClient`, `MockEmbeddingClient`) for testing without API calls |
|
|
61
|
+
| `models.yaml` | Model registry with per-model pricing and feature flags |
|
|
62
|
+
|
|
63
|
+
## Quick start
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from dot_inference import InferenceSettings, get_llm_client
|
|
67
|
+
|
|
68
|
+
settings = InferenceSettings() # reads from DOTI_* env vars (or a .env file)
|
|
69
|
+
client = get_llm_client(settings)
|
|
70
|
+
|
|
71
|
+
response = await client.call([{"role": "user", "content": "Hello!"}])
|
|
72
|
+
print(response.content)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
For explicit provider selection:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from dot_inference import InferenceSettings, Provider, get_llm_client
|
|
79
|
+
|
|
80
|
+
settings = InferenceSettings()
|
|
81
|
+
client = get_llm_client(settings, provider=Provider.OPENROUTER, model="openai/gpt-5-mini")
|
|
82
|
+
response = await client.call([{"role": "user", "content": "Hello!"}])
|
|
83
|
+
```
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Kannon For Deep Tech
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
from dot_inference._client import (
|
|
5
|
+
EmbeddingClient,
|
|
6
|
+
LLMClient,
|
|
7
|
+
get_embedding_client,
|
|
8
|
+
get_llm_client,
|
|
9
|
+
)
|
|
10
|
+
from dot_inference._cost_tracker import CostTracker, compute_cost
|
|
11
|
+
from dot_inference._llm_registry import (
|
|
12
|
+
ModelConfig,
|
|
13
|
+
ResolvedModelConfig,
|
|
14
|
+
allowed_reasoning_efforts,
|
|
15
|
+
default_reasoning_effort,
|
|
16
|
+
get_context_window,
|
|
17
|
+
get_model_config,
|
|
18
|
+
list_models,
|
|
19
|
+
register_model,
|
|
20
|
+
resolve_model_config,
|
|
21
|
+
)
|
|
22
|
+
from dot_inference._local_check import LocalEndpointCheck, check_local_endpoint
|
|
23
|
+
from dot_inference._rate_limit import with_rate_limit
|
|
24
|
+
from dot_inference._types import (
|
|
25
|
+
EmbeddingCallable,
|
|
26
|
+
InputJsonDelta,
|
|
27
|
+
LLMCallable,
|
|
28
|
+
LLMResponse,
|
|
29
|
+
LLMStreamCallable,
|
|
30
|
+
Message,
|
|
31
|
+
ReasoningDelta,
|
|
32
|
+
Role,
|
|
33
|
+
StreamDone,
|
|
34
|
+
StreamEvent,
|
|
35
|
+
StreamUsage,
|
|
36
|
+
SyncLLMCallable,
|
|
37
|
+
TextDelta,
|
|
38
|
+
ToolCall,
|
|
39
|
+
ToolCallDone,
|
|
40
|
+
ToolDef,
|
|
41
|
+
make_sync,
|
|
42
|
+
text_stream,
|
|
43
|
+
)
|
|
44
|
+
from dot_inference.settings import InferenceSettings, Provider, ReasoningEffort
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"CostTracker",
|
|
48
|
+
"EmbeddingCallable",
|
|
49
|
+
"EmbeddingClient",
|
|
50
|
+
"InferenceSettings",
|
|
51
|
+
"InputJsonDelta",
|
|
52
|
+
"LLMCallable",
|
|
53
|
+
"LLMClient",
|
|
54
|
+
"LLMResponse",
|
|
55
|
+
"LLMStreamCallable",
|
|
56
|
+
"LocalEndpointCheck",
|
|
57
|
+
"Message",
|
|
58
|
+
"ModelConfig",
|
|
59
|
+
"Provider",
|
|
60
|
+
"ReasoningDelta",
|
|
61
|
+
"ReasoningEffort",
|
|
62
|
+
"ResolvedModelConfig",
|
|
63
|
+
"Role",
|
|
64
|
+
"StreamDone",
|
|
65
|
+
"StreamEvent",
|
|
66
|
+
"StreamUsage",
|
|
67
|
+
"SyncLLMCallable",
|
|
68
|
+
"TextDelta",
|
|
69
|
+
"ToolCall",
|
|
70
|
+
"ToolCallDone",
|
|
71
|
+
"ToolDef",
|
|
72
|
+
"allowed_reasoning_efforts",
|
|
73
|
+
"check_local_endpoint",
|
|
74
|
+
"compute_cost",
|
|
75
|
+
"default_reasoning_effort",
|
|
76
|
+
"get_context_window",
|
|
77
|
+
"get_embedding_client",
|
|
78
|
+
"get_llm_client",
|
|
79
|
+
"get_model_config",
|
|
80
|
+
"list_models",
|
|
81
|
+
"make_sync",
|
|
82
|
+
"register_model",
|
|
83
|
+
"resolve_model_config",
|
|
84
|
+
"text_stream",
|
|
85
|
+
"with_rate_limit",
|
|
86
|
+
]
|
dot_inference/_client.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Kannon For Deep Tech
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Class-based LLM client interface.
|
|
5
|
+
|
|
6
|
+
Provides ``LLMClient`` and ``EmbeddingClient`` — abstract base classes with
|
|
7
|
+
explicit ``self._client`` attributes for readability — and ``get_llm_client()``
|
|
8
|
+
/ ``get_embedding_client()`` settings-based factories that resolve API keys
|
|
9
|
+
and provider configuration automatically.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from collections.abc import AsyncGenerator, Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, SecretStr
|
|
20
|
+
|
|
21
|
+
from dot_inference._cost_tracker import CostTracker
|
|
22
|
+
from dot_inference._llm_registry import (
|
|
23
|
+
ResolvedModelConfig,
|
|
24
|
+
allowed_reasoning_efforts,
|
|
25
|
+
default_reasoning_effort,
|
|
26
|
+
get_default_provider,
|
|
27
|
+
resolve_model_config,
|
|
28
|
+
)
|
|
29
|
+
from dot_inference._rate_limit import with_rate_limit
|
|
30
|
+
from dot_inference._types import (
|
|
31
|
+
LLMResponse,
|
|
32
|
+
Message,
|
|
33
|
+
StreamDone,
|
|
34
|
+
StreamEvent,
|
|
35
|
+
TextDelta,
|
|
36
|
+
ToolDef,
|
|
37
|
+
)
|
|
38
|
+
from dot_inference.settings import InferenceSettings, Provider, ReasoningEffort
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LLMClient(ABC):
|
|
44
|
+
"""Abstract base class for LLM clients.
|
|
45
|
+
|
|
46
|
+
Subclasses hold the provider SDK client as ``self._client`` and expose
|
|
47
|
+
``call()``, ``stream()``, and ``call_sync()`` methods.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# for readability:
|
|
51
|
+
_client: Any
|
|
52
|
+
_model: str
|
|
53
|
+
_model_config: ResolvedModelConfig | None
|
|
54
|
+
_temperature: float | None
|
|
55
|
+
_reasoning_effort: str | None
|
|
56
|
+
_max_tokens: int | None
|
|
57
|
+
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
self.cost_tracker = CostTracker()
|
|
60
|
+
self.on_retry: Callable[[int, int, str], None] | None = None
|
|
61
|
+
"""Optional callback invoked after each failed attempt, before the retry sleep.
|
|
62
|
+
|
|
63
|
+
Signature: ``(attempt, max_attempts, error_message) -> None``
|
|
64
|
+
where *attempt* is the 1-indexed number of the attempt that just failed
|
|
65
|
+
(1 = first attempt failed / first retry upcoming) and *error_message*
|
|
66
|
+
is a human-readable description of the failure.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
async def call(
|
|
71
|
+
self,
|
|
72
|
+
messages: list[Message],
|
|
73
|
+
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
|
74
|
+
tools: list[ToolDef] | None = None,
|
|
75
|
+
*,
|
|
76
|
+
temperature: float | None = None,
|
|
77
|
+
reasoning_effort: str | None = None,
|
|
78
|
+
tool_choice: str | None = None,
|
|
79
|
+
) -> LLMResponse:
|
|
80
|
+
"""Send messages to the LLM and return a response.
|
|
81
|
+
|
|
82
|
+
Per-call *temperature* and *reasoning_effort* override the instance
|
|
83
|
+
defaults when provided. *tool_choice* controls whether the model
|
|
84
|
+
must call a tool (``"auto"``, ``"required"``, or ``"none"``).
|
|
85
|
+
Defaults to ``"auto"`` when *tools* is provided, ignored otherwise.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
async def stream(
|
|
90
|
+
self,
|
|
91
|
+
messages: list[Message],
|
|
92
|
+
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
|
93
|
+
tools: list[ToolDef] | None = None,
|
|
94
|
+
*,
|
|
95
|
+
temperature: float | None = None,
|
|
96
|
+
reasoning_effort: str | None = None,
|
|
97
|
+
tool_choice: str | None = None,
|
|
98
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
99
|
+
"""Stream events from the LLM.
|
|
100
|
+
|
|
101
|
+
Yields ``StreamEvent`` objects: ``TextDelta`` for text,
|
|
102
|
+
``ToolCallDelta``/``ToolCallDone`` for tool calls, ``StreamUsage``
|
|
103
|
+
for token counts, and ``StreamDone`` with the final assembled
|
|
104
|
+
``LLMResponse``.
|
|
105
|
+
|
|
106
|
+
*tool_choice* controls whether the model must call a tool
|
|
107
|
+
(``"auto"``, ``"required"``, or ``"none"``). Defaults to
|
|
108
|
+
``"auto"`` when *tools* is provided, ignored otherwise.
|
|
109
|
+
"""
|
|
110
|
+
yield StreamDone(response=LLMResponse()) # pragma: no cover
|
|
111
|
+
|
|
112
|
+
async def stream_text(
|
|
113
|
+
self,
|
|
114
|
+
messages: list[Message],
|
|
115
|
+
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
|
116
|
+
tools: list[ToolDef] | None = None,
|
|
117
|
+
*,
|
|
118
|
+
temperature: float | None = None,
|
|
119
|
+
reasoning_effort: str | None = None,
|
|
120
|
+
tool_choice: str | None = None,
|
|
121
|
+
) -> AsyncGenerator[str, None]:
|
|
122
|
+
"""Stream only text content from the LLM.
|
|
123
|
+
|
|
124
|
+
Convenience wrapper around :meth:`stream` that yields plain ``str``
|
|
125
|
+
chunks, filtering out tool-call, usage, and done events.
|
|
126
|
+
"""
|
|
127
|
+
async for event in self.stream(
|
|
128
|
+
messages,
|
|
129
|
+
response_format,
|
|
130
|
+
tools,
|
|
131
|
+
temperature=temperature,
|
|
132
|
+
reasoning_effort=reasoning_effort,
|
|
133
|
+
tool_choice=tool_choice,
|
|
134
|
+
):
|
|
135
|
+
if isinstance(event, TextDelta):
|
|
136
|
+
yield event.content
|
|
137
|
+
|
|
138
|
+
def call_sync(
|
|
139
|
+
self,
|
|
140
|
+
messages: list[Message],
|
|
141
|
+
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
|
142
|
+
tools: list[ToolDef] | None = None,
|
|
143
|
+
*,
|
|
144
|
+
temperature: float | None = None,
|
|
145
|
+
reasoning_effort: str | None = None,
|
|
146
|
+
tool_choice: str | None = None,
|
|
147
|
+
) -> LLMResponse:
|
|
148
|
+
"""Synchronous wrapper around :meth:`call`.
|
|
149
|
+
|
|
150
|
+
Uses a persistent background event loop so async SDK clients
|
|
151
|
+
can clean up properly.
|
|
152
|
+
"""
|
|
153
|
+
from dot_inference._sync import run_sync
|
|
154
|
+
|
|
155
|
+
return run_sync(
|
|
156
|
+
self.call(
|
|
157
|
+
messages,
|
|
158
|
+
response_format,
|
|
159
|
+
tools,
|
|
160
|
+
temperature=temperature,
|
|
161
|
+
reasoning_effort=reasoning_effort,
|
|
162
|
+
tool_choice=tool_choice,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _validate_params(
|
|
168
|
+
response_format: type[BaseModel] | dict[str, Any] | None,
|
|
169
|
+
tools: list[ToolDef] | None,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Raise if incompatible parameters are provided together."""
|
|
172
|
+
if tools and response_format is not None:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"Cannot use both 'tools' and 'response_format' simultaneously. "
|
|
175
|
+
"Most LLM providers do not support combining tool calling with "
|
|
176
|
+
"structured JSON output. Use one or the other."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _resolve_temperature(self, temperature: float | None) -> float | None:
|
|
180
|
+
"""Return per-call override or instance default."""
|
|
181
|
+
return self._temperature if temperature is None else temperature
|
|
182
|
+
|
|
183
|
+
def _resolve_reasoning_effort(self, reasoning_effort: str | None) -> str | None:
|
|
184
|
+
"""Return per-call override or instance default, validated for the model.
|
|
185
|
+
|
|
186
|
+
Covers the per-call path (``call``/``stream`` with ``reasoning_effort=``)
|
|
187
|
+
that bypasses the construction-time check in ``_warn_compatibility``.
|
|
188
|
+
"""
|
|
189
|
+
resolved = self._reasoning_effort if reasoning_effort is None else reasoning_effort
|
|
190
|
+
if self._model_config is not None:
|
|
191
|
+
_validate_reasoning_effort_for_model(
|
|
192
|
+
self._model_config.model_id,
|
|
193
|
+
resolved,
|
|
194
|
+
self._model_config.supports_reasoning,
|
|
195
|
+
)
|
|
196
|
+
return resolved
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class EmbeddingClient(ABC):
|
|
200
|
+
"""Abstract base class for embedding clients.
|
|
201
|
+
|
|
202
|
+
Subclasses hold the provider SDK client as ``self._client`` and expose
|
|
203
|
+
an ``embed()`` method.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
_client: Any
|
|
207
|
+
_model: str
|
|
208
|
+
_batch_size: int
|
|
209
|
+
|
|
210
|
+
@abstractmethod
|
|
211
|
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
212
|
+
"""Embed a list of texts and return their vectors."""
|
|
213
|
+
|
|
214
|
+
def embed_sync(self, texts: list[str]) -> list[list[float]]:
|
|
215
|
+
"""Synchronous wrapper around :meth:`embed`.
|
|
216
|
+
|
|
217
|
+
Uses a persistent background event loop so async SDK clients
|
|
218
|
+
can clean up properly.
|
|
219
|
+
"""
|
|
220
|
+
from dot_inference._sync import run_sync
|
|
221
|
+
|
|
222
|
+
return run_sync(self.embed(texts))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _reveal(secret: SecretStr | None) -> str | None:
|
|
226
|
+
"""Unwrap a ``SecretStr`` right at the point the SDK client is built.
|
|
227
|
+
|
|
228
|
+
Keeping the unwrap here means the key exists in clear text only as an
|
|
229
|
+
argument to the provider SDK, never on a settings object that could be
|
|
230
|
+
printed or serialized.
|
|
231
|
+
"""
|
|
232
|
+
return secret.get_secret_value() if secret is not None else None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _validate_reasoning_effort_for_model(
|
|
236
|
+
model_id: str,
|
|
237
|
+
reasoning_effort: str | None,
|
|
238
|
+
supports_reasoning: bool,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Raise ValueError if *reasoning_effort* isn't accepted by *model_id*.
|
|
241
|
+
|
|
242
|
+
A no-op for non-reasoning models (the value is ignored downstream), for
|
|
243
|
+
``None`` (means "use instance default"), and for models that declare no
|
|
244
|
+
restricted option set.
|
|
245
|
+
"""
|
|
246
|
+
if reasoning_effort is None or not supports_reasoning:
|
|
247
|
+
return
|
|
248
|
+
allowed = allowed_reasoning_efforts(model_id)
|
|
249
|
+
if allowed and str(reasoning_effort) not in allowed:
|
|
250
|
+
raise ValueError(
|
|
251
|
+
f"Model '{model_id}' does not accept reasoning_effort "
|
|
252
|
+
f"'{reasoning_effort}'. Valid values for this model: "
|
|
253
|
+
f"{', '.join(allowed)}."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _warn_compatibility(
|
|
258
|
+
*,
|
|
259
|
+
model: str,
|
|
260
|
+
provider: Provider,
|
|
261
|
+
temperature: float | None,
|
|
262
|
+
reasoning_effort: str | None,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Emit warnings when user settings are incompatible with the chosen model."""
|
|
265
|
+
|
|
266
|
+
_valid_efforts = {e.value for e in ReasoningEffort}
|
|
267
|
+
if reasoning_effort is not None and reasoning_effort not in _valid_efforts:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"Invalid reasoning_effort '{reasoning_effort}'. "
|
|
270
|
+
f"Valid values: {', '.join(sorted(_valid_efforts))}."
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
config = resolve_model_config(model, provider.value)
|
|
274
|
+
|
|
275
|
+
if config is None:
|
|
276
|
+
logger.warning(
|
|
277
|
+
"Model '%s' is not registered for provider '%s'. "
|
|
278
|
+
"The raw model name will be sent to the API — if this is intentional "
|
|
279
|
+
"you can silence this warning by adding the model to a custom models "
|
|
280
|
+
"file (DOTI_CUSTOM_MODELS).",
|
|
281
|
+
model,
|
|
282
|
+
provider.value,
|
|
283
|
+
)
|
|
284
|
+
return # Can't check further without registry info.
|
|
285
|
+
|
|
286
|
+
# Per-model effort validation: a reasoning model may accept only a subset
|
|
287
|
+
# of the global ladder (Mistral Medium 3.5 takes only [none, high]). Reject
|
|
288
|
+
# an out-of-range value with the choices valid for *this* model rather than
|
|
289
|
+
# letting the provider API fail opaquely. The per-call path is covered
|
|
290
|
+
# separately in LLMClient._resolve_reasoning_effort.
|
|
291
|
+
_validate_reasoning_effort_for_model(model, reasoning_effort, config.supports_reasoning)
|
|
292
|
+
|
|
293
|
+
if config.supports_reasoning and temperature:
|
|
294
|
+
logger.warning(
|
|
295
|
+
"Model '%s' is a reasoning model — the temperature setting (%.2f) "
|
|
296
|
+
"will be ignored in favor of reasoning_effort.",
|
|
297
|
+
model,
|
|
298
|
+
temperature,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def get_llm_client(
|
|
303
|
+
settings: InferenceSettings,
|
|
304
|
+
*,
|
|
305
|
+
provider: Provider | None = None,
|
|
306
|
+
model: str | None = None,
|
|
307
|
+
temperature: float | None = None,
|
|
308
|
+
reasoning_effort: str | None = None,
|
|
309
|
+
) -> LLMClient:
|
|
310
|
+
"""Construct an :class:`LLMClient` from settings.
|
|
311
|
+
|
|
312
|
+
API keys are resolved from *settings* — nothing needs to be passed
|
|
313
|
+
explicitly. Optional keyword arguments override the corresponding
|
|
314
|
+
values in *settings* without mutating the original object.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
assert isinstance(settings, InferenceSettings)
|
|
318
|
+
|
|
319
|
+
gen_settings = settings.generation
|
|
320
|
+
if provider is not None:
|
|
321
|
+
resolved_provider = provider
|
|
322
|
+
elif model is not None:
|
|
323
|
+
default = get_default_provider(model)
|
|
324
|
+
resolved_provider = Provider(default) if default else gen_settings.provider
|
|
325
|
+
else:
|
|
326
|
+
resolved_provider = gen_settings.provider
|
|
327
|
+
resolved_provider_cfg = getattr(gen_settings, resolved_provider.value)
|
|
328
|
+
resolved_model = model or resolved_provider_cfg.model_name
|
|
329
|
+
if not resolved_model:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"No model configured for provider '{resolved_provider.value}'. "
|
|
332
|
+
f"Set DOTI_GENERATION__{resolved_provider.value.upper()}__MODEL_NAME "
|
|
333
|
+
f"or pass model= explicitly."
|
|
334
|
+
)
|
|
335
|
+
resolved_temperature = gen_settings.temperature if temperature is None else temperature
|
|
336
|
+
# Precedence: explicit call arg > the model's declared default > global
|
|
337
|
+
# default. The per-model default lets a restricted model (e.g. Mistral
|
|
338
|
+
# Medium 3.5, which rejects the global "medium") pick a value it accepts.
|
|
339
|
+
if reasoning_effort is not None:
|
|
340
|
+
resolved_reasoning_effort = reasoning_effort
|
|
341
|
+
else:
|
|
342
|
+
resolved_reasoning_effort = (
|
|
343
|
+
default_reasoning_effort(resolved_model) or gen_settings.reasoning_effort
|
|
344
|
+
)
|
|
345
|
+
resolved_max_tokens = gen_settings.max_tokens
|
|
346
|
+
resolved_timeout = gen_settings.timeout
|
|
347
|
+
|
|
348
|
+
_warn_compatibility(
|
|
349
|
+
model=resolved_model,
|
|
350
|
+
provider=resolved_provider,
|
|
351
|
+
temperature=resolved_temperature,
|
|
352
|
+
reasoning_effort=resolved_reasoning_effort,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
match resolved_provider:
|
|
356
|
+
case Provider.OPENAI:
|
|
357
|
+
from dot_inference._openai_compat import OpenAICompatClient
|
|
358
|
+
|
|
359
|
+
config = resolve_model_config(resolved_model, "openai")
|
|
360
|
+
client = OpenAICompatClient(
|
|
361
|
+
model=config.api_model_name if config else resolved_model,
|
|
362
|
+
base_url="https://api.openai.com/v1",
|
|
363
|
+
api_key=_reveal(resolved_provider_cfg.api_key),
|
|
364
|
+
model_config=config,
|
|
365
|
+
temperature=resolved_temperature,
|
|
366
|
+
reasoning_effort=resolved_reasoning_effort,
|
|
367
|
+
max_tokens=resolved_max_tokens,
|
|
368
|
+
timeout=resolved_timeout,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
case Provider.OPENROUTER:
|
|
372
|
+
from dot_inference._openai_compat import OpenAICompatClient
|
|
373
|
+
|
|
374
|
+
config = resolve_model_config(resolved_model, "openrouter")
|
|
375
|
+
client = OpenAICompatClient(
|
|
376
|
+
model=config.api_model_name if config else resolved_model,
|
|
377
|
+
base_url="https://openrouter.ai/api/v1",
|
|
378
|
+
api_key=_reveal(resolved_provider_cfg.api_key),
|
|
379
|
+
model_config=config,
|
|
380
|
+
temperature=resolved_temperature,
|
|
381
|
+
reasoning_effort=resolved_reasoning_effort,
|
|
382
|
+
max_tokens=resolved_max_tokens,
|
|
383
|
+
timeout=resolved_timeout,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
case Provider.MISTRAL:
|
|
387
|
+
from dot_inference._mistral_compat import MistralClient
|
|
388
|
+
|
|
389
|
+
config = resolve_model_config(resolved_model, "mistral")
|
|
390
|
+
client = MistralClient(
|
|
391
|
+
model=config.api_model_name if config else resolved_model,
|
|
392
|
+
api_key=_reveal(resolved_provider_cfg.api_key),
|
|
393
|
+
model_config=config,
|
|
394
|
+
temperature=resolved_temperature,
|
|
395
|
+
reasoning_effort=resolved_reasoning_effort,
|
|
396
|
+
max_tokens=resolved_max_tokens,
|
|
397
|
+
timeout=resolved_timeout,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
case Provider.LOCAL:
|
|
401
|
+
from dot_inference._openai_compat import OpenAICompatClient
|
|
402
|
+
|
|
403
|
+
if not resolved_provider_cfg.base_url:
|
|
404
|
+
raise ValueError(
|
|
405
|
+
"base_url is required for the local provider. "
|
|
406
|
+
"Set DOTI_GENERATION__LOCAL__BASE_URL or pass it in settings."
|
|
407
|
+
)
|
|
408
|
+
config = resolve_model_config(resolved_model, "local")
|
|
409
|
+
client = OpenAICompatClient(
|
|
410
|
+
model=config.api_model_name if config else resolved_model,
|
|
411
|
+
base_url=resolved_provider_cfg.base_url,
|
|
412
|
+
api_key="not-needed",
|
|
413
|
+
model_config=config,
|
|
414
|
+
temperature=resolved_temperature,
|
|
415
|
+
reasoning_effort=resolved_reasoning_effort,
|
|
416
|
+
max_tokens=resolved_max_tokens,
|
|
417
|
+
timeout=resolved_timeout,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
case Provider.AZURE:
|
|
421
|
+
raise NotImplementedError("Azure provider is not yet supported.")
|
|
422
|
+
|
|
423
|
+
# Apply rate limiting if configured.
|
|
424
|
+
if gen_settings.calls_per_minute_limit > 0:
|
|
425
|
+
# Deliberate per-instance override: the rate-limited wrapper keeps the
|
|
426
|
+
# exact LLMCallable signature, but a bound method is not a writable
|
|
427
|
+
# attribute as far as the type checker is concerned.
|
|
428
|
+
client.call = with_rate_limit( # ty: ignore[invalid-assignment]
|
|
429
|
+
client.call, gen_settings.calls_per_minute_limit
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
return client
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def get_embedding_client(
|
|
436
|
+
settings: InferenceSettings,
|
|
437
|
+
*,
|
|
438
|
+
provider: Provider | None = None,
|
|
439
|
+
model: str | None = None,
|
|
440
|
+
batch_size: int | None = None,
|
|
441
|
+
) -> EmbeddingClient:
|
|
442
|
+
"""Construct an :class:`EmbeddingClient` from settings.
|
|
443
|
+
|
|
444
|
+
API keys are resolved from ``settings.embedding`` — nothing needs to be
|
|
445
|
+
passed explicitly. Optional keyword arguments override the corresponding
|
|
446
|
+
values in *settings* without mutating the original object.
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
assert isinstance(settings, InferenceSettings)
|
|
450
|
+
|
|
451
|
+
emb_settings = settings.embedding
|
|
452
|
+
if provider is not None:
|
|
453
|
+
resolved_provider = provider
|
|
454
|
+
elif model is not None:
|
|
455
|
+
default = get_default_provider(model)
|
|
456
|
+
resolved_provider = Provider(default) if default else emb_settings.provider
|
|
457
|
+
else:
|
|
458
|
+
resolved_provider = emb_settings.provider
|
|
459
|
+
resolved_provider_cfg = getattr(emb_settings, resolved_provider.value)
|
|
460
|
+
resolved_model = model or resolved_provider_cfg.model_name
|
|
461
|
+
if not resolved_model:
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"No model configured for provider '{resolved_provider.value}'. "
|
|
464
|
+
f"Set DOTI_EMBEDDING__{resolved_provider.value.upper()}__MODEL_NAME "
|
|
465
|
+
f"or pass model= explicitly."
|
|
466
|
+
)
|
|
467
|
+
resolved_batch_size = emb_settings.batch_size if batch_size is None else batch_size
|
|
468
|
+
resolved_timeout = emb_settings.timeout
|
|
469
|
+
|
|
470
|
+
match resolved_provider:
|
|
471
|
+
case Provider.OPENAI:
|
|
472
|
+
from dot_inference._openai_compat import OpenAICompatEmbeddingClient
|
|
473
|
+
|
|
474
|
+
return OpenAICompatEmbeddingClient(
|
|
475
|
+
model=resolved_model,
|
|
476
|
+
base_url="https://api.openai.com/v1",
|
|
477
|
+
api_key=_reveal(resolved_provider_cfg.api_key),
|
|
478
|
+
batch_size=resolved_batch_size,
|
|
479
|
+
timeout=resolved_timeout,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
case Provider.OPENROUTER:
|
|
483
|
+
from dot_inference._openai_compat import OpenAICompatEmbeddingClient
|
|
484
|
+
|
|
485
|
+
return OpenAICompatEmbeddingClient(
|
|
486
|
+
model=resolved_model,
|
|
487
|
+
base_url="https://openrouter.ai/api/v1",
|
|
488
|
+
api_key=_reveal(resolved_provider_cfg.api_key),
|
|
489
|
+
batch_size=resolved_batch_size,
|
|
490
|
+
timeout=resolved_timeout,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
case Provider.MISTRAL:
|
|
494
|
+
from dot_inference._mistral_compat import MistralEmbeddingClient
|
|
495
|
+
|
|
496
|
+
return MistralEmbeddingClient(
|
|
497
|
+
model=resolved_model,
|
|
498
|
+
api_key=_reveal(resolved_provider_cfg.api_key),
|
|
499
|
+
batch_size=resolved_batch_size,
|
|
500
|
+
timeout=resolved_timeout,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
case Provider.LOCAL:
|
|
504
|
+
from dot_inference._openai_compat import OpenAICompatEmbeddingClient
|
|
505
|
+
|
|
506
|
+
if not resolved_provider_cfg.base_url:
|
|
507
|
+
raise ValueError(
|
|
508
|
+
"base_url is required for the local provider. "
|
|
509
|
+
"Set DOTI_EMBEDDING__LOCAL__BASE_URL or pass it in settings."
|
|
510
|
+
)
|
|
511
|
+
return OpenAICompatEmbeddingClient(
|
|
512
|
+
model=resolved_model,
|
|
513
|
+
base_url=resolved_provider_cfg.base_url,
|
|
514
|
+
api_key="not-needed",
|
|
515
|
+
batch_size=resolved_batch_size,
|
|
516
|
+
timeout=resolved_timeout,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
case Provider.AZURE:
|
|
520
|
+
raise NotImplementedError("Azure provider is not yet supported.")
|