diff --git a/cli.py b/cli.py index 4d8d181a0..d2741fe5c 100755 --- a/cli.py +++ b/cli.py @@ -1012,6 +1012,10 @@ class HermesCLI: # Configuration - priority: CLI args > env vars > config file # Model can come from: CLI arg, LLM_MODEL env, OPENAI_MODEL env (custom endpoint), or config self.model = model or os.getenv("LLM_MODEL") or os.getenv("OPENAI_MODEL") or CLI_CONFIG["model"]["default"] + # Track whether model was explicitly chosen by the user or fell back + # to the global default. Provider-specific normalisation may override + # the default silently but should warn when overriding an explicit choice. + self._model_is_default = not (model or os.getenv("LLM_MODEL") or os.getenv("OPENAI_MODEL")) self._explicit_api_key = api_key self._explicit_base_url = base_url @@ -1126,6 +1130,63 @@ class HermesCLI: self._last_invalidate = now self._app.invalidate() + def _normalize_model_for_provider(self, resolved_provider: str) -> bool: + """Normalize obviously incompatible model/provider pairings. + + When the resolved provider is ``openai-codex``, the Codex Responses API + only accepts Codex-compatible model slugs (e.g. ``gpt-5.3-codex``). + If the active model is incompatible (e.g. the OpenRouter default + ``anthropic/claude-opus-4.6``), swap it for the best available Codex + model. Also strips provider prefixes the API does not accept + (``openai/gpt-5.3-codex`` → ``gpt-5.3-codex``). + + Returns True when the active model was changed. + """ + if resolved_provider != "openai-codex": + return False + + current_model = (self.model or "").strip() + current_slug = current_model.split("/")[-1] if current_model else "" + + # Keep explicit Codex models, but strip any provider prefix that the + # Codex Responses API does not accept. + if current_slug and "codex" in current_slug.lower(): + if current_slug != current_model: + self.model = current_slug + if not self._model_is_default: + self.console.print( + f"[yellow]⚠️ Stripped provider prefix from '{current_model}'; " + f"using '{current_slug}' for OpenAI Codex.[/]" + ) + return True + return False + + # Model is not Codex-compatible — replace with the best available + fallback_model = "gpt-5.3-codex" + try: + from hermes_cli.codex_models import get_codex_model_ids + + codex_models = get_codex_model_ids( + access_token=self.api_key if self.api_key else None, + ) + fallback_model = next( + (mid for mid in codex_models if "codex" in mid.lower()), + fallback_model, + ) + except Exception: + pass + + if current_model != fallback_model: + if not self._model_is_default: + self.console.print( + f"[yellow]⚠️ Model '{current_model}' is not supported with " + f"OpenAI Codex; switching to '{fallback_model}'.[/]" + ) + self.model = fallback_model + return True + + return False + def _ensure_runtime_credentials(self) -> bool: """ Ensure runtime credentials are resolved before agent use. @@ -1171,8 +1232,13 @@ class HermesCLI: self.api_key = api_key self.base_url = base_url - # AIAgent/OpenAI client holds auth at init time, so rebuild if key rotated - if (credentials_changed or routing_changed) and self.agent is not None: + # Normalize model for the resolved provider (e.g. swap non-Codex + # models when provider is openai-codex). Fixes #651. + model_changed = self._normalize_model_for_provider(resolved_provider) + + # AIAgent/OpenAI client holds auth at init time, so rebuild if key, + # routing, or the effective model changed. + if (credentials_changed or routing_changed or model_changed) and self.agent is not None: self.agent = None return True diff --git a/tests/test_cli_provider_resolution.py b/tests/test_cli_provider_resolution.py index 3c8fe14a5..cdae01d0c 100644 --- a/tests/test_cli_provider_resolution.py +++ b/tests/test_cli_provider_resolution.py @@ -162,6 +162,128 @@ def test_runtime_resolution_rebuilds_agent_on_routing_change(monkeypatch): assert shell.api_mode == "codex_responses" +def test_codex_provider_replaces_incompatible_default_model(monkeypatch): + """When provider resolves to openai-codex and no model was explicitly + chosen, the global config default (e.g. anthropic/claude-opus-4.6) must + be replaced with a Codex-compatible model. Fixes #651.""" + cli = _import_cli() + + monkeypatch.delenv("LLM_MODEL", raising=False) + monkeypatch.delenv("OPENAI_MODEL", raising=False) + + def _runtime_resolve(**kwargs): + return { + "provider": "openai-codex", + "api_mode": "codex_responses", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "test-key", + "source": "env/config", + } + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve) + monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc)) + monkeypatch.setattr( + "hermes_cli.codex_models.get_codex_model_ids", + lambda access_token=None: ["gpt-5.2-codex", "gpt-5.1-codex-mini"], + ) + + shell = cli.HermesCLI(compact=True, max_turns=1) + + assert shell._model_is_default is True + assert shell._ensure_runtime_credentials() is True + assert shell.provider == "openai-codex" + assert "anthropic" not in shell.model + assert "claude" not in shell.model + assert shell.model == "gpt-5.2-codex" + + +def test_codex_provider_replaces_incompatible_envvar_model(monkeypatch): + """Exact scenario from #651: LLM_MODEL is set to a non-Codex model and + provider resolves to openai-codex. The model must be replaced and a + warning printed since the user explicitly chose it.""" + cli = _import_cli() + + monkeypatch.setenv("LLM_MODEL", "claude-opus-4-6") + monkeypatch.delenv("OPENAI_MODEL", raising=False) + + def _runtime_resolve(**kwargs): + return { + "provider": "openai-codex", + "api_mode": "codex_responses", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "test-key", + "source": "env/config", + } + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve) + monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc)) + monkeypatch.setattr( + "hermes_cli.codex_models.get_codex_model_ids", + lambda access_token=None: ["gpt-5.2-codex", "gpt-5.1-codex-mini"], + ) + + shell = cli.HermesCLI(compact=True, max_turns=1) + + assert shell._model_is_default is False + assert shell._ensure_runtime_credentials() is True + assert shell.provider == "openai-codex" + assert "claude" not in shell.model + assert shell.model == "gpt-5.2-codex" + + +def test_codex_provider_preserves_explicit_codex_model(monkeypatch): + """If the user explicitly passes a Codex-compatible model, it must be + preserved even when the provider resolves to openai-codex.""" + cli = _import_cli() + + monkeypatch.delenv("LLM_MODEL", raising=False) + monkeypatch.delenv("OPENAI_MODEL", raising=False) + + def _runtime_resolve(**kwargs): + return { + "provider": "openai-codex", + "api_mode": "codex_responses", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "test-key", + "source": "env/config", + } + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve) + monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc)) + + shell = cli.HermesCLI(model="gpt-5.1-codex-mini", compact=True, max_turns=1) + + assert shell._model_is_default is False + assert shell._ensure_runtime_credentials() is True + assert shell.model == "gpt-5.1-codex-mini" + + +def test_codex_provider_strips_provider_prefix_from_model(monkeypatch): + """openai/gpt-5.3-codex should become gpt-5.3-codex — the Codex + Responses API does not accept provider-prefixed model slugs.""" + cli = _import_cli() + + monkeypatch.delenv("LLM_MODEL", raising=False) + monkeypatch.delenv("OPENAI_MODEL", raising=False) + + def _runtime_resolve(**kwargs): + return { + "provider": "openai-codex", + "api_mode": "codex_responses", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "test-key", + "source": "env/config", + } + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve) + monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc)) + + shell = cli.HermesCLI(model="openai/gpt-5.3-codex", compact=True, max_turns=1) + + assert shell._ensure_runtime_credentials() is True + assert shell.model == "gpt-5.3-codex" + + def test_cmd_model_falls_back_to_auto_on_invalid_provider(monkeypatch, capsys): monkeypatch.setattr( "hermes_cli.config.load_config",