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.
This commit is contained in:
sudoeren
2026-08-25 21:05:18 +03:00
parent 3663c7fd57
commit 0a6c6128a2
6 changed files with 124 additions and 29 deletions
+6 -5
View File
@@ -524,15 +524,16 @@ def cleanup(text, api_key, model, system_prompt, reasoning="",
def chat(messages, 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. """A conversation, rather than one transcript rewritten.
The messages are the whole history and come back unchanged; the caller keeps 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: if not api_key:
raise ApiError(t("{service} API key is empty. Add it in Settings.", raise ApiError(t("{service} API key is empty. Add it in Settings.",
service="OpenRouter")) service=service))
payload = { payload = {
"model": model, "model": model,
"messages": [{"role": "system", "content": system_prompt}] + list(messages), "messages": [{"role": "system", "content": system_prompt}] + list(messages),
@@ -543,11 +544,11 @@ def chat(messages, api_key, model, system_prompt, reasoning="",
data = _request( data = _request(
f"{base_url.rstrip('/')}/chat/completions", f"{base_url.rstrip('/')}/chat/completions",
json.dumps(payload).encode("utf-8"), json.dumps(payload).encode("utf-8"),
_headers("openrouter", api_key, "application/json"), _headers(provider, api_key, "application/json"),
timeout=timeout, timeout=timeout,
) )
except ApiError as exc: except ApiError as exc:
raise explain(exc, "OpenRouter") from None raise explain(exc, service) from None
choices = data.get("choices") or [] choices = data.get("choices") or []
if not choices: if not choices:
raise ApiError(_extract_error(json.dumps(data))) raise ApiError(_extract_error(json.dumps(data)))
+32 -23
View File
@@ -1,16 +1,17 @@
"""Handing a dictation to an agent as a command, and pasting back its answer. """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 Claude Code `claude -p`, the session you would have opened yourself
Codex `codex exec`, the same idea from the other shop Codex `codex exec`, the same idea from the other shop
OpenRouter a plain chat request, over the key that is already configured 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 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 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 in my calendar on Thursday" a thing you can say. The two chat requests cannot
that, and is there so that a question still gets an answer on a machine with touch any of that, and are there so that a question still gets an answer on a
neither CLI installed. machine with neither CLI installed.
Whichever it is, the reply is pasted exactly where the transcript would have 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 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 from .i18n import t
SESSION_FILE = cfg.DATA_DIR / "assistant.json" 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 # How many messages of a chat provider's conversation are carried forward. The
# CLIs keep their own history and need no such number; here every turn is resent # two CLIs keep their own history and need no such number; here every turn is
# in full, so the window has to end somewhere. # resent in full, so the window has to end somewhere.
MAX_HISTORY = 24 MAX_HISTORY = 24
# What to say in the indicator for a tool, keyed by the name the CLI uses. # 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): def display_name(conf):
"""What to call the thing being asked, in the tray and in the corner.""" """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 ----------------------------------------------------- # --- 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. one, and only the denial explains why it did not do what it was asked to.
""" """
name = provider(conf) name = provider(conf)
if name == "openrouter": if name in ("openrouter", "opencode"):
return _ask_openrouter(prompt, conf, on_stage) service = "OpenRouter" if name == "openrouter" else "OpenCode Go"
return _ask_chat(name, service, prompt, conf, on_stage)
binary = executable(name) binary = executable(name)
if not shutil.which(binary): if not shutil.which(binary):
@@ -338,29 +342,34 @@ def _codex_label(item):
return t("Using {name}", name=item_type or "a tool") return t("Using {name}", name=item_type or "a tool")
# --- OpenRouter ----------------------------------------------------------- # --- OpenRouter and OpenCode Go -------------------------------------------
def _ask_openrouter(prompt, conf, on_stage): def _ask_chat(name, service, prompt, conf, on_stage):
"""No tools, no files, no calendar: a question and an answer. """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 No tools, no files, no calendar. It is the fallback for a machine with
knows and nothing else. The conversation is ours to keep here, since there neither CLI on it, so it says what it knows and nothing else. The
is no session on the other end to resume. conversation is ours to keep here, since there is no session on the other
end to resume.
""" """
if on_stage: if on_stage:
on_stage(t("Thinking…")) 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}] 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: try:
answer = api.chat( answer = api.chat(
messages, conf.openrouter_key(), conf["assistant_openrouter_model"], messages, key, model, conf.assistant_prompt(),
conf.assistant_prompt(), reasoning=conf["assistant_reasoning"], reasoning=conf["assistant_reasoning"], base_url=base_url,
base_url=conf["openrouter_base_url"], timeout=conf["assistant_timeout"], provider=name, service=service,
timeout=conf["assistant_timeout"],
) )
except api.ApiError as exc: except api.ApiError as exc:
raise AssistantError(str(exc)) from exc raise AssistantError(str(exc)) from exc
write_session("openrouter", write_session(name,
messages=messages + [{"role": "assistant", "content": answer}]) messages=messages + [{"role": "assistant", "content": answer}])
return answer, "" return answer, ""
+10 -1
View File
@@ -23,7 +23,7 @@ from . import assistant
from . import ggml from . import ggml
from .i18n import t from .i18n import t
PROVIDERS = ("openrouter", "local", "claude", "codex") PROVIDERS = ("openrouter", "opencode", "local", "claude", "codex")
class CleanupError(api.ApiError): 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 # 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. # here there is only the name of the thing that did it.
return conf["cleanup_codex_model"].strip() or "codex" return conf["cleanup_codex_model"].strip() or "codex"
if name == "opencode":
return conf["cleanup_opencode_model"]
return conf["cleanup_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, base_url=conf["openrouter_base_url"], timeout=timeout,
aborter=aborter, 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": if name == "local":
return _local(text, conf, system_prompt, timeout, aborter) return _local(text, conf, system_prompt, timeout, aborter)
runner = _claude if name == "claude" else _codex runner = _claude if name == "claude" else _codex
+7
View File
@@ -385,6 +385,8 @@ DEFAULTS = {
"groq_base_url": "https://api.groq.com/openai/v1", "groq_base_url": "https://api.groq.com/openai/v1",
"openrouter_api_key": "", "openrouter_api_key": "",
"openrouter_base_url": "https://openrouter.ai/api/v1", "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_provider": "local", # "local", or a key of TRANSCRIBERS
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai "transcribe_model": "gpt-4o-transcribe", # used when provider is openai
"groq_transcribe_model": "whisper-large-v3-turbo", "groq_transcribe_model": "whisper-large-v3-turbo",
@@ -410,6 +412,7 @@ DEFAULTS = {
"cleanup_model": "google/gemini-3.5-flash-lite", "cleanup_model": "google/gemini-3.5-flash-lite",
"cleanup_claude_model": "haiku", # Claude Code: an alias, or a full model id "cleanup_claude_model": "haiku", # Claude Code: an alias, or a full model id
"cleanup_codex_model": "", # empty -> whatever Codex is set to "cleanup_codex_model": "", # empty -> whatever Codex is set to
"cleanup_opencode_model": "deepseek-v4-flash",
"cleanup_reasoning": "", # empty -> whatever the model does by default "cleanup_reasoning": "", # empty -> whatever the model does by default
# --- llama.cpp, on this machine ----------------------------------------- # --- llama.cpp, on this machine -----------------------------------------
@@ -488,6 +491,7 @@ DEFAULTS = {
"assistant_codex_model": "", # empty -> whatever Codex is set to "assistant_codex_model": "", # empty -> whatever Codex is set to
"assistant_codex_sandbox": "workspace-write", "assistant_codex_sandbox": "workspace-write",
"assistant_openrouter_model": "google/gemini-3.5-flash", "assistant_openrouter_model": "google/gemini-3.5-flash",
"assistant_opencode_model": "deepseek-v4-flash",
"assistant_reasoning": "", # empty -> the model's own default "assistant_reasoning": "", # empty -> the model's own default
"assistant_dir": "", # empty -> the home directory "assistant_dir": "", # empty -> the home directory
"assistant_prompt": "", # empty -> language-specific default "assistant_prompt": "", # empty -> language-specific default
@@ -589,6 +593,9 @@ class Config:
def openrouter_key(self): def openrouter_key(self):
return self.api_key("openrouter_api_key") return self.api_key("openrouter_api_key")
def opencode_key(self):
return self.api_key("opencode_api_key")
def transcribe_target(self): def transcribe_target(self):
"""Key, endpoint and model for whichever provider does speech to text. """Key, endpoint and model for whichever provider does speech to text.
+39
View File
@@ -59,6 +59,7 @@ class Provider(DikteTest):
self.assertEqual(assistant.executable("claude"), "claude") self.assertEqual(assistant.executable("claude"), "claude")
self.assertEqual(assistant.executable("codex"), "codex") self.assertEqual(assistant.executable("codex"), "codex")
self.assertEqual(assistant.executable("openrouter"), "") self.assertEqual(assistant.executable("openrouter"), "")
self.assertEqual(assistant.executable("opencode"), "")
def test_what_each_one_is_called(self): def test_what_each_one_is_called(self):
self.assertEqual(assistant.display_name(self.config()), "Claude") self.assertEqual(assistant.display_name(self.config()), "Claude")
@@ -67,6 +68,9 @@ class Provider(DikteTest):
self.assertEqual( self.assertEqual(
assistant.display_name(self.config(assistant_provider="openrouter")), assistant.display_name(self.config(assistant_provider="openrouter")),
"OpenRouter") "OpenRouter")
self.assertEqual(
assistant.display_name(self.config(assistant_provider="opencode")),
"OpenCode Go")
class Effort(unittest.TestCase): class Effort(unittest.TestCase):
@@ -507,6 +511,41 @@ class AskOpenRouter(DikteTest):
assistant.ask("when is it", conf) 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): class Ask(DikteTest):
def test_a_cli_that_is_not_installed_says_where_to_change_it(self): def test_a_cli_that_is_not_installed_says_where_to_change_it(self):
with only_these_tools(), \ with only_these_tools(), \
+30
View File
@@ -50,6 +50,7 @@ class Provider(DikteTest):
self.assertEqual(cleanup.executable("claude"), "claude") self.assertEqual(cleanup.executable("claude"), "claude")
self.assertEqual(cleanup.executable("codex"), "codex") self.assertEqual(cleanup.executable("codex"), "codex")
self.assertEqual(cleanup.executable("openrouter"), "") 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): 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")), self.assertEqual(cleanup.model(self.config(cleanup_model="some/model")),
@@ -65,6 +66,9 @@ class Provider(DikteTest):
self.assertEqual( self.assertEqual(
cleanup.model(self.config(cleanup_provider="codex", cleanup.model(self.config(cleanup_provider="codex",
cleanup_codex_model="gpt-5.4")), "gpt-5.4") 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): class OpenRouter(DikteTest):
@@ -86,6 +90,32 @@ class OpenRouter(DikteTest):
self.assertEqual(calls, []) 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): class ClaudeCode(DikteTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()