mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-27 06:13:07 +00:00
fix(proxy): decode zstd and gzip request bodies before auth reads them (#47418)
* test(proxy): cover gzip and zstd request bodies on /v1/systemone * fix(proxy): decode zstd and gzip request bodies before auth reads them * fix(proxy): bound request body decompression and keep it inside CORS Limit the decoded size by an expansion ratio and the operator's max_request_size_mb, pass bodies without the coding's magic number through, answer 415 when zstd is unavailable, and register the middleware innermost so errors carry CORS and security headers * fix(proxy): budget decoded request bodies per worker and forward plugin bodies as sent Each worker holds at most MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB of decoded bodies and answers 503 past it. /plugin-proxy/ bodies reach the plugin as sent, a zstd skippable frame counts as zstd, the decoded-size 413 names the decoded limit, and an integer max_request_size_mb stays an integer in the 413 text. * fix(proxy): read the magic number before size limits and reserve declared decoded sizes * test(proxy): pin gzip mtime so parametrized test ids match across xdist workers * fix(proxy): parse max_request_size_mb the same way in the auth size check * fix(proxy): pass Claude Code OTLP export bodies through undecoded and skip the size parse without premium * test(proxy): cover client disconnects, chunked bodies past the cap, and body-less request messages in the decompression middleware * test(proxy): name the decompression test cases so CI logs stay small
This commit is contained in:
parent
fad31403db
commit
e34c779c67
11 changed files with 1394 additions and 13 deletions
|
|
@ -191,6 +191,11 @@ DEFAULT_IMAGE_HEIGHT: Final = get_env_int("DEFAULT_IMAGE_HEIGHT", 300)
|
|||
# Maps to OpenAI's 50 MB payload limit - requests with images exceeding this size will be rejected
|
||||
# Set MAX_IMAGE_URL_DOWNLOAD_SIZE_MB=0 to disable image URL handling entirely
|
||||
MAX_IMAGE_URL_DOWNLOAD_SIZE_MB: Final = get_env_float("MAX_IMAGE_URL_DOWNLOAD_SIZE_MB", 50.0)
|
||||
MAX_DECOMPRESSED_REQUEST_BODY_SIZE_MB: Final = get_env_float("MAX_DECOMPRESSED_REQUEST_BODY_SIZE_MB", 50.0)
|
||||
MAX_REQUEST_BODY_DECOMPRESSION_RATIO: Final = get_env_int("MAX_REQUEST_BODY_DECOMPRESSION_RATIO", 200)
|
||||
MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB: Final = get_env_float(
|
||||
"MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB", 200.0
|
||||
)
|
||||
MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB: Final = get_env_int(
|
||||
"MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB", 1024
|
||||
) # 1MB = 1024KB
|
||||
|
|
|
|||
|
|
@ -1047,6 +1047,9 @@ def normalize_request_route(route: str) -> str:
|
|||
return route
|
||||
|
||||
|
||||
MAX_REQUEST_SIZE_MB_SETTING: Final = TypeAdapter[int | float | None](int | float | None)
|
||||
|
||||
|
||||
async def check_if_request_size_is_safe(request: Request) -> bool:
|
||||
"""
|
||||
Enterprise Only:
|
||||
|
|
@ -1062,19 +1065,19 @@ async def check_if_request_size_is_safe(request: Request) -> bool:
|
|||
ProxyException: If the request size is too large
|
||||
|
||||
"""
|
||||
from litellm.proxy.proxy_server import general_settings, premium_user
|
||||
from litellm.proxy.proxy_server import configured_max_request_size_mb, general_settings, premium_user
|
||||
|
||||
max_request_size_mb: Final = general_settings.get("max_request_size_mb", None)
|
||||
|
||||
if max_request_size_mb is not None:
|
||||
# Check if premium user
|
||||
if premium_user is not True:
|
||||
if premium_user is not True:
|
||||
if general_settings.get("max_request_size_mb") is not None:
|
||||
verbose_proxy_logger.warning(
|
||||
"using max_request_size_mb - not checking - this is an enterprise only feature. %s",
|
||||
CommonProxyErrors.not_premium_user.value,
|
||||
)
|
||||
return True
|
||||
return True
|
||||
|
||||
max_request_size_mb: Final = configured_max_request_size_mb()
|
||||
|
||||
if max_request_size_mb is not None:
|
||||
# Get the request body
|
||||
content_length: Final = request.headers.get("content-length")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,418 @@
|
|||
import io
|
||||
import sys
|
||||
import zlib
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol, TypeAlias
|
||||
|
||||
import anyio.to_thread
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.requests import ClientDisconnect, Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from litellm.constants import (
|
||||
MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB,
|
||||
MAX_DECOMPRESSED_REQUEST_BODY_SIZE_MB,
|
||||
MAX_REQUEST_BODY_DECOMPRESSION_RATIO,
|
||||
)
|
||||
from litellm.proxy.auth.auth_utils import get_request_route
|
||||
from litellm.proxy.middleware.request_size_limit_middleware import MaxRequestSizeGetter, send_request_too_large
|
||||
|
||||
RFC_9659_ZSTD_WINDOW_LOG_MAX: Final = 23
|
||||
GZIP_MAGIC: Final = b"\x1f\x8b"
|
||||
ZSTD_FRAME_MAGIC: Final = b"\x28\xb5\x2f\xfd"
|
||||
ZSTD_SKIPPABLE_FRAME_MAGIC_HIGH_BYTES: Final = b"\x2a\x4d\x18"
|
||||
SIGNATURE_LENGTH: Final = len(ZSTD_FRAME_MAGIC)
|
||||
GZIP_HEADER_AND_TRAILER_LENGTH: Final = 18
|
||||
RAW_BODY_ROUTE_PREFIXES: Final = (
|
||||
"/plugin-proxy/",
|
||||
"/claude_code_gateway/v1/metrics",
|
||||
"/claude_code_gateway/v1/logs",
|
||||
"/claude_code_gateway/v1/traces",
|
||||
)
|
||||
CODING_ALIASES: Final[Mapping[str, str]] = MappingProxyType({"x-gzip": "gzip"})
|
||||
_REPLACED_REQUEST_HEADERS: Final = frozenset({b"content-encoding", b"content-length", b"transfer-encoding"})
|
||||
_CONTENT_ENCODING_HEADER: Final = frozenset({b"content-encoding"})
|
||||
|
||||
|
||||
class _Decompressor(Protocol):
|
||||
@property
|
||||
def eof(self) -> bool: ...
|
||||
|
||||
@property
|
||||
def unused_data(self) -> bytes: ...
|
||||
|
||||
def decompress(self, data: bytes, /, max_length: int) -> bytes: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Codec:
|
||||
name: str
|
||||
new_decompressor: Callable[[], _Decompressor]
|
||||
error: type[Exception]
|
||||
declared_size: Callable[[bytes], int | None]
|
||||
|
||||
|
||||
def _new_gzip_decompressor() -> _Decompressor:
|
||||
return zlib.decompressobj(wbits=31)
|
||||
|
||||
|
||||
def _gzip_declared_size(data: bytes) -> int | None:
|
||||
if len(data) < GZIP_HEADER_AND_TRAILER_LENGTH:
|
||||
return None
|
||||
return int.from_bytes(data[-4:], "little")
|
||||
|
||||
|
||||
GZIP: Final = Codec(
|
||||
name="gzip",
|
||||
new_decompressor=_new_gzip_decompressor,
|
||||
error=zlib.error,
|
||||
declared_size=_gzip_declared_size,
|
||||
)
|
||||
|
||||
|
||||
def _starts_a_gzip_member(data: bytes) -> bool:
|
||||
return data.startswith(GZIP_MAGIC)
|
||||
|
||||
|
||||
def _starts_a_zstd_frame(data: bytes) -> bool:
|
||||
if data.startswith(ZSTD_FRAME_MAGIC):
|
||||
return True
|
||||
return len(data) >= 4 and data[0] & 0xF0 == 0x50 and data[1:4] == ZSTD_SKIPPABLE_FRAME_MAGIC_HIGH_BYTES
|
||||
|
||||
|
||||
def load_zstd_codec() -> Codec | None:
|
||||
try:
|
||||
if sys.version_info >= (3, 14):
|
||||
from compression.zstd import DecompressionParameter, ZstdDecompressor, ZstdError, get_frame_info
|
||||
else:
|
||||
from backports.zstd import DecompressionParameter, ZstdDecompressor, ZstdError, get_frame_info
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def new_zstd_decompressor() -> _Decompressor:
|
||||
return ZstdDecompressor(options={DecompressionParameter.window_log_max: RFC_9659_ZSTD_WINDOW_LOG_MAX})
|
||||
|
||||
def zstd_declared_size(data: bytes) -> int | None:
|
||||
try:
|
||||
return get_frame_info(data).decompressed_size
|
||||
except ZstdError:
|
||||
return None
|
||||
|
||||
return Codec(
|
||||
name="zstd",
|
||||
new_decompressor=new_zstd_decompressor,
|
||||
error=ZstdError,
|
||||
declared_size=zstd_declared_size,
|
||||
)
|
||||
|
||||
|
||||
_ZSTD: Final = load_zstd_codec()
|
||||
CODING_SIGNATURES: Final[Mapping[str, Callable[[bytes], bool]]] = MappingProxyType(
|
||||
{"gzip": _starts_a_gzip_member, "zstd": _starts_a_zstd_frame}
|
||||
)
|
||||
AVAILABLE_CODECS: Final[Mapping[str, Codec]] = MappingProxyType(
|
||||
{codec.name: codec for codec in (GZIP, _ZSTD) if codec is not None}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Prefix:
|
||||
data: bytes
|
||||
more_body: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Body:
|
||||
data: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TooLarge:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _DecodedTooLarge:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _OverRatio:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ClientDisconnected:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Undecodable:
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Limits:
|
||||
max_mb: float
|
||||
max_ratio: int
|
||||
|
||||
@property
|
||||
def max_bytes(self) -> int:
|
||||
return int(self.max_mb * 1024 * 1024)
|
||||
|
||||
|
||||
_ReceivedBody: TypeAlias = _Body | _TooLarge | _ClientDisconnected
|
||||
_DecodedBody: TypeAlias = _Body | _DecodedTooLarge | _OverRatio | _Undecodable
|
||||
|
||||
|
||||
class _DecodedBodyBudget:
|
||||
def __init__(self, max_bytes: int) -> None:
|
||||
self.max_bytes: Final = max_bytes
|
||||
self.reserved_bytes = 0
|
||||
|
||||
def try_reserve(self, size: int) -> bool:
|
||||
if self.reserved_bytes > 0 and self.reserved_bytes + size > self.max_bytes:
|
||||
return False
|
||||
self.hold(size)
|
||||
return True
|
||||
|
||||
def hold(self, size: int) -> None:
|
||||
self.reserved_bytes += size
|
||||
|
||||
def release(self, size: int) -> None:
|
||||
self.reserved_bytes -= size
|
||||
|
||||
|
||||
def _coding_for(scope: Scope) -> str | None:
|
||||
if scope["type"] != "http":
|
||||
return None
|
||||
content_encodings: Final = Headers(scope=scope).getlist("content-encoding")
|
||||
if len(content_encodings) != 1:
|
||||
return None
|
||||
named_coding: Final = content_encodings[0].strip().lower()
|
||||
coding: Final = CODING_ALIASES.get(named_coding, named_coding)
|
||||
if coding not in CODING_SIGNATURES:
|
||||
return None
|
||||
if get_request_route(Request(scope)).startswith(RAW_BODY_ROUTE_PREFIXES):
|
||||
return None
|
||||
return coding
|
||||
|
||||
|
||||
def _request_chunk(message: Message) -> _Prefix | _ClientDisconnected:
|
||||
match message:
|
||||
case {"type": "http.disconnect"}:
|
||||
return _ClientDisconnected()
|
||||
case {"body": bytes() as body, "more_body": True}:
|
||||
return _Prefix(body, more_body=True)
|
||||
case {"body": bytes() as body}:
|
||||
return _Prefix(body, more_body=False)
|
||||
case {"more_body": True}:
|
||||
return _Prefix(b"", more_body=True)
|
||||
case _:
|
||||
return _Prefix(b"", more_body=False)
|
||||
|
||||
|
||||
async def _receive_prefix(receive: Receive, received: bytes = b"") -> _Prefix | _ClientDisconnected:
|
||||
match _request_chunk(await receive()):
|
||||
case _ClientDisconnected() as disconnected:
|
||||
return disconnected
|
||||
case _Prefix(data=data, more_body=more_body):
|
||||
prefix: Final = _Prefix(received + data, more_body)
|
||||
if len(prefix.data) >= SIGNATURE_LENGTH or not more_body:
|
||||
return prefix
|
||||
return await _receive_prefix(receive, prefix.data)
|
||||
|
||||
|
||||
async def _receive_body(scope: Scope, receive: Receive, prefix: _Prefix, max_bytes: int) -> _ReceivedBody:
|
||||
if len(prefix.data) > max_bytes:
|
||||
return _TooLarge()
|
||||
if not prefix.more_body:
|
||||
return _Body(prefix.data)
|
||||
buffer: Final = io.BytesIO()
|
||||
buffer.write(prefix.data)
|
||||
try:
|
||||
async for chunk in Request(scope, receive).stream():
|
||||
buffer.write(chunk)
|
||||
if buffer.tell() > max_bytes:
|
||||
return _TooLarge()
|
||||
except ClientDisconnect:
|
||||
return _ClientDisconnected()
|
||||
return _Body(buffer.getvalue())
|
||||
|
||||
|
||||
def _max_decoded_length(codec: Codec, data: bytes, limits: _Limits) -> int:
|
||||
max_by_limits: Final = min(limits.max_bytes, len(data) * limits.max_ratio)
|
||||
declared: Final = codec.declared_size(data)
|
||||
return max_by_limits if declared is None else min(max_by_limits, declared)
|
||||
|
||||
|
||||
def _decode(codec: Codec, data: bytes, limits: _Limits) -> _DecodedBody:
|
||||
max_by_ratio: Final = len(data) * limits.max_ratio
|
||||
declared: Final = codec.declared_size(data)
|
||||
max_length: Final = _max_decoded_length(codec, data, limits)
|
||||
decompressor: Final = codec.new_decompressor()
|
||||
try:
|
||||
decoded: Final = decompressor.decompress(data, max_length=max_length + 1)
|
||||
except codec.error as error:
|
||||
return _Undecodable(f"Request body is not valid {codec.name} data: {error}")
|
||||
if len(decoded) > limits.max_bytes:
|
||||
return _DecodedTooLarge()
|
||||
if len(decoded) > max_by_ratio:
|
||||
return _OverRatio()
|
||||
if declared is not None and len(decoded) > declared:
|
||||
return _Undecodable(f"Request body decodes past the size its {codec.name} data declares")
|
||||
if not decompressor.eof:
|
||||
return _Undecodable(f"Request body ends before the end of its {codec.name} stream")
|
||||
if decompressor.unused_data:
|
||||
return _Undecodable(f"Request body has data after the end of its {codec.name} stream")
|
||||
return _Body(decoded)
|
||||
|
||||
|
||||
def _headers_without(scope: Scope, dropped: frozenset[bytes]) -> tuple[tuple[bytes, bytes], ...]:
|
||||
return tuple((name, value) for name, value in Headers(scope=scope).raw if name.lower() not in dropped)
|
||||
|
||||
|
||||
def _identity_scope(scope: Scope, body_length: int) -> Scope:
|
||||
kept_headers: Final = _headers_without(scope, _REPLACED_REQUEST_HEADERS)
|
||||
return {**scope, "headers": [*kept_headers, (b"content-length", str(body_length).encode("latin-1"))]}
|
||||
|
||||
|
||||
def _replay_body(body: bytes, receive: Receive, more_body: bool = False) -> Receive:
|
||||
body_message: Final[Message] = {"type": "http.request", "body": body, "more_body": more_body}
|
||||
pending: Final = iter((body_message,))
|
||||
|
||||
async def replay() -> Message:
|
||||
return next(pending, None) or await receive()
|
||||
|
||||
return replay
|
||||
|
||||
|
||||
def _error_response(
|
||||
message: str,
|
||||
status_code: int,
|
||||
error_type: str = "invalid_request_error",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{"error": {"message": message, "type": error_type, "param": None, "code": str(status_code)}},
|
||||
status_code=status_code,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _held_length(decoded: _DecodedBody) -> int:
|
||||
return len(decoded.data) if isinstance(decoded, _Body) else 0
|
||||
|
||||
|
||||
def _effective_max_mb(max_decompressed_body_size_mb: float, operator_max_mb: float | None) -> float:
|
||||
if operator_max_mb is None or operator_max_mb <= 0:
|
||||
return max_decompressed_body_size_mb
|
||||
return min(max_decompressed_body_size_mb, operator_max_mb)
|
||||
|
||||
|
||||
def _no_operator_limit() -> None:
|
||||
return None
|
||||
|
||||
|
||||
class RequestBodyDecompressionMiddleware:
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
get_max_request_size_mb: MaxRequestSizeGetter = _no_operator_limit,
|
||||
max_decompressed_body_size_mb: float = MAX_DECOMPRESSED_REQUEST_BODY_SIZE_MB,
|
||||
max_decompression_ratio: int = MAX_REQUEST_BODY_DECOMPRESSION_RATIO,
|
||||
max_decoded_bodies_in_flight_mb: float = MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB,
|
||||
codecs: Mapping[str, Codec] = AVAILABLE_CODECS,
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.get_max_request_size_mb: Final = get_max_request_size_mb
|
||||
self.max_decompressed_body_size_mb: Final = max_decompressed_body_size_mb
|
||||
self.max_decompression_ratio: Final = max_decompression_ratio
|
||||
self.budget: Final = _DecodedBodyBudget(int(max_decoded_bodies_in_flight_mb * 1024 * 1024))
|
||||
self.codecs: Final = codecs
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
coding: Final = _coding_for(scope)
|
||||
if coding is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
match await _receive_prefix(receive):
|
||||
case _ClientDisconnected():
|
||||
return
|
||||
case _Prefix() as prefix:
|
||||
await self._decode_if_encoded(scope, receive, send, coding, prefix)
|
||||
|
||||
async def _decode_if_encoded(
|
||||
self, scope: Scope, receive: Receive, send: Send, coding: str, prefix: _Prefix
|
||||
) -> None:
|
||||
if not CODING_SIGNATURES[coding](prefix.data):
|
||||
sent_as_is: Final = {**scope, "headers": [*_headers_without(scope, _CONTENT_ENCODING_HEADER)]}
|
||||
await self.app(sent_as_is, _replay_body(prefix.data, receive, more_body=prefix.more_body), send)
|
||||
return
|
||||
codec: Final = self.codecs.get(coding)
|
||||
if codec is None:
|
||||
supported: Final = ", ".join(sorted(self.codecs))
|
||||
await _error_response(
|
||||
f"This proxy cannot decode {coding} request bodies. Supported encodings: {supported}",
|
||||
415,
|
||||
headers={"accept-encoding": supported},
|
||||
)(scope, receive, send)
|
||||
return
|
||||
await self._decode_then_call(scope, receive, send, codec, prefix)
|
||||
|
||||
async def _decode_then_call(
|
||||
self, scope: Scope, receive: Receive, send: Send, codec: Codec, prefix: _Prefix
|
||||
) -> None:
|
||||
max_mb: Final = _effective_max_mb(self.max_decompressed_body_size_mb, self.get_max_request_size_mb())
|
||||
limits: Final = _Limits(max_mb=max_mb, max_ratio=self.max_decompression_ratio)
|
||||
match await _receive_body(scope, receive, prefix, limits.max_bytes):
|
||||
case _Body(data=data):
|
||||
await self._decode_within_budget(scope, receive, send, codec, data, limits)
|
||||
case _TooLarge():
|
||||
await send_request_too_large(send=send, max_request_size_mb=max_mb)
|
||||
case _ClientDisconnected():
|
||||
return
|
||||
|
||||
async def _decode_within_budget(
|
||||
self, scope: Scope, receive: Receive, send: Send, codec: Codec, data: bytes, limits: _Limits
|
||||
) -> None:
|
||||
reservation: Final = _max_decoded_length(codec, data, limits)
|
||||
if not self.budget.try_reserve(reservation):
|
||||
await _error_response(
|
||||
"The proxy is decoding too many compressed request bodies right now. "
|
||||
"Retry later, or send this body uncompressed",
|
||||
503,
|
||||
error_type="overloaded_error",
|
||||
headers={"retry-after": "1"},
|
||||
)(scope, receive, send)
|
||||
return
|
||||
try:
|
||||
decoded: Final = await anyio.to_thread.run_sync(_decode, codec, data, limits)
|
||||
finally:
|
||||
self.budget.release(reservation)
|
||||
held: Final = _held_length(decoded)
|
||||
self.budget.hold(held)
|
||||
try:
|
||||
await self._respond(scope, receive, send, decoded, limits)
|
||||
finally:
|
||||
self.budget.release(held)
|
||||
|
||||
async def _respond(
|
||||
self, scope: Scope, receive: Receive, send: Send, decoded: _DecodedBody, limits: _Limits
|
||||
) -> None:
|
||||
match decoded:
|
||||
case _Body(data=body):
|
||||
await self.app(_identity_scope(scope, len(body)), _replay_body(body, receive), send)
|
||||
case _DecodedTooLarge():
|
||||
await _error_response(f"Request body is larger than {limits.max_mb:g} MB when decoded", 413)(
|
||||
scope, receive, send
|
||||
)
|
||||
case _OverRatio():
|
||||
await _error_response(f"Request body expands more than {limits.max_ratio} times when decoded", 413)(
|
||||
scope, receive, send
|
||||
)
|
||||
case _Undecodable(message=message):
|
||||
await _error_response(message, 400)(scope, receive, send)
|
||||
|
|
@ -44,7 +44,7 @@ class RequestSizeLimitMiddleware:
|
|||
|
||||
content_length: Final = _get_content_length(scope=scope)
|
||||
if content_length is not None and content_length > max_request_size_bytes:
|
||||
await _send_request_too_large(send=send, max_request_size_mb=max_request_size_mb)
|
||||
await send_request_too_large(send=send, max_request_size_mb=max_request_size_mb)
|
||||
return
|
||||
|
||||
received_body_bytes = 0
|
||||
|
|
@ -74,7 +74,7 @@ class RequestSizeLimitMiddleware:
|
|||
except RequestEntityTooLarge:
|
||||
if response_started:
|
||||
raise
|
||||
await _send_request_too_large(send=send, max_request_size_mb=max_request_size_mb)
|
||||
await send_request_too_large(send=send, max_request_size_mb=max_request_size_mb)
|
||||
|
||||
|
||||
def _mb_to_bytes(max_request_size_mb: float | None) -> int | None:
|
||||
|
|
@ -97,7 +97,7 @@ def _get_content_length(scope: Scope) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
async def _send_request_too_large(
|
||||
async def send_request_too_large(
|
||||
send: Send,
|
||||
max_request_size_mb: float | None,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -401,6 +401,7 @@ from litellm.proxy.auth.auth_checks import (
|
|||
)
|
||||
from litellm.proxy.auth.auth_object_prefetch import AUTH_OBJECTS_TARGET
|
||||
from litellm.proxy.auth.auth_utils import (
|
||||
MAX_REQUEST_SIZE_MB_SETTING,
|
||||
check_response_size_is_safe,
|
||||
is_request_body_safe,
|
||||
log_model_access_denial,
|
||||
|
|
@ -840,6 +841,7 @@ from litellm.proxy.middleware.per_request_root_path_middleware import (
|
|||
get_server_root_paths,
|
||||
)
|
||||
from litellm.proxy.middleware.prometheus_auth_middleware import PrometheusAuthMiddleware
|
||||
from litellm.proxy.middleware.request_body_decompression_middleware import RequestBodyDecompressionMiddleware
|
||||
from litellm.proxy.middleware.request_size_limit_middleware import (
|
||||
RequestSizeLimitMiddleware,
|
||||
)
|
||||
|
|
@ -2750,6 +2752,14 @@ current_dir = os.path.dirname(os.path.abspath(__file__))
|
|||
# app.mount("/ui", StaticFiles(directory=ui_path, html=True), name="ui")
|
||||
|
||||
|
||||
def configured_max_request_size_mb() -> int | float | None:
|
||||
return MAX_REQUEST_SIZE_MB_SETTING.validate_python(general_settings.get("max_request_size_mb"))
|
||||
|
||||
|
||||
app.add_middleware(
|
||||
RequestBodyDecompressionMiddleware,
|
||||
get_max_request_size_mb=lambda: configured_max_request_size_mb() if premium_user is True else None,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
|
|
@ -21507,7 +21517,7 @@ attach_lazy_features(app)
|
|||
app.router.routes = hot_routes_first(app.router.routes)
|
||||
app.add_middleware(
|
||||
RequestSizeLimitMiddleware,
|
||||
get_max_request_size_mb=lambda: general_settings.get("max_request_size_mb"),
|
||||
get_max_request_size_mb=configured_max_request_size_mb,
|
||||
is_request_size_limit_enabled=lambda: premium_user is True,
|
||||
)
|
||||
app.add_middleware(
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ proxy = [
|
|||
"soundfile>=0.12.1,<1.0",
|
||||
"pyroscope-io>=0.8.16,<1.0; sys_platform != 'win32'",
|
||||
"expression>=5.6.0,<6.0",
|
||||
"backports-zstd>=1.0.0,<2.0; python_version < '3.14'",
|
||||
]
|
||||
# Thin client install for the `lite` CLI on developer laptops. The CLI's heavy
|
||||
# imports are all guarded, so it runs on the base SDK plus these packages, and
|
||||
|
|
@ -259,6 +260,7 @@ proxy-dev = [
|
|||
"opentelemetry-instrumentation-fastapi==0.54b1",
|
||||
"azure-identity==1.25.2",
|
||||
"a2a-sdk==1.1.0",
|
||||
"backports-zstd==1.8.0; python_version < '3.14'",
|
||||
]
|
||||
ci = [
|
||||
"psutil==7.2.2",
|
||||
|
|
|
|||
|
|
@ -179,3 +179,4 @@ pytest-rerunfailures: >=15.1 # MPL 2.0 license
|
|||
pytest-recording: >=0.13.4 # MIT license
|
||||
expression: >=5.6.0 # MIT License - https://github.com/cognitedata/Expression/blob/main/LICENSE
|
||||
typing-extensions: >=4.13.0 # PSF-2.0 license - https://github.com/python/typing_extensions/blob/main/LICENSE
|
||||
backports-zstd: >=1.0.0 # PSF-2.0 license - https://github.com/Rogdham/backports.zstd/blob/master/LICENSE.txt
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@ import pytest
|
|||
from fastapi import HTTPException, Request
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.auth_utils import (
|
||||
_allow_model_level_clientside_configurable_parameters,
|
||||
_get_customer_id_from_standard_headers,
|
||||
abbreviate_api_key,
|
||||
check_complete_credentials,
|
||||
check_if_request_size_is_safe,
|
||||
custom_auth_common_checks_warning,
|
||||
get_customer_user_header_from_mapping,
|
||||
log_once_if_budget_reservation_disabled,
|
||||
|
|
@ -5033,3 +5034,44 @@ def test_get_end_user_id_from_request_body_backwards_compatibility():
|
|||
)
|
||||
for request_body, expected_end_user_id in cases:
|
||||
assert get_end_user_id_from_request_body(request_body) == expected_end_user_id
|
||||
|
||||
|
||||
def _request_with_content_length(content_length: int) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"headers": [(b"content-length", str(content_length).encode("latin-1"))],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_quoted_max_request_size_mb_lets_a_smaller_keyed_request_through():
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.general_settings", {"max_request_size_mb": "10"}),
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
):
|
||||
assert await check_if_request_size_is_safe(request=_request_with_content_length(1024)) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_quoted_max_request_size_mb_rejects_a_larger_keyed_request_with_400():
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.general_settings", {"max_request_size_mb": "10"}),
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
pytest.raises(ProxyException) as raised,
|
||||
):
|
||||
await check_if_request_size_is_safe(request=_request_with_content_length(11 * 1024 * 1024))
|
||||
assert raised.value.code == "400"
|
||||
assert "Max size is 10 MB" in raised.value.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_non_numeric_max_request_size_mb_is_ignored_without_premium():
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.general_settings", {"max_request_size_mb": "10MB"}),
|
||||
patch("litellm.proxy.proxy_server.premium_user", False),
|
||||
):
|
||||
assert await check_if_request_size_is_safe(request=_request_with_content_length(1024)) is True
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
from collections.abc import AsyncGenerator, Iterator, Mapping
|
||||
import random
|
||||
import sys
|
||||
from collections.abc import AsyncGenerator, Callable, Iterator, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
|
@ -18,14 +21,23 @@ from starlette.routing import Match
|
|||
import litellm
|
||||
from litellm.litellm_core_utils.decisions_pricing import apply_decisions_pricing
|
||||
from litellm.proxy._lazy_features import LAZY_FEATURES, LazyFeature, attach_lazy_features
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.decisions_endpoints.endpoints import decisions, router, systemone
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import SafeRouteAdder
|
||||
from litellm.proxy.proxy_server import (
|
||||
app,
|
||||
cleanup_router_config_variables,
|
||||
hash_token,
|
||||
initialize,
|
||||
)
|
||||
from litellm.types.decisions import DecisionsRequestBody, OpenAIDecisionRequestBody
|
||||
from tests._master_key import MASTER_KEY
|
||||
|
||||
if sys.version_info >= (3, 14):
|
||||
from compression import zstd
|
||||
else:
|
||||
from backports import zstd
|
||||
|
||||
_INPUT_TOKENS: Final[int] = 367
|
||||
_OUTPUT_TOKENS: Final[int] = 3
|
||||
|
|
@ -1282,3 +1294,121 @@ def test_the_two_decisions_routes_document_different_request_formats(decisions_o
|
|||
|
||||
assert "state" in properties("/v1/systemone") and "state" not in properties("/v1/decisions")
|
||||
assert "input" in properties("/v1/decisions") and "input" not in properties("/v1/systemone")
|
||||
|
||||
|
||||
def test_proxy_systemone_reads_a_zstd_compressed_request_body(
|
||||
client: TestClient,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
upstream: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
|
||||
|
||||
response: Final = client.post(
|
||||
"/v1/systemone",
|
||||
content=zstd.compress(json.dumps(_REQUEST).encode()),
|
||||
headers={"content-type": "application/json", "content-encoding": "zstd"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["answers"] == _RESPONSE["answers"]
|
||||
assert json.loads(upstream.calls[0].request.content)["questions"] == _REQUEST["questions"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content_encoding", "compress"),
|
||||
(("gzip", gzip.compress), ("zstd", zstd.compress)),
|
||||
)
|
||||
def test_a_compressed_body_naming_a_model_outside_the_key_is_refused_by_auth(
|
||||
client: TestClient,
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
content_encoding: str,
|
||||
compress: Callable[[bytes], bytes],
|
||||
) -> None:
|
||||
user_key: Final = "sk-decider-only"
|
||||
user_api_key_cache: Final = UserApiKeyCache()
|
||||
user_api_key_cache.set_cache(
|
||||
key=hash_token(user_key), value=UserAPIKeyAuth(token=hash_token(user_key), models=["decider"])
|
||||
)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", user_api_key_cache)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "master_key", MASTER_KEY)
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", "no-db-needed-for-a-cached-key")
|
||||
upstream: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
|
||||
|
||||
response: Final = client.post(
|
||||
"/v1/systemone",
|
||||
content=compress(json.dumps({**_REQUEST, "model": "not-on-this-key"}).encode()),
|
||||
headers={
|
||||
"content-type": "application/json",
|
||||
"content-encoding": content_encoding,
|
||||
"authorization": f"Bearer {user_key}",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403, response.text
|
||||
assert "not-on-this-key" in response.text
|
||||
assert not upstream.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content_encoding", "undecodable_body"),
|
||||
(("gzip", gzip.compress(b"{}", mtime=0)[:-8]), ("zstd", zstd.compress(b"{}") + b"trailing")),
|
||||
)
|
||||
def test_a_browser_can_read_the_error_for_a_compressed_body_the_proxy_cannot_decode(
|
||||
client: TestClient,
|
||||
respx_mock: respx.MockRouter,
|
||||
content_encoding: str,
|
||||
undecodable_body: bytes,
|
||||
) -> None:
|
||||
upstream: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
|
||||
origin: Final = "https://app.example.test"
|
||||
|
||||
response: Final = client.post(
|
||||
"/v1/systemone",
|
||||
content=undecodable_body,
|
||||
headers={"content-type": "application/json", "content-encoding": content_encoding, "origin": origin},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
assert response.headers["access-control-allow-origin"] in ("*", origin)
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert not upstream.called
|
||||
|
||||
|
||||
def test_the_operator_request_size_limit_applies_to_the_decoded_body(
|
||||
client: TestClient,
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
max_request_size_mb: Final = 0.01
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True)
|
||||
monkeypatch.setitem(litellm.proxy.proxy_server.general_settings, "max_request_size_mb", max_request_size_mb)
|
||||
upstream: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
|
||||
state_text: Final = "".join(random.Random(0).choices("abcd", k=20_000))
|
||||
compressed: Final = zstd.compress(json.dumps({**_REQUEST, "state": {"source": state_text}}).encode())
|
||||
|
||||
response: Final = client.post(
|
||||
"/v1/systemone",
|
||||
content=compressed,
|
||||
headers={"content-type": "application/json", "content-encoding": "zstd"},
|
||||
)
|
||||
|
||||
assert len(compressed) < max_request_size_mb * 1024 * 1024
|
||||
assert response.status_code == 413, response.text
|
||||
assert response.json()["error"]["message"] == f"Request body is larger than {max_request_size_mb} MB when decoded"
|
||||
assert not upstream.called
|
||||
|
||||
|
||||
def test_a_whole_number_request_size_limit_is_named_as_configured_in_the_413(
|
||||
client: TestClient,
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True)
|
||||
monkeypatch.setitem(litellm.proxy.proxy_server.general_settings, "max_request_size_mb", 1)
|
||||
upstream: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
|
||||
|
||||
response: Final = client.post("/v1/systemone", json={**_REQUEST, "state": {"source": "a" * 1024 * 1024}})
|
||||
|
||||
assert response.status_code == 413, response.text
|
||||
assert response.json() == {"error": "Request size is too large. Max size is 1 MB"}
|
||||
assert not upstream.called
|
||||
|
|
|
|||
|
|
@ -0,0 +1,667 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import struct
|
||||
import sys
|
||||
import tracemalloc
|
||||
import zlib
|
||||
from collections.abc import Callable
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
|
||||
from litellm.proxy.middleware.request_body_decompression_middleware import (
|
||||
GZIP,
|
||||
GZIP_MAGIC,
|
||||
RFC_9659_ZSTD_WINDOW_LOG_MAX,
|
||||
ZSTD_FRAME_MAGIC,
|
||||
RequestBodyDecompressionMiddleware,
|
||||
load_zstd_codec,
|
||||
)
|
||||
|
||||
if sys.version_info >= (3, 14):
|
||||
from compression import zstd
|
||||
else:
|
||||
from backports import zstd
|
||||
|
||||
MIB: Final = 1024 * 1024
|
||||
CAP_MB: Final = 1.0
|
||||
REQUEST_JSON: Final = json.dumps(
|
||||
{"model": "jev", "state": "order history " * 200, "questions": {"is_refund_request": {"type": "noul"}}}
|
||||
).encode()
|
||||
|
||||
|
||||
async def _echo_request(request: Request) -> Response:
|
||||
body: Final = await request.body()
|
||||
return JSONResponse(
|
||||
{
|
||||
"body_hex": body.hex(),
|
||||
"content_encoding": request.headers.getlist("content-encoding"),
|
||||
"content_length": request.headers.get("content-length"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _client(max_decompressed_body_size_mb: float = CAP_MB, **options: object) -> TestClient:
|
||||
echo_app: Final = Starlette(
|
||||
routes=[
|
||||
Route("/v1/systemone", _echo_request, methods=["POST"]),
|
||||
Route("/plugin-proxy/{path:path}", _echo_request, methods=["POST"]),
|
||||
Route("/claude_code_gateway/v1/{signal}", _echo_request, methods=["POST"]),
|
||||
]
|
||||
)
|
||||
return TestClient(
|
||||
RequestBodyDecompressionMiddleware(
|
||||
echo_app,
|
||||
max_decompressed_body_size_mb=max_decompressed_body_size_mb,
|
||||
**options, # pyright: ignore[reportArgumentType] # each test passes one typed keyword
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _low_entropy_text(length: int) -> bytes:
|
||||
return bytes(random.Random(0).choices(b"abcd", k=length))
|
||||
|
||||
|
||||
def _post(client: TestClient, body: bytes, content_encodings: tuple[str, ...], path: str = "/v1/systemone") -> Response:
|
||||
headers: Final = [("content-type", "application/json"), *(("content-encoding", v) for v in content_encodings)]
|
||||
return client.post(path, content=body, headers=headers)
|
||||
|
||||
|
||||
def _decoded_too_large(max_mb: float) -> dict[str, dict[str, str | None]]:
|
||||
return {
|
||||
"error": {
|
||||
"message": f"Request body is larger than {max_mb:g} MB when decoded",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "413",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _zeros_compressed(compress_chunk: Callable[[bytes], bytes], flush: Callable[[], bytes], decoded_mib: int) -> bytes:
|
||||
zero_mib: Final = bytes(MIB)
|
||||
return b"".join([*(compress_chunk(zero_mib) for _ in range(decoded_mib)), flush()])
|
||||
|
||||
|
||||
def _gzip_bomb(decoded_mib: int) -> bytes:
|
||||
compressor: Final = zlib.compressobj(9, zlib.DEFLATED, 31)
|
||||
return _zeros_compressed(compressor.compress, compressor.flush, decoded_mib)
|
||||
|
||||
|
||||
def _zstd_bomb(decoded_mib: int) -> bytes:
|
||||
compressor: Final = zstd.ZstdCompressor()
|
||||
return _zeros_compressed(compressor.compress, compressor.flush, decoded_mib)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("compressed", "content_encoding"),
|
||||
[
|
||||
pytest.param(gzip.compress(REQUEST_JSON, mtime=0), "gzip", id="gzip"),
|
||||
pytest.param(zstd.compress(REQUEST_JSON), "zstd", id="zstd"),
|
||||
pytest.param(gzip.compress(REQUEST_JSON, mtime=0), " GZip ", id="gzip-mixed-case-padded"),
|
||||
pytest.param(zstd.compress(REQUEST_JSON), "ZSTD", id="zstd-upper-case"),
|
||||
pytest.param(gzip.compress(REQUEST_JSON, mtime=0), "x-gzip", id="x-gzip"),
|
||||
],
|
||||
)
|
||||
def test_supported_encoding_reaches_the_app_decoded(compressed: bytes, content_encoding: str):
|
||||
response: Final = _post(_client(), compressed, (content_encoding,))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"body_hex": REQUEST_JSON.hex(),
|
||||
"content_encoding": [],
|
||||
"content_length": str(len(REQUEST_JSON)),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "content_encodings"),
|
||||
[
|
||||
pytest.param(gzip.compress(REQUEST_JSON, mtime=0), (), id="no-header"),
|
||||
pytest.param(gzip.compress(REQUEST_JSON, mtime=0), ("identity",), id="identity"),
|
||||
pytest.param(b"\x1b\x00brotli-ish", ("br",), id="br"),
|
||||
pytest.param(REQUEST_JSON, ("compress",), id="compress"),
|
||||
pytest.param(zlib.compress(REQUEST_JSON), ("deflate",), id="deflate"),
|
||||
pytest.param(gzip.compress(REQUEST_JSON, mtime=0), ("gzip, zstd",), id="stacked-in-one-header"),
|
||||
pytest.param(gzip.compress(REQUEST_JSON, mtime=0), ("gzip", "gzip"), id="two-headers"),
|
||||
],
|
||||
)
|
||||
def test_other_encodings_reach_the_app_untouched(body: bytes, content_encodings: tuple[str, ...]):
|
||||
response: Final = _post(_client(), body, content_encodings)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"body_hex": body.hex(),
|
||||
"content_encoding": list(content_encodings),
|
||||
"content_length": str(len(body)),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "content_encoding"),
|
||||
[
|
||||
pytest.param(REQUEST_JSON, "gzip", id="plain-labeled-gzip"),
|
||||
pytest.param(REQUEST_JSON, "zstd", id="plain-labeled-zstd"),
|
||||
pytest.param(gzip.compress(REQUEST_JSON, mtime=0), "zstd", id="gzip-labeled-zstd"),
|
||||
pytest.param(zstd.compress(REQUEST_JSON), "gzip", id="zstd-labeled-gzip"),
|
||||
pytest.param(_low_entropy_text(2 * int(CAP_MB * MIB)), "gzip", id="plain-past-the-cap-labeled-gzip"),
|
||||
pytest.param(_low_entropy_text(2 * int(CAP_MB * MIB)), "zstd", id="plain-past-the-cap-labeled-zstd"),
|
||||
pytest.param(b"{}", "zstd", id="tiny-plain-labeled-zstd"),
|
||||
],
|
||||
)
|
||||
def test_body_that_is_not_in_its_labeled_encoding_reaches_the_app_as_sent(body: bytes, content_encoding: str):
|
||||
response: Final = _post(_client(), body, (content_encoding,))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"body_hex": body.hex(), "content_encoding": [], "content_length": str(len(body))}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content_encoding", ["gzip", "zstd"])
|
||||
def test_empty_body_with_supported_encoding_reaches_the_app_empty(content_encoding: str):
|
||||
response: Final = _post(_client(), b"", (content_encoding,))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"body_hex": "", "content_encoding": [], "content_length": "0"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("bomb", "content_encoding"), [(_gzip_bomb, "gzip"), (_zstd_bomb, "zstd")])
|
||||
def test_decompression_bomb_is_rejected_without_inflating_past_the_ratio_limit(
|
||||
bomb: Callable[[int], bytes], content_encoding: str
|
||||
):
|
||||
decoded_mib: Final = 200
|
||||
compressed: Final = bomb(decoded_mib)
|
||||
client: Final = _client(max_decompressed_body_size_mb=decoded_mib * 2)
|
||||
tracemalloc.start()
|
||||
try:
|
||||
response = _post(client, compressed, (content_encoding,))
|
||||
_, peak_bytes = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
|
||||
assert response.status_code == 413
|
||||
assert response.json()["error"]["message"] == "Request body expands more than 200 times when decoded"
|
||||
assert response.json()["error"]["type"] == "invalid_request_error"
|
||||
assert peak_bytes < decoded_mib * MIB / 2
|
||||
|
||||
|
||||
def test_ratio_limit_rejects_only_a_body_that_expands_past_it():
|
||||
compressed: Final = zstd.compress(REQUEST_JSON)
|
||||
ratio: Final = math.ceil(len(REQUEST_JSON) / len(compressed))
|
||||
|
||||
at_limit: Final = _post(_client(max_decompression_ratio=ratio), compressed, ("zstd",))
|
||||
over_limit: Final = _post(_client(max_decompression_ratio=ratio - 1), compressed, ("zstd",))
|
||||
|
||||
assert at_limit.status_code == 200
|
||||
assert over_limit.status_code == 413
|
||||
|
||||
|
||||
def test_body_decoding_past_the_cap_is_rejected():
|
||||
compressed: Final = zstd.compress(_low_entropy_text(2 * int(CAP_MB * MIB)))
|
||||
|
||||
response: Final = _post(_client(), compressed, ("zstd",))
|
||||
|
||||
assert len(compressed) < CAP_MB * MIB
|
||||
assert response.status_code == 413
|
||||
assert response.json() == _decoded_too_large(CAP_MB)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("operator_max_mb", "effective_max_mb"),
|
||||
[(0.25, 0.25), (CAP_MB * 4, CAP_MB), (None, CAP_MB), (0, CAP_MB)],
|
||||
)
|
||||
def test_operator_request_size_limit_caps_the_decoded_body_when_it_is_lower(
|
||||
operator_max_mb: float | None, effective_max_mb: float
|
||||
):
|
||||
client: Final = _client(get_max_request_size_mb=lambda: operator_max_mb)
|
||||
within: Final = zstd.compress(_low_entropy_text(int(effective_max_mb * MIB)))
|
||||
over: Final = zstd.compress(_low_entropy_text(int(effective_max_mb * MIB) + 1))
|
||||
|
||||
assert _post(client, within, ("zstd",)).status_code == 200
|
||||
over_response: Final = _post(client, over, ("zstd",))
|
||||
assert over_response.status_code == 413
|
||||
assert over_response.json() == _decoded_too_large(effective_max_mb)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("signature", "content_encoding"), [(GZIP_MAGIC, "gzip"), (ZSTD_FRAME_MAGIC, "zstd")])
|
||||
def test_compressed_body_over_the_cap_is_rejected_before_decoding(signature: bytes, content_encoding: str):
|
||||
over_cap_on_the_wire: Final = signature + bytes(int(CAP_MB * MIB))
|
||||
|
||||
response: Final = _post(_client(), over_cap_on_the_wire, (content_encoding,))
|
||||
|
||||
assert response.status_code == 413
|
||||
assert response.json() == {"error": f"Request size is too large. Max size is {CAP_MB} MB"}
|
||||
|
||||
|
||||
def test_body_decoding_to_exactly_the_cap_reaches_the_app():
|
||||
exactly_cap: Final = _low_entropy_text(int(CAP_MB * MIB))
|
||||
|
||||
response: Final = _post(_client(), zstd.compress(exactly_cap), ("zstd",))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["content_length"] == str(len(exactly_cap))
|
||||
|
||||
|
||||
def _zstd_frame_needing_a_window_over_the_rfc_9659_limit() -> bytes:
|
||||
window_log: Final = RFC_9659_ZSTD_WINDOW_LOG_MAX + 1
|
||||
content: Final = bytes(2**RFC_9659_ZSTD_WINDOW_LOG_MAX + MIB)
|
||||
return zstd.compress(content, options={zstd.CompressionParameter.window_log: window_log})
|
||||
|
||||
|
||||
def _zstd_skippable_frame(content: bytes) -> bytes:
|
||||
return struct.pack("<II", 0x184D2A53, len(content)) + content
|
||||
|
||||
|
||||
def _gzip_declaring_a_smaller_size(content: bytes) -> bytes:
|
||||
return gzip.compress(content, mtime=0)[:-4] + (len(content) // 2).to_bytes(4, "little")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "content_encoding", "message"),
|
||||
[
|
||||
pytest.param(
|
||||
GZIP_MAGIC + b"not gzip at all", "gzip", "Request body is not valid gzip data: ", id="corrupt-gzip"
|
||||
),
|
||||
pytest.param(
|
||||
ZSTD_FRAME_MAGIC + b"not zstd at all", "zstd", "Request body is not valid zstd data: ", id="corrupt-zstd"
|
||||
),
|
||||
pytest.param(
|
||||
gzip.compress(REQUEST_JSON, mtime=0)[:-8],
|
||||
"gzip",
|
||||
"Request body ends before the end of its gzip stream",
|
||||
id="truncated-gzip",
|
||||
),
|
||||
pytest.param(
|
||||
zstd.compress(REQUEST_JSON)[:-8],
|
||||
"zstd",
|
||||
"Request body ends before the end of its zstd stream",
|
||||
id="truncated-zstd",
|
||||
),
|
||||
pytest.param(
|
||||
gzip.compress(REQUEST_JSON, mtime=0) + b"{}",
|
||||
"gzip",
|
||||
"Request body has data after the end of its gzip stream",
|
||||
id="gzip-trailing-data",
|
||||
),
|
||||
pytest.param(
|
||||
zstd.compress(REQUEST_JSON) + b"{}",
|
||||
"zstd",
|
||||
"Request body has data after the end of its zstd stream",
|
||||
id="zstd-trailing-data",
|
||||
),
|
||||
pytest.param(
|
||||
_zstd_frame_needing_a_window_over_the_rfc_9659_limit(),
|
||||
"zstd",
|
||||
"Request body is not valid zstd data: ",
|
||||
id="zstd-window-over-the-rfc-limit",
|
||||
),
|
||||
pytest.param(
|
||||
_zstd_skippable_frame(b"meta") + zstd.compress(REQUEST_JSON),
|
||||
"zstd",
|
||||
"Request body has data after the end of its zstd stream",
|
||||
id="zstd-skippable-frame-first",
|
||||
),
|
||||
pytest.param(
|
||||
_gzip_declaring_a_smaller_size(REQUEST_JSON),
|
||||
"gzip",
|
||||
"Request body decodes past the size its gzip data declares",
|
||||
id="gzip-declaring-a-smaller-size",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_undecodable_body_is_rejected_with_an_openai_style_400(body: bytes, content_encoding: str, message: str):
|
||||
response: Final = _post(_client(max_decompressed_body_size_mb=50.0), body, (content_encoding,))
|
||||
|
||||
assert response.status_code == 400
|
||||
error: Final = response.json()["error"]
|
||||
assert error["message"].startswith(message)
|
||||
assert {key: error[key] for key in ("type", "param", "code")} == {
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "400",
|
||||
}
|
||||
|
||||
|
||||
def test_zstd_body_gets_a_415_naming_gzip_when_this_python_cannot_decode_zstd():
|
||||
client: Final = _client(codecs=MappingProxyType({"gzip": GZIP}))
|
||||
|
||||
zstd_response: Final = _post(client, zstd.compress(REQUEST_JSON), ("zstd",))
|
||||
gzip_response: Final = _post(client, gzip.compress(REQUEST_JSON, mtime=0), ("gzip",))
|
||||
plain_labeled_zstd_response: Final = _post(client, REQUEST_JSON, ("zstd",))
|
||||
|
||||
assert zstd_response.status_code == 415
|
||||
assert zstd_response.headers["accept-encoding"] == "gzip"
|
||||
assert zstd_response.json()["error"]["message"] == (
|
||||
"This proxy cannot decode zstd request bodies. Supported encodings: gzip"
|
||||
)
|
||||
assert gzip_response.status_code == 200
|
||||
assert plain_labeled_zstd_response.json()["body_hex"] == REQUEST_JSON.hex()
|
||||
|
||||
|
||||
def test_zstd_is_unavailable_without_failing_the_import_on_a_python_without_zstd(monkeypatch: pytest.MonkeyPatch):
|
||||
for module in ("_zstd", "compression.zstd", "backports.zstd"):
|
||||
monkeypatch.setitem(sys.modules, module, None)
|
||||
|
||||
assert load_zstd_codec() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/plugin-proxy/notes/v1/run",
|
||||
"/claude_code_gateway/v1/metrics",
|
||||
"/claude_code_gateway/v1/logs",
|
||||
"/claude_code_gateway/v1/traces",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("body", "content_encoding"),
|
||||
[
|
||||
pytest.param(gzip.compress(REQUEST_JSON, mtime=0), "gzip", id="gzip"),
|
||||
pytest.param(zstd.compress(REQUEST_JSON), "zstd", id="zstd"),
|
||||
pytest.param(zstd.compress(_low_entropy_text(int(CAP_MB * MIB) * 2)), "zstd", id="zstd-past-the-cap"),
|
||||
pytest.param(_gzip_bomb(2), "gzip", id="gzip-past-the-ratio-limit"),
|
||||
pytest.param(
|
||||
gzip.compress(REQUEST_JSON, mtime=0) + gzip.compress(REQUEST_JSON, mtime=0), "gzip", id="gzip-two-members"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_raw_body_routes_get_the_body_as_sent(path: str, body: bytes, content_encoding: str):
|
||||
response: Final = _post(_client(codecs=MappingProxyType({"gzip": GZIP})), body, (content_encoding,), path=path)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"body_hex": body.hex(),
|
||||
"content_encoding": [content_encoding],
|
||||
"content_length": str(len(body)),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compressed_bodies_past_the_in_flight_budget_get_a_503_until_earlier_ones_finish():
|
||||
compressed: Final = zstd.compress(REQUEST_JSON)
|
||||
app_has_the_first_body: Final = asyncio.Event()
|
||||
let_the_first_request_finish: Final = asyncio.Event()
|
||||
|
||||
async def route_holding_the_first_request(request: Request) -> Response:
|
||||
body: Final = await request.body()
|
||||
if not app_has_the_first_body.is_set():
|
||||
app_has_the_first_body.set()
|
||||
await let_the_first_request_finish.wait()
|
||||
return JSONResponse({"decoded_bytes": len(body)})
|
||||
|
||||
middleware: Final = RequestBodyDecompressionMiddleware(
|
||||
Starlette(routes=[Route("/v1/systemone", route_holding_the_first_request, methods=["POST"])]),
|
||||
max_decompressed_body_size_mb=CAP_MB,
|
||||
max_decoded_bodies_in_flight_mb=1.5 * len(REQUEST_JSON) / MIB,
|
||||
)
|
||||
headers: Final = {"content-type": "application/json", "content-encoding": "zstd"}
|
||||
with anyio.fail_after(30):
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=middleware), base_url="http://proxy") as client:
|
||||
first: Final = asyncio.create_task(client.post("/v1/systemone", content=compressed, headers=headers))
|
||||
await app_has_the_first_body.wait()
|
||||
while_first_is_in_flight: Final = await client.post("/v1/systemone", content=compressed, headers=headers)
|
||||
let_the_first_request_finish.set()
|
||||
first_response: Final = await first
|
||||
after_first_finished: Final = await client.post("/v1/systemone", content=compressed, headers=headers)
|
||||
|
||||
assert first_response.json() == {"decoded_bytes": len(REQUEST_JSON)}
|
||||
assert while_first_is_in_flight.status_code == 503
|
||||
assert while_first_is_in_flight.headers["retry-after"] == "1"
|
||||
assert while_first_is_in_flight.json()["error"]["type"] == "overloaded_error"
|
||||
assert after_first_finished.json() == {"decoded_bytes": len(REQUEST_JSON)}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("compress", [gzip.compress, zstd.compress])
|
||||
async def test_concurrent_compressed_bodies_count_their_declared_size_against_the_budget(
|
||||
compress: Callable[[bytes], bytes],
|
||||
):
|
||||
hard_to_compress: Final = json.dumps(
|
||||
{"model": "jev", "image": base64.b64encode(random.Random(0).randbytes(48 * 1024)).decode()}
|
||||
).encode()
|
||||
compressed: Final = compress(hard_to_compress)
|
||||
all_bodies_arrived: Final = asyncio.Event()
|
||||
bodies_seen: Final[list[int]] = []
|
||||
|
||||
async def route_holding_every_request(request: Request) -> Response:
|
||||
bodies_seen.append(len(await request.body()))
|
||||
if len(bodies_seen) == 3:
|
||||
all_bodies_arrived.set()
|
||||
await all_bodies_arrived.wait()
|
||||
return JSONResponse({"decoded_bytes": bodies_seen[-1]})
|
||||
|
||||
middleware: Final = RequestBodyDecompressionMiddleware(
|
||||
Starlette(routes=[Route("/v1/systemone", route_holding_every_request, methods=["POST"])]),
|
||||
max_decompressed_body_size_mb=CAP_MB,
|
||||
max_decoded_bodies_in_flight_mb=3.5 * len(hard_to_compress) / MIB,
|
||||
)
|
||||
headers: Final = {
|
||||
"content-type": "application/json",
|
||||
"content-encoding": "zstd" if compress is zstd.compress else "gzip",
|
||||
}
|
||||
with anyio.fail_after(30):
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=middleware), base_url="http://proxy") as client:
|
||||
responses: Final = await asyncio.gather(
|
||||
*(client.post("/v1/systemone", content=compressed, headers=headers) for _ in range(3))
|
||||
)
|
||||
|
||||
assert len(compressed) * 200 > CAP_MB * MIB
|
||||
assert [response.status_code for response in responses] == [200, 200, 200]
|
||||
assert bodies_seen == [len(hard_to_compress)] * 3
|
||||
|
||||
|
||||
def _http_scope(headers: list[tuple[bytes, bytes]]) -> Scope:
|
||||
return {"type": "http", "method": "POST", "path": "/v1/systemone", "headers": headers}
|
||||
|
||||
|
||||
def _scripted_receive(messages: list[Message]) -> Receive:
|
||||
async def receive() -> Message:
|
||||
return messages.pop(0)
|
||||
|
||||
return receive
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chunked_body_is_decoded_whole_and_later_receives_reach_the_client():
|
||||
compressed: Final = zstd.compress(REQUEST_JSON)
|
||||
third: Final = len(compressed) // 3
|
||||
client_messages: Final[list[Message]] = [
|
||||
{"type": "http.request", "body": compressed[:2], "more_body": True},
|
||||
{"type": "http.request", "body": compressed[2:third], "more_body": True},
|
||||
{"type": "http.request", "body": compressed[third : 2 * third], "more_body": True},
|
||||
{"type": "http.request", "body": compressed[2 * third :], "more_body": False},
|
||||
{"type": "http.disconnect"},
|
||||
]
|
||||
seen: Final[list[tuple[Scope, list[Message]]]] = []
|
||||
|
||||
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
seen.append((scope, [await receive(), await receive()]))
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
raise AssertionError(f"middleware sent {message}")
|
||||
|
||||
headers: Final = [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-encoding", b"zstd"),
|
||||
(b"transfer-encoding", b"chunked"),
|
||||
]
|
||||
await RequestBodyDecompressionMiddleware(app)(_http_scope(headers), _scripted_receive(client_messages), send)
|
||||
|
||||
[(app_scope, app_messages)] = seen
|
||||
assert app_scope["headers"] == [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(REQUEST_JSON)).encode()),
|
||||
]
|
||||
assert app_messages == [
|
||||
{"type": "http.request", "body": REQUEST_JSON, "more_body": False},
|
||||
{"type": "http.disconnect"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chunked_plain_body_labeled_gzip_reaches_the_app_in_the_chunks_sent():
|
||||
client_messages: Final[list[Message]] = [
|
||||
{"type": "http.request", "body": REQUEST_JSON[:1], "more_body": True},
|
||||
{"type": "http.request", "body": REQUEST_JSON[1:9], "more_body": True},
|
||||
{"type": "http.request", "body": REQUEST_JSON[9:], "more_body": False},
|
||||
{"type": "http.disconnect"},
|
||||
]
|
||||
seen: Final[list[tuple[Scope, list[Message]]]] = []
|
||||
|
||||
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
seen.append((scope, [await receive(), await receive(), await receive()]))
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
raise AssertionError(f"middleware sent {message}")
|
||||
|
||||
headers: Final = [(b"content-encoding", b"gzip"), (b"transfer-encoding", b"chunked")]
|
||||
await RequestBodyDecompressionMiddleware(app)(_http_scope(headers), _scripted_receive(client_messages), send)
|
||||
|
||||
[(app_scope, app_messages)] = seen
|
||||
assert app_scope["headers"] == [(b"transfer-encoding", b"chunked")]
|
||||
assert app_messages == [
|
||||
{"type": "http.request", "body": REQUEST_JSON[:9], "more_body": True},
|
||||
{"type": "http.request", "body": REQUEST_JSON[9:], "more_body": False},
|
||||
{"type": "http.disconnect"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"client_messages",
|
||||
[
|
||||
pytest.param([{"type": "http.disconnect"}], id="before-the-body"),
|
||||
pytest.param(
|
||||
[{"type": "http.request", "body": GZIP_MAGIC, "more_body": True}, {"type": "http.disconnect"}],
|
||||
id="after-a-short-first-chunk",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
{"type": "http.request", "body": gzip.compress(REQUEST_JSON, mtime=0)[:10], "more_body": True},
|
||||
{"type": "http.disconnect"},
|
||||
],
|
||||
id="mid-body",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_client_disconnect_ends_the_request_without_a_response(client_messages: list[Message]):
|
||||
sent: Final[list[Message]] = []
|
||||
app_calls: Final[list[Scope]] = []
|
||||
|
||||
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
app_calls.append(scope)
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent.append(message)
|
||||
|
||||
await RequestBodyDecompressionMiddleware(app)(
|
||||
_http_scope([(b"content-encoding", b"gzip")]), _scripted_receive(client_messages), send
|
||||
)
|
||||
|
||||
assert (app_calls, sent) == ([], [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chunked_compressed_body_past_the_cap_gets_a_413_without_reading_the_rest():
|
||||
compressed: Final = gzip.compress(random.Random(0).randbytes(2 * MIB), mtime=0)
|
||||
chunk_size: Final = 64 * 1024
|
||||
chunks: Final = [compressed[start : start + chunk_size] for start in range(0, len(compressed), chunk_size)]
|
||||
client_messages: Final[list[Message]] = [
|
||||
*({"type": "http.request", "body": chunk, "more_body": True} for chunk in chunks[:-1]),
|
||||
{"type": "http.request", "body": chunks[-1], "more_body": False},
|
||||
]
|
||||
sent: Final[list[Message]] = []
|
||||
app_calls: Final[list[Scope]] = []
|
||||
|
||||
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
app_calls.append(scope)
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent.append(message)
|
||||
|
||||
await RequestBodyDecompressionMiddleware(app, max_decompressed_body_size_mb=CAP_MB)(
|
||||
_http_scope([(b"content-encoding", b"gzip"), (b"transfer-encoding", b"chunked")]),
|
||||
_scripted_receive(client_messages),
|
||||
send,
|
||||
)
|
||||
|
||||
assert app_calls == []
|
||||
assert sent[0]["status"] == 413
|
||||
assert json.loads(sent[1]["body"]) == {"error": f"Request size is too large. Max size is {CAP_MB} MB"}
|
||||
assert client_messages, "the middleware read the whole body past the cap"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("client_messages", "body_the_app_gets"),
|
||||
[
|
||||
pytest.param(
|
||||
[
|
||||
{"type": "http.request", "more_body": True},
|
||||
{"type": "http.request", "body": gzip.compress(REQUEST_JSON, mtime=0), "more_body": False},
|
||||
{"type": "http.disconnect"},
|
||||
],
|
||||
REQUEST_JSON,
|
||||
id="first-chunk-without-a-body",
|
||||
),
|
||||
pytest.param([{"type": "http.request"}, {"type": "http.disconnect"}], b"", id="empty-request"),
|
||||
],
|
||||
)
|
||||
async def test_request_messages_without_a_body_key_count_as_empty_chunks(
|
||||
client_messages: list[Message], body_the_app_gets: bytes
|
||||
):
|
||||
seen: Final[list[tuple[Scope, Message]]] = []
|
||||
|
||||
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
seen.append((scope, await receive()))
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
raise AssertionError(f"middleware sent {message}")
|
||||
|
||||
await RequestBodyDecompressionMiddleware(app)(
|
||||
_http_scope([(b"content-encoding", b"gzip")]), _scripted_receive(client_messages), send
|
||||
)
|
||||
|
||||
[(app_scope, first_message)] = seen
|
||||
assert (b"content-encoding", b"gzip") not in app_scope["headers"]
|
||||
assert first_message == {"type": "http.request", "body": body_the_app_gets, "more_body": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"scope",
|
||||
[
|
||||
{"type": "lifespan"},
|
||||
{"type": "websocket", "path": "/v1/realtime", "headers": [(b"content-encoding", b"gzip")]},
|
||||
],
|
||||
)
|
||||
async def test_non_http_scopes_reach_the_app_untouched(scope: Scope):
|
||||
seen: Final[list[tuple[Scope, Receive]]] = []
|
||||
|
||||
async def receive() -> Message:
|
||||
raise AssertionError("middleware read from a non-http scope")
|
||||
|
||||
async def app(app_scope: Scope, app_receive: Receive, send: Send) -> None:
|
||||
seen.append((app_scope, app_receive))
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
raise AssertionError(f"middleware sent {message}")
|
||||
|
||||
await RequestBodyDecompressionMiddleware(app)(scope, receive, send)
|
||||
|
||||
[(app_scope, app_receive)] = seen
|
||||
assert app_scope is scope
|
||||
assert app_receive is receive
|
||||
103
uv.lock
generated
103
uv.lock
generated
|
|
@ -725,6 +725,105 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-zstd"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/9c/13569626440e88f09d16f43ec1c2aa0d10a523be2811414580d1cfb7c9f3/backports_zstd-1.8.0.tar.gz", hash = "sha256:9dae4f4c481716e3db473d667457b4f508ff7459c0931b567a5c9677fb3db316", size = 1006566, upload-time = "2026-10-10T16:36:40.642Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a4/3178d6941ebb397b5241ec635e3b116c8203b314578853bf4f02b1c5c9a4/backports_zstd-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5173afe530ca59bba8938a19edcb875c70f78bf9fee01cb3614a97876d112962", size = 439453, upload-time = "2026-10-10T16:33:57.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/62/5ce79a4f9433537e9b233c2c3e946863322150c9c988e7effc0db319e5d4/backports_zstd-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e213317db53e787ef7bf13c5a2070bd98a888ca7603bbd1904ede443c197f3cc", size = 368265, upload-time = "2026-10-10T16:33:59.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/36/30c6aa8155a72959275fe840b9c84efe3bbb075e03d2717d8b9b0bc1c465/backports_zstd-1.8.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:d1c0902770bfcee67b5ff4a5ec69b7ceaf230816e5cd9cc3654a03dd584eead9", size = 508607, upload-time = "2026-10-10T16:34:00.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/75/bd269392aa8bd5f0f44ca9503f4f1daf1df084141efad6934a83a0b53b06/backports_zstd-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb99f835f6d1e6ad0bc1c1ac430baf6d39a9183e37c4f295fb876214ac4c7e28", size = 478536, upload-time = "2026-10-10T16:34:02.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d7/9b6f674e439da130cce94029c6cfd343a2caf78f6d50e6b50e142befa21c/backports_zstd-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:62f633740f25f383b0a3edc7e8bbdc18d38d62a3db7167e77fc715f75e6f233c", size = 583901, upload-time = "2026-10-10T16:34:04.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/e2/6e3e3333ca22f16fc500b4982d4a441bbd6433db5fb23c0dfe3ee7be0fc3/backports_zstd-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38ffdc14e37a0e94eff3b771fc071903b25caa48b092ed59662246970ef01e99", size = 643609, upload-time = "2026-10-10T16:34:07.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/0f/32cf11767d5db2f508d1e01029ae509b92232628504c3c6c0112871f6627/backports_zstd-1.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b58cd328afcb538f3ca5dc2ac47f8dfb68635d5b906d5efcb59054bc86219214", size = 493825, upload-time = "2026-10-10T16:34:08.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/bf/16e5a0af75f2461e4c518796099d5a297803656049e64c0207a88de11a82/backports_zstd-1.8.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f43a0247b7daeea20e792627ec929b995fc290484b11ab314d4c58cc5f5558d8", size = 567704, upload-time = "2026-10-10T16:34:10.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/9c/e761f5eeb780303af2e9be4748bf484621e4e36b59ae85611109ae8587b3/backports_zstd-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1c11797f5129872ca0278d7a1628ff254cf773d9cae337cf30efce5646f8ccd7", size = 484541, upload-time = "2026-10-10T16:34:12.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b8/5e528163601cb3324840346695fdad98463ac01f657b68e61e865568a342/backports_zstd-1.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b37a2189c2be170369dfb083a2ab4793b510e9d0f207cd047ca47f97e8995ba5", size = 512124, upload-time = "2026-10-10T16:34:13.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/16/84b807b56425a1821318b15775706c2549cec1c52d2cf48efafc62beec00/backports_zstd-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70da152b5cf4a75459fb87abc00d263b2012653646372a03904bed67897938be", size = 588257, upload-time = "2026-10-10T16:34:15.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/2a/1f3bbc063f78285ea17e9f12022c22f6b46fc6db4746c6466f093e29c88e/backports_zstd-1.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fc9ee08e6a17f388f670a421b36a5d3a9417a404c2f39ac0bf5e6ad958ac853c", size = 565562, upload-time = "2026-10-10T16:34:17.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/14/a2f8a2eb880a57402cf527ccfaa4fe41edeb0c21428305873f0ce7fb244a/backports_zstd-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52ccf581406f4610570d5e411d5eee9cf0fdde9ee5cd9fc95ae9b12edd150e6c", size = 634170, upload-time = "2026-10-10T16:34:19.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/12/8c1d9e475815d24cf0508bd7cc1811a3b536174b77adeed88a63419c642c/backports_zstd-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d23957b8067e04b15cf59a41098d75855e15e66699dd2b81259316cbe86a3df", size = 497815, upload-time = "2026-10-10T16:34:21.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b4/3916d264038cc9a69693c16aa9dab0de93b8dd418fb6486926a4823aae7d/backports_zstd-1.8.0-cp310-cp310-win32.whl", hash = "sha256:6a73b782aba89d45e2c19c1b6491eed2c90e5de9536c26173fc62be2d011486a", size = 292740, upload-time = "2026-10-10T16:34:22.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/ac/9d56c553c7660a42862d4efdd1f499366ba0f31ff2090e7cb3fb4c56a767/backports_zstd-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:6202f9eb6b44301d3ab62c7d717a1becb530b6d09ccc4d2ff4a4b662220e05e2", size = 330377, upload-time = "2026-10-10T16:34:24.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/49/c659a40b3149f1756505c0c3f26378f0f658d446e04f87e52953f17b989c/backports_zstd-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:b66cfbd6ac3221624ea5088950f243187cb9e24a3e5ad0bc89d093fd143b0696", size = 321825, upload-time = "2026-10-10T16:34:26.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b2/43853a0c366f26b140c272adce74b3c280a2e28ee023c53af53ddd6d9d93/backports_zstd-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c4af1b9542bc6420d55ff47d7efe13c19f56a80cbdd1ffd0a29767801dab886", size = 439457, upload-time = "2026-10-10T16:34:28.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/6d/ab02ba30a51fa9ec452ee0aaccee7e9c3feda8b3a1b0f7e6aeac0a8a5259/backports_zstd-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8efdb220f34418cef987da10d857cf95cdcffe431cc0e536efc25d7279abf118", size = 368264, upload-time = "2026-10-10T16:34:29.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/71/7632053324885d43fe9ad376607885462386a1de6ec6daad3eee291c6ac8/backports_zstd-1.8.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:e70eefb72358ae3c94eac62cf7fa3c392cc21f0a8221d6cdaf3d74aedb9775bf", size = 508606, upload-time = "2026-10-10T16:34:31.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/68/7743d8b0c0b28696b2b4757d90afe2844e8a91121d63951829ad9d27edb2/backports_zstd-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6f9ecc5a251fd9495ee717daa0dc87c195f50d6d3679ddb430eb58256a0ca53", size = 478535, upload-time = "2026-10-10T16:34:32.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/a2/99a32b753e233f501287ee7df2011a9828242c9f0d1c6a5045a4fd587f2e/backports_zstd-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:84d7c45f063ee8cce1dc14cf382511554b0db19234094fa91214be68d185a5a8", size = 583901, upload-time = "2026-10-10T16:34:34.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/fd/1812a60ed4943049accfd820d18eeca8ad79461eea9b0be6f52b29614851/backports_zstd-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:117e1ebc7224ea328c7fba82dfe6b76cead2a2b1f427dabcd8a5fa87c47abd15", size = 643571, upload-time = "2026-10-10T16:34:36.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c9/3eb6466013bbee7f12cf442507ca80d3e31ec1fd68156c57647518a47d27/backports_zstd-1.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7fe40a58dbe1fd358e0ceb5b6b3f50a9b328f8fff42dcb3bdaeb9a022c2506", size = 493818, upload-time = "2026-10-10T16:34:38.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c7/1c8fb5b9e97aa172d68e4bbfb808962a32e9c89b7f25f81cec47c16b5d6d/backports_zstd-1.8.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ba1f16c4196b8392e0adc1f201d0d1aadcc0b78dbe9049fc3d98633cbce565d9", size = 567704, upload-time = "2026-10-10T16:34:40.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/46/8ff2cca539dc1bc35e85c75772ce901ccaa4696cc0c32f8bd00f426595f9/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3568397b72546bab27054fb7526f90b2842a6978cda1224f37c061087ea15bb1", size = 484551, upload-time = "2026-10-10T16:34:41.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/8a/2324e68cb8404b95bdd292575f52c8dd6567a23a4985e6e0322260ea6747/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d0a6cafbc18dd32832bd4c22a40348634d191afadf3e0b82fc5df225dfb94e3b", size = 512127, upload-time = "2026-10-10T16:34:43.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/06/a18156cd52d65f8186a4ee72ce6fe200a23dc3d366f43097d30d77b2cb5d/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e67b330874664e41cb03216e4e33fe79b91304269b329fca82f5bd9e0501a48d", size = 588258, upload-time = "2026-10-10T16:34:45.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/ee/e70d81890364b508fde19979a728161ed836795eab83753c1fdd4e41b395/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:290b41aa11285c8e1eeba7450afb7e9fd61572373410110a2a06a23ae97937f9", size = 565565, upload-time = "2026-10-10T16:34:46.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/72/843335eba25b83c6e1c4febca74cf0e8a80c1108876fef2fe2ebce80bc79/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:13c00e1c66c78a0d1e1c60d0806e9bd430d4c5c92cdce3fa8d087aea436bf449", size = 634218, upload-time = "2026-10-10T16:34:48.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/24/86a428aed44e8389e4436f9e913ba90563efd61779ad5caa360822154fe5/backports_zstd-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0f722107de223fe68efa83b1cc3a11d67d1888441073732f0d350ff8111d23df", size = 497825, upload-time = "2026-10-10T16:34:50.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/0e/a8e246b4ef0e992cd764f7bc898de2878380c3af4b85d5c0e2bd6d22d0fe/backports_zstd-1.8.0-cp311-cp311-win32.whl", hash = "sha256:6b6c46d5d5932b7ad24f42069104919fa806fac0a02144aa8af0f9bb96705274", size = 292853, upload-time = "2026-10-10T16:34:51.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/53/4e36af749d8c115659acfee2bcc6ebbf5cc34fdd30b467c205eae4925c6d/backports_zstd-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a11422c67c6295d36a7a30bac5df82e8a4fc82539d8def0d082ecf15cb24f538", size = 330411, upload-time = "2026-10-10T16:34:53.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/13/9a027f33f95d2d4ab565e9d3655cb8f71e2a1e32e86a57195a787e00483b/backports_zstd-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:0a77b019b80038b1426a74849b0fb8f9b46f876cee74f6d59f26acd1559d4c01", size = 321855, upload-time = "2026-10-10T16:34:54.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/03/3c303d6f3066f84f2c52acfc38852546a836596dd9a2bc7add83bd96b527/backports_zstd-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6e024aee6bfd04094fce60133b0e6bd0f8027cdb2823157880bc87f1ffdfee21", size = 439718, upload-time = "2026-10-10T16:34:56.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/31/1e73b2835c78a9067ecba390b0eea032f827fc0b2f8bf2c8656992c30dc8/backports_zstd-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d810d83c8a703f424ed2a49aa271078c91b530da2d8c104bd88207e68d116de8", size = 368337, upload-time = "2026-10-10T16:34:58.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/43/b0cc88c7d13a544f6d38f288fd96e1595395dad31f49fad2619f06b96d95/backports_zstd-1.8.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:d057948e8cffa19f0cc8668e06fd502ad8a69f398e91a426b39dcc5eeb197c2f", size = 509148, upload-time = "2026-10-10T16:34:59.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/29/81cc731a0408c3cba05a44ece00476305dbe1a52e27a4c323c98685f7015/backports_zstd-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa762cf369d9bfca1e013eaad562f8e129d71b7a82f0c459870d6d21651bcb3", size = 478911, upload-time = "2026-10-10T16:35:01.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/63/dc62779cabb725a8974a2d303bfe0d7cd5b8987fab79ab445c48efcfb2e4/backports_zstd-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b9d6c4ca7d927fd094badcf9174ee5c82ddb4855fe14658806c8c8a07d4a165", size = 584283, upload-time = "2026-10-10T16:35:03.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/12/5e8ce29119d78845cd3351bcd79baa16a30aa8c19f8c359a1719a15d97b3/backports_zstd-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d85b8ce50aea247289be183f853e67c106959c4048ce286b26c4663b06bb6d", size = 643167, upload-time = "2026-10-10T16:35:05.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/08/a9d59fb9e20215ede0c8ea4d729373dc0592aee45776cdd86c92c3c6242c/backports_zstd-1.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e9aa28a44db1897fb637f037175566f3b75890d4bae6cae7ba34f1df1e0804", size = 496867, upload-time = "2026-10-10T16:35:07.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/b8/abcd2be476a47dd236500c405df32aa81902c54750b26c626f190bbef6b9/backports_zstd-1.8.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c431f3cdc7eb663a42574e27a8604a18181ea4e193504f222d8e61c6f5f8b78", size = 571623, upload-time = "2026-10-10T16:35:09.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/ce/31e668dcdfe017b3240f49c3ef67b108224d3f66d90e9f26caecafc3c29c/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e0431230a67e8f07210efe654abda9844a55c3bf57d74e60425d9d65770b1de4", size = 484948, upload-time = "2026-10-10T16:35:10.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/98/d9122b7531830ceb0f62adb88694bb8cc414a27d1d03539c44dd96fa7a63/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9b62b6c8c5a43b294d4358c2016bfbc507cc574315ffa75346ccf0b621746461", size = 512635, upload-time = "2026-10-10T16:35:12.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/f0/168c6d0c93a3ad6568d0b0ac2f732efc9132b2839d4e6759e61f5239107d/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:869ab7e5421873dfbdbf646d52b4e8d711093972819c06c6daf3249a1ec6e0e7", size = 588696, upload-time = "2026-10-10T16:35:14.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/32/b8eacce542dae88df98f923e81c079a01b66b7fbdf103e319f6fb1df2dfa/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ec1a796429674ebc0e2d48feb3b6658bf49d3ae840b0c0e14ad50c4d6b7341fe", size = 568895, upload-time = "2026-10-10T16:35:16.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/16/8abede9513ec8fd584e36159b1dce82042a97214e69f53f08605b245999f/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:775b701a576769df053cfb7d9456b06223b40e329c010be6cc178fe9e404a3d2", size = 633610, upload-time = "2026-10-10T16:35:18.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/74/4e82ed15ae212b0fc0cd8f82c5bbf6a9dd584b6b37df0c3485663c6ad105/backports_zstd-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ab77a2e6e21c57e8341bb7656c71d1a1653151ebe787b3f092ce86a02543eb52", size = 501407, upload-time = "2026-10-10T16:35:19.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/02/7e86774e0a3c2457d23939acbb32bdb019e6bdec48892986255faa262c3d/backports_zstd-1.8.0-cp312-cp312-win32.whl", hash = "sha256:f99b44c2c13fc60f65ad568bf7401d9540370f996b1040793a34988324e3b712", size = 293065, upload-time = "2026-10-10T16:35:21.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/78/2f497fd2bbf46099e46650f75467967d21f25bb921c894d28d493bbfb7e4/backports_zstd-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:1eddf59fedaf19dd3a8e9c597add7eb6f0d51d4467a0924b2dcd2c118ed18ff5", size = 330563, upload-time = "2026-10-10T16:35:22.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/2c/3a1a91cea5b98e24cb54ecf142a72246d2e1efa5efe41504388188598951/backports_zstd-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:2b3247a7a916b90f155b4133eedaceadd0c37b4149ee32e4d74fe512a14be89b", size = 322189, upload-time = "2026-10-10T16:35:24.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/a8/7a04f1daaa42936ec3d98f213b4698b18053d1154f2aee1d067c4121fe3a/backports_zstd-1.8.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4e92ff4ce96b3c61d25900875b6cf1ee249349b8e419abd80893ec9b8026444e", size = 401586, upload-time = "2026-10-10T16:35:26.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/c2/d26216501b3e13583084e11106ade1779b280f3304c75d84d2dfb9e5d609/backports_zstd-1.8.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:0c2e652b4fbc2e6b7bd05a09b6eab3a51bfaed9e7fca1bc81d763dc47361e2ff", size = 455589, upload-time = "2026-10-10T16:35:28.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/66/372b138fa7e7be4d6aff343a55dd77e492867cb5de701899b5aa01722836/backports_zstd-1.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915d3e7e57194b5cee33f10cf2d9f5c4f7658c8a167236f9ba5501520cf133e8", size = 358662, upload-time = "2026-10-10T16:35:29.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/26/0b89de2f83088f89e10ea3f4a5badef9bc95098bdd39a3031362da48dc60/backports_zstd-1.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e6f8483b795a09c0e0fbacca4fa844242bc6d5fc64b8a6ee99f88ad8af27b08", size = 367357, upload-time = "2026-10-10T16:35:31.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/01/5239b39d3f65ba80e2129b9273bf736245e4a1c03b8a317ed399c4fe10dd/backports_zstd-1.8.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1fe4b06a019aa4cdf87af320eef56a4bdbdb924ead36a7a918645d72edece966", size = 447892, upload-time = "2026-10-10T16:35:33.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/13/e4eceee62d144f68944addb0179368d626f96d3644d965620774f1f5e463/backports_zstd-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:49c4006cdf41c15ffcc74f10d9a6485be841106cd4d5aa7ea7bf1075cc37fb83", size = 439240, upload-time = "2026-10-10T16:35:35.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/5f/996aceebbbc4eebc05d99fe1714b1b0930260eac5171e8ebc3a952390c0d/backports_zstd-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fa862d24b7fb392279a95bc9acc1f0ede8a25de9efbed03fb305ceac2f6abb0", size = 367710, upload-time = "2026-10-10T16:35:37.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/0b/c373a7f92df9df1f9e0657ea0dd86c45444b8414db616b3d38b62f90075c/backports_zstd-1.8.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:9af83a6d7dc67896fd91bcd4c2cd182ba97d7cca2b09a94373a5fef154001d98", size = 508347, upload-time = "2026-10-10T16:35:38.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/36/07dca77032300047efd09808d49ab9d1fff8657553adbc8e0e6405aba864/backports_zstd-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a808ba1371231c00a2b71f03840a727088e287d0ee1dfb3230958950f21f421", size = 478416, upload-time = "2026-10-10T16:35:40.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a9/bb96724619a1dcc3a9e3138d15a6f7a2fc40b581926db4ac00e424af79c1/backports_zstd-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6cc15051c282ac2585a2425d22f416ae2deb5afb441b22831b349b02fd58a782", size = 583888, upload-time = "2026-10-10T16:35:42.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/6d/65e6e437eb54b5be2ce7248ac236d82a771a672457c950e7f96849699274/backports_zstd-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a23d38d7b9ca93403acd3c2c306af6e547a24d150c25ac2d7a8acd751fbd968", size = 644796, upload-time = "2026-10-10T16:35:43.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6d/3c422b33d40aaca6e9d9fdd47f1a047ac499de749c887ab3dab62f731fb2/backports_zstd-1.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44a9004f9e809ea56910d326d21946650369db59eb86edc0c76840f21530704c", size = 493385, upload-time = "2026-10-10T16:35:45.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b9/ea08e2c2b8a7bfabff359852e4d7a9cbc2cde09715907250c0e53432fbe9/backports_zstd-1.8.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ff307f3f0ef3b7f40ccfce42c0704fddc99cd30bca451330f42466db1981be9", size = 568613, upload-time = "2026-10-10T16:35:47.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6e/775cb7317f1f693c7f3e96fa5cf5426b461616b52730a72f978f31b334b0/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6c8572e27c5f0b9d11020d3f597bf3c35fe0f5ae6f99156dc52b0bd937ba8908", size = 484237, upload-time = "2026-10-10T16:35:49.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f8/c31798a8911390fb0d4f058f65cba2e54141d6394c35430b1d495d121667/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cc1d9d3660c40abe4095de80f43ce4c955d08f7d9803d3da97176aa61b76d923", size = 511865, upload-time = "2026-10-10T16:35:51.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/df/0ff79b6a2d7f5c10d3ebc7e23b5281f51130feb4db8afadac98ba5131c18/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:83cea5cdd70e1d74382be6deeeda1db79aedd1a06af4f8a8fbafba9eedae5230", size = 588422, upload-time = "2026-10-10T16:35:53.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/a7/d5dbad63911fc3040253dc209a7aac8921e928fe64f3fcde051066aa5a75/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e74eb204b9d7798fc57393202c443fc2ec84283d82387168baeb763f8beb224d", size = 566480, upload-time = "2026-10-10T16:35:55.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/b9/621e734eb144d56c7632b763c0ce3fa196839fc0f82830244206a9d37d8d/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:515497b3d49dd6d7a84fb16a0a0007bc460b4a7e1f55e70f33315c66d3844e8e", size = 635191, upload-time = "2026-10-10T16:35:57.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/72/1b6709f13f2a22a1d72e15f114ab62e852db33ba0f8840c7d102523bcdb6/backports_zstd-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6283c90997038abf46c8a0bb75afb4dc6cbf061421802fda0afc382fe4b348b3", size = 497769, upload-time = "2026-10-10T16:35:59.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/52/cd0a82fd52ae159a0316d2257156968c356cab81062d6050af48a4e8a3d6/backports_zstd-1.8.0-cp313-cp313-win32.whl", hash = "sha256:9d76a3193a3a4a6b1249021e7ecf72e4cabc1dca611c6fb41db1c0b5d2faf741", size = 292645, upload-time = "2026-10-10T16:36:01.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/0e/5c5a916cea73b455850083ccf76078de655face3dfe4126848570c57a6dd/backports_zstd-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:b583990d554cc6f6141c5c43b6db3c7da87a214253e08339d917ee3baa3021b6", size = 330247, upload-time = "2026-10-10T16:36:03.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/3c/7297d87eed9254f6b4823c05b37aa07ec2a99bc5f195760dc574e925eecf/backports_zstd-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:0600e166cb00739a26de74ee1696221a53a4d5dc1f96a0bdeb6b307c1626c15c", size = 322066, upload-time = "2026-10-10T16:36:04.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/c5/a48a8595d151903328ec68041862b42e06ba4cd44662009a5e23d2c912c6/backports_zstd-1.8.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:403985e468f1cccb87a7e9e4f1d78106ea8e77dcdda3038d645d052a8d8e1ce3", size = 414047, upload-time = "2026-10-10T16:36:06.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/a4/6646415f884005001a1dd6b6067e2d4067188b874ff62b2e3313d7bf2f30/backports_zstd-1.8.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:045e15ed3b3ebd8816edaa7d66f024becf050d9aec09605f549ce33cfda01098", size = 344734, upload-time = "2026-10-10T16:36:08.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/a5/5afcdc74fd49eb8536f2db78b9d0ee066f5f68c87261fb2914aed79928fd/backports_zstd-1.8.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:9da207eb5264a03d29d62169d3dfe0790dc47f85b1785f25e9b01763f227dcdd", size = 422892, upload-time = "2026-10-10T16:36:10.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/66/d16f7be06b7bad41311b3d405782b0fcb26e61ed323ae3f2bef347517329/backports_zstd-1.8.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ebee106e5592549e3eca5d2cf2575de73a87b046f5d433f63ffbefcd6ab5e24", size = 396433, upload-time = "2026-10-10T16:36:12.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/65/7c1dbc9a6cb9005001bc64a99a96eeb29f9dbdc82a558ced1ae652a37372/backports_zstd-1.8.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:200313a6aae64e7f54bdd703317b16560e195f37426bb308e9a495e27ec4efd0", size = 416398, upload-time = "2026-10-10T16:36:13.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/a4/b45f63e146f69c3b7516410638c9832db3c254c7a7f6f63be18b3e98268d/backports_zstd-1.8.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7b48d33ef2446bd5f4922757451d8eefbae25cc08da7c216ba200ff1acdb4352", size = 316947, upload-time = "2026-10-10T16:36:15.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/1c/74a4b8310af405f477b5278ae652d35f0609acae3f23c9fc472f79d11600/backports_zstd-1.8.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:900b357bbae805bb98672471ede748c80ccfc1212be0b4ef52a102750ef742a7", size = 413974, upload-time = "2026-10-10T16:36:17.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/1c/3bb324f70aac60a4c5aad60b9d365af2dac81205b20ecf66e04947381228/backports_zstd-1.8.0-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:1eae18c682f7daf8d7b39c988516d7a123ec446beb77f709d0cb1475ab57f0cc", size = 344649, upload-time = "2026-10-10T16:36:19.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/fc/a62c13e0498fb951a65caf8c979624fddd1085e388b067ec7b225b59c1e9/backports_zstd-1.8.0-pp311-pypy311_pp80-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:59d29e16273a440af6beb11965cfa84cd19207b38fb5302b2430bc8eabef4812", size = 422892, upload-time = "2026-10-10T16:36:21.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9b/6d8e6044eb6a829c075f2f1e59dc6a9789de606c4ef95fb66095efb3a47f/backports_zstd-1.8.0-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:307badd18496d7c7c6adb91b524b120b4fd3ab5609ec794c36953b9a5f4f4728", size = 396431, upload-time = "2026-10-10T16:36:23.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/50/c5dd607ca0281509ce22b683d43ad801b68b36b9dd0429e5d34c50886f6f/backports_zstd-1.8.0-pp311-pypy311_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40966dc0a3d08d56f83a6b79239d3f294896c9aee453449064fc3627058448fb", size = 416398, upload-time = "2026-10-10T16:36:25.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/9c/0210e539a290f64d1303afeae4f79f94ed97e8cf7171bd385fc373a4c414/backports_zstd-1.8.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:029bca2385ebb4355135bdb8559792d2768ae19707705eea84e68c42a30a0276", size = 404276, upload-time = "2026-10-10T16:36:27.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/c8/dba9e5905e83ac955c1c19b797f59f5335a351664a7b25a709929d63dfbc/backports_zstd-1.8.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:f710d03f84d74f11737735f846b44ef1545cadb73ef47bcd3d0e124f253dd763", size = 413972, upload-time = "2026-10-10T16:36:28.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/11/8ee691bfd2c8292a573a0378a616372aa01ed9e6001d5778ae666a239265/backports_zstd-1.8.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:2b11fb8b9c798657c97ad3165893f146c300e2f7f800e9c54c0d2143052c1486", size = 344652, upload-time = "2026-10-10T16:36:30.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/33/86bb2cd5c6e827adba98fb091ccecb29dae3bb33e0406f8e08be7bdbe70b/backports_zstd-1.8.0-pp312-pypy312_pp80-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:ec7351d3e6ea92338dc4e0e53c876d2e2092e07ad3a2083088e0160200efdd15", size = 422892, upload-time = "2026-10-10T16:36:32.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/a2/629f5e9c3edd2a31f7dd65b8097241b5036f98105efac251a12c1a8f7cb5/backports_zstd-1.8.0-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63ae348b629121eeb967244fecd254f41b4b3a63d074c252f4d7777f5d17c71c", size = 396431, upload-time = "2026-10-10T16:36:34.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f6/9c223e9cccc5a797c17475fde1a8a78ada0dcdd39be2302f4605e565c0ce/backports_zstd-1.8.0-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:163b5c36321bf5652b6e4aeb04d3644ddbf9c1881a82322e376e5be3532af26b", size = 416398, upload-time = "2026-10-10T16:36:36.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e3/2eb6f517c9a6746a735b49ba4ab3ed3df6c4ec9072169805547ae590e296/backports_zstd-1.8.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:3f0288db18a64f4f4146f4526456ff62b2edb625b2d43956e764885edd3f1da2", size = 404272, upload-time = "2026-10-10T16:36:38.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "basedpyright"
|
||||
version = "1.39.7"
|
||||
|
|
@ -4576,6 +4675,7 @@ proxy = [
|
|||
{ name = "azure-identity" },
|
||||
{ name = "azure-storage-blob" },
|
||||
{ name = "backoff" },
|
||||
{ name = "backports-zstd", marker = "python_full_version < '3.14'" },
|
||||
{ name = "boto3" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "expression" },
|
||||
|
|
@ -4746,6 +4846,7 @@ healthcheck = [
|
|||
proxy-dev = [
|
||||
{ name = "a2a-sdk" },
|
||||
{ name = "azure-identity" },
|
||||
{ name = "backports-zstd", marker = "python_full_version < '3.14'" },
|
||||
{ name = "hypercorn" },
|
||||
{ name = "litellm-admin-mcp", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "opentelemetry-api" },
|
||||
|
|
@ -4775,6 +4876,7 @@ requires-dist = [
|
|||
{ name = "azure-storage-blob", marker = "extra == 'proxy'", specifier = ">=12.28.0,<13.0" },
|
||||
{ name = "azure-storage-file-datalake", marker = "extra == 'proxy-runtime'", specifier = ">=12.20.0,<13.0" },
|
||||
{ name = "backoff", marker = "extra == 'proxy'", specifier = ">=2.2.1,<3.0" },
|
||||
{ name = "backports-zstd", marker = "python_full_version < '3.14' and extra == 'proxy'", specifier = ">=1.0.0,<2.0" },
|
||||
{ name = "boto3", specifier = ">=1.43.1,<2.0" },
|
||||
{ name = "boto3", marker = "extra == 'proxy'", specifier = ">=1.43.1,<2.0" },
|
||||
{ name = "click", specifier = ">=8.0.0,<9.0" },
|
||||
|
|
@ -4965,6 +5067,7 @@ healthcheck = [
|
|||
proxy-dev = [
|
||||
{ name = "a2a-sdk", specifier = "==1.1.0" },
|
||||
{ name = "azure-identity", specifier = "==1.25.2" },
|
||||
{ name = "backports-zstd", marker = "python_full_version < '3.14'", specifier = "==1.8.0" },
|
||||
{ name = "hypercorn", specifier = "==0.17.3" },
|
||||
{ name = "litellm-admin-mcp", marker = "python_full_version >= '3.12'", url = "https://github.com/BerriAI/liteadmin-mcp/archive/d35ec9c19c117d4c50cc1eccf6ce1296aac25a1a.tar.gz" },
|
||||
{ name = "opentelemetry-api", specifier = "==1.33.1" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue