fix(budget): validate budget_duration at the write boundary across all endpoints

Promote the team-only _validate_budget_duration into a shared timezone_utils.validate_budget_duration and call it on the key, customer, org, and budget new/update endpoints (team now delegates to it). An unparseable, zero, or overflowing budget_duration is rejected with a 400 at write time instead of being persisted and later silently reset on the wrong cadence by the daily ResetBudgetJob. This is the fail-closed boundary that complements the word-form healing in the parser: legacy word forms normalize to canonical and pass, genuine garbage is turned away before it reaches the DB.
This commit is contained in:
ryan-crabbe-berri 2026-07-22 19:58:13 -07:00
parent eb2dce8771
commit 2bdc926a56
7 changed files with 100 additions and 29 deletions

View file

@ -1,5 +1,7 @@
from datetime import datetime, time, timezone
from typing import Optional
from fastapi import HTTPException
from pydantic import BaseModel, ConfigDict
import litellm
@ -78,3 +80,34 @@ def get_budget_reset_time(budget_duration: str) -> datetime:
`BudgetResetSettings` by injection (creation/update endpoints, startup backfill).
"""
return compute_budget_reset_at(budget_duration, get_budget_reset_settings())
def validate_budget_duration(budget_duration: Optional[str]) -> None:
"""Reject budget durations that can't be parsed, are non-positive, or overflow
date math, so a bad value can't be persisted and later silently reset on the
wrong cadence (or crash the budget reset job).
Shared by every management endpoint that accepts a `budget_duration` (key, team,
customer, org, budget). `get_next_standardized_reset_time` fails open (warns and
falls back to a next-midnight reset) so a bad row never crashes the reset job;
this is the fail-closed counterpart at the write boundary that stops the bad row
from being written in the first place.
"""
if budget_duration is None:
return
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
try:
if duration_in_seconds(budget_duration) <= 0:
raise ValueError("budget_duration must be positive")
get_budget_reset_time(budget_duration=budget_duration)
except (ValueError, OverflowError):
raise HTTPException(
status_code=400,
detail={
"error": "Invalid budget_duration '{}'. Use a format like '1h', '24h', '7d', or '30d'.".format(
budget_duration
)
},
)

View file

@ -18,7 +18,10 @@ from fastapi import APIRouter, Depends, HTTPException
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.common_utils.timezone_utils import (
get_budget_reset_time,
validate_budget_duration,
)
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
from litellm.proxy.utils import jsonify_object
from litellm.repositories.budget_repository import BudgetRepository
@ -71,6 +74,8 @@ async def new_budget(
detail={"error": f"soft_budget must be a non-negative finite number. Received: {budget_obj.soft_budget}"},
)
validate_budget_duration(budget_obj.budget_duration)
# Validate model_max_budget if present
if budget_obj.model_max_budget is not None and len(budget_obj.model_max_budget) > 0:
from litellm.proxy.management_endpoints.key_management_endpoints import (
@ -152,6 +157,8 @@ async def update_budget(
detail={"error": f"soft_budget must be a non-negative finite number. Received: {budget_obj.soft_budget}"},
)
validate_budget_duration(budget_obj.budget_duration)
# Validate model_max_budget if present in update
if budget_obj.model_max_budget is not None and len(budget_obj.model_max_budget) > 0:
from litellm.proxy.management_endpoints.key_management_endpoints import (

View file

@ -20,6 +20,7 @@ from pydantic import BaseModel
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.proxy.common_utils.timezone_utils import validate_budget_duration
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity
@ -326,6 +327,7 @@ async def new_end_user(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
validate_budget_duration(data.budget_duration)
try:
## VALIDATION ##
if data.default_model is not None:

View file

@ -61,7 +61,10 @@ from litellm.proxy.common_utils.callback_utils import (
encrypt_callback_vars,
)
from litellm.proxy.common_utils.rbac_utils import check_org_admin_can_generate_keys
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.common_utils.timezone_utils import (
get_budget_reset_time,
validate_budget_duration,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks
from litellm.proxy.hooks.model_max_budget_limiter import (
@ -1585,6 +1588,8 @@ async def generate_key_fn(
detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"},
)
validate_budget_duration(data.budget_duration)
if user_custom_key_generate is not None:
if inspect.iscoroutinefunction(user_custom_key_generate):
result = await user_custom_key_generate(data) # type: ignore
@ -2266,6 +2271,8 @@ async def _validate_update_key_data(
# Reject NaN/±inf spend before it can reach the DB / spend counter.
validate_finite_spend(data.spend)
validate_budget_duration(data.budget_duration)
_is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
_check_allowed_routes_caller_permission(

View file

@ -23,6 +23,7 @@ from litellm._uuid import uuid
from litellm.proxy._types import *
from litellm.proxy.auth.auth_checks import can_user_call_model, get_user_object
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.timezone_utils import validate_budget_duration
from litellm.proxy.management_endpoints.budget_management_endpoints import (
new_budget,
update_budget,
@ -232,6 +233,8 @@ async def new_organization(
detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"},
)
validate_budget_duration(data.budget_duration)
user_object_correct_type: Optional[LiteLLM_UserTable] = None
if user_api_key_dict.user_id is not None:
@ -465,6 +468,8 @@ async def update_organization(
detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"},
)
validate_budget_duration(data.budget_duration)
if data.updated_by is None:
data.updated_by = user_api_key_dict.user_id

View file

@ -2845,28 +2845,9 @@ def _build_member_budget_patch(data: TeamMemberUpdateRequest) -> Dict[str, Any]:
def _validate_budget_duration(budget_duration: Optional[str]) -> None:
"""Reject budget durations that can't be parsed, are non-positive, or
overflow date math, so a bad value can't be persisted and later crash the
budget reset job."""
if budget_duration is None:
return
from litellm.proxy.common_utils.timezone_utils import validate_budget_duration
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
try:
if duration_in_seconds(budget_duration) <= 0:
raise ValueError("budget_duration must be positive")
get_budget_reset_time(budget_duration=budget_duration)
except (ValueError, OverflowError):
raise HTTPException(
status_code=400,
detail={
"error": "Invalid budget_duration '{}'. Use a format like '1h', '24h', '7d', or '30d'.".format(
budget_duration
)
},
)
validate_budget_duration(budget_duration)
@router.post(

View file

@ -4,12 +4,12 @@ from datetime import datetime, time, timezone
from zoneinfo import ZoneInfo
import pytest
from fastapi import HTTPException
sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path
import litellm
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.proxy.common_utils.timezone_utils import (
BudgetResetSettings,
compute_budget_reset_at,
@ -17,6 +17,7 @@ from litellm.proxy.common_utils.timezone_utils import (
get_budget_reset_time,
get_budget_reset_timezone,
parse_budget_reset_time,
validate_budget_duration,
)
@ -158,9 +159,7 @@ def test_get_budget_reset_settings_reads_globals():
def test_compute_budget_reset_at_applies_offset():
settings = BudgetResetSettings(
timezone="Asia/Jerusalem", reset_time_of_day=time(12, 0)
)
settings = BudgetResetSettings(timezone="Asia/Jerusalem", reset_time_of_day=time(12, 0))
reset_at = compute_budget_reset_at("1d", settings)
jerusalem = reset_at.astimezone(ZoneInfo("Asia/Jerusalem"))
assert jerusalem.hour == 12
@ -180,3 +179,40 @@ def test_get_budget_reset_time_honors_global_budget_reset_time():
finally:
_restore_attr(litellm, "timezone", orig_tz)
_restore_attr(litellm, "budget_reset_time", orig_rt)
class TestValidateBudgetDuration:
"""`validate_budget_duration` is the fail-closed write-boundary guard shared by
the key/team/customer/org/budget endpoints. It must reject any value the budget
reset job can't honor, so a bad duration can never be persisted and then silently
reset on the wrong cadence.
"""
def test_none_is_a_noop(self):
assert validate_budget_duration(None) is None
@pytest.mark.parametrize("duration", ["1s", "30m", "1h", "24h", "7d", "30d", "1mo"])
def test_canonical_durations_pass(self, duration):
assert validate_budget_duration(duration) is None
@pytest.mark.parametrize("duration", ["hourly", "daily", "weekly", "monthly", " MONTHLY ", "Weekly"])
def test_word_form_durations_pass(self, duration):
assert validate_budget_duration(duration) is None
@pytest.mark.parametrize("duration", ["garbage", "5x", "abc", "", "d30", "1 day"])
def test_unparseable_durations_raise_400(self, duration):
with pytest.raises(HTTPException) as exc_info:
validate_budget_duration(duration)
assert exc_info.value.status_code == 400
assert "budget_duration" in exc_info.value.detail["error"]
@pytest.mark.parametrize("duration", ["0s", "0m", "0h", "0d", "0w"])
def test_non_positive_durations_raise_400(self, duration):
with pytest.raises(HTTPException) as exc_info:
validate_budget_duration(duration)
assert exc_info.value.status_code == 400
@pytest.mark.parametrize("duration", ["1s", "30m", "1h", "24h", "7d", "30d", "1mo", "hourly", "weekly", "monthly"])
def test_accepted_iff_reset_job_can_compute_it(self, duration):
validate_budget_duration(duration)
assert duration_in_seconds(duration) > 0