diff --git a/dikte/assistant.py b/dikte/assistant.py index 7310167..13d564e 100644 --- a/dikte/assistant.py +++ b/dikte/assistant.py @@ -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 `iddisplay 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): diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index b84b65a..61faa19 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -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( diff --git a/tests/test_assistant.py b/tests/test_assistant.py index 634c274..abc79bb 100644 --- a/tests/test_assistant.py +++ b/tests/test_assistant.py @@ -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() diff --git a/tests/test_ui.py b/tests/test_ui.py index 2c9435f..caa4317 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -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)