Merge pull request #73 from yusufipk/llama-nightly-and-model-box

Find the llama.cpp nightly builds, and keep the model box honest
This commit is contained in:
Yusuf İpek
2026-09-05 09:51:24 +03:00
committed by GitHub
8 changed files with 297 additions and 20 deletions
+64 -6
View File
@@ -83,6 +83,10 @@ LLAMA = Program("llama", "ggml-org/llama.cpp", "llama-server", "/health")
WHISPER_MODELS_REPO = "ggerganov/whisper.cpp"
LLM_AUTHOR = "ggml-org"
# The file llama.cpp attaches to its version releases in place of the binaries:
# a line naming the nightly tag those are published under.
NIGHTLY_TAG = "nightly-tag.txt"
# What the whisper repository holds besides models: Core ML encoders for Apple
# hardware and the odd loose file.
WHISPER_PREFIX = "ggml-"
@@ -277,6 +281,65 @@ def _wanted_assets(program):
return (f"bin-ubuntu-{arch}.tar.gz",)
def _matching_asset(program, assets):
"""The archive this machine wants out of one release's files, or None."""
for ending in _wanted_assets(program):
item = next((a for a in assets if a.name.endswith(ending)), None)
if item:
return item
return None
def _pick_asset(program, tag="", refresh=False):
"""(tag, Item) for the release archive to install. Item is None when there
is none for this machine.
A named tag is taken as given. For the newest, what GitHub answers is not
always where the builds are: llama.cpp's latest release is a version marker
carrying a single nightly-tag.txt, which names the tag the archives are
actually attached to, and those are prereleases that "latest" never points
at. The pointer is followed when it is there, and when it is not, the newest
release that does carry a build for this machine is taken instead.
"""
named = bool(tag) and tag != "latest"
missing = None
try:
tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh)
except hub.HubError as exc:
# A release carrying no files at all is the case the search below exists
# for, not a reason to stop before it: the build for this machine may be
# attached to a prerelease that "latest" never points at. The failure is
# kept rather than dropped, because an unreachable GitHub arrives here
# the same way and that one is the message the caller wants.
if named:
raise
missing, assets = exc, []
item = _matching_asset(program, assets)
if item or named:
return tag, item
# Best effort from here on: a machine this project publishes nothing for is
# not a failed lookup, and the caller's message about that is the useful
# one. Whatever goes wrong while looking further leaves it standing.
try:
pointer = next((a for a in assets if a.name == NIGHTLY_TAG), None)
if pointer:
nightly = hub.text(pointer.url).strip()
if nightly:
found, assets = hub.release(program.repo, nightly, refresh=refresh)
item = _matching_asset(program, assets)
if item:
return found, item
for found, assets in hub.releases(program.repo, refresh=refresh):
item = _matching_asset(program, assets)
if item:
return found, item
except hub.HubError:
pass
if missing is not None:
raise missing
return tag, None
def _install_record(program):
return BIN_DIR / program.name / "installed.json"
@@ -375,15 +438,10 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
whisper.cpp has one.
"""
try:
tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh)
tag, item = _pick_asset(program, tag, refresh=refresh)
except hub.HubError as exc:
raise LocalError(str(exc)) from exc
item = None
for ending in _wanted_assets(program):
item = next((a for a in assets if a.name.endswith(ending)), None)
if item:
break
if item is None:
# Nothing to download and nothing to install for you: whisper.cpp
# publishes no macOS binary, and Homebrew's whisper-cpp is configured
+48 -4
View File
@@ -121,6 +121,13 @@ def _digest(value):
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}"
@@ -128,10 +135,47 @@ def release(repo, tag="latest", refresh=False):
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))
assets = [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["assets"] if a.get("browser_download_url")]
return data.get("tag_name") or tag, assets
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):
+6
View File
@@ -792,6 +792,12 @@ TR = {
"Ready: {name}.": "Hazır: {name}.",
"Nothing downloaded yet.": "Henüz bir şey indirilmedi.",
"{name} has not been downloaded yet.": "{name} henüz indirilmedi.",
"{name} is here, but the program above is not. Download it first.":
"{name} burada, ama yukarıdaki program değil. Önce onu indirin.",
"{name} is not on this machine and this publisher does not offer it. "
"Choose another model, or another publisher.":
"{name} bu makinede yok ve bu yayıncı da sunmuyor. Başka bir model, "
"ya da başka bir yayıncı seçin.",
"downloaded": "indirildi",
"not downloaded": "indirilmedi",
"Delete model": "Modeli sil",
+59 -8
View File
@@ -6,7 +6,7 @@ import shutil
import sys
import threading
from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal
from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QTimer, QUrl, pyqtSignal
from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
from PyQt6.QtWidgets import (
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
@@ -247,6 +247,13 @@ class LocalModelBox(QGroupBox):
self._pending = False
self._stop = False
self._wanted = "" # the model to select once a list arrives
self._chosen_in = "" # the publisher the selected model is from
# Typing or arrowing through the publisher box changes its text a
# character at a time, and each of those would otherwise be a request.
self._later = QTimer(self)
self._later.setSingleShot(True)
self._later.setInterval(400)
self._later.timeout.connect(self._later_fetch)
form = QFormLayout(self)
@@ -328,6 +335,8 @@ class LocalModelBox(QGroupBox):
self._wanted = model
self._pending = True
self._show_program()
self._chosen_in = repo or (ggml.SUGGESTED_LLM[0] if self._repos is not None
else "")
if self._repos is not None:
self.repo.blockSignals(True)
self.repo.clear()
@@ -367,11 +376,18 @@ class LocalModelBox(QGroupBox):
def _fill_repos(self, current):
def work():
self._listed.emit([("repos", ggml.llm_repos())], "")
self._listed.emit([("repos", ggml.llm_repos(), "")], "")
threading.Thread(target=work, daemon=True).start()
def _repo_changed(self):
if not self._downloading:
self._later.start()
def _later_fetch(self):
# A download that started inside the wait was not there to be seen when
# the timer went off, and rebuilding the rows underneath one is exactly
# what the guard above is for.
if not self._downloading:
self._fetch_models(self.repository())
@@ -381,18 +397,29 @@ class LocalModelBox(QGroupBox):
def work():
try:
found = self._models(repo) if self._repos is not None else self._models()
self._listed.emit([("models", found)], "")
self._listed.emit([("models", found, repo)], "")
except ggml.LocalError as exc:
self._listed.emit([], str(exc))
self._listed.emit([("models", [], repo)], str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_listed(self, payload, error):
kind, found, repo = payload[0] if payload else ("repos", [], "")
# A publisher changed while its predecessor's list was still on the way
# would otherwise be answered with the wrong models, whichever request
# happened to come back last.
if kind == "models" and repo != self.repository():
return
if error:
# The list is the publisher's, so a failed one leaves the box no
# longer showing this publisher's models: emptying it is what keeps
# the two boxes saying the same thing. The message goes on after,
# because filling the box writes a status of its own.
if kind == "models":
self._fill_models([])
self._refresh_buttons()
self.status.setText(error)
self._refresh_buttons()
return
kind, found = payload[0]
if kind == "repos":
current = self.repo.currentText()
self.repo.blockSignals(True)
@@ -406,7 +433,12 @@ class LocalModelBox(QGroupBox):
def _fill_models(self, items):
"""One row per model, saying what it weighs and whether it is here."""
wanted = self._wanted or self.selected()
# The selection is only worth carrying over within the publisher it was
# made in. Carried across one, a model this repository does not publish
# would be added back as "not downloaded" and selected again, and
# changing the publisher would leave the model box looking untouched.
same = self._repos is None or self.repository() == self._chosen_in
wanted = self._wanted or (self.selected() if same else "")
here = [name for name in (self._model_path(i.name).name for i in items)]
self.model.blockSignals(True)
self.model.clear()
@@ -431,6 +463,7 @@ class LocalModelBox(QGroupBox):
self.model.blockSignals(False)
self._fit_popup(self.model)
self._wanted = ""
self._chosen_in = self.repository()
self._model_changed()
def _on_disk(self):
@@ -459,6 +492,9 @@ class LocalModelBox(QGroupBox):
self._show_program()
if error:
self.program_label.setText(error)
# The model line says whether the program is here, so installing one
# changes what it should read.
self._refresh_buttons()
self.changed.emit()
def _current_item(self):
@@ -545,15 +581,30 @@ class LocalModelBox(QGroupBox):
def _refresh_buttons(self):
name = self.selected()
here = bool(name) and ggml.have_model(self._model_path(name))
# A row carries what it takes to fetch it. The ones that do not are the
# models found on this disk and the one the settings name but the list
# does not offer: there is nothing to press Download for on those, and
# a button that can only do nothing is worse than one that is out.
item = self._current_item()
self.delete_button.setEnabled(here and not self._downloading)
self.download_button.setText(t("Stop") if self._downloading else t("Download"))
self.download_button.setEnabled(self._downloading or (bool(name) and not here))
self.download_button.setEnabled(self._downloading or (item is not None
and not here))
if self._downloading:
return
if not name:
self.status.setText(t("Nothing downloaded yet."))
elif here and not ggml.program_path(self.program):
# The model alone runs nothing, and "Ready" over a missing program
# reads as though it does.
self.status.setText(t("{name} is here, but the program above is "
"not. Download it first.", name=name))
elif here:
self.status.setText(t("Ready: {name}.", name=name))
elif item is None:
self.status.setText(t("{name} is not on this machine and this "
"publisher does not offer it. Choose another "
"model, or another publisher.", name=name))
else:
self.status.setText(t("{name} has not been downloaded yet.", name=name))