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:
Mateo Wang 2026-10-20 05:11:30 +00:00 • committed by GitHub
parent fad31403db
commit e34c779c67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1394 additions and 13 deletions

View file

@ -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

View file

@ -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")

View file

@ -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)

View file

@ -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:

View file

@ -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(

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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" },