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,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
|
+
)
|