mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
@@ -487,6 +487,32 @@ def _agy_label(step):
|
|||||||
return t("Using {name}…", name=name 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 -----------------------------------------------------------
|
# --- OpenRouter -----------------------------------------------------------
|
||||||
|
|
||||||
def _ask_openrouter(prompt, conf, on_stage):
|
def _ask_openrouter(prompt, conf, on_stage):
|
||||||
|
|||||||
+73
-1
@@ -31,7 +31,6 @@ from . import meeting
|
|||||||
from . import paste
|
from . import paste
|
||||||
from . import update
|
from . import update
|
||||||
from .filetranscribe import FileTranscriber
|
from .filetranscribe import FileTranscriber
|
||||||
from . import i18n
|
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")]
|
UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")]
|
||||||
@@ -575,6 +574,9 @@ class SettingsWindow(QDialog):
|
|||||||
_gemini_models_loaded = pyqtSignal(list, str)
|
_gemini_models_loaded = pyqtSignal(list, str)
|
||||||
_transcribe_models_loaded = pyqtSignal(list, str)
|
_transcribe_models_loaded = pyqtSignal(list, str)
|
||||||
_codex_models_loaded = pyqtSignal(list)
|
_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.
|
# Which key was tested, whether it worked, and what to write under it.
|
||||||
_test_done = pyqtSignal(str, bool, str)
|
_test_done = pyqtSignal(str, bool, str)
|
||||||
# The release that was found, or None, and what went wrong instead.
|
# 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._gemini_models_loaded.connect(self._on_gemini_models_loaded)
|
||||||
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
||||||
self._codex_models_loaded.connect(self._on_codex_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._test_done.connect(self._on_test_done)
|
||||||
self._update_checked.connect(self._on_update_checked)
|
self._update_checked.connect(self._on_update_checked)
|
||||||
self.transcriber.progress.connect(self._on_file_progress)
|
self.transcriber.progress.connect(self._on_file_progress)
|
||||||
@@ -646,6 +650,8 @@ class SettingsWindow(QDialog):
|
|||||||
self.meetings.failed.connect(self._on_minutes_failed)
|
self.meetings.failed.connect(self._on_minutes_failed)
|
||||||
self._load()
|
self._load()
|
||||||
self._load_codex_models()
|
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
|
# Connected after the load, so that filling the boxes in is not taken
|
||||||
# for the user ticking them.
|
# for the user ticking them.
|
||||||
self.file_timestamps.toggled.connect(self._remember_file_choices)
|
self.file_timestamps.toggled.connect(self._remember_file_choices)
|
||||||
@@ -2096,6 +2102,72 @@ class SettingsWindow(QDialog):
|
|||||||
combo.addItem(name, name)
|
combo.addItem(name, name)
|
||||||
combo.setCurrentText(current)
|
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):
|
def _test_openai(self):
|
||||||
key, base = self._typed_key("openai")
|
key, base = self._typed_key("openai")
|
||||||
self._test_key("openai", lambda: t(
|
self._test_key("openai", lambda: t(
|
||||||
|
|||||||
@@ -846,5 +846,45 @@ class CodexModels(DikteTest):
|
|||||||
self.assertEqual(assistant.codex_models(), [])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+65
-1
@@ -28,6 +28,11 @@ from dikte import settings_ui
|
|||||||
from dikte import update
|
from dikte import update
|
||||||
from tests.support import DikteTest, only_these_tools
|
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.
|
# One application for the whole run; Qt allows no second one.
|
||||||
_app = QApplication.instance() or QApplication([])
|
_app = QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
@@ -139,6 +144,10 @@ class Settings(DikteTest):
|
|||||||
"_load_transcribe_models"))
|
"_load_transcribe_models"))
|
||||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||||
"_load_codex_models"))
|
"_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,
|
# 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.
|
# from a thread, which is nobody's test failing but a real request.
|
||||||
self.enterContext(mock.patch.object(settings_ui.LocalModelBox,
|
self.enterContext(mock.patch.object(settings_ui.LocalModelBox,
|
||||||
@@ -288,6 +297,53 @@ class Settings(DikteTest):
|
|||||||
self.assertEqual(window.cleanup_codex_model.currentText(),
|
self.assertEqual(window.cleanup_codex_model.currentText(),
|
||||||
"my-own-model")
|
"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):
|
def test_the_update_line_names_the_version_that_is_running(self):
|
||||||
window = self.window(cfg.Config())
|
window = self.window(cfg.Config())
|
||||||
self.assertIn(settings_ui.__version__, window.update_status.text())
|
self.assertIn(settings_ui.__version__, window.update_status.text())
|
||||||
@@ -1005,7 +1061,11 @@ class MeetingSources(DikteTest):
|
|||||||
mock.patch.object(settings_ui.SettingsWindow,
|
mock.patch.object(settings_ui.SettingsWindow,
|
||||||
"_load_transcribe_models"), \
|
"_load_transcribe_models"), \
|
||||||
mock.patch.object(settings_ui.SettingsWindow,
|
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())
|
window = settings_ui.SettingsWindow(cfg.Config())
|
||||||
self.addCleanup(window.deleteLater)
|
self.addCleanup(window.deleteLater)
|
||||||
self.addCleanup(window.close)
|
self.addCleanup(window.close)
|
||||||
@@ -1037,6 +1097,10 @@ class LocalModels(DikteTest):
|
|||||||
# And one with Codex on it would ask it for its model list.
|
# And one with Codex on it would ask it for its model list.
|
||||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||||
"_load_codex_models"))
|
"_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):
|
def window(self, conf):
|
||||||
window = settings_ui.SettingsWindow(conf)
|
window = settings_ui.SettingsWindow(conf)
|
||||||
|
|||||||
Reference in New Issue
Block a user