From cfeed2af8c30270a9565f2332bddefa36c532a4f Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:47:22 +0300 Subject: [PATCH 1/3] 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. --- dikte/ggml.py | 70 ++++++++++++++++++++++++++++++++++++++++++---- dikte/hub.py | 52 +++++++++++++++++++++++++++++++--- tests/test_ggml.py | 39 ++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 10 deletions(-) diff --git a/dikte/ggml.py b/dikte/ggml.py index c2f8da8..03a43ef 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -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 diff --git a/dikte/hub.py b/dikte/hub.py index f5da71a..8c19f5f 100644 --- a/dikte/hub.py +++ b/dikte/hub.py @@ -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): diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 44b9305..33633c4 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -238,6 +238,45 @@ class InstallProgram(Local): "whisper-bin-ubuntu-x64.tar.gz") self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz")) + def test_the_nightly_pointer_is_followed_to_where_the_builds_are(self): + """llama.cpp's latest release carries a tag name, not the binaries.""" + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: False) + marker = self.release(ggml.NIGHTLY_TAG) + nightly = dict(self.release("llama-b10809-bin-ubuntu-x64.tar.gz"), + tag_name="b10809") + + def opener(request, timeout=None): + url = request.full_url + if url.endswith("/releases/latest"): + return json_body(marker) + if url.endswith("/releases/tags/b10809"): + return json_body(nightly) + if url.endswith(ggml.NIGHTLY_TAG): + return body(b"b10809\n") + return body(self.archive) + + with mock.patch("urllib.request.urlopen", side_effect=opener): + tag, found = ggml._pick_asset(ggml.LLAMA) + self.assertEqual(tag, "b10809") + self.assertEqual(found.name, "llama-b10809-bin-ubuntu-x64.tar.gz") + + def test_without_a_pointer_the_newest_release_that_has_a_build_is_taken(self): + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: False) + marker = self.release("source.zip") + listing = [dict(self.release("llama-b2-bin-win-cpu-x64.zip"), tag_name="b2"), + dict(self.release("llama-b1-bin-ubuntu-x64.tar.gz"), tag_name="b1")] + + def opener(request, timeout=None): + url = request.full_url + return json_body(listing if "per_page" in url else marker) + + with mock.patch("urllib.request.urlopen", side_effect=opener): + tag, found = ggml._pick_asset(ggml.LLAMA) + self.assertEqual(tag, "b1") + self.assertEqual(found.name, "llama-b1-bin-ubuntu-x64.tar.gz") + def test_a_release_with_nothing_for_this_machine_says_so(self): self.patch_attr(ggml, "_arch", lambda: "x64") with fake_urlopen(self.release("whisper-bin-Win32.zip")): From fff9cd1c55079f5d019003374724f13944143743 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:47:42 +0300 Subject: [PATCH 2/3] Keep the publisher and the model boxes saying the same thing Changing the publisher left the model box untouched: the old selection was carried over, added back as "not downloaded" and selected again, so a model the new repository does not publish could be saved against it. The selection is now only carried within the publisher it was made in, every keystroke in the publisher box no longer starts its own request, and a list that comes back for a publisher that is no longer chosen is dropped rather than answering the wrong one. The status line grew two things it could not say before. A row rebuilt from a name alone carries no file to fetch, and the Download button stayed lit over it doing nothing; those rows now say the publisher does not offer the model, and the button is out. A model that is here while the program above it is not no longer reads "Ready", which is what had people asking why nothing transcribed. --- dikte/i18n.py | 6 ++++ dikte/settings_ui.py | 67 ++++++++++++++++++++++++++++++++++++----- tests/test_ui.py | 71 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 8 deletions(-) diff --git a/dikte/i18n.py b/dikte/i18n.py index 23ff6ff..961a663 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -787,6 +787,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", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 56771f8..ccf136c 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -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)) diff --git a/tests/test_ui.py b/tests/test_ui.py index 6273447..ca821a4 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -8,6 +8,7 @@ next time anybody presses Save. That is the failure this catches. import os import sys +import time import unittest from typing import ClassVar from unittest import mock @@ -21,6 +22,7 @@ from dikte import cleanup from dikte import config as cfg from dikte import ggml from dikte import hotkey +from dikte import hub from dikte import ipc from dikte import overlay as overlay_module from dikte import paste @@ -1214,6 +1216,75 @@ class LocalModels(DikteTest): for row in range(box.repo.count())) self.assertGreaterEqual(view.minimumWidth(), widest) + @staticmethod + def _item(name, size=1 << 20): + return hub.Item(name, f"https://example.invalid/{name}", size, "") + + def test_a_row_with_nothing_to_fetch_does_not_offer_a_download(self): + # The model the settings name is not in the list any more, so its row + # was rebuilt from the name alone and carries no file to fetch. The + # button stayed lit and the press did nothing at all. + box = self.window(self.config(local_llm_model="gone.gguf")).local_llm + box.load("gone.gguf", "ggml-org/SmolLM3-3B-GGUF") + self.assertEqual(box.selected(), "gone.gguf") + self.assertFalse(box.download_button.isEnabled()) + self.assertIn("gone.gguf", box.status.text()) + self.assertIn("publisher", box.status.text()) + + def test_a_model_without_its_program_does_not_say_it_is_ready(self): + # The model runs on the program above it, and "Ready" over a missing + # one is what had people asking why nothing transcribed. + box = self.window(cfg.Config()).local_whisper + path = ggml.whisper_model_path("ggml-small.bin") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"not really a model") + box.load("ggml-small.bin") + self.assertFalse(ggml.program_path(ggml.WHISPER)) + self.assertNotIn("Ready", box.status.text()) + self.assertIn("program", box.status.text()) + + def test_changing_the_publisher_changes_the_model(self): + # The model chosen under the old publisher is not published by the new + # one. Carried over, it was added back as "not downloaded" and selected + # again, and the box looked as though the change had not taken. + box = self.window(self.config(local_llm_model="gemma-3-4b-it-Q4_K_M.gguf", + local_llm_repo="ggml-org/gemma-3-4b-it-GGUF")).local_llm + box.load("gemma-3-4b-it-Q4_K_M.gguf", "ggml-org/gemma-3-4b-it-GGUF") + box.repo.blockSignals(True) + box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(False) + box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")], + "ggml-org/SmolLM3-3B-GGUF")], "") + self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf") + self.assertEqual(box.model.count(), 1) + + def test_a_list_for_a_publisher_that_is_no_longer_chosen_is_dropped(self): + # Every change starts its own request, and they do not come back in the + # order they went out. + box = self.window(cfg.Config()).local_llm + box.load("", "ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(True) + box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(False) + box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")], + "ggml-org/SmolLM3-3B-GGUF")], "") + box._on_listed([("models", [self._item("gemma-3-4b-it-Q4_K_M.gguf")], + "ggml-org/gemma-3-4b-it-GGUF")], "") + self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf") + + def test_the_publisher_box_is_not_asked_on_every_keystroke(self): + box = self.window(cfg.Config()).local_llm + with mock.patch.object(box, "_fetch_models") as fetch: + for text in ("g", "gg", "ggm", "ggml-org/SmolLM3-3B-GGUF"): + box.repo.setCurrentText(text) + fetch.assert_not_called() + box._later.setInterval(0) + box._later.start() + _app.processEvents() + time.sleep(0.05) + _app.processEvents() + self.assertEqual(fetch.call_count, 1) + def test_only_the_chosen_transcriber_is_on_screen(self): window = self.window(self.config(transcribe_provider="openai")) self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row)) From fb80d85332f2bcfa44f1838feeb5d4a04e422fb1 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:47:50 +0300 Subject: [PATCH 3/3] Stop two tests failing on what is around them The macOS path test set XDG_CONFIG_HOME to "/c" and asserted that "/c" was nowhere in the answer, but the home these run under is a mkdtemp path, so any TMPDIR with a "/c" in it failed the test. macOS is exactly where that happens: its temporary directories are /var/folders//, and one letter in thirty-six starts with c. The needle is now a word no directory can be called. The other is the `ask` verb reading what was piped into it. Nothing was, but the runner's own stdin is not nothing either, so the verb read whatever the runner left there. It now reads an empty stream, whichever runner is in use. --- tests/test_cli.py | 5 +++++ tests/test_paths.py | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 67c6f21..a3ffb55 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ socket is faked, and everything that runs locally runs for real. import contextlib import io import json +import sys import unittest import webbrowser from typing import ClassVar @@ -668,7 +669,11 @@ class WithoutAnInstance(DikteTest): def run_verb(self, argv): # launch_gui replaces this process with the application, so it never # comes back in real use and must not be allowed to here. + # `ask` with no text reads what was piped in, and the runner's own + # stdin is not that: under pytest it is an object that refuses to be + # read at all. with mock.patch.object(ipc, "send", return_value=None), \ + mock.patch.object(sys, "stdin", io.StringIO()), \ mock.patch.object(cli, "launch_gui") as launch, \ captured() as (out, err): code = cli.run(argv) diff --git a/tests/test_paths.py b/tests/test_paths.py index df84a09..af54bde 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -42,9 +42,12 @@ class Directories(unittest.TestCase): def test_a_mac_does_not_read_the_xdg_variables(self): """A Mac with them set from some other tool still stores in one place.""" - with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}): + # Something no temporary directory can be called: the home this runs + # under is a mkdtemp path, and a two-letter needle matched the "/c" in + # somebody's TMPDIR rather than the variable being read. + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/xdg-elsewhere"}): config_dir, _ = paths.directories("darwin") - self.assertNotIn("/c", config_dir.as_posix()) + self.assertNotIn("xdg-elsewhere", config_dir.as_posix()) def test_windows_keeps_the_models_out_of_the_roaming_profile(self): """Settings roam with the account; several gigabytes must not."""