mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
fix(budgets): keep non-divisor minute/second resets on the midnight-aligned grid
This commit is contained in:
parent
f02449ad38
commit
e756bd5a72
2 changed files with 76 additions and 53 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue