diff --git a/litellm/proxy/logo_dark.png b/litellm/proxy/logo_dark.png new file mode 100644 index 00000000000..f92fbefdd22 Binary files /dev/null and b/litellm/proxy/logo_dark.png differ diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 4a342174277..e0546ffb070 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -15256,12 +15256,17 @@ def get_logo_url(): @app.get("/get_image", include_in_schema=False) -async def get_image(): +async def get_image(theme: Literal["light", "dark"] | None = None): """Get logo to show on admin UI""" # get current_dir current_dir: Final = os.path.dirname(os.path.abspath(__file__)) - default_site_logo: Final = os.path.join(current_dir, "logo.jpg") + bundled_light_logo: Final = os.path.join(current_dir, "logo.jpg") + bundled_dark_logo: Final = os.path.join(current_dir, "logo_dark.png") + default_site_logo: Final = ( + bundled_dark_logo if theme == "dark" and os.path.isfile(bundled_dark_logo) else bundled_light_logo + ) + default_logo_filename: Final = os.path.basename(default_site_logo) is_non_root: Final = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" @@ -15284,7 +15289,7 @@ async def get_image(): assets_dir = current_dir # Determine default logo path - default_logo = os.path.join(assets_dir, "logo.jpg") if assets_dir != current_dir else default_site_logo + default_logo = os.path.join(assets_dir, default_logo_filename) if assets_dir != current_dir else default_site_logo if assets_dir != current_dir and not os.path.exists(default_logo): default_logo = default_site_logo @@ -15316,7 +15321,7 @@ async def get_image(): if safe_logo is not None: safe_logo_path, media_type = safe_logo return FileResponse(safe_logo_path, media_type=media_type) - return FileResponse(default_site_logo, media_type="image/jpeg") + return FileResponse(bundled_light_logo, media_type="image/jpeg") @app.get("/get_favicon", include_in_schema=False) diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_misc.py b/tests/test_litellm/proxy/proxy_server/test_routes_misc.py index 677ab8765bb..e0e5a81cb2c 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_misc.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_misc.py @@ -187,6 +187,54 @@ def test_get_image_returns_default_logo(client, monkeypatch): assert shape == {"status": 200, "media_type_image": True, "has_body": True} +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +PNG_IHDR_COLOUR_TYPE_OFFSET = 25 +PNG_COLOUR_TYPE_RGBA = 6 + + +def test_get_image_dark_theme_returns_logo_with_an_alpha_channel(client, monkeypatch): + """?theme=dark serves the dark logo. It must be an RGBA PNG: the light logo is a + JPEG whose baked-in white background renders as a white slab on a dark sidebar.""" + monkeypatch.delenv("UI_LOGO_PATH", raising=False) + response = client.get("/get_image", params={"theme": "dark"}) + body = response.content + shape = { + "status": response.status_code, + "media_type": response.headers.get("content-type", "").split(";")[0], + "is_png": body[:8] == PNG_SIGNATURE, + "colour_type": body[PNG_IHDR_COLOUR_TYPE_OFFSET], + } + assert shape == { + "status": 200, + "media_type": "image/png", + "is_png": True, + "colour_type": PNG_COLOUR_TYPE_RGBA, + } + + +def test_get_image_without_theme_still_serves_the_light_jpeg(client, monkeypatch): + """The default response is unchanged, so light mode keeps the existing logo.""" + monkeypatch.delenv("UI_LOGO_PATH", raising=False) + response = client.get("/get_image") + shape = { + "status": response.status_code, + "media_type": response.headers.get("content-type", "").split(";")[0], + "is_jpeg": response.content[:3] == b"\xff\xd8\xff", + } + assert shape == {"status": 200, "media_type": "image/jpeg", "is_jpeg": True} + + +def test_get_image_dark_theme_keeps_serving_a_custom_ui_logo(client, monkeypatch, tmp_path): + """A custom UI_LOGO_PATH has no dark variant yet, so dark mode must fall back to the + admin's own logo rather than replacing it with LiteLLM's.""" + custom_logo = tmp_path / "custom.png" + custom_logo.write_bytes(PNG_SIGNATURE + b"custom-logo-marker") + monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo)) + response = client.get("/get_image", params={"theme": "dark"}) + shape = {"status": response.status_code, "body": response.content} + assert shape == {"status": 200, "body": PNG_SIGNATURE + b"custom-logo-marker"} + + def test_get_image_redirects_remote_url(client, monkeypatch): """Remote logo URLs are served via redirect — the proxy never fetches them server-side.""" monkeypatch.setenv("UI_LOGO_PATH", "https://example.invalid/logo.png") diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index 61b6bf8e515..f1ebea57ffa 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -105,6 +105,21 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByRole("link", { name: /litellm home/i })).toHaveAttribute("href", "/ui"); }); + it("pairs the logo with a dark-mode variant that swaps on the dark class", () => { + renderWithProviders(); + + const [light, dark] = Array.from(screen.getByRole("link", { name: /litellm home/i }).querySelectorAll("img")); + const classesOf = (el: Element) => new Set(el.className.split(/\s+/)); + + const lightSrc = light.getAttribute("src") ?? ""; + expect(light).toHaveAttribute("src", expect.stringMatching(/\/get_image$/)); + expect(dark).toHaveAttribute("src", `${lightSrc}?theme=dark`); + expect(classesOf(light).has("dark:hidden")).toBe(true); + expect(classesOf(light).has("hidden")).toBe(false); + expect(classesOf(dark).has("hidden")).toBe(true); + expect(classesOf(dark).has("dark:block")).toBe(true); + }); + it("renders all top-level (non-nested) tabs for admin", () => { renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 2ece64271f8..aa1b97116f5 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -81,6 +81,8 @@ import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPa const ICON = { strokeWidth: 1.75 } as const; +const LOGO_CLASS_NAME = "h-7 w-auto max-w-[150px] object-contain group-data-[collapsed=true]/sidebar:w-7"; + interface SidebarProps { setPage: (page: string) => void; defaultSelectedKey: string; @@ -603,6 +605,7 @@ const Sidebar_: React.FC = ({ }; const logoSrc = logoUrl || `${baseUrl}/get_image`; + const darkLogoSrc = logoUrl || `${baseUrl}/get_image?theme=dark`; return ( @@ -610,11 +613,8 @@ const Sidebar_: React.FC = ({
- LiteLLM + LiteLLM + {version && (