fix: normalize incompatible models when provider resolves to Codex
When _ensure_runtime_credentials() resolves the provider to openai-codex, check if the active model is Codex-compatible. If not (e.g. the default anthropic/claude-opus-4.6), swap it for the best available Codex model. Also strips provider prefixes the Codex API rejects (openai/gpt-5.3-codex → gpt-5.3-codex). Adds _model_is_default flag so warnings are only shown when the user explicitly chose an incompatible model (not when it's the config default). Fixes #651. Co-inspired-by: stablegenius49 (PR #661) Co-inspired-by: teyrebaz33 (PR #696)
This commit is contained in:
parent
3fb8938cd3
commit
95b1130485
2 changed files with 190 additions and 2 deletions
70
cli.py
70
cli.py
|
|
@ -1012,6 +1012,10 @@ class HermesCLI:
|
||||||
# Configuration - priority: CLI args > env vars > config file
|
# Configuration - priority: CLI args > env vars > config file
|
||||||
# Model can come from: CLI arg, LLM_MODEL env, OPENAI_MODEL env (custom endpoint), or config
|
# 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"]
|
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_api_key = api_key
|
||||||
self._explicit_base_url = base_url
|
self._explicit_base_url = base_url
|
||||||
|
|
@ -1126,6 +1130,63 @@ class HermesCLI:
|
||||||
self._last_invalidate = now
|
self._last_invalidate = now
|
||||||
self._app.invalidate()
|
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:
|
def _ensure_runtime_credentials(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Ensure runtime credentials are resolved before agent use.
|
Ensure runtime credentials are resolved before agent use.
|
||||||
|
|
@ -1171,8 +1232,13 @@ class HermesCLI:
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
|
|
||||||
# AIAgent/OpenAI client holds auth at init time, so rebuild if key rotated
|
# Normalize model for the resolved provider (e.g. swap non-Codex
|
||||||
if (credentials_changed or routing_changed) and self.agent is not None:
|
# 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
|
self.agent = None
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,128 @@ def test_runtime_resolution_rebuilds_agent_on_routing_change(monkeypatch):
|
||||||
assert shell.api_mode == "codex_responses"
|
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):
|
def test_cmd_model_falls_back_to_auto_on_invalid_provider(monkeypatch, capsys):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"hermes_cli.config.load_config",
|
"hermes_cli.config.load_config",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue