diff --git a/api/config.py b/api/config.py index 2ebabc5f..fc9122a7 100644 --- a/api/config.py +++ b/api/config.py @@ -1902,6 +1902,47 @@ def _get_label_for_model(model_id: str, existing_groups: list) -> str: ) +def _read_visible_codex_cache_model_ids() -> list[str]: + """Return visible model slugs from Codex's local models_cache.json. + + The agent's provider_model_ids('openai-codex') intentionally filters IDs + with ``supported_in_api: false``. Codex CLI still lists some of those models + in its picker (notably ``gpt-5.3-codex-spark`` from #1680), so the WebUI + merges this visible local catalog to stay in sync with Codex itself. + """ + codex_home = Path(os.getenv("CODEX_HOME", "").strip() or (HOME / ".codex")).expanduser() + cache_path = codex_home / "models_cache.json" + try: + payload = json.loads(cache_path.read_text(encoding="utf-8")) + except Exception: + return [] + + entries = payload.get("models") if isinstance(payload, dict) else None + if not isinstance(entries, list): + return [] + + sortable: list[tuple[int, str]] = [] + for item in entries: + if not isinstance(item, dict): + continue + slug = item.get("slug") + if not isinstance(slug, str) or not slug.strip(): + continue + visibility = item.get("visibility", "") + if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"): + continue + priority = item.get("priority") + rank = int(priority) if isinstance(priority, (int, float)) else 10_000 + sortable.append((rank, slug.strip())) + + sortable.sort(key=lambda item: (item[0], item[1])) + ordered: list[str] = [] + for _, slug in sortable: + if slug not in ordered: + ordered.append(slug) + return ordered + + def get_available_models() -> dict: """ Return available models grouped by provider. @@ -2671,6 +2712,43 @@ def get_available_models() -> dict: except Exception: logger.warning("Failed to load Ollama Cloud models from hermes_cli") + if raw_models: + models = _apply_provider_prefix(raw_models, pid, active_provider) + groups.append( + { + "provider": provider_name, + "provider_id": pid, + "models": models, + } + ) + elif pid == "openai-codex": + # Codex account catalogs drift faster than WebUI releases + # (for example gpt-5.3-codex-spark in #1680). Ask the + # agent's Codex resolver first so /api/models inherits the + # live Codex API / local ~/.codex cache / static fallback + # chain instead of freezing the picker to WebUI's curated + # _PROVIDER_MODELS snapshot. + raw_models = [] + codex_ids = [] + try: + from hermes_cli.models import provider_model_ids as _provider_model_ids + + codex_ids = [mid for mid in (_provider_model_ids("openai-codex") or []) if mid] + except Exception: + logger.warning("Failed to load OpenAI Codex models from hermes_cli") + + for mid in _read_visible_codex_cache_model_ids(): + if mid not in codex_ids: + codex_ids.append(mid) + + raw_models = [ + {"id": mid, "label": _get_label_for_model(mid, [])} + for mid in codex_ids + ] + + if not raw_models: + raw_models = copy.deepcopy(_PROVIDER_MODELS.get("openai-codex", [])) + if raw_models: models = _apply_provider_prefix(raw_models, pid, active_provider) groups.append( diff --git a/tests/test_issue1680_codex_spark.py b/tests/test_issue1680_codex_spark.py new file mode 100644 index 00000000..cee1c573 --- /dev/null +++ b/tests/test_issue1680_codex_spark.py @@ -0,0 +1,104 @@ +"""Regression tests for #1680 — Codex model picker uses live Codex discovery.""" + +import json +import sys +import types + +from api import config + + +def _flatten_ids(groups): + return [m.get("id") for g in groups for m in g.get("models", [])] + + +def _install_fake_hermes_models(monkeypatch, provider_model_ids): + hermes_cli = types.ModuleType("hermes_cli") + hermes_cli.__path__ = [] + models = types.ModuleType("hermes_cli.models") + models._PROVIDER_ALIASES = {} + models.provider_model_ids = provider_model_ids + monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli) + monkeypatch.setitem(sys.modules, "hermes_cli.models", models) + + +def _configure_codex(monkeypatch, tmp_path, default="gpt-5.3-codex-spark"): + monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "missing-config.yaml") + monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") + monkeypatch.setattr(config, "cfg", { + "model": {"provider": "openai-codex", "default": default}, + "providers": {}, + "fallback_providers": [], + }) + monkeypatch.setattr(config, "_cfg_mtime", 0.0) + config.invalidate_models_cache() + + +def test_openai_codex_group_uses_provider_model_ids_for_spark(monkeypatch, tmp_path): + """Codex-only models from the Codex catalog must surface in /api/models. + + The static WebUI fallback chronically drifts. ``gpt-5.3-codex-spark`` is + the regression case from #1680: it is discoverable by the Codex provider + resolver but was missing from the picker because get_available_models() + copied _PROVIDER_MODELS["openai-codex"] without asking hermes_cli. + """ + calls = [] + + def provider_model_ids(provider): + calls.append(provider) + assert provider == "openai-codex" + return ["gpt-5.4", "gpt-5.3-codex-spark", "gpt-5.3-codex"] + + _install_fake_hermes_models(monkeypatch, provider_model_ids) + _configure_codex(monkeypatch, tmp_path) + + result = config.get_available_models() + + codex_groups = [g for g in result["groups"] if g.get("provider_id") == "openai-codex"] + assert calls == ["openai-codex"] + assert codex_groups, "OpenAI Codex group should be present" + assert "gpt-5.3-codex-spark" in _flatten_ids(codex_groups) + assert codex_groups[0]["models"][0]["label"] == "GPT 5.4" + + +def test_openai_codex_group_merges_visible_codex_cache_models(monkeypatch, tmp_path): + """Visible Codex CLI cache models should appear even if API-filtered. + + Michael's local Codex cache lists ``gpt-5.3-codex-spark`` with + ``supported_in_api: false``. The agent helper currently filters those IDs + out, but the WebUI picker is a Codex-model selection surface and should + mirror the visible Codex catalog instead of hiding Spark. + """ + def provider_model_ids(provider): + assert provider == "openai-codex" + return ["gpt-5.4", "gpt-5.3-codex"] + + _install_fake_hermes_models(monkeypatch, provider_model_ids) + _configure_codex(monkeypatch, tmp_path, default="gpt-5.4") + + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "models_cache.json").write_text( + json.dumps( + { + "models": [ + {"slug": "gpt-5.4", "visibility": "list", "priority": 0}, + { + "slug": "gpt-5.3-codex-spark", + "visibility": "list", + "supported_in_api": False, + "priority": 7, + }, + {"slug": "hidden-test-model", "visibility": "hide", "priority": 8}, + ] + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + result = config.get_available_models() + + codex_groups = [g for g in result["groups"] if g.get("provider_id") == "openai-codex"] + ids = _flatten_ids(codex_groups) + assert "gpt-5.3-codex-spark" in ids + assert "hidden-test-model" not in ids