diff --git a/README.md b/README.md index b003f5c..8853b8f 100644 --- a/README.md +++ b/README.md @@ -116,11 +116,12 @@ 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`), on **Google AI Studio** -(`gemini-3.5-flash-lite`) or, when one of them is installed, on Claude Code, -Codex or Antigravity. The first two are a single HTTP request; the three CLIs -each open a whole session to do it, which is where their few extra seconds go. -The keys fall back to `OPENAI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY` -and `GEMINI_API_KEY`, and are stored in +(`gemini-3.5-flash-lite`), on **OpenCode Go** (`deepseek-v4-flash`) or, when one +of them is installed, on Claude Code, Codex or Antigravity. The first three are +a single HTTP request; the three CLIs each open a whole session to do it, which +is where their few extra seconds go. The keys fall back to `OPENAI_API_KEY`, +`GROQ_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_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 @@ -195,10 +196,10 @@ running. a thing you can say to a window that is not Claude. Codex (`codex exec`) and Antigravity (`agy -p`) run the same way, though Antigravity takes neither a permission mode nor a sandbox from Dikte: what it may do without asking is - whatever its own allow-rules say. OpenRouter is there as a plain - question-and-answer fallback for a machine with no CLI on it. Provider, model, - permissions and working directory are under Settings → Agent, and commands - close together stay in one conversation. + whatever its own allow-rules say. OpenRouter or OpenCode Go is there as a + plain question-and-answer fallback for a machine with no 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 same time, which settles who said what by the channel a voice arrived on instead of guessing at it. The two sides are transcribed separately and @@ -245,7 +246,7 @@ 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 handing a dictation to Claude Code, Codex, agy or OpenRouter +assistant.py handing a dictation to Claude Code, Codex, agy or a chat model api.py transcription and cleanup requests (stdlib only) cleanup.py who rewrites the transcript: a hosted model, one here, a CLI ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving diff --git a/README.tr.md b/README.tr.md index 26d0772..2e6a461 100644 --- a/README.tr.md +++ b/README.tr.md @@ -113,12 +113,13 @@ seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. Bulu 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`), **Google AI Studio**'da -(`gemini-3.5-flash-lite`) ya da kuruluysa Claude Code, Codex veya -Antigravity'de çalışır. İlk ikisi tek bir HTTP isteği; üç CLI ise bunun için -birer oturum açar, fazladan giden birkaç saniye de oradan gelir. Anahtarları boş -bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY` ve -`GEMINI_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` içinde, -izinler 600, Mac'te ise `~/Library/Application Support/Dikte` altında. +(`gemini-3.5-flash-lite`), **OpenCode Go**'da (`deepseek-v4-flash`) ya da +kuruluysa Claude Code, Codex veya Antigravity'de çalışır. İlk üçü tek bir HTTP +isteği; üç CLI ise bunun için birer oturum açar, fazladan giden birkaç saniye de +oradan gelir. Anahtarları boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY`, +`OPENROUTER_API_KEY`, `GEMINI_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. @@ -190,8 +191,8 @@ olmasını ister. olmanı sağlayan da budur. Codex (`codex exec`) ile Antigravity (`agy -p`) da aynı şekilde çalışır; ama Antigravity'ye Dikte bir izin kipi ya da sandbox veremiyor, sormadan ne yapabileceğini kendi allow-rule'ları belirliyor. - OpenRouter ise hiçbiri 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 hiçbiri 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 @@ -238,7 +239,7 @@ 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, agy ya da OpenRouter'dan geçirme +assistant.py dikteyi Claude Code, Codex, agy ya da sohbet modelinden geçirme api.py transkript ve temizleme istekleri (yalnız stdlib) cleanup.py transkripti kim temizler: bulutta bir model, burası, bir CLI ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma diff --git a/dikte/api.py b/dikte/api.py index 98a6031..61e0857 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -562,15 +562,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), @@ -581,11 +582,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 13d564e..d82acf1 100644 --- a/dikte/assistant.py +++ b/dikte/assistant.py @@ -1,17 +1,18 @@ """Handing a dictation to an agent as a command, and pasting back its answer. -Four of them, because not everyone has the same one installed: +Five 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 Antigravity `agy -p`, Google's, with a browser of its own attached 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 three 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 no -CLI installed at all. +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 no CLI installed at all. What each of the three is allowed to do without asking is settled where that program keeps its own permissions, not here. Dikte hands Claude Code the mode @@ -44,16 +45,16 @@ from . import paths from .i18n import t SESSION_FILE = cfg.DATA_DIR / "assistant.json" -PROVIDERS = ("claude", "codex", "agy", "openrouter") +PROVIDERS = ("claude", "codex", "agy", "openrouter", "opencode") # What each one is called where a person reads it: the tray, the corner of # the screen, and the line an error is written in. SERVICES = {"claude": "Claude", "codex": "Codex", "agy": "Antigravity", - "openrouter": "OpenRouter"} + "openrouter": "OpenRouter", "opencode": "OpenCode Go"} -# 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. @@ -158,6 +159,8 @@ def model(conf): return conf["assistant_agy_model"].strip() or "agy" if name == "openrouter": return conf["assistant_openrouter_model"] + if name == "opencode": + return conf["assistant_opencode_model"] return conf["assistant_model"] @@ -253,8 +256,8 @@ 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"): + return _ask_chat(name, SERVICES[name], prompt, conf, on_stage) binary = executable(name) if not shutil.which(binary): @@ -513,29 +516,34 @@ def agy_models(): return ids -# --- 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 3e70057..a1bced3 100644 --- a/dikte/cleanup.py +++ b/dikte/cleanup.py @@ -29,7 +29,7 @@ from . import ggml from . import paths from .i18n import t -PROVIDERS = ("openrouter", "gemini", "local", "claude", "codex", "agy") +PROVIDERS = ("openrouter", "gemini", "opencode", "local", "claude", "codex", "agy") class CleanupError(api.ApiError): @@ -67,6 +67,8 @@ def model(conf): return conf["cleanup_agy_model"].strip() or "agy" if name == "gemini": return conf["cleanup_gemini_model"] + if name == "opencode": + return conf["cleanup_opencode_model"] return conf["cleanup_model"] @@ -91,6 +93,13 @@ def run(text, conf, system_prompt, timeout=180, aborter=None): base_url=conf["gemini_base_url"], timeout=timeout, provider="gemini", service="Google AI Studio", 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": _claude, "codex": _codex, "agy": _agy}[name] diff --git a/dikte/cli.py b/dikte/cli.py index c44deab..c1ef786 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -217,7 +217,8 @@ def cmd_ask(opts): if opts.model: key = {"claude": "assistant_model", "codex": "assistant_codex_model", "openrouter": "assistant_openrouter_model", - "agy": "assistant_agy_model"}[assistant.provider(conf)] + "agy": "assistant_agy_model", + "opencode": "assistant_opencode_model"}[assistant.provider(conf)] conf[key] = opts.model if opts.dir: conf["assistant_dir"] = opts.dir @@ -511,7 +512,7 @@ def cmd_history_clear(opts): # --- settings --------------------------------------------------------------- SECRET_KEYS = ("openai_api_key", "groq_api_key", "openrouter_api_key", - "gemini_api_key") + "gemini_api_key", "opencode_api_key") def _mask(key, value): @@ -709,6 +710,14 @@ def cmd_test_key(opts): results["gemini"] = {"ok": True, "message": message} except api.ApiError as exc: results["gemini"] = {"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()] @@ -873,13 +882,14 @@ def cmd_doctor(opts): # and marking them by the key they do not use reported every fully local # setup as broken. transcribe_ready = conf.transcribe_ready() - # Only the two that answer over HTTP have a key worth looking at. A CLI has - # a program to find instead, and the model on this machine has neither, so - # "no key" there has to read as beside the point rather than as one that has - # gone missing. + # Only the ones that answer over HTTP have a key worth looking at. A CLI + # has a program to find instead, and the model on this machine has neither, + # so "no key" there has to read as beside the point rather than as one that + # has gone missing. cleanup_service, cleanup_key = { "openrouter": ("OpenRouter", conf.openrouter_key()), "gemini": ("Google AI Studio", conf.gemini_key()), + "opencode": ("OpenCode Go", conf.opencode_key()), }.get(cleaner, ("", "")) if cleanup_service: cleanup_ready = bool(cleanup_key) @@ -1135,7 +1145,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, "gemini")) + choices=("all", *cfg.TRANSCRIBERS, "gemini", "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/dikte/config.py b/dikte/config.py index adf228e..9aa2228 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -391,6 +391,8 @@ DEFAULTS = { # Google's OpenAI-compatible endpoint. Cleanup only: there is no # /audio/transcriptions behind it, so it is not one of the TRANSCRIBERS. "gemini_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + "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", @@ -418,6 +420,7 @@ DEFAULTS = { "cleanup_codex_model": "", # empty -> whatever Codex is set to "cleanup_gemini_model": "gemini-3.5-flash-lite", "cleanup_agy_model": "", # empty -> whatever Antigravity is set to + "cleanup_opencode_model": "deepseek-v4-flash", "cleanup_reasoning": "", # empty -> whatever the model does by default # --- llama.cpp, on this machine ----------------------------------------- @@ -498,6 +501,7 @@ DEFAULTS = { "assistant_codex_sandbox": "workspace-write", "assistant_openrouter_model": "google/gemini-3.5-flash", "assistant_agy_model": "", # empty -> whatever Antigravity is set to + "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 @@ -643,6 +647,9 @@ class Config: def gemini_key(self): return self.api_key("gemini_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/dikte/i18n.py b/dikte/i18n.py index a4f98f1..c1ec602 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -43,10 +43,12 @@ _TR_CASES = { "dative": { "Claude": "Claude'a", "Codex": "Codex'e", "OpenRouter": "OpenRouter'a", "Google AI Studio": "Google AI Studio'ya", "Antigravity": "Antigravity'ye", + "OpenCode Go": "OpenCode Go'ya", }, "accusative": { "Claude": "Claude'u", "Codex": "Codex'i", "OpenRouter": "OpenRouter'ı", "Google AI Studio": "Google AI Studio'yu", "Antigravity": "Antigravity'yi", + "OpenCode Go": "OpenCode Go'yu", }, } @@ -231,25 +233,27 @@ TR = { "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 GEMINI_API_KEY)": "(boşsa GEMINI_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 Google AI Studio.": "Google AI Studio ü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 and Google AI Studio 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 ile Google AI Studio 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.", + "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 " @@ -557,6 +561,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.", "Antigravity has neither a permission mode nor a sandbox to hand it, so " "what it may do without asking is whatever its own allow-rules say. The " "Permissions and Sandbox boxes above belong to the other two; the working " @@ -825,16 +840,6 @@ 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.", "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 147e88f..dfbc1b2 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -76,8 +76,8 @@ AGY_MODELS = [ # agent can run on open a whole session to do the smaller job. CLEANUP_PROVIDERS = [ ("OpenRouter", "openrouter"), ("Google AI Studio", "gemini"), - ("This machine (llama.cpp)", "local"), ("Claude Code", "claude"), - ("Codex", "codex"), ("Antigravity", "agy"), + ("OpenCode Go", "opencode"), ("This machine (llama.cpp)", "local"), + ("Claude Code", "claude"), ("Codex", "codex"), ("Antigravity", "agy"), ] # Cleaning up a sentence is the lightest thing either of them will ever be # asked, so the small model comes first. @@ -90,7 +90,7 @@ MEETING_MODELS = [ ] ASSISTANT_PROVIDERS = [ ("Claude Code", "claude"), ("Codex", "codex"), ("Antigravity", "agy"), - ("OpenRouter", "openrouter"), + ("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. @@ -105,6 +105,13 @@ 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", ] +# 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", +] # 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 = [ @@ -572,6 +579,7 @@ class SettingsWindow(QDialog): _models_loaded = pyqtSignal(list, str) _gemini_models_loaded = pyqtSignal(list, str) + _opencode_models_loaded = pyqtSignal(list, str) _transcribe_models_loaded = pyqtSignal(list, str) _codex_models_loaded = pyqtSignal(list) _agy_models_loaded = pyqtSignal(list) @@ -638,6 +646,7 @@ class SettingsWindow(QDialog): self._gemini_models_loaded.connect(self._on_gemini_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._agy_models_loaded.connect(self._on_agy_models_loaded) self._hosted_models_loaded.connect(self._on_hosted_models_loaded) self._test_done.connect(self._on_test_done) @@ -832,6 +841,9 @@ class SettingsWindow(QDialog): self.gemini_key = self._key_row( keys_form, "gemini", t("(falls back to GEMINI_API_KEY)"), self._test_gemini, service="Google AI Studio") + 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")) @@ -904,11 +916,11 @@ class SettingsWindow(QDialog): for label, value in CLEANUP_PROVIDERS: self.cleanup_provider.addItem(t(label), value) self.cleanup_provider.setToolTip(t( - "OpenRouter and Google AI Studio 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 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) @@ -945,6 +957,16 @@ class SettingsWindow(QDialog): self.cleanup_codex_model.setToolTip(_typed_model_note("Codex")) 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) + self.cleanup_opencode_model.setToolTip(_typed_model_note("OpenCode Go")) + self.refresh_opencode_models = QPushButton(t("Fetch model list")) + self.refresh_opencode_models.clicked.connect(self._load_opencode_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_agy_model = QComboBox() self.cleanup_agy_model.setEditable(True) self.cleanup_agy_model.addItems([t("Antigravity's own default")] + AGY_MODELS) @@ -1165,6 +1187,23 @@ class SettingsWindow(QDialog): agy_form.addRow(agy_note) layout.addWidget(self.agy_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() @@ -1730,6 +1769,7 @@ class SettingsWindow(QDialog): self._key_fields[name].setText(conf[who.key]) self._models[name] = conf[who.model] self.gemini_key.setText(conf["gemini_api_key"]) + self.opencode_key.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 @@ -1750,6 +1790,7 @@ class SettingsWindow(QDialog): self.cleanup_agy_model.setCurrentText( conf["cleanup_agy_model"] or t("Antigravity'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"]) @@ -1781,6 +1822,7 @@ class SettingsWindow(QDialog): self._select_data(self.assistant_codex_sandbox, conf["assistant_codex_sandbox"]) self.assistant_openrouter_model.setCurrentText(conf["assistant_openrouter_model"]) self.assistant_agy_model.setCurrentText(conf["assistant_agy_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"]) @@ -1849,6 +1891,7 @@ class SettingsWindow(QDialog): conf[who.key] = self._key_fields[name].text().strip() conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model] 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() conf["local_gpu"] = self.local_gpu.isChecked() conf["local_preload"] = self.local_preload.isChecked() @@ -1872,6 +1915,10 @@ class SettingsWindow(QDialog): "" if agy_cleanup_model == t("Antigravity's own default") else agy_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() @@ -1914,6 +1961,10 @@ class SettingsWindow(QDialog): conf["assistant_agy_model"] = ( "" if agy_model == t("Antigravity's own default") else agy_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() @@ -2128,6 +2179,37 @@ class SettingsWindow(QDialog): combo.addItem(name, name) combo.setCurrentText(current) + def _load_opencode_models(self): + self.refresh_opencode_models.setEnabled(False) + self.models_label.setText(t("Fetching model list…")) + key, base = self._typed_key("opencode") + + def work(): + try: + self._opencode_models_loaded.emit( + api.openai_models(key, base, "OpenCode Go"), "") + except api.ApiError as exc: + self._opencode_models_loaded.emit([], str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _on_opencode_models_loaded(self, models, error): + self.refresh_opencode_models.setEnabled(True) + if error: + self.models_label.setText(t("Could not fetch the list: {error}", error=error)) + return + self._fill_opencode_boxes(models) + self.models_label.setText(t("{count} models loaded.", count=len(models))) + + def _fill_opencode_boxes(self, models): + # The agent runs on the same key and catalog, so its box is refilled + # from the same list. + for combo in (self.cleanup_opencode_model, self.assistant_opencode_model): + current = combo.currentText() + combo.clear() + combo.addItems(models) + combo.setCurrentText(current) + def _load_agy_models(self): """Ask Antigravity which models it offers, off the interface thread. @@ -2157,7 +2239,7 @@ class SettingsWindow(QDialog): combo.setCurrentText(current) def _load_hosted_models(self): - """Fetch OpenRouter's and Google's lists at open, without being asked. + """Fetch the hosted model lists at open, without being asked. The Fetch buttons stay: they are the retry, and the place a failure is worth explaining. Here nobody asked, so an error changes nothing on @@ -2174,6 +2256,12 @@ class SettingsWindow(QDialog): if gemini_key: jobs.append(("gemini", lambda: api.gemini_models(gemini_key, gemini_base))) + opencode_key = self.conf.opencode_key() + opencode_base = self.conf["opencode_base_url"] + if opencode_key: + jobs.append(("opencode", + lambda: api.openai_models(opencode_key, opencode_base, + "OpenCode Go"))) for provider, fetch in jobs: def work(provider=provider, fetch=fetch): try: @@ -2186,6 +2274,9 @@ class SettingsWindow(QDialog): threading.Thread(target=work, daemon=True).start() def _on_hosted_models_loaded(self, provider, models): + if provider == "opencode": + self._fill_opencode_boxes(models) + return combos = ((self.cleanup_model, self.meeting_model) if provider == "openrouter" else (self.cleanup_gemini_model,)) for combo in combos: @@ -2219,6 +2310,13 @@ class SettingsWindow(QDialog): count=len(api.gemini_models(key, base)), )) + 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 in cfg.TRANSCRIBERS: @@ -2458,6 +2556,8 @@ class SettingsWindow(QDialog): provider == "claude") self.cleanup_form.setRowVisible(self.cleanup_codex_model, provider == "codex") + self.cleanup_form.setRowVisible(self.cleanup_opencode_model_row, + provider == "opencode") self.cleanup_form.setRowVisible(self.cleanup_agy_model, provider == "agy") self.cleanup_form.setRowVisible(self.cleanup_reasoning, @@ -2470,6 +2570,8 @@ class SettingsWindow(QDialog): self.models_label.setText(t("Runs on this machine, on llama.cpp.")) elif provider == "gemini": self.models_label.setText(t("Runs on Google AI Studio.")) + 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: @@ -2487,6 +2589,7 @@ class SettingsWindow(QDialog): self.codex_box.setVisible(provider == "codex") self.openrouter_box.setVisible(provider == "openrouter") self.agy_box.setVisible(provider == "agy") + self.opencode_box.setVisible(provider == "opencode") self._refresh_assistant_status() def _refresh_assistant_status(self): @@ -2494,9 +2597,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_assistant.py b/tests/test_assistant.py index abc79bb..24cac5a 100644 --- a/tests/test_assistant.py +++ b/tests/test_assistant.py @@ -99,6 +99,7 @@ class Provider(DikteTest): self.assertEqual(assistant.executable("codex"), "codex") self.assertEqual(assistant.executable("agy"), "agy") self.assertEqual(assistant.executable("openrouter"), "") + self.assertEqual(assistant.executable("opencode"), "") def test_the_model_recorded_is_the_one_that_answered(self): """The history used to write Claude's setting whoever had answered.""" @@ -118,7 +119,8 @@ class Provider(DikteTest): def test_what_each_one_is_called(self): self.assertEqual(assistant.display_name(self.config()), "Claude") for name, called in (("codex", "Codex"), ("agy", "Antigravity"), - ("openrouter", "OpenRouter")): + ("openrouter", "OpenRouter"), + ("opencode", "OpenCode Go")): with self.subTest(name=name): self.assertEqual( assistant.display_name(self.config(assistant_provider=name)), @@ -736,6 +738,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 af41bbb..9de3dc3 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -60,6 +60,7 @@ class Provider(DikteTest): self.assertEqual(cleanup.executable("agy"), "agy") self.assertEqual(cleanup.executable("openrouter"), "") self.assertEqual(cleanup.executable("gemini"), "") + 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")), @@ -85,6 +86,9 @@ class Provider(DikteTest): cleanup.model(self.config(cleanup_provider="agy", cleanup_agy_model="gemini-3.7-flash-low")), "gemini-3.7-flash-low") + self.assertEqual( + cleanup.model(self.config(cleanup_provider="opencode", + cleanup_opencode_model="glm-5.3")), "glm-5.3") class OpenRouter(DikteTest): @@ -106,6 +110,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_cli(stdout="never") + with patcher, mock.patch.object(api, "cleanup", return_value="Done."): + cleanup.run("uh, done", conf, "the rules") + self.assertEqual(calls, []) + + class GoogleAiStudio(DikteTest): """Cleanup over Google's OpenAI-compatible endpoint: one request, no CLI.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index dc06388..67c6f21 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -424,6 +424,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.""" @@ -504,6 +514,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_survives_every_provider_cleanup_can_be_set_to(self): """It used to raise KeyError on the local model, whose executable is "".""" for name in cleanup.PROVIDERS: diff --git a/tests/test_config.py b/tests/test_config.py index 797462d..2c6535f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -189,6 +189,8 @@ class Keys(DikteTest): self.assertEqual(cfg.Config().groq_key(), "gsk-env") with mock.patch.dict(os.environ, {"GEMINI_API_KEY": "AIza-env"}): self.assertEqual(cfg.Config().gemini_key(), "AIza-env") + with mock.patch.dict(os.environ, {"OPENCODE_API_KEY": "opencode-env"}): + self.assertEqual(cfg.Config().opencode_key(), "opencode-env") class TranscribeTarget(DikteTest): @@ -577,12 +579,19 @@ class Defaults(unittest.TestCase): self.assertEqual(cfg.DEFAULTS["openai_api_key"], "") self.assertEqual(cfg.DEFAULTS["openrouter_api_key"], "") self.assertEqual(cfg.DEFAULTS["gemini_api_key"], "") + self.assertEqual(cfg.DEFAULTS["opencode_api_key"], "") def test_google_ai_studio_is_a_cleanup_provider_and_not_a_transcriber(self): """Its compatible endpoint has no /audio/transcriptions behind it.""" self.assertNotIn("gemini", cfg.TRANSCRIBERS) self.assertIn("gemini", cleanup.PROVIDERS) + 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", "ASSISTANT_PROMPT"): diff --git a/tests/test_ui.py b/tests/test_ui.py index 62eed3f..6273447 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -275,6 +275,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_row, "claude": window.cleanup_claude_model, "codex": window.cleanup_codex_model} for provider, box in boxes.items(): @@ -298,6 +299,42 @@ class Settings(DikteTest): self.assertEqual(window.cleanup_codex_model.currentText(), "my-own-model") + def test_opencode_answering_refills_both_of_its_boxes(self): + """The fetched catalog 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_opencode_s_list_arriving_at_open_leaves_the_other_boxes_alone(self): + 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_hosted_models_loaded("opencode", ["glm-5.3", "kimi-k3"]) + 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_offers_a_fetch_button_of_its_own(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_agy_answering_refills_both_of_its_boxes(self): """The same arrangement as Codex: both boxes, nothing chosen is lost.""" conf = self.config(cleanup_agy_model="my-own-model") @@ -340,10 +377,11 @@ class Settings(DikteTest): def test_a_key_on_file_is_fetched_with_at_open(self): window = self.window(self.config(openrouter_api_key="sk-or-x", - gemini_api_key="AIza-x")) + gemini_api_key="AIza-x", + opencode_api_key="opencode-x")) with mock.patch.object(settings_ui.threading, "Thread") as thread: REAL_LOAD_HOSTED_MODELS(window) - self.assertEqual(thread.call_count, 2) + self.assertEqual(thread.call_count, 3) def test_the_update_line_names_the_version_that_is_running(self): window = self.window(cfg.Config())