From badefacfe69e2c33e848fc3be3ec9958191f8588 Mon Sep 17 00:00:00 2001 From: milan Date: Sat, 1 Aug 2026 00:02:05 +0000 Subject: [PATCH] fix(proxy): merge UI-added callbacks with config.yaml callbacks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/proxy_server.py | 37 +++++++++++++++++++ tests/test_litellm/proxy/test_proxy_server.py | 32 ++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a60ea2da019..2ea65f69700 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -3518,6 +3518,41 @@ _DB_OVERLAY_REMOTE_MODULE_LIST_FIELDS: Dict[str, Tuple[str, ...]] = { ), } +_ADDITIVE_LITELLM_SETTINGS_LIST_FIELDS: tuple[str, ...] = ( + "callbacks", + "success_callback", + "failure_callback", + "service_callback", + "audit_log_callbacks", +) + + +def _callback_dedup_key(callback: Any) -> Any: + return callback.lower() if isinstance(callback, str) else id(callback) + + +def _union_callback_lists(config_value: Any, db_value: Any) -> Any: + """Union a config.yaml callback list with the DB-overlay one, config entries + first, deduped case-insensitively for plain string callback names.""" + if not isinstance(config_value, list) or not isinstance(db_value, list): + return db_value + seen = frozenset(_callback_dedup_key(callback) for callback in config_value) + # mutable-ok: config consumers isinstance-check these callback entries for `list` + return [*config_value, *(cb for cb in db_value if _callback_dedup_key(cb) not in seen)] + + +def _merge_additive_litellm_settings(current_config: dict[str, Any], db_param_value: dict[str, Any]) -> None: + """Fold the config.yaml callback lists into the DB-overlay ones, in place. + + The DB row only holds the callbacks added from the Admin UI, so letting it + replace the config.yaml lists drops every YAML-declared logger.""" + config_litellm_settings = current_config.get("litellm_settings") + if not isinstance(config_litellm_settings, dict): + return + for field in _ADDITIVE_LITELLM_SETTINGS_LIST_FIELDS: + if field in db_param_value: + db_param_value[field] = _union_callback_lists(config_litellm_settings.get(field), db_param_value[field]) + def _is_remote_module_url(value: Any) -> bool: return isinstance(value, str) and (value.startswith("s3://") or value.startswith("gcs://")) @@ -6048,6 +6083,8 @@ class ProxyConfig: if key in LITELLM_SETTINGS_SAFE_DB_OVERRIDES: # params that are safe to override with db values setattr(litellm, key, value) + _merge_additive_litellm_settings(current_config, db_param_value) + # If param doesn't exist in config, add it if param_name not in current_config: current_config[param_name] = db_param_value diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index b9a33bd2cef..e1cdb06f543 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -5813,6 +5813,38 @@ def test_get_config_normalizes_string_callbacks(monkeypatch): assert "datadog" in success_and_failure_callbacks +def test_update_config_fields_unions_callbacks_from_config_and_db(): + """Issue #12118: callbacks added from the Admin UI land in the DB litellm_settings + row, which previously replaced the config.yaml callback lists on config load, so + only the UI-added loggers ran.""" + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + + updated = proxy_config._update_config_fields( + current_config={ + "litellm_settings": { + "success_callback": ["langfuse"], + "failure_callback": ["sentry"], + "callbacks": ["otel"], + "drop_params": True, + } + }, + param_name="litellm_settings", + db_param_value={ + "success_callback": ["datadog", "Langfuse"], + "failure_callback": ["datadog"], + "callbacks": ["prometheus"], + }, + ) + + ls = updated["litellm_settings"] + assert ls["success_callback"] == ["langfuse", "datadog"] + assert ls["failure_callback"] == ["sentry", "datadog"] + assert ls["callbacks"] == ["otel", "prometheus"] + assert ls["drop_params"] is True + + def test_deep_merge_dicts_skips_none_and_empty_lists(monkeypatch): """ Test that _update_config_fields deep merge skips None values and empty lists.