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.
- llmsetup/__main__.py +8 -0
- llmsetup/bench/__init__.py +27 -0
- llmsetup/bench/sweep.py +456 -0
- llmsetup/catalog/__init__.py +5 -0
- llmsetup/catalog/autoupdate.py +285 -0
- llmsetup/catalog/catalog.py +389 -0
- llmsetup/catalog/data/models.yaml +1323 -0
- llmsetup/catalog/discover.py +497 -0
- llmsetup/catalog/refresh.py +308 -0
- llmsetup/cli.py +835 -0
- llmsetup/download.py +415 -0
- llmsetup/gguf.py +347 -0
- llmsetup/hardware/__init__.py +5 -0
- llmsetup/hardware/bandwidth.py +150 -0
- llmsetup/hardware/cpu.py +178 -0
- llmsetup/hardware/detect.py +172 -0
- llmsetup/hardware/gpu.py +279 -0
- llmsetup/interop/__init__.py +5 -0
- llmsetup/interop/export.py +142 -0
- llmsetup/interop/jan.py +74 -0
- llmsetup/interop/llama_swap.py +86 -0
- llmsetup/interop/lmstudio.py +107 -0
- llmsetup/interop/ollama.py +90 -0
- llmsetup/interop/openai_clients.py +161 -0
- llmsetup/interview/__init__.py +5 -0
- llmsetup/interview/questions.py +213 -0
- llmsetup/interview/wizard.py +359 -0
- llmsetup/pipeline.py +773 -0
- llmsetup/planner/__init__.py +6 -0
- llmsetup/planner/memory.py +384 -0
- llmsetup/planner/model_fit.py +186 -0
- llmsetup/planner/moe.py +254 -0
- llmsetup/planner/plan.py +483 -0
- llmsetup/planner/requested.py +209 -0
- llmsetup/planner/score.py +174 -0
- llmsetup/repair/__init__.py +6 -0
- llmsetup/repair/loop.py +192 -0
- llmsetup/repair/strategies.py +795 -0
- llmsetup/report/__init__.py +5 -0
- llmsetup/report/render.py +525 -0
- llmsetup/runtimes/__init__.py +14 -0
- llmsetup/runtimes/base.py +168 -0
- llmsetup/runtimes/capabilities.py +94 -0
- llmsetup/runtimes/data/llamacpp_flags.py +422 -0
- llmsetup/runtimes/dependencies.py +50 -0
- llmsetup/runtimes/gguf_store.py +171 -0
- llmsetup/runtimes/install.py +230 -0
- llmsetup/runtimes/llamacpp.py +381 -0
- llmsetup/runtimes/lmstudio.py +138 -0
- llmsetup/runtimes/mlx.py +119 -0
- llmsetup/runtimes/ollama.py +223 -0
- llmsetup/runtimes/registry.py +215 -0
- llmsetup/runtimes/tabby.py +113 -0
- llmsetup/runtimes/vllm.py +199 -0
- llmsetup/tuning/__init__.py +5 -0
- llmsetup/tuning/advanced.py +653 -0
- llmsetup/tuning/platform_tuning.py +276 -0
- llmsetup/tuning/profiles.py +387 -0
- llmsetup/tuning/publisher.py +135 -0
- llmsetup/tuning/sampling.py +227 -0
- llmsetup/tuning/tuner.py +783 -0
- llmsetup/types.py +680 -0
- llmsetup/ui/__init__.py +5 -0
- llmsetup/ui/server.py +345 -0
- llmsetup/ui/static/index.html +449 -0
- llmsetup/util/__init__.py +1 -0
- llmsetup/util/errors.py +45 -0
- llmsetup/util/fs.py +159 -0
- llmsetup/util/http.py +140 -0
- llmsetup/util/log.py +78 -0
- llmsetup/util/proc.py +147 -0
- llmsetup/util/units.py +61 -0
- llmsetup/verify/__init__.py +5 -0
- llmsetup/verify/checks.py +1041 -0
- llmsetup/verify/client.py +257 -0
- llmsetup/verify/harness.py +182 -0
- llmsetup/verify/signatures.py +125 -0
- llmsetup-0.1.0.dist-info/METADATA +291 -0
- llmsetup-0.1.0.dist-info/RECORD +84 -0
- llmsetup-0.1.0.dist-info/WHEEL +5 -0
- llmsetup-0.1.0.dist-info/entry_points.txt +2 -0
- llmsetup-0.1.0.dist-info/licenses/LICENSE +202 -0
- llmsetup-0.1.0.dist-info/licenses/NOTICE +7 -0
- 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
|
+
|