mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(cli): treat credential-resolution 429 as quota, not auth
A Codex quota wall at startup was labeled "Primary auth failed" and exited 1, so Kanban counted a failure and operators went looking for a bad token. Use is_rate_limited_auth_error() for the fallback notice and EX_TEMPFAIL (75) when HERMES_KANBAN_TASK is set.
This commit is contained in:
1 parent
214623389a
commit
89096ba56c
5 files changed
+184
-15
No files matched your search
@@ -4140,7 +4140,7 @@ _TERMINAL_PROVIDER_REASONS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
def _single_query_exit_code(result) -> int:
|
||||
def _single_query_exit_code(result, *, credentials_rate_limited: bool = False) -> int:
|
||||
"""Map a one-shot turn result onto a process exit code, for both `-q` and `-Q`.
|
||||
|
||||
0 only when the turn completed; 130 when it was interrupted; 1 when it failed, stopped
|
||||
@@ -4149,10 +4149,15 @@ def _single_query_exit_code(result) -> int:
|
||||
failed purely on a provider rate-limit / billing wall exits ``KANBAN_RATE_LIMIT_EXIT_CODE``
|
||||
(EX_TEMPFAIL): the dispatcher books that run ``rate_limited`` and requeues the task
|
||||
WITHOUT counting a failure, so a quota window or a provider outage cannot trip the breaker.
|
||||
One that failed on a terminal provider error (credential revoked, model gone) exits
|
||||
``KANBAN_TERMINAL_PROVIDER_EXIT_CODE`` (EX_CONFIG): the dispatcher blocks the card at once.
|
||||
The same sentinel applies when credential resolution itself is a quota/rate-limit
|
||||
AuthError (no turn result object is produced). One that failed on a terminal provider
|
||||
error (credential revoked, model gone) exits ``KANBAN_TERMINAL_PROVIDER_EXIT_CODE``
|
||||
(EX_CONFIG): the dispatcher blocks the card at once.
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
if credentials_rate_limited and os.environ.get("HERMES_KANBAN_TASK"):
|
||||
from hermes_cli.kanban_db import KANBAN_RATE_LIMIT_EXIT_CODE
|
||||
return KANBAN_RATE_LIMIT_EXIT_CODE
|
||||
return 1
|
||||
if result.get("interrupted"):
|
||||
return 130
|
||||
@@ -4608,10 +4613,12 @@ def _run_single_query_mode(cli, query, image, quiet, oneshot, stream_json: bool
|
||||
emitter.attach(cli.agent)
|
||||
_run_quiet_single_query(cli, effective_query, emitter=emitter)
|
||||
|
||||
fail_code = _single_query_exit_code(
|
||||
None, credentials_rate_limited=getattr(cli, "_credentials_rate_limited", False))
|
||||
if emitter is not None:
|
||||
emitter.emit_result({"failed": True, "error": "credentials or agent init failed"},
|
||||
session_id=cli.session_id or "", exit_code=1)
|
||||
exit_single_query(1) # credentials or agent init failed
|
||||
session_id=cli.session_id or "", exit_code=fail_code)
|
||||
exit_single_query(fail_code) # credentials or agent init failed
|
||||
# No welcome banner (~420 ms cold); session id / resume hint come from _print_exit_summary().
|
||||
_query_label = query or ("[image attached]" if single_query_images else "")
|
||||
if _query_label:
|
||||
@@ -4629,7 +4636,9 @@ def _run_single_query_mode(cli, query, image, quiet, oneshot, stream_json: bool
|
||||
cli._print_exit_summary(clear_screen=False)
|
||||
# Same exit contract as `-Q`: scripts and the Kanban dispatcher read the outcome from
|
||||
# the exit code. This path used to fall through to an implicit 0 for every outcome.
|
||||
exit_single_query(_single_query_exit_code(cli._last_turn_result))
|
||||
exit_single_query(_single_query_exit_code(
|
||||
cli._last_turn_result,
|
||||
credentials_rate_limited=getattr(cli, "_credentials_rate_limited", False)))
|
||||
finally:
|
||||
_finalize_single_query(cli)
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ class CLIAgentSetupMixin:
|
||||
_primary_exc = None
|
||||
runtime = None
|
||||
_model_at_entry = self.model
|
||||
self._credentials_rate_limited = False
|
||||
try:
|
||||
# target_model: the ladder's model-keyed rungs (Zen/Go api_mode, Copilot/Nous
|
||||
# api_mode) must see the model this CLI will actually send, not config's `default`,
|
||||
@@ -248,6 +249,8 @@ class CLIAgentSetupMixin:
|
||||
if runtime is not None:
|
||||
_primary_exc = None
|
||||
if runtime is None:
|
||||
from hermes_cli.auth import is_rate_limited_auth_error
|
||||
self._credentials_rate_limited = bool(_primary_exc) and is_rate_limited_auth_error(_primary_exc)
|
||||
message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed."
|
||||
if getattr(self, "tool_progress_mode", "full") == "off":
|
||||
print(message, file=sys.stderr) # quiet/stream-json: stdout is machine-readable
|
||||
@@ -357,7 +360,7 @@ class CLIAgentSetupMixin:
|
||||
order and switch the CLI's requested_provider/model to the first that resolves.
|
||||
None when the error is not auth-related or no fallback resolves."""
|
||||
from cli import _cprint, logger
|
||||
from hermes_cli.auth import AuthError
|
||||
from hermes_cli.auth import AuthError, is_rate_limited_auth_error
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
if not isinstance(primary_exc, AuthError):
|
||||
return None
|
||||
@@ -378,13 +381,23 @@ class CLIAgentSetupMixin:
|
||||
if _fb_api_key:
|
||||
_fb_kwargs["explicit_api_key"] = _fb_api_key
|
||||
runtime = resolve_runtime_provider(**_fb_kwargs)
|
||||
logger.warning(
|
||||
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
|
||||
primary_exc, _fb_provider, _fb_model)
|
||||
from gateway.warning_notifications import render_notification
|
||||
render_notification(
|
||||
lambda: _cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}"),
|
||||
platform="cli")
|
||||
if is_rate_limited_auth_error(primary_exc):
|
||||
logger.warning(
|
||||
"Primary provider rate-limited (429): %s. Falling through to fallback: %s/%s",
|
||||
primary_exc, _fb_provider, _fb_model)
|
||||
from gateway.warning_notifications import render_notification
|
||||
render_notification(
|
||||
lambda: _cprint(
|
||||
f"⚠️ Primary provider quota exhausted — switching to fallback: {_fb_provider} / {_fb_model}"),
|
||||
platform="cli")
|
||||
else:
|
||||
logger.warning(
|
||||
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
|
||||
primary_exc, _fb_provider, _fb_model)
|
||||
from gateway.warning_notifications import render_notification
|
||||
render_notification(
|
||||
lambda: _cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}"),
|
||||
platform="cli")
|
||||
self.requested_provider = _fb_provider
|
||||
self.model = _fb_model
|
||||
# reasoning_config follows the swap in _ensure_runtime_credentials (the only caller).
|
||||
|
||||
@@ -170,7 +170,7 @@ def test_cli_provider_fallback_notice_honors_policy(tmp_path, monkeypatch, setti
|
||||
from hermes_cli import cli_agent_setup_mixin as m
|
||||
out = []
|
||||
monkeypatch.setattr(climod, "_cprint", lambda s: out.append(s))
|
||||
_cli_block(m, "from gateway.warning_notifications import render_notification\n render_notification(\n lambda: _cprint(f\"⚠️ Primary auth failed",
|
||||
_cli_block(m, "from gateway.warning_notifications import render_notification\n render_notification(\n lambda: _cprint(f\"⚠️ Primary auth failed",
|
||||
"self.requested_provider = _fb_provider", {"_fb_provider": "p", "_fb_model": "m"})
|
||||
assert (len(out) == 1 and "Primary auth failed" in out[0]) is _visible(setting)
|
||||
|
||||
|
||||
@@ -363,6 +363,107 @@ def test_fallback_runtime_resolves_the_fallback_entry_model(monkeypatch, tmp_pat
|
||||
assert runtime["base_url"] == "https://opencode.ai/zen/go/v1"
|
||||
|
||||
|
||||
def _quota_auth_error():
|
||||
from hermes_cli.auth import CODEX_RATE_LIMITED_CODE, AuthError
|
||||
return AuthError(
|
||||
"Codex provider quota exhausted (429); retry after 1839s. Credentials are still valid.",
|
||||
provider="openai-codex",
|
||||
code=CODEX_RATE_LIMITED_CODE,
|
||||
relogin_required=False,
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_runtime_quota_outage_is_not_labeled_auth_failure(monkeypatch, tmp_path):
|
||||
"""A 429 at credential resolution is quota, not bad credentials (#117482)."""
|
||||
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
|
||||
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
printed = []
|
||||
monkeypatch.setattr("cli._cprint", printed.append, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda **kw: {"provider": "custom", "base_url": "http://x/v1", "api_key": "k"},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.fallback_config.resolve_entry_api_key", lambda entry: "k")
|
||||
|
||||
shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin)
|
||||
shell._fallback_model = [{"provider": "custom", "model": "local-model"}]
|
||||
runtime = shell._resolve_fallback_runtime(_quota_auth_error())
|
||||
|
||||
assert runtime is not None
|
||||
assert printed
|
||||
assert "quota exhausted" in printed[-1]
|
||||
assert "auth failed" not in printed[-1]
|
||||
|
||||
|
||||
def test_fallback_runtime_keeps_auth_failed_wording_for_bad_credentials(monkeypatch, tmp_path):
|
||||
from hermes_cli.auth import AuthError
|
||||
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
|
||||
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
printed = []
|
||||
monkeypatch.setattr("cli._cprint", printed.append, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda **kw: {"provider": "custom", "base_url": "http://x/v1", "api_key": "k"},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.fallback_config.resolve_entry_api_key", lambda entry: "k")
|
||||
|
||||
shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin)
|
||||
shell._fallback_model = [{"provider": "custom", "model": "local-model"}]
|
||||
runtime = shell._resolve_fallback_runtime(
|
||||
AuthError("no key", provider="openai-codex", code="missing_api_key"))
|
||||
|
||||
assert runtime is not None
|
||||
assert printed
|
||||
assert "Primary auth failed" in printed[-1]
|
||||
assert "quota exhausted" not in printed[-1]
|
||||
|
||||
|
||||
def test_ensure_runtime_credentials_records_quota_vs_bad_key(monkeypatch, tmp_path):
|
||||
"""Kanban workers need this flag: a quota wall at startup is not a worker failure (#117482)."""
|
||||
from hermes_cli.auth import AuthError
|
||||
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
|
||||
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr("cli._cprint", lambda *a, **k: None, raising=False)
|
||||
|
||||
def _raise_quota(**kw):
|
||||
raise _quota_auth_error()
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _raise_quota)
|
||||
|
||||
quota_shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin)
|
||||
quota_shell.model = "gpt-x"
|
||||
quota_shell.requested_provider = "openai-codex"
|
||||
quota_shell._explicit_api_key = None
|
||||
quota_shell._explicit_base_url = None
|
||||
quota_shell._fallback_model = []
|
||||
quota_shell.tool_progress_mode = "off"
|
||||
assert quota_shell._ensure_runtime_credentials() is False
|
||||
assert quota_shell._credentials_rate_limited is True
|
||||
|
||||
def _raise_missing(**kw):
|
||||
raise AuthError("no key", provider="openai-codex", code="missing_api_key")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _raise_missing)
|
||||
bad_shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin)
|
||||
bad_shell.model = "gpt-x"
|
||||
bad_shell.requested_provider = "openai-codex"
|
||||
bad_shell._explicit_api_key = None
|
||||
bad_shell._explicit_base_url = None
|
||||
bad_shell._fallback_model = []
|
||||
bad_shell.tool_progress_mode = "off"
|
||||
assert bad_shell._ensure_runtime_credentials() is False
|
||||
assert bad_shell._credentials_rate_limited is False
|
||||
|
||||
|
||||
def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch):
|
||||
cli = _import_cli()
|
||||
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
|
||||
|
||||
@@ -86,3 +86,49 @@ def test_dispatcher_spawned_worker_keeps_a_plain_failure_at_one(monkeypatch):
|
||||
)
|
||||
def test_a_plain_one_shot_run_reports_its_outcome(monkeypatch, turn_result, expected):
|
||||
assert _run_non_quiet(monkeypatch, turn_result) == expected
|
||||
|
||||
|
||||
def test_kanban_worker_quota_during_credential_resolution_is_tempfail_not_auth(monkeypatch):
|
||||
"""A 429 at startup never produces a turn result; the worker must still exit 75 (#117482)."""
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc123")
|
||||
assert cli._single_query_exit_code(None, credentials_rate_limited=True) == KANBAN_RATE_LIMIT_EXIT_CODE
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK")
|
||||
assert cli._single_query_exit_code(None, credentials_rate_limited=True) == 1
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc123")
|
||||
assert cli._single_query_exit_code(None, credentials_rate_limited=False) == 1
|
||||
|
||||
|
||||
def test_quiet_kanban_worker_exits_tempfail_when_credentials_are_rate_limited(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc123")
|
||||
monkeypatch.setattr(cli, "_should_seed_interactive", lambda *a, **k: False)
|
||||
monkeypatch.setattr(cli, "_collect_query_images", lambda q, i: (q, []))
|
||||
monkeypatch.setattr(cli, "_collect_kanban_task_images", lambda imgs: [])
|
||||
monkeypatch.setattr(cli, "_finalize_single_query", lambda c: None)
|
||||
stub = SimpleNamespace(
|
||||
_claim_active_session=lambda *a, **k: True,
|
||||
_ensure_runtime_credentials=lambda: False,
|
||||
_credentials_rate_limited=True,
|
||||
session_id="s1",
|
||||
model="gpt-x",
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cli._run_single_query_mode(stub, "do the thing", None, True, True)
|
||||
assert exc.value.code == KANBAN_RATE_LIMIT_EXIT_CODE
|
||||
|
||||
|
||||
def test_quiet_kanban_worker_still_exits_one_on_real_credential_failure(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc123")
|
||||
monkeypatch.setattr(cli, "_should_seed_interactive", lambda *a, **k: False)
|
||||
monkeypatch.setattr(cli, "_collect_query_images", lambda q, i: (q, []))
|
||||
monkeypatch.setattr(cli, "_collect_kanban_task_images", lambda imgs: [])
|
||||
monkeypatch.setattr(cli, "_finalize_single_query", lambda c: None)
|
||||
stub = SimpleNamespace(
|
||||
_claim_active_session=lambda *a, **k: True,
|
||||
_ensure_runtime_credentials=lambda: False,
|
||||
_credentials_rate_limited=False,
|
||||
session_id="s1",
|
||||
model="gpt-x",
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cli._run_single_query_mode(stub, "do the thing", None, True, True)
|
||||
assert exc.value.code == 1
|
||||
Reference in new issue
Block a user