diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 06525e39133..d264b82b873 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -3402,8 +3402,8 @@ class ProxyConfig: def _deep_merge_dicts(dst: dict, src: dict) -> None: """ - Deep-merge src into dst, skipping None values from src. - On conflicts, src (DB) wins. + Deep-merge src into dst, skipping None values and empty lists from src. + On conflicts, src (DB) wins, but empty lists are treated as "no value" and don't overwrite. """ stack = [(dst, src)] while stack: @@ -3412,6 +3412,9 @@ class ProxyConfig: if v is None: # Preserve existing config when DB value is None (matches prior behavior) continue + # Skip empty lists - treat them as "no value" to preserve file config + if isinstance(v, list) and len(v) == 0: + continue if isinstance(v, dict) and isinstance(d.get(k), dict): stack.append((d[k], v)) else: @@ -9762,6 +9765,18 @@ async def get_config(): # noqa: PLR0915 _failure_callbacks = _litellm_settings.get("failure_callback", []) _success_and_failure_callbacks = _litellm_settings.get("callbacks", []) + # Normalize string callbacks to lists + def normalize_callback(callback): + if isinstance(callback, str): + return [callback] + elif callback is None: + return [] + return callback + + _success_callbacks = normalize_callback(_success_callbacks) + _failure_callbacks = normalize_callback(_failure_callbacks) + _success_and_failure_callbacks = normalize_callback(_success_and_failure_callbacks) + _data_to_return = [] """ [ diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 5c7ece04513..53d89df5026 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -3036,3 +3036,91 @@ def test_get_image_root_case_uses_current_dir(monkeypatch): # Verify FileResponse was called assert mock_file_response.called, "FileResponse should be called" + + +def test_get_config_normalizes_string_callbacks(monkeypatch): + """ + Test that /get/config/callbacks normalizes string callbacks to lists. + """ + from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth + + config_data = { + "litellm_settings": { + "success_callback": "langfuse", + "failure_callback": None, + "callbacks": ["prometheus", "datadog"], + }, + "general_settings": {}, + "environment_variables": {}, + } + + mock_router = MagicMock() + mock_router.get_settings.return_value = {} + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router) + monkeypatch.setattr( + proxy_config, "get_config", AsyncMock(return_value=config_data) + ) + + original_overrides = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: MagicMock() + + client = TestClient(app) + try: + response = client.get("/get/config/callbacks") + finally: + app.dependency_overrides = original_overrides + + assert response.status_code == 200 + callbacks = response.json()["callbacks"] + + success_callbacks = [cb["name"] for cb in callbacks if cb.get("type") == "success"] + failure_callbacks = [cb["name"] for cb in callbacks if cb.get("type") == "failure"] + success_and_failure_callbacks = [ + cb["name"] for cb in callbacks if cb.get("type") == "success_and_failure" + ] + + assert "langfuse" in success_callbacks + assert len(failure_callbacks) == 0 + assert "prometheus" in success_and_failure_callbacks + assert "datadog" in success_and_failure_callbacks + + +def test_deep_merge_dicts_skips_none_and_empty_lists(monkeypatch): + """ + Test that _update_config_fields deep merge skips None values and empty lists. + """ + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + + current_config = { + "general_settings": { + "max_parallel_requests": 10, + "allowed_models": ["gpt-3.5-turbo", "gpt-4"], + "nested": { + "key1": "value1", + "key2": "value2", + }, + } + } + + db_param_value = { + "max_parallel_requests": None, + "allowed_models": [], + "new_key": "new_value", + "nested": { + "key1": "updated_value1", + "key3": "value3", + }, + } + + result = proxy_config._update_config_fields( + current_config, "general_settings", db_param_value + ) + + assert result["general_settings"]["max_parallel_requests"] == 10 + assert result["general_settings"]["allowed_models"] == ["gpt-3.5-turbo", "gpt-4"] + assert result["general_settings"]["new_key"] == "new_value" + assert result["general_settings"]["nested"]["key1"] == "updated_value1" + assert result["general_settings"]["nested"]["key2"] == "value2" + assert result["general_settings"]["nested"]["key3"] == "value3"