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/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())