Compare commits

...
Author SHA1 Message Date
yusufipek 0b06d6d16d Say that a model can be typed in, everywhere one can
Every model box has taken a typed name all along, but nothing said so, and a
closed-looking list reads as the whole choice. The Claude Code and Codex boxes
now carry a tooltip saying the list is a starting point, not a fence. Claude
gets no fetched list of its own: its CLI has no catalog command, and the
aliases it takes (sonnet, opus, haiku, fable) already follow the newest model
of each line.
2026-08-25 14:48:59 +03:00
yusufipek e9488a51a4 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.
2026-08-25 14:42:19 +03:00
5 changed files with 144 additions and 4 deletions
+27
View File
@@ -338,6 +338,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):
+4
View File
@@ -556,6 +556,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",
+45 -2
View File
@@ -79,7 +79,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",
@@ -92,6 +96,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"),
@@ -521,6 +531,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.
@@ -577,6 +588,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)
@@ -587,6 +599,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)
@@ -841,11 +854,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()
@@ -1005,7 +1020,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:
@@ -1020,6 +1035,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:
@@ -1876,6 +1892,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(
+47 -1
View File
@@ -15,7 +15,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:
@@ -534,5 +535,50 @@ class Ask(DikteTest):
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__":
unittest.main()
+21 -1
View File
@@ -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,
@@ -254,6 +256,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())
@@ -654,7 +669,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)
@@ -683,6 +700,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)