diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 03bcd4ba1dc..a80a277dc48 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 0bc933145f0..a425e55708c 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)