diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 04875b6a5..570000525 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -45,6 +45,7 @@ class FailoverReason(enum.Enum): # Model model_not_found = "model_not_found" # 404 or invalid model — fallback to different model + provider_policy_blocked = "provider_policy_blocked" # Aggregator (e.g. OpenRouter) blocked the only endpoint due to account data/privacy policy # Request format format_error = "format_error" # 400 bad request — abort or strip + retry @@ -194,6 +195,29 @@ _MODEL_NOT_FOUND_PATTERNS = [ "unsupported model", ] +# OpenRouter aggregator policy-block patterns. +# +# When a user's OpenRouter account privacy setting (or a per-request +# `provider.data_collection: deny` preference) excludes the only endpoint +# serving a model, OpenRouter returns 404 with a *specific* message that is +# distinct from "model not found": +# +# "No endpoints available matching your guardrail restrictions and +# data policy. Configure: https://openrouter.ai/settings/privacy" +# +# We classify this as `provider_policy_blocked` rather than +# `model_not_found` because: +# - The model *exists* — model_not_found is misleading in logs +# - Provider fallback won't help: the account-level setting applies to +# every call on the same OpenRouter account +# - The error body already contains the fix URL, so the user gets +# actionable guidance without us rewriting the message +_PROVIDER_POLICY_BLOCKED_PATTERNS = [ + "no endpoints available matching your guardrail", + "no endpoints available matching your data policy", + "no endpoints found matching your data policy", +] + # Auth patterns (non-status-code signals) _AUTH_PATTERNS = [ "invalid api key", @@ -523,6 +547,17 @@ def _classify_by_status( return _classify_402(error_msg, result_fn) if status_code == 404: + # OpenRouter policy-block 404 — distinct from "model not found". + # The model exists; the user's account privacy setting excludes the + # only endpoint serving it. Falling back to another provider won't + # help (same account setting applies). The error body already + # contains the fix URL, so just surface it. + if any(p in error_msg for p in _PROVIDER_POLICY_BLOCKED_PATTERNS): + return result_fn( + FailoverReason.provider_policy_blocked, + retryable=False, + should_fallback=False, + ) if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS): return result_fn( FailoverReason.model_not_found, @@ -640,6 +675,12 @@ def _classify_400( ) # Some providers return model-not-found as 400 instead of 404 (e.g. OpenRouter). + if any(p in error_msg for p in _PROVIDER_POLICY_BLOCKED_PATTERNS): + return result_fn( + FailoverReason.provider_policy_blocked, + retryable=False, + should_fallback=False, + ) if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS): return result_fn( FailoverReason.model_not_found, @@ -812,6 +853,15 @@ def _classify_by_message( should_fallback=True, ) + # Provider policy-block (aggregator-side guardrail) — check before + # model_not_found so we don't mis-label as a missing model. + if any(p in error_msg for p in _PROVIDER_POLICY_BLOCKED_PATTERNS): + return result_fn( + FailoverReason.provider_policy_blocked, + retryable=False, + should_fallback=False, + ) + # Model not found patterns if any(p in error_msg for p in _MODEL_NOT_FOUND_PATTERNS): return result_fn( diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index c8faffb0c..9bab14548 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -56,6 +56,7 @@ class TestFailoverReason: "overloaded", "server_error", "timeout", "context_overflow", "payload_too_large", "model_not_found", "format_error", + "provider_policy_blocked", "thinking_signature", "long_context_tier", "unknown", } actual = {r.value for r in FailoverReason} @@ -308,6 +309,59 @@ class TestClassifyApiError: assert result.retryable is True assert result.should_fallback is False + # ── Provider policy-block (OpenRouter privacy/guardrail) ── + + def test_404_openrouter_policy_blocked(self): + # Real OpenRouter error when the user's account privacy setting + # excludes the only endpoint serving a model (e.g. DeepSeek V4 Pro + # which is hosted only by DeepSeek, and their endpoint may log + # inputs). Must NOT classify as model_not_found — the model + # exists, falling back won't help (same account setting applies), + # and the error body already tells the user where to fix it. + e = MockAPIError( + "No endpoints available matching your guardrail restrictions " + "and data policy. Configure: https://openrouter.ai/settings/privacy", + status_code=404, + ) + result = classify_api_error(e) + assert result.reason == FailoverReason.provider_policy_blocked + assert result.retryable is False + assert result.should_fallback is False + + def test_400_openrouter_policy_blocked(self): + # Defense-in-depth: if OpenRouter ever returns this as 400 instead + # of 404, still classify it distinctly rather than as format_error + # or model_not_found. + e = MockAPIError( + "No endpoints available matching your data policy", + status_code=400, + ) + result = classify_api_error(e) + assert result.reason == FailoverReason.provider_policy_blocked + assert result.retryable is False + assert result.should_fallback is False + + def test_message_only_openrouter_policy_blocked(self): + # No status code — classifier should still catch the fingerprint + # via the message-pattern fallback. + e = Exception( + "No endpoints available matching your guardrail restrictions " + "and data policy" + ) + result = classify_api_error(e) + assert result.reason == FailoverReason.provider_policy_blocked + + def test_404_model_not_found_still_works(self): + # Regression guard: the new policy-block check must not swallow + # genuine model_not_found 404s. + e = MockAPIError( + "openrouter/nonexistent-model is not a valid model ID", + status_code=404, + ) + result = classify_api_error(e) + assert result.reason == FailoverReason.model_not_found + assert result.should_fallback is True + # ── Payload too large ── def test_413_payload_too_large(self):