Merge pull request #56 from sudoeren/feat/opencode-go-provider

Add OpenCode Go as a cleanup and agent provider
This commit is contained in:
Yusuf İpek
2026-08-27 16:31:47 +03:00
committed by GitHub
14 changed files with 373 additions and 90 deletions
+11 -10
View File
@@ -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
+10 -9
View File
@@ -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
+6 -5
View File
@@ -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)))
+31 -23
View File
@@ -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, ""
+10 -1
View File
@@ -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]
+17 -7
View File
@@ -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)
+7
View File
@@ -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.
+25 -20
View File
@@ -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.":
+120 -12
View File
@@ -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:
+38 -1
View File
@@ -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(), \
+30
View File
@@ -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."""
+19
View File
@@ -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:
+9
View File
@@ -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"):
+40 -2
View File
@@ -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())