diff --git a/cli.py b/cli.py index d7a5bcaa1d25..796c17f1a6b6 100755 --- a/cli.py +++ b/cli.py @@ -1012,6 +1012,11 @@ def __init__( # 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 + # at credential-resolution time but must never silently override an explicit + # user 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 @@ -1161,8 +1166,46 @@ def _ensure_runtime_credentials(self) -> bool: 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: + # If the resolved provider is Codex, ensure the active model is + # Codex-compatible. The Codex Responses API rejects non-Codex models + # (e.g. anthropic/claude-opus-4.6) with a 400 error — the root cause + # of issue #651. We always normalise here regardless of whether the + # model came from a CLI arg, env-var, or the global config default. + if resolved_provider == "openai-codex": + current_model = (self.model or "").strip() + current_slug = current_model.split("/")[-1] + is_codex_model = "codex" in current_slug.lower() + has_provider_prefix = "/" in current_model + if not is_codex_model: + # Model is not Codex-compatible at all — replace with best available + try: + from hermes_cli.codex_models import get_codex_model_ids + codex_models = get_codex_model_ids(access_token=api_key) + codex_default = codex_models[0] if codex_models else None + except Exception: + codex_default = None + if codex_default: + if not getattr(self, "_model_is_default", True): + self.console.print( + f"[yellow]⚠️ Model '{current_model}' is not supported with " + f"OpenAI Codex; switching to '{codex_default}'.[/]" + ) + self.model = codex_default + elif has_provider_prefix: + # Model is Codex-compatible but has a provider prefix the Codex + # Responses API does not accept (e.g. openai/gpt-5.3-codex) + self.model = current_slug + if not getattr(self, "_model_is_default", True): + self.console.print( + f"[yellow]⚠️ Stripped provider prefix from '{current_model}'; " + f"using '{current_slug}' for OpenAI Codex.[/]" + ) + + # AIAgent/OpenAI client holds auth at init time, so rebuild if key, + # routing, or effective model changed. + model_changed = getattr(self, "_last_agent_model", None) != self.model + self._last_agent_model = self.model + 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 3c8fe14a5ed9..4de7b188428b 100644 --- a/tests/test_cli_provider_resolution.py +++ b/tests/test_cli_provider_resolution.py @@ -185,3 +185,131 @@ def _resolve_provider(requested, **kwargs): assert "Warning:" in output assert "falling back to auto provider detection" in output.lower() assert "No change." in output + + +def test_incompatible_model_replaced_with_codex_model_from_config_default(monkeypatch): + """Root cause fix for #651 (config default path): when provider resolves to + openai-codex and no model was explicitly chosen, the global default + (anthropic/claude-opus-4.6) must be replaced with a Codex-compatible model.""" + cli = _import_cli() + + # Clear env-var model overrides so HermesCLI falls back to the config default + 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": "codex-token", + "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_incompatible_model_replaced_with_codex_model_from_env_var(monkeypatch): + """Root cause fix for #651 (env-var path): when LLM_MODEL is set to a + non-Codex model and provider resolves to openai-codex, the model must + still be replaced — this is the exact user scenario reported in #651.""" + cli = _import_cli() + + # Simulate the exact #651 scenario: LLM_MODEL set to an Anthropic model + 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": "codex-token", + "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 # came from env var + 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_explicit_codex_model_not_overridden(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": "codex-token", + "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"], + ) + + 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_model_with_provider_prefix_is_stripped(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": "codex-token", + "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"