From abbe5d7f8591c85f1ca7ebfa323480d5d7d57a1f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 27 Apr 2026 14:50:59 -0700 Subject: [PATCH 1/7] fix(proxy): /config/update writes only sent sections, drop store_model_in_db gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint loaded the full merged YAML+DB config and re-saved every top-level section to LiteLLM_Config rows via save_config(), so a UI toggle of one field persisted unrelated YAML state to DB as a side effect. It also rejected every request when store_model_in_db was False — including the request that would flip the flag to True (chicken-and-egg). Replace save_config with targeted per-section upserts: read the existing litellm_config row, merge in the request, upsert just that row. Sections the caller did not send are not touched. Drop the blanket store_model_in_db guard — the endpoint already requires prisma_client, and the startup-side override at proxy_server.py:6491 picks up general_settings.store_model_in_db=True from the DB on next restart. --- litellm/proxy/proxy_server.py | 144 +++++------ tests/proxy_unit_tests/test_proxy_server.py | 47 ++-- .../test_update_config_endpoint.py | 241 ++++++++++++++++++ 3 files changed, 323 insertions(+), 109 deletions(-) create mode 100644 tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3982d096e6..03bcd4ba1d 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12557,9 +12557,12 @@ async def update_config( # noqa: PLR0915 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - For Admin UI - allows admin to update config via UI + For Admin UI - allows admin to update config via UI. - Currently supports modifying General Settings + LiteLLM settings + Writes only the sections present in the request body to LiteLLM_Config rows + (one row per top-level section). Sections the caller did not send are left + untouched — this endpoint never persists pre-existing YAML values to DB as + a side effect of an unrelated update. """ global llm_router, llm_model_list, general_settings, proxy_config, proxy_logging_obj, master_key, prisma_client try: @@ -12567,108 +12570,77 @@ async def update_config( # noqa: PLR0915 raise HTTPException( status_code=403, detail="Only proxy admins can update config" ) - import base64 - """ - - Update the ConfigTable DB - - Run 'add_deployment' - """ if prisma_client is None: raise Exception("No DB Connected") - 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." + async def _read_section(param_name: str) -> dict: + row = await prisma_client.db.litellm_config.find_first( + where={"param_name": param_name} + ) + if row is None or row.param_value is None: + return {} + return dict(row.param_value) + + async def _upsert_section(param_name: str, value: dict) -> None: + serialized = json.dumps(value) + await prisma_client.db.litellm_config.upsert( + where={"param_name": param_name}, + data={ + "create": {"param_name": param_name, "param_value": serialized}, + "update": {"param_value": serialized}, }, ) - updated_settings = config_info.json(exclude_none=True) - updated_settings = prisma_client.jsonify_object(updated_settings) - for k, v in updated_settings.items(): - if k == "router_settings": - await prisma_client.db.litellm_config.upsert( - where={"param_name": k}, - data={ - "create": {"param_name": k, "param_value": v}, - "update": {"param_value": v}, - }, - ) - - ### OLD LOGIC [TODO] MOVE TO DB ### - - # Load existing config - config = await proxy_config.get_config() - verbose_proxy_logger.debug("Loaded config: %s", config) - - # update the general settings + # general_settings: merge per-key, with the alert_to_webhook_url side + # effect of auto-enabling slack alerting. if config_info.general_settings is not None: - config.setdefault("general_settings", {}) - updated_general_settings = config_info.general_settings.dict( - exclude_none=True - ) - - _existing_settings = config["general_settings"] - for k, v in updated_general_settings.items(): - # overwrite existing settings with updated values + existing = await _read_section("general_settings") + updates = config_info.general_settings.dict(exclude_none=True) + for k, v in updates.items(): if k == "alert_to_webhook_url": - # check if slack is already enabled. if not, enable it - if "alerting" not in _existing_settings: - _existing_settings = {"alerting": ["slack"]} - elif isinstance(_existing_settings["alerting"], list): - if "slack" not in _existing_settings["alerting"]: - _existing_settings["alerting"].append("slack") - _existing_settings[k] = v - config["general_settings"] = _existing_settings + if "alerting" not in existing: + existing["alerting"] = ["slack"] + elif ( + isinstance(existing["alerting"], list) + and "slack" not in existing["alerting"] + ): + existing["alerting"].append("slack") + existing[k] = v + await _upsert_section("general_settings", existing) + # environment_variables: encrypt request values, then merge into existing. if config_info.environment_variables is not None: - config.setdefault("environment_variables", {}) - _updated_environment_variables = config_info.environment_variables + existing = await _read_section("environment_variables") + for k, v in config_info.environment_variables.items(): + existing[k] = encrypt_value_helper(value=v) + await _upsert_section("environment_variables", existing) - # encrypt updated_environment_variables # - for k, v in _updated_environment_variables.items(): - encrypted_value = encrypt_value_helper(value=v) - _updated_environment_variables[k] = encrypted_value - - _existing_env_variables = config["environment_variables"] - - for k, v in _updated_environment_variables.items(): - # overwrite existing env variables with updated values - _existing_env_variables[k] = _updated_environment_variables[k] - - # update the litellm settings + # litellm_settings: existing-wins merge (preserving legacy behavior), + # except success_callback is unioned with the request value. if config_info.litellm_settings is not None: - config.setdefault("litellm_settings", {}) + existing = await _read_section("litellm_settings") updated_litellm_settings = config_info.litellm_settings - config["litellm_settings"] = { - **updated_litellm_settings, - **config["litellm_settings"], - } - - # if litellm.success_callback in updated_litellm_settings and config["litellm_settings"] + merged = {**updated_litellm_settings, **existing} if ( "success_callback" in updated_litellm_settings - and "success_callback" in config["litellm_settings"] + and "success_callback" in existing + and isinstance(existing["success_callback"], list) + and isinstance(updated_litellm_settings["success_callback"], list) ): - # check both success callback are lists - if isinstance( - config["litellm_settings"]["success_callback"], list - ) and isinstance(updated_litellm_settings["success_callback"], list): - updated_success_callbacks_normalized = normalize_callback_names( - updated_litellm_settings["success_callback"] - ) - combined_success_callback = ( - config["litellm_settings"]["success_callback"] - + updated_success_callbacks_normalized - ) - combined_success_callback = list(set(combined_success_callback)) - config["litellm_settings"][ - "success_callback" - ] = combined_success_callback + normalized = normalize_callback_names( + updated_litellm_settings["success_callback"] + ) + merged["success_callback"] = list( + set(existing["success_callback"] + normalized) + ) + await _upsert_section("litellm_settings", merged) - # Save the updated config - await proxy_config.save_config(new_config=config) + # router_settings: merge existing + request, request wins. + if config_info.router_settings is not None: + existing = await _read_section("router_settings") + updates = config_info.router_settings.dict(exclude_none=True) + await _upsert_section("router_settings", {**existing, **updates}) await proxy_config.add_deployment( prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj diff --git a/tests/proxy_unit_tests/test_proxy_server.py b/tests/proxy_unit_tests/test_proxy_server.py index c62c3f41b3..a4aab2ad18 100644 --- a/tests/proxy_unit_tests/test_proxy_server.py +++ b/tests/proxy_unit_tests/test_proxy_server.py @@ -2764,40 +2764,40 @@ async def test_update_config_success_callback_normalization(): import litellm.proxy.proxy_server as proxy_server from litellm.proxy._types import ConfigYAML - # Ensure feature is enabled and prisma_client is set - setattr(proxy_server, "store_model_in_db", True) setattr(proxy_server, "proxy_logging_obj", MagicMock()) + existing_litellm_settings = {"success_callback": ["langfuse"]} + + class FakeRow: + def __init__(self, name, value): + self.param_name = name + self.param_value = value + + upserted = {} + + async def fake_find_first(where=None): + if where and where.get("param_name") == "litellm_settings": + return FakeRow("litellm_settings", existing_litellm_settings) + return None + + async def fake_upsert(where=None, data=None): + upserted[where["param_name"]] = json.loads(data["update"]["param_value"]) + class MockPrisma: def __init__(self): self.db = MagicMock() self.db.litellm_config = MagicMock() - self.db.litellm_config.upsert = AsyncMock() - - # proxy_server.update_config expects this to be sync returning a dict - def jsonify_object(self, obj): - return obj + self.db.litellm_config.find_first = AsyncMock(side_effect=fake_find_first) + self.db.litellm_config.upsert = AsyncMock(side_effect=fake_upsert) setattr(proxy_server, "prisma_client", MockPrisma()) class MockProxyConfig: - def __init__(self): - self.saved_config = None - - async def get_config(self): - # Existing config has one lowercase callback already - return {"litellm_settings": {"success_callback": ["langfuse"]}} - - async def save_config(self, new_config: dict): - self.saved_config = new_config - async def add_deployment(self, prisma_client=None, proxy_logging_obj=None): return None - mock_proxy_config = MockProxyConfig() - setattr(proxy_server, "proxy_config", mock_proxy_config) + setattr(proxy_server, "proxy_config", MockProxyConfig()) - # Update config with mixed-case callbacks - expect normalization to lowercase config_update = ConfigYAML(litellm_settings={"success_callback": ["SQS", "sQs"]}) from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth @@ -2806,9 +2806,10 @@ async def test_update_config_success_callback_normalization(): ) await proxy_server.update_config(config_update, user_api_key_dict=admin_user) - saved = mock_proxy_config.saved_config - assert saved is not None, "save_config was not called" - callbacks = saved["litellm_settings"]["success_callback"] + assert ( + "litellm_settings" in upserted + ), "litellm_config.upsert was not called for litellm_settings" + callbacks = upserted["litellm_settings"]["success_callback"] # Deduped and normalized assert "sqs" in callbacks diff --git a/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py b/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py new file mode 100644 index 0000000000..0bc933145f --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py @@ -0,0 +1,241 @@ +""" +Tests for the /config/update endpoint (litellm.proxy.proxy_server.update_config). + +These tests cover the targeted-per-section upsert behavior that replaced the +legacy save_config-based implementation, plus the removal of the +store_model_in_db blanket guard. +""" + +import json +import os +import sys +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.proxy_server import app + + +class FakeRow: + def __init__(self, param_name, param_value): + self.param_name = param_name + self.param_value = param_value + + +class FakeLitellmConfig: + def __init__(self, initial_rows=None): + self.rows = dict(initial_rows or {}) + self.upsert_calls = [] + self.find_first = AsyncMock(side_effect=self._find_first) + self.upsert = AsyncMock(side_effect=self._upsert) + + async def _find_first(self, where=None): + if where and "param_name" in where: + name = where["param_name"] + if name in self.rows: + return FakeRow(name, self.rows[name]) + return None + + async def _upsert(self, where=None, data=None): + name = where["param_name"] + # The endpoint calls upsert with json.dumps'd payloads. Some paths in + # the codebase pass dicts directly, so handle both. + raw = data["update"]["param_value"] + value = json.loads(raw) if isinstance(raw, str) else raw + self.rows[name] = value + self.upsert_calls.append((name, value)) + + +class FakeDB: + def __init__(self, initial_rows=None): + self.litellm_config = FakeLitellmConfig(initial_rows=initial_rows) + + +class FakePrismaClient: + def __init__(self, initial_rows=None): + self.db = FakeDB(initial_rows=initial_rows) + self.jsonify_object = lambda obj: obj + + +@pytest.fixture +def admin_auth(): + original = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="test_admin", + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-1234", + ) + yield + app.dependency_overrides = original + + +@pytest.fixture +def non_admin_auth(): + original = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="test_user", + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-1234", + ) + yield + app.dependency_overrides = original + + +@pytest.fixture +def patched_proxy(monkeypatch): + """Returns a callable that installs a FakePrismaClient + no-op add_deployment.""" + + def _install(initial_rows=None): + prisma = FakePrismaClient(initial_rows=initial_rows) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma) + # add_deployment is a coroutine method on proxy_config; replace with no-op + from litellm.proxy.proxy_server import proxy_config as real_proxy_config + + monkeypatch.setattr( + real_proxy_config, "add_deployment", AsyncMock(return_value=None) + ) + # Stub encrypt to be identity so we can assert on plain text + monkeypatch.setattr( + "litellm.proxy.proxy_server.encrypt_value_helper", + lambda value, **_: f"enc:{value}", + ) + return prisma + + return _install + + +def test_no_db_returns_500_class_error(admin_auth, monkeypatch): + """When prisma_client is None, the endpoint surfaces a ProxyException.""" + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + client = TestClient(app) + resp = client.post( + "/config/update", json={"general_settings": {"store_model_in_db": True}} + ) + assert resp.status_code >= 400 + # 'No DB Connected' is the message raised inside the endpoint. + assert "No DB" in resp.text or "DB Connected" in resp.text + + +def test_non_admin_rejected(non_admin_auth, patched_proxy): + patched_proxy() + client = TestClient(app) + resp = client.post( + "/config/update", json={"general_settings": {"store_model_in_db": True}} + ) + assert resp.status_code in (401, 403) + + +def test_can_flip_store_model_in_db_when_currently_false( + admin_auth, patched_proxy, monkeypatch +): + """ + Regression: previously the endpoint refused all writes when the global + store_model_in_db flag was False, blocking even the request that would + flip it to True. After this fix, the request succeeds and the flag is + persisted to the general_settings row. + """ + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) + prisma = patched_proxy() + + client = TestClient(app) + resp = client.post( + "/config/update", json={"general_settings": {"store_model_in_db": True}} + ) + + assert resp.status_code == 200 + assert ( + prisma.db.litellm_config.rows["general_settings"]["store_model_in_db"] is True + ) + + +def test_only_sent_section_is_written(admin_auth, patched_proxy): + """ + A request that only touches general_settings must not write + litellm_settings, environment_variables, or router_settings rows. + """ + prisma = patched_proxy( + initial_rows={ + "litellm_settings": {"drop_params": True}, + "environment_variables": {"FOO": "enc:bar"}, + } + ) + + client = TestClient(app) + resp = client.post( + "/config/update", + json={"general_settings": {"store_prompts_in_spend_logs": True}}, + ) + + assert resp.status_code == 200 + written_param_names = {name for name, _ in prisma.db.litellm_config.upsert_calls} + assert written_param_names == {"general_settings"} + # Untouched rows preserved. + assert prisma.db.litellm_config.rows["litellm_settings"] == {"drop_params": True} + assert prisma.db.litellm_config.rows["environment_variables"] == {"FOO": "enc:bar"} + + +def test_environment_variables_encrypted_before_write(admin_auth, patched_proxy): + prisma = patched_proxy() + client = TestClient(app) + resp = client.post( + "/config/update", + json={"environment_variables": {"OPENAI_API_KEY": "sk-secret"}}, + ) + + assert resp.status_code == 200 + stored = prisma.db.litellm_config.rows["environment_variables"] + assert stored == {"OPENAI_API_KEY": "enc:sk-secret"} + + +def test_success_callback_unioned_with_existing(admin_auth, patched_proxy): + prisma = patched_proxy( + initial_rows={"litellm_settings": {"success_callback": ["langfuse"]}} + ) + + client = TestClient(app) + resp = client.post( + "/config/update", + json={"litellm_settings": {"success_callback": ["prometheus"]}}, + ) + + assert resp.status_code == 200 + stored = prisma.db.litellm_config.rows["litellm_settings"]["success_callback"] + assert set(stored) == {"langfuse", "prometheus"} + + +def test_alert_to_webhook_url_enables_slack_alerting(admin_auth, patched_proxy): + prisma = patched_proxy() + client = TestClient(app) + resp = client.post( + "/config/update", + json={ + "general_settings": { + "alert_to_webhook_url": {"spend_reports": "https://hooks/foo"} + } + }, + ) + + assert resp.status_code == 200 + stored = prisma.db.litellm_config.rows["general_settings"] + assert stored["alerting"] == ["slack"] + assert stored["alert_to_webhook_url"] == {"spend_reports": "https://hooks/foo"} + + +def test_router_settings_merged_with_existing(admin_auth, patched_proxy): + prisma = patched_proxy( + initial_rows={"router_settings": {"num_retries": 3, "timeout": 10}} + ) + + client = TestClient(app) + resp = client.post("/config/update", json={"router_settings": {"num_retries": 5}}) + + assert resp.status_code == 200 + stored = prisma.db.litellm_config.rows["router_settings"] + # New value wins, untouched key preserved. + assert stored["num_retries"] == 5 + assert stored["timeout"] == 10 From b6e4ccf8767cb249a4bd11c2c6d4ff38257ad765 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 27 Apr 2026 23:42:43 -0700 Subject: [PATCH 2/7] fix(proxy): /config/update normalize success_callback on first write Previously the normalize_callback_names call only ran when the existing litellm_settings DB row already had a success_callback key. On the very first write (no row yet, or row missing the key), incoming mixed-case values like ["SQS", "sQs"] persisted as-is. delete_callback (lowercase lookup) then could not find them, and a follow-up /config/update would union normalized incoming with mixed-case stored entries, producing duplicates. Always normalize incoming success_callback before merging, and dedupe both the standalone first-write case and the union-with-existing case. Adds test_success_callback_normalized_on_first_write covering the no-existing-row path; the existing union test still passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/proxy_server.py | 35 +++++++++++-------- .../test_update_config_endpoint.py | 20 +++++++++++ 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 03bcd4ba1d..a80a277dc4 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12617,23 +12617,30 @@ async def update_config( # noqa: PLR0915 await _upsert_section("environment_variables", existing) # litellm_settings: existing-wins merge (preserving legacy behavior), - # except success_callback is unioned with the request value. + # except success_callback is always normalized + deduped, and unioned + # with any existing list. Normalizing on every write — not only when + # an existing entry is present — keeps the DB free of mixed-case + # entries that delete_callback (lowercase lookup) cannot find. if config_info.litellm_settings is not None: existing = await _read_section("litellm_settings") - updated_litellm_settings = config_info.litellm_settings + updated_litellm_settings = dict(config_info.litellm_settings) + + incoming_cb = updated_litellm_settings.get("success_callback") + if isinstance(incoming_cb, list): + updated_litellm_settings["success_callback"] = normalize_callback_names( + incoming_cb + ) + merged = {**updated_litellm_settings, **existing} - if ( - "success_callback" in updated_litellm_settings - and "success_callback" in existing - and isinstance(existing["success_callback"], list) - and isinstance(updated_litellm_settings["success_callback"], list) - ): - normalized = normalize_callback_names( - updated_litellm_settings["success_callback"] - ) - merged["success_callback"] = list( - set(existing["success_callback"] + normalized) - ) + + incoming_cb = updated_litellm_settings.get("success_callback") + existing_cb = existing.get("success_callback") + if isinstance(incoming_cb, list): + if isinstance(existing_cb, list): + merged["success_callback"] = list(set(existing_cb + incoming_cb)) + else: + merged["success_callback"] = list(set(incoming_cb)) + await _upsert_section("litellm_settings", merged) # router_settings: merge existing + request, request wins. diff --git a/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py b/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py index 0bc933145f..a425e55708 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py +++ b/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py @@ -208,6 +208,26 @@ def test_success_callback_unioned_with_existing(admin_auth, patched_proxy): assert set(stored) == {"langfuse", "prometheus"} +def test_success_callback_normalized_on_first_write(admin_auth, patched_proxy): + """ + Regression: when no litellm_settings row exists yet, incoming mixed-case + callbacks must still be lowercased and deduped before write. delete_callback + looks up by lowercase name, so a stored "SQS" would be unreachable, and a + follow-up /config/update with ["sqs"] would union mixed-case stored entries + with normalized incoming ones, producing duplicates. + """ + prisma = patched_proxy() + client = TestClient(app) + resp = client.post( + "/config/update", + json={"litellm_settings": {"success_callback": ["SQS", "sQs"]}}, + ) + + assert resp.status_code == 200 + stored = prisma.db.litellm_config.rows["litellm_settings"]["success_callback"] + assert set(stored) == {"sqs"} + + def test_alert_to_webhook_url_enables_slack_alerting(admin_auth, patched_proxy): prisma = patched_proxy() client = TestClient(app) From 1fd38eb5a52549874879db052fe33b66b68f7dd2 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 29 Apr 2026 16:21:51 -0700 Subject: [PATCH 3/7] fix(proxy): /config/update normalize existing success_callback before dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a litellm_settings row already holds mixed-case names (e.g. ["Langfuse"]) — written by another code path or by hand — the union-on-update path was running set([...]) over the raw existing list plus the lowercase-normalized incoming list, so "Langfuse" and "langfuse" survived as duplicates. delete_callback uses a lowercase lookup, leaving the mixed-case entry unreachable. Normalize the existing list with normalize_callback_names before the union so the merged list converges to lowercase. Adds a regression test covering the case where the DB starts with ["Langfuse", "SQS"] and the caller submits ["langfuse"]. --- litellm/proxy/proxy_server.py | 8 ++++++- .../test_update_config_endpoint.py | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a80a277dc4..82f147b3f9 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12637,7 +12637,13 @@ async def update_config( # noqa: PLR0915 existing_cb = existing.get("success_callback") if isinstance(incoming_cb, list): if isinstance(existing_cb, list): - merged["success_callback"] = list(set(existing_cb + incoming_cb)) + # Normalize the existing list too — a row written by a + # different code path may still hold mixed-case names, + # which would otherwise dedup-miss against the lowercase + # incoming entries. + merged["success_callback"] = list( + set(normalize_callback_names(existing_cb) + incoming_cb) + ) else: merged["success_callback"] = list(set(incoming_cb)) diff --git a/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py b/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py index a425e55708..2797b13b4e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py +++ b/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py @@ -208,6 +208,30 @@ def test_success_callback_unioned_with_existing(admin_auth, patched_proxy): assert set(stored) == {"langfuse", "prometheus"} +def test_success_callback_dedups_against_mixed_case_existing(admin_auth, patched_proxy): + """ + Regression: a litellm_settings row written by an older code path (or by + direct DB edit) may still hold mixed-case callback names like + ["Langfuse"]. When the user submits ["langfuse"], the union must + normalize the existing entries too — otherwise the DB ends up with both + "Langfuse" and "langfuse" and delete_callback (lowercase lookup) cannot + find the original. + """ + prisma = patched_proxy( + initial_rows={"litellm_settings": {"success_callback": ["Langfuse", "SQS"]}} + ) + + client = TestClient(app) + resp = client.post( + "/config/update", + json={"litellm_settings": {"success_callback": ["langfuse"]}}, + ) + + assert resp.status_code == 200 + stored = prisma.db.litellm_config.rows["litellm_settings"]["success_callback"] + assert set(stored) == {"langfuse", "sqs"} + + def test_success_callback_normalized_on_first_write(admin_auth, patched_proxy): """ Regression: when no litellm_settings row exists yet, incoming mixed-case From db5cdfc44069a3d724fadc79a8b7419baf3cc0b9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 29 Apr 2026 17:28:04 -0700 Subject: [PATCH 4/7] =?UTF-8?q?fix(proxy):=20/config/update=20litellm=5Fse?= =?UTF-8?q?ttings=20merge=20=E2=80=94=20request=20wins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the litellm_settings dict merge from {**incoming, **existing} to {**existing, **incoming} so the caller's value for any pre-existing key is what gets persisted. The previous direction silently no-op'd a request like {"litellm_settings": {"drop_params": false}} when the DB already held drop_params: true — the endpoint returned 200 OK but the stored value never changed. router_settings (immediately below) had been doing the right thing all along; this brings the two sections into alignment. success_callback semantics are unchanged: it is still always normalized to lowercase, and still unioned with any existing list (callbacks are additive — a caller sends the new entry, not the full set). Adds a regression test (drop_params: True in DB, request flips to False, expect persisted False with other keys preserved). --- litellm/proxy/proxy_server.py | 13 ++++---- .../test_update_config_endpoint.py | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 82f147b3f9..214f2df8a1 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12616,10 +12616,13 @@ async def update_config( # noqa: PLR0915 existing[k] = encrypt_value_helper(value=v) await _upsert_section("environment_variables", existing) - # litellm_settings: existing-wins merge (preserving legacy behavior), - # except success_callback is always normalized + deduped, and unioned - # with any existing list. Normalizing on every write — not only when - # an existing entry is present — keeps the DB free of mixed-case + # litellm_settings: merge existing + request, request wins (matching + # router_settings semantics — the caller's value for any given key is + # what gets persisted). success_callback is special-cased: it is + # always normalized + deduped, and unioned with any existing list, + # because callbacks are additive (callers send the new entry, not + # the full set). Normalizing on every write — not only when an + # existing entry is present — keeps the DB free of mixed-case # entries that delete_callback (lowercase lookup) cannot find. if config_info.litellm_settings is not None: existing = await _read_section("litellm_settings") @@ -12631,7 +12634,7 @@ async def update_config( # noqa: PLR0915 incoming_cb ) - merged = {**updated_litellm_settings, **existing} + merged = {**existing, **updated_litellm_settings} incoming_cb = updated_litellm_settings.get("success_callback") existing_cb = existing.get("success_callback") diff --git a/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py b/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py index 2797b13b4e..aa368952bf 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py +++ b/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py @@ -192,6 +192,36 @@ def test_environment_variables_encrypted_before_write(admin_auth, patched_proxy) assert stored == {"OPENAI_API_KEY": "enc:sk-secret"} +def test_litellm_settings_request_wins_for_non_callback_keys(admin_auth, patched_proxy): + """ + Regression: a /config/update with {"litellm_settings": {"drop_params": + False}} when the existing row holds drop_params: True must persist + drop_params: False. Previously the merge was {**incoming, **existing}, + so existing values silently won and the request was a no-op for any + pre-existing key. + + Untouched keys must be preserved. + """ + prisma = patched_proxy( + initial_rows={ + "litellm_settings": { + "drop_params": True, + "set_verbose": True, + } + } + ) + + client = TestClient(app) + resp = client.post( + "/config/update", json={"litellm_settings": {"drop_params": False}} + ) + + assert resp.status_code == 200 + stored = prisma.db.litellm_config.rows["litellm_settings"] + assert stored["drop_params"] is False + assert stored["set_verbose"] is True + + def test_success_callback_unioned_with_existing(admin_auth, patched_proxy): prisma = patched_proxy( initial_rows={"litellm_settings": {"success_callback": ["langfuse"]}} From fb7724f8ff87d8ef34afe2fee041444d36b64ac6 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 29 Apr 2026 18:28:41 -0700 Subject: [PATCH 5/7] fix(proxy): wire /config/update into the new config DualCache invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After merging litellm_internal_staging (which introduced litellm_config_cache and `await invalidate_config_param(...)` calls paired with every config write), the rewritten /config/update was left in a broken state: the auto-merge stranded one `await invalidate_config_param(k)` call inside the new litellm_settings block where `k` is undefined, raising UnboundLocalError on every request that included litellm_settings. Bake invalidation into the local `_upsert_section` helper so each section write atomically invalidates its own cache key — there's no longer a per-section call site to remember to update. Drop the stray `invalidate_config_param(k)` line. This restores tests/proxy_unit_tests/test_proxy_server.py:: test_update_config_success_callback_normalization, which was the only failing test on the proxy-server GHA shard. --- litellm/proxy/proxy_server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 2088ddf45f..f2f32c4e90 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12698,6 +12698,9 @@ async def update_config( # noqa: PLR0915 "update": {"param_value": serialized}, }, ) + # invalidate the DualCache entry so the next reader (this process + # or any other proxy in the cluster) goes to DB. + await invalidate_config_param(param_name) # general_settings: merge per-key, with the alert_to_webhook_url side # effect of auto-enabling slack alerting. @@ -12740,7 +12743,6 @@ async def update_config( # noqa: PLR0915 updated_litellm_settings["success_callback"] = normalize_callback_names( incoming_cb ) - await invalidate_config_param(k) merged = {**existing, **updated_litellm_settings} From 6d16b822ef28227d5370226038b048b7d0dffb6c Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 29 Apr 2026 19:18:02 -0700 Subject: [PATCH 6/7] [Test] Proxy: Move /config/update tests into test_proxy_server.py and trim to critical paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following standard pytest convention (test_.py), tests for code in litellm/proxy/proxy_server.py belong in tests/test_litellm/proxy/test_proxy_server.py — not a separate symbol-named file. Delete tests/test_litellm/proxy/management_endpoints/ test_update_config_endpoint.py and re-home the coverage there. Also condense from 11 tests to 5 critical paths — the behaviors that broke or changed in the rewrite of update_config: 1. Targeted writes — only the sent section is persisted; other rows left byte-identical (the original bug fix) 2. store_model_in_db chicken-and-egg — endpoint accepts requests when the global flag is False, so it can be flipped to True 3. Environment variables encrypted before DB write 4. litellm_settings request-wins merge for non-callback keys 5. success_callback normalizes existing mixed-case entries before union dedup All 5 use FastAPI's TestClient against the real /config/update route (not direct function calls) so they exercise the same path as a real admin UI request. Dropped: redundant first-write / mixed-case-fresh tests, generic auth + no-DB error-path tests, alert_to_webhook_url side-effect test, and the router_settings merge test (overlaps with litellm_settings test). --- .../test_update_config_endpoint.py | 315 ------------------ 1 file changed, 315 deletions(-) delete mode 100644 tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py diff --git a/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py b/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py deleted file mode 100644 index aa368952bf..0000000000 --- a/tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py +++ /dev/null @@ -1,315 +0,0 @@ -""" -Tests for the /config/update endpoint (litellm.proxy.proxy_server.update_config). - -These tests cover the targeted-per-section upsert behavior that replaced the -legacy save_config-based implementation, plus the removal of the -store_model_in_db blanket guard. -""" - -import json -import os -import sys -from unittest.mock import AsyncMock - -import pytest -from fastapi.testclient import TestClient - -sys.path.insert(0, os.path.abspath("../../../..")) - -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth -from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.proxy_server import app - - -class FakeRow: - def __init__(self, param_name, param_value): - self.param_name = param_name - self.param_value = param_value - - -class FakeLitellmConfig: - def __init__(self, initial_rows=None): - self.rows = dict(initial_rows or {}) - self.upsert_calls = [] - self.find_first = AsyncMock(side_effect=self._find_first) - self.upsert = AsyncMock(side_effect=self._upsert) - - async def _find_first(self, where=None): - if where and "param_name" in where: - name = where["param_name"] - if name in self.rows: - return FakeRow(name, self.rows[name]) - return None - - async def _upsert(self, where=None, data=None): - name = where["param_name"] - # The endpoint calls upsert with json.dumps'd payloads. Some paths in - # the codebase pass dicts directly, so handle both. - raw = data["update"]["param_value"] - value = json.loads(raw) if isinstance(raw, str) else raw - self.rows[name] = value - self.upsert_calls.append((name, value)) - - -class FakeDB: - def __init__(self, initial_rows=None): - self.litellm_config = FakeLitellmConfig(initial_rows=initial_rows) - - -class FakePrismaClient: - def __init__(self, initial_rows=None): - self.db = FakeDB(initial_rows=initial_rows) - self.jsonify_object = lambda obj: obj - - -@pytest.fixture -def admin_auth(): - original = app.dependency_overrides.copy() - app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( - user_id="test_admin", - user_role=LitellmUserRoles.PROXY_ADMIN, - api_key="sk-1234", - ) - yield - app.dependency_overrides = original - - -@pytest.fixture -def non_admin_auth(): - original = app.dependency_overrides.copy() - app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( - user_id="test_user", - user_role=LitellmUserRoles.INTERNAL_USER, - api_key="sk-1234", - ) - yield - app.dependency_overrides = original - - -@pytest.fixture -def patched_proxy(monkeypatch): - """Returns a callable that installs a FakePrismaClient + no-op add_deployment.""" - - def _install(initial_rows=None): - prisma = FakePrismaClient(initial_rows=initial_rows) - monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma) - # add_deployment is a coroutine method on proxy_config; replace with no-op - from litellm.proxy.proxy_server import proxy_config as real_proxy_config - - monkeypatch.setattr( - real_proxy_config, "add_deployment", AsyncMock(return_value=None) - ) - # Stub encrypt to be identity so we can assert on plain text - monkeypatch.setattr( - "litellm.proxy.proxy_server.encrypt_value_helper", - lambda value, **_: f"enc:{value}", - ) - return prisma - - return _install - - -def test_no_db_returns_500_class_error(admin_auth, monkeypatch): - """When prisma_client is None, the endpoint surfaces a ProxyException.""" - monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) - client = TestClient(app) - resp = client.post( - "/config/update", json={"general_settings": {"store_model_in_db": True}} - ) - assert resp.status_code >= 400 - # 'No DB Connected' is the message raised inside the endpoint. - assert "No DB" in resp.text or "DB Connected" in resp.text - - -def test_non_admin_rejected(non_admin_auth, patched_proxy): - patched_proxy() - client = TestClient(app) - resp = client.post( - "/config/update", json={"general_settings": {"store_model_in_db": True}} - ) - assert resp.status_code in (401, 403) - - -def test_can_flip_store_model_in_db_when_currently_false( - admin_auth, patched_proxy, monkeypatch -): - """ - Regression: previously the endpoint refused all writes when the global - store_model_in_db flag was False, blocking even the request that would - flip it to True. After this fix, the request succeeds and the flag is - persisted to the general_settings row. - """ - monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) - prisma = patched_proxy() - - client = TestClient(app) - resp = client.post( - "/config/update", json={"general_settings": {"store_model_in_db": True}} - ) - - assert resp.status_code == 200 - assert ( - prisma.db.litellm_config.rows["general_settings"]["store_model_in_db"] is True - ) - - -def test_only_sent_section_is_written(admin_auth, patched_proxy): - """ - A request that only touches general_settings must not write - litellm_settings, environment_variables, or router_settings rows. - """ - prisma = patched_proxy( - initial_rows={ - "litellm_settings": {"drop_params": True}, - "environment_variables": {"FOO": "enc:bar"}, - } - ) - - client = TestClient(app) - resp = client.post( - "/config/update", - json={"general_settings": {"store_prompts_in_spend_logs": True}}, - ) - - assert resp.status_code == 200 - written_param_names = {name for name, _ in prisma.db.litellm_config.upsert_calls} - assert written_param_names == {"general_settings"} - # Untouched rows preserved. - assert prisma.db.litellm_config.rows["litellm_settings"] == {"drop_params": True} - assert prisma.db.litellm_config.rows["environment_variables"] == {"FOO": "enc:bar"} - - -def test_environment_variables_encrypted_before_write(admin_auth, patched_proxy): - prisma = patched_proxy() - client = TestClient(app) - resp = client.post( - "/config/update", - json={"environment_variables": {"OPENAI_API_KEY": "sk-secret"}}, - ) - - assert resp.status_code == 200 - stored = prisma.db.litellm_config.rows["environment_variables"] - assert stored == {"OPENAI_API_KEY": "enc:sk-secret"} - - -def test_litellm_settings_request_wins_for_non_callback_keys(admin_auth, patched_proxy): - """ - Regression: a /config/update with {"litellm_settings": {"drop_params": - False}} when the existing row holds drop_params: True must persist - drop_params: False. Previously the merge was {**incoming, **existing}, - so existing values silently won and the request was a no-op for any - pre-existing key. - - Untouched keys must be preserved. - """ - prisma = patched_proxy( - initial_rows={ - "litellm_settings": { - "drop_params": True, - "set_verbose": True, - } - } - ) - - client = TestClient(app) - resp = client.post( - "/config/update", json={"litellm_settings": {"drop_params": False}} - ) - - assert resp.status_code == 200 - stored = prisma.db.litellm_config.rows["litellm_settings"] - assert stored["drop_params"] is False - assert stored["set_verbose"] is True - - -def test_success_callback_unioned_with_existing(admin_auth, patched_proxy): - prisma = patched_proxy( - initial_rows={"litellm_settings": {"success_callback": ["langfuse"]}} - ) - - client = TestClient(app) - resp = client.post( - "/config/update", - json={"litellm_settings": {"success_callback": ["prometheus"]}}, - ) - - assert resp.status_code == 200 - stored = prisma.db.litellm_config.rows["litellm_settings"]["success_callback"] - assert set(stored) == {"langfuse", "prometheus"} - - -def test_success_callback_dedups_against_mixed_case_existing(admin_auth, patched_proxy): - """ - Regression: a litellm_settings row written by an older code path (or by - direct DB edit) may still hold mixed-case callback names like - ["Langfuse"]. When the user submits ["langfuse"], the union must - normalize the existing entries too — otherwise the DB ends up with both - "Langfuse" and "langfuse" and delete_callback (lowercase lookup) cannot - find the original. - """ - prisma = patched_proxy( - initial_rows={"litellm_settings": {"success_callback": ["Langfuse", "SQS"]}} - ) - - client = TestClient(app) - resp = client.post( - "/config/update", - json={"litellm_settings": {"success_callback": ["langfuse"]}}, - ) - - assert resp.status_code == 200 - stored = prisma.db.litellm_config.rows["litellm_settings"]["success_callback"] - assert set(stored) == {"langfuse", "sqs"} - - -def test_success_callback_normalized_on_first_write(admin_auth, patched_proxy): - """ - Regression: when no litellm_settings row exists yet, incoming mixed-case - callbacks must still be lowercased and deduped before write. delete_callback - looks up by lowercase name, so a stored "SQS" would be unreachable, and a - follow-up /config/update with ["sqs"] would union mixed-case stored entries - with normalized incoming ones, producing duplicates. - """ - prisma = patched_proxy() - client = TestClient(app) - resp = client.post( - "/config/update", - json={"litellm_settings": {"success_callback": ["SQS", "sQs"]}}, - ) - - assert resp.status_code == 200 - stored = prisma.db.litellm_config.rows["litellm_settings"]["success_callback"] - assert set(stored) == {"sqs"} - - -def test_alert_to_webhook_url_enables_slack_alerting(admin_auth, patched_proxy): - prisma = patched_proxy() - client = TestClient(app) - resp = client.post( - "/config/update", - json={ - "general_settings": { - "alert_to_webhook_url": {"spend_reports": "https://hooks/foo"} - } - }, - ) - - assert resp.status_code == 200 - stored = prisma.db.litellm_config.rows["general_settings"] - assert stored["alerting"] == ["slack"] - assert stored["alert_to_webhook_url"] == {"spend_reports": "https://hooks/foo"} - - -def test_router_settings_merged_with_existing(admin_auth, patched_proxy): - prisma = patched_proxy( - initial_rows={"router_settings": {"num_retries": 3, "timeout": 10}} - ) - - client = TestClient(app) - resp = client.post("/config/update", json={"router_settings": {"num_retries": 5}}) - - assert resp.status_code == 200 - stored = prisma.db.litellm_config.rows["router_settings"] - # New value wins, untouched key preserved. - assert stored["num_retries"] == 5 - assert stored["timeout"] == 10 From be3d27a0b8fb670276885e166db9057181ea0d4b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 29 Apr 2026 19:18:31 -0700 Subject: [PATCH 7/7] [Test] Proxy: Add /config/update critical-path tests to test_proxy_server.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the previous commit which deleted the symbol-named tests/test_litellm/proxy/management_endpoints/test_update_config_endpoint.py. Adds the 5 critical-path tests in their proper home — the test file that mirrors the source file (proxy_server.py). The two commits are one logical change; they were split because git add aborted on a stale path argument. --- tests/test_litellm/proxy/test_proxy_server.py | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 1f4f82a64e..1924b37dfd 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -5471,3 +5471,199 @@ async def test_reseed_warms_cache_even_on_zero_db_spend(): finally: ps.spend_counter_cache = orig_counter ps.prisma_client = orig_prisma + + +# ----------------------------------------------------------------------------- +# /config/update — critical paths only. +# +# These exercise the four behaviors that broke or changed in the rewrite of +# update_config (litellm/proxy/proxy_server.py): targeted per-section writes, +# the removal of the store_model_in_db gate, env var encryption, and the +# success_callback / litellm_settings merge semantics. All other branches +# (auth, missing-DB, slack auto-enable, router_settings merge) are covered +# implicitly or by upstream tests. +# ----------------------------------------------------------------------------- + + +class _FakeRow: + def __init__(self, param_name, param_value): + self.param_name = param_name + self.param_value = param_value + + +class _FakeLitellmConfig: + def __init__(self, initial_rows=None): + self.rows = dict(initial_rows or {}) + self.upsert_calls: list = [] + self.find_first = AsyncMock(side_effect=self._find_first) + self.upsert = AsyncMock(side_effect=self._upsert) + + async def _find_first(self, where=None): + if where and "param_name" in where: + name = where["param_name"] + if name in self.rows: + return _FakeRow(name, self.rows[name]) + return None + + async def _upsert(self, where=None, data=None): + name = where["param_name"] + raw = data["update"]["param_value"] + value = json.loads(raw) if isinstance(raw, str) else raw + self.rows[name] = value + self.upsert_calls.append((name, value)) + + +class _FakePrismaClient: + def __init__(self, initial_rows=None): + self.db = mock.MagicMock() + self.db.litellm_config = _FakeLitellmConfig(initial_rows=initial_rows) + self.jsonify_object = lambda obj: obj + + +@pytest.fixture +def _update_config_setup(monkeypatch): + """Install fakes for the /config/update endpoint and return (client, prisma).""" + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth as auth_dep + + def _install(initial_rows=None, store_model_in_db=True): + prisma = _FakePrismaClient(initial_rows=initial_rows) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma) + monkeypatch.setattr( + "litellm.proxy.proxy_server.store_model_in_db", store_model_in_db + ) + monkeypatch.setattr( + "litellm.proxy.proxy_server.encrypt_value_helper", + lambda value, **_: f"enc:{value}", + ) + monkeypatch.setattr( + "litellm.proxy.proxy_server.invalidate_config_param", + AsyncMock(return_value=None), + ) + from litellm.proxy.proxy_server import proxy_config as real_proxy_config + + monkeypatch.setattr( + real_proxy_config, "add_deployment", AsyncMock(return_value=None) + ) + + original_overrides = app.dependency_overrides.copy() + app.dependency_overrides[auth_dep] = lambda: UserAPIKeyAuth( + user_id="test_admin", + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-1234", + ) + client = TestClient(app) + + def _restore(): + app.dependency_overrides = original_overrides + + return client, prisma, _restore + + return _install + + +def test_update_config_writes_only_sent_section(_update_config_setup): + """A request that only touches general_settings must not write any other + section row, and must leave previously-written rows byte-identical.""" + client, prisma, restore = _update_config_setup( + initial_rows={ + "litellm_settings": {"drop_params": True}, + "environment_variables": {"FOO": "enc:bar"}, + } + ) + try: + resp = client.post( + "/config/update", + json={"general_settings": {"store_prompts_in_spend_logs": True}}, + ) + assert resp.status_code == 200 + written = {name for name, _ in prisma.db.litellm_config.upsert_calls} + assert written == {"general_settings"} + assert prisma.db.litellm_config.rows["litellm_settings"] == { + "drop_params": True + } + assert prisma.db.litellm_config.rows["environment_variables"] == { + "FOO": "enc:bar" + } + finally: + restore() + + +def test_update_config_can_flip_store_model_in_db_when_currently_false( + _update_config_setup, +): + """The endpoint used to refuse all writes when store_model_in_db was + False, blocking the very request that would flip it to True.""" + client, prisma, restore = _update_config_setup(store_model_in_db=False) + try: + resp = client.post( + "/config/update", json={"general_settings": {"store_model_in_db": True}} + ) + assert resp.status_code == 200 + assert ( + prisma.db.litellm_config.rows["general_settings"]["store_model_in_db"] + is True + ) + finally: + restore() + + +def test_update_config_environment_variables_encrypted_before_write( + _update_config_setup, +): + """env var values must be encrypted before they hit the DB row.""" + client, prisma, restore = _update_config_setup() + try: + resp = client.post( + "/config/update", + json={"environment_variables": {"OPENAI_API_KEY": "sk-secret"}}, + ) + assert resp.status_code == 200 + stored = prisma.db.litellm_config.rows["environment_variables"] + assert stored == {"OPENAI_API_KEY": "enc:sk-secret"} + finally: + restore() + + +def test_update_config_litellm_settings_request_wins_for_non_callback_keys( + _update_config_setup, +): + """Sending {"drop_params": False} when the row holds drop_params: True + must persist False (request wins). Untouched keys preserved.""" + client, prisma, restore = _update_config_setup( + initial_rows={ + "litellm_settings": {"drop_params": True, "set_verbose": True}, + } + ) + try: + resp = client.post( + "/config/update", json={"litellm_settings": {"drop_params": False}} + ) + assert resp.status_code == 200 + stored = prisma.db.litellm_config.rows["litellm_settings"] + assert stored["drop_params"] is False + assert stored["set_verbose"] is True + finally: + restore() + + +def test_update_config_success_callback_normalizes_existing_mixed_case( + _update_config_setup, +): + """Existing mixed-case callback names (written elsewhere) must be + normalized to lowercase before union, otherwise the union dedup misses + against the lowercase incoming entry and delete_callback (lowercase + lookup) cannot find the original.""" + client, prisma, restore = _update_config_setup( + initial_rows={"litellm_settings": {"success_callback": ["Langfuse", "SQS"]}} + ) + try: + resp = client.post( + "/config/update", + json={"litellm_settings": {"success_callback": ["langfuse"]}}, + ) + assert resp.status_code == 200 + stored = prisma.db.litellm_config.rows["litellm_settings"]["success_callback"] + assert set(stored) == {"langfuse", "sqs"} + finally: + restore()