mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
fix(sdk): carry body and proxy headers on relayed litellm errors and content policy blocks too
This commit is contained in:
parent
c0c0c9a9eb
commit
825e4f17e9
4 changed files with 72 additions and 46 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue