llmt-cli 0.2.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.
- llmt/__init__.py +7 -0
- llmt/api.py +138 -0
- llmt/cli.py +562 -0
- llmt/clients.py +1316 -0
- llmt/data/manifest-pubkey.json +7 -0
- llmt/doctor.py +970 -0
- llmt/doctorcmd.py +142 -0
- llmt/download.py +321 -0
- llmt/errors.py +132 -0
- llmt/layout.py +676 -0
- llmt/manifest.py +314 -0
- llmt/matcher.py +358 -0
- llmt/models.py +130 -0
- llmt/output.py +51 -0
- llmt/permissions.py +154 -0
- llmt/scancmd.py +556 -0
- llmt/scanner.py +328 -0
- llmt/telemetry/__init__.py +15 -0
- llmt/telemetry/artifacts.py +217 -0
- llmt/telemetry/beat.py +221 -0
- llmt/telemetry/cmd.py +358 -0
- llmt/telemetry/collect.py +252 -0
- llmt/telemetry/config.py +237 -0
- llmt/telemetry/consent.py +57 -0
- llmt/telemetry/errors.py +41 -0
- llmt/telemetry/wire.py +194 -0
- llmt/verify.py +129 -0
- llmt_cli-0.2.0.dist-info/METADATA +307 -0
- llmt_cli-0.2.0.dist-info/RECORD +33 -0
- llmt_cli-0.2.0.dist-info/WHEEL +5 -0
- llmt_cli-0.2.0.dist-info/entry_points.txt +2 -0
- llmt_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- llmt_cli-0.2.0.dist-info/top_level.txt +1 -0
llmt/cli.py
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""llmt — command-line client for the LLM Torrents catalog.
|
|
2
|
+
|
|
3
|
+
Commands
|
|
4
|
+
--------
|
|
5
|
+
search <query> query the catalog, print a readable table
|
|
6
|
+
pull <slug|slug:quant> resolve -> download via aria2c (webseeds) -> verify
|
|
7
|
+
verify <path> <ref> re-verify already-downloaded files, no re-download
|
|
8
|
+
seed <slug|slug:quant> seed an already-downloaded artifact into the swarm
|
|
9
|
+
scan find local models you already have and seed them
|
|
10
|
+
doctor diagnose why your seeds aren't actually seeding
|
|
11
|
+
|
|
12
|
+
``pull``, ``verify`` and ``seed`` authenticate the catalog's Ed25519-signed
|
|
13
|
+
manifest (signature checked against the public key pinned inside this
|
|
14
|
+
package) before trusting any hashes; an unverifiable manifest is a hard
|
|
15
|
+
error (exit 15), never a silent downgrade.
|
|
16
|
+
|
|
17
|
+
All commands accept ``--json`` (machine-readable) and ``--api-base`` /
|
|
18
|
+
``--api-key`` (or env ``LLMT_API_BASE`` / ``LLMT_API_KEY``). Expected failures
|
|
19
|
+
print a single clean line and exit non-zero — never a traceback.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
from . import __version__
|
|
30
|
+
from .api import ApiClient, DEFAULT_API_BASE
|
|
31
|
+
from .download import build_aria2c_args, ensure_aria2c, run_aria2c
|
|
32
|
+
from .errors import (
|
|
33
|
+
LayoutSafetyError,
|
|
34
|
+
LicenseGatedError,
|
|
35
|
+
LlmtError,
|
|
36
|
+
NotAvailableError,
|
|
37
|
+
RateLimitedError,
|
|
38
|
+
)
|
|
39
|
+
from .manifest import (
|
|
40
|
+
check_catalog_consistency,
|
|
41
|
+
fetch_verified_manifest,
|
|
42
|
+
manifest_files,
|
|
43
|
+
)
|
|
44
|
+
from .models import (
|
|
45
|
+
files_of,
|
|
46
|
+
is_gated,
|
|
47
|
+
model_page_of,
|
|
48
|
+
parse_ref,
|
|
49
|
+
require_available_download,
|
|
50
|
+
select_quant,
|
|
51
|
+
)
|
|
52
|
+
from .output import emit_json, render_table
|
|
53
|
+
from .verify import all_ok, resolve_catalog_path, verify_files
|
|
54
|
+
from .scancmd import add_scan_args, cmd_scan
|
|
55
|
+
from .doctorcmd import add_doctor_args, cmd_doctor
|
|
56
|
+
from .telemetry.cmd import add_telemetry_args
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _client(args: argparse.Namespace) -> ApiClient:
|
|
60
|
+
return ApiClient(base=args.api_base, key=args.api_key, timeout=args.timeout)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _verified_files(client: ApiClient, download: dict,
|
|
64
|
+
as_json: bool) -> tuple[list[dict], dict]:
|
|
65
|
+
"""Fetch the torrent's Ed25519-signed manifest, verify it against the
|
|
66
|
+
key pinned in this package, cross-check the catalog's file list against
|
|
67
|
+
it, and return (files, manifest_info) with the SIGNED manifest's files[]
|
|
68
|
+
as the hashing source of truth.
|
|
69
|
+
|
|
70
|
+
Raises :class:`~llmt.errors.ManifestVerificationError` (exit 15, fail
|
|
71
|
+
closed) on ANY failure — fetch error/offline, malformed document, bad
|
|
72
|
+
signature, wrong key, wrong torrent, or catalog/manifest disagreement.
|
|
73
|
+
"""
|
|
74
|
+
info_hash = download.get("info_hash_v1") or download.get("info_hash_v2")
|
|
75
|
+
doc = fetch_verified_manifest(client, info_hash)
|
|
76
|
+
mfiles = manifest_files(doc)
|
|
77
|
+
check_catalog_consistency(files_of(download), mfiles)
|
|
78
|
+
sig = doc.get("signature") or {}
|
|
79
|
+
if not as_json:
|
|
80
|
+
print(f"Manifest signature verified: Ed25519, key id "
|
|
81
|
+
f"'{sig.get('key_id')}' (public key pinned in this llmt build).")
|
|
82
|
+
return mfiles, {"verified": True, "algo": sig.get("algo"),
|
|
83
|
+
"key_id": sig.get("key_id")}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# search
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
90
|
+
client = _client(args)
|
|
91
|
+
extra = {}
|
|
92
|
+
for rig in args.rig or []:
|
|
93
|
+
if "=" in rig:
|
|
94
|
+
k, v = rig.split("=", 1)
|
|
95
|
+
extra[f"rig[{k}]"] = v
|
|
96
|
+
resp = client.list_models(
|
|
97
|
+
q=args.query, fit=args.fit, per_page=args.per_page, sort=args.sort,
|
|
98
|
+
extra=extra or None,
|
|
99
|
+
)
|
|
100
|
+
data = resp.get("data", [])
|
|
101
|
+
if args.json:
|
|
102
|
+
emit_json(resp)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
if not data:
|
|
106
|
+
print("No models matched.")
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
rows = []
|
|
110
|
+
for m in data:
|
|
111
|
+
lic = m.get("license") or {}
|
|
112
|
+
lic_str = lic.get("name") or "-"
|
|
113
|
+
if lic.get("gated"):
|
|
114
|
+
lic_str += " (gated)"
|
|
115
|
+
verdict = m.get("fit_verdict") or "-"
|
|
116
|
+
rows.append([
|
|
117
|
+
m.get("slug", "-"),
|
|
118
|
+
m.get("name", "-"),
|
|
119
|
+
_params_label(m),
|
|
120
|
+
lic_str,
|
|
121
|
+
verdict,
|
|
122
|
+
])
|
|
123
|
+
print(render_table(
|
|
124
|
+
rows, headers=["SLUG", "NAME", "PARAMS", "LICENSE", "FIT"]
|
|
125
|
+
))
|
|
126
|
+
meta = resp.get("meta", {}).get("pagination", {})
|
|
127
|
+
if meta:
|
|
128
|
+
print(f"\n{meta.get('to', len(data))}/{meta.get('total', len(data))} shown"
|
|
129
|
+
f" (page {meta.get('current_page', 1)}/{meta.get('last_page', 1)}).")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _params_label(m: dict) -> str:
|
|
134
|
+
"""Parameter count in billions for the list view.
|
|
135
|
+
|
|
136
|
+
The compact list endpoint reports params, not per-quant file bytes, so we
|
|
137
|
+
show the honest parameter figure (e.g. "70.6B") rather than inventing a
|
|
138
|
+
download size. Actual on-disk sizes are shown per-quant by ``pull``.
|
|
139
|
+
"""
|
|
140
|
+
p = m.get("params_total_b")
|
|
141
|
+
if not isinstance(p, (int, float)):
|
|
142
|
+
return "-"
|
|
143
|
+
return f"{p:.1f}B"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# pull
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
def cmd_pull(args: argparse.Namespace) -> int:
|
|
150
|
+
client = _client(args)
|
|
151
|
+
slug, quant_level = parse_ref(args.ref)
|
|
152
|
+
if args.quant:
|
|
153
|
+
quant_level = args.quant
|
|
154
|
+
|
|
155
|
+
model = client.get_model(slug)["data"]
|
|
156
|
+
|
|
157
|
+
# D7: refuse to go any further for a gated model — print the gate + page.
|
|
158
|
+
if is_gated(model):
|
|
159
|
+
raise LicenseGatedError(slug, model_page_of(model))
|
|
160
|
+
|
|
161
|
+
quant = select_quant(model, quant_level)
|
|
162
|
+
download = require_available_download(model, quant) # raises gate/na
|
|
163
|
+
|
|
164
|
+
# M2: authenticate the signed manifest BEFORE moving a byte. The
|
|
165
|
+
# verified manifest's files[] (not the unauthenticated catalog block)
|
|
166
|
+
# is what the download will be checked against.
|
|
167
|
+
files, manifest_info = _verified_files(client, download,
|
|
168
|
+
as_json=args.json)
|
|
169
|
+
|
|
170
|
+
magnet = download.get("magnet")
|
|
171
|
+
webseeds = download.get("webseeds") or []
|
|
172
|
+
out_dir = args.output or os.path.join(os.getcwd(),
|
|
173
|
+
f"{slug}-{quant.get('quant_level')}")
|
|
174
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
175
|
+
|
|
176
|
+
exe = ensure_aria2c()
|
|
177
|
+
argv = build_aria2c_args(
|
|
178
|
+
magnet=magnet, out_dir=out_dir, webseeds=webseeds,
|
|
179
|
+
trackers=args.tracker or [], seed=False, exe=exe,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if args.dry_run:
|
|
183
|
+
if args.json:
|
|
184
|
+
emit_json({"aria2c": argv, "out_dir": out_dir,
|
|
185
|
+
"files": files, "webseeds": webseeds,
|
|
186
|
+
"manifest": manifest_info})
|
|
187
|
+
else:
|
|
188
|
+
print("Would run:\n " + " ".join(_shquote(a) for a in argv))
|
|
189
|
+
print(f"\nWebseeds ({len(webseeds)}):")
|
|
190
|
+
for w in webseeds:
|
|
191
|
+
print(f" {w}")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
print(f"Downloading {slug}:{quant.get('quant_level')} -> {out_dir}")
|
|
195
|
+
print(f"Using {len(webseeds)} webseed(s) + BitTorrent swarm via aria2c.")
|
|
196
|
+
rc = run_aria2c(argv)
|
|
197
|
+
if rc != 0:
|
|
198
|
+
# Known webseed-origin quirks make aria2c exit non-zero even though
|
|
199
|
+
# every payload byte arrived: the mirrors 403 the bare base URL
|
|
200
|
+
# (directory listings are disabled by design, aria2c exit 22), and
|
|
201
|
+
# hybrid torrents' BEP-47 ``.pad`` files 404 on HTTP mirrors (reported
|
|
202
|
+
# as a 98-99% stall / resource-not-found). Trust the disk, not the
|
|
203
|
+
# exit code: if every expected file is present at its expected size,
|
|
204
|
+
# proceed to the real SHA-256 verification; otherwise fail loudly.
|
|
205
|
+
if _payload_present(out_dir, files):
|
|
206
|
+
print(f"aria2c exited with code {rc}, but all {len(files)} "
|
|
207
|
+
f"expected file(s) are present at the expected sizes "
|
|
208
|
+
f"(known webseed base-URL/.pad quirk). "
|
|
209
|
+
f"Proceeding to SHA-256 verification.",
|
|
210
|
+
file=sys.stderr)
|
|
211
|
+
else:
|
|
212
|
+
print(f"aria2c exited with code {rc} and the download is "
|
|
213
|
+
f"incomplete; skipping verification.", file=sys.stderr)
|
|
214
|
+
return 10
|
|
215
|
+
|
|
216
|
+
return _do_verify(out_dir, files, slug, quant.get("quant_level"),
|
|
217
|
+
as_json=args.json, header="Download complete. Verifying",
|
|
218
|
+
manifest_info=manifest_info)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _payload_present(base_dir: str, files: list[dict]) -> bool:
|
|
222
|
+
"""True iff every expected file exists under ``base_dir`` at the expected
|
|
223
|
+
size (when one was supplied). Presence+size only — the SHA-256 pass
|
|
224
|
+
against the verified manifest that follows is still the real integrity
|
|
225
|
+
check."""
|
|
226
|
+
if not files:
|
|
227
|
+
return False
|
|
228
|
+
for f in files:
|
|
229
|
+
rel = f.get("path")
|
|
230
|
+
if not rel:
|
|
231
|
+
return False
|
|
232
|
+
try:
|
|
233
|
+
full = resolve_catalog_path(base_dir, rel)
|
|
234
|
+
except LayoutSafetyError:
|
|
235
|
+
print(f"Refusing manifest file path {rel!r}: it does not resolve "
|
|
236
|
+
f"inside the download directory.", file=sys.stderr)
|
|
237
|
+
return False
|
|
238
|
+
if not os.path.isfile(full):
|
|
239
|
+
return False
|
|
240
|
+
size = f.get("size")
|
|
241
|
+
if size is not None and os.path.getsize(full) != size:
|
|
242
|
+
return False
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# verify
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
250
|
+
client = _client(args)
|
|
251
|
+
slug, quant_level = parse_ref(args.ref)
|
|
252
|
+
if args.quant:
|
|
253
|
+
quant_level = args.quant
|
|
254
|
+
|
|
255
|
+
model = client.get_model(slug)["data"]
|
|
256
|
+
if is_gated(model):
|
|
257
|
+
raise LicenseGatedError(slug, model_page_of(model))
|
|
258
|
+
|
|
259
|
+
quant = select_quant(model, quant_level)
|
|
260
|
+
download = require_available_download(model, quant)
|
|
261
|
+
|
|
262
|
+
# M2: hashes come from the SIGNATURE-VERIFIED manifest, or not at all.
|
|
263
|
+
files, manifest_info = _verified_files(client, download,
|
|
264
|
+
as_json=args.json)
|
|
265
|
+
|
|
266
|
+
base_dir, note = _resolve_verify_dir(args.path, files)
|
|
267
|
+
if note:
|
|
268
|
+
print(note, file=sys.stderr)
|
|
269
|
+
return _do_verify(base_dir, files, slug, quant.get("quant_level"),
|
|
270
|
+
as_json=args.json, header="Verifying",
|
|
271
|
+
manifest_info=manifest_info)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _resolve_verify_dir(path: str, files: list[dict]) -> tuple[str, Optional[str]]:
|
|
275
|
+
"""Accept either the OUTER download directory (what ``pull`` created) or
|
|
276
|
+
the inner payload directory the torrent unpacked inside it.
|
|
277
|
+
|
|
278
|
+
Manifest ``files[].path`` entries are relative to the outer directory and
|
|
279
|
+
usually start with the payload directory name. If the given ``path``
|
|
280
|
+
already contains the files, use it as-is. If the user instead passed the
|
|
281
|
+
inner payload directory (its basename equals the files' common top-level
|
|
282
|
+
directory and the files resolve from its parent), verify against the
|
|
283
|
+
parent and say so. Otherwise return ``path`` unchanged plus a hint
|
|
284
|
+
naming exactly which directory to pass.
|
|
285
|
+
"""
|
|
286
|
+
rels = [f.get("path") for f in files if f.get("path")]
|
|
287
|
+
if not rels:
|
|
288
|
+
return path, None
|
|
289
|
+
|
|
290
|
+
def _hits(base: str) -> int:
|
|
291
|
+
return sum(1 for r in rels if os.path.isfile(os.path.join(base, r)))
|
|
292
|
+
|
|
293
|
+
if _hits(path) > 0:
|
|
294
|
+
return path, None
|
|
295
|
+
|
|
296
|
+
tops = {r.replace("\\", "/").split("/", 1)[0]
|
|
297
|
+
for r in rels if "/" in r.replace("\\", "/")}
|
|
298
|
+
if len(tops) != 1:
|
|
299
|
+
return path, None
|
|
300
|
+
top = next(iter(tops))
|
|
301
|
+
abspath = os.path.abspath(path)
|
|
302
|
+
parent = os.path.dirname(abspath)
|
|
303
|
+
if os.path.basename(abspath) == top and _hits(parent) > 0:
|
|
304
|
+
return parent, (f"Note: '{path}' is the inner payload directory; "
|
|
305
|
+
f"verifying against its parent '{parent}'.")
|
|
306
|
+
return path, (f"Hint: no catalog files found under '{path}'. Pass the "
|
|
307
|
+
f"outer download directory (the one containing '{top}/').")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _do_verify(base_dir: str, files: list[dict], slug: str,
|
|
311
|
+
quant_level: Any, as_json: bool, header: str,
|
|
312
|
+
manifest_info: Optional[dict] = None) -> int:
|
|
313
|
+
if not files:
|
|
314
|
+
msg = (f"No files listed in the signed manifest for "
|
|
315
|
+
f"{slug}:{quant_level}.")
|
|
316
|
+
if as_json:
|
|
317
|
+
emit_json({"ok": False, "error": msg, "results": []})
|
|
318
|
+
else:
|
|
319
|
+
print(msg, file=sys.stderr)
|
|
320
|
+
return 3
|
|
321
|
+
|
|
322
|
+
if not as_json:
|
|
323
|
+
print(f"{header} {len(files)} file(s) against the Ed25519-verified "
|
|
324
|
+
f"manifest's SHA-256s ...")
|
|
325
|
+
results = verify_files(base_dir, files)
|
|
326
|
+
ok = all_ok(results)
|
|
327
|
+
|
|
328
|
+
if as_json:
|
|
329
|
+
emit_json({
|
|
330
|
+
"ok": ok, "slug": slug, "quant": quant_level,
|
|
331
|
+
"base_dir": base_dir,
|
|
332
|
+
"manifest": manifest_info or {"verified": False},
|
|
333
|
+
"results": [r.to_dict() for r in results],
|
|
334
|
+
})
|
|
335
|
+
else:
|
|
336
|
+
for r in results:
|
|
337
|
+
mark = {
|
|
338
|
+
"ok": "PASS", "mismatch": "FAIL", "missing": "MISSING",
|
|
339
|
+
"size_mismatch": "SIZE-FAIL", "no_hash": "NO-HASH",
|
|
340
|
+
"unsafe_path": "UNSAFE-PATH",
|
|
341
|
+
}.get(r.status, r.status.upper())
|
|
342
|
+
print(f" [{mark}] {r.path}")
|
|
343
|
+
if r.status == "unsafe_path":
|
|
344
|
+
print(" path escapes the target directory — "
|
|
345
|
+
"refused, not hashed")
|
|
346
|
+
if r.status == "mismatch":
|
|
347
|
+
print(f" expected {r.expected}")
|
|
348
|
+
print(f" actual {r.actual}")
|
|
349
|
+
print()
|
|
350
|
+
print("VERIFIED OK — every file matches the Ed25519-signed manifest "
|
|
351
|
+
"(signature checked against the key pinned in this llmt build)."
|
|
352
|
+
if ok else
|
|
353
|
+
"VERIFICATION FAILED — one or more files did not match the "
|
|
354
|
+
"signed manifest.")
|
|
355
|
+
return 0 if ok else 3
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# seed
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
def cmd_seed(args: argparse.Namespace) -> int:
|
|
362
|
+
client = _client(args)
|
|
363
|
+
slug, quant_level = parse_ref(args.ref)
|
|
364
|
+
if args.quant:
|
|
365
|
+
quant_level = args.quant
|
|
366
|
+
|
|
367
|
+
model = client.get_model(slug)["data"]
|
|
368
|
+
if is_gated(model):
|
|
369
|
+
raise LicenseGatedError(slug, model_page_of(model))
|
|
370
|
+
|
|
371
|
+
quant = select_quant(model, quant_level)
|
|
372
|
+
download = require_available_download(model, quant)
|
|
373
|
+
|
|
374
|
+
# M2: never seed a torrent whose signed manifest cannot be
|
|
375
|
+
# authenticated — raises (exit 15) before anything is handed to aria2c.
|
|
376
|
+
_verified_files(client, download, as_json=args.json)
|
|
377
|
+
|
|
378
|
+
magnet = download.get("magnet")
|
|
379
|
+
webseeds = download.get("webseeds") or []
|
|
380
|
+
seed_dir = args.path
|
|
381
|
+
|
|
382
|
+
exe = ensure_aria2c()
|
|
383
|
+
argv = build_aria2c_args(
|
|
384
|
+
magnet=magnet, out_dir=seed_dir, webseeds=webseeds,
|
|
385
|
+
trackers=args.tracker or [], seed=True, exe=exe,
|
|
386
|
+
)
|
|
387
|
+
if args.dry_run:
|
|
388
|
+
if args.json:
|
|
389
|
+
emit_json({"aria2c": argv, "seed_dir": seed_dir})
|
|
390
|
+
else:
|
|
391
|
+
print("Would run:\n " + " ".join(_shquote(a) for a in argv))
|
|
392
|
+
return 0
|
|
393
|
+
|
|
394
|
+
print(f"Seeding {slug}:{quant.get('quant_level')} from {seed_dir} "
|
|
395
|
+
f"(Ctrl-C to stop).")
|
|
396
|
+
return run_aria2c(argv)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _shquote(s: str) -> str:
|
|
400
|
+
if s == "" or any(c in s for c in ' "\'\\&|;<>()$`'):
|
|
401
|
+
return "'" + s.replace("'", "'\\''") + "'"
|
|
402
|
+
return s
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
# permissions (WP-14.4d supply-chain hardening: honest disclosure, D15)
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
def cmd_permissions(args: argparse.Namespace) -> int:
|
|
409
|
+
from .permissions import PERMISSIONS, permissions_text
|
|
410
|
+
if args.json:
|
|
411
|
+
emit_json(PERMISSIONS)
|
|
412
|
+
else:
|
|
413
|
+
print(permissions_text())
|
|
414
|
+
return 0
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
# argparse
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
421
|
+
p = argparse.ArgumentParser(
|
|
422
|
+
prog="llmt",
|
|
423
|
+
description="Find, download (BitTorrent + webseeds) and SHA-256-verify "
|
|
424
|
+
"LLM models from the LLM Torrents catalog.",
|
|
425
|
+
)
|
|
426
|
+
p.add_argument("--version", action="version",
|
|
427
|
+
version=f"llmt {__version__}")
|
|
428
|
+
|
|
429
|
+
def common(sp: argparse.ArgumentParser) -> None:
|
|
430
|
+
sp.add_argument("--api-base", default=None,
|
|
431
|
+
help=f"API base URL (default: env LLMT_API_BASE or "
|
|
432
|
+
f"{DEFAULT_API_BASE})")
|
|
433
|
+
sp.add_argument("--api-key", default=None,
|
|
434
|
+
help="Bearer token (env LLMT_API_KEY); raises rate limits.")
|
|
435
|
+
sp.add_argument("--timeout", type=int, default=30,
|
|
436
|
+
help="Per-request timeout in seconds (default 30).")
|
|
437
|
+
sp.add_argument("--json", action="store_true",
|
|
438
|
+
help="Machine-readable JSON output.")
|
|
439
|
+
|
|
440
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
441
|
+
|
|
442
|
+
sp = sub.add_parser("search", help="Search the catalog.")
|
|
443
|
+
sp.add_argument("query", help="Search text (matched by the API's q param).")
|
|
444
|
+
sp.add_argument("--fit", default=None, help="Fit filter passed to the API.")
|
|
445
|
+
sp.add_argument("--rig", action="append", default=[],
|
|
446
|
+
help="Rig spec as key=value (repeatable), e.g. --rig vram_gb=24.")
|
|
447
|
+
sp.add_argument("--per-page", type=int, default=24, choices=[12, 24, 48])
|
|
448
|
+
sp.add_argument("--sort", default=None)
|
|
449
|
+
common(sp)
|
|
450
|
+
sp.set_defaults(func=cmd_search)
|
|
451
|
+
|
|
452
|
+
sp = sub.add_parser("pull", help="Download + verify a model quant "
|
|
453
|
+
"(Ed25519-signed manifest).")
|
|
454
|
+
sp.add_argument("ref", help="slug or slug:quant (e.g. phi-4-mini:Q4_K_M).")
|
|
455
|
+
sp.add_argument("--quant", default=None,
|
|
456
|
+
help="Override quant level (else the ref's, else best).")
|
|
457
|
+
sp.add_argument("--output", "-o", default=None, help="Output directory.")
|
|
458
|
+
sp.add_argument("--tracker", action="append", default=[],
|
|
459
|
+
help="Extra BT tracker (repeatable).")
|
|
460
|
+
sp.add_argument("--dry-run", action="store_true",
|
|
461
|
+
help="Print the aria2c invocation and exit (no download).")
|
|
462
|
+
common(sp)
|
|
463
|
+
sp.set_defaults(func=cmd_pull)
|
|
464
|
+
|
|
465
|
+
sp = sub.add_parser("verify",
|
|
466
|
+
help="Re-verify downloaded files against the "
|
|
467
|
+
"Ed25519-signed manifest (no re-download).")
|
|
468
|
+
sp.add_argument("path",
|
|
469
|
+
help="Download directory from `llmt pull` (the outer "
|
|
470
|
+
"directory; the inner payload directory is also "
|
|
471
|
+
"accepted and auto-detected).")
|
|
472
|
+
sp.add_argument("ref", help="slug or slug:quant.")
|
|
473
|
+
sp.add_argument("--quant", default=None, help="Override quant level.")
|
|
474
|
+
common(sp)
|
|
475
|
+
sp.set_defaults(func=cmd_verify)
|
|
476
|
+
|
|
477
|
+
sp = sub.add_parser("seed", help="Seed an already-downloaded artifact "
|
|
478
|
+
"(requires a verified signed manifest).")
|
|
479
|
+
sp.add_argument("ref", help="slug or slug:quant.")
|
|
480
|
+
sp.add_argument("path", help="Directory holding the downloaded files.")
|
|
481
|
+
sp.add_argument("--quant", default=None, help="Override quant level.")
|
|
482
|
+
sp.add_argument("--tracker", action="append", default=[])
|
|
483
|
+
sp.add_argument("--dry-run", action="store_true")
|
|
484
|
+
common(sp)
|
|
485
|
+
sp.set_defaults(func=cmd_seed)
|
|
486
|
+
|
|
487
|
+
sp = sub.add_parser(
|
|
488
|
+
"scan",
|
|
489
|
+
help="Scan local model dirs; seed the ones you already have.")
|
|
490
|
+
add_scan_args(sp)
|
|
491
|
+
common(sp)
|
|
492
|
+
sp.set_defaults(func=cmd_scan)
|
|
493
|
+
|
|
494
|
+
sp = sub.add_parser(
|
|
495
|
+
"doctor",
|
|
496
|
+
help="Diagnose why your seeds aren't actually seeding "
|
|
497
|
+
"(client/port/DHT/trackers/caps/layout).")
|
|
498
|
+
add_doctor_args(sp)
|
|
499
|
+
common(sp)
|
|
500
|
+
sp.set_defaults(func=cmd_doctor)
|
|
501
|
+
|
|
502
|
+
sp = sub.add_parser(
|
|
503
|
+
"permissions",
|
|
504
|
+
help="Show exactly what llmt reads, writes, and sends. No network.")
|
|
505
|
+
sp.add_argument("--json", action="store_true",
|
|
506
|
+
help="Machine-readable JSON output.")
|
|
507
|
+
sp.set_defaults(func=cmd_permissions)
|
|
508
|
+
|
|
509
|
+
sp = sub.add_parser(
|
|
510
|
+
"telemetry",
|
|
511
|
+
help="Opt-in seeding telemetry (OFF by default): "
|
|
512
|
+
"enable | disable | status | whoami | beat.")
|
|
513
|
+
add_telemetry_args(sp)
|
|
514
|
+
|
|
515
|
+
return p
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
519
|
+
parser = build_parser()
|
|
520
|
+
args = parser.parse_args(argv)
|
|
521
|
+
try:
|
|
522
|
+
return args.func(args)
|
|
523
|
+
except LicenseGatedError as exc:
|
|
524
|
+
# D7 output: message + model_page, and ZERO artifacts.
|
|
525
|
+
if getattr(args, "json", False):
|
|
526
|
+
emit_json({
|
|
527
|
+
"error": "license_gated",
|
|
528
|
+
"slug": exc.slug,
|
|
529
|
+
"message": str(exc),
|
|
530
|
+
"model_page": exc.model_page,
|
|
531
|
+
})
|
|
532
|
+
else:
|
|
533
|
+
print(str(exc), file=sys.stderr)
|
|
534
|
+
if exc.model_page:
|
|
535
|
+
print(f"Accept the license here, then retry: {exc.model_page}",
|
|
536
|
+
file=sys.stderr)
|
|
537
|
+
else:
|
|
538
|
+
print("Visit the model's page on the catalog to accept the "
|
|
539
|
+
"license.", file=sys.stderr)
|
|
540
|
+
return exc.exit_code
|
|
541
|
+
except RateLimitedError as exc:
|
|
542
|
+
extra = (f" Retry after {exc.retry_after}s."
|
|
543
|
+
if exc.retry_after else "")
|
|
544
|
+
_fail(args, str(exc) + extra, "rate_limited")
|
|
545
|
+
return exc.exit_code
|
|
546
|
+
except LlmtError as exc:
|
|
547
|
+
_fail(args, str(exc), getattr(exc, "code", None) or "error")
|
|
548
|
+
return exc.exit_code
|
|
549
|
+
except KeyboardInterrupt:
|
|
550
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
551
|
+
return 130
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _fail(args: argparse.Namespace, message: str, code: str) -> None:
|
|
555
|
+
if getattr(args, "json", False):
|
|
556
|
+
emit_json({"error": code, "message": message})
|
|
557
|
+
else:
|
|
558
|
+
print(f"error: {message}", file=sys.stderr)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
if __name__ == "__main__":
|
|
562
|
+
sys.exit(main())
|