diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 9c33200107..2239953361 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -110,18 +110,38 @@ def _display_source(source: str) -> str: return source.split(":", 1)[1] if source.startswith("manual:") else source +def _exhausted_label(entry) -> str: + 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" + + 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" + + return "exhausted" + + + def _format_exhausted_status(entry) -> str: if entry.last_status != STATUS_EXHAUSTED: return "" + label = _exhausted_label(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 "" 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 +153,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 fb749b6ae7..69fc2063fe 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -541,7 +541,7 @@ def test_auth_list_does_not_call_mutating_select(monkeypatch, capsys): assert "primary" in out -def test_auth_list_shows_exhausted_cooldown(monkeypatch, capsys): +def test_auth_list_shows_rate_limited_cooldown(monkeypatch, capsys): from hermes_cli.auth_commands import auth_list_command class _Entry: @@ -569,7 +569,41 @@ 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 "59m 30s left" in out