From edc32593e9111ad546e3d05bec4115af2a39872c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:28:39 -0700 Subject: [PATCH] fix(proxy): skip a non-object general_settings row on reload and remove a stored copy of a config-owned litellm_settings field --- litellm/proxy/proxy_server.py | 18 +++++--- .../proxy/proxy_server/test_proxy_config.py | 3 -- ...test_proxy_server_endpoints_and_startup.py | 44 +++++++++++++++++++ 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 607fc2f783d..45c4ddf6f3f 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -5926,6 +5926,10 @@ class ProxyConfig: store.apply_db_row(cast(DbRow, section_name), wrote_section) await invalidate_config_param(section_name) + def config_owns(self, *, section_name: Section, key: str) -> bool: + store: Final = self._settings_stores.get(section_name) + return store is not None and store.owned_by_config(key) + def reject_config_owned_deletes(self, *, section_name: str, keys: tuple[str, ...]) -> None: """Refuse a delete of a setting the config file owns; unlike a write, the value never makes it allowed.""" store: Final = self._settings_stores.get(cast(Section, section_name)) @@ -8211,9 +8215,9 @@ class ProxyConfig: general_settings.get("maximum_spend_logs_retention_interval", "1d"), ) - async def _update_general_settings(self, db_general_settings: Mapping[str, SettingsJsonValue] | None) -> None: + async def _update_general_settings(self, db_general_settings: Mapping[str, SettingsJsonValue] | str | None) -> None: global general_settings - if db_general_settings is None: + if not isinstance(db_general_settings, Mapping): return if not isinstance(general_settings, SettingsStore): self.settings.load_yaml(_as_settings_mapping(general_settings)) @@ -20639,11 +20643,13 @@ async def delete_config_general_settings( ) if data.field_name in _GENERAL_SETTINGS_UI_LITELLM_FIELDS: - reset: Final = await _reset_general_settings_ui_litellm_field(data.field_name, user_api_key_dict) stored_copy: Final = await _stored_general_settings() - if stored_copy is not None and data.field_name in stored_copy: - await _remove_stored_general_setting(data.field_name, stored_copy, user_api_key_dict) - return reset + if stored_copy is None or data.field_name not in stored_copy: + return await _reset_general_settings_ui_litellm_field(data.field_name, user_api_key_dict) + removed: Final = await _remove_stored_general_setting(data.field_name, stored_copy, user_api_key_dict) + if proxy_config.config_owns(section_name="litellm_settings", key=data.field_name): + return removed + return await _reset_general_settings_ui_litellm_field(data.field_name, user_api_key_dict) stored: Final = await _stored_general_settings() diff --git a/tests/unit/proxy/proxy_server/test_proxy_config.py b/tests/unit/proxy/proxy_server/test_proxy_config.py index a211b4ed920..08c43164f47 100644 --- a/tests/unit/proxy/proxy_server/test_proxy_config.py +++ b/tests/unit/proxy/proxy_server/test_proxy_config.py @@ -4851,9 +4851,6 @@ async def test_ProxyConfig__update_general_settings_none_input_noop(): # None input returns early. result = await pc._update_general_settings(db_general_settings=None) assert result is None - # Error-style: dict() will fail on non-mapping non-None input. - with pytest.raises(TypeError): - await pc._update_general_settings(db_general_settings=12345) # type: ignore[arg-type] @pytest.mark.asyncio diff --git a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py index 501c551d6b8..cdc3887448e 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -3756,6 +3756,22 @@ async def test_db_reload_keeps_the_resolved_value_of_a_config_owned_env_referenc assert proxy_server_module.store_model_in_db is True +@pytest.mark.asyncio +async def test_db_reload_of_a_json_string_general_settings_row_reports_no_ignored_keys(monkeypatch): + from litellm.proxy import proxy_server as proxy_server_module + from litellm.proxy.proxy_server import ProxyConfig + + monkeypatch.setattr(proxy_server_module, "user_api_key_cache", MagicMock(), raising=False) + proxy_config: Final = ProxyConfig() + proxy_config.settings.load_yaml({}) + monkeypatch.setattr(proxy_server_module, "general_settings", proxy_config.settings, raising=False) + + await proxy_config._update_general_settings('{"budget_rollover": true, "master_key": "sk-stored"}') + + assert proxy_config.settings.unmanaged_db_keys() == () + assert proxy_config.settings.source("master_key") != "db" + + def test_max_ui_session_budget_default_is_one_dollar(): """LIT-4662: the dashboard session budget default is a product decision; the old 0.25 default locked admins out of auto router Test Connection and the @@ -15511,6 +15527,34 @@ async def test_delete_config_general_settings_removes_a_stored_general_settings_ assert pc.settings.unmanaged_db_keys() == () +@pytest.mark.asyncio +async def test_delete_config_general_settings_removes_a_stored_copy_of_a_ui_litellm_field_the_config_file_sets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm.proxy._types import ConfigFieldDelete + from litellm.proxy.proxy_server import ProxyConfig, delete_config_general_settings + + pc: Final = ProxyConfig() + pc._load_yaml_settings_stores({"general_settings": {}, "litellm_settings": {"budget_rollover": True}}) + stored_row: Final = {"budget_rollover": True, "store_prompts_in_spend_logs": True} + pc.settings.apply_db_row("general_settings", stored_row) + fake_prisma: Final = _fake_prisma_with_config(stored_row) + monkeypatch.setattr(proxy_server_module, "proxy_config", pc) + monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma) + monkeypatch.setattr(litellm, "budget_rollover", True) + + admin: Final = UserAPIKeyAuth(api_key="hashed-admin", user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN) + await delete_config_general_settings( + data=ConfigFieldDelete(field_name="budget_rollover", config_type="general_settings"), + user_api_key_dict=admin, + ) + + written: Final = json.loads(fake_prisma.db.litellm_config.upsert.await_args.kwargs["data"]["update"]["param_value"]) + assert written == {"store_prompts_in_spend_logs": True} + assert litellm.budget_rollover is True + assert pc.settings.unmanaged_db_keys() == () + + @pytest.mark.asyncio async def test_delete_config_general_settings_refuses_an_unknown_key_when_the_stored_row_is_a_json_string( monkeypatch: pytest.MonkeyPatch,