fix(proxy): /config/update litellm_settings merge — request wins

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).
This commit is contained in:
Yuneng Jiang 2026-04-29 17:28:04 -07:00
parent 1fd38eb5a5
commit db5cdfc440
2 changed files with 38 additions and 5 deletions

View file

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

View file

@ -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"]}}