diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a767f5423ed..ad5ad66731d 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11517,6 +11517,15 @@ class ProxyStartupEvent: replace_existing=True, misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME, ) + scheduler.add_job( # pyright: ignore[reportUnknownMemberType] # apscheduler is untyped + sync_ui_settings_to_general_settings, + "interval", + seconds=config_reload_interval_seconds, + args=[prisma_client], + id="sync_ui_settings_job", + replace_existing=True, + misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME, + ) # DB-backed MCP servers are live objects in every mode, so the registry refresh that # store_model_in_db=True deployments get via the add_deployment job must run here # too; without it, a server whose OAuth discovery failed at startup is rebuilt only diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 0efd4e4c619..93114ebc815 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -1911,7 +1911,6 @@ async def update_ui_settings( from litellm.proxy.proxy_server import ( create_config_audit_log, prisma_client, - store_model_in_db, ) if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: @@ -1923,12 +1922,6 @@ async def update_ui_settings( detail={"error": "Database not connected. Please connect a database."}, ) - if store_model_in_db is not True: - raise HTTPException( - status_code=500, - detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."}, - ) - conflicting_keys: Final = sorted( key for key, value in settings_body.items() 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 3647146eb4e..287ab8ba30f 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -952,6 +952,54 @@ async def test_periodic_reload_job_scheduled_without_store_model_in_db(monkeypat scheduler.shutdown(wait=False) +@pytest.mark.asyncio +async def test_ui_settings_resync_job_scheduled_without_store_model_in_db(monkeypatch): + """A UI setting saved through one pod must reach the others without a restart when store_model_in_db is off""" + monkeypatch.delenv("DISABLE_PRISMA_SCHEMA_UPDATE", raising=False) + monkeypatch.delenv("STORE_MODEL_IN_DB", raising=False) + from apscheduler.schedulers.asyncio import AsyncIOScheduler + + from litellm.proxy.proxy_server import ProxyStartupEvent + from litellm.proxy.utils import ProxyLogging + + mock_prisma_client: Final = MagicMock() + mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=None) + mock_prisma_client.db.litellm_uisettings.find_unique = AsyncMock(return_value=None) + mock_proxy_logging: Final = MagicMock(spec=ProxyLogging) + mock_proxy_logging.slack_alerting_instance = MagicMock() + mock_proxy_logging.db_spend_update_writer = MagicMock() + general_settings: Final[dict[str, object]] = {} + scheduler: Final = AsyncIOScheduler() + + try: + with ( + patch("litellm.proxy.proxy_server.proxy_config", _mock_scheduled_proxy_config()), + patch("litellm.proxy.proxy_server.store_model_in_db", False), + patch("litellm.proxy.proxy_server.get_secret_bool", return_value=False), + patch("litellm.proxy.proxy_server.general_settings", general_settings), + patch("litellm.proxy.proxy_server.AsyncIOScheduler", return_value=scheduler), + ): + await ProxyStartupEvent.initialize_scheduled_background_jobs( + general_settings={}, + prisma_client=mock_prisma_client, + proxy_budget_rescheduler_min_time=1, + proxy_budget_rescheduler_max_time=2, + proxy_batch_write_at=5, + proxy_logging_obj=mock_proxy_logging, + ) + job: Final = scheduler.get_job("sync_ui_settings_job") + assert job is not None, [j.id for j in scheduler.get_jobs()] + + mock_prisma_client.db.litellm_uisettings.find_unique.return_value = types.SimpleNamespace( + ui_settings={"disable_model_add_for_internal_users": True} + ) + await job.func(*job.args) + + assert general_settings == {"disable_model_add_for_internal_users": True} + finally: + scheduler.shutdown(wait=False) + + @pytest.mark.asyncio async def test_initialize_scheduled_jobs_registers_cleanup_when_retention_lives_only_in_the_db(monkeypatch): """With no config file, the startup DB sync rebinds general_settings to a store holding the diff --git a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 4360655c8ca..6e087c5f09b 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -1663,6 +1663,35 @@ class TestProxySettingEndpoints: stored_settings = json.loads(create_data["ui_settings"]) assert stored_settings["disable_model_add_for_internal_users"] is True + def test_update_ui_settings_works_without_store_model_in_db(self, monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="test-user-123", user_role=LitellmUserRoles.PROXY_ADMIN + ) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) + mock_prisma: Final = MagicMock() + mock_prisma.db.litellm_uisettings.upsert = AsyncMock() + mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + payload: Final = { + "enabled_ui_pages_internal_users": ["api-keys"], + "team_admin_editable_team_fields": ["max_budget"], + } + + try: + response: Final = client.patch("/update/ui_settings", json=payload) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200, response.text + assert response.json()["settings"] == payload + upsert_data: Final = mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"] + assert json.loads(upsert_data["create"]["ui_settings"]) == payload + def test_update_ui_settings_ignores_non_allowlisted_value( self, mock_auth, monkeypatch ):