From 683ca44046749fda94cef8a0336ab0d79a07cc76 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:38:43 +0000 Subject: [PATCH] fix(classifier): a welcome-host 403 naming the free tier itself is the tier refusing, not a billing wall The "says something else" guard reuses the billing table, which carries the Nous gateway's own free-tier phrases ("not available on the free tier", "model_not_supported_on_free_tier"). On the welcome route those words mean exactly "the tier refused"; routing them to billing prints a credits check to an anonymous session that has none. Leave those two phrases on the tier_disabled path. --- agent/error_classifier.py | 15 ++++++++++++--- tests/agent/test_welcome_tier_recovery.py | 6 ++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 61ef9ec610..a0b8e1466a 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -85,13 +85,16 @@ class ClassifiedError: # Billing exhaustion (not transient rate limit). "out of extra usage" is the # Anthropic OAuth Pro/Max overage bucket depleted (HTTP 400). +# The Nous gateway's own words for "the free tier will not serve this" — a billing wall for a +# named account, the tier refusing for an anonymous one (see ``_WELCOME_403_NAMED_PATTERNS``). +_FREE_TIER_REFUSAL_PATTERNS = ("model_not_supported_on_free_tier", "not available on the free tier") _BILLING_PATTERNS = ( "insufficient credits", "insufficient_quota", "insufficient balance", "credit balance", "credits exhausted", "credits have been exhausted", "requires available credits", "account balance is too low", "no usable credits", "top up your credits", "payment required", "billing hard limit", "exceeded your current quota", "account is deactivated", "plan does not include", "out of extra usage", "out of funds", "run out of funds", "balance_depleted", - "model_not_supported_on_free_tier", "not available on the free tier", + *_FREE_TIER_REFUSAL_PATTERNS, # LiteLLM proxies word a hard cap as "hard billing limit" (structured twin: # ``terminal_quota_exhausted`` in _BILLING_ERROR_CODES). "terminal billing # limit" free text is NOT matched: substring rules can't negate the @@ -546,6 +549,13 @@ def _plugin_verdict(c: _Ctx) -> Optional[Verdict]: return verdict +# A welcome-host 403 that spells one of these out is a safety block or a billing wall, not the +# tier refusing. The free-tier refusal phrases are left OUT: on the free route they mean exactly +# "the tier refused", and an anonymous session has no credits to check. +_WELCOME_403_NAMED_PATTERNS = _CONTENT_POLICY_BLOCKED_PATTERNS + tuple( + p for p in _BILLING_PATTERNS if p not in _FREE_TIER_REFUSAL_PATTERNS) + + def _nous_welcome_tier(c: _Ctx) -> Optional[Verdict]: """The Nous inference gateway's welcome-tier (free tier) refusals, read from the structured body. @@ -571,8 +581,7 @@ def _nous_welcome_tier(c: _Ctx) -> Optional[Verdict]: return _v(_R.rate_limit, should_fallback=True, error_context=ctx) # The route-keyed dark-tier 403 applies only to a 403 that says nothing else: a safety refusal # or a billing wall on the welcome host keeps its own classification (and its own recovery). - plain_403 = c.provider == "nous" and not any( - p in c.msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS + _BILLING_PATTERNS) + plain_403 = c.provider == "nous" and not any(p in c.msg for p in _WELCOME_403_NAMED_PATTERNS) kind = welcome_route_refusal(status, c.msg, c.base_url if plain_403 else None) if kind is None: return None diff --git a/tests/agent/test_welcome_tier_recovery.py b/tests/agent/test_welcome_tier_recovery.py index 663145112e..b1aa300ed7 100644 --- a/tests/agent/test_welcome_tier_recovery.py +++ b/tests/agent/test_welcome_tier_recovery.py @@ -62,6 +62,12 @@ class TestDarkTier403: assert "welcome_route" not in result.error_context assert result.reason == FailoverReason.content_policy_blocked + def test_a_403_naming_the_free_tier_itself_is_still_the_tier_refusing(self): + """The billing table's free-tier phrases are the gateway's own words for this refusal; on the + welcome route they must not send an anonymous session to a credits check.""" + body = {"status": 403, "message": "This model is not available on the free tier."} + assert _classify(_gateway_error(403, body)).error_context.get("welcome_route") == "tier_disabled" + def test_a_403_from_another_provider_on_any_host_is_untouched(self): result = classify_api_error(_generic_403(), provider="openrouter", base_url=WELCOME) assert "welcome_route" not in result.error_context