diff --git a/dikte/api.py b/dikte/api.py index 61e0857..13c4381 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -48,22 +48,33 @@ LOCAL_TIMEOUT = 3600 # 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") +# branches on. `file_model` is what a timestamped run asks for instead of +# `model`, where the two differ; empty means the provider's own whisper. +Target = collections.namedtuple( + "Target", "provider service api_key base_url model file_model", + defaults=[""]) + +# What answers with segment times on OpenRouter when nothing else was chosen. +OPENROUTER_FILE_MODEL = "openai/whisper-1" -def timestamp_model(provider, selected=""): +def timestamp_model(provider, selected="", file_model=""): """Which model answers with segment times. - OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything - Groq transcribes with is a whisper, so the model already chosen does it and - the fallback is only for a provider left on its default. So is everything the - local server runs, whatever the file is called, and there asking for another - model would name one it has never heard of. + OpenAI keeps them to whisper-1. Everything Groq transcribes with is a + whisper, so the model already chosen does it and the fallback is only for a + provider left on its default. So is everything the local server runs, + whatever the file is called, and there asking for another model would name + one it has never heard of. OpenRouter fronts several models that do times + and several that do not, and a request to the wrong one gets a transcript + with no segments in it, so the one to use is a setting of its own + (`file_model`) and whisper-1 is only where that setting is left empty. """ if provider in ("groq", "local"): return selected or "whisper-large-v3-turbo" - return "openai/whisper-1" if provider == "openrouter" else "whisper-1" + if provider == "openrouter": + return file_model or OPENROUTER_FILE_MODEL + return "whisper-1" # What a gateway in front of the model answers of its own accord: the request @@ -444,7 +455,8 @@ def transcribe_segments(target, audio_path, language="", prompt="", timeout=300, aborter=None): """[(start_seconds, end_seconds, text)] using whisper-1's verbose response.""" data = _transcribe_request( - target._replace(model=timestamp_model(target.provider, target.model)), + target._replace(model=timestamp_model(target.provider, target.model, + target.file_model)), audio_path, language, prompt, "verbose_json", granularity="segment", timeout=timeout, aborter=aborter, ) diff --git a/dikte/config.py b/dikte/config.py index 9aa2228..567d5b7 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -397,6 +397,9 @@ DEFAULTS = { "transcribe_model": "gpt-4o-transcribe", # used when provider is openai "groq_transcribe_model": "whisper-large-v3-turbo", "openrouter_transcribe_model": "openai/gpt-4o-transcribe", + # What a timestamped run (subtitles) asks OpenRouter for: not every model + # there returns segment times. Empty -> openai/whisper-1. + "openrouter_file_model": "", "language": "tr", "transcribe_prompt": "", @@ -669,8 +672,9 @@ class Config: # to land on rather than reading it from there. name = "openai" who = TRANSCRIBERS[name] + file_model = self["openrouter_file_model"] if name == "openrouter" else "" return api.Target(name, who.service, self.api_key(who.key), - self[who.url], self[who.model]) + self[who.url], self[who.model], file_model.strip()) def transcribe_ready(self): """Whether speech to text could run right now, without opening Settings.""" diff --git a/dikte/ggml.py b/dikte/ggml.py index fd863b9..51cf4d1 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -90,6 +90,10 @@ MANAGED_WHISPER_SHA256 = ( WHISPER_MODELS_REPO = "ggerganov/whisper.cpp" LLM_AUTHOR = "ggml-org" +# The file llama.cpp attaches to its version releases in place of the binaries: +# a line naming the nightly tag those are published under. +NIGHTLY_TAG = "nightly-tag.txt" + # What the whisper repository holds besides models: Core ML encoders for Apple # hardware and the odd loose file. WHISPER_PREFIX = "ggml-" @@ -295,6 +299,91 @@ def _managed_whisper(program, tag=""): and _has_vulkan()) +def _managed_asset(refresh=False): + """The Vulkan whisper-server Dikte builds itself, or None. + + Taken only when the archive's digest is the reviewed one. Anything else, + a release that is not there yet, a GitHub that cannot be reached, a file + that is not the reviewed bytes, leaves upstream's processor build as the + answer, and the install record says which of the two landed. + """ + try: + _, assets = hub.release(DIKTE_REPO, MANAGED_WHISPER_RELEASE, + refresh=refresh) + except hub.HubError: + return None + return next((a for a in assets + if a.name.endswith(MANAGED_WHISPER_VULKAN) + and a.sha256 == MANAGED_WHISPER_SHA256), None) + + +def _matching_asset(program, assets): + """The archive this machine wants out of one release's files, or None.""" + for ending in _wanted_assets(program): + item = next((a for a in assets if a.name.endswith(ending)), None) + if item: + return item + return None + + +def _pick_asset(program, tag="", refresh=False): + """(tag, Item) for the release archive to install. Item is None when there + is none for this machine. + + Dikte's own Vulkan whisper-server comes before upstream's where this + machine is one it is built for, because whisper.cpp publishes no Vulkan + archive for Linux at all. + + A named tag is taken as given. For the newest, what GitHub answers is not + always where the builds are: llama.cpp's latest release is a version marker + carrying a single nightly-tag.txt, which names the tag the archives are + actually attached to, and those are prereleases that "latest" never points + at. The pointer is followed when it is there, and when it is not, the newest + release that does carry a build for this machine is taken instead. + """ + if _managed_whisper(program, tag): + item = _managed_asset(refresh=refresh) + if item: + return MANAGED_WHISPER_VERSION, item + named = bool(tag) and tag != "latest" + missing = None + try: + tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh) + except hub.HubError as exc: + # A release carrying no files at all is the case the search below exists + # for, not a reason to stop before it: the build for this machine may be + # attached to a prerelease that "latest" never points at. The failure is + # kept rather than dropped, because an unreachable GitHub arrives here + # the same way and that one is the message the caller wants. + if named: + raise + missing, assets = exc, [] + item = _matching_asset(program, assets) + if item or named: + return tag, item + # Best effort from here on: a machine this project publishes nothing for is + # not a failed lookup, and the caller's message about that is the useful + # one. Whatever goes wrong while looking further leaves it standing. + try: + pointer = next((a for a in assets if a.name == NIGHTLY_TAG), None) + if pointer: + nightly = hub.text(pointer.url).strip() + if nightly: + found, assets = hub.release(program.repo, nightly, refresh=refresh) + item = _matching_asset(program, assets) + if item: + return found, item + for found, assets in hub.releases(program.repo, refresh=refresh): + item = _matching_asset(program, assets) + if item: + return found, item + except hub.HubError: + pass + if missing is not None: + raise missing + return tag, None + + def _install_record(program): return BIN_DIR / program.name / "installed.json" @@ -403,41 +492,17 @@ 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. - Ordinary builds follow the program's newest release. The Linux Vulkan build - comes from Dikte's pinned dependency release instead. + `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. The Linux Vulkan whisper-server is the exception, and + _pick_asset says why. """ - repo = program.repo - release_tag = tag or "latest" managed = _managed_whisper(program, tag) - item, vulkan = None, False - if managed: - repo = DIKTE_REPO - release_tag = MANAGED_WHISPER_RELEASE - try: - tag, assets = hub.release(repo, release_tag, refresh=refresh) - except hub.HubError: - # Older Dikte releases have no managed server. The upstream CPU - # build remains the usable answer there and during API failures. - assets = [] - item = next((a for a in assets - if a.name.endswith(MANAGED_WHISPER_VULKAN) - and a.sha256 == MANAGED_WHISPER_SHA256), None) - vulkan = item is not None - if item: - tag = MANAGED_WHISPER_VERSION + try: + tag, item = _pick_asset(program, tag, refresh=refresh) + except hub.HubError as exc: + raise LocalError(str(exc)) from exc - if item is None: - repo = program.repo - wanted = _wanted_assets(program) - try: - tag, assets = hub.release(repo, "latest" if managed else release_tag, - refresh=refresh) - except hub.HubError as exc: - raise LocalError(str(exc)) from exc - for ending in wanted: - item = next((a for a in assets if a.name.endswith(ending)), None) - if item: - break if item is None: # Nothing to download and nothing to install for you: whisper.cpp # publishes no macOS binary, and Homebrew's whisper-cpp is configured @@ -452,7 +517,7 @@ def install_program(program, tag="", on_progress=None, should_stop=None, "or transcribe in the cloud. See the README." )) raise LocalError(t("{repo} {tag} has no build for this machine.", - repo=repo, tag=tag)) + repo=program.repo, tag=tag)) into = BIN_DIR / program.name / tag fresh = into.with_name(tag + ".new") @@ -506,7 +571,9 @@ def install_program(program, tag="", on_progress=None, should_stop=None, # Which of the two builds this machine ended up with. Only written # where both were on offer, so an install that never had the # choice is not made to look like a fallback. - record["backend"] = "vulkan" if vulkan else "processor" + record["backend"] = ( + "vulkan" if item.name.endswith(MANAGED_WHISPER_VULKAN) + else "processor") _install_record(program).write_text(json.dumps(record), encoding="utf-8") except OSError as exc: raise LocalError(t("Could not install {name}: {error}", diff --git a/dikte/hub.py b/dikte/hub.py index f5da71a..8c19f5f 100644 --- a/dikte/hub.py +++ b/dikte/hub.py @@ -121,6 +121,13 @@ def _digest(value): return value.split(":", 1)[1] if value.startswith("sha256:") else value +def _assets(data): + return [Item(a.get("name") or "", a.get("browser_download_url") or "", + int(a.get("size") or 0), _digest(a.get("digest"))) + for a in (data.get("assets") or []) + if a.get("browser_download_url")] + + def release(repo, tag="latest", refresh=False): """(tag, [Item]) for one GitHub release, newest when no tag is given.""" where = "latest" if tag in ("", "latest") else f"tags/{tag}" @@ -128,10 +135,47 @@ def release(repo, tag="latest", refresh=False): f"{GITHUB_API}/repos/{repo}/releases/{where}", refresh=refresh) if not isinstance(data, dict) or not data.get("assets"): raise HubError(t("{repo} has no downloadable release.", repo=repo)) - assets = [Item(a.get("name") or "", a.get("browser_download_url") or "", - int(a.get("size") or 0), _digest(a.get("digest"))) - for a in data["assets"] if a.get("browser_download_url")] - return data.get("tag_name") or tag, assets + return data.get("tag_name") or tag, _assets(data) + + +def releases(repo, limit=20, refresh=False): + """[(tag, [Item])] for the recent releases, newest first, with their files. + + "latest" is one release and this is the list behind it, prereleases + included: a project that attaches its builds to a prerelease is invisible + to release() above, and its newest usable build is in here. + """ + data = _fetch(f"gh-list-{repo}-{limit}", + f"{GITHUB_API}/repos/{repo}/releases?per_page={limit}", + refresh=refresh) + if not isinstance(data, list): + raise HubError(t("{repo} has no downloadable release.", repo=repo)) + out = [] + for entry in data: + tag, items = entry.get("tag_name") or "", _assets(entry) + if tag and items: + out.append((tag, items)) + return out + + +def text(url, limit=4096, timeout=20): + """A small text file from a release, as a string. + + Not cached and not checksummed, because what it carries is a pointer: a few + bytes naming the release the actual archives are attached to, read once on + the way to a download that is checked in full. + """ + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read(limit).decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + exc.close() + raise HubError(t("{url} answered HTTP {code}.", + url=urllib.parse.urlsplit(url).netloc, code=exc.code)) from exc + except (urllib.error.URLError, OSError, ValueError) as exc: + raise HubError(t("Could not reach {url}: {error}", + url=urllib.parse.urlsplit(url).netloc, error=exc)) from exc def newest_release(repo, refresh=False): diff --git a/dikte/i18n.py b/dikte/i18n.py index 7233c74..ea619b9 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -228,6 +228,11 @@ TR = { "Transcript cleanup": "Transkripti temizleme", "API key": "API anahtarı", "Model": "Model", + "Audio file model": "Ses dosyası modeli", + "The model a timestamped audio file (subtitles) is sent to. Not every model on " + "OpenRouter returns segment times; empty means openai/whisper-1.": + "Zaman damgalı bir ses dosyasının (altyazı) gönderildiği model. OpenRouter'daki her " + "model segment zamanı döndürmez; boşsa openai/whisper-1 kullanılır.", "Provider": "Sağlayıcı", "sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)", "gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)", @@ -791,6 +796,12 @@ TR = { "Ready: {name}.": "Hazır: {name}.", "Nothing downloaded yet.": "Henüz bir şey indirilmedi.", "{name} has not been downloaded yet.": "{name} henüz indirilmedi.", + "{name} is here, but the program above is not. Download it first.": + "{name} burada, ama yukarıdaki program değil. Önce onu indirin.", + "{name} is not on this machine and this publisher does not offer it. " + "Choose another model, or another publisher.": + "{name} bu makinede yok ve bu yayıncı da sunmuyor. Başka bir model, " + "ya da başka bir yayıncı seçin.", "downloaded": "indirildi", "not downloaded": "indirilmedi", "Delete model": "Modeli sil", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 978df52..86b5ffc 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -6,7 +6,7 @@ import shutil import sys import threading -from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal +from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QTimer, QUrl, pyqtSignal from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog, @@ -247,6 +247,13 @@ class LocalModelBox(QGroupBox): self._pending = False self._stop = False self._wanted = "" # the model to select once a list arrives + self._chosen_in = "" # the publisher the selected model is from + # Typing or arrowing through the publisher box changes its text a + # character at a time, and each of those would otherwise be a request. + self._later = QTimer(self) + self._later.setSingleShot(True) + self._later.setInterval(400) + self._later.timeout.connect(self._later_fetch) form = QFormLayout(self) @@ -328,6 +335,8 @@ class LocalModelBox(QGroupBox): self._wanted = model self._pending = True self._show_program() + self._chosen_in = repo or (ggml.SUGGESTED_LLM[0] if self._repos is not None + else "") if self._repos is not None: self.repo.blockSignals(True) self.repo.clear() @@ -382,11 +391,18 @@ class LocalModelBox(QGroupBox): def _fill_repos(self, current): def work(): - self._listed.emit([("repos", ggml.llm_repos())], "") + self._listed.emit([("repos", ggml.llm_repos(), "")], "") threading.Thread(target=work, daemon=True).start() def _repo_changed(self): + if not self._downloading: + self._later.start() + + def _later_fetch(self): + # A download that started inside the wait was not there to be seen when + # the timer went off, and rebuilding the rows underneath one is exactly + # what the guard above is for. if not self._downloading: self._fetch_models(self.repository()) @@ -396,18 +412,29 @@ class LocalModelBox(QGroupBox): def work(): try: found = self._models(repo) if self._repos is not None else self._models() - self._listed.emit([("models", found)], "") + self._listed.emit([("models", found, repo)], "") except ggml.LocalError as exc: - self._listed.emit([], str(exc)) + self._listed.emit([("models", [], repo)], str(exc)) threading.Thread(target=work, daemon=True).start() def _on_listed(self, payload, error): + kind, found, repo = payload[0] if payload else ("repos", [], "") + # A publisher changed while its predecessor's list was still on the way + # would otherwise be answered with the wrong models, whichever request + # happened to come back last. + if kind == "models" and repo != self.repository(): + return if error: + # The list is the publisher's, so a failed one leaves the box no + # longer showing this publisher's models: emptying it is what keeps + # the two boxes saying the same thing. The message goes on after, + # because filling the box writes a status of its own. + if kind == "models": + self._fill_models([]) + self._refresh_buttons() self.status.setText(error) - self._refresh_buttons() return - kind, found = payload[0] if kind == "repos": current = self.repo.currentText() self.repo.blockSignals(True) @@ -421,7 +448,12 @@ class LocalModelBox(QGroupBox): def _fill_models(self, items): """One row per model, saying what it weighs and whether it is here.""" - wanted = self._wanted or self.selected() + # The selection is only worth carrying over within the publisher it was + # made in. Carried across one, a model this repository does not publish + # would be added back as "not downloaded" and selected again, and + # changing the publisher would leave the model box looking untouched. + same = self._repos is None or self.repository() == self._chosen_in + wanted = self._wanted or (self.selected() if same else "") here = [name for name in (self._model_path(i.name).name for i in items)] self.model.blockSignals(True) self.model.clear() @@ -446,6 +478,7 @@ class LocalModelBox(QGroupBox): self.model.blockSignals(False) self._fit_popup(self.model) self._wanted = "" + self._chosen_in = self.repository() self._model_changed() def _on_disk(self): @@ -474,6 +507,9 @@ class LocalModelBox(QGroupBox): self._show_program() if error: self.program_label.setText(error) + # The model line says whether the program is here, so installing one + # changes what it should read. + self._refresh_buttons() self.changed.emit() def _current_item(self): @@ -560,15 +596,30 @@ class LocalModelBox(QGroupBox): def _refresh_buttons(self): name = self.selected() here = bool(name) and ggml.have_model(self._model_path(name)) + # A row carries what it takes to fetch it. The ones that do not are the + # models found on this disk and the one the settings name but the list + # does not offer: there is nothing to press Download for on those, and + # a button that can only do nothing is worse than one that is out. + item = self._current_item() self.delete_button.setEnabled(here and not self._downloading) self.download_button.setText(t("Stop") if self._downloading else t("Download")) - self.download_button.setEnabled(self._downloading or (bool(name) and not here)) + self.download_button.setEnabled(self._downloading or (item is not None + and not here)) if self._downloading: return if not name: self.status.setText(t("Nothing downloaded yet.")) + elif here and not ggml.program_path(self.program): + # The model alone runs nothing, and "Ready" over a missing program + # reads as though it does. + self.status.setText(t("{name} is here, but the program above is " + "not. Download it first.", name=name)) elif here: self.status.setText(t("Ready: {name}.", name=name)) + elif item is None: + self.status.setText(t("{name} is not on this machine and this " + "publisher does not offer it. Choose another " + "model, or another publisher.", name=name)) else: self.status.setText(t("{name} has not been downloaded yet.", name=name)) @@ -879,6 +930,15 @@ class SettingsWindow(QDialog): self.transcribe_model_row = self._row(self.transcribe_model, self.refresh_transcribe_models) stt_form.addRow(t("Model"), self.transcribe_model_row) + # OpenRouter only: which of its models a timestamped run asks for. + self.file_model = QComboBox() + self.file_model.setEditable(True) + self.file_model.lineEdit().setPlaceholderText(api.OPENROUTER_FILE_MODEL) + self.file_model.setToolTip( + t("The model a timestamped audio file (subtitles) is sent to. Not every model " + "on OpenRouter returns segment times; empty means openai/whisper-1.")) + self.file_model_row = self._row(self.file_model) + stt_form.addRow(t("Audio file model"), self.file_model_row) # 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("") @@ -1762,6 +1822,7 @@ 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.file_model.setCurrentText(conf["openrouter_file_model"]) self.local_gpu.setChecked(conf["local_gpu"]) self.local_preload.setChecked(conf["local_preload"]) self.local_threads.setValue(int(conf["local_threads"])) @@ -1879,6 +1940,7 @@ class SettingsWindow(QDialog): for name, who in cfg.TRANSCRIBERS.items(): conf[who.key] = self._key_fields[name].text().strip() conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model] + conf["openrouter_file_model"] = self.file_model.currentText().strip() conf["gemini_api_key"] = self.gemini_key.text().strip() conf["opencode_api_key"] = self.opencode_key.text().strip() conf["local_model"] = self.local_whisper.selected() @@ -2052,6 +2114,7 @@ class SettingsWindow(QDialog): self._shown_provider = provider local = provider == "local" self.stt_form.setRowVisible(self.transcribe_model_row, not local) + self.stt_form.setRowVisible(self.file_model_row, provider == "openrouter") self.stt_form.setRowVisible(self.transcribe_status, not local) self.stt_form.setRowVisible(self.local_whisper, local) self.stt_form.setRowVisible(self.local_options, local) @@ -2060,8 +2123,16 @@ class SettingsWindow(QDialog): self.transcribe_model.clear() self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider]) self.transcribe_model.setCurrentText(self._models[provider]) + if provider == "openrouter": + self._fill_file_models(TRANSCRIBE_MODELS[provider]) self.transcribe_status.setText("") + def _fill_file_models(self, models): + current = self.file_model.currentText() + self.file_model.clear() + self.file_model.addItems(models) + self.file_model.setCurrentText(current) + def _load_transcribe_models(self): """The model list of whichever provider is selected.""" provider = self.transcribe_provider.currentData() or "openai" @@ -2090,6 +2161,8 @@ class SettingsWindow(QDialog): self.transcribe_model.clear() self.transcribe_model.addItems(models) self.transcribe_model.setCurrentText(current) + if self._shown_provider == "openrouter": + self._fill_file_models(models) self.transcribe_status.setText(t("{count} models loaded.", count=len(models))) def _load_models(self): diff --git a/tests/test_api.py b/tests/test_api.py index c914017..50b4ef7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -53,6 +53,16 @@ class TimestampModel(unittest.TestCase): self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"), "whisper-1") + def test_openrouter_takes_the_file_model_that_was_set(self): + self.assertEqual( + api.timestamp_model("openrouter", "openai/gpt-4o-transcribe", + "openai/whisper-large-v3"), + "openai/whisper-large-v3") + + def test_openrouter_with_no_file_model_falls_back_to_whisper(self): + self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe", ""), + "openai/whisper-1") + class Explain(DikteTest): def error(self, status): @@ -318,6 +328,13 @@ class TranscribeSegments(DikteTest): api.transcribe_segments(OPENROUTER, self.wav) self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1") + def test_openrouter_asks_for_the_file_model_when_one_is_set(self): + target = OPENROUTER._replace(file_model="mistralai/voxtral-mini-transcribe") + with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls: + api.transcribe_segments(target, self.wav) + self.assertEqual(multipart_fields(calls[0])["model"], + "mistralai/voxtral-mini-transcribe") + def test_groq_stays_on_the_model_it_was_given(self): target = GROQ._replace(model="whisper-large-v3") with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls: diff --git a/tests/test_cli.py b/tests/test_cli.py index 67c6f21..a3ffb55 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ socket is faked, and everything that runs locally runs for real. import contextlib import io import json +import sys import unittest import webbrowser from typing import ClassVar @@ -668,7 +669,11 @@ class WithoutAnInstance(DikteTest): def run_verb(self, argv): # launch_gui replaces this process with the application, so it never # comes back in real use and must not be allowed to here. + # `ask` with no text reads what was piped in, and the runner's own + # stdin is not that: under pytest it is an object that refuses to be + # read at all. with mock.patch.object(ipc, "send", return_value=None), \ + mock.patch.object(sys, "stdin", io.StringIO()), \ mock.patch.object(cli, "launch_gui") as launch, \ captured() as (out, err): code = cli.run(argv) diff --git a/tests/test_config.py b/tests/test_config.py index 2c6535f..0138a04 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -220,6 +220,19 @@ class TranscribeTarget(DikteTest): self.assertEqual(target.service, "OpenRouter") self.assertEqual(target.api_key, "sk-or-test") self.assertEqual(target.model, "openai/whisper-1") + self.assertEqual(target.file_model, "") + + def test_openrouter_carries_its_file_model(self): + conf = self.config(transcribe_provider="openrouter", + openrouter_api_key="sk-or-test", + openrouter_file_model=" openai/whisper-large-v3 ") + self.assertEqual(conf.transcribe_target().file_model, + "openai/whisper-large-v3") + + def test_only_openrouter_has_a_file_model(self): + conf = self.config(transcribe_provider="openai", openai_api_key="sk-test", + openrouter_file_model="openai/whisper-large-v3") + self.assertEqual(conf.transcribe_target().file_model, "") def test_groq_when_it_is_picked(self): conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test", diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 8dcfa0c..3d86324 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -240,6 +240,44 @@ class InstallProgram(Local): "whisper-bin-ubuntu-x64.tar.gz") self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz")) + def test_the_nightly_pointer_is_followed_to_where_the_builds_are(self): + """llama.cpp's latest release carries a tag name, not the binaries.""" + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: False) + marker = self.release(ggml.NIGHTLY_TAG) + nightly = dict(self.release("llama-b10809-bin-ubuntu-x64.tar.gz"), + tag_name="b10809") + + def opener(request, timeout=None): + url = request.full_url + if url.endswith("/releases/latest"): + return json_body(marker) + if url.endswith("/releases/tags/b10809"): + return json_body(nightly) + if url.endswith(ggml.NIGHTLY_TAG): + return body(b"b10809\n") + return body(self.archive) + + with mock.patch("urllib.request.urlopen", side_effect=opener): + tag, found = ggml._pick_asset(ggml.LLAMA) + self.assertEqual(tag, "b10809") + self.assertEqual(found.name, "llama-b10809-bin-ubuntu-x64.tar.gz") + + def test_without_a_pointer_the_newest_release_that_has_a_build_is_taken(self): + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: False) + marker = self.release("source.zip") + listing = [dict(self.release("llama-b2-bin-win-cpu-x64.zip"), tag_name="b2"), + dict(self.release("llama-b1-bin-ubuntu-x64.tar.gz"), tag_name="b1")] + + def opener(request, timeout=None): + url = request.full_url + return json_body(listing if "per_page" in url else marker) + + with mock.patch("urllib.request.urlopen", side_effect=opener): + tag, found = ggml._pick_asset(ggml.LLAMA) + self.assertEqual(tag, "b1") + self.assertEqual(found.name, "llama-b1-bin-ubuntu-x64.tar.gz") def test_linux_x64_with_vulkan_takes_diktes_accelerated_build(self): self.patch_attr(ggml, "_arch", lambda: "x64") self.patch_attr(ggml, "_has_vulkan", lambda: True) diff --git a/tests/test_paths.py b/tests/test_paths.py index df84a09..af54bde 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -42,9 +42,12 @@ class Directories(unittest.TestCase): def test_a_mac_does_not_read_the_xdg_variables(self): """A Mac with them set from some other tool still stores in one place.""" - with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}): + # Something no temporary directory can be called: the home this runs + # under is a mkdtemp path, and a two-letter needle matched the "/c" in + # somebody's TMPDIR rather than the variable being read. + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/xdg-elsewhere"}): config_dir, _ = paths.directories("darwin") - self.assertNotIn("/c", config_dir.as_posix()) + self.assertNotIn("xdg-elsewhere", config_dir.as_posix()) def test_windows_keeps_the_models_out_of_the_roaming_profile(self): """Settings roam with the account; several gigabytes must not.""" diff --git a/tests/test_ui.py b/tests/test_ui.py index ed86910..aa2f29c 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -9,6 +9,7 @@ next time anybody presses Save. That is the failure this catches. import json import os import sys +import time import unittest from typing import ClassVar from unittest import mock @@ -22,12 +23,13 @@ from dikte import cleanup from dikte import config as cfg from dikte import ggml from dikte import hotkey -from dikte.i18n import t +from dikte import hub from dikte import ipc from dikte import overlay as overlay_module from dikte import paste from dikte import settings_ui from dikte import update +from dikte.i18n import t from tests.support import DikteTest, only_these_tools # The harness below replaces this method on the class so that opening a window @@ -506,6 +508,20 @@ class Settings(DikteTest): self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe") self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3") + def test_the_file_model_is_saved_and_only_shown_for_openrouter(self): + self.write_config({"transcribe_provider": "openrouter", + "openrouter_file_model": "openai/whisper-large-v3"}) + conf = cfg.Config() + window = self.window(conf) + self.assertEqual(window.file_model.currentText(), "openai/whisper-large-v3") + self.assertTrue(window.stt_form.isRowVisible(window.file_model_row)) + window.file_model.setCurrentText(" deepgram/nova-3 ") + window._save() + self.assertEqual(conf["openrouter_file_model"], "deepgram/nova-3") + window.transcribe_provider.setCurrentIndex( + window.transcribe_provider.findData("openai")) + self.assertFalse(window.stt_form.isRowVisible(window.file_model_row)) + def test_the_provider_box_offers_every_provider_config_knows(self): window = self.window(cfg.Config()) offered = [window.transcribe_provider.itemData(i) @@ -1216,6 +1232,74 @@ class LocalModels(DikteTest): for row in range(box.repo.count())) self.assertGreaterEqual(view.minimumWidth(), widest) + @staticmethod + def _item(name, size=1 << 20): + return hub.Item(name, f"https://example.invalid/{name}", size, "") + + def test_a_row_with_nothing_to_fetch_does_not_offer_a_download(self): + # The model the settings name is not in the list any more, so its row + # was rebuilt from the name alone and carries no file to fetch. The + # button stayed lit and the press did nothing at all. + box = self.window(self.config(local_llm_model="gone.gguf")).local_llm + box.load("gone.gguf", "ggml-org/SmolLM3-3B-GGUF") + self.assertEqual(box.selected(), "gone.gguf") + self.assertFalse(box.download_button.isEnabled()) + self.assertIn("gone.gguf", box.status.text()) + self.assertIn("publisher", box.status.text()) + + def test_a_model_without_its_program_does_not_say_it_is_ready(self): + # The model runs on the program above it, and "Ready" over a missing + # one is what had people asking why nothing transcribed. + box = self.window(cfg.Config()).local_whisper + path = ggml.whisper_model_path("ggml-small.bin") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"not really a model") + box.load("ggml-small.bin") + self.assertFalse(ggml.program_path(ggml.WHISPER)) + self.assertNotIn("Ready", box.status.text()) + self.assertIn("program", box.status.text()) + + def test_changing_the_publisher_changes_the_model(self): + # The model chosen under the old publisher is not published by the new + # one. Carried over, it was added back as "not downloaded" and selected + # again, and the box looked as though the change had not taken. + box = self.window(self.config(local_llm_model="gemma-3-4b-it-Q4_K_M.gguf", + local_llm_repo="ggml-org/gemma-3-4b-it-GGUF")).local_llm + box.load("gemma-3-4b-it-Q4_K_M.gguf", "ggml-org/gemma-3-4b-it-GGUF") + box.repo.blockSignals(True) + box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(False) + box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")], + "ggml-org/SmolLM3-3B-GGUF")], "") + self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf") + self.assertEqual(box.model.count(), 1) + + def test_a_list_for_a_publisher_that_is_no_longer_chosen_is_dropped(self): + # Every change starts its own request, and they do not come back in the + # order they went out. + box = self.window(cfg.Config()).local_llm + box.load("", "ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(True) + box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(False) + box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")], + "ggml-org/SmolLM3-3B-GGUF")], "") + box._on_listed([("models", [self._item("gemma-3-4b-it-Q4_K_M.gguf")], + "ggml-org/gemma-3-4b-it-GGUF")], "") + self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf") + + def test_the_publisher_box_is_not_asked_on_every_keystroke(self): + box = self.window(cfg.Config()).local_llm + with mock.patch.object(box, "_fetch_models") as fetch: + for text in ("g", "gg", "ggm", "ggml-org/SmolLM3-3B-GGUF"): + box.repo.setCurrentText(text) + fetch.assert_not_called() + box._later.setInterval(0) + box._later.start() + _app.processEvents() + time.sleep(0.05) + _app.processEvents() + self.assertEqual(fetch.call_count, 1) def test_a_processor_build_where_the_vulkan_one_belongs_says_so(self): # The Vulkan whisper-server is published by hand, and until it is # there the download lands upstream's processor build. Said nowhere,