mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user