diff --git a/dikte/assistant.py b/dikte/assistant.py index 21ff241..b2ed598 100644 --- a/dikte/assistant.py +++ b/dikte/assistant.py @@ -339,6 +339,33 @@ def _codex_label(item): return t("Using {name}…", name=item_type or "a tool") +def codex_models(): + """The models Codex itself would offer right now, best first. + + `codex debug models` prints the catalog the CLI's own model picker reads, + fetched from OpenAI and cached beside Codex's config, so the list is as + current as the installed Codex and there is no second list to keep up to + date here. Entries the picker hides are internal and stay hidden. A machine + without Codex, or one too old to have the command, answers with nothing and + the caller keeps its built-in list. + """ + if not shutil.which("codex"): + return [] + try: + proc = subprocess.run(["codex", "debug", "models"], + capture_output=True, text=True, timeout=30) + catalog = json.loads(proc.stdout or "null") + except (OSError, subprocess.SubprocessError, ValueError): + return [] + if not isinstance(catalog, dict): + return [] + rows = [row for row in catalog.get("models") or [] + if isinstance(row, dict) and row.get("slug") + and row.get("visibility") != "hide"] + rows.sort(key=lambda row: row.get("priority") or 0) + return [row["slug"] for row in rows] + + # --- OpenRouter ----------------------------------------------------------- def _ask_openrouter(prompt, conf, on_stage): diff --git a/dikte/i18n.py b/dikte/i18n.py index fc7c59e..4a779ef 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -554,6 +554,10 @@ TR = { "“sonnet” gibi bir ad her zaman o serinin en yenisini seçer. Opus daha " "çok düşünür ve daha geç cevaplar; bu da en çok burada hissedilir, " "çünkü ekranın başında bekliyorsun.", + "The list is a starting point, not a fence: any model name {name} accepts " + "can be typed straight in.": + "Liste başlangıç için, sınır değil: {name} hangi model adını kabul " + "ediyorsa buraya elle yazılabilir.", "Permissions": "İzinler", "Decide on its own, with the safety checks on": "Kendi karar versin, güvenlik denetimleri açık", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 485ddab..f7144ac 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -82,7 +82,11 @@ ASSISTANT_PROVIDERS = [ # 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. ASSISTANT_MODELS = ["sonnet", "opus", "haiku", "fable"] -CODEX_MODELS = ["gpt-5.4-codex", "gpt-5.4", "o4-mini"] +# What the Codex boxes offer before Codex itself has answered, and everything +# they offer when it cannot: the real list comes from `codex debug models` when +# the window opens, so this only has to be roughly right. +CODEX_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", + "gpt-5.5", "gpt-5.4", "gpt-5.4-mini"] # Starting points only; the box is editable and OpenRouter has hundreds. ASSISTANT_OR_MODELS = [ "google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4", @@ -95,6 +99,12 @@ PERMISSION_MODES = [ ("Allow everything", "bypassPermissions"), ("Only what needs no permission", "manual"), ] +def _typed_model_note(name): + """Every model box takes a typed name too; the tooltip that says so.""" + return t("The list is a starting point, not a fence: any model name " + "{name} accepts can be typed straight in.", name=name) + + # Codex confines the commands it runs instead of asking about them. CODEX_SANDBOXES = [ ("Read anything, write in the working directory", "workspace-write"), @@ -549,6 +559,7 @@ class SettingsWindow(QDialog): _models_loaded = pyqtSignal(list, str) _transcribe_models_loaded = pyqtSignal(list, str) + _codex_models_loaded = pyqtSignal(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. @@ -607,6 +618,7 @@ class SettingsWindow(QDialog): self._models_loaded.connect(self._on_models_loaded) self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) + self._codex_models_loaded.connect(self._on_codex_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) @@ -617,6 +629,7 @@ class SettingsWindow(QDialog): self.meetings.finished.connect(self._on_minutes_finished) self.meetings.failed.connect(self._on_minutes_failed) self._load() + self._load_codex_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) @@ -868,11 +881,13 @@ class SettingsWindow(QDialog): self.cleanup_claude_model = QComboBox() self.cleanup_claude_model.setEditable(True) self.cleanup_claude_model.addItems(CLEANUP_CLAUDE_MODELS) + self.cleanup_claude_model.setToolTip(_typed_model_note("Claude Code")) orr_form.addRow(t("Model"), self.cleanup_claude_model) self.cleanup_codex_model = QComboBox() self.cleanup_codex_model.setEditable(True) self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS) + self.cleanup_codex_model.setToolTip(_typed_model_note("Codex")) orr_form.addRow(t("Model"), self.cleanup_codex_model) self.cleanup_reasoning = QComboBox() @@ -1032,7 +1047,7 @@ class SettingsWindow(QDialog): "A name like “sonnet” always means the newest model of that line. " "Opus thinks harder and answers slower, which is felt here more " "than anywhere else: you are standing in front of the screen." - )) + ) + " " + _typed_model_note("Claude Code")) claude_form.addRow(t("Model"), self.assistant_model) self.assistant_permission = QComboBox() for label, value in PERMISSION_MODES: @@ -1047,6 +1062,7 @@ class SettingsWindow(QDialog): self.assistant_codex_model.addItem(t("Codex's own default"), "") for name in CODEX_MODELS: self.assistant_codex_model.addItem(name, name) + self.assistant_codex_model.setToolTip(_typed_model_note("Codex")) codex_form.addRow(t("Model"), self.assistant_codex_model) self.assistant_codex_sandbox = QComboBox() for label, value in CODEX_SANDBOXES: @@ -1955,6 +1971,33 @@ class SettingsWindow(QDialog): combo.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. + + No button and no network of ours: the CLI answers from its own cache in + well under a second. Skipped when Codex is not installed, which is also + when the built-in list stays on screen and nobody is running Codex + anyway. + """ + if not shutil.which("codex"): + return + + def work(): + found = assistant.codex_models() + if found: + self._codex_models_loaded.emit(found) + + threading.Thread(target=work, daemon=True).start() + + def _on_codex_models_loaded(self, models): + for combo in (self.cleanup_codex_model, self.assistant_codex_model): + current = combo.currentText() + combo.clear() + combo.addItem(t("Codex's own default"), "") + for name in models: + combo.addItem(name, name) + 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 faf6362..8ff7d7e 100644 --- a/tests/test_assistant.py +++ b/tests/test_assistant.py @@ -17,7 +17,8 @@ import unittest from unittest import mock from dikte import assistant -from tests.support import DikteTest, fake_urlopen, only_these_tools +from tests.support import (DikteTest, FakeCompleted, fake_urlopen, + only_these_tools) class FakeCli: @@ -664,5 +665,50 @@ class Ask(DikteTest): self.assertEqual(len(calls), 1) +class CodexModels(DikteTest): + """The model list read off `codex debug models`.""" + + CATALOG = {"models": [ + {"slug": "gpt-6-mini", "visibility": "list", "priority": 9}, + {"slug": "gpt-6", "visibility": "list", "priority": 1}, + {"slug": "codex-auto-review", "visibility": "hide", "priority": 3}, + ]} + + def models(self, reply, code=0): + with only_these_tools("codex"), \ + mock.patch.object(subprocess, "run", + return_value=FakeCompleted( + returncode=code, stdout=reply)) as run: + found = assistant.codex_models() + self.run_call = run + return found + + def test_the_catalog_arrives_best_first_without_the_hidden_ones(self): + found = self.models(json.dumps(self.CATALOG)) + self.assertEqual(found, ["gpt-6", "gpt-6-mini"]) + self.assertEqual(self.run_call.call_args.args[0], + ["codex", "debug", "models"]) + + def test_a_codex_that_is_not_installed_is_not_run(self): + with only_these_tools(), \ + mock.patch.object(subprocess, "run") as run: + self.assertEqual(assistant.codex_models(), []) + run.assert_not_called() + + def test_a_codex_too_old_to_have_the_command(self): + self.assertEqual(self.models("error: unknown subcommand", code=2), []) + + def test_a_catalog_that_is_not_what_was_expected(self): + self.assertEqual(self.models(json.dumps(["gpt-6"])), []) + self.assertEqual(self.models(""), []) + + def test_a_codex_that_hangs_is_given_up_on(self): + with only_these_tools("codex"), \ + mock.patch.object(subprocess, "run", + side_effect=subprocess.TimeoutExpired( + ["codex"], 30)): + self.assertEqual(assistant.codex_models(), []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index 3be548b..8c7e0cd 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -133,6 +133,8 @@ class Settings(DikteTest): "_load_models")) self.enterContext(mock.patch.object(settings_ui.SettingsWindow, "_load_transcribe_models")) + self.enterContext(mock.patch.object(settings_ui.SettingsWindow, + "_load_codex_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, @@ -269,6 +271,19 @@ class Settings(DikteTest): self.assertEqual(shown, [provider]) self.assertFalse(box.isHidden()) + def test_codex_answering_refills_both_of_its_boxes(self): + """The list Codex gave replaces the built-in one, in both places, and + neither loses what was already picked.""" + conf = self.config(cleanup_codex_model="my-own-model") + window = self.window(conf) + window._on_codex_models_loaded(["gpt-6", "gpt-6-mini"]) + for combo in (window.cleanup_codex_model, window.assistant_codex_model): + with self.subTest(combo=combo.objectName() or "combo"): + offered = [combo.itemText(i) for i in range(combo.count())] + self.assertEqual(offered[1:], ["gpt-6", "gpt-6-mini"]) + self.assertEqual(window.cleanup_codex_model.currentText(), + "my-own-model") + 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()) @@ -984,7 +999,9 @@ class MeetingSources(DikteTest): only_these_tools(), \ mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \ mock.patch.object(settings_ui.SettingsWindow, - "_load_transcribe_models"): + "_load_transcribe_models"), \ + mock.patch.object(settings_ui.SettingsWindow, + "_load_codex_models"): window = settings_ui.SettingsWindow(cfg.Config()) self.addCleanup(window.deleteLater) self.addCleanup(window.close) @@ -1013,6 +1030,9 @@ class LocalModels(DikteTest): # "nothing can transcribe" question from its real binary and model. self.patch_attr(ggml, "BIN_DIR", self.path("bin")) self.patch_attr(ggml, "MODELS_DIR", self.path("models")) + # And one with Codex on it would ask it for its model list. + self.enterContext(mock.patch.object(settings_ui.SettingsWindow, + "_load_codex_models")) def window(self, conf): window = settings_ui.SettingsWindow(conf)