mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge pull request #56 from sudoeren/feat/opencode-go-provider
Add OpenCode Go as a cleanup and agent provider
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(), \
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user