Files
dikte/dikte/hub.py
T
yusufipek cfeed2af8c Find the llama.cpp builds where they are actually published
"latest" for llama.cpp is a version marker carrying one file, nightly-tag.txt, and the archives it names hang off a prerelease that "latest" never points at. Reading only the latest release meant Dikte offered no llama-server for any machine, so _pick_asset now follows that pointer, and when there is none it walks the recent releases and takes the newest one that does carry a build for this machine. hub grows releases() for the listing and text() for the pointer file; the pointer is read rather than cached, because what it carries is a few bytes on the way to a download that is checksummed in full.

The extra lookups are best effort: whatever goes wrong in them leaves the caller's own message standing, but a first release that could not be fetched at all is kept and re-raised when nothing else turns up, so an unreachable GitHub still reads as an unreachable GitHub rather than as a machine nobody publishes for.
2026-09-05 09:47:22 +03:00

245 lines
9.6 KiB
Python

"""Where the programs and the models come from: GitHub releases and Hugging Face.
Both answer plain JSON over HTTPS without a key, and both publish a sha256 for
every file they hand out: GitHub as the asset digest, Hugging Face as the LFS
object id. Nothing that lands on disk is trusted for having arrived, which
matters more here than it usually would, because half of what is fetched is a
program Dikte then runs.
The lists are read rather than kept. A model catalogue written into the source
means a release of Dikte for every new model, and a pinned whisper.cpp version
means one for every whisper.cpp release; both of those are somebody else's news,
not Dikte's. Answers are cached for a few hours, and a cache that has gone stale
is still a better answer than none when the network is down.
Nothing here imports the rest of Dikte apart from two leaves, the string table
and the path map: this module knows two websites and nothing about dictation.
"""
import collections
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from . import paths
from .i18n import t
GITHUB_API = "https://api.github.com"
HF_API = "https://huggingface.co/api"
HF_FILES = "https://huggingface.co"
USER_AGENT = "dikte/1.0 (+https://github.com/yusufipk/dikte)"
CACHE_DIR = paths.cache_dir()
# Long enough that opening the settings window twice in an evening asks nobody
# anything, short enough that a model published this morning is offered today.
CACHE_TTL = 6 * 3600
# `sha256` is empty for the few files neither side stores in LFS; those are the
# small ones, and a checksum is only worth having where there is something to
# check.
Item = collections.namedtuple("Item", "name url size sha256")
Repo = collections.namedtuple("Repo", "id downloads updated")
class HubError(Exception):
pass
def _get(url, timeout=20):
request = urllib.request.Request(url, headers={
"User-Agent": USER_AGENT,
"Accept": "application/json",
})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
exc.close() # it holds the response body open until it is collected
raise HubError(t("{url} answered HTTP {code}.",
url=urllib.parse.urlsplit(url).netloc, code=exc.code)) from exc
except urllib.error.URLError as exc:
raise HubError(t("Could not reach {url}: {error}",
url=urllib.parse.urlsplit(url).netloc,
error=exc.reason)) from exc
except (ValueError, OSError) as exc:
raise HubError(t("Could not read the answer from {url}: {error}",
url=urllib.parse.urlsplit(url).netloc, error=exc)) from exc
def _cache_file(key):
safe = "".join(c if c.isalnum() or c in "-._" else "-" for c in key)
return CACHE_DIR / f"{safe}.json"
def _read_cache(key, ttl):
"""What was stored under this key, or None. `ttl` of 0 ignores the age."""
path = _cache_file(key)
try:
age = time.time() - path.stat().st_mtime
if ttl and age > ttl:
return None
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def _write_cache(key, payload):
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
_cache_file(key).write_text(json.dumps(payload), encoding="utf-8")
except OSError:
pass # a cache that cannot be written is not a failed lookup
def _fetch(key, url, ttl=CACHE_TTL, refresh=False):
"""The JSON at `url`, from the cache when it is fresh enough.
A lookup that fails falls back to the cache however old it is: an offline
settings window that shows yesterday's list is worth a great deal more than
one that shows an error.
"""
if not refresh:
cached = _read_cache(key, ttl)
if cached is not None:
return cached
try:
payload = _get(url)
except HubError:
stale = _read_cache(key, 0)
if stale is not None:
return stale
raise
_write_cache(key, payload)
return payload
def _digest(value):
"""GitHub writes its digests as "sha256:…"; Hugging Face writes the hash."""
value = (value or "").strip()
return value.split(":", 1)[1] if value.startswith("sha256:") else value
def _assets(data):
return [Item(a.get("name") or "", a.get("browser_download_url") or "",
int(a.get("size") or 0), _digest(a.get("digest")))
for a in (data.get("assets") or [])
if a.get("browser_download_url")]
def release(repo, tag="latest", refresh=False):
"""(tag, [Item]) for one GitHub release, newest when no tag is given."""
where = "latest" if tag in ("", "latest") else f"tags/{tag}"
data = _fetch(f"gh-{repo}-{tag or 'latest'}",
f"{GITHUB_API}/repos/{repo}/releases/{where}", refresh=refresh)
if not isinstance(data, dict) or not data.get("assets"):
raise HubError(t("{repo} has no downloadable release.", repo=repo))
return data.get("tag_name") or tag, _assets(data)
def releases(repo, limit=20, refresh=False):
"""[(tag, [Item])] for the recent releases, newest first, with their files.
"latest" is one release and this is the list behind it, prereleases
included: a project that attaches its builds to a prerelease is invisible
to release() above, and its newest usable build is in here.
"""
data = _fetch(f"gh-list-{repo}-{limit}",
f"{GITHUB_API}/repos/{repo}/releases?per_page={limit}",
refresh=refresh)
if not isinstance(data, list):
raise HubError(t("{repo} has no downloadable release.", repo=repo))
out = []
for entry in data:
tag, items = entry.get("tag_name") or "", _assets(entry)
if tag and items:
out.append((tag, items))
return out
def text(url, limit=4096, timeout=20):
"""A small text file from a release, as a string.
Not cached and not checksummed, because what it carries is a pointer: a few
bytes naming the release the actual archives are attached to, read once on
the way to a download that is checked in full.
"""
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read(limit).decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
exc.close()
raise HubError(t("{url} answered HTTP {code}.",
url=urllib.parse.urlsplit(url).netloc, code=exc.code)) from exc
except (urllib.error.URLError, OSError, ValueError) as exc:
raise HubError(t("Could not reach {url}: {error}",
url=urllib.parse.urlsplit(url).netloc, error=exc)) from exc
def newest_release(repo, refresh=False):
"""(tag, page, published) for the newest release of a repository.
release() above is for taking a file out of one and insists on there being
files to take; this is for the number, which a release with nothing
attached answers just as well. GitHub keeps prereleases out of "latest" on
its own, which is what leaves the nightly build off this answer.
"""
data = _fetch(f"gh-newest-{repo}",
f"{GITHUB_API}/repos/{repo}/releases/latest", refresh=refresh)
if not isinstance(data, dict) or not data.get("tag_name"):
raise HubError(t("{repo} has published no release.", repo=repo))
return (data["tag_name"], data.get("html_url") or "",
data.get("published_at") or "")
def files(repo, revision="main", refresh=False):
"""[Item] for every file in a Hugging Face repository.
The size is there whether or not the file is in LFS; the hash is only there
when it is, which for anything worth downloading it always is.
"""
data = _fetch(f"hf-tree-{repo}-{revision}",
f"{HF_API}/models/{repo}/tree/{revision}?recursive=true",
refresh=refresh)
if not isinstance(data, list):
raise HubError(t("{repo} did not return a file list.", repo=repo))
out = []
for entry in data:
if entry.get("type") != "file":
continue
path = entry.get("path") or ""
lfs = entry.get("lfs") or {}
out.append(Item(
path,
f"{HF_FILES}/{repo}/resolve/{revision}/{urllib.parse.quote(path)}",
int(lfs.get("size") or entry.get("size") or 0),
_digest(lfs.get("oid") or lfs.get("sha256")),
))
return out
def repos(author="", search="", limit=40, refresh=False):
"""[Repo] of GGUF repositories, newest first.
Filtered by author on purpose. Hugging Face's own trending list is open to
everyone and reads like it: asking it for the popular GGUF today answers
with a wall of roleplay merges, which is not what a dictation transcript
wants cleaning up. An author is a small enough thing to trust and a large
enough one to keep the list current without Dikte being updated.
"""
query = {"filter": "gguf", "sort": "lastModified", "direction": "-1",
"limit": str(limit)}
if author:
query["author"] = author
if search:
query["search"] = search
url = f"{HF_API}/models?{urllib.parse.urlencode(query)}"
data = _fetch(f"hf-models-{author}-{search}-{limit}", url, refresh=refresh)
if not isinstance(data, list):
raise HubError(t("Hugging Face did not return a model list."))
return [Repo(m.get("id") or "", int(m.get("downloads") or 0),
m.get("lastModified") or "")
for m in data if m.get("id")]