fix(proxy): merge UI-added callbacks with config.yaml callbacks

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
milan 2026-08-01 00:02:05 +00:00
parent f8375780fe
commit badefacfe6
2 changed files with 69 additions and 0 deletions

View file

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

View file

@ -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.