mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 03:07:45 +02:00
[eric] error-classify: schema-translation 400s aren't auth; gemini RESOURCE_EXHAUSTED is transient
This commit is contained in:
@@ -12,10 +12,30 @@ _TRANSIENT_CAPACITY_PATTERNS = re.compile(
|
||||
r"|internal\s+server\s+error"
|
||||
r"|rate[_\s-]?limit(?:_error)?"
|
||||
r"|ECONNRESET|ETIMEDOUT|ENETUNREACH|fetch\s+failed"
|
||||
r"|resource[_\s-]?exhausted"
|
||||
r"|upstream\s+connect\s+error)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# A first message ships the full tool schema; 9Router rewrites Anthropic
|
||||
# tools[].input_schema into Gemini function_declarations / OpenAI params, and a
|
||||
# construct it can't translate makes the provider 400 (INVALID_ARGUMENT) with
|
||||
# zero tokens. That is NOT auth, reconnecting won't help, the request shape is
|
||||
# wrong, so we classify it apart and stop the catch-all from showing a
|
||||
# "reconnect your subscription" card for a tool-schema 400.
|
||||
_TRANSLATION_ERROR_PATTERNS = re.compile(
|
||||
r"(?:function_declarations"
|
||||
r"|invalid_argument"
|
||||
r"|invalid\s+json\s+payload"
|
||||
r"|unknown\s+name\b"
|
||||
r"|cannot\s+find\s+field"
|
||||
r"|proto\s+field"
|
||||
r"|input_schema"
|
||||
r"|\btools\[\d+\]"
|
||||
r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Patterns that look rate-limit-ish but are actually non-transient (user quota,
|
||||
# auth, context-window tier gate). Must NOT retry, upgrading, reauthing, or
|
||||
# trimming context is required. The long-context-required variant is what
|
||||
@@ -69,6 +89,17 @@ def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _is_translation_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
"""True when the upstream 400 is a tool-schema / protocol translation
|
||||
failure (9Router rewriting Anthropic tools into Gemini function_declarations
|
||||
or OpenAI params), not auth or capacity. Kept distinct so the catch-all
|
||||
stops mislabeling a schema 400 as an expired-subscription reconnect card."""
|
||||
combined = f"{exc!s}\n{extra_text}".strip()
|
||||
if not combined:
|
||||
return False
|
||||
return bool(_TRANSLATION_ERROR_PATTERNS.search(combined))
|
||||
|
||||
|
||||
def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
"""True when the upstream error is a 401/403 auth failure.
|
||||
|
||||
@@ -80,6 +111,10 @@ def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
combined = f"{exc!s}\n{extra_text}".strip()
|
||||
if not combined:
|
||||
return False
|
||||
# A tool-schema translation 400 can carry provider/connection wording that
|
||||
# trips the auth regex below; it isn't auth, so don't claim it is.
|
||||
if _is_translation_error(exc, extra_text):
|
||||
return False
|
||||
return bool(re.search(
|
||||
r"\b(401|403)\b"
|
||||
r"|invalid\s+authentication\s+credentials"
|
||||
|
||||
@@ -523,6 +523,33 @@ def test_resolve_sdk_gemini_prefers_antigravity_over_api_key():
|
||||
assert registry.resolve_model_id_for_sdk("gemini-3-flash", s2) == "gc/gemini-3-flash-preview"
|
||||
|
||||
|
||||
def test_error_classify_schema_translation_400_is_not_auth():
|
||||
"""A 9Router tool-schema translation 400 can carry provider/connection
|
||||
wording that trips the auth regex, so it used to surface a misleading
|
||||
'reconnect your subscription' card for what is really a schema bug. The
|
||||
translation guard must win: schema 400 -> not auth; a real auth failure
|
||||
with no translation signature still reads as auth."""
|
||||
from backend.apps.agents.core.error_classify import _is_auth_error, _is_translation_error
|
||||
both = Exception("provider not connected: 400 INVALID_ARGUMENT at "
|
||||
"tools[0].function_declarations[0].parameters")
|
||||
assert _is_translation_error(both)
|
||||
assert not _is_auth_error(both), "schema-400 must not be classified as auth"
|
||||
# Pure auth failures (no translation signature) still classify as auth.
|
||||
assert _is_auth_error(Exception("provider not connected: gemini"))
|
||||
assert _is_auth_error(Exception("401 invalid authentication credentials"))
|
||||
assert not _is_translation_error(Exception("401 invalid authentication credentials"))
|
||||
|
||||
|
||||
def test_error_classify_gemini_resource_exhausted_is_transient():
|
||||
"""gemini-cli's free-tier 429 surfaces as RESOURCE_EXHAUSTED; it must count
|
||||
as transient so the existing backoff/retry catches it instead of dying as a
|
||||
hard first-message error. A 403 (hard auth/quota) must still NOT retry."""
|
||||
from backend.apps.agents.core.error_classify import _is_transient_capacity_error
|
||||
assert _is_transient_capacity_error(Exception("429 RESOURCE_EXHAUSTED: Quota exceeded"))
|
||||
assert _is_transient_capacity_error(Exception("RESOURCE_EXHAUSTED"))
|
||||
assert not _is_transient_capacity_error(Exception("403 permission denied"))
|
||||
|
||||
|
||||
def test_banned_models_not_offered():
|
||||
"""Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve
|
||||
it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a
|
||||
|
||||
Reference in New Issue
Block a user