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,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
+ ]
@@ -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.")