From 1f6f16d035eceff1dd435ff062c6aa022e69730a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 4 Mar 2026 20:43:22 -0800 Subject: [PATCH] [Fix] UI logo not showing when UI_LOGO_PATH is a local file path - Fix /get_image to use mimetypes.guess_type() for correct Content-Type instead of hardcoding image/jpeg for all file types (breaks PNG logos in strict enterprise proxy environments) - Fix get_ui_theme_settings to null out logo_url when it is a local filesystem path: the browser cannot load local paths as image URLs, so the UI must fall back to /get_image which serves local files via the UI_LOGO_PATH env var - Add test covering the local-path nulling behavior Co-Authored-By: Claude Sonnet 4.6 --- litellm/proxy/proxy_server.py | 11 +++++++++-- .../ui_crud_endpoints/proxy_setting_endpoints.py | 13 ++++++++++++- .../test_proxy_setting_endpoints.py | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index ad31ff33802..7566e02baea 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -3,6 +3,7 @@ import copy import enum import inspect import io +import mimetypes import os import random import secrets @@ -11061,10 +11062,16 @@ async def get_image(): logo_path = os.getenv("UI_LOGO_PATH", default_logo) verbose_proxy_logger.debug("Reading logo from path: %s", logo_path) + def _get_image_media_type(path: str) -> str: + media_type, _ = mimetypes.guess_type(path) + if media_type and media_type.startswith("image/"): + return media_type + return "image/jpeg" + # If UI_LOGO_PATH points to a local file, serve it directly (skip cache) if logo_path != default_logo and not logo_path.startswith(("http://", "https://")): if os.path.exists(logo_path): - return FileResponse(logo_path, media_type="image/jpeg") + return FileResponse(logo_path, media_type=_get_image_media_type(logo_path)) # Custom path doesn't exist — fall back to default verbose_proxy_logger.warning( f"UI_LOGO_PATH '{logo_path}' does not exist, falling back to default logo" @@ -11103,7 +11110,7 @@ async def get_image(): return FileResponse(default_logo, media_type="image/jpeg") else: # Return the local image file if the logo path is not an HTTP/HTTPS URL - return FileResponse(logo_path, media_type="image/jpeg") + return FileResponse(logo_path, media_type=_get_image_media_type(logo_path)) @app.get("/get_favicon", include_in_schema=False) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index ceda08d520a..f054cdff0f5 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -734,17 +734,28 @@ async def get_ui_theme_settings(): Note: This endpoint is public (no authentication required) so all users can see custom branding. Only the /update/ui_theme_settings endpoint requires authentication for admins to change settings. """ + import os + from litellm.proxy.proxy_server import proxy_config # Load existing config config = await proxy_config.get_config() - return await _get_settings_with_schema( + result = await _get_settings_with_schema( settings_key="ui_theme_config", settings_class=UIThemeConfig, config=config, ) + # If logo_url is a local filesystem path (not an HTTP/HTTPS URL), the browser + # cannot load it directly as an image src. Null it out so the UI falls back to + # calling the /get_image endpoint, which reads UI_LOGO_PATH and serves the file. + logo_url = result["values"].get("logo_url") + if logo_url and not logo_url.startswith(("http://", "https://")): + result["values"]["logo_url"] = None + + return result + @router.patch( "/update/ui_theme_settings", diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 31baab00928..4a62bf8dd7f 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -776,6 +776,22 @@ class TestProxySettingEndpoints: == "https://example.com/favicon.ico" ) + def test_get_ui_theme_settings_local_path_is_nulled(self, mock_proxy_config): + """ + Local file paths stored in logo_url cannot be loaded by the browser as an image URL. + The endpoint should null them out so the UI falls back to /get_image, which reads + UI_LOGO_PATH from the environment and serves the file directly. + """ + mock_proxy_config["config"]["litellm_settings"]["ui_theme_config"] = { + "logo_url": "/app/company_logo.png", + } + + response = client.get("/get/ui_theme_settings") + + assert response.status_code == 200 + data = response.json() + assert data["values"]["logo_url"] is None + def test_get_ui_settings(self, mock_auth, monkeypatch): """Test retrieving UI settings with allowlist sanitization""" from unittest.mock import AsyncMock, MagicMock