llmsetup 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 (84) hide show
  1. llmsetup/__main__.py +8 -0
  2. llmsetup/bench/__init__.py +27 -0
  3. llmsetup/bench/sweep.py +456 -0
  4. llmsetup/catalog/__init__.py +5 -0
  5. llmsetup/catalog/autoupdate.py +285 -0
  6. llmsetup/catalog/catalog.py +389 -0
  7. llmsetup/catalog/data/models.yaml +1323 -0
  8. llmsetup/catalog/discover.py +497 -0
  9. llmsetup/catalog/refresh.py +308 -0
  10. llmsetup/cli.py +835 -0
  11. llmsetup/download.py +415 -0
  12. llmsetup/gguf.py +347 -0
  13. llmsetup/hardware/__init__.py +5 -0
  14. llmsetup/hardware/bandwidth.py +150 -0
  15. llmsetup/hardware/cpu.py +178 -0
  16. llmsetup/hardware/detect.py +172 -0
  17. llmsetup/hardware/gpu.py +279 -0
  18. llmsetup/interop/__init__.py +5 -0
  19. llmsetup/interop/export.py +142 -0
  20. llmsetup/interop/jan.py +74 -0
  21. llmsetup/interop/llama_swap.py +86 -0
  22. llmsetup/interop/lmstudio.py +107 -0
  23. llmsetup/interop/ollama.py +90 -0
  24. llmsetup/interop/openai_clients.py +161 -0
  25. llmsetup/interview/__init__.py +5 -0
  26. llmsetup/interview/questions.py +213 -0
  27. llmsetup/interview/wizard.py +359 -0
  28. llmsetup/pipeline.py +773 -0
  29. llmsetup/planner/__init__.py +6 -0
  30. llmsetup/planner/memory.py +384 -0
  31. llmsetup/planner/model_fit.py +186 -0
  32. llmsetup/planner/moe.py +254 -0
  33. llmsetup/planner/plan.py +483 -0
  34. llmsetup/planner/requested.py +209 -0
  35. llmsetup/planner/score.py +174 -0
  36. llmsetup/repair/__init__.py +6 -0
  37. llmsetup/repair/loop.py +192 -0
  38. llmsetup/repair/strategies.py +795 -0
  39. llmsetup/report/__init__.py +5 -0
  40. llmsetup/report/render.py +525 -0
  41. llmsetup/runtimes/__init__.py +14 -0
  42. llmsetup/runtimes/base.py +168 -0
  43. llmsetup/runtimes/capabilities.py +94 -0
  44. llmsetup/runtimes/data/llamacpp_flags.py +422 -0
  45. llmsetup/runtimes/dependencies.py +50 -0
  46. llmsetup/runtimes/gguf_store.py +171 -0
  47. llmsetup/runtimes/install.py +230 -0
  48. llmsetup/runtimes/llamacpp.py +381 -0
  49. llmsetup/runtimes/lmstudio.py +138 -0
  50. llmsetup/runtimes/mlx.py +119 -0
  51. llmsetup/runtimes/ollama.py +223 -0
  52. llmsetup/runtimes/registry.py +215 -0
  53. llmsetup/runtimes/tabby.py +113 -0
  54. llmsetup/runtimes/vllm.py +199 -0
  55. llmsetup/tuning/__init__.py +5 -0
  56. llmsetup/tuning/advanced.py +653 -0
  57. llmsetup/tuning/platform_tuning.py +276 -0
  58. llmsetup/tuning/profiles.py +387 -0
  59. llmsetup/tuning/publisher.py +135 -0
  60. llmsetup/tuning/sampling.py +227 -0
  61. llmsetup/tuning/tuner.py +783 -0
  62. llmsetup/types.py +680 -0
  63. llmsetup/ui/__init__.py +5 -0
  64. llmsetup/ui/server.py +345 -0
  65. llmsetup/ui/static/index.html +449 -0
  66. llmsetup/util/__init__.py +1 -0
  67. llmsetup/util/errors.py +45 -0
  68. llmsetup/util/fs.py +159 -0
  69. llmsetup/util/http.py +140 -0
  70. llmsetup/util/log.py +78 -0
  71. llmsetup/util/proc.py +147 -0
  72. llmsetup/util/units.py +61 -0
  73. llmsetup/verify/__init__.py +5 -0
  74. llmsetup/verify/checks.py +1041 -0
  75. llmsetup/verify/client.py +257 -0
  76. llmsetup/verify/harness.py +182 -0
  77. llmsetup/verify/signatures.py +125 -0
  78. llmsetup-0.1.0.dist-info/METADATA +291 -0
  79. llmsetup-0.1.0.dist-info/RECORD +84 -0
  80. llmsetup-0.1.0.dist-info/WHEEL +5 -0
  81. llmsetup-0.1.0.dist-info/entry_points.txt +2 -0
  82. llmsetup-0.1.0.dist-info/licenses/LICENSE +202 -0
  83. llmsetup-0.1.0.dist-info/licenses/NOTICE +7 -0
  84. llmsetup-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,285 @@
1
+ """Keeping an installed copy's catalog current, without anyone pushing anything.
2
+
3
+ The shipped catalog is a snapshot. This module lets an installation pull a
4
+ newer one on a cadence, so a model released after install still shows up.
5
+
6
+ Four rules, because an auto-updater that misbehaves is worse than none:
7
+
8
+ 1. **Fail open.** Any failure at all (offline, DNS, a 500, malformed YAML) leaves the
9
+ bundled catalog in place and the run continues. A model picker must not stop
10
+ working because a refresh server is down.
11
+ 2. **Never block the user.** One short timeout, checked at most once per
12
+ interval against a local stamp file. No retries, no spinner, no waiting.
13
+ 3. **Validated before adoption.** A downloaded catalog is parsed and
14
+ schema-checked in a temporary location first; it only replaces the cached
15
+ copy if it loads cleanly. A broken update can never brick the install.
16
+ 4. **Opt-out, and off by default in the paths that must be deterministic.**
17
+ ``LLMSETUP_AUTO_UPDATE=0`` disables it, and it never runs when the user asked
18
+ for an offline setup.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import datetime
24
+ import json
25
+ import os
26
+ import tempfile
27
+ import urllib.error
28
+ import urllib.request
29
+ from pathlib import Path
30
+ from typing import Any, Dict, Optional
31
+
32
+ import yaml
33
+
34
+ from llmsetup.util.fs import app_dir, ensure_dir, write_text
35
+ from llmsetup.util.log import Console
36
+
37
+ #: Where updated catalogs come from. Points at this project's own catalog on
38
+ #: main, which a scheduled workflow keeps current, so the repo is the
39
+ #: distribution channel and there is no extra infrastructure to run.
40
+ DEFAULT_CATALOG_URL = (
41
+ "https://raw.githubusercontent.com/catfish-1234/llmsetup/"
42
+ "main/src/llmsetup/catalog/data/models.yaml"
43
+ )
44
+
45
+ #: How often to check. Model releases happen weekly at most; checking more often
46
+ #: spends the user's network for nothing.
47
+ DEFAULT_INTERVAL_HOURS = 24
48
+
49
+ #: One short attempt. This runs on the path to a user's actual task.
50
+ TIMEOUT_SECONDS = 6.0
51
+
52
+ CACHE_NAME = "auto-update.yaml"
53
+ STAMP_NAME = "auto-update.json"
54
+
55
+
56
+ def catalog_url() -> str:
57
+ return os.environ.get("LLMSETUP_CATALOG_URL", DEFAULT_CATALOG_URL)
58
+
59
+
60
+ def disabled_marker() -> Path:
61
+ """A persistent opt-out that survives new shells, unlike an env var."""
62
+ return app_dir() / "auto-update-disabled"
63
+
64
+
65
+ def enabled() -> bool:
66
+ if os.environ.get("LLMSETUP_AUTO_UPDATE", "1").strip().lower() in (
67
+ "0", "false", "no", "off"
68
+ ):
69
+ return False
70
+ return not disabled_marker().exists()
71
+
72
+
73
+ def interval_hours() -> float:
74
+ try:
75
+ return max(1.0, float(os.environ.get("LLMSETUP_AUTO_UPDATE_HOURS",
76
+ DEFAULT_INTERVAL_HOURS)))
77
+ except ValueError:
78
+ return DEFAULT_INTERVAL_HOURS
79
+
80
+
81
+ def cache_dir() -> Path:
82
+ return ensure_dir(app_dir() / "catalog.d")
83
+
84
+
85
+ def cache_path() -> Path:
86
+ return cache_dir() / CACHE_NAME
87
+
88
+
89
+ def stamp_path() -> Path:
90
+ return app_dir() / STAMP_NAME
91
+
92
+
93
+ # --------------------------------------------------------------------------- #
94
+ # state
95
+ # --------------------------------------------------------------------------- #
96
+
97
+
98
+ def _read_stamp() -> Dict[str, Any]:
99
+ try:
100
+ return json.loads(stamp_path().read_text(encoding="utf-8"))
101
+ except (OSError, ValueError):
102
+ return {}
103
+
104
+
105
+ def _write_stamp(**fields: Any) -> None:
106
+ stamp = _read_stamp()
107
+ stamp.update(fields)
108
+ stamp["last_checked"] = _now()
109
+ try:
110
+ write_text(stamp_path(), json.dumps(stamp, indent=2) + "\n")
111
+ except OSError:
112
+ pass # a read-only home must not break the run
113
+
114
+
115
+ def is_due(now: Optional[datetime.datetime] = None) -> bool:
116
+ """Whether enough time has passed since the last check."""
117
+ stamp = _read_stamp()
118
+ last = stamp.get("last_checked")
119
+ if not last:
120
+ return True
121
+ try:
122
+ checked = datetime.datetime.fromisoformat(last)
123
+ except (TypeError, ValueError):
124
+ return True
125
+ now = now or datetime.datetime.now(datetime.timezone.utc)
126
+ if checked.tzinfo is None:
127
+ checked = checked.replace(tzinfo=datetime.timezone.utc)
128
+ return (now - checked).total_seconds() >= interval_hours() * 3600
129
+
130
+
131
+ def status() -> Dict[str, Any]:
132
+ """What the user sees from `llmsetup catalog status`."""
133
+ stamp = _read_stamp()
134
+ cached = cache_path()
135
+ return {
136
+ "enabled": enabled(),
137
+ "url": catalog_url(),
138
+ "interval_hours": interval_hours(),
139
+ "last_checked": stamp.get("last_checked"),
140
+ "last_updated": stamp.get("last_updated"),
141
+ "last_error": stamp.get("last_error"),
142
+ "cached_catalog": str(cached) if cached.exists() else None,
143
+ "cached_models": stamp.get("cached_models"),
144
+ "due": is_due(),
145
+ }
146
+
147
+
148
+ # --------------------------------------------------------------------------- #
149
+ # the update itself
150
+ # --------------------------------------------------------------------------- #
151
+
152
+
153
+ def fetch_catalog(url: str, timeout: float = TIMEOUT_SECONDS) -> Optional[str]:
154
+ """Download a catalog document. None on any failure."""
155
+ try:
156
+ request = urllib.request.Request(
157
+ url, headers={"User-Agent": "llmsetup/0.1 auto-update"}
158
+ )
159
+ with urllib.request.urlopen(request, timeout=timeout) as response:
160
+ # Only HTTP responses carry a status; a file:// or other local
161
+ # source has none, and that is a legitimate way to point an
162
+ # air-gapped install at an internal catalog.
163
+ status_code = getattr(response, "status", None)
164
+ if status_code is not None and status_code != 200:
165
+ return None
166
+ # Cap the read: a catalog is tens of kilobytes, and an unbounded
167
+ # read from a URL the user can point anywhere is not acceptable.
168
+ return response.read(8 * 1024 * 1024).decode("utf-8", "replace")
169
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError):
170
+ return None
171
+
172
+
173
+ def validate_catalog_text(text: str) -> Optional[Dict[str, Any]]:
174
+ """Parse and sanity-check a downloaded catalog before adopting it.
175
+
176
+ Rejects anything that is not a well-formed catalog with real entries, so a
177
+ truncated download or an HTML error page never replaces a working catalog.
178
+ """
179
+ try:
180
+ document = yaml.safe_load(text)
181
+ except yaml.YAMLError:
182
+ return None
183
+ if not isinstance(document, dict):
184
+ return None
185
+
186
+ models = document.get("models")
187
+ if not isinstance(models, list) or not models:
188
+ return None
189
+ for entry in models:
190
+ if not isinstance(entry, dict) or not entry.get("id"):
191
+ return None
192
+ return document
193
+
194
+
195
+ def apply_update(console: Console, force: bool = False,
196
+ url: Optional[str] = None) -> Dict[str, Any]:
197
+ """Fetch, validate and cache a newer catalog. Never raises."""
198
+ target = url or catalog_url()
199
+
200
+ if not force and not enabled():
201
+ return {"updated": False, "reason": "auto-update is disabled"}
202
+ if not force and not is_due():
203
+ return {"updated": False, "reason": "checked recently"}
204
+
205
+ text = fetch_catalog(target)
206
+ if text is None:
207
+ _write_stamp(last_error=f"could not fetch {target}")
208
+ return {"updated": False, "reason": f"could not reach {target}"}
209
+
210
+ document = validate_catalog_text(text)
211
+ if document is None:
212
+ _write_stamp(last_error="downloaded catalog failed validation")
213
+ return {"updated": False, "reason": "the downloaded catalog is not valid"}
214
+
215
+ # Prove it loads through the real loader before it can affect anything.
216
+ if not _loads_cleanly(text):
217
+ _write_stamp(last_error="downloaded catalog did not load")
218
+ return {"updated": False, "reason": "the downloaded catalog did not load"}
219
+
220
+ count = len(document.get("models") or [])
221
+ header = (
222
+ "# Auto-updated catalog, fetched by llmsetup. Do not edit: it is replaced\n"
223
+ f"# on each update. Source: {target}\n"
224
+ "# Disable with LLMSETUP_AUTO_UPDATE=0. Your own entries in other files in\n"
225
+ "# this directory are layered on top of this one and are never touched.\n\n"
226
+ )
227
+ try:
228
+ write_text(cache_path(), header + text)
229
+ except OSError as exc:
230
+ _write_stamp(last_error=str(exc))
231
+ return {"updated": False, "reason": f"could not write the cache: {exc}"}
232
+
233
+ _write_stamp(last_updated=_now(), cached_models=count, last_error=None)
234
+ console.debug(f"catalog auto-updated from {target} ({count} models)")
235
+ return {"updated": True, "models": count, "url": target,
236
+ "path": str(cache_path()), "catalog_date": document.get("updated")}
237
+
238
+
239
+ def _loads_cleanly(text: str) -> bool:
240
+ """Run a candidate catalog through the real loader in isolation."""
241
+ from llmsetup.catalog.catalog import load_catalog
242
+
243
+ with tempfile.TemporaryDirectory() as directory:
244
+ path = Path(directory) / "candidate.yaml"
245
+ path.write_text(text, encoding="utf-8")
246
+ try:
247
+ catalog = load_catalog(extra_paths=[path], include_user_dir=False)
248
+ except Exception:
249
+ # Any failure at all is a reason to reject the download; the
250
+ # bundled catalog stays in place.
251
+ return False
252
+ return len(catalog) > 0
253
+
254
+
255
+ def maybe_update(console: Console, offline: bool = False) -> Dict[str, Any]:
256
+ """The hook called on ordinary runs. Silent, bounded, and never fatal."""
257
+ if offline or not enabled():
258
+ return {"updated": False, "reason": "skipped"}
259
+ if not is_due():
260
+ return {"updated": False, "reason": "checked recently"}
261
+ try:
262
+ return apply_update(console)
263
+ except Exception as exc:
264
+ # An updater must never break the run it is trying to improve.
265
+ console.debug(f"catalog auto-update skipped: {exc}")
266
+ return {"updated": False, "reason": str(exc)}
267
+
268
+
269
+ def disable_persistently() -> Path:
270
+ """Write an opt-out the user does not have to re-export in every shell."""
271
+ marker = disabled_marker()
272
+ write_text(marker, "Delete this file to re-enable catalog auto-update.\n")
273
+ return marker
274
+
275
+
276
+ def enable_persistently() -> None:
277
+ """Remove the opt-out marker. The env var, if set, still wins."""
278
+ try:
279
+ disabled_marker().unlink()
280
+ except OSError:
281
+ pass
282
+
283
+
284
+ def _now() -> str:
285
+ return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
@@ -0,0 +1,389 @@
1
+ """Loading and querying the model catalog.
2
+
3
+ The catalog is data with a schema, not a Python literal, so a user can extend it
4
+ without forking the tool. Layering is: bundled catalog → files in
5
+ ``$LLMSETUP_HOME/catalog.d/*.yaml`` → an explicit ``--catalog`` path. Later layers
6
+ override earlier entries by ``id`` and may add new ones.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import os
13
+ from dataclasses import replace
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Iterable, List, Optional
16
+
17
+ import yaml
18
+
19
+ from llmsetup.types import ModelSpec, Task, UseCaseProfile
20
+ from llmsetup.util.errors import CatalogError
21
+ from llmsetup.util.fs import app_dir
22
+
23
+ BUNDLED = Path(__file__).parent / "data" / "models.yaml"
24
+
25
+ # Used when a catalog entry names a quant we have no bits-per-weight figure for.
26
+ DEFAULT_BPW = 4.85
27
+
28
+ _SPEC_FIELDS = set(ModelSpec.__dataclass_fields__)
29
+
30
+
31
+ class Catalog:
32
+ """A loaded catalog: model specs plus the quantization size table."""
33
+
34
+ def __init__(self, models: List[ModelSpec], quant_bpw: Dict[str, float],
35
+ updated: str = "", sources: Optional[List[str]] = None,
36
+ extras: Optional[Dict[str, dict]] = None):
37
+ self.models = models
38
+ self.quant_bpw = quant_bpw
39
+ self.updated = updated
40
+ self.sources = sources or []
41
+ # Non-schema keys (arch_source and other provenance) live on the instance,
42
+ # not in a module global: two Catalog objects in one process must not
43
+ # report each other's provenance.
44
+ self.extras = extras or {}
45
+ self._reindex()
46
+
47
+ def _reindex(self) -> None:
48
+ self._by_id = {m.id: m for m in self.models}
49
+
50
+ def restrict_to(self, model_id: str) -> "Catalog":
51
+ """A view containing only one model, for `--model` and `doctor`.
52
+
53
+ Returns a new Catalog rather than mutating this one, so callers never
54
+ have to reach into private indexes to keep them consistent.
55
+ """
56
+ spec = self.require(model_id)
57
+ return Catalog([spec], dict(self.quant_bpw), self.updated, list(self.sources),
58
+ {spec.id: dict(self.extras.get(spec.id, {}))})
59
+
60
+ def arch_source(self, model_id: str) -> str:
61
+ """'measured' or 'estimated', whether the architecture figures are trustworthy."""
62
+ return str(self.extras.get(model_id, {}).get("arch_source", "measured"))
63
+
64
+ def __len__(self) -> int:
65
+ return len(self.models)
66
+
67
+ def get(self, model_id: str) -> Optional[ModelSpec]:
68
+ return self._by_id.get(model_id)
69
+
70
+ def require(self, model_id: str) -> ModelSpec:
71
+ spec = self.get(model_id)
72
+ if spec is None:
73
+ close = [m for m in self._by_id if model_id.lower() in m.lower()]
74
+ raise CatalogError(
75
+ f"unknown model id {model_id!r}",
76
+ hint=(f"did you mean: {', '.join(close[:3])}?" if close
77
+ else "run `llmsetup catalog list` to see available models"),
78
+ )
79
+ return spec
80
+
81
+ def bpw(self, quant: str) -> float:
82
+ """Bits per weight for a quantization, case-insensitively."""
83
+ if quant in self.quant_bpw:
84
+ return self.quant_bpw[quant]
85
+ upper = quant.upper()
86
+ for key, value in self.quant_bpw.items():
87
+ if key.upper() == upper:
88
+ return value
89
+ # Native formats can be selected for a retained model even when the
90
+ # catalog only lists GGUF quantizations. Keep later tuning/recovery
91
+ # estimates consistent with the planner's actual weight format.
92
+ return {"F16": 16.0, "BF16": 16.0, "MLX2": 2.85,
93
+ "MLX4": 4.85, "MLX8": 8.5}.get(upper, DEFAULT_BPW)
94
+
95
+ # ----------------------------------------------------------------- queries
96
+
97
+ def chat_models(self) -> List[ModelSpec]:
98
+ return [m for m in self.models if not m.is_embedding]
99
+
100
+ def embedding_models(self) -> List[ModelSpec]:
101
+ return [m for m in self.models if m.is_embedding]
102
+
103
+ def completion_models(self) -> List[ModelSpec]:
104
+ """Models that can fill a gap between a prefix and a suffix."""
105
+ return [m for m in self.models if m.supports_fim and not m.is_embedding]
106
+
107
+ def matching(self, profile: UseCaseProfile) -> List[ModelSpec]:
108
+ """Hard capability filter.
109
+
110
+ Everything here is a *requirement the user stated*, so failing it removes
111
+ the model outright rather than costing it points. Soft preferences
112
+ (quality, speed, size) are handled by scoring instead.
113
+ """
114
+ out = []
115
+ for spec in self.chat_models():
116
+ if not self.satisfies(spec, profile):
117
+ continue
118
+ out.append(spec)
119
+ return out
120
+
121
+ def satisfies(self, spec: ModelSpec, profile: UseCaseProfile) -> bool:
122
+ if profile.needs_vision and not spec.supports_vision:
123
+ return False
124
+ if profile.needs_tools and not spec.supports_tools:
125
+ return False
126
+ if profile.needs_json and not spec.supports_json:
127
+ return False
128
+ if profile.needs_reasoning and not spec.is_reasoning:
129
+ return False
130
+ if profile.commercial_use and not spec.commercial_ok:
131
+ return False
132
+ # A model whose maximum context is below what the user asked for cannot
133
+ # do the job, regardless of how good it is.
134
+ if profile.context_tokens > spec.max_context:
135
+ return False
136
+ if spec.tasks and profile.tasks:
137
+ wanted = {t.value for t in profile.tasks}
138
+ if not wanted & set(spec.tasks):
139
+ return False
140
+ return True
141
+
142
+ def unmatched_reasons(self, profile: UseCaseProfile) -> Dict[str, int]:
143
+ """Why models were filtered out, which turns 'nothing fits' into an explanation."""
144
+ reasons: Dict[str, int] = {}
145
+
146
+ def bump(key: str) -> None:
147
+ reasons[key] = reasons.get(key, 0) + 1
148
+
149
+ for spec in self.chat_models():
150
+ if self.satisfies(spec, profile):
151
+ continue
152
+ if profile.needs_vision and not spec.supports_vision:
153
+ bump("no vision support")
154
+ elif profile.needs_tools and not spec.supports_tools:
155
+ bump("no tool-calling support")
156
+ elif profile.needs_json and not spec.supports_json:
157
+ bump("no structured-output support")
158
+ elif profile.needs_reasoning and not spec.is_reasoning:
159
+ bump("not a reasoning model")
160
+ elif profile.commercial_use and not spec.commercial_ok:
161
+ bump("licence disallows commercial use")
162
+ elif profile.context_tokens > spec.max_context:
163
+ bump(f"max context below {profile.context_tokens}")
164
+ else:
165
+ bump("task mismatch")
166
+ return reasons
167
+
168
+ def for_task(self, task: Task) -> List[ModelSpec]:
169
+ return [m for m in self.models if task.value in m.tasks]
170
+
171
+
172
+ def load_catalog(extra_paths: Optional[Iterable[Path]] = None,
173
+ include_user_dir: bool = True) -> Catalog:
174
+ """Load the bundled catalog, then layer user overrides on top."""
175
+ paths: List[Path] = [BUNDLED]
176
+
177
+ if include_user_dir:
178
+ user_dir = app_dir() / "catalog.d"
179
+ if user_dir.is_dir():
180
+ paths.extend(sorted(p for p in user_dir.iterdir()
181
+ if p.suffix in (".yaml", ".yml")))
182
+ env_path = os.environ.get("LLMSETUP_CATALOG")
183
+ if env_path:
184
+ paths.append(Path(env_path).expanduser())
185
+ if extra_paths:
186
+ paths.extend(Path(p).expanduser() for p in extra_paths)
187
+
188
+ merged: Dict[str, ModelSpec] = {}
189
+ extras: Dict[str, dict] = {}
190
+ quant_bpw: Dict[str, float] = {}
191
+ updated = ""
192
+ used: List[str] = []
193
+
194
+ for path in paths:
195
+ if not path.exists():
196
+ if path is BUNDLED:
197
+ raise CatalogError(f"bundled catalog missing at {path}",
198
+ hint="reinstall llmsetup")
199
+ continue
200
+ data = _read_yaml(path)
201
+ version = data.get("schema_version", 1)
202
+ if type(version) is not int or version != 1:
203
+ raise CatalogError(f"unsupported schema_version {version!r} in {path}; expected 1")
204
+ table = data.get("quant_bpw", {})
205
+ if not isinstance(table, dict):
206
+ raise CatalogError(f"quant_bpw must be a mapping in {path}")
207
+ for key, value in table.items():
208
+ converted = _coerce_types({"params_b": value}, f"quant_bpw[{key}]", path)
209
+ quant_bpw[str(key)] = converted["params_b"]
210
+ updated = data.get("updated") or updated
211
+ entries = data.get("models", [])
212
+ if not isinstance(entries, list):
213
+ raise CatalogError(f"models must be a list in {path}")
214
+ seen = set()
215
+ for raw in entries:
216
+ if not isinstance(raw, dict) or "id" not in raw:
217
+ raise CatalogError(f"catalog entry without an 'id' in {path}")
218
+ model_id = raw["id"]
219
+ if not isinstance(model_id, str) or not model_id.strip():
220
+ raise CatalogError(f"model id must be a nonempty string in {path}: {model_id!r}")
221
+ if model_id in seen:
222
+ raise CatalogError(f"duplicate model id {model_id!r} in {path}")
223
+ seen.add(model_id)
224
+ # A later layer that repeats an id patches the earlier entry field by
225
+ # field, so a user can override just `ollama_name` without restating
226
+ # the whole model. Patching must not require a complete entry, so the
227
+ # existence check comes before we try to build a full ModelSpec.
228
+ if model_id in merged:
229
+ merged[model_id] = _patch(merged[model_id], raw, path)
230
+ else:
231
+ merged[model_id] = _to_spec(raw, path)
232
+ _record_extras(extras, model_id, raw)
233
+ used.append(str(path))
234
+
235
+ if not merged:
236
+ raise CatalogError("catalog contains no models", hint="check your catalog YAML files")
237
+ return Catalog(list(merged.values()), quant_bpw, updated, used, extras)
238
+
239
+
240
+ def _read_yaml(path: Path) -> dict:
241
+ try:
242
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
243
+ except (OSError, yaml.YAMLError) as exc:
244
+ raise CatalogError(f"could not read catalog {path}: {exc}",
245
+ hint="the file must be valid YAML") from None
246
+ if data is None:
247
+ data = {}
248
+ if not isinstance(data, dict):
249
+ raise CatalogError(f"catalog {path} must be a YAML mapping")
250
+ return data
251
+
252
+
253
+ def _to_spec(raw: dict, path: Path) -> ModelSpec:
254
+ """Build a full spec from a complete entry.
255
+
256
+ Keys outside the ModelSpec schema (``arch_source`` and other provenance
257
+ notes) are preserved out-of-band by :func:`_record_extras` rather than
258
+ dropped, so the planner can flag estimated architectures.
259
+ """
260
+ if not isinstance(raw, dict) or "id" not in raw:
261
+ raise CatalogError(f"catalog entry without an 'id' in {path}")
262
+ fields = {k: v for k, v in raw.items() if k in _SPEC_FIELDS}
263
+ fields.setdefault("name", raw["id"])
264
+ fields.setdefault("publisher", "unknown")
265
+ fields = _coerce_types(fields, raw["id"], path)
266
+ try:
267
+ spec = ModelSpec(**fields)
268
+ except TypeError as exc:
269
+ raise CatalogError(f"bad catalog entry {raw.get('id')!r} in {path}: {exc}") from None
270
+ _validate_spec(spec, path)
271
+ return spec
272
+
273
+
274
+ def _record_extras(store: Dict[str, dict], model_id: str, raw: dict) -> None:
275
+ found = {k: v for k, v in raw.items() if k not in _SPEC_FIELDS}
276
+ if found:
277
+ store.setdefault(model_id, {}).update(found)
278
+
279
+
280
+ def _patch(base: ModelSpec, raw: dict, path: Path) -> ModelSpec:
281
+ changes = {k: v for k, v in raw.items() if k in _SPEC_FIELDS and k != "id"}
282
+ changes = _coerce_types(changes, base.id, path)
283
+ spec = replace(base, **changes)
284
+ _validate_spec(spec, path)
285
+ return spec
286
+
287
+
288
+ #: Fields the memory model does arithmetic on. A string here does not fail at
289
+ #: load time. It fails several layers down inside the fit maths, with an error
290
+ #: that says nothing about the catalog. Types are enforced at the boundary.
291
+ _NUMERIC_FIELDS = {
292
+ "params_b": float, "active_params_b": float, "layers": int,
293
+ "hidden_size": int, "kv_heads": int, "head_dim": int, "max_context": int,
294
+ "quality_tier": int, "max_disk_gb": float,
295
+ "sliding_window": int, "swa_pattern": int,
296
+ "full_attention_interval": int, "recurrent_state_bytes": int,
297
+ }
298
+ _BOOL_FIELDS = (
299
+ "commercial_ok", "supports_tools", "supports_vision", "supports_json",
300
+ "is_reasoning", "is_embedding", "draft_capable", "mtp_heads",
301
+ "supports_fim",
302
+ )
303
+ _LIST_FIELDS = ("tasks", "languages", "recommended_quants")
304
+
305
+
306
+ def _coerce_types(fields: Dict[str, Any], model_id: str, path: Path) -> Dict[str, Any]:
307
+ """Validate and normalise an entry's field types.
308
+
309
+ Catalogs are extended by users and fetched automatically, so an entry can
310
+ carry anything YAML can express. Rejecting a bad value here, naming the file
311
+ and the field, beats a TypeError deep in the planner.
312
+ """
313
+ out = dict(fields)
314
+
315
+ for name, caster in _NUMERIC_FIELDS.items():
316
+ if name not in out:
317
+ continue
318
+ value = out[name]
319
+ if value is None and name in {"active_params_b", "head_dim", "max_disk_gb", "sliding_window"}:
320
+ continue
321
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
322
+ raise CatalogError(
323
+ f"{model_id}.{name} must be a number, got {value!r} in {path}"
324
+ )
325
+ try:
326
+ number = float(value)
327
+ if not math.isfinite(number) or (caster is int and not number.is_integer()):
328
+ raise ValueError
329
+ out[name] = caster(value)
330
+ except (TypeError, ValueError, OverflowError):
331
+ raise CatalogError(
332
+ f"{model_id}.{name} must be a number, got {value!r} in {path}"
333
+ ) from None
334
+ if out[name] < 0 or (out[name] == 0 and name != "recurrent_state_bytes"):
335
+ raise CatalogError(
336
+ f"{model_id}.{name} must be positive, got {out[name]} in {path}"
337
+ )
338
+
339
+ for name in _BOOL_FIELDS:
340
+ if name in out and not isinstance(out[name], bool):
341
+ raise CatalogError(f"{model_id}.{name} must be a boolean in {path}")
342
+
343
+ for name in _LIST_FIELDS:
344
+ if name in out:
345
+ value = out[name]
346
+ if isinstance(value, str):
347
+ value = [value]
348
+ if not isinstance(value, (list, tuple)):
349
+ raise CatalogError(
350
+ f"{model_id}.{name} must be a list, got {value!r} in {path}"
351
+ )
352
+ if not all(isinstance(item, str) and item.strip() for item in value):
353
+ raise CatalogError(f"{model_id}.{name} must contain nonempty strings in {path}")
354
+ out[name] = list(value)
355
+
356
+ for name in ("id", "name", "publisher", "license", "notes"):
357
+ if name in out and (not isinstance(out[name], str) or
358
+ (name != "notes" and not out[name].strip())):
359
+ raise CatalogError(f"{model_id}.{name} must be a string in {path}")
360
+
361
+ return out
362
+
363
+
364
+ def _validate_spec(spec: ModelSpec, path: Path) -> None:
365
+ from llmsetup.download import validate_repo, validate_revision
366
+ from llmsetup.util.errors import ProvisionError
367
+
368
+ for field in ("gguf_repo", "hf_repo", "mlx_repo", "gguf_revision", "hf_revision", "mlx_revision"):
369
+ value = getattr(spec, field)
370
+ if value is not None:
371
+ try:
372
+ if not isinstance(value, str):
373
+ raise ProvisionError("must be a string")
374
+ if field.endswith("_repo"):
375
+ setattr(spec, field, validate_repo(value))
376
+ else:
377
+ setattr(spec, field, validate_revision(value))
378
+ except ProvisionError as exc:
379
+ raise CatalogError(f"{spec.id}.{field}: {exc.message} in {path}") from None
380
+ if not 1 <= spec.quality_tier <= 5:
381
+ raise CatalogError(f"{spec.id}.quality_tier must be between 1 and 5 in {path}")
382
+ if spec.active_params_b is not None and spec.active_params_b > spec.params_b:
383
+ raise CatalogError(f"{spec.id}.active_params_b exceeds params_b in {path}")
384
+ unknown = set(spec.tasks) - {task.value for task in Task}
385
+ if unknown:
386
+ raise CatalogError(f"{spec.id}.tasks has unknown values {sorted(unknown)!r} in {path}")
387
+ if not spec.recommended_quants:
388
+ raise CatalogError(f"{spec.id}.recommended_quants must not be empty in {path}")
389
+