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
25 changes: 25 additions & 0 deletions agent/error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class FailoverReason(enum.Enum):
thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid
long_context_tier = "long_context_tier" # Anthropic "extra usage" tier gate
oauth_long_context_beta_forbidden = "oauth_long_context_beta_forbidden" # Anthropic OAuth subscription rejects 1M context beta — disable beta and retry
anthropic_oauth_tools_overage = "anthropic_oauth_tools_overage" # Anthropic Max OAuth rejects tool-use as overage — fallback or abort with guidance
llama_cpp_grammar_pattern = "llama_cpp_grammar_pattern" # llama.cpp json-schema-to-grammar rejects regex escapes in `pattern` / `format` — strip from tools and retry

# Catch-all
Expand Down Expand Up @@ -336,6 +337,8 @@ def classify_api_error(
approx_tokens: int = 0,
context_length: int = 200000,
num_messages: int = 0,
is_anthropic_oauth: bool = False,
has_tools: bool = False,
) -> ClassifiedError:
"""Classify an API error into a structured recovery recommendation.

Expand All @@ -355,6 +358,8 @@ def classify_api_error(
model: Current model slug.
approx_tokens: Approximate token count of the current context.
context_length: Maximum context length for the current model.
is_anthropic_oauth: Whether the active Anthropic auth path uses OAuth.
has_tools: Whether the failed request included tool definitions.

Returns:
ClassifiedError with reason and recovery action hints.
Expand Down Expand Up @@ -471,6 +476,26 @@ def _result(reason: FailoverReason, **overrides) -> ClassifiedError:
should_compress=False,
)

# Claude Max/Pro OAuth can authenticate normal /v1/messages calls while
# deterministically rejecting the same request once the `tools` array is
# present, with the misleading "out of extra usage" billing text. Treat it
# as a distinct non-retryable lane-routing failure so the agent can switch
# fallback providers immediately or print the right user guidance.
if (
status_code == 400
and provider_lower == "anthropic"
and is_anthropic_oauth
and has_tools
and "out of extra usage" in error_msg
and "claude.ai/settings/usage" in error_msg
):
return _result(
FailoverReason.anthropic_oauth_tools_overage,
retryable=False,
should_rotate_credential=False,
should_fallback=True,
)

# llama.cpp's ``json-schema-to-grammar`` converter (used by its OAI
# server to build GBNF tool-call parsers) rejects regex escape classes
# like ``\d``/``\w``/``\s`` and most ``format`` values. MCP servers
Expand Down
25 changes: 24 additions & 1 deletion run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -12178,6 +12178,8 @@ def _stop_spinner():
approx_tokens=approx_tokens,
context_length=_ctx_len,
num_messages=len(api_messages) if api_messages else 0,
is_anthropic_oauth=bool(getattr(self, "_is_anthropic_oauth", False)),
has_tools=bool(api_kwargs and api_kwargs.get("tools")),
)
logger.debug(
"Error classified: reason=%s status=%s retryable=%s compress=%s rotate=%s fallback=%s",
Expand Down Expand Up @@ -12896,7 +12898,28 @@ def _stop_spinner():
self._vprint(f"{self.log_prefix} 🔌 Provider: {_provider} Model: {_model}", force=True)
self._vprint(f"{self.log_prefix} 🌐 Endpoint: {_base}", force=True)
# Actionable guidance for common auth errors
if classified.is_auth or classified.reason == FailoverReason.billing:
if classified.reason == FailoverReason.anthropic_oauth_tools_overage:
self._vprint(
f"{self.log_prefix} 💡 Anthropic rejected this tools-carrying OAuth request as overage.",
force=True,
)
self._vprint(
f"{self.log_prefix} Your Claude Max/Pro lane may still have usage available, but external OAuth tool-use",
force=True,
)
self._vprint(
f"{self.log_prefix} appears to be routed to an overage/API-credit lane that is disabled.",
force=True,
)
self._vprint(
f"{self.log_prefix} Options: configure a fallback provider, add API credits at https://claude.ai/settings/usage,",
force=True,
)
self._vprint(
f"{self.log_prefix} or switch providers with `/model <model> --provider openrouter`.",
force=True,
)
elif classified.is_auth or classified.reason == FailoverReason.billing:
if _provider == "openai-codex" and status_code == 401:
self._vprint(f"{self.log_prefix} 💡 Codex OAuth token was rejected (HTTP 401). Your token may have been", force=True)
self._vprint(f"{self.log_prefix} refreshed by another client (Codex CLI, VS Code). To fix:", force=True)
Expand Down
47 changes: 47 additions & 0 deletions tests/agent/test_error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def test_enum_members_exist(self):
"provider_policy_blocked",
"thinking_signature", "long_context_tier",
"oauth_long_context_beta_forbidden",
"anthropic_oauth_tools_overage",
"llama_cpp_grammar_pattern",
"unknown",
}
Expand Down Expand Up @@ -564,6 +565,52 @@ def test_400_without_beta_phrase_is_not_1m_beta_forbidden(self):
result = classify_api_error(e, provider="anthropic")
assert result.reason != FailoverReason.oauth_long_context_beta_forbidden

# ── Provider-specific: Anthropic OAuth tool-use overage routing ──

def test_anthropic_oauth_tools_overage_is_distinct_non_retryable(self):
e = MockAPIError(
"You're out of extra usage. Add more at claude.ai/settings/usage and keep going.",
status_code=400,
body={
"error": {
"type": "invalid_request_error",
"message": "You're out of extra usage. Add more at claude.ai/settings/usage and keep going.",
}
},
)
result = classify_api_error(
e,
provider="anthropic",
model="claude-opus-4-7",
is_anthropic_oauth=True,
has_tools=True,
)
assert result.reason == FailoverReason.anthropic_oauth_tools_overage
assert result.retryable is False
assert result.should_fallback is True
assert result.should_rotate_credential is False

def test_anthropic_oauth_tools_overage_requires_oauth_and_tools(self):
e = MockAPIError(
"You're out of extra usage. Add more at claude.ai/settings/usage and keep going.",
status_code=400,
)
without_oauth = classify_api_error(
e,
provider="anthropic",
is_anthropic_oauth=False,
has_tools=True,
)
without_tools = classify_api_error(
e,
provider="anthropic",
is_anthropic_oauth=True,
has_tools=False,
)

assert without_oauth.reason == FailoverReason.format_error
assert without_tools.reason == FailoverReason.format_error

# ── Transport errors ──

def test_read_timeout(self):
Expand Down
Loading