diff --git a/cli.py b/cli.py index 10315d6cf57c..ea43cdf5a0cf 100644 --- a/cli.py +++ b/cli.py @@ -3083,6 +3083,8 @@ def _ensure_runtime_credentials(self) -> bool: format_runtime_provider_error, ) + _primary_exc = None + runtime = None try: runtime = resolve_runtime_provider( requested=self.requested_provider, @@ -3090,7 +3092,34 @@ def _ensure_runtime_credentials(self) -> bool: explicit_base_url=self._explicit_base_url, ) except Exception as exc: - message = format_runtime_provider_error(exc) + _primary_exc = exc + + # Primary provider auth failed — try fallback providers before giving up. + if runtime is None and _primary_exc is not None: + from hermes_cli.auth import AuthError + if isinstance(_primary_exc, AuthError): + _fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else [] + for _fb in _fb_chain: + _fb_provider = (_fb.get("provider") or "").strip().lower() + _fb_model = (_fb.get("model") or "").strip() + if not _fb_provider or not _fb_model: + continue + try: + runtime = resolve_runtime_provider(requested=_fb_provider) + logger.warning( + "Primary provider auth failed (%s). Falling through to fallback: %s/%s", + _primary_exc, _fb_provider, _fb_model, + ) + _cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}") + self.requested_provider = _fb_provider + self.model = _fb_model + _primary_exc = None + break + except Exception: + continue + + if runtime is None: + message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed." ChatConsole().print(f"[bold red]{message}[/]") return False diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 8a625fbb2efe..dbce736cc642 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1547,12 +1547,21 @@ def refresh_codex_oauth_pure( try: err = response.json() if isinstance(err, dict): - err_code = err.get("error") - if isinstance(err_code, str) and err_code.strip(): - code = err_code.strip() - err_desc = err.get("error_description") or err.get("message") - if isinstance(err_desc, str) and err_desc.strip(): - message = f"Codex token refresh failed: {err_desc.strip()}" + err_obj = err.get("error") + # OpenAI shape: {"error": {"code": "...", "message": "...", "type": "..."}} + if isinstance(err_obj, dict): + nested_code = err_obj.get("code") or err_obj.get("type") + if isinstance(nested_code, str) and nested_code.strip(): + code = nested_code.strip() + nested_msg = err_obj.get("message") + if isinstance(nested_msg, str) and nested_msg.strip(): + message = f"Codex token refresh failed: {nested_msg.strip()}" + # OAuth spec shape: {"error": "code_str", "error_description": "..."} + elif isinstance(err_obj, str) and err_obj.strip(): + code = err_obj.strip() + err_desc = err.get("error_description") or err.get("message") + if isinstance(err_desc, str) and err_desc.strip(): + message = f"Codex token refresh failed: {err_desc.strip()}" except Exception: pass if code in {"invalid_grant", "invalid_token", "invalid_request"}: @@ -3088,52 +3097,61 @@ def login_command(args) -> None: raise SystemExit(0) -def _login_openai_codex(args, pconfig: ProviderConfig) -> None: +def _login_openai_codex( + args, + pconfig: ProviderConfig, + *, + force_new_login: bool = False, +) -> None: """OpenAI Codex login via device code flow. Tokens stored in ~/.hermes/auth.json.""" + del args, pconfig # kept for parity with other provider login helpers + # Check for existing Hermes-owned credentials - try: - existing = resolve_codex_runtime_credentials() - # Verify the resolved token is actually usable (not expired). - # resolve_codex_runtime_credentials attempts refresh, so if we get - # here the token should be valid — but double-check before telling - # the user "Login successful!". - _resolved_key = existing.get("api_key", "") - if isinstance(_resolved_key, str) and _resolved_key and not _codex_access_token_is_expiring(_resolved_key, 60): - print("Existing Codex credentials found in Hermes auth store.") + if not force_new_login: + try: + existing = resolve_codex_runtime_credentials() + # Verify the resolved token is actually usable (not expired). + # resolve_codex_runtime_credentials attempts refresh, so if we get + # here the token should be valid — but double-check before telling + # the user "Login successful!". + _resolved_key = existing.get("api_key", "") + if isinstance(_resolved_key, str) and _resolved_key and not _codex_access_token_is_expiring(_resolved_key, 60): + print("Existing Codex credentials found in Hermes auth store.") + try: + reuse = input("Use existing credentials? [Y/n]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + reuse = "y" + if reuse in ("", "y", "yes"): + config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL)) + print() + print("Login successful!") + print(f" Config updated: {config_path} (model.provider=openai-codex)") + return + else: + print("Existing Codex credentials are expired. Starting fresh login...") + except AuthError: + pass + + # Check for existing Codex CLI tokens we can import + if not force_new_login: + cli_tokens = _import_codex_cli_tokens() + if cli_tokens: + print("Found existing Codex CLI credentials at ~/.codex/auth.json") + print("Hermes will create its own session to avoid conflicts with Codex CLI / VS Code.") try: - reuse = input("Use existing credentials? [Y/n]: ").strip().lower() + do_import = input("Import these credentials? (a separate login is recommended) [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): - reuse = "y" - if reuse in ("", "y", "yes"): - config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL)) + do_import = "n" + if do_import in ("y", "yes"): + _save_codex_tokens(cli_tokens) + base_url = os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL + config_path = _update_config_for_provider("openai-codex", base_url) print() - print("Login successful!") + print("Credentials imported. Note: if Codex CLI refreshes its token,") + print("Hermes will keep working independently with its own session.") print(f" Config updated: {config_path} (model.provider=openai-codex)") return - else: - print("Existing Codex credentials are expired. Starting fresh login...") - except AuthError: - pass - - # Check for existing Codex CLI tokens we can import - cli_tokens = _import_codex_cli_tokens() - if cli_tokens: - print("Found existing Codex CLI credentials at ~/.codex/auth.json") - print("Hermes will create its own session to avoid conflicts with Codex CLI / VS Code.") - try: - do_import = input("Import these credentials? (a separate login is recommended) [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - do_import = "n" - if do_import in ("y", "yes"): - _save_codex_tokens(cli_tokens) - base_url = os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL - config_path = _update_config_for_provider("openai-codex", base_url) - print() - print("Credentials imported. Note: if Codex CLI refreshes its token,") - print("Hermes will keep working independently with its own session.") - print(f" Config updated: {config_path} (model.provider=openai-codex)") - return # Run a fresh device code flow — Hermes gets its own OAuth session print() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9a21cfa44b77..0ded14c382c1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2328,7 +2328,41 @@ def _model_flow_openai_codex(config, current_model=""): from hermes_cli.codex_models import get_codex_model_ids status = get_codex_auth_status() - if not status.get("logged_in"): + if status.get("logged_in"): + print(" OpenAI Codex credentials: ✓") + print() + print(" 1. Use existing credentials") + print(" 2. Reauthenticate (new OAuth login)") + print(" 3. Cancel") + print() + try: + choice = input(" Choice [1/2/3]: ").strip() + except (KeyboardInterrupt, EOFError): + choice = "1" + + if choice == "2": + print("Starting a fresh OpenAI Codex login...") + print() + try: + mock_args = argparse.Namespace() + _login_openai_codex( + mock_args, + PROVIDER_REGISTRY["openai-codex"], + force_new_login=True, + ) + except SystemExit: + print("Login cancelled or failed.") + return + except Exception as exc: + print(f"Login failed: {exc}") + return + status = get_codex_auth_status() + if not status.get("logged_in"): + print("Login failed.") + return + elif choice == "3": + return + else: print("Not logged into OpenAI Codex. Starting login...") print() try: diff --git a/run_agent.py b/run_agent.py index e68e8f544e0d..8994f206f713 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9532,28 +9532,47 @@ def _stop_spinner(): response_invalid = True error_details.append("response is None") else: - # output_text fallback: stream backfill may have failed - # but normalize can still recover from output_text - _out_text = getattr(response, "output_text", None) - _out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else "" - if _out_text_stripped: - logger.debug( - "Codex response.output is empty but output_text is present " - "(%d chars); deferring to normalization.", - len(_out_text_stripped), + # Provider returned a terminal failure (e.g. quota exhaustion). + # Treat as invalid so the fallback chain is triggered instead of + # letting the error bubble up outside the retry/fallback loop. + _codex_resp_status = str(getattr(response, "status", "") or "").strip().lower() + if _codex_resp_status in {"failed", "cancelled"}: + _codex_error_obj = getattr(response, "error", None) + _codex_error_msg = ( + _codex_error_obj.get("message") if isinstance(_codex_error_obj, dict) + else str(_codex_error_obj) if _codex_error_obj + else f"Responses API returned status '{_codex_resp_status}'" ) - else: - _resp_status = getattr(response, "status", None) - _resp_incomplete = getattr(response, "incomplete_details", None) - logger.warning( - "Codex response.output is empty after stream backfill " - "(status=%s, incomplete_details=%s, model=%s). %s", - _resp_status, _resp_incomplete, - getattr(response, "model", None), - f"api_mode={self.api_mode} provider={self.provider}", + logging.warning( + "Codex response status='%s' (error=%s). Routing to fallback. %s", + _codex_resp_status, _codex_error_msg, + self._client_log_context(), ) response_invalid = True - error_details.append("response.output is empty") + error_details.append(f"response.status={_codex_resp_status}: {_codex_error_msg}") + else: + # output_text fallback: stream backfill may have failed + # but normalize can still recover from output_text + _out_text = getattr(response, "output_text", None) + _out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else "" + if _out_text_stripped: + logger.debug( + "Codex response.output is empty but output_text is present " + "(%d chars); deferring to normalization.", + len(_out_text_stripped), + ) + else: + _resp_status = getattr(response, "status", None) + _resp_incomplete = getattr(response, "incomplete_details", None) + logger.warning( + "Codex response.output is empty after stream backfill " + "(status=%s, incomplete_details=%s, model=%s). %s", + _resp_status, _resp_incomplete, + getattr(response, "model", None), + f"api_mode={self.api_mode} provider={self.provider}", + ) + response_invalid = True + error_details.append("response.output is empty") elif self.api_mode == "anthropic_messages": _tv = self._get_transport() if not _tv.validate_response(response): diff --git a/scripts/release.py b/scripts/release.py index 5da91df18cb6..9b75748f2e82 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -171,6 +171,8 @@ "satelerd@gmail.com": "satelerd", "dan@danlynn.com": "danklynn", "mattmaximo@hotmail.com": "MattMaximo", + "149063006+j3ffffff@users.noreply.github.com": "j3ffffff", + "A-FdL-Prog@users.noreply.github.com": "A-FdL-Prog", "numman.ali@gmail.com": "nummanali", "rohithsaimidigudla@gmail.com": "whitehatjr1001", "0xNyk@users.noreply.github.com": "0xNyk", diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index ddcaf1721f38..ad5ce40f3db3 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -4,6 +4,7 @@ import time import base64 from pathlib import Path +from types import SimpleNamespace import pytest import yaml @@ -15,8 +16,10 @@ _read_codex_tokens, _save_codex_tokens, _import_codex_cli_tokens, + _login_openai_codex, get_codex_auth_status, get_provider_auth_state, + refresh_codex_oauth_pure, resolve_codex_runtime_credentials, resolve_provider, ) @@ -190,3 +193,161 @@ def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch): assert creds["source"] == "hermes-auth-store" assert creds["provider"] == "openai-codex" assert creds["base_url"] == DEFAULT_CODEX_BASE_URL + + +class _StubHTTPResponse: + def __init__(self, status_code: int, payload): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if isinstance(payload, (dict, list)) else str(payload) + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +class _StubHTTPClient: + def __init__(self, response): + self._response = response + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def post(self, *args, **kwargs): + return self._response + + +def _patch_httpx(monkeypatch, response): + def _factory(*args, **kwargs): + return _StubHTTPClient(response) + + monkeypatch.setattr("hermes_cli.auth.httpx.Client", _factory) + + +def test_refresh_parses_openai_nested_error_shape_refresh_token_reused(monkeypatch): + """OpenAI returns {"error": {"code": "refresh_token_reused", "message": "..."}} + — parser must surface relogin_required and the dedicated message. + """ + response = _StubHTTPResponse( + 401, + { + "error": { + "message": "Your refresh token has already been used to generate a new access token. Please try signing in again.", + "type": "invalid_request_error", + "param": None, + "code": "refresh_token_reused", + } + }, + ) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("a-tok", "r-tok") + + err = exc_info.value + assert err.code == "refresh_token_reused" + assert err.relogin_required is True + # The existing dedicated branch should override the message with actionable guidance. + assert "already consumed by another client" in str(err) + + +def test_refresh_parses_openai_nested_error_shape_generic_code(monkeypatch): + """Nested error with arbitrary code still surfaces code + message.""" + response = _StubHTTPResponse( + 400, + { + "error": { + "message": "Invalid client credentials.", + "type": "invalid_request_error", + "code": "invalid_client", + } + }, + ) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("a-tok", "r-tok") + + err = exc_info.value + assert err.code == "invalid_client" + assert "Invalid client credentials." in str(err) + + +def test_refresh_parses_oauth_spec_flat_error_shape_invalid_grant(monkeypatch): + """Fallback path: OAuth spec-shape {"error": "invalid_grant", "error_description": "..."} + must still map to relogin_required=True via the existing code set. + """ + response = _StubHTTPResponse( + 400, + { + "error": "invalid_grant", + "error_description": "Refresh token is expired or revoked.", + }, + ) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("a-tok", "r-tok") + + err = exc_info.value + assert err.code == "invalid_grant" + assert err.relogin_required is True + assert "Refresh token is expired or revoked." in str(err) + + +def test_refresh_falls_back_to_generic_message_on_unparseable_body(monkeypatch): + """No JSON body → generic 'with status 401' message; 401 always forces relogin.""" + response = _StubHTTPResponse(401, ValueError("not json")) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("a-tok", "r-tok") + + err = exc_info.value + assert err.code == "codex_refresh_failed" + # 401/403 from the token endpoint always means the refresh token is + # invalid/expired — force relogin even without a parseable error body. + assert err.relogin_required is True + assert "status 401" in str(err) + + +def test_login_openai_codex_force_new_login_skips_existing_reuse_prompt(monkeypatch): + called = {"device_login": 0} + + monkeypatch.setattr( + "hermes_cli.auth.resolve_codex_runtime_credentials", + lambda: {"base_url": DEFAULT_CODEX_BASE_URL}, + ) + monkeypatch.setattr( + "hermes_cli.auth._import_codex_cli_tokens", + lambda: {"access_token": "cli-at", "refresh_token": "cli-rt"}, + ) + monkeypatch.setattr( + "hermes_cli.auth._codex_device_code_login", + lambda: { + "tokens": {"access_token": "fresh-at", "refresh_token": "fresh-rt"}, + "last_refresh": "2026-04-01T00:00:00Z", + "base_url": DEFAULT_CODEX_BASE_URL, + }, + ) + + def _fake_save(tokens, last_refresh=None): + called["device_login"] += 1 + called["tokens"] = dict(tokens) + called["last_refresh"] = last_refresh + + monkeypatch.setattr("hermes_cli.auth._save_codex_tokens", _fake_save) + monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda *args, **kwargs: "/tmp/config.yaml") + monkeypatch.setattr( + "builtins.input", + lambda prompt="": (_ for _ in ()).throw(AssertionError("force_new_login should not prompt for reuse/import")), + ) + + _login_openai_codex(SimpleNamespace(), PROVIDER_REGISTRY["openai-codex"], force_new_login=True) + + assert called["device_login"] == 1 + assert called["tokens"]["access_token"] == "fresh-at" diff --git a/tests/hermes_cli/test_codex_models.py b/tests/hermes_cli/test_codex_models.py index cffce2a0e445..949d1c8e2395 100644 --- a/tests/hermes_cli/test_codex_models.py +++ b/tests/hermes_cli/test_codex_models.py @@ -72,7 +72,9 @@ 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}, @@ -107,6 +109,83 @@ def _fake_prompt_model_selection(model_ids, current_model=""): 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="": 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="": 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 ──────────────────────────