diff --git a/litellm/constants.py b/litellm/constants.py index 1211a600747..cbf5efdbca0 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1980,6 +1980,8 @@ UNSAFE_PROXY_RESPONSE_HEADERS: Final[frozenset[str]] = ( HTTP_FRAMING_HEADERS | BROWSER_SECURITY_HEADERS | ORIGIN_SERVER_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( diff --git a/litellm/exceptions.py b/litellm/exceptions.py index fdc2cc1f169..eb4b5f535ff 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -235,7 +235,9 @@ class BadRequestError(openai.BadRequestError): self.litellm_debug_info = litellm_debug_info self.max_retries = max_retries self.num_retries = num_retries - self.headers: dict[str, str] | None = {k: str(v) for k, v in headers.items()} if headers else None + self.headers = ( + {k: str(v) for k, v in headers.items()} if headers else None # mutable-ok: the proxy updates it in place + ) # Use response if it's a valid httpx.Response with a request, otherwise use minimal error response # Note: We check _request (not .request property) to avoid RuntimeError when _request is None if ( diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 36b53a26c99..b3dec655092 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -3,6 +3,7 @@ import json import re import traceback from collections.abc import Mapping +from types import MappingProxyType from typing import Any, Final, Protocol, cast import httpx @@ -206,7 +207,7 @@ def _get_response_headers(original_exception: Exception) -> httpx.Headers | None 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} + return MappingProxyType({name: value for name, value in candidates.items() if name in accepted}) def extract_and_raise_litellm_exception( @@ -236,7 +237,9 @@ def extract_and_raise_litellm_exception( message=error_str, llm_provider=custom_llm_provider, model=model, - **_accepted_init_kwargs(raised_exception_obj, {"response": response, "body": body, "headers": headers}), + **_accepted_init_kwargs( + raised_exception_obj, MappingProxyType({"response": response, "body": body, "headers": headers}) + ), ) diff --git a/litellm/proxy/common_utils/openai_error_payload.py b/litellm/proxy/common_utils/openai_error_payload.py index 90b3c998247..fe23ab2c4b6 100644 --- a/litellm/proxy/common_utils/openai_error_payload.py +++ b/litellm/proxy/common_utils/openai_error_payload.py @@ -8,7 +8,7 @@ from typing import Final from fastapi import status -_STRINGIFIED_NONE: Final = "None" +from litellm.constants import STRINGIFIED_NONE _OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType( { @@ -37,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) and carried != _STRINGIFIED_NONE: + 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: @@ -51,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) and carried != _STRINGIFIED_NONE else None + return carried if isinstance(carried, str) and carried != STRINGIFIED_NONE else None 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 1ff2bbb9bdd..653c07d06ad 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 @@ -1430,8 +1430,6 @@ def _openai_handler_error( 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, "code": str(status_code), "message": message} return OpenAIError( status_code=status_code, @@ -1448,9 +1446,6 @@ _PROXY_HEADERS = {"x-litellm-call-id": "call-guardrail", "x-litellm-applied-guar ("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", @@ -1469,9 +1464,6 @@ def test_litellm_proxy_guardrail_block_keeps_body_and_headers(error_type: str, s "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: @@ -1489,8 +1481,6 @@ def test_litellm_proxy_relayed_litellm_error_keeps_body_and_headers(relayed_clas def test_openai_compatible_vendor_400_keeps_body_but_not_headers(): - """A vendor's own response headers stay on e.response the way every other mapped provider - error keeps them; only a LiteLLM proxy upstream puts headers on e.headers.""" with pytest.raises(litellm.BadRequestError) as exc_info: exception_type( model="gpt-5.4-mini", 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 90f1da84a61..90850840ab4 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 @@ -146,9 +146,6 @@ def test_a_status_carried_by_an_exception_drives_the_type_it_reports(): 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.""" from litellm.exceptions import BadRequestError carried = BadRequestError( diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 1f8fa762fbd..ef5741e472a 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -4064,9 +4064,6 @@ class TestHandleLLMApiExceptionFramingHeaders: assert proxy_exc.headers["x-request-id"] == "abc-123" async def test_strips_the_date_and_server_headers_of_an_upstream_litellm_proxy(self): - """A proxy fronting another LiteLLM proxy gets the upstream's date and server - on the mapped exception; forwarding them would duplicate the Date header - uvicorn adds to every response and leak the upstream server identity.""" exc = litellm.BadRequestError( message="Content blocked", llm_provider="litellm_proxy",