From bbc62c34f6e19b646095479dc3db6b47f61e16b2 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 22 Jun 2026 18:26:17 -0700 Subject: [PATCH] fix(proxy): allow clearing empty-collection user fields via /user/update _update_internal_user_params dumps the request with exclude_unset and then discards any value equal to [] or {}, so an explicitly sent empty collection never reached the database. Because exclude_unset already drops fields the caller never sent, that filter only ever fired on values set on purpose, leaving a user's old models, allowed_cache_controls and model_max_budget in place after a clear attempt. Route those real user columns through the same fields_set check max_budget already uses so an explicit empty value is written. config, permissions and aliases are not user-table columns, so they keep going through the empty-collection filter and never reach Prisma. --- .../internal_user_endpoints.py | 14 +++++- .../test_internal_user_endpoints.py | 46 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 6d7f565fb85..0bed373bb2a 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1127,6 +1127,15 @@ def _process_keys_for_user_info( return returned_keys +# Real LiteLLM_UserTable columns whose empty value ([] / {}) is a deliberate +# "clear it". The caller dumps with exclude_unset, so an empty value here always +# means the field was sent on purpose and must reach the DB instead of being +# dropped by the generic empty-collection filter below. +USER_FIELDS_CLEARABLE_WHEN_SET = frozenset( + {"models", "allowed_cache_controls", "model_max_budget"} +) + + def _update_internal_user_params( data_json: dict, data: Union[UpdateUserRequest, UpdateUserRequestNoUserIDorEmail] ) -> dict: @@ -1137,6 +1146,9 @@ def _update_internal_user_params( if k == "max_budget": if "max_budget" in fields_set: non_default_values[k] = v + elif k in USER_FIELDS_CLEARABLE_WHEN_SET: + if k in fields_set: + non_default_values[k] = v elif ( v is not None and v @@ -1145,7 +1157,7 @@ def _update_internal_user_params( {}, ) and k not in LiteLLM_ManagementEndpoint_MetadataFields - ): # models default to [], spend defaults to 0, we should not reset these values + ): # drop [] / {} so list/dict fields left unset aren't reset to empty non_default_values[k] = v is_internal_user = False 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 b4602e0ad8b..7bb214705cf 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 @@ -1696,6 +1696,52 @@ def test_update_internal_user_params_keeps_original_max_budget_when_not_provided assert "user_alias" in non_default_values +@pytest.mark.parametrize( + "field, empty_value", + [ + ("models", []), + ("allowed_cache_controls", []), + ("model_max_budget", {}), + ], +) +def test_update_internal_user_params_clears_empty_collection_field(field, empty_value): + """ + Regression for LIT-3888: an explicitly provided empty list/dict for a real + user column must reach the DB update so the field can be cleared via + /user/update. Previously [] / {} were filtered out as "defaults" and the old + values persisted. + """ + data = UpdateUserRequest(user_id="test_user", **{field: empty_value}) + data_json = data.model_dump(exclude_unset=True) + + non_default_values = _update_internal_user_params(data_json=data_json, data=data) + + assert field in non_default_values + assert non_default_values[field] == empty_value + + +def test_update_internal_user_params_sets_personal_models(): + """A non-empty models list is still applied unchanged.""" + data = UpdateUserRequest(user_id="test_user", models=["gpt-4o"]) + data_json = data.model_dump(exclude_unset=True) + + non_default_values = _update_internal_user_params(data_json=data_json, data=data) + + assert non_default_values["models"] == ["gpt-4o"] + + +def test_update_internal_user_params_keeps_clearable_fields_when_not_provided(): + """When a clearable field is omitted, its column must be left untouched.""" + data = UpdateUserRequest(user_id="test_user", user_alias="test_alias") + data_json = data.model_dump(exclude_unset=True) + + non_default_values = _update_internal_user_params(data_json=data_json, data=data) + + assert "models" not in non_default_values + assert "allowed_cache_controls" not in non_default_values + assert "model_max_budget" not in non_default_values + + def test_generate_request_base_validator(): """ Test that GenerateRequestBase validator converts empty string to None for max_budget