From 208349e7ef30b29e44e5434791f7cfab101aec1d Mon Sep 17 00:00:00 2001 From: LeeJuOh Date: Sat, 21 Feb 2026 18:06:36 +0900 Subject: [PATCH 1/2] fix(budget): fix timezone config lookup and replace hardcoded timezone map with ZoneInfo --- .../docs/proxy/budget_reset_and_tz.md | 2 + docs/my-website/sidebars.js | 1 + litellm/litellm_core_utils/duration_parser.py | 28 +++---- litellm/proxy/common_utils/timezone_utils.py | 16 ++-- .../test_duration_parser.py | 60 +++++++++++++- .../proxy/common_utils/test_timezone_utils.py | 83 +++++++++++++++++-- 6 files changed, 153 insertions(+), 37 deletions(-) diff --git a/docs/my-website/docs/proxy/budget_reset_and_tz.md b/docs/my-website/docs/proxy/budget_reset_and_tz.md index 340e33afe18..0fedff8be18 100644 --- a/docs/my-website/docs/proxy/budget_reset_and_tz.md +++ b/docs/my-website/docs/proxy/budget_reset_and_tz.md @@ -22,6 +22,8 @@ litellm_settings: This ensures that all budget resets happen at midnight in your specified timezone rather than in UTC. If no timezone is specified, UTC will be used by default. +Any valid [IANA timezone string](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) is supported (powered by Python's `zoneinfo` module). DST transitions are handled automatically. + Common timezone values: - `UTC` - Coordinated Universal Time diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 1d43484fd1b..3133a3b7de3 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -417,6 +417,7 @@ const sidebars = { "proxy/dynamic_rate_limit", "proxy/rate_limit_tiers", "proxy/temporary_budget_increase", + "proxy/budget_reset_and_tz", ], }, "proxy/caching", diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index 9a317cfcf0d..70c28c4e067 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -8,8 +8,9 @@ import re import time -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone, tzinfo from typing import Optional, Tuple +from zoneinfo import ZoneInfo def _extract_from_regex(duration: str) -> Tuple[int, str]: @@ -116,7 +117,7 @@ def get_next_standardized_reset_time( - Next reset time at a standardized interval in the specified timezone """ # Set up timezone and normalize current time - current_time, timezone = _setup_timezone(current_time, timezone_str) + current_time, tz = _setup_timezone(current_time, timezone_str) # Parse duration value, unit = _parse_duration(duration) @@ -131,7 +132,7 @@ def get_next_standardized_reset_time( # Handle different time units if unit == "d": - return _handle_day_reset(current_time, base_midnight, value, timezone) + return _handle_day_reset(current_time, base_midnight, value, tz) elif unit == "h": return _handle_hour_reset(current_time, base_midnight, value) elif unit == "m": @@ -147,22 +148,13 @@ def get_next_standardized_reset_time( def _setup_timezone( current_time: datetime, timezone_str: str = "UTC" -) -> Tuple[datetime, timezone]: +) -> Tuple[datetime, tzinfo]: """Set up timezone and normalize current time to that timezone.""" try: if timezone_str is None: - tz = timezone.utc + tz: tzinfo = timezone.utc else: - # Map common timezone strings to their UTC offsets - timezone_map = { - "US/Eastern": timezone(timedelta(hours=-4)), # EDT - "US/Pacific": timezone(timedelta(hours=-7)), # PDT - "Asia/Kolkata": timezone(timedelta(hours=5, minutes=30)), # IST - "Asia/Bangkok": timezone(timedelta(hours=7)), # ICT (Indochina Time) - "Europe/London": timezone(timedelta(hours=1)), # BST - "UTC": timezone.utc, - } - tz = timezone_map.get(timezone_str, timezone.utc) + tz = ZoneInfo(timezone_str) except Exception: # If timezone is invalid, fall back to UTC tz = timezone.utc @@ -190,7 +182,7 @@ def _parse_duration(duration: str) -> Tuple[Optional[int], Optional[str]]: def _handle_day_reset( - current_time: datetime, base_midnight: datetime, value: int, timezone: timezone + current_time: datetime, base_midnight: datetime, value: int, tz: tzinfo ) -> datetime: """Handle day-based reset times.""" # Handle zero value - immediate expiration @@ -215,7 +207,7 @@ def _handle_day_reset( minute=0, second=0, microsecond=0, - tzinfo=timezone, + tzinfo=tz, ) else: next_reset = datetime( @@ -226,7 +218,7 @@ def _handle_day_reset( minute=0, second=0, microsecond=0, - tzinfo=timezone, + tzinfo=tz, ) return next_reset else: # Custom day value - next interval is value days from current diff --git a/litellm/proxy/common_utils/timezone_utils.py b/litellm/proxy/common_utils/timezone_utils.py index a289e5328b2..b7ab494d712 100644 --- a/litellm/proxy/common_utils/timezone_utils.py +++ b/litellm/proxy/common_utils/timezone_utils.py @@ -1,22 +1,18 @@ from datetime import datetime, timezone +import litellm from litellm.litellm_core_utils.duration_parser import get_next_standardized_reset_time def get_budget_reset_timezone(): """ - Get the budget reset timezone from general_settings. + Get the budget reset timezone from litellm_settings. Falls back to UTC if not specified. - """ - # Import at function level to avoid circular imports - from litellm.proxy.proxy_server import general_settings - - if general_settings: - litellm_settings = general_settings.get("litellm_settings", {}) - if litellm_settings and "timezone" in litellm_settings: - return litellm_settings["timezone"] - return "UTC" + litellm_settings values are set as attributes on the litellm module + by proxy_server.py at startup (via setattr(litellm, key, value)). + """ + return getattr(litellm, "timezone", None) or "UTC" def get_budget_reset_time(budget_duration: str): diff --git a/tests/test_litellm/litellm_core_utils/test_duration_parser.py b/tests/test_litellm/litellm_core_utils/test_duration_parser.py index cebad07c07a..52316d4d97d 100644 --- a/tests/test_litellm/litellm_core_utils/test_duration_parser.py +++ b/tests/test_litellm/litellm_core_utils/test_duration_parser.py @@ -91,7 +91,9 @@ def test_timezone_handling(self): # Test Bangkok timezone (UTC+7): 5:30 AM next day, so next reset is midnight the day after bangkok = ZoneInfo("Asia/Bangkok") bangkok_expected = datetime(2023, 5, 17, 0, 0, 0, tzinfo=bangkok) - bangkok_result = get_next_standardized_reset_time("1d", base_time, "Asia/Bangkok") + bangkok_result = get_next_standardized_reset_time( + "1d", base_time, "Asia/Bangkok" + ) self.assertEqual(bangkok_result, bangkok_expected) def test_edge_cases(self): @@ -125,6 +127,62 @@ def test_edge_cases(self): ) self.assertEqual(invalid_tz_result, invalid_tz_expected) + def test_iana_timezones_previously_unsupported(self): + """Test IANA timezones that were previously unsupported by the hardcoded map.""" + # Base time: 2023-05-15 15:00:00 UTC + base_time = datetime(2023, 5, 15, 15, 0, 0, tzinfo=timezone.utc) + + # Asia/Tokyo (UTC+9): 15:00 UTC = 00:00 JST May 16, exactly on midnight boundary → next day + tokyo = ZoneInfo("Asia/Tokyo") + tokyo_expected = datetime(2023, 5, 17, 0, 0, 0, tzinfo=tokyo) + tokyo_result = get_next_standardized_reset_time( + "1d", base_time, "Asia/Tokyo" + ) + self.assertEqual(tokyo_result, tokyo_expected) + + # Australia/Sydney (UTC+10): 2023-05-16 01:00 AEST + sydney = ZoneInfo("Australia/Sydney") + # At 15:00 UTC it's 01:00 AEST May 16 → next midnight is May 17 00:00 AEST + sydney_expected = datetime(2023, 5, 17, 0, 0, 0, tzinfo=sydney) + sydney_result = get_next_standardized_reset_time( + "1d", base_time, "Australia/Sydney" + ) + self.assertEqual(sydney_result, sydney_expected) + + # America/Chicago (UTC-5): at 15:00 UTC it's 10:00 CDT → next midnight is May 16 00:00 CDT + chicago = ZoneInfo("America/Chicago") + chicago_expected = datetime(2023, 5, 16, 0, 0, 0, tzinfo=chicago) + chicago_result = get_next_standardized_reset_time( + "1d", base_time, "America/Chicago" + ) + self.assertEqual(chicago_result, chicago_expected) + + def test_dst_fall_back(self): + """Test DST fall-back transition (clocks go back 1 hour).""" + # US/Eastern DST ends first Sunday of November 2023 (Nov 5) + # At 2023-11-05 05:30 UTC = 01:30 EDT (before fall-back) + # After fall-back at 06:00 UTC = 01:00 EST + pre_fallback = datetime(2023, 11, 5, 5, 30, 0, tzinfo=timezone.utc) + eastern = ZoneInfo("US/Eastern") + + # Daily reset: next midnight should be Nov 6 00:00 EST + expected = datetime(2023, 11, 6, 0, 0, 0, tzinfo=eastern) + result = get_next_standardized_reset_time("1d", pre_fallback, "US/Eastern") + self.assertEqual(result, expected) + + def test_dst_spring_forward(self): + """Test DST spring-forward transition (clocks go forward 1 hour).""" + # US/Eastern DST starts second Sunday of March 2023 (Mar 12) + # At 2023-03-12 06:30 UTC = 01:30 EST (before spring-forward) + # After spring-forward at 07:00 UTC = 03:00 EDT + pre_spring = datetime(2023, 3, 12, 6, 30, 0, tzinfo=timezone.utc) + eastern = ZoneInfo("US/Eastern") + + # Daily reset: next midnight should be Mar 13 00:00 EDT + expected = datetime(2023, 3, 13, 0, 0, 0, tzinfo=eastern) + result = get_next_standardized_reset_time("1d", pre_spring, "US/Eastern") + self.assertEqual(result, expected) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_litellm/proxy/common_utils/test_timezone_utils.py b/tests/test_litellm/proxy/common_utils/test_timezone_utils.py index fed96418f91..80b813226df 100644 --- a/tests/test_litellm/proxy/common_utils/test_timezone_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_timezone_utils.py @@ -1,18 +1,17 @@ -import asyncio -import json import os import sys -import time -from datetime import datetime, timedelta, timezone - -import pytest -from fastapi.testclient import TestClient +from datetime import datetime, timezone +from zoneinfo import ZoneInfo sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path -from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time +import litellm +from litellm.proxy.common_utils.timezone_utils import ( + get_budget_reset_time, + get_budget_reset_timezone, +) def test_get_budget_reset_time(): @@ -33,3 +32,71 @@ def test_get_budget_reset_time(): # Verify budget_reset_at is set to first of next month assert get_budget_reset_time(budget_duration="1mo") == expected_reset_at + + +def test_get_budget_reset_timezone_reads_litellm_attr(): + """ + Test that get_budget_reset_timezone reads from litellm.timezone attribute. + """ + original = getattr(litellm, "timezone", None) + try: + litellm.timezone = "Asia/Tokyo" + assert get_budget_reset_timezone() == "Asia/Tokyo" + finally: + if original is None: + if hasattr(litellm, "timezone"): + delattr(litellm, "timezone") + else: + litellm.timezone = original + + +def test_get_budget_reset_timezone_fallback_utc(): + """ + Test that get_budget_reset_timezone falls back to UTC when litellm.timezone is not set. + """ + original = getattr(litellm, "timezone", None) + try: + if hasattr(litellm, "timezone"): + delattr(litellm, "timezone") + assert get_budget_reset_timezone() == "UTC" + finally: + if original is not None: + litellm.timezone = original + + +def test_get_budget_reset_timezone_fallback_on_none(): + """ + Test that get_budget_reset_timezone falls back to UTC when litellm.timezone is None. + """ + original = getattr(litellm, "timezone", None) + try: + litellm.timezone = None + assert get_budget_reset_timezone() == "UTC" + finally: + if original is None: + if hasattr(litellm, "timezone"): + delattr(litellm, "timezone") + else: + litellm.timezone = original + + +def test_get_budget_reset_time_respects_timezone(): + """ + Test that get_budget_reset_time uses the configured timezone for reset calculation. + A daily reset should align to midnight in the configured timezone. + """ + original = getattr(litellm, "timezone", None) + try: + litellm.timezone = "Asia/Tokyo" + reset_at = get_budget_reset_time(budget_duration="1d") + # The reset time should be midnight in Asia/Tokyo + tokyo_reset = reset_at.astimezone(ZoneInfo("Asia/Tokyo")) + assert tokyo_reset.hour == 0 + assert tokyo_reset.minute == 0 + assert tokyo_reset.second == 0 + finally: + if original is None: + if hasattr(litellm, "timezone"): + delattr(litellm, "timezone") + else: + litellm.timezone = original From f613762ee8397ba271b37e2b415514e2604d82b2 Mon Sep 17 00:00:00 2001 From: LeeJuOh Date: Sat, 21 Feb 2026 18:30:51 +0900 Subject: [PATCH 2/2] fix(budget): update stale docstring on get_budget_reset_time --- litellm/proxy/common_utils/timezone_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/common_utils/timezone_utils.py b/litellm/proxy/common_utils/timezone_utils.py index b7ab494d712..700a9197f6f 100644 --- a/litellm/proxy/common_utils/timezone_utils.py +++ b/litellm/proxy/common_utils/timezone_utils.py @@ -17,7 +17,7 @@ def get_budget_reset_timezone(): def get_budget_reset_time(budget_duration: str): """ - Get the budget reset time from general_settings. + Get the budget reset time based on the configured timezone. Falls back to UTC if not specified. """