fix(sdk): carry body and proxy headers on relayed litellm errors and content policy blocks too

This commit is contained in:
mateo-berri 2026-09-13 02:23:34 -07:00
parent c0c0c9a9eb
commit 825e4f17e9
4 changed files with 72 additions and 46 deletions

View file

@ -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):

View file

@ -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"

View file

@ -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():

View file

@ -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."""