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/telemetry/wire.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Client side of the FROZEN Agent Write-API contract v1 (WP-14.4-T).
|
|
2
|
+
|
|
3
|
+
Namespace: ``<endpoint>/agent/v1``. Auth: ``Authorization: Bearer llmta_...``
|
|
4
|
+
on every request. This module speaks EXACTLY the frozen wire and no more:
|
|
5
|
+
|
|
6
|
+
* POST /agent/v1/heartbeats -> accepted / unmatched / challenges
|
|
7
|
+
* POST /agent/v1/challenges/{nonce} -> pass|fail|expired|already-used
|
|
8
|
+
* GET /agent/v1/me -> user / scopes / telemetry_consent
|
|
9
|
+
|
|
10
|
+
Status handling: 401 -> TelemetryAuthError, 403 -> TelemetryScopeError,
|
|
11
|
+
429 -> RateLimitedError honoring ``retry_after_seconds`` (body) or Retry-After
|
|
12
|
+
(header). Unknown RESPONSE fields are ignored by callers; we send exactly the
|
|
13
|
+
frozen request fields (the server 422s unknown request fields).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
from urllib.parse import quote, urlsplit
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from ..errors import ApiError, RateLimitedError, TimeoutErrorLlmt
|
|
25
|
+
from .errors import TelemetryAuthError, TelemetryConfigError, TelemetryScopeError
|
|
26
|
+
|
|
27
|
+
_TOKEN_RE = re.compile(r"^llmta_[A-Za-z0-9_-]{40,64}$")
|
|
28
|
+
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
|
|
29
|
+
_NONCE_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,256}$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def looks_like_token(token: object) -> bool:
|
|
33
|
+
"""Best-effort shape check for ``llmta_`` + base64url(32 bytes).
|
|
34
|
+
|
|
35
|
+
32 bytes base64url is 43 chars (unpadded); we allow a small range to be
|
|
36
|
+
tolerant of padding/format drift. Used only for a friendly enable-time
|
|
37
|
+
warning — the server is the authority (a bad token 401s).
|
|
38
|
+
"""
|
|
39
|
+
return isinstance(token, str) and bool(_TOKEN_RE.match(token))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_https(base: str) -> None:
|
|
43
|
+
parts = urlsplit(base)
|
|
44
|
+
host = (parts.hostname or "").lower()
|
|
45
|
+
if parts.scheme == "https":
|
|
46
|
+
return
|
|
47
|
+
if parts.scheme == "http" and host in _LOOPBACK:
|
|
48
|
+
return
|
|
49
|
+
raise TelemetryConfigError(
|
|
50
|
+
f"refusing to send telemetry over a non-HTTPS endpoint: {base!r}. "
|
|
51
|
+
f"Use an https:// endpoint (MITM/token-theft defense).")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def clamp_retry_after(seconds: Optional[int],
|
|
55
|
+
max_seconds: int) -> Optional[int]:
|
|
56
|
+
"""Clamp a server-supplied 429 backoff to [0, max_seconds] so a hostile
|
|
57
|
+
huge value cannot make a --loop sleep effectively forever."""
|
|
58
|
+
if seconds is None:
|
|
59
|
+
return None
|
|
60
|
+
if max_seconds < 0:
|
|
61
|
+
max_seconds = 0
|
|
62
|
+
return max(0, min(int(seconds), int(max_seconds)))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _retry_after(resp: "requests.Response", payload: Any) -> Optional[int]:
|
|
66
|
+
# Body wins if present and sane, else the Retry-After header.
|
|
67
|
+
if isinstance(payload, dict):
|
|
68
|
+
ra = payload.get("retry_after_seconds")
|
|
69
|
+
if isinstance(ra, bool):
|
|
70
|
+
ra = None
|
|
71
|
+
if isinstance(ra, (int, float)) and ra >= 0:
|
|
72
|
+
return int(ra)
|
|
73
|
+
hdr = resp.headers.get("Retry-After")
|
|
74
|
+
if hdr is not None:
|
|
75
|
+
try:
|
|
76
|
+
return max(0, int(hdr))
|
|
77
|
+
except (ValueError, TypeError):
|
|
78
|
+
return None
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AgentClient:
|
|
83
|
+
"""Thin, dependency-light wrapper over the agent write API."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, endpoint: str, token: str, *, timeout: int = 30,
|
|
86
|
+
session: Optional[requests.Session] = None):
|
|
87
|
+
endpoint = (endpoint or "").rstrip("/")
|
|
88
|
+
if not endpoint:
|
|
89
|
+
raise TelemetryConfigError("no telemetry endpoint configured.")
|
|
90
|
+
self.endpoint = endpoint
|
|
91
|
+
self.base = endpoint + "/agent/v1"
|
|
92
|
+
_require_https(self.base)
|
|
93
|
+
if not token:
|
|
94
|
+
raise TelemetryConfigError(
|
|
95
|
+
"no telemetry token stored; run `llmt telemetry enable`.")
|
|
96
|
+
self.token = token
|
|
97
|
+
self.timeout = timeout
|
|
98
|
+
self.session = session or requests.Session()
|
|
99
|
+
|
|
100
|
+
# ---- low level ------------------------------------------------------
|
|
101
|
+
def _headers(self, *, body: bool = False) -> dict[str, str]:
|
|
102
|
+
h = {
|
|
103
|
+
"Accept": "application/json",
|
|
104
|
+
"User-Agent": "llmt-cli",
|
|
105
|
+
"Authorization": f"Bearer {self.token}",
|
|
106
|
+
}
|
|
107
|
+
if body:
|
|
108
|
+
h["Content-Type"] = "application/json"
|
|
109
|
+
return h
|
|
110
|
+
|
|
111
|
+
def _request(self, method: str, path: str,
|
|
112
|
+
json_body: Optional[dict] = None) -> dict[str, Any]:
|
|
113
|
+
url = f"{self.base}/{path.lstrip('/')}"
|
|
114
|
+
try:
|
|
115
|
+
resp = self.session.request(
|
|
116
|
+
method, url, json=json_body,
|
|
117
|
+
headers=self._headers(body=json_body is not None),
|
|
118
|
+
timeout=self.timeout,
|
|
119
|
+
allow_redirects=False,
|
|
120
|
+
)
|
|
121
|
+
except requests.Timeout as exc:
|
|
122
|
+
raise TimeoutErrorLlmt(
|
|
123
|
+
f"telemetry request timed out after {self.timeout}s: {url}"
|
|
124
|
+
) from exc
|
|
125
|
+
except requests.RequestException as exc:
|
|
126
|
+
raise ApiError(f"telemetry request failed: {exc}") from exc
|
|
127
|
+
return self._handle(resp, url)
|
|
128
|
+
|
|
129
|
+
def _handle(self, resp: "requests.Response", url: str) -> dict[str, Any]:
|
|
130
|
+
payload: Any = None
|
|
131
|
+
try:
|
|
132
|
+
payload = resp.json()
|
|
133
|
+
except ValueError:
|
|
134
|
+
payload = None
|
|
135
|
+
msg = None
|
|
136
|
+
if isinstance(payload, dict):
|
|
137
|
+
err = payload.get("error")
|
|
138
|
+
if isinstance(err, dict):
|
|
139
|
+
msg = err.get("message")
|
|
140
|
+
elif isinstance(err, str):
|
|
141
|
+
msg = err
|
|
142
|
+
msg = msg or payload.get("message")
|
|
143
|
+
|
|
144
|
+
sc = resp.status_code
|
|
145
|
+
if 200 <= sc < 300:
|
|
146
|
+
return payload if isinstance(payload, dict) else {}
|
|
147
|
+
if sc == 401:
|
|
148
|
+
raise TelemetryAuthError(
|
|
149
|
+
msg or "telemetry token missing/unknown/revoked (401). "
|
|
150
|
+
"Re-issue a token on the website and run "
|
|
151
|
+
"`llmt telemetry enable` again.")
|
|
152
|
+
if sc == 403:
|
|
153
|
+
raise TelemetryScopeError(
|
|
154
|
+
msg or "telemetry token lacks the telemetry:write scope (403).")
|
|
155
|
+
if sc == 429:
|
|
156
|
+
raise RateLimitedError(
|
|
157
|
+
msg or "telemetry rate-limited (429).",
|
|
158
|
+
retry_after=_retry_after(resp, payload),
|
|
159
|
+
code="rate_limited", status=429,
|
|
160
|
+
)
|
|
161
|
+
if sc == 422:
|
|
162
|
+
raise ApiError(msg or "telemetry request rejected (422).",
|
|
163
|
+
code="unprocessable", status=422)
|
|
164
|
+
if 300 <= sc < 400:
|
|
165
|
+
raise ApiError(
|
|
166
|
+
f"telemetry endpoint returned a redirect (HTTP {sc}); refusing "
|
|
167
|
+
f"to re-send heartbeat data to "
|
|
168
|
+
f"{resp.headers.get('Location') or 'another host'}. "
|
|
169
|
+
f"Point telemetry.endpoint at the final HTTPS URL.",
|
|
170
|
+
code="redirect", status=sc)
|
|
171
|
+
raise ApiError(
|
|
172
|
+
msg or f"unexpected telemetry response (HTTP {sc}).", status=sc)
|
|
173
|
+
|
|
174
|
+
# ---- endpoints ------------------------------------------------------
|
|
175
|
+
def heartbeat(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
176
|
+
"""POST /agent/v1/heartbeats. ``payload`` MUST already be the exact
|
|
177
|
+
frozen wire object (see beat.build_heartbeat_payload)."""
|
|
178
|
+
return self._request("POST", "/heartbeats", json_body=payload)
|
|
179
|
+
|
|
180
|
+
def answer_challenge(self, nonce: str, hash_hex: str) -> str:
|
|
181
|
+
"""POST /agent/v1/challenges/{nonce} with {"hash": hex64}. Returns the
|
|
182
|
+
result string ("pass"|"fail"|"expired"|"already-used")."""
|
|
183
|
+
if not isinstance(nonce, str) or not _NONCE_RE.match(nonce):
|
|
184
|
+
raise ApiError("refusing a malformed challenge nonce.")
|
|
185
|
+
resp = self._request(
|
|
186
|
+
"POST", f"/challenges/{quote(nonce, safe='')}",
|
|
187
|
+
json_body={"hash": hash_hex},
|
|
188
|
+
)
|
|
189
|
+
result = resp.get("result")
|
|
190
|
+
return result if isinstance(result, str) else "unknown"
|
|
191
|
+
|
|
192
|
+
def me(self) -> dict[str, Any]:
|
|
193
|
+
"""GET /agent/v1/me — identity + scopes + telemetry_consent."""
|
|
194
|
+
return self._request("GET", "/me")
|
llmt/verify.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""SHA-256 verification — the core trust feature of llmt.
|
|
2
|
+
|
|
3
|
+
Given a base directory and a ``files[]`` list (each entry has a ``path``
|
|
4
|
+
and an expected ``sha256`` — since Fix M2 the CLI sources these from the
|
|
5
|
+
Ed25519-VERIFIED manifest, see llmt.manifest), we hash every file on disk in streaming
|
|
6
|
+
64 KiB chunks and compare, constant work per byte, no whole-file buffering.
|
|
7
|
+
|
|
8
|
+
A single mismatch, a missing file, or a wrong size is a FAILURE. ``pull`` and
|
|
9
|
+
``verify`` both exit non-zero when any file fails — that non-zero exit is the
|
|
10
|
+
signal an automated pipeline keys on.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
from .layout import LayoutSafetyError, safe_join, sanitize_relpath
|
|
21
|
+
|
|
22
|
+
_CHUNK = 64 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha256_file(path: str) -> str:
|
|
26
|
+
h = hashlib.sha256()
|
|
27
|
+
with open(path, "rb") as fh:
|
|
28
|
+
for chunk in iter(lambda: fh.read(_CHUNK), b""):
|
|
29
|
+
h.update(chunk)
|
|
30
|
+
return h.hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class FileResult:
|
|
35
|
+
path: str
|
|
36
|
+
expected: str
|
|
37
|
+
actual: Optional[str]
|
|
38
|
+
size_expected: Optional[int]
|
|
39
|
+
size_actual: Optional[int]
|
|
40
|
+
status: str # "ok" | "mismatch" | "missing" | "size_mismatch" | "no_hash" | "unsafe_path"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def ok(self) -> bool:
|
|
44
|
+
return self.status == "ok"
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"path": self.path,
|
|
49
|
+
"status": self.status,
|
|
50
|
+
"expected_sha256": self.expected,
|
|
51
|
+
"actual_sha256": self.actual,
|
|
52
|
+
"expected_size": self.size_expected,
|
|
53
|
+
"actual_size": self.size_actual,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_catalog_path(base_dir: str, rel: str) -> str:
|
|
58
|
+
"""Resolve an untrusted catalog ``files[].path`` strictly under ``base_dir``.
|
|
59
|
+
|
|
60
|
+
Catalog paths are relative by contract. A hostile API response must not be
|
|
61
|
+
able to point verification at a file OUTSIDE the target directory (e.g.
|
|
62
|
+
``/etc/hostname`` or ``../../secret``) — hashing such a file and printing
|
|
63
|
+
"VERIFIED OK" would attest to bytes the user never downloaded. Absolute
|
|
64
|
+
paths, ``..`` traversal, and anything else that resolves outside
|
|
65
|
+
``base_dir`` raise :class:`~llmt.errors.LayoutSafetyError` (fail closed).
|
|
66
|
+
"""
|
|
67
|
+
if rel.startswith(("/", "\\")) or os.path.isabs(rel) or (
|
|
68
|
+
len(rel) >= 2 and rel[1] == ":"):
|
|
69
|
+
raise LayoutSafetyError(f"absolute catalog path rejected: {rel!r}")
|
|
70
|
+
segments = sanitize_relpath(rel) # rejects '..', NULs, ':', control chars
|
|
71
|
+
return safe_join(os.path.abspath(base_dir), segments)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def verify_files(base_dir: str, files: list[dict[str, Any]]) -> list[FileResult]:
|
|
75
|
+
"""Verify each catalog file entry against disk under ``base_dir``."""
|
|
76
|
+
results: list[FileResult] = []
|
|
77
|
+
for f in files:
|
|
78
|
+
rel = f.get("path")
|
|
79
|
+
expected = (f.get("sha256") or "").lower()
|
|
80
|
+
size_expected = f.get("size")
|
|
81
|
+
|
|
82
|
+
full: Optional[str] = None
|
|
83
|
+
if rel:
|
|
84
|
+
try:
|
|
85
|
+
full = resolve_catalog_path(base_dir, rel)
|
|
86
|
+
except LayoutSafetyError:
|
|
87
|
+
# Fail closed: a hostile catalog path must never make us hash
|
|
88
|
+
# (let alone attest) a file outside base_dir.
|
|
89
|
+
results.append(FileResult(
|
|
90
|
+
path=rel, expected=expected, actual=None,
|
|
91
|
+
size_expected=size_expected, size_actual=None,
|
|
92
|
+
status="unsafe_path",
|
|
93
|
+
))
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
if full is None or not os.path.isfile(full):
|
|
97
|
+
results.append(FileResult(
|
|
98
|
+
path=rel, expected=expected, actual=None,
|
|
99
|
+
size_expected=size_expected, size_actual=None, status="missing",
|
|
100
|
+
))
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
size_actual = os.path.getsize(full)
|
|
104
|
+
if not expected:
|
|
105
|
+
# The API gave us no hash — we cannot vouch for it. Report loudly
|
|
106
|
+
# rather than silently pass (never fabricate a "safe" verdict).
|
|
107
|
+
results.append(FileResult(
|
|
108
|
+
path=rel, expected="", actual=sha256_file(full),
|
|
109
|
+
size_expected=size_expected, size_actual=size_actual,
|
|
110
|
+
status="no_hash",
|
|
111
|
+
))
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
actual = sha256_file(full)
|
|
115
|
+
if actual != expected:
|
|
116
|
+
status = "mismatch"
|
|
117
|
+
elif size_expected is not None and size_actual != size_expected:
|
|
118
|
+
status = "size_mismatch"
|
|
119
|
+
else:
|
|
120
|
+
status = "ok"
|
|
121
|
+
results.append(FileResult(
|
|
122
|
+
path=rel, expected=expected, actual=actual,
|
|
123
|
+
size_expected=size_expected, size_actual=size_actual, status=status,
|
|
124
|
+
))
|
|
125
|
+
return results
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def all_ok(results: list[FileResult]) -> bool:
|
|
129
|
+
return bool(results) and all(r.ok for r in results)
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llmt-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Find, download (over BitTorrent + webseeds), and SHA-256-verify LLM models from the LLM Torrents catalog.
|
|
5
|
+
Author: LLM Torrents
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://llmtorrents.com
|
|
8
|
+
Project-URL: Source, https://llmtorrents.com
|
|
9
|
+
Keywords: llm,torrent,bittorrent,webseed,sha256,ed25519,models,aria2
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Communications :: File Sharing
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: requests>=2.25
|
|
21
|
+
Requires-Dist: PyNaCl>=1.5
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
Requires-Dist: requests-mock>=1.9; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# llmt — LLM Torrents command-line client
|
|
28
|
+
|
|
29
|
+
`llmt` finds LLM models in the [LLM Torrents](https://llmtorrents.com) catalog,
|
|
30
|
+
downloads them over **BitTorrent with HTTP webseeds**, and — the point of the
|
|
31
|
+
whole thing — **verifies what you got**: it fetches the catalog's
|
|
32
|
+
**Ed25519-signed manifest** for the torrent, checks the signature against a
|
|
33
|
+
public key **pinned inside this package** (not fetched from the network), and
|
|
34
|
+
then verifies every downloaded file's SHA-256 against that verified manifest.
|
|
35
|
+
If a byte is wrong — or the manifest cannot be authenticated — `llmt` tells
|
|
36
|
+
you and exits non-zero.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install llmt-cli # from PyPI (once published; installs the `llmt` command)
|
|
42
|
+
# or, from a checkout:
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python ≥ 3.9 and [`aria2c`](https://aria2.github.io/) on your `PATH`
|
|
47
|
+
for downloading/seeding (`apt install aria2`, `brew install aria2`, …). If
|
|
48
|
+
`aria2c` is missing, `llmt` prints a clear message instead of crashing.
|
|
49
|
+
|
|
50
|
+
## Security & supply chain
|
|
51
|
+
|
|
52
|
+
`llmt` is high-trust software (it hashes your disk and drives a torrent
|
|
53
|
+
client), so it is built to be audited, not trusted:
|
|
54
|
+
|
|
55
|
+
- Catalog manifests are **Ed25519-signed**; `pull`/`verify`/`seed` verify the
|
|
56
|
+
signature against the public key **pinned in the package**
|
|
57
|
+
(`llmt/data/manifest-pubkey.json`) — the trust root ships with the code and
|
|
58
|
+
is never fetched from the network at runtime. A manifest that cannot be
|
|
59
|
+
authenticated (unreachable, malformed, bad signature, wrong/rotated key, or
|
|
60
|
+
disagreeing with the catalog) is **never reported as verified**: distinct
|
|
61
|
+
error, exit `15`. Key rotation ships as a new `llmt` release.
|
|
62
|
+
- `llmt permissions` prints exactly what the tool reads, writes, and sends
|
|
63
|
+
(no telemetry, **no self-update** — enforced by unit test). Prose version:
|
|
64
|
+
[docs/PERMISSIONS.md](docs/PERMISSIONS.md).
|
|
65
|
+
- Dependencies are pinned: `pip install llmt-cli -c constraints.txt`, or
|
|
66
|
+
fully hash-verified in two steps (the lock file covers the dependency
|
|
67
|
+
closure only — `llmt` itself is not in it, so install it separately):
|
|
68
|
+
`pip install --require-hashes -r requirements.lock && pip install --no-deps .`
|
|
69
|
+
- Releases ship a `SHA256SUMS` file (checksums published on
|
|
70
|
+
llmtorrents.com); build + signing process: [docs/RELEASING.md](docs/RELEASING.md).
|
|
71
|
+
- Vulnerability reports: [SECURITY.md](SECURITY.md).
|
|
72
|
+
- Docker image (pinned base, non-root) and homelab recipes:
|
|
73
|
+
[docs/recipes/](docs/recipes/).
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
llmt search "llama" # readable table of matches
|
|
79
|
+
llmt search "qwen" --fit --rig vram_gb=24 # fit verdict for your rig
|
|
80
|
+
llmt search "phi" --json # machine-readable
|
|
81
|
+
|
|
82
|
+
llmt pull microsoft-phi-4-mini # best quant -> download -> verify
|
|
83
|
+
llmt pull microsoft-phi-4-mini:Q4_K_M # a specific quant
|
|
84
|
+
llmt pull acme-tiny-1b --dry-run # show the exact aria2c command
|
|
85
|
+
|
|
86
|
+
llmt verify ./acme-tiny-1b-Q4_K_M acme-tiny-1b:Q4_K_M # re-check, no download
|
|
87
|
+
llmt seed acme-tiny-1b:Q4_K_M ./acme-tiny-1b-Q4_K_M # help the swarm
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Global flags on every command: `--json`, `--api-base`, `--api-key`,
|
|
91
|
+
`--timeout`. Configure via env: `LLMT_API_BASE`, `LLMT_API_KEY`
|
|
92
|
+
(the complete list of every env var `llmt` reads is in
|
|
93
|
+
[docs/PERMISSIONS.md](docs/PERMISSIONS.md) and `llmt permissions`).
|
|
94
|
+
An API key is optional and only **raises your rate limits** — all reads are
|
|
95
|
+
public.
|
|
96
|
+
|
|
97
|
+
## The trust story (signed manifest + SHA-256)
|
|
98
|
+
|
|
99
|
+
A torrent's own piece hashes only prove the bytes match *that* `.torrent`.
|
|
100
|
+
They don't prove the `.torrent` describes the model the catalog vouched for
|
|
101
|
+
— and TLS alone only proves who you *talked to*, not that the file list is
|
|
102
|
+
the one the catalog *signed*. `llmt` closes both gaps:
|
|
103
|
+
|
|
104
|
+
1. **The Ed25519-signed manifest is the source of truth.** For a non-gated
|
|
105
|
+
model, `llmt` fetches the torrent's manifest
|
|
106
|
+
(`GET /api/manifests/{info_hash}`) and **verifies its detached Ed25519
|
|
107
|
+
signature** against the public key pinned inside this package
|
|
108
|
+
(`llmt/data/manifest-pubkey.json`, per the open
|
|
109
|
+
[manifest spec](https://llmtorrents.com/schema/manifest-v0.1.0.json)).
|
|
110
|
+
It also checks that the signed document actually names the torrent being
|
|
111
|
+
verified, and that the API's `download.files[]` block agrees with the
|
|
112
|
+
signed manifest.
|
|
113
|
+
2. **After the download**, `llmt` streams each file through SHA-256 (64 KiB
|
|
114
|
+
chunks, no whole-file buffering) and compares byte-for-byte against the
|
|
115
|
+
**verified manifest's** hash. This is a real, independent check — not
|
|
116
|
+
aria2c's piece check.
|
|
117
|
+
3. **Any mismatch, missing file, or wrong size fails the run** and `llmt`
|
|
118
|
+
exits non-zero (`3`). A tampered or truncated file cannot pass silently.
|
|
119
|
+
A file for which no hash was supplied is flagged `NO-HASH`, never waved
|
|
120
|
+
through.
|
|
121
|
+
4. **An unauthenticatable manifest fails the run before any hashing** —
|
|
122
|
+
unreachable endpoint (e.g. offline), malformed document, invalid
|
|
123
|
+
signature, unknown/rotated signing key, wrong torrent, or a
|
|
124
|
+
catalog/manifest disagreement each produce a distinct error and exit
|
|
125
|
+
`15`. `llmt` never silently falls back to trusting unsigned hashes over
|
|
126
|
+
TLS. (If the key was rotated, upgrading to the newest `llmt` release
|
|
127
|
+
picks up the new pinned key.)
|
|
128
|
+
5. **`llmt verify`** re-runs exactly this check on already-downloaded files
|
|
129
|
+
— including the manifest signature check, so it needs the catalog API to
|
|
130
|
+
be reachable; there is no offline "verified". `llmt seed` refuses to
|
|
131
|
+
seed a torrent whose manifest cannot be authenticated.
|
|
132
|
+
|
|
133
|
+
Because a wrong file trips a non-zero exit, `llmt pull` / `llmt verify` drop
|
|
134
|
+
straight into a pipeline: if it exits `0`, every file matched the
|
|
135
|
+
Ed25519-verified manifest.
|
|
136
|
+
|
|
137
|
+
## License-gated models (why some models won't download)
|
|
138
|
+
|
|
139
|
+
Some models are distributed under a click-through license. For those, the
|
|
140
|
+
catalog API serves **no download artifacts at all** — no magnet, no hashes, no
|
|
141
|
+
file list. `llmt` detects this and tells you to accept the license on the
|
|
142
|
+
model's page first:
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
$ llmt pull meta-llama-llama-3-3-70b-instruct
|
|
146
|
+
'meta-llama-llama-3-3-70b-instruct' is license-gated. The catalog API does not
|
|
147
|
+
serve download artifacts for gated models.
|
|
148
|
+
Accept the license here, then retry: https://llmtorrents.com/models/meta-llama-llama-3-3-70b-instruct
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`llmt` will never fabricate a magnet or hash to work around a gate.
|
|
152
|
+
|
|
153
|
+
## How the download works (aria2c + webseeds)
|
|
154
|
+
|
|
155
|
+
`llmt` delegates the transfer to `aria2c`, passing the catalog's magnet URI
|
|
156
|
+
plus every webseed URL as trailing HTTP URIs, so aria2c uses the project's
|
|
157
|
+
HTTP mirrors as web-seeds alongside the BitTorrent swarm. Roughly:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
aria2c --dir <out> --enable-dht=true --bt-enable-lpd=true \
|
|
161
|
+
--check-integrity=true --max-connection-per-server=8 --seed-time=0 \
|
|
162
|
+
[--bt-tracker <tracker> ...] \
|
|
163
|
+
<magnet> <webseed-url-1> <webseed-url-2> ...
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Use `llmt pull ... --dry-run` to print the exact command for your model.
|
|
167
|
+
|
|
168
|
+
### Known webseed quirks (why aria2c may exit non-zero on a good download)
|
|
169
|
+
|
|
170
|
+
Both HTTP origins serve the model files but **403 the bare base URL**
|
|
171
|
+
(directory listings are disabled by design), and hybrid v1+v2 torrents
|
|
172
|
+
contain BEP-47 `.pad` entries that **404 on the HTTP mirrors** (often seen as
|
|
173
|
+
a "stall" at 98-99% in aria2c's progress summary). Either can make `aria2c`
|
|
174
|
+
exit non-zero (e.g. code 22) even though every payload byte arrived.
|
|
175
|
+
|
|
176
|
+
`llmt pull` handles this: when aria2c exits non-zero it checks that every
|
|
177
|
+
expected file is present on disk at its expected size. If so, it notes the
|
|
178
|
+
quirk on stderr and proceeds straight to the full SHA-256 verification — the
|
|
179
|
+
hash check against the signed manifest, not aria2c's exit code, is the trust
|
|
180
|
+
signal. If files are missing or the wrong size, the download really did fail
|
|
181
|
+
and `llmt` exits `10` loudly.
|
|
182
|
+
|
|
183
|
+
`llmt verify <dir> <ref>` accepts either the **outer** download directory
|
|
184
|
+
(the one `pull` created) or the **inner** payload directory the torrent
|
|
185
|
+
unpacked inside it — it detects which you passed. If it can't find the files
|
|
186
|
+
at all, it tells you exactly which directory to pass.
|
|
187
|
+
|
|
188
|
+
## Seeding your existing models (the layout mapper)
|
|
189
|
+
|
|
190
|
+
SHA-256 tells you a file *is* the right bytes, but a BitTorrent client won't
|
|
191
|
+
seed those bytes unless they appear at the exact path the torrent expects,
|
|
192
|
+
under the torrent's root folder. Your model lives at
|
|
193
|
+
`/models/qwen3-30b/model.gguf`; the torrent wants
|
|
194
|
+
`qwen3-30b-a3b-q4-k-m/Qwen3-30B-A3B-Q4_K_M.gguf`. The layout mapper plans how to
|
|
195
|
+
present your existing file to the client **without moving or copying it**.
|
|
196
|
+
|
|
197
|
+
- **Direct-use:** if your file already sits at the expected relative path, the
|
|
198
|
+
plan is just "add the torrent with this save path" — no links at all.
|
|
199
|
+
- **Link farm (default for mismatched layouts):** the mapper builds a tiny tree
|
|
200
|
+
under `~/.llmt/seeding-layouts/<torrent-root>/…` whose entries are hard/sym
|
|
201
|
+
**links** back to your real file. Your model folder stays your model folder;
|
|
202
|
+
the farm is just a set of pointers the client can follow. Save path is the
|
|
203
|
+
layouts dir; the client rechecks, sees 100%, and seeds. Model weights are
|
|
204
|
+
never re-downloaded and nothing is duplicated on disk. One honest caveat for
|
|
205
|
+
multi-file torrents you hold only partially: small companion files
|
|
206
|
+
(license/README-class, at most 10 MiB total per torrent) may be fetched by
|
|
207
|
+
your client, and `llmt scan` prints a notice naming them and where they land
|
|
208
|
+
first; any larger missing file is marked "do not download" in your client
|
|
209
|
+
before the torrent can resume, with a warning that it will seed only what
|
|
210
|
+
you have and show as incomplete.
|
|
211
|
+
|
|
212
|
+
Hardlinks are preferred whenever the file and the farm are on the same
|
|
213
|
+
filesystem/volume — they need no privileges and every client follows them. A
|
|
214
|
+
symlink is the labelled cross-volume fallback.
|
|
215
|
+
|
|
216
|
+
| Platform | Same volume | Cross volume |
|
|
217
|
+
| --- | --- | --- |
|
|
218
|
+
| Linux / macOS | hardlink | symlink |
|
|
219
|
+
| Windows | hardlink (same drive, no admin) | symlink — needs **Administrator or Developer Mode** (NTFS junctions redirect directories only, not files) |
|
|
220
|
+
|
|
221
|
+
The client must **force-recheck** after adding so it verifies the linked bytes
|
|
222
|
+
and reaches 100%. The mapper refuses any path that would escape the layouts dir
|
|
223
|
+
(`..`, absolute/drive-letter/UNC tricks, reserved names, null bytes) and never
|
|
224
|
+
silently replaces an existing link that points somewhere else.
|
|
225
|
+
|
|
226
|
+
**Dockerized or remote clients (seedbox / unRAID):** a qBittorrent or
|
|
227
|
+
Transmission running in a container only sees its own **mounted volumes**, not
|
|
228
|
+
host paths like `~/.llmt/seeding-layouts`. If the link farm lives on a path the
|
|
229
|
+
client cannot see, its force-recheck reads **0%** and `llmt scan` **fails
|
|
230
|
+
closed** (it never resumes an unverified torrent). Point the farm at a directory
|
|
231
|
+
the client has mounted with `--layout-base`, e.g.
|
|
232
|
+
`llmt scan --layout-base /mnt/user/appdata/qbittorrent/llmt-layouts`. `llmt
|
|
233
|
+
scan` also prints an early advisory when it detects the client's own default
|
|
234
|
+
save path does not exist on the machine llmt is running on (a sign the two are
|
|
235
|
+
on different filesystems), and `llmt doctor` reports a `layout-base-visible`
|
|
236
|
+
check that WARNs in the same situation.
|
|
237
|
+
|
|
238
|
+
## Opt-in seeding telemetry (off by default)
|
|
239
|
+
|
|
240
|
+
Telemetry lets the LLM Torrents site **credit you for the artifacts you
|
|
241
|
+
seed**. It is a *separate, explicit, off-by-default, revocable* opt-in — it
|
|
242
|
+
never runs until you turn it on, and it never sends file paths or scan data.
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
llmt telemetry enable # prints the exact consent statement, then
|
|
246
|
+
# prompts for your token (from the website)
|
|
247
|
+
llmt telemetry enable --yes --token llmta_xx… # non-interactive (scripts)
|
|
248
|
+
llmt telemetry status # consent state, token presence, last beat
|
|
249
|
+
llmt telemetry whoami # who the stored token belongs to (GET /me)
|
|
250
|
+
llmt telemetry beat --qb-url http://localhost:8080 # send one heartbeat
|
|
251
|
+
llmt telemetry beat --loop # keep beating every 15 min (or --interval N)
|
|
252
|
+
llmt telemetry disable # stop sending AND delete the stored token
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`beat` reads the torrents from your client (`--qb-url`/`--tr-url` or
|
|
256
|
+
`LLMT_QB_URL`/`LLMT_TR_URL`, same flags as `scan`/`doctor`), reports each
|
|
257
|
+
torrent's infohashes (v1 **and** v2 for hybrid torrents), name, state and
|
|
258
|
+
progress plus the SHA-256s of the artifacts you hold, and answers any
|
|
259
|
+
possession challenges the server issues by hashing a server-chosen byte range
|
|
260
|
+
of the local file. Run it from `cron` (the primary mode) or with `--loop`.
|
|
261
|
+
|
|
262
|
+
**What is sent:** torrent infohashes/names/states/progress, artifact
|
|
263
|
+
SHA-256s, and challenge byte-range hashes — nothing else.
|
|
264
|
+
**What is never sent:** file paths, filenames, scan data, or machine info.
|
|
265
|
+
The token is stored at `~/.llmt/agent-token` (mode `0600`) and is never
|
|
266
|
+
printed after it is saved. View or delete your server-side data on the
|
|
267
|
+
website (Account -> Telemetry). See [docs/PERMISSIONS.md](docs/PERMISSIONS.md)
|
|
268
|
+
and `llmt permissions` for the full statement.
|
|
269
|
+
|
|
270
|
+
Config keys (in `~/.llmt/config.json`, all under `telemetry`):
|
|
271
|
+
`enabled` (default `false`), `endpoint` (default `https://llmtorrents.com`),
|
|
272
|
+
`interval_minutes` (`15`), `max_torrents_per_heartbeat` (`500`),
|
|
273
|
+
`report_artifacts` (`true`), `token_path`, `challenge_chunk_size`.
|
|
274
|
+
Override the endpoint per-run with `--endpoint` or `LLMT_TELEMETRY_ENDPOINT`.
|
|
275
|
+
|
|
276
|
+
## Exit codes
|
|
277
|
+
|
|
278
|
+
| code | meaning |
|
|
279
|
+
|------|---------|
|
|
280
|
+
| 0 | success / all files verified |
|
|
281
|
+
| 2 | generic API error (incl. 403) |
|
|
282
|
+
| 3 | **SHA-256 verification failed** |
|
|
283
|
+
| 4 | not found (404) |
|
|
284
|
+
| 5 | rate limited (429; respects `Retry-After`) |
|
|
285
|
+
| 6 | request timed out |
|
|
286
|
+
| 7 | model is license-gated (D7) |
|
|
287
|
+
| 8 | no gate-passed torrent available |
|
|
288
|
+
| 9 | `aria2c` not installed |
|
|
289
|
+
| 10 | `aria2c` failed and the download is incomplete |
|
|
290
|
+
| 14 | torrent-client add/connection failed |
|
|
291
|
+
| 15 | **manifest unverifiable** (fetch failed/offline, malformed, bad signature, wrong/rotated key, wrong torrent, or catalog/manifest mismatch) — nothing was verified |
|
|
292
|
+
| 20 | telemetry error (generic / disabled) |
|
|
293
|
+
| 21 | telemetry token missing/unknown/revoked (401) |
|
|
294
|
+
| 22 | telemetry token lacks `telemetry:write` scope (403) |
|
|
295
|
+
| 23 | telemetry config error (no token / bad endpoint) |
|
|
296
|
+
|
|
297
|
+
## Development
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
pip install -e ".[dev]"
|
|
301
|
+
pytest -m "not live" # offline unit suite (mocked API)
|
|
302
|
+
pytest -m live # read-only smoke calls to the real API
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## License
|
|
306
|
+
|
|
307
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
llmt/__init__.py,sha256=3xJ4TYgXuGtJIq8DeJLP7WS-MCq6K9vDN8OIJKgtKWs,190
|
|
2
|
+
llmt/api.py,sha256=G0afRrZtaCb_Uk2G72lm6n4AzF9-dCbcw3156xflp2c,5031
|
|
3
|
+
llmt/cli.py,sha256=T9BejN8hN_lU5bkyjqN4AgRCdz_Kj0p6NjnF6lysBdg,21950
|
|
4
|
+
llmt/clients.py,sha256=cuEEY3DsGo8daWBcq8F7FiK1uRcoLkYH1WvbXgaSQA8,65824
|
|
5
|
+
llmt/doctor.py,sha256=SElmJ03LZEftMMh9a2o1DAgmmBxSqgqxq1oK6l6-Zas,42487
|
|
6
|
+
llmt/doctorcmd.py,sha256=M_2QRr_YcU47zje--vkv4r_fW1TkFXL70sOokb1GtmI,5770
|
|
7
|
+
llmt/download.py,sha256=Dujd3E52ftZhurj7EmhptiD-X28WyaGA1crTzODSdNM,13325
|
|
8
|
+
llmt/errors.py,sha256=hP8cCUMCj45PnBMUULXper_7hym5GFa71samYe4YJYg,3901
|
|
9
|
+
llmt/layout.py,sha256=Qo0_17GODs8-wR16EDECO6hVI7bSxU8NsHI6SZDVfuE,27408
|
|
10
|
+
llmt/manifest.py,sha256=G323gyJqIgIetxW7RyGazn7XrjVVISQ0ukv6wi3Jxeo,13063
|
|
11
|
+
llmt/matcher.py,sha256=maY5pjOhy1ocP-FJ1oo9RIBXAsixc36Lt8nItV_WeZ4,13859
|
|
12
|
+
llmt/models.py,sha256=fTkfeKbjC7AKpvGCoH30AujUkySuhSJMW1mQ8qS2ZtQ,4501
|
|
13
|
+
llmt/output.py,sha256=isQIQGclRAldnmYdXpu7cDtwrzoVyCjS816UkyyXmW8,1683
|
|
14
|
+
llmt/permissions.py,sha256=VHtcZGyCoyKxs7vPG-2p3A_3ZQriMYHAF8-gmDoqY_Y,8599
|
|
15
|
+
llmt/scancmd.py,sha256=IXzH6f8Y5SK3VKyChRy5syjVRrDg4t5joqwhKXHtk8Y,25228
|
|
16
|
+
llmt/scanner.py,sha256=ehmoy_dfhlG5acWyRIlIYaR3V-oe1wbAHQCTUAKKciE,12606
|
|
17
|
+
llmt/verify.py,sha256=-oiaU4VVicnTENQabIOkVvAm0B4ab3wBlqZrvSl6P7o,4755
|
|
18
|
+
llmt/data/manifest-pubkey.json,sha256=jkJQjfy7xZY27CtoOiQa80zXkq4ZThxVjBZosSH5zkM,826
|
|
19
|
+
llmt/telemetry/__init__.py,sha256=P-CiNwRgLgRPCt5tuV522_FDX8kshmkI8wgBmfteQJ0,614
|
|
20
|
+
llmt/telemetry/artifacts.py,sha256=o2lGk_kzZs5gcaE-SHjVR504THJMh0Yydl8TYyLqn-I,7700
|
|
21
|
+
llmt/telemetry/beat.py,sha256=jp4imwu2XFNpPLCE50j-9ckXw1glOV-nd4MrlzhPUIY,7910
|
|
22
|
+
llmt/telemetry/cmd.py,sha256=CUOkm-zcn72jkCMxftJLvJJjns7V9kk6C9qb-_NKwhU,14386
|
|
23
|
+
llmt/telemetry/collect.py,sha256=C-AONmeiehTeCA_y-vsw200nnIv7StJwr7-ZtUKjoSs,9379
|
|
24
|
+
llmt/telemetry/config.py,sha256=hpH57eTZF-b8TgGxS7ZK_YN6SyOoTypXdsJa8j5r1fM,8285
|
|
25
|
+
llmt/telemetry/consent.py,sha256=5UqX7QQiG49037EXxZz_OQtgdnPONPhwnyp11kES_2E,2302
|
|
26
|
+
llmt/telemetry/errors.py,sha256=9doXH9YOBHVXa62BwhzB6v91WJSEvlwtInDBGoC4jGA,1099
|
|
27
|
+
llmt/telemetry/wire.py,sha256=6DhC8G43nC-QWGpPknwxepgfniEy7RCjaejvUxfqhy4,7772
|
|
28
|
+
llmt_cli-0.2.0.dist-info/licenses/LICENSE,sha256=eg7KBPmi0icU5gbQ74lXkvP4gELLCv6viKNYGIjCJig,1069
|
|
29
|
+
llmt_cli-0.2.0.dist-info/METADATA,sha256=Jv1iLfUgWqHKU7ZZ08bbDu6MTjE48VSZWi1agFNZBzY,15276
|
|
30
|
+
llmt_cli-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
31
|
+
llmt_cli-0.2.0.dist-info/entry_points.txt,sha256=TrwRcAeqlhMTLb27G4J5UQonZ_CRmMCFhoel5lAyz2E,39
|
|
32
|
+
llmt_cli-0.2.0.dist-info/top_level.txt,sha256=-v9nLTNARRXP6yaL6tfqfAYvftCYThx6dsUHE2CVuRk,5
|
|
33
|
+
llmt_cli-0.2.0.dist-info/RECORD,,
|