diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index ed3ec7b4dde..85ebe1c53b0 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1376,6 +1376,17 @@ async def _read_request_body_deferring_parse_failure( parsed_body: Final = await _read_request_body(request=request) except ProxyException as parse_exception: return {}, parse_exception # mutable-ok: request_data is a plain dict across the whole auth path + if not isinstance(parsed_body, dict): + # Provider pass-through endpoints may forward a JSON array or scalar. + # Auth only needs a mapping for its own checks; leave the raw body intact. + if request_dispatched_to_pass_through_endpoint(request) or request_dispatched_to_provider_pass_through(request): + return {}, None # mutable-ok: request_data is a plain dict across the whole auth path + return {}, ProxyException( + message="JSON request body must be an object", + type="invalid_request_error", + param="request_body", + code=status.HTTP_400_BAD_REQUEST, + ) return populate_request_with_path_params(request_data=parsed_body, request=request), 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 7929a0b21af..7cbcafd0753 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 @@ -46,6 +46,44 @@ def _starlette_request(body: bytes, content_type: str) -> Request: return Request(scope, receive) +@pytest.mark.parametrize("body", [b"[]", b"123", b'"str"', b"true", b"null"]) +@pytest.mark.asyncio +async def test_read_request_body_preserves_non_object_json_for_passthrough(body: bytes): + request = _starlette_request(body, "application/json") + + assert await _read_request_body(request) == orjson.loads(body) + assert _safe_get_request_parsed_body(request) is None + + +@pytest.mark.parametrize("body", [b"[]", b"123", b'"str"', b"true", b"null"]) +@pytest.mark.asyncio +async def test_auth_defers_non_object_json_rejection(body: bytes): + from litellm.proxy.auth.user_api_key_auth import _read_request_body_deferring_parse_failure + + request = _starlette_request(body, "application/json") + parsed_body, error = await _read_request_body_deferring_parse_failure(request) + + assert parsed_body == {} + assert isinstance(error, ProxyException) + assert error.code == "400" + assert "JSON request body must be an object" in error.message + + +@pytest.mark.asyncio +async def test_auth_preserves_non_object_json_for_provider_passthrough(): + from litellm.proxy.auth.user_api_key_auth import _read_request_body_deferring_parse_failure + + request = _starlette_request(b'[{"prompt":"hi"}]', "application/json") + request.scope["path"] = "/vertex_ai/v1/projects/test:rawPredict" + request.scope["path_params"] = {"endpoint": "v1/projects/test:rawPredict"} + + parsed_body, error = await _read_request_body_deferring_parse_failure(request) + + assert parsed_body == {} + assert error is None + assert await request.body() == b'[{"prompt":"hi"}]' + + @pytest.mark.asyncio async def test_read_raw_json_body_returns_the_bytes_the_parsed_body_came_from(): body = b'{"model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "hi"}]}'