Merge pull request #59 from Oztturk/gemini-and-agy-cleanup

Clean up on Google AI Studio, or on Antigravity
This commit is contained in:
Yusuf İpek
2026-08-27 16:14:34 +03:00
committed by GitHub
16 changed files with 1130 additions and 123 deletions
+79 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+213 -20
View File
@@ -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."""
who = cfg.TRANSCRIBERS[provider]
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
View File
@@ -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,
}