From 2a266d764e211564a6d7df842372e0673f40b3bf Mon Sep 17 00:00:00 2001 From: Aftab Date: Thu, 3 Sep 2026 13:36:17 +0530 Subject: [PATCH 1/2] fix(duration_parser): raise ValueError for unparseable budget_duration Replaces the silent fallback-to-next-midnight behavior in get_next_standardized_reset_time when _parse_duration returns (None, None) with a clear ValueError. Empty strings and unrecognized formats now raise instead of silently scheduling a daily reset. The validate_budget_duration guard in management endpoints already catches this ValueError and converts it to HTTP 400, so no creation-path breakage occurs. The budget reset job is hardened in a companion change so that stale rows with budget_duration='' are skipped rather than wiped. Fixes: BerriAI/litellm#39368 --- litellm/litellm_core_utils/duration_parser.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index 7ac2299b7d8..a3133596caf 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -12,8 +12,6 @@ from datetime import datetime, time, timedelta, timezone, tzinfo from typing import Final from zoneinfo import ZoneInfo -from litellm._logging import verbose_logger - _BUDGET_DURATION_WORD_ALIASES: Final[dict[str, str]] = { "hourly": "1h", "daily": "24h", @@ -131,6 +129,9 @@ def get_next_standardized_reset_time( Returns: - Next reset time at a standardized interval in the specified timezone + + Raises: + - ValueError: If the duration string is empty or cannot be parsed. """ # Set up timezone and normalize current time current_time, _ = _setup_timezone(current_time, timezone_str) @@ -138,12 +139,10 @@ def get_next_standardized_reset_time( # Parse duration value, unit = _parse_duration(_normalize_duration(duration)) if value is None: - verbose_logger.warning( - "Unrecognized budget_duration %r; falling back to a next-midnight reset. " - "Use the format (e.g. '1h', '7d', '30d', '1mo').", - duration, + raise ValueError( + f"Invalid budget_duration {duration!r}. " + "Use the format (e.g. '1h', '7d', '30d', '1mo')." ) - return current_time.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) # Midnight of the current day in the specified timezone base_midnight: Final = current_time.replace(hour=0, minute=0, second=0, microsecond=0) From eb2782c7c90cc18fb662c0d0d0fce72f9448fe5c Mon Sep 17 00:00:00 2001 From: Aftab Date: Thu, 3 Sep 2026 13:38:10 +0530 Subject: [PATCH 2/2] fix(reset_budget_job): skip rows with empty/falsy budget_duration Harden the three places in the budget reset job that call compute_budget_reset_at against stale rows carrying budget_duration="": - _BudgetCascade construction: filter guard changes from "is not None" to a truthy check so empty strings are excluded. - _reset_budget_common: same truthy guard prevents an empty-string budget_duration from triggering compute_budget_reset_at. - _reset_expired_window: explicit early-return with a warning when budget_duration is falsy. Companion to the duration_parser change that replaces the silent fallback with ValueError. Fixes: BerriAI/litellm#39368 --- litellm/proxy/common_utils/reset_budget_job.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 1682cf12f4e..0c0a3a60457 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -657,7 +657,7 @@ class ResetBudgetJob: compute_budget_reset_at(budget_duration=b.budget_duration, settings=self.reset_settings), ) for b in budgets_to_reset - if b.budget_id is not None and b.budget_duration is not None + if b.budget_id is not None and b.budget_duration ), endusers=await self._collect_endusers_to_reset(budget_ids), counter_resets=( @@ -1266,6 +1266,12 @@ class ResetBudgetJob: except Exception as redis_err: verbose_proxy_logger.warning("Failed to reset Redis counter %s: %s", counter_key, redis_err) budget_duration: Final = window["budget_duration"] + if not budget_duration: + verbose_proxy_logger.warning( + "Skipping window reset for entity %s: budget_duration is empty or missing", + entity_id, + ) + return False next_reset_at: Final = compute_budget_reset_at(budget_duration=budget_duration, settings=reset_settings) window["reset_at"] = next_reset_at.isoformat() await ResetBudgetJob._roll_window_spend_row( @@ -1444,7 +1450,7 @@ class ResetBudgetJob: """ try: item.spend = _carried_spend(item.spend, _rollover_cap(item.max_budget)) if _rollover_enabled() else 0.0 - if hasattr(item, "budget_duration") and item.budget_duration is not None: + if hasattr(item, "budget_duration") and item.budget_duration: item.budget_reset_at = compute_budget_reset_at( budget_duration=item.budget_duration, settings=reset_settings )