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:
|
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`),
|
speech to text on **OpenAI**, **Groq** or **OpenRouter** (`gpt-4o-transcribe`),
|
||||||
cleanup on OpenRouter (`google/gemini-3.5-flash-lite`), on **Google AI Studio**
|
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,
|
(`gemini-3.5-flash-lite`), on **OpenCode Go** (`deepseek-v4-flash`) or, when one
|
||||||
Codex or Antigravity. The first two are a single HTTP request; the three CLIs
|
of them is installed, on Claude Code, Codex or Antigravity. The first three are
|
||||||
each open a whole session to do it, which is where their few extra seconds go.
|
a single HTTP request; the three CLIs each open a whole session to do it, which
|
||||||
The keys fall back to `OPENAI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY`
|
is where their few extra seconds go. The keys fall back to `OPENAI_API_KEY`,
|
||||||
and `GEMINI_API_KEY`, and are stored in
|
`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
|
`~/.config/dikte/config.json`, mode 600, or in
|
||||||
`~/Library/Application Support/Dikte` on a Mac. Cleanup can be switched off, 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
|
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
|
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
|
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
|
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
|
whatever its own allow-rules say. OpenRouter or OpenCode Go is there as a
|
||||||
question-and-answer fallback for a machine with no CLI on it. Provider, model,
|
plain question-and-answer fallback for a machine with no CLI on it. Provider,
|
||||||
permissions and working directory are under Settings → Agent, and commands
|
model, permissions and working directory are under Settings → Agent, and
|
||||||
close together stay in one conversation.
|
commands close together stay in one conversation.
|
||||||
- **Meetings** are recorded from the microphone and the speaker output at the
|
- **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
|
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
|
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
|
ipc.py one request and one reply over the local socket
|
||||||
audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting
|
audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting
|
||||||
meeting.py channel split, speaker labelling, cleanup, minutes
|
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)
|
api.py transcription and cleanup requests (stdlib only)
|
||||||
cleanup.py who rewrites the transcript: a hosted model, one here, a CLI
|
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
|
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
|
seçersen sesi yazıya çevirme **OpenAI**, **Groq** ya da **OpenRouter**'da
|
||||||
(varsayılan `gpt-4o-transcribe`), temizleme OpenRouter'da
|
(varsayılan `gpt-4o-transcribe`), temizleme OpenRouter'da
|
||||||
(`google/gemini-3.5-flash-lite`), **Google AI Studio**'da
|
(`google/gemini-3.5-flash-lite`), **Google AI Studio**'da
|
||||||
(`gemini-3.5-flash-lite`) ya da kuruluysa Claude Code, Codex veya
|
(`gemini-3.5-flash-lite`), **OpenCode Go**'da (`deepseek-v4-flash`) ya da
|
||||||
Antigravity'de çalışır. İlk ikisi tek bir HTTP isteği; üç CLI ise bunun için
|
kuruluysa Claude Code, Codex veya Antigravity'de çalışır. İlk üçü tek bir HTTP
|
||||||
birer oturum açar, fazladan giden birkaç saniye de oradan gelir. Anahtarları boş
|
isteği; üç CLI ise bunun için birer oturum açar, fazladan giden birkaç saniye de
|
||||||
bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY` ve
|
oradan gelir. Anahtarları boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY`,
|
||||||
`GEMINI_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json` içinde,
|
`OPENROUTER_API_KEY`, `GEMINI_API_KEY` ve `OPENCODE_API_KEY` kullanılır;
|
||||||
izinler 600, Mac'te ise `~/Library/Application Support/Dikte` altında.
|
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
|
Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin
|
||||||
yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
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
|
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
|
aynı şekilde çalışır; ama Antigravity'ye Dikte bir izin kipi ya da sandbox
|
||||||
veremiyor, sormadan ne yapabileceğini kendi allow-rule'ları belirliyor.
|
veremiyor, sormadan ne yapabileceğini kendi allow-rule'ları belirliyor.
|
||||||
OpenRouter ise hiçbiri kurulu olmayan bir makinede düz soru cevap için
|
OpenRouter ya da OpenCode Go ise hiçbiri kurulu olmayan bir makinede düz soru
|
||||||
duruyor. Sağlayıcı, model, izinler ve çalışma dizini Ayarlar → Ajan
|
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.
|
sekmesinde; arka arkaya verilen komutlar tek bir konuşmada kalır.
|
||||||
- **Toplantılar** mikrofonla hoparlör çıkışından aynı anda kaydedilir; kimin ne
|
- **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
|
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
|
ipc.py yerel sokette bir istek, bir cevap
|
||||||
audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg
|
audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg
|
||||||
meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak
|
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)
|
api.py transkript ve temizleme istekleri (yalnız stdlib)
|
||||||
cleanup.py transkripti kim temizler: bulutta bir model, burası, bir CLI
|
cleanup.py transkripti kim temizler: bulutta bir model, burası, bir CLI
|
||||||
ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma
|
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="",
|
def chat(messages, api_key, model, system_prompt, reasoning="",
|
||||||
base_url=OPENROUTER_URL, timeout=180):
|
base_url=OPENROUTER_URL, timeout=180, provider="openrouter",
|
||||||
|
service="OpenRouter"):
|
||||||
"""A conversation, rather than one transcript rewritten.
|
"""A conversation, rather than one transcript rewritten.
|
||||||
|
|
||||||
The messages are the whole history and come back unchanged; the caller keeps
|
The messages are the whole history and come back unchanged; the caller keeps
|
||||||
them, because there is no session on OpenRouter's side to resume.
|
them, because there is no session on the provider's side to resume.
|
||||||
"""
|
"""
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||||
service="OpenRouter"))
|
service=service))
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": [{"role": "system", "content": system_prompt}] + list(messages),
|
"messages": [{"role": "system", "content": system_prompt}] + list(messages),
|
||||||
@@ -581,11 +582,11 @@ def chat(messages, api_key, model, system_prompt, reasoning="",
|
|||||||
data = _request(
|
data = _request(
|
||||||
f"{base_url.rstrip('/')}/chat/completions",
|
f"{base_url.rstrip('/')}/chat/completions",
|
||||||
json.dumps(payload).encode("utf-8"),
|
json.dumps(payload).encode("utf-8"),
|
||||||
_headers("openrouter", api_key, "application/json"),
|
_headers(provider, api_key, "application/json"),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
raise explain(exc, "OpenRouter") from None
|
raise explain(exc, service) from None
|
||||||
choices = data.get("choices") or []
|
choices = data.get("choices") or []
|
||||||
if not choices:
|
if not choices:
|
||||||
raise ApiError(_extract_error(json.dumps(data)))
|
raise ApiError(_extract_error(json.dumps(data)))
|
||||||
|
|||||||
+31
-23
@@ -1,17 +1,18 @@
|
|||||||
"""Handing a dictation to an agent as a command, and pasting back its answer.
|
"""Handing a dictation to an agent as a command, and pasting back its answer.
|
||||||
|
|
||||||
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
|
Claude Code `claude -p`, the session you would have opened yourself
|
||||||
Codex `codex exec`, the same idea from the other shop
|
Codex `codex exec`, the same idea from the other shop
|
||||||
Antigravity `agy -p`, Google's, with a browser of its own attached
|
Antigravity `agy -p`, Google's, with a browser of its own attached
|
||||||
OpenRouter a plain chat request, over the key that is already configured
|
OpenRouter a plain chat request, over the key that is already configured
|
||||||
|
OpenCode Go a plain chat request, over a subscription to open coding models
|
||||||
|
|
||||||
The first three are the whole machine: they run commands, read files, and reach
|
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
|
whatever skills and services you have connected, which is what makes "put that
|
||||||
in my calendar on Thursday" a thing you can say. OpenRouter cannot touch any of
|
in my calendar on Thursday" a thing you can say. The two chat requests cannot
|
||||||
that, and is there so that a question still gets an answer on a machine with no
|
touch any of that, and are there so that a question still gets an answer on a
|
||||||
CLI installed at all.
|
machine with no CLI installed at all.
|
||||||
|
|
||||||
What each of the three is allowed to do without asking is settled where that
|
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
|
program keeps its own permissions, not here. Dikte hands Claude Code the mode
|
||||||
@@ -44,16 +45,16 @@ from . import paths
|
|||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
SESSION_FILE = cfg.DATA_DIR / "assistant.json"
|
SESSION_FILE = cfg.DATA_DIR / "assistant.json"
|
||||||
PROVIDERS = ("claude", "codex", "agy", "openrouter")
|
PROVIDERS = ("claude", "codex", "agy", "openrouter", "opencode")
|
||||||
|
|
||||||
# What each one is called where a person reads it: the tray, the corner of
|
# 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.
|
# the screen, and the line an error is written in.
|
||||||
SERVICES = {"claude": "Claude", "codex": "Codex", "agy": "Antigravity",
|
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
|
# How many messages of a chat provider's conversation are carried forward. The
|
||||||
# CLIs keep their own history and need no such number; here every turn is resent
|
# two CLIs keep their own history and need no such number; here every turn is
|
||||||
# in full, so the window has to end somewhere.
|
# resent in full, so the window has to end somewhere.
|
||||||
MAX_HISTORY = 24
|
MAX_HISTORY = 24
|
||||||
|
|
||||||
# What to say in the indicator for a tool, keyed by the name the CLI uses.
|
# What to say in the indicator for a tool, keyed by the name the CLI uses.
|
||||||
@@ -158,6 +159,8 @@ def model(conf):
|
|||||||
return conf["assistant_agy_model"].strip() or "agy"
|
return conf["assistant_agy_model"].strip() or "agy"
|
||||||
if name == "openrouter":
|
if name == "openrouter":
|
||||||
return conf["assistant_openrouter_model"]
|
return conf["assistant_openrouter_model"]
|
||||||
|
if name == "opencode":
|
||||||
|
return conf["assistant_opencode_model"]
|
||||||
return conf["assistant_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.
|
one, and only the denial explains why it did not do what it was asked to.
|
||||||
"""
|
"""
|
||||||
name = provider(conf)
|
name = provider(conf)
|
||||||
if name == "openrouter":
|
if name in ("openrouter", "opencode"):
|
||||||
return _ask_openrouter(prompt, conf, on_stage)
|
return _ask_chat(name, SERVICES[name], prompt, conf, on_stage)
|
||||||
|
|
||||||
binary = executable(name)
|
binary = executable(name)
|
||||||
if not shutil.which(binary):
|
if not shutil.which(binary):
|
||||||
@@ -513,29 +516,34 @@ def agy_models():
|
|||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
|
||||||
# --- OpenRouter -----------------------------------------------------------
|
# --- OpenRouter and OpenCode Go -------------------------------------------
|
||||||
|
|
||||||
def _ask_openrouter(prompt, conf, on_stage):
|
def _ask_chat(name, service, prompt, conf, on_stage):
|
||||||
"""No tools, no files, no calendar: a question and an answer.
|
"""A plain question and answer, over a chat provider's key.
|
||||||
|
|
||||||
It is the fallback for a machine with neither CLI on it, so it says what it
|
No tools, no files, no calendar. It is the fallback for a machine with
|
||||||
knows and nothing else. The conversation is ours to keep here, since there
|
neither CLI on it, so it says what it knows and nothing else. The
|
||||||
is no session on the other end to resume.
|
conversation is ours to keep here, since there is no session on the other
|
||||||
|
end to resume.
|
||||||
"""
|
"""
|
||||||
if on_stage:
|
if on_stage:
|
||||||
on_stage(t("Thinking…"))
|
on_stage(t("Thinking…"))
|
||||||
history = read_messages("openrouter", conf["assistant_session_minutes"] * 60)
|
history = read_messages(name, conf["assistant_session_minutes"] * 60)
|
||||||
messages = history + [{"role": "user", "content": prompt}]
|
messages = history + [{"role": "user", "content": prompt}]
|
||||||
|
model = (conf["assistant_openrouter_model"] if name == "openrouter"
|
||||||
|
else conf["assistant_opencode_model"])
|
||||||
|
base_url = (conf["openrouter_base_url"] if name == "openrouter"
|
||||||
|
else conf["opencode_base_url"])
|
||||||
|
key = conf.openrouter_key() if name == "openrouter" else conf.opencode_key()
|
||||||
try:
|
try:
|
||||||
answer = api.chat(
|
answer = api.chat(
|
||||||
messages, conf.openrouter_key(), conf["assistant_openrouter_model"],
|
messages, key, model, conf.assistant_prompt(),
|
||||||
conf.assistant_prompt(), reasoning=conf["assistant_reasoning"],
|
reasoning=conf["assistant_reasoning"], base_url=base_url,
|
||||||
base_url=conf["openrouter_base_url"],
|
timeout=conf["assistant_timeout"], provider=name, service=service,
|
||||||
timeout=conf["assistant_timeout"],
|
|
||||||
)
|
)
|
||||||
except api.ApiError as exc:
|
except api.ApiError as exc:
|
||||||
raise AssistantError(str(exc)) from exc
|
raise AssistantError(str(exc)) from exc
|
||||||
write_session("openrouter",
|
write_session(name,
|
||||||
messages=messages + [{"role": "assistant", "content": answer}])
|
messages=messages + [{"role": "assistant", "content": answer}])
|
||||||
return answer, ""
|
return answer, ""
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -29,7 +29,7 @@ from . import ggml
|
|||||||
from . import paths
|
from . import paths
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
PROVIDERS = ("openrouter", "gemini", "local", "claude", "codex", "agy")
|
PROVIDERS = ("openrouter", "gemini", "opencode", "local", "claude", "codex", "agy")
|
||||||
|
|
||||||
|
|
||||||
class CleanupError(api.ApiError):
|
class CleanupError(api.ApiError):
|
||||||
@@ -67,6 +67,8 @@ def model(conf):
|
|||||||
return conf["cleanup_agy_model"].strip() or "agy"
|
return conf["cleanup_agy_model"].strip() or "agy"
|
||||||
if name == "gemini":
|
if name == "gemini":
|
||||||
return conf["cleanup_gemini_model"]
|
return conf["cleanup_gemini_model"]
|
||||||
|
if name == "opencode":
|
||||||
|
return conf["cleanup_opencode_model"]
|
||||||
return conf["cleanup_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,
|
base_url=conf["gemini_base_url"], timeout=timeout,
|
||||||
provider="gemini", service="Google AI Studio", aborter=aborter,
|
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":
|
if name == "local":
|
||||||
return _local(text, conf, system_prompt, timeout, aborter)
|
return _local(text, conf, system_prompt, timeout, aborter)
|
||||||
runner = {"claude": _claude, "codex": _codex, "agy": _agy}[name]
|
runner = {"claude": _claude, "codex": _codex, "agy": _agy}[name]
|
||||||
|
|||||||
+17
-7
@@ -217,7 +217,8 @@ def cmd_ask(opts):
|
|||||||
if opts.model:
|
if opts.model:
|
||||||
key = {"claude": "assistant_model", "codex": "assistant_codex_model",
|
key = {"claude": "assistant_model", "codex": "assistant_codex_model",
|
||||||
"openrouter": "assistant_openrouter_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
|
conf[key] = opts.model
|
||||||
if opts.dir:
|
if opts.dir:
|
||||||
conf["assistant_dir"] = opts.dir
|
conf["assistant_dir"] = opts.dir
|
||||||
@@ -511,7 +512,7 @@ def cmd_history_clear(opts):
|
|||||||
# --- settings ---------------------------------------------------------------
|
# --- settings ---------------------------------------------------------------
|
||||||
|
|
||||||
SECRET_KEYS = ("openai_api_key", "groq_api_key", "openrouter_api_key",
|
SECRET_KEYS = ("openai_api_key", "groq_api_key", "openrouter_api_key",
|
||||||
"gemini_api_key")
|
"gemini_api_key", "opencode_api_key")
|
||||||
|
|
||||||
|
|
||||||
def _mask(key, value):
|
def _mask(key, value):
|
||||||
@@ -709,6 +710,14 @@ def cmd_test_key(opts):
|
|||||||
results["gemini"] = {"ok": True, "message": message}
|
results["gemini"] = {"ok": True, "message": message}
|
||||||
except api.ApiError as exc:
|
except api.ApiError as exc:
|
||||||
results["gemini"] = {"ok": False, "message": str(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())
|
everything_ok = all(item["ok"] for item in results.values())
|
||||||
lines = [f"{'✓' if item['ok'] else '✗'} {name}: {item['message']}"
|
lines = [f"{'✓' if item['ok'] else '✗'} {name}: {item['message']}"
|
||||||
for name, item in results.items()]
|
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
|
# and marking them by the key they do not use reported every fully local
|
||||||
# setup as broken.
|
# setup as broken.
|
||||||
transcribe_ready = conf.transcribe_ready()
|
transcribe_ready = conf.transcribe_ready()
|
||||||
# Only the two that answer over HTTP have a key worth looking at. A CLI has
|
# Only the ones that answer over HTTP have a key worth looking at. A CLI
|
||||||
# a program to find instead, and the model on this machine has neither, so
|
# has a program to find instead, and the model on this machine has neither,
|
||||||
# "no key" there has to read as beside the point rather than as one that has
|
# so "no key" there has to read as beside the point rather than as one that
|
||||||
# gone missing.
|
# has gone missing.
|
||||||
cleanup_service, cleanup_key = {
|
cleanup_service, cleanup_key = {
|
||||||
"openrouter": ("OpenRouter", conf.openrouter_key()),
|
"openrouter": ("OpenRouter", conf.openrouter_key()),
|
||||||
"gemini": ("Google AI Studio", conf.gemini_key()),
|
"gemini": ("Google AI Studio", conf.gemini_key()),
|
||||||
|
"opencode": ("OpenCode Go", conf.opencode_key()),
|
||||||
}.get(cleaner, ("", ""))
|
}.get(cleaner, ("", ""))
|
||||||
if cleanup_service:
|
if cleanup_service:
|
||||||
cleanup_ready = bool(cleanup_key)
|
cleanup_ready = bool(cleanup_key)
|
||||||
@@ -1135,7 +1145,7 @@ def build_parser():
|
|||||||
models.set_defaults(func=cmd_models)
|
models.set_defaults(func=cmd_models)
|
||||||
test = leaf(subs, "test-key", "check the API keys")
|
test = leaf(subs, "test-key", "check the API keys")
|
||||||
test.add_argument("which", nargs="?", default="all",
|
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)
|
test.set_defaults(func=cmd_test_key)
|
||||||
leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor)
|
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
|
# Google's OpenAI-compatible endpoint. Cleanup only: there is no
|
||||||
# /audio/transcriptions behind it, so it is not one of the TRANSCRIBERS.
|
# /audio/transcriptions behind it, so it is not one of the TRANSCRIBERS.
|
||||||
"gemini_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
"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_provider": "local", # "local", or a key of TRANSCRIBERS
|
||||||
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
||||||
"groq_transcribe_model": "whisper-large-v3-turbo",
|
"groq_transcribe_model": "whisper-large-v3-turbo",
|
||||||
@@ -418,6 +420,7 @@ DEFAULTS = {
|
|||||||
"cleanup_codex_model": "", # empty -> whatever Codex is set to
|
"cleanup_codex_model": "", # empty -> whatever Codex is set to
|
||||||
"cleanup_gemini_model": "gemini-3.5-flash-lite",
|
"cleanup_gemini_model": "gemini-3.5-flash-lite",
|
||||||
"cleanup_agy_model": "", # empty -> whatever Antigravity is set to
|
"cleanup_agy_model": "", # empty -> whatever Antigravity is set to
|
||||||
|
"cleanup_opencode_model": "deepseek-v4-flash",
|
||||||
"cleanup_reasoning": "", # empty -> whatever the model does by default
|
"cleanup_reasoning": "", # empty -> whatever the model does by default
|
||||||
|
|
||||||
# --- llama.cpp, on this machine -----------------------------------------
|
# --- llama.cpp, on this machine -----------------------------------------
|
||||||
@@ -498,6 +501,7 @@ DEFAULTS = {
|
|||||||
"assistant_codex_sandbox": "workspace-write",
|
"assistant_codex_sandbox": "workspace-write",
|
||||||
"assistant_openrouter_model": "google/gemini-3.5-flash",
|
"assistant_openrouter_model": "google/gemini-3.5-flash",
|
||||||
"assistant_agy_model": "", # empty -> whatever Antigravity is set to
|
"assistant_agy_model": "", # empty -> whatever Antigravity is set to
|
||||||
|
"assistant_opencode_model": "deepseek-v4-flash",
|
||||||
"assistant_reasoning": "", # empty -> the model's own default
|
"assistant_reasoning": "", # empty -> the model's own default
|
||||||
"assistant_dir": "", # empty -> the home directory
|
"assistant_dir": "", # empty -> the home directory
|
||||||
"assistant_prompt": "", # empty -> language-specific default
|
"assistant_prompt": "", # empty -> language-specific default
|
||||||
@@ -643,6 +647,9 @@ class Config:
|
|||||||
def gemini_key(self):
|
def gemini_key(self):
|
||||||
return self.api_key("gemini_api_key")
|
return self.api_key("gemini_api_key")
|
||||||
|
|
||||||
|
def opencode_key(self):
|
||||||
|
return self.api_key("opencode_api_key")
|
||||||
|
|
||||||
def transcribe_target(self):
|
def transcribe_target(self):
|
||||||
"""Key, endpoint and model for whichever provider does speech to text.
|
"""Key, endpoint and model for whichever provider does speech to text.
|
||||||
|
|
||||||
|
|||||||
+25
-20
@@ -43,10 +43,12 @@ _TR_CASES = {
|
|||||||
"dative": {
|
"dative": {
|
||||||
"Claude": "Claude'a", "Codex": "Codex'e", "OpenRouter": "OpenRouter'a",
|
"Claude": "Claude'a", "Codex": "Codex'e", "OpenRouter": "OpenRouter'a",
|
||||||
"Google AI Studio": "Google AI Studio'ya", "Antigravity": "Antigravity'ye",
|
"Google AI Studio": "Google AI Studio'ya", "Antigravity": "Antigravity'ye",
|
||||||
|
"OpenCode Go": "OpenCode Go'ya",
|
||||||
},
|
},
|
||||||
"accusative": {
|
"accusative": {
|
||||||
"Claude": "Claude'u", "Codex": "Codex'i", "OpenRouter": "OpenRouter'ı",
|
"Claude": "Claude'u", "Codex": "Codex'i", "OpenRouter": "OpenRouter'ı",
|
||||||
"Google AI Studio": "Google AI Studio'yu", "Antigravity": "Antigravity'yi",
|
"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)",
|
"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)",
|
"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 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",
|
"Test": "Test et",
|
||||||
"Trying…": "Deneniyor…",
|
"Trying…": "Deneniyor…",
|
||||||
"Runs on OpenRouter.": "OpenRouter üzerinde çalışır.",
|
"Runs on OpenRouter.": "OpenRouter üzerinde çalışır.",
|
||||||
"Runs on Google AI Studio.": "Google AI Studio ü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.":
|
"Connection works. {count} audio models visible.":
|
||||||
"Bağlantı tamam. {count} ses modeli görünüyor.",
|
"Bağlantı tamam. {count} ses modeli görünüyor.",
|
||||||
"Connection works. {count} models visible.":
|
"Connection works. {count} models visible.":
|
||||||
"Bağlantı tamam. {count} model görünüyor.",
|
"Bağlantı tamam. {count} model görünüyor.",
|
||||||
"Clean the transcript with a model": "Transkripti bir modelle temizle",
|
"Clean the transcript with a model": "Transkripti bir modelle temizle",
|
||||||
"OpenRouter and Google AI Studio are the quick ones that need nothing "
|
"OpenRouter, Google AI Studio and OpenCode Go are the quick ones that need "
|
||||||
"installed. llama.cpp runs here, on a model downloaded below. Claude Code, "
|
"nothing installed. llama.cpp runs here, on a model downloaded below. "
|
||||||
"Codex and Antigravity clean up on a subscription you already have, without "
|
"Claude Code, Codex and Antigravity clean up on a subscription you already "
|
||||||
"a second key, and take a few seconds longer because each opens a session "
|
"have, without a second key, and take a few seconds longer because each "
|
||||||
"to do it.":
|
"opens a session to do it.":
|
||||||
"OpenRouter ile Google AI Studio kurulum istemeyen hızlı seçeneklerdir. "
|
"OpenRouter, Google AI Studio ve OpenCode Go kurulum istemeyen hızlı "
|
||||||
"llama.cpp burada, aşağıdan indirilen bir modelle çalışır. Claude Code, "
|
"seçeneklerdir. llama.cpp burada, aşağıdan indirilen bir modelle "
|
||||||
"Codex ve Antigravity temizliği hâlihazırda sahip olduğun bir abonelik "
|
"çalışır. Claude Code, Codex ve Antigravity temizliği hâlihazırda sahip "
|
||||||
"üzerinden, ikinci anahtar olmadan yapar; her biri bunun için bir oturum "
|
"olduğun bir abonelik üzerinden, ikinci anahtar olmadan yapar; her biri "
|
||||||
"açtığından birkaç saniye daha uzun sürer.",
|
"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 "
|
"{binary} is not on your PATH, so cleanup would fail and the raw transcript "
|
||||||
"would be pasted. Install it, or pick another one above.":
|
"would be pasted. Install it, or pick another one above.":
|
||||||
"{binary} PATH'te değil; temizleme başarısız olur ve ham transkript "
|
"{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.",
|
"Yukarıdaki çalışma dizini ve izinler burada bir şey ifade etmez.",
|
||||||
"Needs no program installed, only the OpenRouter key.":
|
"Needs no program installed, only the OpenRouter key.":
|
||||||
"Kurulu bir programa değil, yalnızca OpenRouter anahtarına ihtiyaç duyar.",
|
"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 "
|
"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 "
|
"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 "
|
"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 "
|
"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 "
|
"için 300 token akıl yürütmek 300 token'lık bekleyiştir. Temizleme için "
|
||||||
"doğrusu Kapalı.",
|
"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 "
|
"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 "
|
"it is running was made with one. A build without any of them runs on the "
|
||||||
"processor whatever this says.":
|
"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.
|
# agent can run on open a whole session to do the smaller job.
|
||||||
CLEANUP_PROVIDERS = [
|
CLEANUP_PROVIDERS = [
|
||||||
("OpenRouter", "openrouter"), ("Google AI Studio", "gemini"),
|
("OpenRouter", "openrouter"), ("Google AI Studio", "gemini"),
|
||||||
("This machine (llama.cpp)", "local"), ("Claude Code", "claude"),
|
("OpenCode Go", "opencode"), ("This machine (llama.cpp)", "local"),
|
||||||
("Codex", "codex"), ("Antigravity", "agy"),
|
("Claude Code", "claude"), ("Codex", "codex"), ("Antigravity", "agy"),
|
||||||
]
|
]
|
||||||
# Cleaning up a sentence is the lightest thing either of them will ever be
|
# Cleaning up a sentence is the lightest thing either of them will ever be
|
||||||
# asked, so the small model comes first.
|
# asked, so the small model comes first.
|
||||||
@@ -90,7 +90,7 @@ MEETING_MODELS = [
|
|||||||
]
|
]
|
||||||
ASSISTANT_PROVIDERS = [
|
ASSISTANT_PROVIDERS = [
|
||||||
("Claude Code", "claude"), ("Codex", "codex"), ("Antigravity", "agy"),
|
("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
|
# 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.
|
# 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",
|
"google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4",
|
||||||
"x-ai/grok-4.5", "google/gemini-3.1-pro-preview",
|
"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
|
# 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.
|
# window to answer in, so a mode that would have prompted denies instead.
|
||||||
PERMISSION_MODES = [
|
PERMISSION_MODES = [
|
||||||
@@ -572,6 +579,7 @@ class SettingsWindow(QDialog):
|
|||||||
|
|
||||||
_models_loaded = pyqtSignal(list, str)
|
_models_loaded = pyqtSignal(list, str)
|
||||||
_gemini_models_loaded = pyqtSignal(list, str)
|
_gemini_models_loaded = pyqtSignal(list, str)
|
||||||
|
_opencode_models_loaded = pyqtSignal(list, str)
|
||||||
_transcribe_models_loaded = pyqtSignal(list, str)
|
_transcribe_models_loaded = pyqtSignal(list, str)
|
||||||
_codex_models_loaded = pyqtSignal(list)
|
_codex_models_loaded = pyqtSignal(list)
|
||||||
_agy_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._gemini_models_loaded.connect(self._on_gemini_models_loaded)
|
||||||
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
||||||
self._codex_models_loaded.connect(self._on_codex_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._agy_models_loaded.connect(self._on_agy_models_loaded)
|
||||||
self._hosted_models_loaded.connect(self._on_hosted_models_loaded)
|
self._hosted_models_loaded.connect(self._on_hosted_models_loaded)
|
||||||
self._test_done.connect(self._on_test_done)
|
self._test_done.connect(self._on_test_done)
|
||||||
@@ -832,6 +841,9 @@ class SettingsWindow(QDialog):
|
|||||||
self.gemini_key = self._key_row(
|
self.gemini_key = self._key_row(
|
||||||
keys_form, "gemini", t("(falls back to GEMINI_API_KEY)"),
|
keys_form, "gemini", t("(falls back to GEMINI_API_KEY)"),
|
||||||
self._test_gemini, service="Google AI Studio")
|
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)
|
outer.addWidget(keys)
|
||||||
|
|
||||||
stt = QGroupBox(t("Speech to text"))
|
stt = QGroupBox(t("Speech to text"))
|
||||||
@@ -904,11 +916,11 @@ class SettingsWindow(QDialog):
|
|||||||
for label, value in CLEANUP_PROVIDERS:
|
for label, value in CLEANUP_PROVIDERS:
|
||||||
self.cleanup_provider.addItem(t(label), value)
|
self.cleanup_provider.addItem(t(label), value)
|
||||||
self.cleanup_provider.setToolTip(t(
|
self.cleanup_provider.setToolTip(t(
|
||||||
"OpenRouter and Google AI Studio are the quick ones that need nothing "
|
"OpenRouter, Google AI Studio and OpenCode Go are the quick ones "
|
||||||
"installed. llama.cpp runs here, on a model downloaded below. Claude "
|
"that need nothing installed. llama.cpp runs here, on a model "
|
||||||
"Code, Codex and Antigravity clean up on a subscription you already "
|
"downloaded below. Claude Code, Codex and Antigravity clean up on "
|
||||||
"have, without a second key, and take a few seconds longer because "
|
"a subscription you already have, without a second key, and take a "
|
||||||
"each opens a session to do it."
|
"few seconds longer because each opens a session to do it."
|
||||||
))
|
))
|
||||||
self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed)
|
self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed)
|
||||||
orr_form.addRow(t("Runs on"), self.cleanup_provider)
|
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"))
|
self.cleanup_codex_model.setToolTip(_typed_model_note("Codex"))
|
||||||
orr_form.addRow(t("Model"), self.cleanup_codex_model)
|
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 = QComboBox()
|
||||||
self.cleanup_agy_model.setEditable(True)
|
self.cleanup_agy_model.setEditable(True)
|
||||||
self.cleanup_agy_model.addItems([t("Antigravity's own default")] + AGY_MODELS)
|
self.cleanup_agy_model.addItems([t("Antigravity's own default")] + AGY_MODELS)
|
||||||
@@ -1165,6 +1187,23 @@ class SettingsWindow(QDialog):
|
|||||||
agy_form.addRow(agy_note)
|
agy_form.addRow(agy_note)
|
||||||
layout.addWidget(self.agy_box)
|
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 = QGroupBox(t("The conversation"))
|
||||||
thread_form = QFormLayout(thread)
|
thread_form = QFormLayout(thread)
|
||||||
self.assistant_session_minutes = QSpinBox()
|
self.assistant_session_minutes = QSpinBox()
|
||||||
@@ -1730,6 +1769,7 @@ class SettingsWindow(QDialog):
|
|||||||
self._key_fields[name].setText(conf[who.key])
|
self._key_fields[name].setText(conf[who.key])
|
||||||
self._models[name] = conf[who.model]
|
self._models[name] = conf[who.model]
|
||||||
self.gemini_key.setText(conf["gemini_api_key"])
|
self.gemini_key.setText(conf["gemini_api_key"])
|
||||||
|
self.opencode_key.setText(conf["opencode_api_key"])
|
||||||
self._shown_provider = ""
|
self._shown_provider = ""
|
||||||
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
||||||
self._provider_changed() # selecting index 0 fires no signal
|
self._provider_changed() # selecting index 0 fires no signal
|
||||||
@@ -1750,6 +1790,7 @@ class SettingsWindow(QDialog):
|
|||||||
self.cleanup_agy_model.setCurrentText(
|
self.cleanup_agy_model.setCurrentText(
|
||||||
conf["cleanup_agy_model"] or t("Antigravity's own default")
|
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._select_data(self.cleanup_provider, conf["cleanup_provider"])
|
||||||
self._cleanup_provider_changed() # selecting index 0 fires no signal
|
self._cleanup_provider_changed() # selecting index 0 fires no signal
|
||||||
self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"])
|
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._select_data(self.assistant_codex_sandbox, conf["assistant_codex_sandbox"])
|
||||||
self.assistant_openrouter_model.setCurrentText(conf["assistant_openrouter_model"])
|
self.assistant_openrouter_model.setCurrentText(conf["assistant_openrouter_model"])
|
||||||
self.assistant_agy_model.setCurrentText(conf["assistant_agy_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._assistant_provider_changed() # selecting index 0 fires no signal
|
||||||
self._select_data(self.assistant_reasoning, conf["assistant_reasoning"])
|
self._select_data(self.assistant_reasoning, conf["assistant_reasoning"])
|
||||||
self.assistant_dir.setText(conf["assistant_dir"])
|
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.key] = self._key_fields[name].text().strip()
|
||||||
conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
|
conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
|
||||||
conf["gemini_api_key"] = self.gemini_key.text().strip()
|
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_model"] = self.local_whisper.selected()
|
||||||
conf["local_gpu"] = self.local_gpu.isChecked()
|
conf["local_gpu"] = self.local_gpu.isChecked()
|
||||||
conf["local_preload"] = self.local_preload.isChecked()
|
conf["local_preload"] = self.local_preload.isChecked()
|
||||||
@@ -1872,6 +1915,10 @@ class SettingsWindow(QDialog):
|
|||||||
"" if agy_cleanup_model == t("Antigravity's own default")
|
"" if agy_cleanup_model == t("Antigravity's own default")
|
||||||
else agy_cleanup_model
|
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["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or ""
|
||||||
conf["local_llm_model"] = self.local_llm.selected()
|
conf["local_llm_model"] = self.local_llm.selected()
|
||||||
conf["local_llm_repo"] = self.local_llm.repository()
|
conf["local_llm_repo"] = self.local_llm.repository()
|
||||||
@@ -1914,6 +1961,10 @@ class SettingsWindow(QDialog):
|
|||||||
conf["assistant_agy_model"] = (
|
conf["assistant_agy_model"] = (
|
||||||
"" if agy_model == t("Antigravity's own default") else 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_reasoning"] = self.assistant_reasoning.currentData() or ""
|
||||||
conf["assistant_dir"] = self.assistant_dir.text().strip()
|
conf["assistant_dir"] = self.assistant_dir.text().strip()
|
||||||
conf["assistant_timeout"] = self.assistant_timeout.value()
|
conf["assistant_timeout"] = self.assistant_timeout.value()
|
||||||
@@ -2128,6 +2179,37 @@ class SettingsWindow(QDialog):
|
|||||||
combo.addItem(name, name)
|
combo.addItem(name, name)
|
||||||
combo.setCurrentText(current)
|
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):
|
def _load_agy_models(self):
|
||||||
"""Ask Antigravity which models it offers, off the interface thread.
|
"""Ask Antigravity which models it offers, off the interface thread.
|
||||||
|
|
||||||
@@ -2157,7 +2239,7 @@ class SettingsWindow(QDialog):
|
|||||||
combo.setCurrentText(current)
|
combo.setCurrentText(current)
|
||||||
|
|
||||||
def _load_hosted_models(self):
|
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
|
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
|
worth explaining. Here nobody asked, so an error changes nothing on
|
||||||
@@ -2174,6 +2256,12 @@ class SettingsWindow(QDialog):
|
|||||||
if gemini_key:
|
if gemini_key:
|
||||||
jobs.append(("gemini",
|
jobs.append(("gemini",
|
||||||
lambda: api.gemini_models(gemini_key, gemini_base)))
|
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:
|
for provider, fetch in jobs:
|
||||||
def work(provider=provider, fetch=fetch):
|
def work(provider=provider, fetch=fetch):
|
||||||
try:
|
try:
|
||||||
@@ -2186,6 +2274,9 @@ class SettingsWindow(QDialog):
|
|||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
def _on_hosted_models_loaded(self, provider, models):
|
def _on_hosted_models_loaded(self, provider, models):
|
||||||
|
if provider == "opencode":
|
||||||
|
self._fill_opencode_boxes(models)
|
||||||
|
return
|
||||||
combos = ((self.cleanup_model, self.meeting_model)
|
combos = ((self.cleanup_model, self.meeting_model)
|
||||||
if provider == "openrouter" else (self.cleanup_gemini_model,))
|
if provider == "openrouter" else (self.cleanup_gemini_model,))
|
||||||
for combo in combos:
|
for combo in combos:
|
||||||
@@ -2219,6 +2310,13 @@ class SettingsWindow(QDialog):
|
|||||||
count=len(api.gemini_models(key, base)),
|
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):
|
def _typed_key(self, provider):
|
||||||
"""(key, base URL) for a provider, preferring what is in the field now."""
|
"""(key, base URL) for a provider, preferring what is in the field now."""
|
||||||
if provider in cfg.TRANSCRIBERS:
|
if provider in cfg.TRANSCRIBERS:
|
||||||
@@ -2458,6 +2556,8 @@ class SettingsWindow(QDialog):
|
|||||||
provider == "claude")
|
provider == "claude")
|
||||||
self.cleanup_form.setRowVisible(self.cleanup_codex_model,
|
self.cleanup_form.setRowVisible(self.cleanup_codex_model,
|
||||||
provider == "codex")
|
provider == "codex")
|
||||||
|
self.cleanup_form.setRowVisible(self.cleanup_opencode_model_row,
|
||||||
|
provider == "opencode")
|
||||||
self.cleanup_form.setRowVisible(self.cleanup_agy_model,
|
self.cleanup_form.setRowVisible(self.cleanup_agy_model,
|
||||||
provider == "agy")
|
provider == "agy")
|
||||||
self.cleanup_form.setRowVisible(self.cleanup_reasoning,
|
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."))
|
self.models_label.setText(t("Runs on this machine, on llama.cpp."))
|
||||||
elif provider == "gemini":
|
elif provider == "gemini":
|
||||||
self.models_label.setText(t("Runs on Google AI Studio."))
|
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:
|
elif not binary:
|
||||||
self.models_label.setText(t("Runs on OpenRouter."))
|
self.models_label.setText(t("Runs on OpenRouter."))
|
||||||
elif found:
|
elif found:
|
||||||
@@ -2487,6 +2589,7 @@ class SettingsWindow(QDialog):
|
|||||||
self.codex_box.setVisible(provider == "codex")
|
self.codex_box.setVisible(provider == "codex")
|
||||||
self.openrouter_box.setVisible(provider == "openrouter")
|
self.openrouter_box.setVisible(provider == "openrouter")
|
||||||
self.agy_box.setVisible(provider == "agy")
|
self.agy_box.setVisible(provider == "agy")
|
||||||
|
self.opencode_box.setVisible(provider == "opencode")
|
||||||
self._refresh_assistant_status()
|
self._refresh_assistant_status()
|
||||||
|
|
||||||
def _refresh_assistant_status(self):
|
def _refresh_assistant_status(self):
|
||||||
@@ -2494,9 +2597,14 @@ class SettingsWindow(QDialog):
|
|||||||
binary = assistant.executable(provider)
|
binary = assistant.executable(provider)
|
||||||
found = shutil.which(binary) if binary else ""
|
found = shutil.which(binary) if binary else ""
|
||||||
if not binary:
|
if not binary:
|
||||||
self.assistant_found.setText(
|
if provider == "opencode":
|
||||||
t("Needs no program installed, only the OpenRouter key.")
|
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:
|
elif found:
|
||||||
self.assistant_found.setText(t("Found: {path}", path=found))
|
self.assistant_found.setText(t("Found: {path}", path=found))
|
||||||
else:
|
else:
|
||||||
|
|||||||
+38
-1
@@ -99,6 +99,7 @@ class Provider(DikteTest):
|
|||||||
self.assertEqual(assistant.executable("codex"), "codex")
|
self.assertEqual(assistant.executable("codex"), "codex")
|
||||||
self.assertEqual(assistant.executable("agy"), "agy")
|
self.assertEqual(assistant.executable("agy"), "agy")
|
||||||
self.assertEqual(assistant.executable("openrouter"), "")
|
self.assertEqual(assistant.executable("openrouter"), "")
|
||||||
|
self.assertEqual(assistant.executable("opencode"), "")
|
||||||
|
|
||||||
def test_the_model_recorded_is_the_one_that_answered(self):
|
def test_the_model_recorded_is_the_one_that_answered(self):
|
||||||
"""The history used to write Claude's setting whoever had answered."""
|
"""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):
|
def test_what_each_one_is_called(self):
|
||||||
self.assertEqual(assistant.display_name(self.config()), "Claude")
|
self.assertEqual(assistant.display_name(self.config()), "Claude")
|
||||||
for name, called in (("codex", "Codex"), ("agy", "Antigravity"),
|
for name, called in (("codex", "Codex"), ("agy", "Antigravity"),
|
||||||
("openrouter", "OpenRouter")):
|
("openrouter", "OpenRouter"),
|
||||||
|
("opencode", "OpenCode Go")):
|
||||||
with self.subTest(name=name):
|
with self.subTest(name=name):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
assistant.display_name(self.config(assistant_provider=name)),
|
assistant.display_name(self.config(assistant_provider=name)),
|
||||||
@@ -736,6 +738,41 @@ class AskOpenRouter(DikteTest):
|
|||||||
assistant.ask("when is it", conf)
|
assistant.ask("when is it", conf)
|
||||||
|
|
||||||
|
|
||||||
|
class AskOpenCode(DikteTest):
|
||||||
|
def test_a_question_and_an_answer(self):
|
||||||
|
conf = self.config(assistant_provider="opencode",
|
||||||
|
opencode_api_key="opencode-test-key")
|
||||||
|
with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}):
|
||||||
|
answer, warning = assistant.ask("when is it", conf)
|
||||||
|
self.assertEqual(answer, "on Thursday")
|
||||||
|
self.assertEqual(warning, "")
|
||||||
|
|
||||||
|
def test_the_conversation_is_ours_to_keep(self):
|
||||||
|
conf = self.config(assistant_provider="opencode",
|
||||||
|
opencode_api_key="opencode-test-key")
|
||||||
|
with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}):
|
||||||
|
assistant.ask("when is it", conf)
|
||||||
|
stored = assistant.read_messages("opencode", 1800)
|
||||||
|
self.assertEqual([row["content"] for row in stored],
|
||||||
|
["when is it", "on Thursday"])
|
||||||
|
|
||||||
|
def test_the_model_and_endpoint_are_opencode_s_own(self):
|
||||||
|
conf = self.config(assistant_provider="opencode",
|
||||||
|
opencode_api_key="opencode-test-key",
|
||||||
|
assistant_opencode_model="glm-5.3")
|
||||||
|
with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}) as calls:
|
||||||
|
assistant.ask("when is it", conf)
|
||||||
|
sent = json.loads(calls[0].data.decode("utf-8"))
|
||||||
|
self.assertEqual(sent["model"], "glm-5.3")
|
||||||
|
self.assertIn("https://opencode.ai/zen/go/v1/chat/completions",
|
||||||
|
calls[0].full_url)
|
||||||
|
|
||||||
|
def test_an_api_failure_reads_as_an_assistant_failure(self):
|
||||||
|
conf = self.config(assistant_provider="opencode")
|
||||||
|
with self.assertRaises(assistant.AssistantError):
|
||||||
|
assistant.ask("when is it", conf)
|
||||||
|
|
||||||
|
|
||||||
class Ask(DikteTest):
|
class Ask(DikteTest):
|
||||||
def test_a_cli_that_is_not_installed_says_where_to_change_it(self):
|
def test_a_cli_that_is_not_installed_says_where_to_change_it(self):
|
||||||
with only_these_tools(), \
|
with only_these_tools(), \
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class Provider(DikteTest):
|
|||||||
self.assertEqual(cleanup.executable("agy"), "agy")
|
self.assertEqual(cleanup.executable("agy"), "agy")
|
||||||
self.assertEqual(cleanup.executable("openrouter"), "")
|
self.assertEqual(cleanup.executable("openrouter"), "")
|
||||||
self.assertEqual(cleanup.executable("gemini"), "")
|
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):
|
def test_the_model_named_in_the_history_is_the_one_that_did_it(self):
|
||||||
self.assertEqual(cleanup.model(self.config(cleanup_model="some/model")),
|
self.assertEqual(cleanup.model(self.config(cleanup_model="some/model")),
|
||||||
@@ -85,6 +86,9 @@ class Provider(DikteTest):
|
|||||||
cleanup.model(self.config(cleanup_provider="agy",
|
cleanup.model(self.config(cleanup_provider="agy",
|
||||||
cleanup_agy_model="gemini-3.7-flash-low")),
|
cleanup_agy_model="gemini-3.7-flash-low")),
|
||||||
"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):
|
class OpenRouter(DikteTest):
|
||||||
@@ -106,6 +110,32 @@ class OpenRouter(DikteTest):
|
|||||||
self.assertEqual(calls, [])
|
self.assertEqual(calls, [])
|
||||||
|
|
||||||
|
|
||||||
|
class OpenCode(DikteTest):
|
||||||
|
def test_it_is_one_request_with_the_settings_as_they_were(self):
|
||||||
|
conf = self.config(cleanup_provider="opencode",
|
||||||
|
opencode_api_key="opencode-test-key",
|
||||||
|
cleanup_opencode_model="some/model",
|
||||||
|
cleanup_reasoning="low")
|
||||||
|
with mock.patch.object(api, "cleanup", return_value="Done.") as call:
|
||||||
|
self.assertEqual(cleanup.run("uh, done", conf, "the rules"), "Done.")
|
||||||
|
text, key, model, prompt = call.call_args.args
|
||||||
|
self.assertEqual((text, key, model, prompt),
|
||||||
|
("uh, done", "opencode-test-key", "some/model", "the rules"))
|
||||||
|
self.assertEqual(call.call_args.kwargs["reasoning"], "low")
|
||||||
|
self.assertEqual(call.call_args.kwargs["provider"], "opencode")
|
||||||
|
self.assertEqual(call.call_args.kwargs["service"], "OpenCode Go")
|
||||||
|
self.assertEqual(call.call_args.kwargs["base_url"],
|
||||||
|
"https://opencode.ai/zen/go/v1")
|
||||||
|
|
||||||
|
def test_no_cli_is_started_for_it(self):
|
||||||
|
conf = self.config(cleanup_provider="opencode",
|
||||||
|
opencode_api_key="opencode-test-key")
|
||||||
|
patcher, calls = fake_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):
|
class GoogleAiStudio(DikteTest):
|
||||||
"""Cleanup over Google's OpenAI-compatible endpoint: one request, no CLI."""
|
"""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)
|
||||||
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):
|
class Updates(DikteTest):
|
||||||
"""`dikte update` looks, says what it found, and installs nothing."""
|
"""`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.assertIn("OpenRouter key, cleaning up on some/model",
|
||||||
self.run_doctor(as_json=False, cleanup_model="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):
|
def test_it_survives_every_provider_cleanup_can_be_set_to(self):
|
||||||
"""It used to raise KeyError on the local model, whose executable is ""."""
|
"""It used to raise KeyError on the local model, whose executable is ""."""
|
||||||
for name in cleanup.PROVIDERS:
|
for name in cleanup.PROVIDERS:
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ class Keys(DikteTest):
|
|||||||
self.assertEqual(cfg.Config().groq_key(), "gsk-env")
|
self.assertEqual(cfg.Config().groq_key(), "gsk-env")
|
||||||
with mock.patch.dict(os.environ, {"GEMINI_API_KEY": "AIza-env"}):
|
with mock.patch.dict(os.environ, {"GEMINI_API_KEY": "AIza-env"}):
|
||||||
self.assertEqual(cfg.Config().gemini_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):
|
class TranscribeTarget(DikteTest):
|
||||||
@@ -577,12 +579,19 @@ class Defaults(unittest.TestCase):
|
|||||||
self.assertEqual(cfg.DEFAULTS["openai_api_key"], "")
|
self.assertEqual(cfg.DEFAULTS["openai_api_key"], "")
|
||||||
self.assertEqual(cfg.DEFAULTS["openrouter_api_key"], "")
|
self.assertEqual(cfg.DEFAULTS["openrouter_api_key"], "")
|
||||||
self.assertEqual(cfg.DEFAULTS["gemini_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):
|
def test_google_ai_studio_is_a_cleanup_provider_and_not_a_transcriber(self):
|
||||||
"""Its compatible endpoint has no /audio/transcriptions behind it."""
|
"""Its compatible endpoint has no /audio/transcriptions behind it."""
|
||||||
self.assertNotIn("gemini", cfg.TRANSCRIBERS)
|
self.assertNotIn("gemini", cfg.TRANSCRIBERS)
|
||||||
self.assertIn("gemini", cleanup.PROVIDERS)
|
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):
|
def test_every_language_specific_prompt_has_both_languages(self):
|
||||||
for name in ("CLEANUP_PROMPT", "FILE_CLEANUP_PROMPT", "MEETING_PROMPT",
|
for name in ("CLEANUP_PROMPT", "FILE_CLEANUP_PROMPT", "MEETING_PROMPT",
|
||||||
"ASSISTANT_PROMPT"):
|
"ASSISTANT_PROMPT"):
|
||||||
|
|||||||
+40
-2
@@ -275,6 +275,7 @@ class Settings(DikteTest):
|
|||||||
"""An OpenRouter id and a Claude alias are not the same field."""
|
"""An OpenRouter id and a Claude alias are not the same field."""
|
||||||
window = self.window(cfg.Config())
|
window = self.window(cfg.Config())
|
||||||
boxes = {"openrouter": window.cleanup_model_row,
|
boxes = {"openrouter": window.cleanup_model_row,
|
||||||
|
"opencode": window.cleanup_opencode_model_row,
|
||||||
"claude": window.cleanup_claude_model,
|
"claude": window.cleanup_claude_model,
|
||||||
"codex": window.cleanup_codex_model}
|
"codex": window.cleanup_codex_model}
|
||||||
for provider, box in boxes.items():
|
for provider, box in boxes.items():
|
||||||
@@ -298,6 +299,42 @@ class Settings(DikteTest):
|
|||||||
self.assertEqual(window.cleanup_codex_model.currentText(),
|
self.assertEqual(window.cleanup_codex_model.currentText(),
|
||||||
"my-own-model")
|
"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):
|
def test_agy_answering_refills_both_of_its_boxes(self):
|
||||||
"""The same arrangement as Codex: both boxes, nothing chosen is lost."""
|
"""The same arrangement as Codex: both boxes, nothing chosen is lost."""
|
||||||
conf = self.config(cleanup_agy_model="my-own-model")
|
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):
|
def test_a_key_on_file_is_fetched_with_at_open(self):
|
||||||
window = self.window(self.config(openrouter_api_key="sk-or-x",
|
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:
|
with mock.patch.object(settings_ui.threading, "Thread") as thread:
|
||||||
REAL_LOAD_HOSTED_MODELS(window)
|
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):
|
def test_the_update_line_names_the_version_that_is_running(self):
|
||||||
window = self.window(cfg.Config())
|
window = self.window(cfg.Config())
|
||||||
|
|||||||
Reference in New Issue
Block a user