Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions tests/test_cli_provider_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"