diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f7a27e4896c..de98a4d571b 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12453,7 +12453,7 @@ class ProxyStartupEvent: _scheduled_weekly_spend_report, "interval", days=days, - next_run_time=datetime.now() + timedelta(seconds=10 + random.randint(0, 300)), + next_run_time=datetime.now(timezone.utc) + timedelta(seconds=10 + random.randint(0, 300)), id=WEEKLY_SPEND_REPORT_JOB_ID, replace_existing=True, misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME, diff --git a/pyproject.toml b/pyproject.toml index e80ba2d5d44..76bb3a54223 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ proxy = [ "hiredis>=3.0.0,<4.0; python_version < '3.15'", "hiredis>=3.5.0,<4.0; python_version >= '3.15'", "apscheduler>=3.11.2,<4.0", + "tzdata>=2024.1", "fastapi-sso>=0.19.0,<1.0", "PyJWT>=2.13.0,<3.0", "python-multipart>=0.0.27,<1.0", diff --git a/tests/unit/proxy/proxy_server/test_lifecycle.py b/tests/unit/proxy/proxy_server/test_lifecycle.py index db5be07fb6f..7f99d12c868 100644 --- a/tests/unit/proxy/proxy_server/test_lifecycle.py +++ b/tests/unit/proxy/proxy_server/test_lifecycle.py @@ -25,6 +25,7 @@ import os import subprocess from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -1393,6 +1394,25 @@ async def test_monthly_spend_report_sent_when_the_lock_is_free_or_absent(acquire proxy_logging_obj.slack_alerting_instance.send_monthly_spend_report.assert_awaited_once_with() +@pytest.mark.asyncio +async def test_weekly_spend_report_first_run_is_minutes_ahead_when_the_scheduler_zone_differs_from_the_clock(): + """TZ=Asia/Calcutta on a host without the legacy alias puts the scheduler on IST through tzdata while + datetime.now() stays on UTC, so a naive first run landed hours in the past, past the misfire grace.""" + local_offset: Final = datetime.now().astimezone().utcoffset() or timedelta(0) + scheduler: Final = AsyncIOScheduler(timezone=timezone(local_offset + timedelta(hours=5, minutes=30))) + registered_after: Final = datetime.now(timezone.utc) + + await ProxyStartupEvent._initialize_slack_alerting_jobs( + scheduler=scheduler, + general_settings={}, + proxy_logging_obj=_make_slack_alerting_proxy_logging(acquire_lock_result=True), + prisma_client=MagicMock(), + ) + + first_run: Final = scheduler.get_job("weekly_spend_report_job").next_run_time + assert registered_after < first_run < datetime.now(timezone.utc) + timedelta(seconds=311) + + @pytest.mark.asyncio async def test_spend_report_locks_are_never_released(): """The lock is a per-window marker, not a mutex: releasing it lets the next pod re-send.""" diff --git a/tests/unit/test_package_metadata.py b/tests/unit/test_package_metadata.py index dc6e3dca64e..d7dce33bd74 100644 --- a/tests/unit/test_package_metadata.py +++ b/tests/unit/test_package_metadata.py @@ -2,6 +2,7 @@ import sys from pathlib import Path from typing import Final +import pytest from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet from packaging.utils import NormalizedName, canonicalize_name @@ -22,6 +23,11 @@ AIOHTTP_POOL_POISONING_SOURCE: Final = "aio-libs/aiohttp#12953, fixed in aiohttp ANYIO_STRANDED_LOCK_WAITER_FIX: Final = Version("4.14.0") ANYIO_STRANDED_LOCK_WAITER_SOURCE: Final = "agronholm/anyio#1145, fixed in anyio 4.14.0 released 2026-06-15" CLI_RUNTIME_DEPENDENCIES: Final = frozenset({"rich", "pyyaml", "requests"}) +PROXY_INSTALL_PLATFORMS: Final = ( + ("linux", "Linux", "posix"), + ("darwin", "Darwin", "posix"), + ("win32", "Windows", "nt"), +) PROXY_SERVER_ONLY_DEPENDENCIES: Final = frozenset( { "fastapi", @@ -50,6 +56,20 @@ def _requirement_names(requirements: list[str]) -> frozenset[NormalizedName]: return frozenset(canonicalize_name(Requirement(requirement).name) for requirement in requirements) +def _proxy_requirements_installed_on(sys_platform: str, platform_system: str, os_name: str) -> frozenset[NormalizedName]: + environment: Final = { + "sys_platform": sys_platform, + "platform_system": platform_system, + "os_name": os_name, + "extra": "proxy", + } + return frozenset( + canonicalize_name(requirement.name) + for requirement in map(Requirement, OPTIONAL_DEPENDENCIES["proxy"]) + if requirement.marker is None or requirement.marker.evaluate(environment) + ) + + def test_importing_litellm_reports_the_declared_release_version() -> None: import litellm @@ -79,6 +99,15 @@ def test_cli_extra_is_a_thin_client_install() -> None: assert cli & PROXY_SERVER_ONLY_DEPENDENCIES == frozenset() +@pytest.mark.parametrize(("sys_platform", "platform_system", "os_name"), PROXY_INSTALL_PLATFORMS) +def test_proxy_extra_installs_tzdata_beside_its_scheduler_on_every_platform( + sys_platform: str, platform_system: str, os_name: str +) -> None: + installed: Final = _proxy_requirements_installed_on(sys_platform, platform_system, os_name) + assert canonicalize_name("apscheduler") in installed + assert canonicalize_name("tzdata") in installed + + def test_declared_aiohttp_floor_excludes_pool_poisoning_releases() -> None: declared: Final = tuple( Requirement(requirement) diff --git a/uv.lock b/uv.lock index 2dec937800c..386f4339b3f 100644 --- a/uv.lock +++ b/uv.lock @@ -5138,6 +5138,7 @@ proxy = [ { name = "soundfile" }, { name = "starlette" }, { name = "tomlkit" }, + { name = "tzdata" }, { name = "uvicorn" }, { name = "uvloop", marker = "sys_platform != 'win32'" }, { name = "websockets" }, @@ -5423,6 +5424,7 @@ requires-dist = [ { name = "tomlkit", marker = "extra == 'cli'", specifier = ">=0.13.3,<1.0" }, { name = "tomlkit", marker = "extra == 'proxy'", specifier = ">=0.13.3,<1.0" }, { name = "typing-extensions", specifier = ">=4.13.0,<5.0" }, + { name = "tzdata", marker = "extra == 'proxy'", specifier = ">=2024.1" }, { name = "uvicorn", marker = "extra == 'proxy'", specifier = ">=0.33.0,<1.0" }, { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.22.1,<1.0" }, { name = "websockets", marker = "extra == 'proxy'", specifier = ">=15.0.1,<16.0" },