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

Find the llama.cpp nightly builds, and keep the model box honest
This commit is contained in:
Yusuf İpek
2026-09-05 09:51:24 +03:00
committed by GitHub
8 changed files with 297 additions and 20 deletions
+64 -6
View File
@@ -83,6 +83,10 @@ LLAMA = Program("llama", "ggml-org/llama.cpp", "llama-server", "/health")
WHISPER_MODELS_REPO = "ggerganov/whisper.cpp" WHISPER_MODELS_REPO = "ggerganov/whisper.cpp"
LLM_AUTHOR = "ggml-org" 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 # What the whisper repository holds besides models: Core ML encoders for Apple
# hardware and the odd loose file. # hardware and the odd loose file.
WHISPER_PREFIX = "ggml-" WHISPER_PREFIX = "ggml-"
@@ -277,6 +281,65 @@ def _wanted_assets(program):
return (f"bin-ubuntu-{arch}.tar.gz",) return (f"bin-ubuntu-{arch}.tar.gz",)
def _matching_asset(program, assets):
"""The archive this machine wants out of one release's files, or None."""
for ending in _wanted_assets(program):
item = next((a for a in assets if a.name.endswith(ending)), None)
if item:
return item
return None
def _pick_asset(program, tag="", refresh=False):
"""(tag, Item) for the release archive to install. Item is None when there
is none for this machine.
A named tag is taken as given. For the newest, what GitHub answers is not
always where the builds are: llama.cpp's latest release is a version marker
carrying a single nightly-tag.txt, which names the tag the archives are
actually attached to, and those are prereleases that "latest" never points
at. The pointer is followed when it is there, and when it is not, the newest
release that does carry a build for this machine is taken instead.
"""
named = bool(tag) and tag != "latest"
missing = None
try:
tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh)
except hub.HubError as exc:
# A release carrying no files at all is the case the search below exists
# for, not a reason to stop before it: the build for this machine may be
# attached to a prerelease that "latest" never points at. The failure is
# kept rather than dropped, because an unreachable GitHub arrives here
# the same way and that one is the message the caller wants.
if named:
raise
missing, assets = exc, []
item = _matching_asset(program, assets)
if item or named:
return tag, item
# Best effort from here on: a machine this project publishes nothing for is
# not a failed lookup, and the caller's message about that is the useful
# one. Whatever goes wrong while looking further leaves it standing.
try:
pointer = next((a for a in assets if a.name == NIGHTLY_TAG), None)
if pointer:
nightly = hub.text(pointer.url).strip()
if nightly:
found, assets = hub.release(program.repo, nightly, refresh=refresh)
item = _matching_asset(program, assets)
if item:
return found, item
for found, assets in hub.releases(program.repo, refresh=refresh):
item = _matching_asset(program, assets)
if item:
return found, item
except hub.HubError:
pass
if missing is not None:
raise missing
return tag, None
def _install_record(program): def _install_record(program):
return BIN_DIR / program.name / "installed.json" return BIN_DIR / program.name / "installed.json"
@@ -375,15 +438,10 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
whisper.cpp has one. whisper.cpp has one.
""" """
try: try:
tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh) tag, item = _pick_asset(program, tag, refresh=refresh)
except hub.HubError as exc: except hub.HubError as exc:
raise LocalError(str(exc)) from 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: if item is None:
# Nothing to download and nothing to install for you: whisper.cpp # Nothing to download and nothing to install for you: whisper.cpp
# publishes no macOS binary, and Homebrew's whisper-cpp is configured # publishes no macOS binary, and Homebrew's whisper-cpp is configured
+48 -4
View File
@@ -121,6 +121,13 @@ def _digest(value):
return value.split(":", 1)[1] if value.startswith("sha256:") else value 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): def release(repo, tag="latest", refresh=False):
"""(tag, [Item]) for one GitHub release, newest when no tag is given.""" """(tag, [Item]) for one GitHub release, newest when no tag is given."""
where = "latest" if tag in ("", "latest") else f"tags/{tag}" 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) f"{GITHUB_API}/repos/{repo}/releases/{where}", refresh=refresh)
if not isinstance(data, dict) or not data.get("assets"): if not isinstance(data, dict) or not data.get("assets"):
raise HubError(t("{repo} has no downloadable release.", repo=repo)) raise HubError(t("{repo} has no downloadable release.", repo=repo))
assets = [Item(a.get("name") or "", a.get("browser_download_url") or "", return data.get("tag_name") or tag, _assets(data)
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 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): def newest_release(repo, refresh=False):
+6
View File
@@ -792,6 +792,12 @@ TR = {
"Ready: {name}.": "Hazır: {name}.", "Ready: {name}.": "Hazır: {name}.",
"Nothing downloaded yet.": "Henüz bir şey indirilmedi.", "Nothing downloaded yet.": "Henüz bir şey indirilmedi.",
"{name} has not been downloaded yet.": "{name} henüz 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", "downloaded": "indirildi",
"not downloaded": "indirilmedi", "not downloaded": "indirilmedi",
"Delete model": "Modeli sil", "Delete model": "Modeli sil",
+59 -8
View File
@@ -6,7 +6,7 @@ import shutil
import sys import sys
import threading 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.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog, QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
@@ -247,6 +247,13 @@ class LocalModelBox(QGroupBox):
self._pending = False self._pending = False
self._stop = False self._stop = False
self._wanted = "" # the model to select once a list arrives 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) form = QFormLayout(self)
@@ -328,6 +335,8 @@ class LocalModelBox(QGroupBox):
self._wanted = model self._wanted = model
self._pending = True self._pending = True
self._show_program() 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: if self._repos is not None:
self.repo.blockSignals(True) self.repo.blockSignals(True)
self.repo.clear() self.repo.clear()
@@ -367,11 +376,18 @@ class LocalModelBox(QGroupBox):
def _fill_repos(self, current): def _fill_repos(self, current):
def work(): def work():
self._listed.emit([("repos", ggml.llm_repos())], "") self._listed.emit([("repos", ggml.llm_repos(), "")], "")
threading.Thread(target=work, daemon=True).start() threading.Thread(target=work, daemon=True).start()
def _repo_changed(self): 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: if not self._downloading:
self._fetch_models(self.repository()) self._fetch_models(self.repository())
@@ -381,18 +397,29 @@ class LocalModelBox(QGroupBox):
def work(): def work():
try: try:
found = self._models(repo) if self._repos is not None else self._models() 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: except ggml.LocalError as exc:
self._listed.emit([], str(exc)) self._listed.emit([("models", [], repo)], str(exc))
threading.Thread(target=work, daemon=True).start() threading.Thread(target=work, daemon=True).start()
def _on_listed(self, payload, error): 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: 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.status.setText(error)
self._refresh_buttons()
return return
kind, found = payload[0]
if kind == "repos": if kind == "repos":
current = self.repo.currentText() current = self.repo.currentText()
self.repo.blockSignals(True) self.repo.blockSignals(True)
@@ -406,7 +433,12 @@ class LocalModelBox(QGroupBox):
def _fill_models(self, items): def _fill_models(self, items):
"""One row per model, saying what it weighs and whether it is here.""" """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)] here = [name for name in (self._model_path(i.name).name for i in items)]
self.model.blockSignals(True) self.model.blockSignals(True)
self.model.clear() self.model.clear()
@@ -431,6 +463,7 @@ class LocalModelBox(QGroupBox):
self.model.blockSignals(False) self.model.blockSignals(False)
self._fit_popup(self.model) self._fit_popup(self.model)
self._wanted = "" self._wanted = ""
self._chosen_in = self.repository()
self._model_changed() self._model_changed()
def _on_disk(self): def _on_disk(self):
@@ -459,6 +492,9 @@ class LocalModelBox(QGroupBox):
self._show_program() self._show_program()
if error: if error:
self.program_label.setText(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() self.changed.emit()
def _current_item(self): def _current_item(self):
@@ -545,15 +581,30 @@ class LocalModelBox(QGroupBox):
def _refresh_buttons(self): def _refresh_buttons(self):
name = self.selected() name = self.selected()
here = bool(name) and ggml.have_model(self._model_path(name)) 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.delete_button.setEnabled(here and not self._downloading)
self.download_button.setText(t("Stop") if self._downloading else t("Download")) 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: if self._downloading:
return return
if not name: if not name:
self.status.setText(t("Nothing downloaded yet.")) 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: elif here:
self.status.setText(t("Ready: {name}.", name=name)) 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: else:
self.status.setText(t("{name} has not been downloaded yet.", name=name)) self.status.setText(t("{name} has not been downloaded yet.", name=name))
+5
View File
@@ -9,6 +9,7 @@ socket is faked, and everything that runs locally runs for real.
import contextlib import contextlib
import io import io
import json import json
import sys
import unittest import unittest
import webbrowser import webbrowser
from typing import ClassVar from typing import ClassVar
@@ -668,7 +669,11 @@ class WithoutAnInstance(DikteTest):
def run_verb(self, argv): def run_verb(self, argv):
# launch_gui replaces this process with the application, so it never # launch_gui replaces this process with the application, so it never
# comes back in real use and must not be allowed to here. # 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), \ with mock.patch.object(ipc, "send", return_value=None), \
mock.patch.object(sys, "stdin", io.StringIO()), \
mock.patch.object(cli, "launch_gui") as launch, \ mock.patch.object(cli, "launch_gui") as launch, \
captured() as (out, err): captured() as (out, err):
code = cli.run(argv) code = cli.run(argv)
+39
View File
@@ -238,6 +238,45 @@ class InstallProgram(Local):
"whisper-bin-ubuntu-x64.tar.gz") "whisper-bin-ubuntu-x64.tar.gz")
self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz")) self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz"))
def test_the_nightly_pointer_is_followed_to_where_the_builds_are(self):
"""llama.cpp's latest release carries a tag name, not the binaries."""
self.patch_attr(ggml, "_arch", lambda: "x64")
self.patch_attr(ggml, "_has_vulkan", lambda: False)
marker = self.release(ggml.NIGHTLY_TAG)
nightly = dict(self.release("llama-b10809-bin-ubuntu-x64.tar.gz"),
tag_name="b10809")
def opener(request, timeout=None):
url = request.full_url
if url.endswith("/releases/latest"):
return json_body(marker)
if url.endswith("/releases/tags/b10809"):
return json_body(nightly)
if url.endswith(ggml.NIGHTLY_TAG):
return body(b"b10809\n")
return body(self.archive)
with mock.patch("urllib.request.urlopen", side_effect=opener):
tag, found = ggml._pick_asset(ggml.LLAMA)
self.assertEqual(tag, "b10809")
self.assertEqual(found.name, "llama-b10809-bin-ubuntu-x64.tar.gz")
def test_without_a_pointer_the_newest_release_that_has_a_build_is_taken(self):
self.patch_attr(ggml, "_arch", lambda: "x64")
self.patch_attr(ggml, "_has_vulkan", lambda: False)
marker = self.release("source.zip")
listing = [dict(self.release("llama-b2-bin-win-cpu-x64.zip"), tag_name="b2"),
dict(self.release("llama-b1-bin-ubuntu-x64.tar.gz"), tag_name="b1")]
def opener(request, timeout=None):
url = request.full_url
return json_body(listing if "per_page" in url else marker)
with mock.patch("urllib.request.urlopen", side_effect=opener):
tag, found = ggml._pick_asset(ggml.LLAMA)
self.assertEqual(tag, "b1")
self.assertEqual(found.name, "llama-b1-bin-ubuntu-x64.tar.gz")
def test_a_release_with_nothing_for_this_machine_says_so(self): def test_a_release_with_nothing_for_this_machine_says_so(self):
self.patch_attr(ggml, "_arch", lambda: "x64") self.patch_attr(ggml, "_arch", lambda: "x64")
with fake_urlopen(self.release("whisper-bin-Win32.zip")): with fake_urlopen(self.release("whisper-bin-Win32.zip")):
+5 -2
View File
@@ -42,9 +42,12 @@ class Directories(unittest.TestCase):
def test_a_mac_does_not_read_the_xdg_variables(self): 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.""" """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") 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): def test_windows_keeps_the_models_out_of_the_roaming_profile(self):
"""Settings roam with the account; several gigabytes must not.""" """Settings roam with the account; several gigabytes must not."""
+71
View File
@@ -8,6 +8,7 @@ next time anybody presses Save. That is the failure this catches.
import os import os
import sys import sys
import time
import unittest import unittest
from typing import ClassVar from typing import ClassVar
from unittest import mock from unittest import mock
@@ -21,6 +22,7 @@ from dikte import cleanup
from dikte import config as cfg from dikte import config as cfg
from dikte import ggml from dikte import ggml
from dikte import hotkey from dikte import hotkey
from dikte import hub
from dikte import ipc from dikte import ipc
from dikte import overlay as overlay_module from dikte import overlay as overlay_module
from dikte import paste from dikte import paste
@@ -1228,6 +1230,75 @@ class LocalModels(DikteTest):
for row in range(box.repo.count())) for row in range(box.repo.count()))
self.assertGreaterEqual(view.minimumWidth(), widest) self.assertGreaterEqual(view.minimumWidth(), widest)
@staticmethod
def _item(name, size=1 << 20):
return hub.Item(name, f"https://example.invalid/{name}", size, "")
def test_a_row_with_nothing_to_fetch_does_not_offer_a_download(self):
# The model the settings name is not in the list any more, so its row
# was rebuilt from the name alone and carries no file to fetch. The
# button stayed lit and the press did nothing at all.
box = self.window(self.config(local_llm_model="gone.gguf")).local_llm
box.load("gone.gguf", "ggml-org/SmolLM3-3B-GGUF")
self.assertEqual(box.selected(), "gone.gguf")
self.assertFalse(box.download_button.isEnabled())
self.assertIn("gone.gguf", box.status.text())
self.assertIn("publisher", box.status.text())
def test_a_model_without_its_program_does_not_say_it_is_ready(self):
# The model runs on the program above it, and "Ready" over a missing
# one is what had people asking why nothing transcribed.
box = self.window(cfg.Config()).local_whisper
path = ggml.whisper_model_path("ggml-small.bin")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"not really a model")
box.load("ggml-small.bin")
self.assertFalse(ggml.program_path(ggml.WHISPER))
self.assertNotIn("Ready", box.status.text())
self.assertIn("program", box.status.text())
def test_changing_the_publisher_changes_the_model(self):
# The model chosen under the old publisher is not published by the new
# one. Carried over, it was added back as "not downloaded" and selected
# again, and the box looked as though the change had not taken.
box = self.window(self.config(local_llm_model="gemma-3-4b-it-Q4_K_M.gguf",
local_llm_repo="ggml-org/gemma-3-4b-it-GGUF")).local_llm
box.load("gemma-3-4b-it-Q4_K_M.gguf", "ggml-org/gemma-3-4b-it-GGUF")
box.repo.blockSignals(True)
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
box.repo.blockSignals(False)
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
"ggml-org/SmolLM3-3B-GGUF")], "")
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
self.assertEqual(box.model.count(), 1)
def test_a_list_for_a_publisher_that_is_no_longer_chosen_is_dropped(self):
# Every change starts its own request, and they do not come back in the
# order they went out.
box = self.window(cfg.Config()).local_llm
box.load("", "ggml-org/SmolLM3-3B-GGUF")
box.repo.blockSignals(True)
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
box.repo.blockSignals(False)
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
"ggml-org/SmolLM3-3B-GGUF")], "")
box._on_listed([("models", [self._item("gemma-3-4b-it-Q4_K_M.gguf")],
"ggml-org/gemma-3-4b-it-GGUF")], "")
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
def test_the_publisher_box_is_not_asked_on_every_keystroke(self):
box = self.window(cfg.Config()).local_llm
with mock.patch.object(box, "_fetch_models") as fetch:
for text in ("g", "gg", "ggm", "ggml-org/SmolLM3-3B-GGUF"):
box.repo.setCurrentText(text)
fetch.assert_not_called()
box._later.setInterval(0)
box._later.start()
_app.processEvents()
time.sleep(0.05)
_app.processEvents()
self.assertEqual(fetch.call_count, 1)
def test_only_the_chosen_transcriber_is_on_screen(self): def test_only_the_chosen_transcriber_is_on_screen(self):
window = self.window(self.config(transcribe_provider="openai")) window = self.window(self.config(transcribe_provider="openai"))
self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row)) self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))