refactor(sdk): move the None sentinel to constants and freeze the init kwargs filter

This commit is contained in:
mateo-berri 2026-09-14 21:46:22 -07:00
parent 879fcd847f
commit f4f1e2eace
7 changed files with 13 additions and 22 deletions

View file

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

View file

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

View file

@ -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})
),
)

View file

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

View file

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

View file

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

View file

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