diff --git a/litellm/exceptions.py b/litellm/exceptions.py index 3d9e26e450b..fdc2cc1f169 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -626,6 +626,7 @@ class ContentPolicyViolationError(BadRequestError): litellm_debug_info: str | None = None, provider_specific_fields: dict | None = None, body: dict | None = None, + headers: Mapping[str, str] | None = None, ): self.status_code = 400 self.message = f"litellm.ContentPolicyViolationError: {message}" @@ -640,6 +641,7 @@ class ContentPolicyViolationError(BadRequestError): response=response, litellm_debug_info=self.litellm_debug_info, body=body, + headers=headers, ) # Call the base class constructor with the parameters it needs def __str__(self): diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index fd1ba666887..36b53a26c99 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -1,3 +1,4 @@ +import inspect import json import re import traceback @@ -203,11 +204,18 @@ def _get_response_headers(original_exception: Exception) -> httpx.Headers | None return _response_headers +def _accepted_init_kwargs(exception_class: type[Exception], candidates: Mapping[str, object]) -> Mapping[str, object]: + accepted: Final = inspect.signature(exception_class).parameters + return {name: value for name, value in candidates.items() if name in accepted} + + def extract_and_raise_litellm_exception( response: Any | None, error_str: str, model: str, custom_llm_provider: str, + body: object | None = None, + headers: Mapping[str, str] | None = None, ): """ Covers scenario where litellm sdk calling proxy. @@ -217,32 +225,19 @@ def extract_and_raise_litellm_exception( Relevant Issue: https://github.com/BerriAI/litellm/issues/7259 """ pattern: Final = r"litellm\.\w+Error" - - # Search for the exception in the error string match: Final = re.search(pattern, error_str) - - # Extract the exception if found - if match: - exception_name = match.group(0) - exception_name = exception_name.strip().replace("litellm.", "") - raised_exception_obj: Final = getattr(litellm, exception_name, None) - if raised_exception_obj: - # Try with response parameter first, fall back to without it - # Some exceptions (e.g., APIConnectionError) don't accept response param - try: - raise raised_exception_obj( - message=error_str, - llm_provider=custom_llm_provider, - model=model, - response=response, - ) - except TypeError: - # Exception doesn't accept response parameter - raise raised_exception_obj( - message=error_str, - llm_provider=custom_llm_provider, - model=model, - ) + if match is None: + return + exception_name: Final = match.group(0).removeprefix("litellm.") + raised_exception_obj: Final = getattr(litellm, exception_name, None) + if not raised_exception_obj: + return + raise raised_exception_obj( + message=error_str, + llm_provider=custom_llm_provider, + model=model, + **_accepted_init_kwargs(raised_exception_obj, {"response": response, "body": body, "headers": headers}), + ) class _ProviderHTTPException(Protocol): @@ -339,6 +334,8 @@ def _map_openai_exception( model=model, response=getattr(original_exception, "response", None), litellm_debug_info=extra_information, + body=getattr(original_exception, "body", None), + headers=upstream_headers, ) elif "invalid_encrypted_content" in error_str or "could not be verified" in error_str: helpful_message: Final = ( @@ -2443,6 +2440,8 @@ def exception_type( error_str=error_str, model=model, custom_llm_provider=custom_llm_provider, + body=getattr(original_exception, "body", None), + headers=_litellm_proxy_response_headers(mappable_exception, custom_llm_provider), ) if ( custom_llm_provider == "openai" diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index ea6ac17ad45..1ff2bbb9bdd 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -1424,35 +1424,37 @@ _GUARDRAIL_BLOCK_ERROR = { } -def _openai_handler_error(error_type: str, headers: dict[str, str]) -> OpenAIError: - """What litellm/llms/openai/openai.py raises after the openai SDK rejects a 400: +def _openai_handler_error( + error_type: str, + headers: dict[str, str], + status_code: int = 400, + message: str = _GUARDRAIL_BLOCK_ERROR["message"], +) -> OpenAIError: + """What litellm/llms/openai/openai.py raises after the openai SDK rejects a request: the SDK's str() carries the wire body, and the handler copies headers and body over.""" - wire_error = {**_GUARDRAIL_BLOCK_ERROR, "type": error_type} - wire = httpx.Response( - status_code=400, - headers=headers, - json={"error": wire_error}, - request=httpx.Request("POST", "http://localhost:4000/v1/chat/completions"), - ) + wire_error = {**_GUARDRAIL_BLOCK_ERROR, "type": error_type, "code": str(status_code), "message": message} return OpenAIError( - status_code=400, - message=f"Error code: 400 - {{'error': {wire_error}}}", - headers=wire.headers, + status_code=status_code, + message=f"Error code: {status_code} - {{'error': {wire_error}}}", + headers=httpx.Headers(headers), body=wire_error, ) -@pytest.mark.parametrize("error_type", ["None", "invalid_request_error"]) -def test_litellm_proxy_guardrail_block_keeps_body_and_headers(error_type: str): - """An SDK caller behind a proxy tells a guardrail block from any other 400 by the body's - provider_specific_fields and the proxy's x-litellm-* headers, so the mapped BadRequestError - must carry both whichever error.type the proxy version on the other end emits.""" - proxy_headers = {"x-litellm-call-id": "call-guardrail", "x-litellm-applied-guardrails": "block-secret-project"} +_PROXY_HEADERS = {"x-litellm-call-id": "call-guardrail", "x-litellm-applied-guardrails": "block-secret-project"} + +@pytest.mark.parametrize( + ("error_type", "status_code"), [("None", 400), ("invalid_request_error", 400), ("None", 422)] +) +def test_litellm_proxy_guardrail_block_keeps_body_and_headers(error_type: str, status_code: int): + """An SDK caller behind a proxy tells a guardrail block from any other 4xx by the body's + provider_specific_fields and the proxy's x-litellm-* headers, so the mapped BadRequestError + must carry both whichever error.type and status the proxy version on the other end emits.""" with pytest.raises(litellm.BadRequestError) as exc_info: exception_type( model="claude-haiku-4-5", - original_exception=_openai_handler_error(error_type, proxy_headers), + original_exception=_openai_handler_error(error_type, _PROXY_HEADERS, status_code=status_code), custom_llm_provider="litellm_proxy", completion_kwargs={}, extra_kwargs={}, @@ -1460,7 +1462,30 @@ def test_litellm_proxy_guardrail_block_keeps_body_and_headers(error_type: str): assert exc_info.value.body["provider_specific_fields"]["guardrail_name"] == "block-secret-project" assert exc_info.value.body["type"] == error_type - assert proxy_headers.items() <= exc_info.value.headers.items() + assert exc_info.value.headers == _PROXY_HEADERS + + +@pytest.mark.parametrize( + "relayed_class", [litellm.BadRequestError, litellm.ContentPolicyViolationError] +) +def test_litellm_proxy_relayed_litellm_error_keeps_body_and_headers(relayed_class: type[litellm.BadRequestError]): + """A proxy relaying a provider's own litellm error names the class in the message, which + re-raises that class on the SDK side before the generic 400 mapping runs; it must carry the + body and the proxy headers the same way the generic mapping now does.""" + message = f"litellm.{relayed_class.__name__}: {_GUARDRAIL_BLOCK_ERROR['message']}" + + with pytest.raises(relayed_class) as exc_info: + exception_type( + model="claude-haiku-4-5", + original_exception=_openai_handler_error("None", _PROXY_HEADERS, message=message), + custom_llm_provider="litellm_proxy", + completion_kwargs={}, + extra_kwargs={}, + ) + + assert type(exc_info.value) is relayed_class + assert exc_info.value.body["provider_specific_fields"]["guardrail_name"] == "block-secret-project" + assert exc_info.value.headers == _PROXY_HEADERS def test_openai_compatible_vendor_400_keeps_body_but_not_headers(): diff --git a/tests/test_litellm/proxy/common_utils/test_openai_error_payload.py b/tests/test_litellm/proxy/common_utils/test_openai_error_payload.py index db9fe3a2253..90f1da84a61 100644 --- a/tests/test_litellm/proxy/common_utils/test_openai_error_payload.py +++ b/tests/test_litellm/proxy/common_utils/test_openai_error_payload.py @@ -145,7 +145,7 @@ def test_a_status_carried_by_an_exception_drives_the_type_it_reports(): assert openai_error_type(exc, error_status_code(exc, 400)) == "permission_error" -def test_the_stringified_none_an_older_upstream_proxy_sent_is_treated_as_absent(): +def test_a_stringified_none_type_or_param_is_treated_as_absent(): """A proxy fronting a proxy older than 1.102 receives {"type": "None", "param": "None"} on the wire; the SDK now keeps that body on the mapped exception, and re-emitting the literal is the exact bug this module exists to stop."""