diff --git a/run_agent.py b/run_agent.py index 793ddd6750f0f..0fb6d92adc648 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2182,6 +2182,19 @@ def _save_trajectory(self, messages: List[Dict[str, Any]], user_query: str, comp trajectory = self._convert_to_trajectory_format(messages, user_query, completed) _save_trajectory_to_file(trajectory, self.model, completed) + @staticmethod + def _extract_api_error_message(error: Exception) -> Optional[str]: + """Extract the most useful provider-supplied error message, if present.""" + body = getattr(error, "body", None) + if isinstance(body, dict): + payload = body.get("error") if isinstance(body.get("error"), dict) else body + if isinstance(payload, dict): + for key in ("message", "error_description", "detail"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + @staticmethod def _summarize_api_error(error: Exception) -> str: """Extract a human-readable one-liner from an API error. @@ -2210,19 +2223,65 @@ def _summarize_api_error(error: Exception) -> str: return " — ".join(parts) # JSON body errors from OpenAI/Anthropic SDKs - body = getattr(error, "body", None) - if isinstance(body, dict): - msg = body.get("error", {}).get("message") if isinstance(body.get("error"), dict) else body.get("message") - if msg: - status_code = getattr(error, "status_code", None) - prefix = f"HTTP {status_code}: " if status_code else "" - return f"{prefix}{msg[:300]}" + msg = AIAgent._extract_api_error_message(error) + if msg: + status_code = getattr(error, "status_code", None) + prefix = f"HTTP {status_code}: " if status_code else "" + return f"{prefix}{msg[:300]}" # Fallback: truncate the raw string but give more room than 200 chars status_code = getattr(error, "status_code", None) prefix = f"HTTP {status_code}: " if status_code else "" return f"{prefix}{raw[:500]}" + @staticmethod + def _is_anthropic_extra_usage_exhausted( + error: Exception, + *, + provider: Optional[str] = None, + base_url: Optional[str] = None, + ) -> bool: + """Detect Anthropic's subscription/OAuth extra-usage exhaustion error.""" + if getattr(error, "status_code", None) != 400: + return False + + provider_lower = (provider or "").lower() + base_lower = (base_url or "").lower() + if provider_lower != "anthropic" and "anthropic.com" not in base_lower: + return False + + error_msg = (AIAgent._extract_api_error_message(error) or str(error)).lower() + return ( + ("out of extra usage" in error_msg or "claude.ai/settings/usage" in error_msg) + and "long context" not in error_msg + ) + + def _format_user_visible_api_error( + self, + error: Exception, + *, + provider: Optional[str] = None, + model: Optional[str] = None, + base_url: Optional[str] = None, + ) -> str: + """Format an actionable user-facing error string for failed API calls.""" + if self._is_anthropic_extra_usage_exhausted( + error, + provider=provider, + base_url=base_url, + ): + summary = self._summarize_api_error(error) + return ( + "Anthropic reported that this account is out of extra usage for the " + "API path Hermes uses (Claude subscription/OAuth via third-party " + f"clients). Provider message: {summary}. Restarting or re-authenticating " + "usually will not fix it until usage is added or you switch auth/provider. " + "Next steps: add usage at https://claude.ai/settings/usage, switch to " + "another provider with `hermes model`, or add an Anthropic API key " + "with `hermes auth add anthropic`." + ) + return str(error) + def _mask_api_key_for_logs(self, key: Optional[str]) -> Optional[str]: if not key: return None @@ -8405,9 +8464,23 @@ def _stop_spinner(): ])) and not is_context_length_error if is_client_error: + anthropic_extra_usage_error = self._is_anthropic_extra_usage_exhausted( + api_error, + provider=_provider, + base_url=_base, + ) + user_visible_error = self._format_user_visible_api_error( + api_error, + provider=_provider, + model=_model, + base_url=_base, + ) # Try fallback before aborting — a different provider # may not have the same issue (rate limit, auth, etc.) - self._emit_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...") + if anthropic_extra_usage_error: + self._emit_status("⚠️ Anthropic extra usage is exhausted for this API path — trying fallback...") + else: + self._emit_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...") if self._try_activate_fallback(): retry_count = 0 continue @@ -8434,6 +8507,24 @@ def _stop_spinner(): self._vprint(f"{self.log_prefix} • Does your account have access to {_model}?", force=True) if "openrouter" in str(_base).lower(): self._vprint(f"{self.log_prefix} • Check credits: https://openrouter.ai/settings/credits", force=True) + elif anthropic_extra_usage_error: + self._vprint( + f"{self.log_prefix} 💡 Anthropic says this account is out of extra usage for " + f"Hermes / third-party API requests.", + force=True, + ) + self._vprint( + f"{self.log_prefix} • Add usage: https://claude.ai/settings/usage", + force=True, + ) + self._vprint( + f"{self.log_prefix} • Switch provider: hermes model", + force=True, + ) + self._vprint( + f"{self.log_prefix} • Or add an Anthropic API key: hermes auth add anthropic", + force=True, + ) else: self._vprint(f"{self.log_prefix} 💡 This type of error won't be fixed by retrying.", force=True) logging.error(f"{self.log_prefix}Non-retryable client error: {api_error}") @@ -8456,7 +8547,7 @@ def _stop_spinner(): "api_calls": api_call_count, "completed": False, "failed": True, - "error": str(api_error), + "error": user_visible_error, } if retry_count >= max_retries: diff --git a/tests/run_agent/test_anthropic_error_handling.py b/tests/run_agent/test_anthropic_error_handling.py index 3d7660aa8d60e..8104932f5c9a8 100644 --- a/tests/run_agent/test_anthropic_error_handling.py +++ b/tests/run_agent/test_anthropic_error_handling.py @@ -81,6 +81,22 @@ def __init__(self): self.status_code = 400 +class _ExtraUsageError(Exception): + """Simulates Anthropic 400 extra-usage exhaustion on subscription auth.""" + def __init__(self): + message = "You're out of extra usage. Add more at claude.ai/settings/usage and keep going." + super().__init__(message) + self.status_code = 400 + self.body = { + "type": "error", + "error": { + "type": "invalid_request_error", + "message": message, + }, + "request_id": "", + } + + class _UnauthorizedError(Exception): """Simulates Anthropic 401 unauthorized error.""" def __init__(self): @@ -238,6 +254,20 @@ def test_400_bad_request_is_non_retryable(monkeypatch): assert "400" in str(result.get("final_response", "")) +def test_400_extra_usage_error_gets_actionable_guidance(monkeypatch): + """Anthropic extra-usage exhaustion should surface specific next steps.""" + agent_cls = _make_agent_cls(_ExtraUsageError) + result = _run_with_agent(monkeypatch, agent_cls) + response = str(result.get("final_response", "")) + + assert result["api_calls"] == 1 + assert "extra usage" in response.lower() + assert "third-party" in response.lower() + assert "claude.ai/settings/usage" in response + assert "hermes model" in response + assert "hermes auth add anthropic" in response + + def test_500_server_error_is_retried_and_recovers(monkeypatch): """500 should be retried with backoff. First call fails, second succeeds.""" agent_cls = _make_agent_cls(_ServerError, recover_after=1)