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/models.py ADDED
@@ -0,0 +1,130 @@
1
+ """Model / quant resolution against the frozen API detail shape.
2
+
3
+ A quant reference is ``slug`` or ``slug:quant`` where ``quant`` matches a
4
+ quant's ``quant_level`` (case-insensitive), e.g. ``phi-4-mini:Q4_K_M``.
5
+
6
+ Download-block states (from ModelDetailResource):
7
+ "available" -> full public artifacts
8
+ "license_acceptance_required" -> D7 gate, only {model_page}
9
+ "not_available" -> no gate-passed torrent
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Optional
15
+
16
+ from .errors import (
17
+ LicenseGatedError,
18
+ NotAvailableError,
19
+ NotFoundError,
20
+ )
21
+
22
+
23
+ def parse_ref(ref: str) -> tuple[str, Optional[str]]:
24
+ """Split ``slug`` / ``slug:quant`` into (slug, quant_level|None)."""
25
+ if ":" in ref:
26
+ slug, quant = ref.split(":", 1)
27
+ return slug.strip(), (quant.strip() or None)
28
+ return ref.strip(), None
29
+
30
+
31
+ def model_page_of(model: dict[str, Any]) -> Optional[str]:
32
+ """Best-effort model_page URL for a gated model, without fabricating one.
33
+
34
+ Prefer the acceptance_url the API supplied; else a gated quant's
35
+ download.model_page. Returns None if the API gave us nothing.
36
+ """
37
+ lic = model.get("license") or {}
38
+ if lic.get("acceptance_url"):
39
+ return lic["acceptance_url"]
40
+ for q in model.get("quants") or []:
41
+ dl = q.get("download") or {}
42
+ if dl.get("model_page"):
43
+ return dl["model_page"]
44
+ return None
45
+
46
+
47
+ def is_gated(model: dict[str, Any]) -> bool:
48
+ return bool((model.get("license") or {}).get("gated"))
49
+
50
+
51
+ def _quant_size(q: dict[str, Any]) -> int:
52
+ for k in ("file_size_bytes", "size_bytes", "file_size"):
53
+ v = q.get(k)
54
+ if isinstance(v, (int, float)):
55
+ return int(v)
56
+ return 0
57
+
58
+
59
+ def select_quant(model: dict[str, Any], quant_level: Optional[str]) -> dict[str, Any]:
60
+ """Pick a quant from a model detail dict.
61
+
62
+ - explicit ``quant_level`` -> exact (case-insensitive) match or NotFound.
63
+ - otherwise "best": the largest quant that has an *available* download,
64
+ falling back to the largest quant overall so gate/availability errors
65
+ are still surfaced to the user with real context.
66
+ """
67
+ quants = model.get("quants") or []
68
+ slug = model.get("slug", "?")
69
+ if not quants:
70
+ raise NotFoundError(f"Model '{slug}' has no published quants.")
71
+
72
+ if quant_level is not None:
73
+ want = quant_level.lower()
74
+ for q in quants:
75
+ if str(q.get("quant_level", "")).lower() == want:
76
+ return q
77
+ available = ", ".join(str(q.get("quant_level")) for q in quants)
78
+ raise NotFoundError(
79
+ f"Quant '{quant_level}' not found for '{slug}'. Available: {available}"
80
+ )
81
+
82
+ # "best" — prefer an available quant, largest first.
83
+ def state(q: dict) -> str:
84
+ return (q.get("download") or {}).get("state", "not_available")
85
+
86
+ available = [q for q in quants if state(q) == "available"]
87
+ pool = available or quants
88
+ return max(pool, key=_quant_size)
89
+
90
+
91
+ def require_available_download(model: dict[str, Any], quant: dict[str, Any]) -> dict[str, Any]:
92
+ """Return the download block, or raise the right gate/availability error.
93
+
94
+ D7: for a gated model we NEVER read artifact fields — we raise
95
+ LicenseGatedError carrying only the model_page pointer.
96
+ """
97
+ slug = model.get("slug", "?")
98
+ dl = quant.get("download") or {}
99
+ state = dl.get("state")
100
+
101
+ if state == "license_acceptance_required" or is_gated(model):
102
+ raise LicenseGatedError(slug, dl.get("model_page") or model_page_of(model))
103
+
104
+ if state == "not_available" or not state:
105
+ raise NotAvailableError(
106
+ f"No gate-passed torrent available for "
107
+ f"'{slug}:{quant.get('quant_level')}'."
108
+ )
109
+ if state != "available":
110
+ raise NotAvailableError(
111
+ f"'{slug}:{quant.get('quant_level')}' is not downloadable "
112
+ f"(state={state})."
113
+ )
114
+ return dl
115
+
116
+
117
+ def files_of(download: dict[str, Any]) -> list[dict[str, Any]]:
118
+ """Normalise the download.files[] list to {path, sha256, size} dicts.
119
+
120
+ The frozen resource emits ``size_bytes``; we also tolerate ``size``.
121
+ """
122
+ out = []
123
+ for f in download.get("files") or []:
124
+ size = f.get("size_bytes", f.get("size"))
125
+ out.append({
126
+ "path": f.get("path"),
127
+ "sha256": (f.get("sha256") or "").lower(),
128
+ "size": int(size) if isinstance(size, (int, float)) else None,
129
+ })
130
+ return out
llmt/output.py ADDED
@@ -0,0 +1,51 @@
1
+ """Human + machine (``--json``) output helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+
10
+ def safe_text(s: Any) -> str:
11
+ """Strip control characters (ANSI escapes, BEL, CR, NUL, ...) from a
12
+ server-derived string before printing it to a terminal, so a hostile
13
+ ``torrent_name`` / quant label cannot inject escape sequences. JSON output
14
+ does not need this (``json.dump`` already escapes control chars)."""
15
+ if not isinstance(s, str):
16
+ s = str(s)
17
+ return "".join(ch for ch in s if ch >= " " and ch != "\x7f")
18
+
19
+
20
+ def emit_json(obj: Any) -> None:
21
+ json.dump(obj, sys.stdout, indent=2, sort_keys=False, default=str)
22
+ sys.stdout.write("\n")
23
+
24
+
25
+ def human_size(n: Any) -> str:
26
+ if not isinstance(n, (int, float)) or n <= 0:
27
+ return "-"
28
+ units = ["B", "KB", "MB", "GB", "TB", "PB"]
29
+ f = float(n)
30
+ for u in units:
31
+ if f < 1024 or u == units[-1]:
32
+ return f"{f:.1f}{u}" if u != "B" else f"{int(f)}B"
33
+ f /= 1024
34
+ return f"{f:.1f}PB"
35
+
36
+
37
+ def render_table(rows: list[list[str]], headers: list[str]) -> str:
38
+ """Render a fixed-width text table (no external deps)."""
39
+ cols = len(headers)
40
+ widths = [len(h) for h in headers]
41
+ for r in rows:
42
+ for i in range(cols):
43
+ widths[i] = max(widths[i], len(str(r[i])) if i < len(r) else 0)
44
+ line = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers))
45
+ sep = " ".join("-" * widths[i] for i in range(cols))
46
+ out = [line, sep]
47
+ for r in rows:
48
+ out.append(" ".join(
49
+ str(r[i] if i < len(r) else "").ljust(widths[i]) for i in range(cols)
50
+ ))
51
+ return "\n".join(out)
llmt/permissions.py ADDED
@@ -0,0 +1,154 @@
1
+ """llmt's permissions statement — what it reads, writes, and sends.
2
+
3
+ This is a static, honest declaration (Decision D15: brutal honesty) exposed
4
+ as ``llmt permissions``. It requires no network and touches no files. The
5
+ same statement lives in prose form in docs/PERMISSIONS.md; if you change one,
6
+ change both.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # Every environment variable the shipped CLI reads, and what it does.
12
+ # SINGLE SOURCE OF TRUTH for the env-var disclosure: the PERMISSIONS
13
+ # statement below embeds this list, docs/PERMISSIONS.md mirrors it, and
14
+ # tests/test_supply_chain.py greps src/ for `os.environ`/`getenv` reads and
15
+ # fails if this dict and the code ever disagree (in either direction).
16
+ # If you add an env read anywhere in src/llmt/, disclose it here AND in
17
+ # docs/PERMISSIONS.md, or the suite goes red.
18
+ ENV_VARS: dict = {
19
+ "LLMT_API_BASE": "catalog API base URL (same as --api-base)",
20
+ "LLMT_API_KEY": "optional catalog API key, sent as a bearer header "
21
+ "(same as --api-key)",
22
+ "LLMT_QB_URL": "qBittorrent WebUI URL (same as --qb-url)",
23
+ "LLMT_QB_USER": "qBittorrent WebUI username (same as --qb-user)",
24
+ "LLMT_QB_PASS": "qBittorrent WebUI PASSWORD — a credential, read from "
25
+ "your environment if set (same as --qb-pass)",
26
+ "LLMT_TR_URL": "Transmission RPC URL (same as --tr-url)",
27
+ "LLMT_TR_USER": "Transmission RPC username (same as --tr-user)",
28
+ "LLMT_TR_PASS": "Transmission RPC PASSWORD — a credential, read from "
29
+ "your environment if set (same as --tr-pass)",
30
+ "LLMT_HOME": "relocates llmt's state directory (default ~/.llmt)",
31
+ "LLMT_TELEMETRY_ENDPOINT": "per-run telemetry endpoint override for the "
32
+ "opt-in telemetry commands; never persisted",
33
+ "LLMT_AGENT_TOKEN": "telemetry token for `llmt telemetry enable` "
34
+ "(opt-in telemetry only)",
35
+ }
36
+
37
+ PERMISSIONS: dict = {
38
+ "statement_version": 4,
39
+ "reads": [
40
+ {"what": "The LLM Torrents catalog API (HTTPS GET only)",
41
+ "detail": "Base URL you control via --api-base / LLMT_API_BASE; "
42
+ "default https://llmtorrents.com/api. This includes the "
43
+ "Ed25519-signed manifest for a torrent, which "
44
+ "pull/verify/seed authenticate against a public key "
45
+ "pinned inside the llmt package (never fetched from the "
46
+ "network)."},
47
+ {"what": "Files and directories YOU pass on the command line",
48
+ "detail": "`pull -o DIR`, `verify DIR`, `seed REF DIR`: llmt reads "
49
+ "those bytes to hash or seed them. It never walks "
50
+ "anywhere you did not point it."},
51
+ {"what": "Environment variables — exactly these, nothing more: "
52
+ + ", ".join(ENV_VARS) + ".",
53
+ "detail": "LLMT_API_BASE / LLMT_API_KEY configure the catalog API. "
54
+ "The LLMT_QB_* / LLMT_TR_* variables configure your "
55
+ "torrent client's WebUI connection — that INCLUDES the "
56
+ "WebUI passwords (LLMT_QB_PASS, LLMT_TR_PASS) if you set "
57
+ "them; command-line flags always take precedence. "
58
+ "LLMT_HOME relocates ~/.llmt. LLMT_TELEMETRY_ENDPOINT and "
59
+ "LLMT_AGENT_TOKEN affect only the opt-in telemetry "
60
+ "commands. Beyond these, llmt reads no other "
61
+ "environment variable to configure its own behavior; "
62
+ "standard indirect lookups still apply, such as HOME "
63
+ "to resolve `~`, PATH to locate aria2c, and the env "
64
+ "vars the `requests` library honors (proxy, CA-bundle, "
65
+ "and netrc variables), as with any HTTP client."},
66
+ {"what": "Directories you point `llmt scan` at (read-only)",
67
+ "detail": "Hashes candidate model files where you say to look."},
68
+ {"what": "Your torrent client's WebUI on localhost",
69
+ "detail": "Only if you configure it, only on addresses you supply, "
70
+ "with credentials you supply — to add torrents and "
71
+ "trigger rechecks."},
72
+ ],
73
+ "writes": [
74
+ {"what": "Downloaded model files",
75
+ "detail": "Only into the output directory you choose for "
76
+ "`llmt pull` (default: current directory)."},
77
+ {"what": "Link farms under ~/.llmt/seeding-layouts/",
78
+ "detail": "Hardlinks/symlinks pointing at YOUR existing files so a "
79
+ "client can seed them in place. Never copies, never moves, "
80
+ "never modifies your originals."},
81
+ {"what": "Its own state under ~/.llmt/",
82
+ "detail": "An optional config file (~/.llmt/config.json) and, ONLY if "
83
+ "you opt into telemetry, the telemetry token "
84
+ "(~/.llmt/agent-token, mode 0600) plus a local artifact "
85
+ "hash cache. Nothing here is created until you set a config "
86
+ "value or run `llmt telemetry enable`."},
87
+ ],
88
+ "sends": [
89
+ {"what": "Catalog API requests",
90
+ "detail": "Your search text, model slugs you request, the info "
91
+ "hashes of torrents whose signed manifests it fetches "
92
+ "(pull/verify/seed), and — only if you set one — your "
93
+ "API key as a bearer header."},
94
+ {"what": "SHA-256 hashes to the artifact-lookup endpoint",
95
+ "detail": "When `llmt scan` runs, it sends ONLY the SHA-256 hashes "
96
+ "of files it scanned — never paths, never filenames, "
97
+ "never directory listings, never machine info."},
98
+ {"what": "BitTorrent traffic (via aria2c, a separate program)",
99
+ "detail": "`pull`/`seed` hand the magnet + webseeds to aria2c; your "
100
+ "IP is visible to trackers/peers exactly as with any "
101
+ "torrent client."},
102
+ {"what": "Opt-in seeding telemetry — OFF by default, only after "
103
+ "`llmt telemetry enable`",
104
+ "detail": "When (and only when) you explicitly opt in, heartbeats "
105
+ "send your torrents' infohashes/names/states/progress, the "
106
+ "SHA-256s of artifacts you seed, and server-challenged "
107
+ "byte-range hashes, plus your telemetry token as a bearer "
108
+ "header — to the telemetry endpoint you configure. NEVER "
109
+ "file paths, scan data, or machine info. Revoke any time "
110
+ "with `llmt telemetry disable` (which deletes the token); "
111
+ "view/delete server-side data on the website."},
112
+ ],
113
+ "environment": [
114
+ {"name": name, "why": why} for name, why in ENV_VARS.items()
115
+ ],
116
+ "never": [
117
+ "No HIDDEN telemetry: analytics, crash reporting and usage pings do "
118
+ "not exist. Seeding telemetry is a SEPARATE, explicit, off-by-default, "
119
+ "revocable opt-in (`llmt telemetry enable`) and never runs without "
120
+ "your consent; even then it sends only infohashes/names/states/"
121
+ "progress + artifact SHA-256s + challenge range hashes — never paths, "
122
+ "scan data, or machine fingerprints.",
123
+ "No update checks and NO self-update mechanism: llmt never replaces "
124
+ "its own code. You update it with pip or your package manager.",
125
+ "Never sends file paths, filenames, directory listings, hardware "
126
+ "or OS fingerprints.",
127
+ "Never reads or scans anything you did not explicitly point it at.",
128
+ "Never runs with elevated privileges or asks you to.",
129
+ ],
130
+ }
131
+
132
+
133
+ def permissions_text() -> str:
134
+ """Human-readable rendering of :data:`PERMISSIONS`."""
135
+ out = ["llmt permissions — what this tool reads, writes and sends",
136
+ "=" * 58, ""]
137
+ sections = [("READS", "reads"), ("WRITES", "writes"), ("SENDS", "sends")]
138
+ for title, key in sections:
139
+ out.append(title)
140
+ for item in PERMISSIONS[key]:
141
+ tag = " "
142
+ out.append(f"{tag}* {item['what']}")
143
+ out.append(f" {item['detail']}")
144
+ out.append("")
145
+ out.append("ENVIRONMENT VARIABLES READ (the complete list)")
146
+ for name, why in ENV_VARS.items():
147
+ out.append(f" * {name}: {why}")
148
+ out.append("")
149
+ out.append("NEVER")
150
+ for line in PERMISSIONS["never"]:
151
+ out.append(f" * {line}")
152
+ out.append("")
153
+ out.append("Full prose version: docs/PERMISSIONS.md in the source tree.")
154
+ return "\n".join(out)