diff --git a/litellm/models/team.py b/litellm/models/team.py index da526515e6e..2e7709a0c94 100644 --- a/litellm/models/team.py +++ b/litellm/models/team.py @@ -10,7 +10,7 @@ import json from datetime import datetime from typing import Final, Literal, Optional -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: list[str] | None = None default_team_member_models: list[str] | None = 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 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ed49ca2caa9..70325ba91e6 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1106,6 +1106,12 @@ from litellm.types.object_permission import ( # 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 @@ -1151,6 +1157,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 @@ -1787,6 +1798,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] @@ -1980,6 +1996,11 @@ class PatchTeamRequest(UpdateTeamRequest): team_id: str | None = None + @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 8bce967b316..864ff142f00 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 @@ -2079,6 +2079,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 634b90e445a..75e92c0874a 100644 --- a/tests/test_litellm/proxy/test_proxy_types.py +++ b/tests/test_litellm/proxy/test_proxy_types.py @@ -102,6 +102,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