modelroster 0.1.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.
Files changed (39) hide show
  1. modelroster/__init__.py +144 -0
  2. modelroster/_version.py +1 -0
  3. modelroster/cli.py +383 -0
  4. modelroster/data/anthropic.json +2175 -0
  5. modelroster/data/inception.json +145 -0
  6. modelroster/data/nvidia.json +7873 -0
  7. modelroster/data/openai.json +63047 -0
  8. modelroster/discover/__init__.py +44 -0
  9. modelroster/discover/base.py +24 -0
  10. modelroster/discover/huggingface.py +56 -0
  11. modelroster/discover/nvidia_nim.py +67 -0
  12. modelroster/discover/ollama_library.py +39 -0
  13. modelroster/emit.py +121 -0
  14. modelroster/http.py +293 -0
  15. modelroster/providers/__init__.py +88 -0
  16. modelroster/providers/anthropic.py +168 -0
  17. modelroster/providers/base.py +108 -0
  18. modelroster/providers/cohere.py +89 -0
  19. modelroster/providers/google.py +99 -0
  20. modelroster/providers/inception.py +69 -0
  21. modelroster/providers/mistral.py +66 -0
  22. modelroster/providers/nvidia.py +20 -0
  23. modelroster/providers/ollama.py +109 -0
  24. modelroster/providers/openai.py +341 -0
  25. modelroster/providers/openai_compat.py +98 -0
  26. modelroster/providers/openai_docs.py +405 -0
  27. modelroster/providers/xai.py +68 -0
  28. modelroster/py.typed +0 -0
  29. modelroster/ref.py +164 -0
  30. modelroster/registry.py +283 -0
  31. modelroster/schema.py +247 -0
  32. modelroster/store.py +87 -0
  33. modelroster/update.py +201 -0
  34. modelroster/validate.py +168 -0
  35. modelroster-0.1.0.dist-info/METADATA +258 -0
  36. modelroster-0.1.0.dist-info/RECORD +39 -0
  37. modelroster-0.1.0.dist-info/WHEEL +4 -0
  38. modelroster-0.1.0.dist-info/entry_points.txt +2 -0
  39. modelroster-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,144 @@
1
+ """
2
+ modelroster — accurate, current LLM model identifiers and capabilities for
3
+ every provider, shipped as data, refreshed from official sources.
4
+
5
+ import modelroster
6
+ r = modelroster.load()
7
+ r.models(tool_calling=True, reasoning=True)
8
+ modelroster.ModelRef.parse("openai/gpt-5.4").validate()
9
+ modelroster.context_window("claude-opus-5")
10
+
11
+ Every capability is tri-state: True / False / None, where None means "the
12
+ source does not say" — never "no".
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from ._version import __version__
20
+ from .ref import AmbiguousModelError, ModelRef, RetiredModelError, UnknownModelError, is_retired
21
+ from .registry import Registry, clear_cache, load
22
+ from .schema import CAPABILITY_FIELDS, PARSER_VERSION, SCHEMA_VERSION, Capabilities, ModelRecord
23
+
24
+
25
+ def _rec(model: str | ModelRef | ModelRecord, provider: str | None = None) -> ModelRecord | None:
26
+ if isinstance(model, ModelRecord):
27
+ return model
28
+ try:
29
+ return load().get(model, provider)
30
+ except FileNotFoundError:
31
+ return None
32
+
33
+
34
+ def get(model: str | ModelRef, provider: str | None = None) -> ModelRecord | None:
35
+ """Exact-id lookup across the shipped registry."""
36
+ return _rec(model, provider)
37
+
38
+
39
+ def supports(model: str | ModelRef | ModelRecord, capability: str, provider: str | None = None) -> bool | None:
40
+ """Tri-state: True / False / None (unknown model or undocumented capability)."""
41
+ rec = _rec(model, provider)
42
+ return None if rec is None else rec.supports(capability)
43
+
44
+
45
+ def supports_tool_calling(model, provider=None):
46
+ return supports(model, "tool_calling", provider)
47
+
48
+
49
+ def supports_reasoning(model, provider=None):
50
+ return supports(model, "reasoning", provider)
51
+
52
+
53
+ def supports_structured_outputs(model, provider=None):
54
+ return supports(model, "structured_outputs", provider)
55
+
56
+
57
+ def supports_streaming(model, provider=None):
58
+ return supports(model, "streaming", provider)
59
+
60
+
61
+ def supported_reasoning_efforts(model, provider=None) -> list[str] | None:
62
+ rec = _rec(model, provider)
63
+ return None if rec is None else rec.capabilities.reasoning_efforts
64
+
65
+
66
+ def supports_endpoint(model, endpoint: str, provider=None) -> bool | None:
67
+ rec = _rec(model, provider)
68
+ return None if rec is None else rec.endpoints.get(endpoint)
69
+
70
+
71
+ def supports_builtin_tool(model, tool: str, provider=None) -> bool | None:
72
+ rec = _rec(model, provider)
73
+ if rec is None or rec.builtin_tools is None:
74
+ return None
75
+ return rec.builtin_tools.get(tool)
76
+
77
+
78
+ def supports_modality(model, modality: str, direction: str = "input", provider=None) -> bool | None:
79
+ rec = _rec(model, provider)
80
+ return None if rec is None else rec.modality(modality, direction)
81
+
82
+
83
+ def context_window(model, provider=None) -> int | None:
84
+ rec = _rec(model, provider)
85
+ return None if rec is None else rec.context_window
86
+
87
+
88
+ def max_output_tokens(model, provider=None) -> int | None:
89
+ rec = _rec(model, provider)
90
+ return None if rec is None else rec.max_output_tokens
91
+
92
+
93
+ def max_input_tokens(model, provider=None) -> int | None:
94
+ rec = _rec(model, provider)
95
+ return None if rec is None else rec.max_input_tokens
96
+
97
+
98
+ def models_supporting(capability: str, provider: str | None = None, **kw: Any) -> list[str]:
99
+ """Ids whose source documents support for `capability` (None never counts)."""
100
+ return load().ids(provider, **{capability: True}, **kw)
101
+
102
+
103
+ def models_supporting_tool_calling(provider=None):
104
+ return models_supporting("tool_calling", provider)
105
+
106
+
107
+ def models_supporting_reasoning(provider=None):
108
+ return models_supporting("reasoning", provider)
109
+
110
+
111
+ def models_supporting_endpoint(endpoint: str, provider=None) -> list[str]:
112
+ return load().ids(provider, endpoint=endpoint)
113
+
114
+
115
+ def models_supporting_builtin_tool(tool: str, provider=None) -> list[str]:
116
+ return load().ids(provider, builtin_tool=tool)
117
+
118
+
119
+ def available_models(provider: str | None = None) -> list[str]:
120
+ return load().ids(provider)
121
+
122
+
123
+ def info(provider: str | None = None):
124
+ return load().info(provider)
125
+
126
+
127
+ def refresh(providers: list[str] | None = None, **kw: Any) -> dict[str, dict]:
128
+ """Refresh registry data from the providers' official sources; returns per-provider drift/status."""
129
+ from .update import refresh as _refresh
130
+ out = _refresh(providers, **kw)
131
+ clear_cache()
132
+ return out
133
+
134
+
135
+ __all__ = [
136
+ "__version__", "PARSER_VERSION", "SCHEMA_VERSION", "CAPABILITY_FIELDS",
137
+ "Registry", "ModelRecord", "Capabilities", "ModelRef", "load", "clear_cache", "get",
138
+ "UnknownModelError", "RetiredModelError", "AmbiguousModelError", "is_retired",
139
+ "supports", "supports_tool_calling", "supports_reasoning", "supports_structured_outputs",
140
+ "supports_streaming", "supported_reasoning_efforts", "supports_endpoint", "supports_builtin_tool",
141
+ "supports_modality", "context_window", "max_output_tokens", "max_input_tokens",
142
+ "models_supporting", "models_supporting_tool_calling", "models_supporting_reasoning",
143
+ "models_supporting_endpoint", "models_supporting_builtin_tool", "available_models", "info", "refresh",
144
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
modelroster/cli.py ADDED
@@ -0,0 +1,383 @@
1
+ """
2
+ modelroster command-line interface.
3
+
4
+ modelroster update [--provider X ...|--all] [--offline] [--dry-run] [--data-dir D] [--fixtures DIR]
5
+ modelroster list [--provider X] [--capability reasoning --capability tool_calling ...] [--json]
6
+ modelroster show <model_id|provider/model_id> [--json]
7
+ modelroster diff [--provider X]
8
+ modelroster validate [--provider X] [--data-dir D]
9
+ modelroster emit --out FILE [--provider X ...] [--capability ...]
10
+ modelroster discover <source> [--limit N] [--json]
11
+ modelroster providers
12
+ modelroster capture --provider X --out DIR # save live listing responses as fixtures
13
+
14
+ Exit status: 0 ok · 2 validation refused (previous data preserved) · 3 fetch
15
+ failure · 4 usage error. One provider's failure never blocks the others; the
16
+ exit status is the worst stage.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from . import __version__, providers as provider_registry
28
+ from .ref import ModelRef
29
+ from .registry import load
30
+ from .schema import CAPABILITY_FIELDS
31
+ from .store import available_providers, data_dir as resolve_data_dir, drift_path, read_json, registry_path
32
+ from .update import EXIT_OK, EXIT_REFUSED, EXIT_USAGE, load_dotenv_if_available, run_provider, worst_code
33
+ from .validate import format_drift, validate
34
+
35
+
36
+ def _parse_capability_filters(values: list[str] | None) -> dict[str, Any]:
37
+ """['reasoning', 'tool_calling=true', 'streaming=false', 'batch=unknown'] -> filters."""
38
+ out: dict[str, Any] = {}
39
+ for v in values or []:
40
+ name, _, val = v.partition("=")
41
+ val = val.strip().lower()
42
+ if val in ("", "true", "yes", "1"):
43
+ out[name.strip()] = True
44
+ elif val in ("false", "no", "0"):
45
+ out[name.strip()] = False
46
+ elif val in ("none", "unknown", "null"):
47
+ out[name.strip()] = None
48
+ else:
49
+ raise argparse.ArgumentTypeError("capability filter %r must be name[=true|false|unknown]" % v)
50
+ return out
51
+
52
+
53
+ def _tri(v: Any) -> str:
54
+ return "unknown" if v is None else ("yes" if v is True else ("no" if v is False else str(v)))
55
+
56
+
57
+ def cmd_update(args: argparse.Namespace) -> int:
58
+ load_dotenv_if_available()
59
+ names = args.provider or provider_registry.names()
60
+ results = []
61
+ for n in names:
62
+ results.append(run_provider(n, data_dir=args.data_dir, offline=args.offline, no_cache=args.no_cache,
63
+ dry_run=args.dry_run, fixtures_root=args.fixtures, quiet=args.quiet))
64
+ print()
65
+ for r in results:
66
+ print(r.summary())
67
+ code = worst_code(results)
68
+ if args.emit and code == EXIT_OK and not args.dry_run:
69
+ from .emit import emit
70
+ emit(load(data_dir=args.data_dir, force=True), args.emit)
71
+ print("snapshot module written -> %s" % args.emit)
72
+ return code
73
+
74
+
75
+ def cmd_list(args: argparse.Namespace) -> int:
76
+ filters = _parse_capability_filters(args.capability)
77
+ reg = load(args.provider, data_dir=args.data_dir) if args.provider else load(data_dir=args.data_dir)
78
+ tier = None if args.all_tiers else ("discovered" if args.discovered else "verified")
79
+ recs = reg.models(args.provider, unknown_ok=args.unknown_ok, include_retired=not args.exclude_retired,
80
+ tier=tier, endpoint=args.endpoint, **filters)
81
+ if args.json:
82
+ print(json.dumps([r.to_dict() for r in recs], indent=1))
83
+ return EXIT_OK
84
+ if not recs:
85
+ print("no models match", file=sys.stderr)
86
+ return EXIT_OK
87
+ width = max(len(r.model_id) for r in recs)
88
+ print("%-10s %-*s %-9s %-9s %-9s %-9s %-9s %s" % ("provider", width, "model_id", "reason", "tools", "struct", "stream", "context", "rel"))
89
+ for r in recs:
90
+ c = r.capabilities
91
+ print("%-10s %-*s %-9s %-9s %-9s %-9s %-9s %s" % (
92
+ r.provider, width, r.model_id, _tri(c.reasoning), _tri(c.tool_calling), _tri(c.structured_outputs),
93
+ _tri(c.streaming), r.context_window if r.context_window is not None else "unknown", r.relationship))
94
+ print("\n%d model(s)" % len(recs))
95
+ return EXIT_OK
96
+
97
+
98
+ def cmd_show(args: argparse.Namespace) -> int:
99
+ reg = load(data_dir=args.data_dir)
100
+ try:
101
+ ref = ModelRef.parse(args.model, registry=reg)
102
+ except LookupError as exc:
103
+ print("error: %s" % exc, file=sys.stderr)
104
+ return EXIT_USAGE
105
+ rec = reg.get(ref)
106
+ if rec is None:
107
+ print("error: %s is not in the registry%s" % (ref, " (provider guessed from the id prefix)" if ref.inferred else ""), file=sys.stderr)
108
+ return EXIT_USAGE
109
+ if args.json:
110
+ print(json.dumps(rec.to_dict(), indent=1))
111
+ return EXIT_OK
112
+ print("%s/%s" % (rec.provider, rec.model_id))
113
+ for k in ("display_name", "description", "family", "relationship", "aliases", "snapshots", "default_snapshot",
114
+ "routes_to", "released", "deprecated", "shutdown_date", "context_window", "max_input_tokens",
115
+ "max_output_tokens", "knowledge_cutoff", "knowledge_cutoff_raw", "tier", "retrieved_at", "parser_version"):
116
+ v = getattr(rec, k)
117
+ if v not in (None, [], ""):
118
+ print(" %-20s %s" % (k + ":", v))
119
+ print(" capabilities:")
120
+ for k in CAPABILITY_FIELDS:
121
+ print(" %-20s %s" % (k + ":", _tri(rec.capabilities.get(k))))
122
+ if rec.capabilities.reasoning_efforts is not None:
123
+ print(" %-20s %s (default %s)" % ("reasoning_efforts:", ", ".join(rec.capabilities.reasoning_efforts), rec.capabilities.default_effort))
124
+ for k, v in rec.capabilities.extra.items():
125
+ print(" %-20s %s" % (k + ":", _tri(v)))
126
+ print(" modalities:")
127
+ for m, d in rec.modalities.items():
128
+ print(" %-20s in=%s out=%s" % (m + ":", _tri(d.get("input")), _tri(d.get("output"))))
129
+ if rec.endpoints:
130
+ print(" endpoints:")
131
+ for k, v in rec.endpoints.items():
132
+ print(" %-20s %s" % (k + ":", _tri(v)))
133
+ if rec.builtin_tools:
134
+ print(" builtin_tools:")
135
+ for k, v in rec.builtin_tools.items():
136
+ print(" %-20s %s" % (k + ":", _tri(v)))
137
+ if rec.pricing:
138
+ print(" pricing (USD / 1M tokens): input=%s output=%s cached_input=%s" % (
139
+ rec.pricing.get("input"), rec.pricing.get("output"), rec.pricing.get("cached_input")))
140
+ print(" sources:")
141
+ for k, v in rec.sources.items():
142
+ print(" %-20s %s" % (k + ":", v))
143
+ if args.provenance:
144
+ print(" provenance:")
145
+ for k, v in rec.provenance.items():
146
+ print(" %-28s %s" % (k + ":", json.dumps(v)))
147
+ if rec.warnings:
148
+ print(" warnings:")
149
+ for w in rec.warnings:
150
+ print(" - %s" % w)
151
+ return EXIT_OK
152
+
153
+
154
+ def cmd_diff(args: argparse.Namespace) -> int:
155
+ names = args.provider or available_providers(args.data_dir)
156
+ found = False
157
+ for n in names:
158
+ d = read_json(drift_path(n, args.data_dir))
159
+ if d is None:
160
+ print("%s: no drift report (run `modelroster update --provider %s`)" % (n, n))
161
+ continue
162
+ found = True
163
+ if args.json:
164
+ print(json.dumps(d, indent=1))
165
+ else:
166
+ print(format_drift(d))
167
+ print()
168
+ return EXIT_OK if found or not names else EXIT_USAGE
169
+
170
+
171
+ def cmd_validate(args: argparse.Namespace) -> int:
172
+ names = args.provider or available_providers(args.data_dir)
173
+ worst = EXIT_OK
174
+ for n in names:
175
+ cur = read_json(registry_path(n, args.data_dir))
176
+ if cur is None:
177
+ print("%s: no data" % n)
178
+ worst = max(worst, EXIT_USAGE)
179
+ continue
180
+ prev = read_json(registry_path(n, args.data_dir).with_name(n + ".previous.json"))
181
+ try:
182
+ prov = provider_registry.get(n)
183
+ except KeyError:
184
+ prov = None
185
+ errors, warnings = validate(cur, prev, prov)
186
+ n_models = len(cur.get("models") or {})
187
+ print("%s: %d model(s), retrieved %s, parser %s — %s" % (
188
+ n, n_models, cur.get("retrieved_at"), cur.get("parser_version"),
189
+ "OK" if not errors else "INVALID"))
190
+ for e in errors:
191
+ print(" ERROR: %s" % e)
192
+ if args.verbose:
193
+ for w in warnings:
194
+ print(" warning: %s" % w)
195
+ elif warnings:
196
+ print(" %d warning(s) (use -v to list)" % len(warnings))
197
+ if errors:
198
+ worst = max(worst, EXIT_REFUSED)
199
+ return worst
200
+
201
+
202
+ def cmd_emit(args: argparse.Namespace) -> int:
203
+ from .emit import emit
204
+ filters = _parse_capability_filters(args.capability)
205
+ reg = load(data_dir=args.data_dir)
206
+ try:
207
+ ns = emit(reg, args.out, args.provider, **filters)
208
+ except Exception as exc:
209
+ print("error: generated module does not compile: %s" % exc, file=sys.stderr)
210
+ return EXIT_REFUSED
211
+ print("wrote %s (%d models)" % (args.out, len(ns.get("MODELS") or {})))
212
+ return EXIT_OK
213
+
214
+
215
+ def cmd_discover(args: argparse.Namespace) -> int:
216
+ from . import discover
217
+ from .http import Fetcher, FixtureFetcher
218
+ from .store import write_json_atomic
219
+ from .update import build_envelope
220
+ from .providers.base import ProviderResult
221
+ src = discover.get(args.source)
222
+ if args.fixtures:
223
+ http = FixtureFetcher(src.fixtures(Path(args.fixtures)) or {})
224
+ else:
225
+ http = Fetcher(resolve_data_dir(args.data_dir) / "cache" / "discover" / src.name, offline=args.offline)
226
+ try:
227
+ recs = src.discover(http, limit=args.limit)
228
+ finally:
229
+ http.close()
230
+ if args.json:
231
+ print(json.dumps([r.to_dict() for r in recs], indent=1))
232
+ else:
233
+ for r in recs:
234
+ print("%-40s %s" % (r.model_id, r.display_name or ""))
235
+ print("\n%d candidate(s) from %s (tier: discovered, not verified)" % (len(recs), src.name))
236
+ if args.write:
237
+ env = build_envelope(src.name, ProviderResult(records=recs, sources={"listing": src.describe}))
238
+ env["tier"] = "discovered"
239
+ out = resolve_data_dir(args.data_dir) / "discovered" / (src.name + ".json")
240
+ write_json_atomic(out, env, keep_previous=False)
241
+ print("written -> %s" % out)
242
+ return EXIT_OK
243
+
244
+
245
+ def cmd_providers(args: argparse.Namespace) -> int:
246
+ have = set(available_providers(args.data_dir))
247
+ for n in provider_registry.names():
248
+ p = provider_registry.get(n)
249
+ env = read_json(registry_path(n, args.data_dir)) if n in have else None
250
+ print("%-10s auth=%-32s data=%s" % (
251
+ n, ",".join(p.auth) or "(none)",
252
+ "%d models @ %s" % (len(env.get("models") or {}), env.get("retrieved_at")) if env else "—"))
253
+ if args.verbose and getattr(p, "describe", ""):
254
+ print(" %s" % p.describe)
255
+ return EXIT_OK
256
+
257
+
258
+ def cmd_capture(args: argparse.Namespace) -> int:
259
+ """Fetch a provider's live source URLs and save them under --out using the fixture names."""
260
+ from .http import Fetcher
261
+ load_dotenv_if_available()
262
+ p = provider_registry.get(args.provider)
263
+ pages = p.fixtures(Path(args.out)) or {}
264
+ http = Fetcher(None)
265
+ code = EXIT_OK
266
+ try:
267
+ headers = {}
268
+ if hasattr(p, "headers"):
269
+ try:
270
+ headers = p.headers()
271
+ except Exception:
272
+ headers = {}
273
+ elif hasattr(p, "_headers"):
274
+ headers = p._headers()
275
+ for url, path in pages.items():
276
+ if "#" in url or "/api/docs/models/" in url:
277
+ continue # POST bodies and the per-page docs are not captured here
278
+ try:
279
+ text, _ = http.get_text(url, headers=headers, cache=False)
280
+ except Exception as exc:
281
+ print(" ! %s: %s" % (url, exc))
282
+ code = 3
283
+ continue
284
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
285
+ Path(path).write_text(text, "utf-8")
286
+ print(" %s -> %s" % (url, path))
287
+ finally:
288
+ http.close()
289
+ return code
290
+
291
+
292
+ def build_parser() -> argparse.ArgumentParser:
293
+ ap = argparse.ArgumentParser(prog="modelroster", description=__doc__.split("\n\n")[0])
294
+ ap.add_argument("--version", action="version", version="modelroster " + __version__)
295
+ ap.add_argument("--data-dir", default=None, help="registry data directory (default: package data, or $MODELROSTER_DATA_DIR)")
296
+ sub = ap.add_subparsers(dest="cmd", required=True)
297
+
298
+ u = sub.add_parser("update", help="refresh registry data from official sources")
299
+ u.add_argument("--provider", action="append", help="provider name (repeatable); default: all")
300
+ u.add_argument("--all", action="store_true", help="explicitly all providers (the default)")
301
+ u.add_argument("--offline", action="store_true", help="serve every request from the on-disk cache; never open a socket")
302
+ u.add_argument("--no-cache", action="store_true", help="ignore and do not write the HTTP cache")
303
+ u.add_argument("--dry-run", action="store_true", help="fetch, parse, validate, report; write nothing")
304
+ u.add_argument("--fixtures", default=None, help="replay captured fixtures from this directory instead of the network")
305
+ u.add_argument("--emit", default=None, help="also write a dependency-free snapshot module to this path")
306
+ u.add_argument("--quiet", "-q", action="store_true")
307
+ u.set_defaults(func=cmd_update)
308
+
309
+ l = sub.add_parser("list", help="list models, optionally filtered by capability")
310
+ l.add_argument("--provider", default=None)
311
+ l.add_argument("--capability", "-c", action="append", help="name[=true|false|unknown] (repeatable)")
312
+ l.add_argument("--endpoint", default=None, help="require this endpoint key to be supported")
313
+ l.add_argument("--unknown-ok", action="store_true", help="let undocumented (None) values pass True/False filters")
314
+ l.add_argument("--exclude-retired", action="store_true")
315
+ l.add_argument("--discovered", action="store_true", help="list the discovered tier instead of verified")
316
+ l.add_argument("--all-tiers", action="store_true")
317
+ l.add_argument("--json", action="store_true")
318
+ l.set_defaults(func=cmd_list)
319
+
320
+ s = sub.add_parser("show", help="show one model record")
321
+ s.add_argument("model")
322
+ s.add_argument("--json", action="store_true")
323
+ s.add_argument("--provenance", action="store_true", help="print the provenance of every field")
324
+ s.set_defaults(func=cmd_show)
325
+
326
+ d = sub.add_parser("diff", help="print the drift report of the last update")
327
+ d.add_argument("--provider", action="append")
328
+ d.add_argument("--json", action="store_true")
329
+ d.set_defaults(func=cmd_diff)
330
+
331
+ v = sub.add_parser("validate", help="re-run the validation gates on the stored data")
332
+ v.add_argument("--provider", action="append")
333
+ v.add_argument("-v", "--verbose", action="store_true")
334
+ v.set_defaults(func=cmd_validate)
335
+
336
+ e = sub.add_parser("emit", help="write a dependency-free Python snapshot module")
337
+ e.add_argument("--out", required=True)
338
+ e.add_argument("--provider", action="append")
339
+ e.add_argument("--capability", "-c", action="append")
340
+ e.set_defaults(func=cmd_emit)
341
+
342
+ di = sub.add_parser("discover", help="scan a broad registry for candidate models (discovered tier)")
343
+ di.add_argument("source", help="huggingface | ollama_library | nvidia_nim")
344
+ di.add_argument("--limit", type=int, default=None)
345
+ di.add_argument("--offline", action="store_true")
346
+ di.add_argument("--fixtures", default=None)
347
+ di.add_argument("--write", action="store_true", help="write <data-dir>/discovered/<source>.json")
348
+ di.add_argument("--json", action="store_true")
349
+ di.set_defaults(func=cmd_discover)
350
+
351
+ pr = sub.add_parser("providers", help="list known providers and their data status")
352
+ pr.add_argument("-v", "--verbose", action="store_true")
353
+ pr.set_defaults(func=cmd_providers)
354
+
355
+ c = sub.add_parser("capture", help="save a provider's live listing responses as test fixtures")
356
+ c.add_argument("--provider", required=True)
357
+ c.add_argument("--out", default="tests/fixtures")
358
+ c.set_defaults(func=cmd_capture)
359
+ return ap
360
+
361
+
362
+ def main(argv: list[str] | None = None) -> int:
363
+ ap = build_parser()
364
+ try:
365
+ args = ap.parse_args(argv)
366
+ except SystemExit as exc: # argparse uses 2 for usage errors; our contract says 4
367
+ code = exc.code if isinstance(exc.code, int) else 0
368
+ return EXIT_USAGE if code == 2 else code
369
+ try:
370
+ return args.func(args)
371
+ except FileNotFoundError as exc:
372
+ print("error: %s" % exc, file=sys.stderr)
373
+ return EXIT_USAGE
374
+ except argparse.ArgumentTypeError as exc:
375
+ print("error: %s" % exc, file=sys.stderr)
376
+ return EXIT_USAGE
377
+ except KeyError as exc:
378
+ print("error: %s" % exc.args[0], file=sys.stderr)
379
+ return EXIT_USAGE
380
+
381
+
382
+ if __name__ == "__main__": # pragma: no cover
383
+ sys.exit(main())