diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index d52716951df..ff0f803ff9a 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -305,76 +305,55 @@ def _handle_hour_reset(current_time: datetime, base_midnight: datetime, value: i def _handle_minute_reset(current_time: datetime, base_midnight: datetime, value: int) -> datetime: - """Handle minute-based reset times.""" + """Handle minute-based reset times. + + Boundaries are multiples of `value` minutes since midnight, matching the + hour handler's continuous anchoring. Computing the next boundary from the + minute-of-hour alone only agrees with that grid when `value` divides 60; + for other values (e.g. 45m) it lands off-grid and produces uneven reset + intervals. + """ # Handle zero value - immediate expiration if value == 0: return current_time - current_hour: Final = current_time.hour - current_minute: Final = current_time.minute - current_second: Final = current_time.second - current_microsecond: Final = current_time.microsecond - - # Calculate next minute aligned with the value - if current_second == 0 and current_microsecond == 0: - next_minute = ( - current_minute + value - (current_minute % value) if current_minute % value != 0 else current_minute + value - ) - else: - next_minute = ( - current_minute + value - (current_minute % value) if current_minute % value != 0 else current_minute + value - ) - - # Handle hour rollover - next_hour = current_hour + (next_minute // 60) - next_minute = next_minute % 60 + minutes_since_midnight: Final = current_time.hour * 60 + current_time.minute + next_minutes: Final = (minutes_since_midnight // value + 1) * value # Handle overnight case - if next_hour >= 24: - next_hour = next_hour % 24 + if next_minutes >= 24 * 60: next_day: Final = base_midnight + timedelta(days=1) - return next_day.replace(hour=next_hour, minute=next_minute, second=0, microsecond=0) + wrapped: Final = next_minutes % (24 * 60) + return next_day.replace(hour=wrapped // 60, minute=wrapped % 60) - return current_time.replace(hour=next_hour, minute=next_minute, second=0, microsecond=0) + return base_midnight.replace(hour=next_minutes // 60, minute=next_minutes % 60) def _handle_second_reset(current_time: datetime, base_midnight: datetime, value: int) -> datetime: - """Handle second-based reset times.""" + """Handle second-based reset times. + + Boundaries are multiples of `value` seconds since midnight, matching the + hour handler's continuous anchoring. Computing the next boundary from the + second-of-minute alone only agrees with that grid when `value` divides 60. + """ # Handle zero value - immediate expiration if value == 0: return current_time - current_hour: Final = current_time.hour - current_minute: Final = current_time.minute - current_second: Final = current_time.second - current_microsecond: Final = current_time.microsecond - - # Calculate next second aligned with the value - if current_microsecond == 0: - next_second = ( - current_second + value - (current_second % value) if current_second % value != 0 else current_second + value - ) - else: - next_second = ( - current_second + value - (current_second % value) if current_second % value != 0 else current_second + value - ) - - # Handle minute rollover - additional_minutes: Final = next_second // 60 - next_second = next_second % 60 - next_minute = current_minute + additional_minutes - - # Handle hour rollover - next_hour = current_hour + (next_minute // 60) - next_minute = next_minute % 60 + seconds_since_midnight: Final = current_time.hour * 3600 + current_time.minute * 60 + current_time.second + next_seconds: Final = (seconds_since_midnight // value + 1) * value # Handle overnight case - if next_hour >= 24: - next_hour = next_hour % 24 + if next_seconds >= 24 * 3600: next_day: Final = base_midnight + timedelta(days=1) - return next_day.replace(hour=next_hour, minute=next_minute, second=next_second, microsecond=0) + wrapped: Final = next_seconds % (24 * 3600) + return next_day.replace(hour=wrapped // 3600, minute=(wrapped % 3600) // 60, second=wrapped % 60) - return current_time.replace(hour=next_hour, minute=next_minute, second=next_second, microsecond=0) + return base_midnight.replace( + hour=next_seconds // 3600, + minute=(next_seconds % 3600) // 60, + second=next_seconds % 60, + ) def _handle_month_reset( diff --git a/tests/unit/litellm_core_utils/test_duration_parser.py b/tests/unit/litellm_core_utils/test_duration_parser.py index f14015effe6..b13f4a51310 100644 --- a/tests/unit/litellm_core_utils/test_duration_parser.py +++ b/tests/unit/litellm_core_utils/test_duration_parser.py @@ -1,12 +1,12 @@ 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 import pytest -import litellm.litellm_core_utils.duration_parser as duration_parser +from litellm.litellm_core_utils import duration_parser from litellm.litellm_core_utils.duration_parser import ( duration_in_seconds, duration_in_seconds_from, @@ -194,6 +194,50 @@ class TestStandardizedResetTime(unittest.TestCase): result = get_next_standardized_reset_time("1d", pre_spring, "US/Eastern") self.assertEqual(result, expected) + def test_minute_reset_non_divisor_of_hour_stays_on_grid(self): + """45m resets must land on 45-minute boundaries anchored at midnight. + + Computing the next boundary from the minute-of-hour alone only agrees + with that grid when the value divides 60; for 45m it used to produce + uneven reset intervals (45, 15, 45, 15 minutes). + """ + # Multiples of 45 minutes since midnight: ..., 10:30, 11:15, 12:00, ... + base_time = datetime(2026, 1, 5, 10, 31, 0, tzinfo=timezone.utc) + expected = datetime(2026, 1, 5, 11, 15, 0, tzinfo=timezone.utc) + result = get_next_standardized_reset_time("45m", base_time, "UTC") + self.assertEqual(result, expected) + + # Consecutive resets are a steady 45 minutes apart + first = get_next_standardized_reset_time("45m", datetime(2026, 1, 5, 9, 46, 0, tzinfo=timezone.utc), "UTC") + second = get_next_standardized_reset_time("45m", first + timedelta(seconds=1), "UTC") + self.assertEqual(second - first, timedelta(minutes=45)) + + def test_minute_reset_non_divisor_overnight(self): + """45m reset just before midnight lands at midnight, not 00:30.""" + base_time = datetime(2026, 1, 5, 23, 50, 0, tzinfo=timezone.utc) + expected = datetime(2026, 1, 6, 0, 0, 0, tzinfo=timezone.utc) + result = get_next_standardized_reset_time("45m", base_time, "UTC") + self.assertEqual(result, expected) + + def test_second_reset_non_divisor_of_minute_stays_on_grid(self): + """90s resets must land on 90-second boundaries anchored at midnight.""" + base_time = datetime(2026, 1, 5, 0, 0, 50, tzinfo=timezone.utc) + expected = datetime(2026, 1, 5, 0, 1, 30, tzinfo=timezone.utc) + result = get_next_standardized_reset_time("90s", base_time, "UTC") + self.assertEqual(result, expected) + + def test_second_reset_non_divisor_overnight(self): + """90s reset just before midnight lands at midnight.""" + base_time = datetime(2026, 1, 5, 23, 59, 0, tzinfo=timezone.utc) + expected = datetime(2026, 1, 6, 0, 0, 0, tzinfo=timezone.utc) + result = get_next_standardized_reset_time("90s", base_time, "UTC") + self.assertEqual(result, expected) + + def test_zero_minute_and_second_durations_expire_immediately(self): + """0m and 0s resolve to "now", matching the hour and day handlers.""" + base_time = datetime(2026, 1, 5, 10, 31, 42, tzinfo=timezone.utc) + self.assertEqual(get_next_standardized_reset_time("0m", base_time, "UTC"), base_time) + self.assertEqual(get_next_standardized_reset_time("0s", base_time, "UTC"), base_time) class TestResetTimeOfDay(unittest.TestCase): """A configurable reset_time_of_day shifts day/week/month resets off midnight."""