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.
This commit is contained in:
ryan-crabbe-berri 2026-06-22 18:26:17 -07:00
parent 3fc73c1aff
commit bbc62c34f6
2 changed files with 59 additions and 1 deletions

View file

@ -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

View file

@ -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