Ask Codex itself which models it offers

The Codex model boxes carried a hand-written list, which was already out of
date. The settings window now asks `codex debug models` for the catalog the
CLI's own picker reads, off the interface thread, and refills both boxes with
it; the built-in list is only what is on screen until Codex answers, or when
it is not installed at all.
This commit is contained in:
2026-08-25 14:42:19 +03:00
parent 3663c7fd57
commit e9488a51a4
4 changed files with 130 additions and 3 deletions
+27
View File
@@ -338,6 +338,33 @@ def _codex_label(item):
return t("Using {name}", name=item_type or "a tool") 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 ----------------------------------------------------------- # --- OpenRouter -----------------------------------------------------------
def _ask_openrouter(prompt, conf, on_stage): def _ask_openrouter(prompt, conf, on_stage):
+35 -1
View File
@@ -79,7 +79,11 @@ ASSISTANT_PROVIDERS = [
# Aliases resolve to the newest model of that name, so they age better than an # 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. # id does; a full id can be typed in when a particular one is wanted.
ASSISTANT_MODELS = ["sonnet", "opus", "haiku", "fable"] 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. # Starting points only; the box is editable and OpenRouter has hundreds.
ASSISTANT_OR_MODELS = [ ASSISTANT_OR_MODELS = [
"google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4", "google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4",
@@ -521,6 +525,7 @@ class SettingsWindow(QDialog):
_models_loaded = pyqtSignal(list, str) _models_loaded = pyqtSignal(list, str)
_transcribe_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. # 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.
@@ -577,6 +582,7 @@ class SettingsWindow(QDialog):
self._models_loaded.connect(self._on_models_loaded) self._models_loaded.connect(self._on_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._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)
@@ -587,6 +593,7 @@ class SettingsWindow(QDialog):
self.meetings.finished.connect(self._on_minutes_finished) self.meetings.finished.connect(self._on_minutes_finished)
self.meetings.failed.connect(self._on_minutes_failed) self.meetings.failed.connect(self._on_minutes_failed)
self._load() self._load()
self._load_codex_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)
@@ -1876,6 +1883,33 @@ class SettingsWindow(QDialog):
combo.setCurrentText(current) combo.setCurrentText(current)
self.models_label.setText(t("{count} models loaded.", count=len(models))) 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): 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(
+47 -1
View File
@@ -15,7 +15,8 @@ import unittest
from unittest import mock from unittest import mock
from dikte import assistant 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: class FakeCli:
@@ -534,5 +535,50 @@ class Ask(DikteTest):
self.assertEqual(assistant.stored_provider(), "") self.assertEqual(assistant.stored_provider(), "")
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__": if __name__ == "__main__":
unittest.main() unittest.main()
+21 -1
View File
@@ -133,6 +133,8 @@ class Settings(DikteTest):
"_load_models")) "_load_models"))
self.enterContext(mock.patch.object(settings_ui.SettingsWindow, self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
"_load_transcribe_models")) "_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, # 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,
@@ -254,6 +256,19 @@ class Settings(DikteTest):
self.assertEqual(shown, [provider]) self.assertEqual(shown, [provider])
self.assertFalse(box.isHidden()) 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): 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())
@@ -654,7 +669,9 @@ class MeetingSources(DikteTest):
only_these_tools(), \ only_these_tools(), \
mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \ mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \
mock.patch.object(settings_ui.SettingsWindow, 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()) window = settings_ui.SettingsWindow(cfg.Config())
self.addCleanup(window.deleteLater) self.addCleanup(window.deleteLater)
self.addCleanup(window.close) self.addCleanup(window.close)
@@ -683,6 +700,9 @@ class LocalModels(DikteTest):
# "nothing can transcribe" question from its real binary and model. # "nothing can transcribe" question from its real binary and model.
self.patch_attr(ggml, "BIN_DIR", self.path("bin")) self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
self.patch_attr(ggml, "MODELS_DIR", self.path("models")) 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): def window(self, conf):
window = settings_ui.SettingsWindow(conf) window = settings_ui.SettingsWindow(conf)