fix(proxy): skip a non-object general_settings row on reload and remove a stored copy of a config-owned litellm_settings field

This commit is contained in:
mateo-berri 2026-10-29 10:28:39 -07:00
parent f700a8867b
commit edc32593e9
3 changed files with 56 additions and 9 deletions

View file

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

View file

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

View file

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