Fetch every model list at open, without being asked

Codex's list already arrived on its own, because reading a cache on disk
costs nothing. The other three lists were behind a Fetch button, which
meant the built-in ones aged in front of anyone who never pressed it. Now
OpenRouter's and Google's lists are fetched when the settings window
opens, and Antigravity's comes off `agy models`, which prints one
id-tab-name line per model and answers over the network in a couple of
seconds; all three run off the interface thread.

Nobody asked, so nothing is reported: a failure changes nothing on
screen, the built-in lists stay, and the Fetch buttons remain both the
retry and the place an error is worth explaining. A provider whose key
has not been given is not called at all, so opening Settings is not by
itself a request to two vendors.

Also drops an import the merge had left in twice.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-08-27 16:12:18 +03:00
co-authored by Claude Fable 5
parent 1df2d03056
commit d331ccb877
4 changed files with 204 additions and 2 deletions
+26
View File
@@ -487,6 +487,32 @@ def _agy_label(step):
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):
+73 -1
View File
@@ -31,7 +31,6 @@ from . import meeting
from . import paste
from . import update
from .filetranscribe import FileTranscriber
from . import i18n
from .i18n import t
UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")]
@@ -575,6 +574,9 @@ class SettingsWindow(QDialog):
_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.
@@ -635,6 +637,8 @@ class SettingsWindow(QDialog):
self._gemini_models_loaded.connect(self._on_gemini_models_loaded)
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
self._codex_models_loaded.connect(self._on_codex_models_loaded)
self._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)
@@ -646,6 +650,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)
@@ -2096,6 +2102,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(
+40
View File
@@ -846,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()
+65 -1
View File
@@ -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([])
@@ -139,6 +144,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,
@@ -288,6 +297,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())
@@ -1005,7 +1061,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)
@@ -1037,6 +1097,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)