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 ADDED
@@ -0,0 +1,7 @@
1
+ """llmt — LLM Torrents command-line client.
2
+
3
+ Find, download (over BitTorrent + webseeds) and SHA-256-verify LLM models
4
+ from the public LLM Torrents catalog API.
5
+ """
6
+
7
+ __version__ = "0.2.0"
llmt/api.py ADDED
@@ -0,0 +1,138 @@
1
+ """HTTP client for the FROZEN WP-8.1 public API.
2
+
3
+ Envelope contract:
4
+ success : {"data": ..., "meta"?: ...}
5
+ error : {"error": {"code", "message", "details"?}}
6
+
7
+ Base URL: default https://llmtorrents.com/api, overridable via the
8
+ ``LLMT_API_BASE`` env var or ``--api-base``. An optional bearer token
9
+ (``LLMT_API_KEY`` / ``--api-key``) only raises rate limits (reads are public).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import Any, Optional
16
+
17
+ import requests
18
+
19
+ from .errors import (
20
+ ApiError,
21
+ NotFoundError,
22
+ RateLimitedError,
23
+ TimeoutErrorLlmt,
24
+ )
25
+
26
+ DEFAULT_API_BASE = "https://llmtorrents.com/api"
27
+ DEFAULT_TIMEOUT = 30
28
+
29
+
30
+ def resolve_base(explicit: Optional[str] = None) -> str:
31
+ base = explicit or os.environ.get("LLMT_API_BASE") or DEFAULT_API_BASE
32
+ return base.rstrip("/")
33
+
34
+
35
+ def resolve_key(explicit: Optional[str] = None) -> Optional[str]:
36
+ return explicit or os.environ.get("LLMT_API_KEY") or None
37
+
38
+
39
+ class ApiClient:
40
+ """Thin, dependency-light wrapper over the catalog's JSON API."""
41
+
42
+ def __init__(self, base: Optional[str] = None, key: Optional[str] = None,
43
+ timeout: int = DEFAULT_TIMEOUT,
44
+ session: Optional[requests.Session] = None):
45
+ self.base = resolve_base(base)
46
+ self.key = resolve_key(key)
47
+ self.timeout = timeout
48
+ self.session = session or requests.Session()
49
+
50
+ # ---- low-level ------------------------------------------------------
51
+ def _headers(self) -> dict[str, str]:
52
+ h = {"Accept": "application/json", "User-Agent": "llmt-cli"}
53
+ if self.key:
54
+ h["Authorization"] = f"Bearer {self.key}"
55
+ return h
56
+
57
+ def _get(self, path: str, params: Optional[dict] = None) -> dict[str, Any]:
58
+ url = f"{self.base}/{path.lstrip('/')}"
59
+ try:
60
+ resp = self.session.get(
61
+ url, params=params, headers=self._headers(), timeout=self.timeout
62
+ )
63
+ except requests.Timeout as exc:
64
+ raise TimeoutErrorLlmt(
65
+ f"API request timed out after {self.timeout}s: {url}"
66
+ ) from exc
67
+ except requests.RequestException as exc:
68
+ raise ApiError(f"API request failed: {exc}") from exc
69
+ return self._handle(resp, url)
70
+
71
+ def _handle(self, resp: "requests.Response", url: str) -> dict[str, Any]:
72
+ # Try to decode an error envelope regardless of status.
73
+ payload: Any = None
74
+ try:
75
+ payload = resp.json()
76
+ except ValueError:
77
+ payload = None
78
+
79
+ err = payload.get("error") if isinstance(payload, dict) else None
80
+ code = err.get("code") if isinstance(err, dict) else None
81
+ message = err.get("message") if isinstance(err, dict) else None
82
+ details = err.get("details") if isinstance(err, dict) else None
83
+
84
+ if resp.status_code == 200 and isinstance(payload, dict) and "error" not in payload:
85
+ return payload
86
+
87
+ if resp.status_code == 429:
88
+ ra = resp.headers.get("Retry-After")
89
+ retry_after = None
90
+ if ra is not None:
91
+ try:
92
+ retry_after = int(ra)
93
+ except ValueError:
94
+ retry_after = None
95
+ raise RateLimitedError(
96
+ message or "Rate limited by the API.",
97
+ retry_after=retry_after, code=code or "rate_limited",
98
+ status=429, details=details,
99
+ )
100
+ if resp.status_code == 404:
101
+ raise NotFoundError(
102
+ message or "Not found.", code=code or "not_found",
103
+ status=404, details=details,
104
+ )
105
+ if resp.status_code == 403:
106
+ raise ApiError(
107
+ message or "Forbidden (the resource may be license-gated).",
108
+ code=code or "forbidden", status=403, details=details,
109
+ )
110
+ # Any other non-200, or a 200 that is actually an error envelope.
111
+ raise ApiError(
112
+ message or f"Unexpected API response (HTTP {resp.status_code}).",
113
+ code=code, status=resp.status_code, details=details,
114
+ )
115
+
116
+ # ---- endpoints ------------------------------------------------------
117
+ def list_models(self, q: Optional[str] = None, fit: Optional[str] = None,
118
+ per_page: int = 24, sort: Optional[str] = None,
119
+ extra: Optional[dict] = None) -> dict[str, Any]:
120
+ params: dict[str, Any] = {"per_page": per_page}
121
+ if q:
122
+ params["q"] = q
123
+ if fit:
124
+ params["fit"] = fit
125
+ if sort:
126
+ params["sort"] = sort
127
+ if extra:
128
+ params.update(extra)
129
+ return self._get("/models", params=params)
130
+
131
+ def get_model(self, slug: str) -> dict[str, Any]:
132
+ return self._get(f"/models/{slug}")
133
+
134
+ def get_manifest(self, info_hash: str) -> dict[str, Any]:
135
+ return self._get(f"/manifests/{info_hash}")
136
+
137
+ def get_fit(self, params: dict) -> dict[str, Any]:
138
+ return self._get("/fit", params=params)