Merge master into the OpenCode Go branch

Master grew Google AI Studio and Antigravity as providers, a doctor that
names each provider's own key, and settings that fetch every hosted
model list as the window opens. OpenCode Go is folded into each: its
name joins SERVICES and the doctor's key table, its key row sits beside
Google's, its Fetch button follows the per-provider pattern Google's
uses, and _load_hosted_models fetches its catalog at open when a key is
on file, filling the cleanup and agent boxes alike.
This commit is contained in:
2026-08-27 16:27:57 +03:00
18 changed files with 1242 additions and 231 deletions
+283 -94
View File
@@ -60,13 +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"), ("OpenCode Go", "opencode"),
("This machine (llama.cpp)", "local"),
("Claude Code", "claude"), ("Codex", "codex"),
("OpenRouter", "openrouter"), ("Google AI Studio", "gemini"),
("OpenCode Go", "opencode"), ("This machine (llama.cpp)", "local"),
("Claude Code", "claude"), ("Codex", "codex"), ("Antigravity", "agy"),
]
# Cleaning up a sentence is the lightest thing either of them will ever be
# asked, so the small model comes first.
@@ -78,7 +89,7 @@ MEETING_MODELS = [
"anthropic/claude-sonnet-5", "openai/gpt-5.4", "x-ai/grok-4.5",
]
ASSISTANT_PROVIDERS = [
("Claude Code", "claude"), ("Codex", "codex"),
("Claude Code", "claude"), ("Codex", "codex"), ("Antigravity", "agy"),
("OpenRouter", "openrouter"), ("OpenCode Go", "opencode"),
]
# Aliases resolve to the newest model of that name, so they age better than an
@@ -566,10 +577,14 @@ class SettingsWindow(QDialog):
self._sources = audio.list_sources()
return self._sources
_models_loaded = pyqtSignal(list, str, str)
_models_loaded = pyqtSignal(list, str)
_gemini_models_loaded = pyqtSignal(list, str)
_opencode_models_loaded = pyqtSignal(list, str)
_transcribe_models_loaded = pyqtSignal(list, str)
_codex_models_loaded = pyqtSignal(list)
_opencode_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.
@@ -604,6 +619,7 @@ class SettingsWindow(QDialog):
tabs = self.tabs = QTabWidget(self)
tabs.addTab(self._scrolled(self._general_tab()), t("General"))
tabs.addTab(self._scrolled(self._display_tab()), t("Display"))
self.api_tab_index = tabs.addTab(
self._scrolled(self._api_tab()), t("API and models"))
tabs.addTab(self._scrolled(self._prompt_tab()), t("Cleanup rules"))
@@ -627,9 +643,12 @@ 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._opencode_models_loaded.connect(self._on_opencode_models_loaded)
self._agy_models_loaded.connect(self._on_agy_models_loaded)
self._hosted_models_loaded.connect(self._on_hosted_models_loaded)
self._test_done.connect(self._on_test_done)
self._update_checked.connect(self._on_update_checked)
self.transcriber.progress.connect(self._on_file_progress)
@@ -641,7 +660,8 @@ class SettingsWindow(QDialog):
self.meetings.failed.connect(self._on_minutes_failed)
self._load()
self._load_codex_models()
self._load_opencode_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)
@@ -726,11 +746,6 @@ class SettingsWindow(QDialog):
self.restore_clipboard = QCheckBox(t("Restore the previous clipboard after pasting"))
form.addRow("", self.restore_clipboard)
self.corner = QComboBox()
for value in CORNERS:
self.corner.addItem(t(value), value)
form.addRow(t("Indicator corner"), self.corner)
self.max_seconds = QSpinBox()
self.max_seconds.setRange(10, 3600)
self.max_seconds.setSuffix(t(" s"))
@@ -781,6 +796,31 @@ class SettingsWindow(QDialog):
self.update_now))
return page
def _display_tab(self):
page = QWidget()
form = QFormLayout(page)
self.indicator_screen = QComboBox()
self.indicator_screen.addItem(t("Follow the mouse pointer"), "")
for screen in QGuiApplication.screens():
# The native resolution, so that a scaled 4K screen reads
# 3840 × 2160 and not the 1920 × 1080 Qt sees through the scale.
area = screen.geometry()
ratio = screen.devicePixelRatio()
self.indicator_screen.addItem(
t("{name} ({width} × {height})", name=screen.name(),
width=round(area.width() * ratio),
height=round(area.height() * ratio)),
screen.name(),
)
form.addRow(t("Indicator screen"), self.indicator_screen)
self.corner = QComboBox()
for value in CORNERS:
self.corner.addItem(t(value), value)
form.addRow(t("Indicator corner"), self.corner)
return page
def _api_tab(self):
page = QWidget()
outer = QVBoxLayout(page)
@@ -798,6 +838,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")
self.opencode_key = self._key_row(
keys_form, "opencode", t("(falls back to OPENCODE_API_KEY)"),
self._test_opencode, service="OpenCode Go")
@@ -873,11 +916,11 @@ class SettingsWindow(QDialog):
for label, value in CLEANUP_PROVIDERS:
self.cleanup_provider.addItem(t(label), value)
self.cleanup_provider.setToolTip(t(
"OpenRouter is the quickest; OpenCode Go needs nothing installed "
"either. llama.cpp runs here, on a model downloaded below. Claude "
"Code and Codex clean up on the subscription you already have, "
"without a second key, and take a few seconds longer because each "
"one opens a session to do it."
"OpenRouter, Google AI Studio and OpenCode Go are the quick ones "
"that need nothing installed. llama.cpp runs here, on a model "
"downloaded below. Claude Code, Codex and Antigravity clean up on "
"a subscription you already have, without a second key, and take a "
"few seconds longer because each opens a session to do it."
))
self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed)
orr_form.addRow(t("Runs on"), self.cleanup_provider)
@@ -890,6 +933,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.
@@ -909,14 +961,17 @@ class SettingsWindow(QDialog):
self.cleanup_opencode_model.setEditable(True)
self.cleanup_opencode_model.addItems(OPENCODE_MODELS)
self.cleanup_opencode_model.setToolTip(_typed_model_note("OpenCode Go"))
# Its own button, because a widget lives in one row and the OpenRouter
# button is hidden along with its box whenever OpenCode Go is chosen.
self.refresh_opencode_models = QPushButton(t("Fetch model list"))
self.refresh_opencode_models.clicked.connect(self._load_models)
self.refresh_opencode_models.clicked.connect(self._load_opencode_models)
self.cleanup_opencode_model_row = self._row(self.cleanup_opencode_model,
self.refresh_opencode_models)
orr_form.addRow(t("Model"), self.cleanup_opencode_model_row)
self.cleanup_agy_model = QComboBox()
self.cleanup_agy_model.setEditable(True)
self.cleanup_agy_model.addItems([t("Antigravity's own default")] + AGY_MODELS)
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)
@@ -1000,9 +1055,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)
@@ -1040,7 +1095,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:
@@ -1063,7 +1118,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)
@@ -1114,6 +1169,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)
self.opencode_box = QGroupBox("OpenCode Go")
og_form = QFormLayout(self.opencode_box)
self.assistant_opencode_model = QComboBox()
@@ -1616,8 +1689,8 @@ class SettingsWindow(QDialog):
button.clicked.connect(tester)
answer = QLabel("")
answer.setWordWrap(True)
form.addRow(service or 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)
@@ -1679,6 +1752,10 @@ class SettingsWindow(QDialog):
self.auto_paste.setChecked(conf["auto_paste"])
self.paste_shortcut.setCurrentText(conf["paste_shortcut"])
self.restore_clipboard.setChecked(conf["restore_clipboard"])
screen_name = conf["overlay_screen"]
if screen_name and self.indicator_screen.findData(screen_name) < 0:
self.indicator_screen.addItem(t("{name} (not connected)", name=screen_name), screen_name)
self._select_data(self.indicator_screen, screen_name)
self._select_data(self.corner, conf["overlay_corner"])
self.max_seconds.setValue(conf["max_seconds"])
self.skip_silent.setChecked(conf["skip_silent"])
@@ -1691,7 +1768,8 @@ 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._key_fields["opencode"].setText(conf["opencode_api_key"])
self.gemini_key.setText(conf["gemini_api_key"])
self.opencode_key.setText(conf["opencode_api_key"])
self._shown_provider = ""
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
self._provider_changed() # selecting index 0 fires no signal
@@ -1702,10 +1780,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.cleanup_opencode_model.setCurrentText(conf["cleanup_opencode_model"])
self._select_data(self.cleanup_provider, conf["cleanup_provider"])
self._cleanup_provider_changed() # selecting index 0 fires no signal
@@ -1737,6 +1821,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_opencode_model.setCurrentText(conf["assistant_opencode_model"])
self._assistant_provider_changed() # selecting index 0 fires no signal
self._select_data(self.assistant_reasoning, conf["assistant_reasoning"])
@@ -1789,6 +1874,7 @@ class SettingsWindow(QDialog):
conf["auto_paste"] = self.auto_paste.isChecked()
conf["paste_shortcut"] = self.paste_shortcut.currentText().strip()
conf["restore_clipboard"] = self.restore_clipboard.isChecked()
conf["overlay_screen"] = self.indicator_screen.currentData() or ""
conf["overlay_corner"] = self.corner.currentData() or "bottom-left"
conf["max_seconds"] = self.max_seconds.value()
conf["skip_silent"] = self.skip_silent.isChecked()
@@ -1804,7 +1890,8 @@ 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["opencode_api_key"] = self._key_fields["opencode"].text().strip()
conf["gemini_api_key"] = self.gemini_key.text().strip()
conf["opencode_api_key"] = self.opencode_key.text().strip()
conf["local_model"] = self.local_whisper.selected()
conf["local_gpu"] = self.local_gpu.isChecked()
conf["local_preload"] = self.local_preload.isChecked()
@@ -1813,12 +1900,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_opencode_model"] = (
self.cleanup_opencode_model.currentText().strip()
or cfg.DEFAULTS["cleanup_opencode_model"]
@@ -1861,6 +1957,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_opencode_model"] = (
self.assistant_opencode_model.currentText().strip()
or cfg.DEFAULTS["assistant_opencode_model"]
@@ -2005,55 +2105,51 @@ class SettingsWindow(QDialog):
def _load_models(self):
self.refresh_models.setEnabled(False)
self.refresh_opencode_models.setEnabled(False)
self.models_label.setText(t("Fetching model list…"))
# Whichever provider is selected for cleanup is the one whose models are
# fetched, so the list lands in the box of the provider on screen.
provider = self.cleanup_provider.currentData() or "openrouter"
if provider == "opencode":
key = (self.opencode_key.text().strip() or self.conf.opencode_key())
base = self.conf["opencode_base_url"]
def work():
try:
self._models_loaded.emit(
api.openai_models(key, base, "OpenCode Go"), "", provider)
except api.ApiError as exc:
self._models_loaded.emit([], str(exc), provider)
threading.Thread(target=work, daemon=True).start()
return
key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
def work():
try:
self._models_loaded.emit(api.openrouter_models(key), "", provider)
self._models_loaded.emit(api.openrouter_models(key), "")
except api.ApiError as exc:
self._models_loaded.emit([], str(exc), provider)
self._models_loaded.emit([], str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_models_loaded(self, models, error, provider):
def _on_models_loaded(self, models, error):
self.refresh_models.setEnabled(True)
self.refresh_opencode_models.setEnabled(True)
if error:
self.models_label.setText(t("Could not fetch the list: {error}", error=error))
return
if provider == "opencode":
combo = self.cleanup_opencode_model
else:
combo = self.cleanup_model
current = combo.currentText()
combo.clear()
combo.addItems(models)
combo.setCurrentText(current)
if provider == "openrouter":
# The minutes summary runs on the same key, so the meeting box is
# filled from the same list.
current = self.meeting_model.currentText()
self.meeting_model.clear()
self.meeting_model.addItems(models)
self.meeting_model.setCurrentText(current)
for combo in (self.cleanup_model, self.meeting_model):
current = combo.currentText()
combo.clear()
combo.addItems(models)
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):
@@ -2084,36 +2180,111 @@ class SettingsWindow(QDialog):
combo.setCurrentText(current)
def _load_opencode_models(self):
"""Ask OpenCode Go for its catalog of the day, off the interface thread.
The same courtesy Codex gets: the built-in list is only a starting
point, so the boxes are refreshed from the source as the window opens.
Skipped without a key, so a machine that never touched OpenCode Go
sends it nothing; the Fetch button stays for a key typed in just now.
"""
key = self.conf.opencode_key()
if not key:
return
self.refresh_opencode_models.setEnabled(False)
self.models_label.setText(t("Fetching model list…"))
key, base = self._typed_key("opencode")
def work():
try:
found = api.openai_models(key, self.conf["opencode_base_url"],
"OpenCode Go")
except api.ApiError:
# The window is only opening; the Test button says what failed.
return
if found:
self._opencode_models_loaded.emit(found)
self._opencode_models_loaded.emit(
api.openai_models(key, base, "OpenCode Go"), "")
except api.ApiError as exc:
self._opencode_models_loaded.emit([], str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_opencode_models_loaded(self, models):
def _on_opencode_models_loaded(self, models, error):
self.refresh_opencode_models.setEnabled(True)
if error:
self.models_label.setText(t("Could not fetch the list: {error}", error=error))
return
self._fill_opencode_boxes(models)
self.models_label.setText(t("{count} models loaded.", count=len(models)))
def _fill_opencode_boxes(self, models):
# The agent runs on the same key and catalog, so its box is refilled
# from the same list.
for combo in (self.cleanup_opencode_model, self.assistant_opencode_model):
current = combo.currentText()
combo.clear()
combo.addItems(models)
combo.setCurrentText(current)
def _load_agy_models(self):
"""Ask Antigravity which models it offers, off the interface thread.
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 the hosted model lists at open, without being asked.
The Fetch buttons stay: they are the retry, and the place a failure is
worth explaining. Here nobody asked, so an error changes nothing on
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)))
opencode_key = self.conf.opencode_key()
opencode_base = self.conf["opencode_base_url"]
if opencode_key:
jobs.append(("opencode",
lambda: api.openai_models(opencode_key, opencode_base,
"OpenCode Go")))
for provider, fetch in jobs:
def work(provider=provider, fetch=fetch):
try:
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):
if provider == "opencode":
self._fill_opencode_boxes(models)
return
combos = ((self.cleanup_model, self.meeting_model)
if provider == "openrouter" else (self.cleanup_gemini_model,))
for combo in combos:
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(
@@ -2132,6 +2303,13 @@ 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 _test_opencode(self):
key, base = self._typed_key("opencode")
self._test_key("opencode", lambda: t(
@@ -2141,14 +2319,14 @@ class SettingsWindow(QDialog):
def _typed_key(self, provider):
"""(key, base URL) for a provider, preferring what is in the field now."""
if provider == "opencode":
# The one key that is not in the TRANSCRIBERS table: it pays for
# cleanup and the agent rather than for speech to text.
typed = self._key_fields["opencode"].text().strip()
return typed or self.conf.opencode_key(), self.conf["opencode_base_url"]
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.
@@ -2372,12 +2550,16 @@ 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_opencode_model_row,
provider == "opencode")
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")
@@ -2386,6 +2568,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 provider == "opencode":
self.models_label.setText(t("Runs on OpenCode Go."))
elif not binary:
@@ -2404,6 +2588,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.opencode_box.setVisible(provider == "opencode")
self._refresh_assistant_status()
@@ -2538,7 +2723,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)