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