diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 9c3320010..aa092a058 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -110,18 +110,40 @@ def _display_source(source: str) -> str: return source.split(":", 1)[1] if source.startswith("manual:") else source +def _classify_exhausted_status(entry) -> tuple[str, bool]: + code = getattr(entry, "last_error_code", None) + reason = str(getattr(entry, "last_error_reason", "") or "").strip().lower() + message = str(getattr(entry, "last_error_message", "") or "").strip().lower() + + if code == 429 or any(token in reason for token in ("rate_limit", "usage_limit", "quota", "exhausted")) or any( + token in message for token in ("rate limit", "usage limit", "quota", "too many requests") + ): + return "rate-limited", True + + if code in {401, 403} or any(token in reason for token in ("invalid_token", "invalid_grant", "unauthorized", "forbidden", "auth")) or any( + token in message for token in ("unauthorized", "forbidden", "expired", "revoked", "invalid token", "authentication") + ): + return "auth failed", False + + return "exhausted", True + + + def _format_exhausted_status(entry) -> str: if entry.last_status != STATUS_EXHAUSTED: return "" + label, show_retry_window = _classify_exhausted_status(entry) reason = getattr(entry, "last_error_reason", None) reason_text = f" {reason}" if isinstance(reason, str) and reason.strip() else "" code = f" ({entry.last_error_code})" if entry.last_error_code else "" + if not show_retry_window: + return f" {label}{reason_text}{code} (re-auth may be required)" exhausted_until = _exhausted_until(entry) if exhausted_until is None: - return f" exhausted{reason_text}{code}" + return f" {label}{reason_text}{code}" remaining = max(0, int(math.ceil(exhausted_until - time.time()))) if remaining <= 0: - return f" exhausted{reason_text}{code} (ready to retry)" + return f" {label}{reason_text}{code} (ready to retry)" minutes, seconds = divmod(remaining, 60) hours, minutes = divmod(minutes, 60) days, hours = divmod(hours, 24) @@ -133,7 +155,7 @@ def _format_exhausted_status(entry) -> str: wait = f"{minutes}m {seconds}s" else: wait = f"{seconds}s" - return f" exhausted{reason_text}{code} ({wait} left)" + return f" {label}{reason_text}{code} ({wait} left)" def auth_add_command(args) -> None: diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 388386a6b..23602c9f0 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -654,10 +654,45 @@ def test_auth_list_shows_exhausted_cooldown(monkeypatch, capsys): auth_list_command(_Args()) out = capsys.readouterr().out - assert "exhausted (429)" in out + assert "rate-limited (429)" in out assert "59m 30s left" in out +def test_auth_list_shows_auth_failure_when_exhausted_entry_is_unauthorized(monkeypatch, capsys): + from hermes_cli.auth_commands import auth_list_command + + class _Entry: + id = "cred-1" + label = "primary" + auth_type = "oauth" + source = "manual:device_code" + last_status = "exhausted" + last_error_code = 401 + last_error_reason = "invalid_token" + last_error_message = "Access token expired or revoked." + last_status_at = 1000.0 + + class _Pool: + def entries(self): + return [_Entry()] + + def peek(self): + return None + + monkeypatch.setattr("hermes_cli.auth_commands.load_pool", lambda provider: _Pool()) + monkeypatch.setattr("hermes_cli.auth_commands.time.time", lambda: 1030.0) + + class _Args: + provider = "openai-codex" + + auth_list_command(_Args()) + + out = capsys.readouterr().out + assert "auth failed invalid_token (401)" in out + assert "re-auth may be required" in out + assert "left" not in out + + def test_auth_list_prefers_explicit_reset_time(monkeypatch, capsys): from hermes_cli.auth_commands import auth_list_command