diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py index 2310e4ac80d..83088a597a7 100644 --- a/litellm/proxy/common_utils/callback_utils.py +++ b/litellm/proxy/common_utils/callback_utils.py @@ -587,7 +587,9 @@ def process_callback( def normalize_callback_names( - callbacks: Iterable[Union[str, object]], + callbacks: Iterable[Union[str, object]] | None, ) -> List[Union[str, object]]: """Lowercase string callback names; pass through non-strings (e.g. from config) unchanged.""" + if callbacks is None: + return [] return [c.lower() if isinstance(c, str) else c for c in callbacks] diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 20763b5d41b..b3e8c67bdd7 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -3620,9 +3620,13 @@ class ProxyConfig: merged_failure = list( dict.fromkeys(failure_from_settings + (failure_callbacks or [])) ) - merged_callbacks = list( - dict.fromkeys(callbacks_from_settings + (callbacks or [])) - ) + seen_both = {c if isinstance(c, str) else next(iter(c)) for c in callbacks_from_settings} + merged_callbacks: list = list(callbacks_from_settings) + for c in (callbacks or []): + name = c if isinstance(c, str) else next(iter(c.keys())) + if name not in seen_both: + merged_callbacks.append(c) + seen_both.add(name) for success_callback in merged_success: self._add_callback_from_db_to_in_memory_litellm_callbacks( @@ -11666,7 +11670,11 @@ async def get_config(): # noqa: PLR0915 (_callback_name, _callback_params), = _callback.items() _callback = _callback_name elif isinstance(_callback, str) and _callback in _callback_settings: - _callback_params = _callback_settings.get(_callback) + _raw = _callback_settings.get(_callback) or {} + _callback_params = ( + {k: v for k, v in _raw.items() if k not in ("callback_type", "event_types")} + or None + ) _data_to_return.append( process_callback( _callback, diff --git a/tests/test_litellm/proxy/common_utils/test_callback_utils.py b/tests/test_litellm/proxy/common_utils/test_callback_utils.py index 6a5e707d308..94025cd3615 100644 --- a/tests/test_litellm/proxy/common_utils/test_callback_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_callback_utils.py @@ -122,6 +122,12 @@ def test_normalize_callback_names_lowercases_strings(): assert normalize_callback_names(["SQS", "S3", "CUSTOM_CALLBACK"]) == ["sqs", "s3", "custom_callback"] +def test_normalize_callback_names_none_returns_empty_list(): + """None and empty list return [] to support callers that may pass unguarded None.""" + assert normalize_callback_names(None) == [] + assert normalize_callback_names([]) == [] + + def test_remaining_tokens_requests_returns_empty_when_metadata_empty_or_missing(): """Empty or missing metadata returns {}.""" assert get_remaining_tokens_and_requests_from_request_data({}) == {} diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index cf3657a7887..2505d21bb80 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -2985,6 +2985,109 @@ def test_root_redirect_when_docs_url_not_root_and_redirect_url_set(monkeypatch): assert response.headers["location"] == test_redirect_url +@pytest.mark.asyncio +async def test_get_image_non_root_uses_var_lib_assets_dir(monkeypatch): + """ + Test that get_image uses /var/lib/litellm/assets when LITELLM_NON_ROOT is true. + get_image only calls makedirs when the assets dir does not exist, so we must + have exists() return False for that path to trigger the create-dir branch. + """ + from unittest.mock import patch + + from litellm.proxy.proxy_server import get_image + + # Set LITELLM_NON_ROOT to true + monkeypatch.setenv("LITELLM_NON_ROOT", "true") + monkeypatch.delenv("UI_LOGO_PATH", raising=False) + + def exists_side_effect(path): + # Return False for the assets dir so get_image calls makedirs() + if path == "/var/lib/litellm/assets": + return False + return True + + with patch("litellm.proxy.proxy_server.os.makedirs") as mock_makedirs, \ + patch("litellm.proxy.proxy_server.os.path.exists", side_effect=exists_side_effect), \ + patch("litellm.proxy.proxy_server.os.getenv") as mock_getenv, \ + patch("litellm.proxy.proxy_server.FileResponse") as mock_file_response: + + # Setup mock_getenv to return empty string for UI_LOGO_PATH + def getenv_side_effect(key, default=""): + if key == "UI_LOGO_PATH": + return "" + elif key == "LITELLM_NON_ROOT": + return "true" + return default + + mock_getenv.side_effect = getenv_side_effect + + # Call the function + await get_image() + + # Verify makedirs was called with /var/lib/litellm/assets + mock_makedirs.assert_called_once_with("/var/lib/litellm/assets", exist_ok=True) + + +@pytest.mark.asyncio +async def test_get_image_non_root_fallback_to_default_logo(monkeypatch): + """ + Test that get_image falls back to default_site_logo when logo doesn't exist + in /var/lib/litellm/assets for non-root case. + get_image only calls makedirs when the assets dir does not exist, so we must + have exists() return False for that path to trigger the create-dir branch. + """ + from unittest.mock import patch + + from litellm.proxy.proxy_server import get_image + + # Set LITELLM_NON_ROOT to true + monkeypatch.setenv("LITELLM_NON_ROOT", "true") + monkeypatch.delenv("UI_LOGO_PATH", raising=False) + + # Track path.exists calls to verify it checks /var/lib/litellm/assets/logo.jpg + exists_calls = [] + + def exists_side_effect(path): + exists_calls.append(path) + # Return False for assets dir so get_image calls makedirs() + if path == "/var/lib/litellm/assets": + return False + # Return False for /var/lib/litellm/assets/logo.jpg to trigger fallback + if "/var/lib/litellm/assets/logo.jpg" in path or path == "/var/lib/litellm/assets/logo.jpg": + return False + return True + + # Mock os.path operations + with patch("litellm.proxy.proxy_server.os.makedirs") as mock_makedirs, \ + patch("litellm.proxy.proxy_server.os.path.exists", side_effect=exists_side_effect), \ + patch("litellm.proxy.proxy_server.os.getenv") as mock_getenv, \ + patch("litellm.proxy.proxy_server.FileResponse") as mock_file_response: + + # Setup mock_getenv + def getenv_side_effect(key, default=""): + if key == "UI_LOGO_PATH": + return "" + elif key == "LITELLM_NON_ROOT": + return "true" + return default + + mock_getenv.side_effect = getenv_side_effect + + # Call the function + await get_image() + + # Verify makedirs was called with /var/lib/litellm/assets + mock_makedirs.assert_called_once_with("/var/lib/litellm/assets", exist_ok=True) + + # Verify that exists was called to check /var/lib/litellm/assets/logo.jpg + assets_logo_path = "/var/lib/litellm/assets/logo.jpg" + assert any(assets_logo_path in str(call) for call in exists_calls), \ + f"Should check if {assets_logo_path} exists" + + # Verify FileResponse was called (with fallback logo) + assert mock_file_response.called, "FileResponse should be called" + + @pytest.mark.asyncio async def test_get_image_root_case_uses_current_dir(monkeypatch): """ diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index ec3cf68ec46..1edb28c215d 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -340,7 +340,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, setIsAddingCallback(true); } - const payload = buildCallbackPayload(formValues, callbackName, callbacks, isEdit, undefined, callbackSettings); + const payload = buildCallbackPayload(formValues, callbackName, callbacks, isEdit, selectedEditCallback?.type as "success" | "failure" | "success_and_failure" | undefined, callbackSettings); try { await setCallbacksCall(accessToken, payload);