mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(ui): serve a dark-mode variant of the LiteLLM logo (#37656)
The bundled logo is a JPEG, so it carries no alpha and its white background renders as a bright slab against a dark sidebar. Making it transparent alone would not be enough either: the wordmark is near-black and would disappear on dark. Adds logo_dark.png, derived from the light logo. The sky-blue disc and train are kept as they are behind a circular alpha mask, and the wordmark's antialiasing is un-flattened from white into straight alpha and repainted in the dark theme's own foreground colour. Both files are 1000x257, so swapping between them cannot shift the sidebar header. /get_image gains a theme query param. The default response is byte for byte what it was, and a logo configured through UI_LOGO_PATH is served unchanged in both themes, since custom logos have no dark variant yet.
This commit is contained in:
parent
282bcdadcc
commit
edbb3429a3
6 changed files with 89 additions and 10 deletions
BIN
litellm/proxy/logo_dark.png
Normal file
BIN
litellm/proxy/logo_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(<Sidebar {...defaultProps} />);
|
||||
|
||||
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(<Sidebar {...defaultProps} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SidebarProps> = ({
|
|||
};
|
||||
|
||||
const logoSrc = logoUrl || `${baseUrl}/get_image`;
|
||||
const darkLogoSrc = logoUrl || `${baseUrl}/get_image?theme=dark`;
|
||||
|
||||
return (
|
||||
<Sidebar collapsed={collapsed}>
|
||||
|
|
@ -610,11 +613,8 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
<div className="flex items-center justify-between gap-2 group-data-[collapsed=true]/sidebar:flex-col">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Link href={migratedHref("")} className="flex min-w-0 items-center" aria-label="LiteLLM home">
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="LiteLLM"
|
||||
className="h-7 w-auto max-w-[150px] object-contain group-data-[collapsed=true]/sidebar:w-7"
|
||||
/>
|
||||
<img src={logoSrc} alt="LiteLLM" className={cn(LOGO_CLASS_NAME, "dark:hidden")} />
|
||||
<img src={darkLogoSrc} alt="" aria-hidden className={cn(LOGO_CLASS_NAME, "hidden dark:block")} />
|
||||
</Link>
|
||||
{version && (
|
||||
<Badge
|
||||
|
|
|
|||
13
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
13
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -43633,7 +43633,9 @@ export interface operations {
|
|||
};
|
||||
get_image_get_image_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
theme?: ("light" | "dark") | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
|
|
@ -43649,6 +43651,15 @@ export interface operations {
|
|||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_logo_url_get_logo_url_get: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue