Merge pull request #40994 from BerriAI/litellm_sdk_exception_body_headers

fix(sdk): keep body and proxy headers on BadRequestError mapped from a litellm_proxy 400
This commit is contained in:
Mateo Wang 2026-09-14 23:27:29 -07:00 committed by GitHub
commit b52de1675a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 201 additions and 124 deletions

View file

@ -1976,6 +1976,8 @@ BROWSER_SECURITY_HEADERS: Final[frozenset[str]] = frozenset(
UNSAFE_PROXY_RESPONSE_HEADERS: Final[frozenset[str]] = HTTP_FRAMING_HEADERS | BROWSER_SECURITY_HEADERS
STRINGIFIED_NONE: Final[str] = "None"
# A retrieved response replays the usage of the call that created it, so pricing these
# read/management routes like inference bills the same tokens twice.
NON_INFERENCE_CALL_TYPES: Final[frozenset[str]] = frozenset(

View file

@ -1,6 +1,9 @@
import inspect
import json
import re
import traceback
from collections.abc import Mapping
from types import MappingProxyType
from typing import Any, Final, Protocol, cast
import httpx
@ -202,11 +205,17 @@ 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 MappingProxyType({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,
):
"""
Covers scenario where litellm sdk calling proxy.
@ -216,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, MappingProxyType({"response": response, "body": body})),
)
class _ProviderHTTPException(Protocol):
@ -254,6 +250,23 @@ class _ProviderHTTPException(Protocol):
llm_provider: str
def _litellm_proxy_response(
original_exception: _ProviderHTTPException, custom_llm_provider: str
) -> httpx.Response | None:
response: Final = getattr(original_exception, "response", None)
if custom_llm_provider != "litellm_proxy" or not isinstance(response, httpx.Response) or response.headers:
return response
headers: Final = getattr(original_exception, "headers", None)
if not isinstance(headers, Mapping) or not headers:
return response
pairs: Final = headers.multi_items() if isinstance(headers, httpx.Headers) else headers.items()
return httpx.Response(
status_code=response.status_code,
headers=[(str(k), str(v)) for k, v in pairs],
request=getattr(original_exception, "request", None),
)
def _map_openai_exception(
*,
model: str,
@ -264,6 +277,7 @@ def _map_openai_exception(
exception_provider: str,
extra_information: str,
) -> None:
response: Final = _litellm_proxy_response(original_exception, custom_llm_provider)
# custom_llm_provider is openai, make it OpenAI
message = get_error_message(error_obj=original_exception)
if message is None:
@ -292,14 +306,14 @@ def _map_openai_exception(
message=f"RateLimitError: {exception_provider} - {message}",
model=model,
llm_provider=custom_llm_provider,
response=getattr(original_exception, "response", None),
response=response,
)
elif ExceptionCheckers.is_error_str_context_window_exceeded(error_str):
raise ContextWindowExceededError(
message=f"ContextWindowExceededError: {exception_provider} - {message}",
llm_provider=custom_llm_provider,
model=model,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
)
elif "invalid_request_error" in error_str and "model_not_found" in error_str:
@ -307,7 +321,7 @@ def _map_openai_exception(
message=f"{exception_provider} - {message}",
llm_provider=custom_llm_provider,
model=model,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
)
elif "A timeout occurred" in error_str:
@ -326,8 +340,9 @@ def _map_openai_exception(
message=f"ContentPolicyViolationError: {exception_provider} - {message}",
llm_provider=custom_llm_provider,
model=model,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
body=getattr(original_exception, "body", None),
)
elif "invalid_encrypted_content" in error_str or "could not be verified" in error_str:
helpful_message: Final = (
@ -345,7 +360,7 @@ def _map_openai_exception(
message=helpful_message,
llm_provider=custom_llm_provider,
model=model,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
body=getattr(original_exception, "body", None),
)
@ -354,7 +369,7 @@ def _map_openai_exception(
message=f"{exception_provider} - {message}",
llm_provider=custom_llm_provider,
model=model,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
body=getattr(original_exception, "body", None),
)
@ -372,7 +387,7 @@ def _map_openai_exception(
message=f"RateLimitError: {exception_provider} - {message}",
model=model,
llm_provider=custom_llm_provider,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
)
elif (
@ -383,7 +398,7 @@ def _map_openai_exception(
message=f"AuthenticationError: {exception_provider} - {message}",
llm_provider=custom_llm_provider,
model=model,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
)
elif "Mistral API raised a streaming error" in error_str:
@ -402,15 +417,16 @@ def _map_openai_exception(
message=f"{exception_provider} - {message}",
llm_provider=custom_llm_provider,
model=model,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
body=getattr(original_exception, "body", None),
)
elif original_exception.status_code == 401:
raise AuthenticationError(
message=f"AuthenticationError: {exception_provider} - {message}",
llm_provider=custom_llm_provider,
model=model,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
)
elif original_exception.status_code == 404:
@ -418,7 +434,7 @@ def _map_openai_exception(
message=f"NotFoundError: {exception_provider} - {message}",
model=model,
llm_provider=custom_llm_provider,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
)
elif original_exception.status_code == 408:
@ -433,7 +449,7 @@ def _map_openai_exception(
message=f"{exception_provider} - {message}",
model=model,
llm_provider=custom_llm_provider,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
body=getattr(original_exception, "body", None),
)
@ -442,7 +458,7 @@ def _map_openai_exception(
message=f"RateLimitError: {exception_provider} - {message}",
model=model,
llm_provider=custom_llm_provider,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
)
elif original_exception.status_code == 500:
@ -450,7 +466,7 @@ def _map_openai_exception(
message=f"InternalServerError: {exception_provider} - {message}",
model=model,
llm_provider=custom_llm_provider,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
)
elif original_exception.status_code == 502:
@ -458,7 +474,7 @@ def _map_openai_exception(
message=f"BadGatewayError: {exception_provider} - {message}",
model=model,
llm_provider=custom_llm_provider,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
)
elif original_exception.status_code == 503:
@ -466,7 +482,7 @@ def _map_openai_exception(
message=f"ServiceUnavailableError: {exception_provider} - {message}",
model=model,
llm_provider=custom_llm_provider,
response=getattr(original_exception, "response", None),
response=response,
litellm_debug_info=extra_information,
)
elif original_exception.status_code == 504: # gateway timeout error
@ -2423,10 +2439,11 @@ def exception_type(
custom_llm_provider == "litellm_proxy"
): # handle special case where calling litellm proxy + exception str contains error message
extract_and_raise_litellm_exception(
response=getattr(original_exception, "response", None),
response=_litellm_proxy_response(mappable_exception, custom_llm_provider),
error_str=error_str,
model=model,
custom_llm_provider=custom_llm_provider,
body=getattr(original_exception, "body", None),
)
if (
custom_llm_provider == "openai"

View file

@ -8,6 +8,8 @@ from typing import Final
from fastapi import status
from litellm.constants import STRINGIFIED_NONE
_OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType(
{
status.HTTP_401_UNAUTHORIZED: "authentication_error",
@ -35,7 +37,7 @@ def openai_error_type(exc: object, status_code: int) -> str:
"""OpenAI types ``error.type`` as a required string, so an exception carrying none
falls back to the type its status code stands for."""
carried: Final = attribute_of(exc, "type")
if isinstance(carried, str):
if isinstance(carried, str) and carried != STRINGIFIED_NONE:
return carried
mapped: Final = _OPENAI_ERROR_TYPE_BY_STATUS.get(status_code)
if mapped is not None:
@ -49,4 +51,4 @@ def openai_error_param(exc: object) -> str | None:
"""OpenAI types ``error.param`` as nullable, so an exception carrying none
serializes as JSON ``null``."""
carried: Final = attribute_of(exc, "param")
return carried if isinstance(carried, str) else None
return carried if isinstance(carried, str) and carried != STRINGIFIED_NONE else None

View file

@ -1,4 +1,3 @@
import httpx
import openai
import pytest
@ -178,9 +177,7 @@ class TestExceptionCheckers:
]
for error_str in error_strings:
result = ExceptionCheckers.is_azure_content_policy_violation_error(
error_str
)
result = ExceptionCheckers.is_azure_content_policy_violation_error(error_str)
assert result is True, f"Should detect policy violation in: {error_str}"
def test_is_azure_content_policy_violation_error_case_insensitive(self):
@ -194,12 +191,8 @@ class TestExceptionCheckers:
]
for error_str in error_strings:
result = ExceptionCheckers.is_azure_content_policy_violation_error(
error_str
)
assert (
result is True
), f"Should detect policy violation in uppercase: {error_str}"
result = ExceptionCheckers.is_azure_content_policy_violation_error(error_str)
assert result is True, f"Should detect policy violation in uppercase: {error_str}"
def test_is_azure_content_policy_violation_error_with_non_policy_errors(self):
"""Test that non-policy violation errors are not detected as policy violations"""
@ -216,12 +209,8 @@ class TestExceptionCheckers:
]
for error_str in error_strings:
result = ExceptionCheckers.is_azure_content_policy_violation_error(
error_str
)
assert (
result is False
), f"Should NOT detect policy violation in: {error_str}"
result = ExceptionCheckers.is_azure_content_policy_violation_error(error_str)
assert result is False, f"Should NOT detect policy violation in: {error_str}"
def test_is_azure_content_policy_violation_error_with_partial_matches(self):
"""Test that partial keyword matches work correctly"""
@ -234,9 +223,7 @@ class TestExceptionCheckers:
]
for error_str in positive_cases:
result = ExceptionCheckers.is_azure_content_policy_violation_error(
error_str
)
result = ExceptionCheckers.is_azure_content_policy_violation_error(error_str)
assert result is True, f"Should detect policy violation in: {error_str}"
# These should not match even though they contain similar words
@ -248,12 +235,8 @@ class TestExceptionCheckers:
]
for error_str in negative_cases:
result = ExceptionCheckers.is_azure_content_policy_violation_error(
error_str
)
assert (
result is False
), f"Should NOT detect policy violation in: {error_str}"
result = ExceptionCheckers.is_azure_content_policy_violation_error(error_str)
assert result is False, f"Should NOT detect policy violation in: {error_str}"
gemini_context_window_test_cases = [
@ -271,12 +254,8 @@ gemini_context_window_test_cases = [
]
@pytest.mark.parametrize(
"error_message, should_raise_context_window", gemini_context_window_test_cases
)
def test_gemini_context_window_error_mapping(
error_message, should_raise_context_window
):
@pytest.mark.parametrize("error_message, should_raise_context_window", gemini_context_window_test_cases)
def test_gemini_context_window_error_mapping(error_message, should_raise_context_window):
"""
Tests that the exception_type function correctly maps Gemini's
context window exceeded errors to litellm.ContextWindowExceededError.
@ -421,9 +400,7 @@ vertex_rate_limit_test_cases = [
]
@pytest.mark.parametrize(
"error_message, should_raise_rate_limit", vertex_rate_limit_test_cases
)
@pytest.mark.parametrize("error_message, should_raise_rate_limit", vertex_rate_limit_test_cases)
def test_vertex_ai_rate_limit_error_mapping(error_message, should_raise_rate_limit):
"""
Tests that the exception_type function correctly maps Vertex AI's
@ -458,10 +435,7 @@ class TestGetBodyErrorCode:
"""Unit tests for _get_body_error_code helper."""
def test_parses_int_code(self):
body = (
'{"error":{"message":"high demand","type":"upstream_error",'
'"param":"","code":429}}'
)
body = '{"error":{"message":"high demand","type":"upstream_error","param":"","code":429}}'
assert _get_body_error_code(body) == 429
def test_parses_string_code(self):
@ -498,8 +472,7 @@ gemini_body_code_429_test_cases = [
),
(
503,
'{"error":{"message":"upstream unavailable","type":"upstream_error",'
'"param":"","code":429}}',
'{"error":{"message":"upstream unavailable","type":"upstream_error","param":"","code":429}}',
litellm.RateLimitError,
"HTTP 503 envelope with body code:429 -> RateLimitError",
),
@ -769,9 +742,7 @@ class _UpstreamHTTPError(Exception):
self.message = "upstream failure"
self.status_code = status_code
self.request = httpx.Request("POST", "https://api.example.com/v1/chat/completions")
self.response = httpx.Response(
status_code=status_code, request=self.request, text="upstream failure"
)
self.response = httpx.Response(status_code=status_code, request=self.request, text="upstream failure")
UPSTREAM_STATUS_CODES = (400, 401, 403, 404, 408, 422, 429, 500, 503)
@ -892,15 +863,13 @@ PROVIDERS_WITHOUT_A_HANDLER = tuple(
MINIMAX_401_BODY = (
'{"type":"error","error":{"type":"authorized_error","message":"login fail: Please carry the API secret key '
"in the 'Authorization' field of the request header (1004)\",\"http_code\":\"401\"},"
'in the \'Authorization\' field of the request header (1004)","http_code":"401"},'
'"request_id":"06ddc9ba97ee6340e38f10e09787f547"}'
)
def _expected_for(provider: str, status_code: int) -> tuple[type[Exception], int]:
return DEVIATIONS_FROM_THE_OPENAI_SHAPE.get(provider, {}).get(
status_code, OPENAI_SHAPED[status_code]
)
return DEVIATIONS_FROM_THE_OPENAI_SHAPE.get(provider, {}).get(status_code, OPENAI_SHAPED[status_code])
@pytest.fixture
@ -910,9 +879,7 @@ def quiet_exception_mapping(monkeypatch):
@pytest.mark.parametrize("status_code", UPSTREAM_STATUS_CODES)
@pytest.mark.parametrize("provider", PROVIDERS_WITH_A_HANDLER)
def test_an_upstream_status_maps_to_one_exception_per_provider(
provider, status_code, quiet_exception_mapping
):
def test_an_upstream_status_maps_to_one_exception_per_provider(provider, status_code, quiet_exception_mapping):
expected_class, expected_status = _expected_for(provider, status_code)
with pytest.raises(openai.APIError) as raised:
@ -928,9 +895,7 @@ def test_an_upstream_status_maps_to_one_exception_per_provider(
@pytest.mark.parametrize("status_code", UPSTREAM_STATUS_CODES)
@pytest.mark.parametrize("provider", PROVIDERS_WITH_A_HANDLER)
def test_a_mapped_exception_keeps_the_provider_and_model_it_came_from(
provider, status_code, quiet_exception_mapping
):
def test_a_mapped_exception_keeps_the_provider_and_model_it_came_from(provider, status_code, quiet_exception_mapping):
with pytest.raises(openai.APIError) as raised:
exception_type(
model="test-model",
@ -943,12 +908,8 @@ def test_a_mapped_exception_keeps_the_provider_and_model_it_came_from(
@pytest.mark.parametrize("provider", PROVIDERS_WITH_A_HANDLER)
def test_an_already_mapped_litellm_exception_passes_through_untouched(
provider, quiet_exception_mapping
):
already_mapped = litellm.RateLimitError(
message="already mapped", llm_provider=provider, model="test-model"
)
def test_an_already_mapped_litellm_exception_passes_through_untouched(provider, quiet_exception_mapping):
already_mapped = litellm.RateLimitError(message="already mapped", llm_provider=provider, model="test-model")
returned = exception_type(
model="test-model",
@ -961,9 +922,7 @@ def test_an_already_mapped_litellm_exception_passes_through_untouched(
@pytest.mark.parametrize("status_code", UPSTREAM_STATUS_CODES)
@pytest.mark.parametrize("provider", PROVIDERS_WITHOUT_A_HANDLER)
def test_a_provider_without_a_handler_maps_by_the_upstream_status(
provider, status_code, quiet_exception_mapping
):
def test_a_provider_without_a_handler_maps_by_the_upstream_status(provider, status_code, quiet_exception_mapping):
expected_class, expected_status = STATUS_KEYED[status_code]
with pytest.raises(openai.APIError) as raised:
@ -1015,9 +974,7 @@ def test_an_unmapped_exception_with_no_model_or_provider_is_a_connection_error(q
assert "boom" in raised.value.message
def _raise_and_map(
model: str | None, original_exception: Exception, custom_llm_provider: str | None
) -> None:
def _raise_and_map(model: str | None, original_exception: Exception, custom_llm_provider: str | None) -> None:
"""Calls exception_type() from inside the except block, as litellm/main.py does,
so traceback.format_exc() has a real stack."""
try:
@ -1058,9 +1015,7 @@ def test_an_unmapped_exception_with_no_model_or_provider_message_keeps_traceback
CONTEXT_WINDOW_MESSAGE = "This model's maximum context length is 4096 tokens."
CONTENT_POLICY_MESSAGE = (
'{"error": {"type": "invalid_request_error", "code": "content_policy_violation"}}'
)
CONTENT_POLICY_MESSAGE = '{"error": {"type": "invalid_request_error", "code": "content_policy_violation"}}'
TIMEOUT_MESSAGE = "Request timed out."
PROVIDERS_THAT_RECOGNISE_A_FULL_CONTEXT_WINDOW = (
@ -1103,15 +1058,11 @@ class _UpstreamErrorWithMessage(_UpstreamHTTPError):
super().__init__(status_code=status_code)
self.args = (message,)
self.message = message
self.response = httpx.Response(
status_code=status_code, request=self.request, text=message
)
self.response = httpx.Response(status_code=status_code, request=self.request, text=message)
@pytest.mark.parametrize("provider", PROVIDERS_WITH_A_HANDLER)
def test_a_full_context_window_reaches_the_caller_as_the_router_needs_it(
provider, quiet_exception_mapping
):
def test_a_full_context_window_reaches_the_caller_as_the_router_needs_it(provider, quiet_exception_mapping):
if provider in PROVIDERS_THAT_RECOGNISE_A_FULL_CONTEXT_WINDOW:
expected_class, expected_status = litellm.ContextWindowExceededError, 400
else:
@ -1129,9 +1080,7 @@ def test_a_full_context_window_reaches_the_caller_as_the_router_needs_it(
@pytest.mark.parametrize("provider", PROVIDERS_WITH_A_HANDLER)
def test_a_content_policy_block_reaches_the_caller_as_the_router_needs_it(
provider, quiet_exception_mapping
):
def test_a_content_policy_block_reaches_the_caller_as_the_router_needs_it(provider, quiet_exception_mapping):
if provider in PROVIDERS_THAT_RECOGNISE_A_CONTENT_POLICY_BLOCK:
expected_class, expected_status = litellm.ContentPolicyViolationError, 400
else:
@ -1149,9 +1098,7 @@ def test_a_content_policy_block_reaches_the_caller_as_the_router_needs_it(
@pytest.mark.parametrize("provider", PROVIDERS_WITH_A_HANDLER)
def test_a_timed_out_request_is_a_timeout_for_every_provider(
provider, quiet_exception_mapping
):
def test_a_timed_out_request_is_a_timeout_for_every_provider(provider, quiet_exception_mapping):
with pytest.raises(litellm.Timeout) as raised:
exception_type(
model="test-model",
@ -1409,3 +1356,97 @@ def test_bedrock_timeout_mapping_keeps_retry_after_readable(status_code):
exception_headers = _get_response_headers(original_exception=exc_info.value)
assert exception_headers is not None
assert litellm.utils._get_retry_after_from_exception_header(response_headers=exception_headers) == 7
_GUARDRAIL_BLOCK_ERROR = {
"message": "Content blocked: secret_project_codename pattern detected",
"param": "None",
"code": "400",
"provider_specific_fields": {
"error": "Content blocked: secret_project_codename pattern detected",
"pattern": "secret_project_codename",
"guardrail_name": "block-secret-project",
"guardrail_mode": "pre_call",
},
}
def _openai_handler_error(
error_type: str,
headers: dict[str, str] | list[tuple[str, str]],
status_code: int = 400,
message: str = _GUARDRAIL_BLOCK_ERROR["message"],
) -> OpenAIError:
wire_error = {**_GUARDRAIL_BLOCK_ERROR, "type": error_type, "code": str(status_code), "message": message}
return OpenAIError(
status_code=status_code,
message=f"Error code: {status_code} - {{'error': {wire_error}}}",
headers=httpx.Headers(headers),
body=wire_error,
)
_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):
with pytest.raises(litellm.BadRequestError) as exc_info:
exception_type(
model="claude-haiku-4-5",
original_exception=_openai_handler_error(error_type, _PROXY_HEADERS, status_code=status_code),
custom_llm_provider="litellm_proxy",
completion_kwargs={},
extra_kwargs={},
)
assert exc_info.value.body["provider_specific_fields"]["guardrail_name"] == "block-secret-project"
assert exc_info.value.body["type"] == error_type
assert dict(exc_info.value.response.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]):
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 dict(exc_info.value.response.headers) == _PROXY_HEADERS
def test_openai_compatible_vendor_400_keeps_body_but_not_headers():
with pytest.raises(litellm.BadRequestError) as exc_info:
exception_type(
model="gpt-5.4-mini",
original_exception=_openai_handler_error("vendor_specific_error", {"openai-organization": "org-1"}),
custom_llm_provider="openai",
completion_kwargs={},
extra_kwargs={},
)
assert exc_info.value.body["type"] == "vendor_specific_error"
assert not exc_info.value.response.headers
def test_litellm_proxy_repeated_response_header_keeps_each_value():
repeated = [("x-litellm-call-id", "call-guardrail"), ("set-cookie", "a=1"), ("set-cookie", "b=2")]
with pytest.raises(litellm.BadRequestError) as exc_info:
exception_type(
model="claude-haiku-4-5",
original_exception=_openai_handler_error("None", repeated),
custom_llm_provider="litellm_proxy",
completion_kwargs={},
extra_kwargs={},
)
assert exc_info.value.response.headers.multi_items() == repeated

View file

@ -143,3 +143,18 @@ def test_a_status_carried_by_an_exception_drives_the_type_it_reports():
exc = HTTPException(status_code=403, detail="blocked by policy")
assert openai_error_type(exc, error_status_code(exc, 400)) == "permission_error"
def test_a_stringified_none_type_or_param_is_treated_as_absent():
from litellm.exceptions import BadRequestError
carried = BadRequestError(
message="Content blocked",
model="claude-haiku-4-5",
llm_provider="litellm_proxy",
body={"message": "Content blocked", "type": "None", "param": "None", "code": "400"},
)
assert carried.type == "None"
assert openai_error_type(carried, 400) == "invalid_request_error"
assert openai_error_param(carried) is None