Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 100 additions & 9 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}")
Expand All @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions tests/run_agent/test_anthropic_error_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down