llm-preflight 2.0.0__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.
llm_bench/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Local, cross-provider preflight checks for an LLM model switch."""
2
+
3
+ __version__ = "2.0.0"
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def load_ledger(path: Path) -> dict[str, Any]:
12
+ if not path.exists():
13
+ return {"schema_version": 1, "probes": {}}
14
+ payload = json.loads(path.read_text(encoding="utf-8"))
15
+ if not isinstance(payload, dict) or payload.get("schema_version") != 1:
16
+ raise ValueError(f"invalid capability ledger: {path}")
17
+ if not isinstance(payload.get("probes"), dict):
18
+ raise ValueError(f"invalid capability ledger probes: {path}")
19
+ return payload
20
+
21
+
22
+ def record_probe(path: Path, probe: dict[str, Any]) -> None:
23
+ provider = str(probe.get("provider", ""))
24
+ model = str(probe.get("model", ""))
25
+ if not provider or not model:
26
+ raise ValueError("probe requires provider and model")
27
+ allowed = {
28
+ "adapter",
29
+ "outcome",
30
+ "fingerprint",
31
+ "request_options",
32
+ "error_category",
33
+ "observed_at",
34
+ }
35
+ entry = {key: value for key, value in probe.items() if key in allowed}
36
+ entry["observed_at"] = (
37
+ entry.get("observed_at") or datetime.now(timezone.utc).isoformat()
38
+ )
39
+ ledger = load_ledger(path)
40
+ ledger["probes"][f"{provider}:{model}"] = entry
41
+ _write_private_json(path, ledger)
42
+
43
+
44
+ def _write_private_json(path: Path, payload: dict[str, Any]) -> None:
45
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
46
+ path.parent.chmod(0o700)
47
+ descriptor, temporary = tempfile.mkstemp(
48
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", text=True
49
+ )
50
+ try:
51
+ os.fchmod(descriptor, 0o600)
52
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
53
+ json.dump(payload, handle, indent=2)
54
+ handle.write("\n")
55
+ os.replace(temporary, path)
56
+ path.chmod(0o600)
57
+ except BaseException:
58
+ try:
59
+ os.unlink(temporary)
60
+ except FileNotFoundError:
61
+ pass
62
+ raise
63
+
64
+
65
+ def apply_probe_evidence(
66
+ models: list[dict[str, Any]], ledger: dict[str, Any]
67
+ ) -> list[dict[str, Any]]:
68
+ """Apply only matching local probe evidence; a changed fingerprint expires it."""
69
+ probes = ledger.get("probes", {})
70
+ enriched = []
71
+ for model in models:
72
+ copy = dict(model)
73
+ key = f"{copy.get('provider', 'openai_compatible')}:{copy['model']}"
74
+ probe = probes.get(key)
75
+ fingerprint = (copy.get("capabilities") or {}).get("fingerprint")
76
+ if (
77
+ probe
78
+ and probe.get("outcome") != "indeterminate"
79
+ and (
80
+ not probe.get("fingerprint") or probe.get("fingerprint") == fingerprint
81
+ )
82
+ ):
83
+ copy["catalog_type"] = probe["outcome"]
84
+ copy["catalog_confidence"] = "probe"
85
+ copy["capabilities"] = {
86
+ **(copy.get("capabilities") or {}),
87
+ **({"adapter": probe["adapter"]} if probe.get("adapter") else {}),
88
+ }
89
+ copy["capability_evidence"] = [
90
+ *(copy.get("capability_evidence") or []),
91
+ {
92
+ "source": "probe",
93
+ "confidence": "high",
94
+ "observed_at": probe["observed_at"],
95
+ },
96
+ ]
97
+ enriched.append(copy)
98
+ return enriched
llm_bench/catalog.py ADDED
@@ -0,0 +1,424 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+ from .client import PROVIDER_DEFAULTS
11
+ from .pricing import apply_public_pricing
12
+ from .security import require_http_url
13
+
14
+
15
+ _MAX_CATALOG_RESPONSE_BYTES = 8 * 1024 * 1024
16
+
17
+ _NON_CHAT_NAME_TYPES = {
18
+ "realtime": "realtime",
19
+ "transcribe": "audio",
20
+ "tts": "audio",
21
+ "audio": "audio",
22
+ "image": "image",
23
+ "video": "video",
24
+ "embed": "embedding",
25
+ "computer-use": "agent",
26
+ "robotics": "agent",
27
+ "multi-agent": "agent",
28
+ "omni": "media",
29
+ }
30
+
31
+
32
+ def classify_catalog_model(model: dict[str, Any]) -> dict[str, Any]:
33
+ """Attach a conservative benchmark category without guessing chat support."""
34
+ classified = dict(model)
35
+ if classified.get("catalog_type"):
36
+ classified.setdefault("catalog_confidence", "manual")
37
+ return classified
38
+ capabilities = classified.get("capabilities") or {}
39
+ name = str(classified.get("model", "")).casefold()
40
+ output = {
41
+ str(item).casefold() for item in capabilities.get("output_modalities") or []
42
+ }
43
+ if capabilities.get("text_generation") == "ready":
44
+ classified["catalog_type"] = "text-ready"
45
+ classified["catalog_confidence"] = "official"
46
+ return classified
47
+ named_type = next(
48
+ (kind for term, kind in _NON_CHAT_NAME_TYPES.items() if term in name), None
49
+ )
50
+ if named_type and not output:
51
+ classified["catalog_type"] = named_type
52
+ classified["catalog_confidence"] = "heuristic"
53
+ return classified
54
+ if capabilities.get("text_generation") == "candidate":
55
+ classified["catalog_type"] = "text-candidate"
56
+ classified["catalog_confidence"] = "official"
57
+ return classified
58
+ if output:
59
+ if "text" in output:
60
+ catalog_type = "text-chat"
61
+ elif "image" in output:
62
+ catalog_type = "image"
63
+ elif "audio" in output:
64
+ catalog_type = "audio"
65
+ elif "video" in output:
66
+ catalog_type = "video"
67
+ elif "embeddings" in output or "embedding" in output:
68
+ catalog_type = "embedding"
69
+ else:
70
+ catalog_type = "unknown"
71
+ confidence = "official"
72
+ else:
73
+ catalog_type = next(
74
+ (kind for term, kind in _NON_CHAT_NAME_TYPES.items() if term in name),
75
+ "unknown",
76
+ )
77
+ confidence = "heuristic" if catalog_type != "unknown" else "unknown"
78
+ classified["catalog_type"] = catalog_type
79
+ classified["catalog_confidence"] = confidence
80
+ return classified
81
+
82
+
83
+ def _get_json(
84
+ url: str, key_env: str | None, headers: dict[str, str] | None = None
85
+ ) -> dict[str, Any]:
86
+ require_http_url(url)
87
+ key = os.environ.get(key_env) if key_env else None
88
+ if key_env and not key:
89
+ raise ValueError(f"environment variable {key_env!r} is not set")
90
+ request_headers = dict(headers or {})
91
+ if key:
92
+ request_headers.setdefault("Authorization", f"Bearer {key}")
93
+ request = urllib.request.Request(url, headers=request_headers)
94
+ with urllib.request.urlopen(request, timeout=30) as response: # nosec B310
95
+ body = response.read(_MAX_CATALOG_RESPONSE_BYTES + 1)
96
+ if len(body) > _MAX_CATALOG_RESPONSE_BYTES:
97
+ raise ValueError("catalog response exceeded the 8 MiB safety limit")
98
+ return json.loads(body)
99
+
100
+
101
+ def _base(source: dict[str, Any]) -> dict[str, Any]:
102
+ provider = source["provider"]
103
+ return {**PROVIDER_DEFAULTS.get(provider, {}), **source}
104
+
105
+
106
+ def _openai(source: dict[str, Any]) -> list[dict[str, Any]]:
107
+ config = _base(source)
108
+ payload = _get_json(
109
+ config["base_url"].rstrip("/") + "/models",
110
+ config.get("api_key_env"),
111
+ config.get("headers"),
112
+ )
113
+ return [
114
+ {
115
+ "name": item["id"],
116
+ "provider": source["provider"],
117
+ "model": item["id"],
118
+ "created": item.get("created"),
119
+ "owned_by": item.get("owned_by"),
120
+ "capabilities": {
121
+ "reasoning": None,
122
+ **_openai_capabilities(item["id"]),
123
+ },
124
+ "capability_evidence": [{"source": "official-id", "confidence": "low"}],
125
+ "catalog_metadata": item,
126
+ }
127
+ for item in payload.get("data", [])
128
+ ]
129
+
130
+
131
+ def _openai_capabilities(model_id: str) -> dict[str, Any]:
132
+ name = model_id.casefold()
133
+ if name.startswith("gpt-") and not any(
134
+ term in name for term in ("realtime", "audio", "image", "transcribe", "tts")
135
+ ):
136
+ return {
137
+ "text_generation": "candidate",
138
+ "adapter": "openai_responses",
139
+ }
140
+ return {}
141
+
142
+
143
+ def _anthropic(source: dict[str, Any]) -> list[dict[str, Any]]:
144
+ config = _base(source)
145
+ headers = {
146
+ "x-api-key": os.environ.get(config.get("api_key_env", ""), ""),
147
+ "anthropic-version": config.get("api_version", "2023-06-01"),
148
+ **config.get("headers", {}),
149
+ }
150
+ payload = _get_json(
151
+ config["base_url"].rstrip("/") + "/models?limit=1000",
152
+ config.get("api_key_env"),
153
+ headers,
154
+ )
155
+ return [
156
+ {
157
+ "name": item.get("display_name", item["id"]),
158
+ "provider": "anthropic",
159
+ "model": item["id"],
160
+ "created": item.get("created_at"),
161
+ "capabilities": {
162
+ **(item.get("capabilities") or {}),
163
+ "reasoning": (item.get("capabilities") or {}).get("thinking"),
164
+ "text_generation": "ready",
165
+ "adapter": "anthropic_messages",
166
+ },
167
+ "capability_evidence": [{"source": "official", "confidence": "high"}],
168
+ "catalog_metadata": item,
169
+ }
170
+ for item in payload.get("data", [])
171
+ ]
172
+
173
+
174
+ def _gemini(source: dict[str, Any]) -> list[dict[str, Any]]:
175
+ config = _base(source)
176
+ key = os.environ.get(config.get("api_key_env", ""))
177
+ if not key:
178
+ raise ValueError(
179
+ f"environment variable {config.get('api_key_env')!r} is not set"
180
+ )
181
+ url = config["base_url"].rstrip("/") + "/models?pageSize=1000"
182
+ payload = _get_json(url, None, {"x-goog-api-key": key, **config.get("headers", {})})
183
+ result = []
184
+ for item in payload.get("models", []):
185
+ methods = item.get("supportedGenerationMethods", [])
186
+ if "generateContent" not in methods:
187
+ continue
188
+ model_id = item["name"].removeprefix("models/")
189
+ result.append(
190
+ {
191
+ "name": item.get("displayName", model_id),
192
+ "provider": "gemini",
193
+ "model": model_id,
194
+ "context_length": item.get("inputTokenLimit"),
195
+ "max_output_tokens": item.get("outputTokenLimit"),
196
+ "capabilities": {
197
+ "reasoning": item.get("thinking"),
198
+ "methods": methods,
199
+ "temperature": item.get("temperature") is not None,
200
+ "max_temperature": item.get("maxTemperature"),
201
+ "text_generation": "candidate",
202
+ "adapter": "gemini_generate_content",
203
+ },
204
+ "capability_evidence": [{"source": "official", "confidence": "high"}],
205
+ "catalog_metadata": item,
206
+ }
207
+ )
208
+ return result
209
+
210
+
211
+ def _openrouter(source: dict[str, Any]) -> list[dict[str, Any]]:
212
+ config = _base(source)
213
+ query = {"output_modalities": source.get("output_modalities", "text")}
214
+ if source.get("sort"):
215
+ query["sort"] = source["sort"]
216
+ if source.get("require_parameters"):
217
+ query["supported_parameters"] = ",".join(source["require_parameters"])
218
+ url = (
219
+ config["base_url"].rstrip("/")
220
+ + "/models?"
221
+ + urllib.parse.urlencode(query, doseq=True)
222
+ )
223
+ payload = _get_json(url, config.get("api_key_env"), config.get("headers"))
224
+ result = []
225
+ for item in payload.get("data", []):
226
+ parameters = item.get("supported_parameters") or []
227
+ pricing = item.get("pricing") or {}
228
+ prompt_price = _number(pricing.get("prompt"))
229
+ completion_price = _number(pricing.get("completion"))
230
+ architecture = item.get("architecture") or {}
231
+ model = {
232
+ "name": item.get("name", item["id"]),
233
+ "provider": "openrouter",
234
+ "model": item["id"],
235
+ "created": item.get("created"),
236
+ "context_length": item.get("context_length"),
237
+ "capabilities": {
238
+ "reasoning": "reasoning" in parameters,
239
+ "structured_outputs": "structured_outputs" in parameters,
240
+ "tools": "tools" in parameters,
241
+ "input_modalities": architecture.get("input_modalities"),
242
+ "output_modalities": architecture.get("output_modalities"),
243
+ "supported_parameters": parameters,
244
+ "temperature": "temperature" in parameters,
245
+ "text_generation": "ready"
246
+ if "text" in (architecture.get("output_modalities") or [])
247
+ else None,
248
+ "adapter": "openrouter_chat",
249
+ },
250
+ "capability_evidence": [{"source": "aggregator", "confidence": "high"}],
251
+ "catalog_metadata": item,
252
+ }
253
+ if prompt_price is not None:
254
+ model["input_cost_per_million"] = prompt_price * 1_000_000
255
+ if completion_price is not None:
256
+ model["output_cost_per_million"] = completion_price * 1_000_000
257
+ if prompt_price is not None and completion_price is not None:
258
+ model["pricing_metadata"] = {
259
+ "source": "openrouter routed",
260
+ "confidence": "authoritative",
261
+ }
262
+ result.append(model)
263
+ return result
264
+
265
+
266
+ def _xai(source: dict[str, Any]) -> list[dict[str, Any]]:
267
+ config = _base(source)
268
+ result = []
269
+ for endpoint, output_type in (
270
+ ("language-models", None),
271
+ ("image-generation-models", "image"),
272
+ ("video-generation-models", "video"),
273
+ ):
274
+ payload = _get_json(
275
+ config["base_url"].rstrip("/") + f"/{endpoint}",
276
+ config.get("api_key_env"),
277
+ config.get("headers"),
278
+ )
279
+ for item in payload.get("models", []):
280
+ result.append(
281
+ {
282
+ "name": item["id"],
283
+ "provider": "xai",
284
+ "model": item["id"],
285
+ "created": item.get("created"),
286
+ "capabilities": {
287
+ "input_modalities": item.get("input_modalities"),
288
+ "output_modalities": item.get("output_modalities")
289
+ or ([output_type] if output_type else None),
290
+ "fingerprint": item.get("fingerprint"),
291
+ "aliases": item.get("aliases", []),
292
+ "text_generation": "ready"
293
+ if "text" in (item.get("output_modalities") or [])
294
+ and not output_type
295
+ else None,
296
+ "adapter": "xai_chat",
297
+ },
298
+ "capability_evidence": [
299
+ {"source": "official", "confidence": "high"}
300
+ ],
301
+ "catalog_metadata": item,
302
+ }
303
+ )
304
+ return result
305
+
306
+
307
+ def _number(value: Any) -> float | None:
308
+ try:
309
+ return float(value)
310
+ except (TypeError, ValueError):
311
+ return None
312
+
313
+
314
+ DISCOVERERS = {
315
+ "openai": _openai,
316
+ "openai_compatible": _openai,
317
+ "xai": _xai,
318
+ "anthropic": _anthropic,
319
+ "gemini": _gemini,
320
+ "openrouter": _openrouter,
321
+ }
322
+
323
+
324
+ def discover_models(source: dict[str, Any]) -> list[dict[str, Any]]:
325
+ provider = source.get("provider")
326
+ if provider not in DISCOVERERS:
327
+ raise ValueError(f"catalog discovery is unsupported for provider {provider!r}")
328
+ if "limit" not in source or int(source["limit"]) < 1:
329
+ raise ValueError("every discovery source requires a positive 'limit'")
330
+ models = DISCOVERERS[provider](source)
331
+ include = source.get("include")
332
+ exclude = source.get("exclude")
333
+ if include:
334
+ models = [model for model in models if re.search(include, model["model"], re.I)]
335
+ if exclude:
336
+ models = [
337
+ model for model in models if not re.search(exclude, model["model"], re.I)
338
+ ]
339
+ if not source.get("sort"):
340
+ models.sort(key=lambda model: str(model.get("created") or ""), reverse=True)
341
+ inherited: dict[str, Any] = {
342
+ key: source[key]
343
+ for key in (
344
+ "base_url",
345
+ "api_key_env",
346
+ "headers",
347
+ "api_version",
348
+ )
349
+ if key in source
350
+ }
351
+ return [
352
+ apply_public_pricing(classify_catalog_model({**model, **inherited}))
353
+ for model in models[: int(source["limit"])]
354
+ ]
355
+
356
+
357
+ def resolve_models(config: dict[str, Any]) -> list[dict[str, Any]]:
358
+ models = list(config.get("models", []))
359
+ for source in config.get("discovery", []):
360
+ models.extend(discover_models(source))
361
+ seen: set[tuple[str, str]] = set()
362
+ unique = []
363
+ for model in models:
364
+ identity = (model.get("provider", "openai_compatible"), model["model"])
365
+ if identity not in seen:
366
+ seen.add(identity)
367
+ unique.append(apply_public_pricing(classify_catalog_model(model)))
368
+ return _enrich_from_openrouter(unique)
369
+
370
+
371
+ def _enrich_from_openrouter(models: list[dict[str, Any]]) -> list[dict[str, Any]]:
372
+ """Use OpenRouter modality metadata as labelled enrichment for direct models."""
373
+ aliases = {"xai": "x-ai", "gemini": "google"}
374
+ router_models = {
375
+ tuple(str(item["model"]).split("/", 1)): item
376
+ for item in models
377
+ if item.get("provider") == "openrouter" and "/" in str(item["model"])
378
+ }
379
+ normalized_router_models = {
380
+ (author, _normalized_model_id(model_id)): item
381
+ for (author, model_id), item in router_models.items()
382
+ }
383
+ enriched = []
384
+ for model in models:
385
+ provider = model.get("provider", "openai_compatible")
386
+ author = aliases.get(provider, provider)
387
+ candidate = router_models.get((author, str(model["model"])))
388
+ match_source = "openrouter-exact"
389
+ if candidate is None:
390
+ candidate = normalized_router_models.get(
391
+ (author, _normalized_model_id(str(model["model"])))
392
+ )
393
+ match_source = "openrouter-normalized"
394
+ if provider == "openrouter" or candidate is None:
395
+ enriched.append(model)
396
+ continue
397
+ router_capabilities = candidate.get("capabilities") or {}
398
+ if not router_capabilities.get("output_modalities"):
399
+ enriched.append(model)
400
+ continue
401
+ merged = dict(model)
402
+ merged["capabilities"] = {
403
+ **(model.get("capabilities") or {}),
404
+ **{
405
+ key: value
406
+ for key, value in router_capabilities.items()
407
+ if value is not None
408
+ },
409
+ }
410
+ merged = classify_catalog_model(
411
+ {key: value for key, value in merged.items() if key != "catalog_type"}
412
+ )
413
+ merged["catalog_confidence"] = "aggregator"
414
+ merged["capability_evidence"] = [
415
+ *(model.get("capability_evidence") or []),
416
+ {"source": match_source, "confidence": "medium"},
417
+ ]
418
+ enriched.append(merged)
419
+ return enriched
420
+
421
+
422
+ def _normalized_model_id(model_id: str) -> str:
423
+ """Normalize provider spelling variants without fuzzy family matching."""
424
+ return re.sub(r"(?<=\d)[.-](?=\d)", "", model_id.casefold())
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .capability_ledger import record_probe
7
+ from .client import create_client
8
+
9
+
10
+ def probe_model(
11
+ model: dict[str, Any], ledger_path: Path, timeout: float = 30
12
+ ) -> dict[str, Any]:
13
+ """Make one minimal provider-native request and persist only safe evidence."""
14
+ result = create_client(model, timeout).run(
15
+ "Reply with OK.",
16
+ {"max_output_tokens": 32, "retry": {"max_attempts": 1}},
17
+ )
18
+ if result["ok"]:
19
+ outcome = "text-ready"
20
+ error_category = None
21
+ else:
22
+ raw_category = result.get("failure_category")
23
+ error_category = str(raw_category) if raw_category else None
24
+ if error_category == "not_found":
25
+ outcome = "unavailable"
26
+ elif error_category == "unsupported_parameter":
27
+ outcome = "text-special"
28
+ else:
29
+ outcome = "indeterminate"
30
+ probe = {
31
+ "provider": model.get("provider", "openai_compatible"),
32
+ "model": model["model"],
33
+ "adapter": (model.get("capabilities") or {}).get("adapter"),
34
+ "outcome": outcome,
35
+ "fingerprint": (model.get("capabilities") or {}).get("fingerprint"),
36
+ "request_options": {"max_output_tokens": 32},
37
+ "error_category": error_category,
38
+ }
39
+ record_probe(ledger_path, probe)
40
+ return probe