diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index d52716951df..63843bd16f9 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -295,11 +295,14 @@ def _handle_hour_reset(current_time: datetime, base_midnight: datetime, value: i else: next_hour = current_hour + value - (current_hour % value) if current_hour % value != 0 else current_hour + value - # Handle overnight case + # Handle overnight case. Carry past midnight with real date arithmetic instead + # of wrapping mod 24: for durations of a day or more (e.g. "48h") the wrap + # collapses the reset onto the next midnight, so the budget window comes out + # far shorter than configured on every reset. Anchoring at today's midnight + # keeps sub-day grids wall-clock identical and gives day-plus durations the + # same "from the start of today" convention the day handler uses for Nd. if next_hour >= 24: - next_hour = next_hour % 24 - next_day: Final = base_midnight + timedelta(days=1) - return next_day.replace(hour=next_hour) + return base_midnight + timedelta(hours=next_hour) return current_time.replace(hour=next_hour, minute=0, second=0, microsecond=0) diff --git a/tests/unit/litellm_core_utils/test_duration_parser.py b/tests/unit/litellm_core_utils/test_duration_parser.py index f14015effe6..463563284e0 100644 --- a/tests/unit/litellm_core_utils/test_duration_parser.py +++ b/tests/unit/litellm_core_utils/test_duration_parser.py @@ -1,5 +1,5 @@ import unittest -from datetime import datetime, time, timezone +from datetime import datetime, time, timedelta, timezone from typing import Final from unittest.mock import patch from zoneinfo import ZoneInfo @@ -407,3 +407,71 @@ def test_a_month_starting_now_matches_duration_in_seconds(): if __name__ == "__main__": unittest.main() + + +class TestMultiDayHourDurations(unittest.TestCase): + """Hour durations of a day or more must not collapse onto the next midnight. + + The overnight branch used to wrap the next boundary hour mod 24 into the + following day, so "48h" reset at the next midnight (a <24h window) and + chained resets kept enforcing ~daily windows. Sub-day grids are unchanged: + for <24h values the carried boundary is wall-clock identical to the old wrap. + """ + + def test_48h_resets_two_days_from_midnight_not_next_midnight(self): + base = datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc) + self.assertEqual( + get_next_standardized_reset_time("48h", base, "UTC"), + datetime(2023, 5, 17, 0, 0, tzinfo=timezone.utc), + ) + + def test_72h_from_late_evening(self): + base = datetime(2023, 5, 15, 23, 30, tzinfo=timezone.utc) + self.assertEqual( + get_next_standardized_reset_time("72h", base, "UTC"), + datetime(2023, 5, 18, 0, 0, tzinfo=timezone.utc), + ) + + def test_25h_carries_past_midnight(self): + base = datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc) + self.assertEqual( + get_next_standardized_reset_time("25h", base, "UTC"), + datetime(2023, 5, 16, 1, 0, tzinfo=timezone.utc), + ) + + def test_chained_48h_resets_stay_48_hours_apart(self): + first = get_next_standardized_reset_time("48h", datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc), "UTC") + second = get_next_standardized_reset_time("48h", first, "UTC") + self.assertEqual(second - first, timedelta(hours=48)) + + def test_window_start_matches_the_reset_schedule(self): + reset_at = get_next_standardized_reset_time("48h", datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc), "UTC") + self.assertEqual(get_budget_window_start("48h", reset_at), reset_at - timedelta(hours=48)) + + def test_multi_day_hours_respect_configured_timezone(self): + base = datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc) + reset_at = get_next_standardized_reset_time("48h", base, "Asia/Kolkata") + self.assertEqual((reset_at.utcoffset(),), (timedelta(hours=5, minutes=30),)) + self.assertEqual((reset_at.hour, reset_at.minute), (0, 0)) + + def test_sub_day_hour_grids_unchanged(self): + # 5h at 20:30 crosses midnight onto 01:00, as before + self.assertEqual( + get_next_standardized_reset_time("5h", datetime(2023, 5, 15, 20, 30, tzinfo=timezone.utc), "UTC"), + datetime(2023, 5, 16, 1, 0, tzinfo=timezone.utc), + ) + # 2h mid-afternoon + self.assertEqual( + get_next_standardized_reset_time("2h", datetime(2023, 5, 15, 15, 20, 30, tzinfo=timezone.utc), "UTC"), + datetime(2023, 5, 15, 16, 0, tzinfo=timezone.utc), + ) + # 24h still lands on the next midnight + self.assertEqual( + get_next_standardized_reset_time("24h", datetime(2023, 5, 15, 10, 30, tzinfo=timezone.utc), "UTC"), + datetime(2023, 5, 16, 0, 0, tzinfo=timezone.utc), + ) + # exact boundary rolls to the next grid point, as before + self.assertEqual( + get_next_standardized_reset_time("7h", datetime(2023, 5, 15, 14, 0, tzinfo=timezone.utc), "UTC"), + datetime(2023, 5, 15, 21, 0, tzinfo=timezone.utc), + )