diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index 7ac2299b7d8..3e5b1d3ddf2 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -260,15 +260,9 @@ def _handle_hour_reset(current_time: datetime, base_midnight: datetime, value: i 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 hour aligned with the value - if current_minute == 0 and current_second == 0 and current_microsecond == 0: - next_hour = current_hour + value - (current_hour % value) if current_hour % value != 0 else current_hour + value - else: - next_hour = current_hour + value - (current_hour % value) if current_hour % value != 0 else current_hour + value + next_hour = current_hour + value - (current_hour % value) if current_hour % value != 0 else current_hour + value # Handle overnight case if next_hour >= 24: @@ -287,18 +281,11 @@ def _handle_minute_reset(current_time: datetime, base_midnight: datetime, value: 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 - ) + 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) @@ -322,17 +309,11 @@ def _handle_second_reset(current_time: datetime, base_midnight: datetime, value: 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 - ) + 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 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 cb9f273a0a7..9a0a0852efc 100644 --- a/tests/test_litellm/litellm_core_utils/test_duration_parser.py +++ b/tests/test_litellm/litellm_core_utils/test_duration_parser.py @@ -320,6 +320,59 @@ class TestResetTimeOfDay(unittest.TestCase): datetime(2023, 5, 16, 0, 0, 0, tzinfo=timezone.utc), ) + def test_boundary_alignment_is_independent_of_sub_unit_precision(self): + """Reset alignment must depend only on the unit being aligned on. + + _handle_hour_reset, _handle_minute_reset and _handle_second_reset each + used to branch on whether the sub-unit fields were exactly zero, but + both branches computed the same value. These cases pin that the + sub-unit fields genuinely do not affect the result, so the dead + branches cannot be reintroduced silently. + """ + # --- hours: minutes/seconds/microseconds must not shift the result --- + for minute, second, micro in [ + (0, 0, 0), + (0, 0, 1), + (30, 15, 500000), + (59, 59, 999999), + ]: + t = datetime( + 2023, 5, 15, 14, minute, second, micro, tzinfo=timezone.utc + ) + self.assertEqual( + get_next_standardized_reset_time("2h", t, "UTC"), + datetime(2023, 5, 15, 16, 0, 0, tzinfo=timezone.utc), + f"2h reset shifted for minute={minute} " + f"second={second} micro={micro}", + ) + + # --- minutes: seconds/microseconds must not shift the result --- + for second, micro in [ + (0, 0), + (0, 1), + (30, 500000), + (59, 999999), + ]: + t = datetime( + 2023, 5, 15, 14, 30, second, micro, tzinfo=timezone.utc + ) + self.assertEqual( + get_next_standardized_reset_time("30m", t, "UTC"), + datetime(2023, 5, 15, 15, 0, 0, tzinfo=timezone.utc), + f"30m reset shifted for second={second} micro={micro}", + ) + + # --- seconds: microseconds must not shift the result --- + for micro in [0, 1, 500000, 999999]: + t = datetime( + 2023, 5, 15, 14, 30, 45, micro, tzinfo=timezone.utc + ) + self.assertEqual( + get_next_standardized_reset_time("15s", t, "UTC"), + datetime(2023, 5, 15, 14, 31, 0, tzinfo=timezone.utc), + f"15s reset shifted for micro={micro}", + ) + class TestWordFormBudgetDurations(unittest.TestCase): """The Admin UI historically persisted word-form budget durations