From 3c336e717833d5db7d448713fb470ca89cb07fa8 Mon Sep 17 00:00:00 2001 From: sudoeren Date: Tue, 25 Aug 2026 21:05:21 +0300 Subject: [PATCH] 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():