mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
+6
-5
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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(), \
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user