This commit is contained in:
samtin0x 2026-10-31 14:35:00 +08:00 • committed by GitHub
commit 7907a67150
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 86 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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