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="",
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)))
+32 -23
View File
@@ -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, ""
+10 -1
View File
@@ -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
+7
View File
@@ -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.
+39
View File
@@ -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(), \
+30
View File
@@ -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()