From dff4a31ffc09087b907449901daf1cc6051e0fce Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:35:17 +0000 Subject: [PATCH] fix(proxy): normalize empty budget_duration to None so budgets can be reset to unlimited --- litellm/models/team.py | 9 ++++- litellm/proxy/_types.py | 21 +++++++++++ .../test_internal_user_endpoints.py | 35 +++++++++++++++++++ tests/test_litellm/proxy/test_proxy_types.py | 26 ++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/litellm/models/team.py b/litellm/models/team.py index f11c21a078e..9f1ff223427 100644 --- a/litellm/models/team.py +++ b/litellm/models/team.py @@ -10,7 +10,7 @@ import json from datetime import datetime from typing import List, Literal, Optional, Union -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from litellm.models.object_permission import LiteLLM_ObjectPermissionTable from litellm.types.llms.base import LiteLLMPydanticObjectBase @@ -81,6 +81,13 @@ class TeamBase(LiteLLMPydanticObjectBase): access_group_ids: Optional[List[str]] = None default_team_member_models: Optional[List[str]] = None + @field_validator("budget_duration", mode="before") + @classmethod + def check_budget_duration(cls, v): + if isinstance(v, str) and v.strip() == "": + return None + return v + class LiteLLM_TeamTable(TeamBase): team_id: str # type: ignore diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b6bef568637..aba829dd330 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1015,6 +1015,12 @@ from litellm.types.object_permission import ( # noqa: E402 from litellm.models.team import BudgetLimitEntry as BudgetLimitEntry # noqa: E402 +def normalize_empty_budget_duration(value: Optional[str]) -> Optional[str]: + if isinstance(value, str) and value.strip() == "": + return None + return value + + class GenerateRequestBase(LiteLLMPydanticObjectBase): """ Overlapping schema between key and user generate/update requests @@ -1060,6 +1066,11 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase): return None return v + @field_validator("budget_duration", mode="before") + @classmethod + def check_budget_duration(cls, v): + return normalize_empty_budget_duration(v) + class AllowedVectorStoreIndexItem(LiteLLMPydanticObjectBase): index_name: str @@ -1634,6 +1645,11 @@ class BudgetNewRequest(LiteLLMPydanticObjectBase): description="Datetime when the budget is reset", ) + @field_validator("budget_duration", mode="before") + @classmethod + def check_budget_duration(cls, v): + return normalize_empty_budget_duration(v) + class BudgetRequest(LiteLLMPydanticObjectBase): budgets: List[str] @@ -1812,6 +1828,11 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase): budget_limits: Optional[List[BudgetLimitEntry]] = None # multiple concurrent budget windows default_team_member_models: Optional[List[str]] = None # default allowed_models seeded onto new team members + @field_validator("budget_duration", mode="before") + @classmethod + def check_budget_duration(cls, v): + return normalize_empty_budget_duration(v) + class ResetTeamBudgetRequest(LiteLLMPydanticObjectBase): """ diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index ce2d04f0d26..8bff422a84b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2032,6 +2032,41 @@ def test_generate_request_base_validator(): assert req.max_budget is None +def test_generate_request_base_normalizes_empty_budget_duration(): + """ + Regression test for https://github.com/BerriAI/litellm/issues/32474 + + Clearing budget_duration from the UI sends an empty string; downstream duration + parsing rejects "" with "Invalid duration format". The request model must normalize + empty/whitespace-only values to None so resetting a budget to unlimited succeeds. + """ + from litellm.proxy._types import GenerateRequestBase + + assert GenerateRequestBase(budget_duration="").budget_duration is None + assert GenerateRequestBase(budget_duration=" ").budget_duration is None + assert GenerateRequestBase(budget_duration=None).budget_duration is None + assert GenerateRequestBase(budget_duration="30d").budget_duration == "30d" + + +def test_update_internal_user_params_reset_budget_duration_to_unlimited(): + """ + Regression test for https://github.com/BerriAI/litellm/issues/32474 + + Sending an empty budget_duration alongside an empty max_budget (what the UI sends + when resetting a user's budget to unlimited) must not raise "Invalid duration format" + and must not fabricate a budget_reset_at. + """ + data = UpdateUserRequest(user_id="test_user", max_budget="", budget_duration="") + assert data.max_budget is None + assert data.budget_duration is None + + non_default_values = _update_internal_user_params(data_json=data.model_dump(exclude_unset=True), data=data) + + assert non_default_values["max_budget"] is None + assert non_default_values.get("budget_duration") is None + assert "budget_reset_at" not in non_default_values + + @pytest.mark.asyncio async def test_get_user_daily_activity_non_admin_cannot_view_other_users(monkeypatch): """ diff --git a/tests/test_litellm/proxy/test_proxy_types.py b/tests/test_litellm/proxy/test_proxy_types.py index bc77a9ba3c0..c887eff4d9e 100644 --- a/tests/test_litellm/proxy/test_proxy_types.py +++ b/tests/test_litellm/proxy/test_proxy_types.py @@ -107,6 +107,32 @@ def test_user_api_key_auth_hashes_authorization_header_form_of_key(): assert not from_header.api_key.lower().startswith("bearer") +@pytest.mark.parametrize( + "model_name, extra_kwargs", + [ + ("GenerateKeyRequest", {}), + ("NewUserRequest", {}), + ("UpdateUserRequest", {"user_id": "u1"}), + ("NewCustomerRequest", {"user_id": "c1"}), + ("NewTeamRequest", {}), + ("UpdateTeamRequest", {"team_id": "t1"}), + ], +) +def test_request_models_normalize_empty_budget_duration(model_name, extra_kwargs): + """Regression for https://github.com/BerriAI/litellm/issues/32474: clearing a budget + from the UI sends budget_duration="", which downstream duration parsing rejects with + "Invalid duration format". Request models must coerce empty/whitespace values to None + while leaving real durations untouched.""" + import litellm.proxy._types as proxy_types + + model = getattr(proxy_types, model_name) + + assert model(budget_duration="", **extra_kwargs).budget_duration is None + assert model(budget_duration=" ", **extra_kwargs).budget_duration is None + assert model(budget_duration=None, **extra_kwargs).budget_duration is None + assert model(budget_duration="30d", **extra_kwargs).budget_duration == "30d" + + def test_proxy_exception_str_returns_message(): """ProxyException must stringify to its message: OTEL's ``span.record_exception`` and ``str(exc)``-based logging read the string