diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index d3ad8074..3a5d4aea 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -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" diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 6b7f0bf3..51d146ed 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -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