From 0a6c6128a224a545ec7c3c35d1f2c9752f6105da Mon Sep 17 00:00:00 2001 From: sudoeren Date: Tue, 25 Aug 2026 21:05:18 +0300 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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)