From c0b892f53c1a066c2931afc3c791819b513d5952 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 1 Aug 2026 20:00:21 +0300 Subject: [PATCH 1/6] Fetch whisper.cpp and llama.cpp, and keep a server alive on each Two programs, one treatment: ask GitHub what was released, take the build this machine can run, check it against the sha256 the index published, then fetch a model from Hugging Face the same way and keep one server up on a port of its own. Neither list is written into the source. A model catalogue in here means a release of Dikte for every model somebody else publishes, and a pinned version means one for every whisper.cpp release; both are somebody else's news. The lists are read at the moment the settings window is opened and cached for a few hours, and a cache that has gone stale still answers when the network does not. Nothing that lands on disk is trusted for having arrived, which matters more here than usual because half of what is fetched is a program Dikte then runs. --- ggml.py | 760 +++++++++++++++++++++++++++++++++++++++++++++ hub.py | 186 +++++++++++ tests/test_ggml.py | 582 ++++++++++++++++++++++++++++++++++ tests/test_hub.py | 184 +++++++++++ 4 files changed, 1712 insertions(+) create mode 100644 ggml.py create mode 100644 hub.py create mode 100644 tests/test_ggml.py create mode 100644 tests/test_hub.py diff --git a/ggml.py b/ggml.py new file mode 100644 index 0000000..1ea8d66 --- /dev/null +++ b/ggml.py @@ -0,0 +1,760 @@ +"""Speech to text and cleanup on this machine: whisper.cpp and llama.cpp. + +Two programs, one treatment. Fetch a release from GitHub, unpack it under the +data directory, fetch a model from Hugging Face, then keep one server alive on a +port of its own. Both of them speak the shape api.py already sends to the hosted +providers, so what the rest of Dikte sees is a base URL and nothing else: +whisper-server is started on `--inference-path /v1/audio/transcriptions`, the +exact path api.py builds, and llama-server answers /v1/chat/completions the way +OpenRouter does. + +A server rather than a one-shot run, because the model is the slow part. Loading +a large whisper model takes a second or two while transcribing a few seconds of +speech takes a fraction of one, and an LLM is worse: a server pays that once and +a run per dictation pays it every time. + +Nothing downloaded is trusted for having arrived. Every file is checked against +the sha256 its index published, and the bytes go to a `.part` that is only +renamed once the whole thing is there, so an interrupted download can never be +mistaken for a working one. + +This module imports hub and the string table, and nothing else of Dikte's: it +knows how to fetch a file and how to run a process, and nothing about dictation. +Its errors leave as LocalError and api.py turns them into the ApiError the +interface already knows how to show. +""" + +import atexit +import collections +import ctypes.util +import hashlib +import http.client +import json +import os +import pathlib +import platform +import shutil +import signal +import socket +import subprocess +import tarfile +import threading +import time +import urllib.error +import urllib.request + +import hub +from i18n import t + +HOST = "127.0.0.1" +# The path api.py asks for, so its URL and the server's line up. +INFERENCE_PATH = "/v1/audio/transcriptions" + +DATA_DIR = (pathlib.Path(os.environ.get("XDG_DATA_HOME") + or os.path.expanduser("~/.local/share")) / "dikte") +BIN_DIR = DATA_DIR / "bin" +MODELS_DIR = DATA_DIR / "models" + +# Loading a large model onto a GPU is the slow part of a start, and on a cold +# page cache a large LLM read from a spinning disk is slower still. +STARTUP_TIMEOUT = 180.0 +DOWNLOAD_CHUNK = 1 << 20 + +# `health` is the path that answers only once the model is in memory. whisper +# does not have one and does not need one: it binds its port after the model is +# loaded, so the port opening is the signal. +Program = collections.namedtuple("Program", "name repo binary health") + +WHISPER = Program("whisper", "ggml-org/whisper.cpp", "whisper-server", "") +LLAMA = Program("llama", "ggml-org/llama.cpp", "llama-server", "/health") + +# Where the models are listed. Neither list is written into Dikte: a catalogue +# in the source means a release of Dikte for every model somebody else +# publishes. +WHISPER_MODELS_REPO = "ggerganov/whisper.cpp" +LLM_AUTHOR = "ggml-org" + +# What the whisper repository holds besides models: Core ML encoders for Apple +# hardware and the odd loose file. +WHISPER_PREFIX = "ggml-" +WHISPER_SUFFIX = ".bin" + +# What a GGUF repository holds besides the model: mmproj is the vision half of a +# multimodal model, mtp a draft head for speculative decoding. Neither is a model +# a server can be started on, and offering them is offering a failure. +GGUF_SKIP = ("mmproj", "mtp-") +# Big enough for a 12B at Q4 and far past anything cleanup wants; the point is +# to keep a 400 GB frontier model out of a list somebody might click. +GGUF_MAX_BYTES = 16 << 30 + +# Suggestions, not a catalogue: the list itself is fetched, and these are only +# the rows that float to the top of it. Small instruction-following models, +# because cleanup is punctuation and filler words rather than anything that +# wants thinking about. +SUGGESTED_LLM = ( + "ggml-org/gemma-3-4b-it-GGUF", + "ggml-org/gemma-4-E2B-it-GGUF", + "ggml-org/gemma-4-E4B-it-GGUF", + "ggml-org/SmolLM3-3B-GGUF", +) +# Turbo at q5_0 is smaller than `small` and better than it, which makes the +# usual "start small" advice point at the same file as "start good". +SUGGESTED_WHISPER = "ggml-large-v3-turbo-q5_0.bin" + + +class LocalError(Exception): + pass + + +def human_size(count): + for unit in ("B", "KB", "MB", "GB"): + if count < 1024 or unit == "GB": + return f"{count:.0f} {unit}" if unit == "B" else f"{count:.1f} {unit}" + count /= 1024.0 + return f"{count:.1f} GB" + + +# --- fetching ------------------------------------------------------------- + + +def download(item, target, on_progress=None, should_stop=None): + """Fetch one hub.Item to `target`. True when it landed, False when stopped. + + The bytes go to a `.part` that is renamed only after both the length and the + hash agree with what the index said. A truncated file would otherwise sit + there looking installed and fail much later, inside a server, as a corrupt + model; a file that is the right length but the wrong content is worse, and + this is a program as often as it is a model. + """ + target = pathlib.Path(target) + part = target.with_name(target.name + ".part") + try: + target.parent.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise LocalError(t("Could not create {path}: {error}", + path=target.parent, error=exc)) from exc + + request = urllib.request.Request(item.url, headers={"User-Agent": hub.USER_AGENT}) + digest = hashlib.sha256() + done = 0 + try: + with urllib.request.urlopen(request, timeout=60) as response: + total = int(response.headers.get("Content-Length") or item.size or 0) + with open(part, "wb") as out: + while True: + if should_stop is not None and should_stop(): + part.unlink(missing_ok=True) + return False + block = response.read(DOWNLOAD_CHUNK) + if not block: + break + out.write(block) + digest.update(block) + done += len(block) + # More than was announced: a body that does not end is the + # one way this loop could run until the disk is full. + if total and done > total: + part.unlink(missing_ok=True) + raise LocalError(t("{name} is longer than it said it " + "would be.", name=item.name)) + if on_progress is not None: + on_progress(done, total) + # A proxy notice or an error page that came back as 200 would otherwise + # be renamed into place and only fail when something tries to read it. + if total and done != total: + part.unlink(missing_ok=True) + raise LocalError(t("The download stopped early ({done} of {total}).", + done=human_size(done), total=human_size(total))) + if item.sha256 and digest.hexdigest() != item.sha256: + part.unlink(missing_ok=True) + raise LocalError(t("{name} does not match its published checksum. " + "Nothing was installed.", name=item.name)) + part.replace(target) + return True + except urllib.error.HTTPError as exc: + part.unlink(missing_ok=True) + exc.close() # it holds the response body open until it is collected + raise LocalError(t("Could not download {name}: HTTP {code}", + name=item.name, code=exc.code)) from exc + except urllib.error.URLError as exc: + part.unlink(missing_ok=True) + raise LocalError(t("Could not download {name}: {error}", + name=item.name, error=exc.reason)) from exc + except OSError as exc: + # A connection cut mid-body arrives here too, and gigabytes in is + # exactly where that happens. + part.unlink(missing_ok=True) + raise LocalError(t("Could not write {name}: {error}", + name=item.name, error=exc)) from exc + + +# --- the programs --------------------------------------------------------- + + +def _arch(): + machine = platform.machine().lower() + if machine in ("aarch64", "arm64"): + return "arm64" + return "x64" + + +def _has_vulkan(): + """Whether a Vulkan loader is installed, which decides which build to fetch. + + llama.cpp publishes no CUDA build for Linux, so Vulkan is what a graphics + card gets here. The build without it is smaller and runs on the CPU, and + fetching the Vulkan one for a machine that cannot load it would only make + the download bigger. + """ + return bool(ctypes.util.find_library("vulkan")) + + +def _wanted_assets(program): + """Asset name endings to accept, best first.""" + arch = _arch() + if program is LLAMA and _has_vulkan(): + return (f"bin-ubuntu-vulkan-{arch}.tar.gz", f"bin-ubuntu-{arch}.tar.gz") + return (f"bin-ubuntu-{arch}.tar.gz",) + + +def _install_record(program): + return BIN_DIR / program.name / "installed.json" + + +def installed_program(program): + """The binary Dikte downloaded, or "" when there is none that still runs.""" + try: + record = json.loads(_install_record(program).read_text(encoding="utf-8")) + path = record.get("binary") or "" + except (OSError, ValueError): + return "" + return path if os.path.isfile(path) and os.access(path, os.X_OK) else "" + + +def installed_version(program): + try: + record = json.loads(_install_record(program).read_text(encoding="utf-8")) + return record.get("tag") or "" + except (OSError, ValueError): + return "" + + +def program_path(program, custom=""): + """Which copy of the program to run, or "" when there is none. + + A system one wins over a downloaded one. The distribution package is built + against whatever the machine has, which on this platform means it may reach + the graphics card, while the release binaries carry CPU backends only. + """ + custom = (custom or "").strip() + if custom: + return custom if os.path.isfile(custom) and os.access(custom, os.X_OK) else "" + return shutil.which(program.binary) or installed_program(program) + + +def system_program(program): + """Whether the program came from the system rather than from Dikte.""" + return bool(shutil.which(program.binary)) + + +def _find_binary(root, name): + for path in sorted(pathlib.Path(root).rglob(name)): + if path.is_file(): + return path + return None + + +def _extract(archive, into): + """Unpack a release tarball, refusing anything that reaches outside `into`. + + The archives lay their libraries next to their binaries and are linked with + an $ORIGIN runpath, so a whole directory is what has to survive the trip and + the binary cannot be lifted out of it. + """ + try: + with tarfile.open(archive, "r:gz") as tar: + try: + tar.extractall(into, filter="data") + except TypeError: # Python without the extraction filters + tar.extractall(into) + except (tarfile.TarError, OSError) as exc: + raise LocalError(t("Could not unpack {name}: {error}", + name=os.path.basename(str(archive)), error=exc)) from exc + + +def install_program(program, tag="", on_progress=None, should_stop=None, + refresh=False): + """Fetch and unpack a release. The path to the binary, or "" when stopped. + + `tag` is empty for whatever the project released last, which is the point: + a version pinned in Dikte's source would mean a release of Dikte every time + whisper.cpp has one. + """ + try: + tag, assets = hub.release(program.repo, tag or "latest", 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: + raise LocalError(t("{repo} {tag} has no build for this machine.", + repo=program.repo, tag=tag)) + + into = BIN_DIR / program.name / tag + shutil.rmtree(into, ignore_errors=True) + archive = BIN_DIR / program.name / item.name + try: + if not download(item, archive, on_progress, should_stop): + return "" + _extract(archive, into) + binary = _find_binary(into, program.binary) + if binary is None: + raise LocalError(t("{name} was not in the download.", + name=program.binary)) + binary.chmod(binary.stat().st_mode | 0o111) + _install_record(program).write_text( + json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8") + except OSError as exc: + raise LocalError(t("Could not install {name}: {error}", + name=program.name, error=exc)) from exc + finally: + try: + archive.unlink(missing_ok=True) + except OSError: + pass + _drop_old_versions(program, keep=tag) + return str(binary) + + +def _drop_old_versions(program, keep): + """Leave one unpacked release behind, not one per update.""" + root = BIN_DIR / program.name + try: + for path in root.iterdir(): + if path.is_dir() and path.name != keep: + shutil.rmtree(path, ignore_errors=True) + except OSError: + pass + + +# --- the models ----------------------------------------------------------- + + +def whisper_models(refresh=False): + """[hub.Item] for every whisper model on offer, smallest first.""" + try: + files = hub.files(WHISPER_MODELS_REPO, refresh=refresh) + except hub.HubError as exc: + raise LocalError(str(exc)) from exc + models = [f for f in files + if f.name.startswith(WHISPER_PREFIX) and f.name.endswith(WHISPER_SUFFIX) + and f.size > 0] + return sorted(models, key=lambda f: f.size) + + +def llm_repos(refresh=False): + """Repository ids for the GGUF models on offer, suggestions first.""" + try: + found = [r.id for r in hub.repos(author=LLM_AUTHOR, refresh=refresh)] + except hub.HubError: + # A menu rather than a catalogue: with nothing to show, the suggestions + # are still worth showing, and whatever is wrong with the network will + # say so where it matters, when a download is asked for. + found = [] + if not found: + return list(SUGGESTED_LLM) + first = [r for r in SUGGESTED_LLM if r in found] + return first + [r for r in found if r not in first] + + +def llm_quants(repo, refresh=False): + """[hub.Item] for the model files in one GGUF repository, smallest first.""" + try: + files = hub.files(repo, refresh=refresh) + except hub.HubError as exc: + raise LocalError(str(exc)) from exc + out = [] + for item in files: + name = item.name.rsplit("/", 1)[-1] + if not name.endswith(".gguf") or name.startswith(GGUF_SKIP): + continue + # A model split across files needs all of them and a different command + # line; anything cleanup wants fits in one. + if "-of-000" in name or not 0 < item.size <= GGUF_MAX_BYTES: + continue + out.append(item) + return sorted(out, key=lambda f: f.size) + + +def whisper_model_path(name): + return MODELS_DIR / "whisper" / name + + +def llm_model_path(name): + return MODELS_DIR / "llm" / name.rsplit("/", 1)[-1] + + +def have_model(path): + path = pathlib.Path(path) + return path.is_file() and path.stat().st_size > 0 + + +def installed_whisper_models(): + return sorted(p.name for p in (MODELS_DIR / "whisper").glob("*.bin")) + + +def installed_llm_models(): + return sorted(p.name for p in (MODELS_DIR / "llm").glob("*.gguf")) + + +def delete_model(path): + try: + pathlib.Path(path).unlink() + except FileNotFoundError: + pass + except OSError as exc: + raise LocalError(t("Could not delete the model: {error}", error=exc)) from exc + + +# --- one server ----------------------------------------------------------- + + +def _free_port(): + """A port nothing is listening on, handed straight to the server. + + Between closing this socket and the server binding it, something else could + take it; that is why a start retries rather than trusting the number. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((HOST, 0)) + return sock.getsockname()[1] + + +def _listening(port): + try: + with socket.create_connection((HOST, port), timeout=0.5): + return True + except OSError: + return False + + +def _healthy(port, path): + """Whether the model is in memory, for a server that says so. + + Spoken over http.client rather than urllib because this never leaves the + machine: it is the same question as _listening, one layer up. + """ + connection = http.client.HTTPConnection(HOST, port, timeout=2) + try: + connection.request("GET", path) + # 503 for as long as the model is still being read in. + return connection.getresponse().status == 200 + except (http.client.HTTPException, OSError): + return False + finally: + connection.close() + + +def _tail(path, lines=3): + try: + with open(path, encoding="utf-8", errors="replace") as fh: + found = [line.strip() for line in fh if line.strip()] + except OSError: + return "" + return " | ".join(found[-lines:]) + + +class Server: + """One process, started when something needs it and stopped when nothing does. + + `build` turns the settings into a command line; everything else about + running a server is the same for both programs. + """ + + def __init__(self, program, build, defaults): + self.program = program + self._build = build + self._settings = dict(defaults) + # Two locks on purpose. `_lock` is held for the length of a dictionary + # lookup, so the interface can ask what is running while a model is + # being loaded; `_starting` is held across the start itself, which can + # take a minute and which two threads must not both do. + self._lock = threading.Lock() + self._starting = threading.Lock() + self._proc = None + self._port = 0 + self._log = "" + self._key = None + + # ---- settings -------------------------------------------------------- + + def configure(self, **changes): + """Apply settings. A server started on the old ones is stopped.""" + with self._lock: + for key, value in changes.items(): + if value is not None and key in self._settings: + self._settings[key] = value + stale = self._proc is not None and self._key != self._settings_key() + if stale: + self.stop() + + def settings(self): + with self._lock: + return dict(self._settings) + + def _settings_key(self): + """What a running server would have to be restarted for.""" + return json.dumps(self._settings, sort_keys=True, default=str) + + # ---- process --------------------------------------------------------- + + @property + def running(self): + with self._lock: + return self._proc is not None and self._proc.poll() is None + + def base_url(self): + with self._lock: + return f"http://{HOST}:{self._port}/v1" if self._port else "" + + def error(self): + """The last thing the server printed, for a failure after it started.""" + with self._lock: + log = self._log + return _tail(log) if log else "" + + def serve(self): + """The base URL of a server that is up and running the current settings.""" + ready = self._current_url() + if ready: + return ready + with self._starting: + # Somebody may have started it while this thread waited its turn. + ready = self._current_url() + if ready: + return ready + self.stop() + with self._lock: + settings, key = dict(self._settings), self._settings_key() + proc, port, log = self._launch(settings) + with self._lock: + self._proc, self._port, self._log, self._key = proc, port, log, key + return self.base_url() + + def _current_url(self): + with self._lock: + up = self._proc is not None and self._proc.poll() is None + return (f"http://{HOST}:{self._port}/v1" + if up and self._key == self._settings_key() else "") + + def _launch(self, settings): + args = self._build(settings) # raises LocalError when unusable + last = "" + for _ in range(3): + port = _free_port() + log = DATA_DIR / f"{self.program.name}-server.log" + try: + log.parent.mkdir(parents=True, exist_ok=True) + sink = open(log, "wb") + except OSError as exc: + raise LocalError(t("Could not start {name}: {error}", + name=self.program.name, error=exc)) from exc + try: + with sink: + proc = subprocess.Popen( + args + ["--host", HOST, "--port", str(port)], + stdout=sink, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + ) + except OSError as exc: + raise LocalError(t("Could not start {name}: {error}", + name=self.program.name, error=exc)) from exc + + # Written before it is ready rather than after, so that a kill + # during the model load leaves something for the sweep to find. + self._remember(proc.pid) + if self._wait_ready(proc, port): + return proc, port, str(log) + last = _tail(log) + self._forget() + # A port taken between the probe and the bind is the one failure + # worth another go; anything else will fail the same way again. + if "address" not in last.lower() and "bind" not in last.lower(): + break + raise LocalError(t("{name} did not start: {error}", + name=self.program.binary, error=last or t("no output"))) + + def _wait_ready(self, proc, port): + deadline = time.monotonic() + STARTUP_TIMEOUT + while time.monotonic() < deadline: + if proc.poll() is not None: + return False + if _listening(port): + # whisper binds after the model is loaded, so the open port is + # the answer. llama binds first and answers /health with 503 + # until it is ready. + if not self.program.health or _healthy(port, self.program.health): + return True + time.sleep(0.1) + proc.kill() + proc.wait(timeout=5) + return False + + def stop(self): + with self._lock: + proc, self._proc = self._proc, None + self._port, self._log, self._key = 0, "", None + if proc is not None and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + if proc is not None: + self._forget() + + # ---- servers a killed Dikte left behind ------------------------------- + + def _pid_file(self): + return DATA_DIR / f"{self.program.name}-server.pid" + + def _remember(self, pid): + try: + path = self._pid_file() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(str(pid)) + except OSError: + pass # the sweep is a safety net, not something to fail a run over + + def _forget(self): + try: + self._pid_file().unlink() + except OSError: + pass + + def _is_ours(self, pid): + """Whether that pid is still the server this Dikte started. + + Asked because pids are handed out again: by the time anyone looks, the + number could belong to something else entirely, and killing it would be + a good deal worse than the leak being cleaned up. The program name alone + could be somebody else's copy; the name together with Dikte's own data + directory on the command line could not. + """ + try: + blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes() + except OSError: + return False + return (self.program.binary.encode() in blob + and str(DATA_DIR).encode() in blob) + + def sweep(self): + """Kill a server a previous Dikte left behind. True when one was found. + + stop() and atexit cover every exit that gets to run code. A SIGKILL does + not, and neither does a session torn down from under it, and the server + would then sit there holding the model with nothing left alive to ask it + anything. + """ + try: + pid = int(self._pid_file().read_text().strip()) + except (OSError, ValueError): + return False + self._forget() + if not self._is_ours(pid): + return False + try: + os.kill(pid, signal.SIGTERM) + except OSError: + return False + return True + + +# --- the two of them ------------------------------------------------------ + + +def _whisper_args(settings): + binary = program_path(WHISPER, settings["binary"]) + if not binary: + raise LocalError(t("whisper.cpp is not installed. Settings → API and " + "models → Download.")) + model = whisper_model_path(settings["model"]) + if not settings["model"] or not have_model(model): + raise LocalError(t("No whisper model has been downloaded yet. " + "Settings → API and models → Download.")) + args = [ + binary, "-m", str(model), + "--inference-path", INFERENCE_PATH, + # Whatever language the request does not name. api.py leaves the field + # out when the language is "auto", and the server's own default is + # English rather than detection. + "-l", "auto", + # Stock phrases invented for near-silence come from non-speech tokens, + # and verbose_json otherwise pays for a language probability sweep + # nothing here reads. + "-sns", "-nlp", + ] + if int(settings["threads"]) > 0: + args += ["-t", str(int(settings["threads"]))] + if not settings["gpu"]: + args.append("-ng") + return args + + +def _llm_args(settings): + binary = program_path(LLAMA, settings["binary"]) + if not binary: + raise LocalError(t("llama.cpp is not installed. Settings → API and " + "models → Download.")) + model = llm_model_path(settings["model"]) + if not settings["model"] or not have_model(model): + raise LocalError(t("No local cleanup model has been downloaded yet. " + "Settings → API and models → Download.")) + args = [binary, "-m", str(model), "-c", str(int(settings["context"]))] + # All of them, or as many as fit: llama.cpp stops offloading when the card + # is full rather than failing, and a build with no GPU backend ignores it. + args += ["-ngl", "99" if settings["gpu"] else "0"] + if int(settings["threads"]) > 0: + args += ["-t", str(int(settings["threads"]))] + return args + + +whisper = Server(WHISPER, _whisper_args, { + "model": "", + "threads": 0, + "gpu": True, + "binary": "", +}) + +llm = Server(LLAMA, _llm_args, { + "model": "", + "threads": 0, + "gpu": True, + "binary": "", + # A dictation and its prompt are short. This is sized for the longest + # cleanup block rather than for a conversation, and it is what the model + # costs in memory beyond its own weights. + "context": 8192, +}) + +SERVERS = (whisper, llm) + + +def sweep(): + """Clean up after a Dikte that was killed outright. True when one was found.""" + return any([server.sweep() for server in SERVERS]) + + +def stop_all(): + for server in SERVERS: + server.stop() + + +# Dikte stops the servers itself on quit and on restart; this catches the paths +# that skip that, such as an unhandled exception on the way out. +atexit.register(stop_all) diff --git a/hub.py b/hub.py new file mode 100644 index 0000000..f793d1c --- /dev/null +++ b/hub.py @@ -0,0 +1,186 @@ +"""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 the string table: this module +knows two websites and nothing about dictation. +""" + +import collections +import json +import os +import pathlib +import time +import urllib.error +import urllib.parse +import urllib.request + +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 = (pathlib.Path(os.environ.get("XDG_CACHE_HOME") + or os.path.expanduser("~/.cache")) / "dikte") +# 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 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)) + 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 + + +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")] diff --git a/tests/test_ggml.py b/tests/test_ggml.py new file mode 100644 index 0000000..ea2af74 --- /dev/null +++ b/tests/test_ggml.py @@ -0,0 +1,582 @@ +"""Fetching a program and a model, and keeping a server alive on them. + +No network and no whisper.cpp: the downloads are answered from memory, and the +servers are stand-in scripts that take the same arguments and open their port +when they are told to, which is the only thing the code waits on. +""" + +import contextlib +import hashlib +import io +import os +import signal +import sys +import tarfile +import textwrap +import threading +import time +from unittest import mock + +import ggml +import hub +from tests.support import (DikteTest, fake_urlopen, http_error, json_body, + linux_only, url_error) + + +def body(data, length=None): + """What urlopen hands back for a download: a reader with a length header.""" + class Body: + def __init__(self): + self._buf = io.BytesIO(data) + self.headers = {"Content-Length": + str(len(data) if length is None else length)} + + def read(self, count=-1): + return self._buf.read(count) + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + return Body() + + +def item(name, data, url="https://example.invalid/f", sha=True): + return hub.Item(name, url, len(data), + hashlib.sha256(data).hexdigest() if sha else "") + + +@contextlib.contextmanager +def serving(release, archive): + """Answer by what is being asked for rather than by what came before. + + An install asks GitHub what the release is and then asks for one file out of + it, and the first of those two comes from the cache the second time around. + Answering in order would then hand the archive request the release listing. + """ + def opener(request, timeout=None): + url = request.full_url + if "api.github.com" in url: + return json_body(release) + return body(archive) + + with mock.patch("urllib.request.urlopen", side_effect=opener) as calls: + yield calls + + +def tarball(entries): + """A .tar.gz laid out the way the releases are: one directory of files.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for name, content in entries.items(): + info = tarfile.TarInfo(name) + info.size = len(content) + info.mode = 0o755 + tar.addfile(info, io.BytesIO(content)) + return buf.getvalue() + + +class Local(DikteTest): + """A test with its own bin, models and cache directories.""" + + def setUp(self): + super().setUp() + self.patch_attr(ggml, "DATA_DIR", self.path("data")) + self.patch_attr(ggml, "BIN_DIR", self.path("data", "bin")) + self.patch_attr(ggml, "MODELS_DIR", self.path("data", "models")) + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + + +# --- downloading ---------------------------------------------------------- + + +class Download(Local): + def test_it_lands_and_the_part_file_is_gone(self): + data = b"a model, more or less" * 100 + target = self.path("data", "models", "m.bin") + with fake_urlopen(body(data)): + self.assertTrue(ggml.download(item("m.bin", data), target)) + self.assertEqual(target.read_bytes(), data) + self.assertFalse(target.with_name("m.bin.part").exists()) + + def test_a_wrong_checksum_installs_nothing(self): + data = b"the bytes that arrived" + wrong = hub.Item("m.bin", "https://example.invalid/f", len(data), "f" * 64) + target = self.path("data", "models", "m.bin") + with fake_urlopen(body(data)): + with self.assertRaises(ggml.LocalError) as caught: + ggml.download(wrong, target) + self.assertIn("checksum", str(caught.exception)) + self.assertFalse(target.exists()) + self.assertFalse(target.with_name("m.bin.part").exists()) + + def test_a_body_shorter_than_its_header_installs_nothing(self): + data = b"half of it" + target = self.path("data", "models", "m.bin") + with fake_urlopen(body(data, length=len(data) * 2)): + with self.assertRaises(ggml.LocalError): + ggml.download(item("m.bin", data), target) + self.assertFalse(target.exists()) + + def test_a_file_with_no_published_checksum_is_still_taken(self): + data = b"a README, say" + target = self.path("data", "models", "readme") + with fake_urlopen(body(data)): + self.assertTrue(ggml.download(item("readme", data, sha=False), target)) + self.assertTrue(target.exists()) + + def test_stopping_leaves_nothing_behind(self): + data = b"x" * (ggml.DOWNLOAD_CHUNK * 3) + target = self.path("data", "models", "m.bin") + with fake_urlopen(body(data)): + landed = ggml.download(item("m.bin", data), target, + should_stop=lambda: True) + self.assertFalse(landed) + self.assertFalse(target.exists()) + self.assertFalse(target.with_name("m.bin.part").exists()) + + def test_progress_is_reported_against_the_total(self): + data = b"y" * (ggml.DOWNLOAD_CHUNK + 5) + seen = [] + with fake_urlopen(body(data)): + ggml.download(item("m.bin", data), self.path("data", "m.bin"), + on_progress=lambda done, total: seen.append((done, total))) + self.assertEqual(seen[-1], (len(data), len(data))) + self.assertGreater(len(seen), 1) + + def test_a_refused_connection_says_which_file(self): + with fake_urlopen(url_error("no route to host")): + with self.assertRaises(ggml.LocalError) as caught: + ggml.download(item("m.bin", b"x"), self.path("data", "m.bin")) + self.assertIn("m.bin", str(caught.exception)) + + def test_an_http_error_is_not_written_to_disk(self): + target = self.path("data", "m.bin") + with fake_urlopen(http_error(404)): + with self.assertRaises(ggml.LocalError): + ggml.download(item("m.bin", b"x"), target) + self.assertFalse(target.exists()) + + +# --- installing a program ------------------------------------------------- + + +class InstallProgram(Local): + def release(self, *names): + return {"tag_name": "v1.9.1", "assets": [ + {"name": name, "browser_download_url": f"https://example.invalid/{name}", + "size": 10, "digest": ""} for name in names]} + + def archive(self): + return tarball({ + "whisper-bin-ubuntu-x64/whisper-server": b"#!/bin/sh\nexit 0\n", + "whisper-bin-ubuntu-x64/libwhisper.so": b"not really a library", + }) + + def install(self, *names, archive=None): + self.patch_attr(ggml, "_arch", lambda: "x64") + with serving(self.release(*names), + self.archive() if archive is None else archive) as calls: + path = ggml.install_program(ggml.WHISPER) + return path, [call.args[0].full_url for call in calls.call_args_list] + + def test_the_binary_and_its_libraries_land_together(self): + path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz") + self.assertTrue(os.path.isfile(path)) + self.assertTrue(os.access(path, os.X_OK)) + self.assertTrue(os.path.isfile(os.path.join(os.path.dirname(path), + "libwhisper.so"))) + + def test_the_build_for_this_machine_is_the_one_fetched(self): + _, urls = self.install("whisper-bin-x64.zip", "whisper-bin-ubuntu-arm64.tar.gz", + "whisper-bin-ubuntu-x64.tar.gz") + self.assertTrue(urls[1].endswith("whisper-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")): + with self.assertRaises(ggml.LocalError) as caught: + ggml.install_program(ggml.WHISPER) + self.assertIn("this machine", str(caught.exception)) + + def test_what_was_installed_is_remembered(self): + path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz") + self.assertEqual(ggml.installed_program(ggml.WHISPER), path) + self.assertEqual(ggml.installed_version(ggml.WHISPER), "v1.9.1") + + def test_a_record_pointing_at_a_deleted_binary_counts_for_nothing(self): + path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz") + os.unlink(path) + self.assertEqual(ggml.installed_program(ggml.WHISPER), "") + + def test_the_archive_is_not_kept(self): + self.install("whisper-bin-ubuntu-x64.tar.gz") + left = list((self.path("data", "bin", "whisper")).glob("*.tar.gz")) + self.assertEqual(left, []) + + def test_the_previous_version_is_swept_up(self): + self.install("whisper-bin-ubuntu-x64.tar.gz") + old = self.path("data", "bin", "whisper", "v1.9.0") + old.mkdir(parents=True) + (old / "whisper-server").write_bytes(b"older") + self.install("whisper-bin-ubuntu-x64.tar.gz") + self.assertFalse(old.exists()) + + def test_an_archive_without_the_binary_is_refused(self): + empty = tarball({"whisper-bin-ubuntu-x64/README": b"nothing here"}) + with self.assertRaises(ggml.LocalError) as caught: + self.install("whisper-bin-ubuntu-x64.tar.gz", archive=empty) + self.assertIn("whisper-server", str(caught.exception)) + + def test_llama_takes_the_vulkan_build_when_there_is_a_loader(self): + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: True) + self.assertEqual(ggml._wanted_assets(ggml.LLAMA)[0], + "bin-ubuntu-vulkan-x64.tar.gz") + + def test_llama_falls_back_to_the_plain_build_without_one(self): + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: False) + self.assertEqual(ggml._wanted_assets(ggml.LLAMA), ("bin-ubuntu-x64.tar.gz",)) + + +class WhichCopyRuns(Local): + def test_a_system_build_wins_over_a_downloaded_one(self): + self.patch_attr(ggml, "installed_program", lambda program: "/data/whisper-server") + with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"): + self.assertEqual(ggml.program_path(ggml.WHISPER), "/usr/bin/whisper-server") + + def test_the_downloaded_one_is_used_when_there_is_no_system_build(self): + self.patch_attr(ggml, "installed_program", lambda program: "/data/whisper-server") + with mock.patch("shutil.which", return_value=None): + self.assertEqual(ggml.program_path(ggml.WHISPER), "/data/whisper-server") + + def test_a_setting_pointing_at_nothing_is_no_program(self): + self.assertEqual(ggml.program_path(ggml.WHISPER, "/nowhere/whisper-server"), "") + + def test_a_setting_pointing_at_a_program_wins(self): + mine = self.path("mine") + mine.write_text("#!/bin/sh\n") + mine.chmod(0o755) + with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"): + self.assertEqual(ggml.program_path(ggml.WHISPER, str(mine)), str(mine)) + + +# --- the lists ------------------------------------------------------------ + + +WHISPER_TREE = [ + {"type": "file", "path": "ggml-base.bin", "size": 147951465, + "lfs": {"oid": "a" * 64}}, + {"type": "file", "path": "ggml-large-v3-turbo-q5_0.bin", "size": 574041195, + "lfs": {"oid": "b" * 64}}, + {"type": "file", "path": "ggml-base-encoder.mlmodelc.zip", "size": 37922638, + "lfs": {"oid": "c" * 64}}, + {"type": "file", "path": "README.md", "size": 3196}, +] + +GGUF_TREE = [ + {"type": "file", "path": "gemma-3-4b-it-Q4_K_M.gguf", "size": 2489000000, + "lfs": {"oid": "a" * 64}}, + {"type": "file", "path": "gemma-3-4b-it-Q8_0.gguf", "size": 4130000000, + "lfs": {"oid": "b" * 64}}, + {"type": "file", "path": "mmproj-model-f16.gguf", "size": 851000000, + "lfs": {"oid": "c" * 64}}, + {"type": "file", "path": "mtp-gemma-4-E4B-it-Q4_0.gguf", "size": 59000000, + "lfs": {"oid": "d" * 64}}, + {"type": "file", "path": "huge-00001-of-00009.gguf", "size": 40000000000, + "lfs": {"oid": "e" * 64}}, + {"type": "file", "path": "README.md", "size": 100}, +] + + +class Catalogue(Local): + def test_only_models_are_offered_and_the_small_ones_first(self): + with fake_urlopen(WHISPER_TREE): + models = ggml.whisper_models() + self.assertEqual([m.name for m in models], + ["ggml-base.bin", "ggml-large-v3-turbo-q5_0.bin"]) + + def test_the_core_ml_encoders_are_not_models(self): + with fake_urlopen(WHISPER_TREE): + names = [m.name for m in ggml.whisper_models()] + self.assertNotIn("ggml-base-encoder.mlmodelc.zip", names) + + def test_the_projector_and_the_draft_head_are_not_models(self): + with fake_urlopen(GGUF_TREE): + names = [q.name for q in ggml.llm_quants("ggml-org/gemma-3-4b-it-GGUF")] + self.assertEqual(names, + ["gemma-3-4b-it-Q4_K_M.gguf", "gemma-3-4b-it-Q8_0.gguf"]) + + def test_a_model_split_across_files_is_left_out(self): + with fake_urlopen(GGUF_TREE): + names = [q.name for q in ggml.llm_quants("ggml-org/gemma-3-4b-it-GGUF")] + self.assertNotIn("huge-00001-of-00009.gguf", names) + + def test_the_suggestions_come_first_and_the_rest_follow(self): + listing = [{"id": "ggml-org/something-new-GGUF"}, + {"id": ggml.SUGGESTED_LLM[0]}] + with fake_urlopen(listing): + found = ggml.llm_repos() + self.assertEqual(found[0], ggml.SUGGESTED_LLM[0]) + self.assertIn("ggml-org/something-new-GGUF", found) + + def test_an_unreachable_list_still_offers_the_suggestions(self): + with fake_urlopen(url_error()): + self.assertEqual(ggml.llm_repos(), list(ggml.SUGGESTED_LLM)) + + def test_an_unreachable_whisper_list_is_an_error_worth_showing(self): + with fake_urlopen(url_error()): + with self.assertRaises(ggml.LocalError): + ggml.whisper_models() + + def test_what_is_on_disk_is_read_from_disk(self): + self.assertEqual(ggml.installed_whisper_models(), []) + path = ggml.whisper_model_path("ggml-base.bin") + path.parent.mkdir(parents=True) + path.write_bytes(b"model") + self.assertEqual(ggml.installed_whisper_models(), ["ggml-base.bin"]) + self.assertTrue(ggml.have_model(path)) + + def test_an_empty_file_is_not_a_model(self): + path = ggml.llm_model_path("ggml-org/x-GGUF/model.gguf") + path.parent.mkdir(parents=True) + path.write_bytes(b"") + self.assertFalse(ggml.have_model(path)) + + def test_a_model_is_named_by_its_file_not_its_repository(self): + self.assertEqual(ggml.llm_model_path("ggml-org/x-GGUF/model.gguf").name, + "model.gguf") + + +# --- keeping a server alive ----------------------------------------------- + + +STAND_IN = textwrap.dedent(""" + import http.server, sys, threading, time + + args = sys.argv[1:] + + def opt(name, default=""): + return args[args.index(name) + 1] if name in args else default + + if "--die" in args: + print("could not load model: no such file") + sys.exit(2) + + time.sleep(float(opt("--wait", "0"))) + + started = time.monotonic() + healthy_after = float(opt("--healthy-after", "0")) + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + ok = time.monotonic() - started >= healthy_after + self.send_response(200 if ok else 503) + self.end_headers() + self.wfile.write(b"{}") + + def log_message(self, *a): + pass + + server = http.server.HTTPServer((opt("--host"), int(opt("--port"))), Handler) + print("listening on " + opt("--port"), flush=True) + server.serve_forever() +""") + + +class Servers(Local): + def setUp(self): + super().setUp() + self.path("data").mkdir(parents=True, exist_ok=True) + # Named for the program and kept inside the data directory, because that + # is what the sweep looks for on a command line. + self.script = self.path("data", "whisper-server.py") + self.script.write_text(STAND_IN) + self.addCleanup(ggml.stop_all) + self.servers = [] + + def server(self, program=ggml.WHISPER, **settings): + defaults = {"extra": []} + defaults.update(settings) + made = ggml.Server( + program, + lambda values: [sys.executable, str(self.script)] + list(values["extra"]), + defaults, + ) + self.servers.append(made) + self.addCleanup(made.stop) + return made + + def test_a_started_server_hands_back_its_address(self): + server = self.server() + url = server.serve() + self.assertRegex(url, r"^http://127\.0\.0\.1:\d+/v1$") + self.assertTrue(server.running) + + def test_the_second_call_does_not_start_a_second_one(self): + server = self.server() + first = server.serve() + self.assertEqual(server.serve(), first) + + def test_a_settings_change_stops_what_was_running(self): + server = self.server() + server.serve() + server.configure(extra=["--wait", "0"]) + self.assertFalse(server.running) + + def test_the_new_settings_are_what_the_next_start_uses(self): + server = self.server() + server.serve() + server.configure(extra=["--healthy-after", "0"]) + second = server.serve() + self.assertTrue(server.running) + self.assertTrue(second) + + def test_a_program_that_dies_reports_what_it_printed(self): + server = self.server(extra=["--die"]) + with self.assertRaises(ggml.LocalError) as caught: + server.serve() + self.assertIn("no such file", str(caught.exception)) + self.assertFalse(server.running) + + def test_a_model_that_is_still_loading_is_not_ready_yet(self): + # llama binds its port first and answers /health with 503 until the + # model is in memory, so the open port on its own is not the signal. + server = self.server(program=ggml.LLAMA, extra=["--healthy-after", "0.4"]) + started = time.monotonic() + server.serve() + self.assertGreaterEqual(time.monotonic() - started, 0.4) + + def test_a_start_that_never_becomes_ready_gives_up(self): + self.patch_attr(ggml, "STARTUP_TIMEOUT", 0.5) + server = self.server(program=ggml.LLAMA, extra=["--healthy-after", "30"]) + with self.assertRaises(ggml.LocalError): + server.serve() + + def test_stopping_leaves_nothing_running(self): + server = self.server() + server.serve() + server.stop() + self.assertFalse(server.running) + self.assertEqual(server.base_url(), "") + + def test_the_last_thing_it_printed_is_available(self): + server = self.server() + server.serve() + self.assertIn("listening", server.error()) + + def test_asking_what_is_running_does_not_wait_for_a_start(self): + """A model being loaded must not freeze the settings window. + + The interface asks a running server what it is doing while a start is in + flight, and a lock held across the whole start would stop it dead. + """ + server = self.server(extra=["--wait", "0.6"]) + answers = [] + + def start(): + server.serve() + + thread = __import__("threading").Thread(target=start) + thread.start() + try: + time.sleep(0.15) + began = time.monotonic() + answers.append(server.settings()) + answers.append(server.running) + self.assertLess(time.monotonic() - began, 0.2) + finally: + thread.join(timeout=10) + + @linux_only + def test_a_server_a_killed_dikte_left_behind_is_swept_up(self): + server = self.server() + server.serve() + # What a SIGKILL of Dikte leaves: the child still running, the pid file + # still on disk, and nothing left that knows about either. + proc, server._proc = server._proc, None + self.assertTrue(server.sweep()) + self.assertEqual(proc.wait(timeout=5), -signal.SIGTERM) + + @linux_only + def test_a_pid_that_belongs_to_something_else_is_left_alone(self): + server = self.server() + server._remember(os.getpid()) # this test runner, not a server + self.assertFalse(server.sweep()) + + def test_no_pid_file_is_nothing_to_sweep(self): + self.assertFalse(self.server().sweep()) + + +class Arguments(Local): + """What the two command lines say, since neither program is here to say it.""" + + def setUp(self): + super().setUp() + self.binary = self.path("whisper-server") + self.binary.write_text("#!/bin/sh\n") + self.binary.chmod(0o755) + + def whisper_model(self, name="ggml-base.bin"): + path = ggml.whisper_model_path(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"model") + return name + + def test_the_inference_path_is_the_one_api_py_builds(self): + args = ggml._whisper_args({"binary": str(self.binary), "gpu": True, + "threads": 0, "model": self.whisper_model()}) + self.assertIn("--inference-path", args) + self.assertEqual(args[args.index("--inference-path") + 1], + "/v1/audio/transcriptions") + + def test_detection_rather_than_english_when_nothing_is_asked_for(self): + args = ggml._whisper_args({"binary": str(self.binary), "gpu": True, + "threads": 0, "model": self.whisper_model()}) + self.assertEqual(args[args.index("-l") + 1], "auto") + + def test_the_graphics_card_is_turned_off_rather_than_asked_for(self): + settings = {"binary": str(self.binary), "gpu": False, "threads": 2, + "model": self.whisper_model()} + args = ggml._whisper_args(settings) + self.assertIn("-ng", args) + self.assertEqual(args[args.index("-t") + 1], "2") + + def test_a_missing_model_is_a_message_about_settings(self): + with self.assertRaises(ggml.LocalError) as caught: + ggml._whisper_args({"binary": str(self.binary), "gpu": True, + "threads": 0, "model": "ggml-nothing.bin"}) + self.assertIn("Settings", str(caught.exception)) + + def test_a_missing_program_says_so_before_a_missing_model(self): + with mock.patch("shutil.which", return_value=None): + with self.assertRaises(ggml.LocalError) as caught: + ggml._whisper_args({"binary": "", "gpu": True, "threads": 0, + "model": self.whisper_model()}) + self.assertIn("whisper.cpp", str(caught.exception)) + + def test_the_layers_go_to_the_card_when_there_is_one(self): + model = ggml.llm_model_path("m.gguf") + model.parent.mkdir(parents=True, exist_ok=True) + model.write_bytes(b"gguf") + args = ggml._llm_args({"binary": str(self.binary), "gpu": True, + "threads": 0, "model": "m.gguf", "context": 4096}) + self.assertEqual(args[args.index("-ngl") + 1], "99") + self.assertEqual(args[args.index("-c") + 1], "4096") + + def test_no_card_means_no_layers_offloaded(self): + model = ggml.llm_model_path("m.gguf") + model.parent.mkdir(parents=True, exist_ok=True) + model.write_bytes(b"gguf") + args = ggml._llm_args({"binary": str(self.binary), "gpu": False, + "threads": 0, "model": "m.gguf", "context": 4096}) + self.assertEqual(args[args.index("-ngl") + 1], "0") + + +class Sizes(DikteTest): + def test_bytes_are_written_the_way_a_download_is_talked_about(self): + self.assertEqual(ggml.human_size(512), "512 B") + self.assertEqual(ggml.human_size(574041195), "547.4 MB") + self.assertEqual(ggml.human_size(3_095_033_483), "2.9 GB") diff --git a/tests/test_hub.py b/tests/test_hub.py new file mode 100644 index 0000000..f3741c2 --- /dev/null +++ b/tests/test_hub.py @@ -0,0 +1,184 @@ +"""What GitHub and Hugging Face are asked, and what is believed of the answer.""" + +import json + +import hub +from tests.support import DikteTest, fake_urlopen, http_error, url_error + +RELEASE = { + "tag_name": "v1.9.1", + "assets": [ + {"name": "whisper-bin-ubuntu-x64.tar.gz", + "browser_download_url": "https://example.invalid/ubuntu-x64.tar.gz", + "size": 9379235, "digest": "sha256:" + "a" * 64}, + {"name": "whisper-bin-x64.zip", + "browser_download_url": "https://example.invalid/win.zip", + "size": 100, "digest": "sha256:" + "b" * 64}, + {"name": "no-url-here.zip", "size": 1}, + ], +} + +TREE = [ + {"type": "file", "path": ".gitattributes", "size": 1477}, + {"type": "file", "path": "ggml-base.bin", "size": 147951465, + "lfs": {"oid": "c" * 64, "size": 147951465}}, + {"type": "directory", "path": "extra"}, + {"type": "file", "path": "extra/ggml-tiny.bin", "size": 77691713, + "lfs": {"oid": "d" * 64, "size": 77691713}}, +] + +MODELS = [ + {"id": "ggml-org/gemma-3-4b-it-GGUF", "downloads": 44606, + "lastModified": "2026-07-01T00:00:00.000Z"}, + {"id": "ggml-org/gpt-oss-20b-GGUF", "downloads": 47975}, + {"noid": True}, +] + + +class Releases(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + + def test_the_tag_and_the_assets_come_back(self): + with fake_urlopen(RELEASE) as calls: + tag, assets = hub.release("ggml-org/whisper.cpp") + self.assertEqual(tag, "v1.9.1") + self.assertEqual([a.name for a in assets], + ["whisper-bin-ubuntu-x64.tar.gz", "whisper-bin-x64.zip"]) + self.assertEqual(calls[0].full_url, + "https://api.github.com/repos/ggml-org/whisper.cpp/" + "releases/latest") + + def test_the_sha256_prefix_is_dropped(self): + with fake_urlopen(RELEASE): + _, assets = hub.release("ggml-org/whisper.cpp") + self.assertEqual(assets[0].sha256, "a" * 64) + + def test_a_tag_asks_for_that_tag(self): + with fake_urlopen(RELEASE) as calls: + hub.release("ggml-org/whisper.cpp", "v1.9.1") + self.assertTrue(calls[0].full_url.endswith("/releases/tags/v1.9.1")) + + def test_a_release_with_no_assets_is_an_error(self): + with fake_urlopen({"tag_name": "v1", "assets": []}): + with self.assertRaises(hub.HubError): + hub.release("ggml-org/whisper.cpp") + + def test_the_second_call_asks_nobody(self): + with fake_urlopen(RELEASE) as calls: + hub.release("ggml-org/whisper.cpp") + hub.release("ggml-org/whisper.cpp") + self.assertEqual(len(calls), 1) + + def test_a_refresh_asks_again(self): + with fake_urlopen(RELEASE) as calls: + hub.release("ggml-org/whisper.cpp") + hub.release("ggml-org/whisper.cpp", refresh=True) + self.assertEqual(len(calls), 2) + + def test_an_old_cache_beats_no_answer(self): + with fake_urlopen(RELEASE): + hub.release("ggml-org/whisper.cpp") + # Old enough that it would normally be fetched again, and no network + # to fetch it with. + for path in self.path("cache").iterdir(): + os_utime(path) + with fake_urlopen(url_error()): + tag, assets = hub.release("ggml-org/whisper.cpp") + self.assertEqual(tag, "v1.9.1") + self.assertEqual(len(assets), 2) + + def test_no_cache_and_no_network_says_so(self): + with fake_urlopen(url_error("no route to host")): + with self.assertRaises(hub.HubError) as caught: + hub.release("ggml-org/whisper.cpp") + self.assertIn("api.github.com", str(caught.exception)) + + def test_an_http_error_names_the_host_and_the_code(self): + with fake_urlopen(http_error(404, "nope")): + with self.assertRaises(hub.HubError) as caught: + hub.release("ggml-org/nothing") + self.assertIn("404", str(caught.exception)) + + +class Files(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + + def test_directories_are_left_out(self): + with fake_urlopen(TREE): + files = hub.files("ggerganov/whisper.cpp") + self.assertEqual([f.name for f in files], + [".gitattributes", "ggml-base.bin", "extra/ggml-tiny.bin"]) + + def test_the_url_is_the_one_that_serves_the_bytes(self): + with fake_urlopen(TREE): + files = hub.files("ggerganov/whisper.cpp") + self.assertEqual( + files[1].url, + "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin") + + def test_the_lfs_object_id_is_the_checksum(self): + with fake_urlopen(TREE): + files = hub.files("ggerganov/whisper.cpp") + self.assertEqual(files[1].sha256, "c" * 64) + self.assertEqual(files[1].size, 147951465) + + def test_a_file_outside_lfs_has_no_checksum(self): + with fake_urlopen(TREE): + files = hub.files("ggerganov/whisper.cpp") + self.assertEqual(files[0].sha256, "") + + def test_an_answer_that_is_not_a_list_is_an_error(self): + with fake_urlopen({"error": "Invalid username or password."}): + with self.assertRaises(hub.HubError): + hub.files("ggml-org/whisper.cpp") + + +class Repos(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + + def test_it_asks_for_one_author_and_for_gguf(self): + with fake_urlopen(MODELS) as calls: + found = hub.repos(author="ggml-org") + self.assertIn("author=ggml-org", calls[0].full_url) + self.assertIn("filter=gguf", calls[0].full_url) + self.assertEqual([r.id for r in found], + ["ggml-org/gemma-3-4b-it-GGUF", "ggml-org/gpt-oss-20b-GGUF"]) + + def test_a_missing_download_count_is_zero(self): + with fake_urlopen(MODELS): + found = hub.repos(author="ggml-org") + self.assertEqual(found[0].downloads, 44606) + self.assertEqual(found[1].updated, "") + + +def os_utime(path): + """Backdate a cache file past its time to live.""" + import os + import time + old = time.time() - hub.CACHE_TTL - 60 + os.utime(path, (old, old)) + + +class CacheOnDisk(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + + def test_what_is_stored_is_what_came_back(self): + with fake_urlopen(RELEASE): + hub.release("ggml-org/whisper.cpp") + stored = [json.loads(p.read_text()) for p in self.path("cache").iterdir()] + self.assertEqual(stored[0]["tag_name"], "v1.9.1") + + def test_a_cache_that_cannot_be_written_is_not_a_failure(self): + self.patch_attr(hub, "CACHE_DIR", self.path("nope", "deeper")) + self.path("nope").write_text("a file where a directory would go") + with fake_urlopen(RELEASE): + tag, _ = hub.release("ggml-org/whisper.cpp") + self.assertEqual(tag, "v1.9.1") From 2cfbbb2d990905df8841d97e9a6172c834bd4a8c Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 1 Aug 2026 20:00:35 +0300 Subject: [PATCH 2/6] Transcribe and clean up on this machine, without installing anything first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit whisper-server is started on --inference-path /v1/audio/transcriptions, which is exactly the path api.py already builds for the hosted providers, and llama-server answers /chat/completions the way OpenRouter does. So the local half is one more base URL rather than a second code path: worker.py, filetranscribe.py and meeting.py are untouched, and dictation, subtitles and meetings all work here on the first try. Three findings worth naming, none of them in the new code: whisper.cpp cuts segments on tokens, which in Turkish lands inside a word about as often as between two. Pasted raw that gives "akraba değ\niller."; in a subtitle it gives a cue reading "değ". Whisper marks the start of a word with a leading space, so a piece that does not begin with one continues the word above it. A small model will repeat the transcript until the context is full, and every one of those tokens is a second of somebody waiting: measured at 206 seconds, and 25 with a ceiling on the reply. Hosted models are left alone, where the same runaway is rare and a ceiling would cut the minutes short. A server outlives SIGTERM and SIGKILL holding its model in memory. Signals are now turned into an event Qt delivers, since Qt blocks in C where a Python handler never runs, and a pid file lets the next start sweep up what a SIGKILL left behind. The minutes keep their own provider rather than following cleanup's. The two jobs are not the same size: a 4B model here will strip the filler words out of a dictation and will not write up an hour long meeting. The suite runs offline now: a test that reaches the network says so instead of quietly going there. --- README.md | 22 +- README.tr.md | 23 +- api.py | 208 +++++++++++++--- config.py | 111 ++++++++- dikte.py | 94 +++++++- filetranscribe.py | 10 +- install.sh | 3 +- meeting.py | 24 +- settings_ui.py | 452 +++++++++++++++++++++++++++++++++-- tests/support.py | 12 + tests/test_api.py | 192 ++++++++++++++- tests/test_config.py | 87 ++++++- tests/test_filetranscribe.py | 2 +- tests/test_ui.py | 60 +++++ tests/test_worker.py | 2 +- worker.py | 12 +- 16 files changed, 1194 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 1d36007..bd27986 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Dikte -Press `Ctrl+Space`, talk, press again. The recording goes to OpenAI or OpenRouter -for transcription, a model on OpenRouter cleans it up (dropping the *uh*s, the -restarts, the missing punctuation), and the result lands in your clipboard and -is pasted into whatever window you were typing in. +Press `Ctrl+Space`, talk, press again. The recording is transcribed on this +machine by default, a model cleans it up (dropping the *uh*s, the restarts, the +missing punctuation), and the result lands in your clipboard and is pasted into +whatever window you were typing in. Built for KDE Plasma 6 on Wayland. No dependencies beyond system packages: just the Python standard library and PyQt6. @@ -39,10 +39,12 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg `install.sh` adds the `dikte` command, a menu entry and an autostart entry. The settings window installs a GNOME or KDE global shortcut. -Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text -runs on either one (`gpt-4o-transcribe` by default), cleanup always on -OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key can -cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are +Speech to text and cleanup each pick a provider in the settings window. Both can +run here, on whisper.cpp and llama.cpp: the program and the model are downloaded +from that window (checksummed, into `~/.local/share/dikte`), so nothing has to be +installed first and nothing leaves the machine. The hosted alternatives want a +key: **OpenAI** or **OpenRouter** for speech to text, **OpenRouter** for cleanup +(`google/gemini-3.5-flash-lite`), so a single OpenRouter key can cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off, in which case the raw transcript is pasted, and a thinking model's effort can be set next to it. @@ -144,7 +146,9 @@ ipc.py one request and one reply over the local socket audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting meeting.py channel split, speaker labelling, cleanup, minutes assistant.py running a dictation through Claude Code, Codex or OpenRouter -api.py transcription on either provider, OpenRouter cleanup (stdlib only) +api.py transcription and cleanup on any provider (stdlib only) +ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving +hub.py what GitHub and Hugging Face have on offer today worker.py transcribe → clean up → clipboard → paste vad.py deciding whether a recording holds speech at all filetranscribe.py file transcription: ffmpeg, chunking, timestamps diff --git a/README.tr.md b/README.tr.md index b6d3917..454ec6a 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,9 +1,8 @@ # Dikte -`Ctrl+Space`'e bas, konuş, tekrar bas. Ses OpenAI'ye ya da OpenRouter'a gidip -yazıya çevrilir, OpenRouter'daki bir model transkripti temizler (ıı'lar, -tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye -yapıştırılır. +`Ctrl+Space`'e bas, konuş, tekrar bas. Ses varsayılan olarak bu makinede yazıya +çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama), +sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. KDE Plasma 6 / Wayland için yazıldı. Sistem paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi ve PyQt6. @@ -39,10 +38,14 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg `install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar. -Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi -yazıya çevirme ikisinden birinde çalışır (varsayılan `gpt-4o-transcribe`), -temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir -OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY` ve +Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı +seçer. İkisi de burada çalışabilir, whisper.cpp ve llama.cpp üzerinde: program +da model de o pencereden indirilir (sha256 doğrulamasıyla, +`~/.local/share/dikte` altına), yani önceden hiçbir şey kurman gerekmez ve +makineden hiçbir şey çıkmaz. Bulut seçenekleri anahtar ister: yazıya çevirme +için **OpenAI** ya da **OpenRouter**, temizleme için **OpenRouter** +(`google/gemini-3.5-flash-lite`), yani tek bir OpenRouter anahtarı ikisine de +yeter. Boş bırakırsan `OPENAI_API_KEY` ve `OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin. @@ -142,7 +145,9 @@ ipc.py yerel sokette bir istek, bir cevap audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak assistant.py dikteyi Claude Code, Codex ya da OpenRouter'dan geçirme -api.py iki sağlayıcıda transkript + OpenRouter temizleme (yalnız stdlib) +api.py her sağlayıcıda transkript ve temizleme (yalnız stdlib) +ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma +hub.py GitHub ve Hugging Face'te bugün ne olduğu worker.py transkript → temizleme → pano → yapıştırma vad.py kayıtta gerçekten konuşma var mı kararı filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları diff --git a/api.py b/api.py index 95d794c..df9bab6 100644 --- a/api.py +++ b/api.py @@ -1,9 +1,14 @@ -"""OpenAI and OpenRouter calls, stdlib only. +"""OpenAI, OpenRouter and this machine, stdlib only. -Transcription runs on either provider: OpenRouter mirrors OpenAI's -/audio/transcriptions endpoint field for field, so one multipart request serves -both and only the key, the base URL and the model id change. Cleanup is always -OpenRouter. +Transcription runs on any of three providers and cleanup on two, and none of +them needs code of its own. OpenRouter mirrors OpenAI's /audio/transcriptions +endpoint field for field, and ggml.py starts whisper.cpp on that same path, so +one multipart request serves all three; llama.cpp answers /chat/completions the +way OpenRouter does, so one JSON request serves both. What changes between them +is the key, the base URL and the model id. + +The local ones have no key, and their base URL is not known until a server is +up, which is the one thing this module has to fill in for them. """ import collections @@ -14,6 +19,7 @@ import secrets import urllib.error import urllib.request +import ggml from i18n import t APP_URL = "https://github.com/yusufipk/dikte" @@ -21,14 +27,29 @@ USER_AGENT = f"dikte/1.0 (+{APP_URL})" OPENAI_URL = "https://api.openai.com/v1" OPENROUTER_URL = "https://openrouter.ai/api/v1" -# Where a transcription request goes; built by config.Config.transcribe_target(). -# `service` is the name the user sees in an error, `provider` the one the code -# branches on. -Target = collections.namedtuple("Target", "provider service api_key base_url model") +# The floor for a local request. The timeouts elsewhere are sized for a hosted +# API, where a slow answer is a bill running; here the only thing being spent is +# time, and a long recording on a machine without a graphics card takes a good +# deal of it. Cutting that off would throw the work away for nothing. +LOCAL_TIMEOUT = 3600 + +# Where a request goes; built by config.Config's *_target() methods. `service` +# is the name the user sees in an error, `provider` the one the code branches +# on. `reasoning` is only read by cleanup, which is the only job with a model +# that might think about anything. +Target = collections.namedtuple( + "Target", "provider service api_key base_url model reasoning", defaults=("",)) -def timestamp_model(provider): - """Only whisper-1 returns segment times, and OpenRouter namespaces the id.""" +def timestamp_model(provider, model): + """Only whisper-1 returns segment times, and OpenRouter namespaces the id. + + Whisper is what the local server runs whatever the file is called, so there + it stays on the model that is already loaded; asking for another one would + name a model that server has never heard of. + """ + if provider == "local": + return model return "openai/whisper-1" if provider == "openrouter" else "whisper-1" @@ -106,7 +127,11 @@ def _multipart(fields, file_field, file_path): def _headers(provider, api_key, content_type=None): - headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT} + headers = {"User-Agent": USER_AGENT} + # A server on this machine has nothing to authorise, and sending it a + # bearer token would only be a made-up one. + if api_key: + headers["Authorization"] = f"Bearer {api_key}" if content_type: headers["Content-Type"] = content_type if provider == "openrouter": @@ -116,17 +141,44 @@ def _headers(provider, api_key, content_type=None): return headers +def _serving(target, server, timeout): + """A local target with the address of a running server in it. + + The server is started on demand and picks its own port, so this is the first + moment its address exists. serve() is idempotent: once it is up this costs + nothing. + """ + try: + return target._replace(base_url=server.serve()), max(timeout, LOCAL_TIMEOUT) + except ggml.LocalError as exc: + raise ApiError(str(exc)) from None + + +def _local_failure(target, server, exc): + """A server that died mid-request, explained by its own output. + + Without this the message is that the connection dropped, when the reason for + it was printed by the process at the other end. + """ + detail = server.error() + return ApiError(f"{target.service}: {exc}" + (f" ({detail})" if detail else ""), + exc.status) + + def _transcribe_request(target, wav_path, language, prompt, response_format, granularity=None, timeout=300): - if not target.api_key: + if target.provider == "local": + target, timeout = _serving(target, ggml.whisper, timeout) + elif not target.api_key: raise ApiError(t("{service} API key is empty. Add it in Settings.", service=target.service)) fields = [("model", target.model), ("response_format", response_format)] if language and language != "auto": fields.append(("language", language)) # OpenRouter takes the hint field and throws it away, so spare it the bytes. - # The same words still reach the cleanup model as a glossary. - if prompt and target.provider == "openai": + # The same words still reach the cleanup model as a glossary. whisper.cpp + # takes it as the initial prompt, the way OpenAI does. + if prompt and target.provider in ("openai", "local"): fields.append(("prompt", prompt)) if granularity: fields.append(("timestamp_granularities[]", granularity)) @@ -137,14 +189,58 @@ def _transcribe_request(target, wav_path, language, prompt, response_format, _headers(target.provider, target.api_key, ctype), timeout=timeout, ) except ApiError as exc: + if target.provider == "local": + raise _local_failure(target, ggml.whisper, exc) from None raise explain(exc, target.service) from None +# Whisper marks the start of a word with a leading space, so a piece of text +# that does not begin with one continues the word before it rather than starting +# a new one. Both helpers below turn on that. +def _continues_a_word(previous, following): + return bool(previous) and not previous[-1:].isspace() and not following[:1].isspace() + + +def _local_text(text): + """whisper.cpp's segments, joined back into the flowing line OpenAI returns. + + Its plain text puts one segment per line, and a segment boundary falls + wherever the tokens fell, which in Turkish lands inside a word about as + often as between two. Nothing takes the line break's place: whisper's own + leading spaces are what separate the words, and a break inside "değ|iller" + has nothing on either side of it worth keeping. + """ + return "".join(text.split("\n")) + + +def _merge_word_splits(segments): + """Fold a segment that begins mid-word into the one it continues. + + The hosted whisper-1 hands back segments cut on sentences; whisper.cpp cuts + them on tokens, and a subtitle cue reading "değ" is not a cue. The times are + joined along with the text, so the merged segment still covers the whole + word. + """ + merged = [] + for seg in segments: + text = seg.get("text") or "" + if merged and _continues_a_word(merged[-1]["text"], text): + merged[-1]["text"] += text + merged[-1]["end"] = seg.get("end") or merged[-1]["end"] + continue + merged.append({"text": text, "start": seg.get("start") or 0.0, + "end": seg.get("end") or 0.0}) + return merged + + def transcribe(target, wav_path, language="", prompt="", timeout=300): data = _transcribe_request( target, wav_path, language, prompt, "json", timeout=timeout ) - text = (data.get("text") or "").strip() + text = data.get("text") or "" + if target.provider == "local": + text = _local_text(text) + text = text.strip() if not text: raise ApiError(t("Transcript came back empty.")) return text @@ -153,11 +249,13 @@ def transcribe(target, wav_path, language="", prompt="", timeout=300): def transcribe_segments(target, wav_path, language="", prompt="", timeout=300): """[(start_seconds, end_seconds, text)] using whisper-1's verbose response.""" data = _transcribe_request( - target._replace(model=timestamp_model(target.provider)), + target._replace(model=timestamp_model(target.provider, target.model)), wav_path, language, prompt, "verbose_json", granularity="segment", timeout=timeout, ) segments = data.get("segments") or [] + if target.provider == "local": + segments = _merge_word_splits(segments) out = [] for seg in segments: text = (seg.get("text") or "").strip() @@ -166,45 +264,91 @@ def transcribe_segments(target, wav_path, language="", prompt="", timeout=300): end = float(seg.get("end") or 0.0) out.append((start, max(end, start), text)) if not out: - text = (data.get("text") or "").strip() + text = data.get("text") or "" + if target.provider == "local": + text = _local_text(text) + text = text.strip() if not text: raise ApiError(t("Transcript came back empty.")) out = [(0.0, 0.0, text)] return out -def cleanup(text, api_key, model, system_prompt, reasoning="", - base_url=OPENROUTER_URL, timeout=180): - if not api_key: +def _thinking(target, payload): + """Ask for as much thinking as this provider understands, or for none. + + An empty level means "whatever the model does on its own", so nothing is + sent. The two providers mean opposite things by that, which is why the + setting is kept per provider: OpenRouter's cleanup models answer straight + away, while a local model that was trained to think will think, and cleanup + is punctuation rather than a job worth thinking about. + """ + if not target.reasoning: + return + if target.provider == "local-llm": + # What llama.cpp passes to the chat template. The models that think + # read it; the ones that do not ignore it. + payload["chat_template_kwargs"] = { + "enable_thinking": target.reasoning != "none"} + return + if target.reasoning != "none": + # The thinking itself is never shown, so ask for it to be left out. + payload["reasoning"] = {"effort": target.reasoning, "exclude": True} + + +def _local_ceiling(text): + """How much of a reply is worth waiting for from a model on this machine. + + Cleanup gives back what it was given, near enough, so a reply several times + the length of the transcript is a model that has lost the thread rather than + one doing the job. A small one will happily repeat the transcript until the + context is full, and every one of those tokens is a second of somebody + waiting. A hosted model is left alone: there the same runaway is rare, and a + ceiling would cut the minutes short instead. + """ + return max(512, len(text)) + + +def cleanup(target, text, system_prompt, timeout=180): + if target.provider == "local-llm": + target, timeout = _serving(target, ggml.llm, timeout) + elif not target.api_key: raise ApiError(t("{service} API key is empty. Add it in Settings.", - service="OpenRouter")) + service=target.service)) payload = { - "model": model, + "model": target.model, "temperature": 0, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"\n{text}\n"}, ], } - # An empty level means "whatever the model does on its own"; anything else is - # one of OpenRouter's efforts. The thinking itself is never shown, so ask for - # it to be left out of the reply. - if reasoning: - payload["reasoning"] = {"effort": reasoning, "exclude": True} + if target.provider == "local-llm": + payload["max_tokens"] = _local_ceiling(text) + _thinking(target, payload) try: data = _request( - f"{base_url.rstrip('/')}/chat/completions", + f"{target.base_url.rstrip('/')}/chat/completions", json.dumps(payload).encode("utf-8"), - _headers("openrouter", api_key, "application/json"), + _headers(target.provider, target.api_key, "application/json"), timeout=timeout, ) except ApiError as exc: - raise explain(exc, "OpenRouter") from None + if target.provider == "local-llm": + raise _local_failure(target, ggml.llm, exc) from None + raise explain(exc, target.service) from None choices = data.get("choices") or [] if not choices: raise ApiError(_extract_error(json.dumps(data))) - content = ((choices[0].get("message") or {}).get("content") or "").strip() + message = choices[0].get("message") or {} + content = (message.get("content") or "").strip() if not content: + # A thinking model can spend the whole reply on the thinking and leave + # nothing to paste. Worth naming, because the fix is a setting rather + # than a retry: cleanup is not a job that wants thinking. + if message.get("reasoning_content") or message.get("reasoning"): + raise ApiError(t("The cleanup model spent its whole reply on " + "thinking. Set Thinking to “Off”.")) raise ApiError(t("The cleanup model returned an empty reply.")) return content diff --git a/config.py b/config.py index 396bd58..16d9c0d 100644 --- a/config.py +++ b/config.py @@ -6,7 +6,9 @@ import os import pathlib import api +import ggml import i18n +from i18n import t def _xdg(var, default): @@ -365,14 +367,46 @@ DEFAULTS = { "openai_base_url": "https://api.openai.com/v1", "openrouter_api_key": "", "openrouter_base_url": "https://openrouter.ai/api/v1", - "transcribe_provider": "openai", # openai | openrouter + "transcribe_provider": "local", # local | openai | openrouter "transcribe_model": "gpt-4o-transcribe", # used when provider is openai "openrouter_transcribe_model": "openai/gpt-4o-transcribe", "language": "tr", "transcribe_prompt": "", + + # --- whisper.cpp, on this machine --------------------------------------- + # The program and the model are both fetched from Settings; empty means + # nothing has been downloaded yet, which is what opens Settings on a first + # run. + # Pointed at the suggestion rather than at nothing, so the settings window + # opens with the Download button already on the right model. + "local_model": ggml.SUGGESTED_WHISPER, + "local_threads": 0, # 0 -> whisper.cpp picks + "local_gpu": True, + "local_preload": True, # load the model while Dikte starts, rather + # than on the first dictation + "local_binary": "", # empty -> whichever copy ggml.py finds + "cleanup_enabled": True, + "cleanup_provider": "openrouter", # openrouter | local "cleanup_model": "google/gemini-3.5-flash-lite", "cleanup_reasoning": "", # empty -> whatever the model does by default + + # --- llama.cpp, on this machine ----------------------------------------- + # Kept apart from the meeting settings on purpose. Cleanup is punctuation + # and filler words, which a small model does in a moment; the minutes are a + # summary of an hour, which it does not. + "local_llm_model": "", # a file name, e.g. gemma-3-4b-it-Q4_K_M.gguf + # Where the model list is read from; the settings window offers the + # publishers ggml.py knows of and takes any other one that is typed in. + "local_llm_repo": ggml.SUGGESTED_LLM[0], + "local_llm_threads": 0, + "local_llm_gpu": True, + "local_llm_context": 8192, + "local_llm_binary": "", + "local_llm_preload": False, # heavier than whisper, so only when asked + # Off rather than empty: a model trained to think will, and 300 tokens of + # reasoning about a comma is 300 tokens of waiting. + "local_llm_reasoning": "none", "cleanup_prompt": "", # empty -> language-specific default "auto_paste": True, "paste_shortcut": "ctrl+v", @@ -400,6 +434,7 @@ DEFAULTS = { "meeting_language": "", # empty -> the dictation speech language "meeting_max_seconds": 14400, # 4 hours "meeting_cleanup": True, + "meeting_provider": "openrouter", # openrouter | local "meeting_model": "google/gemini-3.5-flash", "meeting_reasoning": "", "meeting_prompt": "", # empty -> language-specific default @@ -495,14 +530,84 @@ class Config: return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip() def transcribe_target(self): - """Key, endpoint and model for whichever provider does speech to text.""" - if self["transcribe_provider"] == "openrouter": + """Key, endpoint and model for whichever provider does speech to text. + + The local one leaves its base URL empty on purpose: the server picks a + port when it starts, and starting it here would make reading a setting + launch a process. api.py fills the address in when it is about to send + the request, which is the moment the server is needed anyway. + """ + provider = self["transcribe_provider"] + if provider == "local": + return api.Target("local", t("Local whisper"), "", "", + self["local_model"]) + if provider == "openrouter": return api.Target("openrouter", "OpenRouter", self.openrouter_key(), self["openrouter_base_url"], self["openrouter_transcribe_model"]) return api.Target("openai", "OpenAI", self.openai_key(), self["openai_base_url"], self["transcribe_model"]) + def cleanup_target(self): + """The same, for the model that tidies a transcript up.""" + if self["cleanup_provider"] == "local": + return api.Target("local-llm", t("Local model"), "", "", + self["local_llm_model"], self["local_llm_reasoning"]) + return api.Target("openrouter", "OpenRouter", self.openrouter_key(), + self["openrouter_base_url"], self["cleanup_model"], + self["cleanup_reasoning"]) + + def minutes_target(self): + """The same again, for the minutes. + + Its own provider rather than the cleanup one. The two jobs are not the + same size: a 4B model on this machine will strip the filler words out of + a dictation perfectly well and will not write up an hour long meeting, + so choosing it for the first must not quietly choose it for the second. + """ + if self["meeting_provider"] == "local": + return api.Target("local-llm", t("Local model"), "", "", + self["local_llm_model"], self["local_llm_reasoning"]) + return api.Target("openrouter", "OpenRouter", self.openrouter_key(), + self["openrouter_base_url"], self["meeting_model"], + self["meeting_reasoning"]) + + def transcribe_ready(self): + """Whether speech to text could run right now, without opening Settings.""" + if self["transcribe_provider"] == "local": + return self.local_whisper_ready() + return bool(self.transcribe_target().api_key) + + def local_whisper_ready(self): + return bool(ggml.program_path(ggml.WHISPER, self["local_binary"]) + and self["local_model"] + and ggml.have_model(ggml.whisper_model_path(self["local_model"]))) + + def local_llm_ready(self): + return bool(ggml.program_path(ggml.LLAMA, self["local_llm_binary"]) + and self["local_llm_model"] + and ggml.have_model(ggml.llm_model_path(self["local_llm_model"]))) + + def apply_local(self): + """Hand the local settings to the servers, restarting what they change.""" + ggml.whisper.configure( + model=self["local_model"], + threads=int(self["local_threads"]), + gpu=bool(self["local_gpu"]), + binary=self["local_binary"], + ) + ggml.llm.configure( + model=self["local_llm_model"], + threads=int(self["local_llm_threads"]), + gpu=bool(self["local_llm_gpu"]), + binary=self["local_llm_binary"], + context=int(self["local_llm_context"]), + ) + + def uses_local_llm(self): + """Whether anything is set to run the local cleanup model.""" + return "local" in (self["cleanup_provider"], self["meeting_provider"]) + def cleanup_prompt(self, with_timestamps=False, with_speakers=False, subtitles=False): turkish = i18n.language() == "tr" diff --git a/dikte.py b/dikte.py index cc17c6c..c667634 100755 --- a/dikte.py +++ b/dikte.py @@ -7,16 +7,20 @@ terminal talks to. Every verb it answers is in cli.py, which is also what runs command line says "there is no instance to talk to, so be one". """ +import contextlib import json import os +import signal +import socket import sys +import threading # A Wayland client cannot place a window in a screen corner, so the indicator # is drawn through XWayland. if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"): os.environ.setdefault("QT_QPA_PLATFORM", "xcb") -from PyQt6.QtCore import QTimer, QElapsedTimer # noqa: E402 +from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402 from PyQt6.QtGui import QAction, QIcon # noqa: E402 from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402 @@ -25,6 +29,7 @@ import assistant # noqa: E402 import audio # noqa: E402 import cli # noqa: E402 import config as cfg # noqa: E402 +import ggml # noqa: E402 import hotkey # noqa: E402 import i18n # noqa: E402 import ipc # noqa: E402 @@ -92,6 +97,9 @@ class Dikte: self.meeting_recorder = audio.MeetingRecorder() self.meetings = MeetingPipeline(self.conf) self.evdev = hotkey.EvdevHotkey() + # Before anything of ours is started: a server from a Dikte that was + # killed outright is still holding a model in memory. + ggml.sweep() self.recorder.level.connect(self._on_level) self.recorder.stopped.connect(self._on_recorded) @@ -802,9 +810,44 @@ class Dikte: # Don't drop the object while its own signal is still being delivered. QTimer.singleShot(0, lambda: setattr(self, "settings_window", None)) + def _apply_local(self): + """Pass the local settings on, and hold the models ready if asked to. + + Loading a model takes a second or two for whisper and longer for an LLM. + Doing it while Dikte starts rather than on the first dictation is the + whole reason a server is kept alive instead of running the program once + per recording; the checkboxes are there for the machine whose memory is + wanted elsewhere. + """ + self.conf.apply_local() + wanted = [] + if self.conf["transcribe_provider"] == "local": + if self.conf["local_preload"] and self.conf.local_whisper_ready(): + wanted.append((ggml.whisper, "whisper")) + else: + ggml.whisper.stop() # give the memory back when it is not in use + if self.conf.uses_local_llm(): + if self.conf["local_llm_preload"] and self.conf.local_llm_ready(): + wanted.append((ggml.llm, "llama")) + else: + ggml.llm.stop() + + def warm(): + for server, name in wanted: + try: + server.serve() + except ggml.LocalError as exc: + # Not worth an indicator: the first dictation raises the + # same thing where the user can act on it. + print(f"dikte: {name}: {exc}", file=sys.stderr) + + if wanted: + threading.Thread(target=warm, daemon=True).start() + def _apply_settings(self): self.overlay.corner = self.conf["overlay_corner"] self.ask_overlay.corner = self.conf["overlay_corner"] + self._apply_local() self._build_tray() self._refresh_tray() if self.conf["evdev_hotkey"]: @@ -834,6 +877,9 @@ class Dikte: self.meeting_recorder.stop() self.overlay.dismiss() self.ask_overlay.dismiss() + # Also on the restart path, which replaces the process without ever + # reaching atexit and would otherwise leave the models in memory. + ggml.stop_all() self.tray.hide() @@ -872,6 +918,41 @@ def main(): return run_app([arg for arg in argv if arg != "--gui"]) +def install_signal_handlers(app): + """Quit properly on the signals a session sends, rather than dying where we stand. + + Qt spends its time blocked inside C, and a Python signal handler only runs + between bytecodes, so on its own it would not run until the next event + arrived, which for an idle tray icon may be never. set_wakeup_fd writes the + signal number to a socket instead, and a notifier turns that into an event + Qt does deliver. + + Worth the trouble because of what shutdown() does: a logout sends SIGTERM, + and without this a whisper.cpp or llama.cpp server outlives the session + holding its model in memory. SIGKILL cannot be caught at all, which is what + ggml.sweep() is for. + + Returns the objects it made; they have to stay alive to keep working. + """ + reader, writer = socket.socketpair() + reader.setblocking(False) + writer.setblocking(False) + signal.set_wakeup_fd(writer.fileno()) + notifier = QSocketNotifier(reader.fileno(), QSocketNotifier.Type.Read) + + def woken(): + with contextlib.suppress(OSError): + reader.recv(64) + app.quit() # aboutToQuit runs shutdown() + + notifier.activated.connect(woken) + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + # A handler that does nothing, so that the default action, stopping the + # process where it stands, is replaced by the wakeup above. + signal.signal(sig, lambda *_: None) + return reader, writer, notifier + + def run_app(args): command = args[0] if args else "" @@ -879,6 +960,12 @@ def run_app(args): app.setApplicationName("Dikte") app.setDesktopFileName("dikte") app.setQuitOnLastWindowClosed(False) + # Before Dikte is built, because building it is what may start a server, and + # a signal arriving in the middle of that would otherwise take the default + # action and leave the server behind. A signal this early lands in the + # socket and is delivered as soon as the event loop starts. Held in a name + # so that the notifier and its socket outlive this function. + signal_plumbing = install_signal_handlers(app) # noqa: F841 if not QSystemTrayIcon.isSystemTrayAvailable(): print("dikte: no system tray found, running anyway") @@ -927,7 +1014,10 @@ def run_app(args): # No key for the chosen transcription provider means nothing can work yet, # so the settings window is the only useful thing to open. - if command == "settings" or not dikte.conf.transcribe_target().api_key: + # A transcription provider that cannot run yet, whether that is a missing + # API key or a model nobody has downloaded, means nothing can work, so the + # settings window is the only useful thing to open. + if command == "settings" or not dikte.conf.transcribe_ready(): dikte.open_settings() elif command == "toggle": QTimer.singleShot(0, dikte.toggle) diff --git a/filetranscribe.py b/filetranscribe.py index 4839d13..d931264 100644 --- a/filetranscribe.py +++ b/filetranscribe.py @@ -127,17 +127,11 @@ class FileTranscriber(QObject): def _cleanup(self, text, timestamps): conf = self.conf prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True) + target = conf.cleanup_target() out = [] for block in split_text(text, timestamps): self._check() - out.append(api.cleanup( - block, - conf.openrouter_key(), - conf["cleanup_model"], - prompt, - reasoning=conf["cleanup_reasoning"], - base_url=conf["openrouter_base_url"], - )) + out.append(api.cleanup(target, block, prompt)) return ("\n" if timestamps else "\n\n").join(out) diff --git a/install.sh b/install.sh index 2cbaaee..10a89c4 100755 --- a/install.sh +++ b/install.sh @@ -116,5 +116,6 @@ fi echo ok "Done. Start it with: dikte" -say "The settings window opens on first run; add an OpenAI, Groq or OpenRouter key." +say "The settings window opens on first run: download a speech model, or add" +say "an OpenAI or OpenRouter key instead." echo diff --git a/meeting.py b/meeting.py index c0713c6..d6bfbbe 100644 --- a/meeting.py +++ b/meeting.py @@ -118,18 +118,12 @@ class MeetingPipeline(QObject): self._check() self._say(t("Writing the minutes…")) - minutes = api.cleanup( - transcript, - self.conf.openrouter_key(), - self.conf["meeting_model"], - self.conf.meeting_prompt(), - reasoning=self.conf["meeting_reasoning"], - base_url=self.conf["openrouter_base_url"], - timeout=600, - ) + writer = self.conf.minutes_target() + minutes = api.cleanup(writer, transcript, self.conf.meeting_prompt(), + timeout=600) title = self._write(doc_path, minutes, transcript, entry) cfg.update_meeting(base, status="done", error="", title=title, - model=self.conf["meeting_model"]) + model=writer.model) self._discard_audio(wav_path) self.finished.emit(base, title) @@ -201,6 +195,7 @@ class MeetingPipeline(QObject): def _cleanup(self, transcript): conf = self.conf prompt = conf.cleanup_prompt(with_timestamps=True, with_speakers=True) + target = conf.cleanup_target() out = [] blocks = filetranscribe.split_text(transcript, True) for index, block in enumerate(blocks, start=1): @@ -208,14 +203,7 @@ class MeetingPipeline(QObject): if len(blocks) > 1: self._say(t("Cleaning up {index}/{count}…", index=index, count=len(blocks))) - out.append(api.cleanup( - block, - conf.openrouter_key(), - conf["cleanup_model"], - prompt, - reasoning=conf["cleanup_reasoning"], - base_url=conf["openrouter_base_url"], - )) + out.append(api.cleanup(target, block, prompt)) return "\n".join(out) def _write(self, doc_path, minutes, transcript, entry): diff --git a/settings_ui.py b/settings_ui.py index 1690a6b..c70ed66 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -18,6 +18,7 @@ import assistant import audio import config as cfg import filetranscribe +import ggml import hotkey import meeting from filetranscribe import FileTranscriber @@ -29,7 +30,10 @@ LANGUAGES = [ ("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"), ] CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"] -TRANSCRIBE_PROVIDERS = [("OpenAI", "openai"), ("OpenRouter", "openrouter")] +TRANSCRIBE_PROVIDERS = [("This machine (whisper.cpp)", "local"), + ("OpenAI", "openai"), ("OpenRouter", "openrouter")] +CLEANUP_PROVIDERS = [("OpenRouter", "openrouter"), + ("This machine (llama.cpp)", "local")] # Starting points for the model box; "Fetch model list" replaces them with # whatever the provider offers today. TRANSCRIBE_MODELS = { @@ -103,6 +107,316 @@ AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma " "*.mp4 *.mkv *.webm *.mov *.avi") +class LocalModelBox(QGroupBox): + """The program, the model, and the two downloads that put them there. + + One class for whisper.cpp and llama.cpp, because the job is the same one + twice: say whether the program is here, offer the models somebody publishes, + fetch the chosen one, and stay usable while a gigabyte arrives. Nothing is + listed in the source; `repos` and `models` are asked at the moment the box is + opened, so a model published this morning is in the list this afternoon. + """ + + _listed = pyqtSignal(list, str) + _quants = pyqtSignal(list, str) + _progress = pyqtSignal(int, int) + _finished = pyqtSignal(str, str) + _installed = pyqtSignal(str, str) + + changed = pyqtSignal() + + def __init__(self, program, title, models, model_path, repos=None, parent=None): + super().__init__(title, parent) + self.program = program + self._models = models # () -> [hub.Item], or (repo) -> [hub.Item] + self._model_path = model_path # (name) -> Path + self._repos = repos # None, or () -> [repo id] + self._downloading = False + self._pending = False + self._stop = False + self._wanted = "" # the model to select once a list arrives + + form = QFormLayout(self) + + self.program_label = QLabel("") + self.program_label.setWordWrap(True) + self.install_button = QPushButton(t("Download")) + self.install_button.clicked.connect(self._install_program) + form.addRow(t("Program"), self._side_by_side(self.program_label, + self.install_button)) + + if self._repos is not None: + self.repo = QComboBox() + self.repo.setEditable(True) + self.repo.setToolTip(t("A Hugging Face repository of GGUF files. The " + "list is fetched; any other one can be typed in.")) + self.repo.currentTextChanged.connect(self._repo_changed) + form.addRow(t("Publisher"), self.repo) + + self.model = QComboBox() + self.download_button = QPushButton(t("Download")) + self.download_button.clicked.connect(self._download) + self.delete_button = QPushButton(t("Delete")) + self.delete_button.clicked.connect(self._delete) + form.addRow(t("Model"), self._side_by_side(self.model, + self.download_button, + self.delete_button)) + self.model.currentIndexChanged.connect(self._model_changed) + + self.status = QLabel("") + self.status.setWordWrap(True) + form.addRow(self.status) + + self._listed.connect(self._on_listed) + self._quants.connect(self._on_listed) + self._progress.connect(self._on_progress) + self._finished.connect(self._on_finished) + self._installed.connect(self._on_installed) + + @staticmethod + def _side_by_side(*widgets): + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + for index, widget in enumerate(widgets): + layout.addWidget(widget, 1 if index == 0 else 0) + holder = QWidget() + holder.setLayout(layout) + return holder + + # ---- what is here ---------------------------------------------------- + + def selected(self): + return self.model.currentData() or "" + + def repository(self): + return self.repo.currentText().strip() if self._repos is not None else "" + + def load(self, model, repo=""): + """Show what is stored. What else is on offer is asked for on the way up. + + Nothing is fetched here: building the settings window is not the same as + opening it, and a list nobody is looking at is not worth a request. What + is already on this disk is shown straight away either way. + """ + self._wanted = model + self._pending = True + self._show_program() + if self._repos is not None: + self.repo.blockSignals(True) + self.repo.clear() + self.repo.addItems(list(ggml.SUGGESTED_LLM)) + self.repo.setCurrentText(repo or ggml.SUGGESTED_LLM[0]) + self.repo.blockSignals(False) + self._fill_models([]) + + def showEvent(self, event): + super().showEvent(event) + if self._pending: + self._pending = False + if self._repos is not None: + self._fill_repos(self.repository()) + self._fetch_models(self.repository()) + + def _show_program(self): + path = ggml.program_path(self.program) + if not path: + self.program_label.setText(t("Not installed.")) + self.install_button.setVisible(True) + return + self.install_button.setVisible(not ggml.installed_program(self.program) + and not ggml.system_program(self.program)) + if ggml.system_program(self.program): + # Worth saying which one is running: a distribution package is built + # for this machine and may reach the graphics card, while the + # released binaries carry processor backends only. + self.program_label.setText(t("Installed on the system: {path}", path=path)) + else: + self.program_label.setText( + t("Downloaded, version {version}.", + version=ggml.installed_version(self.program) or "?")) + + # ---- the lists ------------------------------------------------------- + + def _fill_repos(self, current): + def work(): + self._listed.emit([("repos", ggml.llm_repos())], "") + + threading.Thread(target=work, daemon=True).start() + + def _repo_changed(self): + if not self._downloading: + self._fetch_models(self.repository()) + + def _fetch_models(self, repo=""): + self.status.setText(t("Fetching the model list…")) + + def work(): + try: + found = self._models(repo) if self._repos is not None else self._models() + self._quants.emit([("models", found)], "") + except ggml.LocalError as exc: + self._quants.emit([], str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _on_listed(self, payload, error): + if error: + self.status.setText(error) + self._refresh_buttons() + return + kind, found = payload[0] + if kind == "repos": + current = self.repo.currentText() + self.repo.blockSignals(True) + self.repo.clear() + self.repo.addItems(found) + self.repo.setCurrentText(current) + self.repo.blockSignals(False) + return + self._fill_models(found) + + def _fill_models(self, items): + """One row per model, saying what it weighs and whether it is here.""" + wanted = self._wanted or self.selected() + here = [name for name in (self._model_path(i.name).name for i in items)] + self.model.blockSignals(True) + self.model.clear() + for item, name in zip(items, here): + mark = (t("downloaded") if ggml.have_model(self._model_path(item.name)) + else ggml.human_size(item.size)) + self.model.addItem(f"{name} ({mark})", name) + self.model.setItemData(self.model.count() - 1, item, Qt.ItemDataRole.UserRole + 1) + # A model that was downloaded and then dropped from the list upstream is + # still on this disk and still works, so it stays on offer. + for name in self._on_disk(): + if self.model.findData(name) < 0: + self.model.addItem(f"{name} ({t('downloaded')})", name) + # And one that is chosen but not here, because the file was deleted from + # underneath or the settings came from another machine, stays chosen: + # Save reads this box, and a row missing here would quietly empty the + # setting rather than showing that the model needs downloading again. + if wanted and self.model.findData(wanted) < 0: + self.model.addItem(f"{wanted} ({t('not downloaded')})", wanted) + index = self.model.findData(wanted) + self.model.setCurrentIndex(max(index, 0)) + self.model.blockSignals(False) + self._wanted = "" + self._model_changed() + + def _on_disk(self): + return (ggml.installed_whisper_models() if self.program is ggml.WHISPER + else ggml.installed_llm_models()) + + # ---- fetching -------------------------------------------------------- + + def _install_program(self): + self.install_button.setEnabled(False) + self.program_label.setText(t("Downloading…")) + + def work(): + try: + ggml.install_program(self.program, on_progress=self._report) + self._installed.emit("", "") + except ggml.LocalError as exc: + self._installed.emit("", str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _on_installed(self, _, error): + self.install_button.setEnabled(True) + self._show_program() + if error: + self.program_label.setText(error) + self.changed.emit() + + def _current_item(self): + return self.model.currentData(Qt.ItemDataRole.UserRole + 1) + + def _download(self): + if self._downloading: + self._stop = True + return + item = self._current_item() + if item is None: + return + self._downloading, self._stop = True, False + self._refresh_buttons() + + def work(): + try: + landed = ggml.download(item, self._model_path(item.name), + on_progress=self._report, + should_stop=lambda: self._stop) + self._finished.emit(item.name if landed else "", "") + except ggml.LocalError as exc: + self._finished.emit("", str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _report(self, done, total): + self._progress.emit(done, total) + + def _on_progress(self, done, total): + share = f" ({done * 100 // total}%)" if total else "" + text = t("Downloading: {done} of {total}{share}", + done=ggml.human_size(done), total=ggml.human_size(total or done), + share=share) + if self._downloading: + self.status.setText(text) + else: + self.program_label.setText(text) + + def _on_finished(self, name, error): + self._downloading = False + if error: + self.status.setText(error) + elif not name: + self.status.setText(t("Download stopped.")) + self._fill_models_from_current() + self.changed.emit() + + def _fill_models_from_current(self): + """Redraw the rows without asking anybody anything again.""" + items = [self.model.itemData(i, Qt.ItemDataRole.UserRole + 1) + for i in range(self.model.count())] + self._wanted = self.selected() + self._fill_models([i for i in items if i is not None]) + + def _delete(self): + name = self.selected() + if not name or not ggml.have_model(self._model_path(name)): + return + if QMessageBox.question(self, t("Delete model"), + t("Delete {name} from this machine?", name=name)) \ + != QMessageBox.StandardButton.Yes: + return + try: + ggml.delete_model(self._model_path(name)) + except ggml.LocalError as exc: + self.status.setText(str(exc)) + self._fill_models_from_current() + self.changed.emit() + + def _model_changed(self): + self._refresh_buttons() + self.changed.emit() + + def _refresh_buttons(self): + name = self.selected() + here = bool(name) and ggml.have_model(self._model_path(name)) + 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)) + if self._downloading: + return + if not name: + self.status.setText(t("Nothing downloaded yet.")) + elif here: + self.status.setText(t("Ready: {name}.", name=name)) + else: + self.status.setText(t("{name} has not been downloaded yet.", name=name)) + + class SettingsWindow(QDialog): applied = pyqtSignal() @@ -127,9 +441,9 @@ class SettingsWindow(QDialog): self.setWindowTitle(t("Dikte Settings")) self.resize(680, 640) - tabs = QTabWidget(self) + tabs = self.tabs = QTabWidget(self) tabs.addTab(self._general_tab(), t("General")) - tabs.addTab(self._api_tab(), t("API and models")) + self.api_tab_index = tabs.addTab(self._api_tab(), t("API and models")) tabs.addTab(self._prompt_tab(), t("Cleanup rules")) tabs.addTab(self._assistant_tab(), t("Agent")) tabs.addTab(self._meeting_tab(), t("Meeting")) @@ -161,6 +475,10 @@ class SettingsWindow(QDialog): self.meetings.finished.connect(self._on_minutes_finished) self.meetings.failed.connect(self._on_minutes_failed) self._load() + # On a machine where nothing can transcribe yet, this window was opened + # because of that, so open it on the tab that fixes it. + if not conf.transcribe_ready(): + self.tabs.setCurrentIndex(self.api_tab_index) # ---- tabs ---------------------------------------------------------- @@ -269,20 +587,55 @@ class SettingsWindow(QDialog): stt_form = QFormLayout(stt) self.transcribe_provider = QComboBox() for label, value in TRANSCRIBE_PROVIDERS: - self.transcribe_provider.addItem(label, value) + self.transcribe_provider.addItem(t(label), value) stt_form.addRow(t("Provider"), self.transcribe_provider) + # The hosted providers take any model id that is typed at them; the + # local one offers what has been published, so the two are separate + # blocks and only one of them is ever visible. + self.hosted_stt = QWidget() + hosted_form = QFormLayout(self.hosted_stt) + hosted_form.setContentsMargins(0, 0, 0, 0) self.transcribe_model = QComboBox() self.transcribe_model.setEditable(True) self.refresh_transcribe_models = QPushButton(t("Fetch model list")) self.refresh_transcribe_models.clicked.connect(self._load_transcribe_models) - stt_form.addRow(t("Model"), - self._row(self.transcribe_model, self.refresh_transcribe_models)) + hosted_form.addRow(t("Model"), + self._row(self.transcribe_model, + self.refresh_transcribe_models)) # A spanning row: in the narrow field column a wrapped label gets a # height that fits one line, and the rest of the text is cut off. self.transcribe_status = QLabel("") self.transcribe_status.setWordWrap(True) - stt_form.addRow(self.transcribe_status) + hosted_form.addRow(self.transcribe_status) + stt_form.addRow(self.hosted_stt) + + self.local_whisper = LocalModelBox( + ggml.WHISPER, t("On this machine"), + ggml.whisper_models, ggml.whisper_model_path) + stt_form.addRow(self.local_whisper) + + self.local_gpu = QCheckBox(t("Use the graphics card")) + self.local_gpu.setToolTip( + t("whisper.cpp reaches the card through CUDA, ROCm or Vulkan when the " + "build it is running was made with one. A build without any of them " + "runs on the processor whatever this says.")) + self.local_preload = QCheckBox(t("Load the model when Dikte starts")) + self.local_preload.setToolTip( + t("A large model takes a second or two to load. Loading it up front " + "spends that once instead of on the first dictation, at the cost of " + "the memory it sits in.")) + self.local_threads = QSpinBox() + self.local_threads.setRange(0, 64) + self.local_threads.setSpecialValueText(t("Automatic")) + self.local_options = QWidget() + options_form = QFormLayout(self.local_options) + options_form.setContentsMargins(0, 0, 0, 0) + options_form.addRow("", self.local_gpu) + options_form.addRow("", self.local_preload) + options_form.addRow(t("Threads"), self.local_threads) + stt_form.addRow(self.local_options) + self.transcribe_provider.currentIndexChanged.connect(self._provider_changed) outer.addWidget(stt) @@ -291,12 +644,22 @@ class SettingsWindow(QDialog): self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model")) orr_form.addRow("", self.cleanup_enabled) + self.cleanup_provider = QComboBox() + for label, value in CLEANUP_PROVIDERS: + self.cleanup_provider.addItem(t(label), value) + self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed) + orr_form.addRow(t("Provider"), self.cleanup_provider) + + self.hosted_cleanup = QWidget() + cleanup_form = QFormLayout(self.hosted_cleanup) + cleanup_form.setContentsMargins(0, 0, 0, 0) self.cleanup_model = QComboBox() self.cleanup_model.setEditable(True) self.cleanup_model.addItems(CLEANUP_MODELS) self.refresh_models = QPushButton(t("Fetch model list")) self.refresh_models.clicked.connect(self._load_models) - orr_form.addRow(t("Model"), self._row(self.cleanup_model, self.refresh_models)) + cleanup_form.addRow(t("Model"), self._row(self.cleanup_model, + self.refresh_models)) self.cleanup_reasoning = QComboBox() for label, value in REASONING_LEVELS: @@ -306,11 +669,38 @@ class SettingsWindow(QDialog): "a light job, so more thinking mostly costs time and tokens. Models " "that cannot think ignore this.") ) - orr_form.addRow(t("Thinking"), self.cleanup_reasoning) + cleanup_form.addRow(t("Thinking"), self.cleanup_reasoning) self.models_label = QLabel(t("Runs on OpenRouter.")) self.models_label.setWordWrap(True) - orr_form.addRow(self.models_label) + cleanup_form.addRow(self.models_label) + orr_form.addRow(self.hosted_cleanup) + + self.local_llm = LocalModelBox( + ggml.LLAMA, t("On this machine"), + ggml.llm_quants, ggml.llm_model_path, repos=ggml.llm_repos) + orr_form.addRow(self.local_llm) + + self.local_llm_gpu = QCheckBox(t("Use the graphics card")) + self.local_llm_preload = QCheckBox(t("Load the model when Dikte starts")) + self.local_llm_preload.setToolTip( + t("An LLM is slower to load than a whisper model and sits in more " + "memory. Off means it is loaded on the first cleanup instead.")) + self.local_llm_reasoning = QComboBox() + for label, value in REASONING_LEVELS: + self.local_llm_reasoning.addItem(t(label), value) + self.local_llm_reasoning.setToolTip( + t("A model trained to think will think unless it is told not to, and " + "spending 300 tokens of reasoning on a comma is 300 tokens of " + "waiting. Off is what cleanup wants.")) + self.local_llm_options = QWidget() + llm_form = QFormLayout(self.local_llm_options) + llm_form.setContentsMargins(0, 0, 0, 0) + llm_form.addRow("", self.local_llm_gpu) + llm_form.addRow("", self.local_llm_preload) + llm_form.addRow(t("Thinking"), self.local_llm_reasoning) + orr_form.addRow(self.local_llm_options) + outer.addWidget(orr) outer.addStretch(1) return page @@ -926,9 +1316,20 @@ class SettingsWindow(QDialog): self._shown_provider = "" self._select_data(self.transcribe_provider, conf["transcribe_provider"]) self._provider_changed() # selecting index 0 fires no signal + self.local_gpu.setChecked(conf["local_gpu"]) + self.local_preload.setChecked(conf["local_preload"]) + self.local_threads.setValue(int(conf["local_threads"])) + self.local_whisper.load(conf["local_model"]) + self.cleanup_enabled.setChecked(conf["cleanup_enabled"]) + self._select_data(self.cleanup_provider, conf["cleanup_provider"]) + self._cleanup_provider_changed() self.cleanup_model.setCurrentText(conf["cleanup_model"]) self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"]) + self.local_llm_gpu.setChecked(conf["local_llm_gpu"]) + self.local_llm_preload.setChecked(conf["local_llm_preload"]) + self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"]) + self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"]) self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt()) self.file_cleanup_prompt.setPlainText( conf["file_cleanup_prompt"] or cfg.default_file_cleanup_prompt() @@ -1003,16 +1404,27 @@ class SettingsWindow(QDialog): conf["openai_api_key"] = self.openai_key.text().strip() conf["openrouter_api_key"] = self.openrouter_key.text().strip() - provider = self.transcribe_provider.currentData() or "openai" - self._models[provider] = self.transcribe_model.currentText().strip() + provider = self.transcribe_provider.currentData() or "local" + if provider in TRANSCRIBE_MODELS: + self._models[provider] = self.transcribe_model.currentText().strip() conf["transcribe_provider"] = provider for key, name in (("openai", "transcribe_model"), ("openrouter", "openrouter_transcribe_model")): conf[name] = self._models[key].strip() or cfg.DEFAULTS[name] + conf["local_model"] = self.local_whisper.selected() + conf["local_gpu"] = self.local_gpu.isChecked() + conf["local_preload"] = self.local_preload.isChecked() + conf["local_threads"] = self.local_threads.value() conf["cleanup_enabled"] = self.cleanup_enabled.isChecked() + conf["cleanup_provider"] = self.cleanup_provider.currentData() or "openrouter" conf["cleanup_model"] = self.cleanup_model.currentText().strip() conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or "" + conf["local_llm_model"] = self.local_llm.selected() + conf["local_llm_repo"] = self.local_llm.repository() + conf["local_llm_gpu"] = self.local_llm_gpu.isChecked() + conf["local_llm_preload"] = self.local_llm_preload.isChecked() + conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or "" # Store an empty prompt when it matches the default, so switching the # interface language also switches the prompt language. @@ -1093,15 +1505,27 @@ class SettingsWindow(QDialog): def _provider_changed(self): """Swap the model box over to the newly chosen provider's own model.""" - if self._shown_provider: + if self._shown_provider in TRANSCRIBE_MODELS: self._models[self._shown_provider] = self.transcribe_model.currentText().strip() - provider = self.transcribe_provider.currentData() or "openai" + provider = self.transcribe_provider.currentData() or "local" self._shown_provider = provider + local = provider == "local" + self.hosted_stt.setVisible(not local) + self.local_whisper.setVisible(local) + self.local_options.setVisible(local) + if local: + return self.transcribe_model.clear() self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider]) self.transcribe_model.setCurrentText(self._models[provider]) self.transcribe_status.setText("") + def _cleanup_provider_changed(self): + local = (self.cleanup_provider.currentData() or "openrouter") == "local" + self.hosted_cleanup.setVisible(not local) + self.local_llm.setVisible(local) + self.local_llm_options.setVisible(local) + def _load_transcribe_models(self): """The model list of whichever provider is selected.""" provider = self.transcribe_provider.currentData() or "openai" diff --git a/tests/support.py b/tests/support.py index b199940..26d069b 100644 --- a/tests/support.py +++ b/tests/support.py @@ -18,6 +18,7 @@ import sys import tempfile import unittest import urllib.error +import urllib.request import wave from unittest import mock @@ -40,6 +41,12 @@ linux_only = unittest.skipUnless( ) +def _no_network(*args, **kwargs): + raise AssertionError( + "a test reached the network; wrap the call in support.fake_urlopen" + ) + + def _no_exec(*args, **kwargs): raise AssertionError( "a test reached os.execv, which would replace the test process with the " @@ -78,6 +85,11 @@ class DikteTest(unittest.TestCase): # with it and hang, so it fails loudly here instead. self.patch_attr(os, "execv", _no_exec) + # Every way out of here goes through urllib, so closing it is enough to + # keep the suite offline. A test that means to answer a request patches + # this again through fake_urlopen. + self.patch_attr(urllib.request, "urlopen", _no_network) + # ---- helpers --------------------------------------------------------- def path(self, *parts): diff --git a/tests/test_api.py b/tests/test_api.py index 30e7e3e..0a4e69c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,6 +10,7 @@ import os import unittest import api +import ggml from tests.support import ( DikteTest, fake_urlopen, @@ -27,10 +28,18 @@ OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test", class TimestampModel(unittest.TestCase): def test_only_whisper_returns_segment_times(self): - self.assertEqual(api.timestamp_model("openai"), "whisper-1") + self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"), + "whisper-1") def test_openrouter_namespaces_the_id(self): - self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1") + self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe"), + "openai/whisper-1") + + def test_the_local_server_stays_on_the_model_it_loaded(self): + # Asking it for whisper-1 would name a model it has never heard of, and + # it is running whisper whatever the file is called. + self.assertEqual(api.timestamp_model("local", "ggml-base.bin"), + "ggml-base.bin") class Explain(DikteTest): @@ -278,10 +287,15 @@ def chat_reply(content): return {"choices": [{"message": {"content": content}}]} +def openrouter(model="some/model", key="sk-or-test", reasoning="", + base_url="https://openrouter.ai/api/v1"): + return api.Target("openrouter", "OpenRouter", key, base_url, model, reasoning) + + class Cleanup(DikteTest): - def call(self, replies, **kwargs): + def call(self, replies, target=None, **kwargs): with fake_urlopen(replies) as calls: - result = api.cleanup("uh, hello", "sk-or-test", "some/model", + result = api.cleanup(target or openrouter(), "uh, hello", "you clean up text", **kwargs) return result, calls @@ -311,32 +325,34 @@ class Cleanup(DikteTest): self.assertNotIn("reasoning", sent_json(calls[0])) def test_an_effort_is_passed_on_and_the_thinking_left_out(self): - _, calls = self.call(chat_reply("Hello."), reasoning="high") + _, calls = self.call(chat_reply("Hello."), + target=openrouter(reasoning="high")) self.assertEqual(sent_json(calls[0])["reasoning"], {"effort": "high", "exclude": True}) def test_a_local_base_url(self): - _, calls = self.call(chat_reply("Hello."), base_url="http://localhost:1234/v1") + _, calls = self.call(chat_reply("Hello."), + target=openrouter(base_url="http://localhost:1234/v1")) self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions") def test_no_key(self): with self.assertRaises(api.ApiError): - api.cleanup("hello", "", "some/model", "prompt") + api.cleanup(openrouter(key=""), "hello", "prompt") def test_a_reply_with_no_choices_says_why(self): with fake_urlopen({"error": {"message": "model is offline"}}), \ self.assertRaises(api.ApiError) as caught: - api.cleanup("hello", "k", "m", "p") + api.cleanup(openrouter(), "hello", "p") self.assertIn("model is offline", str(caught.exception)) def test_an_empty_answer(self): with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError): - api.cleanup("hello", "k", "m", "p") + api.cleanup(openrouter(), "hello", "p") def test_a_rate_limit_is_explained(self): with fake_urlopen(http_error(429)), \ self.assertRaises(api.ApiError) as caught: - api.cleanup("hello", "k", "m", "p") + api.cleanup(openrouter(), "hello", "p") self.assertIn("OpenRouter", str(caught.exception)) @@ -435,3 +451,159 @@ class ModelLists(DikteTest): if __name__ == "__main__": unittest.main() + + +class FakeServer: + """A ggml.Server as far as api.py is concerned.""" + + def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log=""): + self.url = url + self.fails = fails + self.log = log + self.starts = 0 + + def serve(self): + self.starts += 1 + if self.fails: + raise ggml.LocalError(self.fails) + return self.url + + def error(self): + return self.log + + +LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin") +LOCAL_LLM = api.Target("local-llm", "Local model", "", "", "gemma.gguf", "none") + + +class TranscribeHere(DikteTest): + def setUp(self): + super().setUp() + self.wav = str(self.path("clip.wav")) + os.makedirs(self.root, exist_ok=True) + with open(self.wav, "wb") as fh: + fh.write(b"RIFFfake") + self.server = FakeServer() + self.patch_attr(ggml, "whisper", self.server) + + def test_the_address_comes_from_the_server_it_starts(self): + with fake_urlopen({"text": "hello"}) as calls: + api.transcribe(LOCAL, self.wav) + self.assertEqual(self.server.starts, 1) + self.assertEqual(calls[0].full_url, + "http://127.0.0.1:9999/v1/audio/transcriptions") + + def test_nothing_local_is_authorised(self): + with fake_urlopen({"text": "hello"}) as calls: + api.transcribe(LOCAL, self.wav) + self.assertNotIn("Authorization", calls[0].headers) + + def test_a_server_that_will_not_start_is_the_error_shown(self): + self.patch_attr(ggml, "whisper", FakeServer(fails="no model downloaded")) + with self.assertRaises(api.ApiError) as caught: + api.transcribe(LOCAL, self.wav) + self.assertIn("no model downloaded", str(caught.exception)) + + def test_a_server_that_dies_mid_request_says_what_it_printed(self): + self.patch_attr(ggml, "whisper", FakeServer(log="out of memory")) + with fake_urlopen(url_error("connection reset")): + with self.assertRaises(api.ApiError) as caught: + api.transcribe(LOCAL, self.wav) + self.assertIn("out of memory", str(caught.exception)) + + def test_the_hint_reaches_whisper_as_its_initial_prompt(self): + with fake_urlopen({"text": "hi"}) as calls: + api.transcribe(LOCAL, self.wav, prompt="Dikte, Paraşüt") + self.assertEqual(multipart_fields(calls[0])["prompt"], "Dikte, Paraşüt") + + def test_a_word_broken_over_two_lines_is_put_back_together(self): + # whisper.cpp cuts on tokens and writes one segment per line, which in + # Turkish lands inside a word about as often as between two. + with fake_urlopen({"text": "Onlar akraba değ\niller. Ve\n devamı."}): + # The line break inside a word leaves nothing in its place; the + # one between two words is where whisper's own leading space is. + self.assertEqual(api.transcribe(LOCAL, self.wav), + "Onlar akraba değiller. Ve devamı.") + + def test_a_local_timeout_is_not_a_hosted_one(self): + # Nothing is being spent but time, and a long file on a machine without + # a graphics card takes a good deal of it. + with fake_urlopen({"text": "hi"}): + api.transcribe(LOCAL, self.wav, timeout=300) + self.assertGreaterEqual(api.LOCAL_TIMEOUT, 600) + + def test_segments_that_continue_a_word_are_merged(self): + reply = {"segments": [ + {"start": 0.0, "end": 1.0, "text": " Onlar akraba değ"}, + {"start": 1.0, "end": 1.4, "text": "iller."}, + {"start": 2.0, "end": 3.0, "text": " Başka bir cümle."}, + ]} + with fake_urlopen(reply): + out = api.transcribe_segments(LOCAL, self.wav) + self.assertEqual([text for _, _, text in out], + ["Onlar akraba değiller.", "Başka bir cümle."]) + self.assertEqual(out[0][1], 1.4) # the merged cue covers the whole word + + def test_the_loaded_model_is_the_one_asked_for_again(self): + with fake_urlopen({"segments": [{"start": 0, "end": 1, "text": " hi"}]}) as calls: + api.transcribe_segments(LOCAL, self.wav) + self.assertEqual(multipart_fields(calls[0])["model"], "ggml-base.bin") + + +class CleanupHere(DikteTest): + def setUp(self): + super().setUp() + self.server = FakeServer("http://127.0.0.1:8888/v1") + self.patch_attr(ggml, "llm", self.server) + + def test_it_goes_to_the_server_it_starts(self): + with fake_urlopen(chat_reply("Hello.")) as calls: + result = api.cleanup(LOCAL_LLM, "uh, hello", "clean it up") + self.assertEqual(result, "Hello.") + self.assertEqual(calls[0].full_url, + "http://127.0.0.1:8888/v1/chat/completions") + + def test_no_key_is_wanted_and_none_is_sent(self): + with fake_urlopen(chat_reply("Hello.")) as calls: + api.cleanup(LOCAL_LLM, "hello", "prompt") + self.assertNotIn("Authorization", calls[0].headers) + + def test_thinking_is_turned_off_in_the_words_llama_cpp_uses(self): + with fake_urlopen(chat_reply("Hello.")) as calls: + api.cleanup(LOCAL_LLM, "hello", "prompt") + self.assertEqual(sent_json(calls[0])["chat_template_kwargs"], + {"enable_thinking": False}) + + def test_the_models_own_default_asks_for_nothing(self): + with fake_urlopen(chat_reply("Hello.")) as calls: + api.cleanup(LOCAL_LLM._replace(reasoning=""), "hello", "prompt") + self.assertNotIn("chat_template_kwargs", sent_json(calls[0])) + + def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self): + reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]} + with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught: + api.cleanup(LOCAL_LLM, "hello", "prompt") + self.assertIn("Thinking", str(caught.exception)) + + def test_a_reply_longer_than_the_transcript_is_cut_off(self): + # A small model will repeat the transcript until the context is full, + # and every one of those tokens is a second of somebody waiting. + with fake_urlopen(chat_reply("Hello.")) as calls: + api.cleanup(LOCAL_LLM, "x" * 4000, "prompt") + self.assertEqual(sent_json(calls[0])["max_tokens"], 4000) + + def test_a_short_dictation_still_gets_room_to_answer(self): + with fake_urlopen(chat_reply("Hello.")) as calls: + api.cleanup(LOCAL_LLM, "uh, hi", "prompt") + self.assertEqual(sent_json(calls[0])["max_tokens"], 512) + + def test_a_hosted_model_is_left_to_answer_at_length(self): + with fake_urlopen(chat_reply("Hello.")) as calls: + api.cleanup(openrouter(), "uh, hi", "prompt") + self.assertNotIn("max_tokens", sent_json(calls[0])) + + def test_a_server_that_will_not_start_is_the_error_shown(self): + self.patch_attr(ggml, "llm", FakeServer(fails="llama.cpp is not installed")) + with self.assertRaises(api.ApiError) as caught: + api.cleanup(LOCAL_LLM, "hello", "prompt") + self.assertIn("llama.cpp", str(caught.exception)) diff --git a/tests/test_config.py b/tests/test_config.py index 7120a0c..28e3218 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -13,6 +13,7 @@ from unittest import mock import api import config as cfg +import ggml import i18n from tests.support import DikteTest @@ -127,8 +128,17 @@ class Keys(DikteTest): class TranscribeTarget(DikteTest): - def test_openai_by_default(self): - target = self.config(openai_api_key="sk-test").transcribe_target() + def test_this_machine_by_default(self): + target = cfg.Config().transcribe_target() + self.assertEqual(target.provider, "local") + self.assertEqual(target.api_key, "") + # Empty on purpose: the server picks a port when it starts, and reading + # a setting must not be what starts it. + self.assertEqual(target.base_url, "") + + def test_openai_when_it_is_picked(self): + target = self.config(transcribe_provider="openai", + openai_api_key="sk-test").transcribe_target() self.assertEqual(target.provider, "openai") self.assertEqual(target.service, "OpenAI") self.assertEqual(target.api_key, "sk-test") @@ -146,7 +156,8 @@ class TranscribeTarget(DikteTest): self.assertEqual(target.model, "openai/whisper-1") def test_a_self_hosted_endpoint(self): - conf = self.config(openai_base_url="http://localhost:8080/v1") + conf = self.config(transcribe_provider="openai", + openai_base_url="http://localhost:8080/v1") self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1") @@ -425,3 +436,73 @@ class Defaults(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class LocalTargets(DikteTest): + def test_cleanup_can_run_here_while_the_minutes_do_not(self): + # The two jobs are not the same size: a small model on this machine + # strips filler words perfectly well and will not write up an hour. + conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf") + self.assertEqual(conf.cleanup_target().provider, "local-llm") + self.assertEqual(conf.minutes_target().provider, "openrouter") + self.assertEqual(conf.minutes_target().model, cfg.DEFAULTS["meeting_model"]) + + def test_the_minutes_can_run_here_on_their_own(self): + conf = self.config(meeting_provider="local", local_llm_model="gemma.gguf") + self.assertEqual(conf.minutes_target().model, "gemma.gguf") + self.assertEqual(conf.cleanup_target().provider, "openrouter") + + def test_the_local_cleanup_target_carries_the_thinking_level(self): + conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf", + local_llm_reasoning="none") + target = conf.cleanup_target() + self.assertEqual(target.reasoning, "none") + self.assertEqual(target.api_key, "") + + def test_either_of_them_counts_as_using_the_local_model(self): + self.assertFalse(cfg.Config().uses_local_llm()) + self.assertTrue(self.config(cleanup_provider="local").uses_local_llm()) + self.assertTrue(self.config(meeting_provider="local").uses_local_llm()) + + +class ReadyToRun(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(ggml, "MODELS_DIR", self.path("models")) + + def install(self, name): + path = ggml.whisper_model_path(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"model") + + def test_a_missing_program_is_not_ready(self): + with mock.patch("shutil.which", return_value=None): + self.install("ggml-base.bin") + conf = self.config(local_model="ggml-base.bin") + self.assertFalse(conf.transcribe_ready()) + + def test_a_missing_model_is_not_ready_either(self): + with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"): + conf = self.config(local_model="ggml-base.bin") + self.assertFalse(conf.transcribe_ready()) + + def test_both_halves_in_place(self): + with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"): + self.install("ggml-base.bin") + conf = self.config(local_model="ggml-base.bin") + self.assertTrue(conf.transcribe_ready()) + + def test_a_hosted_provider_is_ready_when_it_has_a_key(self): + conf = self.config(transcribe_provider="openai", openai_api_key="sk-test") + self.assertTrue(conf.transcribe_ready()) + + def test_the_settings_reach_the_servers(self): + conf = self.config(local_model="ggml-base.bin", local_threads=4, + local_gpu=False, local_llm_model="gemma.gguf", + local_llm_context=4096) + conf.apply_local() + self.addCleanup(ggml.whisper.configure, model="", threads=0, gpu=True) + self.assertEqual(ggml.whisper.settings()["model"], "ggml-base.bin") + self.assertEqual(ggml.whisper.settings()["threads"], 4) + self.assertFalse(ggml.whisper.settings()["gpu"]) + self.assertEqual(ggml.llm.settings()["context"], 4096) diff --git a/tests/test_filetranscribe.py b/tests/test_filetranscribe.py index def4f5c..5ed22d6 100644 --- a/tests/test_filetranscribe.py +++ b/tests/test_filetranscribe.py @@ -197,7 +197,7 @@ class Transcriber(DikteTest): def test_cleanup_is_told_it_is_writing_subtitles(self): _, _, _, cleanup_call = self.run_chain(cleanup=True) - prompt = cleanup_call.call_args.args[3] + prompt = cleanup_call.call_args.args[2] self.assertEqual(prompt, self.conf.cleanup_prompt(subtitles=True)) def test_timestamps_come_back_as_segments_and_as_stamped_lines(self): diff --git a/tests/test_ui.py b/tests/test_ui.py index a616e94..10fac61 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -41,8 +41,18 @@ CHANGED = { "transcribe_model": "whisper-1", "openrouter_transcribe_model": "openai/whisper-1", "cleanup_enabled": False, + "cleanup_provider": "local", "cleanup_model": "some/other-model", "cleanup_reasoning": "high", + "local_model": "ggml-small.bin", + "local_gpu": False, + "local_preload": False, + "local_threads": 6, + "local_llm_model": "gemma-3-4b-it-Q4_K_M.gguf", + "local_llm_repo": "ggml-org/gemma-4-E2B-it-GGUF", + "local_llm_gpu": False, + "local_llm_preload": True, + "local_llm_reasoning": "low", "cleanup_prompt": "Only fix the punctuation.", "file_cleanup_prompt": "Keep the stamps where they are.", "transcribe_prompt": "Paraşüt, OpenFrame", @@ -259,3 +269,53 @@ class Overlay(DikteTest): if __name__ == "__main__": unittest.main() + + +class LocalModels(DikteTest): + """The download boxes, without a network and without either program.""" + + def window(self, conf): + window = settings_ui.SettingsWindow(conf, "dikte toggle") + self.addCleanup(window.deleteLater) + self.addCleanup(window.close) + return window + + def test_it_opens_where_the_missing_model_is_fixed(self): + # Nothing can transcribe on a fresh install, which is why this window + # was opened at all. + window = self.window(cfg.Config()) + self.assertEqual(window.tabs.currentIndex(), window.api_tab_index) + + def test_it_opens_where_it_was_left_when_everything_works(self): + conf = self.config(transcribe_provider="openai", openai_api_key="sk-test") + self.assertEqual(self.window(conf).tabs.currentIndex(), 0) + + def test_a_model_that_is_not_here_yet_survives_a_save(self): + # The box is filled from what is on this disk, so a model that was + # deleted from underneath is not in the list. Dropping it on save would + # quietly empty the setting instead of asking for the download again. + conf = self.config(local_model="ggml-large-v3-turbo-q5_0.bin") + with mock.patch.object(QMessageBox, "information"): + self.window(conf)._save() + self.assertEqual(conf["local_model"], "ggml-large-v3-turbo-q5_0.bin") + + def test_nothing_is_fetched_for_a_window_nobody_opened(self): + # DikteTest closes the network, so a request would fail the test. The + # lists are asked for when the box is shown, not when it is built. + window = self.window(cfg.Config()) + self.assertTrue(window.local_whisper._pending) + + def test_the_hosted_boxes_go_away_when_the_work_happens_here(self): + window = self.window(self.config(transcribe_provider="openai")) + self.assertTrue(window.hosted_stt.isVisibleTo(window)) + self.assertFalse(window.local_whisper.isVisibleTo(window)) + window._select_data(window.transcribe_provider, "local") + self.assertFalse(window.hosted_stt.isVisibleTo(window)) + self.assertTrue(window.local_whisper.isVisibleTo(window)) + + def test_the_same_for_cleanup(self): + window = self.window(cfg.Config()) + self.assertTrue(window.hosted_cleanup.isVisibleTo(window)) + window._select_data(window.cleanup_provider, "local") + self.assertTrue(window.local_llm.isVisibleTo(window)) + self.assertFalse(window.hosted_cleanup.isVisibleTo(window)) diff --git a/tests/test_worker.py b/tests/test_worker.py index d128378..1617efb 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -234,7 +234,7 @@ class Chain(DikteTest): self.assertEqual(row["raw"], "uh, book it for Thursday") self.assertEqual(row["text"], "Book it for Thursday.") self.assertEqual(row["duration"], 2.0) - self.assertEqual(row["model"], self.conf["transcribe_model"]) + self.assertEqual(row["model"], self.conf.transcribe_target().model) self.assertEqual(row["mode"], "") def test_a_command_is_recorded_as_one(self): diff --git a/worker.py b/worker.py index ba27107..69871ab 100644 --- a/worker.py +++ b/worker.py @@ -106,20 +106,14 @@ class Pipeline(QObject): text = raw warning = "" + cleaner = conf.cleanup_target() # Claude reads through “eee” and “hani” without help, so a dictation # on its way there is normally sent as it was heard, one API call and # a second or two lighter. if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]): self.stage.emit(t("Cleaning up…")) try: - text = api.cleanup( - raw, - conf.openrouter_key(), - conf["cleanup_model"], - conf.cleanup_prompt(), - reasoning=conf["cleanup_reasoning"], - base_url=conf["openrouter_base_url"], - ) + text = api.cleanup(cleaner, raw, conf.cleanup_prompt()) except api.ApiError as exc: # Keep the transcript, but never let the failure pass unseen: # a rejected key would otherwise look like working dictation. @@ -159,7 +153,7 @@ class Pipeline(QObject): "duration": round(duration, 1), "elapsed": round(time.monotonic() - started, 1), "model": target.model, - "cleanup_model": conf["cleanup_model"] if conf["cleanup_enabled"] else "", + "cleanup_model": cleaner.model if conf["cleanup_enabled"] else "", "cleanup_error": warning, "mode": "ask" if ask else "", "question": question, From 634b46fcb0e1044f5b7fa24477a04613e9debf7d Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 1 Aug 2026 20:09:00 +0300 Subject: [PATCH 3/6] Take the process with a start that goes wrong _wait_ready can raise rather than return, and the process it was waiting on is ours with nothing else holding a reference to it. Leaving it running leaks a loaded model with nobody left to ask it anything, which is the whole failure this class is careful about everywhere else. Found by two stand-in servers still running after a test run. --- ggml.py | 42 ++++++++++++++++++++++++++++++++---------- tests/test_ggml.py | 16 ++++++++++++++++ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/ggml.py b/ggml.py index 1ea8d66..04e46f8 100644 --- a/ggml.py +++ b/ggml.py @@ -577,7 +577,18 @@ class Server: # Written before it is ready rather than after, so that a kill # during the model load leaves something for the sweep to find. self._remember(proc.pid) - if self._wait_ready(proc, port): + try: + ready = self._wait_ready(proc, port) + except BaseException: + # Whatever went wrong while waiting, the process is ours and + # nothing else is left holding a reference to it. Leaving it + # running would leak a loaded model with nobody to ask it + # anything, which is the whole failure this class is careful + # about elsewhere. + self._kill(proc) + self._forget() + raise + if ready: return proc, port, str(log) last = _tail(log) self._forget() @@ -600,21 +611,32 @@ class Server: if not self.program.health or _healthy(port, self.program.health): return True time.sleep(0.1) - proc.kill() - proc.wait(timeout=5) + self._kill(proc) return False + @staticmethod + def _kill(proc, gently=False): + """Stop a process of ours, and wait for it rather than assume.""" + if proc is None or proc.poll() is not None: + return + if gently: + proc.terminate() + try: + proc.wait(timeout=5) + return + except subprocess.TimeoutExpired: + pass + proc.kill() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + def stop(self): with self._lock: proc, self._proc = self._proc, None self._port, self._log, self._key = 0, "", None - if proc is not None and proc.poll() is None: - proc.terminate() - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait(timeout=5) + self._kill(proc, gently=True) if proc is not None: self._forget() diff --git a/tests/test_ggml.py b/tests/test_ggml.py index ea2af74..48eff50 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -509,6 +509,22 @@ class Servers(Local): def test_no_pid_file_is_nothing_to_sweep(self): self.assertFalse(self.server().sweep()) + def test_a_start_that_goes_wrong_takes_its_process_with_it(self): + started = [] + + def explode(inner, proc, port): + started.append(proc) + raise RuntimeError("something in the wait went wrong") + + self.patch_attr(ggml.Server, "_wait_ready", explode) + server = self.server() + with self.assertRaises(RuntimeError): + server.serve() + # Nothing else holds a reference to it, so leaving it running would leak + # a loaded model with nobody left to ask it anything. + self.assertIsNotNone(started[0].poll()) + self.assertFalse(server.sweep()) # and the pid file went with it + class Arguments(Local): """What the two command lines say, since neither program is here to say it.""" From f3573908d0fbc1c46b245db64791831bfd500a75 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 1 Aug 2026 20:14:50 +0300 Subject: [PATCH 4/6] Count a model bigger than two gigabytes upwards Qt's int is C++'s 32-bit one, so the progress of a 2.3 GB download arrived at the other end of the signal as -1805209440, which is the real figure less 2^32. The file was landing correctly; only the line under it was wrong, reading "1.0 MB of -1805209440 B (-1%)". --- settings_ui.py | 4 +++- tests/test_ui.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/settings_ui.py b/settings_ui.py index c70ed66..7033a8e 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -119,7 +119,9 @@ class LocalModelBox(QGroupBox): _listed = pyqtSignal(list, str) _quants = pyqtSignal(list, str) - _progress = pyqtSignal(int, int) + # qint64 rather than int, which is C++'s 32-bit one: a 2.3 GB model is more + # than fits in it, and the count comes out the far side negative. + _progress = pyqtSignal("qint64", "qint64") _finished = pyqtSignal(str, str) _installed = pyqtSignal(str, str) diff --git a/tests/test_ui.py b/tests/test_ui.py index 10fac61..1f1a179 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -305,6 +305,16 @@ class LocalModels(DikteTest): window = self.window(cfg.Config()) self.assertTrue(window.local_whisper._pending) + def test_a_model_bigger_than_two_gigabytes_counts_up_rather_than_down(self): + # Qt's int is C++'s 32-bit one, and a 2.3 GB model is more than fits in + # it: the count came out the far side negative, at "-1%". + box = self.window(cfg.Config()).local_llm + box._downloading = True + box._report(1_048_576, 2_489_757_856) + _app.processEvents() + self.assertIn("2.3 GB", box.status.text()) + self.assertNotIn("-", box.status.text()) + def test_the_hosted_boxes_go_away_when_the_work_happens_here(self): window = self.window(self.config(transcribe_provider="openai")) self.assertTrue(window.hosted_stt.isVisibleTo(window)) From 593d307ecc8b55bda567300839ef7fc670a8a1a4 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 1 Aug 2026 20:16:38 +0300 Subject: [PATCH 5/6] Show a model name whole in the list it is chosen from A combo box hands its own width to the list under it and elides whatever does not fit, in the middle of the word: "ggml-org/Qwen....7B-Base-GGUF" is not something anybody can choose between. The list is now as wide as its longest row while the box stays the width the form gave it. --- settings_ui.py | 20 ++++++++++++++++++++ tests/test_ui.py | 12 ++++++++++++ 2 files changed, 32 insertions(+) diff --git a/settings_ui.py b/settings_ui.py index 7033a8e..1ed8d0a 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -175,6 +175,23 @@ class LocalModelBox(QGroupBox): self._finished.connect(self._on_finished) self._installed.connect(self._on_installed) + @staticmethod + def _fit_popup(combo): + """Let the list that drops down be as wide as its longest row. + + A combo box hands its own width to the list under it and elides + whatever does not fit, which lands in the middle of the name: + `ggml-org/Qwen....7B-Base-GGUF` is not a model anybody can choose + between. The box itself stays the width the form gave it. + """ + view = combo.view() + view.setTextElideMode(Qt.TextElideMode.ElideNone) + metrics = combo.fontMetrics() + widest = max((metrics.horizontalAdvance(combo.itemText(row)) + for row in range(combo.count())), default=0) + # Room for the frame and for a scroll bar, which a long list will have. + view.setMinimumWidth(widest + view.verticalScrollBar().sizeHint().width() + 24) + @staticmethod def _side_by_side(*widgets): layout = QHBoxLayout() @@ -209,6 +226,7 @@ class LocalModelBox(QGroupBox): self.repo.addItems(list(ggml.SUGGESTED_LLM)) self.repo.setCurrentText(repo or ggml.SUGGESTED_LLM[0]) self.repo.blockSignals(False) + self._fit_popup(self.repo) self._fill_models([]) def showEvent(self, event): @@ -274,6 +292,7 @@ class LocalModelBox(QGroupBox): self.repo.addItems(found) self.repo.setCurrentText(current) self.repo.blockSignals(False) + self._fit_popup(self.repo) return self._fill_models(found) @@ -302,6 +321,7 @@ class LocalModelBox(QGroupBox): index = self.model.findData(wanted) self.model.setCurrentIndex(max(index, 0)) self.model.blockSignals(False) + self._fit_popup(self.model) self._wanted = "" self._model_changed() diff --git a/tests/test_ui.py b/tests/test_ui.py index 1f1a179..b2aa933 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -315,6 +315,18 @@ class LocalModels(DikteTest): self.assertIn("2.3 GB", box.status.text()) self.assertNotIn("-", box.status.text()) + def test_a_long_model_name_is_not_cut_in_half(self): + # The list under a combo box takes the box's width and elides what does + # not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF". + box = self.window(cfg.Config()).local_llm + box.repo.addItem("ggml-org/a-model-with-a-name-that-runs-on-and-on-GGUF") + box._fit_popup(box.repo) + view = box.repo.view() + self.assertEqual(view.textElideMode(), settings_ui.Qt.TextElideMode.ElideNone) + widest = max(box.repo.fontMetrics().horizontalAdvance(box.repo.itemText(row)) + for row in range(box.repo.count())) + self.assertGreaterEqual(view.minimumWidth(), widest) + def test_the_hosted_boxes_go_away_when_the_work_happens_here(self): window = self.window(self.config(transcribe_provider="openai")) self.assertTrue(window.hosted_stt.isVisibleTo(window)) From 97396d4734e02ef6818a82a3696860ad7bf31bfe Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 1 Aug 2026 20:42:47 +0300 Subject: [PATCH 6/6] Refuse a download that was published without a checksum The hash was checked when there was one and skipped when there was not, which is the wrong way round: everything fetched here is either run or parsed by something written in C++, and the case with no hash is the one where that matters most and says least. Not hypothetical. GitHub publishes a digest for whisper.cpp v1.9.1 and for llama.cpp today, and publishes none for whisper.cpp v1.8.0 and older, so anything reaching for an older tag would have installed an executable nobody checked. --- ggml.py | 12 +++++- tests/test_ggml.py | 94 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/ggml.py b/ggml.py index 04e46f8..d8778b5 100644 --- a/ggml.py +++ b/ggml.py @@ -117,7 +117,7 @@ def human_size(count): # --- fetching ------------------------------------------------------------- -def download(item, target, on_progress=None, should_stop=None): +def download(item, target, on_progress=None, should_stop=None, require_hash=True): """Fetch one hub.Item to `target`. True when it landed, False when stopped. The bytes go to a `.part` that is renamed only after both the length and the @@ -125,8 +125,18 @@ def download(item, target, on_progress=None, should_stop=None): there looking installed and fail much later, inside a server, as a corrupt model; a file that is the right length but the wrong content is worse, and this is a program as often as it is a model. + + A file whose index published no hash is refused rather than taken on trust. + Everything fetched here is either run or parsed by something written in C++, + and GitHub did not always publish a digest: a release old enough to predate + that would otherwise install unchecked, which is the one case where this + would matter most and say least. """ target = pathlib.Path(target) + if require_hash and not item.sha256: + raise LocalError(t("{name} is published without a checksum, so there is " + "no way to tell what arrived. Nothing was installed.", + name=item.name)) part = target.with_name(target.name + ".part") try: target.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 48eff50..548d4c3 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -119,12 +119,23 @@ class Download(Local): ggml.download(item("m.bin", data), target) self.assertFalse(target.exists()) - def test_a_file_with_no_published_checksum_is_still_taken(self): - data = b"a README, say" - target = self.path("data", "models", "readme") + def test_a_file_with_no_published_checksum_is_refused(self): + # Everything fetched here is run or parsed by something written in C++, + # and GitHub did not always publish a digest. + data = b"a program, say" + target = self.path("data", "models", "m.bin") with fake_urlopen(body(data)): - self.assertTrue(ggml.download(item("readme", data, sha=False), target)) - self.assertTrue(target.exists()) + with self.assertRaises(ggml.LocalError) as caught: + ggml.download(item("m.bin", data, sha=False), target) + self.assertIn("checksum", str(caught.exception)) + self.assertFalse(target.exists()) + + def test_nothing_is_asked_for_before_it_is_refused(self): + # The refusal is not worth a gigabyte of somebody's bandwidth first. + with fake_urlopen(body(b"never read")) as calls: + with self.assertRaises(ggml.LocalError): + ggml.download(item("m.bin", b"x", sha=False), self.path("m.bin")) + self.assertEqual(calls, []) def test_stopping_leaves_nothing_behind(self): data = b"x" * (ggml.DOWNLOAD_CHUNK * 3) @@ -163,21 +174,26 @@ class Download(Local): class InstallProgram(Local): - def release(self, *names): - return {"tag_name": "v1.9.1", "assets": [ - {"name": name, "browser_download_url": f"https://example.invalid/{name}", - "size": 10, "digest": ""} for name in names]} - - def archive(self): - return tarball({ + def setUp(self): + super().setUp() + # Built once, because the release listing has to publish its checksum + # and a tarball is not the same bytes twice. + self.archive = tarball({ "whisper-bin-ubuntu-x64/whisper-server": b"#!/bin/sh\nexit 0\n", "whisper-bin-ubuntu-x64/libwhisper.so": b"not really a library", }) + def release(self, *names, archive=None): + digest = hashlib.sha256(self.archive if archive is None else archive) + return {"tag_name": "v1.9.1", "assets": [ + {"name": name, "browser_download_url": f"https://example.invalid/{name}", + "size": 10, "digest": "sha256:" + digest.hexdigest()} + for name in names]} + def install(self, *names, archive=None): self.patch_attr(ggml, "_arch", lambda: "x64") - with serving(self.release(*names), - self.archive() if archive is None else archive) as calls: + blob = self.archive if archive is None else archive + with serving(self.release(*names, archive=blob), blob) as calls: path = ggml.install_program(ggml.WHISPER) return path, [call.args[0].full_url for call in calls.call_args_list] @@ -229,6 +245,55 @@ class InstallProgram(Local): self.install("whisper-bin-ubuntu-x64.tar.gz", archive=empty) self.assertIn("whisper-server", str(caught.exception)) + + def test_a_release_without_a_published_checksum_is_refused(self): + # GitHub did not always publish one, and whisper.cpp v1.8.0 and older + # still have none. + self.patch_attr(ggml, "_arch", lambda: "x64") + listing = {"tag_name": "v1.8.0", "assets": [ + {"name": "whisper-bin-ubuntu-x64.tar.gz", + "browser_download_url": "https://example.invalid/w.tar.gz", + "size": 10}]} + with serving(listing, self.archive): + with self.assertRaises(ggml.LocalError) as caught: + ggml.install_program(ggml.WHISPER) + self.assertIn("checksum", str(caught.exception)) + self.assertEqual(ggml.installed_program(ggml.WHISPER), "") + + def test_an_archive_that_is_not_what_was_promised_installs_nothing(self): + listing = self.release("whisper-bin-ubuntu-x64.tar.gz") + other = tarball({"whisper-bin-ubuntu-x64/whisper-server": b"#!/bin/sh\nrm -rf\n"}) + self.patch_attr(ggml, "_arch", lambda: "x64") + with serving(listing, other): + with self.assertRaises(ggml.LocalError) as caught: + ggml.install_program(ggml.WHISPER) + self.assertIn("checksum", str(caught.exception)) + self.assertEqual(ggml.installed_program(ggml.WHISPER), "") + + def test_an_archive_cannot_write_outside_the_directory_it_is_opened_in(self): + # An archive is not a trusted thing to unpack: a member named ../../ is + # how one writes over a file it was never given. + escape = tarball({"../../../escaped": b"should not land"}) + path = self.path("data", "bin", "whisper", "v1.9.1") + with self.assertRaises(ggml.LocalError): + self.install("whisper-bin-ubuntu-x64.tar.gz", archive=escape) + self.assertFalse(self.path("escaped").exists()) + self.assertFalse((path.parent.parent / "escaped").exists()) + + def test_a_symlink_out_of_the_directory_does_not_survive_either(self): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + info = tarfile.TarInfo("whisper-bin-ubuntu-x64/whisper-server") + info.type, info.linkname = tarfile.SYMTYPE, "/etc/passwd" + tar.addfile(info) + with self.assertRaises(ggml.LocalError): + self.install("whisper-bin-ubuntu-x64.tar.gz", archive=buf.getvalue()) + + def test_everything_is_asked_for_over_tls(self): + for url in (hub.GITHUB_API, hub.HF_API, hub.HF_FILES): + with self.subTest(url=url): + self.assertTrue(url.startswith("https://")) + def test_llama_takes_the_vulkan_build_when_there_is_a_loader(self): self.patch_attr(ggml, "_arch", lambda: "x64") self.patch_attr(ggml, "_has_vulkan", lambda: True) @@ -596,3 +661,4 @@ class Sizes(DikteTest): self.assertEqual(ggml.human_size(512), "512 B") self.assertEqual(ggml.human_size(574041195), "547.4 MB") self.assertEqual(ggml.human_size(3_095_033_483), "2.9 GB") +