From db5cdfc44069a3d724fadc79a8b7419baf3cc0b9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 29 Apr 2026 17:28:04 -0700 Subject: [PATCH] =?UTF-8?q?fix(proxy):=20/config/update=20litellm=5Fsettin?= =?UTF-8?q?gs=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 82f147b3f9d..214f2df8a1a 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 2797b13b4e6..aa368952bfc 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"]}}