"""Parity corpus for provider failure classification. This corpus was written against the branch-based ``classify_provider_error`` and encodes its exact behavior: every status-code branch, every substring trigger, the raw-code text channel, the empty-response exact-match shapes, order-dependent combinations, and inputs that must keep falling through to UNKNOWN. The table-driven refactor must keep every case green unchanged. All strings are synthetic; some mirror generic public provider error phrasings because those phrasings are exactly what the classifier matches on. """ from __future__ import annotations import pytest from opensquilla.provider.failures import ProviderFailureKind, classify_provider_error K = ProviderFailureKind _UNREGISTERED = "totally-unknown-provider" _GATEWAY_STATUSES = (499, 500, 502, 503, 504, 520, 521, 522, 523, 524, 529) Case = tuple[str, int | None, str, str, ProviderFailureKind] # --- shared pre-pass: context overflow (family-independent, highest priority) --- _CONTEXT_OVERFLOW_CASES: list[Case] = [ ("openai", None, "", "context length exceeded", K.CONTEXT_OVERFLOW), ("anthropic", None, "", "this exceeds the context window", K.CONTEXT_OVERFLOW), ("ollama", None, "", "maximum context reached", K.CONTEXT_OVERFLOW), (_UNREGISTERED, None, "", "prompt is too long", K.CONTEXT_OVERFLOW), ("openrouter", None, "", "input is too long for this model", K.CONTEXT_OVERFLOW), ("openai", None, "", "input exceeds the allowed size", K.CONTEXT_OVERFLOW), ( "openrouter", None, "provider_request_budget_exhausted", '{"fallback_reason":"provider_request_budget_exhausted"}', K.CONTEXT_OVERFLOW, ), ("anthropic", None, "", "too many tokens in request", K.CONTEXT_OVERFLOW), # Pre-pass outranks the family tables (a 401 would otherwise be AUTH_INVALID). ("openai", 401, "", "context length exceeded", K.CONTEXT_OVERFLOW), # Context overflow outranks policy refusal within the pre-pass. ("openai", None, "", "context window hit; flagged by moderation", K.CONTEXT_OVERFLOW), ] # --- shared pre-pass: policy refusal --- _POLICY_REFUSAL_CASES: list[Case] = [ ("openai", None, "", "violates our content policy", K.POLICY_REFUSAL), ("anthropic", None, "", "policy violation detected", K.POLICY_REFUSAL), ("ollama", None, "", "safety policy triggered", K.POLICY_REFUSAL), (_UNREGISTERED, None, "", "flagged by moderation", K.POLICY_REFUSAL), ("openrouter", None, "", "refusal: cannot help with that", K.POLICY_REFUSAL), ("openai", None, "", "request blocked by policy", K.POLICY_REFUSAL), # Pre-pass outranks the family 429 branch. ("anthropic", 429, "", "flagged by moderation", K.POLICY_REFUSAL), ] # --- shared pre-pass: empty response (exact-match shapes, not substrings) --- _EMPTY_RESPONSE_CASES: list[Case] = [ ("openrouter", None, "empty_response", "", K.EMPTY_RESPONSE), ("openrouter", None, "", "empty_response", K.EMPTY_RESPONSE), ("openai", None, "", "empty response", K.EMPTY_RESPONSE), ("anthropic", None, "", "Provider returned an empty response", K.EMPTY_RESPONSE), ("openai", None, "", " EMPTY RESPONSE ", K.EMPTY_RESPONSE), ("openai", None, " EMPTY_RESPONSE ", "", K.EMPTY_RESPONSE), # Pre-pass outranks the family 429 branch. ("openai", 429, "empty_response", "", K.EMPTY_RESPONSE), # Exact-match only: a longer message containing the phrase is NOT empty-response; # this one lands on the openai_compat gateway-status branch instead. ( "openai", 500, "", "OpenAI chat request failed (HTTP 500): empty response body", K.PROVIDER_OVERLOADED, ), ] # --- openai_compat family --- _OPENAI_COMPAT_CASES: list[Case] = [ # MODEL_NOT_FOUND: status 404 OR any model-unavailable marker. ("openai", 404, "", "", K.MODEL_NOT_FOUND), ("openrouter", None, "", "no endpoints found for this model", K.MODEL_NOT_FOUND), ("deepseek", None, "", "model not found", K.MODEL_NOT_FOUND), ("openai", None, "", "the model is not available right now", K.MODEL_NOT_FOUND), ("openrouter", None, "", "model not available", K.MODEL_NOT_FOUND), ("openrouter", None, "", "not available in the requested region", K.MODEL_NOT_FOUND), # Model-unavailable marker outranks the 403 auth branch. ("openai", 403, "", "This model is not available in your region.", K.MODEL_NOT_FOUND), # TokenRhythm's custom envelope arrives as HTTP 400 with a top-level # code + localized message; _http_error_body_text prefixes the code, and # either marker (code or Chinese text) must outrank BAD_REQUEST. ("tokenrhythm", 400, "", "MODEL_NOT_AVAILABLE: 模型不可用:some-model", K.MODEL_NOT_FOUND), ("tokenrhythm", 400, "", "模型不可用:some-model", K.MODEL_NOT_FOUND), # Status 404 outranks the auth substring branch. ("openai", 404, "", "unauthorized", K.MODEL_NOT_FOUND), # AUTH_INVALID: 401/403 OR "invalid api key" / "unauthorized". ("openai", 401, "", "", K.AUTH_INVALID), ("openrouter", 403, "", "HTTP 403: forbidden", K.AUTH_INVALID), ("deepseek", None, "", "invalid api key", K.AUTH_INVALID), ("tokenrhythm", 401, "", "UNAUTHORIZED: 未认证或登录已过期", K.AUTH_INVALID), ("openai", None, "", "Unauthorized", K.AUTH_INVALID), ("openai_responses", 401, "", "invalid api key", K.AUTH_INVALID), # INSUFFICIENT_CREDITS: 402 OR "insufficient credits" / "no credits". ("openai", 402, "", "", K.INSUFFICIENT_CREDITS), ("openrouter", None, "", "insufficient credits to complete request", K.INSUFFICIENT_CREDITS), ("openrouter", None, "", "no credits remaining", K.INSUFFICIENT_CREDITS), # 402 outranks the rate-limit substring branch. ("openrouter", 402, "", "rate limit will apply", K.INSUFFICIENT_CREDITS), # RATE_LIMITED: 429 OR "rate limit" / "rate_limit". ("openai", 429, "", "", K.RATE_LIMITED), ("openrouter", None, "", "rate limit exceeded", K.RATE_LIMITED), ("openai", 429, "", "Too Many Requests", K.RATE_LIMITED), # raw_code participates in the joined text channel. ("openai", None, "rate_limit_exceeded", "", K.RATE_LIMITED), # 429 outranks the unsupported branch. ("openai", 429, "", "does not support this", K.RATE_LIMITED), # UNSUPPORTED_FEATURE: "does not support" / "unsupported". ("openai", None, "", "model does not support tools", K.UNSUPPORTED_FEATURE), # Unsupported outranks the bare-400 branch. ("openrouter", 400, "", "unsupported parameter", K.UNSUPPORTED_FEATURE), # Unsupported outranks the gateway-status branch. ("openai", 503, "", "unsupported model configuration", K.UNSUPPORTED_FEATURE), # PROVIDER_OVERLOADED: "overloaded" OR gateway-transient text. ("openai", None, "", "server overloaded, try again", K.PROVIDER_OVERLOADED), ("openrouter", None, "", "Cloudflare returned 520", K.PROVIDER_OVERLOADED), ("openai", None, "", "HTTP 522", K.PROVIDER_OVERLOADED), ("deepseek", None, "", "status_code: 523", K.PROVIDER_OVERLOADED), ("openrouter", None, "", "upstream returned 522", K.PROVIDER_OVERLOADED), ("openrouter", None, "", "OpenRouter upstream error 520", K.PROVIDER_OVERLOADED), # BAD_REQUEST: 400 OR "invalid_request". ("openai", 400, "", "", K.BAD_REQUEST), ("openrouter", None, "invalid_request", "", K.BAD_REQUEST), ("openai", None, "", "invalid_request_error: bad payload", K.BAD_REQUEST), # Family miss falls through to the shared tail. ("openai", None, "", "malformed chunk received", K.MALFORMED_RESPONSE), ("openrouter", None, "", "request error: connection reset by peer", K.TRANSPORT_TRANSIENT), ("openai", None, "timeout", "Request timed out: ", K.TRANSPORT_TRANSIENT), ] # --- anthropic family --- _ANTHROPIC_CASES: list[Case] = [ ("anthropic", 404, "", "", K.MODEL_NOT_FOUND), ("anthropic", None, "not_found_error", "", K.MODEL_NOT_FOUND), ("anthropic", None, "", '{"type":"not_found_error","message":"model: x"}', K.MODEL_NOT_FOUND), ("anthropic", None, "", "model not available", K.MODEL_NOT_FOUND), ("minimax", 401, "authentication_error", "", K.AUTH_INVALID), ("anthropic", 403, "", "", K.AUTH_INVALID), ("anthropic", None, "", "authentication_error: bad key", K.AUTH_INVALID), ("anthropic", 402, "", "", K.INSUFFICIENT_CREDITS), # "credit balance" outranks everything a 400 could otherwise become. ( "anthropic", 400, "", "Your credit balance is too low to access the Anthropic API.", K.INSUFFICIENT_CREDITS, ), ("anthropic", 429, "", "", K.RATE_LIMITED), ("anthropic", None, "rate_limit_error", "", K.RATE_LIMITED), # Family branch only matches "rate_limit_error"; plain "rate limit" is # caught by the shared tail instead. ("anthropic", None, "", "rate limit hit", K.RATE_LIMITED), ("anthropic", None, "overloaded_error", "", K.PROVIDER_OVERLOADED), # Family miss on text; the shared-tail gateway regex catches it. ("anthropic", None, "", "HTTP 520", K.PROVIDER_OVERLOADED), ("anthropic", None, "invalid_request_error", "", K.BAD_REQUEST), # anthropic has no bare-400 branch: a naked 400 stays UNKNOWN. ("anthropic", 400, "", "", K.UNKNOWN), # openai_compat-only substrings do not apply to the anthropic family. ("anthropic", None, "", "unsupported parameter", K.UNKNOWN), ("anthropic", None, "", "invalid api key", K.UNKNOWN), # "invalid_request" without the "_error" suffix misses the anthropic branch. ("anthropic", None, "", "invalid_request in body", K.UNKNOWN), ] # --- ollama family --- _OLLAMA_CASES: list[Case] = [ ("ollama", None, "", "model not found", K.MODEL_NOT_FOUND), # Conjunction: "pull" AND "model" together classify as model-not-found. ("ollama", None, "", 'try "ollama pull llama3" to download the model', K.MODEL_NOT_FOUND), # The pull+model conjunction outranks the timeout transport branch. ("ollama", None, "", "timeout while pulling model manifest", K.MODEL_NOT_FOUND), ("ollama", 401, "", "", K.AUTH_INVALID), ("ollama", 403, "", "", K.AUTH_INVALID), ("ollama", None, "", "HTTP 401: unauthorized", K.AUTH_INVALID), ("ollama", None, "", "connection refused", K.TRANSPORT_TRANSIENT), ("ollama", None, "", "connection error while contacting host", K.TRANSPORT_TRANSIENT), ("ollama", None, "", "request error: boom", K.TRANSPORT_TRANSIENT), ("ollama", None, "", "read timeout", K.TRANSPORT_TRANSIENT), # Family misses fall through to the shared tail. ("ollama", 429, "", "", K.RATE_LIMITED), ("ollama", 500, "", "", K.PROVIDER_OVERLOADED), ("ollama", None, "", "invalid json in response", K.MALFORMED_RESPONSE), # ollama has no 404 status branch. ("ollama", 404, "", "", K.UNKNOWN), # "pull" without "model" (and vice versa) does not classify. ("ollama", None, "", "pull the latest image", K.UNKNOWN), ("ollama", None, "", "model exploded", K.UNKNOWN), ] # --- shared/generic tail (unregistered provider => no family table) --- _GENERIC_TAIL_CASES: list[Case] = [ (_UNREGISTERED, 429, "", "", K.RATE_LIMITED), (_UNREGISTERED, None, "", "rate limit exceeded", K.RATE_LIMITED), # The underscore variant is an openai_compat-family substring only. (_UNREGISTERED, None, "", "rate_limit_exceeded", K.UNKNOWN), (_UNREGISTERED, None, "", "upstream returned 522", K.PROVIDER_OVERLOADED), (_UNREGISTERED, None, "", "malformed response", K.MALFORMED_RESPONSE), (_UNREGISTERED, None, "", "invalid json payload", K.MALFORMED_RESPONSE), (_UNREGISTERED, None, "", "timeout after 30s", K.TRANSPORT_TRANSIENT), (_UNREGISTERED, None, "", "request error: peer reset", K.TRANSPORT_TRANSIENT), # Tail order: malformed is checked before timeout. (_UNREGISTERED, None, "", "malformed response after timeout", K.MALFORMED_RESPONSE), ] # --- inputs that must keep falling through to UNKNOWN --- _UNKNOWN_CASES: list[Case] = [ (_UNREGISTERED, 401, "", "", K.UNKNOWN), (_UNREGISTERED, 400, "", "", K.UNKNOWN), (_UNREGISTERED, 418, "", "", K.UNKNOWN), (_UNREGISTERED, None, "", "", K.UNKNOWN), ("", None, "", "", K.UNKNOWN), (_UNREGISTERED, None, "", "invalid api key", K.UNKNOWN), (_UNREGISTERED, None, "", "something inexplicable happened", K.UNKNOWN), # Unscoped gateway-looking numbers stay unclassified. (_UNREGISTERED, None, "", "line 520", K.UNKNOWN), (_UNREGISTERED, None, "", "the provider sent 520 tokens", K.UNKNOWN), # A bare "429 Too Many Requests" body without a status code has no trigger. (_UNREGISTERED, None, "", "429 Too Many Requests", K.UNKNOWN), # Near-miss on the exact-match empty-response shapes. (_UNREGISTERED, None, "", "the empty response body was discarded", K.UNKNOWN), ] CORPUS: list[Case] = ( _CONTEXT_OVERFLOW_CASES + _POLICY_REFUSAL_CASES + _EMPTY_RESPONSE_CASES + _OPENAI_COMPAT_CASES + _ANTHROPIC_CASES + _OLLAMA_CASES + _GENERIC_TAIL_CASES + _UNKNOWN_CASES ) # Every gateway-transient status code classifies PROVIDER_OVERLOADED for the # openai_compat and anthropic families and via the shared tail (ollama and # unregistered providers have no family status branch and fall through). for _status in _GATEWAY_STATUSES: for _provider in ("openrouter", "anthropic", "ollama", _UNREGISTERED): CORPUS.append((_provider, _status, "", "", K.PROVIDER_OVERLOADED)) @pytest.mark.parametrize( ("provider", "status_code", "raw_code", "message", "expected"), CORPUS, ids=[ f"{i:03d}-{provider or 'blank'}-{status_code}-{expected.value}" for i, (provider, status_code, raw_code, message, expected) in enumerate(CORPUS) ], ) def test_classification_parity( provider: str, status_code: int | None, raw_code: str, message: str, expected: ProviderFailureKind, ) -> None: assert ( classify_provider_error(provider, status_code, raw_code=raw_code, message=message) is expected ) def test_corpus_covers_every_failure_kind_reachable_from_classification() -> None: reachable = {expected for *_ignored, expected in CORPUS} assert reachable == set(ProviderFailureKind)