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,71 @@
1
+ # SPDX-FileCopyrightText: Kannon For Deep Tech
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """Cost and token usage tracker for aggregating across multiple LLM calls."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from dot_inference._types import LLMResponse
11
+
12
+
13
+ # Per-token costs are sourced from models.yaml via ResolvedModelConfig
14
+ # and passed in by the provider clients (OpenAI, Mistral, etc.).
15
+ def compute_cost(
16
+ usage: Any,
17
+ input_cost_per_token: float | None,
18
+ output_cost_per_token: float | None,
19
+ ) -> float | None:
20
+ """Compute USD cost from token usage and per-token prices.
21
+
22
+ Returns ``None`` when any input is missing so callers can treat
23
+ the result as "unknown cost" without special-casing.
24
+ """
25
+ if usage is None or input_cost_per_token is None or output_cost_per_token is None:
26
+ return None
27
+ return (
28
+ usage.prompt_tokens * input_cost_per_token + usage.completion_tokens * output_cost_per_token
29
+ )
30
+
31
+
32
+ class CostTracker:
33
+ """Accumulates token usage and cost across multiple ``LLMResponse`` instances.
34
+
35
+ Usage::
36
+
37
+ tracker = CostTracker()
38
+ response = await llm(messages)
39
+ tracker.record(response)
40
+ # ... more calls ...
41
+ print(tracker.total_cost, tracker.total_tokens)
42
+ """
43
+
44
+ def __init__(self) -> None:
45
+ self.total_prompt_tokens: int = 0
46
+ self.total_completion_tokens: int = 0
47
+ self.total_cost: float = 0.0
48
+ self.call_count: int = 0
49
+
50
+ @property
51
+ def total_tokens(self) -> int:
52
+ """Total tokens (prompt + completion) across all tracked calls."""
53
+ return self.total_prompt_tokens + self.total_completion_tokens
54
+
55
+ def record(self, response: LLMResponse) -> LLMResponse:
56
+ """Record usage from a response. Returns the response unchanged for chaining."""
57
+ if response.prompt_tokens is not None:
58
+ self.total_prompt_tokens += response.prompt_tokens
59
+ if response.completion_tokens is not None:
60
+ self.total_completion_tokens += response.completion_tokens
61
+ if response.cost is not None:
62
+ self.total_cost += response.cost
63
+ self.call_count += 1
64
+ return response
65
+
66
+ def reset(self) -> None:
67
+ """Reset all counters to zero."""
68
+ self.total_prompt_tokens = 0
69
+ self.total_completion_tokens = 0
70
+ self.total_cost = 0.0
71
+ self.call_count = 0
@@ -0,0 +1,302 @@
1
+ # SPDX-FileCopyrightText: Kannon For Deep Tech
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """Model registry — loads model configurations from models.yaml.
5
+
6
+ The built-in ``models.yaml`` ships with the package. Users can extend or
7
+ override it by pointing the ``DOTI_CUSTOM_MODELS`` environment variable at
8
+ their own YAML file (same format). Entries in the custom file are merged on
9
+ top of the built-in registry — new model IDs are added, and existing ones
10
+ are fully replaced.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from dataclasses import dataclass, field, replace
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import yaml
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ProviderModelConfig:
25
+ """Provider-specific configuration for a model."""
26
+
27
+ api_model_name: str
28
+ input_token_cost: float | None = None
29
+ output_token_cost: float | None = None
30
+ # Per-provider override for reasoning support (None = inherit from model).
31
+ # Useful when reasoning works through one provider (e.g. OpenRouter) but
32
+ # not another (e.g. native SDK doesn't expose the parameter yet).
33
+ supports_reasoning: bool | None = None
34
+ # OpenRouter-specific features (ignored by other providers)
35
+ response_healing: bool = False
36
+ fallback_models: list[str] = field(default_factory=list)
37
+ provider_routing: dict[str, Any] | None = None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ModelConfig:
42
+ """Configuration for a single model from the registry."""
43
+
44
+ model_id: str
45
+ supports_strict_json: bool = True
46
+ supports_reasoning: bool = False
47
+ requires_cache_control: bool = False
48
+ input_token_cost: float | None = None
49
+ output_token_cost: float | None = None
50
+ # Maximum tokens (input + output) the model accepts in one request.
51
+ # Model-level (not per-provider) — public spec value. ``None`` when
52
+ # the figure is not declared.
53
+ context_window: int | None = None
54
+ default_provider: str | None = None
55
+ # The reasoning_effort values this model accepts. Empty means "not declared" — callers
56
+ # fall back to the standard ladder via allowed_reasoning_efforts().
57
+ reasoning_effort_options: list[str] = field(default_factory=list)
58
+ # The effort used when the caller specifies none; None falls back to the
59
+ # global GenerationSettings default.
60
+ default_reasoning_effort: str | None = None
61
+ providers: dict[str, ProviderModelConfig] = field(default_factory=dict)
62
+
63
+ def for_provider(self, provider: str) -> ResolvedModelConfig:
64
+ """Resolve a flattened config for a specific provider.
65
+
66
+ Merges model-level defaults with provider-level overrides.
67
+ Raises KeyError if the model is not available through this provider.
68
+ """
69
+ pmc = self.providers[provider]
70
+ return ResolvedModelConfig(
71
+ model_id=self.model_id,
72
+ provider=provider,
73
+ api_model_name=pmc.api_model_name,
74
+ supports_strict_json=self.supports_strict_json,
75
+ supports_reasoning=pmc.supports_reasoning
76
+ if pmc.supports_reasoning is not None
77
+ else self.supports_reasoning,
78
+ requires_cache_control=self.requires_cache_control,
79
+ input_token_cost=pmc.input_token_cost
80
+ if pmc.input_token_cost is not None
81
+ else self.input_token_cost,
82
+ output_token_cost=pmc.output_token_cost
83
+ if pmc.output_token_cost is not None
84
+ else self.output_token_cost,
85
+ context_window=self.context_window,
86
+ response_healing=pmc.response_healing,
87
+ fallback_models=pmc.fallback_models,
88
+ provider_routing=pmc.provider_routing,
89
+ )
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class ResolvedModelConfig:
94
+ """Fully resolved config for a model+provider pair. Ready for use by callables."""
95
+
96
+ model_id: str
97
+ provider: str
98
+ api_model_name: str
99
+ supports_strict_json: bool = True
100
+ supports_reasoning: bool = False
101
+ requires_cache_control: bool = False
102
+ input_token_cost: float | None = None
103
+ output_token_cost: float | None = None
104
+ context_window: int | None = None
105
+ # OpenRouter-specific (no-ops for other providers)
106
+ response_healing: bool = False
107
+ fallback_models: list[str] = field(default_factory=list)
108
+ provider_routing: dict[str, Any] | None = None
109
+
110
+
111
+ _REGISTRY: dict[str, ModelConfig] | None = None
112
+
113
+
114
+ def _to_float(val: Any) -> float | None:
115
+ """Coerce cost values to float (YAML may parse e.g. ``5e-7`` as str)."""
116
+ if val is None:
117
+ return None
118
+ return float(val)
119
+
120
+
121
+ def _parse_models_yaml(raw: dict[str, Any]) -> dict[str, ModelConfig]:
122
+ """Parse a raw YAML dict into ModelConfig entries."""
123
+ registry: dict[str, ModelConfig] = {}
124
+ for model_id, cfg in raw.items():
125
+ providers: dict[str, ProviderModelConfig] = {}
126
+ for prov_name, prov_cfg in cfg.get("providers", {}).items():
127
+ features = prov_cfg.get("features", {})
128
+ providers[prov_name] = ProviderModelConfig(
129
+ api_model_name=prov_cfg["api_model_name"],
130
+ input_token_cost=_to_float(prov_cfg.get("input_token_cost")),
131
+ output_token_cost=_to_float(prov_cfg.get("output_token_cost")),
132
+ supports_reasoning=prov_cfg.get("supports_reasoning"),
133
+ response_healing=features.get("response_healing", False),
134
+ fallback_models=features.get("fallback_models", []),
135
+ provider_routing=features.get("provider_routing"),
136
+ )
137
+ ctx_window = cfg.get("context_window")
138
+ registry[model_id] = ModelConfig(
139
+ model_id=model_id,
140
+ supports_strict_json=cfg.get("supports_strict_json", True),
141
+ supports_reasoning=cfg.get("supports_reasoning", False),
142
+ requires_cache_control=cfg.get("requires_cache_control", False),
143
+ input_token_cost=_to_float(cfg.get("input_token_cost")),
144
+ output_token_cost=_to_float(cfg.get("output_token_cost")),
145
+ context_window=int(ctx_window) if ctx_window is not None else None,
146
+ default_provider=cfg.get("default_provider"),
147
+ reasoning_effort_options=list(cfg.get("reasoning_effort_options") or []),
148
+ default_reasoning_effort=cfg.get("default_reasoning_effort"),
149
+ providers=providers,
150
+ )
151
+ return registry
152
+
153
+
154
+ def _load_registry() -> dict[str, ModelConfig]:
155
+ global _REGISTRY
156
+ if _REGISTRY is not None:
157
+ return _REGISTRY
158
+
159
+ # 1. Load built-in models
160
+ yaml_path = Path(__file__).parent / "models.yaml"
161
+ with yaml_path.open() as f:
162
+ raw = yaml.safe_load(f)
163
+ registry = _parse_models_yaml(raw)
164
+
165
+ # 2. Merge user-defined models (DOTI_CUSTOM_MODELS env var)
166
+ custom_path = os.environ.get("DOTI_CUSTOM_MODELS")
167
+ if custom_path:
168
+ path = Path(custom_path).expanduser()
169
+ with path.open() as f:
170
+ custom_raw = yaml.safe_load(f)
171
+ if custom_raw:
172
+ registry.update(_parse_models_yaml(custom_raw))
173
+
174
+ _REGISTRY = registry
175
+ return registry
176
+
177
+
178
+ def register_model(config: ModelConfig) -> None:
179
+ """Register (or replace) a model configuration at runtime.
180
+
181
+ Adds *config* to the in-memory registry under its ``model_id``, replacing
182
+ any existing entry with that ID. This is the programmatic counterpart to
183
+ the ``DOTI_CUSTOM_MODELS`` YAML merge: use it to declare a custom model's
184
+ costs and capabilities in code so cost tracking and feature detection work
185
+ for models that are not in the built-in registry::
186
+
187
+ register_model(ModelConfig(model_id="my-org/llm", input_token_cost=...))
188
+
189
+ The registry is loaded on first use if it has not been already, so this is
190
+ safe to call as the very first registry interaction in a process.
191
+ """
192
+ _load_registry()[config.model_id] = config
193
+
194
+
195
+ def resolve_model_config(
196
+ model: str,
197
+ provider: str,
198
+ *,
199
+ input_token_cost: float | None = None,
200
+ output_token_cost: float | None = None,
201
+ ) -> ResolvedModelConfig | None:
202
+ """Build a ResolvedModelConfig from the registry + optional cost overrides.
203
+
204
+ If the model is in the registry, returns the resolved config with any
205
+ user-provided costs taking precedence. If the model is NOT in the
206
+ registry but costs are provided, returns a minimal config with those
207
+ costs (so cost tracking still works). Returns None only when the model
208
+ is unknown AND no costs were provided.
209
+ """
210
+ config = get_model_config(model, provider)
211
+ has_overrides = input_token_cost is not None or output_token_cost is not None
212
+
213
+ if config is not None and has_overrides:
214
+ overrides: dict[str, float] = {}
215
+ if input_token_cost is not None:
216
+ overrides["input_token_cost"] = input_token_cost
217
+ if output_token_cost is not None:
218
+ overrides["output_token_cost"] = output_token_cost
219
+ return replace(config, **overrides)
220
+
221
+ if config is None and has_overrides:
222
+ return ResolvedModelConfig(
223
+ model_id=model,
224
+ provider=provider,
225
+ api_model_name=model,
226
+ input_token_cost=input_token_cost,
227
+ output_token_cost=output_token_cost,
228
+ )
229
+
230
+ return config
231
+
232
+
233
+ def get_model_config(model_id: str, provider: str) -> ResolvedModelConfig | None:
234
+ """Look up a model by its canonical ID, resolved for a specific provider.
235
+
236
+ Returns None if the model is not in the registry or is not available
237
+ through the specified provider.
238
+ """
239
+ model = _load_registry().get(model_id)
240
+ if model is None:
241
+ return None
242
+ try:
243
+ return model.for_provider(provider)
244
+ except KeyError:
245
+ return None
246
+
247
+
248
+ def get_default_provider(model_id: str) -> str | None:
249
+ """Return the declared default provider for a model, if any."""
250
+ model = _load_registry().get(model_id)
251
+ return model.default_provider if model is not None else None
252
+
253
+
254
+ def get_context_window(model_id: str) -> int | None:
255
+ """Return the advertised context window for *model_id*, or None if unknown."""
256
+ model = _load_registry().get(model_id)
257
+ return model.context_window if model is not None else None
258
+
259
+
260
+ def list_models(provider: str | None = None) -> list[str]:
261
+ """Return registered model IDs, optionally filtered by provider availability."""
262
+ registry = _load_registry()
263
+ if provider is None:
264
+ return list(registry.keys())
265
+ return [mid for mid, mc in registry.items() if provider in mc.providers]
266
+
267
+
268
+ # Fallback effort ladder for reasoning models that don't declare an explicit
269
+ # option set (the OpenAI/Anthropic convention). "none" is intentionally
270
+ # excluded — it's opt-in via reasoning_effort_options (Mistral models).
271
+ # TODO: this will be made better later.
272
+ _DEFAULT_REASONING_LADDER = ["low", "medium", "high"]
273
+
274
+
275
+ def allowed_reasoning_efforts(model_id: str) -> list[str]:
276
+ """Return the reasoning_effort values valid for *model_id*.
277
+
278
+ Empty list when the model is unknown or doesn't support reasoning. When
279
+ the model supports reasoning but declares no explicit
280
+ ``reasoning_effort_options``, returns the standard ``[low, medium,
281
+ high]`` ladder. Lets a caller (e.g. a client UI) offer only the choices a
282
+ given model accepts — Mistral Medium 3.5 returns ``[none, high]``.
283
+ """
284
+ model = _load_registry().get(model_id)
285
+ if model is None or not model.supports_reasoning:
286
+ return []
287
+ if model.reasoning_effort_options:
288
+ return list(model.reasoning_effort_options)
289
+ return list(_DEFAULT_REASONING_LADDER)
290
+
291
+
292
+ def default_reasoning_effort(model_id: str) -> str | None:
293
+ """Return the model's declared default reasoning_effort, if any.
294
+
295
+ ``None`` when the model is unknown, doesn't support reasoning, or simply
296
+ doesn't pin a default (in which case the caller's global default
297
+ applies).
298
+ """
299
+ model = _load_registry().get(model_id)
300
+ if model is None or not model.supports_reasoning:
301
+ return None
302
+ return model.default_reasoning_effort
@@ -0,0 +1,76 @@
1
+ # SPDX-FileCopyrightText: Kannon For Deep Tech
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """Sanity check for the ``local`` provider endpoint.
5
+
6
+ Single-model OpenAI-compatible servers (SGLang, vLLM in single-model mode)
7
+ typically ignore the ``model`` field of a request and answer with whatever
8
+ checkpoint is loaded — some even echo the requested name back in the
9
+ response. Selecting a model the server does not actually serve therefore
10
+ runs the WRONG model with zero failure signal. This module lets callers
11
+ detect that situation up front by comparing the registry's expected
12
+ ``api_model_name`` against the endpoint's ``GET /models`` listing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ import httpx
20
+
21
+ from dot_inference._llm_registry import get_default_provider, get_model_config
22
+ from dot_inference.settings import InferenceSettings
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class LocalEndpointCheck:
27
+ """Result of probing the local endpoint for a given model."""
28
+
29
+ base_url: str
30
+ # The api_model_name the registry maps the model to on the local provider.
31
+ expected_model: str
32
+ # Model ids the endpoint reports via GET /models.
33
+ served_models: list[str]
34
+
35
+ @property
36
+ def matches(self) -> bool:
37
+ return self.expected_model in self.served_models
38
+
39
+
40
+ async def check_local_endpoint(
41
+ model_id: str,
42
+ *,
43
+ settings: InferenceSettings | None = None,
44
+ timeout: float = 3.0,
45
+ transport: httpx.AsyncBaseTransport | None = None,
46
+ ) -> LocalEndpointCheck | None:
47
+ """Probe the ``local`` provider endpoint for *model_id*.
48
+
49
+ Returns ``None`` when there is nothing to check: the model is unknown to
50
+ the registry, its default provider is not ``local``, or no local
51
+ ``base_url`` is configured. Otherwise returns a
52
+ :class:`LocalEndpointCheck` — inspect ``.matches`` to know whether the
53
+ endpoint actually serves the expected model. Network failures propagate
54
+ as ``httpx`` exceptions; callers decide whether they are fatal.
55
+
56
+ *transport* is a test seam (``httpx.MockTransport``).
57
+ """
58
+ if get_default_provider(model_id) != "local":
59
+ return None
60
+ resolved = get_model_config(model_id, "local")
61
+ if resolved is None:
62
+ return None
63
+ settings = settings or InferenceSettings()
64
+ base_url = settings.generation.local.base_url
65
+ if not base_url:
66
+ return None
67
+ async with httpx.AsyncClient(timeout=timeout, transport=transport) as client:
68
+ resp = await client.get(f"{base_url.rstrip('/')}/models")
69
+ resp.raise_for_status()
70
+ data = resp.json().get("data", [])
71
+ served = [m["id"] for m in data if isinstance(m, dict) and "id" in m]
72
+ return LocalEndpointCheck(
73
+ base_url=base_url,
74
+ expected_model=resolved.api_model_name,
75
+ served_models=served,
76
+ )