Compare commits

..
Author SHA1 Message Date
yusufipek eda1398a2b Call the OpenRouter subtitle model the audio file model
Timestamps are a file transcription option, so the model box is named
after the file rather than the format it ends up in.
2026-09-02 12:43:07 +03:00
yusufipek 6e307bd8d0 Let OpenRouter subtitles use a chosen model instead of whisper-1
A timestamped run on OpenRouter always asked openai/whisper-1 for the
segments, whatever model was picked for plain transcription. Not every
model there returns segment times, so the one to use is now its own
setting, openrouter_subtitle_model, shown in the speech-to-text box only
when OpenRouter is the provider. Empty keeps the old whisper-1 fallback.

Target carries the choice as subtitle_model and timestamp_model() reads
it; the other providers are unchanged.
2026-09-02 12:41:43 +03:00
12 changed files with 118 additions and 308 deletions
+22 -10
View File
@@ -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,
)
+5 -1
View File
@@ -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."""
+6 -64
View File
@@ -83,10 +83,6 @@ LLAMA = Program("llama", "ggml-org/llama.cpp", "llama-server", "/health")
WHISPER_MODELS_REPO = "ggerganov/whisper.cpp"
LLM_AUTHOR = "ggml-org"
# The file llama.cpp attaches to its version releases in place of the binaries:
# a line naming the nightly tag those are published under.
NIGHTLY_TAG = "nightly-tag.txt"
# What the whisper repository holds besides models: Core ML encoders for Apple
# hardware and the odd loose file.
WHISPER_PREFIX = "ggml-"
@@ -281,65 +277,6 @@ def _wanted_assets(program):
return (f"bin-ubuntu-{arch}.tar.gz",)
def _matching_asset(program, assets):
"""The archive this machine wants out of one release's files, or None."""
for ending in _wanted_assets(program):
item = next((a for a in assets if a.name.endswith(ending)), None)
if item:
return item
return None
def _pick_asset(program, tag="", refresh=False):
"""(tag, Item) for the release archive to install. Item is None when there
is none for this machine.
A named tag is taken as given. For the newest, what GitHub answers is not
always where the builds are: llama.cpp's latest release is a version marker
carrying a single nightly-tag.txt, which names the tag the archives are
actually attached to, and those are prereleases that "latest" never points
at. The pointer is followed when it is there, and when it is not, the newest
release that does carry a build for this machine is taken instead.
"""
named = bool(tag) and tag != "latest"
missing = None
try:
tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh)
except hub.HubError as exc:
# A release carrying no files at all is the case the search below exists
# for, not a reason to stop before it: the build for this machine may be
# attached to a prerelease that "latest" never points at. The failure is
# kept rather than dropped, because an unreachable GitHub arrives here
# the same way and that one is the message the caller wants.
if named:
raise
missing, assets = exc, []
item = _matching_asset(program, assets)
if item or named:
return tag, item
# Best effort from here on: a machine this project publishes nothing for is
# not a failed lookup, and the caller's message about that is the useful
# one. Whatever goes wrong while looking further leaves it standing.
try:
pointer = next((a for a in assets if a.name == NIGHTLY_TAG), None)
if pointer:
nightly = hub.text(pointer.url).strip()
if nightly:
found, assets = hub.release(program.repo, nightly, refresh=refresh)
item = _matching_asset(program, assets)
if item:
return found, item
for found, assets in hub.releases(program.repo, refresh=refresh):
item = _matching_asset(program, assets)
if item:
return found, item
except hub.HubError:
pass
if missing is not None:
raise missing
return tag, None
def _install_record(program):
return BIN_DIR / program.name / "installed.json"
@@ -438,10 +375,15 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
whisper.cpp has one.
"""
try:
tag, item = _pick_asset(program, tag, refresh=refresh)
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:
# Nothing to download and nothing to install for you: whisper.cpp
# publishes no macOS binary, and Homebrew's whisper-cpp is configured
+4 -48
View File
@@ -121,13 +121,6 @@ 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}"
@@ -135,47 +128,10 @@ 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))
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
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 newest_release(repo, refresh=False):
+5 -6
View File
@@ -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)",
@@ -787,12 +792,6 @@ 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",
+30 -59
View File
@@ -6,7 +6,7 @@ import shutil
import sys
import threading
from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QTimer, QUrl, pyqtSignal
from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal
from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
from PyQt6.QtWidgets import (
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
@@ -247,13 +247,6 @@ 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)
@@ -335,8 +328,6 @@ 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()
@@ -376,18 +367,11 @@ 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())
@@ -397,29 +381,18 @@ class LocalModelBox(QGroupBox):
def work():
try:
found = self._models(repo) if self._repos is not None else self._models()
self._listed.emit([("models", found, repo)], "")
self._listed.emit([("models", found)], "")
except ggml.LocalError as exc:
self._listed.emit([("models", [], repo)], str(exc))
self._listed.emit([], 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)
@@ -433,12 +406,7 @@ class LocalModelBox(QGroupBox):
def _fill_models(self, items):
"""One row per model, saying what it weighs and whether it is here."""
# 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 "")
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()
@@ -463,7 +431,6 @@ 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):
@@ -492,9 +459,6 @@ 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):
@@ -581,30 +545,15 @@ 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 (item is not None
and not here))
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 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))
@@ -915,6 +864,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("")
@@ -1798,6 +1756,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"]))
@@ -1915,6 +1874,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()
@@ -2088,6 +2048,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)
@@ -2096,8 +2057,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"
@@ -2126,6 +2095,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):
+17
View File
@@ -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:
-5
View File
@@ -9,7 +9,6 @@ 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
@@ -669,11 +668,7 @@ 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)
+13
View File
@@ -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",
-39
View File
@@ -238,45 +238,6 @@ class InstallProgram(Local):
"whisper-bin-ubuntu-x64.tar.gz")
self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz"))
def test_the_nightly_pointer_is_followed_to_where_the_builds_are(self):
"""llama.cpp's latest release carries a tag name, not the binaries."""
self.patch_attr(ggml, "_arch", lambda: "x64")
self.patch_attr(ggml, "_has_vulkan", lambda: False)
marker = self.release(ggml.NIGHTLY_TAG)
nightly = dict(self.release("llama-b10809-bin-ubuntu-x64.tar.gz"),
tag_name="b10809")
def opener(request, timeout=None):
url = request.full_url
if url.endswith("/releases/latest"):
return json_body(marker)
if url.endswith("/releases/tags/b10809"):
return json_body(nightly)
if url.endswith(ggml.NIGHTLY_TAG):
return body(b"b10809\n")
return body(self.archive)
with mock.patch("urllib.request.urlopen", side_effect=opener):
tag, found = ggml._pick_asset(ggml.LLAMA)
self.assertEqual(tag, "b10809")
self.assertEqual(found.name, "llama-b10809-bin-ubuntu-x64.tar.gz")
def test_without_a_pointer_the_newest_release_that_has_a_build_is_taken(self):
self.patch_attr(ggml, "_arch", lambda: "x64")
self.patch_attr(ggml, "_has_vulkan", lambda: False)
marker = self.release("source.zip")
listing = [dict(self.release("llama-b2-bin-win-cpu-x64.zip"), tag_name="b2"),
dict(self.release("llama-b1-bin-ubuntu-x64.tar.gz"), tag_name="b1")]
def opener(request, timeout=None):
url = request.full_url
return json_body(listing if "per_page" in url else marker)
with mock.patch("urllib.request.urlopen", side_effect=opener):
tag, found = ggml._pick_asset(ggml.LLAMA)
self.assertEqual(tag, "b1")
self.assertEqual(found.name, "llama-b1-bin-ubuntu-x64.tar.gz")
def test_a_release_with_nothing_for_this_machine_says_so(self):
self.patch_attr(ggml, "_arch", lambda: "x64")
with fake_urlopen(self.release("whisper-bin-Win32.zip")):
+2 -5
View File
@@ -42,12 +42,9 @@ 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."""
# 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"}):
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}):
config_dir, _ = paths.directories("darwin")
self.assertNotIn("xdg-elsewhere", config_dir.as_posix())
self.assertNotIn("/c", 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."""
+14 -71
View File
@@ -8,7 +8,6 @@ next time anybody presses Save. That is the failure this catches.
import os
import sys
import time
import unittest
from typing import ClassVar
from unittest import mock
@@ -22,7 +21,6 @@ from dikte import cleanup
from dikte import config as cfg
from dikte import ggml
from dikte import hotkey
from dikte import hub
from dikte import ipc
from dikte import overlay as overlay_module
from dikte import paste
@@ -506,6 +504,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,75 +1228,6 @@ class LocalModels(DikteTest):
for row in range(box.repo.count()))
self.assertGreaterEqual(view.minimumWidth(), widest)
@staticmethod
def _item(name, size=1 << 20):
return hub.Item(name, f"https://example.invalid/{name}", size, "")
def test_a_row_with_nothing_to_fetch_does_not_offer_a_download(self):
# The model the settings name is not in the list any more, so its row
# was rebuilt from the name alone and carries no file to fetch. The
# button stayed lit and the press did nothing at all.
box = self.window(self.config(local_llm_model="gone.gguf")).local_llm
box.load("gone.gguf", "ggml-org/SmolLM3-3B-GGUF")
self.assertEqual(box.selected(), "gone.gguf")
self.assertFalse(box.download_button.isEnabled())
self.assertIn("gone.gguf", box.status.text())
self.assertIn("publisher", box.status.text())
def test_a_model_without_its_program_does_not_say_it_is_ready(self):
# The model runs on the program above it, and "Ready" over a missing
# one is what had people asking why nothing transcribed.
box = self.window(cfg.Config()).local_whisper
path = ggml.whisper_model_path("ggml-small.bin")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"not really a model")
box.load("ggml-small.bin")
self.assertFalse(ggml.program_path(ggml.WHISPER))
self.assertNotIn("Ready", box.status.text())
self.assertIn("program", box.status.text())
def test_changing_the_publisher_changes_the_model(self):
# The model chosen under the old publisher is not published by the new
# one. Carried over, it was added back as "not downloaded" and selected
# again, and the box looked as though the change had not taken.
box = self.window(self.config(local_llm_model="gemma-3-4b-it-Q4_K_M.gguf",
local_llm_repo="ggml-org/gemma-3-4b-it-GGUF")).local_llm
box.load("gemma-3-4b-it-Q4_K_M.gguf", "ggml-org/gemma-3-4b-it-GGUF")
box.repo.blockSignals(True)
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
box.repo.blockSignals(False)
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
"ggml-org/SmolLM3-3B-GGUF")], "")
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
self.assertEqual(box.model.count(), 1)
def test_a_list_for_a_publisher_that_is_no_longer_chosen_is_dropped(self):
# Every change starts its own request, and they do not come back in the
# order they went out.
box = self.window(cfg.Config()).local_llm
box.load("", "ggml-org/SmolLM3-3B-GGUF")
box.repo.blockSignals(True)
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
box.repo.blockSignals(False)
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
"ggml-org/SmolLM3-3B-GGUF")], "")
box._on_listed([("models", [self._item("gemma-3-4b-it-Q4_K_M.gguf")],
"ggml-org/gemma-3-4b-it-GGUF")], "")
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
def test_the_publisher_box_is_not_asked_on_every_keystroke(self):
box = self.window(cfg.Config()).local_llm
with mock.patch.object(box, "_fetch_models") as fetch:
for text in ("g", "gg", "ggm", "ggml-org/SmolLM3-3B-GGUF"):
box.repo.setCurrentText(text)
fetch.assert_not_called()
box._later.setInterval(0)
box._later.start()
_app.processEvents()
time.sleep(0.05)
_app.processEvents()
self.assertEqual(fetch.call_count, 1)
def test_only_the_chosen_transcriber_is_on_screen(self):
window = self.window(self.config(transcribe_provider="openai"))
self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))