fix(budgets): keep non-divisor minute/second resets on the midnight-aligned grid

This commit is contained in:
Mrun25 2026-10-17 00:50:48 +05:30 • committed by Instinct Agent
parent f02449ad38
commit e756bd5a72
2 changed files with 76 additions and 53 deletions

View file

@ -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(

View file

@ -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."""