diff --git a/agent/context_compressor.py b/agent/context_compressor.py index c8386b841974..1a7d96b77dbb 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -26,6 +26,7 @@ from typing import Any, Dict, List, Optional from agent.auxiliary_client import call_llm, _is_connection_error, aux_interrupt_protection from agent.context_engine import ContextEngine +from agent.error_classifier import FailoverReason, classify_api_error from agent.model_metadata import ( MINIMUM_CONTEXT_LENGTH, get_model_context_length, @@ -36,11 +37,7 @@ from agent.redact import redact_sensitive_text logger = logging.getLogger(__name__) -_SUMMARY_ACCESS_OR_QUOTA_ERROR_SUBSTRINGS: tuple[str, ...] = ( - "invalid api key", - "invalid x-api-key", - "unauthorized", - "authentication", +_SUMMARY_PERMANENT_QUOTA_MARKERS: tuple[str, ...] = ( "insufficient_quota", "quota exceeded", "quota_exceeded", @@ -54,6 +51,12 @@ _SUMMARY_ACCESS_OR_QUOTA_ERROR_SUBSTRINGS: tuple[str, ...] = ( def _is_summary_access_or_quota_error(exc: Exception) -> bool: """Return True for non-retryable summary auth, permission, or quota errors.""" + classified = classify_api_error(exc) + if classified.reason is FailoverReason.rate_limit: + return False + if classified.reason in {FailoverReason.auth, FailoverReason.auth_permanent}: + return True + status = getattr(exc, "status_code", None) or getattr( getattr(exc, "response", None), "status_code", None ) @@ -61,9 +64,9 @@ def _is_summary_access_or_quota_error(exc: Exception) -> bool: return True err_text = str(exc).lower() - if "api key" in err_text and ("invalid" in err_text or "blocked" in err_text): - return True - return any(marker in err_text for marker in _SUMMARY_ACCESS_OR_QUOTA_ERROR_SUBSTRINGS) + if classified.reason is FailoverReason.billing: + return any(marker in err_text for marker in _SUMMARY_PERMANENT_QUOTA_MARKERS) + return any(marker in err_text for marker in _SUMMARY_PERMANENT_QUOTA_MARKERS) HISTORICAL_TASK_HEADING = "## Historical Task Snapshot" diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 2d21be3e3a30..d24daf59467f 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -860,6 +860,7 @@ class TestAuthFailureAborts: "billing portal is temporarily unavailable", "usage limit documentation could not be loaded", "rate limit exceeded; retry later", + "quota exceeded, please retry after the window resets", "request timed out", ], ) @@ -904,6 +905,29 @@ class TestAuthFailureAborts: assert c._last_compress_aborted is True assert c._last_summary_fallback_used is False + def test_402_quota_with_retry_uses_existing_fallback(self): + """A reset-window quota remains transient instead of aborting compression.""" + err = StubProviderError( + "quota exceeded, please retry after the window resets", + status_code=402, + ) + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=2, + protect_last_n=2, + abort_on_summary_failure=False, + ) + msgs = self._msgs(12) + with patch("agent.context_compressor.call_llm", side_effect=err): + result = c.compress(msgs, current_tokens=999999, force=True) + + assert result != msgs + assert c._last_summary_auth_failure is False + assert c._last_compress_aborted is False + assert c._last_summary_fallback_used is True + def test_generate_summary_flags_auth_failure(self): with patch("agent.context_compressor.get_model_context_length", return_value=100000): c = ContextCompressor(model="test", quiet_mode=True)