From e4599794002616516996b1ec3cf228db5026da53 Mon Sep 17 00:00:00 2001 From: ajitsharmas2007 Date: Tue, 29 Sep 2026 21:11:57 +0530 Subject: [PATCH 1/5] fix(proxy): read a non-object JSON body as no fields instead of crashing A body that is valid JSON but not an object (`[]`, `123`, `"s"`, `true`, `null`) parsed into a list or scalar that `_read_request_body` returned under its `-> dict` annotation. Auth reads the body before any route does and treats it as a mapping, so it raised AttributeError in `pre_db_read_auth_checks`, then again inside `_handle_authentication_error` while turning the first one into a response. With the handler itself broken the caller got a bare 500 and no hint of what was wrong, plus a prefilled "file a bug report" link for their own mistake. Honour the annotation: a non-object body reads as `{}`, the same as an empty body, since every field a caller looks for is a key and a non-object body carries none of them. The route's own validation then returns its usual 400. Coerce rather than reject, because a passthrough body is the caller's own provider payload and need not be an object, as `is_passthrough_request_streaming` documents. Raw forwarding re-reads `request.body()`, so the real payload still reaches the provider untouched. Guard the second crash site too: `_parse_request_data_by_content_type` reads the body itself and looked for envelope fields on whatever `request.json()` returned. Fixes #43711 --- .../proxy/common_utils/http_parsing_utils.py | 17 ++++-- .../pass_through_endpoints.py | 31 ++++++---- .../proxy/auth/test_user_api_key_auth.py | 38 ++++++++++++ .../common_utils/test_http_parsing_utils.py | 60 +++++++++++++++++++ .../test_pass_through_endpoints.py | 52 ++++++++++++++++ 5 files changed, 183 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 1c2bd7ea217..78c01afbd1a 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -127,7 +127,14 @@ async def _read_request_body(request: Request | None) -> dict: - request: The request object to read the body from Returns: - - dict: Parsed request data as a dictionary or an empty dictionary if parsing fails + - dict: Parsed request data as a dictionary. A body that is valid JSON but not an + object reads as ``{}``, the same as an empty body: every field a caller can look + for is a key, so a non-object body carries none of them. Honouring the annotation + matters because auth reads the body before any route does, and both read it with + ``.get(...)``, so returning a list here surfaced as AttributeError -> 500 (#43711) + + Raises: + - ProxyException: 400, when the body is present but malformed """ try: if request is None: @@ -208,9 +215,11 @@ async def _read_request_body(request: Request | None) -> dict: code=status.HTTP_400_BAD_REQUEST, ) - # Cache the parsed result - _safe_set_request_parsed_body(request=request, parsed_body=parsed_body) - return parsed_body + # Cache the parsed result. Raw passthrough forwarding re-reads ``request.body()``, + # so dropping a non-object body here never changes what reaches the provider. + object_body: Final[dict] = parsed_body if isinstance(parsed_body, dict) else {} + _safe_set_request_parsed_body(request=request, parsed_body=object_body) + return object_body except (json.JSONDecodeError, orjson.JSONDecodeError, ProxyException) as e: # Re-raise ProxyException as-is diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index e0a4184291e..b85bca99168 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1914,6 +1914,13 @@ class _PassThroughRequestEnvelope(TypedDict, total=False): stream: bool | None +def _passthrough_envelope(body: object) -> _PassThroughRequestEnvelope | None: + """``request.json()`` returns any JSON kind, and a passthrough body is the caller's own + provider payload, so a list or scalar is legitimate here and simply carries no envelope + fields. Reading it as one raised AttributeError -> 500 (#43711).""" + return body if isinstance(body, dict) else None + + async def _parse_request_data_by_content_type( request: Request, ) -> tuple[object, object, None, bool | None]: @@ -1935,10 +1942,11 @@ async def _parse_request_data_by_content_type( if "application/json" in content_type: # ✅ Handle JSON try: - body: _PassThroughRequestEnvelope = await request.json() - query_params_data = body.get("query_params") - custom_body_data = body.get("custom_body") - stream = body.get("stream") + body: _PassThroughRequestEnvelope | None = _passthrough_envelope(await request.json()) + if body is not None: + query_params_data = body.get("query_params") + custom_body_data = body.get("custom_body") + stream = body.get("stream") except json.JSONDecodeError: # Handle requests with no body (e.g., DELETE requests) pass @@ -1946,14 +1954,15 @@ async def _parse_request_data_by_content_type( # ✅ Try to parse as JSON first (handles misconfigured clients sending JSON with multipart content-type) # If that fails, skip parsing - pass_through_request will handle actual multipart try: - body = await request.json() + body = _passthrough_envelope(await request.json()) # Successfully parsed as JSON - treat as JSON body - query_params_data = body.get("query_params") - custom_body_data = body.get("custom_body") - stream = body.get("stream") - # If custom_body is not set, use the entire body - if custom_body_data is None and body: - custom_body_data = body + if body is not None: + query_params_data = body.get("query_params") + custom_body_data = body.get("custom_body") + stream = body.get("stream") + # If custom_body is not set, use the entire body + if custom_body_data is None and body: + custom_body_data = body except (json.JSONDecodeError, Exception): # Not JSON - this is actual multipart data # Skip parsing here to avoid consuming the request body stream diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 470db99108a..51183eb51b0 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -53,6 +53,7 @@ from litellm.proxy.auth.user_api_key_auth import ( _ensure_parent_otel_span_on_request_state, _PendingAutoRegister, _matches_routing_override, + _read_request_body_deferring_parse_failure, _reserve_budget_after_common_checks, _route_requires_auth_despite_public, _routing_selector_matches_claim, @@ -6419,6 +6420,43 @@ async def test_user_api_key_auth_authenticates_before_raising_malformed_body_err setattr(_proxy_server_mod, k, v) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + pytest.param(b"[]", id="array"), + pytest.param(b'[{"role": "user"}]', id="array-of-objects"), + pytest.param(b"123", id="number"), + pytest.param(b'"gpt-4o"', id="string"), + pytest.param(b"true", id="boolean"), + pytest.param(b"null", id="null"), + ], +) +async def test_auth_reads_a_non_object_body_as_no_fields(body: bytes): + """Auth reads the body before any route does and then treats it as a mapping, in + ``pre_db_read_auth_checks`` and again in ``populate_request_with_path_params``. A body + that parsed to a list/int/str raised AttributeError in both, and in the handler meant + to turn the first one into a response, so the caller got a bare 500 (#43711).""" + from fastapi import Request + from starlette.datastructures import URL + + request = Request( + scope={ + "type": "http", + "headers": [(b"content-type", b"application/json")], + "method": "POST", + "path_params": {"vector_store_id": "vs-1"}, + } + ) + request._url = URL(url="/v1/vector_stores/vs-1/search") + request._body = body + + request_data, parse_exception = await _read_request_body_deferring_parse_failure(request=request) + + assert parse_exception is None + assert request_data == {"vector_store_id": "vs-1", "vector_store_ids": ["vs-1"]} + + async def _run_auth_with_malformed_body(post_call_failure_hook): """Drive ``user_api_key_auth`` for an authenticated caller whose body never parses, with ``proxy_logging_obj.post_call_failure_hook`` swapped for the passed double. diff --git a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py index 7929a0b21af..28f3be96065 100644 --- a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py @@ -504,6 +504,66 @@ def _make_json_request(body: bytes) -> MagicMock: return mock_request +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + pytest.param(b"[]", id="empty-array"), + pytest.param(b'[{"model": "gpt-4o"}]', id="array-of-objects"), + pytest.param(b"123", id="integer"), + pytest.param(b"1.5", id="float"), + pytest.param(b'"gpt-4o"', id="string"), + pytest.param(b"true", id="boolean"), + pytest.param(b"null", id="null"), + ], +) +async def test_non_object_json_body_reads_as_no_fields(body: bytes): + """ + ``orjson.loads`` returns whatever JSON kind it found, so this ``-> dict`` used to hand + back a list/int/str. Auth reads the body before any route does, and both read it with + ``.get(...)``, so that surfaced as AttributeError -> 500 (#43711). Every field a caller + looks for is a key, so a non-object body carries none of them and reads as ``{}``. + """ + assert await _read_request_body(_make_json_request(body)) == {} + + +@pytest.mark.asyncio +async def test_non_object_body_is_not_cached_for_later_readers(): + """The coercion only helps if a second read, or a route reading the cache after auth, + cannot pull the list back out and call ``.get()`` on it.""" + request = _starlette_request(b"[1, 2, 3]", "application/json") + + assert await _read_request_body(request) == {} + assert _safe_get_request_parsed_body(request=request) == {} + assert await _read_request_body(request) == {} + + +@pytest.mark.asyncio +async def test_non_object_body_still_reaches_the_provider_verbatim(): + """Passthrough forwards the raw bytes, not the parsed body, so coercing the parsed view + must not change what a provider actually receives.""" + body = b'[{"role": "user", "content": "hi"}]' + request = _starlette_request(body, "application/json") + + assert await _read_request_body(request) == {} + assert await read_raw_json_body(request) == body + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body, expected", + [ + pytest.param(b"{}", {}, id="empty-object"), + pytest.param(b"", {}, id="empty-body"), + pytest.param(b'{"model": "gpt-4o", "n": [1, 2]}', {"model": "gpt-4o", "n": [1, 2]}, id="object"), + ], +) +async def test_object_bodies_are_untouched(body: bytes, expected: dict): + """The coercion must only ever fire on a non-object body: an object's own list values + stay exactly as the caller sent them.""" + assert await _read_request_body(_make_json_request(body)) == expected + + @pytest.mark.asyncio async def test_surrogate_repair_skipped_above_size_limit(monkeypatch): """ diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 3469df082e0..0935fb3c42d 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -32,6 +32,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY, HttpPassThroughEndpointHelpers, InitPassThroughEndpointHelpers, + _parse_request_data_by_content_type, _registered_pass_through_routes, _truncate_upstream_error_body, _with_trace_context, @@ -7622,3 +7623,54 @@ def test_passthrough_attributes_a_cli_session_to_its_alias_not_the_login_token() metadata = kwargs["litellm_params"]["metadata"] assert metadata["user_api_key"] == "cli-session-alice" assert _get_spend_logs_metadata(metadata)["user_api_key"] == "cli-session-alice" + + +def _json_request(body: bytes, content_type: str = "application/json") -> Request: + async def receive(): + return {"type": "http.request", "body": body, "more_body": False} + + return Request( + { + "type": "http", + "method": "POST", + "path": "/anthropic/v1/messages", + "headers": [(b"content-type", content_type.encode())], + "query_string": b"", + }, + receive, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + pytest.param(b"[]", id="empty-array"), + pytest.param(b'[{"role": "user", "content": "hi"}]', id="array-of-objects"), + pytest.param(b"123", id="number"), + pytest.param(b'"claude-sonnet-4-5"', id="string"), + pytest.param(b"true", id="boolean"), + pytest.param(b"null", id="null"), + ], +) +async def test_non_object_passthrough_body_carries_no_envelope_fields(body: bytes): + """A passthrough body is the caller's own provider payload, so it need not be a JSON + object. Reading a list or scalar as the query_params/custom_body envelope raised + AttributeError and the caller got a bare 500 (#43711).""" + query_params_data, custom_body_data, file_data, stream = await _parse_request_data_by_content_type( + _json_request(body) + ) + + assert (query_params_data, custom_body_data, file_data, stream) == (None, None, None, None) + + +@pytest.mark.asyncio +async def test_object_passthrough_body_still_yields_its_envelope_fields(): + """The guard must only fire on a non-object body: a real envelope is unaffected.""" + body = json.dumps({"query_params": {"alt": "sse"}, "custom_body": {"model": "x"}, "stream": True}).encode() + + query_params_data, custom_body_data, _, stream = await _parse_request_data_by_content_type(_json_request(body)) + + assert query_params_data == {"alt": "sse"} + assert custom_body_data == {"model": "x"} + assert stream is True From 2666539d0cd5370c1cb17a83da08bba4df5c9662 Mon Sep 17 00:00:00 2001 From: ajitsharmas2007 Date: Tue, 29 Sep 2026 23:02:38 +0530 Subject: [PATCH 2/5] fix(proxy): forward a non-object passthrough body as the bytes it arrived as Coercing a non-object body to `{}` fixed the crash but broke what the provider receives. `pass_through_request` builds the upstream request as `json=_parsed_body` unless `state_raw_body` is set, and only SigV4 signers set that, so every ordinary `/anthropic` or `/openai` passthrough sent `{}` in place of the caller's own payload. Silent data loss is worse than the 500 it replaced. Reuse the exact-bytes path that already exists for SigV4 rather than adding a second mechanism: `_read_request_body` marks the request when it coerces, `non_object_raw_body` returns the original bytes for exactly those requests, and `pass_through_request` feeds them into `state_raw_body`, which both the streaming and non-streaming branches already honour. Only a coerced body takes this path. An object body still goes through `json=_parsed_body`, so hook mutations reach the provider, and a SigV4 body keeps its own bytes because the fallback runs only when `state_raw_body` is still None. The earlier manual check could not have caught this: Anthropic answers `model: Field required` for both `[]` and `{}`. The new test captures the request actually built for the provider and asserts the outbound bytes. --- .../proxy/common_utils/http_parsing_utils.py | 42 ++++++++++-- .../pass_through_endpoints.py | 5 ++ .../common_utils/test_http_parsing_utils.py | 46 +++++++++++-- .../test_pass_through_endpoints.py | 65 +++++++++++++++++++ 4 files changed, 149 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 78c01afbd1a..6e3ea71890e 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -24,6 +24,8 @@ _FORM_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-www-form- _ANNOTATION_QUALIFIERS: Final[frozenset[object]] = frozenset({Annotated, NotRequired, ReadOnly, Required}) +_NON_OBJECT_BODY_SCOPE_KEY: Final[str] = "litellm_non_object_json_body" + def _normalize_media_type(content_type: str) -> str: """Return the bare media type per RFC 7231: strip params, trim, lowercase.""" @@ -215,11 +217,15 @@ async def _read_request_body(request: Request | None) -> dict: code=status.HTTP_400_BAD_REQUEST, ) - # Cache the parsed result. Raw passthrough forwarding re-reads ``request.body()``, - # so dropping a non-object body here never changes what reaches the provider. - object_body: Final[dict] = parsed_body if isinstance(parsed_body, dict) else {} - _safe_set_request_parsed_body(request=request, parsed_body=object_body) - return object_body + if isinstance(parsed_body, dict): + _safe_set_request_parsed_body(request=request, parsed_body=parsed_body) + return parsed_body + + # Anything that forwards the parsed body upstream would now send an empty object in + # place of the caller's payload, so mark the request for ``non_object_raw_body``. + _mark_non_object_body(request=request) + _safe_set_request_parsed_body(request=request, parsed_body={}) + return {} except (json.JSONDecodeError, orjson.JSONDecodeError, ProxyException) as e: # Re-raise ProxyException as-is @@ -239,6 +245,32 @@ def is_opaque_audio_pass_through_request(route: str, content_type: str) -> bool: ) +def _mark_non_object_body(request: Request | None) -> None: + try: + if request is not None: + request.scope[_NON_OBJECT_BODY_SCOPE_KEY] = True + except Exception as e: + verbose_proxy_logger.debug("Unexpected error marking non-object request body - %s", e) + + +async def non_object_raw_body(request: Request | None) -> bytes | None: + """The bytes of a body that is valid JSON but not an object, else ``None``. + + ``_read_request_body`` reads such a body as ``{}`` so every caller that treats it as a + mapping is safe, which means forwarding the parsed body would send an empty object in + place of the caller's payload. Passthrough sends these bytes instead. + """ + if request is None: + return None + scope: Final[object] = getattr(request, "scope", None) + if not isinstance(scope, Mapping) or scope.get(_NON_OBJECT_BODY_SCOPE_KEY) is not True: + return None + try: + return await request.body() + except RuntimeError: + return None + + async def read_raw_json_body(request: Request | None) -> bytes | None: if request is None or _safe_get_request_parsed_body(request=request) is None: return None diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index b85bca99168..311add853a7 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -86,6 +86,7 @@ from litellm.proxy.common_utils.error_body_call_id import JSON_OBJECT, error_bod from litellm.proxy.common_utils.http_parsing_utils import ( _read_request_body, _safe_get_request_headers, + non_object_raw_body, ) from litellm.proxy.common_utils.openai_error_payload import ( LITELLM_CALL_ID_HEADER, @@ -1120,6 +1121,10 @@ async def pass_through_request( _parsed_body = {} else: _parsed_body = await _read_request_body(request) + if state_raw_body is None: + # A non-object JSON body reads as ``{}``, so forwarding the parsed body would + # send an empty object in place of the caller's own provider payload. + state_raw_body = await non_object_raw_body(request) verbose_proxy_logger.debug( "Pass through endpoint sending request to \nURL %s\nheaders: %s\nbody: %s\n", url, diff --git a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py index 28f3be96065..ec9de77b0fd 100644 --- a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py @@ -26,6 +26,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( get_tags_from_request_body, numeric_form_fields, populate_request_with_path_params, + non_object_raw_body, read_raw_json_body, ) @@ -539,16 +540,53 @@ async def test_non_object_body_is_not_cached_for_later_readers(): @pytest.mark.asyncio -async def test_non_object_body_still_reaches_the_provider_verbatim(): - """Passthrough forwards the raw bytes, not the parsed body, so coercing the parsed view - must not change what a provider actually receives.""" - body = b'[{"role": "user", "content": "hi"}]' +@pytest.mark.parametrize( + "body", + [ + pytest.param(b'[{"role": "user", "content": "hi"}]', id="array"), + pytest.param(b"123", id="number"), + pytest.param(b'"gpt-4o"', id="string"), + pytest.param(b"true", id="boolean"), + pytest.param(b"null", id="null"), + ], +) +async def test_non_object_body_is_recoverable_verbatim_for_forwarding(body: bytes): + """Passthrough forwards ``_parsed_body`` as JSON unless it is handed exact bytes, so the + coerced ``{}`` would reach the provider in place of the caller's payload. The original + bytes must stay recoverable.""" request = _starlette_request(body, "application/json") assert await _read_request_body(request) == {} + assert await non_object_raw_body(request) == body assert await read_raw_json_body(request) == body +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + pytest.param(b'{"model": "gpt-4o"}', id="object"), + pytest.param(b"{}", id="empty-object"), + pytest.param(b"", id="empty-body"), + ], +) +async def test_object_body_is_not_offered_for_raw_forwarding(body: bytes): + """Only a coerced body may bypass ``json=_parsed_body``: an object body must keep going + through the parsed path, so hooks that mutate it are still what gets sent.""" + request = _starlette_request(body, "application/json") + + assert isinstance(await _read_request_body(request), dict) + assert await non_object_raw_body(request) is None + + +@pytest.mark.asyncio +async def test_no_raw_forwarding_before_the_body_has_been_read(): + """Nothing may be marked for raw forwarding until a read has established the body is + actually a non-object.""" + assert await non_object_raw_body(_starlette_request(b"[]", "application/json")) is None + assert await non_object_raw_body(None) is None + + @pytest.mark.asyncio @pytest.mark.parametrize( "body, expected", diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 0935fb3c42d..a18530b57f3 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -7674,3 +7674,68 @@ async def test_object_passthrough_body_still_yields_its_envelope_fields(): assert query_params_data == {"alt": "sse"} assert custom_body_data == {"model": "x"} assert stream is True + + +async def _capture_upstream_request(body: bytes) -> httpx.Request: + """Drive ``pass_through_request`` for ``body`` and return the request it built for the + provider, by letting a real httpx client encode it and failing the send.""" + captured: list[httpx.Request] = [] # mutable-ok: the send double records what it was given + + async def _send(req, **kwargs): + captured.append(req) + raise httpx.HTTPError("stop after capture") + + real_client = httpx.AsyncClient() + real_client.send = _send + + with ( + patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_proxy_logging, + patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_async_httpx_client" + ) as mock_get_client, + ): + mock_proxy_logging.pre_call_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["data"]) + mock_get_client.return_value = SimpleNamespace(client=real_client) + + # The failure handler runs on a MagicMock proxy_logging_obj and dies with TypeError, + # the same way test_pass_through_request_uses_resolved_timeout relies on. + with pytest.raises(TypeError): + await pass_through_request( + request=_json_request(body), + target="http://upstream.test/v1/messages", + custom_headers={}, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + ) + + assert captured, "pass_through_request never built an upstream request" + return captured[0] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + pytest.param(b'[{"role": "user", "content": "hi"}]', id="array"), + pytest.param(b"[1, 2, 3]", id="array-of-numbers"), + pytest.param(b"123", id="number"), + pytest.param(b'"claude-sonnet-4-5"', id="string"), + pytest.param(b"true", id="boolean"), + pytest.param(b"null", id="null"), + ], +) +async def test_non_object_body_is_forwarded_to_the_provider_verbatim(body: bytes): + """A passthrough body is the caller's own provider payload. The parsed view of a non-object + body is ``{}``, and the forwarding path sends the parsed body as JSON, so without the raw + bytes the provider silently received ``{}`` in place of what the caller sent.""" + upstream = await _capture_upstream_request(body) + + assert upstream.content == body + + +@pytest.mark.asyncio +async def test_object_body_is_still_forwarded_from_the_parsed_view(): + """Hooks mutate the parsed body and those mutations must still reach the provider, so an + object body must not take the raw-bytes path.""" + upstream = await _capture_upstream_request(b'{"model": "claude-sonnet-4-5"}') + + assert json.loads(upstream.content)["model"] == "claude-sonnet-4-5" From d8ef042bd94e0621045f7c6d65074de46c094d33 Mon Sep 17 00:00:00 2001 From: ajitsharmas2007 Date: Wed, 30 Sep 2026 14:48:08 +0530 Subject: [PATCH 3/5] fix(proxy): refuse a guarded passthrough body that guardrails cannot read Passthrough guardrails are opt-in, and when a route configures them the pre-call hook inspects the parsed body. For a non-object payload that is `{}`, which carries none of the caller's content, while the bytes forwarded upstream carry all of it. So content a guardrail would block could reach the provider uninspected inside a JSON array. Running the guardrails on `{}` and forwarding anyway would be worse than not running them, because it records "inspected" against content nobody looked at. Refuse the request instead: a route that configured guardrails has no safe way to serve a body they cannot read. The refusal is scoped to that case alone. An unguarded route still forwards a non-object body verbatim, so the Vertex rawPredict contract holds; a guarded route with an object body is untouched; and a SigV4-signed body keeps its own bytes. Also move the request marker to constants.py as NON_OBJECT_JSON_BODY_SCOPE_KEY, alongside the other scope keys, and cover the branches Codecov flagged: the two defensive handlers in http_parsing_utils and the multipart-that-is-actually-JSON branch. --- litellm/constants.py | 1 + .../proxy/common_utils/http_parsing_utils.py | 7 +- .../pass_through_endpoints.py | 19 +++- .../common_utils/test_http_parsing_utils.py | 34 +++++++ .../test_pass_through_endpoints.py | 96 ++++++++++++++++++- 5 files changed, 151 insertions(+), 6 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 39c10d71709..42907425a5a 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -341,6 +341,7 @@ BEDROCK_REALTIME_SDK_DISTRIBUTION: Final = "aws-sdk-bedrock-runtime" BEDROCK_REALTIME_SDK_SUPPORTED_RANGE: Final = ">=0.10.0,<0.12.0" CLIENT_REQUESTED_MODEL_SCOPE_KEY: Final = "litellm.client_requested_model" MODEL_GROUP_ALIAS_RESOLVED_SCOPE_KEY: Final = "litellm.model_group_alias_resolved" +NON_OBJECT_JSON_BODY_SCOPE_KEY: Final = "litellm.non_object_json_body" REALTIME_SESSION_SUCCESS_LOGGED_KEY: Final = "realtime_session_success_logged" REALTIME_SESSION_FAILURE_LOGGED_KEY: Final = "realtime_session_failure_logged" diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 6e3ea71890e..2ec397c60a2 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -13,6 +13,7 @@ from litellm.constants import ( AZURE_SPEECH_PASS_THROUGH_ROUTE_PREFIX, CLIENT_REQUESTED_MODEL_SCOPE_KEY, MAX_REQUEST_BODY_SIZE_TO_REPAIR_MB, + NON_OBJECT_JSON_BODY_SCOPE_KEY, ) from litellm.proxy._types import ProxyException from litellm.proxy.common_utils.callback_utils import ( @@ -24,8 +25,6 @@ _FORM_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-www-form- _ANNOTATION_QUALIFIERS: Final[frozenset[object]] = frozenset({Annotated, NotRequired, ReadOnly, Required}) -_NON_OBJECT_BODY_SCOPE_KEY: Final[str] = "litellm_non_object_json_body" - def _normalize_media_type(content_type: str) -> str: """Return the bare media type per RFC 7231: strip params, trim, lowercase.""" @@ -248,7 +247,7 @@ def is_opaque_audio_pass_through_request(route: str, content_type: str) -> bool: def _mark_non_object_body(request: Request | None) -> None: try: if request is not None: - request.scope[_NON_OBJECT_BODY_SCOPE_KEY] = True + request.scope[NON_OBJECT_JSON_BODY_SCOPE_KEY] = True except Exception as e: verbose_proxy_logger.debug("Unexpected error marking non-object request body - %s", e) @@ -263,7 +262,7 @@ async def non_object_raw_body(request: Request | None) -> bytes | None: if request is None: return None scope: Final[object] = getattr(request, "scope", None) - if not isinstance(scope, Mapping) or scope.get(_NON_OBJECT_BODY_SCOPE_KEY) is not True: + if not isinstance(scope, Mapping) or scope.get(NON_OBJECT_JSON_BODY_SCOPE_KEY) is not True: return None try: return await request.body() diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 311add853a7..d21f64157ff 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1074,6 +1074,8 @@ async def pass_through_request( # parsed request body _parsed_body: dict | None = None + # bytes of a non-object JSON body: forwarded verbatim, but unreadable to guardrails + uninspectable_body: bytes | None = None # kwargs for pass through endpoint, contains metadata, litellm_params, call_type, litellm_call_id, passthrough_logging_payload kwargs: dict | None = None logging_obj: Logging | None = None @@ -1124,7 +1126,8 @@ async def pass_through_request( if state_raw_body is None: # A non-object JSON body reads as ``{}``, so forwarding the parsed body would # send an empty object in place of the caller's own provider payload. - state_raw_body = await non_object_raw_body(request) + uninspectable_body = await non_object_raw_body(request) + state_raw_body = uninspectable_body verbose_proxy_logger.debug( "Pass through endpoint sending request to \nURL %s\nheaders: %s\nbody: %s\n", url, @@ -1140,6 +1143,20 @@ async def pass_through_request( passthrough_guardrails_config=guardrails_config, ) + if guardrails_to_run and uninspectable_body is not None: + # Guardrails read the parsed body, which for a non-object payload carries none of + # the caller's content, while the bytes forwarded upstream carry all of it. Running + # them would report "inspected" on content nobody looked at, so refuse instead. + raise ProxyException( + message=( + "Guardrails are configured for this route and cannot inspect a JSON body " + "that is not an object. Send the payload as a JSON object." + ), + type="invalid_request_error", + param="request_body", + code=status.HTTP_400_BAD_REQUEST, + ) + # Add guardrails to metadata if any should run if guardrails_to_run and len(guardrails_to_run) > 0: if _parsed_body is None: diff --git a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py index ec9de77b0fd..9e68d07a073 100644 --- a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py @@ -1308,3 +1308,37 @@ class TestCoerceNumericFormFields: numeric_fields=self.numeric_fields, ) assert result == {"n": 3, "temperature": None, "image": buffer} + + +@pytest.mark.asyncio +async def test_a_request_that_cannot_be_marked_still_parses(): + """Marking is best-effort bookkeeping for passthrough forwarding. A request object that + rejects the write (a test double, a frozen scope) must still get its parsed body rather + than turning a client's bad body into a 500.""" + + class _UnwritableScope(dict): + def __setitem__(self, key, value): + raise TypeError("scope is read-only") + + request = MagicMock() + request.body = AsyncMock(return_value=b"[1, 2, 3]") + request.headers = {"content-type": "application/json"} + request.scope = _UnwritableScope() + + assert await _read_request_body(request) == {} + assert await non_object_raw_body(request) is None + + +@pytest.mark.asyncio +async def test_a_consumed_body_stream_yields_no_bytes_to_forward(): + """Starlette raises RuntimeError once a body stream has been consumed. Forwarding must + fall back to the parsed view rather than propagating that as a 500.""" + request = MagicMock() + request.body = AsyncMock(return_value=b"[1, 2, 3]") + request.headers = {"content-type": "application/json"} + request.scope = {} + + assert await _read_request_body(request) == {} + + request.body = AsyncMock(side_effect=RuntimeError("Stream consumed")) + assert await non_object_raw_body(request) is None diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index a18530b57f3..ee008dc8e9e 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -7676,7 +7676,7 @@ async def test_object_passthrough_body_still_yields_its_envelope_fields(): assert stream is True -async def _capture_upstream_request(body: bytes) -> httpx.Request: +async def _capture_upstream_request(body: bytes, guardrails: list[str] | None = None) -> httpx.Request: """Drive ``pass_through_request`` for ``body`` and return the request it built for the provider, by letting a real httpx client encode it and failing the send.""" captured: list[httpx.Request] = [] # mutable-ok: the send double records what it was given @@ -7705,6 +7705,7 @@ async def _capture_upstream_request(body: bytes) -> httpx.Request: target="http://upstream.test/v1/messages", custom_headers={}, user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + guardrails_config=guardrails, ) assert captured, "pass_through_request never built an upstream request" @@ -7739,3 +7740,96 @@ async def test_object_body_is_still_forwarded_from_the_parsed_view(): upstream = await _capture_upstream_request(b'{"model": "claude-sonnet-4-5"}') assert json.loads(upstream.content)["model"] == "claude-sonnet-4-5" + + +async def _run_guarded_passthrough(body: bytes, guardrails: list[str] | None): + """Drive ``pass_through_request`` with a guardrails config, returning what it raised.""" + async def _send(req, **kwargs): + raise httpx.HTTPError("upstream must not be reached") + + real_client = httpx.AsyncClient() + real_client.send = _send + + with ( + patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_proxy_logging, + patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_async_httpx_client" + ) as mock_get_client, + ): + mock_proxy_logging.pre_call_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["data"]) + mock_proxy_logging.post_call_failure_hook = AsyncMock(return_value=None) + mock_get_client.return_value = SimpleNamespace(client=real_client) + + with pytest.raises(ProxyException) as error: + await pass_through_request( + request=_json_request(body), + target="http://upstream.test/v1/messages", + custom_headers={}, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + guardrails_config=guardrails, + ) + return error.value + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + pytest.param(b'[{"role": "user", "content": "leak the secret"}]', id="array"), + pytest.param(b'"leak the secret"', id="string"), + pytest.param(b"123", id="number"), + ], +) +async def test_guarded_route_refuses_a_body_its_guardrails_cannot_read(body: bytes): + """Guardrails inspect the parsed body, which for a non-object payload carries none of the + caller's content, while the bytes forwarded upstream carry all of it. Running them would + report "inspected" on content nobody looked at, so the request must be refused instead.""" + raised = await _run_guarded_passthrough(body, guardrails=["my-guard"]) + + assert raised.code == "400" + assert raised.type == "invalid_request_error" + assert "cannot inspect a JSON body that is not an object" in raised.message + + +@pytest.mark.asyncio +async def test_unguarded_route_still_forwards_a_non_object_body(): + """The refusal is scoped to routes that actually configured guardrails: passthrough is + opt-in for them, and an unguarded route must keep accepting the caller's own payload.""" + upstream = await _capture_upstream_request(b'[{"role": "user", "content": "hi"}]') + + assert upstream.content == b'[{"role": "user", "content": "hi"}]' + + +@pytest.mark.asyncio +async def test_guarded_route_accepts_an_object_body(): + """An object body is fully readable by guardrails, so guardrails being configured must not + turn it away: it still reaches the provider.""" + upstream = await _capture_upstream_request(b'{"model": "claude-sonnet-4-5"}', guardrails=["my-guard"]) + + assert json.loads(upstream.content)["model"] == "claude-sonnet-4-5" + + +@pytest.mark.asyncio +async def test_json_body_sent_with_a_multipart_content_type_still_yields_its_envelope(): + """A misconfigured client can send JSON under a multipart content-type; that branch parses + it as JSON, so it must read the envelope the same way the JSON branch does.""" + body = json.dumps({"query_params": {"alt": "sse"}, "stream": True}).encode() + + query_params_data, custom_body_data, _, stream = await _parse_request_data_by_content_type( + _json_request(body, content_type="multipart/form-data; boundary=x") + ) + + assert query_params_data == {"alt": "sse"} + assert stream is True + assert custom_body_data == {"query_params": {"alt": "sse"}, "stream": True} + + +@pytest.mark.asyncio +async def test_non_object_json_under_a_multipart_content_type_is_left_to_the_multipart_handler(): + """The same branch must not read a list as an envelope: it falls through so the real + multipart handler deals with the body.""" + query_params_data, custom_body_data, file_data, stream = await _parse_request_data_by_content_type( + _json_request(b"[1, 2, 3]", content_type="multipart/form-data; boundary=x") + ) + + assert (query_params_data, custom_body_data, file_data, stream) == (None, None, None, None) From d8969d86f69b505daf020fff744c1eb406465fda Mon Sep 17 00:00:00 2001 From: ajitsharmas2007 Date: Wed, 30 Sep 2026 18:13:50 +0530 Subject: [PATCH 4/5] fix(proxy): only let a request-reading guardrail gate a non-object body `collect_guardrails` returns names and nothing else, so refusing whenever a passthrough route had any guardrail configured also turned away routes whose only guardrail is `post_call` or `logging_only`. Those inspect the response, never the request body, so a body they were never going to read is no reason to reject the request. Filter the collected names by event mode and refuse only when one of them actually reads the request. `during_call` counts alongside `pre_call` for that; `post_call` and `logging_only` do not. A configured name with no initialized callback inspects nothing either, since there is nothing to run. The event-mode question is answered by the guardrail itself via `_event_hook_is_event_type`, the same private predicate common_request_processing.py uses, rather than re-deriving rules that handle None, a single hook, a list of hooks and the enterprise Mode form. The round's tests passed a guardrail name with nothing behind it, which proves nothing once the filter exists, so they now register real CustomGuardrail instances through litellm.callbacks. --- .../pass_through_endpoints.py | 13 +++-- .../passthrough_guardrails.py | 32 +++++++++++ .../test_pass_through_endpoints.py | 55 ++++++++++++++++--- 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index d21f64157ff..d671d28d7e2 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1143,10 +1143,15 @@ async def pass_through_request( passthrough_guardrails_config=guardrails_config, ) - if guardrails_to_run and uninspectable_body is not None: - # Guardrails read the parsed body, which for a non-object payload carries none of - # the caller's content, while the bytes forwarded upstream carry all of it. Running - # them would report "inspected" on content nobody looked at, so refuse instead. + if ( + guardrails_to_run + and uninspectable_body is not None + and PassthroughGuardrailHandler.any_inspects_the_request_body(guardrails_to_run) + ): + # A request-inspecting guardrail reads the parsed body, which for a non-object + # payload carries none of the caller's content, while the bytes forwarded upstream + # carry all of it. Running it would report "inspected" on content nobody looked at, + # so refuse instead. A post_call guardrail reads the response, so it does not gate. raise ProxyException( message=( "Guardrails are configured for this route and cannot inspect a JSON body " diff --git a/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py b/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py index de9b0acf081..6f219e0ae9f 100644 --- a/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py +++ b/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py @@ -7,15 +7,37 @@ Handles guardrail execution for passthrough endpoints with: - Automatic inheritance from org/team/key levels when enabled """ +from collections.abc import Collection from typing import Any, Final +import litellm from litellm._logging import verbose_proxy_logger +from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import ( PassThroughGuardrailsConfig, PassThroughGuardrailSettings, UserAPIKeyAuth, ) from litellm.proxy.pass_through_endpoints.jsonpath_extractor import JsonPathExtractor +from litellm.types.guardrails import GuardrailEventHooks + +# The hooks that read the request body. ``post_call`` and ``logging_only`` read the response. +_REQUEST_INSPECTING_EVENT_HOOKS: Final = ( + GuardrailEventHooks.pre_call, + GuardrailEventHooks.during_call, +) + + +def _reads_the_request_body(callback: object, guardrail_names: Collection[str]) -> bool: + if not isinstance(callback, CustomGuardrail) or callback.guardrail_name not in guardrail_names: + return False + # Same private predicate common_request_processing.py uses to ask a callback which + # lifecycle hooks it is configured for, rather than re-deriving the matching rules. + return any( + callback._event_hook_is_event_type(hook) # pyright: ignore[reportPrivateUsage] # no public equivalent + for hook in _REQUEST_INSPECTING_EVENT_HOOKS + ) + # Type for raw guardrails config input (before normalization) # Can be a list of names or a dict with settings @@ -273,6 +295,16 @@ class PassthroughGuardrailHandler: return guardrails_to_run if guardrails_to_run else None + @staticmethod + def any_inspects_the_request_body(guardrail_names: Collection[str]) -> bool: + """Whether any of these guardrails reads the request body. + + A ``post_call`` or ``logging_only`` guardrail inspects the response, so a request + body it never reads is no reason to turn the request away. A name with no + initialized callback inspects nothing either, since there is nothing to run. + """ + return any(_reads_the_request_body(callback, guardrail_names) for callback in litellm.callbacks) + @staticmethod def get_field_targeted_text( data: dict, diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index ee008dc8e9e..57859e27bc9 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -24,6 +24,7 @@ from starlette.datastructures import UploadFile as StarletteUploadFile import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import DEFAULT_REQUEST_TIMEOUT_SECONDS +from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import ProxyException, UserAPIKeyAuth @@ -49,6 +50,7 @@ from litellm.proxy.pass_through_endpoints.success_handler import ( ) from litellm.proxy.route_llm_request import ProxyModelNotFoundError from litellm.types import utils as types_utils +from litellm.types.guardrails import GuardrailEventHooks from litellm.types.passthrough_endpoints.pass_through_endpoints import ( LITELLM_PASS_THROUGH_DEPLOYMENT_MODEL_INFO_STATE_KEY, LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY, @@ -7771,6 +7773,18 @@ async def _run_guarded_passthrough(body: bytes, guardrails: list[str] | None): return error.value +@contextmanager +def _registered_guardrail(name: str, mode: GuardrailEventHooks): + """Register a real guardrail the way the proxy does, so the code under test resolves its + event mode instead of being told the answer.""" + guardrail = CustomGuardrail(guardrail_name=name, event_hook=mode) + litellm.callbacks.append(guardrail) + try: + yield + finally: + litellm.callbacks.remove(guardrail) + + @pytest.mark.asyncio @pytest.mark.parametrize( "body", @@ -7780,17 +7794,37 @@ async def _run_guarded_passthrough(body: bytes, guardrails: list[str] | None): pytest.param(b"123", id="number"), ], ) -async def test_guarded_route_refuses_a_body_its_guardrails_cannot_read(body: bytes): - """Guardrails inspect the parsed body, which for a non-object payload carries none of the - caller's content, while the bytes forwarded upstream carry all of it. Running them would - report "inspected" on content nobody looked at, so the request must be refused instead.""" - raised = await _run_guarded_passthrough(body, guardrails=["my-guard"]) +async def test_route_with_a_request_guardrail_refuses_a_body_it_cannot_read(body: bytes): + """A pre_call guardrail inspects the parsed body, which for a non-object payload carries + none of the caller's content, while the bytes forwarded upstream carry all of it. Running + it would report "inspected" on content nobody looked at, so refuse instead.""" + with _registered_guardrail("precall-guard", GuardrailEventHooks.pre_call): + raised = await _run_guarded_passthrough(body, guardrails=["precall-guard"]) assert raised.code == "400" assert raised.type == "invalid_request_error" assert "cannot inspect a JSON body that is not an object" in raised.message +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mode", + [ + pytest.param(GuardrailEventHooks.post_call, id="post_call"), + pytest.param(GuardrailEventHooks.logging_only, id="logging_only"), + ], +) +async def test_a_response_only_guardrail_does_not_gate_the_request_body(mode: GuardrailEventHooks): + """A guardrail that inspects the response never reads the request body, so a body it was + never going to look at is no reason to turn the request away.""" + body = b'[{"role": "user", "content": "hi"}]' + + with _registered_guardrail("response-guard", mode): + upstream = await _capture_upstream_request(body, guardrails=["response-guard"]) + + assert upstream.content == body + + @pytest.mark.asyncio async def test_unguarded_route_still_forwards_a_non_object_body(): """The refusal is scoped to routes that actually configured guardrails: passthrough is @@ -7801,10 +7835,13 @@ async def test_unguarded_route_still_forwards_a_non_object_body(): @pytest.mark.asyncio -async def test_guarded_route_accepts_an_object_body(): - """An object body is fully readable by guardrails, so guardrails being configured must not - turn it away: it still reaches the provider.""" - upstream = await _capture_upstream_request(b'{"model": "claude-sonnet-4-5"}', guardrails=["my-guard"]) +async def test_a_request_guardrail_still_accepts_an_object_body(): + """An object body is fully readable, so a pre_call guardrail being configured must not turn + it away: it still reaches the provider.""" + with _registered_guardrail("precall-guard", GuardrailEventHooks.pre_call): + upstream = await _capture_upstream_request( + b'{"model": "claude-sonnet-4-5"}', guardrails=["precall-guard"] + ) assert json.loads(upstream.content)["model"] == "claude-sonnet-4-5" From 03b05fe8c8a4b945a0856945df5cdabb9ca0715e Mon Sep 17 00:00:00 2001 From: ajitsharmas2007 Date: Wed, 30 Sep 2026 19:18:28 +0530 Subject: [PATCH 5/5] test(proxy): pin that another route's guardrail cannot gate this body A proxy registers every callback globally in one list: loggers, and the guardrails of every route. `_reads_the_request_body` must therefore look only at the guardrails this route configured, and nothing was checking that. Another route's pre_call guardrail making this route refuse a body would be a real bug. Registers a plain CustomLogger and a pre_call guardrail named for someone else, then sends a non-object body to a route whose own guardrail is post-call only, and asserts it still reaches the provider verbatim. Covers the last line Codecov flagged. --- .../test_pass_through_endpoints.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 57859e27bc9..afd59063e2f 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -7870,3 +7870,20 @@ async def test_non_object_json_under_a_multipart_content_type_is_left_to_the_mul ) assert (query_params_data, custom_body_data, file_data, stream) == (None, None, None, None) + + +@pytest.mark.asyncio +async def test_another_routes_request_guardrail_does_not_gate_this_route(): + """A proxy registers every callback globally, so this route must look only at the + guardrails it configured. Another route's pre_call guardrail, or a plain logger, must not + make this route turn a body away.""" + body = b'[{"role": "user", "content": "hi"}]' + unrelated_logger = CustomLogger() + litellm.callbacks.append(unrelated_logger) + try: + with _registered_guardrail("someone-elses-guard", GuardrailEventHooks.pre_call): + upstream = await _capture_upstream_request(body, guardrails=["postcall-guard"]) + finally: + litellm.callbacks.remove(unrelated_logger) + + assert upstream.content == body