Merge pull request #41323 from BerriAI/litellm_backport_1_99_x_41230_0915

chore(release): backport #41230 to stable/1.99.x and cut 1.99.2
This commit is contained in:
yuneng-jiang 2026-09-15 17:26:38 -07:00 committed by GitHub
commit 3c08f26b6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 219 additions and 23 deletions

View file

@ -42,6 +42,7 @@ OPTIONAL_KWARGS_KEYS: Final = (
"azure_password",
"azure_scope",
"timeout",
"client_side_timeout",
"gcs_bucket_name",
"bucket_name",
"vertex_credentials",

View file

@ -137,6 +137,7 @@ from litellm.router_utils.cooldown_handlers import (
_get_cooldown_deployments,
_set_cooldown_deployments,
is_advisor_orchestration_failure,
is_caller_timeout_408,
)
from litellm.router_utils.fallback_event_handlers import (
_check_non_standard_fallback_format,
@ -7222,6 +7223,13 @@ class Router:
litellm_params: Final = kwargs.get("litellm_params", {})
_model_info: Final = litellm_params.get("model_info", {})
if is_caller_timeout_408(kwargs, exception_status):
verbose_router_logger.debug(
"Router: Exiting 'deployment_callback_on_failure' without cooldown. "
"A timeout the caller set caused this 408, not the deployment's health."
)
return False
exception_headers: Final = litellm.litellm_core_utils.exception_mapping_utils._get_response_headers(
original_exception=exception
)

View file

@ -9,6 +9,7 @@ Router cooldown handlers
import asyncio
import math
from collections.abc import Mapping
from datetime import datetime
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final
@ -623,3 +624,23 @@ def cast_exception_status_to_int(exception_status: str | int) -> int:
)
exception_status = 500
return exception_status
def is_caller_timeout_408(
model_call_details: Mapping[str, object], exception_status: str | int, ended: datetime | None = None
) -> bool:
"""A 408 that arrives before the caller-set timeout could have fired came from the provider.
``ended`` overrides ``model_call_details["end_time"]`` for callers that run before the
failure logger has stamped the current API call's end time."""
if cast_exception_status_to_int(exception_status) != 408:
return False
litellm_params: Final = model_call_details.get("litellm_params")
if not isinstance(litellm_params, Mapping) or not litellm_params.get("client_side_timeout"):
return False
timeout: Final = litellm_params.get("timeout")
started: Final = model_call_details.get("api_call_start_time") or model_call_details.get("start_time")
finished: Final = ended if ended is not None else model_call_details.get("end_time")
if not isinstance(timeout, (int, float)) or not isinstance(started, datetime) or not isinstance(finished, datetime):
return False
return (finished - started).total_seconds() >= timeout

View file

@ -2,7 +2,9 @@ import hashlib
import json
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final
import litellm
@ -19,6 +21,7 @@ from litellm.router_utils.cooldown_handlers import (
_set_cooldown_deployments, # pyright: ignore[reportPrivateUsage] - shared helper, used across router_utils
cast_exception_status_to_int,
is_advisor_orchestration_failure,
is_caller_timeout_408,
)
from litellm.router_utils.router_callbacks.track_deployment_metrics import (
increment_deployment_failures_for_current_minute,
@ -35,12 +38,14 @@ else:
# Status codes a generic API call's caller-supplied resource id can trigger on its own
# (e.g. a nonexistent file/batch/thread id), independent of the selected deployment's health.
_REQUEST_SCOPED_STATUS_CODES: Final = frozenset((404,))
_NO_MODEL_CALL_DETAILS: Final[Mapping[str, object]] = MappingProxyType({})
def _trigger_cooldown_for_failed_deployment(
litellm_router: LitellmRouter,
kwargs: Mapping[str, Any],
exception: Exception,
model_call_details: Mapping[str, object] = _NO_MODEL_CALL_DETAILS,
) -> None:
"""
Trigger cooldown for a failed fallback deployment.
@ -79,7 +84,11 @@ def _trigger_cooldown_for_failed_deployment(
# timeout, which litellm.Timeout reports as status 408 regardless of the deployment's
# actual health. Left unguarded, a caller could force a 408 on every deployment in
# the fallback chain from a single request with a near-zero timeout.
if kwargs.get("client_side_timeout") and cast_exception_status_to_int(exception_status) == 408:
if is_caller_timeout_408(
model_call_details,
exception_status,
ended=datetime.now(), # noqa: DTZ005 # naive to match the logging pipeline's api_call_start_time
):
verbose_router_logger.debug(
"Not triggering cooldown for fallback deployment: a caller-supplied "
"x-litellm-timeout caused this 408, not deployment health."
@ -412,6 +421,7 @@ async def run_async_fallback(
litellm_router=litellm_router,
kwargs=kwargs,
exception=e,
model_call_details=logging_obj.model_call_details,
)
raise error_from_fallbacks

View file

@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.99.1"
version = "1.99.2"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.15"
@ -310,7 +310,7 @@ members = ["enterprise", "litellm-proxy-extras"]
profile = "black"
[tool.commitizen]
version = "1.99.1"
version = "1.99.2"
version_files = [
"pyproject.toml:^version",
]

View file

@ -1,4 +1,5 @@
import json
from datetime import datetime, timedelta
from typing import NoReturn
from unittest.mock import MagicMock, patch
@ -728,7 +729,11 @@ class TestTriggerCooldownForFailedDeployment:
"""The proxy's x-litellm-timeout header lets a caller set an arbitrarily short
timeout, which litellm.Timeout reports as status 408 regardless of the
deployment's actual health. Without this guard, a caller could force a 408 on
every deployment in the fallback chain from a single request."""
every deployment in the fallback chain from a single request.
The failure logger never stamps end_time for a fallback hop (has_logged_async_failure
is already set), so model_call_details still carries the previous hop's end_time, which
predates this hop's api_call_start_time. The guard must not trust it."""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
@ -746,11 +751,61 @@ class TestTriggerCooldownForFailedDeployment:
litellm_router=mock_router,
kwargs={"client_side_timeout": True},
exception=exc,
model_call_details={
"litellm_params": {"client_side_timeout": True, "timeout": 0.5},
"api_call_start_time": datetime.now() - timedelta(seconds=1),
"end_time": datetime.now() - timedelta(seconds=5),
},
)
mock_set_cooldown.assert_not_called()
mock_increment.assert_not_called()
@pytest.mark.asyncio
async def test_still_cools_down_provider_408_before_caller_deadline(self):
"""client_side_timeout only records that the caller configured a timeout. A 408
that comes back before that deadline was raised by the provider itself, so it is
a real health signal and must still cool the deployment down."""
from litellm.router_utils.router_callbacks.track_deployment_metrics import (
get_deployment_failures_for_current_minute,
)
router = litellm.Router(
model_list=[
{
"model_name": "fallback-model",
"litellm_params": {"model": "openai/gpt-5.6", "api_key": "sk-fake"},
"model_info": {"id": "fallback-deployment"},
}
],
allowed_fails=0,
cooldown_time=60,
num_retries=0,
)
exc = litellm.Timeout(message="timeout", model="gpt-5.6", llm_provider="openai")
exc.failed_deployment_id = "fallback-deployment"
started = datetime.now()
_trigger_cooldown_for_failed_deployment(
litellm_router=router,
kwargs={"client_side_timeout": True},
exception=exc,
model_call_details={
"litellm_params": {"client_side_timeout": True, "timeout": 30},
"api_call_start_time": started,
"end_time": started + timedelta(seconds=1),
},
)
assert (
get_deployment_failures_for_current_minute(
litellm_router_instance=router, deployment_id="fallback-deployment"
)
== 1
)
active = router.cooldown_cache.get_active_cooldowns(model_ids=["fallback-deployment"], parent_otel_span=None)
assert [entry[0] for entry in active] == ["fallback-deployment"]
def test_still_cools_down_408_without_client_side_timeout_flag(self):
"""The client-side-timeout guard is scoped to caller-supplied timeouts only: a
408 that did not come from x-litellm-timeout (no client_side_timeout in kwargs)

View file

@ -4,6 +4,7 @@ import json
import logging
import os
import threading
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
@ -7056,6 +7057,106 @@ class TestAdvisorSubCallCooldown:
assert "dep-1" not in self._cooled_down_ids(router)
class TestCallerTimeoutCooldown:
"""A timeout the caller set (the proxy's `timeout` body field or x-litellm-timeout
header) comes back as a 408 whatever the deployment's health, so it must neither
count toward allowed_fails nor bench the deployment. A 408 without that marker, or
one that arrives before the caller's deadline could have fired, is the provider's
and keeps cooling the deployment down."""
def _router(self):
return litellm.Router(
model_list=[
{
"model_name": "slow-model",
"litellm_params": {"model": "openai/gpt-5.6", "api_key": "sk-fake"},
"model_info": {"id": "dep-1"},
}
],
allowed_fails=0,
cooldown_time=120,
num_retries=0,
)
def _kwargs(self, marker, started=None, ended=None):
exception = litellm.Timeout(message="Request timed out", model="gpt-5.6", llm_provider="openai")
return {
"exception": exception,
"api_call_start_time": started,
"end_time": ended,
"litellm_params": {"model_info": {"id": "dep-1"}, "metadata": {}, **marker},
}
def _fail_count(self, router):
from litellm.router_utils.router_callbacks.track_deployment_metrics import (
get_deployment_failures_for_current_minute,
)
return get_deployment_failures_for_current_minute(litellm_router_instance=router, deployment_id="dep-1")
def _cooled_down_ids(self, router):
active = router.cooldown_cache.get_active_cooldowns(model_ids=["dep-1"], parent_otel_span=None)
return [entry[0] for entry in active]
@pytest.mark.asyncio
async def test_caller_timeout_408_leaves_failure_counter_and_cooldown_untouched(self):
router = self._router()
started = datetime.now()
ended = started + timedelta(seconds=2.05)
kwargs = self._kwargs({"client_side_timeout": True, "timeout": 2}, started=started, ended=ended)
assert router.deployment_callback_on_failure(kwargs, None, started, ended) is False
assert self._fail_count(router) == 0
assert self._cooled_down_ids(router) == []
@pytest.mark.asyncio
async def test_provider_timeout_408_still_counts_and_cools_down(self):
router = self._router()
now = datetime.now()
assert router.deployment_callback_on_failure(self._kwargs({}), None, now, now) is True
assert self._fail_count(router) == 1
assert self._cooled_down_ids(router) == ["dep-1"]
@pytest.mark.asyncio
async def test_provider_408_before_caller_deadline_still_counts_and_cools_down(self):
"""The marker only says the caller configured a timeout. A 408 that comes back
well before that deadline was raised by the provider, so it is a real health
signal and must not hide behind the caller's timeout."""
router = self._router()
started = datetime.now()
ended = started + timedelta(seconds=0.4)
kwargs = self._kwargs({"client_side_timeout": True, "timeout": 30}, started=started, ended=ended)
assert router.deployment_callback_on_failure(kwargs, None, started, ended) is True
assert self._fail_count(router) == 1
assert self._cooled_down_ids(router) == ["dep-1"]
@pytest.mark.asyncio
async def test_caller_timeout_marker_reaches_failure_callback_end_to_end(self):
router = self._router()
seen = []
recorded = threading.Event()
def record(kwargs, completion_response, start_time, end_time):
seen.append(kwargs)
recorded.set()
litellm.failure_callback.append(record)
try:
with pytest.raises(litellm.Timeout):
await router.acompletion(
model="slow-model",
messages=[{"role": "user", "content": "hello"}],
mock_timeout=True,
timeout=0.001,
client_side_timeout=True,
)
assert await asyncio.to_thread(recorded.wait, 5)
finally:
litellm.failure_callback.remove(record)
assert seen[0]["litellm_params"]["client_side_timeout"] is True
assert self._fail_count(router) == 0
assert self._cooled_down_ids(router) == []
def test_stream_chunks_have_generated_content_detects_text_and_non_text():
from litellm.router import _stream_chunks_have_generated_content
from litellm.types.utils import (

38
uv.lock generated
View file

@ -10,7 +10,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-08-29T19:55:59.005565Z"
exclude-newer = "2026-09-12T23:03:29.512701Z"
exclude-newer-span = "P3D"
[manifest]
@ -2373,14 +2373,14 @@ wheels = [
[[package]]
name = "gitpython"
version = "3.1.58"
version = "3.1.59"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gitdb" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/d6/5f358ff283325580c2003a6d953aea18cfe10ae87b46f5ebc80fa3a386dc/gitpython-3.1.58.tar.gz", hash = "sha256:621416df10ef3fd0e19fabf9172ddeed0fa704d353d04f194eec56a625a95b22", size = 228498, upload-time = "2026-08-04T15:05:49.47Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/dc/126b28e76b24a9268ba931ad3e012f71ebdadf62fd9f17758f7074bb0b20/gitpython-3.1.59.tar.gz", hash = "sha256:0a1475cfdc38a5bfba1a3e9a4a9da52a39749ecec322b772915c019f94e5b7e4", size = 230445, upload-time = "2026-08-10T12:03:20.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/0c/9d8752098bc442f0726e64aa6135940b3a96809915d1aa4206c1bb97881d/gitpython-3.1.58-py3-none-any.whl", hash = "sha256:d331e722577f0fd7fc1f857419b3ecc07af66282b933d2a4d95f84a042fdd50f", size = 220183, upload-time = "2026-08-04T15:05:48.025Z" },
{ url = "https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl", hash = "sha256:67a82f537384578643624c8b2c531938a9b82be431663e575dcf638526631d4c", size = 220996, upload-time = "2026-08-10T12:03:18.804Z" },
]
[[package]]
@ -4266,7 +4266,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.99.1"
version = "1.99.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@ -7556,14 +7556,14 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.15.0"
version = "6.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/17/ee75a92718ec7212de831e71454d702225aa5e474a805cce169806044453/pypdf-6.15.0.tar.gz", hash = "sha256:d39c4d955a76409284a905e2d65b40076d77ab76129e0faaeeb6612403ecfc79", size = 6993794, upload-time = "2026-08-06T13:06:49.929Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/5a/df92d1c1ef8806ca28f20f978ee059894868d93de797a7e2edebe7fe1a43/pypdf-6.16.1.tar.gz", hash = "sha256:c4d1b43ddae921387321cf63936cd16a7743b91d2da92f165c149a195c972ba9", size = 7003737, upload-time = "2026-08-14T12:24:04.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl", hash = "sha256:14e001d6504822cb1ca9c7ed9a69bccb320f59b320730f55af804361abe4d5ee", size = 378123, upload-time = "2026-08-06T13:06:47.709Z" },
{ url = "https://files.pythonhosted.org/packages/33/a1/724b18d6757ab7253a8fecd3a430eb8d980ed26872ba16651e7b5ddfc63f/pypdf-6.16.1-py3-none-any.whl", hash = "sha256:63fec31c4092ae50b6729beedcb469055b60d20c834bde1c402df241f371f644", size = 382924, upload-time = "2026-08-14T12:24:02.854Z" },
]
[[package]]
@ -9433,19 +9433,19 @@ wheels = [
[[package]]
name = "tornado"
version = "6.5.7"
version = "6.5.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" }
sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" },
{ url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" },
{ url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" },
{ url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" },
{ url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" },
{ url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" },
{ url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" },
{ url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" },
{ url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" },
{ url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" },
{ url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" },
{ url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" },
{ url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" },
{ url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" },
{ url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" },
]
[[package]]