Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 44 additions & 19 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15255,6 +15255,29 @@ def get_logo_url():
return {"logo_url": ""}


def _serve_custom_ui_logo(candidate: str) -> Response | None:
"""Serve one admin-configured logo, or None when it is unusable so the caller falls back."""
from litellm.proxy.common_utils.static_asset_utils import (
resolve_validated_local_image_path,
)

# Remote logo URLs are loaded by the browser. The proxy should not fetch
# arbitrary admin-configured URLs server-side.
if candidate.startswith(("http://", "https://")):
return RedirectResponse(url=candidate)

safe_logo: Final = resolve_validated_local_image_path(candidate)
if safe_logo is None:
verbose_proxy_logger.warning(
"Custom UI logo %r is not a supported image file or does not exist, falling back",
candidate,
)
return None

safe_logo_path, media_type = safe_logo
return FileResponse(safe_logo_path, media_type=media_type)


@app.get("/get_image", include_in_schema=False)
async def get_image(theme: Literal["light", "dark"] | None = None):
"""Get logo to show on admin UI"""
Expand Down Expand Up @@ -15293,31 +15316,33 @@ async def get_image(theme: Literal["light", "dark"] | None = None):
if assets_dir != current_dir and not os.path.exists(default_logo):
default_logo = default_site_logo

logo_path = os.getenv("UI_LOGO_PATH", default_logo)
verbose_proxy_logger.debug("Reading logo from path: %s", logo_path)
custom_logo_candidates: Final = tuple(
candidate.strip()
for candidate in (
os.getenv("UI_LOGO_PATH_DARK", "") if theme == "dark" else "",
os.getenv("UI_LOGO_PATH", ""),
)
if candidate.strip()
)
verbose_proxy_logger.debug("Custom logo candidates, in fallback order: %s", custom_logo_candidates)

custom_logo_response: Final = next(
(
response
for response in (_serve_custom_ui_logo(candidate) for candidate in custom_logo_candidates)
if response is not None
),
None,
)
if custom_logo_response is not None:
return custom_logo_response

from litellm.proxy.common_utils.static_asset_utils import (
resolve_validated_local_image_path,
)

if logo_path != default_logo and not logo_path.startswith(("http://", "https://")):
safe_logo = resolve_validated_local_image_path(logo_path)
if safe_logo is not None:
safe_logo_path, media_type = safe_logo
return FileResponse(safe_logo_path, media_type=media_type)
verbose_proxy_logger.warning(
"UI_LOGO_PATH %r is not a supported image file or does not exist, falling back to default logo",
logo_path,
)
logo_path = default_logo

# Remote logo URLs are loaded by the browser. The proxy should not fetch
# arbitrary admin-configured URLs server-side.
if logo_path.startswith(("http://", "https://")):
return RedirectResponse(url=logo_path)

# Default logo (resolved from the bundled asset, not user-controlled).
safe_logo = resolve_validated_local_image_path(logo_path)
safe_logo: Final = resolve_validated_local_image_path(default_logo)
if safe_logo is not None:
safe_logo_path, media_type = safe_logo
return FileResponse(safe_logo_path, media_type=media_type)
Expand Down
22 changes: 17 additions & 5 deletions litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ def _config_param_db(repo: _HasConfigParamTable) -> _PrismaTableActions[_ConfigP
# reflect a deployment branded purely through process env.
_UI_THEME_FIELD_ENV_VARS: Final[dict[str, str]] = {
"logo_url": "UI_LOGO_PATH",
"logo_url_dark": "UI_LOGO_PATH_DARK",
"favicon_url": "LITELLM_FAVICON_URL",
}

Expand Down Expand Up @@ -156,6 +157,14 @@ class UIThemeConfig(BaseModel):
description="URL or path to custom logo image. Can be a local file path or HTTP/HTTPS URL",
)

logo_url_dark: str | None = Field(
default=None,
description=(
"URL or path to a custom logo image for dark mode. Can be a local file path or HTTP/HTTPS URL. "
"Leave unset to reuse logo_url in dark mode"
),
)

# Favicon configuration
favicon_url: str | None = Field(
default=None,
Expand Down Expand Up @@ -1184,6 +1193,7 @@ async def update_ui_theme_settings(
)

_validate_public_image_url(theme_config.logo_url, "logo_url")
_validate_public_image_url(theme_config.logo_url_dark, "logo_url_dark")
_validate_public_image_url(theme_config.favicon_url, "favicon_url")

if store_model_in_db is not True:
Expand All @@ -1204,16 +1214,18 @@ async def update_ui_theme_settings(
config["litellm_settings"] = {}
config["litellm_settings"]["ui_theme_config"] = theme_data

# UI_LOGO_PATH and LITELLM_FAVICON_URL are the only environment variables
# this endpoint owns. A non-empty value sets the var; an empty or missing
# one clears it back to the default. Apply to the live process immediately,
# then persist only these two keys so an unrelated env var (a YAML/OS value
# merged in by get_config) is never snapshotted into the DB.
# The vars below are the only environment variables this endpoint owns, and
# they must stay in step with _UI_THEME_FIELD_ENV_VARS. A non-empty value
# sets the var; an empty or missing one clears it back to the default. Apply
# to the live process immediately, then persist only those keys so an
# unrelated env var (a YAML/OS value merged in by get_config) is never
# snapshotted into the DB.
def _clean(url: str | None) -> str | None:
return url if url is not None and url.strip() else None

env_updates: Final[dict[str, str | None]] = {
"UI_LOGO_PATH": _clean(theme_config.logo_url),
"UI_LOGO_PATH_DARK": _clean(theme_config.logo_url_dark),
"LITELLM_FAVICON_URL": _clean(theme_config.favicon_url),
}
for env_key, env_value in env_updates.items():
Expand Down
62 changes: 60 additions & 2 deletions tests/test_litellm/proxy/proxy_server/test_routes_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ def test_get_image_without_theme_still_serves_the_light_jpeg(client, monkeypatch


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."""
"""With no UI_LOGO_PATH_DARK set, dark mode falls back to the admin's own light logo
rather than replacing their branding 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))
Expand All @@ -235,6 +235,64 @@ def test_get_image_dark_theme_keeps_serving_a_custom_ui_logo(client, monkeypatch
assert shape == {"status": 200, "body": PNG_SIGNATURE + b"custom-logo-marker"}


def test_get_image_dark_theme_prefers_the_dark_custom_logo(client, monkeypatch, tmp_path):
"""UI_LOGO_PATH_DARK outranks UI_LOGO_PATH when the dark logo is requested."""
light_logo = tmp_path / "light.png"
light_logo.write_bytes(PNG_SIGNATURE + b"light-marker")
dark_logo = tmp_path / "dark.png"
dark_logo.write_bytes(PNG_SIGNATURE + b"dark-marker")
monkeypatch.setenv("UI_LOGO_PATH", str(light_logo))
monkeypatch.setenv("UI_LOGO_PATH_DARK", str(dark_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"dark-marker"}


def test_get_image_unusable_dark_logo_falls_back_to_the_light_custom_logo(client, monkeypatch, tmp_path):
"""A broken UI_LOGO_PATH_DARK must not drop the admin all the way to LiteLLM's own
logo while their light logo is still perfectly serviceable."""
light_logo = tmp_path / "light.png"
light_logo.write_bytes(PNG_SIGNATURE + b"light-marker")
monkeypatch.setenv("UI_LOGO_PATH", str(light_logo))
monkeypatch.setenv("UI_LOGO_PATH_DARK", str(tmp_path / "missing.png"))

response = client.get("/get_image", params={"theme": "dark"})

shape = {"status": response.status_code, "body": response.content}
assert shape == {"status": 200, "body": PNG_SIGNATURE + b"light-marker"}


def test_get_image_light_theme_ignores_the_dark_custom_logo(client, monkeypatch, tmp_path):
"""The dark logo must never leak into a light-mode request."""
light_logo = tmp_path / "light.png"
light_logo.write_bytes(PNG_SIGNATURE + b"light-marker")
dark_logo = tmp_path / "dark.png"
dark_logo.write_bytes(PNG_SIGNATURE + b"dark-marker")
monkeypatch.setenv("UI_LOGO_PATH", str(light_logo))
monkeypatch.setenv("UI_LOGO_PATH_DARK", str(dark_logo))

response = client.get("/get_image")

shape = {"status": response.status_code, "body": response.content}
assert shape == {"status": 200, "body": PNG_SIGNATURE + b"light-marker"}


def test_get_image_dark_logo_alone_still_serves_the_bundled_light_logo_in_light_mode(client, monkeypatch):
"""Setting only UI_LOGO_PATH_DARK leaves light mode on the bundled default."""
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
monkeypatch.setenv("UI_LOGO_PATH_DARK", "https://cdn.example.invalid/logo-dark.png")

response = client.get("/get_image")

shape = {
"status": response.status_code,
"media_type": response.headers.get("content-type", "").split(";")[0],
}
assert shape == {"status": 200, "media_type": "image/jpeg"}


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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1064,11 +1064,15 @@ def test_update_ui_theme_settings(self, mock_proxy_config, mock_auth, monkeypatc
assert mock_proxy_config["save_call_count"]() == 1

# env vars are persisted through the dedicated per-key path, and ONLY
# the two keys this endpoint owns are touched. The unrelated SSO env
# the keys this endpoint owns are touched. The unrelated SSO env
# vars in the merged config are never snapshotted.
env_updates = mock_proxy_config["env_updates"]()
assert env_updates == [
{"UI_LOGO_PATH": "https://example.com/new-logo.png", "LITELLM_FAVICON_URL": None}
{
"UI_LOGO_PATH": "https://example.com/new-logo.png",
"UI_LOGO_PATH_DARK": None,
"LITELLM_FAVICON_URL": None,
}
]

def test_update_ui_theme_settings_with_favicon(
Expand Down Expand Up @@ -1097,14 +1101,90 @@ def test_update_ui_theme_settings_with_favicon(

assert os.environ["UI_LOGO_PATH"] == "https://example.com/new-logo.png"
assert os.environ["LITELLM_FAVICON_URL"] == "https://example.com/custom-favicon.ico"
# Only the two owned keys are persisted, both with their new values
# Only the owned keys are persisted, each with its new value
assert mock_proxy_config["env_updates"]() == [
{
"UI_LOGO_PATH": "https://example.com/new-logo.png",
"UI_LOGO_PATH_DARK": None,
"LITELLM_FAVICON_URL": "https://example.com/custom-favicon.ico",
}
]

def test_update_ui_theme_settings_with_dark_logo(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""A dark-mode logo is stored and applied to the live process like the light one."""
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

new_theme = {
"logo_url": "https://example.com/logo.png",
"logo_url_dark": "https://example.com/logo-dark.png",
}

response = client.patch("/update/ui_theme_settings", json=new_theme)

assert response.status_code == 200
assert response.json()["theme_config"]["logo_url_dark"] == "https://example.com/logo-dark.png"
assert os.environ["UI_LOGO_PATH_DARK"] == "https://example.com/logo-dark.png"
assert mock_proxy_config["env_updates"]() == [
{
"UI_LOGO_PATH": "https://example.com/logo.png",
"UI_LOGO_PATH_DARK": "https://example.com/logo-dark.png",
"LITELLM_FAVICON_URL": None,
}
]

def test_update_ui_theme_settings_rejects_local_path_dark_logo(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""The dark logo is served by the unauthenticated /get_image, so a local
filesystem path must be refused exactly as it is for the light logo."""
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

response = client.patch(
"/update/ui_theme_settings",
json={"logo_url_dark": "/etc/passwd"},
)

assert response.status_code == 400
assert "logo_url_dark" in str(response.json())

def test_update_ui_theme_settings_persists_every_env_var_it_resolves(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""Read and write must cover the same env vars.

/get/ui_theme_settings resolves each field through _UI_THEME_FIELD_ENV_VARS,
so a var missing from the update path would read back from an env value the
save never cleared, and the settings page would show a field it cannot unset.
"""
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
_UI_THEME_FIELD_ENV_VARS,
)

monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)

response = client.patch("/update/ui_theme_settings", json={})

assert response.status_code == 200
persisted = mock_proxy_config["env_updates"]()
assert len(persisted) == 1
assert set(persisted[0]) == set(_UI_THEME_FIELD_ENV_VARS.values())

def test_get_ui_theme_settings_surfaces_dark_logo_from_process_env(
self, mock_proxy_config, monkeypatch
):
"""A dark logo supplied only as a process env var must surface in the read."""
monkeypatch.setenv("UI_LOGO_PATH_DARK", "https://cdn.example.com/logo-dark.png")

response = client.get("/get/ui_theme_settings")

assert response.status_code == 200
assert response.json()["values"]["logo_url_dark"] == "https://cdn.example.com/logo-dark.png"

def test_update_ui_theme_settings_clear_favicon(
self, mock_proxy_config, mock_auth, monkeypatch
):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ import { toast } from "@/lib/toast";
import UIThemeSettings from "./UIThemeSettings";

const setLogoUrl = vi.fn();
const setLogoUrlDark = vi.fn();
const setFaviconUrl = vi.fn();

vi.mock("@/contexts/ThemeContext", () => ({
useTheme: () => ({ logoUrl: null, setLogoUrl, faviconUrl: null, setFaviconUrl }),
useTheme: () => ({
logoUrl: null,
setLogoUrl,
logoUrlDark: null,
setLogoUrlDark,
faviconUrl: null,
setFaviconUrl,
}),
}));

vi.mock("@/components/networking", () => ({
Expand All @@ -19,6 +27,7 @@ vi.mock("@/components/networking", () => ({
}));

const LOGO_PLACEHOLDER = "https://example.com/logo.png";
const LOGO_DARK_PLACEHOLDER = "https://example.com/logo-dark.png";
const FAVICON_PLACEHOLDER = "https://example.com/favicon.ico";

const okResponse = (values: Record<string, string | null> = {}) =>
Expand Down Expand Up @@ -76,11 +85,32 @@ describe("UIThemeSettings", () => {
await waitFor(() => expect(patchCalls()).toHaveLength(1));
expect(bodyOf(patchCalls()[0])).toEqual({
logo_url: "https://a.test/logo.png",
logo_url_dark: null,
favicon_url: "https://a.test/fav.ico",
});
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Theme settings updated successfully!"));
});

it("should load and save a separate dark-mode logo url", async () => {
const user = userEvent.setup();
fetchMock.mockImplementation(() => okResponse({ logo_url_dark: "https://cdn.example.com/logo-dark.svg" }));

render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);

await waitFor(() => {
expect(screen.getByPlaceholderText(LOGO_DARK_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo-dark.svg");
});
expect(setLogoUrlDark).toHaveBeenCalledWith("https://cdn.example.com/logo-dark.svg");

fireEvent.change(screen.getByPlaceholderText(LOGO_DARK_PLACEHOLDER), {
target: { value: "https://a.test/logo-dark.png" },
});
await user.click(screen.getByRole("button", { name: "Save Changes" }));

await waitFor(() => expect(patchCalls()).toHaveLength(1));
expect(bodyOf(patchCalls()[0]).logo_url_dark).toBe("https://a.test/logo-dark.png");
});

it("should surface a backend failure when saving fails", async () => {
const user = userEvent.setup();
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
Expand Down Expand Up @@ -109,10 +139,12 @@ describe("UIThemeSettings", () => {
await user.click(screen.getByRole("button", { name: "Reset to Default" }));

await waitFor(() => expect(patchCalls()).toHaveLength(1));
expect(bodyOf(patchCalls()[0])).toEqual({ logo_url: null, favicon_url: null });
expect(bodyOf(patchCalls()[0])).toEqual({ logo_url: null, logo_url_dark: null, favicon_url: null });
expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("");
expect(screen.getByPlaceholderText(LOGO_DARK_PLACEHOLDER)).toHaveValue("");
expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue("");
expect(setLogoUrl).toHaveBeenLastCalledWith(null);
expect(setLogoUrlDark).toHaveBeenLastCalledWith(null);
expect(setFaviconUrl).toHaveBeenLastCalledWith(null);
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Theme settings reset to default!"));
});
Expand Down
Loading
Loading