diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 4486cd7de59..a59c9c19aa2 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -17,6 +17,7 @@ import traceback import warnings from collections.abc import Mapping from datetime import datetime, timedelta, timezone +from types import MappingProxyType from typing import ( TYPE_CHECKING, Any, @@ -702,6 +703,58 @@ _EXTRA_SECRET_GENERAL_SETTINGS_FIELDS = frozenset( ) +# general_settings fields the Admin UI can read and write (GET /config/list, +# POST /config/field/update). Kept in one place so the DB->runtime propagation +# in ProxyConfig._update_general_settings stays in sync with what the UI offers +_GENERAL_SETTINGS_UI_EDITABLE_FIELDS: Mapping[str, str] = MappingProxyType( + { + "max_parallel_requests": "Integer", + "global_max_parallel_requests": "Integer", + "max_request_size_mb": "Integer", + "max_response_size_mb": "Integer", + "proxy_config_reload_interval_seconds": "Integer", + "pass_through_endpoints": "PydanticModel", + "store_model_in_db": "Boolean", + "store_prompts_in_spend_logs": "Boolean", + "maximum_spend_logs_retention_period": "String", + "mcp_internal_ip_ranges": "List", + "mcp_trusted_proxy_ranges": "List", + "mcp_xff_num_trusted_hops": "Integer", + "always_include_stream_usage": "Boolean", + "forward_client_headers_to_llm_api": "Boolean", + "mcp_required_fields": "List", + "cancel_on_disconnect": "Boolean", + "skip_user_budget_on_team_key": "Boolean", + "disable_auto_add_proxy_admin_to_teams": "Boolean", + } +) + +# UI-editable fields whose DB value needs custom handling (normalization, a +# global other than general_settings, or a side effect such as rescheduling a +# job); _update_general_settings applies these itself +_GENERAL_SETTINGS_DB_SIDE_EFFECT_FIELDS = frozenset( + { + "pass_through_endpoints", + "store_model_in_db", + "store_prompts_in_spend_logs", + "maximum_spend_logs_retention_period", + "disable_auto_add_proxy_admin_to_teams", + } +) + +# Every other UI-editable field is read straight out of general_settings at +# request time, so copying the DB value across is all the propagation needed +_GENERAL_SETTINGS_DB_PASSTHROUGH_FIELDS = ( + frozenset(_GENERAL_SETTINGS_UI_EDITABLE_FIELDS) - _GENERAL_SETTINGS_DB_SIDE_EFFECT_FIELDS +) | frozenset( + { + "user_url_allowed_hosts", + "user_url_validation", + "provider_url_destination_allowed_hosts", + } +) + + def _redact_worker_config_for_logging(worker_config: str | dict[str, JsonValue] | None) -> JsonValue: """Mask sensitive fields in the worker config before it enters a log record. @@ -5970,11 +6023,7 @@ class ProxyConfig: if old_value != new_value: await self._reschedule_spend_log_cleanup_job() - for key in ( - "user_url_allowed_hosts", - "user_url_validation", - "provider_url_destination_allowed_hosts", - ): + for key in _GENERAL_SETTINGS_DB_PASSTHROUGH_FIELDS: if key in _general_settings: general_settings[key] = _general_settings[key] _apply_ssrf_general_settings(_general_settings) @@ -15153,26 +15202,7 @@ async def get_config_list( else: db_general_settings_dict = {} - allowed_args = { - "max_parallel_requests": {"type": "Integer"}, - "global_max_parallel_requests": {"type": "Integer"}, - "max_request_size_mb": {"type": "Integer"}, - "max_response_size_mb": {"type": "Integer"}, - "proxy_config_reload_interval_seconds": {"type": "Integer"}, - "pass_through_endpoints": {"type": "PydanticModel"}, - "store_model_in_db": {"type": "Boolean"}, - "store_prompts_in_spend_logs": {"type": "Boolean"}, - "maximum_spend_logs_retention_period": {"type": "String"}, - "mcp_internal_ip_ranges": {"type": "List"}, - "mcp_trusted_proxy_ranges": {"type": "List"}, - "mcp_xff_num_trusted_hops": {"type": "Integer"}, - "always_include_stream_usage": {"type": "Boolean"}, - "forward_client_headers_to_llm_api": {"type": "Boolean"}, - "mcp_required_fields": {"type": "List"}, - "cancel_on_disconnect": {"type": "Boolean"}, - "skip_user_budget_on_team_key": {"type": "Boolean"}, - "disable_auto_add_proxy_admin_to_teams": {"type": "Boolean"}, - } + allowed_args = _GENERAL_SETTINGS_UI_EDITABLE_FIELDS return_val = [] @@ -15180,7 +15210,7 @@ async def get_config_list( if field_name in allowed_args: ## HANDLE TYPED DICT - typed_dict_type = allowed_args[field_name]["type"] + typed_dict_type = allowed_args[field_name] if typed_dict_type == "PydanticModel": if field_name == "pass_through_endpoints": @@ -15222,7 +15252,7 @@ async def get_config_list( _response_obj = ConfigList( field_name=field_name, - field_type=allowed_args[field_name]["type"], + field_type=allowed_args[field_name], field_description=field_info.description or "", field_value=_redact_general_setting_value( field_name, @@ -15250,7 +15280,7 @@ async def get_config_list( _response_obj = ConfigList( field_name=field_name, - field_type=allowed_args[field_name]["type"], + field_type=allowed_args[field_name], field_description=field_info.description or "", field_value=_redact_general_setting_value(field_name, _field_value, is_full_admin), stored_in_db=_stored_in_db, diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 536d24d4b4e..76148484049 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -6739,6 +6739,35 @@ async def test_update_general_settings_disable_auto_add_proxy_admin_to_teams(db_ assert ps.general_settings["disable_auto_add_proxy_admin_to_teams"] is expected +@pytest.mark.asyncio +async def test_update_general_settings_propagates_ui_editable_fields(): + """ + Every general setting the Admin UI can write must reach the live + general_settings dict on the next config poll. Regression for + skip_user_budget_on_team_key (issue #35076): the UI wrote it to the DB and + /config/list rendered it as enabled while auth kept enforcing the user + budget, because DB->runtime propagation had its own hardcoded allowlist. + """ + from litellm.proxy.proxy_server import ( + _GENERAL_SETTINGS_DB_SIDE_EFFECT_FIELDS, + _GENERAL_SETTINGS_UI_EDITABLE_FIELDS, + ProxyConfig, + ) + + proxy_config = ProxyConfig() + db_general_settings = { + field: True for field in _GENERAL_SETTINGS_UI_EDITABLE_FIELDS if field not in _GENERAL_SETTINGS_DB_SIDE_EFFECT_FIELDS + } + + with patch("litellm.proxy.proxy_server.general_settings", {}): + await proxy_config._update_general_settings(db_general_settings=db_general_settings) + + import litellm.proxy.proxy_server as ps + + assert ps.general_settings["skip_user_budget_on_team_key"] is True + assert {field: ps.general_settings.get(field) for field in db_general_settings} == db_general_settings + + @pytest.mark.asyncio async def test_update_general_settings_store_model_in_db_string_normalization(): """