mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Merge pull request #59 from Oztturk/gemini-and-agy-cleanup
Clean up on Google AI Studio, or on Antigravity
This commit is contained in:
@@ -115,9 +115,12 @@ or runs it on the spot.
|
||||
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`) or, when either is
|
||||
installed, on Claude Code or Codex. The keys fall back to `OPENAI_API_KEY`,
|
||||
`GROQ_API_KEY` and `OPENROUTER_API_KEY`, and are stored in
|
||||
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
|
||||
`~/.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
|
||||
@@ -189,11 +192,13 @@ running.
|
||||
what comes of it: the answer, or a sentence saying what was done. It is the
|
||||
session you would have opened yourself, so your skills and connected services
|
||||
are there, which is what makes "put that in my calendar on Thursday at three"
|
||||
a thing you can say to a window that is not Claude. Codex (`codex exec`) runs
|
||||
the same way, and OpenRouter is there as a plain question-and-answer fallback
|
||||
for a machine with neither CLI on it. Provider, model, permissions and working
|
||||
directory are under Settings → Agent, and commands close together stay in one
|
||||
conversation.
|
||||
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.
|
||||
- **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
|
||||
@@ -240,9 +245,9 @@ 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 running a dictation through Claude Code, Codex or OpenRouter
|
||||
assistant.py handing a dictation to Claude Code, Codex, agy or OpenRouter
|
||||
api.py transcription and cleanup requests (stdlib only)
|
||||
cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex
|
||||
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
|
||||
hub.py what GitHub and Hugging Face have on offer today
|
||||
update.py whether a newer release is out, and the page it is on
|
||||
|
||||
+13
-8
@@ -112,10 +112,13 @@ Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıc
|
||||
seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. Bulutu
|
||||
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`) ya da kuruluysa Claude Code veya Codex'te
|
||||
çalışır. Anahtarları boş bırakırsan `OPENAI_API_KEY`, `GROQ_API_KEY` ve
|
||||
`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json`
|
||||
içinde, izinler 600, Mac'te ise `~/Library/Application Support/Dikte` altında.
|
||||
(`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.
|
||||
Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript yapıştırılır; modelin
|
||||
yanındaki kutudan düşünme seviyesini de seçebilirsin.
|
||||
|
||||
@@ -184,8 +187,10 @@ olmasını ister.
|
||||
yapıştırır: cevabı ya da ne yapıldığını söyleyen bir cümle. Kendi açacağın
|
||||
oturumun aynısıdır, yani skill'lerin ve bağlı servislerin oradadır; "bunu
|
||||
perşembe üçe takvime koy" cümlesini Claude olmayan bir pencerede söyleyebilir
|
||||
olmanı sağlayan da budur. Codex (`codex exec`) da aynı şekilde çalışır;
|
||||
OpenRouter ise ikisi de kurulu olmayan bir makinede düz soru cevap için
|
||||
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
|
||||
sekmesinde; arka arkaya verilen komutlar tek bir konuşmada kalır.
|
||||
- **Toplantılar** mikrofonla hoparlör çıkışından aynı anda kaydedilir; kimin ne
|
||||
@@ -233,9 +238,9 @@ 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 ya da OpenRouter'dan geçirme
|
||||
assistant.py dikteyi Claude Code, Codex, agy ya da OpenRouter'dan geçirme
|
||||
api.py transkript ve temizleme istekleri (yalnız stdlib)
|
||||
cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex
|
||||
cleanup.py transkripti kim temizler: bulutta bir model, burası, bir CLI
|
||||
ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma
|
||||
hub.py GitHub ve Hugging Face'te bugün ne olduğu
|
||||
update.py yeni sürüm çıkmış mı, çıkmışsa hangi sayfada
|
||||
|
||||
+79
-10
@@ -1,10 +1,17 @@
|
||||
"""OpenAI, Groq, OpenRouter and this machine, stdlib only.
|
||||
"""OpenAI, Groq, OpenRouter, Google AI Studio and this machine, stdlib only.
|
||||
|
||||
Transcription runs on any of the four: Groq and OpenRouter both mirror OpenAI's
|
||||
/audio/transcriptions endpoint field for field, and ggml.py starts whisper.cpp
|
||||
on that same path, so one multipart request serves all of them and only the key,
|
||||
the base URL and the model id change. llama.cpp answers /chat/completions the way
|
||||
OpenRouter does, so cleanup here is the same request too.
|
||||
Transcription runs on the first three and on this machine: Groq and OpenRouter
|
||||
both mirror OpenAI's /audio/transcriptions endpoint field for field, and ggml.py
|
||||
starts whisper.cpp on that same path, so one multipart request serves all of
|
||||
them and only the key, the base URL and the model id change. llama.cpp answers
|
||||
/chat/completions the way OpenRouter does, so cleanup here is the same request
|
||||
too.
|
||||
|
||||
Google AI Studio is here for cleanup and nothing else. Its OpenAI-compatible
|
||||
endpoint answers /chat/completions and /models, but there is no
|
||||
/audio/transcriptions behind it: audio only goes in as base64 inside a chat
|
||||
message, and what comes back has none of the segment times a subtitle file or a
|
||||
meeting transcript is built out of.
|
||||
|
||||
What is on this machine has no key, and its base URL is not known until a server
|
||||
is up, which is the one thing this module has to fill in for it.
|
||||
@@ -31,6 +38,7 @@ USER_AGENT = f"dikte/1.0 (+{APP_URL})"
|
||||
OPENAI_URL = "https://api.openai.com/v1"
|
||||
GROQ_URL = "https://api.groq.com/openai/v1"
|
||||
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
||||
GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
|
||||
# The floor for a local request. The timeouts elsewhere are sized for a hosted
|
||||
# API, where a slow answer is a bill running; here the only thing being spent is
|
||||
@@ -254,10 +262,23 @@ def _request(url, data, headers, timeout=120, aborter=None):
|
||||
|
||||
|
||||
def _extract_error(body):
|
||||
"""The line worth showing out of a failed request's body.
|
||||
|
||||
Whatever comes back, this has to end in a string: it is called while an
|
||||
ApiError is being raised, and an exception thrown here would escape the
|
||||
`except ApiError` every caller is holding and lose the dictation the raw
|
||||
transcript would otherwise have been pasted from.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return body[:300]
|
||||
if isinstance(payload, list):
|
||||
# Google answers some failures with an array holding the object the
|
||||
# other providers send on its own.
|
||||
payload = next((item for item in payload if isinstance(item, dict)), None)
|
||||
if not isinstance(payload, dict):
|
||||
return body[:300]
|
||||
err = payload.get("error")
|
||||
if isinstance(err, dict):
|
||||
return err.get("message") or json.dumps(err)[:300]
|
||||
@@ -448,14 +469,24 @@ def transcribe_segments(target, audio_path, language="", prompt="", timeout=300,
|
||||
return out
|
||||
|
||||
|
||||
# The settings window offers OpenRouter's ladder, and Google has neither end of
|
||||
# it: "none" is refused outright with a 400, and there is nothing above "high".
|
||||
# Both ends land on the nearest rung that does exist, which costs the cleanup
|
||||
# rather than the dictation when it is wrong. "minimal" is where "off" goes, and
|
||||
# it is the quickest of them by a wide margin, which is what cleanup wants
|
||||
# anyway.
|
||||
GEMINI_EFFORT = {"none": "minimal", "xhigh": "high", "max": "high"}
|
||||
|
||||
|
||||
def _thinking(payload, provider, reasoning):
|
||||
"""Ask for as much thinking as this provider understands, or for none.
|
||||
|
||||
An empty level means "whatever the model does on its own", so nothing is
|
||||
sent. The two mean opposite things by that, which is why the setting is kept
|
||||
per provider: OpenRouter's cleanup models answer straight away, while a local
|
||||
model that was trained to think will think, and cleanup is punctuation rather
|
||||
than a job worth thinking about.
|
||||
sent. The three mean opposite things by that, which is why the setting is
|
||||
kept per provider: OpenRouter's cleanup models answer straight away, while a
|
||||
local model that was trained to think will think, and a Gemini Flash left to
|
||||
itself thinks too. Cleanup is punctuation rather than a job worth thinking
|
||||
about.
|
||||
"""
|
||||
if not reasoning:
|
||||
return
|
||||
@@ -463,6 +494,13 @@ def _thinking(payload, provider, reasoning):
|
||||
# What llama.cpp passes to the chat template. The models that think read
|
||||
# it; the ones that do not ignore it.
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": reasoning != "none"}
|
||||
elif provider == "gemini":
|
||||
# Google's compatibility layer takes OpenAI's flat field rather than
|
||||
# OpenRouter's object, and it has no word for off, so "none" is asked
|
||||
# for as the lowest rung it has rather than skipped: a Flash model left
|
||||
# to decide for itself thinks, and thinking about a comma is the second
|
||||
# this provider was chosen to save.
|
||||
payload["reasoning_effort"] = GEMINI_EFFORT.get(reasoning, reasoning)
|
||||
elif reasoning != "none":
|
||||
# The thinking itself is never shown, so ask for it to be left out.
|
||||
payload["reasoning"] = {"effort": reasoning, "exclude": True}
|
||||
@@ -611,6 +649,37 @@ def openrouter_models(api_key="", transcription=False):
|
||||
return sorted(m["id"] for m in models if m.get("id"))
|
||||
|
||||
|
||||
# What a `gemini` id can be besides a model that answers a chat request: an
|
||||
# embedding, a picture, or a voice. None of them is any use to cleanup.
|
||||
NOT_CHAT = ("embedding", "-image", "-tts", "-audio")
|
||||
|
||||
|
||||
def gemini_models(api_key, base_url=GEMINI_URL):
|
||||
"""The Gemini models Google AI Studio will answer a chat request with.
|
||||
|
||||
Google serves its embedding, image and speech models out of the same list
|
||||
and names them all `gemini` too, so the prefix alone is not the question;
|
||||
none of those can clean up a sentence. The listing has also been known to
|
||||
hand the ids back in their long form, `models/gemini-3.5-flash`, while a
|
||||
request wants the short one; taking the prefix off costs nothing and is
|
||||
right whichever form arrives.
|
||||
"""
|
||||
service = "Google AI Studio"
|
||||
if not api_key:
|
||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||
service=service))
|
||||
try:
|
||||
data = _get_json(
|
||||
f"{base_url.rstrip('/')}/models",
|
||||
{"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
|
||||
)
|
||||
except ApiError as exc:
|
||||
raise explain(exc, service) from None
|
||||
ids = [(m.get("id") or "").removeprefix("models/") for m in data.get("data", [])]
|
||||
return sorted(i for i in ids
|
||||
if i.startswith("gemini") and not any(w in i for w in NOT_CHAT))
|
||||
|
||||
|
||||
def openai_models(api_key, base_url=OPENAI_URL, service="OpenAI"):
|
||||
"""The audio models of anything that speaks OpenAI's /models, Groq included.
|
||||
|
||||
|
||||
+161
-13
@@ -1,22 +1,28 @@
|
||||
"""Handing a dictation to an agent as a command, and pasting back its answer.
|
||||
|
||||
Three of them, because not everyone has the same one installed:
|
||||
Four of them, because not everyone has the same one installed:
|
||||
|
||||
Claude Code `claude -p`, the session you would have opened yourself
|
||||
Codex `codex exec`, the same idea from the other shop
|
||||
Antigravity `agy -p`, Google's, with a browser of its own attached
|
||||
OpenRouter a plain chat request, over the key that is already configured
|
||||
|
||||
The first two 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
|
||||
in my calendar on Thursday" a thing you can say. OpenRouter cannot touch any of
|
||||
that, and is there so that a question still gets an answer on a machine with
|
||||
neither CLI installed.
|
||||
that, and is 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
|
||||
chosen in Settings because it has a flag for one; Codex gets a sandbox for the
|
||||
same reason; Antigravity has neither, and reads its own allow-rules instead.
|
||||
|
||||
Whichever it is, the reply is pasted exactly where the transcript would have
|
||||
been, and the conversation carries across dictations so that "and move that to
|
||||
Friday" knows what "that" is.
|
||||
|
||||
The two CLIs are read as they stream rather than waited out. A command that
|
||||
The three CLIs are read as they stream rather than waited out. A command that
|
||||
reaches for the calendar or the web takes long enough that a still indicator is
|
||||
indistinguishable from a hang, so every tool they pick up is named in the corner
|
||||
while they work.
|
||||
@@ -38,7 +44,12 @@ from . import paths
|
||||
from .i18n import t
|
||||
|
||||
SESSION_FILE = cfg.DATA_DIR / "assistant.json"
|
||||
PROVIDERS = ("claude", "codex", "openrouter")
|
||||
PROVIDERS = ("claude", "codex", "agy", "openrouter")
|
||||
|
||||
# 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"}
|
||||
|
||||
# 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
|
||||
@@ -70,6 +81,29 @@ CODEX_ITEMS = {
|
||||
"patch_apply": "Editing a file…",
|
||||
"todo_list": "Planning…",
|
||||
}
|
||||
# Antigravity carries a browser around with it, so the handful of names below
|
||||
# stand in for the couple of dozen browser_* tools it can pick up; being told
|
||||
# which mouse button moved is not what the corner of the screen is for.
|
||||
AGY_TOOLS = {
|
||||
"run_command": "Running a command…",
|
||||
"command_status": "Running a command…",
|
||||
"send_command_input": "Running a command…",
|
||||
"view_file": "Reading…",
|
||||
"read_url_content": "Reading a web page…",
|
||||
"list_dir": "Looking through files…",
|
||||
"find_by_name": "Looking through files…",
|
||||
"grep_search": "Searching the files…",
|
||||
"search_web": "Searching the web…",
|
||||
"replace_file_content": "Editing a file…",
|
||||
"multi_replace_file_content": "Editing a file…",
|
||||
"sed_file": "Editing a file…",
|
||||
"notebook_edit": "Editing a file…",
|
||||
"write_to_file": "Writing a file…",
|
||||
"generate_image": "Drawing…",
|
||||
"manage_task": "Planning…",
|
||||
"invoke_subagent": "Handing it to a subagent…",
|
||||
"browser_subagent": "Handing it to a subagent…",
|
||||
}
|
||||
|
||||
|
||||
# How hard to think, in each provider's own vocabulary. The setting is one
|
||||
@@ -85,6 +119,12 @@ CLAUDE_EFFORT = {"none": "low", "minimal": "low", "low": "low",
|
||||
CODEX_EFFORT = {"none": "low", "minimal": "low", "low": "low",
|
||||
"medium": "medium", "high": "high", "xhigh": "high",
|
||||
"max": "high"}
|
||||
# agy has three rungs and no word for off, so the bottom of the ladder lands on
|
||||
# "low" and the top two on "high". Shared with cleanup, which runs the same
|
||||
# program for the smaller job.
|
||||
AGY_EFFORT = {"none": "low", "minimal": "low", "low": "low",
|
||||
"medium": "medium", "high": "high", "xhigh": "high",
|
||||
"max": "high"}
|
||||
|
||||
|
||||
class AssistantError(Exception):
|
||||
@@ -102,12 +142,28 @@ def provider(conf):
|
||||
|
||||
def executable(name):
|
||||
"""The CLI a provider runs, or "" when it needs none."""
|
||||
return {"claude": "claude", "codex": "codex"}.get(name, "")
|
||||
return {"claude": "claude", "codex": "codex", "agy": "agy"}.get(name, "")
|
||||
|
||||
|
||||
def model(conf):
|
||||
"""Which model answered, for the history to record.
|
||||
|
||||
Each provider keeps its own setting, and the one a CLI is left on has no id
|
||||
to report, only a name — the same arrangement cleanup.model() makes.
|
||||
"""
|
||||
name = provider(conf)
|
||||
if name == "codex":
|
||||
return conf["assistant_codex_model"].strip() or "codex"
|
||||
if name == "agy":
|
||||
return conf["assistant_agy_model"].strip() or "agy"
|
||||
if name == "openrouter":
|
||||
return conf["assistant_openrouter_model"]
|
||||
return conf["assistant_model"]
|
||||
|
||||
|
||||
def display_name(conf):
|
||||
"""What to call the thing being asked, in the tray and in the corner."""
|
||||
return {"claude": "Claude", "codex": "Codex"}.get(provider(conf), "OpenRouter")
|
||||
return SERVICES.get(provider(conf), "OpenRouter")
|
||||
|
||||
|
||||
# --- the conversation -----------------------------------------------------
|
||||
@@ -207,7 +263,7 @@ def ask(prompt, conf, on_stage=None, should_stop=None):
|
||||
"Settings → Agent.", binary=binary,
|
||||
))
|
||||
|
||||
run = _ask_claude if name == "claude" else _ask_codex
|
||||
run = {"claude": _ask_claude, "codex": _ask_codex, "agy": _ask_agy}[name]
|
||||
session = read_session(name, conf["assistant_session_minutes"] * 60)
|
||||
try:
|
||||
return run(prompt, conf, session, on_stage, should_stop)
|
||||
@@ -259,7 +315,7 @@ def _ask_claude(prompt, conf, session, on_stage, should_stop):
|
||||
found["warning"] = _denial_warning(event)
|
||||
|
||||
code, stderr = _stream(cmd, conf, on_event, should_stop)
|
||||
return _conclude(found, code, stderr, session, "Claude")
|
||||
return _conclude(found, code, stderr, session, "claude")
|
||||
|
||||
|
||||
def _claude_label(block):
|
||||
@@ -327,7 +383,7 @@ def _ask_codex(prompt, conf, session, on_stage, should_stop):
|
||||
else str(error)) or t("Codex ended with an error.")
|
||||
|
||||
code, stderr = _stream(cmd, conf, on_event, should_stop)
|
||||
return _conclude(found, code, stderr, session, "Codex")
|
||||
return _conclude(found, code, stderr, session, "codex")
|
||||
|
||||
|
||||
def _codex_label(item):
|
||||
@@ -366,6 +422,97 @@ def codex_models():
|
||||
return [row["slug"] for row in rows]
|
||||
|
||||
|
||||
# --- Antigravity ----------------------------------------------------------
|
||||
|
||||
def _ask_agy(prompt, conf, session, on_stage, should_stop):
|
||||
# Antigravity takes no system prompt of its own either, so the instruction
|
||||
# rides in front of the command, kept apart from it so the two are not read
|
||||
# as one.
|
||||
body = f"{conf.assistant_prompt()}\n\n---\n\n{prompt}"
|
||||
cmd = [
|
||||
"agy", "-p", body,
|
||||
"--output-format", "stream-json",
|
||||
# agy stops after five minutes unless it is told otherwise, which is
|
||||
# shorter than the timeout this setting offers.
|
||||
"--print-timeout", f"{conf['assistant_timeout']}s",
|
||||
]
|
||||
# One or the other, always: left with neither, agy picks up whichever
|
||||
# project it was last in and works in that project's directory rather than
|
||||
# the one _stream is about to start it in.
|
||||
cmd += ["--conversation", session] if session else ["--new-project"]
|
||||
if conf["assistant_agy_model"].strip():
|
||||
cmd += ["--model", conf["assistant_agy_model"].strip()]
|
||||
effort = AGY_EFFORT.get(conf["assistant_reasoning"], "")
|
||||
if effort:
|
||||
# Most of agy's own model ids carry the effort in their suffix already;
|
||||
# this is for the ones that do not.
|
||||
cmd += ["--effort", effort]
|
||||
|
||||
found = {"answer": "", "warning": "", "session": "", "failure": ""}
|
||||
|
||||
def on_event(event):
|
||||
kind = event.get("event")
|
||||
if kind == "init":
|
||||
found["session"] = event.get("conversation_id") or found["session"]
|
||||
elif kind == "step_update":
|
||||
step = event.get("step_update") or {}
|
||||
# A tool is reported twice, once when it starts and once when it is
|
||||
# done; the corner wants the first of those.
|
||||
if (on_stage and step.get("step_type") == "tool"
|
||||
and step.get("state") == "ACTIVE"):
|
||||
on_stage(_agy_label(step))
|
||||
elif kind == "result":
|
||||
result = event.get("result") or {}
|
||||
found["session"] = result.get("conversation_id") or found["session"]
|
||||
answer = (result.get("response") or "").strip()
|
||||
if result.get("status") == "SUCCESS":
|
||||
found["answer"] = answer
|
||||
else:
|
||||
found["failure"] = answer or t("{service} ended with an error.",
|
||||
service="Antigravity")
|
||||
|
||||
code, stderr = _stream(cmd, conf, on_event, should_stop)
|
||||
return _conclude(found, code, stderr, session, "agy")
|
||||
|
||||
|
||||
def _agy_label(step):
|
||||
name = step.get("tool_name", "")
|
||||
if name in AGY_TOOLS:
|
||||
return t(AGY_TOOLS[name])
|
||||
if name.startswith("browser_") or name.startswith("capture_browser"):
|
||||
return t("Working in the browser…")
|
||||
if name == "call_mcp_tool":
|
||||
server = (step.get("tool_info") or {}).get("parameters") or {}
|
||||
return t("Using {name}…", name=server.get("server") or "a tool")
|
||||
return t("Using {name}…", name=name or "a tool")
|
||||
|
||||
|
||||
def agy_models():
|
||||
"""The models Antigravity itself would offer right now, in its own order.
|
||||
|
||||
`agy models` prints one `id<TAB>display name` line per model, so the list
|
||||
is as current as the account behind the CLI. Unlike Codex it asks Google
|
||||
rather than a cache on disk, a couple of seconds the caller spends off the
|
||||
interface thread. A machine without agy, or a call that fails, answers
|
||||
with nothing and the caller keeps its built-in list.
|
||||
"""
|
||||
if not shutil.which("agy"):
|
||||
return []
|
||||
try:
|
||||
proc = subprocess.run(["agy", "models"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
ids = []
|
||||
for line in (proc.stdout or "").splitlines():
|
||||
model_id, tab, _ = line.partition("\t")
|
||||
if tab and model_id.strip():
|
||||
ids.append(model_id.strip())
|
||||
return ids
|
||||
|
||||
|
||||
# --- OpenRouter -----------------------------------------------------------
|
||||
|
||||
def _ask_openrouter(prompt, conf, on_stage):
|
||||
@@ -469,8 +616,9 @@ _API_TROUBLE = re.compile(
|
||||
r"\b(401|403|429|5\d\d)\b")
|
||||
|
||||
|
||||
def _conclude(found, code, stderr, session, service):
|
||||
def _conclude(found, code, stderr, session, name):
|
||||
"""Turn what the stream said into an answer, or into the reason there is none."""
|
||||
service = SERVICES.get(name, name)
|
||||
if code != 0 and not found["answer"]:
|
||||
# A resumed run that died with nothing to show is treated as the
|
||||
# session being gone, whatever the wording: this code used to look for
|
||||
@@ -489,7 +637,7 @@ def _conclude(found, code, stderr, session, service):
|
||||
if not found["answer"]:
|
||||
raise AssistantError(t("{service} answered with nothing.", service=service))
|
||||
if found["session"]:
|
||||
write_session("claude" if service == "Claude" else "codex", found["session"])
|
||||
write_session(name, found["session"])
|
||||
return found["answer"], found["warning"]
|
||||
|
||||
|
||||
|
||||
+58
-7
@@ -1,7 +1,8 @@
|
||||
"""Who rewrites the transcript once it has been heard.
|
||||
|
||||
Normally a small model on OpenRouter: one request, a second, a few tenths of a
|
||||
cent. A machine with Claude Code or Codex on it is already paying for a model
|
||||
Normally a small model over one HTTP request: a second, and a few tenths of a
|
||||
cent on OpenRouter or nothing at all on Google AI Studio's free tier. A machine
|
||||
with Claude Code, Codex or Antigravity on it is already paying for a model
|
||||
though, and the subscription that answers "put that in my calendar on Thursday"
|
||||
can just as well take the "eee"s out of a sentence. No second key, no second
|
||||
bill. It costs seconds rather than one, because a CLI opens a whole session to
|
||||
@@ -10,7 +11,11 @@ do it, which is the trade.
|
||||
Whoever does it, the job is the same one: no tools, no files, no memory of the
|
||||
last dictation. There is nothing here to look up and nothing to carry over, and
|
||||
a transcript is text from a microphone rather than an instruction, so the less
|
||||
the agent can reach while it reads one, the better.
|
||||
the agent can reach while it reads one, the better. Claude Code is handed an
|
||||
empty tool list and Codex a read-only sandbox. Antigravity has neither switch,
|
||||
and this is worth saying plainly rather than implying parity: there the
|
||||
transcript is read by an agent that could go and do something. What can be done
|
||||
is done — a project of its own, the home directory, and its slash commands off.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -24,7 +29,7 @@ from . import ggml
|
||||
from . import paths
|
||||
from .i18n import t
|
||||
|
||||
PROVIDERS = ("openrouter", "local", "claude", "codex")
|
||||
PROVIDERS = ("openrouter", "gemini", "local", "claude", "codex", "agy")
|
||||
|
||||
|
||||
class CleanupError(api.ApiError):
|
||||
@@ -43,7 +48,7 @@ def provider(conf):
|
||||
|
||||
def executable(name):
|
||||
"""The CLI a provider runs, or "" when it needs none."""
|
||||
return {"claude": "claude", "codex": "codex"}.get(name, "")
|
||||
return {"claude": "claude", "codex": "codex", "agy": "agy"}.get(name, "")
|
||||
|
||||
|
||||
def model(conf):
|
||||
@@ -57,13 +62,18 @@ def model(conf):
|
||||
# Codex is left on whatever it is set to unless a model is typed in, so
|
||||
# here there is only the name of the thing that did it.
|
||||
return conf["cleanup_codex_model"].strip() or "codex"
|
||||
if name == "agy":
|
||||
# The same arrangement as Codex, and the same reason for it.
|
||||
return conf["cleanup_agy_model"].strip() or "agy"
|
||||
if name == "gemini":
|
||||
return conf["cleanup_gemini_model"]
|
||||
return conf["cleanup_model"]
|
||||
|
||||
|
||||
def run(text, conf, system_prompt, timeout=180, aborter=None):
|
||||
"""Hand the transcript to whoever is set to clean it up.
|
||||
|
||||
`aborter` is only of use to the two that answer over HTTP; a CLI is stopped
|
||||
`aborter` is only of use to the three that answer over HTTP; a CLI is stopped
|
||||
between blocks instead, which is close enough when a block is seconds.
|
||||
"""
|
||||
name = provider(conf)
|
||||
@@ -74,9 +84,16 @@ def run(text, conf, system_prompt, timeout=180, aborter=None):
|
||||
base_url=conf["openrouter_base_url"], timeout=timeout,
|
||||
aborter=aborter,
|
||||
)
|
||||
if name == "gemini":
|
||||
return api.cleanup(
|
||||
text, conf.gemini_key(), conf["cleanup_gemini_model"], system_prompt,
|
||||
reasoning=conf["cleanup_reasoning"],
|
||||
base_url=conf["gemini_base_url"], timeout=timeout,
|
||||
provider="gemini", service="Google AI Studio", aborter=aborter,
|
||||
)
|
||||
if name == "local":
|
||||
return _local(text, conf, system_prompt, timeout, aborter)
|
||||
runner = _claude if name == "claude" else _codex
|
||||
runner = {"claude": _claude, "codex": _codex, "agy": _agy}[name]
|
||||
return runner(text, conf, system_prompt, timeout)
|
||||
|
||||
|
||||
@@ -172,6 +189,40 @@ def _codex(text, conf, system_prompt, timeout):
|
||||
return answer
|
||||
|
||||
|
||||
# --- Antigravity ----------------------------------------------------------
|
||||
|
||||
def _agy(text, conf, system_prompt, timeout):
|
||||
# Antigravity takes no system prompt of its own either, so the rules ride in
|
||||
# front of the transcript, kept apart from it so the two are not read as one.
|
||||
body = f"{system_prompt}\n\n---\n\n{_wrap(text)}"
|
||||
cmd = [
|
||||
"agy", "-p", body,
|
||||
"--output-format", "text", # the answer, and nothing around it
|
||||
# Left to itself agy picks up whichever project it was last in and works
|
||||
# in that project's directory rather than this one. A dictation belongs
|
||||
# to no project, so each one starts on a project of its own.
|
||||
"--new-project",
|
||||
# A transcript that happens to begin with a slash is still a transcript.
|
||||
"--disable-slash-commands",
|
||||
# agy gives up after five minutes of its own accord, which would have it
|
||||
# killed from outside rather than answering.
|
||||
"--print-timeout", f"{timeout}s",
|
||||
]
|
||||
if conf["cleanup_agy_model"].strip():
|
||||
cmd += ["--model", conf["cleanup_agy_model"].strip()]
|
||||
effort = assistant.AGY_EFFORT.get(conf["cleanup_reasoning"], "")
|
||||
if effort:
|
||||
# agy's own model ids carry the effort in their suffix, so this only
|
||||
# matters for the ones that do not, and for a model typed in by hand.
|
||||
cmd += ["--effort", effort]
|
||||
|
||||
answer = _output(cmd, timeout, "Antigravity")
|
||||
if not answer:
|
||||
raise CleanupError(t("{service} answered with nothing.",
|
||||
service="Antigravity"))
|
||||
return answer
|
||||
|
||||
|
||||
def _read(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
|
||||
+30
-11
@@ -216,7 +216,8 @@ def cmd_ask(opts):
|
||||
conf["assistant_provider"] = opts.provider
|
||||
if opts.model:
|
||||
key = {"claude": "assistant_model", "codex": "assistant_codex_model",
|
||||
"openrouter": "assistant_openrouter_model"}[assistant.provider(conf)]
|
||||
"openrouter": "assistant_openrouter_model",
|
||||
"agy": "assistant_agy_model"}[assistant.provider(conf)]
|
||||
conf[key] = opts.model
|
||||
if opts.dir:
|
||||
conf["assistant_dir"] = opts.dir
|
||||
@@ -255,7 +256,8 @@ def cmd_ask(opts):
|
||||
"cleanup_error": warning,
|
||||
"mode": "ask",
|
||||
"question": text,
|
||||
"assistant_model": conf["assistant_model"],
|
||||
"assistant": assistant.provider(conf),
|
||||
"assistant_model": assistant.model(conf),
|
||||
"raw": text,
|
||||
"text": answer,
|
||||
})
|
||||
@@ -508,7 +510,8 @@ def cmd_history_clear(opts):
|
||||
|
||||
# --- 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")
|
||||
|
||||
|
||||
def _mask(key, value):
|
||||
@@ -698,6 +701,14 @@ def cmd_test_key(opts):
|
||||
results[name] = {"ok": True, "message": message}
|
||||
except api.ApiError as exc:
|
||||
results[name] = {"ok": False, "message": str(exc)}
|
||||
if opts.which in ("gemini", "all"):
|
||||
try:
|
||||
count = len(api.gemini_models(conf.gemini_key(),
|
||||
conf["gemini_base_url"]))
|
||||
message = f"connection works, {count} models visible"
|
||||
results["gemini"] = {"ok": True, "message": message}
|
||||
except api.ApiError as exc:
|
||||
results["gemini"] = {"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()]
|
||||
@@ -862,8 +873,16 @@ 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()
|
||||
if cleaner == "openrouter":
|
||||
cleanup_ready = bool(conf.openrouter_key())
|
||||
# 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.
|
||||
cleanup_service, cleanup_key = {
|
||||
"openrouter": ("OpenRouter", conf.openrouter_key()),
|
||||
"gemini": ("Google AI Studio", conf.gemini_key()),
|
||||
}.get(cleaner, ("", ""))
|
||||
if cleanup_service:
|
||||
cleanup_ready = bool(cleanup_key)
|
||||
elif cleaner == "local":
|
||||
cleanup_ready = conf.local_llm_ready()
|
||||
else:
|
||||
@@ -875,7 +894,7 @@ def cmd_doctor(opts):
|
||||
"ready": transcribe_ready},
|
||||
"cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner,
|
||||
"model": cleanup.model(conf),
|
||||
"key": bool(conf.openrouter_key()),
|
||||
"key": bool(cleanup_key) if cleanup_service else None,
|
||||
"ready": cleanup_ready},
|
||||
"agent": {"provider": assistant.provider(conf),
|
||||
"directory": assistant.working_dir(conf)},
|
||||
@@ -887,9 +906,9 @@ def cmd_doctor(opts):
|
||||
else:
|
||||
transcribe_line = (f"{'✓' if transcribe_ready else '✗'} {target.service} "
|
||||
f"key, transcribing on {target.model}")
|
||||
if cleaner == "openrouter":
|
||||
cleanup_line = (f"{'✓' if cleanup_ready else '✗'} OpenRouter key, "
|
||||
f"cleaning up on {conf['cleanup_model']}")
|
||||
if cleanup_service:
|
||||
cleanup_line = (f"{'✓' if cleanup_ready else '✗'} {cleanup_service} key, "
|
||||
f"cleaning up on {cleanup.model(conf)}")
|
||||
elif cleaner == "local":
|
||||
cleanup_line = (f"{'✓' if cleanup_ready else '✗'} Local model, "
|
||||
f"cleaning up on {conf['local_llm_model'] or 'no model yet'}")
|
||||
@@ -990,7 +1009,7 @@ def build_parser():
|
||||
ask = leaf(subs, "ask", "put a command to the agent")
|
||||
ask.add_argument("text", nargs="*", help="the command; read from stdin, or "
|
||||
"recorded when there is none")
|
||||
ask.add_argument("--provider", choices=("claude", "codex", "openrouter"),
|
||||
ask.add_argument("--provider", choices=assistant.PROVIDERS,
|
||||
help="just for this run")
|
||||
ask.add_argument("--model", help="just for this run")
|
||||
ask.add_argument("--dir", help="working directory, just for this run")
|
||||
@@ -1116,7 +1135,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))
|
||||
choices=("all", *cfg.TRANSCRIBERS, "gemini"))
|
||||
test.set_defaults(func=cmd_test_key)
|
||||
leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor)
|
||||
|
||||
|
||||
+11
-1
@@ -387,6 +387,10 @@ DEFAULTS = {
|
||||
"groq_base_url": "https://api.groq.com/openai/v1",
|
||||
"openrouter_api_key": "",
|
||||
"openrouter_base_url": "https://openrouter.ai/api/v1",
|
||||
"gemini_api_key": "",
|
||||
# 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",
|
||||
"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",
|
||||
@@ -412,6 +416,8 @@ DEFAULTS = {
|
||||
"cleanup_model": "google/gemini-3.5-flash-lite",
|
||||
"cleanup_claude_model": "haiku", # Claude Code: an alias, or a full model id
|
||||
"cleanup_codex_model": "", # empty -> whatever Codex is set to
|
||||
"cleanup_gemini_model": "gemini-3.5-flash-lite",
|
||||
"cleanup_agy_model": "", # empty -> whatever Antigravity is set to
|
||||
"cleanup_reasoning": "", # empty -> whatever the model does by default
|
||||
|
||||
# --- llama.cpp, on this machine -----------------------------------------
|
||||
@@ -485,12 +491,13 @@ DEFAULTS = {
|
||||
|
||||
# --- speaking a command to an agent -------------------------------------
|
||||
"assistant_shortcut": "", # empty -> tray only
|
||||
"assistant_provider": "claude", # claude | codex | openrouter
|
||||
"assistant_provider": "claude", # claude | codex | agy | openrouter
|
||||
"assistant_model": "sonnet", # Claude Code: an alias, or a full model id
|
||||
"assistant_permission_mode": "auto",
|
||||
"assistant_codex_model": "", # empty -> whatever Codex is set to
|
||||
"assistant_codex_sandbox": "workspace-write",
|
||||
"assistant_openrouter_model": "google/gemini-3.5-flash",
|
||||
"assistant_agy_model": "", # empty -> whatever Antigravity is set to
|
||||
"assistant_reasoning": "", # empty -> the model's own default
|
||||
"assistant_dir": "", # empty -> the home directory
|
||||
"assistant_prompt": "", # empty -> language-specific default
|
||||
@@ -633,6 +640,9 @@ class Config:
|
||||
def openrouter_key(self):
|
||||
return self.api_key("openrouter_api_key")
|
||||
|
||||
def gemini_key(self):
|
||||
return self.api_key("gemini_api_key")
|
||||
|
||||
def transcribe_target(self):
|
||||
"""Key, endpoint and model for whichever provider does speech to text.
|
||||
|
||||
|
||||
+37
-17
@@ -40,8 +40,14 @@ def t(text, /, **kwargs):
|
||||
# by the sentence, so it arrives already inflected. English takes the name as it
|
||||
# is and puts the preposition in the sentence, where it belongs.
|
||||
_TR_CASES = {
|
||||
"dative": {"Claude": "Claude'a", "Codex": "Codex'e", "OpenRouter": "OpenRouter'a"},
|
||||
"accusative": {"Claude": "Claude'u", "Codex": "Codex'i", "OpenRouter": "OpenRouter'ı"},
|
||||
"dative": {
|
||||
"Claude": "Claude'a", "Codex": "Codex'e", "OpenRouter": "OpenRouter'a",
|
||||
"Google AI Studio": "Google AI Studio'ya", "Antigravity": "Antigravity'ye",
|
||||
},
|
||||
"accusative": {
|
||||
"Claude": "Claude'u", "Codex": "Codex'i", "OpenRouter": "OpenRouter'ı",
|
||||
"Google AI Studio": "Google AI Studio'yu", "Antigravity": "Antigravity'yi",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -224,26 +230,33 @@ TR = {
|
||||
"sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_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)",
|
||||
"(falls back to GEMINI_API_KEY)": "(boşsa GEMINI_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.",
|
||||
"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 is the quickest and the only one that needs nothing installed. "
|
||||
"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.":
|
||||
"En hızlısı OpenRouter'dır ve kurulu bir program istemeyen tek seçenektir. "
|
||||
"Claude Code ile Codex, temizliği hâlihazırda ödediğin abonelik üzerinden "
|
||||
"yapar, ikinci bir anahtar istemez; her biri bunun için bir oturum açtığından "
|
||||
"birkaç saniye daha uzun sürer.",
|
||||
"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.",
|
||||
"{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 "
|
||||
"yapıştırılır. Kur ya da yukarıdan başka birini seç.",
|
||||
"Thinking": "Düşünme",
|
||||
"Model's own default": "Modelin kendi varsayılanı",
|
||||
"Antigravity's own default": "Antigravity'nin kendi varsayılanı",
|
||||
"Off": "Kapalı",
|
||||
"Minimal": "En az",
|
||||
"Low": "Düşük",
|
||||
@@ -509,15 +522,15 @@ TR = {
|
||||
"This shortcut records the same way dictation does, but the transcript is "
|
||||
"not what gets pasted. It goes to an agent as a command, and what comes "
|
||||
"back is pasted instead: the answer to a question, or a sentence saying "
|
||||
"what was done. Claude Code and Codex run as the session you would have "
|
||||
"opened yourself, with your skills, your connected services and your "
|
||||
"account.":
|
||||
"what was done. Claude Code, Codex and Antigravity run as the session you "
|
||||
"would have opened yourself, with your skills, your connected services and "
|
||||
"your account.":
|
||||
"Bu kısayol dikte ile aynı şekilde kaydeder, ama yapıştırılan şey "
|
||||
"transkript değildir. Transkript bir ajana komut olarak gider ve yerine "
|
||||
"oradan döneni yapıştırılır: bir sorunun cevabı ya da ne yapıldığını "
|
||||
"söyleyen bir cümle. Claude Code ve Codex, kendi açacağın oturumun "
|
||||
"aynısı olarak çalışır: skill'lerinle, bağlı servislerinle ve kendi "
|
||||
"hesabınla.",
|
||||
"söyleyen bir cümle. Claude Code, Codex ve Antigravity kendi açacağın "
|
||||
"oturumun aynısı olarak çalışır: skill'lerinle, bağlı servislerinle "
|
||||
"ve kendi hesabınla.",
|
||||
"How it runs": "Nasıl çalışıyor",
|
||||
"Runs on": "Şunun üstünde çalışır",
|
||||
"More thinking is slower, and you are standing in front of the screen while "
|
||||
@@ -544,6 +557,13 @@ 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.",
|
||||
"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 "
|
||||
"directory still applies.":
|
||||
"Antigravity'ye verilebilecek bir izin kipi ya da sandbox yok; sormadan "
|
||||
"ne yapabileceğini kendi allow-rule'ları belirler. Yukarıdaki İzinler ve "
|
||||
"Sandbox kutuları diğer ikisine ait; çalışma dizini burada da geçerli.",
|
||||
"{binary} is not on your PATH, so this cannot run yet. Install it, or pick "
|
||||
"another one above.":
|
||||
"{binary} PATH'te değil, dolayısıyla bu henüz çalışamaz. Kur ya da "
|
||||
@@ -602,7 +622,7 @@ TR = {
|
||||
"configuration already says.":
|
||||
"Her komutla birlikte ajana söylenir, kendi yapılandırmanın zaten "
|
||||
"söylediklerinin üstüne eklenir.",
|
||||
" · asked Claude: {question}": " · Claude'a soruldu: {question}",
|
||||
" · asked {who}: {question}": " · {who} soruldu: {question}",
|
||||
|
||||
# --- meetings: tray and pipeline ---------------------------------------
|
||||
"Record a meeting": "Toplantı kaydet",
|
||||
|
||||
+212
-19
@@ -60,12 +60,24 @@ CLEANUP_MODELS = [
|
||||
"google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5",
|
||||
"openai/gpt-5-mini", "meta-llama/llama-3.3-70b-instruct",
|
||||
]
|
||||
# In the order they answer in. A request to OpenRouter is over in a second, a
|
||||
# model here takes a little longer and costs nothing, and the two CLIs the agent
|
||||
# can run on open a whole session to do the smaller job.
|
||||
GEMINI_MODELS = [
|
||||
"gemini-3.5-flash-lite", "gemini-3.1-flash-lite",
|
||||
"gemini-2.5-flash-lite", "gemini-3.5-flash", "gemini-2.5-flash",
|
||||
]
|
||||
# agy's model ids carry the reasoning effort in their suffix, which is why one
|
||||
# model appears here at more than one level. The same list seeds two boxes:
|
||||
# cleanup, which wants the bottom rung, and the agent, which sometimes does not.
|
||||
AGY_MODELS = [
|
||||
"gemini-3.7-flash-low", "gemini-3.7-flash-medium", "gemini-3.7-flash-high",
|
||||
"gemini-3.5-flash-low", "gemini-3.1-pro-low",
|
||||
]
|
||||
# In the order they answer in. The hosted requests are over in a second, a
|
||||
# model here takes a little longer and costs nothing, and the three CLIs the
|
||||
# agent can run on open a whole session to do the smaller job.
|
||||
CLEANUP_PROVIDERS = [
|
||||
("OpenRouter", "openrouter"), ("This machine (llama.cpp)", "local"),
|
||||
("Claude Code", "claude"), ("Codex", "codex"),
|
||||
("OpenRouter", "openrouter"), ("Google AI Studio", "gemini"),
|
||||
("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.
|
||||
@@ -77,7 +89,8 @@ MEETING_MODELS = [
|
||||
"anthropic/claude-sonnet-5", "openai/gpt-5.4", "x-ai/grok-4.5",
|
||||
]
|
||||
ASSISTANT_PROVIDERS = [
|
||||
("Claude Code", "claude"), ("Codex", "codex"), ("OpenRouter", "openrouter"),
|
||||
("Claude Code", "claude"), ("Codex", "codex"), ("Antigravity", "agy"),
|
||||
("OpenRouter", "openrouter"),
|
||||
]
|
||||
# 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.
|
||||
@@ -558,8 +571,12 @@ class SettingsWindow(QDialog):
|
||||
return self._sources
|
||||
|
||||
_models_loaded = pyqtSignal(list, str)
|
||||
_gemini_models_loaded = pyqtSignal(list, str)
|
||||
_transcribe_models_loaded = pyqtSignal(list, str)
|
||||
_codex_models_loaded = pyqtSignal(list)
|
||||
_agy_models_loaded = pyqtSignal(list)
|
||||
# Which hosted provider's list arrived on its own at open, and the list.
|
||||
_hosted_models_loaded = pyqtSignal(str, list)
|
||||
# Which key was tested, whether it worked, and what to write under it.
|
||||
_test_done = pyqtSignal(str, bool, str)
|
||||
# The release that was found, or None, and what went wrong instead.
|
||||
@@ -618,8 +635,11 @@ class SettingsWindow(QDialog):
|
||||
self._size_to_screen(680, 640)
|
||||
|
||||
self._models_loaded.connect(self._on_models_loaded)
|
||||
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._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)
|
||||
self._update_checked.connect(self._on_update_checked)
|
||||
self.transcriber.progress.connect(self._on_file_progress)
|
||||
@@ -631,6 +651,8 @@ class SettingsWindow(QDialog):
|
||||
self.meetings.failed.connect(self._on_minutes_failed)
|
||||
self._load()
|
||||
self._load_codex_models()
|
||||
self._load_agy_models()
|
||||
self._load_hosted_models()
|
||||
# Connected after the load, so that filling the boxes in is not taken
|
||||
# for the user ticking them.
|
||||
self.file_timestamps.toggled.connect(self._remember_file_choices)
|
||||
@@ -807,6 +829,9 @@ class SettingsWindow(QDialog):
|
||||
self.openrouter_key = self._key_row(
|
||||
keys_form, "openrouter", t("sk-or-… (falls back to OPENROUTER_API_KEY)"),
|
||||
self._test_openrouter)
|
||||
self.gemini_key = self._key_row(
|
||||
keys_form, "gemini", t("(falls back to GEMINI_API_KEY)"),
|
||||
self._test_gemini, service="Google AI Studio")
|
||||
outer.addWidget(keys)
|
||||
|
||||
stt = QGroupBox(t("Speech to text"))
|
||||
@@ -879,11 +904,11 @@ class SettingsWindow(QDialog):
|
||||
for label, value in CLEANUP_PROVIDERS:
|
||||
self.cleanup_provider.addItem(t(label), value)
|
||||
self.cleanup_provider.setToolTip(t(
|
||||
"OpenRouter is the quickest and the only one that needs nothing "
|
||||
"OpenRouter and Google AI Studio are the quick ones that need 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."
|
||||
"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)
|
||||
@@ -896,6 +921,15 @@ class SettingsWindow(QDialog):
|
||||
self.cleanup_model_row = self._row(self.cleanup_model, self.refresh_models)
|
||||
orr_form.addRow(t("Model"), self.cleanup_model_row)
|
||||
|
||||
self.cleanup_gemini_model = QComboBox()
|
||||
self.cleanup_gemini_model.setEditable(True)
|
||||
self.cleanup_gemini_model.addItems(GEMINI_MODELS)
|
||||
self.refresh_gemini_models = QPushButton(t("Fetch model list"))
|
||||
self.refresh_gemini_models.clicked.connect(self._load_gemini_models)
|
||||
self.cleanup_gemini_model_row = self._row(
|
||||
self.cleanup_gemini_model, self.refresh_gemini_models)
|
||||
orr_form.addRow(t("Model"), self.cleanup_gemini_model_row)
|
||||
|
||||
# One row per provider rather than one box that means a different thing
|
||||
# in each: an OpenRouter id and a Claude alias do not belong in the same
|
||||
# field, and only the row of whoever is chosen is on screen.
|
||||
@@ -911,6 +945,11 @@ class SettingsWindow(QDialog):
|
||||
self.cleanup_codex_model.setToolTip(_typed_model_note("Codex"))
|
||||
orr_form.addRow(t("Model"), self.cleanup_codex_model)
|
||||
|
||||
self.cleanup_agy_model = QComboBox()
|
||||
self.cleanup_agy_model.setEditable(True)
|
||||
self.cleanup_agy_model.addItems([t("Antigravity's own default")] + AGY_MODELS)
|
||||
orr_form.addRow(t("Model"), self.cleanup_agy_model)
|
||||
|
||||
self.cleanup_reasoning = QComboBox()
|
||||
for label, value in REASONING_LEVELS:
|
||||
self.cleanup_reasoning.addItem(t(label), value)
|
||||
@@ -994,9 +1033,9 @@ class SettingsWindow(QDialog):
|
||||
"This shortcut records the same way dictation does, but the "
|
||||
"transcript is not what gets pasted. It goes to an agent as a "
|
||||
"command, and what comes back is pasted instead: the answer to a "
|
||||
"question, or a sentence saying what was done. Claude Code and "
|
||||
"Codex run as the session you would have opened yourself, with your "
|
||||
"skills, your connected services and your account."
|
||||
"question, or a sentence saying what was done. Claude Code, Codex "
|
||||
"and Antigravity run as the session you would have opened yourself, "
|
||||
"with your skills, your connected services and your account."
|
||||
))
|
||||
intro.setWordWrap(True)
|
||||
layout.addWidget(intro)
|
||||
@@ -1034,7 +1073,7 @@ class SettingsWindow(QDialog):
|
||||
dir_note.setWordWrap(True)
|
||||
how_form.addRow(dir_note)
|
||||
|
||||
# One scale for all three: how hard to think is one thing to want, and
|
||||
# One scale for all four: how hard to think is one thing to want, and
|
||||
# each provider is handed the nearest rung it actually has.
|
||||
self.assistant_reasoning = QComboBox()
|
||||
for label, value in REASONING_LEVELS:
|
||||
@@ -1057,7 +1096,7 @@ class SettingsWindow(QDialog):
|
||||
layout.addWidget(how)
|
||||
|
||||
# One box per provider, only the chosen one on screen: they have nothing
|
||||
# in common past the model, and three sets of half-relevant fields would
|
||||
# in common past the model, and four sets of half-relevant fields would
|
||||
# be worse than none.
|
||||
self.claude_box = QGroupBox(t("Claude Code"))
|
||||
claude_form = QFormLayout(self.claude_box)
|
||||
@@ -1108,6 +1147,24 @@ class SettingsWindow(QDialog):
|
||||
or_form.addRow(or_note)
|
||||
layout.addWidget(self.openrouter_box)
|
||||
|
||||
self.agy_box = QGroupBox(t("Antigravity"))
|
||||
agy_form = QFormLayout(self.agy_box)
|
||||
self.assistant_agy_model = QComboBox()
|
||||
self.assistant_agy_model.setEditable(True)
|
||||
self.assistant_agy_model.addItem(t("Antigravity's own default"), "")
|
||||
for name in AGY_MODELS:
|
||||
self.assistant_agy_model.addItem(name, name)
|
||||
agy_form.addRow(t("Model"), self.assistant_agy_model)
|
||||
agy_note = QLabel(t(
|
||||
"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 directory still applies."
|
||||
))
|
||||
agy_note.setWordWrap(True)
|
||||
agy_form.addRow(agy_note)
|
||||
layout.addWidget(self.agy_box)
|
||||
|
||||
thread = QGroupBox(t("The conversation"))
|
||||
thread_form = QFormLayout(thread)
|
||||
self.assistant_session_minutes = QSpinBox()
|
||||
@@ -1579,7 +1636,7 @@ class SettingsWindow(QDialog):
|
||||
box.lineEdit().setPlaceholderText(placeholder)
|
||||
return box
|
||||
|
||||
def _key_row(self, form, provider, placeholder, tester):
|
||||
def _key_row(self, form, provider, placeholder, tester, service=""):
|
||||
"""A key field, its Test button and the line the answer lands on.
|
||||
|
||||
The field and the pair the answer needs are filed under the provider's
|
||||
@@ -1593,7 +1650,8 @@ class SettingsWindow(QDialog):
|
||||
button.clicked.connect(tester)
|
||||
answer = QLabel("")
|
||||
answer.setWordWrap(True)
|
||||
form.addRow(cfg.TRANSCRIBERS[provider].service, self._row(field, button))
|
||||
label = service or cfg.TRANSCRIBERS[provider].service
|
||||
form.addRow(label, self._row(field, button))
|
||||
form.addRow("", answer)
|
||||
self._key_fields[provider] = field
|
||||
self._testers[provider] = (button, answer)
|
||||
@@ -1671,6 +1729,7 @@ class SettingsWindow(QDialog):
|
||||
for name, who in cfg.TRANSCRIBERS.items():
|
||||
self._key_fields[name].setText(conf[who.key])
|
||||
self._models[name] = conf[who.model]
|
||||
self.gemini_key.setText(conf["gemini_api_key"])
|
||||
self._shown_provider = ""
|
||||
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
||||
self._provider_changed() # selecting index 0 fires no signal
|
||||
@@ -1681,10 +1740,16 @@ class SettingsWindow(QDialog):
|
||||
|
||||
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
|
||||
self.cleanup_model.setCurrentText(conf["cleanup_model"])
|
||||
self.cleanup_gemini_model.setCurrentText(
|
||||
conf["cleanup_gemini_model"] or cfg.DEFAULTS["cleanup_gemini_model"]
|
||||
)
|
||||
self.cleanup_claude_model.setCurrentText(conf["cleanup_claude_model"])
|
||||
self.cleanup_codex_model.setCurrentText(
|
||||
conf["cleanup_codex_model"] or t("Codex's own default")
|
||||
)
|
||||
self.cleanup_agy_model.setCurrentText(
|
||||
conf["cleanup_agy_model"] or t("Antigravity's own default")
|
||||
)
|
||||
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"])
|
||||
@@ -1715,6 +1780,7 @@ class SettingsWindow(QDialog):
|
||||
self.assistant_codex_model.setCurrentText(conf["assistant_codex_model"])
|
||||
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_provider_changed() # selecting index 0 fires no signal
|
||||
self._select_data(self.assistant_reasoning, conf["assistant_reasoning"])
|
||||
self.assistant_dir.setText(conf["assistant_dir"])
|
||||
@@ -1782,6 +1848,7 @@ class SettingsWindow(QDialog):
|
||||
for name, who in cfg.TRANSCRIBERS.items():
|
||||
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["local_model"] = self.local_whisper.selected()
|
||||
conf["local_gpu"] = self.local_gpu.isChecked()
|
||||
conf["local_preload"] = self.local_preload.isChecked()
|
||||
@@ -1790,12 +1857,21 @@ class SettingsWindow(QDialog):
|
||||
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
|
||||
conf["cleanup_provider"] = self.cleanup_provider.currentData() or "openrouter"
|
||||
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
|
||||
conf["cleanup_gemini_model"] = (
|
||||
self.cleanup_gemini_model.currentText().strip()
|
||||
or cfg.DEFAULTS["cleanup_gemini_model"]
|
||||
)
|
||||
conf["cleanup_claude_model"] = (self.cleanup_claude_model.currentText().strip()
|
||||
or cfg.DEFAULTS["cleanup_claude_model"])
|
||||
codex_cleanup_model = self.cleanup_codex_model.currentText().strip()
|
||||
conf["cleanup_codex_model"] = (
|
||||
"" if codex_cleanup_model == t("Codex's own default") else codex_cleanup_model
|
||||
)
|
||||
agy_cleanup_model = self.cleanup_agy_model.currentText().strip()
|
||||
conf["cleanup_agy_model"] = (
|
||||
"" if agy_cleanup_model == t("Antigravity's own default")
|
||||
else agy_cleanup_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()
|
||||
@@ -1834,6 +1910,10 @@ class SettingsWindow(QDialog):
|
||||
self.assistant_openrouter_model.currentText().strip()
|
||||
or cfg.DEFAULTS["assistant_openrouter_model"]
|
||||
)
|
||||
agy_model = self.assistant_agy_model.currentText().strip()
|
||||
conf["assistant_agy_model"] = (
|
||||
"" if agy_model == t("Antigravity's own default") else agy_model
|
||||
)
|
||||
conf["assistant_reasoning"] = self.assistant_reasoning.currentData() or ""
|
||||
conf["assistant_dir"] = self.assistant_dir.text().strip()
|
||||
conf["assistant_timeout"] = self.assistant_timeout.value()
|
||||
@@ -1997,6 +2077,30 @@ class SettingsWindow(QDialog):
|
||||
combo.setCurrentText(current)
|
||||
self.models_label.setText(t("{count} models loaded.", count=len(models)))
|
||||
|
||||
def _load_gemini_models(self):
|
||||
self.refresh_gemini_models.setEnabled(False)
|
||||
self.models_label.setText(t("Fetching model list…"))
|
||||
key, base = self._typed_key("gemini")
|
||||
|
||||
def work():
|
||||
try:
|
||||
self._gemini_models_loaded.emit(api.gemini_models(key, base), "")
|
||||
except api.ApiError as exc:
|
||||
self._gemini_models_loaded.emit([], str(exc))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_gemini_models_loaded(self, models, error):
|
||||
self.refresh_gemini_models.setEnabled(True)
|
||||
if error:
|
||||
self.models_label.setText(t("Could not fetch the list: {error}", error=error))
|
||||
return
|
||||
current = self.cleanup_gemini_model.currentText()
|
||||
self.cleanup_gemini_model.clear()
|
||||
self.cleanup_gemini_model.addItems(models)
|
||||
self.cleanup_gemini_model.setCurrentText(current)
|
||||
self.models_label.setText(t("{count} models loaded.", count=len(models)))
|
||||
|
||||
def _load_codex_models(self):
|
||||
"""Ask Codex which models it offers, off the interface thread.
|
||||
|
||||
@@ -2024,6 +2128,72 @@ class SettingsWindow(QDialog):
|
||||
combo.addItem(name, name)
|
||||
combo.setCurrentText(current)
|
||||
|
||||
def _load_agy_models(self):
|
||||
"""Ask Antigravity which models it offers, off the interface thread.
|
||||
|
||||
The same arrangement as Codex, except agy answers over the network
|
||||
rather than from a cache, so the couple of seconds it takes are spent
|
||||
where nobody is waiting. Skipped when agy is not installed, which is
|
||||
also when the built-in list stays on screen and nobody is running
|
||||
Antigravity anyway.
|
||||
"""
|
||||
if not shutil.which("agy"):
|
||||
return
|
||||
|
||||
def work():
|
||||
found = assistant.agy_models()
|
||||
if found:
|
||||
self._agy_models_loaded.emit(found)
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_agy_models_loaded(self, models):
|
||||
for combo in (self.cleanup_agy_model, self.assistant_agy_model):
|
||||
current = combo.currentText()
|
||||
combo.clear()
|
||||
combo.addItem(t("Antigravity's own default"), "")
|
||||
for name in models:
|
||||
combo.addItem(name, name)
|
||||
combo.setCurrentText(current)
|
||||
|
||||
def _load_hosted_models(self):
|
||||
"""Fetch OpenRouter's and Google's 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
|
||||
screen and the built-in lists remain, and a provider whose key has not
|
||||
been given yet is not called at all.
|
||||
"""
|
||||
jobs = []
|
||||
openrouter_key = self.conf.openrouter_key()
|
||||
if openrouter_key:
|
||||
jobs.append(("openrouter",
|
||||
lambda: api.openrouter_models(openrouter_key)))
|
||||
gemini_key = self.conf.gemini_key()
|
||||
gemini_base = self.conf["gemini_base_url"]
|
||||
if gemini_key:
|
||||
jobs.append(("gemini",
|
||||
lambda: api.gemini_models(gemini_key, gemini_base)))
|
||||
for provider, fetch in jobs:
|
||||
def work(provider=provider, fetch=fetch):
|
||||
try:
|
||||
found = fetch()
|
||||
except api.ApiError:
|
||||
return
|
||||
if found:
|
||||
self._hosted_models_loaded.emit(provider, found)
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_hosted_models_loaded(self, provider, models):
|
||||
combos = ((self.cleanup_model, self.meeting_model)
|
||||
if provider == "openrouter" else (self.cleanup_gemini_model,))
|
||||
for combo in combos:
|
||||
current = combo.currentText()
|
||||
combo.clear()
|
||||
combo.addItems(models)
|
||||
combo.setCurrentText(current)
|
||||
|
||||
def _test_openai(self):
|
||||
key, base = self._typed_key("openai")
|
||||
self._test_key("openai", lambda: t(
|
||||
@@ -2042,11 +2212,23 @@ class SettingsWindow(QDialog):
|
||||
key, _ = self._typed_key("openrouter")
|
||||
self._test_key("openrouter", lambda: api.openrouter_key_status(key))
|
||||
|
||||
def _test_gemini(self):
|
||||
key, base = self._typed_key("gemini")
|
||||
self._test_key("gemini", lambda: t(
|
||||
"Connection works. {count} models visible.",
|
||||
count=len(api.gemini_models(key, base)),
|
||||
))
|
||||
|
||||
def _typed_key(self, provider):
|
||||
"""(key, base URL) for a provider, preferring what is in the field now."""
|
||||
if provider in cfg.TRANSCRIBERS:
|
||||
who = cfg.TRANSCRIBERS[provider]
|
||||
key_setting, url_setting = who.key, who.url
|
||||
else:
|
||||
key_setting = f"{provider}_api_key"
|
||||
url_setting = f"{provider}_base_url"
|
||||
typed = self._key_fields[provider].text().strip()
|
||||
return typed or self.conf.api_key(who.key), self.conf[who.url]
|
||||
return typed or self.conf.api_key(key_setting), self.conf[url_setting]
|
||||
|
||||
def _test_key(self, provider, ask):
|
||||
"""Run `ask` off the interface thread and write its answer under the key.
|
||||
@@ -2270,10 +2452,14 @@ class SettingsWindow(QDialog):
|
||||
provider = self.cleanup_provider.currentData() or "openrouter"
|
||||
self.cleanup_form.setRowVisible(self.cleanup_model_row,
|
||||
provider == "openrouter")
|
||||
self.cleanup_form.setRowVisible(self.cleanup_gemini_model_row,
|
||||
provider == "gemini")
|
||||
self.cleanup_form.setRowVisible(self.cleanup_claude_model,
|
||||
provider == "claude")
|
||||
self.cleanup_form.setRowVisible(self.cleanup_codex_model,
|
||||
provider == "codex")
|
||||
self.cleanup_form.setRowVisible(self.cleanup_agy_model,
|
||||
provider == "agy")
|
||||
self.cleanup_form.setRowVisible(self.cleanup_reasoning,
|
||||
provider != "local")
|
||||
self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
|
||||
@@ -2282,6 +2468,8 @@ class SettingsWindow(QDialog):
|
||||
found = shutil.which(binary) if binary else ""
|
||||
if provider == "local":
|
||||
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 not binary:
|
||||
self.models_label.setText(t("Runs on OpenRouter."))
|
||||
elif found:
|
||||
@@ -2298,6 +2486,7 @@ class SettingsWindow(QDialog):
|
||||
self.claude_box.setVisible(provider == "claude")
|
||||
self.codex_box.setVisible(provider == "codex")
|
||||
self.openrouter_box.setVisible(provider == "openrouter")
|
||||
self.agy_box.setVisible(provider == "agy")
|
||||
self._refresh_assistant_status()
|
||||
|
||||
def _refresh_assistant_status(self):
|
||||
@@ -2426,7 +2615,11 @@ class SettingsWindow(QDialog):
|
||||
# The text of an answer says nothing about what was asked, and
|
||||
# out of that context half of them read like non sequiturs.
|
||||
asked = (row.get("question") or row.get("raw") or "").replace("\n", " ")
|
||||
header += t(" · asked Claude: {question}",
|
||||
# Rows written before the provider was recorded are all Claude's,
|
||||
# because it was the only one the history could name.
|
||||
who = assistant.SERVICES.get(row.get("assistant"), "Claude")
|
||||
header += t(" · asked {who}: {question}",
|
||||
who=i18n.name(who, "dative"),
|
||||
question=asked[:60] + ("…" if len(asked) > 60 else ""))
|
||||
item = QListWidgetItem(f"{header}\n{preview}")
|
||||
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||
|
||||
+2
-1
@@ -181,7 +181,8 @@ class Pipeline(QObject):
|
||||
"cleanup_error": warning,
|
||||
"mode": "ask" if ask else "",
|
||||
"question": question,
|
||||
"assistant_model": conf["assistant_model"] if ask else "",
|
||||
"assistant": assistant.provider(conf) if ask else "",
|
||||
"assistant_model": assistant.model(conf) if ask else "",
|
||||
"raw": raw,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
@@ -119,9 +119,22 @@ class ExtractError(unittest.TestCase):
|
||||
body = json.dumps({"error": {"code": 42}})
|
||||
self.assertIn("42", api._extract_error(body))
|
||||
|
||||
def test_an_error_wrapped_in_an_array(self):
|
||||
"""Google's 503 arrives this way, and .get() on a list raises."""
|
||||
body = json.dumps([{"error": {"code": 503,
|
||||
"message": "The model is overloaded."}}])
|
||||
self.assertEqual(api._extract_error(body), "The model is overloaded.")
|
||||
|
||||
def test_a_body_that_is_not_json(self):
|
||||
self.assertEqual(api._extract_error("<html>502</html>"), "<html>502</html>")
|
||||
|
||||
def test_no_shape_at_all_still_comes_back_as_a_string(self):
|
||||
"""It runs while an ApiError is being raised: throwing here would
|
||||
escape the `except ApiError` holding the raw transcript."""
|
||||
for body in ("[]", "[1, 2]", '"a string"', "null", "17"):
|
||||
with self.subTest(body=body):
|
||||
self.assertIsInstance(api._extract_error(body), str)
|
||||
|
||||
def test_a_wall_of_html_is_cut_short(self):
|
||||
self.assertEqual(len(api._extract_error("x" * 5000)), 300)
|
||||
|
||||
@@ -384,6 +397,37 @@ class Cleanup(DikteTest):
|
||||
self.assertEqual(sent_json(calls[0])["reasoning"],
|
||||
{"effort": "high", "exclude": True})
|
||||
|
||||
def test_gemini_takes_openai_s_flat_field_rather_than_the_object(self):
|
||||
_, calls = self.call(chat_reply("Hello."), reasoning="low",
|
||||
provider="gemini", service="Google AI Studio")
|
||||
payload = sent_json(calls[0])
|
||||
self.assertEqual(payload["reasoning_effort"], "low")
|
||||
self.assertNotIn("reasoning", payload)
|
||||
|
||||
def test_off_is_asked_for_as_the_lowest_rung_google_actually_has(self):
|
||||
"""Sending "none" is a 400, and Flash left alone thinks."""
|
||||
_, calls = self.call(chat_reply("Hello."), reasoning="none",
|
||||
provider="gemini", service="Google AI Studio")
|
||||
self.assertEqual(sent_json(calls[0])["reasoning_effort"], "minimal")
|
||||
|
||||
def test_a_rung_google_does_not_have_lands_on_the_nearest_one(self):
|
||||
for asked in ("xhigh", "max"):
|
||||
with self.subTest(asked=asked):
|
||||
_, calls = self.call(chat_reply("Hello."), reasoning=asked,
|
||||
provider="gemini", service="Google AI Studio")
|
||||
self.assertEqual(sent_json(calls[0])["reasoning_effort"], "high")
|
||||
|
||||
def test_gemini_left_on_the_model_s_own_default_is_told_nothing(self):
|
||||
_, calls = self.call(chat_reply("Hello."), provider="gemini",
|
||||
service="Google AI Studio")
|
||||
self.assertNotIn("reasoning_effort", sent_json(calls[0]))
|
||||
|
||||
def test_a_missing_gemini_key_says_google_ai_studio(self):
|
||||
with self.assertRaises(api.ApiError) as caught:
|
||||
api.cleanup("hello", "", "gemini-3.5-flash-lite", "prompt",
|
||||
provider="gemini", service="Google AI Studio")
|
||||
self.assertIn("Google AI Studio", str(caught.exception))
|
||||
|
||||
def test_a_local_base_url(self):
|
||||
_, calls = self.call(chat_reply("Hello."), base_url="http://localhost:1234/v1")
|
||||
self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions")
|
||||
@@ -513,6 +557,40 @@ class ModelLists(DikteTest):
|
||||
api.openai_models("", api.GROQ_URL, "Groq")
|
||||
self.assertIn("Groq", str(caught.exception))
|
||||
|
||||
def test_gemini_keeps_only_the_models_that_answer_a_chat_request(self):
|
||||
with fake_urlopen({"data": [{"id": "gemini-3.5-flash"},
|
||||
{"id": "text-embedding-004"},
|
||||
{"id": "imagen-4.0"},
|
||||
{"id": "gemini-2.5-flash-lite"}]}) as calls:
|
||||
models = api.gemini_models("AIza-test")
|
||||
self.assertEqual(calls[0].full_url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/models")
|
||||
self.assertEqual(models, ["gemini-2.5-flash-lite", "gemini-3.5-flash"])
|
||||
|
||||
def test_the_long_form_of_an_id_is_shortened_to_what_a_request_wants(self):
|
||||
with fake_urlopen({"data": [{"id": "models/gemini-3.5-flash-lite"}]}):
|
||||
self.assertEqual(api.gemini_models("AIza-test"),
|
||||
["gemini-3.5-flash-lite"])
|
||||
|
||||
def test_a_gemini_id_that_is_not_a_chat_model_is_left_out(self):
|
||||
"""Google names its pictures and its voices `gemini` too."""
|
||||
with fake_urlopen({"data": [{"id": "gemini-3.5-flash"},
|
||||
{"id": "gemini-embedding-001"},
|
||||
{"id": "gemini-2.5-flash-image"},
|
||||
{"id": "gemini-2.5-flash-preview-tts"},
|
||||
{"id": "gemini-2.5-native-audio"}]}):
|
||||
self.assertEqual(api.gemini_models("AIza-test"), ["gemini-3.5-flash"])
|
||||
|
||||
def test_gemini_sends_the_key_as_a_bearer_token(self):
|
||||
with fake_urlopen({"data": []}) as calls:
|
||||
api.gemini_models("AIza-test")
|
||||
self.assertEqual(calls[0].get_header("Authorization"), "Bearer AIza-test")
|
||||
|
||||
def test_a_missing_gemini_key_says_google_ai_studio(self):
|
||||
with self.assertRaises(api.ApiError) as caught:
|
||||
api.gemini_models("")
|
||||
self.assertIn("Google AI Studio", str(caught.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+199
-23
@@ -97,15 +97,36 @@ class Provider(DikteTest):
|
||||
def test_what_each_one_runs(self):
|
||||
self.assertEqual(assistant.executable("claude"), "claude")
|
||||
self.assertEqual(assistant.executable("codex"), "codex")
|
||||
self.assertEqual(assistant.executable("agy"), "agy")
|
||||
self.assertEqual(assistant.executable("openrouter"), "")
|
||||
|
||||
def test_the_model_recorded_is_the_one_that_answered(self):
|
||||
"""The history used to write Claude's setting whoever had answered."""
|
||||
self.assertEqual(assistant.model(self.config()), "sonnet")
|
||||
self.assertEqual(
|
||||
assistant.model(self.config(assistant_provider="codex")), "codex")
|
||||
self.assertEqual(
|
||||
assistant.model(self.config(assistant_provider="agy")), "agy")
|
||||
self.assertEqual(
|
||||
assistant.model(self.config(assistant_provider="agy",
|
||||
assistant_agy_model="gemini-3.1-pro-low")),
|
||||
"gemini-3.1-pro-low")
|
||||
self.assertEqual(
|
||||
assistant.model(self.config(assistant_provider="openrouter")),
|
||||
"google/gemini-3.5-flash")
|
||||
|
||||
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")):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(
|
||||
assistant.display_name(self.config(assistant_provider="codex")), "Codex")
|
||||
self.assertEqual(
|
||||
assistant.display_name(self.config(assistant_provider="openrouter")),
|
||||
"OpenRouter")
|
||||
assistant.display_name(self.config(assistant_provider=name)),
|
||||
called)
|
||||
|
||||
def test_every_provider_has_a_name_to_be_called_by(self):
|
||||
"""_conclude writes its errors in it, so a gap here is a bare id."""
|
||||
self.assertEqual(set(assistant.SERVICES), set(assistant.PROVIDERS))
|
||||
|
||||
|
||||
class Effort(unittest.TestCase):
|
||||
@@ -113,21 +134,26 @@ class Effort(unittest.TestCase):
|
||||
|
||||
def test_the_scales_cover_the_same_settings(self):
|
||||
self.assertEqual(set(assistant.CLAUDE_EFFORT), set(assistant.CODEX_EFFORT))
|
||||
self.assertEqual(set(assistant.CLAUDE_EFFORT), set(assistant.AGY_EFFORT))
|
||||
|
||||
def test_codex_has_no_rung_above_high(self):
|
||||
self.assertEqual(assistant.CODEX_EFFORT["xhigh"], "high")
|
||||
self.assertEqual(assistant.CODEX_EFFORT["max"], "high")
|
||||
def test_neither_codex_nor_agy_has_a_rung_above_high(self):
|
||||
for scale in (assistant.CODEX_EFFORT, assistant.AGY_EFFORT):
|
||||
self.assertEqual(scale["xhigh"], "high")
|
||||
self.assertEqual(scale["max"], "high")
|
||||
|
||||
def test_neither_one_asks_for_a_rung_below_low(self):
|
||||
def test_none_of_them_asks_for_a_rung_below_low(self):
|
||||
# Claude has none; Codex has one, but calls it "minimal" on the older
|
||||
# models and "none" on the newer ones, and refuses the wrong word.
|
||||
for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT):
|
||||
# models and "none" on the newer ones, and refuses the wrong word; agy
|
||||
# has three rungs and no word for off at all.
|
||||
for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT,
|
||||
assistant.AGY_EFFORT):
|
||||
self.assertEqual(scale["none"], "low")
|
||||
self.assertEqual(scale["minimal"], "low")
|
||||
|
||||
def test_an_empty_setting_asks_for_nothing(self):
|
||||
self.assertEqual(assistant.CLAUDE_EFFORT.get("", ""), "")
|
||||
self.assertEqual(assistant.CODEX_EFFORT.get("", ""), "")
|
||||
for scale in (assistant.CLAUDE_EFFORT, assistant.CODEX_EFFORT,
|
||||
assistant.AGY_EFFORT):
|
||||
self.assertEqual(scale.get("", ""), "")
|
||||
|
||||
|
||||
class Session(DikteTest):
|
||||
@@ -276,25 +302,32 @@ class Conclude(DikteTest):
|
||||
|
||||
def test_an_answer_and_its_session(self):
|
||||
answer, warning = assistant._conclude(
|
||||
self.found(answer="done", session="abc"), 0, "", "", "Claude")
|
||||
self.found(answer="done", session="abc"), 0, "", "", "claude")
|
||||
self.assertEqual(answer, "done")
|
||||
self.assertEqual(warning, "")
|
||||
self.assertEqual(assistant.read_session("claude", 1800), "abc")
|
||||
|
||||
def test_codex_stores_under_its_own_name(self):
|
||||
assistant._conclude(self.found(answer="done", session="t-1"), 0, "",
|
||||
"", "Codex")
|
||||
self.assertEqual(assistant.read_session("codex", 1800), "t-1")
|
||||
def test_each_one_stores_under_its_own_name(self):
|
||||
for name, session in (("codex", "t-1"), ("agy", "c-9")):
|
||||
with self.subTest(name=name):
|
||||
assistant._conclude(self.found(answer="done", session=session),
|
||||
0, "", "", name)
|
||||
self.assertEqual(assistant.read_session(name, 1800), session)
|
||||
|
||||
def test_the_error_is_written_in_the_provider_s_own_name(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(), 1, "", "", "agy")
|
||||
self.assertIn("Antigravity", str(caught.exception))
|
||||
|
||||
def test_a_non_zero_exit_with_nothing_to_show_for_it(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(), 1, "it all went wrong\n", "", "Claude")
|
||||
assistant._conclude(self.found(), 1, "it all went wrong\n", "", "claude")
|
||||
self.assertIn("it all went wrong", str(caught.exception))
|
||||
|
||||
def test_a_session_that_is_gone_is_raised_apart(self):
|
||||
with self.assertRaises(assistant._SessionGone):
|
||||
assistant._conclude(self.found(), 1, "session abc not found",
|
||||
"abc", "Claude")
|
||||
"abc", "claude")
|
||||
|
||||
def test_the_recovery_no_longer_hangs_on_the_words_the_cli_chose(self):
|
||||
# The complaint used to be matched by substring, which a CLI update or
|
||||
@@ -321,11 +354,11 @@ class Conclude(DikteTest):
|
||||
def test_a_session_that_is_gone_only_matters_when_one_was_resumed(self):
|
||||
with self.assertRaises(assistant.AssistantError):
|
||||
assistant._conclude(self.found(), 1, "session abc not found",
|
||||
"", "Claude")
|
||||
"", "claude")
|
||||
|
||||
def test_an_answer_survives_a_non_zero_exit(self):
|
||||
answer, _ = assistant._conclude(self.found(answer="done"), 1, "noise",
|
||||
"", "Claude")
|
||||
"", "claude")
|
||||
self.assertEqual(answer, "done")
|
||||
|
||||
def test_an_answer_on_a_resumed_session_is_kept_rather_than_retried(self):
|
||||
@@ -336,12 +369,12 @@ class Conclude(DikteTest):
|
||||
def test_a_reported_failure_with_no_answer(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(failure="the model refused"), 0, "",
|
||||
"", "Claude")
|
||||
"", "claude")
|
||||
self.assertIn("refused", str(caught.exception))
|
||||
|
||||
def test_a_run_that_said_nothing_at_all(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(), 0, "", "", "Codex")
|
||||
assistant._conclude(self.found(), 0, "", "", "codex")
|
||||
self.assertIn("Codex", str(caught.exception))
|
||||
|
||||
|
||||
@@ -565,6 +598,109 @@ class AskCodex(DikteTest):
|
||||
self.assertIn("quota", str(caught.exception))
|
||||
|
||||
|
||||
class AskAgy(DikteTest):
|
||||
"""agy's stream is shaped nothing like the other two: the key is `event`,
|
||||
the answer arrives whole in `result.response`, and the conversation to
|
||||
resume is named in the first line rather than the last."""
|
||||
|
||||
def run_ask(self, conf=None, events=None, session=""):
|
||||
conf = conf or self.config(assistant_provider="agy")
|
||||
proc = FakeCli(events or [
|
||||
{"event": "init", "conversation_id": "c-9", "init": {"cwd": "/home"}},
|
||||
{"event": "result",
|
||||
"result": {"conversation_id": "c-9", "status": "SUCCESS",
|
||||
"response": " done "}},
|
||||
])
|
||||
stages = []
|
||||
with only_these_tools("agy"), \
|
||||
mock.patch.object(subprocess, "Popen", return_value=proc) as popen:
|
||||
result = assistant._ask_agy("book it", conf, session,
|
||||
stages.append, None)
|
||||
return result, popen.call_args.args[0], stages
|
||||
|
||||
def test_the_answer_comes_back_stripped(self):
|
||||
(answer, warning), _, _ = self.run_ask()
|
||||
self.assertEqual(answer, "done")
|
||||
self.assertEqual(warning, "")
|
||||
|
||||
def test_the_instruction_is_kept_apart_from_the_command(self):
|
||||
"""agy takes no system prompt, so the two must not read as one."""
|
||||
conf = self.config(assistant_provider="agy")
|
||||
_, cmd, _ = self.run_ask(conf)
|
||||
body = cmd[cmd.index("-p") + 1]
|
||||
self.assertTrue(body.startswith(conf.assistant_prompt()))
|
||||
self.assertIn("\n\n---\n\n", body)
|
||||
self.assertTrue(body.endswith("book it"))
|
||||
|
||||
def test_a_first_command_starts_a_project_of_its_own(self):
|
||||
"""Without it agy works in whichever project it was last in."""
|
||||
_, cmd, _ = self.run_ask()
|
||||
self.assertIn("--new-project", cmd)
|
||||
self.assertNotIn("--conversation", cmd)
|
||||
|
||||
def test_a_second_command_carries_the_conversation_rather_than_starting_one(self):
|
||||
_, cmd, _ = self.run_ask(session="c-9")
|
||||
self.assertEqual(cmd[cmd.index("--conversation") + 1], "c-9")
|
||||
self.assertNotIn("--new-project", cmd)
|
||||
|
||||
def test_the_conversation_is_kept_under_agy_s_own_name(self):
|
||||
self.run_ask()
|
||||
self.assertEqual(assistant.read_session("agy", 1800), "c-9")
|
||||
|
||||
def test_it_is_not_left_to_give_up_before_the_caller_does(self):
|
||||
conf = self.config(assistant_provider="agy", assistant_timeout=90)
|
||||
_, cmd, _ = self.run_ask(conf)
|
||||
self.assertEqual(cmd[cmd.index("--print-timeout") + 1], "90s")
|
||||
|
||||
def test_no_model_named_means_whatever_agy_is_set_to(self):
|
||||
_, cmd, _ = self.run_ask()
|
||||
self.assertNotIn("--model", cmd)
|
||||
|
||||
def test_a_model_of_your_own(self):
|
||||
_, cmd, _ = self.run_ask(
|
||||
self.config(assistant_provider="agy",
|
||||
assistant_agy_model="gemini-3.1-pro-low"))
|
||||
self.assertEqual(cmd[cmd.index("--model") + 1], "gemini-3.1-pro-low")
|
||||
|
||||
def test_a_tool_is_named_in_the_corner_as_it_starts(self):
|
||||
_, _, stages = self.run_ask(events=[
|
||||
{"event": "step_update",
|
||||
"step_update": {"step_type": "tool", "state": "ACTIVE",
|
||||
"tool_name": "run_command"}},
|
||||
{"event": "step_update",
|
||||
"step_update": {"step_type": "tool", "state": "DONE",
|
||||
"tool_name": "run_command"}},
|
||||
{"event": "result",
|
||||
"result": {"status": "SUCCESS", "response": "done"}},
|
||||
])
|
||||
self.assertEqual(stages, ["Running a command…"])
|
||||
|
||||
def test_the_two_dozen_browser_tools_are_one_line_between_them(self):
|
||||
_, _, stages = self.run_ask(events=[
|
||||
{"event": "step_update",
|
||||
"step_update": {"step_type": "tool", "state": "ACTIVE",
|
||||
"tool_name": "browser_click_element"}},
|
||||
{"event": "result",
|
||||
"result": {"status": "SUCCESS", "response": "done"}},
|
||||
])
|
||||
self.assertEqual(stages, ["Working in the browser…"])
|
||||
|
||||
def test_a_turn_that_did_not_succeed_is_a_failure_rather_than_an_answer(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
self.run_ask(events=[
|
||||
{"event": "result",
|
||||
"result": {"status": "ERROR", "response": "the model refused"}},
|
||||
])
|
||||
self.assertIn("refused", str(caught.exception))
|
||||
|
||||
def test_a_failure_with_nothing_to_say_is_still_named(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
self.run_ask(events=[
|
||||
{"event": "result", "result": {"status": "ERROR"}},
|
||||
])
|
||||
self.assertIn("Antigravity", str(caught.exception))
|
||||
|
||||
|
||||
class AskOpenRouter(DikteTest):
|
||||
def test_a_question_and_an_answer(self):
|
||||
conf = self.config(assistant_provider="openrouter",
|
||||
@@ -710,5 +846,45 @@ class CodexModels(DikteTest):
|
||||
self.assertEqual(assistant.codex_models(), [])
|
||||
|
||||
|
||||
class AgyModels(DikteTest):
|
||||
"""The model list read off `agy models`: one id, a tab, a display name."""
|
||||
|
||||
LISTING = ("gemini-4-flash-high\tGemini 4 Flash (High)\n"
|
||||
"gemini-4-flash-low\tGemini 4 Flash (Low)\n"
|
||||
"a line with no tab is not a model\n"
|
||||
"\ta tab with no id in front of it is not one either\n")
|
||||
|
||||
def models(self, reply, code=0):
|
||||
with only_these_tools("agy"), \
|
||||
mock.patch.object(subprocess, "run",
|
||||
return_value=FakeCompleted(
|
||||
returncode=code, stdout=reply)) as run:
|
||||
found = assistant.agy_models()
|
||||
self.run_call = run
|
||||
return found
|
||||
|
||||
def test_the_listing_arrives_in_agy_s_own_order(self):
|
||||
found = self.models(self.LISTING)
|
||||
self.assertEqual(found, ["gemini-4-flash-high", "gemini-4-flash-low"])
|
||||
self.assertEqual(self.run_call.call_args.args[0], ["agy", "models"])
|
||||
|
||||
def test_an_agy_that_is_not_installed_is_not_run(self):
|
||||
with only_these_tools(), \
|
||||
mock.patch.object(subprocess, "run") as run:
|
||||
self.assertEqual(assistant.agy_models(), [])
|
||||
run.assert_not_called()
|
||||
|
||||
def test_a_call_that_failed_answers_with_nothing(self):
|
||||
self.assertEqual(self.models("error: not logged in", code=1), [])
|
||||
self.assertEqual(self.models(""), [])
|
||||
|
||||
def test_an_agy_that_hangs_is_given_up_on(self):
|
||||
with only_these_tools("agy"), \
|
||||
mock.patch.object(subprocess, "run",
|
||||
side_effect=subprocess.TimeoutExpired(
|
||||
["agy"], 30)):
|
||||
self.assertEqual(assistant.agy_models(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -57,7 +57,9 @@ class Provider(DikteTest):
|
||||
def test_what_each_one_runs(self):
|
||||
self.assertEqual(cleanup.executable("claude"), "claude")
|
||||
self.assertEqual(cleanup.executable("codex"), "codex")
|
||||
self.assertEqual(cleanup.executable("agy"), "agy")
|
||||
self.assertEqual(cleanup.executable("openrouter"), "")
|
||||
self.assertEqual(cleanup.executable("gemini"), "")
|
||||
|
||||
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")),
|
||||
@@ -73,6 +75,16 @@ class Provider(DikteTest):
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="codex",
|
||||
cleanup_codex_model="gpt-5.4")), "gpt-5.4")
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="gemini")),
|
||||
"gemini-3.5-flash-lite")
|
||||
# Antigravity is left on its own default the way Codex is.
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="agy")), "agy")
|
||||
self.assertEqual(
|
||||
cleanup.model(self.config(cleanup_provider="agy",
|
||||
cleanup_agy_model="gemini-3.7-flash-low")),
|
||||
"gemini-3.7-flash-low")
|
||||
|
||||
|
||||
class OpenRouter(DikteTest):
|
||||
@@ -94,6 +106,46 @@ class OpenRouter(DikteTest):
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
class GoogleAiStudio(DikteTest):
|
||||
"""Cleanup over Google's OpenAI-compatible endpoint: one request, no CLI."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.conf = self.config(cleanup_provider="gemini",
|
||||
gemini_api_key="AIza-test")
|
||||
|
||||
def test_it_goes_to_google_with_the_settings_as_they_were(self):
|
||||
self.conf["cleanup_reasoning"] = "none"
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
self.assertEqual(cleanup.run("uh, done", self.conf, "the rules"),
|
||||
"Done.")
|
||||
self.assertEqual(
|
||||
calls[0].full_url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions")
|
||||
payload = sent_json(calls[0])
|
||||
self.assertEqual(payload["model"], "gemini-3.5-flash-lite")
|
||||
self.assertEqual(payload["reasoning_effort"], "minimal")
|
||||
self.assertIn("uh, done", payload["messages"][1]["content"])
|
||||
|
||||
def test_the_key_travels_as_a_bearer_token(self):
|
||||
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(calls[0].get_header("Authorization"), "Bearer AIza-test")
|
||||
|
||||
def test_a_missing_key_names_google_rather_than_openrouter(self):
|
||||
self.conf["gemini_api_key"] = ""
|
||||
with mock.patch.dict(os.environ, {}, clear=True), \
|
||||
self.assertRaises(api.ApiError) as caught:
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertIn("Google AI Studio", str(caught.exception))
|
||||
|
||||
def test_no_cli_is_started_for_it(self):
|
||||
patcher, calls = fake_cli(stdout="never")
|
||||
with patcher, fake_urlopen(chat_reply("Done.")):
|
||||
cleanup.run("uh, done", self.conf, "the rules")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
class ClaudeCode(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -221,6 +273,63 @@ class Codex(DikteTest):
|
||||
self.run_cleanup(stdout="tokens used 400", last_message="")
|
||||
|
||||
|
||||
class Antigravity(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.conf = self.config(cleanup_provider="agy")
|
||||
self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||
|
||||
def run_cleanup(self, text="uh, book it", **kwargs):
|
||||
patcher, calls = fake_cli(**kwargs)
|
||||
with patcher:
|
||||
answer = cleanup.run(text, self.conf, "the rules")
|
||||
return answer, calls[0]
|
||||
|
||||
def test_the_rules_ride_in_front_of_the_transcript(self):
|
||||
answer, cmd = self.run_cleanup(stdout="Book it.\n")
|
||||
self.assertEqual(answer, "Book it.")
|
||||
self.assertEqual(cmd[0], "agy")
|
||||
self.assertEqual(cmd[cmd.index("-p") + 1],
|
||||
"the rules\n\n---\n\n<transcript>\nuh, book it\n</transcript>")
|
||||
|
||||
def test_it_starts_somewhere_of_its_own_and_takes_no_slash_commands(self):
|
||||
"""Without --new-project agy works in whichever project it was last in."""
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertIn("--new-project", cmd)
|
||||
self.assertIn("--disable-slash-commands", cmd)
|
||||
self.assertEqual(cmd[cmd.index("--output-format") + 1], "text")
|
||||
|
||||
def test_it_is_not_left_to_give_up_before_the_caller_does(self):
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertEqual(cmd[cmd.index("--print-timeout") + 1], "180s")
|
||||
|
||||
def test_the_model_is_left_alone_until_one_is_typed_in(self):
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertNotIn("--model", cmd)
|
||||
self.conf["cleanup_agy_model"] = "gemini-3.7-flash-low"
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertEqual(cmd[cmd.index("--model") + 1], "gemini-3.7-flash-low")
|
||||
|
||||
def test_the_thinking_setting_lands_on_the_nearest_rung_agy_has(self):
|
||||
self.conf["cleanup_reasoning"] = "max"
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertEqual(cmd[cmd.index("--effort") + 1], "high")
|
||||
|
||||
def test_no_thinking_setting_means_no_flag(self):
|
||||
_, cmd = self.run_cleanup(stdout="Book it.")
|
||||
self.assertNotIn("--effort", cmd)
|
||||
|
||||
def test_an_answer_of_nothing_is_a_failure_rather_than_an_empty_paste(self):
|
||||
with self.assertRaises(cleanup.CleanupError):
|
||||
self.run_cleanup(stdout=" ")
|
||||
|
||||
def test_a_program_that_is_not_installed_says_so_before_running_anything(self):
|
||||
self.patch_attr(cleanup.shutil, "which", lambda name: "")
|
||||
with self.assertRaises(cleanup.CleanupError) as caught:
|
||||
self.run_cleanup(stdout="Book it.")
|
||||
self.assertIn("agy", str(caught.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
from dikte import audio
|
||||
from dikte import cleanup
|
||||
from dikte import cli
|
||||
from dikte import config as cfg
|
||||
from dikte import ggml
|
||||
@@ -503,6 +504,26 @@ class Doctor(DikteTest):
|
||||
self.assertIn("OpenRouter key, cleaning up on some/model",
|
||||
self.run_doctor(as_json=False, cleanup_model="some/model"))
|
||||
|
||||
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:
|
||||
with self.subTest(provider=name):
|
||||
reply = self.run_doctor(cleanup_provider=name)
|
||||
self.assertEqual(reply["cleanup"]["provider"], name)
|
||||
self.run_doctor(as_json=False, cleanup_provider=name)
|
||||
|
||||
def test_a_provider_with_no_key_to_check_says_so_rather_than_no(self):
|
||||
"""A CLI needs none, so `false` there would read as one gone missing."""
|
||||
self.assertIsNone(self.run_doctor(cleanup_provider="claude")["cleanup"]["key"])
|
||||
self.assertIsNone(self.run_doctor(cleanup_provider="local")["cleanup"]["key"])
|
||||
self.assertIs(self.run_doctor(cleanup_provider="gemini")["cleanup"]["key"],
|
||||
False)
|
||||
|
||||
def test_cleanup_on_google_is_a_question_about_its_own_key(self):
|
||||
line = self.run_doctor(as_json=False, cleanup_provider="gemini",
|
||||
cleanup_gemini_model="gemini-2.5-flash")
|
||||
self.assertIn("Google AI Studio key, cleaning up on gemini-2.5-flash", line)
|
||||
|
||||
def test_it_asks_after_the_programs_this_desktop_actually_uses(self):
|
||||
"""A missing ydotool on a Mac is a red mark with nothing behind it."""
|
||||
with mock.patch.object(cli.paste, "desktop", return_value=paste.MACOS):
|
||||
|
||||
@@ -187,6 +187,8 @@ class Keys(DikteTest):
|
||||
def test_every_provider_falls_back_to_the_variable_of_its_own_name(self):
|
||||
with mock.patch.dict(os.environ, {"GROQ_API_KEY": "gsk-env"}):
|
||||
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")
|
||||
|
||||
|
||||
class TranscribeTarget(DikteTest):
|
||||
@@ -574,6 +576,12 @@ class Defaults(unittest.TestCase):
|
||||
def test_the_keys_ship_empty(self):
|
||||
self.assertEqual(cfg.DEFAULTS["openai_api_key"], "")
|
||||
self.assertEqual(cfg.DEFAULTS["openrouter_api_key"], "")
|
||||
self.assertEqual(cfg.DEFAULTS["gemini_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_every_language_specific_prompt_has_both_languages(self):
|
||||
for name in ("CLEANUP_PROMPT", "FILE_CLEANUP_PROMPT", "MEETING_PROMPT",
|
||||
|
||||
+95
-1
@@ -28,6 +28,11 @@ from dikte import settings_ui
|
||||
from dikte import update
|
||||
from tests.support import DikteTest, only_these_tools
|
||||
|
||||
# The harness below replaces this method on the class so that opening a window
|
||||
# in a test never calls anybody; taken here, before any test runs, so the two
|
||||
# tests about what it does when called still have the real one.
|
||||
REAL_LOAD_HOSTED_MODELS = settings_ui.SettingsWindow._load_hosted_models
|
||||
|
||||
# One application for the whole run; Qt allows no second one.
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
|
||||
@@ -51,6 +56,7 @@ CHANGED = {
|
||||
"openai_api_key": "sk-test-key",
|
||||
"groq_api_key": "gsk-test-key",
|
||||
"openrouter_api_key": "sk-or-test-key",
|
||||
"gemini_api_key": "AIza-test-key",
|
||||
"transcribe_provider": "openrouter",
|
||||
"transcribe_model": "whisper-1",
|
||||
"groq_transcribe_model": "whisper-large-v3",
|
||||
@@ -60,6 +66,8 @@ CHANGED = {
|
||||
"cleanup_model": "some/other-model",
|
||||
"cleanup_claude_model": "opus",
|
||||
"cleanup_codex_model": "gpt-5",
|
||||
"cleanup_gemini_model": "gemini-2.5-flash",
|
||||
"cleanup_agy_model": "gemini-3.1-pro-low",
|
||||
"cleanup_reasoning": "high",
|
||||
"local_model": "ggml-small.bin",
|
||||
"local_gpu": False,
|
||||
@@ -79,6 +87,7 @@ CHANGED = {
|
||||
"assistant_codex_model": "gpt-5",
|
||||
"assistant_codex_sandbox": "read-only",
|
||||
"assistant_openrouter_model": "some/agent-model",
|
||||
"assistant_agy_model": "gemini-3.1-pro-low",
|
||||
"assistant_reasoning": "high",
|
||||
"assistant_dir": "/tmp",
|
||||
"assistant_timeout": 600,
|
||||
@@ -136,6 +145,10 @@ class Settings(DikteTest):
|
||||
"_load_transcribe_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_codex_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_agy_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_hosted_models"))
|
||||
# The local model boxes fetch their own list the moment they are shown,
|
||||
# from a thread, which is nobody's test failing but a real request.
|
||||
self.enterContext(mock.patch.object(settings_ui.LocalModelBox,
|
||||
@@ -285,6 +298,53 @@ class Settings(DikteTest):
|
||||
self.assertEqual(window.cleanup_codex_model.currentText(),
|
||||
"my-own-model")
|
||||
|
||||
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")
|
||||
window = self.window(conf)
|
||||
window._on_agy_models_loaded(["gemini-4-flash-low", "gemini-4-pro-low"])
|
||||
for combo in (window.cleanup_agy_model, window.assistant_agy_model):
|
||||
with self.subTest(combo=combo.objectName() or "combo"):
|
||||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||
self.assertEqual(offered[1:],
|
||||
["gemini-4-flash-low", "gemini-4-pro-low"])
|
||||
self.assertEqual(window.cleanup_agy_model.currentText(), "my-own-model")
|
||||
|
||||
def test_openrouter_s_list_arriving_at_open_refills_cleanup_and_meetings(self):
|
||||
conf = self.config(cleanup_model="my/own-model")
|
||||
window = self.window(conf)
|
||||
window._on_hosted_models_loaded("openrouter", ["a/one", "b/two"])
|
||||
for combo in (window.cleanup_model, window.meeting_model):
|
||||
with self.subTest(combo=combo.objectName() or "combo"):
|
||||
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||
self.assertEqual(offered, ["a/one", "b/two"])
|
||||
self.assertEqual(window.cleanup_model.currentText(), "my/own-model")
|
||||
|
||||
def test_google_s_list_arriving_at_open_refills_its_own_box_only(self):
|
||||
window = self.window(self.config(cleanup_gemini_model="gemini-x"))
|
||||
before = window.cleanup_model.count()
|
||||
window._on_hosted_models_loaded("gemini", ["gemini-4-flash"])
|
||||
offered = [window.cleanup_gemini_model.itemText(i)
|
||||
for i in range(window.cleanup_gemini_model.count())]
|
||||
self.assertEqual(offered, ["gemini-4-flash"])
|
||||
self.assertEqual(window.cleanup_gemini_model.currentText(), "gemini-x")
|
||||
self.assertEqual(window.cleanup_model.count(), before)
|
||||
|
||||
def test_no_key_no_call_home_at_open(self):
|
||||
"""Opening Settings is not consent to be talked about to two vendors."""
|
||||
window = self.window(self.config())
|
||||
with mock.patch.dict(os.environ, {}, clear=True), \
|
||||
mock.patch.object(settings_ui.threading, "Thread") as thread:
|
||||
REAL_LOAD_HOSTED_MODELS(window)
|
||||
thread.assert_not_called()
|
||||
|
||||
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"))
|
||||
with mock.patch.object(settings_ui.threading, "Thread") as thread:
|
||||
REAL_LOAD_HOSTED_MODELS(window)
|
||||
self.assertEqual(thread.call_count, 2)
|
||||
|
||||
def test_the_update_line_names_the_version_that_is_running(self):
|
||||
window = self.window(cfg.Config())
|
||||
self.assertIn(settings_ui.__version__, window.update_status.text())
|
||||
@@ -1013,7 +1073,11 @@ class MeetingSources(DikteTest):
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_transcribe_models"), \
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_codex_models"):
|
||||
"_load_codex_models"), \
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_agy_models"), \
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_hosted_models"):
|
||||
window = settings_ui.SettingsWindow(cfg.Config())
|
||||
self.addCleanup(window.deleteLater)
|
||||
self.addCleanup(window.close)
|
||||
@@ -1045,6 +1109,10 @@ class LocalModels(DikteTest):
|
||||
# And one with Codex on it would ask it for its model list.
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_codex_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_agy_models"))
|
||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_hosted_models"))
|
||||
|
||||
def window(self, conf):
|
||||
window = settings_ui.SettingsWindow(conf)
|
||||
@@ -1125,3 +1193,29 @@ class LocalModels(DikteTest):
|
||||
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_model_row))
|
||||
# Its own thinking box, because the two default to opposite things.
|
||||
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
|
||||
|
||||
def test_each_cleaner_brings_its_own_model_row_and_no_other(self):
|
||||
window = self.window(cfg.Config())
|
||||
rows = {"openrouter": window.cleanup_model_row,
|
||||
"gemini": window.cleanup_gemini_model_row,
|
||||
"claude": window.cleanup_claude_model,
|
||||
"codex": window.cleanup_codex_model,
|
||||
"agy": window.cleanup_agy_model}
|
||||
for chosen, row in rows.items():
|
||||
with self.subTest(provider=chosen):
|
||||
window._select_data(window.cleanup_provider, chosen)
|
||||
for name, other in rows.items():
|
||||
self.assertEqual(window.cleanup_form.isRowVisible(other),
|
||||
name == chosen)
|
||||
|
||||
def test_each_agent_brings_its_own_box_and_no_other(self):
|
||||
window = self.window(cfg.Config())
|
||||
boxes = {"claude": window.claude_box, "codex": window.codex_box,
|
||||
"agy": window.agy_box, "openrouter": window.openrouter_box}
|
||||
for chosen, box in boxes.items():
|
||||
with self.subTest(provider=chosen):
|
||||
window._select_data(window.assistant_provider, chosen)
|
||||
for name, other in boxes.items():
|
||||
# isHidden rather than isVisible: the window itself is never
|
||||
# shown in a test, so nothing in it is ever visible.
|
||||
self.assertEqual(other.isHidden(), name != chosen)
|
||||
|
||||
Reference in New Issue
Block a user