diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index ccd15a68437..177b5d64881 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1090,9 +1090,12 @@ def _update_internal_user_params( if "budget_duration" in non_default_values: from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time - non_default_values["budget_reset_at"] = get_budget_reset_time( - budget_duration=non_default_values["budget_duration"] - ) + budget_duration = non_default_values["budget_duration"] + if isinstance(budget_duration, str) and not budget_duration.strip(): + non_default_values["budget_duration"] = None + non_default_values["budget_reset_at"] = None + else: + non_default_values["budget_reset_at"] = get_budget_reset_time(budget_duration=budget_duration) if "max_budget" not in non_default_values: if ( 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..509904786d8 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 @@ -1979,6 +1979,38 @@ def test_update_internal_user_params_reset_max_budget_with_none(): assert non_default_values["user_id"] == "test_user" +def test_update_internal_user_params_blank_budget_duration_clears_reset(): + """ + Relevant Issue: https://github.com/BerriAI/litellm/issues/32474 + + A blank budget_duration (empty or whitespace-only string) means "no reset" + (unlimited). It must be persisted as NULL with budget_reset_at cleared, + instead of an unparseable empty string that later crashes duration parsing + with "Invalid duration format". + """ + from litellm.litellm_core_utils.duration_parser import duration_in_seconds + + for blank in ("", " "): + data = UpdateUserRequest(user_id="test_user", max_budget=None, budget_duration=blank) + non_default_values = _update_internal_user_params( + data_json=data.model_dump(exclude_unset=True), data=data + ) + + assert non_default_values["budget_duration"] is None + assert non_default_values["budget_reset_at"] is None + assert non_default_values["max_budget"] is None + + # A real duration is still honored and produces a reset time + data = UpdateUserRequest(user_id="test_user", budget_duration="30d") + non_default_values = _update_internal_user_params(data_json=data.model_dump(exclude_unset=True), data=data) + assert non_default_values["budget_duration"] == "30d" + assert non_default_values["budget_reset_at"] is not None + + # Guard the regression: an empty duration must never reach duration_in_seconds + with pytest.raises(ValueError): + duration_in_seconds("") + + def test_update_internal_user_params_ignores_other_nones(): """ Test that other fields are still filtered out if None