mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-23 05:34:41 +00:00
Merge 8b15b6a49c into 82ca10cc2d
This commit is contained in:
commit
cbf95480ef
3 changed files with 167 additions and 18 deletions
|
|
@ -8,13 +8,15 @@ import orjson
|
|||
from fastapi import Request, UploadFile, status
|
||||
from pydantic import TypeAdapter
|
||||
from starlette._utils import get_route_path
|
||||
from starlette.requests import HTTPConnection
|
||||
from starlette.datastructures import FormData
|
||||
from starlette.requests import ClientDisconnect, HTTPConnection
|
||||
from typing_extensions import NotRequired, ReadOnly, Required, assert_never
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import (
|
||||
AZURE_SPEECH_PASS_THROUGH_ROUTE_PREFIX,
|
||||
CLIENT_REQUESTED_MODEL_SCOPE_KEY,
|
||||
LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED,
|
||||
MAX_REQUEST_BODY_SIZE_TO_REPAIR_MB,
|
||||
)
|
||||
from litellm.integrations.otel.runtime import phase_event
|
||||
|
|
@ -191,6 +193,29 @@ def is_otlp_trace_request(request: HTTPConnection) -> bool:
|
|||
return request.scope.get("method") == "POST" and get_route_path(request.scope) in {"/v1/traces", "/v1/logs"}
|
||||
|
||||
|
||||
async def _read_form_data(request: Request) -> FormData:
|
||||
try:
|
||||
form_data: Final = await request.form()
|
||||
_mark_body_received(_declared_content_length(request.headers))
|
||||
except ClientDisconnect:
|
||||
raise
|
||||
except Exception as e:
|
||||
# ``request.form()`` raises on malformed multipart (missing
|
||||
# boundary, malformed chunk encoding, …). Surface as 400 so
|
||||
# the auth-time pre-read does not silently cache ``{}`` while
|
||||
# a later raw-body re-read sees the original payload —
|
||||
# banned-param checks must see the same body the handler
|
||||
# acts on.
|
||||
verbose_proxy_logger.error("Invalid form payload: %s", e)
|
||||
raise ProxyException(
|
||||
message=f"Invalid form payload: {e}",
|
||||
type="invalid_request_error",
|
||||
param="request_body",
|
||||
code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return form_data
|
||||
|
||||
|
||||
async def read_request_body(request: Request | None) -> dict:
|
||||
"""
|
||||
Safely read the request body and parse it as JSON.
|
||||
|
|
@ -221,23 +246,7 @@ async def read_request_body(request: Request | None) -> dict:
|
|||
_mark_body_received(len(binary_body))
|
||||
parsed_body = _parse_binary_body(binary_body)
|
||||
elif _is_form_content_type(content_type):
|
||||
try:
|
||||
form_data: Final = await request.form()
|
||||
_mark_body_received(_declared_content_length(request.headers))
|
||||
except Exception as e:
|
||||
# ``request.form()`` raises on malformed multipart (missing
|
||||
# boundary, malformed chunk encoding, …). Surface as 400 so
|
||||
# the auth-time pre-read does not silently cache ``{}`` while
|
||||
# a later raw-body re-read sees the original payload —
|
||||
# banned-param checks must see the same body the handler
|
||||
# acts on.
|
||||
verbose_proxy_logger.error("Invalid form payload: %s", e)
|
||||
raise ProxyException(
|
||||
message=f"Invalid form payload: {e}",
|
||||
type="invalid_request_error",
|
||||
param="request_body",
|
||||
code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
form_data: Final = await _read_form_data(request)
|
||||
parsed_body = dict(form_data)
|
||||
if "metadata" in parsed_body and isinstance(parsed_body["metadata"], str):
|
||||
parsed_body["metadata"] = json.loads(parsed_body["metadata"])
|
||||
|
|
@ -297,6 +306,14 @@ async def read_request_body(request: Request | None) -> dict:
|
|||
# Re-raise ProxyException as-is
|
||||
verbose_proxy_logger.error("Invalid JSON payload received: %s", e)
|
||||
raise
|
||||
except ClientDisconnect:
|
||||
verbose_proxy_logger.info("Client disconnected before the request body was fully received")
|
||||
raise ProxyException(
|
||||
message="Client disconnected the request",
|
||||
type="client_disconnect",
|
||||
param=None,
|
||||
code=LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED,
|
||||
)
|
||||
except Exception as e:
|
||||
# Catch unexpected errors to avoid crashes
|
||||
verbose_proxy_logger.exception("Unexpected error reading request body - %s", e)
|
||||
|
|
|
|||
|
|
@ -7859,6 +7859,71 @@ async def test_user_api_key_auth_returns_the_parse_error_even_if_logging_it_fail
|
|||
assert raised.code == str(status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_api_key_auth_rejects_a_client_disconnect_with_499_instead_of_an_empty_body():
|
||||
"""Regression (#44797): a client that disconnects mid-upload must be rejected by auth
|
||||
with the 499 the body reader raised, not handed on to the endpoint as an empty body."""
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
|
||||
builder_token = UserAPIKeyAuth(api_key="sk-test", user_id="u1", team_id="team-1")
|
||||
messages = iter(
|
||||
[
|
||||
{"type": "http.request", "body": b'{"model": "gpt-4o", "mess', "more_body": True},
|
||||
{"type": "http.disconnect"},
|
||||
]
|
||||
)
|
||||
|
||||
async def receive():
|
||||
return next(messages)
|
||||
|
||||
request = Request(
|
||||
scope={
|
||||
"type": "http",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"method": "POST",
|
||||
"path": "/chat/completions",
|
||||
},
|
||||
receive=receive,
|
||||
)
|
||||
request._url = URL(url="/chat/completions")
|
||||
hook = AsyncMock(return_value=None)
|
||||
|
||||
attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None)
|
||||
attrs["proxy_logging_obj"].post_call_failure_hook = hook
|
||||
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
|
||||
try:
|
||||
for k, v in attrs.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.user_api_key_auth_builder",
|
||||
new_callable=AsyncMock,
|
||||
return_value=builder_token,
|
||||
) as mock_builder,
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.run_centralized_common_checks",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_common_checks,
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.RouteChecks.should_call_route",
|
||||
),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await user_api_key_auth(request=request, api_key="Bearer sk-test")
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
assert exc_info.value.code == "499"
|
||||
assert exc_info.value.type == "client_disconnect"
|
||||
mock_builder.assert_awaited_once()
|
||||
mock_common_checks.assert_not_awaited()
|
||||
assert hook.await_args.kwargs["original_exception"] is exc_info.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_api_key_auth_malformed_body_with_rejected_key_still_returns_the_parse_error():
|
||||
"""The body is read before the key is authenticated, so a caller who sends both a
|
||||
|
|
|
|||
|
|
@ -1141,6 +1141,73 @@ class TestReadRequestBodyFormParseFailure:
|
|||
assert str(exc_info.value.code) == "400"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"content_type, partial_body",
|
||||
[
|
||||
("application/json", b'{"model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "'),
|
||||
(
|
||||
"multipart/form-data; boundary=litellm",
|
||||
b'--litellm\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhi',
|
||||
),
|
||||
("application/x-protobuf", b"\x0a\x05"),
|
||||
],
|
||||
)
|
||||
async def test_client_disconnect_mid_upload_raises_499_and_caches_nothing(content_type: str, partial_body: bytes):
|
||||
messages: Final = iter(
|
||||
(
|
||||
{"type": "http.request", "body": partial_body, "more_body": True},
|
||||
{"type": "http.disconnect"},
|
||||
)
|
||||
)
|
||||
|
||||
async def receive() -> Mapping[str, object]:
|
||||
return next(messages)
|
||||
|
||||
request: Final = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"headers": [(b"content-type", content_type.encode())],
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await read_request_body(request)
|
||||
|
||||
assert exc_info.value.code == "499"
|
||||
assert exc_info.value.type == "client_disconnect"
|
||||
assert _safe_get_request_parsed_body(request) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_pre_read_defers_client_disconnect_instead_of_returning_an_empty_body():
|
||||
from litellm.proxy.auth.user_api_key_auth import _read_request_body_deferring_parse_failure
|
||||
|
||||
messages: Final = iter(
|
||||
(
|
||||
{"type": "http.request", "body": b'{"model": "claude-sonnet-4-5", "messa', "more_body": True},
|
||||
{"type": "http.disconnect"},
|
||||
)
|
||||
)
|
||||
|
||||
async def receive() -> Mapping[str, object]:
|
||||
return next(messages)
|
||||
|
||||
request: Final = Request(
|
||||
{"type": "http", "method": "POST", "path": "/v1/messages", "headers": [(b"content-type", b"application/json")]},
|
||||
receive,
|
||||
)
|
||||
|
||||
parsed, parse_error = await _read_request_body_deferring_parse_failure(request)
|
||||
|
||||
assert parsed == {}
|
||||
assert parse_error is not None
|
||||
assert parse_error.code == "499"
|
||||
|
||||
|
||||
class TestGetRequestBody:
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_with_charset_param_parses_as_json(self):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue