mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-25 01:02:15 +00:00
feat(logging): add normalized_error cluster key to error_information (#41715)
* feat(logging): add normalized_error cluster key to error_information Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(logging): stop classifying parameter length errors as context window errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): assert failure spend rows share normalized_error across provider wording Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(logging): map agent model access denials and ignore non-string proxy error types Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(logging): cover budget exceeded errors with custom wording Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(logging): cluster router no-healthy and provider-budget wording correctly Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(logging): let the exception class win over router fallback wording Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(logging): cluster peer closed connection errors as provider connection errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(logging): cluster tag routing denials as 403_MODEL_ACCESS_DENIED Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: shivam <shivam@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: yucheng <yucheng@berri.ai>
This commit is contained in:
parent
7177d3b6d1
commit
392e807172
7 changed files with 479 additions and 0 deletions
195
litellm/litellm_core_utils/error_normalization.py
Normal file
195
litellm/litellm_core_utils/error_normalization.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""
|
||||
Map any exception litellm logs to one stable ``normalized_error`` code so dashboards can cluster
|
||||
failures without parsing free-text messages that embed team names, token counts, model names, etc.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol, runtime_checkable
|
||||
|
||||
from litellm.exceptions import (
|
||||
APIConnectionError,
|
||||
AuthenticationError,
|
||||
BadGatewayError,
|
||||
BadRequestError,
|
||||
BlockedPiiEntityError,
|
||||
BudgetExceededError,
|
||||
ContentPolicyViolationError,
|
||||
ContextWindowExceededError,
|
||||
GuardrailRaisedException,
|
||||
InternalServerError,
|
||||
MidStreamFallbackError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
RateLimitError,
|
||||
RateLimitType,
|
||||
ServiceUnavailableError,
|
||||
Timeout,
|
||||
UnprocessableEntityError,
|
||||
UnsupportedParamsError,
|
||||
)
|
||||
|
||||
RATE_LIMIT_EXCEEDED: Final = "429_RATE_LIMIT_EXCEEDED"
|
||||
BUDGET_EXCEEDED: Final = "429_BUDGET_EXCEEDED"
|
||||
NO_HEALTHY_DEPLOYMENTS: Final = "429_NO_HEALTHY_DEPLOYMENTS"
|
||||
AUTHENTICATION_FAILED: Final = "401_AUTHENTICATION_FAILED"
|
||||
MODEL_ACCESS_DENIED: Final = "403_MODEL_ACCESS_DENIED"
|
||||
PERMISSION_DENIED: Final = "403_PERMISSION_DENIED"
|
||||
MISSING_REQUIRED_PARAMETER: Final = "400_MISSING_REQUIRED_PARAMETER"
|
||||
INVALID_PARAMETER_VALUE: Final = "400_INVALID_PARAMETER_VALUE"
|
||||
CONTEXT_WINDOW_EXCEEDED: Final = "400_CONTEXT_WINDOW_EXCEEDED"
|
||||
CONTENT_POLICY_VIOLATION: Final = "400_CONTENT_POLICY_VIOLATION"
|
||||
INVALID_REQUEST: Final = "400_INVALID_REQUEST"
|
||||
RESOURCE_NOT_FOUND: Final = "404_RESOURCE_NOT_FOUND"
|
||||
UPSTREAM_TIMEOUT: Final = "408_UPSTREAM_TIMEOUT"
|
||||
PROVIDER_CONNECTION_ERROR: Final = "500_PROVIDER_CONNECTION_ERROR"
|
||||
PROVIDER_OVERLOADED: Final = "503_PROVIDER_OVERLOADED"
|
||||
PROVIDER_INTERNAL_ERROR: Final = "500_PROVIDER_INTERNAL_ERROR"
|
||||
ROUTER_NO_FALLBACK: Final = "500_ROUTER_NO_FALLBACK"
|
||||
ROUTER_FALLBACK_FAILURE: Final = "500_ROUTER_FALLBACK_FAILURE"
|
||||
UPSTREAM_PASSTHROUGH: Final = "500_UPSTREAM_PASSTHROUGH"
|
||||
UNSUPPORTED_OPERATION: Final = "500_UNSUPPORTED_OPERATION"
|
||||
INTERNAL_STATE_ERROR: Final = "500_INTERNAL_STATE_ERROR"
|
||||
UNCLASSIFIED: Final = "UNCLASSIFIED"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _HasProxyErrorType(Protocol):
|
||||
type: str
|
||||
|
||||
|
||||
_MESSAGE_PATTERNS: Final[tuple[tuple[re.Pattern[str], str], ...]] = (
|
||||
(
|
||||
re.compile(r"budget has been exceeded|max budget|exceeded.*budget|crossed budget", re.IGNORECASE),
|
||||
BUDGET_EXCEEDED,
|
||||
),
|
||||
(re.compile(r"no healthy deployments?|no deployments available", re.IGNORECASE), NO_HEALTHY_DEPLOYMENTS),
|
||||
(re.compile(r"not allowed to access model due to tags configuration", re.IGNORECASE), MODEL_ACCESS_DENIED),
|
||||
(re.compile(r"upstream passthrough request failed", re.IGNORECASE), UPSTREAM_PASSTHROUGH),
|
||||
(re.compile(r"is not supported for provider|not implemented", re.IGNORECASE), UNSUPPORTED_OPERATION),
|
||||
(
|
||||
re.compile(r"context window|context length|(prompt|input) is too long|tokens? ?> ?\d+ ?maximum", re.IGNORECASE),
|
||||
CONTEXT_WINDOW_EXCEEDED,
|
||||
),
|
||||
(re.compile(r"missing required parameter|field required", re.IGNORECASE), MISSING_REQUIRED_PARAMETER),
|
||||
(re.compile(r"overloaded|unable to process your request", re.IGNORECASE), PROVIDER_OVERLOADED),
|
||||
(
|
||||
re.compile(
|
||||
r"connection error|APIConnectionError|TransferEncodingError|payload is not completed|connection reset"
|
||||
r"|peer closed connection|incomplete chunked read",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
PROVIDER_CONNECTION_ERROR,
|
||||
),
|
||||
(re.compile(r"timed? ?out", re.IGNORECASE), UPSTREAM_TIMEOUT),
|
||||
)
|
||||
|
||||
_ROUTER_WRAPPER_PATTERNS: Final[tuple[tuple[re.Pattern[str], str], ...]] = (
|
||||
(re.compile(r"no fallback model group found", re.IGNORECASE), ROUTER_NO_FALLBACK),
|
||||
(re.compile(r"error doing the fallback|MidStreamFallbackError", re.IGNORECASE), ROUTER_FALLBACK_FAILURE),
|
||||
)
|
||||
|
||||
_PROXY_ERROR_TYPE_MAP: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"budget_exceeded": BUDGET_EXCEEDED,
|
||||
"auth_error": AUTHENTICATION_FAILED,
|
||||
"expired_key": AUTHENTICATION_FAILED,
|
||||
"token_not_found_in_db": AUTHENTICATION_FAILED,
|
||||
"auth_provider_unavailable": AUTHENTICATION_FAILED,
|
||||
"key_model_access_denied": MODEL_ACCESS_DENIED,
|
||||
"team_model_access_denied": MODEL_ACCESS_DENIED,
|
||||
"user_model_access_denied": MODEL_ACCESS_DENIED,
|
||||
"org_model_access_denied": MODEL_ACCESS_DENIED,
|
||||
"project_model_access_denied": MODEL_ACCESS_DENIED,
|
||||
"agent_model_access_denied": MODEL_ACCESS_DENIED,
|
||||
"key_vector_store_access_denied": PERMISSION_DENIED,
|
||||
"team_vector_store_access_denied": PERMISSION_DENIED,
|
||||
"org_vector_store_access_denied": PERMISSION_DENIED,
|
||||
"tool_access_denied": PERMISSION_DENIED,
|
||||
"team_member_permission_error": PERMISSION_DENIED,
|
||||
"not_found_error": RESOURCE_NOT_FOUND,
|
||||
}
|
||||
)
|
||||
|
||||
_STATUS_CODE_MAP: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"400": INVALID_REQUEST,
|
||||
"401": AUTHENTICATION_FAILED,
|
||||
"403": PERMISSION_DENIED,
|
||||
"404": RESOURCE_NOT_FOUND,
|
||||
"408": UPSTREAM_TIMEOUT,
|
||||
"422": INVALID_PARAMETER_VALUE,
|
||||
"429": RATE_LIMIT_EXCEEDED,
|
||||
"500": PROVIDER_INTERNAL_ERROR,
|
||||
"502": PROVIDER_INTERNAL_ERROR,
|
||||
"503": PROVIDER_OVERLOADED,
|
||||
"504": UPSTREAM_TIMEOUT,
|
||||
}
|
||||
)
|
||||
|
||||
_INTERNAL_STATE_EXCEPTIONS: Final[tuple[type[BaseException], ...]] = (
|
||||
TypeError,
|
||||
KeyError,
|
||||
AttributeError,
|
||||
IndexError,
|
||||
RuntimeError,
|
||||
AssertionError,
|
||||
ZeroDivisionError,
|
||||
)
|
||||
|
||||
_CLASS_CODE_TABLE: Final[tuple[tuple[tuple[type[BaseException], ...], str], ...]] = (
|
||||
((AuthenticationError,), AUTHENTICATION_FAILED),
|
||||
((PermissionDeniedError,), PERMISSION_DENIED),
|
||||
((ContextWindowExceededError,), CONTEXT_WINDOW_EXCEEDED),
|
||||
((ContentPolicyViolationError, GuardrailRaisedException, BlockedPiiEntityError), CONTENT_POLICY_VIOLATION),
|
||||
((UnsupportedParamsError,), INVALID_PARAMETER_VALUE),
|
||||
((NotFoundError,), RESOURCE_NOT_FOUND),
|
||||
((Timeout,), UPSTREAM_TIMEOUT),
|
||||
((MidStreamFallbackError,), ROUTER_FALLBACK_FAILURE),
|
||||
((APIConnectionError,), PROVIDER_CONNECTION_ERROR),
|
||||
((ServiceUnavailableError,), PROVIDER_OVERLOADED),
|
||||
((InternalServerError, BadGatewayError), PROVIDER_INTERNAL_ERROR),
|
||||
((BadRequestError, UnprocessableEntityError), INVALID_REQUEST),
|
||||
((NotImplementedError,), UNSUPPORTED_OPERATION),
|
||||
)
|
||||
|
||||
|
||||
def _classify_by_message(message: str, patterns: tuple[tuple[re.Pattern[str], str], ...]) -> str | None:
|
||||
return next((code for pattern, code in patterns if pattern.search(message)), None)
|
||||
|
||||
|
||||
def _classify_by_class(exc: Exception) -> str | None:
|
||||
if isinstance(exc, BudgetExceededError):
|
||||
return BUDGET_EXCEEDED
|
||||
if isinstance(exc, RateLimitError):
|
||||
return BUDGET_EXCEEDED if exc.rate_limit_type == RateLimitType.BUDGET.value else RATE_LIMIT_EXCEEDED
|
||||
for exc_types, code in _CLASS_CODE_TABLE:
|
||||
if isinstance(exc, exc_types):
|
||||
return code
|
||||
if isinstance(exc, _INTERNAL_STATE_EXCEPTIONS):
|
||||
return INTERNAL_STATE_ERROR
|
||||
return None
|
||||
|
||||
|
||||
def normalize_error(exc: Exception | None, status_code: str, message: str) -> str | None:
|
||||
"""
|
||||
Return a stable cluster key for ``exc``. ``status_code`` and ``message`` are the values
|
||||
``get_error_information`` already extracted, so the same exception always yields the same code.
|
||||
"""
|
||||
if exc is None:
|
||||
return None
|
||||
proxy_type: Final = exc.type if isinstance(exc, _HasProxyErrorType) else None
|
||||
by_proxy_type: Final = _PROXY_ERROR_TYPE_MAP.get(proxy_type) if isinstance(proxy_type, str) else None
|
||||
if by_proxy_type is not None:
|
||||
return by_proxy_type
|
||||
by_message: Final = _classify_by_message(message, _MESSAGE_PATTERNS)
|
||||
if by_message is not None:
|
||||
return by_message
|
||||
by_class: Final = _classify_by_class(exc)
|
||||
if by_class is not None:
|
||||
return by_class
|
||||
by_router_wrapper: Final = _classify_by_message(message, _ROUTER_WRAPPER_PATTERNS)
|
||||
if by_router_wrapper is not None:
|
||||
return by_router_wrapper
|
||||
return _STATUS_CODE_MAP.get(status_code, UNCLASSIFIED)
|
||||
|
|
@ -76,6 +76,7 @@ from litellm.litellm_core_utils.core_helpers import (
|
|||
reconstruct_model_name,
|
||||
set_response_cost_in_hidden_params,
|
||||
)
|
||||
from litellm.litellm_core_utils.error_normalization import normalize_error
|
||||
from litellm.litellm_core_utils.get_litellm_params import get_litellm_params
|
||||
from litellm.litellm_core_utils.internal_call_metadata import (
|
||||
MODEL_ACCESS_GROUP_METADATA_KEY,
|
||||
|
|
@ -6100,6 +6101,7 @@ class StandardLoggingPayloadSetup:
|
|||
error_budget_entity_id=budget_error.entity_id if budget_error else None,
|
||||
error_budget_limit=budget_error.max_budget if budget_error else None,
|
||||
error_budget_spend=budget_error.current_cost if budget_error else None,
|
||||
normalized_error=normalize_error(original_exception, error_status, error_message),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -3256,6 +3256,7 @@ class StandardLoggingPayloadErrorInformation(TypedDict, total=False):
|
|||
error_budget_entity_id: str | None
|
||||
error_budget_limit: float | None
|
||||
error_budget_spend: float | None
|
||||
normalized_error: ReadOnly[str | None]
|
||||
|
||||
|
||||
class GuardrailMode(TypedDict, total=False):
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
- {id: quota_management.spend_tracking.end_user.attributes_spend, module: quota_management, tier: P1, behavior: spend_tracking, variant: end_user, assertions: [attributes_spend], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "user= attribution lands the end-user id on the spend row"}
|
||||
- {id: quota_management.spend_tracking.per_model.writes_own_rows, module: quota_management, tier: P2, behavior: spend_tracking, variant: per_model, assertions: [writes_own_rows], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "Each model on a shared key gets its own spend row"}
|
||||
- {id: quota_management.spend_tracking.failure.writes_failure_row, module: quota_management, tier: P1, behavior: spend_tracking, variant: failure, assertions: [writes_failure_row], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_log_error_logger.py", rationale: "A failed call writes a failure-status spend row"}
|
||||
- {id: quota_management.spend_tracking.failure.writes_normalized_error, module: quota_management, tier: P1, behavior: spend_tracking, variant: failure, assertions: [writes_normalized_error], exercised_on: [chat_completions], source: "litellm_core_utils/error_normalization.py", rationale: "Failure rows carry a stable metadata.error_information.normalized_error key next to the unchanged error_message, so two upstream auth failures with different provider wording share one cluster key a dashboard can group by"}
|
||||
- {id: quota_management.spend_tracking.failure.attributes_provider, module: quota_management, tier: P1, behavior: spend_tracking, variant: failure, assertions: [attributes_provider], exercised_on: [chat_completions], source: "proxy/utils.py", rationale: "A request rejected in pre_call_hook (rate limit, guardrail) still lands its single deployment's provider and model_id on the failure spend row"}
|
||||
- {id: quota_management.spend_tracking.spend_calculate.returns_cost, module: quota_management, tier: P2, behavior: spend_tracking, variant: spend_calculate, assertions: [returns_cost], exercised_on: [spend_calculate], source: "proxy/spend_tracking/spend_management_endpoints.py", rationale: "/spend/calculate prices a hypothetical request at nonzero cost"}
|
||||
- {id: quota_management.spend_tracking.pagination.keeps_total, module: quota_management, tier: P2, behavior: spend_tracking, variant: pagination, assertions: [keeps_total], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_management_endpoints.py", rationale: "Spend-logs v2 pagination caps page size without losing the total"}
|
||||
|
|
|
|||
|
|
@ -937,10 +937,18 @@ class GuardrailRunRecord(BaseModel):
|
|||
guardrail_response: object | None = None
|
||||
|
||||
|
||||
class SpendLogErrorInformation(BaseModel):
|
||||
error_code: str | None = None
|
||||
error_class: str | None = None
|
||||
error_message: str | None = None
|
||||
normalized_error: str | None = None
|
||||
|
||||
|
||||
class SpendLogMetadata(BaseModel):
|
||||
user_api_key_alias: str | None = None
|
||||
applied_guardrails: list[str] | None = None
|
||||
guardrail_information: list[GuardrailRunRecord] | None = None
|
||||
error_information: SpendLogErrorInformation | None = None
|
||||
|
||||
|
||||
class SpendLogRow(BaseModel):
|
||||
|
|
|
|||
|
|
@ -499,6 +499,47 @@ def test_failure_call_writes_failure_status_row(
|
|||
assert (failure_row.spend or 0) == 0.0, "failed call must not be charged"
|
||||
|
||||
|
||||
@pytest.mark.covers("quota_management.spend_tracking.failure.writes_normalized_error")
|
||||
def test_failure_rows_share_normalized_error_across_provider_wording(
|
||||
client: SpendClient, resources: ResourceManager, scoped_key: str
|
||||
) -> None:
|
||||
"""Two upstream auth failures with different provider wording land as failure rows
|
||||
whose metadata.error_information keeps each provider's own error_message and
|
||||
carries the same stable normalized_error cluster key."""
|
||||
marker = unique_marker()
|
||||
deployments: Final = (
|
||||
(f"e2e-norm-openai-{marker}", "openai/gpt-5.5"),
|
||||
(f"e2e-norm-anthropic-{marker}", "anthropic/claude-haiku-4-5"),
|
||||
)
|
||||
for name, provider_model in deployments:
|
||||
model_id = client.proxy.create_model(
|
||||
name, LiteLLMParamsBody(model=provider_model, api_key=f"sk-invalid-{marker}")
|
||||
)
|
||||
resources.defer(lambda model_id=model_id: client.proxy.delete_model(model_id))
|
||||
result = client.chat(scoped_key, name, f"normalize failure {marker}", max_tokens=1)
|
||||
assert not is_ok(result), f"{name}: invalid upstream key must fail the call, got {result}"
|
||||
|
||||
rows = client.poll_logs_for_key(
|
||||
scoped_key,
|
||||
min_rows=2,
|
||||
predicate=lambda rs: sum(1 for r in rs if r.status == "failure") >= 2,
|
||||
)
|
||||
failure_rows = [r for r in rows if r.status == "failure"]
|
||||
assert len(failure_rows) == 2, f"expected one failure row per deployment: {_summarize(rows)}"
|
||||
|
||||
infos = [r.metadata.error_information if r.metadata else None for r in failure_rows]
|
||||
assert all(info is not None for info in infos), (
|
||||
f"failure rows must carry metadata.error_information: {[r.model_dump() for r in failure_rows]}"
|
||||
)
|
||||
messages = {info.error_message for info in infos if info is not None}
|
||||
assert len(messages) == 2, f"provider wording must stay distinct in error_message: {messages}"
|
||||
normalized = {info.normalized_error for info in infos if info is not None}
|
||||
assert normalized == {"401_AUTHENTICATION_FAILED"}, (
|
||||
f"both auth failures must share one normalized_error cluster key; saw {normalized} "
|
||||
f"for messages {messages}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.covers("quota_management.spend_tracking.failure.attributes_provider")
|
||||
def test_pre_call_rejection_row_attributes_provider_and_model_id(
|
||||
client: SpendClient, resources: ResourceManager
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.exceptions import MidStreamFallbackError
|
||||
from litellm.litellm_core_utils.error_normalization import normalize_error
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException
|
||||
from litellm.types.router import RouterErrors
|
||||
|
||||
_RESPONSE = httpx.Response(status_code=500, request=httpx.Request("POST", "https://example.invalid"))
|
||||
|
||||
|
||||
def _proxy_exc(message: str, error_type: str, code: int) -> ProxyException:
|
||||
return ProxyException(message=message, type=error_type, param=None, code=code)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("messages", "expected"),
|
||||
[
|
||||
(
|
||||
(
|
||||
_proxy_exc("Rate limit exceeded for team X. Reset at 10:01", "rate_limit_error", 429),
|
||||
_proxy_exc("Rate limit exceeded for team Y. Reset at 10:02", "rate_limit_error", 429),
|
||||
),
|
||||
"429_RATE_LIMIT_EXCEEDED",
|
||||
),
|
||||
(
|
||||
(
|
||||
litellm.BudgetExceededError(current_cost=3501.85, max_budget=3500),
|
||||
_proxy_exc(
|
||||
"User=abc, Current cost=1000.03, Max budget=1000", ProxyErrorTypes.budget_exceeded.value, 400
|
||||
),
|
||||
litellm.RateLimitError(
|
||||
"budget",
|
||||
llm_provider="openai",
|
||||
model="gpt",
|
||||
rate_limit_type=litellm.exceptions.RateLimitType.BUDGET,
|
||||
),
|
||||
),
|
||||
"429_BUDGET_EXCEEDED",
|
||||
),
|
||||
(
|
||||
(
|
||||
_proxy_exc("Token Expired", ProxyErrorTypes.expired_key.value, 401),
|
||||
_proxy_exc("Malformed API Key", ProxyErrorTypes.auth_error.value, 401),
|
||||
litellm.AuthenticationError("Signature verification failed", llm_provider="azure", model="gpt"),
|
||||
),
|
||||
"401_AUTHENTICATION_FAILED",
|
||||
),
|
||||
(
|
||||
(
|
||||
_proxy_exc("No team has access to gpt-5.5-mini", ProxyErrorTypes.team_model_access_denied.value, 401),
|
||||
_proxy_exc("key not allowed to access claude", ProxyErrorTypes.key_model_access_denied.value, 401),
|
||||
ValueError(
|
||||
"Not allowed to access model due to tags configuration. Passed model=gpt-5.5 and tags=['team-a']"
|
||||
),
|
||||
),
|
||||
"403_MODEL_ACCESS_DENIED",
|
||||
),
|
||||
(
|
||||
(
|
||||
_proxy_exc("Missing required parameter: messages", ProxyErrorTypes.bad_request_error.value, 400),
|
||||
litellm.BadRequestError("Missing required parameter: input", llm_provider="openai", model="gpt"),
|
||||
),
|
||||
"400_MISSING_REQUIRED_PARAMETER",
|
||||
),
|
||||
(
|
||||
(
|
||||
litellm.ContextWindowExceededError(
|
||||
"1002823 tokens > 1000000 maximum", model="g", llm_provider="vertex"
|
||||
),
|
||||
litellm.BadRequestError("Input is too long for requested model", llm_provider="anthropic", model="c"),
|
||||
),
|
||||
"400_CONTEXT_WINDOW_EXCEEDED",
|
||||
),
|
||||
(
|
||||
(
|
||||
litellm.NotFoundError("Response id xxx not found", llm_provider="openai", model="gpt"),
|
||||
_proxy_exc("No vector store found with id abc", ProxyErrorTypes.not_found_error.value, 404),
|
||||
),
|
||||
"404_RESOURCE_NOT_FOUND",
|
||||
),
|
||||
(
|
||||
(
|
||||
litellm.APIConnectionError("Connection error", llm_provider="openai", model="gpt"),
|
||||
litellm.InternalServerError("TransferEncodingError", llm_provider="openai", model="gpt"),
|
||||
litellm.APIError(500, "Response payload is not completed", llm_provider="openai", model="gpt"),
|
||||
httpx.RemoteProtocolError(
|
||||
"peer closed connection without sending complete message body (incomplete chunked read)"
|
||||
),
|
||||
),
|
||||
"500_PROVIDER_CONNECTION_ERROR",
|
||||
),
|
||||
(
|
||||
(
|
||||
litellm.ServiceUnavailableError("server_is_overloaded", llm_provider="anthropic", model="c"),
|
||||
litellm.InternalServerError(
|
||||
"Bedrock is unable to process your request", llm_provider="bedrock", model="c"
|
||||
),
|
||||
litellm.APIError(529, "Overloaded", llm_provider="anthropic", model="c"),
|
||||
),
|
||||
"503_PROVIDER_OVERLOADED",
|
||||
),
|
||||
(
|
||||
(
|
||||
litellm.InternalServerError(
|
||||
"The server had an error while processing your request", llm_provider="openai", model="gpt"
|
||||
),
|
||||
litellm.APIError(500, "server_error", llm_provider="openai", model="gpt"),
|
||||
),
|
||||
"500_PROVIDER_INTERNAL_ERROR",
|
||||
),
|
||||
(
|
||||
(
|
||||
_proxy_exc("No fallback model group found for gpt-5.6", "internal_server_error", 500),
|
||||
_proxy_exc("No fallback model group found for claude-46-sonnet", "internal_server_error", 500),
|
||||
),
|
||||
"500_ROUTER_NO_FALLBACK",
|
||||
),
|
||||
(
|
||||
(
|
||||
_proxy_exc("Error doing the fallback: RateLimitError", "internal_server_error", 500),
|
||||
MidStreamFallbackError(
|
||||
"stream died", model="gpt", llm_provider="openai", original_exception=ValueError("boom")
|
||||
),
|
||||
),
|
||||
"500_ROUTER_FALLBACK_FAILURE",
|
||||
),
|
||||
(
|
||||
(
|
||||
TypeError("cannot pickle '_thread.RLock' object"),
|
||||
RuntimeError("dictionary changed size during iteration"),
|
||||
TypeError("'NoneType' object is not iterable"),
|
||||
),
|
||||
"500_INTERNAL_STATE_ERROR",
|
||||
),
|
||||
(
|
||||
(
|
||||
litellm.Timeout("Timeout on reading data from socket", model="gpt", llm_provider="openai"),
|
||||
litellm.APIError(504, "Request timed out", llm_provider="openai", model="gpt"),
|
||||
),
|
||||
"408_UPSTREAM_TIMEOUT",
|
||||
),
|
||||
(
|
||||
(
|
||||
_proxy_exc("500: Upstream passthrough request failed", "internal_server_error", 500),
|
||||
_proxy_exc("503: Upstream passthrough request failed", "internal_server_error", 503),
|
||||
),
|
||||
"500_UPSTREAM_PASSTHROUGH",
|
||||
),
|
||||
(
|
||||
(
|
||||
_proxy_exc("OCR is not supported for provider openai", "internal_server_error", 500),
|
||||
NotImplementedError("rerank"),
|
||||
),
|
||||
"500_UNSUPPORTED_OPERATION",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_variants_of_one_failure_share_a_normalized_error(messages: tuple[Exception, ...], expected: str) -> None:
|
||||
normalized = {StandardLoggingPayloadSetup.get_error_information(exc)["normalized_error"] for exc in messages}
|
||||
assert normalized == {expected}
|
||||
|
||||
|
||||
def test_router_no_healthy_deployment_wording_clusters_as_no_healthy_deployments() -> None:
|
||||
for message in (RouterErrors.no_healthy_deployments.value, "No healthy deployments found."):
|
||||
exc = litellm.BadRequestError(message, llm_provider="openai", model="gpt-4o")
|
||||
assert normalize_error(exc, "400", message) == "429_NO_HEALTHY_DEPLOYMENTS", message
|
||||
|
||||
|
||||
def test_provider_budget_routing_wording_clusters_as_budget_exceeded() -> None:
|
||||
message = RouterErrors.no_deployments_with_provider_budget_routing.value
|
||||
exc = litellm.BadRequestError(message, llm_provider="openai", model="gpt-4o")
|
||||
assert normalize_error(exc, "400", message) == "429_BUDGET_EXCEEDED"
|
||||
|
||||
|
||||
def test_router_fallback_wording_does_not_hide_the_wrapped_exception_class() -> None:
|
||||
provider_message = "litellm.AuthenticationError: OpenAIException - Incorrect API key provided"
|
||||
exc = litellm.AuthenticationError(
|
||||
provider_message + "\nNo fallback model group found for lookup_groups=['x']",
|
||||
llm_provider="openai",
|
||||
model="gpt",
|
||||
)
|
||||
assert normalize_error(exc, "401", str(exc)) == "401_AUTHENTICATION_FAILED"
|
||||
wrapped = litellm.AuthenticationError(
|
||||
"Error doing the fallback: " + provider_message, llm_provider="openai", model="gpt"
|
||||
)
|
||||
assert normalize_error(wrapped, "401", str(wrapped)) == "401_AUTHENTICATION_FAILED"
|
||||
|
||||
|
||||
def test_parameter_length_error_is_not_a_context_window_error() -> None:
|
||||
exc = litellm.BadRequestError("string too long: 'user' max 64 chars", llm_provider="openai", model="gpt")
|
||||
assert StandardLoggingPayloadSetup.get_error_information(exc)["normalized_error"] == "400_INVALID_REQUEST"
|
||||
|
||||
|
||||
def test_no_exception_has_no_normalized_error() -> None:
|
||||
assert StandardLoggingPayloadSetup.get_error_information(None)["normalized_error"] is None
|
||||
|
||||
|
||||
def test_unknown_exception_falls_back_to_status_then_unclassified() -> None:
|
||||
assert normalize_error(Exception("x"), "429", "x") == "429_RATE_LIMIT_EXCEEDED"
|
||||
assert normalize_error(Exception("x"), "", "x") == "UNCLASSIFIED"
|
||||
|
||||
|
||||
def test_budget_exceeded_error_with_custom_wording_is_still_a_budget_error() -> None:
|
||||
exc = litellm.BudgetExceededError(current_cost=2.0, max_budget=1.0, message="Spending cap reached for key")
|
||||
assert StandardLoggingPayloadSetup.get_error_information(exc)["normalized_error"] == "429_BUDGET_EXCEEDED"
|
||||
|
||||
|
||||
def test_every_model_access_denied_proxy_type_shares_one_cluster() -> None:
|
||||
access_denied_types = tuple(t for t in ProxyErrorTypes if t.value.endswith("_model_access_denied"))
|
||||
assert len(access_denied_types) >= 6, access_denied_types
|
||||
codes = {normalize_error(_proxy_exc("denied", t.value, 403), "403", "denied") for t in access_denied_types}
|
||||
assert codes == {"403_MODEL_ACCESS_DENIED"}, codes
|
||||
|
||||
|
||||
def test_non_string_type_attribute_falls_through_to_status() -> None:
|
||||
class _OddType(Exception):
|
||||
type = {"kind": "odd"}
|
||||
|
||||
assert normalize_error(_OddType("odd"), "500", "odd") == "500_PROVIDER_INTERNAL_ERROR"
|
||||
|
||||
|
||||
def test_normalized_error_never_embeds_dynamic_parts() -> None:
|
||||
exc = _proxy_exc(
|
||||
"No team has access to anthropic.claude-sonnet-4-5", ProxyErrorTypes.team_model_access_denied.value, 401
|
||||
)
|
||||
info = StandardLoggingPayloadSetup.get_error_information(exc)
|
||||
assert info["error_message"] == "No team has access to anthropic.claude-sonnet-4-5"
|
||||
assert "claude" not in (info["normalized_error"] or "")
|
||||
Loading…
Add table
Reference in a new issue