From 0a6c6128a224a545ec7c3c35d1f2c9752f6105da Mon Sep 17 00:00:00 2001 From: sudoeren Date: Tue, 25 Aug 2026 21:05:18 +0300 Subject: [PATCH 01/37] Add OpenCode Go as a cleanup and agent provider OpenCode Go serves open coding models from an OpenAI-compatible endpoint. It cannot transcribe, so it joins the two LLM jobs: transcript cleanup and the plain-chat agent. The key falls back to OPENCODE_API_KEY, and api.chat() stops hardcoding the OpenRouter name so the same call serves both. --- dikte/api.py | 11 +++++---- dikte/assistant.py | 55 ++++++++++++++++++++++++----------------- dikte/cleanup.py | 11 ++++++++- dikte/config.py | 7 ++++++ tests/test_assistant.py | 39 +++++++++++++++++++++++++++++ tests/test_cleanup.py | 30 ++++++++++++++++++++++ 6 files changed, 124 insertions(+), 29 deletions(-) diff --git a/dikte/api.py b/dikte/api.py index 7a0e78f..d649835 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -524,15 +524,16 @@ def cleanup(text, api_key, model, system_prompt, reasoning="", def chat(messages, api_key, model, system_prompt, reasoning="", - base_url=OPENROUTER_URL, timeout=180): + base_url=OPENROUTER_URL, timeout=180, provider="openrouter", + service="OpenRouter"): """A conversation, rather than one transcript rewritten. The messages are the whole history and come back unchanged; the caller keeps - them, because there is no session on OpenRouter's side to resume. + them, because there is no session on the provider's side to resume. """ if not api_key: raise ApiError(t("{service} API key is empty. Add it in Settings.", - service="OpenRouter")) + service=service)) payload = { "model": model, "messages": [{"role": "system", "content": system_prompt}] + list(messages), @@ -543,11 +544,11 @@ def chat(messages, api_key, model, system_prompt, reasoning="", data = _request( f"{base_url.rstrip('/')}/chat/completions", json.dumps(payload).encode("utf-8"), - _headers("openrouter", api_key, "application/json"), + _headers(provider, api_key, "application/json"), timeout=timeout, ) except ApiError as exc: - raise explain(exc, "OpenRouter") from None + raise explain(exc, service) from None choices = data.get("choices") or [] if not choices: raise ApiError(_extract_error(json.dumps(data))) diff --git a/dikte/assistant.py b/dikte/assistant.py index b57fd06..8d60409 100644 --- a/dikte/assistant.py +++ b/dikte/assistant.py @@ -1,16 +1,17 @@ """Handing a dictation to an agent as a command, and pasting back its answer. -Three of them, because not everyone has the same one installed: +Four of them, because not everyone has the same one installed: Claude Code `claude -p`, the session you would have opened yourself Codex `codex exec`, the same idea from the other shop OpenRouter a plain chat request, over the key that is already configured + OpenCode Go a plain chat request, over a subscription to open coding models The first two are the whole machine: they run commands, read files, and reach whatever skills and services you have connected, which is what makes "put that -in my calendar on Thursday" a thing you can say. OpenRouter cannot touch any of -that, and is there so that a question still gets an answer on a machine with -neither CLI installed. +in my calendar on Thursday" a thing you can say. The two chat requests cannot +touch any of that, and are there so that a question still gets an answer on a +machine with neither CLI installed. Whichever it is, the reply is pasted exactly where the transcript would have been, and the conversation carries across dictations so that "and move that to @@ -34,11 +35,11 @@ from . import config as cfg from .i18n import t SESSION_FILE = cfg.DATA_DIR / "assistant.json" -PROVIDERS = ("claude", "codex", "openrouter") +PROVIDERS = ("claude", "codex", "openrouter", "opencode") -# How many messages of an OpenRouter conversation are carried forward. The two -# CLIs keep their own history and need no such number; here every turn is resent -# in full, so the window has to end somewhere. +# How many messages of a chat provider's conversation are carried forward. The +# two CLIs keep their own history and need no such number; here every turn is +# resent in full, so the window has to end somewhere. MAX_HISTORY = 24 # What to say in the indicator for a tool, keyed by the name the CLI uses. @@ -103,7 +104,9 @@ def executable(name): def display_name(conf): """What to call the thing being asked, in the tray and in the corner.""" - return {"claude": "Claude", "codex": "Codex"}.get(provider(conf), "OpenRouter") + names = {"claude": "Claude", "codex": "Codex", + "openrouter": "OpenRouter", "opencode": "OpenCode Go"} + return names.get(provider(conf), "OpenRouter") # --- the conversation ----------------------------------------------------- @@ -196,8 +199,9 @@ def ask(prompt, conf, on_stage=None, should_stop=None): one, and only the denial explains why it did not do what it was asked to. """ name = provider(conf) - if name == "openrouter": - return _ask_openrouter(prompt, conf, on_stage) + if name in ("openrouter", "opencode"): + service = "OpenRouter" if name == "openrouter" else "OpenCode Go" + return _ask_chat(name, service, prompt, conf, on_stage) binary = executable(name) if not shutil.which(binary): @@ -338,29 +342,34 @@ def _codex_label(item): return t("Using {name}…", name=item_type or "a tool") -# --- OpenRouter ----------------------------------------------------------- +# --- OpenRouter and OpenCode Go ------------------------------------------- -def _ask_openrouter(prompt, conf, on_stage): - """No tools, no files, no calendar: a question and an answer. +def _ask_chat(name, service, prompt, conf, on_stage): + """A plain question and answer, over a chat provider's key. - It is the fallback for a machine with neither CLI on it, so it says what it - knows and nothing else. The conversation is ours to keep here, since there - is no session on the other end to resume. + No tools, no files, no calendar. It is the fallback for a machine with + neither CLI on it, so it says what it knows and nothing else. The + conversation is ours to keep here, since there is no session on the other + end to resume. """ if on_stage: on_stage(t("Thinking…")) - history = read_messages("openrouter", conf["assistant_session_minutes"] * 60) + history = read_messages(name, conf["assistant_session_minutes"] * 60) messages = history + [{"role": "user", "content": prompt}] + model = (conf["assistant_openrouter_model"] if name == "openrouter" + else conf["assistant_opencode_model"]) + base_url = (conf["openrouter_base_url"] if name == "openrouter" + else conf["opencode_base_url"]) + key = conf.openrouter_key() if name == "openrouter" else conf.opencode_key() try: answer = api.chat( - messages, conf.openrouter_key(), conf["assistant_openrouter_model"], - conf.assistant_prompt(), reasoning=conf["assistant_reasoning"], - base_url=conf["openrouter_base_url"], - timeout=conf["assistant_timeout"], + messages, key, model, conf.assistant_prompt(), + reasoning=conf["assistant_reasoning"], base_url=base_url, + timeout=conf["assistant_timeout"], provider=name, service=service, ) except api.ApiError as exc: raise AssistantError(str(exc)) from exc - write_session("openrouter", + write_session(name, messages=messages + [{"role": "assistant", "content": answer}]) return answer, "" diff --git a/dikte/cleanup.py b/dikte/cleanup.py index 96eab1d..f63e024 100644 --- a/dikte/cleanup.py +++ b/dikte/cleanup.py @@ -23,7 +23,7 @@ from . import assistant from . import ggml from .i18n import t -PROVIDERS = ("openrouter", "local", "claude", "codex") +PROVIDERS = ("openrouter", "opencode", "local", "claude", "codex") class CleanupError(api.ApiError): @@ -56,6 +56,8 @@ def model(conf): # Codex is left on whatever it is set to unless a model is typed in, so # here there is only the name of the thing that did it. return conf["cleanup_codex_model"].strip() or "codex" + if name == "opencode": + return conf["cleanup_opencode_model"] return conf["cleanup_model"] @@ -73,6 +75,13 @@ def run(text, conf, system_prompt, timeout=180, aborter=None): base_url=conf["openrouter_base_url"], timeout=timeout, aborter=aborter, ) + if name == "opencode": + return api.cleanup( + text, conf.opencode_key(), conf["cleanup_opencode_model"], system_prompt, + reasoning=conf["cleanup_reasoning"], + base_url=conf["opencode_base_url"], timeout=timeout, + provider="opencode", service="OpenCode Go", aborter=aborter, + ) if name == "local": return _local(text, conf, system_prompt, timeout, aborter) runner = _claude if name == "claude" else _codex diff --git a/dikte/config.py b/dikte/config.py index fb7683a..5a9f15f 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -385,6 +385,8 @@ DEFAULTS = { "groq_base_url": "https://api.groq.com/openai/v1", "openrouter_api_key": "", "openrouter_base_url": "https://openrouter.ai/api/v1", + "opencode_api_key": "", + "opencode_base_url": "https://opencode.ai/zen/go/v1", "transcribe_provider": "local", # "local", or a key of TRANSCRIBERS "transcribe_model": "gpt-4o-transcribe", # used when provider is openai "groq_transcribe_model": "whisper-large-v3-turbo", @@ -410,6 +412,7 @@ DEFAULTS = { "cleanup_model": "google/gemini-3.5-flash-lite", "cleanup_claude_model": "haiku", # Claude Code: an alias, or a full model id "cleanup_codex_model": "", # empty -> whatever Codex is set to + "cleanup_opencode_model": "deepseek-v4-flash", "cleanup_reasoning": "", # empty -> whatever the model does by default # --- llama.cpp, on this machine ----------------------------------------- @@ -488,6 +491,7 @@ DEFAULTS = { "assistant_codex_model": "", # empty -> whatever Codex is set to "assistant_codex_sandbox": "workspace-write", "assistant_openrouter_model": "google/gemini-3.5-flash", + "assistant_opencode_model": "deepseek-v4-flash", "assistant_reasoning": "", # empty -> the model's own default "assistant_dir": "", # empty -> the home directory "assistant_prompt": "", # empty -> language-specific default @@ -589,6 +593,9 @@ class Config: def openrouter_key(self): return self.api_key("openrouter_api_key") + def opencode_key(self): + return self.api_key("opencode_api_key") + def transcribe_target(self): """Key, endpoint and model for whichever provider does speech to text. diff --git a/tests/test_assistant.py b/tests/test_assistant.py index 1c682c6..a4c44e9 100644 --- a/tests/test_assistant.py +++ b/tests/test_assistant.py @@ -59,6 +59,7 @@ class Provider(DikteTest): self.assertEqual(assistant.executable("claude"), "claude") self.assertEqual(assistant.executable("codex"), "codex") self.assertEqual(assistant.executable("openrouter"), "") + self.assertEqual(assistant.executable("opencode"), "") def test_what_each_one_is_called(self): self.assertEqual(assistant.display_name(self.config()), "Claude") @@ -67,6 +68,9 @@ class Provider(DikteTest): self.assertEqual( assistant.display_name(self.config(assistant_provider="openrouter")), "OpenRouter") + self.assertEqual( + assistant.display_name(self.config(assistant_provider="opencode")), + "OpenCode Go") class Effort(unittest.TestCase): @@ -507,6 +511,41 @@ class AskOpenRouter(DikteTest): assistant.ask("when is it", conf) +class AskOpenCode(DikteTest): + def test_a_question_and_an_answer(self): + conf = self.config(assistant_provider="opencode", + opencode_api_key="opencode-test-key") + with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}): + answer, warning = assistant.ask("when is it", conf) + self.assertEqual(answer, "on Thursday") + self.assertEqual(warning, "") + + def test_the_conversation_is_ours_to_keep(self): + conf = self.config(assistant_provider="opencode", + opencode_api_key="opencode-test-key") + with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}): + assistant.ask("when is it", conf) + stored = assistant.read_messages("opencode", 1800) + self.assertEqual([row["content"] for row in stored], + ["when is it", "on Thursday"]) + + def test_the_model_and_endpoint_are_opencode_s_own(self): + conf = self.config(assistant_provider="opencode", + opencode_api_key="opencode-test-key", + assistant_opencode_model="glm-5.3") + with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}) as calls: + assistant.ask("when is it", conf) + sent = json.loads(calls[0].data.decode("utf-8")) + self.assertEqual(sent["model"], "glm-5.3") + self.assertIn("https://opencode.ai/zen/go/v1/chat/completions", + calls[0].full_url) + + def test_an_api_failure_reads_as_an_assistant_failure(self): + conf = self.config(assistant_provider="opencode") + with self.assertRaises(assistant.AssistantError): + assistant.ask("when is it", conf) + + class Ask(DikteTest): def test_a_cli_that_is_not_installed_says_where_to_change_it(self): with only_these_tools(), \ diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index 7753029..5cce2ef 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -50,6 +50,7 @@ class Provider(DikteTest): self.assertEqual(cleanup.executable("claude"), "claude") self.assertEqual(cleanup.executable("codex"), "codex") self.assertEqual(cleanup.executable("openrouter"), "") + self.assertEqual(cleanup.executable("opencode"), "") def test_the_model_named_in_the_history_is_the_one_that_did_it(self): self.assertEqual(cleanup.model(self.config(cleanup_model="some/model")), @@ -65,6 +66,9 @@ class Provider(DikteTest): self.assertEqual( cleanup.model(self.config(cleanup_provider="codex", cleanup_codex_model="gpt-5.4")), "gpt-5.4") + self.assertEqual( + cleanup.model(self.config(cleanup_provider="opencode", + cleanup_opencode_model="glm-5.3")), "glm-5.3") class OpenRouter(DikteTest): @@ -86,6 +90,32 @@ class OpenRouter(DikteTest): self.assertEqual(calls, []) +class OpenCode(DikteTest): + def test_it_is_one_request_with_the_settings_as_they_were(self): + conf = self.config(cleanup_provider="opencode", + opencode_api_key="opencode-test-key", + cleanup_opencode_model="some/model", + cleanup_reasoning="low") + with mock.patch.object(api, "cleanup", return_value="Done.") as call: + self.assertEqual(cleanup.run("uh, done", conf, "the rules"), "Done.") + text, key, model, prompt = call.call_args.args + self.assertEqual((text, key, model, prompt), + ("uh, done", "opencode-test-key", "some/model", "the rules")) + self.assertEqual(call.call_args.kwargs["reasoning"], "low") + self.assertEqual(call.call_args.kwargs["provider"], "opencode") + self.assertEqual(call.call_args.kwargs["service"], "OpenCode Go") + self.assertEqual(call.call_args.kwargs["base_url"], + "https://opencode.ai/zen/go/v1") + + def test_no_cli_is_started_for_it(self): + conf = self.config(cleanup_provider="opencode", + opencode_api_key="opencode-test-key") + patcher, calls = fake_run(stdout="never") + with patcher, mock.patch.object(api, "cleanup", return_value="Done."): + cleanup.run("uh, done", conf, "the rules") + self.assertEqual(calls, []) + + class ClaudeCode(DikteTest): def setUp(self): super().setUp() From 3c336e717833d5db7d448713fb470ca89cb07fa8 Mon Sep 17 00:00:00 2001 From: sudoeren Date: Tue, 25 Aug 2026 21:05:21 +0300 Subject: [PATCH 02/37] Offer OpenCode Go in the settings window A key row under Keys, a model box in the cleanup tab and a box in the agent tab, each with its own model list of the models OpenCode Go serves over /chat/completions. Fetch model list reads whichever cleanup provider is on screen, and the Turkish strings cover the new rows. --- dikte/i18n.py | 29 +++++++++- dikte/settings_ui.py | 130 +++++++++++++++++++++++++++++++++++++------ tests/test_ui.py | 1 + 3 files changed, 141 insertions(+), 19 deletions(-) diff --git a/dikte/i18n.py b/dikte/i18n.py index f7f4910..27de89d 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -40,8 +40,10 @@ def t(text, /, **kwargs): # by the sentence, so it arrives already inflected. English takes the name as it # is and puts the preposition in the sentence, where it belongs. _TR_CASES = { - "dative": {"Claude": "Claude'a", "Codex": "Codex'e", "OpenRouter": "OpenRouter'a"}, - "accusative": {"Claude": "Claude'u", "Codex": "Codex'i", "OpenRouter": "OpenRouter'ı"}, + "dative": {"Claude": "Claude'a", "Codex": "Codex'e", "OpenRouter": "OpenRouter'a", + "OpenCode Go": "OpenCode Go'ya"}, + "accusative": {"Claude": "Claude'u", "Codex": "Codex'i", + "OpenRouter": "OpenRouter'ı", "OpenCode Go": "OpenCode Go'yu"}, } @@ -221,11 +223,15 @@ TR = { "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)", "sk-or-… (falls back to OPENROUTER_API_KEY)": "sk-or-… (boşsa OPENROUTER_API_KEY kullanılır)", + "(falls back to OPENCODE_API_KEY)": "(boşsa OPENCODE_API_KEY kullanılır)", "Test": "Test et", "Trying…": "Deneniyor…", "Runs on OpenRouter.": "OpenRouter üzerinde çalışır.", + "Runs on OpenCode Go.": "OpenCode Go üzerinde çalışır.", "Connection works. {count} audio models visible.": "Bağlantı tamam. {count} ses modeli görünüyor.", + "Connection works. {count} models visible.": + "Bağlantı tamam. {count} model görünüyor.", "Clean the transcript with a model": "Transkripti bir modelle temizle", "OpenRouter is the quickest and the only one that needs nothing installed. " "Claude Code and Codex clean up on the subscription you already have, " @@ -235,6 +241,14 @@ TR = { "Claude Code ile Codex, temizliği hâlihazırda ödediğin abonelik üzerinden " "yapar, ikinci bir anahtar istemez; her biri bunun için bir oturum açtığından " "birkaç saniye daha uzun sürer.", + "OpenRouter and OpenCode Go are the quickest and need nothing installed. " + "Claude Code and Codex clean up on the subscription you already have, " + "without a second key, and take a few seconds longer because each one opens " + "a session to do it.": + "En hızlıları OpenRouter ve OpenCode Go'dur; kurulu bir program " + "istemezler. Claude Code ile Codex, temizliği hâlihazırda ödediğin " + "abonelik üzerinden yapar, ikinci bir anahtar istemez; her biri bunun " + "için bir oturum açtığından birkaç saniye daha uzun sürer.", "{binary} is not on your PATH, so cleanup would fail and the raw transcript " "would be pasted. Install it, or pick another one above.": "{binary} PATH'te değil; temizleme başarısız olur ve ham transkript " @@ -541,6 +555,17 @@ TR = { "Yukarıdaki çalışma dizini ve izinler burada bir şey ifade etmez.", "Needs no program installed, only the OpenRouter key.": "Kurulu bir programa değil, yalnızca OpenRouter anahtarına ihtiyaç duyar.", + "A plain question and a plain answer, over the OpenCode Go key you already " + "have. It runs no commands, opens no files and reaches none of your " + "services, so it can tell you what the capital of Peru is but not what is " + "in your calendar. Working directory and permissions above mean nothing " + "here.": + "Elindeki OpenCode Go anahtarı üzerinden düz bir soru ve düz bir cevap. " + "Komut çalıştırmaz, dosya açmaz, servislerinin hiçbirine erişmez; yani " + "Peru'nun başkentini söyler ama takviminde ne olduğunu söyleyemez. " + "Yukarıdaki çalışma dizini ve izinler burada bir şey ifade etmez.", + "Needs no program installed, only an OpenCode Go key.": + "Kurulu bir programa değil, yalnızca bir OpenCode Go anahtarına ihtiyaç duyar.", "{binary} is not on your PATH, so this cannot run yet. Install it, or pick " "another one above.": "{binary} PATH'te değil, dolayısıyla bu henüz çalışamaz. Kur ya da " diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index d3a97b7..9427198 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -61,7 +61,8 @@ CLEANUP_MODELS = [ # model here takes a little longer and costs nothing, and the two CLIs the agent # can run on open a whole session to do the smaller job. CLEANUP_PROVIDERS = [ - ("OpenRouter", "openrouter"), ("This machine (llama.cpp)", "local"), + ("OpenRouter", "openrouter"), ("OpenCode Go", "opencode"), + ("This machine (llama.cpp)", "local"), ("Claude Code", "claude"), ("Codex", "codex"), ] # Cleaning up a sentence is the lightest thing either of them will ever be @@ -74,7 +75,8 @@ MEETING_MODELS = [ "anthropic/claude-sonnet-5", "openai/gpt-5.4", "x-ai/grok-4.5", ] ASSISTANT_PROVIDERS = [ - ("Claude Code", "claude"), ("Codex", "codex"), ("OpenRouter", "openrouter"), + ("Claude Code", "claude"), ("Codex", "codex"), + ("OpenRouter", "openrouter"), ("OpenCode Go", "opencode"), ] # Aliases resolve to the newest model of that name, so they age better than an # id does; a full id can be typed in when a particular one is wanted. @@ -85,6 +87,14 @@ ASSISTANT_OR_MODELS = [ "google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4", "x-ai/grok-4.5", "google/gemini-3.1-pro-preview", ] +# The models OpenCode Go serves over /chat/completions. The ones its catalog +# lists under /responses or /messages (Grok, GPT-5.6 Luna, MiniMax, Qwen) are +# not offered here, because Dikte speaks only the chat endpoint. +OPENCODE_MODELS = [ + "deepseek-v4-flash", "deepseek-v4-pro", "glm-5.3", "glm-5.2", "glm-5.1", + "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "longcat-2.0", + "mimo-v2.5", "mimo-v2.5-pro", "hy3", "ox-alpha-free", +] # What Claude Code may do without being able to ask. It cannot ask: there is no # window to answer in, so a mode that would have prompted denies instead. PERMISSION_MODES = [ @@ -519,7 +529,7 @@ class SettingsWindow(QDialog): # hears about it from here rather than waiting for its own next check. update_found = pyqtSignal(object) - _models_loaded = pyqtSignal(list, str) + _models_loaded = pyqtSignal(list, str, str) _transcribe_models_loaded = pyqtSignal(list, str) # Which key was tested, whether it worked, and what to write under it. _test_done = pyqtSignal(str, bool, str) @@ -746,6 +756,9 @@ class SettingsWindow(QDialog): self.openrouter_key = self._key_row( keys_form, "openrouter", t("sk-or-… (falls back to OPENROUTER_API_KEY)"), self._test_openrouter) + self.opencode_key = self._key_row( + keys_form, "opencode", t("(falls back to OPENCODE_API_KEY)"), + self._test_opencode, service="OpenCode Go") outer.addWidget(keys) stt = QGroupBox(t("Speech to text")) @@ -818,7 +831,7 @@ class SettingsWindow(QDialog): for label, value in CLEANUP_PROVIDERS: self.cleanup_provider.addItem(t(label), value) self.cleanup_provider.setToolTip(t( - "OpenRouter is the quickest and the only one that needs nothing " + "OpenRouter and OpenCode Go are the quickest and need nothing " "installed. llama.cpp runs here, on a model downloaded below. Claude " "Code and Codex clean up on the subscription you already have, " "without a second key, and take a few seconds longer because each " @@ -848,6 +861,11 @@ class SettingsWindow(QDialog): self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS) orr_form.addRow(t("Model"), self.cleanup_codex_model) + self.cleanup_opencode_model = QComboBox() + self.cleanup_opencode_model.setEditable(True) + self.cleanup_opencode_model.addItems(OPENCODE_MODELS) + orr_form.addRow(t("Model"), self.cleanup_opencode_model) + self.cleanup_reasoning = QComboBox() for label, value in REASONING_LEVELS: self.cleanup_reasoning.addItem(t(label), value) @@ -1044,6 +1062,23 @@ class SettingsWindow(QDialog): or_form.addRow(or_note) layout.addWidget(self.openrouter_box) + self.opencode_box = QGroupBox("OpenCode Go") + og_form = QFormLayout(self.opencode_box) + self.assistant_opencode_model = QComboBox() + self.assistant_opencode_model.setEditable(True) + self.assistant_opencode_model.addItems(OPENCODE_MODELS) + og_form.addRow(t("Model"), self.assistant_opencode_model) + og_note = QLabel(t( + "A plain question and a plain answer, over the OpenCode Go key you " + "already have. It runs no commands, opens no files and reaches none " + "of your services, so it can tell you what the capital of Peru is " + "but not what is in your calendar. Working directory and permissions " + "above mean nothing here." + )) + og_note.setWordWrap(True) + og_form.addRow(og_note) + layout.addWidget(self.opencode_box) + thread = QGroupBox(t("The conversation")) thread_form = QFormLayout(thread) self.assistant_session_minutes = QSpinBox() @@ -1515,7 +1550,7 @@ class SettingsWindow(QDialog): box.lineEdit().setPlaceholderText(placeholder) return box - def _key_row(self, form, provider, placeholder, tester): + def _key_row(self, form, provider, placeholder, tester, service=""): """A key field, its Test button and the line the answer lands on. The field and the pair the answer needs are filed under the provider's @@ -1529,7 +1564,8 @@ class SettingsWindow(QDialog): button.clicked.connect(tester) answer = QLabel("") answer.setWordWrap(True) - form.addRow(cfg.TRANSCRIBERS[provider].service, self._row(field, button)) + form.addRow(service or cfg.TRANSCRIBERS[provider].service, + self._row(field, button)) form.addRow("", answer) self._key_fields[provider] = field self._testers[provider] = (button, answer) @@ -1603,6 +1639,7 @@ class SettingsWindow(QDialog): for name, who in cfg.TRANSCRIBERS.items(): self._key_fields[name].setText(conf[who.key]) self._models[name] = conf[who.model] + self._key_fields["opencode"].setText(conf["opencode_api_key"]) self._shown_provider = "" self._select_data(self.transcribe_provider, conf["transcribe_provider"]) self._provider_changed() # selecting index 0 fires no signal @@ -1617,6 +1654,7 @@ class SettingsWindow(QDialog): self.cleanup_codex_model.setCurrentText( conf["cleanup_codex_model"] or t("Codex's own default") ) + self.cleanup_opencode_model.setCurrentText(conf["cleanup_opencode_model"]) self._select_data(self.cleanup_provider, conf["cleanup_provider"]) self._cleanup_provider_changed() # selecting index 0 fires no signal self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"]) @@ -1636,6 +1674,7 @@ class SettingsWindow(QDialog): self.assistant_codex_model.setCurrentText(conf["assistant_codex_model"]) self._select_data(self.assistant_codex_sandbox, conf["assistant_codex_sandbox"]) self.assistant_openrouter_model.setCurrentText(conf["assistant_openrouter_model"]) + self.assistant_opencode_model.setCurrentText(conf["assistant_opencode_model"]) self._assistant_provider_changed() # selecting index 0 fires no signal self._select_data(self.assistant_reasoning, conf["assistant_reasoning"]) self.assistant_dir.setText(conf["assistant_dir"]) @@ -1701,6 +1740,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["opencode_api_key"] = self._key_fields["opencode"].text().strip() conf["local_model"] = self.local_whisper.selected() conf["local_gpu"] = self.local_gpu.isChecked() conf["local_preload"] = self.local_preload.isChecked() @@ -1715,6 +1755,10 @@ class SettingsWindow(QDialog): conf["cleanup_codex_model"] = ( "" if codex_cleanup_model == t("Codex's own default") else codex_cleanup_model ) + conf["cleanup_opencode_model"] = ( + self.cleanup_opencode_model.currentText().strip() + or cfg.DEFAULTS["cleanup_opencode_model"] + ) conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or "" conf["local_llm_model"] = self.local_llm.selected() conf["local_llm_repo"] = self.local_llm.repository() @@ -1748,6 +1792,10 @@ class SettingsWindow(QDialog): self.assistant_openrouter_model.currentText().strip() or cfg.DEFAULTS["assistant_openrouter_model"] ) + conf["assistant_opencode_model"] = ( + self.assistant_opencode_model.currentText().strip() + or cfg.DEFAULTS["assistant_opencode_model"] + ) conf["assistant_reasoning"] = self.assistant_reasoning.currentData() or "" conf["assistant_dir"] = self.assistant_dir.text().strip() conf["assistant_timeout"] = self.assistant_timeout.value() @@ -1854,26 +1902,52 @@ class SettingsWindow(QDialog): def _load_models(self): self.refresh_models.setEnabled(False) self.models_label.setText(t("Fetching model list…")) + # Whichever provider is selected for cleanup is the one whose models are + # fetched, so the list lands in the box of the provider on screen. + provider = self.cleanup_provider.currentData() or "openrouter" + if provider == "opencode": + key = (self.opencode_key.text().strip() or self.conf.opencode_key()) + base = self.conf["opencode_base_url"] + + def work(): + try: + self._models_loaded.emit( + api.openai_models(key, base, "OpenCode Go"), "", provider) + except api.ApiError as exc: + self._models_loaded.emit([], str(exc), provider) + + threading.Thread(target=work, daemon=True).start() + return key = self.openrouter_key.text().strip() or self.conf.openrouter_key() def work(): try: - self._models_loaded.emit(api.openrouter_models(key), "") + self._models_loaded.emit(api.openrouter_models(key), "", provider) except api.ApiError as exc: - self._models_loaded.emit([], str(exc)) + self._models_loaded.emit([], str(exc), provider) threading.Thread(target=work, daemon=True).start() - def _on_models_loaded(self, models, error): + def _on_models_loaded(self, models, error, provider): self.refresh_models.setEnabled(True) if error: self.models_label.setText(t("Could not fetch the list: {error}", error=error)) return - for combo in (self.cleanup_model, self.meeting_model): - current = combo.currentText() - combo.clear() - combo.addItems(models) - combo.setCurrentText(current) + if provider == "opencode": + combo = self.cleanup_opencode_model + else: + combo = self.cleanup_model + current = combo.currentText() + combo.clear() + combo.addItems(models) + combo.setCurrentText(current) + if provider == "openrouter": + # The minutes summary runs on the same key, so the meeting box is + # filled from the same list. + current = self.meeting_model.currentText() + self.meeting_model.clear() + self.meeting_model.addItems(models) + self.meeting_model.setCurrentText(current) self.models_label.setText(t("{count} models loaded.", count=len(models))) def _test_openai(self): @@ -1894,8 +1968,20 @@ class SettingsWindow(QDialog): key, _ = self._typed_key("openrouter") self._test_key("openrouter", lambda: api.openrouter_key_status(key)) + def _test_opencode(self): + key, base = self._typed_key("opencode") + self._test_key("opencode", lambda: t( + "Connection works. {count} models visible.", + count=len(api.openai_models(key, base, "OpenCode Go")), + )) + def _typed_key(self, provider): """(key, base URL) for a provider, preferring what is in the field now.""" + if provider == "opencode": + # The one key that is not in the TRANSCRIBERS table: it pays for + # cleanup and the agent rather than for speech to text. + typed = self._key_fields["opencode"].text().strip() + return typed or self.conf.opencode_key(), self.conf["opencode_base_url"] who = cfg.TRANSCRIBERS[provider] typed = self._key_fields[provider].text().strip() return typed or self.conf.api_key(who.key), self.conf[who.url] @@ -2118,6 +2204,8 @@ class SettingsWindow(QDialog): provider == "claude") self.cleanup_form.setRowVisible(self.cleanup_codex_model, provider == "codex") + self.cleanup_form.setRowVisible(self.cleanup_opencode_model, + provider == "opencode") self.cleanup_form.setRowVisible(self.cleanup_reasoning, provider != "local") self.cleanup_form.setRowVisible(self.local_llm, provider == "local") @@ -2126,6 +2214,8 @@ class SettingsWindow(QDialog): found = shutil.which(binary) if binary else "" if provider == "local": self.models_label.setText(t("Runs on this machine, on llama.cpp.")) + elif provider == "opencode": + self.models_label.setText(t("Runs on OpenCode Go.")) elif not binary: self.models_label.setText(t("Runs on OpenRouter.")) elif found: @@ -2142,6 +2232,7 @@ class SettingsWindow(QDialog): self.claude_box.setVisible(provider == "claude") self.codex_box.setVisible(provider == "codex") self.openrouter_box.setVisible(provider == "openrouter") + self.opencode_box.setVisible(provider == "opencode") self._refresh_assistant_status() def _refresh_assistant_status(self): @@ -2149,9 +2240,14 @@ class SettingsWindow(QDialog): binary = assistant.executable(provider) found = shutil.which(binary) if binary else "" if not binary: - self.assistant_found.setText( - t("Needs no program installed, only the OpenRouter key.") - ) + if provider == "opencode": + self.assistant_found.setText( + t("Needs no program installed, only an OpenCode Go key.") + ) + else: + self.assistant_found.setText( + t("Needs no program installed, only the OpenRouter key.") + ) elif found: self.assistant_found.setText(t("Found: {path}", path=found)) else: diff --git a/tests/test_ui.py b/tests/test_ui.py index 0b04be9..f76ffc1 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -244,6 +244,7 @@ class Settings(DikteTest): """An OpenRouter id and a Claude alias are not the same field.""" window = self.window(cfg.Config()) boxes = {"openrouter": window.cleanup_model_row, + "opencode": window.cleanup_opencode_model, "claude": window.cleanup_claude_model, "codex": window.cleanup_codex_model} for provider, box in boxes.items(): From e6b3fcf48f07dafd37360ebef01286e0fff74a0a Mon Sep 17 00:00:00 2001 From: sudoeren Date: Tue, 25 Aug 2026 21:05:23 +0300 Subject: [PATCH 03/37] Reach OpenCode Go from the command line test-key accepts opencode and reports the model count its /models endpoint offers, ask --provider accepts it and maps --model to its own setting, doctor reports its key for cleanup on it, and config list masks the key like the other two. --- dikte/cli.py | 23 ++++++++++++++++++----- tests/test_cli.py | 19 +++++++++++++++++++ tests/test_config.py | 9 +++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/dikte/cli.py b/dikte/cli.py index 3109348..584ab8a 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -227,7 +227,8 @@ def cmd_ask(opts): conf["assistant_provider"] = opts.provider if opts.model: key = {"claude": "assistant_model", "codex": "assistant_codex_model", - "openrouter": "assistant_openrouter_model"}[assistant.provider(conf)] + "openrouter": "assistant_openrouter_model", + "opencode": "assistant_opencode_model"}[assistant.provider(conf)] conf[key] = opts.model if opts.dir: conf["assistant_dir"] = opts.dir @@ -519,7 +520,7 @@ def cmd_history_clear(opts): # --- settings --------------------------------------------------------------- -SECRET_KEYS = ("openai_api_key", "openrouter_api_key") +SECRET_KEYS = ("openai_api_key", "openrouter_api_key", "opencode_api_key") def _mask(key, value): @@ -709,6 +710,14 @@ def cmd_test_key(opts): results[name] = {"ok": True, "message": message} except api.ApiError as exc: results[name] = {"ok": False, "message": str(exc)} + if opts.which in ("opencode", "all"): + try: + count = len(api.openai_models(conf.opencode_key(), + conf["opencode_base_url"], "OpenCode Go")) + results["opencode"] = {"ok": True, + "message": f"connection works, {count} models visible"} + except api.ApiError as exc: + results["opencode"] = {"ok": False, "message": str(exc)} everything_ok = all(item["ok"] for item in results.values()) lines = [f"{'✓' if item['ok'] else '✗'} {name}: {item['message']}" for name, item in results.items()] @@ -875,7 +884,9 @@ def cmd_doctor(opts): "key": bool(target.api_key)}, "cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner, "model": cleanup.model(conf), - "key": bool(conf.openrouter_key())}, + "key": (bool(conf.openrouter_key()) if cleaner == "openrouter" + else bool(conf.opencode_key()) + if cleaner == "opencode" else None)}, "agent": {"provider": assistant.provider(conf), "directory": assistant.working_dir(conf)}, "running": ipc.send("status") is not None, @@ -888,6 +899,8 @@ def cmd_doctor(opts): # Cleanup on a CLI needs no key, so what is checked is the program. (f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on " f"{conf['cleanup_model']}") if cleaner == "openrouter" else + (f"{'✓' if conf.opencode_key() else '✗'} OpenCode Go key, cleaning up on " + f"{conf['cleanup_opencode_model']}") if cleaner == "opencode" else (f"{'✓' if programs[cleanup.executable(cleaner)] else '✗'} " f"{cleanup.executable(cleaner)}, cleaning up on {cleanup.model(conf)}"), f"{'✓' if checks['running'] else '·'} application " @@ -977,7 +990,7 @@ def build_parser(): ask = leaf(subs, "ask", "put a command to the agent") ask.add_argument("text", nargs="*", help="the command; read from stdin, or " "recorded when there is none") - ask.add_argument("--provider", choices=("claude", "codex", "openrouter"), + ask.add_argument("--provider", choices=("claude", "codex", "openrouter", "opencode"), help="just for this run") ask.add_argument("--model", help="just for this run") ask.add_argument("--dir", help="working directory, just for this run") @@ -1100,7 +1113,7 @@ def build_parser(): models.set_defaults(func=cmd_models) test = leaf(subs, "test-key", "check the API keys") test.add_argument("which", nargs="?", default="all", - choices=("all", *cfg.TRANSCRIBERS)) + choices=("all", *cfg.TRANSCRIBERS, "opencode")) test.set_defaults(func=cmd_test_key) leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor) diff --git a/tests/test_cli.py b/tests/test_cli.py index 585bad5..7ef369f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -423,6 +423,16 @@ class Providers(DikteTest): self.assertIn("groq", out) self.assertIn("Groq", out) + def test_opencode_is_a_choice_and_reports_under_its_own_name(self): + parser = cli.build_parser() + self.assertEqual( + parser.parse_args(["test-key", "opencode"]).which, "opencode") + self.write_config({"opencode_api_key": "opencode-test"}) + with fake_urlopen({"data": [{"id": "deepseek-v4-flash"}]}): + code, out, _ = self.run_cmd(cli.cmd_test_key, which="opencode") + self.assertEqual(code, 0) + self.assertIn("opencode: connection works, 1 models visible", out) + class Updates(DikteTest): """`dikte update` looks, says what it found, and installs nothing.""" @@ -503,6 +513,15 @@ class Doctor(DikteTest): self.assertIn("OpenRouter key, cleaning up on some/model", self.run_doctor(as_json=False, cleanup_model="some/model")) + def test_cleanup_on_opencode_is_a_question_about_its_own_key(self): + reply = self.run_doctor(cleanup_provider="opencode", + cleanup_opencode_model="glm-5.3") + self.assertEqual(reply["cleanup"]["provider"], "opencode") + self.assertEqual(reply["cleanup"]["model"], "glm-5.3") + self.assertIn("OpenCode Go key, cleaning up on glm-5.3", + self.run_doctor(as_json=False, cleanup_provider="opencode", + cleanup_opencode_model="glm-5.3")) + def test_it_asks_after_the_programs_this_desktop_actually_uses(self): """A missing ydotool on a Mac is a red mark with nothing behind it.""" with mock.patch.object(cli.paste, "desktop", return_value=paste.MACOS): diff --git a/tests/test_config.py b/tests/test_config.py index 9d8e372..24f4da6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -134,6 +134,8 @@ class Keys(DikteTest): def test_every_provider_falls_back_to_the_variable_of_its_own_name(self): with mock.patch.dict(os.environ, {"GROQ_API_KEY": "gsk-env"}): self.assertEqual(cfg.Config().groq_key(), "gsk-env") + with mock.patch.dict(os.environ, {"OPENCODE_API_KEY": "opencode-env"}): + self.assertEqual(cfg.Config().opencode_key(), "opencode-env") class TranscribeTarget(DikteTest): @@ -449,6 +451,13 @@ class Defaults(unittest.TestCase): def test_the_keys_ship_empty(self): self.assertEqual(cfg.DEFAULTS["openai_api_key"], "") self.assertEqual(cfg.DEFAULTS["openrouter_api_key"], "") + self.assertEqual(cfg.DEFAULTS["opencode_api_key"], "") + + def test_opencode_ships_on_its_own_endpoint(self): + self.assertEqual(cfg.DEFAULTS["opencode_base_url"], + "https://opencode.ai/zen/go/v1") + self.assertEqual(cfg.DEFAULTS["cleanup_opencode_model"], "deepseek-v4-flash") + self.assertEqual(cfg.DEFAULTS["assistant_opencode_model"], "deepseek-v4-flash") def test_every_language_specific_prompt_has_both_languages(self): for name in ("CLEANUP_PROMPT", "FILE_CLEANUP_PROMPT", "MEETING_PROMPT", From 52880bd81a42885120c23f3580207b96a848c470 Mon Sep 17 00:00:00 2001 From: sudoeren Date: Tue, 25 Aug 2026 21:05:25 +0300 Subject: [PATCH 04/37] Document the OpenCode Go provider --- README.md | 16 +++++++++------- README.tr.md | 18 ++++++++++-------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index fe36166..b2c2da0 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,10 @@ or runs it on the spot. Speech to text and cleanup each pick a provider in the settings window, and both run here by default, on models of your own. The cloud is the other option: speech to text on **OpenAI**, **Groq** or **OpenRouter** (`gpt-4o-transcribe`), -cleanup on OpenRouter (`google/gemini-3.5-flash-lite`) or, when either is -installed, on Claude Code or Codex. The keys fall back to `OPENAI_API_KEY`, -`GROQ_API_KEY` and `OPENROUTER_API_KEY`, and are stored in +cleanup on OpenRouter (`google/gemini-3.5-flash-lite`), on **OpenCode Go** +(`deepseek-v4-flash`) or, when either is installed, on Claude Code or Codex. +The keys fall back to `OPENAI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY` +and `OPENCODE_API_KEY`, and are stored in `~/.config/dikte/config.json`, mode 600, or in `~/Library/Application Support/Dikte` on a Mac. Cleanup can be switched off, in which case the raw transcript is pasted, and a thinking model's effort can be @@ -189,8 +190,9 @@ running. session you would have opened yourself, so your skills and connected services are there, which is what makes "put that in my calendar on Thursday at three" a thing you can say to a window that is not Claude. Codex (`codex exec`) runs - the same way, and OpenRouter is there as a plain question-and-answer fallback - for a machine with neither CLI on it. Provider, model, permissions and working + the same way, and OpenRouter or OpenCode Go is there as a plain + question-and-answer fallback for a machine with neither CLI on it. Provider, + model, permissions and working directory are under Settings → Agent, and commands close together stay in one conversation. - **Meetings** are recorded from the microphone and the speaker output at the @@ -239,9 +241,9 @@ cli.py the command line: every verb, and what it answers with 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 +assistant.py running a dictation through Claude Code, Codex, OpenRouter or OpenCode Go api.py transcription and cleanup requests (stdlib only) -cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex +cleanup.py who rewrites the transcript: OpenRouter, OpenCode Go, here, Claude or Codex ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving hub.py what GitHub and Hugging Face have on offer today update.py whether a newer release is out, and the page it is on diff --git a/README.tr.md b/README.tr.md index 5e88c69..9794a02 100644 --- a/README.tr.md +++ b/README.tr.md @@ -112,10 +112,12 @@ Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıc seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. Bulutu seçersen sesi yazıya çevirme **OpenAI**, **Groq** ya da **OpenRouter**'da (varsayılan `gpt-4o-transcribe`), temizleme OpenRouter'da -(`google/gemini-3.5-flash-lite`) ya da kuruluysa Claude Code veya Codex'te -çalışır. Anahtarları boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY` ve -`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` -içinde, izinler 600, Mac'te ise `~/Library/Application Support/Dikte` altında. +(`google/gemini-3.5-flash-lite`), **OpenCode Go**'da +(`deepseek-v4-flash`) ya da kuruluysa Claude Code veya Codex'te çalışır. +Anahtarları boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY`, +`OPENROUTER_API_KEY` ve `OPENCODE_API_KEY` kullanılır; anahtarlar +`~/.config/dikte/config.json` içinde, izinler 600, Mac'te ise +`~/Library/Application Support/Dikte` altında. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin. @@ -184,8 +186,8 @@ olmasını ister. oturumun aynısıdır, yani skill'lerin ve bağlı servislerin oradadır; "bunu perşembe üçe takvime koy" cümlesini Claude olmayan bir pencerede söyleyebilir olmanı sağlayan da budur. Codex (`codex exec`) da aynı şekilde çalışır; - OpenRouter ise ikisi de kurulu olmayan bir makinede düz soru cevap için - duruyor. Sağlayıcı, model, izinler ve çalışma dizini Ayarlar → Ajan + OpenRouter ya da OpenCode Go ise ikisi de kurulu olmayan bir makinede düz soru + cevap için duruyor. Sağlayıcı, model, izinler ve çalışma dizini Ayarlar → Ajan sekmesinde; arka arkaya verilen komutlar tek bir konuşmada kalır. - **Toplantılar** mikrofonla hoparlör çıkışından aynı anda kaydedilir; kimin ne dediği tahmin edilmez, sesin hangi kanaldan geldiğiyle belli olur. İki taraf @@ -232,9 +234,9 @@ cli.py komut satırı: bütün fiiller ve verdikleri cevap 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 +assistant.py dikteyi Claude Code, Codex, OpenRouter ya da OpenCode Go'dan geçirme api.py transkript ve temizleme istekleri (yalnız stdlib) -cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex +cleanup.py transkripti kim temizler: OpenRouter, OpenCode Go, burası, Claude ya da Codex 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 update.py yeni sürüm çıkmış mı, çıkmışsa hangi sayfada From 65055eeb56652e412b60bc07ce4abd8b66bfbaca Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 27 Aug 2026 15:53:44 +0300 Subject: [PATCH 05/37] Give OpenCode Go a reachable Fetch model list button The one button lived in the OpenRouter model row, which leaves the screen whenever another provider is chosen, so the OpenCode fetch path could never be clicked. The OpenCode row now carries its own button into the same handler, the row as a whole is what hides, and the fetched list lands in the box it belongs to without touching the meeting box. --- dikte/settings_ui.py | 13 +++++++++++-- tests/test_ui.py | 26 +++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 87fbdfc..b81cdde 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -906,7 +906,14 @@ class SettingsWindow(QDialog): self.cleanup_opencode_model = QComboBox() self.cleanup_opencode_model.setEditable(True) self.cleanup_opencode_model.addItems(OPENCODE_MODELS) - orr_form.addRow(t("Model"), self.cleanup_opencode_model) + self.cleanup_opencode_model.setToolTip(_typed_model_note("OpenCode Go")) + # Its own button, because a widget lives in one row and the OpenRouter + # button is hidden along with its box whenever OpenCode Go is chosen. + self.refresh_opencode_models = QPushButton(t("Fetch model list")) + self.refresh_opencode_models.clicked.connect(self._load_models) + self.cleanup_opencode_model_row = self._row(self.cleanup_opencode_model, + self.refresh_opencode_models) + orr_form.addRow(t("Model"), self.cleanup_opencode_model_row) self.cleanup_reasoning = QComboBox() for label, value in REASONING_LEVELS: @@ -1996,6 +2003,7 @@ class SettingsWindow(QDialog): def _load_models(self): self.refresh_models.setEnabled(False) + self.refresh_opencode_models.setEnabled(False) self.models_label.setText(t("Fetching model list…")) # Whichever provider is selected for cleanup is the one whose models are # fetched, so the list lands in the box of the provider on screen. @@ -2025,6 +2033,7 @@ class SettingsWindow(QDialog): def _on_models_loaded(self, models, error, provider): self.refresh_models.setEnabled(True) + self.refresh_opencode_models.setEnabled(True) if error: self.models_label.setText(t("Could not fetch the list: {error}", error=error)) return @@ -2334,7 +2343,7 @@ class SettingsWindow(QDialog): provider == "claude") self.cleanup_form.setRowVisible(self.cleanup_codex_model, provider == "codex") - self.cleanup_form.setRowVisible(self.cleanup_opencode_model, + self.cleanup_form.setRowVisible(self.cleanup_opencode_model_row, provider == "opencode") self.cleanup_form.setRowVisible(self.cleanup_reasoning, provider != "local") diff --git a/tests/test_ui.py b/tests/test_ui.py index a02d533..0ed35ab 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -261,7 +261,7 @@ class Settings(DikteTest): """An OpenRouter id and a Claude alias are not the same field.""" window = self.window(cfg.Config()) boxes = {"openrouter": window.cleanup_model_row, - "opencode": window.cleanup_opencode_model, + "opencode": window.cleanup_opencode_model_row, "claude": window.cleanup_claude_model, "codex": window.cleanup_codex_model} for provider, box in boxes.items(): @@ -285,6 +285,30 @@ class Settings(DikteTest): self.assertEqual(window.cleanup_codex_model.currentText(), "my-own-model") + def test_a_fetched_opencode_list_lands_in_opencode_s_own_box(self): + """The OpenRouter and meeting boxes are not refilled by another + provider's catalog, and the picked model survives the refill.""" + conf = self.config(cleanup_opencode_model="my-own-model", + meeting_model="some/meeting-model") + window = self.window(conf) + before = [window.meeting_model.itemText(i) + for i in range(window.meeting_model.count())] + window._on_models_loaded(["glm-5.3", "kimi-k3"], "", "opencode") + combo = window.cleanup_opencode_model + offered = [combo.itemText(i) for i in range(combo.count())] + self.assertEqual(offered, ["glm-5.3", "kimi-k3"]) + self.assertEqual(combo.currentText(), "my-own-model") + self.assertEqual([window.meeting_model.itemText(i) + for i in range(window.meeting_model.count())], before) + + def test_opencode_cleanup_still_offers_the_fetch_button(self): + """The OpenRouter button leaves the screen with its box, so OpenCode Go + carries its own.""" + window = self.window(cfg.Config()) + window._select_data(window.cleanup_provider, "opencode") + self.assertFalse(window.cleanup_opencode_model_row.isHidden()) + self.assertTrue(window.cleanup_model_row.isHidden()) + def test_the_update_line_names_the_version_that_is_running(self): window = self.window(cfg.Config()) self.assertIn(settings_ui.__version__, window.update_status.text()) From 0ecd4d251eb785c9c9c3171a7e503173a595cca3 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 27 Aug 2026 15:54:54 +0300 Subject: [PATCH 06/37] Translate the cleanup provider tooltip again The tooltip gained OpenCode Go but its translation was added without the llama.cpp sentence the tooltip actually carries, so a Turkish window showed the English text. The full entry now matches the tooltip word for word, and the two shorter variants nothing looks up any more are gone. --- dikte/i18n.py | 36 ++++++++++-------------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/dikte/i18n.py b/dikte/i18n.py index 37057df..9cc54c0 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -232,22 +232,6 @@ TR = { "Connection works. {count} models visible.": "Bağlantı tamam. {count} model görünüyor.", "Clean the transcript with a model": "Transkripti bir modelle temizle", - "OpenRouter is the quickest and the only one that needs nothing installed. " - "Claude Code and Codex clean up on the subscription you already have, " - "without a second key, and take a few seconds longer because each one opens " - "a session to do it.": - "En hızlısı OpenRouter'dır ve kurulu bir program istemeyen tek seçenektir. " - "Claude Code ile Codex, temizliği hâlihazırda ödediğin abonelik üzerinden " - "yapar, ikinci bir anahtar istemez; her biri bunun için bir oturum açtığından " - "birkaç saniye daha uzun sürer.", - "OpenRouter and OpenCode Go are the quickest and need nothing installed. " - "Claude Code and Codex clean up on the subscription you already have, " - "without a second key, and take a few seconds longer because each one opens " - "a session to do it.": - "En hızlıları OpenRouter ve OpenCode Go'dur; kurulu bir program " - "istemezler. Claude Code ile Codex, temizliği hâlihazırda ödediğin " - "abonelik üzerinden yapar, ikinci bir anahtar istemez; her biri bunun " - "için bir oturum açtığından birkaç saniye daha uzun sürer.", "{binary} is not on your PATH, so cleanup would fail and the raw transcript " "would be pasted. Install it, or pick another one above.": "{binary} PATH'te değil; temizleme başarısız olur ve ham transkript " @@ -826,16 +810,16 @@ TR = { "Düşünmeye eğitilmiş bir model, aksi söylenmedikçe düşünür; bir virgül " "için 300 token akıl yürütmek 300 token'lık bekleyiştir. Temizleme için " "doğrusu Kapalı.", - "OpenRouter is the quickest and the only one that needs nothing " - "installed. llama.cpp runs here, on a model downloaded below. Claude Code " - "and Codex clean up on the subscription you already have, without a " - "second key, and take a few seconds longer because each one opens a " - "session to do it.": - "OpenRouter en hızlısıdır ve kurulum istemeyen tek seçenektir. " - "llama.cpp burada, aşağıda indirilen bir modelle çalışır. Claude Code " - "ve Codex, ikinci bir anahtar olmadan zaten sahip olduğun abonelikle " - "temizler; her biri bunun için bir oturum açtığından birkaç saniye " - "daha sürer.", + "OpenRouter and OpenCode Go are the quickest and need nothing " + "installed. llama.cpp runs here, on a model downloaded below. Claude " + "Code and Codex clean up on the subscription you already have, " + "without a second key, and take a few seconds longer because each " + "one opens a session to do it.": + "En hızlıları OpenRouter ve OpenCode Go'dur; kurulu bir program " + "istemezler. llama.cpp burada, aşağıda indirilen bir modelle çalışır. " + "Claude Code ve Codex, ikinci bir anahtar olmadan zaten sahip olduğun " + "abonelikle temizler; her biri bunun için bir oturum açtığından birkaç " + "saniye daha sürer.", "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.": From 664a6f53a44d58db08fd82098884d2cbe120894f Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 27 Aug 2026 15:55:32 +0300 Subject: [PATCH 07/37] Offer only models the OpenCode Go catalog actually lists ox-alpha-free is not in the /models answer, and the comment claimed the chat endpoint hides Grok, Luna, MiniMax and Qwen when its own catalog lists them. The built-in list is a starting set; the Fetch button asks the endpoint for the full catalog of the day. --- dikte/settings_ui.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index b81cdde..94eec2d 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -94,13 +94,12 @@ ASSISTANT_OR_MODELS = [ "google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4", "x-ai/grok-4.5", "google/gemini-3.1-pro-preview", ] -# The models OpenCode Go serves over /chat/completions. The ones its catalog -# lists under /responses or /messages (Grok, GPT-5.6 Luna, MiniMax, Qwen) are -# not offered here, because Dikte speaks only the chat endpoint. +# A starting set of the models OpenCode Go serves over /chat/completions; the +# Fetch button asks the endpoint itself for the full catalog of the day. OPENCODE_MODELS = [ "deepseek-v4-flash", "deepseek-v4-pro", "glm-5.3", "glm-5.2", "glm-5.1", "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "longcat-2.0", - "mimo-v2.5", "mimo-v2.5-pro", "hy3", "ox-alpha-free", + "mimo-v2.5", "mimo-v2.5-pro", "hy3", ] # What Claude Code may do without being able to ask. It cannot ask: there is no # window to answer in, so a mode that would have prompted denies instead. From 1ff18e46ab34e1fed28c7002dbea8e37519954bd Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 27 Aug 2026 16:04:09 +0300 Subject: [PATCH 08/37] Call OpenRouter the quickest again Nothing was measured that put OpenCode Go beside it, so the tooltip claims only what is known: OpenRouter is the quickest, and OpenCode Go merely needs nothing installed either. The translation follows word for word. --- dikte/i18n.py | 13 ++++++------- dikte/settings_ui.py | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/dikte/i18n.py b/dikte/i18n.py index 9cc54c0..b290d33 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -810,16 +810,15 @@ TR = { "Düşünmeye eğitilmiş bir model, aksi söylenmedikçe düşünür; bir virgül " "için 300 token akıl yürütmek 300 token'lık bekleyiştir. Temizleme için " "doğrusu Kapalı.", - "OpenRouter and OpenCode Go are the quickest and need nothing " - "installed. llama.cpp runs here, on a model downloaded below. Claude " + "OpenRouter is the quickest; OpenCode Go needs nothing installed " + "either. llama.cpp runs here, on a model downloaded below. Claude " "Code and Codex clean up on the subscription you already have, " "without a second key, and take a few seconds longer because each " "one opens a session to do it.": - "En hızlıları OpenRouter ve OpenCode Go'dur; kurulu bir program " - "istemezler. llama.cpp burada, aşağıda indirilen bir modelle çalışır. " - "Claude Code ve Codex, ikinci bir anahtar olmadan zaten sahip olduğun " - "abonelikle temizler; her biri bunun için bir oturum açtığından birkaç " - "saniye daha sürer.", + "En hızlısı OpenRouter'dır; OpenCode Go da kurulum istemez. llama.cpp " + "burada, aşağıda indirilen bir modelle çalışır. Claude Code ve Codex, " + "ikinci bir anahtar olmadan zaten sahip olduğun abonelikle temizler; " + "her biri bunun için bir oturum açtığından birkaç saniye daha sürer.", "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.": diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 94eec2d..72b2ada 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -870,8 +870,8 @@ class SettingsWindow(QDialog): for label, value in CLEANUP_PROVIDERS: self.cleanup_provider.addItem(t(label), value) self.cleanup_provider.setToolTip(t( - "OpenRouter and OpenCode Go are the quickest and need nothing " - "installed. llama.cpp runs here, on a model downloaded below. Claude " + "OpenRouter is the quickest; OpenCode Go needs nothing installed " + "either. llama.cpp runs here, on a model downloaded below. Claude " "Code and Codex clean up on the subscription you already have, " "without a second key, and take a few seconds longer because each " "one opens a session to do it." From fa3d72f5a75e65f3dbd2a683f6ae7f90b8907b64 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 27 Aug 2026 16:04:30 +0300 Subject: [PATCH 09/37] Fetch OpenCode Go's catalog as the settings window opens Codex already refreshes its boxes from the source at open, so the built-in list is never the whole truth for longer than a window takes to build. OpenCode Go now gets the same courtesy: one request to /models on a background thread, both of its boxes refilled with the picked model kept, skipped entirely when there is no key to send. --- dikte/settings_ui.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_ui.py | 22 +++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 72b2ada..2c891c2 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -569,6 +569,7 @@ class SettingsWindow(QDialog): _models_loaded = pyqtSignal(list, str, str) _transcribe_models_loaded = pyqtSignal(list, str) _codex_models_loaded = pyqtSignal(list) + _opencode_models_loaded = pyqtSignal(list) # Which key was tested, whether it worked, and what to write under it. _test_done = pyqtSignal(str, bool, str) # The release that was found, or None, and what went wrong instead. @@ -628,6 +629,7 @@ class SettingsWindow(QDialog): self._models_loaded.connect(self._on_models_loaded) self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) self._codex_models_loaded.connect(self._on_codex_models_loaded) + self._opencode_models_loaded.connect(self._on_opencode_models_loaded) self._test_done.connect(self._on_test_done) self._update_checked.connect(self._on_update_checked) self.transcriber.progress.connect(self._on_file_progress) @@ -639,6 +641,7 @@ class SettingsWindow(QDialog): self.meetings.failed.connect(self._on_minutes_failed) self._load() self._load_codex_models() + self._load_opencode_models() # Connected after the load, so that filling the boxes in is not taken # for the user ticking them. self.file_timestamps.toggled.connect(self._remember_file_choices) @@ -2080,6 +2083,37 @@ class SettingsWindow(QDialog): combo.addItem(name, name) combo.setCurrentText(current) + def _load_opencode_models(self): + """Ask OpenCode Go for its catalog of the day, off the interface thread. + + The same courtesy Codex gets: the built-in list is only a starting + point, so the boxes are refreshed from the source as the window opens. + Skipped without a key, so a machine that never touched OpenCode Go + sends it nothing; the Fetch button stays for a key typed in just now. + """ + key = self.conf.opencode_key() + if not key: + return + + def work(): + try: + found = api.openai_models(key, self.conf["opencode_base_url"], + "OpenCode Go") + except api.ApiError: + # The window is only opening; the Test button says what failed. + return + if found: + self._opencode_models_loaded.emit(found) + + threading.Thread(target=work, daemon=True).start() + + def _on_opencode_models_loaded(self, models): + for combo in (self.cleanup_opencode_model, self.assistant_opencode_model): + current = combo.currentText() + combo.clear() + combo.addItems(models) + combo.setCurrentText(current) + def _test_openai(self): key, base = self._typed_key("openai") self._test_key("openai", lambda: t( diff --git a/tests/test_ui.py b/tests/test_ui.py index 0ed35ab..4f59318 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -135,6 +135,8 @@ class Settings(DikteTest): "_load_transcribe_models")) self.enterContext(mock.patch.object(settings_ui.SettingsWindow, "_load_codex_models")) + self.enterContext(mock.patch.object(settings_ui.SettingsWindow, + "_load_opencode_models")) # The local model boxes fetch their own list the moment they are shown, # from a thread, which is nobody's test failing but a real request. self.enterContext(mock.patch.object(settings_ui.LocalModelBox, @@ -285,6 +287,20 @@ class Settings(DikteTest): self.assertEqual(window.cleanup_codex_model.currentText(), "my-own-model") + def test_opencode_answering_refills_both_of_its_boxes(self): + """The catalog fetched at open replaces the built-in list in the + cleanup and agent boxes alike, and neither loses what was picked.""" + conf = self.config(cleanup_opencode_model="my-own-model") + window = self.window(conf) + window._on_opencode_models_loaded(["glm-9", "kimi-k9"]) + for combo in (window.cleanup_opencode_model, + window.assistant_opencode_model): + with self.subTest(combo=combo.objectName() or "combo"): + offered = [combo.itemText(i) for i in range(combo.count())] + self.assertEqual(offered, ["glm-9", "kimi-k9"]) + self.assertEqual(window.cleanup_opencode_model.currentText(), + "my-own-model") + def test_a_fetched_opencode_list_lands_in_opencode_s_own_box(self): """The OpenRouter and meeting boxes are not refilled by another provider's catalog, and the picked model survives the refill.""" @@ -1026,7 +1042,9 @@ class MeetingSources(DikteTest): mock.patch.object(settings_ui.SettingsWindow, "_load_transcribe_models"), \ mock.patch.object(settings_ui.SettingsWindow, - "_load_codex_models"): + "_load_codex_models"), \ + mock.patch.object(settings_ui.SettingsWindow, + "_load_opencode_models"): window = settings_ui.SettingsWindow(cfg.Config()) self.addCleanup(window.deleteLater) self.addCleanup(window.close) @@ -1058,6 +1076,8 @@ class LocalModels(DikteTest): # And one with Codex on it would ask it for its model list. self.enterContext(mock.patch.object(settings_ui.SettingsWindow, "_load_codex_models")) + self.enterContext(mock.patch.object(settings_ui.SettingsWindow, + "_load_opencode_models")) def window(self, conf): window = settings_ui.SettingsWindow(conf) From c503d12e620293ba655c1d107775ac65786ee2e0 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 27 Aug 2026 16:25:59 +0300 Subject: [PATCH 10/37] Leave both halves of libxkbcommon to the system Qt's xcb plugin links libxkbcommon and libxkbcommon-x11, and the x11 half hands keymap objects to the core half to free, so the pair must come from one build. The build machine has only the core half installed, so PyInstaller bundled that one alone and the other kept loading from the user's system; an Ubuntu 22.04 core freeing what a current x11 half allocated is the segfault of issue #57, which a Turkish layout happened to move to startup. Every desktop that can show a window carries both libraries from one build, so the bundle now carries neither. Fixes #57 --- packaging/dikte.spec | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packaging/dikte.spec b/packaging/dikte.spec index 6cfd651..f977777 100644 --- a/packaging/dikte.spec +++ b/packaging/dikte.spec @@ -56,6 +56,18 @@ analysis = Analysis( # noqa: F821 noarchive=False, ) +# Qt's xcb platform plugin uses libxkbcommon in two halves: the core library +# and libxkbcommon-x11, which allocates keymap objects and hands them to the +# core half to use and free, so the two must come from the same build. The +# build machine has only the core half installed, which had PyInstaller +# bundling that one while the other kept coming from the user's system, and a +# 22.04-era core freeing what a current x11 half allocated is the startup +# crash of issue #57. Ship neither: any desktop that can show a window +# carries both, from one build. +if not (MACOS or WINDOWS): + analysis.binaries = [entry for entry in analysis.binaries + if "libxkbcommon" not in entry[0]] + archive = PYZ(analysis.pure) # noqa: F821 executable = EXE( # noqa: F821 From c1554c092edd1b59643de768fb269dd2aaf2e476 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 27 Aug 2026 16:35:01 +0300 Subject: [PATCH 11/37] Drop three explanatory texts from the settings window The agent and meeting tabs opened with an intro paragraph, and the cleanup provider box showed a long tooltip comparing the providers. None of them earned the space: the intro texts were unclear and the tooltip restated what picking a provider already shows. The orphaned Turkish translations go with them. --- dikte/i18n.py | 28 ---------------------------- dikte/settings_ui.py | 26 -------------------------- 2 files changed, 54 deletions(-) diff --git a/dikte/i18n.py b/dikte/i18n.py index c1ec602..23ff6ff 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -244,16 +244,6 @@ TR = { "Connection works. {count} models visible.": "Bağlantı tamam. {count} model görünüyor.", "Clean the transcript with a model": "Transkripti bir modelle temizle", - "OpenRouter, Google AI Studio and OpenCode Go are the quick ones that need " - "nothing installed. llama.cpp runs here, on a model downloaded below. " - "Claude Code, Codex and Antigravity clean up on a subscription you already " - "have, without a second key, and take a few seconds longer because each " - "opens a session to do it.": - "OpenRouter, Google AI Studio ve OpenCode Go kurulum istemeyen hızlı " - "seçeneklerdir. llama.cpp burada, aşağıdan indirilen bir modelle " - "çalışır. Claude Code, Codex ve Antigravity temizliği hâlihazırda sahip " - "olduğun bir abonelik üzerinden, ikinci anahtar olmadan yapar; her biri " - "bunun için bir oturum açtığından birkaç saniye daha uzun sürer.", "{binary} is not on your PATH, so cleanup would fail and the raw transcript " "would be pasted. Install it, or pick another one above.": "{binary} PATH'te değil; temizleme başarısız olur ve ham transkript " @@ -523,18 +513,6 @@ TR = { # --- settings: the agent ------------------------------------------------ "Agent": "Ajan", - "This shortcut records the same way dictation does, but the transcript is " - "not what gets pasted. It goes to an agent as a command, and what comes " - "back is pasted instead: the answer to a question, or a sentence saying " - "what was done. Claude Code, Codex and Antigravity run as the session you " - "would have opened yourself, with your skills, your connected services and " - "your account.": - "Bu kısayol dikte ile aynı şekilde kaydeder, ama yapıştırılan şey " - "transkript değildir. Transkript bir ajana komut olarak gider ve yerine " - "oradan döneni yapıştırılır: bir sorunun cevabı ya da ne yapıldığını " - "söyleyen bir cümle. Claude Code, Codex ve Antigravity kendi açacağın " - "oturumun aynısı olarak çalışır: skill'lerinle, bağlı servislerinle " - "ve kendi hesabınla.", "How it runs": "Nasıl çalışıyor", "Runs on": "Şunun üstünde çalışır", "More thinking is slower, and you are standing in front of the screen while " @@ -707,12 +685,6 @@ TR = { # --- settings: meeting -------------------------------------------------- "Minutes": "Tutanaklar", - "A meeting is recorded from two devices at once: your microphone and " - "whatever comes out of your speakers. Nothing has to guess who was " - "speaking, because the two never share a channel.": - "Toplantı iki aygıttan aynı anda kaydedilir: mikrofonun ve hoparlöründen " - "çıkan ses. Kimin konuştuğunun tahmin edilmesi gerekmez, çünkü ikisi hiç " - "aynı kanala girmez.", "Sound": "Ses", "Same as dictation": "Diktedekiyle aynı", "Current output": "Geçerli çıkış", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index dfbc1b2..56771f8 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -915,13 +915,6 @@ class SettingsWindow(QDialog): self.cleanup_provider = QComboBox() for label, value in CLEANUP_PROVIDERS: self.cleanup_provider.addItem(t(label), value) - self.cleanup_provider.setToolTip(t( - "OpenRouter, Google AI Studio and OpenCode Go are the quick ones " - "that need nothing installed. llama.cpp runs here, on a model " - "downloaded below. Claude Code, Codex and Antigravity clean up on " - "a subscription you already have, without a second key, and take a " - "few seconds longer because each opens a session to do it." - )) self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed) orr_form.addRow(t("Runs on"), self.cleanup_provider) @@ -1051,17 +1044,6 @@ class SettingsWindow(QDialog): def _assistant_tab(self): page = QWidget() layout = QVBoxLayout(page) - intro = QLabel(t( - "This shortcut records the same way dictation does, but the " - "transcript is not what gets pasted. It goes to an agent as a " - "command, and what comes back is pasted instead: the answer to a " - "question, or a sentence saying what was done. Claude Code, Codex " - "and Antigravity run as the session you would have opened yourself, " - "with your skills, your connected services and your account." - )) - intro.setWordWrap(True) - layout.addWidget(intro) - self.assistant_found = QLabel("") self.assistant_found.setWordWrap(True) layout.addWidget(self.assistant_found) @@ -1259,14 +1241,6 @@ class SettingsWindow(QDialog): def _meeting_tab(self): page = QWidget() layout = QVBoxLayout(page) - intro = QLabel(t( - "A meeting is recorded from two devices at once: your microphone and " - "whatever comes out of your speakers. Nothing has to guess who was " - "speaking, because the two never share a channel." - )) - intro.setWordWrap(True) - layout.addWidget(intro) - sources = QGroupBox(t("Sound")) sources_form = QFormLayout(sources) self.meeting_mic = QComboBox() From 310ef8d7cf112fc28905470dacb69c9dd3c46a76 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 27 Aug 2026 16:38:59 +0300 Subject: [PATCH 12/37] Dikte 1.1.0 --- dikte/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dikte/__init__.py b/dikte/__init__.py index c95ff63..9cd7362 100644 --- a/dikte/__init__.py +++ b/dikte/__init__.py @@ -10,4 +10,4 @@ business loading Qt to answer one question. # both the .dmg's Info.plist and the AppImage's file name are built from it. A # build off master rather than off a tag appends the commit to it, so that a # bug report from someone running "latest" names a commit. -__version__ = "1.0.2" +__version__ = "1.1.0" From 6e307bd8d0f223c920414c375e0b6ba0f15ad38c Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 2 Sep 2026 12:41:43 +0300 Subject: [PATCH 13/37] 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. --- dikte/api.py | 32 ++++++++++++++++++++++---------- dikte/config.py | 6 +++++- dikte/i18n.py | 5 +++++ dikte/settings_ui.py | 22 ++++++++++++++++++++++ tests/test_api.py | 17 +++++++++++++++++ tests/test_config.py | 13 +++++++++++++ tests/test_ui.py | 14 ++++++++++++++ 7 files changed, 98 insertions(+), 11 deletions(-) diff --git a/dikte/api.py b/dikte/api.py index 61e0857..f74e282 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. `subtitle_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 subtitle_model", + defaults=[""]) + +# What answers with segment times on OpenRouter when nothing else was chosen. +OPENROUTER_SUBTITLE_MODEL = "openai/whisper-1" -def timestamp_model(provider, selected=""): +def timestamp_model(provider, selected="", subtitle=""): """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 + (`subtitle`) 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 subtitle or OPENROUTER_SUBTITLE_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.subtitle_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..6f96f37 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_subtitle_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] + subtitle = self["openrouter_subtitle_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], subtitle.strip()) def transcribe_ready(self): """Whether speech to text could run right now, without opening Settings.""" diff --git a/dikte/i18n.py b/dikte/i18n.py index 23ff6ff..ecfa0e3 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -228,6 +228,11 @@ TR = { "Transcript cleanup": "Transkripti temizleme", "API key": "API anahtarı", "Model": "Model", + "Subtitle model": "Altyazı modeli", + "The model a timestamped run (subtitles) asks for. Not every model on " + "OpenRouter returns segment times; empty means openai/whisper-1.": + "Zaman damgalı bir çeviride (altyazı) istenen 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)", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 56771f8..4cafda1 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -864,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.subtitle_model = QComboBox() + self.subtitle_model.setEditable(True) + self.subtitle_model.lineEdit().setPlaceholderText(api.OPENROUTER_SUBTITLE_MODEL) + self.subtitle_model.setToolTip( + t("The model a timestamped run (subtitles) asks for. Not every model " + "on OpenRouter returns segment times; empty means openai/whisper-1.")) + self.subtitle_model_row = self._row(self.subtitle_model) + stt_form.addRow(t("Subtitle model"), self.subtitle_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("") @@ -1747,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.subtitle_model.setCurrentText(conf["openrouter_subtitle_model"]) self.local_gpu.setChecked(conf["local_gpu"]) self.local_preload.setChecked(conf["local_preload"]) self.local_threads.setValue(int(conf["local_threads"])) @@ -1864,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_subtitle_model"] = self.subtitle_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() @@ -2037,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.subtitle_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) @@ -2045,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_subtitle_models(TRANSCRIBE_MODELS[provider]) self.transcribe_status.setText("") + def _fill_subtitle_models(self, models): + current = self.subtitle_model.currentText() + self.subtitle_model.clear() + self.subtitle_model.addItems(models) + self.subtitle_model.setCurrentText(current) + def _load_transcribe_models(self): """The model list of whichever provider is selected.""" provider = self.transcribe_provider.currentData() or "openai" @@ -2075,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_subtitle_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..bbc5033 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_subtitle_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_subtitle_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_subtitle_model_when_one_is_set(self): + target = OPENROUTER._replace(subtitle_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_config.py b/tests/test_config.py index 2c6535f..03bca9c 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.subtitle_model, "") + + def test_openrouter_carries_its_subtitle_model(self): + conf = self.config(transcribe_provider="openrouter", + openrouter_api_key="sk-or-test", + openrouter_subtitle_model=" openai/whisper-large-v3 ") + self.assertEqual(conf.transcribe_target().subtitle_model, + "openai/whisper-large-v3") + + def test_only_openrouter_has_a_subtitle_model(self): + conf = self.config(transcribe_provider="openai", openai_api_key="sk-test", + openrouter_subtitle_model="openai/whisper-large-v3") + self.assertEqual(conf.transcribe_target().subtitle_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_ui.py b/tests/test_ui.py index 6273447..12751e8 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -504,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_subtitle_model_is_saved_and_only_shown_for_openrouter(self): + self.write_config({"transcribe_provider": "openrouter", + "openrouter_subtitle_model": "openai/whisper-large-v3"}) + conf = cfg.Config() + window = self.window(conf) + self.assertEqual(window.subtitle_model.currentText(), "openai/whisper-large-v3") + self.assertTrue(window.stt_form.isRowVisible(window.subtitle_model_row)) + window.subtitle_model.setCurrentText(" deepgram/nova-3 ") + window._save() + self.assertEqual(conf["openrouter_subtitle_model"], "deepgram/nova-3") + window.transcribe_provider.setCurrentIndex( + window.transcribe_provider.findData("openai")) + self.assertFalse(window.stt_form.isRowVisible(window.subtitle_model_row)) + def test_the_provider_box_offers_every_provider_config_knows(self): window = self.window(cfg.Config()) offered = [window.transcribe_provider.itemData(i) From eda1398a2b9269d2bcddf174340910fbb1d64f8d Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 2 Sep 2026 12:43:07 +0300 Subject: [PATCH 14/37] 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. --- dikte/api.py | 14 +++++++------- dikte/config.py | 6 +++--- dikte/i18n.py | 6 +++--- dikte/settings_ui.py | 34 +++++++++++++++++----------------- tests/test_api.py | 8 ++++---- tests/test_config.py | 14 +++++++------- tests/test_ui.py | 14 +++++++------- 7 files changed, 48 insertions(+), 48 deletions(-) diff --git a/dikte/api.py b/dikte/api.py index f74e282..13c4381 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -48,17 +48,17 @@ 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. `subtitle_model` is what a timestamped run asks for instead of +# 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 subtitle_model", + "Target", "provider service api_key base_url model file_model", defaults=[""]) # What answers with segment times on OpenRouter when nothing else was chosen. -OPENROUTER_SUBTITLE_MODEL = "openai/whisper-1" +OPENROUTER_FILE_MODEL = "openai/whisper-1" -def timestamp_model(provider, selected="", subtitle=""): +def timestamp_model(provider, selected="", file_model=""): """Which model answers with segment times. OpenAI keeps them to whisper-1. Everything Groq transcribes with is a @@ -68,12 +68,12 @@ def timestamp_model(provider, selected="", subtitle=""): 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 - (`subtitle`) and whisper-1 is only where that setting is left empty. + (`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" if provider == "openrouter": - return subtitle or OPENROUTER_SUBTITLE_MODEL + return file_model or OPENROUTER_FILE_MODEL return "whisper-1" @@ -456,7 +456,7 @@ def transcribe_segments(target, audio_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.model, - target.subtitle_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 6f96f37..567d5b7 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -399,7 +399,7 @@ DEFAULTS = { "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_subtitle_model": "", + "openrouter_file_model": "", "language": "tr", "transcribe_prompt": "", @@ -672,9 +672,9 @@ class Config: # to land on rather than reading it from there. name = "openai" who = TRANSCRIBERS[name] - subtitle = self["openrouter_subtitle_model"] if name == "openrouter" else "" + 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], subtitle.strip()) + 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/i18n.py b/dikte/i18n.py index ecfa0e3..4ff7aea 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -228,10 +228,10 @@ TR = { "Transcript cleanup": "Transkripti temizleme", "API key": "API anahtarı", "Model": "Model", - "Subtitle model": "Altyazı modeli", - "The model a timestamped run (subtitles) asks for. Not every model on " + "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 çeviride (altyazı) istenen model. OpenRouter'daki her " + "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)", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 4cafda1..f2d4f22 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -865,14 +865,14 @@ class SettingsWindow(QDialog): 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.subtitle_model = QComboBox() - self.subtitle_model.setEditable(True) - self.subtitle_model.lineEdit().setPlaceholderText(api.OPENROUTER_SUBTITLE_MODEL) - self.subtitle_model.setToolTip( - t("The model a timestamped run (subtitles) asks for. Not every model " + 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.subtitle_model_row = self._row(self.subtitle_model) - stt_form.addRow(t("Subtitle model"), self.subtitle_model_row) + 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("") @@ -1756,7 +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.subtitle_model.setCurrentText(conf["openrouter_subtitle_model"]) + 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"])) @@ -1874,7 +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_subtitle_model"] = self.subtitle_model.currentText().strip() + 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() @@ -2048,7 +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.subtitle_model_row, provider == "openrouter") + 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) @@ -2058,14 +2058,14 @@ class SettingsWindow(QDialog): self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider]) self.transcribe_model.setCurrentText(self._models[provider]) if provider == "openrouter": - self._fill_subtitle_models(TRANSCRIBE_MODELS[provider]) + self._fill_file_models(TRANSCRIBE_MODELS[provider]) self.transcribe_status.setText("") - def _fill_subtitle_models(self, models): - current = self.subtitle_model.currentText() - self.subtitle_model.clear() - self.subtitle_model.addItems(models) - self.subtitle_model.setCurrentText(current) + 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.""" @@ -2096,7 +2096,7 @@ class SettingsWindow(QDialog): self.transcribe_model.addItems(models) self.transcribe_model.setCurrentText(current) if self._shown_provider == "openrouter": - self._fill_subtitle_models(models) + 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 bbc5033..50b4ef7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -53,13 +53,13 @@ class TimestampModel(unittest.TestCase): self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"), "whisper-1") - def test_openrouter_takes_the_subtitle_model_that_was_set(self): + 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_subtitle_model_falls_back_to_whisper(self): + 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") @@ -328,8 +328,8 @@ 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_subtitle_model_when_one_is_set(self): - target = OPENROUTER._replace(subtitle_model="mistralai/voxtral-mini-transcribe") + 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"], diff --git a/tests/test_config.py b/tests/test_config.py index 03bca9c..0138a04 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -220,19 +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.subtitle_model, "") + self.assertEqual(target.file_model, "") - def test_openrouter_carries_its_subtitle_model(self): + def test_openrouter_carries_its_file_model(self): conf = self.config(transcribe_provider="openrouter", openrouter_api_key="sk-or-test", - openrouter_subtitle_model=" openai/whisper-large-v3 ") - self.assertEqual(conf.transcribe_target().subtitle_model, + openrouter_file_model=" openai/whisper-large-v3 ") + self.assertEqual(conf.transcribe_target().file_model, "openai/whisper-large-v3") - def test_only_openrouter_has_a_subtitle_model(self): + def test_only_openrouter_has_a_file_model(self): conf = self.config(transcribe_provider="openai", openai_api_key="sk-test", - openrouter_subtitle_model="openai/whisper-large-v3") - self.assertEqual(conf.transcribe_target().subtitle_model, "") + 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_ui.py b/tests/test_ui.py index 12751e8..25d9e4f 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -504,19 +504,19 @@ class Settings(DikteTest): self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe") self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3") - def test_the_subtitle_model_is_saved_and_only_shown_for_openrouter(self): + def test_the_file_model_is_saved_and_only_shown_for_openrouter(self): self.write_config({"transcribe_provider": "openrouter", - "openrouter_subtitle_model": "openai/whisper-large-v3"}) + "openrouter_file_model": "openai/whisper-large-v3"}) conf = cfg.Config() window = self.window(conf) - self.assertEqual(window.subtitle_model.currentText(), "openai/whisper-large-v3") - self.assertTrue(window.stt_form.isRowVisible(window.subtitle_model_row)) - window.subtitle_model.setCurrentText(" deepgram/nova-3 ") + 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_subtitle_model"], "deepgram/nova-3") + 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.subtitle_model_row)) + 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()) From e0eae4d8fee1502aac9f31914576f9902a5b8be4 Mon Sep 17 00:00:00 2001 From: nomoreshow <45514669+nomoreshow@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:34:55 +0300 Subject: [PATCH 15/37] Ship a managed Vulkan whisper-server for Linux x64 --- .github/workflows/whisper-vulkan.yml | 189 ++++++++++++++++++ README.md | 5 +- README.tr.md | 3 + dikte/ggml.py | 60 ++++-- packaging/whisper-vulkan/Dockerfile.build | 44 ++++ .../whisper-vulkan/Dockerfile.runtime-cpu | 6 + .../whisper-vulkan/Dockerfile.runtime-vulkan | 7 + packaging/whisper-vulkan/build-package.sh | 128 ++++++++++++ .../licenses/cpp-httplib-MIT.txt | 21 ++ .../licenses/nlohmann-json-MIT.txt | 21 ++ .../whisper-vulkan/lunarg-signing-key-pub.asc | 31 +++ packaging/whisper-vulkan/make-sbom.py | 116 +++++++++++ packaging/whisper-vulkan/smoke-runtime.sh | 64 ++++++ packaging/whisper-vulkan/validate-package.sh | 145 ++++++++++++++ tests/test_ggml.py | 112 +++++++++++ tests/test_packaging.py | 180 +++++++++++++++++ 16 files changed, 1117 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/whisper-vulkan.yml create mode 100644 packaging/whisper-vulkan/Dockerfile.build create mode 100644 packaging/whisper-vulkan/Dockerfile.runtime-cpu create mode 100644 packaging/whisper-vulkan/Dockerfile.runtime-vulkan create mode 100755 packaging/whisper-vulkan/build-package.sh create mode 100644 packaging/whisper-vulkan/licenses/cpp-httplib-MIT.txt create mode 100644 packaging/whisper-vulkan/licenses/nlohmann-json-MIT.txt create mode 100644 packaging/whisper-vulkan/lunarg-signing-key-pub.asc create mode 100755 packaging/whisper-vulkan/make-sbom.py create mode 100755 packaging/whisper-vulkan/smoke-runtime.sh create mode 100755 packaging/whisper-vulkan/validate-package.sh create mode 100644 tests/test_packaging.py diff --git a/.github/workflows/whisper-vulkan.yml b/.github/workflows/whisper-vulkan.yml new file mode 100644 index 0000000..e3bdf05 --- /dev/null +++ b/.github/workflows/whisper-vulkan.yml @@ -0,0 +1,189 @@ +name: whisper.cpp Vulkan bundle + +on: + pull_request: + paths: + - packaging/whisper-vulkan/** + - .github/workflows/whisper-vulkan.yml + - dikte/ggml.py + - tests/test_ggml.py + - tests/test_packaging.py + - README.md + - README.tr.md + workflow_dispatch: + inputs: + whisper_version: + description: Upstream whisper.cpp version (without v) + required: true + default: "1.9.3" + type: string + whisper_commit: + description: Peeled commit SHA for that reviewed upstream tag + required: true + default: "371b5a7561823ab2bb32142d2751e35e7534727b" + type: string + publish: + description: Publish a Dikte dependency release + required: true + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: whisper-vulkan-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + WHISPER_VERSION: ${{ inputs.whisper_version || '1.9.3' }} + WHISPER_COMMIT: ${{ inputs.whisper_commit || '371b5a7561823ab2bb32142d2751e35e7534727b' }} + MANAGED_WHISPER_SHA256: c25ca76504144da488eb74441390a7b9aa7ce547e5f2f391cbd831253c9b54d8 + +jobs: + build: + runs-on: ubuntu-22.04 + timeout-minutes: 45 + steps: + - name: Check out Dikte + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Check out pinned whisper.cpp source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: ggml-org/whisper.cpp + ref: ${{ env.WHISPER_COMMIT }} + path: vendor/whisper.cpp + fetch-depth: 0 + persist-credentials: false + + - name: Validate source coordinates + shell: bash + run: | + set -euo pipefail + [[ "$WHISPER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + [[ "$WHISPER_COMMIT" =~ ^[0-9a-f]{40}$ ]] + + - name: Verify source version and commit + shell: bash + run: | + set -euo pipefail + test "$(git -C vendor/whisper.cpp rev-parse HEAD)" = "$WHISPER_COMMIT" + git -C vendor/whisper.cpp fetch --depth=1 origin \ + "refs/tags/v$WHISPER_VERSION:refs/tags/v$WHISPER_VERSION" + test "$(git -C vendor/whisper.cpp rev-list -n1 "v$WHISPER_VERSION")" = "$WHISPER_COMMIT" + echo "SOURCE_DATE_EPOCH=$(git -C vendor/whisper.cpp show -s --format=%ct HEAD)" >> "$GITHUB_ENV" + + - name: Build pinned build environment + run: docker build --pull=false -f packaging/whisper-vulkan/Dockerfile.build -t dikte-whisper-builder packaging/whisper-vulkan + + - name: Build deterministic archive + run: | + docker run --rm \ + -e WHISPER_VERSION -e WHISPER_COMMIT -e SOURCE_DATE_EPOCH \ + -v "$PWD/vendor/whisper.cpp:/src:ro" \ + -v "$PWD/packaging/whisper-vulkan:/packaging:ro" \ + -v "$PWD/work:/work" \ + dikte-whisper-builder \ + bash /packaging/build-package.sh + mkdir -p dist + cp work/out/whisper-bin-ubuntu-vulkan-x64.* dist/ + + - name: Verify reviewed archive digest + shell: bash + run: | + read -r actual _ < dist/whisper-bin-ubuntu-vulkan-x64.tar.gz.sha256 + test "$actual" = "$MANAGED_WHISPER_SHA256" + + - name: Validate archive and ELF contract + run: OUT_DIR=dist packaging/whisper-vulkan/validate-package.sh + + - name: Schema-validate CycloneDX 1.6 SBOM + run: | + docker run --rm \ + -v "$PWD/dist/whisper-bin-ubuntu-vulkan-x64.cdx.json:/sbom.json:ro" \ + cyclonedx/cyclonedx-cli@sha256:252c2e26f468c25fea1e63ecde1bc3198ad6e9dbb57f5ed3236bddcb2281b3a7 \ + validate --input-file /sbom.json --input-format json \ + --input-version v1_6 --fail-on-errors + + - name: CPU fallback smoke test (no Vulkan loader) + run: OUT_DIR=dist packaging/whisper-vulkan/smoke-runtime.sh cpu + + - name: Vulkan plugin-load smoke test (Mesa llvmpipe) + run: OUT_DIR=dist packaging/whisper-vulkan/smoke-runtime.sh vulkan + + - name: Upload reviewed outputs + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: whisper-bin-ubuntu-vulkan-x64 + path: dist/* + if-no-files-found: error + retention-days: 14 + + publish: + if: >- + github.event_name == 'workflow_dispatch' && inputs.publish && + github.ref == 'refs/heads/master' + needs: build + runs-on: ubuntu-22.04 + environment: dependency-release + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + steps: + - name: Download the exact tested outputs + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: whisper-bin-ubuntu-vulkan-x64 + path: dist + + - name: Verify digest sidecar + run: (cd dist && sha256sum --check whisper-bin-ubuntu-vulkan-x64.tar.gz.sha256) + + - name: Attest build provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: dist/whisper-bin-ubuntu-vulkan-x64.tar.gz + + - name: Attest SBOM to archive + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: dist/whisper-bin-ubuntu-vulkan-x64.tar.gz + sbom-path: dist/whisper-bin-ubuntu-vulkan-x64.cdx.json + + - name: Publish dependency release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: whisper.cpp-v${{ inputs.whisper_version }} + RELEASE_TITLE: whisper.cpp v${{ inputs.whisper_version }} Vulkan bundle + RELEASE_NOTES: >- + Pinned source: ggml-org/whisper.cpp@${{ inputs.whisper_commit }}. + Verify with: gh attestation verify + whisper-bin-ubuntu-vulkan-x64.tar.gz + --repo ${{ github.repository }} + run: | + set -euo pipefail + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "refusing to replace existing release $RELEASE_TAG" >&2 + exit 1 + fi + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" >/dev/null 2>&1; then + echo "refusing to replace existing tag $RELEASE_TAG" >&2 + exit 1 + fi + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$RELEASE_TAG" \ + -f sha="$GITHUB_SHA" >/dev/null + test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ + --jq .object.sha)" = "$GITHUB_SHA" + gh release create "$RELEASE_TAG" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --prerelease \ + --latest=false \ + --title "$RELEASE_TITLE" \ + --notes "$RELEASE_NOTES" diff --git a/README.md b/README.md index 8853b8f..601311e 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,10 @@ running. the program and the model, verifies the sha256 and refuses a download published without one, then keeps a server alive while you dictate. The graphics card is reached through CUDA, ROCm or Vulkan where the build allows. No key, no - account, nothing leaving the machine. + account, nothing leaving the machine. On x86_64 Linux, that same Download + button tries the reviewed Vulkan bundle when a loader is present, and falls + back to upstream's CPU build if it is unavailable. The bundle also carries CPU + backends for systems where a Vulkan device cannot start. - **Silence never reaches the API.** Handed near-silence, a transcription model invents a sentence instead of returning nothing ("Thanks for watching", or in Turkish "Altyazı M.K."). A recording is dropped when nothing rose 10 dB above diff --git a/README.tr.md b/README.tr.md index 2e6a461..0fc592f 100644 --- a/README.tr.md +++ b/README.tr.md @@ -160,6 +160,9 @@ olmasını ister. checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu ayakta tutar. Derleme destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok. + x86_64 Linux'ta aynı İndir düğmesi, Vulkan yükleyicisi varsa incelenmiş Vulkan + paketini dener; paket kullanılamıyorsa upstream'in işlemci derlemesine döner. + Vulkan aygıtı başlatılamadığında kullanılacak işlemci arka uçları da pakettedir. - **Sessizlik API'ye gitmez.** Sessize yakın bir ses verildiğinde model boş dize döndürmez, bir cümle uydurur ("Altyazı M.K.", "Thanks for watching"). *O kaydın kendi* gürültü tabanının 10 dB üstüne en az 0,3 saniye çıkan bir şey diff --git a/dikte/ggml.py b/dikte/ggml.py index c2f8da8..792a72d 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -76,6 +76,13 @@ 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") +DIKTE_REPO = "yusufipk/dikte" +MANAGED_WHISPER_RELEASE = "whisper.cpp-v1.9.3" +MANAGED_WHISPER_VERSION = "v1.9.3" +MANAGED_WHISPER_VULKAN = "whisper-bin-ubuntu-vulkan-x64.tar.gz" +MANAGED_WHISPER_SHA256 = ( + "c25ca76504144da488eb74441390a7b9aa7ce547e5f2f391cbd831253c9b54d8" +) # 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 @@ -346,8 +353,11 @@ def _extract(archive, into): 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 TypeError as exc: # Python 3.11.0-3 lack extraction filters + raise LocalError(t( + "Could not safely unpack {name} with this Python version", + name=os.path.basename(str(archive)), + )) from exc except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc: raise LocalError(t("Could not unpack {name}: {error}", name=os.path.basename(str(archive)), error=exc)) from exc @@ -370,20 +380,42 @@ 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. + Ordinary builds follow the program's newest release. The Linux Vulkan build + comes from Dikte's pinned dependency release instead. """ - try: - tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh) - except hub.HubError as exc: - raise LocalError(str(exc)) from exc - + repo = program.repo + release_tag = tag or "latest" + managed = (not tag and program is WHISPER and sys.platform == "linux" + and platform.machine().lower() in ("x86_64", "amd64") + and _has_vulkan()) item = None - for ending in _wanted_assets(program): - item = next((a for a in assets if a.name.endswith(ending)), None) + 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) if item: - break + tag = MANAGED_WHISPER_VERSION + + 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 @@ -398,7 +430,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=program.repo, tag=tag)) + repo=repo, tag=tag)) into = BIN_DIR / program.name / tag fresh = into.with_name(tag + ".new") diff --git a/packaging/whisper-vulkan/Dockerfile.build b/packaging/whisper-vulkan/Dockerfile.build new file mode 100644 index 0000000..fae985b --- /dev/null +++ b/packaging/whisper-vulkan/Dockerfile.build @@ -0,0 +1,44 @@ +FROM ubuntu@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc + +ARG DEBIAN_FRONTEND=noninteractive +ARG CMAKE_VERSION=3.31.6 +ARG CMAKE_SHA256=5a1133ff103c71eb5120e2cc3de922733e7d8a26a98ae716397e8676adb367bf + +COPY lunarg-signing-key-pub.asc /tmp/lunarg.asc + +RUN set -eux; \ + test "$(sha256sum /tmp/lunarg.asc | cut -d' ' -f1)" = aa1c3c29673140e77f0d6a9aaeed5d9b5621e305ead51c59fae4458bbb4df92b; \ + apt-get update; \ + apt-get install --no-install-recommends -y \ + build-essential=12.9ubuntu3 \ + ca-certificates \ + curl \ + file \ + git \ + gnupg \ + ninja-build=1.10.1-1 \ + patchelf=0.14.3-1 \ + python3 \ + xz-utils; \ + install -d -m 0755 /usr/share/keyrings; \ + gpg --dearmor -o /usr/share/keyrings/lunarg.gpg /tmp/lunarg.asc; \ + printf '%s\n' 'deb [signed-by=/usr/share/keyrings/lunarg.gpg] https://packages.lunarg.com/vulkan jammy main' \ + > /etc/apt/sources.list.d/lunarg-vulkan.list; \ + apt-get update; \ + apt-get install --no-install-recommends -y \ + libvulkan-dev=1.4.313.0~rc1-1lunarg22.04-1 \ + vulkan-headers=1.4.313.0~rc1-1lunarg22.04-1 \ + shaderc=2025.2~rc1-1lunarg22.04-1 \ + spirv-headers=1.6.1+1.4.313.0~rc1-1lunarg22.04-1; \ + curl --fail --location --retry 3 \ + "https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz" \ + -o /tmp/cmake.tar.gz; \ + test "$(sha256sum /tmp/cmake.tar.gz | cut -d' ' -f1)" = "$CMAKE_SHA256"; \ + tar -xzf /tmp/cmake.tar.gz --strip-components=1 -C /usr/local; \ + rm -rf /var/lib/apt/lists/* /tmp/cmake.tar.gz /tmp/lunarg.asc; \ + cmake --version; \ + glslc --version; \ + test -f /usr/include/vulkan/vulkan.h; \ + test -f /usr/share/cmake/SPIRV-Headers/SPIRV-HeadersConfig.cmake + +WORKDIR /work diff --git a/packaging/whisper-vulkan/Dockerfile.runtime-cpu b/packaging/whisper-vulkan/Dockerfile.runtime-cpu new file mode 100644 index 0000000..89ac704 --- /dev/null +++ b/packaging/whisper-vulkan/Dockerfile.runtime-cpu @@ -0,0 +1,6 @@ +FROM ubuntu@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install --no-install-recommends -y ca-certificates curl libstdc++6 \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /bundle diff --git a/packaging/whisper-vulkan/Dockerfile.runtime-vulkan b/packaging/whisper-vulkan/Dockerfile.runtime-vulkan new file mode 100644 index 0000000..3a16288 --- /dev/null +++ b/packaging/whisper-vulkan/Dockerfile.runtime-vulkan @@ -0,0 +1,7 @@ +FROM ubuntu@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + ca-certificates curl libstdc++6 libvulkan1 mesa-vulkan-drivers vulkan-tools \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /bundle diff --git a/packaging/whisper-vulkan/build-package.sh b/packaging/whisper-vulkan/build-package.sh new file mode 100755 index 0000000..f4e2215 --- /dev/null +++ b/packaging/whisper-vulkan/build-package.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail +shopt -s nullglob + +: "${SOURCE_DIR:=/src}" +: "${OUT_DIR:=/work/out}" +: "${WHISPER_VERSION:=1.9.3}" +: "${WHISPER_COMMIT:=371b5a7561823ab2bb32142d2751e35e7534727b}" +: "${SOURCE_DATE_EPOCH:=1787219223}" + +export SOURCE_DATE_EPOCH TZ=UTC LC_ALL=C LANG=C +asset=whisper-bin-ubuntu-vulkan-x64 +build=/work/build +source_copy=/work/source +root="$OUT_DIR/root/$asset" + +rm -rf "$build" "$source_copy" "$OUT_DIR" +mkdir -p "$build" "$root/LICENSES" +# Upstream configures bindings/javascript/package.json in the source directory. +# Build a private copy so the checked-out, verified source remains untouched. +cp -a "$SOURCE_DIR" "$source_copy" +chmod -R u+w "$source_copy" +git config --global --add safe.directory "$source_copy" + +cmake -S "$source_copy" -B "$build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_BUILD_RPATH='$ORIGIN' \ + -DCMAKE_INSTALL_RPATH='$ORIGIN' \ + -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ + -DCMAKE_C_FLAGS="-ffile-prefix-map=$source_copy=. -fdebug-prefix-map=$source_copy=. -fmacro-prefix-map=$source_copy=." \ + -DCMAKE_CXX_FLAGS="-ffile-prefix-map=$source_copy=. -fdebug-prefix-map=$source_copy=. -fmacro-prefix-map=$source_copy=." \ + -DBUILD_SHARED_LIBS=ON \ + -DGGML_BACKEND_DL=ON \ + -DGGML_CPU_ALL_VARIANTS=ON \ + -DGGML_NATIVE=OFF \ + -DGGML_CCACHE=OFF \ + -DGGML_OPENMP=OFF \ + -DGGML_VULKAN=ON \ + -DWHISPER_BUILD_EXAMPLES=ON \ + -DWHISPER_BUILD_SERVER=ON \ + -DWHISPER_BUILD_TESTS=OFF \ + -DWHISPER_BUILD_IS_DEV=OFF \ + -DWHISPER_CURL=OFF \ + -DWHISPER_SDL2=OFF \ + -DWHISPER_COMMON_FFMPEG=OFF \ + -DWHISPER_BUILD_COMMIT="$WHISPER_COMMIT" \ + -DWHISPER_BUILD_NUMBER=0 +cmake --build "$build" --target whisper-server --parallel "$(nproc)" + +# Package an allowlist, not everything examples/ happens to build in the future. +cp -a "$build/bin/whisper-server" "$root/" +cp -a "$build/bin"/libwhisper.so* "$root/" +cp -a "$build/bin"/libggml.so* "$root/" +cp -a "$build/bin"/libggml-base.so* "$root/" +cp -a "$build/bin"/libggml-cpu*.so* "$root/" +cp -a "$build/bin"/libggml-vulkan.so* "$root/" + +# Strip real ELF files only; preserve the SONAME symlink chains. +while IFS= read -r -d '' file; do + if file "$file" | grep -q ELF; then + strip --strip-unneeded "$file" + patchelf --set-rpath '$ORIGIN' "$file" + fi +done < <(find "$root" -type f -print0) + +cp "$SOURCE_DIR/LICENSE" "$root/LICENSES/whisper.cpp-MIT.txt" +cp /packaging/licenses/cpp-httplib-MIT.txt "$root/LICENSES/" +cp /packaging/licenses/nlohmann-json-MIT.txt "$root/LICENSES/" + +cat > "$root/BUILD-INFO.json" < "$root/$asset.cdx.json" + +( + cd "$root" + find . -type f ! -name SHA256SUMS -print0 \ + | sort -z \ + | xargs -0 sha256sum +) > "$root/SHA256SUMS" + +mkdir -p "$OUT_DIR" +tar --sort=name --owner=0 --group=0 --numeric-owner \ + --mtime="@$SOURCE_DATE_EPOCH" \ + --pax-option=delete=atime,delete=ctime \ + -C "$OUT_DIR/root" -cf - "$asset" \ + | gzip -n -9 > "$OUT_DIR/$asset.tar.gz" +( + cd "$OUT_DIR" + sha256sum "$asset.tar.gz" > "$asset.tar.gz.sha256" +) +cp "$root/$asset.cdx.json" "$OUT_DIR/$asset.cdx.json" diff --git a/packaging/whisper-vulkan/licenses/cpp-httplib-MIT.txt b/packaging/whisper-vulkan/licenses/cpp-httplib-MIT.txt new file mode 100644 index 0000000..47c418e --- /dev/null +++ b/packaging/whisper-vulkan/licenses/cpp-httplib-MIT.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 yhirose + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packaging/whisper-vulkan/licenses/nlohmann-json-MIT.txt b/packaging/whisper-vulkan/licenses/nlohmann-json-MIT.txt new file mode 100644 index 0000000..70c6af6 --- /dev/null +++ b/packaging/whisper-vulkan/licenses/nlohmann-json-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2022 Niels Lohmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packaging/whisper-vulkan/lunarg-signing-key-pub.asc b/packaging/whisper-vulkan/lunarg-signing-key-pub.asc new file mode 100644 index 0000000..59b5d72 --- /dev/null +++ b/packaging/whisper-vulkan/lunarg-signing-key-pub.asc @@ -0,0 +1,31 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFuOrjYBCADT5MjShtbeSsWHADqVP7PZIp+m/wWkSUA7/FX/qrixhQE9DFyt +XtKSbBdwh+Jg5nsttCUiePtdrrRD1tcyowG256Tus3vOysZzvpfjWA4gcVmTjJXn +gwezKsPZLQi0wvjwQD8ByxnM1i2eiJC4xcMjT21uZkwDfgLTzVO4InWlVyZDB/da +PLJl4r1MqnsI603RKalMQmZzs43YUDssdeOiGOpXvb1Rj0XcsOOqnAEvIwyUWGku +1Hr+b6C9Nj6wksD7TCB10IdOeuwBqFgrVDzicG4fijwnpzA+UUfncIhKYdI/oIvj +mcAPobWzcBkM3uc+Yf/CxlBahzu6jv7AFdT1ABEBAAG0VEx1bmFyRyBTaWduaW5n +IEtleSAoS2V5IHVzZWQgYnkgTHVuYXJHIHRvIHNpZ24gcGFja2FnZXMpIDxsaW51 +eC1wYWNrYWdlc0BsdW5hcmcuY29tPokBTgQTAQoAOBYhBAP11iGjcQ+pWpPYm6qE +UggOOD9+BQJbjq42AhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEKqEUggO +OD9+ECgH/Ro6LVB08FifApBS235v0Af3dsJlZGE0miKu2hR12qAvWackE6//E5GN +5xKSNpgLzV6kyylBntQDhcFzW3hLt/AsMLOXvuxYNFcLes2y10DrqVekNeJiR95V +KiTPI2jP8m4eFpcSnY0riHk2MmstN1icehQhYrWFyUtt3VxSsRWiRDeNUfCHC6YP +MjOXonmTWfH7T+UA2IqLFrt9dAsYGiCtMKVgzaZaZwm727c0aqy0e43nsWqjWxmE +EsEA1RvzjKKyzyixwpnzIyQ8dqL8sH0G3E2OYTlS7A8//yfgykRQVHwg2TsTBKfG +LlTmKj7RCT6GqISo+rbYYo/hZ6l2hH25AQ0EW46uNgEIANZfPWerTPzmvswWqp0P +iQvW+0qTBxZH3gQlwq5s6ahpY1pIebfrL/SAYJUGyjJVcjkG+HBXRGyRxtWFDE+D ++WEuziBfKd3aBUXb5DnvWdCiXeyQnFfwUVYNXhU5PlpAB5M409a30p9gGOrYy3Ah +g4VHhpM9wzGUAOzTwQ4WaC2WkR84sZYyqdKoo6C3m4IR4KHMYXF9nRlPSNEckL9U +MZe6I2uvor9FOPIfIOAI8lN+gbj/anf3lfy0ZYPyUtl3EWveGpWAPvdw3LMKg5QN +B8bR9TkPk0YZyQQcWkmN7gLUg0Vba+PYHH9DRlG8w1rH4TKxXJV3wmHo2aZRF1kc +30kAEQEAAYkBNgQYAQoAIBYhBAP11iGjcQ+pWpPYm6qEUggOOD9+BQJbjq42AhsM +AAoJEKqEUggOOD9+MEUH/2pm2QOttjd7DmEaS4LGvaTlEif0xtymRAh3axGuqQhl +KCZbw0jwsQlo/DwMRZwZHYCj1A/5H8mEg9qNGjF35GEpQTFSQI6Mt7F2DK69J86w +61v8tjxs4eO201ndhy+DRwDwG8vryFldx3f0nEdlE7IusgiUdvkcJPc8rX7p0MJJ +istTREAq8bRnvWYJzd4k3tgwHglEDxyjBRwLtqZyQ19XZb3V/aVKygqvZbwdJyXO +RHAZxK81p9Gp/8VkogJHLx6+3V8UlDepJg9/8MUCBQ9wWkdF0Pfqzgu7xtIHSxvW +62EF4nxqVuC946OIeITgXpd4F+iTFVII8w0P+nyCzac= +=nXAe +-----END PGP PUBLIC KEY BLOCK----- diff --git a/packaging/whisper-vulkan/make-sbom.py b/packaging/whisper-vulkan/make-sbom.py new file mode 100755 index 0000000..1dcf99c --- /dev/null +++ b/packaging/whisper-vulkan/make-sbom.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +import datetime +import hashlib +import json +import os +import uuid +from pathlib import Path + +root = Path(os.environ["ROOT"]) +version = os.environ["VERSION"] +commit = os.environ["COMMIT"] +epoch = int(os.environ["EPOCH"]) +asset = "whisper-bin-ubuntu-vulkan-x64" +sbom_path = root / f"{asset}.cdx.json" + +def digest(path): + h = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + h.update(block) + return h.hexdigest() + +files = [] +for path in sorted(root.rglob("*")): + if path != sbom_path and path.is_file() and not path.is_symlink(): + rel = path.relative_to(root).as_posix() + files.append({ + "type": "file", + "bom-ref": f"file:{rel}", + "name": rel, + "hashes": [{"alg": "SHA-256", "content": digest(path)}], + }) + +ts = datetime.datetime.fromtimestamp( + epoch, datetime.timezone.utc, +).isoformat().replace("+00:00", "Z") +root_ref = f"pkg:github/ggml-org/whisper.cpp@{version}?commit={commit}" +ggml_ref = "pkg:github/ggml-org/ggml@0.20.2" +httplib_ref = "pkg:github/yhirose/cpp-httplib@0.20.0" +json_ref = "pkg:github/nlohmann/json@3.11.2" + +sbom = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, root_ref)}", + "version": 1, + "metadata": { + "timestamp": ts, + "tools": {"components": [ + {"type": "application", "name": "make-sbom.py", "version": "1"}, + {"type": "application", "name": "CMake", "version": "3.31.6"}, + {"type": "application", "name": "glslc", "version": "2025.2"}, + ]}, + "component": { + "type": "application", + "bom-ref": root_ref, + "group": "ggml-org", + "name": "whisper-server", + "version": version, + "purl": root_ref, + "licenses": [{"expression": "MIT"}], + "externalReferences": [{ + "type": "vcs", + "url": f"https://github.com/ggml-org/whisper.cpp/tree/{commit}", + }], + "properties": [ + {"name": "dikte:asset-name", "value": f"{asset}.tar.gz"}, + {"name": "dikte:source-commit", "value": commit}, + {"name": "dikte:runtime:glibc-minimum", "value": "2.34"}, + {"name": "dikte:runtime:glibcxx-minimum", "value": "3.4.30"}, + {"name": "dikte:runtime:vulkan-loader", "value": "optional; libvulkan.so.1"}, + ], + }, + }, + "components": [ + { + "type": "library", + "bom-ref": ggml_ref, + "group": "ggml-org", + "name": "ggml", + "version": "0.20.2", + "purl": ggml_ref, + "licenses": [{"expression": "MIT"}], + "properties": [{ + "name": "dikte:source", + "value": "vendored by the pinned whisper.cpp commit", + }], + }, + { + "type": "library", + "bom-ref": httplib_ref, + "group": "yhirose", + "name": "cpp-httplib", + "version": "0.20.0", + "purl": httplib_ref, + "licenses": [{"expression": "MIT"}], + }, + { + "type": "library", + "bom-ref": json_ref, + "group": "nlohmann", + "name": "json", + "version": "3.11.2", + "purl": json_ref, + "licenses": [{"expression": "MIT"}], + }, + *files, + ], + "dependencies": [{ + "ref": root_ref, + "dependsOn": [ggml_ref, httplib_ref, json_ref] + + [item["bom-ref"] for item in files], + }], +} +json.dump(sbom, fp=os.sys.stdout, indent=2, sort_keys=True) +print() diff --git a/packaging/whisper-vulkan/smoke-runtime.sh b/packaging/whisper-vulkan/smoke-runtime.sh new file mode 100755 index 0000000..22c0626 --- /dev/null +++ b/packaging/whisper-vulkan/smoke-runtime.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode=${1:?usage: smoke-runtime.sh cpu|vulkan} +: "${OUT_DIR:=work/out}" +: "${FIXTURE_SOURCE:=vendor/whisper.cpp}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="$(realpath "$OUT_DIR")" +FIXTURE_SOURCE="$(realpath "$FIXTURE_SOURCE")" +asset=whisper-bin-ubuntu-vulkan-x64 +case "$mode" in + cpu) dockerfile=Dockerfile.runtime-cpu; image=dikte-whisper-runtime-cpu:spike ;; + vulkan) dockerfile=Dockerfile.runtime-vulkan; image=dikte-whisper-runtime-vulkan:spike ;; + *) echo "unknown mode: $mode" >&2; exit 2 ;; +esac + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +tar -xzf "$OUT_DIR/$asset.tar.gz" -C "$tmp" +docker build --pull=false -f "$SCRIPT_DIR/$dockerfile" -t "$image" "$SCRIPT_DIR" + +args=("/bundle/$asset/whisper-server" -m /fixtures/model.bin + --host 127.0.0.1 --port 8080 + --inference-path /v1/audio/transcriptions -l auto -sns -nlp) +env_args=() +if [[ "$mode" == cpu ]]; then + args+=(-ng) +else + env_args=(-e LIBGL_ALWAYS_SOFTWARE=1 + -e VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json) +fi + +docker run --rm --name "dikte-whisper-$mode-smoke" \ + -e SMOKE_MODE="$mode" \ + "${env_args[@]}" \ + -v "$tmp/$asset:/bundle/$asset:ro" \ + -v "$FIXTURE_SOURCE/models/for-tests-ggml-base.en.bin:/fixtures/model.bin:ro" \ + -v "$FIXTURE_SOURCE/samples/jfk.wav:/fixtures/jfk.wav:ro" \ + "$image" bash -ec ' + if [ "$SMOKE_MODE" = cpu ] && ldconfig -p | grep -q libvulkan.so.1; then + echo "CPU smoke image unexpectedly has a Vulkan loader" >&2 + exit 1 + fi + "$@" >/tmp/server.log 2>&1 & + pid=$! + trap "kill $pid 2>/dev/null || true" EXIT + for _ in $(seq 1 120); do + kill -0 "$pid" 2>/dev/null || { cat /tmp/server.log; exit 1; } + if curl --silent --show-error --fail --max-time 180 \ + -F file=@/fixtures/jfk.wav -F response_format=json \ + http://127.0.0.1:8080/v1/audio/transcriptions >/tmp/response.json; then + grep -q "\"text\"" /tmp/response.json + if [ "$SMOKE_MODE" = vulkan ]; then + grep -q "loaded Vulkan backend" /tmp/server.log + fi + cat /tmp/response.json + cat /tmp/server.log + exit 0 + fi + sleep 1 + done + cat /tmp/server.log + exit 1 + ' bash "${args[@]}" diff --git a/packaging/whisper-vulkan/validate-package.sh b/packaging/whisper-vulkan/validate-package.sh new file mode 100755 index 0000000..d21a083 --- /dev/null +++ b/packaging/whisper-vulkan/validate-package.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${OUT_DIR:=work/out}" +: "${SOURCE_DIR:=whisper.cpp}" +asset=whisper-bin-ubuntu-vulkan-x64 +archive="$OUT_DIR/$asset.tar.gz" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +test -s "$archive" +(cd "$OUT_DIR" && sha256sum --check "$asset.tar.gz.sha256") +ARCHIVE="$archive" ASSET="$asset" python3 - <<'PY' +import os +import posixpath +import tarfile + +archive = os.environ["ARCHIVE"] +asset = os.environ["ASSET"] + + +def under_root(name): + normalized = posixpath.normpath(name) + return (not posixpath.isabs(normalized) + and normalized != ".." + and not normalized.startswith("../") + and normalized.split("/", 1)[0] == asset) + + +with tarfile.open(archive, "r:gz") as bundle: + for member in bundle: + if not under_root(member.name): + raise SystemExit(f"unsafe archive member: {member.name}") + if member.isdev() or member.isfifo(): + raise SystemExit(f"special archive member: {member.name}") + if not (member.isdir() or member.isfile() + or member.issym() or member.islnk()): + raise SystemExit(f"unsupported archive member: {member.name}") + if member.issym(): + target = posixpath.join(posixpath.dirname(member.name), + member.linkname) + if not under_root(target): + raise SystemExit(f"unsafe symlink: {member.name}") + if member.islnk() and not under_root(member.linkname): + raise SystemExit(f"unsafe hardlink: {member.name}") +PY +tar -xzf "$archive" -C "$tmp" +root="$tmp/$asset" + +test -x "$root/whisper-server" +test -f "$root/libwhisper.so" +test -f "$root/libggml.so" +test -f "$root/libggml-base.so" +test -f "$root/libggml-vulkan.so" +compgen -G "$root/libggml-cpu-*.so" >/dev/null +test -f "$root/LICENSES/whisper.cpp-MIT.txt" +test -f "$root/LICENSES/cpp-httplib-MIT.txt" +test -f "$root/LICENSES/nlohmann-json-MIT.txt" +(cd "$root" && sha256sum --check SHA256SUMS) + +# All shipped ELF objects must be relocatable and must not remember /work. +while IFS= read -r -d '' file; do + file "$file" | grep -q ELF || continue + dynamic=$(readelf -d "$file") + if ! grep -Fq 'Library runpath: [$ORIGIN]' <<<"$dynamic"; then + echo "runpath is not \$ORIGIN in $file" >&2 + exit 1 + fi + if grep -Eq '/(home|tmp|work)/' <<<"$dynamic"; then + echo "build path remains in $file" >&2 + exit 1 + fi +done < <(find "$root" -type f -print0) + +# Vulkan remains a plugin dependency. The executable must start without a loader. +if readelf -d "$root/whisper-server" | grep -q 'libvulkan.so'; then + echo "whisper-server links Vulkan instead of loading it as a plugin" >&2 + exit 1 +fi +readelf -d "$root/libggml-vulkan.so" | grep -q 'libvulkan.so.1' + +# Ubuntu 22.04 establishes the glibc ceiling promised by this artifact. +ROOT="$root" python3 - <<'PY' +import os, pathlib, re, subprocess +root = pathlib.Path(os.environ['ROOT']) +seen = {'GLIBC': set(), 'GLIBCXX': set(), 'CXXABI': set()} +external = { + 'libc.so.6', 'libgcc_s.so.1', 'libm.so.6', 'libstdc++.so.6', + 'libvulkan.so.1', 'ld-linux-x86-64.so.2', +} +for path in root.iterdir(): + if not path.is_file() or path.is_symlink(): + continue + header = subprocess.run(['readelf', '-h', path], text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL).stdout + if not header: + continue + if 'Machine: Advanced Micro Devices X86-64' not in header: + raise SystemExit(f'wrong ELF architecture: {path.name}') + dynamic = subprocess.run(['readelf', '-d', path], text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL).stdout + needed = re.findall(r'\(NEEDED\).*\[(.*?)\]', dynamic) + unexpected = [name for name in needed + if name not in external + and not re.fullmatch( + r'lib(?:whisper|ggml(?:-base)?)\.so\.\d+', name)] + if unexpected: + raise SystemExit( + f'unexpected DT_NEEDED in {path.name}: {unexpected}') + if path.name != 'libggml-vulkan.so' and 'libvulkan.so.1' in needed: + raise SystemExit(f'Vulkan is not plugin-only in {path.name}') + contents = path.read_bytes() + for marker in (b'/home/', b'/tmp/', b'/work/'): + if marker in contents: + raise SystemExit( + f'build path {marker!r} remains in {path.name}') + text = subprocess.run(['objdump', '-T', path], text=True, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout + for family in seen: + pattern = rf'{family}_([0-9]+(?:\.[0-9]+)+)' + seen[family].update(tuple(map(int, version.split('.'))) + for version in re.findall(pattern, text)) +assert seen['GLIBC'] and max(seen['GLIBC']) <= (2, 34), max(seen['GLIBC']) +assert seen['GLIBCXX'] and max(seen['GLIBCXX']) <= (3, 4, 30), max(seen['GLIBCXX']) +assert seen['CXXABI'] and max(seen['CXXABI']) <= (1, 3, 13), max(seen['CXXABI']) +for family, versions in seen.items(): + print(f'maximum {family} symbol:', '.'.join(map(str, max(versions)))) +PY + +python3 - "$root/$asset.cdx.json" <<'PY' +import json, sys +with open(sys.argv[1], encoding='utf-8') as stream: + doc = json.load(stream) +assert doc['bomFormat'] == 'CycloneDX' +assert doc['specVersion'] == '1.6' +assert doc['metadata']['component']['name'] == 'whisper-server' +assert len(doc['components']) >= 3 +print('SBOM components:', len(doc['components'])) +PY + +LD_LIBRARY_PATH='' "$root/whisper-server" --help >/dev/null 2>&1 + +echo "structure: PASS" diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 44b9305..94a69b5 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -221,6 +221,7 @@ class InstallProgram(Local): def install(self, *names, archive=None): self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: False) 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) @@ -238,6 +239,105 @@ class InstallProgram(Local): "whisper-bin-ubuntu-x64.tar.gz") self.assertTrue(urls[1].endswith("whisper-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) + listing = self.release("whisper-bin-ubuntu-vulkan-x64.tar.gz") + listing["tag_name"] = "whisper.cpp-v1.9.3" + managed_sha = hashlib.sha256(self.archive).hexdigest() + with mock.patch.object(ggml, "MANAGED_WHISPER_SHA256", managed_sha, + create=True): + with fake_urlopen(listing, body(self.archive)) as calls: + path = ggml.install_program(ggml.WHISPER) + urls = [call.full_url for call in calls] + self.assertIn( + "/repos/yusufipk/dikte/releases/tags/whisper.cpp-v1.9.3", + urls[0], + ) + self.assertTrue(urls[1].endswith( + "whisper-bin-ubuntu-vulkan-x64.tar.gz")) + self.assertTrue(os.path.isfile(path)) + self.assertEqual("v1.9.3", ggml.installed_version(ggml.WHISPER)) + + def test_an_explicit_whisper_version_still_comes_from_upstream(self): + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: True) + listing = self.release("whisper-bin-ubuntu-x64.tar.gz") + with fake_urlopen(listing, body(self.archive)) as calls: + ggml.install_program(ggml.WHISPER, tag="v1.9.1") + self.assertIn( + "/repos/ggml-org/whisper.cpp/releases/tags/v1.9.1", + calls[0].full_url, + ) + + def test_linux_arm64_keeps_using_the_upstream_cpu_build(self): + self.patch_attr(ggml, "_arch", lambda: "arm64") + self.patch_attr(ggml.platform, "machine", lambda: "aarch64") + self.patch_attr(ggml, "_has_vulkan", lambda: True) + listing = self.release("whisper-bin-ubuntu-arm64.tar.gz") + with fake_urlopen(listing, body(self.archive)) as calls: + ggml.install_program(ggml.WHISPER) + self.assertIn( + "/repos/ggml-org/whisper.cpp/releases/latest", + calls[0].full_url, + ) + + def test_linux_non_x86_does_not_try_the_managed_x64_build(self): + self.patch_attr(ggml, "_has_vulkan", lambda: True) + listing = self.release("whisper-bin-ubuntu-arm64.tar.gz") + with mock.patch("platform.machine", return_value="ppc64le"): + with fake_urlopen(listing, listing) as calls: + with self.assertRaises(ggml.LocalError): + ggml.install_program(ggml.WHISPER) + self.assertIn( + "/repos/ggml-org/whisper.cpp/releases/latest", + calls[0].full_url, + ) + + def test_a_missing_managed_build_falls_back_to_upstream_cpu(self): + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: True) + managed = self.release("Dikte-1.1.0-x86_64.AppImage") + managed["tag_name"] = "whisper.cpp-v1.9.3" + upstream = self.release("whisper-bin-ubuntu-x64.tar.gz") + with fake_urlopen(managed, upstream, body(self.archive)) as calls: + path = ggml.install_program(ggml.WHISPER) + urls = [call.full_url for call in calls] + self.assertIn( + "/repos/yusufipk/dikte/releases/tags/whisper.cpp-v1.9.3", + urls[0], + ) + self.assertIn("/repos/ggml-org/whisper.cpp/releases/latest", urls[1]) + self.assertTrue(urls[2].endswith("whisper-bin-ubuntu-x64.tar.gz")) + self.assertTrue(os.path.isfile(path)) + + def test_a_managed_build_with_an_unreviewed_digest_falls_back(self): + self.patch_attr(ggml, "_has_vulkan", lambda: True) + managed = self.release("whisper-bin-ubuntu-vulkan-x64.tar.gz") + managed["assets"][0]["digest"] = "sha256:" + "0" * 64 + upstream = self.release("whisper-bin-ubuntu-x64.tar.gz") + with fake_urlopen(managed, upstream, body(self.archive)) as calls: + try: + path = ggml.install_program(ggml.WHISPER) + except ggml.LocalError as exc: + self.fail(f"unreviewed digest did not fall back: {exc}") + urls = [call.full_url for call in calls] + self.assertEqual(3, len(urls)) + self.assertTrue(urls[2].endswith("whisper-bin-ubuntu-x64.tar.gz")) + self.assertTrue(os.path.isfile(path)) + + def test_an_unavailable_managed_release_falls_back_to_upstream_cpu(self): + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: True) + upstream = self.release("whisper-bin-ubuntu-x64.tar.gz") + with fake_urlopen(http_error(404), upstream, + body(self.archive)) as calls: + path = ggml.install_program(ggml.WHISPER) + self.assertEqual(3, len(calls)) + self.assertTrue(calls[2].full_url.endswith( + "whisper-bin-ubuntu-x64.tar.gz")) + self.assertTrue(os.path.isfile(path)) + 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")): @@ -399,6 +499,18 @@ class InstallProgram(Local): with self.assertRaises(ggml.LocalError): self.install("whisper-bin-ubuntu-x64.tar.gz", archive=buf.getvalue()) + def test_python_without_safe_tar_filters_refuses_the_archive(self): + archive = self.path("bundle.tar.gz") + archive.write_bytes(self.archive) + destination = self.path("unpacked") + destination.mkdir() + with mock.patch.object(tarfile.TarFile, "extractall", + side_effect=[TypeError("no filter"), None]) as extract: + with self.assertRaises(ggml.LocalError) as caught: + ggml._extract(archive, destination) + self.assertEqual(1, extract.call_count) + self.assertIn("safely", str(caught.exception)) + 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): diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..2d63e6e --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,180 @@ +"""The release build that makes Linux Vulkan a one-click install.""" + +import hashlib +import io +import json +import os +import pathlib +import shutil +import subprocess +import sys +import tarfile +import tempfile +import unittest + +from dikte import ggml + + +ROOT = pathlib.Path(__file__).parents[1] +PACKAGING = ROOT / "packaging" / "whisper-vulkan" +WORKFLOW = ROOT / ".github" / "workflows" / "whisper-vulkan.yml" + + +class WhisperVulkanPackaging(unittest.TestCase): + @unittest.skipUnless(shutil.which("bash"), "bash is unavailable") + def test_the_release_scripts_parse_as_shell(self): + for name in ("build-package.sh", "validate-package.sh", + "smoke-runtime.sh"): + script = PACKAGING / name + checked = subprocess.run( + ["bash", "-n", script], capture_output=True, text=True, + ) + self.assertEqual("", checked.stderr) + self.assertEqual(0, checked.returncode) + + def test_the_workflow_builds_validates_smokes_and_publishes(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + for step in ("Build deterministic archive", + "Verify reviewed archive digest", + "Validate archive and ELF contract", + "CPU fallback smoke test (no Vulkan loader)", + "Vulkan plugin-load smoke test (Mesa llvmpipe)", + "Publish dependency release"): + self.assertIn(step, workflow) + self.assertNotRegex(workflow, r"uses: [^\n]+@v\d+(?:\s|$)") + + def test_publish_is_safe_for_dikte_and_limited_to_reviewed_master(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertGreaterEqual(workflow.count("persist-credentials: false"), 2) + self.assertIn("github.ref == 'refs/heads/master'", workflow) + self.assertIn("--prerelease", workflow) + self.assertIn("--latest=false", workflow) + self.assertIn("--verify-tag", workflow) + self.assertIn("refusing to replace existing tag", workflow) + self.assertIn("^[0-9]+\\.[0-9]+\\.[0-9]+$", workflow) + self.assertIn("^[0-9a-f]{40}$", workflow) + publish_script = workflow.split(" - name: Publish dependency release", 1)[1] + publish_script = publish_script.split(" run: |", 1)[1] + self.assertNotIn("${{ inputs.", publish_script) + + def test_bundle_ci_runs_when_its_installer_or_contract_changes(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + for path in ("dikte/ggml.py", "tests/test_packaging.py", + "README.md", "README.tr.md"): + self.assertIn(f"- {path}", workflow) + + def test_the_validator_checks_tar_links_before_extraction(self): + validator = (PACKAGING / "validate-package.sh").read_text( + encoding="utf-8") + for check in ("member.issym()", "member.islnk()", "member.isdev()"): + self.assertIn(check, validator) + + @unittest.skipUnless(sys.platform == "linux" and shutil.which("bash"), + "Linux packaging test is unavailable") + def test_the_validator_rejects_an_escaping_symlink(self): + asset = "whisper-bin-ubuntu-vulkan-x64" + with tempfile.TemporaryDirectory() as temporary: + output = pathlib.Path(temporary) + archive = output / f"{asset}.tar.gz" + with tarfile.open(archive, "w:gz") as bundle: + link = tarfile.TarInfo(f"{asset}/whisper-server") + link.type = tarfile.SYMTYPE + link.linkname = "/etc/passwd" + bundle.addfile(link, io.BytesIO()) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + (output / f"{asset}.tar.gz.sha256").write_text( + f"{digest} {asset}.tar.gz\n", encoding="utf-8", + ) + checked = subprocess.run( + ["bash", PACKAGING / "validate-package.sh"], + env=os.environ | {"OUT_DIR": str(output)}, + capture_output=True, text=True, + ) + self.assertNotEqual(0, checked.returncode) + self.assertIn("unsafe symlink", checked.stderr) + + def test_the_validator_checks_elf_architecture_dependencies_and_paths(self): + validator = (PACKAGING / "validate-package.sh").read_text( + encoding="utf-8") + for check in ("Advanced Micro Devices X86-64", "unexpected DT_NEEDED", + "path.read_bytes()"): + self.assertIn(check, validator) + + def test_the_builder_and_its_downloads_are_pinned(self): + dockerfile = (PACKAGING / "Dockerfile.build").read_text( + encoding="utf-8") + self.assertRegex(dockerfile, r"FROM ubuntu@sha256:[0-9a-f]{64}") + self.assertIn("CMAKE_SHA256=", dockerfile) + self.assertIn("libvulkan-dev=", dockerfile) + self.assertIn("shaderc=", dockerfile) + key = (PACKAGING / "lunarg-signing-key-pub.asc").read_bytes() + self.assertEqual( + "aa1c3c29673140e77f0d6a9aaeed5d9b5621e305ead51c59fae4458bbb4df92b", + hashlib.sha256(key).hexdigest(), + ) + + def test_the_bundle_has_portable_dynamic_backends(self): + script = (PACKAGING / "build-package.sh").read_text( + encoding="utf-8") + for flag in ("GGML_BACKEND_DL=ON", "GGML_CPU_ALL_VARIANTS=ON", + "GGML_NATIVE=OFF", "GGML_OPENMP=OFF", + "GGML_VULKAN=ON"): + self.assertIn(flag, script) + self.assertIn("libggml-cpu*.so", script) + self.assertIn("libggml-vulkan.so", script) + + def test_the_dependency_release_matches_the_installer(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + script = (PACKAGING / "build-package.sh").read_text( + encoding="utf-8") + self.assertEqual("whisper.cpp-v1.9.3", + ggml.MANAGED_WHISPER_RELEASE) + self.assertEqual("v1.9.3", ggml.MANAGED_WHISPER_VERSION) + self.assertIn("RELEASE_TAG: whisper.cpp-v${{ inputs.whisper_version }}", + workflow) + self.assertIn("WHISPER_VERSION:=1.9.3", script) + commit = "371b5a7561823ab2bb32142d2751e35e7534727b" + self.assertIn(f"WHISPER_COMMIT:={commit}", script) + self.assertIn(commit, workflow) + self.assertIn(ggml.MANAGED_WHISPER_VULKAN, workflow) + self.assertIn(ggml.MANAGED_WHISPER_SHA256, workflow) + + def test_the_bundle_carries_metadata_and_all_required_licenses(self): + script = (PACKAGING / "build-package.sh").read_text( + encoding="utf-8") + for name in ("BUILD-INFO.json", "SHA256SUMS", ".cdx.json"): + self.assertIn(name, script) + for name in ("cpp-httplib-MIT.txt", "nlohmann-json-MIT.txt"): + self.assertTrue((PACKAGING / "licenses" / name).is_file()) + + def _make_test_sbom(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + (root / "whisper-server").write_bytes(b"elf") + sbom = root / "whisper-bin-ubuntu-vulkan-x64.cdx.json" + environment = os.environ | { + "ROOT": str(root), + "VERSION": "1.9.3", + "COMMIT": "371b5a7561823ab2bb32142d2751e35e7534727b", + "EPOCH": "1787219223", + } + with sbom.open("w", encoding="utf-8") as output: + subprocess.run( + [sys.executable, PACKAGING / "make-sbom.py"], + env=environment, stdout=output, check=True, + ) + return json.loads(sbom.read_text(encoding="utf-8")), sbom.name + + def test_the_sbom_does_not_record_the_file_being_written(self): + document, sbom_name = self._make_test_sbom() + names = {component["name"] for component in document["components"]} + self.assertNotIn(sbom_name, names) + + def test_the_sbom_lists_ggml(self): + document, _ = self._make_test_sbom() + names = {component["name"] for component in document["components"]} + self.assertIn("ggml", names) + + +if __name__ == "__main__": + unittest.main() From 1f57455a3450baaadeea77749777eee8ada2bd03 Mon Sep 17 00:00:00 2001 From: nomoreshow <45514669+nomoreshow@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:04:36 +0300 Subject: [PATCH 16/37] Fix cross-platform test assumptions --- tests/test_ggml.py | 1 + tests/test_packaging.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 94a69b5..e97254f 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -205,6 +205,7 @@ class InstallProgram(Local): # These fixtures are Ubuntu release archives. Keep checking that path # on every host, including the Mac that checks the macOS backend. self.patch_attr(sys, "platform", "linux") + self.patch_attr(ggml.platform, "machine", lambda: "x86_64") # Built once, because the release listing has to publish its checksum # and a tarball is not the same bytes twice. self.archive = tarball({ diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 2d63e6e..f9addff 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -21,7 +21,8 @@ WORKFLOW = ROOT / ".github" / "workflows" / "whisper-vulkan.yml" class WhisperVulkanPackaging(unittest.TestCase): - @unittest.skipUnless(shutil.which("bash"), "bash is unavailable") + @unittest.skipUnless(sys.platform != "win32" and shutil.which("bash"), + "bash syntax check is unavailable") def test_the_release_scripts_parse_as_shell(self): for name in ("build-package.sh", "validate-package.sh", "smoke-runtime.sh"): @@ -108,6 +109,7 @@ class WhisperVulkanPackaging(unittest.TestCase): self.assertIn("libvulkan-dev=", dockerfile) self.assertIn("shaderc=", dockerfile) key = (PACKAGING / "lunarg-signing-key-pub.asc").read_bytes() + key = key.replace(b"\r\n", b"\n") self.assertEqual( "aa1c3c29673140e77f0d6a9aaeed5d9b5621e305ead51c59fae4458bbb4df92b", hashlib.sha256(key).hexdigest(), From 956c3eaf3c1ec21e0ae133330eed2d8bc88e86ec Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:16:49 +0300 Subject: [PATCH 17/37] Leave the tar extraction fallback where it was Refusing the install on a Python without the extraction filters is a change to how every archive on every platform is unpacked, and it has nothing to do with shipping a Vulkan whisper-server. On those Pythons the download stops working altogether, which is a worse answer than the one that was there. Worth doing on its own terms, in its own change, where the versions it turns away can be argued about without a backend release riding on it. --- dikte/ggml.py | 7 ++----- tests/test_ggml.py | 12 ------------ 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/dikte/ggml.py b/dikte/ggml.py index 792a72d..00e9929 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -353,11 +353,8 @@ def _extract(archive, into): with tarfile.open(archive, "r:gz") as tar: try: tar.extractall(into, filter="data") - except TypeError as exc: # Python 3.11.0-3 lack extraction filters - raise LocalError(t( - "Could not safely unpack {name} with this Python version", - name=os.path.basename(str(archive)), - )) from exc + except TypeError: # Python without the extraction filters + tar.extractall(into) except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc: raise LocalError(t("Could not unpack {name}: {error}", name=os.path.basename(str(archive)), error=exc)) from exc diff --git a/tests/test_ggml.py b/tests/test_ggml.py index e97254f..184f6fc 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -500,18 +500,6 @@ class InstallProgram(Local): with self.assertRaises(ggml.LocalError): self.install("whisper-bin-ubuntu-x64.tar.gz", archive=buf.getvalue()) - def test_python_without_safe_tar_filters_refuses_the_archive(self): - archive = self.path("bundle.tar.gz") - archive.write_bytes(self.archive) - destination = self.path("unpacked") - destination.mkdir() - with mock.patch.object(tarfile.TarFile, "extractall", - side_effect=[TypeError("no filter"), None]) as extract: - with self.assertRaises(ggml.LocalError) as caught: - ggml._extract(archive, destination) - self.assertEqual(1, extract.call_count) - self.assertIn("safely", str(caught.exception)) - 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): From 59180b8efff18736cab98811744f003f73b3cfd4 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:16:59 +0300 Subject: [PATCH 18/37] Say when the processor build landed instead of the Vulkan one The install falls back to upstream's CPU archive whenever Dikte's own release, the file in it, or its reviewed digest is not there, and until the package is published by hand that is every download. It happened without a word: the window said "Downloaded, version v1.9.3." either way, and a graphics card sitting idle looks exactly like one being used. The install record now carries which of the two builds landed, written only where both were on offer, and the settings window says so on the line that already reports the version. --- dikte/ggml.py | 42 ++++++++++++++++++++++++++++++++++++------ dikte/i18n.py | 3 +++ dikte/settings_ui.py | 7 +++++++ tests/test_ggml.py | 19 +++++++++++++++++++ tests/test_ui.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/dikte/ggml.py b/dikte/ggml.py index 00e9929..fd863b9 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -284,6 +284,17 @@ def _wanted_assets(program): return (f"bin-ubuntu-{arch}.tar.gz",) +def _managed_whisper(program, tag=""): + """Whether this machine is one Dikte publishes its own whisper-server for. + + Linux x86_64 with a Vulkan loader on it, and no version asked for by hand: + a pinned version is upstream's to answer. + """ + return (not tag and program is WHISPER and sys.platform == "linux" + and platform.machine().lower() in ("x86_64", "amd64") + and _has_vulkan()) + + def _install_record(program): return BIN_DIR / program.name / "installed.json" @@ -307,6 +318,21 @@ def installed_version(program): return _read_record(program).get("tag") or "" +def vulkan_missing(program): + """Whether what Dikte installed is the processor build on a machine the + Vulkan one was fetched for. + + The Vulkan whisper-server is a release of Dikte's own, published by hand + once the reviewed archive is built, and the install falls back to the + upstream processor build whenever that release, the file in it, or its + reviewed digest is not there. Nothing is wrong with the fallback except + that it is invisible: a graphics card sitting idle looks exactly like a + graphics card being used. + """ + return (bool(installed_program(program)) + and _read_record(program).get("backend") == "processor") + + def program_path(program, custom=""): """Which copy of the program to run, or "" when there is none. @@ -382,10 +408,8 @@ def install_program(program, tag="", on_progress=None, should_stop=None, """ repo = program.repo release_tag = tag or "latest" - managed = (not tag and program is WHISPER and sys.platform == "linux" - and platform.machine().lower() in ("x86_64", "amd64") - and _has_vulkan()) - item = None + managed = _managed_whisper(program, tag) + item, vulkan = None, False if managed: repo = DIKTE_REPO release_tag = MANAGED_WHISPER_RELEASE @@ -398,6 +422,7 @@ def install_program(program, tag="", on_progress=None, should_stop=None, 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 @@ -476,8 +501,13 @@ def install_program(program, tag="", on_progress=None, should_stop=None, # Found under the sibling, run from the final directory. binary = into / binary.relative_to(fresh) # Written last, so the record never points at anything half-made. - _install_record(program).write_text( - json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8") + record = {"tag": tag, "binary": str(binary)} + if managed: + # 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" + _install_record(program).write_text(json.dumps(record), encoding="utf-8") except OSError as exc: raise LocalError(t("Could not install {name}: {error}", name=program.name, error=exc)) from exc diff --git a/dikte/i18n.py b/dikte/i18n.py index 23ff6ff..fc7476e 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -780,6 +780,9 @@ TR = { "Not installed.": "Kurulu değil.", "Installed on the system: {path}": "Sistemde kurulu: {path}", "Downloaded, version {version}.": "İndirildi, sürüm {version}.", + "Downloaded, version {version}. There was no Vulkan build, " + "so this one runs on the processor.": + "İndirildi, sürüm {version}. Vulkan sürümü yoktu, bu sürüm işlemcide çalışıyor.", "Fetching the model list…": "Model listesi çekiliyor…", "Downloading…": "İndiriliyor…", "Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 56771f8..c3155da 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -358,6 +358,13 @@ class LocalModelBox(QGroupBox): # 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)) + elif ggml.vulkan_missing(self.program): + # The download landed the processor build where the graphics card + # one belongs, and nothing else on this window would say so. + self.program_label.setText( + t("Downloaded, version {version}. There was no Vulkan build, " + "so this one runs on the processor.", + version=ggml.installed_version(self.program) or "?")) else: self.program_label.setText( t("Downloaded, version {version}.", diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 184f6fc..8dcfa0c 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -259,6 +259,7 @@ class InstallProgram(Local): "whisper-bin-ubuntu-vulkan-x64.tar.gz")) self.assertTrue(os.path.isfile(path)) self.assertEqual("v1.9.3", ggml.installed_version(ggml.WHISPER)) + self.assertFalse(ggml.vulkan_missing(ggml.WHISPER)) def test_an_explicit_whisper_version_still_comes_from_upstream(self): self.patch_attr(ggml, "_arch", lambda: "x64") @@ -339,6 +340,24 @@ class InstallProgram(Local): "whisper-bin-ubuntu-x64.tar.gz")) self.assertTrue(os.path.isfile(path)) + def test_a_fallback_to_the_processor_build_is_there_to_be_shown(self): + """Until the Vulkan package is published every download lands the + processor build, and a graphics card sitting idle looks exactly like + one being used. The window asks this and says so.""" + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: True) + managed = self.release("Dikte-1.1.0-x86_64.AppImage") + managed["tag_name"] = "whisper.cpp-v1.9.3" + upstream = self.release("whisper-bin-ubuntu-x64.tar.gz") + with fake_urlopen(managed, upstream, body(self.archive)): + ggml.install_program(ggml.WHISPER) + self.assertTrue(ggml.vulkan_missing(ggml.WHISPER)) + + def test_a_machine_with_no_vulkan_is_not_told_it_is_missing_one(self): + # Nothing was on offer to fall back from, so there is nothing to say. + self.install("whisper-bin-ubuntu-x64.tar.gz") + self.assertFalse(ggml.vulkan_missing(ggml.WHISPER)) + 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")): diff --git a/tests/test_ui.py b/tests/test_ui.py index 6273447..e147068 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -6,6 +6,7 @@ save, so a setting added to one half and not the other is silently reset the next time anybody presses Save. That is the failure this catches. """ +import json import os import sys import unittest @@ -1214,6 +1215,34 @@ class LocalModels(DikteTest): for row in range(box.repo.count())) self.assertGreaterEqual(view.minimumWidth(), widest) + 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, + # an idle graphics card looks exactly like one that is being used. + binary = self.path("bin/whisper/v1.9.3/whisper-server") + binary.parent.mkdir(parents=True) + binary.write_text("") + binary.chmod(0o755) + self.path("bin/whisper/installed.json").write_text(json.dumps( + {"tag": "v1.9.3", "binary": str(binary), "backend": "processor"})) + # A whisper-server on this machine's PATH would win over the download. + self.patch_attr(ggml.shutil, "which", lambda name: None) + label = self.window(cfg.Config()).local_whisper.program_label.text() + self.assertIn("v1.9.3", label) + self.assertIn("Vulkan", label) + + def test_an_ordinary_install_is_reported_without_a_word_about_vulkan(self): + binary = self.path("bin/whisper/v1.9.3/whisper-server") + binary.parent.mkdir(parents=True) + binary.write_text("") + binary.chmod(0o755) + self.path("bin/whisper/installed.json").write_text(json.dumps( + {"tag": "v1.9.3", "binary": str(binary)})) + self.patch_attr(ggml.shutil, "which", lambda name: None) + label = self.window(cfg.Config()).local_whisper.program_label.text() + self.assertIn("v1.9.3", label) + self.assertNotIn("Vulkan", label) + def test_only_the_chosen_transcriber_is_on_screen(self): window = self.window(self.config(transcribe_provider="openai")) self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row)) From c3bf328eef10c619ba3e10b08095aca49f3300b0 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:37:08 +0300 Subject: [PATCH 19/37] Build the bundle only for what the bundle is built from The trigger listed dikte/ggml.py, both test files and the two READMEs, which are among the files that change most often. A typo fix in the README started a run that builds a container image, compiles every Vulkan shader and starts two more containers, with a 45 minute timeout on it because that is roughly what it costs. Nothing is lost. What ties ggml.py to the release, the tag, version, commit and reviewed digest, is asserted in tests/test_packaging.py, and that file already runs on every pull request in milliseconds. --- .github/workflows/whisper-vulkan.yml | 9 ++++----- tests/test_packaging.py | 15 +++++++++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/whisper-vulkan.yml b/.github/workflows/whisper-vulkan.yml index e3bdf05..4e76d0c 100644 --- a/.github/workflows/whisper-vulkan.yml +++ b/.github/workflows/whisper-vulkan.yml @@ -1,15 +1,14 @@ name: whisper.cpp Vulkan bundle +# Only what the bundle is built from. Compiling the Vulkan shaders takes +# tens of minutes, and a README typo is not worth one: what ties ggml.py to +# this release is a handful of assertions in tests/test_packaging.py, and +# those run on every pull request in milliseconds. on: pull_request: paths: - packaging/whisper-vulkan/** - .github/workflows/whisper-vulkan.yml - - dikte/ggml.py - - tests/test_ggml.py - - tests/test_packaging.py - - README.md - - README.tr.md workflow_dispatch: inputs: whisper_version: diff --git a/tests/test_packaging.py b/tests/test_packaging.py index f9addff..9bffa10 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -58,11 +58,18 @@ class WhisperVulkanPackaging(unittest.TestCase): publish_script = publish_script.split(" run: |", 1)[1] self.assertNotIn("${{ inputs.", publish_script) - def test_bundle_ci_runs_when_its_installer_or_contract_changes(self): + def test_bundle_ci_runs_only_for_what_the_bundle_is_built_from(self): + """A 45 minute build on a README typo is a tax on every other change. + + What ties ggml.py to the release is checked in this file instead, and + this file runs on every pull request in milliseconds.""" workflow = WORKFLOW.read_text(encoding="utf-8") - for path in ("dikte/ggml.py", "tests/test_packaging.py", - "README.md", "README.tr.md"): - self.assertIn(f"- {path}", workflow) + trigger = workflow.split("workflow_dispatch:", 1)[0] + self.assertIn("- packaging/whisper-vulkan/**", trigger) + self.assertIn("- .github/workflows/whisper-vulkan.yml", trigger) + for path in ("dikte/ggml.py", "tests/test_ggml.py", + "tests/test_packaging.py", "README.md", "README.tr.md"): + self.assertNotIn(f"- {path}", trigger) def test_the_validator_checks_tar_links_before_extraction(self): validator = (PACKAGING / "validate-package.sh").read_text( From 00a5283adb9aedcd18129d05318011f3fa9bfc0f Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:45:00 +0300 Subject: [PATCH 20/37] Let a downloaded program be downloaded again The button disappeared the moment anything landed, and nothing else on the window asks for that download. So a machine that got the processor build before its graphics driver was installed can never be given the Vulkan one, and nobody can pick up a newer whisper.cpp either: both of those are the same missing control. It reads "Download again" once a copy is here, and stays hidden while a system one is on the PATH, because that is the copy that would run. --- dikte/i18n.py | 1 + dikte/settings_ui.py | 12 ++++++++++-- tests/test_ui.py | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/dikte/i18n.py b/dikte/i18n.py index fc7476e..7233c74 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -779,6 +779,7 @@ TR = { "Local model": "Yerel model", "Not installed.": "Kurulu değil.", "Installed on the system: {path}": "Sistemde kurulu: {path}", + "Download again": "Yeniden indir", "Downloaded, version {version}.": "İndirildi, sürüm {version}.", "Downloaded, version {version}. There was no Vulkan build, " "so this one runs on the processor.": diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index c3155da..978df52 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -349,10 +349,18 @@ class LocalModelBox(QGroupBox): path = ggml.program_path(self.program) if not path: self.program_label.setText(t("Not installed.")) + self.install_button.setText(t("Download")) self.install_button.setVisible(True) return - self.install_button.setVisible(not ggml.installed_program(self.program) - and not ggml.system_program(self.program)) + # A copy that is here is not a copy that is right. whisper.cpp releases + # every few weeks, and a graphics card installed after Dikte was + # changes which build this machine should be running; the button was + # hidden the moment anything landed, and nothing else on this window + # asks for the download again. + self.install_button.setText(t("Download again") + if ggml.installed_program(self.program) + else t("Download")) + self.install_button.setVisible(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 diff --git a/tests/test_ui.py b/tests/test_ui.py index e147068..ed86910 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -22,6 +22,7 @@ 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 ipc from dikte import overlay as overlay_module from dikte import paste @@ -1243,6 +1244,27 @@ class LocalModels(DikteTest): self.assertIn("v1.9.3", label) self.assertNotIn("Vulkan", label) + def test_a_downloaded_program_can_still_be_asked_for_again(self): + # The button used to disappear the moment anything landed, which left + # no way to pick up a newer whisper.cpp, or the Vulkan build on a + # machine whose driver was installed after Dikte was. + binary = self.path("bin/whisper/v1.9.3/whisper-server") + binary.parent.mkdir(parents=True) + binary.write_text("") + binary.chmod(0o755) + self.path("bin/whisper/installed.json").write_text(json.dumps( + {"tag": "v1.9.3", "binary": str(binary)})) + self.patch_attr(ggml.shutil, "which", lambda name: None) + box = self.window(cfg.Config()).local_whisper + self.assertTrue(box.install_button.isVisibleTo(box)) + self.assertEqual(box.install_button.text(), t("Download again")) + + def test_a_system_copy_is_not_offered_for_download(self): + # Nothing Dikte downloads would be run while one is on the PATH. + self.patch_attr(ggml.shutil, "which", lambda name: "/usr/bin/" + name) + box = self.window(cfg.Config()).local_whisper + self.assertFalse(box.install_button.isVisibleTo(box)) + def test_only_the_chosen_transcriber_is_on_screen(self): window = self.window(self.config(transcribe_provider="openai")) self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row)) From 10ef4e62a9b9631607c77ee18d53142055e3c700 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:45:19 +0300 Subject: [PATCH 21/37] Smoke-test the bundle the way Dikte starts it The CPU run passed -ng, which tells whisper.cpp not to look for a backend at all, so the claim the bundle rests on, that it starts and transcribes where Vulkan cannot, was never tested. Dikte passes -ng only when its GPU setting is off; every other run goes through the backend registry. Both runs lose the flag, and a third one is added between them: the loader installed, nothing behind it. That is a real machine, libvulkan pulled in by something else with no driver to go with it, and it is the one whose answer nobody knew. --- .github/workflows/whisper-vulkan.yml | 3 +++ .../whisper-vulkan/Dockerfile.runtime-noicd | 9 +++++++++ packaging/whisper-vulkan/smoke-runtime.sh | 20 +++++++++++++++---- tests/test_packaging.py | 14 +++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 packaging/whisper-vulkan/Dockerfile.runtime-noicd diff --git a/.github/workflows/whisper-vulkan.yml b/.github/workflows/whisper-vulkan.yml index 4e76d0c..ef4a28c 100644 --- a/.github/workflows/whisper-vulkan.yml +++ b/.github/workflows/whisper-vulkan.yml @@ -110,6 +110,9 @@ jobs: - name: CPU fallback smoke test (no Vulkan loader) run: OUT_DIR=dist packaging/whisper-vulkan/smoke-runtime.sh cpu + - name: Vulkan loader present, no device smoke test + run: OUT_DIR=dist packaging/whisper-vulkan/smoke-runtime.sh noicd + - name: Vulkan plugin-load smoke test (Mesa llvmpipe) run: OUT_DIR=dist packaging/whisper-vulkan/smoke-runtime.sh vulkan diff --git a/packaging/whisper-vulkan/Dockerfile.runtime-noicd b/packaging/whisper-vulkan/Dockerfile.runtime-noicd new file mode 100644 index 0000000..bda8b71 --- /dev/null +++ b/packaging/whisper-vulkan/Dockerfile.runtime-noicd @@ -0,0 +1,9 @@ +FROM ubuntu@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc +ARG DEBIAN_FRONTEND=noninteractive +# The loader and nothing behind it: the machine that has libvulkan because +# something else pulled it in, and no driver to go with it. +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + ca-certificates curl libstdc++6 libvulkan1 \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /bundle diff --git a/packaging/whisper-vulkan/smoke-runtime.sh b/packaging/whisper-vulkan/smoke-runtime.sh index 22c0626..ac71319 100755 --- a/packaging/whisper-vulkan/smoke-runtime.sh +++ b/packaging/whisper-vulkan/smoke-runtime.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -mode=${1:?usage: smoke-runtime.sh cpu|vulkan} +mode=${1:?usage: smoke-runtime.sh cpu|noicd|vulkan} : "${OUT_DIR:=work/out}" : "${FIXTURE_SOURCE:=vendor/whisper.cpp}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -10,6 +10,7 @@ FIXTURE_SOURCE="$(realpath "$FIXTURE_SOURCE")" asset=whisper-bin-ubuntu-vulkan-x64 case "$mode" in cpu) dockerfile=Dockerfile.runtime-cpu; image=dikte-whisper-runtime-cpu:spike ;; + noicd) dockerfile=Dockerfile.runtime-noicd; image=dikte-whisper-runtime-noicd:spike ;; vulkan) dockerfile=Dockerfile.runtime-vulkan; image=dikte-whisper-runtime-vulkan:spike ;; *) echo "unknown mode: $mode" >&2; exit 2 ;; esac @@ -23,9 +24,10 @@ args=("/bundle/$asset/whisper-server" -m /fixtures/model.bin --host 127.0.0.1 --port 8080 --inference-path /v1/audio/transcriptions -l auto -sns -nlp) env_args=() -if [[ "$mode" == cpu ]]; then - args+=(-ng) -else +# No -ng anywhere: Dikte passes it only when its GPU setting is off, so the +# run that has to survive a missing loader or a missing device is this one, +# where the backend registry actually goes looking for them. +if [[ "$mode" == vulkan ]]; then env_args=(-e LIBGL_ALWAYS_SOFTWARE=1 -e VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json) fi @@ -41,6 +43,16 @@ docker run --rm --name "dikte-whisper-$mode-smoke" \ echo "CPU smoke image unexpectedly has a Vulkan loader" >&2 exit 1 fi + if [ "$SMOKE_MODE" = noicd ]; then + if ! ldconfig -p | grep -q libvulkan.so.1; then + echo "no-ICD smoke image has no Vulkan loader to load" >&2 + exit 1 + fi + if compgen -G "/usr/share/vulkan/icd.d/*.json" >/dev/null; then + echo "no-ICD smoke image has a driver after all" >&2 + exit 1 + fi + fi "$@" >/tmp/server.log 2>&1 & pid=$! trap "kill $pid 2>/dev/null || true" EXIT diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 9bffa10..fdbfcb9 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -39,6 +39,7 @@ class WhisperVulkanPackaging(unittest.TestCase): "Verify reviewed archive digest", "Validate archive and ELF contract", "CPU fallback smoke test (no Vulkan loader)", + "Vulkan loader present, no device smoke test", "Vulkan plugin-load smoke test (Mesa llvmpipe)", "Publish dependency release"): self.assertIn(step, workflow) @@ -71,6 +72,19 @@ class WhisperVulkanPackaging(unittest.TestCase): "tests/test_packaging.py", "README.md", "README.tr.md"): self.assertNotIn(f"- {path}", trigger) + def test_the_smoke_tests_run_what_dikte_runs(self): + """-ng is what Dikte passes when its GPU setting is off, and a run + with it never asks for a backend at all. The three runs that have to + hold are the ones without it: no loader, a loader with nothing behind + it, and a working device.""" + script = (PACKAGING / "smoke-runtime.sh").read_text(encoding="utf-8") + code = "\n".join(line for line in script.splitlines() + if not line.lstrip().startswith("#")) + self.assertNotIn("-ng", code) + for mode in ("cpu)", "noicd)", "vulkan)"): + self.assertIn(mode, script) + self.assertTrue((PACKAGING / "Dockerfile.runtime-noicd").is_file()) + def test_the_validator_checks_tar_links_before_extraction(self): validator = (PACKAGING / "validate-package.sh").read_text( encoding="utf-8") From 5f6e4ad78245dfadfae36ada91412c462b114674 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:45:48 +0300 Subject: [PATCH 22/37] Make a version bump possible without editing the workflow first The digest was a literal in the workflow while the version was an input, so a dispatch for anything but 1.9.3 built the archive and then failed its own gate. The digest of a version nobody has reviewed cannot be known before it is built, which made the inputs unusable for the one job they exist for. An empty expected_sha256 now reports the digest of what was built and refuses to publish; a digest handed in is checked the way the literal was. The input shapes are also checked before the checkout that uses them rather than after it. --- .github/workflows/whisper-vulkan.yml | 49 +++++++++++++++++++++++----- tests/test_packaging.py | 14 ++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/.github/workflows/whisper-vulkan.yml b/.github/workflows/whisper-vulkan.yml index ef4a28c..eb9bc32 100644 --- a/.github/workflows/whisper-vulkan.yml +++ b/.github/workflows/whisper-vulkan.yml @@ -21,6 +21,14 @@ on: required: true default: "371b5a7561823ab2bb32142d2751e35e7534727b" type: string + expected_sha256: + description: >- + Reviewed archive SHA-256. Leave empty for a version this file has + not reviewed: the digest of what was built is reported instead of + being checked, and publishing is refused. + required: false + default: "" + type: string publish: description: Publish a Dikte dependency release required: true @@ -37,7 +45,8 @@ concurrency: env: WHISPER_VERSION: ${{ inputs.whisper_version || '1.9.3' }} WHISPER_COMMIT: ${{ inputs.whisper_commit || '371b5a7561823ab2bb32142d2751e35e7534727b' }} - MANAGED_WHISPER_SHA256: c25ca76504144da488eb74441390a7b9aa7ce547e5f2f391cbd831253c9b54d8 + REVIEWED_WHISPER_VERSION: "1.9.3" + REVIEWED_WHISPER_SHA256: c25ca76504144da488eb74441390a7b9aa7ce547e5f2f391cbd831253c9b54d8 jobs: build: @@ -49,6 +58,13 @@ jobs: with: persist-credentials: false + - name: Validate source coordinates + shell: bash + run: | + set -euo pipefail + [[ "$WHISPER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + [[ "$WHISPER_COMMIT" =~ ^[0-9a-f]{40}$ ]] + - name: Check out pinned whisper.cpp source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -58,13 +74,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Validate source coordinates - shell: bash - run: | - set -euo pipefail - [[ "$WHISPER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] - [[ "$WHISPER_COMMIT" =~ ^[0-9a-f]{40}$ ]] - - name: Verify source version and commit shell: bash run: | @@ -92,9 +101,31 @@ jobs: - name: Verify reviewed archive digest shell: bash + env: + EXPECTED_SHA256: ${{ inputs.expected_sha256 }} + PUBLISH: ${{ inputs.publish }} run: | + set -euo pipefail read -r actual _ < dist/whisper-bin-ubuntu-vulkan-x64.tar.gz.sha256 - test "$actual" = "$MANAGED_WHISPER_SHA256" + echo "built archive sha256: $actual" + expected="$EXPECTED_SHA256" + if [ -z "$expected" ] \ + && [ "$WHISPER_VERSION" = "$REVIEWED_WHISPER_VERSION" ]; then + expected="$REVIEWED_WHISPER_SHA256" + fi + if [ -z "$expected" ]; then + # The digest of a version nobody has reviewed yet cannot be known + # before it is built. Reporting it is the whole point of the run; + # a release out of it is not. + if [ "${PUBLISH:-false}" = true ]; then + echo "refusing to publish an archive whose digest has not been reviewed" >&2 + exit 1 + fi + echo "::notice::no reviewed digest for $WHISPER_VERSION." \ + "Review the one above, then dispatch again with expected_sha256." + exit 0 + fi + test "$actual" = "$expected" - name: Validate archive and ELF contract run: OUT_DIR=dist packaging/whisper-vulkan/validate-package.sh diff --git a/tests/test_packaging.py b/tests/test_packaging.py index fdbfcb9..dde74b6 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -85,6 +85,20 @@ class WhisperVulkanPackaging(unittest.TestCase): self.assertIn(mode, script) self.assertTrue((PACKAGING / "Dockerfile.runtime-noicd").is_file()) + def test_an_unreviewed_version_is_reported_and_never_published(self): + """The digest of a version nobody has reviewed cannot be known before + it is built, so the gate cannot be the only way through.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("expected_sha256", workflow) + self.assertIn( + "refusing to publish an archive whose digest has not been reviewed", + workflow) + + def test_the_shape_of_the_inputs_is_checked_before_they_are_used(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertLess(workflow.index("- name: Validate source coordinates"), + workflow.index("- name: Check out pinned whisper.cpp")) + def test_the_validator_checks_tar_links_before_extraction(self): validator = (PACKAGING / "validate-package.sh").read_text( encoding="utf-8") From 156d8bf8e8b596b0ef3804b53f0f40a494e486c0 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:46:08 +0300 Subject: [PATCH 23/37] Write the release order down where it will be read Enabling immutable releases, configuring the dependency-release environment, which order the two dispatch runs go in, and which five constants have to move together were all in the pull request description, which is not a place anybody looks a year later. The same file says what this costs: the pinned digest means a backend update is a Dikte release, and the build is deterministic between two runs of one builder rather than across time, because the apt metadata behind the pinned packages is not pinned and LunarG drops superseded ones. Both are worth knowing before the next version bump, not during it. The README keeps a clause instead of the paragraph it had grown. --- README.md | 7 ++--- README.tr.md | 5 ++- packaging/whisper-vulkan/README.md | 49 ++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 packaging/whisper-vulkan/README.md diff --git a/README.md b/README.md index 601311e..048b652 100644 --- a/README.md +++ b/README.md @@ -163,10 +163,9 @@ running. the program and the model, verifies the sha256 and refuses a download published without one, then keeps a server alive while you dictate. The graphics card is reached through CUDA, ROCm or Vulkan where the build allows. No key, no - account, nothing leaving the machine. On x86_64 Linux, that same Download - button tries the reviewed Vulkan bundle when a loader is present, and falls - back to upstream's CPU build if it is unavailable. The bundle also carries CPU - backends for systems where a Vulkan device cannot start. + account, nothing leaving the machine. On x86_64 Linux the same button fetches + a Vulkan build of whisper-server that Dikte publishes itself, because + upstream's Linux archive is processor-only. - **Silence never reaches the API.** Handed near-silence, a transcription model invents a sentence instead of returning nothing ("Thanks for watching", or in Turkish "Altyazı M.K."). A recording is dropped when nothing rose 10 dB above diff --git a/README.tr.md b/README.tr.md index 0fc592f..0fb43e4 100644 --- a/README.tr.md +++ b/README.tr.md @@ -160,9 +160,8 @@ olmasını ister. checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu ayakta tutar. Derleme destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok. - x86_64 Linux'ta aynı İndir düğmesi, Vulkan yükleyicisi varsa incelenmiş Vulkan - paketini dener; paket kullanılamıyorsa upstream'in işlemci derlemesine döner. - Vulkan aygıtı başlatılamadığında kullanılacak işlemci arka uçları da pakettedir. + x86_64 Linux'ta aynı düğme, whisper-server'ın Dikte'nin kendi yayınladığı + Vulkan derlemesini indirir; upstream'in Linux arşivi yalnızca işlemci için. - **Sessizlik API'ye gitmez.** Sessize yakın bir ses verildiğinde model boş dize döndürmez, bir cümle uydurur ("Altyazı M.K.", "Thanks for watching"). *O kaydın kendi* gürültü tabanının 10 dB üstüne en az 0,3 saniye çıkan bir şey diff --git a/packaging/whisper-vulkan/README.md b/packaging/whisper-vulkan/README.md new file mode 100644 index 0000000..371b7c9 --- /dev/null +++ b/packaging/whisper-vulkan/README.md @@ -0,0 +1,49 @@ +# The Vulkan whisper-server bundle + +whisper.cpp publishes a CPU-only archive for Linux, so the graphics card on a +Linux machine is out of reach through the Download button. This directory +builds the archive upstream does not: `whisper-server` with a dynamic Vulkan +backend next to the CPU ones, for x86_64, against the Ubuntu 22.04 runtime +contract. + +It is published as a release of Dikte's own, `whisper.cpp-v`, marked +as a prerelease and kept off Latest so that neither the update check nor the +download page picks it up. `dikte/ggml.py` fetches it by tag and installs it +only when the archive's digest is the reviewed one; anything else falls back +to upstream's CPU archive, and the settings window says when it did. + +## Publishing a new bundle + +1. Enable GitHub's immutable releases setting for the repository, and give the + `dependency-release` environment a required reviewer. Both are repository + settings, not something this workflow can do for itself. +2. Run **whisper.cpp Vulkan bundle** on `master` with the new version and its + peeled commit, `expected_sha256` empty and `publish: false`. The run builds + the archive and reports its digest; without a reviewed digest it refuses to + publish, which is what the first run is for. +3. Review that digest against a build of your own, then run the workflow again + with the same version and commit, `expected_sha256` set to it, and + `publish: true`. Approve the environment when it asks. +4. Write the same version, tag and digest into `MANAGED_WHISPER_RELEASE`, + `MANAGED_WHISPER_VERSION` and `MANAGED_WHISPER_SHA256` in `dikte/ggml.py`, + and into `REVIEWED_WHISPER_VERSION` and `REVIEWED_WHISPER_SHA256` in the + workflow. `tests/test_packaging.py` holds the two sides together. +5. Ship a Dikte release. Until one goes out, nobody's Dikte knows the new + bundle exists. + +## What this costs, and what it does not promise + +The digest lives in Dikte's source, so a backend update is a Dikte release. +Linux x86_64 machines with a Vulkan loader stay on the pinned whisper.cpp +version until step 5 happens, while every other platform follows upstream's +newest release on its own. That is the deliberate trade: an executable Dikte +downloads is not allowed to change without a reviewed digest behind it. + +The build is deterministic between two runs of the same builder, not across +time. The base image, the CMake tarball, the LunarG packages and the direct +apt packages are pinned by digest or version, but the Ubuntu and LunarG +repository metadata behind them is not, and LunarG drops superseded packages. +A rebuild months later can fail to resolve, or resolve to something that +produces a different digest. Treat the published archive as the artifact, not +as something reproducible on demand: a version bump means building, +validating, reviewing the new digest and updating the pinned tuple together. From cfeed2af8c30270a9565f2332bddefa36c532a4f Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:47:22 +0300 Subject: [PATCH 24/37] Find the llama.cpp builds where they are actually published "latest" for llama.cpp is a version marker carrying one file, nightly-tag.txt, and the archives it names hang off a prerelease that "latest" never points at. Reading only the latest release meant Dikte offered no llama-server for any machine, so _pick_asset now follows that pointer, and when there is none it walks the recent releases and takes the newest one that does carry a build for this machine. hub grows releases() for the listing and text() for the pointer file; the pointer is read rather than cached, because what it carries is a few bytes on the way to a download that is checksummed in full. The extra lookups are best effort: whatever goes wrong in them leaves the caller's own message standing, but a first release that could not be fetched at all is kept and re-raised when nothing else turns up, so an unreachable GitHub still reads as an unreachable GitHub rather than as a machine nobody publishes for. --- dikte/ggml.py | 70 ++++++++++++++++++++++++++++++++++++++++++---- dikte/hub.py | 52 +++++++++++++++++++++++++++++++--- tests/test_ggml.py | 39 ++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 10 deletions(-) diff --git a/dikte/ggml.py b/dikte/ggml.py index c2f8da8..03a43ef 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -83,6 +83,10 @@ LLAMA = Program("llama", "ggml-org/llama.cpp", "llama-server", "/health") WHISPER_MODELS_REPO = "ggerganov/whisper.cpp" LLM_AUTHOR = "ggml-org" +# The file llama.cpp attaches to its version releases in place of the binaries: +# a line naming the nightly tag those are published under. +NIGHTLY_TAG = "nightly-tag.txt" + # What the whisper repository holds besides models: Core ML encoders for Apple # hardware and the odd loose file. WHISPER_PREFIX = "ggml-" @@ -277,6 +281,65 @@ def _wanted_assets(program): return (f"bin-ubuntu-{arch}.tar.gz",) +def _matching_asset(program, assets): + """The archive this machine wants out of one release's files, or None.""" + for ending in _wanted_assets(program): + item = next((a for a in assets if a.name.endswith(ending)), None) + if item: + return item + return None + + +def _pick_asset(program, tag="", refresh=False): + """(tag, Item) for the release archive to install. Item is None when there + is none for this machine. + + A named tag is taken as given. For the newest, what GitHub answers is not + always where the builds are: llama.cpp's latest release is a version marker + carrying a single nightly-tag.txt, which names the tag the archives are + actually attached to, and those are prereleases that "latest" never points + at. The pointer is followed when it is there, and when it is not, the newest + release that does carry a build for this machine is taken instead. + """ + named = bool(tag) and tag != "latest" + missing = None + try: + tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh) + except hub.HubError as exc: + # A release carrying no files at all is the case the search below exists + # for, not a reason to stop before it: the build for this machine may be + # attached to a prerelease that "latest" never points at. The failure is + # kept rather than dropped, because an unreachable GitHub arrives here + # the same way and that one is the message the caller wants. + if named: + raise + missing, assets = exc, [] + item = _matching_asset(program, assets) + if item or named: + return tag, item + # Best effort from here on: a machine this project publishes nothing for is + # not a failed lookup, and the caller's message about that is the useful + # one. Whatever goes wrong while looking further leaves it standing. + try: + pointer = next((a for a in assets if a.name == NIGHTLY_TAG), None) + if pointer: + nightly = hub.text(pointer.url).strip() + if nightly: + found, assets = hub.release(program.repo, nightly, refresh=refresh) + item = _matching_asset(program, assets) + if item: + return found, item + for found, assets in hub.releases(program.repo, refresh=refresh): + item = _matching_asset(program, assets) + if item: + return found, item + except hub.HubError: + pass + if missing is not None: + raise missing + return tag, None + + def _install_record(program): return BIN_DIR / program.name / "installed.json" @@ -375,15 +438,10 @@ def install_program(program, tag="", on_progress=None, should_stop=None, whisper.cpp has one. """ try: - tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh) + tag, item = _pick_asset(program, tag, refresh=refresh) except hub.HubError as exc: raise LocalError(str(exc)) from exc - item = None - for ending in _wanted_assets(program): - item = next((a for a in assets if a.name.endswith(ending)), None) - if item: - break if item is None: # Nothing to download and nothing to install for you: whisper.cpp # publishes no macOS binary, and Homebrew's whisper-cpp is configured diff --git a/dikte/hub.py b/dikte/hub.py index f5da71a..8c19f5f 100644 --- a/dikte/hub.py +++ b/dikte/hub.py @@ -121,6 +121,13 @@ def _digest(value): return value.split(":", 1)[1] if value.startswith("sha256:") else value +def _assets(data): + return [Item(a.get("name") or "", a.get("browser_download_url") or "", + int(a.get("size") or 0), _digest(a.get("digest"))) + for a in (data.get("assets") or []) + if a.get("browser_download_url")] + + def release(repo, tag="latest", refresh=False): """(tag, [Item]) for one GitHub release, newest when no tag is given.""" where = "latest" if tag in ("", "latest") else f"tags/{tag}" @@ -128,10 +135,47 @@ def release(repo, tag="latest", refresh=False): f"{GITHUB_API}/repos/{repo}/releases/{where}", refresh=refresh) if not isinstance(data, dict) or not data.get("assets"): raise HubError(t("{repo} has no downloadable release.", repo=repo)) - assets = [Item(a.get("name") or "", a.get("browser_download_url") or "", - int(a.get("size") or 0), _digest(a.get("digest"))) - for a in data["assets"] if a.get("browser_download_url")] - return data.get("tag_name") or tag, assets + return data.get("tag_name") or tag, _assets(data) + + +def releases(repo, limit=20, refresh=False): + """[(tag, [Item])] for the recent releases, newest first, with their files. + + "latest" is one release and this is the list behind it, prereleases + included: a project that attaches its builds to a prerelease is invisible + to release() above, and its newest usable build is in here. + """ + data = _fetch(f"gh-list-{repo}-{limit}", + f"{GITHUB_API}/repos/{repo}/releases?per_page={limit}", + refresh=refresh) + if not isinstance(data, list): + raise HubError(t("{repo} has no downloadable release.", repo=repo)) + out = [] + for entry in data: + tag, items = entry.get("tag_name") or "", _assets(entry) + if tag and items: + out.append((tag, items)) + return out + + +def text(url, limit=4096, timeout=20): + """A small text file from a release, as a string. + + Not cached and not checksummed, because what it carries is a pointer: a few + bytes naming the release the actual archives are attached to, read once on + the way to a download that is checked in full. + """ + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read(limit).decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + exc.close() + raise HubError(t("{url} answered HTTP {code}.", + url=urllib.parse.urlsplit(url).netloc, code=exc.code)) from exc + except (urllib.error.URLError, OSError, ValueError) as exc: + raise HubError(t("Could not reach {url}: {error}", + url=urllib.parse.urlsplit(url).netloc, error=exc)) from exc def newest_release(repo, refresh=False): diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 44b9305..33633c4 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -238,6 +238,45 @@ class InstallProgram(Local): "whisper-bin-ubuntu-x64.tar.gz") self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz")) + def test_the_nightly_pointer_is_followed_to_where_the_builds_are(self): + """llama.cpp's latest release carries a tag name, not the binaries.""" + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: False) + marker = self.release(ggml.NIGHTLY_TAG) + nightly = dict(self.release("llama-b10809-bin-ubuntu-x64.tar.gz"), + tag_name="b10809") + + def opener(request, timeout=None): + url = request.full_url + if url.endswith("/releases/latest"): + return json_body(marker) + if url.endswith("/releases/tags/b10809"): + return json_body(nightly) + if url.endswith(ggml.NIGHTLY_TAG): + return body(b"b10809\n") + return body(self.archive) + + with mock.patch("urllib.request.urlopen", side_effect=opener): + tag, found = ggml._pick_asset(ggml.LLAMA) + self.assertEqual(tag, "b10809") + self.assertEqual(found.name, "llama-b10809-bin-ubuntu-x64.tar.gz") + + def test_without_a_pointer_the_newest_release_that_has_a_build_is_taken(self): + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: False) + marker = self.release("source.zip") + listing = [dict(self.release("llama-b2-bin-win-cpu-x64.zip"), tag_name="b2"), + dict(self.release("llama-b1-bin-ubuntu-x64.tar.gz"), tag_name="b1")] + + def opener(request, timeout=None): + url = request.full_url + return json_body(listing if "per_page" in url else marker) + + with mock.patch("urllib.request.urlopen", side_effect=opener): + tag, found = ggml._pick_asset(ggml.LLAMA) + self.assertEqual(tag, "b1") + self.assertEqual(found.name, "llama-b1-bin-ubuntu-x64.tar.gz") + def test_a_release_with_nothing_for_this_machine_says_so(self): self.patch_attr(ggml, "_arch", lambda: "x64") with fake_urlopen(self.release("whisper-bin-Win32.zip")): From fff9cd1c55079f5d019003374724f13944143743 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:47:42 +0300 Subject: [PATCH 25/37] Keep the publisher and the model boxes saying the same thing Changing the publisher left the model box untouched: the old selection was carried over, added back as "not downloaded" and selected again, so a model the new repository does not publish could be saved against it. The selection is now only carried within the publisher it was made in, every keystroke in the publisher box no longer starts its own request, and a list that comes back for a publisher that is no longer chosen is dropped rather than answering the wrong one. The status line grew two things it could not say before. A row rebuilt from a name alone carries no file to fetch, and the Download button stayed lit over it doing nothing; those rows now say the publisher does not offer the model, and the button is out. A model that is here while the program above it is not no longer reads "Ready", which is what had people asking why nothing transcribed. --- dikte/i18n.py | 6 ++++ dikte/settings_ui.py | 67 ++++++++++++++++++++++++++++++++++++----- tests/test_ui.py | 71 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 8 deletions(-) diff --git a/dikte/i18n.py b/dikte/i18n.py index 23ff6ff..961a663 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -787,6 +787,12 @@ TR = { "Ready: {name}.": "Hazır: {name}.", "Nothing downloaded yet.": "Henüz bir şey indirilmedi.", "{name} has not been downloaded yet.": "{name} henüz indirilmedi.", + "{name} is here, but the program above is not. Download it first.": + "{name} burada, ama yukarıdaki program değil. Önce onu indirin.", + "{name} is not on this machine and this publisher does not offer it. " + "Choose another model, or another publisher.": + "{name} bu makinede yok ve bu yayıncı da sunmuyor. Başka bir model, " + "ya da başka bir yayıncı seçin.", "downloaded": "indirildi", "not downloaded": "indirilmedi", "Delete model": "Modeli sil", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 56771f8..ccf136c 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -6,7 +6,7 @@ import shutil import sys import threading -from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal +from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QTimer, QUrl, pyqtSignal from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog, @@ -247,6 +247,13 @@ class LocalModelBox(QGroupBox): self._pending = False self._stop = False self._wanted = "" # the model to select once a list arrives + self._chosen_in = "" # the publisher the selected model is from + # Typing or arrowing through the publisher box changes its text a + # character at a time, and each of those would otherwise be a request. + self._later = QTimer(self) + self._later.setSingleShot(True) + self._later.setInterval(400) + self._later.timeout.connect(self._later_fetch) form = QFormLayout(self) @@ -328,6 +335,8 @@ class LocalModelBox(QGroupBox): self._wanted = model self._pending = True self._show_program() + self._chosen_in = repo or (ggml.SUGGESTED_LLM[0] if self._repos is not None + else "") if self._repos is not None: self.repo.blockSignals(True) self.repo.clear() @@ -367,11 +376,18 @@ class LocalModelBox(QGroupBox): def _fill_repos(self, current): def work(): - self._listed.emit([("repos", ggml.llm_repos())], "") + self._listed.emit([("repos", ggml.llm_repos(), "")], "") threading.Thread(target=work, daemon=True).start() def _repo_changed(self): + if not self._downloading: + self._later.start() + + def _later_fetch(self): + # A download that started inside the wait was not there to be seen when + # the timer went off, and rebuilding the rows underneath one is exactly + # what the guard above is for. if not self._downloading: self._fetch_models(self.repository()) @@ -381,18 +397,29 @@ class LocalModelBox(QGroupBox): def work(): try: found = self._models(repo) if self._repos is not None else self._models() - self._listed.emit([("models", found)], "") + self._listed.emit([("models", found, repo)], "") except ggml.LocalError as exc: - self._listed.emit([], str(exc)) + self._listed.emit([("models", [], repo)], str(exc)) threading.Thread(target=work, daemon=True).start() def _on_listed(self, payload, error): + kind, found, repo = payload[0] if payload else ("repos", [], "") + # A publisher changed while its predecessor's list was still on the way + # would otherwise be answered with the wrong models, whichever request + # happened to come back last. + if kind == "models" and repo != self.repository(): + return if error: + # The list is the publisher's, so a failed one leaves the box no + # longer showing this publisher's models: emptying it is what keeps + # the two boxes saying the same thing. The message goes on after, + # because filling the box writes a status of its own. + if kind == "models": + self._fill_models([]) + self._refresh_buttons() self.status.setText(error) - self._refresh_buttons() return - kind, found = payload[0] if kind == "repos": current = self.repo.currentText() self.repo.blockSignals(True) @@ -406,7 +433,12 @@ class LocalModelBox(QGroupBox): def _fill_models(self, items): """One row per model, saying what it weighs and whether it is here.""" - wanted = self._wanted or self.selected() + # The selection is only worth carrying over within the publisher it was + # made in. Carried across one, a model this repository does not publish + # would be added back as "not downloaded" and selected again, and + # changing the publisher would leave the model box looking untouched. + same = self._repos is None or self.repository() == self._chosen_in + wanted = self._wanted or (self.selected() if same else "") here = [name for name in (self._model_path(i.name).name for i in items)] self.model.blockSignals(True) self.model.clear() @@ -431,6 +463,7 @@ class LocalModelBox(QGroupBox): self.model.blockSignals(False) self._fit_popup(self.model) self._wanted = "" + self._chosen_in = self.repository() self._model_changed() def _on_disk(self): @@ -459,6 +492,9 @@ class LocalModelBox(QGroupBox): self._show_program() if error: self.program_label.setText(error) + # The model line says whether the program is here, so installing one + # changes what it should read. + self._refresh_buttons() self.changed.emit() def _current_item(self): @@ -545,15 +581,30 @@ class LocalModelBox(QGroupBox): def _refresh_buttons(self): name = self.selected() here = bool(name) and ggml.have_model(self._model_path(name)) + # A row carries what it takes to fetch it. The ones that do not are the + # models found on this disk and the one the settings name but the list + # does not offer: there is nothing to press Download for on those, and + # a button that can only do nothing is worse than one that is out. + item = self._current_item() self.delete_button.setEnabled(here and not self._downloading) self.download_button.setText(t("Stop") if self._downloading else t("Download")) - self.download_button.setEnabled(self._downloading or (bool(name) and not here)) + self.download_button.setEnabled(self._downloading or (item is not None + and not here)) if self._downloading: return if not name: self.status.setText(t("Nothing downloaded yet.")) + elif here and not ggml.program_path(self.program): + # The model alone runs nothing, and "Ready" over a missing program + # reads as though it does. + self.status.setText(t("{name} is here, but the program above is " + "not. Download it first.", name=name)) elif here: self.status.setText(t("Ready: {name}.", name=name)) + elif item is None: + self.status.setText(t("{name} is not on this machine and this " + "publisher does not offer it. Choose another " + "model, or another publisher.", name=name)) else: self.status.setText(t("{name} has not been downloaded yet.", name=name)) diff --git a/tests/test_ui.py b/tests/test_ui.py index 6273447..ca821a4 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -8,6 +8,7 @@ next time anybody presses Save. That is the failure this catches. import os import sys +import time import unittest from typing import ClassVar from unittest import mock @@ -21,6 +22,7 @@ from dikte import cleanup from dikte import config as cfg from dikte import ggml from dikte import hotkey +from dikte import hub from dikte import ipc from dikte import overlay as overlay_module from dikte import paste @@ -1214,6 +1216,75 @@ class LocalModels(DikteTest): for row in range(box.repo.count())) self.assertGreaterEqual(view.minimumWidth(), widest) + @staticmethod + def _item(name, size=1 << 20): + return hub.Item(name, f"https://example.invalid/{name}", size, "") + + def test_a_row_with_nothing_to_fetch_does_not_offer_a_download(self): + # The model the settings name is not in the list any more, so its row + # was rebuilt from the name alone and carries no file to fetch. The + # button stayed lit and the press did nothing at all. + box = self.window(self.config(local_llm_model="gone.gguf")).local_llm + box.load("gone.gguf", "ggml-org/SmolLM3-3B-GGUF") + self.assertEqual(box.selected(), "gone.gguf") + self.assertFalse(box.download_button.isEnabled()) + self.assertIn("gone.gguf", box.status.text()) + self.assertIn("publisher", box.status.text()) + + def test_a_model_without_its_program_does_not_say_it_is_ready(self): + # The model runs on the program above it, and "Ready" over a missing + # one is what had people asking why nothing transcribed. + box = self.window(cfg.Config()).local_whisper + path = ggml.whisper_model_path("ggml-small.bin") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"not really a model") + box.load("ggml-small.bin") + self.assertFalse(ggml.program_path(ggml.WHISPER)) + self.assertNotIn("Ready", box.status.text()) + self.assertIn("program", box.status.text()) + + def test_changing_the_publisher_changes_the_model(self): + # The model chosen under the old publisher is not published by the new + # one. Carried over, it was added back as "not downloaded" and selected + # again, and the box looked as though the change had not taken. + box = self.window(self.config(local_llm_model="gemma-3-4b-it-Q4_K_M.gguf", + local_llm_repo="ggml-org/gemma-3-4b-it-GGUF")).local_llm + box.load("gemma-3-4b-it-Q4_K_M.gguf", "ggml-org/gemma-3-4b-it-GGUF") + box.repo.blockSignals(True) + box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(False) + box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")], + "ggml-org/SmolLM3-3B-GGUF")], "") + self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf") + self.assertEqual(box.model.count(), 1) + + def test_a_list_for_a_publisher_that_is_no_longer_chosen_is_dropped(self): + # Every change starts its own request, and they do not come back in the + # order they went out. + box = self.window(cfg.Config()).local_llm + box.load("", "ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(True) + box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF") + box.repo.blockSignals(False) + box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")], + "ggml-org/SmolLM3-3B-GGUF")], "") + box._on_listed([("models", [self._item("gemma-3-4b-it-Q4_K_M.gguf")], + "ggml-org/gemma-3-4b-it-GGUF")], "") + self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf") + + def test_the_publisher_box_is_not_asked_on_every_keystroke(self): + box = self.window(cfg.Config()).local_llm + with mock.patch.object(box, "_fetch_models") as fetch: + for text in ("g", "gg", "ggm", "ggml-org/SmolLM3-3B-GGUF"): + box.repo.setCurrentText(text) + fetch.assert_not_called() + box._later.setInterval(0) + box._later.start() + _app.processEvents() + time.sleep(0.05) + _app.processEvents() + self.assertEqual(fetch.call_count, 1) + def test_only_the_chosen_transcriber_is_on_screen(self): window = self.window(self.config(transcribe_provider="openai")) self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row)) From fb80d85332f2bcfa44f1838feeb5d4a04e422fb1 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 09:47:50 +0300 Subject: [PATCH 26/37] Stop two tests failing on what is around them The macOS path test set XDG_CONFIG_HOME to "/c" and asserted that "/c" was nowhere in the answer, but the home these run under is a mkdtemp path, so any TMPDIR with a "/c" in it failed the test. macOS is exactly where that happens: its temporary directories are /var/folders//, and one letter in thirty-six starts with c. The needle is now a word no directory can be called. The other is the `ask` verb reading what was piped into it. Nothing was, but the runner's own stdin is not nothing either, so the verb read whatever the runner left there. It now reads an empty stream, whichever runner is in use. --- tests/test_cli.py | 5 +++++ tests/test_paths.py | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 67c6f21..a3ffb55 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ socket is faked, and everything that runs locally runs for real. import contextlib import io import json +import sys import unittest import webbrowser from typing import ClassVar @@ -668,7 +669,11 @@ class WithoutAnInstance(DikteTest): def run_verb(self, argv): # launch_gui replaces this process with the application, so it never # comes back in real use and must not be allowed to here. + # `ask` with no text reads what was piped in, and the runner's own + # stdin is not that: under pytest it is an object that refuses to be + # read at all. with mock.patch.object(ipc, "send", return_value=None), \ + mock.patch.object(sys, "stdin", io.StringIO()), \ mock.patch.object(cli, "launch_gui") as launch, \ captured() as (out, err): code = cli.run(argv) diff --git a/tests/test_paths.py b/tests/test_paths.py index df84a09..af54bde 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -42,9 +42,12 @@ class Directories(unittest.TestCase): def test_a_mac_does_not_read_the_xdg_variables(self): """A Mac with them set from some other tool still stores in one place.""" - with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}): + # Something no temporary directory can be called: the home this runs + # under is a mkdtemp path, and a two-letter needle matched the "/c" in + # somebody's TMPDIR rather than the variable being read. + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/xdg-elsewhere"}): config_dir, _ = paths.directories("darwin") - self.assertNotIn("/c", config_dir.as_posix()) + self.assertNotIn("xdg-elsewhere", config_dir.as_posix()) def test_windows_keeps_the_models_out_of_the_roaming_profile(self): """Settings roam with the account; several gigabytes must not.""" From 08fc2e4a9df9df8f23b6b67fc3879ba25ca63919 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 11:12:42 +0300 Subject: [PATCH 27/37] Group the model lists and say which row this machine should take The two local model boxes handed over a flat list sorted by size and left every choice in it to the reader. For whisper that interleaved the models: large-v3-turbo-q5_0 landed between the two medium quantisations, half a screen from the turbo model it is a copy of. For cleanup it was forty repository ids, half of which answer with nothing at all because what they publish is split across files or larger than the cap, and an empty box read as though the click had not registered. Now each box says what the machine is, groups the list by model, and marks the row to take: - whisper rows are grouped by model, with the quantisations and the English-only files under the model they are a copy of, and every row says its bit depth rather than leaving q5_1 and Q4_K_M and BF16 to be decoded. - the recommendation follows the machine. Under 4 GB it is small-q5_1; with a graphics interface and 15 GB it is large-v3-q5_0, which is worth about two and a half points of word error in the languages that are not English; in between it is turbo, and a processor build where the Vulkan one belongs is not counted as a card. - a row larger than half the memory less a gigabyte says it is too big. - the publisher box holds the five suggestions until the switch beside it is turned on, and a line under it says in words what the chosen one is. - a publisher that answers with nothing says why instead of going blank. - the draft heads (dflash, dspark, eagle3) are no longer offered as models, and neither are the base models that sit beside their tuned twin. --- README.md | 4 +- README.tr.md | 4 +- dikte/ggml.py | 319 +++++++++++++++++++++++++++++++++++++++++-- dikte/i18n.py | 69 ++++++++++ dikte/settings_ui.py | 273 +++++++++++++++++++++++++++++++----- tests/test_ggml.py | 215 +++++++++++++++++++++++++++++ tests/test_ui.py | 185 ++++++++++++++++++++++++- 7 files changed, 1021 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 048b652..628b2d6 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,9 @@ running. - **It all runs on this machine by default.** Speech to text on whisper.cpp and cleanup on llama.cpp, neither installed beforehand: the settings window fetches the program and the model, verifies the sha256 and refuses a download published - without one, then keeps a server alive while you dictate. The graphics card is + without one, then keeps a server alive while you dictate. The model list is + grouped by model rather than by file size, and the row this machine's memory + and graphics can take is marked. The graphics card is reached through CUDA, ROCm or Vulkan where the build allows. No key, no account, nothing leaving the machine. On x86_64 Linux the same button fetches a Vulkan build of whisper-server that Dikte publishes itself, because diff --git a/README.tr.md b/README.tr.md index 0fb43e4..a243c12 100644 --- a/README.tr.md +++ b/README.tr.md @@ -158,7 +158,9 @@ olmasını ister. whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez: ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular, checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu - ayakta tutar. Derleme destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan + ayakta tutar. Model listesi dosya boyutuna değil modele göre gruplanır ve bu + makinenin belleğine ve ekran kartına uyan satır işaretlenir. Derleme + destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok. x86_64 Linux'ta aynı düğme, whisper-server'ın Dikte'nin kendi yayınladığı Vulkan derlemesini indirir; upstream'in Linux arşivi yalnızca işlemci için. diff --git a/dikte/ggml.py b/dikte/ggml.py index 51cf4d1..da17dde 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -26,6 +26,7 @@ interface already knows how to show. import atexit import collections +import ctypes import ctypes.util import hashlib import http.client @@ -98,28 +99,126 @@ NIGHTLY_TAG = "nightly-tag.txt" # hardware and the odd loose file. WHISPER_PREFIX = "ggml-" WHISPER_SUFFIX = ".bin" +# The mark on the whisper models trained on English alone. They are half of the +# list, and they belong under the model they are a variant of rather than +# scattered through it by size. +ENGLISH_ONLY = ".en" + +# Full-precision weights, however they are spelled. Several times the memory of +# a quantisation of the same model, for a difference dictation and cleanup +# cannot see, so nothing here ever points at one. +SIXTEEN_BIT = ("bf16", "f16", "fp16") + +# How many bits a weight is stored in, read off the file name. Every one of +# these lists spells it differently, `q5_1` and `Q4_K_M` and `MXFP4` and +# `BF16`, and the only part of that anybody choosing between two rows needs is +# the number. Longest mark first, so `bf16` is not read as `f16`. +BIT_DEPTHS = (("mxfp4", 4), ("bf16", 16), ("fp16", 16), ("f16", 16), + ("q2", 2), ("q3", 3), ("q4", 4), ("q5", 5), ("q6", 6), ("q8", 8)) # 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-") +# multimodal model, and mtp, dflash, dspark and eagle3 are draft heads for +# speculative decoding. None of them is a model a server can be started on, and +# they are the small files in the repository, so a list sorted by size puts them +# at the top where they are likeliest to be clicked. +GGUF_SKIP = ("mmproj", "mtp-", "dflash-", "dspark-", "eagle3-", "draft-") # 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 +# Repositories that carry GGUF files but nothing a cleanup server can be started +# on: a vision or audio tower with no text half worth running, a speech model, +# and the base models, which continue text rather than following an instruction +# and answer a cleanup prompt by carrying on writing the transcript. +LLM_REPO_SKIP = ("-Base-GGUF", "-VL-", "-Vision-", "-Omni-", "-Video-", + "-TTS-", "parakeet", "test-") + +GB = 1 << 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. +# the rows that float to the top of it. Cleanup is punctuation, capitals and +# filler words rather than anything that wants thinking about, so what it is +# picked on is instruction following at a size a desktop can spare. Gemma 4 +# scores 94.6 on IFEval at E2B and 96.7 at E4B, and E2B leads here rather than +# E4B because two points of instruction following is not worth twice the +# weights on a job that runs while somebody waits for their sentence to appear. +# SmolLM3 and Gemma 3 are the older pair below them. Qwen3.5 0.8B is for the +# machines nothing else fits on; it thinks before it answers, which is what the +# Thinking box in the settings window turns off. 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/gemma-3-4b-it-GGUF", "ggml-org/SmolLM3-3B-GGUF", + "ggml-org/Qwen3.5-0.8B-GGUF", ) +# Roughly what each of those weighs at the quantisation cleanup would run, to +# the nearest half gigabyte. Not a catalogue of files: the sizes on the rows +# come from the publisher, and this only decides which suggestion is offered +# first on a machine that has room for some of them and not others. +SUGGESTED_LLM_SIZE = { + "ggml-org/gemma-4-E2B-it-GGUF": 3 * GB, + "ggml-org/gemma-4-E4B-it-GGUF": 5 * GB, + "ggml-org/gemma-3-4b-it-GGUF": 5 * GB // 2, + "ggml-org/SmolLM3-3B-GGUF": 2 * GB, + "ggml-org/Qwen3.5-0.8B-GGUF": GB // 2, +} + +# What each of them is, in the words somebody choosing between them would +# use. A repository id says the publisher, the parameter count, the shape of +# the weights and nothing at all about whether it is the one to click, and +# `ggml-org/gemma-4-E2B-it-GGUF` reads as four pieces of jargon to everybody +# who has not been reading model cards all year. +SUGGESTED_LLM_NOTE = { + "ggml-org/gemma-4-E2B-it-GGUF": + "Google Gemma 4, the small one. The default: nothing else this size " + "follows an instruction as closely, and cleanup is all instruction.", + "ggml-org/gemma-4-E4B-it-GGUF": + "The same model one size up. A little more accurate, about twice the " + "weights and twice the wait.", + "ggml-org/gemma-3-4b-it-GGUF": + "The previous Gemma. Still good, and the smallest of the Gemmas here.", + "ggml-org/SmolLM3-3B-GGUF": + "Hugging Face's own small model, for a machine the Gemmas crowd.", + "ggml-org/Qwen3.5-0.8B-GGUF": + "The smallest of them, for a machine nothing else fits on. It thinks " + "before it answers unless Thinking below is off.", +} + # 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". +# usual "start small" advice point at the same file as "start good". It is +# large-v3 with the decoder cut from 32 layers to 4: several times faster, at +# one to two points of word error in English and about two and a half in the +# other languages. SUGGESTED_WHISPER = "ggml-large-v3-turbo-q5_0.bin" +# Those two and a half points back, for twice the file and several times the +# work per second. Only suggested where there is a card to do the work and +# memory to hold it, because that is where the trade stops costing anything a +# person waiting for a dictation would notice. +ACCURATE_WHISPER = "ggml-large-v3-q5_0.bin" +# What to point at instead on a machine the turbo model would crowd. Same +# quantisation ladder, one rung down in size and in accuracy. +SMALL_MACHINE_WHISPER = "ggml-small-q5_1.bin" +# Under this much system memory, a 600 MB model plus the rest of a desktop is +# already tight, so the suggestion drops to the smaller one. Over the other, +# the accurate model is the one to point at. +SMALL_MACHINE = 4 * GB +# Fifteen and not sixteen: what the machine reports is what is left after the +# firmware and the graphics have taken their reservations out of it, and a +# 16 GB machine answers about 15.4. A threshold written at the number on the +# box is one no machine sold as that size ever reaches. +ROOMY_MACHINE = 15 * GB +# What a model may take of this machine's memory before it is called too big: +# half of it, less a gigabyte for the context and the runtime around the +# weights. A rule of thumb rather than a measurement, and deliberately a +# cautious one, because the failure it is guarding against is a machine that +# swaps itself to a standstill rather than a model that refuses to load. +MEMORY_SHARE = 0.5 +MEMORY_OVERHEAD = GB +# What is left to offer on a machine too small for the sum above to leave +# anything. Enough for the smallest whisper models and for a sub-billion +# cleanup model, which is what such a machine can run. +MEMORY_FLOOR = GB // 2 class LocalError(Exception): @@ -605,9 +704,190 @@ def _drop_old_versions(program, keep): pass +# --- what this machine can run -------------------------------------------- + + +def total_memory(): + """Bytes of memory on this machine, or 0 when it cannot be read. + + Zero is a real answer and not a failure: every caller treats an unknown + machine as one big enough for whatever it is looking at, because a wrong + "too big" is worse advice than none. + """ + try: + return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") + except (AttributeError, ValueError, OSError): + pass + if sys.platform == "darwin": + # Not every build of Python on a Mac has SC_PHYS_PAGES in its sysconf + # table, and this is the number the system itself is asked for. + try: + out = subprocess.run(["sysctl", "-n", "hw.memsize"], check=True, + capture_output=True, text=True, timeout=5) + return int(out.stdout.strip()) + except (OSError, ValueError, subprocess.SubprocessError): + return 0 + if sys.platform != "win32": + return 0 + + class Status(ctypes.Structure): + _fields_ = [("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong)] + + try: + status = Status() + status.dwLength = ctypes.sizeof(Status) + if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)): + return int(status.ullTotalPhys) + except (AttributeError, OSError, ValueError): + pass + return 0 + + +def accelerator(): + """The graphics interface this machine offers, or "". + + The machine's half of the answer only. Whether a card is actually reached + also depends on which build landed, and the program line above says that: + a processor build ignores the card whatever is installed here. What this + is for is the other half, which nothing else on the window says at all. + """ + if sys.platform == "darwin": + return "Metal" + return "Vulkan" if _has_vulkan() else "" + + +def memory_budget(memory=None): + """What a model may weigh on this machine, or 0 when that is unknown. + + Floored rather than allowed to reach zero: on a 2 GB machine the share + less the overhead is nothing at all, and a budget of nothing is the same + number this returns for a machine it could not read, which would turn the + tightest machine there is into the one where everything is offered. + """ + memory = total_memory() if memory is None else memory + if not memory: + return 0 + return max(int(memory * MEMORY_SHARE) - MEMORY_OVERHEAD, MEMORY_FLOOR) + + +def fits(size, memory=None): + """Whether a model of this size is worth offering on this machine.""" + budget = memory_budget(memory) + return not budget or size <= budget + + +def suggested_whisper(memory=None, graphics=None): + """The whisper model to point at here, by name. + + Three machines. One with no room, which gets the model that leaves some. + One with a card and memory to spare, which gets the accurate model, because + the several times the work it is per second is several times a fraction of + a second there. Everything in between gets turbo, which is the answer + almost every time somebody asks. + + A Vulkan or Metal loader is not proof of a fast card, so the accurate model + waits on the memory as well: a machine with 16 GB in it and a driver + installed is one that will not notice either way. + """ + memory = total_memory() if memory is None else memory + graphics = accelerator() if graphics is None else graphics + if memory and memory < SMALL_MACHINE: + return SMALL_MACHINE_WHISPER + if graphics and memory >= ROOMY_MACHINE: + return ACCURATE_WHISPER + return SUGGESTED_WHISPER + + +def suggested_llm(memory=None): + """The suggested cleanup repositories, the ones that fit here first. + + The order they are written in is the order they are worth having. What + this changes is only which of them a machine that cannot hold the best one + is shown first, and nothing is dropped: a model that does not fit today + fits once something else is closed. + """ + return sorted(SUGGESTED_LLM, + key=lambda repo: not fits(SUGGESTED_LLM_SIZE.get(repo, 0), + memory)) + + +def recommended(items, want="", memory=None): + """The one row out of `items` worth pointing at here, or "". + + `want` is taken when it is on offer and fits. Without it, which is the + cleanup list, the smallest file that does is taken: q4 is where these + lists start, and every rung above it is roughly twice the memory and twice + the wait for a difference neither dictation nor cleanup can see. The + 16-bit weights are left out for the same reason, twice over. + """ + fitting = [i for i in items if fits(i.size, memory)] + if want and any(i.name == want for i in fitting): + return want + usable = [i for i in fitting + if not any(mark in i.name.lower() for mark in SIXTEEN_BIT)] + return min(usable, key=lambda i: i.size).name if usable else "" + + # --- the models ----------------------------------------------------------- +def bit_depth(name): + """The bits per weight the file name says, or 0 when it says nothing.""" + lowered = name.lower() + for mark, bits in BIT_DEPTHS: + if mark in lowered: + return bits + return 0 + + +def whisper_family(name): + """The model a whisper file belongs to: ggml-small.en-q5_1.bin is `small`. + + The list arrives sorted by size and nothing else, which interleaves the + families: `large-v3-turbo-q5_0` lands between the two `medium` + quantisations, half a screen from the turbo model it is a copy of. Grouping + is what puts the choice between models above the choice of quantisation, + which is the order somebody actually makes them in. + """ + stem = name + if stem.startswith(WHISPER_PREFIX): + stem = stem[len(WHISPER_PREFIX):] + if stem.endswith(WHISPER_SUFFIX): + stem = stem[:-len(WHISPER_SUFFIX)] + head, _, last = stem.rpartition("-") + # q5_0, q5_1, q8_0. `turbo` is the other thing a last chunk can be, and it + # is part of the model's name rather than a quantisation of it. + if head and last.startswith("q") and last[1:].replace("_", "").isdigit(): + stem = head + return stem[:-len(ENGLISH_ONLY)] if stem.endswith(ENGLISH_ONLY) else stem + + +def whisper_groups(items): + """[(family, [Item])] for a whisper list: one group per model. + + Groups by how big the model gets rather than by a ladder written down + here, so a family published next year sorts itself. Inside one, the + multilingual files come before the English-only ones and the small + quantisations before the large. + """ + groups = {} + for item in items: + groups.setdefault(whisper_family(item.name), []).append(item) + ordered = sorted(groups.items(), + key=lambda pair: (max(i.size for i in pair[1]), pair[0])) + return [(family, sorted(files, + key=lambda i: (ENGLISH_ONLY in i.name, i.size))) + for family, files in ordered] + + def whisper_models(refresh=False): """[hub.Item] for every whisper model on offer, smallest first.""" try: @@ -620,10 +900,23 @@ def whisper_models(refresh=False): return sorted(models, key=lambda f: f.size) +def can_clean(repo): + """Whether a repository could hold a model cleanup can be started on. + + By name, because the alternative is a file listing per repository and the + list is forty of them. It catches the kinds that are never a cleanup model + rather than the ones that are too big, which the file sizes answer exactly + once a publisher is chosen. + """ + lowered = repo.lower() + return not any(mark.lower() in lowered for mark in LLM_REPO_SKIP) + + 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)] + found = [r.id for r in hub.repos(author=LLM_AUTHOR, refresh=refresh) + if can_clean(r.id)] 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 @@ -631,6 +924,14 @@ def llm_repos(refresh=False): found = [] if not found: return list(SUGGESTED_LLM) + # Gemma publishes its base models under the instruction-tuned one's name + # with the `-it` taken out, so the two sit next to each other in the list + # and the wrong one answers a cleanup prompt by carrying on writing the + # transcript. Dropped only where the tuned sibling is here to drop it for. + tuned = set(found) + found = [r for r in found + if not r.endswith("-GGUF") + or r[:-len("-GGUF")] + "-it-GGUF" not in tuned] first = [r for r in SUGGESTED_LLM if r in found] return first + [r for r in found if r not in first] diff --git a/dikte/i18n.py b/dikte/i18n.py index ea619b9..0fd0ef5 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -804,6 +804,75 @@ TR = { "ya da başka bir yayıncı seçin.", "downloaded": "indirildi", "not downloaded": "indirilmedi", + "recommended": "önerilen", + "{bits}-bit": "{bits} bit", + "English only": "yalnızca İngilizce", + "All": "Tümü", + "Everything ggml-org publishes, including the models that are too big to " + "run here and the ones that are not for cleaning up text.": + "ggml-org'un yayımladığı her şey; burada çalıştırılamayacak kadar " + "büyük olanlar ve metin temizlemek için olmayanlar dahil.", + "Google Gemma 4, the small one. The default: nothing else this size " + "follows an instruction as closely, and cleanup is all instruction.": + "Google Gemma 4'ün küçüğü. Varsayılan: bu boyutta verilen yönergeyi " + "bu kadar iyi izleyen başka bir model yok, temizleme de baştan sona " + "yönerge demek.", + "The same model one size up. A little more accurate, about twice the " + "weights and twice the wait.": + "Aynı modelin bir boy büyüğü. Biraz daha isabetli, yaklaşık iki katı " + "ağırlık ve iki katı bekleyiş.", + "The previous Gemma. Still good, and the smallest of the Gemmas here.": + "Bir önceki Gemma. Hâlâ iyi ve buradaki Gemma'ların en küçüğü.", + "Hugging Face's own small model, for a machine the Gemmas crowd.": + "Hugging Face'in kendi küçük modeli; Gemma'ların sıkıştırdığı bir " + "makine için.", + "The smallest of them, for a machine nothing else fits on. It thinks " + "before it answers unless Thinking below is off.": + "En küçükleri; başka hiçbir şeyin sığmadığı bir makine için. " + "Aşağıdaki Düşünme kapalı değilse cevaplamadan önce düşünür.", + "too big for this machine": "bu makine için fazla büyük", + "This machine": "Bu makine", + "Graphics: {name}.": "Ekran kartı: {name}.", + "No graphics interface found, so this runs on the processor.": + "Ekran kartı arayüzü bulunamadı, bu yüzden işlemcide çalışıyor.", + "Memory: {size}.": "Bellek: {size}.", + "A model may take half of this memory, less a gigabyte for the context " + "around the weights. Anything past that is marked too big; it may still " + "load, on a machine with nothing else open.": + "Bir model bu belleğin yarısını, ağırlıkların çevresindeki bağlam için " + "bir gigabayt düşülerek kullanabilir. Bunu aşan modeller fazla büyük " + "diye işaretlenir; başka hiçbir şeyin açık olmadığı bir makinede yine " + "de yüklenebilirler.", + "Recommended for this machine": "Bu makine için önerilen", + "Everything this publisher offers": "Bu yayıncının sunduğu her şey", + "Already on this machine": "Bu makinede zaten var", + "Chosen, but not downloaded": "Seçili, ama indirilmedi", + "{repo} publishes nothing that can be run here. Its models are split " + "across files, larger than {cap}, or pieces of a model rather than one. " + "Choose another publisher.": + "{repo} burada çalıştırılabilecek bir şey yayımlamıyor. Modelleri " + "birden çok dosyaya bölünmüş, {cap} boyutundan büyük ya da modelin " + "kendisi değil parçaları. Başka bir yayıncı seçin.", + "large-v3 makes the fewest mistakes and is the slowest of them. " + "large-v3-turbo is that model with a four layer decoder in place of a " + "thirty-two layer one: several times faster, at one to two points of word " + "error in English and about two and a half in the other languages. Below " + "those, every step down the list trades accuracy for size, and the .en " + "models are trained on English alone.": + "En az hatayı large-v3 yapar, en yavaşı da odur. large-v3-turbo, aynı " + "modelin otuz iki katmanlı çözücüsü yerine dört katmanlı bir çözücü " + "konmuş hâli: birkaç kat hızlı, karşılığında İngilizcede bir iki " + "puan, diğer dillerde yaklaşık iki buçuk puan kelime hatası. Bunların " + "altında listede her basamak, doğruluğu boyuta değişir; .en modelleri " + "ise yalnızca İngilizce ile eğitilmiştir.", + "Cleanup is punctuation, capitals and filler words, so what these are " + "picked on is following an instruction rather than knowing anything. " + "Start at a q4 file; the 16-bit ones are several times the memory for a " + "difference this job cannot see.": + "Temizleme; noktalama, büyük harf ve dolgu sözcükleri demek, yani bu " + "modeller bir şey bilmelerine değil verilen yönergeyi izlemelerine " + "göre seçilir. Bir q4 dosyasından başlayın; 16 bitlik olanlar, bu işin " + "göremeyeceği bir fark için kat kat bellek ister.", "Delete model": "Modeli sil", "Delete {name} from this machine?": "{name} bu makineden silinsin mi?", "Runs on this machine, on llama.cpp.": "Bu makinede, llama.cpp üzerinde çalışır.", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 86b5ffc..34fbce4 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -248,6 +248,13 @@ class LocalModelBox(QGroupBox): self._stop = False self._wanted = "" # the model to select once a list arrives self._chosen_in = "" # the publisher the selected model is from + # Whether a list for the publisher on screen has come back. An empty + # box before one has is a box nobody has asked anything yet, and the + # two read the same without this. + self._answered = False + # What the last publisher listing held, so that the switch beside the + # box can be flipped without asking for it again. + self._found_repos = [] # 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) @@ -263,15 +270,55 @@ class LocalModelBox(QGroupBox): form.addRow(t("Program"), self._side_by_side(self.program_label, self.install_button)) + # What the model rows are judged against, said out loud. Without it, + # "too big for this machine" and the recommendation above the list are + # a verdict with no visible reason behind them. + self.machine_label = WrappedLabel() + self.machine_label.setToolTip( + t("A model may take half of this memory, less a gigabyte for the " + "context around the weights. Anything past that is marked too " + "big; it may still load, on a machine with nothing else open.")) + form.addRow(t("This machine"), self.machine_label) + self._show_machine() + 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) + # Forty repository ids is not a choice anybody can make. The few + # that were picked for this job are what the box holds until + # somebody asks for the rest. + self.every_repo = QCheckBox(t("All")) + self.every_repo.setToolTip( + t("Everything ggml-org publishes, including the models that " + "are too big to run here and the ones that are not for " + "cleaning up text.")) + self.every_repo.toggled.connect(self._every_repo_changed) + form.addRow(t("Publisher"), + self._side_by_side(self.repo, self.every_repo)) + # A repository id names the publisher, the parameter count and the + # shape of the weights, and says nothing about whether it is the + # one to click. + self.repo_note = WrappedLabel() + form.addRow("", self.repo_note) self.model = QComboBox() + self.model.setToolTip( + t("large-v3 makes the fewest mistakes and is the slowest of them. " + "large-v3-turbo is that model with a four layer decoder in place " + "of a thirty-two layer one: several times faster, at one to two " + "points of word error in English and about two and a half in " + "the other languages. Below those, every step down the list " + "trades accuracy for size, and the .en models are trained on " + "English alone.") + if program is ggml.WHISPER else + t("Cleanup is punctuation, capitals and filler words, so what " + "these are picked on is following an instruction rather than " + "knowing anything. Start at a q4 file; the 16-bit ones are " + "several times the memory for a difference this job cannot " + "see.")) self.download_button = QPushButton(t("Download")) self.download_button.clicked.connect(self._download) self.delete_button = QPushButton(t("Delete")) @@ -334,16 +381,16 @@ class LocalModelBox(QGroupBox): """ self._wanted = model self._pending = True + self._answered = False self._show_program() - self._chosen_in = repo or (ggml.SUGGESTED_LLM[0] if self._repos is not None - else "") + self._chosen_in = "" if self._repos is not None: + suggested = ggml.suggested_llm() + self._chosen_in = repo or suggested[0] 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.setCurrentText(self._chosen_in) self.repo.blockSignals(False) - self._fit_popup(self.repo) + self._fill_repos_box(suggested) self._fill_models([]) def showEvent(self, event): @@ -387,6 +434,15 @@ class LocalModelBox(QGroupBox): t("Downloaded, version {version}.", version=ggml.installed_version(self.program) or "?")) + def _show_machine(self): + where = ggml.accelerator() + memory = ggml.total_memory() + parts = [t("Graphics: {name}.", name=where) if where else + t("No graphics interface found, so this runs on the processor.")] + if memory: + parts.append(t("Memory: {size}.", size=ggml.human_size(memory))) + self.machine_label.setText(" ".join(parts)) + # ---- the lists ------------------------------------------------------- def _fill_repos(self, current): @@ -395,10 +451,50 @@ class LocalModelBox(QGroupBox): threading.Thread(target=work, daemon=True).start() + def _fill_repos_box(self, found): + """The publishers, with the suggested ones kept apart from the rest. + + Forty repositories in one run is a list nobody reads to the end of, and + the few worth starting from are lost in it. A separator rather than a + heading, because this box is typed into as well as chosen from and a + heading would land in the field as though it were a repository. + """ + self._found_repos = found + current = self.repo.currentText() + # Every suggestion, whether or not it came back in the listing: that + # listing is the forty repositories touched most recently, and a + # publisher that has not been updated in a season falls off it while + # still being the one to point at. + first = list(ggml.suggested_llm()) + rest = [r for r in found if r not in first] + if not self.every_repo.isChecked(): + # The one being used stays on offer whatever the switch says, so + # that a repository somebody typed in is not dropped out from + # under them by the next fetch. + rest = [r for r in rest if r == current] + self.repo.blockSignals(True) + self.repo.clear() + self.repo.addItems(first) + if first and rest: + self.repo.insertSeparator(self.repo.count()) + self.repo.addItems(rest) + self.repo.setCurrentText(current) + self.repo.blockSignals(False) + self._fit_popup(self.repo) + self._show_repo_note() + def _repo_changed(self): + self._show_repo_note() if not self._downloading: self._later.start() + def _show_repo_note(self): + note = ggml.SUGGESTED_LLM_NOTE.get(self.repository(), "") + self.repo_note.setText(t(note) if note else "") + + def _every_repo_changed(self): + self._fill_repos_box(self._found_repos) + 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 @@ -407,6 +503,7 @@ class LocalModelBox(QGroupBox): self._fetch_models(self.repository()) def _fetch_models(self, repo=""): + self._answered = False self.status.setText(t("Fetching the model list…")) def work(): @@ -436,45 +533,131 @@ class LocalModelBox(QGroupBox): self.status.setText(error) return 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) - self._fit_popup(self.repo) + self._fill_repos_box(found) return + self._answered = True self._fill_models(found) + def _sections(self, items, best): + """[(heading, [Item])] for the rows to show, in the order to show them. + + The list arrives sorted by size and nothing else, which for whisper + interleaves the models: `large-v3-turbo-q5_0` lands between the two + `medium` quantisations, half a screen away from the turbo model it is a + copy of. Grouping puts the choice of model above the choice of + quantisation, and the row this machine should take goes on top, where + somebody who does not want to make either choice can stop reading. + """ + if not items: + return [] + groups = (ggml.whisper_groups(items) if self.program is ggml.WHISPER + else [("", items)]) + # A publisher with one file on offer is not a choice, and a row of its + # own above the only row there is would be the same model twice. + top = [i for i in items if i.name == best] if len(items) > 1 else [] + if not top: + return groups + if len(groups) == 1 and not groups[0][0]: + groups = [(t("Everything this publisher offers"), groups[0][1])] + return [(t("Recommended for this machine"), top)] + groups + + def _suggested(self): + """The name to prefer when it is on offer, or "" for whatever fits.""" + if self.program is not ggml.WHISPER: + return "" + # A Vulkan loader on the machine is not a card in play when what was + # installed is the processor build: recommending the accurate model + # off the loader alone would put a 1 GB model on a processor and the + # wait for it in front of somebody who asked for a sentence. + return ggml.suggested_whisper( + graphics="" if ggml.vulkan_missing(self.program) else None) + + def _add_heading(self, text): + """A row that names the group under it and cannot be chosen.""" + self.model.addItem(text) + row = self.model.count() - 1 + font = self.model.font() + font.setBold(True) + self.model.setItemData(row, font, Qt.ItemDataRole.FontRole) + listing = self.model.model() + entry = listing.item(row) if hasattr(listing, "item") else None + if entry is not None: + entry.setEnabled(False) + + def _add_model(self, name, item, best): + """One row: the file, what it weighs, and whether it is worth taking.""" + here = ggml.have_model(self._model_path(name)) + if here: + marks = [t("downloaded")] + elif item is None: + # Chosen but neither here nor on offer: the file was deleted from + # underneath, or the settings came from another machine. + marks = [t("not downloaded")] + else: + marks = [ggml.human_size(item.size)] + # `q5_1`, `Q4_K_M`, `MXFP4`, `BF16`: four spellings of the same thing + # in one list, and the number is the whole of what any of them says. A + # whisper file with no mark at all is the full 16-bit model, which is + # the one convention here that a name does not carry. + bits = ggml.bit_depth(name) or (16 if self.program is ggml.WHISPER + else 0) + if bits: + marks.append(t("{bits}-bit", bits=bits)) + if ggml.ENGLISH_ONLY in name: + marks.append(t("English only")) + # The verdicts last, after everything the row is: what to do about the + # row rather than what it holds. + if item is not None and not here and not ggml.fits(item.size): + marks.append(t("too big for this machine")) + if name == best: + marks.append(t("recommended")) + self.model.addItem(f"{name} ({', '.join(marks)})", name) + self.model.setItemData(self.model.count() - 1, item, + Qt.ItemDataRole.UserRole + 1) + + def _first_model(self): + """The first row that is a model rather than a heading.""" + for row in range(self.model.count()): + if self.model.itemData(row): + return row + return -1 + def _fill_models(self, items): - """One row per model, saying what it weighs and whether it is here.""" + """One row per model, grouped, saying what it weighs and where it is.""" # 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)] + best = ggml.recommended(items, self._suggested()) if items else "" 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) + listed = set() + for heading, group in self._sections(items, best): + if heading: + self._add_heading(heading) + for item in group: + name = self._model_path(item.name).name + self._add_model(name, item, best) + listed.add(name) # 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) + # still on this disk and still works, so it stays on offer. So does one + # that is chosen but not here: Save reads this box, and a row missing + # here would quietly empty the setting rather than showing that the + # model needs downloading again. + extras = [(t("Already on this machine"), + [name for name in self._on_disk() if name not in listed])] + if wanted and wanted not in listed \ + and not ggml.have_model(self._model_path(wanted)): + extras.append((t("Chosen, but not downloaded"), [wanted])) + for heading, names in extras: + if names and listed: + self._add_heading(heading) + for name in names: + self._add_model(name, None, best) index = self.model.findData(wanted) - self.model.setCurrentIndex(max(index, 0)) + self.model.setCurrentIndex(index if index >= 0 else self._first_model()) self.model.blockSignals(False) self._fit_popup(self.model) self._wanted = "" @@ -569,10 +752,17 @@ class LocalModelBox(QGroupBox): 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())] + # By name, because the recommended model has a row of its own at the + # top as well as one in its group, and reading the rows back twice + # would double it in the list every time a download finished. + items, seen = [], set() + for row in range(self.model.count()): + item = self.model.itemData(row, Qt.ItemDataRole.UserRole + 1) + if item is not None and item.name not in seen: + seen.add(item.name) + items.append(item) self._wanted = self.selected() - self._fill_models([i for i in items if i is not None]) + self._fill_models(items) def _delete(self): name = self.selected() @@ -607,7 +797,18 @@ class LocalModelBox(QGroupBox): and not here)) if self._downloading: return - if not name: + if not name and self._repos is not None and self._answered \ + and self._first_model() < 0: + # An empty box under a publisher that answered perfectly well: what + # it publishes is split across files, past the size cap, or a + # projector or draft head rather than a model of its own. Said + # nowhere, it read as though the click had not registered. + self.status.setText( + t("{repo} publishes nothing that can be run here. Its models " + "are split across files, larger than {cap}, or pieces of a " + "model rather than one. Choose another publisher.", + repo=self.repository(), cap=ggml.human_size(ggml.GGUF_MAX_BYTES))) + elif 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 diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 3d86324..9a1897b 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -49,6 +49,11 @@ def item(name, data, url="https://example.invalid/f", sha=True): hashlib.sha256(data).hexdigest() if sha else "") +def listed(name, size): + """A row as a listing hands it over: a name and a size, no bytes.""" + return hub.Item(name, f"https://example.invalid/{name}", size, "a" * 64) + + @contextlib.contextmanager def serving(release, archive): """Answer by what is being asked for rather than by what came before. @@ -664,6 +669,47 @@ class Catalogue(Local): with self.assertRaises(ggml.LocalError): ggml.whisper_models() + def test_the_speculative_decoding_heads_are_not_models(self): + # They are the small files in a repository, so a list sorted by size + # puts them first, where the eye lands and the click goes. + tree = GGUF_TREE + [ + {"type": "file", "path": "dflash-Qwen3-8B-Q8_0.gguf", + "size": 1_120_000_000, "lfs": {"oid": "f" * 64}}, + {"type": "file", "path": "eagle3-gpt-oss-20b-Q8_0.gguf", + "size": 920_000_000, "lfs": {"oid": "0" * 64}}, + ] + with fake_urlopen(tree): + names = [q.name for q in ggml.llm_quants("ggml-org/x-GGUF")] + self.assertEqual(names, + ["gemma-3-4b-it-Q4_K_M.gguf", "gemma-3-4b-it-Q8_0.gguf"]) + + def test_a_speech_or_vision_repository_is_not_a_cleanup_publisher(self): + listing = [{"id": "ggml-org/parakeet-GGUF"}, + {"id": "ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF"}, + {"id": "ggml-org/SmolVLM2-256M-Video-Instruct-GGUF"}, + {"id": "ggml-org/Qwen3-8B-Base-GGUF"}, + {"id": "ggml-org/SmolLM3-3B-GGUF"}] + with fake_urlopen(listing): + found = ggml.llm_repos() + self.assertEqual([r for r in found if r.startswith("ggml-org/Smol")], + ["ggml-org/SmolLM3-3B-GGUF"]) + self.assertNotIn("ggml-org/parakeet-GGUF", found) + self.assertNotIn("ggml-org/Qwen3-8B-Base-GGUF", found) + + def test_a_base_model_beside_its_tuned_twin_is_dropped(self): + # Gemma names the base model after the tuned one with the `-it` taken + # out, so the two sit next to each other and the wrong one answers a + # cleanup prompt by carrying on writing the transcript. + listing = [{"id": "ggml-org/gemma-4-E2B-GGUF"}, + {"id": "ggml-org/gemma-4-E2B-it-GGUF"}, + {"id": "ggml-org/Qwen3-0.6B-GGUF"}] + with fake_urlopen(listing): + found = ggml.llm_repos() + self.assertNotIn("ggml-org/gemma-4-E2B-GGUF", found) + self.assertIn("ggml-org/gemma-4-E2B-it-GGUF", found) + # Nothing named it, so nothing says it is the wrong half of a pair. + self.assertIn("ggml-org/Qwen3-0.6B-GGUF", found) + 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") @@ -1183,3 +1229,172 @@ class WindowsOwnership(Local): # from here", and only one of those makes the pid file safe to drop. self.image("") self.assertIsNone(self.made._is_ours(1234)) + + +class Machine(Local): + """What this machine can hold, and what that makes worth pointing at.""" + + def test_the_memory_is_read_the_way_each_system_reports_it(self): + # Linux and most Macs answer through sysconf. + with mock.patch.object(ggml.os, "sysconf", lambda name: + 4096 if name == "SC_PAGE_SIZE" else 4_194_304): + self.assertEqual(ggml.total_memory(), 16 * ggml.GB) + + def test_a_mac_without_the_page_count_is_asked_for_the_number(self): + # Not every build of Python on a Mac carries SC_PHYS_PAGES, and a Mac + # that answered nothing would be a Mac with none of this on it. + def answer(args, **kwargs): + self.assertEqual(args, ["sysctl", "-n", "hw.memsize"]) + return mock.Mock(stdout=f"{32 * ggml.GB}\n") + + with mock.patch.object(ggml.os, "sysconf", side_effect=ValueError), \ + mock.patch.object(sys, "platform", "darwin"), \ + mock.patch.object(ggml.subprocess, "run", answer): + self.assertEqual(ggml.total_memory(), 32 * ggml.GB) + + def test_a_system_that_answers_nothing_is_an_unknown_machine(self): + with mock.patch.object(ggml.os, "sysconf", side_effect=ValueError), \ + mock.patch.object(sys, "platform", "linux"): + self.assertEqual(ggml.total_memory(), 0) + + def test_a_mac_is_taken_to_have_a_graphics_interface(self): + with mock.patch.object(sys, "platform", "darwin"): + self.assertEqual(ggml.accelerator(), "Metal") + + def test_elsewhere_the_vulkan_loader_is_what_says_so(self): + with mock.patch.object(sys, "platform", "linux"), \ + mock.patch.object(ggml.ctypes.util, "find_library", + lambda name: "/usr/lib/libvulkan.so.1"): + self.assertEqual(ggml.accelerator(), "Vulkan") + with mock.patch.object(sys, "platform", "linux"), \ + mock.patch.object(ggml.ctypes.util, "find_library", + lambda name: None): + self.assertEqual(ggml.accelerator(), "") + + def test_a_model_is_measured_against_half_the_memory(self): + self.assertTrue(ggml.fits(2 * ggml.GB, memory=8 * ggml.GB)) + self.assertFalse(ggml.fits(4 * ggml.GB, memory=8 * ggml.GB)) + + def test_a_machine_whose_memory_could_not_be_read_holds_anything(self): + # A wrong "too big" is worse advice than none. + self.assertTrue(ggml.fits(40 * ggml.GB, memory=0)) + + def test_the_smallest_machine_is_not_the_one_where_everything_fits(self): + # Half of 2 GB less the gigabyte of overhead is nothing, and a budget + # of nothing used to read as the unknown machine above. + self.assertFalse(ggml.fits(3 * ggml.GB, memory=2 * ggml.GB)) + + def test_a_crowded_machine_is_pointed_at_the_smaller_model(self): + self.assertEqual(ggml.suggested_whisper(memory=3 * ggml.GB, graphics=""), + ggml.SMALL_MACHINE_WHISPER) + + def test_a_card_and_the_memory_for_it_are_pointed_at_the_accurate_one(self): + self.assertEqual( + ggml.suggested_whisper(memory=32 * ggml.GB, graphics="Vulkan"), + ggml.ACCURATE_WHISPER) + + def test_memory_without_a_card_is_pointed_at_the_fast_one(self): + # Several times the work per second is several times a long wait on a + # processor, whatever there is room for. + self.assertEqual( + ggml.suggested_whisper(memory=32 * ggml.GB, graphics=""), + ggml.SUGGESTED_WHISPER) + + def test_a_sixteen_gigabyte_machine_counts_as_a_roomy_one(self): + # What a machine reports is what the firmware and the graphics left + # of it: 16 GB answers about 15.4, and a threshold written at the + # number on the box is one no machine ever reaches. + self.assertEqual( + ggml.suggested_whisper(memory=int(15.4 * ggml.GB), graphics="Metal"), + ggml.ACCURATE_WHISPER) + + def test_the_suggestion_that_fits_is_offered_first(self): + first = ggml.suggested_llm(memory=6 * ggml.GB)[0] + self.assertTrue(ggml.fits(ggml.SUGGESTED_LLM_SIZE[first], + memory=6 * ggml.GB)) + # Nothing is dropped: what does not fit today fits once something else + # is closed. + self.assertEqual(sorted(ggml.suggested_llm(memory=6 * ggml.GB)), + sorted(ggml.SUGGESTED_LLM)) + + def test_the_wanted_model_wins_when_there_is_room_for_it(self): + items = [listed("ggml-tiny.bin", 70 << 20), + listed("ggml-large-v3-turbo-q5_0.bin", 574 << 20)] + self.assertEqual( + ggml.recommended(items, "ggml-large-v3-turbo-q5_0.bin", + memory=16 * ggml.GB), + "ggml-large-v3-turbo-q5_0.bin") + + def test_a_model_too_big_for_the_machine_is_not_recommended(self): + items = [listed("small.gguf", 1 << 30), listed("huge.gguf", 12 * ggml.GB)] + self.assertEqual(ggml.recommended(items, "huge.gguf", + memory=8 * ggml.GB), "small.gguf") + + def test_the_full_precision_weights_are_never_the_recommendation(self): + # Twice the memory and twice the wait for a difference this job + # cannot see. + items = [listed("model-Q4_0.gguf", 2 * ggml.GB), + listed("model-BF16.gguf", 3 * ggml.GB)] + self.assertEqual(ggml.recommended(items, memory=32 * ggml.GB), + "model-Q4_0.gguf") + + def test_nothing_is_recommended_when_nothing_fits(self): + self.assertEqual( + ggml.recommended([listed("huge.gguf", 40 * ggml.GB)], + memory=8 * ggml.GB), "") + + +class Grouping(Local): + """One group per model, rather than one long list sorted by size.""" + + def test_every_spelling_of_a_quantisation_reads_as_its_number(self): + # One list holds q5_1, Q4_K_M, MXFP4 and BF16, and the number is the + # whole of what any of them says to somebody choosing a row. + self.assertEqual(ggml.bit_depth("ggml-small-q5_1.bin"), 5) + self.assertEqual(ggml.bit_depth("SmolLM3-Q4_K_M.gguf"), 4) + self.assertEqual(ggml.bit_depth("gpt-oss-20b-MXFP4.gguf"), 4) + self.assertEqual(ggml.bit_depth("gemma-4-E2B-it-Q8_0.gguf"), 8) + # bf16 is not f16 read badly. + self.assertEqual(ggml.bit_depth("gemma-4-E2B-it-BF16.gguf"), 16) + self.assertEqual(ggml.bit_depth("mmproj-model-f16.gguf"), 16) + # A whisper file with no mark is the full model, and its name is the + # one convention here that does not carry the answer. + self.assertEqual(ggml.bit_depth("ggml-large-v3-turbo.bin"), 0) + + def test_a_quantisation_belongs_to_the_model_it_is_a_copy_of(self): + self.assertEqual(ggml.whisper_family("ggml-small.en-q5_1.bin"), "small") + self.assertEqual(ggml.whisper_family("ggml-large-v3-q5_0.bin"), + "large-v3") + self.assertEqual(ggml.whisper_family("ggml-large-v3-turbo.bin"), + "large-v3-turbo") + self.assertEqual(ggml.whisper_family("ggml-medium.en.bin"), "medium") + + def test_turbo_is_a_model_and_not_a_quantisation(self): + # The last chunk of the name is a quantisation for most of the list + # and part of the model's name here. + self.assertEqual(ggml.whisper_family("ggml-large-v3-turbo-q8_0.bin"), + "large-v3-turbo") + + def test_the_turbo_files_are_not_scattered_through_the_medium_ones(self): + # Sorted by size alone, large-v3-turbo-q5_0 lands between the two + # medium quantisations, half a screen from the model it is a copy of. + models = [listed("ggml-medium-q5_0.bin", 539 << 20), + listed("ggml-large-v3-turbo-q5_0.bin", 574 << 20), + listed("ggml-medium-q8_0.bin", 823 << 20), + listed("ggml-large-v3-turbo.bin", 1624 << 20)] + groups = dict(ggml.whisper_groups(models)) + self.assertEqual([i.name for i in groups["large-v3-turbo"]], + ["ggml-large-v3-turbo-q5_0.bin", + "ggml-large-v3-turbo.bin"]) + self.assertEqual([i.name for i in groups["medium"]], + ["ggml-medium-q5_0.bin", "ggml-medium-q8_0.bin"]) + + def test_the_smallest_model_comes_first_and_the_english_ones_last(self): + models = [listed("ggml-small.en-q5_1.bin", 190 << 20), + listed("ggml-small-q5_1.bin", 190 << 20), + listed("ggml-tiny.bin", 77 << 20)] + groups = ggml.whisper_groups(models) + self.assertEqual([family for family, _ in groups], ["tiny", "small"]) + self.assertEqual([i.name for _, group in groups for i in group], + ["ggml-tiny.bin", "ggml-small-q5_1.bin", + "ggml-small.en-q5_1.bin"]) diff --git a/tests/test_ui.py b/tests/test_ui.py index aa2f29c..8e18db5 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1236,6 +1236,25 @@ class LocalModels(DikteTest): def _item(name, size=1 << 20): return hub.Item(name, f"https://example.invalid/{name}", size, "") + @staticmethod + def _rows(box): + """Every row's text, headings included.""" + return [box.model.itemText(row) for row in range(box.model.count())] + + @staticmethod + def _repos(box): + return [box.repo.itemText(row) for row in range(box.repo.count())] + + @staticmethod + def _offered(box): + """The model names in the box, headings and duplicates left out.""" + names = [] + for row in range(box.model.count()): + name = box.model.itemData(row) + if name and name not in names: + names.append(name) + return names + 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 @@ -1272,7 +1291,7 @@ class LocalModels(DikteTest): 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) + self.assertEqual(self._offered(box), ["SmolLM3-Q4_K_M.gguf"]) 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 @@ -1300,6 +1319,170 @@ class LocalModels(DikteTest): time.sleep(0.05) _app.processEvents() self.assertEqual(fetch.call_count, 1) + def test_the_models_are_grouped_by_the_model_rather_than_by_size(self): + # Sorted by size alone, the turbo files land between the two medium + # ones, half a screen from the model they are a copy of. + box = self.window(cfg.Config()).local_whisper + with mock.patch.object(ggml, "total_memory", return_value=8 << 30), \ + mock.patch.object(ggml, "accelerator", return_value=""): + box._on_listed([("models", [ + self._item("ggml-medium-q5_0.bin", 539 << 20), + self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20), + self._item("ggml-medium-q8_0.bin", 823 << 20), + self._item("ggml-large-v3-turbo.bin", 1624 << 20), + ], "")], "") + rows = self._rows(box) + # The two medium files under one heading, the two turbo ones under + # theirs, and the model rather than the file deciding the order. + self.assertEqual(rows[rows.index("medium"):], + ["medium", + "ggml-medium-q5_0.bin (539.0 MB, 5-bit)", + "ggml-medium-q8_0.bin (823.0 MB, 8-bit)", + "large-v3-turbo", + "ggml-large-v3-turbo-q5_0.bin " + "(574.0 MB, 5-bit, recommended)", + "ggml-large-v3-turbo.bin (1.6 GB, 16-bit)"]) + # A heading is not a model, and nothing can be saved from one. + self.assertIsNone(box.model.itemData(rows.index("medium"))) + + def test_the_row_for_this_machine_is_on_top_and_says_so(self): + box = self.window(cfg.Config()).local_whisper + with mock.patch.object(ggml, "total_memory", return_value=8 << 30), \ + mock.patch.object(ggml, "accelerator", return_value=""): + box._on_listed([("models", [ + self._item("ggml-tiny.bin", 77 << 20), + self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20), + ], "")], "") + self.assertEqual(box.selected(), "ggml-large-v3-turbo-q5_0.bin") + self.assertEqual(box.model.itemData(1), "ggml-large-v3-turbo-q5_0.bin") + self.assertIn(t("recommended"), box.model.itemText(1)) + + def test_a_model_the_memory_cannot_hold_says_so_on_its_row(self): + box = self.window(cfg.Config()).local_llm + box.repo.blockSignals(True) + box.repo.setCurrentText("ggml-org/x-GGUF") + box.repo.blockSignals(False) + with mock.patch.object(ggml, "total_memory", return_value=8 << 30): + box._on_listed([("models", [ + self._item("small-Q4_0.gguf", 1 << 30), + self._item("huge-Q8_0.gguf", 12 << 30), + ], "ggml-org/x-GGUF")], "") + rows = {box.model.itemData(row): box.model.itemText(row) + for row in range(box.model.count())} + self.assertNotIn(t("too big for this machine"), rows["small-Q4_0.gguf"]) + self.assertIn(t("too big for this machine"), rows["huge-Q8_0.gguf"]) + + def test_a_recommended_row_is_not_listed_twice_after_a_download(self): + # It has a row of its own on top as well as one in its group, and + # reading the rows back the way a finished download does was doubling + # it in the list every time. + box = self.window(cfg.Config()).local_whisper + box._on_listed([("models", [ + self._item("ggml-tiny.bin", 77 << 20), + self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20), + ], "")], "") + before = self._offered(box) + box._fill_models_from_current() + self.assertEqual(self._offered(box), before) + names = [box.model.itemData(row) for row in range(box.model.count())] + self.assertEqual(len([n for n in names if n]), len(before) + 1) + + def test_a_processor_build_is_not_recommended_the_accurate_model(self): + # The Vulkan loader is on the machine but what was installed is the + # processor build, so there is no card in play whatever the loader + # says, and a 1 GB model on a processor is a wait somebody is sitting + # through with a sentence half typed. + binary = self.path("bin/whisper/v1.9.3/whisper-server") + binary.parent.mkdir(parents=True) + binary.write_text("") + binary.chmod(0o755) + self.path("bin/whisper/installed.json").write_text(json.dumps( + {"tag": "v1.9.3", "binary": str(binary), "backend": "processor"})) + self.patch_attr(ggml.shutil, "which", lambda name: None) + box = self.window(cfg.Config()).local_whisper + with mock.patch.object(ggml, "total_memory", return_value=32 << 30), \ + mock.patch.object(ggml, "accelerator", return_value="Vulkan"): + self.assertEqual(box._suggested(), ggml.SUGGESTED_WHISPER) + + def test_a_publisher_with_nothing_to_offer_says_why(self): + # Half of what ggml-org publishes is split across files or past the + # size cap, and an empty box read as though the click had not landed. + box = self.window(cfg.Config()).local_llm + box.repo.blockSignals(True) + box.repo.setCurrentText("ggml-org/gpt-oss-120b-GGUF") + box.repo.blockSignals(False) + box._on_listed([("models", [], "ggml-org/gpt-oss-120b-GGUF")], "") + self.assertIn("ggml-org/gpt-oss-120b-GGUF", box.status.text()) + self.assertIn("publisher", box.status.text()) + + def test_an_empty_box_nobody_has_asked_yet_is_not_a_publisher_fault(self): + box = self.window(cfg.Config()).local_llm + box.load("", "ggml-org/SmolLM3-3B-GGUF") + self.assertNotIn("publisher", box.status.text()) + + def test_only_the_suggested_publishers_are_offered_to_start_with(self): + # Forty repository ids is not a choice anybody can make. + box = self.window(cfg.Config()).local_llm + box._on_listed([("repos", [ggml.SUGGESTED_LLM[0], + "ggml-org/something-else-GGUF"], "")], "") + self.assertEqual(self._repos(box), list(ggml.SUGGESTED_LLM)) + + def test_a_suggestion_missing_from_the_listing_is_still_offered(self): + # The listing is the forty repositories touched most recently, and a + # publisher that has not been updated in a season falls off it while + # still being the one to point at. + box = self.window(cfg.Config()).local_llm + box._on_listed([("repos", ["ggml-org/something-else-GGUF"], "")], "") + self.assertIn(ggml.SUGGESTED_LLM[0], self._repos(box)) + + def test_the_switch_brings_the_rest_and_keeps_them_apart(self): + box = self.window(cfg.Config()).local_llm + box._on_listed([("repos", [ggml.SUGGESTED_LLM[0], + "ggml-org/something-else-GGUF"], "")], "") + box.every_repo.setChecked(True) + rows = self._repos(box) + self.assertEqual(rows[:len(ggml.SUGGESTED_LLM)], + list(ggml.SUGGESTED_LLM)) + # A separator rather than a heading: the box is typed into as well as + # chosen from, and a heading would land in the field as a repository. + self.assertEqual(rows[len(ggml.SUGGESTED_LLM)], "") + self.assertEqual(rows[-1], "ggml-org/something-else-GGUF") + + def test_a_publisher_typed_in_is_not_dropped_by_the_next_fetch(self): + box = self.window(cfg.Config()).local_llm + box.repo.blockSignals(True) + box.repo.setCurrentText("ggml-org/something-else-GGUF") + box.repo.blockSignals(False) + box._on_listed([("repos", [ggml.SUGGESTED_LLM[0], + "ggml-org/something-else-GGUF"], "")], "") + self.assertFalse(box.every_repo.isChecked()) + self.assertIn("ggml-org/something-else-GGUF", self._repos(box)) + self.assertEqual(box.repository(), "ggml-org/something-else-GGUF") + + def test_the_chosen_publisher_is_said_in_words(self): + # A repository id names the publisher, the parameter count and the + # shape of the weights, and none of that says whether to click it. + box = self.window(cfg.Config()).local_llm + box.repo.setCurrentText(ggml.SUGGESTED_LLM[0]) + self.assertTrue(box.repo_note.text()) + box.repo.setCurrentText("ggml-org/nobody-wrote-a-note-GGUF") + self.assertEqual(box.repo_note.text(), "") + + def test_the_box_says_what_this_machine_will_run_on(self): + box = self.window(cfg.Config()).local_whisper + with mock.patch.object(ggml, "accelerator", return_value="Vulkan"), \ + mock.patch.object(ggml, "total_memory", return_value=32 << 30): + box._show_machine() + self.assertIn("Vulkan", box.machine_label.text()) + self.assertIn("32.0 GB", box.machine_label.text()) + + def test_a_machine_with_no_card_is_told_it_is_on_the_processor(self): + box = self.window(cfg.Config()).local_whisper + with mock.patch.object(ggml, "accelerator", return_value=""), \ + mock.patch.object(ggml, "total_memory", return_value=8 << 30): + box._show_machine() + self.assertIn("processor", box.machine_label.text()) + 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, From f36348d536064108b19ddf84ee18d061118b106e Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 11:19:02 +0300 Subject: [PATCH 28/37] Put the indicator on the screen the session is actually on The indicator asked QCursor.pos() which screen to appear on, and Wayland tells a client where the pointer is only while it is over one of that client's own windows. The indicator is never under the pointer, so the answer came back stale, or at the origin when the pointer had never been over a window of ours. Measured on Plasma 6: the origin under the Wayland platform, and a point frozen for the whole run through XWayland, which is the platform Dikte actually uses. Every indicator therefore landed in the corner of whichever screen holds 0,0, which on a two monitor desk is the wrong screen most of the time. KWin knows, and answers for it over D-Bus with activeOutputName, naming outputs the way Qt names screens: by connector, natively and through XWayland alike. That answer now comes before the pointer, and the pointer still decides everywhere else, which is right on X11 and no worse than before on other Wayland desktops. It is the active output and not the pointer's, so on Plasma the two are the same screen only where the active screen is set to follow the mouse, and otherwise it is the focused window that decides, which is where the typing is going anyway. Nothing in the settings window promises the pointer any more. Deciding the screen once, when the indicator appears, leaves it behind when the work moves to another monitor mid-recording, so overlay_follows_pointer keeps it up to date while it is up. Off by default: a ribbon that changes desks mid-sentence is one more thing moving while you are trying to talk. The compositor is asked four times a second rather than at the ribbon's 33 ms, because a hand moving a mouse across a desk is slower than that, and the call is given a 200 ms timeout so a wedged compositor cannot freeze the indicator with it. One indicator stacking on another takes that one's screen and never asks for its own. Asked for itself it would answer where the session is now, which is not where the ribbon underneath was put a minute ago, and the pair would end up a monitor apart with the top one raised over nothing. That one is checked every tick, since its answer costs nothing. --- dikte/app.py | 14 +++--- dikte/config.py | 3 ++ dikte/i18n.py | 4 +- dikte/overlay.py | 114 ++++++++++++++++++++++++++++++++++++++---- dikte/settings_ui.py | 25 +++++++++- tests/test_ui.py | 116 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 258 insertions(+), 18 deletions(-) diff --git a/dikte/app.py b/dikte/app.py index d321256..43aa8c2 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -164,12 +164,14 @@ class Dikte: self._front_watch = None self.overlay = Overlay(self.conf["overlay_corner"], - screen_name=self.conf["overlay_screen"]) + screen_name=self.conf["overlay_screen"], + follow_pointer=self.conf["overlay_follows_pointer"]) # The agent's indicator sits on top of the dictation one when both are # up, and drops into the corner when it is alone there. self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay, dismissable=True, - screen_name=self.conf["overlay_screen"]) + screen_name=self.conf["overlay_screen"], + follow_pointer=self.conf["overlay_follows_pointer"]) self.recorder = audio.Recorder() self.pipeline = Pipeline(self.conf) self.ask_pipeline = Pipeline(self.conf) @@ -1297,10 +1299,10 @@ class Dikte: threading.Thread(target=warm, daemon=True).start() def _apply_settings(self): - self.overlay.corner = self.conf["overlay_corner"] - self.overlay.screen_name = self.conf["overlay_screen"] - self.ask_overlay.corner = self.conf["overlay_corner"] - self.ask_overlay.screen_name = self.conf["overlay_screen"] + for indicator in (self.overlay, self.ask_overlay): + indicator.corner = self.conf["overlay_corner"] + indicator.screen_name = self.conf["overlay_screen"] + indicator.follow_pointer = self.conf["overlay_follows_pointer"] self._apply_local() self._build_tray() self._refresh_tray() diff --git a/dikte/config.py b/dikte/config.py index 567d5b7..6025286 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -470,6 +470,9 @@ DEFAULTS = { "evdev_hotkey": False, "overlay_corner": "bottom-left", "overlay_screen": "", + # Off, so that an indicator stays where it appeared unless it is asked to + # keep up with the pointer. Nothing to say when a screen is named above. + "overlay_follows_pointer": False, "keep_audio": False, "history_limit": 200, # A look at the releases page once a day, and nothing more than a look: diff --git a/dikte/i18n.py b/dikte/i18n.py index ea619b9..4f9d08e 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -189,8 +189,10 @@ TR = { "Restore the previous clipboard after pasting": "Yapıştırdıktan sonra eski pano içeriğini geri koy", "Indicator screen": "Gösterge ekranı", - "Follow the mouse pointer": "Fare imlecini takip et", + "Follow the active screen": "Etkin ekranı takip et", "{name} (not connected)": "{name} (bağlı değil)", + "Move it when the active screen changes": + "Etkin ekran değiştiğinde göstergeyi de taşı", "Indicator corner": "Gösterge köşesi", "bottom-left": "sol-alt", "bottom-right": "sağ-alt", diff --git a/dikte/overlay.py b/dikte/overlay.py index 4993681..79601c7 100644 --- a/dikte/overlay.py +++ b/dikte/overlay.py @@ -1,6 +1,7 @@ """The small recording indicator that appears in a screen corner without taking focus.""" import math +import os import sys from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF @@ -15,6 +16,7 @@ MIN_WIDTH = 210 MAX_WIDTH = 460 MARGIN = 28 GAP = 10 # between two indicators sharing a corner +FOLLOW_EVERY = 8 # ticks between two looks for the pointer: about four a second BG = QColor(22, 24, 29, 238) BORDER = QColor(255, 255, 255, 28) @@ -37,16 +39,68 @@ STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY, LIVE = ("recording", "asking", "meeting") +# KWin's interface, kept once one has been built. See _compositor_screen. +_kwin = None + + +def _compositor_screen(): + """The screen KWin says the session is on, or None where nothing says. + + Wayland tells a client where the pointer is only while it is over one of + that client's own windows, and the indicator is never under the pointer, so + QCursor.pos() answers with a stale point or, when the pointer has never + been over a window of ours, with the origin. Either way the indicator lands + in the corner of whichever screen holds 0,0 instead of the one being worked + on, and on a two-monitor desk that is the wrong screen most of the time. + KWin does know, and it names outputs the way Qt names screens, by + connector, natively and through XWayland alike. No other Wayland desktop + answers this, so the rest are left with the pointer, which is right on X11 + and wrong on Wayland exactly as before. + + What it answers with is the active output, which is the one under the + pointer only where Plasma is set to let the active screen follow the mouse. + Under the default, click to focus, it is the focused window's screen, so + the indicator lands where the typing is going rather than where the mouse + was left. Which is why nothing here, and nothing in the settings window, + promises the pointer. + """ + global _kwin + if _kwin is None or not _kwin.isValid(): + # Which also leaves macOS and Windows out, where nothing sets it and + # the pointer can be asked where it is like anywhere else. + desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() + if "kde" not in desktop and "plasma" not in desktop: + return None + try: + from PyQt6.QtDBus import QDBusConnection, QDBusInterface + _kwin = QDBusInterface("org.kde.KWin", "/KWin", "org.kde.KWin", + QDBusConnection.sessionBus()) + except Exception: + return None + if not _kwin.isValid(): + return None + # A compositor busy enough not to answer in a fifth of a second is one + # the indicator should stop waiting for, not one it should freeze with. + _kwin.setTimeout(200) + answer = _kwin.call("activeOutputName").arguments() + name = answer[0] if answer else "" + return next((item for item in QApplication.screens() if item.name() == name), + None) + + class Overlay(QWidget): """One indicator. Give it `below` and it stacks on top of that one instead of covering it, which is what lets a dictation and a command to the agent be under way at the same time and still both be visible.""" def __init__(self, corner="bottom-left", below=None, dismissable=False, - screen_name=""): + screen_name="", follow_pointer=False): super().__init__(None) self.corner = corner self.screen_name = screen_name + # Whether it goes on following the pointer once it is up, rather than + # settling on the screen it appeared on. + self.follow_pointer = follow_pointer self.below = below # A job that can run for ten minutes should not have to be watched for # ten minutes. Clicking such an indicator puts the progress away; the @@ -65,6 +119,8 @@ class Overlay(QWidget): self.seconds = 0.0 self._phase = 0.0 self._concealed = True + self._shown_on = "" # the screen it was last put on, by name + self._looks = 0 # ticks since the pointer was last looked for flags = ( Qt.WindowType.FramelessWindowHint @@ -252,16 +308,52 @@ class Overlay(QWidget): min(MAX_WIDTH, metrics.horizontalAdvance(self.message) + extra)) self.resize(width, HEIGHT) - def _reposition(self): - # The screen the settings name, or, when none is named or it is not - # plugged in right now, where the user actually is. Names are connector - # names on X11 and model names on macOS, where two identical monitors - # can share one; the first then wins. - screen = next( + def _screen(self): + """The screen this indicator belongs on right now. + + The one the settings name, or, when none is named or it is not plugged + in right now, where the user actually is. Names are connector names on + X11 and model names on macOS, where two identical monitors can share + one; the first then wins. + + One stacking on another belongs on that one's screen and nowhere else. + Asked for itself it would answer where the user is now, which is not + where the ribbon it stacks on was put a minute ago, and the pair would + end up a monitor apart with this one raised over nothing. + """ + if self.below is not None and self.below.showing: + under = next((item for item in QApplication.screens() + if item.name() == self.below._shown_on), None) + if under is not None: + return under + named = next( (item for item in QApplication.screens() if item.name() == self.screen_name), None, ) - screen = screen or QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen() + return (named or _compositor_screen() + or QApplication.screenAt(QCursor.pos()) + or QApplication.primaryScreen()) + + def _wandered_off(self): + """Whether the pointer has left the screen the indicator is on. + + Only asked while it is following, and only every few ticks: the answer + costs a word with the compositor, and a hand moving a mouse across a + desk is slow next to a 33 ms ribbon. Every tick for one that stacks on + another, where the answer is free and waiting a third of a second for + it would leave the pair split over two monitors for that long. + """ + if not self.follow_pointer or self.screen_name: + return False + if self.below is None or not self.below.showing: + self._looks = (self._looks + 1) % FOLLOW_EVERY + if self._looks: + return False + return self._screen().name() != self._shown_on + + def _reposition(self): + screen = self._screen() + self._shown_on = screen.name() area = screen.availableGeometry() left = "left" in self.corner top = "top" in self.corner @@ -277,8 +369,10 @@ class Overlay(QWidget): def _tick(self): self._phase += 0.12 # The one underneath can come and go while this one is up; drop back to - # the corner when it does rather than leaving a gap where it was. - if self.below is not None and self.below.showing != self._stacked: + # the corner when it does rather than leaving a gap where it was. And + # the screen under the pointer can change while it is up too. + moved = self.below is not None and self.below.showing != self._stacked + if moved or self._wandered_off(): self._reposition() if self.state in LIVE and not self.paused: # keep the ribbon moving even through a pause in speech diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 86b5ffc..b72211a 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -867,7 +867,11 @@ class SettingsWindow(QDialog): form = QFormLayout(page) self.indicator_screen = QComboBox() - self.indicator_screen.addItem(t("Follow the mouse pointer"), "") + # The active screen rather than the pointer, for the reason in + # overlay._compositor_screen: it is what a compositor will answer for, + # and on Plasma the two are one screen only where the active screen is + # set to follow the mouse. + self.indicator_screen.addItem(t("Follow the active screen"), "") for screen in QGuiApplication.screens(): # The native resolution, so that a scaled 4K screen reads # 3840 × 2160 and not the 1920 × 1080 Qt sees through the scale. @@ -881,12 +885,26 @@ class SettingsWindow(QDialog): ) form.addRow(t("Indicator screen"), self.indicator_screen) + # Only the screen it appeared on is decided when it appears; this is + # what makes it keep up with a session that moves to another one + # mid-recording. The active screen and not the pointer, because that is + # what a compositor will answer for: on Plasma the two are the same + # screen only where the active screen is set to follow the mouse, and + # otherwise it is the focused window that decides. Nothing to offer + # when a screen is named above, since that name is the whole answer. + self.follow_pointer = QCheckBox(t("Move it when the active screen changes")) + self.indicator_screen.currentIndexChanged.connect(self._sync_follow_pointer) + form.addRow("", self.follow_pointer) + self.corner = QComboBox() for value in CORNERS: self.corner.addItem(t(value), value) form.addRow(t("Indicator corner"), self.corner) return page + def _sync_follow_pointer(self): + self.follow_pointer.setEnabled(not self.indicator_screen.currentData()) + def _api_tab(self): page = QWidget() outer = QVBoxLayout(page) @@ -1805,6 +1823,8 @@ class SettingsWindow(QDialog): if screen_name and self.indicator_screen.findData(screen_name) < 0: self.indicator_screen.addItem(t("{name} (not connected)", name=screen_name), screen_name) self._select_data(self.indicator_screen, screen_name) + self.follow_pointer.setChecked(conf["overlay_follows_pointer"]) + self._sync_follow_pointer() self._select_data(self.corner, conf["overlay_corner"]) self.max_seconds.setValue(conf["max_seconds"]) self.skip_silent.setChecked(conf["skip_silent"]) @@ -1925,6 +1945,9 @@ class SettingsWindow(QDialog): conf["paste_shortcut"] = self.paste_shortcut.currentText().strip() conf["restore_clipboard"] = self.restore_clipboard.isChecked() conf["overlay_screen"] = self.indicator_screen.currentData() or "" + # Read even while it is greyed out, so that naming a screen and taking + # the name back again does not clear a preference nobody touched. + conf["overlay_follows_pointer"] = self.follow_pointer.isChecked() conf["overlay_corner"] = self.corner.currentData() or "bottom-left" conf["max_seconds"] = self.max_seconds.value() conf["skip_silent"] = self.skip_silent.isChecked() diff --git a/tests/test_ui.py b/tests/test_ui.py index aa2f29c..18820c1 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -52,6 +52,7 @@ CHANGED = { "restore_clipboard": True, "overlay_corner": "top-right", "overlay_screen": "DP-1", + "overlay_follows_pointer": True, "max_seconds": 120, "skip_silent": False, "silence_db": -42.0, @@ -1069,6 +1070,121 @@ class Overlay(DikteTest): screen_at.assert_not_called() self.assertEqual(widget.pos(), QPoint(1948, 995)) + def _screen(self, name, area): + screen = mock.Mock() + screen.name.return_value = name + screen.availableGeometry.return_value = area + return screen + + def _kwin(self, *answer): + kwin = mock.Mock() + kwin.isValid.return_value = True + kwin.call.return_value.arguments.return_value = list(answer) + return kwin + + def test_the_compositor_says_which_screen_the_pointer_is_on(self): + """Wayland tells a client where the pointer is only while it is over one + of that client's own windows, so QCursor.pos() comes back at the origin + and every indicator lands on whichever screen holds it. KWin knows.""" + screens = [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)), + self._screen("DP-2", settings_ui.QRect(1920, 0, 1920, 1080))] + widget = self.overlay() + with mock.patch.object(overlay_module, "_kwin", self._kwin("DP-2")), \ + mock.patch.object(QApplication, "screens", return_value=screens), \ + mock.patch.object(QApplication, "screenAt") as screen_at: + widget._reposition() + screen_at.assert_not_called() + self.assertEqual(widget.pos(), QPoint(1948, 995)) + + def test_the_pointer_decides_when_the_compositor_will_not_say(self): + """Every desktop but Plasma, and Plasma while KWin is being replaced.""" + screens = [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080))] + widget = self.overlay() + with mock.patch.object(overlay_module, "_kwin", self._kwin()), \ + mock.patch.object(QApplication, "screens", return_value=screens), \ + mock.patch.object(QApplication, "screenAt", + return_value=screens[0]) as screen_at: + widget._reposition() + screen_at.assert_called() + self.assertEqual(widget.pos(), QPoint(28, 995)) + + def _two_screens(self): + return [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)), + self._screen("DP-2", settings_ui.QRect(1920, 0, 1920, 1080))] + + def _ticks_on(self, widget, screens, kwin): + """Run the ribbon long enough for one look at where the pointer is.""" + with mock.patch.object(overlay_module, "_kwin", kwin), \ + mock.patch.object(QApplication, "screens", return_value=screens), \ + mock.patch.object(QApplication, "screenAt", return_value=screens[0]): + for _ in range(overlay_module.FOLLOW_EVERY): + widget._tick() + + def test_it_can_be_told_to_keep_up_with_the_pointer(self): + """The screen it started on is not always the screen you end up on.""" + screens = self._two_screens() + kwin = self._kwin("DP-2") + widget = self.overlay(follow_pointer=True) + with mock.patch.object(overlay_module, "_kwin", kwin), \ + mock.patch.object(QApplication, "screens", return_value=screens): + widget.show_recording() + self.assertEqual(widget.pos(), QPoint(1948, 995)) + kwin.call.return_value.arguments.return_value = ["DP-1"] + self._ticks_on(widget, screens, kwin) + self.assertEqual(widget.pos(), QPoint(28, 995)) + + def test_it_stays_where_it_appeared_unless_it_was_told_otherwise(self): + """Left off, because an indicator that jumps desks mid-sentence is one + more thing moving while you are trying to talk.""" + screens = self._two_screens() + kwin = self._kwin("DP-2") + widget = self.overlay() + with mock.patch.object(overlay_module, "_kwin", kwin), \ + mock.patch.object(QApplication, "screens", return_value=screens): + widget.show_recording() + kwin.call.return_value.arguments.return_value = ["DP-1"] + self._ticks_on(widget, screens, kwin) + self.assertEqual(widget.pos(), QPoint(1948, 995)) + + def test_a_named_screen_is_never_left_for_the_pointer(self): + """Naming one is the whole answer; following it would undo the naming.""" + screens = self._two_screens() + kwin = self._kwin("DP-2") + widget = self.overlay(screen_name="DP-1", follow_pointer=True) + with mock.patch.object(QApplication, "screens", return_value=screens): + widget.show_recording() + self._ticks_on(widget, screens, kwin) + kwin.call.assert_not_called() + self.assertEqual(widget.pos(), QPoint(28, 995)) + + def test_the_one_on_top_goes_where_the_one_underneath_is(self): + """Asking for itself would put the pair on two monitors, with this one + raised over a ribbon that is not underneath it.""" + screens = self._two_screens() + kwin = self._kwin("DP-2") + first = self.overlay() + with mock.patch.object(overlay_module, "_kwin", kwin), \ + mock.patch.object(QApplication, "screens", return_value=screens): + first.show_recording() + kwin.call.return_value.arguments.return_value = ["DP-1"] + second = self.overlay(below=first) + second.show_busy("Asking Claude…") + self.assertEqual(first.pos(), QPoint(1948, 995)) + self.assertEqual(second.pos(), QPoint(1948, 929)) + + def test_the_compositor_is_asked_only_now_and_then(self): + """Every tick would be thirty conversations a second about a hand + moving a mouse.""" + screens = self._two_screens() + kwin = self._kwin("DP-2") + widget = self.overlay(follow_pointer=True) + with mock.patch.object(overlay_module, "_kwin", kwin), \ + mock.patch.object(QApplication, "screens", return_value=screens): + widget.show_recording() + kwin.call.reset_mock() + self._ticks_on(widget, screens, kwin) + self.assertEqual(kwin.call.call_count, 1) + def test_a_warning_and_an_error_both_show(self): widget = self.overlay() widget.show_warning("cleanup failed") From 74e17cbd150943fc8f7676b236f07e608a384375 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 11:22:12 +0300 Subject: [PATCH 29/37] Do not let a shrugging sysconf turn a workstation into a tiny machine Three from the review of the change before this one. sysconf answers -1 for a limit it holds to be indeterminate, and CPython hands that back rather than raising, so the page count times the page size came out negative. A negative is truthy, so it went past the check for a machine nothing could be read from and floored at half a gigabyte: a 64 GB workstation was told every model past 512 MB was too big for it, the suggestion dropped to small-q5_1, and the machine line read "Memory: -4096 B". Anything not positive is now the unknown machine it always was. The memory is read once and kept. It does not change while Dikte runs, and a thirty row list asked seventy times per draw, which on the Mac path is seventy processes started on the interface thread every time a download finished, a model was deleted or a publisher changed. And "test-" is matched as a plain substring, so it was also inside "Latest-" and dropped a publisher nothing is wrong with. Anchored the way every other mark in that list already is. --- dikte/ggml.py | 29 ++++++++++++++++++++++++++++- tests/support.py | 5 +++++ tests/test_ggml.py | 26 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/dikte/ggml.py b/dikte/ggml.py index da17dde..dbf768f 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -130,8 +130,11 @@ GGUF_MAX_BYTES = 16 << 30 # on: a vision or audio tower with no text half worth running, a speech model, # and the base models, which continue text rather than following an instruction # and answer a cleanup prompt by carrying on writing the transcript. +# Matched as plain substrings, so every one of these carries its own +# delimiters: an unanchored "test-" is also inside "Latest-" and would drop a +# publisher that is perfectly usable. LLM_REPO_SKIP = ("-Base-GGUF", "-VL-", "-Vision-", "-Omni-", "-Video-", - "-TTS-", "parakeet", "test-") + "-TTS-", "parakeet", "/test-") GB = 1 << 30 @@ -219,6 +222,8 @@ MEMORY_OVERHEAD = GB # anything. Enough for the smallest whisper models and for a sub-billion # cleanup model, which is what such a machine can run. MEMORY_FLOOR = GB // 2 +# What total_memory() read the one time it asked. None until it has. +_MEMORY = None class LocalError(Exception): @@ -713,6 +718,28 @@ def total_memory(): Zero is a real answer and not a failure: every caller treats an unknown machine as one big enough for whatever it is looking at, because a wrong "too big" is worse advice than none. + + Read once and kept. The memory in a machine does not change while Dikte + runs, and a list of thirty rows asks this question seventy times: on the + Mac path below, where the answer comes from a program rather than a + library call, that was seventy processes started on the interface thread + every time a list was drawn. + """ + global _MEMORY + if _MEMORY is None: + _MEMORY = max(_read_memory(), 0) + return _MEMORY + + +def _read_memory(): + """What the system says, which on a bad day is a negative number. + + sysconf answers -1 for a limit it holds to be indeterminate, and CPython + hands that straight back rather than raising, so the product below can + come out negative. The caller floors it at zero, which is the answer for + a machine nothing could be read from: a 64 GB workstation whose sysconf + shrugged was otherwise being told every model past 512 MB was too big + for it. """ try: return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") diff --git a/tests/support.py b/tests/support.py index 8a005f1..f96fab9 100644 --- a/tests/support.py +++ b/tests/support.py @@ -24,6 +24,7 @@ from unittest import mock from dikte import assistant from dikte import config as cfg +from dikte import ggml from dikte import i18n from dikte import update @@ -95,6 +96,10 @@ class DikteTest(unittest.TestCase): i18n.set_language("en") self.addCleanup(i18n.set_language, "en") + # Read once and kept for the life of the process, which across a test + # run means one test's machine answering for the next one's. + self.patch_attr(ggml, "_MEMORY", None) + # cli.launch_gui replaces this process with the application when no # instance is running. A test that reaches it would take the whole run # with it and hang, so it fails loudly here instead. diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 9a1897b..bff3f61 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -696,6 +696,12 @@ class Catalogue(Local): self.assertNotIn("ggml-org/parakeet-GGUF", found) self.assertNotIn("ggml-org/Qwen3-8B-Base-GGUF", found) + def test_a_publisher_is_not_dropped_for_a_word_it_happens_to_contain(self): + # The skip marks are matched as plain substrings, and an unanchored + # "test-" is also inside "Latest-". + self.assertTrue(ggml.can_clean("ggml-org/Qwen3-Latest-GGUF")) + self.assertFalse(ggml.can_clean("ggml-org/test-model-router-download")) + def test_a_base_model_beside_its_tuned_twin_is_dropped(self): # Gemma names the base model after the tuned one with the `-it` taken # out, so the two sit next to each other and the wrong one answers a @@ -1252,6 +1258,26 @@ class Machine(Local): mock.patch.object(ggml.subprocess, "run", answer): self.assertEqual(ggml.total_memory(), 32 * ggml.GB) + def test_a_sysconf_that_shrugs_is_an_unknown_machine_and_not_a_tiny_one(self): + # sysconf answers -1 for a limit it holds to be indeterminate and + # CPython hands that back rather than raising, so the product came out + # negative: a 64 GB workstation was told every model past 512 MB was + # too big for it, and the machine line read "Memory: -4096 B". + with mock.patch.object(ggml.os, "sysconf", lambda name: + 4096 if name == "SC_PAGE_SIZE" else -1): + self.assertEqual(ggml.total_memory(), 0) + self.assertTrue(ggml.fits(574 << 20, memory=0)) + + def test_the_memory_is_read_once_and_kept(self): + # A list of thirty rows asks seventy times, and on the Mac path the + # answer comes from a program rather than a library call. + calls = [] + with mock.patch.object(ggml, "_read_memory", + lambda: calls.append(1) or 16 * ggml.GB): + self.assertEqual(ggml.total_memory(), 16 * ggml.GB) + self.assertEqual(ggml.total_memory(), 16 * ggml.GB) + self.assertEqual(len(calls), 1) + def test_a_system_that_answers_nothing_is_an_unknown_machine(self): with mock.patch.object(ggml.os, "sysconf", side_effect=ValueError), \ mock.patch.object(sys, "platform", "linux"): From fedb4fe5c04aa9c773cb045aec3beb9509f5d8de Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 11:31:03 +0300 Subject: [PATCH 30/37] Let the new tests run on a Windows box and on a small machine Two ways the tests were standing on this machine rather than on the one they meant to describe. Windows has no os.sysconf at all, and mock.patch.object insists the attribute exists before it will replace it, so four tests failed at the patch rather than in the body. `create=True` is what lets them stand somewhere that has no such function, which is the case the code under test already handles two lines down and which is checked on its own. And the publisher order follows the memory on purpose: a runner with 7 GB in it puts the two Gemma 4 rows last and is right to. The three tests that read an order now say which machine they are standing on instead of assuming the one that ran them has room for everything. --- tests/test_ggml.py | 25 +++++++++++++++++++------ tests/test_ui.py | 39 ++++++++++++++++++++++++++------------- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/tests/test_ggml.py b/tests/test_ggml.py index bff3f61..649e192 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -1240,10 +1240,22 @@ class WindowsOwnership(Local): class Machine(Local): """What this machine can hold, and what that makes worth pointing at.""" + def _sysconf(self, phys_pages, page_size=4096): + """Stand where sysconf answers whatever this test wants it to. + + `create` because Windows has no os.sysconf at all, and a patch that + insists on the real attribute fails there before the test runs. What + the code under test does about that absence is two lines down from + what these are checking, and it is checked on its own below. + """ + return mock.patch.object( + ggml.os, "sysconf", create=True, + side_effect=lambda name: (page_size if name == "SC_PAGE_SIZE" + else phys_pages)) + def test_the_memory_is_read_the_way_each_system_reports_it(self): # Linux and most Macs answer through sysconf. - with mock.patch.object(ggml.os, "sysconf", lambda name: - 4096 if name == "SC_PAGE_SIZE" else 4_194_304): + with self._sysconf(4_194_304): self.assertEqual(ggml.total_memory(), 16 * ggml.GB) def test_a_mac_without_the_page_count_is_asked_for_the_number(self): @@ -1253,7 +1265,8 @@ class Machine(Local): self.assertEqual(args, ["sysctl", "-n", "hw.memsize"]) return mock.Mock(stdout=f"{32 * ggml.GB}\n") - with mock.patch.object(ggml.os, "sysconf", side_effect=ValueError), \ + with mock.patch.object(ggml.os, "sysconf", create=True, + side_effect=ValueError), \ mock.patch.object(sys, "platform", "darwin"), \ mock.patch.object(ggml.subprocess, "run", answer): self.assertEqual(ggml.total_memory(), 32 * ggml.GB) @@ -1263,8 +1276,7 @@ class Machine(Local): # CPython hands that back rather than raising, so the product came out # negative: a 64 GB workstation was told every model past 512 MB was # too big for it, and the machine line read "Memory: -4096 B". - with mock.patch.object(ggml.os, "sysconf", lambda name: - 4096 if name == "SC_PAGE_SIZE" else -1): + with self._sysconf(-1): self.assertEqual(ggml.total_memory(), 0) self.assertTrue(ggml.fits(574 << 20, memory=0)) @@ -1279,7 +1291,8 @@ class Machine(Local): self.assertEqual(len(calls), 1) def test_a_system_that_answers_nothing_is_an_unknown_machine(self): - with mock.patch.object(ggml.os, "sysconf", side_effect=ValueError), \ + with mock.patch.object(ggml.os, "sysconf", create=True, + side_effect=ValueError), \ mock.patch.object(sys, "platform", "linux"): self.assertEqual(ggml.total_memory(), 0) diff --git a/tests/test_ui.py b/tests/test_ui.py index 8e18db5..b215987 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1245,6 +1245,16 @@ class LocalModels(DikteTest): def _repos(box): return [box.repo.itemText(row) for row in range(box.repo.count())] + @staticmethod + def _roomy(): + """Stand on a machine with room for every suggestion. + + The order the publishers come in follows the memory, so a test that + reads it has to say which machine it is standing on. A build runner + with 7 GB in it puts the two Gemma 4 rows last and is right to. + """ + return mock.patch.object(ggml, "total_memory", return_value=64 << 30) + @staticmethod def _offered(box): """The model names in the box, headings and duplicates left out.""" @@ -1377,12 +1387,13 @@ class LocalModels(DikteTest): # reading the rows back the way a finished download does was doubling # it in the list every time. box = self.window(cfg.Config()).local_whisper - box._on_listed([("models", [ - self._item("ggml-tiny.bin", 77 << 20), - self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20), - ], "")], "") - before = self._offered(box) - box._fill_models_from_current() + with self._roomy(): + box._on_listed([("models", [ + self._item("ggml-tiny.bin", 77 << 20), + self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20), + ], "")], "") + before = self._offered(box) + box._fill_models_from_current() self.assertEqual(self._offered(box), before) names = [box.model.itemData(row) for row in range(box.model.count())] self.assertEqual(len([n for n in names if n]), len(before) + 1) @@ -1423,9 +1434,10 @@ class LocalModels(DikteTest): def test_only_the_suggested_publishers_are_offered_to_start_with(self): # Forty repository ids is not a choice anybody can make. box = self.window(cfg.Config()).local_llm - box._on_listed([("repos", [ggml.SUGGESTED_LLM[0], - "ggml-org/something-else-GGUF"], "")], "") - self.assertEqual(self._repos(box), list(ggml.SUGGESTED_LLM)) + with self._roomy(): + box._on_listed([("repos", [ggml.SUGGESTED_LLM[0], + "ggml-org/something-else-GGUF"], "")], "") + self.assertEqual(self._repos(box), list(ggml.SUGGESTED_LLM)) def test_a_suggestion_missing_from_the_listing_is_still_offered(self): # The listing is the forty repositories touched most recently, and a @@ -1437,10 +1449,11 @@ class LocalModels(DikteTest): def test_the_switch_brings_the_rest_and_keeps_them_apart(self): box = self.window(cfg.Config()).local_llm - box._on_listed([("repos", [ggml.SUGGESTED_LLM[0], - "ggml-org/something-else-GGUF"], "")], "") - box.every_repo.setChecked(True) - rows = self._repos(box) + with self._roomy(): + box._on_listed([("repos", [ggml.SUGGESTED_LLM[0], + "ggml-org/something-else-GGUF"], "")], "") + box.every_repo.setChecked(True) + rows = self._repos(box) self.assertEqual(rows[:len(ggml.SUGGESTED_LLM)], list(ggml.SUGGESTED_LLM)) # A separator rather than a heading: the box is typed into as well as From e282e6b0cf0508526b571c8e0d138498c509e803 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 11:37:19 +0300 Subject: [PATCH 31/37] Say the download started before the first byte arrives Opening the connection takes ten or twenty seconds, and the byte counts under the model box only start after it. Until then the line read "X has not been downloaded yet" beside a button that had just turned into Stop, so a download that was running looked like a click that had not landed. The stop had the same gap the other way round: should_stop is read between blocks, and the wait for the server to answer is not between blocks, so pressing Stop during it changed nothing on screen either. --- dikte/i18n.py | 1 + dikte/settings_ui.py | 9 +++++++++ tests/test_ui.py | 22 ++++++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/dikte/i18n.py b/dikte/i18n.py index 9bb7fad..50c497d 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -793,6 +793,7 @@ TR = { "İndirildi, sürüm {version}. Vulkan sürümü yoktu, bu sürüm işlemcide çalışıyor.", "Fetching the model list…": "Model listesi çekiliyor…", "Downloading…": "İndiriliyor…", + "Starting the download…": "İndirme başlatılıyor…", "Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}", "Download stopped.": "İndirme durduruldu.", "Ready: {name}.": "Hazır: {name}.", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 3f32a72..6a98298 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -701,12 +701,21 @@ class LocalModelBox(QGroupBox): def _download(self): if self._downloading: self._stop = True + # The flag is only read between blocks, and the wait for the server + # to answer is not between blocks: a click during it changes + # nothing on screen for as long as the connection takes. + self.status.setText(t("Stopping…")) return item = self._current_item() if item is None: return self._downloading, self._stop = True, False self._refresh_buttons() + # Opening the connection can take ten or twenty seconds, and the first + # byte counts are what the line below would otherwise wait for. Left + # saying "not downloaded yet" beside a button that now reads Stop, a + # download that started looks like a click that did not register. + self.status.setText(t("Starting the download…")) def work(): try: diff --git a/tests/test_ui.py b/tests/test_ui.py index 1324f33..51af17e 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1336,6 +1336,28 @@ class LocalModels(DikteTest): self.assertIn("10", box.program_label.text()) self.assertIn("20", box.status.text()) + def test_a_download_says_something_before_the_first_byte(self): + # Opening the connection takes ten or twenty seconds, and the byte + # counts only start after it. The line underneath still read "has not + # been downloaded yet" beside a button that now said Stop, so a + # download that had started looked like a click that had not landed. + 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")], "") + with mock.patch.object(settings_ui.threading, "Thread"): + box._download() + self.assertIn("Starting", box.status.text()) + # And the same again for the stop, which is read between blocks and so + # not read at all while the connection is still being opened. + with mock.patch.object(settings_ui.threading, "Thread"): + box._download() + self.assertTrue(box._stop) + self.assertIn("Stopping", 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". From 70bc4c16fad2b933281bc7c46cc3bf6f12095ff6 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 12:02:28 +0300 Subject: [PATCH 32/37] Give a local model room to think without spending the answer on it llama.cpp counts the thinking towards max_tokens along with the answer it precedes, and the local ceiling was sized for the answer alone. Turning Thinking up therefore came out of the reply rather than being added to it, and on a short dictation the 512 floor is the whole budget, so the model spent it in the think block and came back with nothing to paste. Each rung of the ladder now carries its own budget, doubling from 256 at "minimal" to 8192 at "maximum", added on top of the answer's share rather than taken out of it. The rungs are small because cleanup is punctuation and locally every one of these tokens is also a second of somebody standing in front of the screen. "Off" keeps the old tight ceiling untouched, and an empty setting is given a middling amount, since a template that can think thinks by default and there is no way to ask which kind of model this is. The ceiling is also held under what the server was started with. Above the context it is not a ceiling at all: the runaway it exists to stop would run to the end of the context instead, which on CPU is minutes of waiting. The prompt keeps its share at two characters to the token, which is under any tokeniser's rate for natural language and so reserves too much rather than promising room that is not there. Separately, a reply cut off at somebody's ceiling was returned as if it were whole. Half a sentence looks like a cleaned-up transcript and is not one, so finish_reason is now read in both cleanup and chat. The callers already keep the transcript they started with, which is the better of the two. This one is not local-only: a hosted provider stopping at its own output limit was silently pasted the same way. --- dikte/api.py | 68 +++++++++++++++++++++++++++++++++++++++---- dikte/cleanup.py | 3 ++ dikte/i18n.py | 4 +++ tests/test_api.py | 38 +++++++++++++++++++++++- tests/test_cleanup.py | 46 +++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 7 deletions(-) diff --git a/dikte/api.py b/dikte/api.py index 13c4381..58cc30b 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -518,22 +518,65 @@ def _thinking(payload, provider, reasoning): payload["reasoning"] = {"effort": reasoning, "exclude": True} -def local_ceiling(text): +# Room for the thinking on this machine, one budget per rung of the settings +# ladder. llama.cpp counts the thinking towards max_tokens along with the answer +# it precedes, so a ceiling sized for the answer alone leaves a model that +# thinks nothing to answer with. The rungs double, starting where a small model +# lands when it barely thinks at all: cleanup is punctuation, and locally every +# one of these tokens is also a second of somebody standing in front of the +# screen, so the low rungs are the ones meant to be used. +THINKING_ROOM = { + "minimal": 256, "low": 512, "medium": 1024, + "high": 2048, "xhigh": 4096, "max": 8192, +} +# An empty setting leaves it to the model, and the templates that can think +# think by default. Room for a middling amount of it, since there is no way to +# ask which kind of model this is. +DEFAULT_THINKING_ROOM = THINKING_ROOM["medium"] + + +def local_ceiling(text, reasoning="", context=0, prompt=""): """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. + waiting, with only the hour-long local timeout underneath. A hosted model is + left alone: there the same runaway is rare, and a ceiling would cut the + minutes short instead. + + The answer's share is the transcript's length in characters spent as a + budget in tokens, so what it really allows is two to four times the + transcript depending on how well the language tokenises. Turkish sits at the + tight end of that and still has room to spare for a reply that is meant to + come back the same length it went in. + + Thinking is added on top of that share rather than taken out of it. Sharing + one budget is what makes turning thinking up quietly cost the answer, and on + a short dictation the 512 floor is the whole budget, so the answer is what + goes missing first. + + `context` is what the server was started with, and the whole of it is the + real limit whatever is asked for here: a ceiling above it is not a ceiling, + because the runaway it exists to stop would run to the end of the context + instead. So the ceiling is held below what the prompt leaves. Two characters + to the token is under any tokeniser's rate for natural language, Turkish + included, which makes the reserve an over-estimate rather than a promise of + room that is not there. """ - return max(512, len(text)) + answer = max(512, len(text)) + if reasoning != "none": + answer += THINKING_ROOM.get(reasoning, DEFAULT_THINKING_ROOM) + context = int(context or 0) + if not context: + return answer + return max(256, min(answer, context - (len(prompt) + len(text)) // 2)) def cleanup(text, api_key, model, system_prompt, reasoning="", base_url=OPENROUTER_URL, timeout=180, provider="openrouter", - service="OpenRouter", aborter=None): + service="OpenRouter", aborter=None, context=0): if not api_key and provider != "local-llm": raise ApiError(t("{service} API key is empty. Add it in Settings.", service=service)) @@ -546,7 +589,8 @@ def cleanup(text, api_key, model, system_prompt, reasoning="", ], } if provider == "local-llm": - payload["max_tokens"] = local_ceiling(text) + payload["max_tokens"] = local_ceiling(text, reasoning, context, + system_prompt) _thinking(payload, provider, reasoning) try: data = _request( @@ -570,6 +614,13 @@ def cleanup(text, api_key, model, system_prompt, reasoning="", raise ApiError(t("The cleanup model spent its whole reply on " "thinking. Set Thinking to \u201cOff\u201d.")) raise ApiError(t("The cleanup model returned an empty reply.")) + if choices[0].get("finish_reason") == "length": + # Cut off at somebody's ceiling: ours locally, the provider's otherwise. + # What came back is a sentence that stops mid-word, and cleanup is meant + # to hand back the whole dictation, so the half is refused rather than + # returned. The callers keep the transcript they started with, which is + # the better of the two. + raise ApiError(t("The cleanup model was cut off before it finished.")) return content @@ -605,6 +656,11 @@ def chat(messages, api_key, model, system_prompt, reasoning="", content = ((choices[0].get("message") or {}).get("content") or "").strip() if not content: raise ApiError(t("The model returned an empty reply.")) + if choices[0].get("finish_reason") == "length": + # An answer that stops mid-sentence reads like a whole one once it has + # been pasted, so it is refused here for the same reason cleanup refuses + # a half transcript. + raise ApiError(t("The model was cut off before it finished.")) return content diff --git a/dikte/cleanup.py b/dikte/cleanup.py index a1bced3..36a3ee9 100644 --- a/dikte/cleanup.py +++ b/dikte/cleanup.py @@ -121,6 +121,9 @@ def _local(text, conf, system_prompt, timeout, aborter=None): base_url=api.serving(ggml.llm), timeout=max(timeout, api.LOCAL_TIMEOUT), provider="local-llm", service=service, aborter=aborter, + # The ceiling is only a ceiling while it sits under what the server + # was started with; above that the context is what stops the reply. + context=ggml.llm.settings()["context"], ) except api.ApiError as exc: # A server that died mid-request would otherwise report only that the diff --git a/dikte/i18n.py b/dikte/i18n.py index 50c497d..6375e55 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -959,6 +959,10 @@ TR = { "“Off”.": "Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi " "“Kapalı” yap.", + "The cleanup model was cut off before it finished.": + "Temizleme modeli bitiremeden kesildi.", + "The model was cut off before it finished.": + "Model bitiremeden kesildi.", # --- this pass's new messages --------------------------------------- "Audio recorder stopped before receiving sound": diff --git a/tests/test_api.py b/tests/test_api.py index 50b4ef7..4b35774 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -463,6 +463,29 @@ class Cleanup(DikteTest): with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError): api.cleanup("hello", "k", "m", "p") + def test_a_reply_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self): + # Half a sentence looks like a cleaned-up transcript and is not one. The + # caller keeps what it was given, which is the whole dictation. + reply = {"choices": [{"message": {"content": "Hello, and then the"}, + "finish_reason": "length"}]} + with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught: + api.cleanup("hello", "k", "m", "p") + self.assertIn("cut off", str(caught.exception)) + + def test_a_reply_that_stopped_on_its_own_is_kept(self): + reply = {"choices": [{"message": {"content": "Hello."}, + "finish_reason": "stop"}]} + with fake_urlopen(reply): + self.assertEqual(api.cleanup("hello", "k", "m", "p"), "Hello.") + + def test_all_thinking_is_named_before_the_ceiling_it_was_cut_at(self): + """Both are true at once, and only one of them says what to change.""" + reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}, + "finish_reason": "length"}]} + with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught: + api.cleanup("hello", "k", "m", "p") + self.assertIn("Thinking", str(caught.exception)) + def test_a_rate_limit_is_explained(self): with fake_urlopen(http_error(429)), \ self.assertRaises(api.ApiError) as caught: @@ -471,6 +494,14 @@ class Cleanup(DikteTest): class Chat(DikteTest): + def test_an_answer_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self): + # Half an answer reads like a whole one once it is on the screen. + reply = {"choices": [{"message": {"content": "Booked it for the"}, + "finish_reason": "length"}]} + with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught: + api.chat([{"role": "user", "content": "book it"}], "k", "m", "p") + self.assertIn("cut off", str(caught.exception)) + def test_the_history_is_sent_after_the_system_prompt(self): history = [{"role": "user", "content": "book it"}, {"role": "assistant", "content": "done"}] @@ -616,11 +647,13 @@ if __name__ == "__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=""): + def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log="", + context=8192): self.url = url self.fails = fails self.log = log self.starts = 0 + self.context = context def serve(self): self.starts += 1 @@ -631,6 +664,9 @@ class FakeServer: def error(self): return self.log + def settings(self): + return {"context": self.context} + LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin") diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index 9de3dc3..245053f 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -411,6 +411,52 @@ class Here(DikteTest): cleanup.run("uh, done", self.conf, "the rules") self.assertEqual(sent_json(calls[0])["max_tokens"], 512) + def test_thinking_is_given_room_of_its_own_rather_than_the_answer_s(self): + # llama.cpp counts the thinking towards the same ceiling, so a rung that + # took its budget out of the answer would leave a short dictation with + # nothing to reply with. On a context roomy enough that the clamp the + # top rung would otherwise meet is not what is being measured. + self.patch_attr(ggml, "llm", FakeServer(context=32768)) + for rung, room in api.THINKING_ROOM.items(): + with self.subTest(rung=rung): + self.conf["local_llm_reasoning"] = rung + with fake_urlopen(chat_reply("Done.")) as calls: + cleanup.run("uh, done", self.conf, "the rules") + self.assertEqual(sent_json(calls[0])["max_tokens"], 512 + room) + + def test_each_rung_of_the_ladder_thinks_longer_than_the_one_below(self): + rungs = [api.THINKING_ROOM[name] for name in + ("minimal", "low", "medium", "high", "xhigh", "max")] + self.assertEqual(rungs, sorted(rungs)) + self.assertEqual(len(set(rungs)), len(rungs)) + + def test_the_models_own_default_is_given_room_to_think_in_too(self): + # Nothing is sent, so a template that thinks will think, and the ceiling + # has to survive that as well. + self.conf["local_llm_reasoning"] = "" + with fake_urlopen(chat_reply("Done.")) as calls: + cleanup.run("uh, done", self.conf, "the rules") + self.assertEqual(sent_json(calls[0])["max_tokens"], + 512 + api.DEFAULT_THINKING_ROOM) + + def test_the_ceiling_stays_under_the_context_the_server_was_started_with(self): + # Above the context there is no ceiling at all: the runaway would run to + # the end of the context instead of stopping where this says. + self.patch_attr(ggml, "llm", FakeServer(context=2048)) + self.conf["local_llm_reasoning"] = "max" + with fake_urlopen(chat_reply("Done.")) as calls: + cleanup.run("uh, done", self.conf, "the rules") + self.assertLess(sent_json(calls[0])["max_tokens"], 2048) + + def test_the_prompt_keeps_its_share_of_a_small_context(self): + self.patch_attr(ggml, "llm", FakeServer(context=2048)) + self.conf["local_llm_reasoning"] = "max" + with fake_urlopen(chat_reply("Done.")) as calls: + cleanup.run("x" * 2000, self.conf, "the rules") + # 2048 less half the characters of prompt and transcript together. + self.assertEqual(sent_json(calls[0])["max_tokens"], + 2048 - (len("the rules") + 2000) // 2) + 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: From 44db26c45985603f3b57009caee77368bc7f017a Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 12:07:13 +0300 Subject: [PATCH 33/37] Measure a wrapped label against a width it actually has The publisher note is written while the settings window is still being built, when its label is eight pixels wide. Wrapped against that width the sentence came out a hundred and twenty lines tall, and the minimum taken from it did not stay a minimum: QLabel folds the widget's minimum size into its own cached size hints and clears that cache only when the text changes. So the row stood two thousand pixels tall, carrying the model box, the status line and the options under it off the bottom of the window, and picking another publisher was what brought them back. Nothing to measure against yet means nothing to claim yet. The show and the resize come back for it once there is a real width. --- dikte/settings_ui.py | 26 +++++++++++++++++++++----- tests/test_ui.py | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 3f32a72..44c99e1 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -181,18 +181,34 @@ class WrappedLabel(QLabel): super().setText(text) self._fit() + def showEvent(self, event): + # Text set while the window was still being built was measured against + # nothing; this is the first moment the width means anything. + super().showEvent(event) + self._fit() + def resizeEvent(self, event): super().resizeEvent(event) self._fit() def _fit(self): + # A label the layout has not placed yet is a handful of pixels wide, + # and wrapping a sentence against that width invents a hundred lines. + # The minimum set from it does not stay a minimum either: QLabel folds + # it into its own cached size hints and clears that cache only when the + # text changes, so the row stands thousands of pixels tall and carries + # the model box and everything under it off the bottom of the window + # until another publisher is picked. Nothing to measure against yet + # means nothing to claim yet, and the show and resize above come back + # for it. + if not self.isVisible() or self.width() <= 0: + return # Measured off the font rather than asked of the label, whose own answer # is floored by the minimum set here a moment ago and so only ever grows. - if self.width() > 0: - wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere - box = QRect(0, 0, self.width(), 0) - self.setMinimumHeight( - self.fontMetrics().boundingRect(box, wrap, self.text()).height()) + wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere + box = QRect(0, 0, self.width(), 0) + self.setMinimumHeight( + self.fontMetrics().boundingRect(box, wrap, self.text()).height()) class WheelGuard(QObject): diff --git a/tests/test_ui.py b/tests/test_ui.py index 1324f33..cc239fb 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -244,6 +244,32 @@ class Settings(DikteTest): label.resize(2000, line) self.assertLessEqual(label.minimumHeight(), line) + def test_a_label_written_before_the_layout_places_it_claims_nothing(self): + # The publisher note is written while the settings window is still + # being built, when the label is a handful of pixels wide. Wrapped + # against that width the sentence became a hundred lines, and the + # minimum taken from it did not stay a minimum: QLabel folds it into + # its own cached size hints and clears that cache only when the text + # changes. The group box stood thousands of pixels tall, with the + # model box and everything under it off the bottom of the window, + # until another publisher was picked. + label = settings_ui.WrappedLabel() + self.addCleanup(label.deleteLater) + line = label.fontMetrics().height() + label.resize(8, line) + label.setText("Google Gemma 4, the small one. The default: nothing " + "else this size follows an instruction as closely, and " + "cleanup is all instruction.") + self.assertEqual(label.minimumHeight(), 0) + # Placed and shown, which is the first width worth measuring against. + # The room the wrapping needs is claimed then, and it is the lines the + # sentence actually takes at this width rather than at the last one. + label.resize(400, line) + label.show() + self.assertGreater(label.minimumHeight(), line) + self.assertLessEqual(label.minimumHeight(), 4 * line) + self.assertLessEqual(label.sizeHint().height(), 4 * line) + def test_saving_without_touching_anything_changes_nothing(self): """Every widget has to load what is stored, or Save writes its default over it. This says so for the whole table at once.""" From 22d2a40341c8cc553100cfe8f513fbe8c68031f1 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 12:10:58 +0300 Subject: [PATCH 34/37] Count the lines off the font, not off this machine's font The new test pinned the wrapped height at four lines, which is four lines on a Linux runner and four and a half on a Windows one, where the same sentence in the same 400 pixels needs 54 of the box's 48. What the test is actually about is that the height comes from the width the label has now rather than the eight pixels it had while the window was being built, so it measures that width itself and compares against the answer. --- tests/test_ui.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_ui.py b/tests/test_ui.py index cc239fb..f759bf7 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -14,7 +14,7 @@ import unittest from typing import ClassVar from unittest import mock -from PyQt6.QtCore import QPoint, QPointF, Qt +from PyQt6.QtCore import QPoint, QPointF, QRect, Qt from PyQt6.QtGui import QWheelEvent from PyQt6.QtWidgets import QApplication, QMessageBox @@ -263,12 +263,19 @@ class Settings(DikteTest): self.assertEqual(label.minimumHeight(), 0) # Placed and shown, which is the first width worth measuring against. # The room the wrapping needs is claimed then, and it is the lines the - # sentence actually takes at this width rather than at the last one. + # sentence takes at this width rather than at the last one. Counted + # off the font rather than written down here, because how many lines + # 400 pixels hold is a different answer on every machine. label.resize(400, line) label.show() - self.assertGreater(label.minimumHeight(), line) - self.assertLessEqual(label.minimumHeight(), 4 * line) - self.assertLessEqual(label.sizeHint().height(), 4 * line) + wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere + needed = label.fontMetrics().boundingRect( + QRect(0, 0, 400, 0), wrap, label.text()).height() + self.assertGreater(needed, line) # or the sentence never wrapped + self.assertEqual(label.minimumHeight(), needed) + # And the label's own hints are the wrapping at this width too, not + # the hundred lines the eight pixel one asked for. + self.assertLessEqual(label.sizeHint().height(), 3 * needed) def test_saving_without_touching_anything_changes_nothing(self): """Every widget has to load what is stored, or Save writes its default From 825f089fe9f8071059d9814a4c3c557b578f7997 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 12:15:14 +0300 Subject: [PATCH 35/37] Give the memory back when a local model has been sitting unused A whisper.cpp or llama.cpp server started for one dictation stayed loaded until Dikte quit. On this machine that is 1.3 GB of VRAM for large-v3 plus whatever the cleanup LLM takes, held all day between dictations that last seconds. Each Server now carries an idle window. A watcher thread per launch stops the server once nothing has asked it anything for that long, and the next request loads it again through serve(), which already starts what is not running. Settings has one checkbox and one number for both servers, on by default at ten minutes, and it only appears for a machine that runs a model here. The tray menu says which models are loaded and offers to unload them now. Two things the clock alone gets wrong, both held off by a count of requests in flight: * A file or a meeting is one address lookup and then minutes of work, which to a clock started at the lookup looks exactly like a model nobody wants. api.py and cleanup.py hold the count for the length of the request. * The count must survive the start it triggered. cleanup._local takes the hold and only then asks for the address, so a cold start happens inside it; neither serve() nor _stop_now() resets the count any more. Unloading by hand runs on the interface's thread, so it asks for the start lock rather than waiting on it: a model still being read in is refused, the way one in the middle of a request is, instead of freezing the window for as long as the load takes. --- README.md | 3 +- README.tr.md | 3 +- dikte/api.py | 15 +++-- dikte/app.py | 36 ++++++++++++ dikte/cleanup.py | 17 +++--- dikte/config.py | 17 ++++++ dikte/ggml.py | 130 ++++++++++++++++++++++++++++++++++++++++++- dikte/i18n.py | 20 +++++++ dikte/settings_ui.py | 45 +++++++++++++++ tests/test_api.py | 10 ++++ tests/test_config.py | 21 +++++++ tests/test_ggml.py | 122 +++++++++++++++++++++++++++++++++++++++- tests/test_ui.py | 22 ++++++++ 13 files changed, 444 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 628b2d6..d1b164c 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,8 @@ running. - **It all runs on this machine by default.** Speech to text on whisper.cpp and cleanup on llama.cpp, neither installed beforehand: the settings window fetches the program and the model, verifies the sha256 and refuses a download published - without one, then keeps a server alive while you dictate. The model list is + without one, then keeps a server alive while you dictate and hands the memory + back once it has sat unused for ten minutes. The model list is grouped by model rather than by file size, and the row this machine's memory and graphics can take is marked. The graphics card is reached through CUDA, ROCm or Vulkan where the build allows. No key, no diff --git a/README.tr.md b/README.tr.md index a243c12..774ea2f 100644 --- a/README.tr.md +++ b/README.tr.md @@ -158,7 +158,8 @@ olmasını ister. whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez: ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular, checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu - ayakta tutar. Model listesi dosya boyutuna değil modele göre gruplanır ve bu + ayakta tutar ve on dakika kullanılmayan modelin belleğini geri verir. Model + listesi dosya boyutuna değil modele göre gruplanır ve bu makinenin belleğine ve ekran kartına uyan satır işaretlenir. Derleme destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok. diff --git a/dikte/api.py b/dikte/api.py index 13c4381..9c10eb4 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -387,12 +387,17 @@ def _transcribe_request(target, audio_path, language, prompt, response_format, if granularity: fields.append(("timestamp_granularities[]", granularity)) body, ctype = _multipart(fields, "file", audio_path) + # An hour of meeting takes the local server a while, and the idle unload has + # to count that as the model being used rather than as nobody wanting it. + held = (ggml.whisper.busy() if target.provider == "local" + else contextlib.nullcontext()) try: - return _request( - f"{target.base_url.rstrip('/')}/audio/transcriptions", body, - _headers(target.provider, target.api_key, ctype), timeout=timeout, - aborter=aborter, - ) + with held: + return _request( + f"{target.base_url.rstrip('/')}/audio/transcriptions", body, + _headers(target.provider, target.api_key, ctype), timeout=timeout, + aborter=aborter, + ) except ApiError as exc: if target.provider == "local": raise local_failure(target.service, ggml.whisper, exc) from None diff --git a/dikte/app.py b/dikte/app.py index 43aa8c2..e31f5d8 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -289,6 +289,11 @@ class Dikte: self.update_action.triggered.connect(self.open_release_page) self.menu.addAction(self.update_action) + # Named in _refresh_tray, which is where the loaded models are known. + self.unload_action = QAction("", self.menu) + self.unload_action.triggered.connect(self.unload_models) + self.menu.addAction(self.unload_action) + self.settings_action = QAction(t("Settings…"), self.menu) self.settings_action.triggered.connect(self.open_settings) self.menu.addAction(self.settings_action) @@ -303,6 +308,10 @@ class Dikte: self.menu.addAction(self.quit_action) self.tray.setContextMenu(self.menu) + # A model unloads itself in the background, so what the unload row says + # goes stale between state changes. Refreshed as the menu opens, which + # is the only moment anybody reads it. + self.menu.aboutToShow.connect(self._refresh_tray) self.tray.setToolTip(t("Dikte: ready")) self.tray.activated.connect(self._tray_clicked) self._refresh_update() @@ -398,6 +407,21 @@ class Dikte: ) self.ask_cancel_action.setEnabled(self.ask_state == BUSY) + # A local model holds its memory whether or not anything is using it, so + # the menu says which of the two are loaded and offers to give it back. + # Hidden on a machine that runs neither: there is nothing to unload and + # nothing to report. + loaded = [server for server in (ggml.whisper, ggml.llm) if server.running] + self.unload_action.setVisible( + self.conf["transcribe_provider"] == "local" or self.conf.uses_local_llm() + ) + self.unload_action.setText( + t("Unload the models") if len(loaded) > 1 + else t("Unload the model") if loaded + else t("No model loaded") + ) + self.unload_action.setEnabled(bool(loaded)) + # The agent speaks through the icon only when dictation has nothing to # say, since dictation is the one being waited on in front of a screen. if self.state == IDLE and self.ask_state != IDLE: @@ -1209,6 +1233,18 @@ class Dikte: QDesktopServices.openUrl( QUrl(release.url if release is not None else update.RELEASES_PAGE)) + def unload_models(self): + """Give the memory back now rather than when the idle window closes.""" + held = [server for server in (ggml.whisper, ggml.llm) + if not server.unload()] + self._refresh_tray() + if held: + self.tray.showMessage( + "Dikte", + t("A model is loading or answering right now. Try again in a " + "moment."), + QSystemTrayIcon.MessageIcon.Information, 5000) + # ---- settings --------------------------------------------------------- def open_settings(self): diff --git a/dikte/cleanup.py b/dikte/cleanup.py index a1bced3..b35abf8 100644 --- a/dikte/cleanup.py +++ b/dikte/cleanup.py @@ -115,13 +115,16 @@ def _local(text, conf, system_prompt, timeout, aborter=None): """ service = t("Local model") try: - return api.cleanup( - text, "", conf["local_llm_model"], system_prompt, - reasoning=conf["local_llm_reasoning"], - base_url=api.serving(ggml.llm), - timeout=max(timeout, api.LOCAL_TIMEOUT), - provider="local-llm", service=service, aborter=aborter, - ) + # Held for the length of the request so that the idle unload does not + # take the model away from a block still being cleaned up. + with ggml.llm.busy(): + return api.cleanup( + text, "", conf["local_llm_model"], system_prompt, + reasoning=conf["local_llm_reasoning"], + base_url=api.serving(ggml.llm), + timeout=max(timeout, api.LOCAL_TIMEOUT), + provider="local-llm", service=service, aborter=aborter, + ) except api.ApiError as exc: # A server that died mid-request would otherwise report only that the # connection dropped, when the reason is in its own output. diff --git a/dikte/config.py b/dikte/config.py index 6025286..86df888 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -442,6 +442,15 @@ DEFAULTS = { # 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", + + # --- what happens to both of them when nothing is using them ------------- + # One pair for the two servers rather than a pair each: what is being + # decided is whether a machine keeps gigabytes tied up between dictations, + # and nobody wants that answered one model at a time. On by default because + # a reload costs seconds and the memory costs the rest of the desktop. + "local_idle_unload": True, + "local_idle_minutes": 10, + "cleanup_prompt": "", # empty -> language-specific default "auto_paste": True, "paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac @@ -710,6 +719,14 @@ class Config: binary=self["local_llm_binary"], context=int(self["local_llm_context"]), ) + ggml.whisper.set_idle(self.idle_seconds()) + ggml.llm.set_idle(self.idle_seconds()) + + def idle_seconds(self): + """How long a loaded model may sit unused. 0 means it is kept.""" + if not self["local_idle_unload"]: + return 0 + return max(1, int(self["local_idle_minutes"])) * 60 def uses_local_llm(self): """Whether anything is set to run the local cleanup model.""" diff --git a/dikte/ggml.py b/dikte/ggml.py index dbf768f..2160f36 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -26,6 +26,7 @@ interface already knows how to show. import atexit import collections +import contextlib import ctypes import ctypes.util import hashlib @@ -68,6 +69,10 @@ STARTUP_TIMEOUT = 180.0 # to load takes longer than this to be read in first. The line between "worth # another port" and "would fail the same way again" is drawn on time. EARLY_EXIT_WINDOW = 5.0 +# How often the watcher looks at a model it has been asked to unload when idle. +# Short next to any window worth setting, so the memory goes back within seconds +# of the window closing rather than a minute after it. +IDLE_CHECK_SECONDS = 5.0 DOWNLOAD_CHUNK = 1 << 20 # `health` is the path that answers only once the model is in memory. whisper @@ -1116,6 +1121,15 @@ class Server: # The pid this instance last wrote to its pid file, so _forget never # removes a file some other Dikte wrote after us. self._pid = 0 + # The idle unload. `_idle` is the window in seconds, zero meaning the + # model stays loaded until something else stops it; `_used` is when the + # address was last handed out or a request last finished; `_busy` counts + # the requests still in flight. The count is there because a file being + # transcribed is one address lookup and then minutes of work, which to a + # clock started at the lookup looks exactly like a model nobody wants. + self._idle = 0.0 + self._used = 0.0 + self._busy = 0 # ---- settings -------------------------------------------------------- @@ -1133,6 +1147,23 @@ class Server: with self._lock: return dict(self._settings) + def set_idle(self, seconds): + """How long a loaded model may sit unused before the memory goes back. + + Deliberately not one of the settings above: those describe the server + that is running, and changing one has to restart it. This describes how + long to keep it, which the server it is applied to never needs to know. + Zero keeps the model until something else stops it. + """ + with self._lock: + self._idle = max(0.0, float(seconds)) + + @property + def idle(self): + """The window `set_idle` was last given, in seconds.""" + with self._lock: + return self._idle + def _settings_key(self): """What a running server would have to be restarted for.""" return json.dumps(self._settings, sort_keys=True, default=str) @@ -1172,13 +1203,83 @@ class Server: proc, port, log = self._launch(settings) with self._lock: self._proc, self._port, self._log, self._key = proc, port, log, key + # Only the clock. The count is not this launch's to reset: a + # caller that took a hold and then asked for the address, which + # is what the local cleanup does, would have it wiped here and + # spend the whole request unprotected. + self._used = time.monotonic() + threading.Thread(target=self._watch, args=(proc,), daemon=True).start() return self.base_url() def _current_url(self): + """The address of a server running the current settings, or "". + + Asking counts as using it. Everything that asks is about to send a + request, and the idle watcher reads the same clock, so the stamp has to + be set here rather than where the answer comes back. + """ 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 "") + if not (up and self._key == self._settings_key()): + return "" + self._used = time.monotonic() + return f"http://{HOST}:{self._port}/v1" + + @contextlib.contextmanager + def busy(self): + """Hold the model for the length of one request. + + A dictation is over a second after the address was handed out, but a + file is minutes of it, and an hour of meeting is longer still. Without + the count the watcher would unload the model out from under the request + that started it. + """ + with self._lock: + self._busy += 1 + try: + yield + finally: + with self._lock: + # Nothing else moves the count, so every hold that was taken is + # given back here and it stays balanced across a restart. A hold + # outliving the server it was taken against only keeps the next + # one loaded a moment longer, which is the safe way round. + self._busy -= 1 + self._used = time.monotonic() + + def _idle_now(self, proc): + """Whether `proc` is still ours and has been sitting unused long enough.""" + with self._lock: + if self._proc is not proc or not self._idle or self._busy: + return False + return time.monotonic() - self._used >= self._idle + + def _watch(self, proc): + """Give the memory back when nothing has asked anything for a while. + + One thread per launch, holding the process it was started for, so that a + server stopped and started again is watched by the new thread alone and + this one leaves on the first pass that finds its own process gone. + + Started whatever the window is, zero included: turning the unload on in + Settings has to reach a model that is already loaded, and a thread that + wakes every few seconds to read one number is cheaper than the machinery + for starting one later. + """ + while True: + time.sleep(IDLE_CHECK_SECONDS) + with self._lock: + if self._proc is not proc: + return # stopped, or replaced by a later launch + if not self._idle_now(proc): + continue + with self._starting: + # Asked once more under the lock a start has to take. An address + # handed out while this thread waited its turn stamps _used, and + # the request behind it must not arrive at a server killed here. + if self._idle_now(proc): + self._stop_now() + return def _launch(self, settings): args = self._build(settings) # raises LocalError when unusable @@ -1285,6 +1386,31 @@ class Server: except subprocess.TimeoutExpired: pass + def unload(self): + """Stop the server unless it is in the middle of something. + + The same rule the idle watcher goes by, taken by hand from the menu, and + it says no for the same reason: the memory is worth having back, but not + at the price of the dictation waiting on it. A model still being read in + counts as in the middle of something too, and that is why the lock is + asked for rather than waited on: this runs on the interface's own + thread, and a start holds _starting for as long as the load takes, which + for a large model on a cold cache is most of a minute. True when nothing + is loaded any more, either way. + """ + if not self._starting.acquire(blocking=False): + return False + try: + with self._lock: + if self._proc is None: + return True + if self._busy: + return False + self._stop_now() + return True + finally: + self._starting.release() + def stop(self): # Taking _starting means a stop cannot slide past a launch in flight: # serve() finishes registering its child first, and the child is then diff --git a/dikte/i18n.py b/dikte/i18n.py index 50c497d..8974553 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -782,6 +782,26 @@ TR = { "On this machine": "Bu makinede", "Use the graphics card": "Ekran kartını kullan", "Load the model when Dikte starts": "Modeli Dikte açılırken yükle", + "Models on this machine": "Bu makinedeki modeller", + "Unload a model that is sitting unused": "Kullanılmayan modeli bellekten çıkar", + "A loaded model holds its memory whether anything is using it or " + "not: over a gigabyte for whisper, several for an LLM. Unloading " + "gives that back to the rest of the desktop, and the next " + "dictation loads it again at the cost of the seconds that takes.": + "Yüklü bir model, kullanılsa da kullanılmasa da belleği tutar: whisper " + "için bir gigabaytın üzerinde, bir LLM için birkaç gigabayt. Bellekten " + "çıkarmak bunu masaüstünün geri kalanına iade eder, sonraki dikte de " + "modeli birkaç saniye bekleyerek yeniden yükler.", + " minute": " dakika", + " minutes": " dakika", + "After": "Şu kadar sonra", + "Unload the model": "Modeli bellekten çıkar", + "Unload the models": "Modelleri bellekten çıkar", + "No model loaded": "Yüklü model yok", + "A model is loading or answering right now. Try again in a " + "moment.": + "Bir model şu anda yükleniyor ya da cevap veriyor. Az sonra tekrar " + "deneyin.", "Local whisper": "Yerel whisper", "Local model": "Yerel model", "Not installed.": "Kurulu değil.", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 6a98298..828e373 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -1309,9 +1309,47 @@ class SettingsWindow(QDialog): orr_form.addRow(self.local_llm_options) outer.addWidget(orr) + + # One box for both servers rather than a row inside each: what is being + # decided is whether this machine keeps gigabytes tied up between + # dictations, and that is not a question anybody wants to answer once + # per model. + self.local_box = QGroupBox(t("Models on this machine")) + local_form = QFormLayout(self.local_box) + self.local_idle_unload = QCheckBox(t("Unload a model that is sitting unused")) + self.local_idle_unload.setToolTip( + t("A loaded model holds its memory whether anything is using it or " + "not: over a gigabyte for whisper, several for an LLM. Unloading " + "gives that back to the rest of the desktop, and the next " + "dictation loads it again at the cost of the seconds that takes.")) + self.local_idle_minutes = QSpinBox() + self.local_idle_minutes.setRange(1, 720) + self.local_idle_minutes.valueChanged.connect(self._idle_suffix) + self._idle_suffix(self.local_idle_minutes.value()) + self.local_idle_unload.toggled.connect(self.local_idle_minutes.setEnabled) + local_form.addRow("", self.local_idle_unload) + local_form.addRow(t("After"), self.local_idle_minutes) + outer.addWidget(self.local_box) + outer.addStretch(1) return page + def _idle_suffix(self, minutes): + """The spin box's own noun, since its lowest value is one of them. + + Turkish is handed both and translates them the same: a number there is + followed by the singular however many it counts. + """ + self.local_idle_minutes.setSuffix( + t(" minute") if minutes == 1 else t(" minutes")) + + def _refresh_local_box(self): + """The idle unload is only on screen when something here runs locally.""" + self.local_box.setVisible( + (self.transcribe_provider.currentData() or "local") == "local" + or (self.cleanup_provider.currentData() or "openrouter") == "local" + ) + def _prompt_tab(self): page = QWidget() layout = QVBoxLayout(page) @@ -2078,6 +2116,9 @@ class SettingsWindow(QDialog): 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.local_idle_unload.setChecked(conf["local_idle_unload"]) + self.local_idle_minutes.setValue(int(conf["local_idle_minutes"])) + self.local_idle_minutes.setEnabled(conf["local_idle_unload"]) # The defaults as they read NOW, kept for the save comparison: after a # language switch the boxes still hold the old language's default, and # comparing against the new one would store that text as a custom @@ -2209,6 +2250,8 @@ class SettingsWindow(QDialog): 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 "" + conf["local_idle_unload"] = self.local_idle_unload.isChecked() + conf["local_idle_minutes"] = self.local_idle_minutes.value() # Store an empty prompt when it matches a default: the one it was # loaded with, or today's (a Reset click in a session that switched @@ -2351,6 +2394,7 @@ class SettingsWindow(QDialog): 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) + self._refresh_local_box() if local: return self.transcribe_model.clear() @@ -2859,6 +2903,7 @@ class SettingsWindow(QDialog): provider != "local") self.cleanup_form.setRowVisible(self.local_llm, provider == "local") self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local") + self._refresh_local_box() binary = cleanup.executable(provider) found = shutil.which(binary) if binary else "" if provider == "local": diff --git a/tests/test_api.py b/tests/test_api.py index 50b4ef7..ac539c4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -9,6 +9,7 @@ is blocked on, and a faked urlopen has no socket to cut, so those tests talk to a server of their own on the loopback interface. """ +import contextlib import http.server import json import os @@ -621,6 +622,7 @@ class FakeServer: self.fails = fails self.log = log self.starts = 0 + self.held = 0 def serve(self): self.starts += 1 @@ -628,6 +630,14 @@ class FakeServer: raise ggml.LocalError(self.fails) return self.url + @contextlib.contextmanager + def busy(self): + self.held += 1 + try: + yield + finally: + self.held -= 1 + def error(self): return self.log diff --git a/tests/test_config.py b/tests/test_config.py index 0138a04..a925579 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -689,3 +689,24 @@ class ReadyToRun(DikteTest): self.assertEqual(ggml.whisper.settings()["threads"], 4) self.assertFalse(ggml.whisper.settings()["gpu"]) self.assertEqual(ggml.llm.settings()["context"], 4096) + + def test_the_idle_window_is_in_seconds(self): + conf = self.config(local_idle_unload=True, local_idle_minutes=15) + self.assertEqual(conf.idle_seconds(), 900) + + def test_an_unchecked_box_keeps_the_model(self): + conf = self.config(local_idle_unload=False, local_idle_minutes=15) + self.assertEqual(conf.idle_seconds(), 0) + + def test_a_window_of_no_minutes_is_still_a_window(self): + """The spin box will not go below one; a config edited by hand can.""" + conf = self.config(local_idle_unload=True, local_idle_minutes=0) + self.assertEqual(conf.idle_seconds(), 60) + + def test_both_servers_are_told_the_window(self): + conf = self.config(local_idle_unload=True, local_idle_minutes=3) + self.addCleanup(ggml.llm.set_idle, 0) + self.addCleanup(ggml.whisper.set_idle, 0) + conf.apply_local() + self.assertEqual(ggml.whisper.idle, 180) + self.assertEqual(ggml.llm.idle, 180) diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 649e192..2497aec 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -783,7 +783,9 @@ STAND_IN = textwrap.dedent(""" """) -class Servers(Local): +class ServerCase(Local): + """The stand-in server and the fixture around it, with no tests of its own.""" + def setUp(self): super().setUp() self.path("data").mkdir(parents=True, exist_ok=True) @@ -806,6 +808,8 @@ class Servers(Local): self.addCleanup(made.stop) return made + +class Servers(ServerCase): def test_a_started_server_hands_back_its_address(self): server = self.server() url = server.serve() @@ -1037,6 +1041,122 @@ class Servers(Local): self.assertFalse(server.sweep()) # and the pid file went with it +class IdleUnload(ServerCase): + """Giving the memory back when nothing has asked anything for a while.""" + + IDLE = 0.3 + + def setUp(self): + super().setUp() + # The real check runs every five seconds against a window of minutes. + # Both are scaled down here; what is being tested is the decision, and + # nothing in it reads the clock in units of its own. + self.patch_attr(ggml, "IDLE_CHECK_SECONDS", 0.05) + + def idle_server(self, seconds=None, **settings): + server = self.server(**settings) + server.set_idle(self.IDLE if seconds is None else seconds) + return server + + def wait_for(self, predicate, timeout=5.0): + """True as soon as `predicate` holds, False once the wait runs out.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.02) + return False + + def test_a_model_nobody_is_using_is_unloaded(self): + server = self.idle_server() + server.serve() + self.assertTrue(self.wait_for(lambda: not server.running)) + + def test_the_default_is_to_keep_it(self): + """A server nobody set a window on stays until something stops it.""" + server = self.server() + server.serve() + self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6)) + + def test_a_window_of_zero_keeps_it_too(self): + server = self.idle_server(0) + server.serve() + self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6)) + + def test_a_request_in_flight_holds_the_model(self): + """A file is one address lookup and then minutes of work: the clock + alone would call that idle and unload it mid-transcription.""" + server = self.idle_server() + server.serve() + with server.busy(): + self.assertFalse( + self.wait_for(lambda: not server.running, timeout=self.IDLE * 3)) + self.assertTrue(self.wait_for(lambda: not server.running)) + + def test_asking_for_the_address_puts_the_window_back(self): + server = self.idle_server() + first = server.serve() + for _ in range(4): + time.sleep(self.IDLE / 2) + self.assertEqual(server.serve(), first) # never restarted + self.assertTrue(server.running) + + def test_the_next_request_loads_it_again(self): + server = self.idle_server() + first = server.serve() + self.assertTrue(self.wait_for(lambda: not server.running)) + second = server.serve() + self.assertTrue(server.running) + self.assertNotEqual(second, first) # a new process, a new port + + def test_the_watcher_of_a_stopped_server_does_not_touch_the_next_one(self): + server = self.idle_server() + server.serve() + server.stop() + server.set_idle(0) + server.serve() + self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6)) + + def test_unloading_by_hand_does_not_wait_for_the_window(self): + server = self.idle_server(0) + server.serve() + self.assertTrue(server.unload()) + self.assertFalse(server.running) + + def test_a_hold_taken_before_the_start_survives_it(self): + """The local cleanup takes the hold and only then asks for the address, + so the start it triggers must not be what drops the hold.""" + server = self.idle_server() + with server.busy(): + server.serve() + self.assertFalse( + self.wait_for(lambda: not server.running, timeout=self.IDLE * 3)) + self.assertTrue(self.wait_for(lambda: not server.running)) + + def test_unloading_is_refused_while_the_model_is_still_loading(self): + """It runs on the interface's thread, and a start holds its lock for as + long as the load takes: waiting there would freeze the whole window.""" + server = self.idle_server(0, extra=["--wait", "0.6"]) + thread = threading.Thread(target=server.serve) + thread.start() + try: + began = time.monotonic() + self.assertFalse(server.unload()) + self.assertLess(time.monotonic() - began, 0.2) + finally: + thread.join(timeout=10) + + def test_unloading_is_refused_while_a_request_is_in_flight(self): + server = self.idle_server(0) + server.serve() + with server.busy(): + self.assertFalse(server.unload()) + self.assertTrue(server.running) + + def test_unloading_nothing_is_not_a_refusal(self): + self.assertTrue(self.server().unload()) + + class Arguments(Local): """What the two command lines say, since neither program is here to say it.""" diff --git a/tests/test_ui.py b/tests/test_ui.py index 51af17e..01b1ba4 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -83,6 +83,8 @@ CHANGED = { "local_llm_gpu": False, "local_llm_preload": True, "local_llm_reasoning": "low", + "local_idle_unload": False, + "local_idle_minutes": 45, "cleanup_prompt": "Only fix the punctuation.", "file_cleanup_prompt": "Keep the stamps where they are.", "transcribe_prompt": "Paraşüt, OpenFrame", @@ -1701,6 +1703,26 @@ class LocalModels(DikteTest): # Its own thinking box, because the two default to opposite things. self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning)) + def test_the_idle_unload_is_offered_to_whoever_runs_a_model_here(self): + for transcriber, cleaner in (("local", "openrouter"), + ("openai", "local"), + ("local", "local")): + with self.subTest(transcriber=transcriber, cleaner=cleaner): + window = self.window(self.config(transcribe_provider=transcriber, + cleanup_provider=cleaner)) + self.assertTrue(window.local_box.isVisibleTo(window)) + + def test_a_machine_that_runs_neither_is_not_asked_about_memory(self): + window = self.window(self.config(transcribe_provider="openai", + cleanup_provider="openrouter")) + self.assertFalse(window.local_box.isVisibleTo(window)) + + def test_the_minutes_follow_the_checkbox(self): + window = self.window(self.config(local_idle_unload=False)) + self.assertFalse(window.local_idle_minutes.isEnabled()) + window.local_idle_unload.setChecked(True) + self.assertTrue(window.local_idle_minutes.isEnabled()) + def test_each_cleaner_brings_its_own_model_row_and_no_other(self): window = self.window(cfg.Config()) rows = {"openrouter": window.cleanup_model_row, From 4b3ae8d70b8707f8e8ec0293413b41af87db68ee Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 5 Sep 2026 12:21:48 +0300 Subject: [PATCH 36/37] Dikte 1.2.0 --- dikte/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dikte/__init__.py b/dikte/__init__.py index 9cd7362..cb137c2 100644 --- a/dikte/__init__.py +++ b/dikte/__init__.py @@ -10,4 +10,4 @@ business loading Qt to answer one question. # both the .dmg's Info.plist and the AppImage's file name are built from it. A # build off master rather than off a tag appends the commit to it, so that a # bug report from someone running "latest" names a commit. -__version__ = "1.1.0" +__version__ = "1.2.0" From e85622aefbcd7a886b1a0d5095dc188d92225bc1 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Mon, 7 Sep 2026 11:56:19 +0300 Subject: [PATCH 37/37] Build cues out of word times where a model marks no segments Not every model behind /audio/transcriptions marks segments the way whisper does. microsoft/mai-transcribe-2 answers a fourteen minute video with three of them, one per paragraph, and to_srt turns each into a cue that stays up for minutes. The model is worth keeping for what it hears, so the times are taken from somewhere else instead: the same request now asks for word timestamps too, and where the segments come back too long to be cues, the cues are cut out of the words. A cue ends where a sentence does, and failing that where it has grown too long to read or to leave up. A full stop too early in a cue is not the end of a sentence but a list marker or a shortened word, and one that ends up short anyway is held on screen until the next needs the space. Whisper still answers with its own segments and nothing on that path changes; the local server is not asked for words it was never asked for, and a hosted model that refuses the field falls back to the request it used to answer. A cue is short enough now that two can begin in the same second, so to_srt hands out every timing a second holds rather than the first. --- dikte/api.py | 125 +++++++++++++++++++++++++++++++++++++--- dikte/filetranscribe.py | 13 ++++- tests/test_api.py | 75 +++++++++++++++++++++++- 3 files changed, 202 insertions(+), 11 deletions(-) diff --git a/dikte/api.py b/dikte/api.py index 99d43f3..503ee1f 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -384,8 +384,8 @@ def _transcribe_request(target, audio_path, language, prompt, response_format, # takes it as the initial prompt, the way OpenAI does. if prompt and target.provider != "openrouter": fields.append(("prompt", prompt)) - if granularity: - fields.append(("timestamp_granularities[]", granularity)) + for level in granularity or (): + fields.append(("timestamp_granularities[]", level)) body, ctype = _multipart(fields, "file", audio_path) # An hour of meeting takes the local server a while, and the idle unload has # to count that as the model being used rather than as nobody wanting it. @@ -443,6 +443,96 @@ def _merge_word_splits(segments): return merged +# A cue built here is one a reader has time for: about two lines of subtitle, +# and no longer on screen than a sentence takes to say. Neither is a hard rule +# for a sentence that ends early, only the point past which one is broken. +MAX_CUE_SECONDS = 7.0 +MAX_CUE_CHARS = 84 +# The other end of it: a cue nobody can read because it was gone before they +# looked. A full stop this early in a cue is not the end of anything worth +# breaking on, which is what "1." and "Dr." are, and a cue that ends up short +# anyway is held on screen until the next one needs the space. +MIN_CUE_SECONDS = 1.2 +# No whisper segment is longer than the window it was heard in, so a segment +# that runs past this came from a model that is not marking segments at all. +WHISPER_WINDOW = 30.0 +SENTENCE_END = ".!?…" + + +def _too_coarse(segments): + """Whether these segments are too long to be cues, or are not there at all. + + Not every model behind /audio/transcriptions marks segments the way whisper + does. Some fill the field with one entry per paragraph, or with a single one + covering the whole file, which turns a fourteen minute video into three + subtitles. Word times are what those models do give, and cues built from + them are better than what the segments would have been. + """ + if not segments: + return True + return any(float(seg.get("end") or 0.0) - float(seg.get("start") or 0.0) + > WHISPER_WINDOW for seg in segments) + + +def cues_from_words(words): + """[(start, end, text)] cut out of word times, where segments were no use. + + A cue ends where a sentence does, and failing that wherever it has grown too + long to read or too long to leave up. Nothing is ever cut between two words: + the times that arrive are per word, and so are the ones that leave. + """ + cues = [] + start = end = 0.0 + current = [] + + def flush(): + nonlocal current + if current: + cues.append((start, max(end, start), " ".join(current))) + current = [] + + for word in words: + text = (word.get("word") or "").strip() + if not text: + continue + at = float(word.get("start") or 0.0) + until = float(word.get("end") or at) + if current: + grown = len(" ".join(current)) + 1 + len(text) + if grown > MAX_CUE_CHARS or until - start > MAX_CUE_SECONDS: + flush() + if not current: + start = at + current.append(text) + end = until + # A sentence can end inside the punctuation that closes a quote. What + # is too short to have been a sentence is a list marker or a shortened + # word, and the cue goes on rather than ending on it. + if (end - start >= MIN_CUE_SECONDS + and text.rstrip("\"')]»”’").endswith(tuple(SENTENCE_END))): + flush() + flush() + return _held(cues) + + +def _held(cues): + """Keep a cue that is still too short on screen, without covering the next. + + A one word sentence is a fifth of a second of audio and so a fifth of a + second of subtitle, which is a flicker. It stays up until the cue after it + starts, or for as long as it takes to read, whichever comes first. + """ + out = [] + for index, (start, end, text) in enumerate(cues): + if end - start < MIN_CUE_SECONDS: + room = start + MIN_CUE_SECONDS + if index + 1 < len(cues): + room = min(room, cues[index + 1][0]) + end = max(end, room) + out.append((start, end, text)) + return out + + def transcribe(target, audio_path, language="", prompt="", timeout=300, aborter=None): data = _transcribe_request( target, audio_path, language, prompt, "json", timeout=timeout, aborter=aborter @@ -459,15 +549,34 @@ def transcribe(target, audio_path, language="", prompt="", timeout=300, aborter= 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.file_model)), - audio_path, language, prompt, "verbose_json", - granularity="segment", timeout=timeout, aborter=aborter, - ) + target = target._replace(model=timestamp_model(target.provider, target.model, + target.file_model)) + ask = dict(language=language, prompt=prompt, response_format="verbose_json", + timeout=timeout, aborter=aborter) + # Word times are the way out of a model that does not mark segments, and + # whisper.cpp is not one of those, so the local server is only ever asked + # for what it has always been asked for. A hosted model that refuses the + # field says so with a 400, and the request it used to answer is still + # there to fall back on rather than losing the run over a field it did not + # need in the first place. + if target.provider == "local": + data = _transcribe_request(target, audio_path, granularity=("segment",), **ask) + else: + try: + data = _transcribe_request(target, audio_path, + granularity=("segment", "word"), **ask) + except ApiError as exc: + if exc.status != 400: + raise + data = _transcribe_request(target, audio_path, + granularity=("segment",), **ask) segments = data.get("segments") or [] if target.provider == "local": segments = _merge_word_splits(segments) + if _too_coarse(segments): + cues = cues_from_words(data.get("words") or []) + if cues: + return cues out = [] for seg in segments: text = (seg.get("text") or "").strip() diff --git a/dikte/filetranscribe.py b/dikte/filetranscribe.py index c69bc2f..62430fd 100644 --- a/dikte/filetranscribe.py +++ b/dikte/filetranscribe.py @@ -283,11 +283,20 @@ def to_srt(text, segments): hours, minutes, secs = (int(g or 0) for g in match.groups()) cues.append([hours * 3600 + minutes * 60 + secs, None, body]) + # Several cues can share a whole second, so a second holds every segment + # that began in it and they are handed out in the order they were spoken. timing = {} for start, end, _ in segments: - timing.setdefault(int(start), (start, end)) + timing.setdefault(int(start), []).append((start, end)) for cue in cues: - cue[0], cue[1] = timing.get(cue[0], (float(cue[0]), 0.0)) + found = timing.get(cue[0]) + if found: + # The last one stays, so a second with more lines than it has + # timings hands the last of them out again rather than falling back + # to the bare second, which would run backwards from the line above. + cue[0], cue[1] = found.pop(0) if len(found) > 1 else found[0] + else: + cue[0], cue[1] = float(cue[0]), 0.0 for index, cue in enumerate(cues): following = cues[index + 1][0] if index + 1 < len(cues) else 0.0 if following > cue[0]: diff --git a/tests/test_api.py b/tests/test_api.py index 6be712a..b29ac74 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -322,7 +322,12 @@ class TranscribeSegments(DikteTest): fields = multipart_fields(calls[0]) self.assertEqual(fields["model"], "whisper-1") self.assertEqual(fields["response_format"], "verbose_json") - self.assertEqual(fields["timestamp_granularities[]"], "segment") + # Both are asked for: whisper answers with segments, and a model that + # does not mark them still answers with word times. + body = calls[0].data.decode("utf-8", "replace") + for level in ("segment", "word"): + self.assertIn( + f'name="timestamp_granularities[]"\r\n\r\n{level}\r\n', body) def test_openrouter_uses_the_namespaced_id(self): with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls: @@ -363,6 +368,74 @@ class TranscribeSegments(DikteTest): self.assertEqual(api.transcribe_segments(OPENAI, self.wav), [(5.0, 5.0, "hi")]) + def test_a_long_sentence_is_broken_where_it_gets_too_long_to_read(self): + words = [{"word": "word", "start": i * 0.2, "end": i * 0.2 + 0.2} + for i in range(60)] + cues = api.cues_from_words(words) + self.assertGreater(len(cues), 1) + for start, end, text in cues: + self.assertLessEqual(len(text), api.MAX_CUE_CHARS) + self.assertLessEqual(end - start, api.MAX_CUE_SECONDS + 0.2) + + def test_a_pause_between_short_sentences_does_not_join_them(self): + cues = api.cues_from_words([ + {"word": "Yes.", "start": 0.0, "end": 0.3}, + {"word": "No.", "start": 9.0, "end": 9.3}, + ]) + self.assertEqual([(start, text) for start, _, text in cues], + [(0.0, "Yes."), (9.0, "No.")]) + + def test_a_cue_too_short_to_read_is_held_until_the_next_one(self): + cues = api.cues_from_words([ + {"word": "Yes.", "start": 0.0, "end": 0.3}, + {"word": "No.", "start": 9.0, "end": 9.3}, + ]) + # The first has the room for it, the last has nothing after it to wait for. + self.assertEqual(cues[0][1], api.MIN_CUE_SECONDS) + self.assertEqual(cues[1][1], 9.0 + api.MIN_CUE_SECONDS) + + def test_a_list_marker_does_not_end_a_cue_on_its_own(self): + cues = api.cues_from_words([ + {"word": "1.", "start": 0.0, "end": 0.2}, + {"word": "Antivirus.", "start": 0.4, "end": 1.6}, + ]) + self.assertEqual([text for _, _, text in cues], ["1. Antivirus."]) + + def test_a_sentence_ending_inside_a_quote_still_ends_the_cue(self): + cues = api.cues_from_words([ + {"word": '"Stop', "start": 0.0, "end": 1.0}, + {"word": 'there."', "start": 1.1, "end": 2.0}, + {"word": "Then", "start": 2.2, "end": 2.6}, + ]) + self.assertEqual([text for _, _, text in cues], + ['"Stop there."', "Then"]) + + def test_word_times_take_over_from_segments_too_long_to_read(self): + # What a model that does not mark segments answers with: one entry for + # the whole file, and the real timing in the words beside it. + reply = { + "text": "One. Two.", + "segments": [{"start": 0, "end": 60, "text": "One. Two."}], + "words": [ + {"word": "One.", "start": 0.1, "end": 1.5}, + {"word": "Two.", "start": 1.7, "end": 3.0}, + ], + } + with fake_urlopen(reply): + self.assertEqual(api.transcribe_segments(OPENAI, self.wav), + [(0.1, 1.5, "One."), (1.7, 3.0, "Two.")]) + + def test_whisper_segments_are_left_alone_when_words_come_too(self): + reply = { + "text": "hi there", + "segments": [{"start": 0, "end": 2, "text": "hi there"}], + "words": [{"word": "hi", "start": 0.0, "end": 0.5}, + {"word": "there", "start": 0.5, "end": 2.0}], + } + with fake_urlopen(reply): + self.assertEqual(api.transcribe_segments(OPENAI, self.wav), + [(0.0, 2.0, "hi there")]) + def test_a_model_that_returned_no_segments_still_gives_its_text(self): with fake_urlopen(self.reply([], text="the whole thing")): self.assertEqual(api.transcribe_segments(OPENAI, self.wav),