From d4915766d6b446497010183e5d39e6f02251cb4f Mon Sep 17 00:00:00 2001 From: Nitish Agarwal <1592163+nitishagar@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:15:41 +0530 Subject: [PATCH] fix(proxy): require premium only when enabling premium metadata fields (#30285) (#30506) Co-authored-by: Sameer Kankute --- .../management_endpoints/common_utils.py | 14 +++--- .../key_management_endpoints.py | 3 +- .../management_endpoints/test_common_utils.py | 26 +++++++++++ .../test_key_management_endpoints.py | 45 +++++++++++++++++++ 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index 458cba686e6..f28bcc2bcd4 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -397,7 +397,7 @@ def _set_object_metadata_field( field_name: Name of the metadata field to set value: Value to set for the field """ - if field_name in LiteLLM_ManagementEndpoint_MetadataFields_Premium: + if field_name in LiteLLM_ManagementEndpoint_MetadataFields_Premium and value: _premium_user_check(field_name) object_data.metadata = object_data.metadata or {} @@ -563,13 +563,11 @@ def _update_metadata_field(updated_kv: dict, field_name: str) -> None: field_name: Name of the metadata field being updated """ if field_name in LiteLLM_ManagementEndpoint_MetadataFields_Premium: - value = updated_kv.get(field_name) - # Skip the premium check for empty collections ([] or {}). - # The UI sends these as defaults even when the user hasn't configured - # any enterprise features (see issue #20304). However, we still - # proceed with the update so that users can intentionally clear a - # previously-set field by sending an empty list/dict. - if value is not None and value != [] and value != {}: + # The UI sends falsy defaults (False, [], {}) even when the user has not + # enabled any enterprise feature (see #20304, #30285); require a license + # only for a truthy value. The falsy value is still persisted below so a + # previously-set field can be cleared. + if updated_kv.get(field_name): _premium_user_check() if field_name in updated_kv and updated_kv[field_name] is not None: diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 6e567a428e4..ac5f14db910 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -1793,7 +1793,8 @@ def prepare_metadata_fields( if k in LiteLLM_ManagementEndpoint_MetadataFields_Premium: from litellm.proxy.utils import _premium_user_check - _premium_user_check(k) + if v: + _premium_user_check(k) casted_metadata[k] = v except Exception as e: diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_utils.py b/tests/test_litellm/proxy/management_endpoints/test_common_utils.py index d53ea6fa34d..7a8d04507dc 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_utils.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_utils.py @@ -157,6 +157,32 @@ class TestUpdateMetadataFieldsEmptyCollections: assert "guardrails" not in updated_kv assert updated_kv["metadata"]["guardrails"] == ["my-guardrail"] + @patch("litellm.proxy.management_endpoints.common_utils._premium_user_check") + def test_false_boolean_does_not_trigger_premium_check(self, mock_premium_check): + """ + Regression #30285: /team/update sends disable_global_guardrails=False + (the UI's unchanged default). A falsy boolean must not trigger the + premium check, so non-premium users are not wrongly 403'd. + """ + updated_kv = {"team_id": "test-team", "disable_global_guardrails": False} + _update_metadata_fields(updated_kv=updated_kv) + mock_premium_check.assert_not_called() + + @patch("litellm.proxy.management_endpoints.common_utils._premium_user_check") + def test_false_boolean_still_updates_metadata(self, mock_premium_check): + """A falsy boolean must still be moved into metadata so it persists.""" + updated_kv = {"team_id": "test-team", "disable_global_guardrails": False} + _update_metadata_fields(updated_kv=updated_kv) + assert "disable_global_guardrails" not in updated_kv + assert updated_kv["metadata"]["disable_global_guardrails"] is False + + @patch("litellm.proxy.management_endpoints.common_utils._premium_user_check") + def test_true_boolean_triggers_premium_check(self, mock_premium_check): + """Control: enabling the premium feature (True) still requires a license.""" + updated_kv = {"team_id": "test-team", "disable_global_guardrails": True} + _update_metadata_fields(updated_kv=updated_kv) + mock_premium_check.assert_called() + @patch("litellm.proxy.management_endpoints.common_utils._premium_user_check") def test_ui_typical_payload_does_not_trigger_premium_check( self, mock_premium_check diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 9c5206722aa..cc8b4c7f5cc 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -1547,6 +1547,51 @@ async def test_prepare_key_update_data_budget_limits_serializes_windows(): assert windows[0]["reset_at"] is not None +@pytest.mark.asyncio +async def test_prepare_key_update_data_disable_global_guardrails_false_no_premium( + monkeypatch, +): + """ + Regression #30285: editing a key via the UI sends disable_global_guardrails=False + (unchanged default). A non-premium user must NOT get a 403, and False must persist. + """ + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) + data = UpdateKeyRequest(key="sk-1", disable_global_guardrails=False) + existing_key = LiteLLM_VerificationToken(token="hashed") + + result = await prepare_key_update_data(data=data, existing_key_row=existing_key) + + assert result["metadata"]["disable_global_guardrails"] is False + + +@pytest.mark.asyncio +async def test_prepare_key_update_data_disable_global_guardrails_true_requires_premium( + monkeypatch, +): + """Control: enabling the premium feature (True) without a license still 403s.""" + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) + data = UpdateKeyRequest(key="sk-1", disable_global_guardrails=True) + existing_key = LiteLLM_VerificationToken(token="hashed") + + with pytest.raises(HTTPException) as exc_info: + await prepare_key_update_data(data=data, existing_key_row=existing_key) + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_prepare_key_update_data_disable_global_guardrails_true_premium_persists( + monkeypatch, +): + """A premium user enabling the feature (True) succeeds and the value persists.""" + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + data = UpdateKeyRequest(key="sk-1", disable_global_guardrails=True) + existing_key = LiteLLM_VerificationToken(token="hashed") + + result = await prepare_key_update_data(data=data, existing_key_row=existing_key) + + assert result["metadata"]["disable_global_guardrails"] is True + + @pytest.mark.asyncio async def test_validate_team_id_used_in_service_account_request_requires_team_id(): """