diff --git a/litellm/constants.py b/litellm/constants.py index 7d8311eb915..c7673db6976 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -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 diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 7a05121ff2b..96142d75403 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -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") diff --git a/litellm/proxy/middleware/request_body_decompression_middleware.py b/litellm/proxy/middleware/request_body_decompression_middleware.py new file mode 100644 index 00000000000..5eb8a9bfc91 --- /dev/null +++ b/litellm/proxy/middleware/request_body_decompression_middleware.py @@ -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) diff --git a/litellm/proxy/middleware/request_size_limit_middleware.py b/litellm/proxy/middleware/request_size_limit_middleware.py index 1715752d145..cbbc79cc2ca 100644 --- a/litellm/proxy/middleware/request_size_limit_middleware.py +++ b/litellm/proxy/middleware/request_size_limit_middleware.py @@ -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: diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 811d4b8601e..341f12ba720 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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( diff --git a/pyproject.toml b/pyproject.toml index 6c71140e6b8..9871a83b421 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/code_coverage_tests/liccheck.ini b/tests/code_coverage_tests/liccheck.ini index b66493386c1..e533907659b 100644 --- a/tests/code_coverage_tests/liccheck.ini +++ b/tests/code_coverage_tests/liccheck.ini @@ -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 diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index 02be27d921e..7b1c895483d 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -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 diff --git a/tests/unit/proxy/decisions_endpoints/test_endpoints.py b/tests/unit/proxy/decisions_endpoints/test_endpoints.py index 6ca8c723b06..fcf56eaea9a 100644 --- a/tests/unit/proxy/decisions_endpoints/test_endpoints.py +++ b/tests/unit/proxy/decisions_endpoints/test_endpoints.py @@ -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 diff --git a/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py b/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py new file mode 100644 index 00000000000..ca5774b6be0 --- /dev/null +++ b/tests/unit/proxy/middleware/test_request_body_decompression_middleware.py @@ -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(" 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 diff --git a/uv.lock b/uv.lock index 69d9f777687..9b490cf95f4 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },