import json from unittest.mock import patch from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, get_codex_model_ids def test_get_codex_model_ids_prioritizes_default_and_cache(tmp_path, monkeypatch): codex_home = tmp_path / "codex-home" codex_home.mkdir(parents=True, exist_ok=True) (codex_home / "config.toml").write_text('model = "gpt-5.2-codex"\n') (codex_home / "models_cache.json").write_text( json.dumps( { "models": [ {"slug": "gpt-5.3-codex", "priority": 20, "supported_in_api": True}, {"slug": "gpt-5.3-codex-spark", "priority": 6, "supported_in_api": False}, {"slug": "gpt-5.1-codex", "priority": 5, "supported_in_api": True}, {"slug": "gpt-5.4", "priority": 1, "supported_in_api": True}, {"slug": "gpt-5-hidden-codex", "priority": 2, "visibility": "hidden"}, ] } ) ) monkeypatch.setenv("CODEX_HOME", str(codex_home)) models = get_codex_model_ids() assert models[0] == "gpt-5.2-codex" assert "gpt-5.1-codex" in models assert "gpt-5.3-codex" in models # Codex CLI marks Spark unsupported in the public API, but the Codex # backend still accepts it via the OAuth-backed CLI/Hermes route. assert "gpt-5.3-codex-spark" in models # Non-codex-suffixed models are included when the cache says they're available assert "gpt-5.4" in models assert "gpt-5.4-mini" in models assert "gpt-5-hidden-codex" not in models def test_setup_wizard_codex_import_resolves(): """Regression test for #712: setup.py must import the correct function name.""" # This mirrors the exact import used in hermes_cli/setup.py line 873. # A prior bug had 'get_codex_models' (wrong) instead of 'get_codex_model_ids'. from hermes_cli.codex_models import get_codex_model_ids as setup_import assert callable(setup_import) def test_get_codex_model_ids_falls_back_to_curated_defaults(tmp_path, monkeypatch): codex_home = tmp_path / "codex-home" codex_home.mkdir(parents=True, exist_ok=True) monkeypatch.setenv("CODEX_HOME", str(codex_home)) models = get_codex_model_ids() assert models[: len(DEFAULT_CODEX_MODELS)] == DEFAULT_CODEX_MODELS assert "gpt-5.4" in models assert "gpt-5.3-codex-spark" in models def test_get_codex_model_ids_adds_forward_compat_models_from_templates(monkeypatch): monkeypatch.setattr( "hermes_cli.codex_models._fetch_models_from_api", lambda access_token: ["gpt-5.3-codex"], ) models = get_codex_model_ids(access_token="codex-access-token") # When live discovery only returns gpt-5.3-codex, forward-compat synthesis # should surface gpt-5.5, gpt-5.4, gpt-5.4-mini, and gpt-5.3-codex-spark # (each is templated off gpt-5.3-codex). assert models == [ "gpt-5.3-codex", "gpt-5.5", "gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex-spark", ] def test_fetch_from_api_keeps_supported_in_api_false_models(monkeypatch): """Regression: gpt-5.3-codex-spark is returned by the live Codex backend with ``supported_in_api: false`` because it isn't in the public OpenAI API. The Codex CLI / OAuth route still serves it for ChatGPT Pro accounts, so we must not drop it on that flag. visibility=hidden is the separate signal that *should* still filter entries out. """ import sys from hermes_cli import codex_models class _FakeResp: status_code = 200 def json(self): return { "models": [ {"slug": "gpt-5.5", "priority": 0, "supported_in_api": True}, {"slug": "gpt-5.3-codex-spark", "priority": 7, "supported_in_api": False}, {"slug": "gpt-5-internal", "priority": 99, "visibility": "hidden"}, ] } class _FakeHttpx: @staticmethod def get(url, headers=None, timeout=None): return _FakeResp() monkeypatch.setitem(sys.modules, "httpx", _FakeHttpx) models = codex_models._fetch_models_from_api(access_token="tok") assert "gpt-5.5" in models assert "gpt-5.3-codex-spark" in models assert "gpt-5-internal" not in models def test_fetch_from_api_sends_chatgpt_account_id_header(monkeypatch): """The Codex /models endpoint only returns the per-account catalog when the ``ChatGPT-Account-Id`` header is present. Without it, the response is ``{"models":[]}`` (HTTP 200), which makes the picker silently degrade to the curated fallback list and send invalid slugs on later requests. Regression test for the upstream bug behind slow first responses and HTTP 520/120s SSE hangs. """ import sys from hermes_cli import codex_models captured = {} class _FakeResp: status_code = 200 def json(self): return {"models": [{"slug": "gpt-5.6-sol", "priority": 0}]} class _FakeHttpx: @staticmethod def get(url, headers=None, timeout=None): captured["url"] = url captured["headers"] = dict(headers or {}) return _FakeResp() monkeypatch.setitem(sys.modules, "httpx", _FakeHttpx) # Hand-crafted JWT carrying the chatgpt_account_id claim. import base64 import json payload = base64.urlsafe_b64encode( json.dumps( {"https://api.openai.com/auth": {"chatgpt_account_id": "acct-test-123"}} ).encode() ).rstrip(b"=").decode() fake_jwt = f"header.{payload}.sig" models = codex_models._fetch_models_from_api(access_token=fake_jwt) assert captured["headers"]["Authorization"] == f"Bearer {fake_jwt}" assert captured["headers"].get("ChatGPT-Account-Id") == "acct-test-123" assert "gpt-5.6-sol" in models def test_fetch_from_api_omits_account_id_header_when_jwt_unparseable(monkeypatch): """A malformed token must not crash the probe — it should still send the bearer header and let the upstream decide. We just verify the probe returns ``[]`` cleanly without the optional header. """ import sys from hermes_cli import codex_models captured = {} class _FakeResp: status_code = 200 def json(self): return {"models": []} class _FakeHttpx: @staticmethod def get(url, headers=None, timeout=None): captured["headers"] = dict(headers or {}) return _FakeResp() monkeypatch.setitem(sys.modules, "httpx", _FakeHttpx) models = codex_models._fetch_models_from_api(access_token="not-a-jwt") assert "ChatGPT-Account-Id" not in captured["headers"] assert captured["headers"]["Authorization"] == "Bearer not-a-jwt" assert models == [] def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch): from hermes_cli.main import _model_flow_openai_codex captured = {} choices = iter(["1"]) monkeypatch.setattr("builtins.input", lambda prompt="": next(choices)) monkeypatch.setattr( "hermes_cli.auth.get_codex_auth_status", lambda: {"logged_in": True}, ) monkeypatch.setattr( "hermes_cli.auth.resolve_codex_runtime_credentials", lambda *args, **kwargs: {"api_key": "codex-access-token"}, ) def _fake_get_codex_model_ids(access_token=None): captured["access_token"] = access_token return ["gpt-5.2-codex", "gpt-5.2"] def _fake_prompt_model_selection(model_ids, current_model="", **_kwargs): captured["model_ids"] = list(model_ids) captured["current_model"] = current_model return None monkeypatch.setattr( "hermes_cli.codex_models.get_codex_model_ids", _fake_get_codex_model_ids, ) monkeypatch.setattr( "hermes_cli.auth._prompt_model_selection", _fake_prompt_model_selection, ) _model_flow_openai_codex({}, current_model="openai/gpt-5.4") assert captured["access_token"] == "codex-access-token" assert captured["model_ids"] == ["gpt-5.2-codex", "gpt-5.2"] assert captured["current_model"] == "openai/gpt-5.4" def test_model_command_prompts_to_reuse_or_reauthenticate_codex_session(monkeypatch, capsys): from hermes_cli.main import _model_flow_openai_codex captured = {"login_calls": 0} choices = iter(["2"]) monkeypatch.setattr("builtins.input", lambda prompt="": next(choices)) monkeypatch.setattr( "hermes_cli.auth.get_codex_auth_status", lambda: {"logged_in": True, "source": "hermes-auth-store"}, ) monkeypatch.setattr( "hermes_cli.auth.resolve_codex_runtime_credentials", lambda *args, **kwargs: {"api_key": "fresh-codex-token"}, ) def _fake_login(*args, force_new_login=False, **kwargs): captured["login_calls"] += 1 captured["force_new_login"] = force_new_login monkeypatch.setattr("hermes_cli.auth._login_openai_codex", _fake_login) monkeypatch.setattr( "hermes_cli.codex_models.get_codex_model_ids", lambda access_token=None: ["gpt-5.4", "gpt-5.3-codex"], ) monkeypatch.setattr( "hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", **_kwargs: None, ) _model_flow_openai_codex({}, current_model="gpt-5.4") out = capsys.readouterr().out assert "Use existing credentials" in out assert "Reauthenticate (new OAuth login)" in out assert captured["login_calls"] == 1 assert captured["force_new_login"] is True def test_model_command_uses_existing_codex_session_without_relogin(monkeypatch): from hermes_cli.main import _model_flow_openai_codex choices = iter(["1"]) captured = {} monkeypatch.setattr("builtins.input", lambda prompt="": next(choices)) monkeypatch.setattr( "hermes_cli.auth.get_codex_auth_status", lambda: {"logged_in": True, "source": "hermes-auth-store"}, ) monkeypatch.setattr( "hermes_cli.auth.resolve_codex_runtime_credentials", lambda *args, **kwargs: {"api_key": "existing-codex-token"}, ) def _fake_get_codex_model_ids(access_token=None): captured["access_token"] = access_token return ["gpt-5.4"] monkeypatch.setattr( "hermes_cli.codex_models.get_codex_model_ids", _fake_get_codex_model_ids, ) monkeypatch.setattr( "hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", **_kwargs: None, ) monkeypatch.setattr( "hermes_cli.auth._login_openai_codex", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not reauthenticate")), ) _model_flow_openai_codex({}, current_model="gpt-5.4") assert captured["access_token"] == "existing-codex-token" # ── Tests for _normalize_model_for_provider ────────────────────────── def _make_cli(model="anthropic/claude-opus-4.6", **kwargs): """Create a HermesCLI with minimal mocking.""" import cli as _cli_mod from cli import HermesCLI _clean_config = { "model": { "default": "anthropic/claude-opus-4.6", "base_url": "https://openrouter.ai/api/v1", "provider": "auto", }, "display": {"compact": False, "tool_progress": "all", "resume_display": "full"}, "agent": {}, "terminal": {"env_type": "local"}, } clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} with ( patch("cli.get_tool_definitions", return_value=[]), patch.dict("os.environ", clean_env, clear=False), patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}), ): cli = HermesCLI(model=model, **kwargs) return cli class TestNormalizeModelForProvider: """_normalize_model_for_provider() trusts user-selected models. Only two things happen: 1. Provider prefixes are stripped (API needs bare slugs) 2. The *untouched default* model is swapped for a Codex model Everything else passes through — the API is the judge. """ def test_non_codex_provider_is_noop(self): cli = _make_cli(model="gpt-5.4") changed = cli._normalize_model_for_provider("openrouter") assert changed is False assert cli.model == "gpt-5.4" def test_native_provider_prefix_is_stripped_before_agent_startup(self): cli = _make_cli(model="zai/glm-5.1") changed = cli._normalize_model_for_provider("zai") assert changed is True assert cli.model == "glm-5.1" def test_bare_codex_model_passes_through(self): cli = _make_cli(model="gpt-5.3-codex") changed = cli._normalize_model_for_provider("openai-codex") assert changed is False assert cli.model == "gpt-5.3-codex" def test_bare_non_codex_model_passes_through(self): """gpt-5.4 (no 'codex' suffix) passes through — user chose it.""" cli = _make_cli(model="gpt-5.4") changed = cli._normalize_model_for_provider("openai-codex") assert changed is False assert cli.model == "gpt-5.4" def test_any_bare_model_trusted(self): """Even a non-OpenAI bare model passes through — user explicitly set it.""" cli = _make_cli(model="claude-opus-4-6") changed = cli._normalize_model_for_provider("openai-codex") # User explicitly chose this model — we trust them, API will error if wrong assert changed is False assert cli.model == "claude-opus-4-6" def test_provider_prefix_stripped(self): """openai/gpt-5.4 → gpt-5.4 (strip prefix, keep model).""" cli = _make_cli(model="openai/gpt-5.4") changed = cli._normalize_model_for_provider("openai-codex") assert changed is True assert cli.model == "gpt-5.4" def test_any_provider_prefix_stripped(self): """anthropic/claude-opus-4.6 → claude-opus-4.6 (strip prefix only). User explicitly chose this — let the API decide if it works.""" cli = _make_cli(model="anthropic/claude-opus-4.6") changed = cli._normalize_model_for_provider("openai-codex") assert changed is True assert cli.model == "claude-opus-4.6" def test_opencode_go_prefix_stripped(self): cli = _make_cli(model="opencode-go/kimi-k2.5") cli.api_mode = "chat_completions" changed = cli._normalize_model_for_provider("opencode-go") assert changed is True assert cli.model == "kimi-k2.5" assert cli.api_mode == "chat_completions" def test_opencode_zen_claude_sets_messages_mode(self): cli = _make_cli(model="opencode-zen/claude-sonnet-4-6") cli.api_mode = "chat_completions" changed = cli._normalize_model_for_provider("opencode-zen") assert changed is True assert cli.model == "claude-sonnet-4-6" assert cli.api_mode == "anthropic_messages" def test_default_model_replaced(self): """No model configured (empty default) gets swapped for codex.""" import cli as _cli_mod _clean_config = { "model": { "default": "", "base_url": "", "provider": "auto", }, "display": {"compact": False, "tool_progress": "all", "resume_display": "full"}, "agent": {}, "terminal": {"env_type": "local"}, } # Don't pass model= so _model_is_default is True with ( patch("cli.get_tool_definitions", return_value=[]), patch.dict("os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False), patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}), ): from cli import HermesCLI cli = HermesCLI() assert cli._model_is_default is True with patch( "hermes_cli.codex_models.get_codex_model_ids", return_value=["gpt-5.3-codex", "gpt-5.4"], ): changed = cli._normalize_model_for_provider("openai-codex") assert changed is True # Uses first from available list assert cli.model == "gpt-5.3-codex" def test_default_fallback_when_api_fails(self): """No model configured falls back to gpt-5.3-codex when API unreachable.""" import cli as _cli_mod _clean_config = { "model": { "default": "", "base_url": "", "provider": "auto", }, "display": {"compact": False, "tool_progress": "all", "resume_display": "full"}, "agent": {}, "terminal": {"env_type": "local"}, } with ( patch("cli.get_tool_definitions", return_value=[]), patch.dict("os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False), patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}), ): from cli import HermesCLI cli = HermesCLI() with patch( "hermes_cli.codex_models.get_codex_model_ids", side_effect=Exception("offline"), ): changed = cli._normalize_model_for_provider("openai-codex") assert changed is True assert cli.model == "gpt-5.3-codex"