Allow adding favicon to ui theme config

This commit is contained in:
yuneng-jiang 2026-01-02 15:33:31 -08:00
parent 57d9b9e591
commit be9da4004c
2 changed files with 172 additions and 24 deletions

View file

@ -22,13 +22,19 @@ class IPAddress(BaseModel):
class UIThemeConfig(BaseModel):
"""Configuration for UI theme customization"""
# Logo configuration
logo_url: Optional[str] = Field(
default=None,
description="URL or path to custom logo image. Can be a local file path or HTTP/HTTPS URL"
)
# Favicon configuration
favicon_url: Optional[str] = Field(
default=None,
description="URL or path to custom favicon image. Can be a local file path or HTTP/HTTPS URL"
)
class SettingsResponse(BaseModel):
"""Base response model for settings with values and schema information"""
@ -648,7 +654,7 @@ async def get_ui_theme_settings():
async def update_ui_theme_settings(theme_config: UIThemeConfig):
"""
Update UI theme configuration.
Updates logo settings for the admin UI.
Updates logo and favicon settings for the admin UI.
"""
from litellm.proxy.proxy_server import proxy_config, store_model_in_db
import os
@ -671,31 +677,66 @@ async def update_ui_theme_settings(theme_config: UIThemeConfig):
if "environment_variables" not in config:
config["environment_variables"] = {}
# Convert theme config to dict
theme_data = theme_config.model_dump(exclude_none=True)
# Convert theme config to dict - use exclude_unset to only get explicitly provided fields
# This allows us to distinguish between fields not provided vs fields set to None
theme_data = theme_config.model_dump(exclude_unset=True)
# Store UI theme config in litellm_settings (where it's retrieved from)
if "litellm_settings" not in config:
config["litellm_settings"] = {}
config["litellm_settings"]["ui_theme_config"] = theme_data
# Get existing ui_theme_config or create empty dict
existing_theme_config = config["litellm_settings"].get("ui_theme_config", {})
# Merge new values with existing config (only update provided fields)
merged_theme_config = existing_theme_config.copy()
# Update UI_LOGO_PATH environment variable if logo_url is provided
# If logo_url is empty string, None, or null, remove the environment variable to use default
logo_url = theme_data.get("logo_url")
verbose_proxy_logger.debug(f"Updating logo_url: {logo_url}")
# Handle explicit null values to remove fields (must check before updating)
for key, value in theme_data.items():
if value is None:
# Remove the field if it exists
merged_theme_config.pop(key, None)
else:
# Update with non-None values
merged_theme_config[key] = value
config["litellm_settings"]["ui_theme_config"] = merged_theme_config
if logo_url and isinstance(logo_url, str) and logo_url.strip(): # Check if logo_url exists and is not empty/whitespace
config["environment_variables"]["UI_LOGO_PATH"] = logo_url
os.environ["UI_LOGO_PATH"] = logo_url
verbose_proxy_logger.debug(f"Set UI_LOGO_PATH to: {logo_url}")
else:
# Remove the environment variable to restore default logo
if "UI_LOGO_PATH" in config.get("environment_variables", {}):
del config["environment_variables"]["UI_LOGO_PATH"]
verbose_proxy_logger.debug("Removed UI_LOGO_PATH from config")
if "UI_LOGO_PATH" in os.environ:
del os.environ["UI_LOGO_PATH"]
verbose_proxy_logger.debug("Removed UI_LOGO_PATH from environment")
# Update UI_LOGO_PATH environment variable only if logo_url was explicitly provided in the request
if "logo_url" in theme_data:
logo_url = theme_data["logo_url"]
verbose_proxy_logger.debug(f"Updating logo_url: {logo_url}")
if logo_url and isinstance(logo_url, str) and logo_url.strip(): # Check if logo_url is a valid non-empty string
config["environment_variables"]["UI_LOGO_PATH"] = logo_url
os.environ["UI_LOGO_PATH"] = logo_url
verbose_proxy_logger.debug(f"Set UI_LOGO_PATH to: {logo_url}")
else:
# Remove the environment variable to restore default logo (when explicitly set to None/empty)
if "UI_LOGO_PATH" in config.get("environment_variables", {}):
del config["environment_variables"]["UI_LOGO_PATH"]
verbose_proxy_logger.debug("Removed UI_LOGO_PATH from config")
if "UI_LOGO_PATH" in os.environ:
del os.environ["UI_LOGO_PATH"]
verbose_proxy_logger.debug("Removed UI_LOGO_PATH from environment")
# Update UI_FAVICON_PATH environment variable only if favicon_url was explicitly provided in the request
if "favicon_url" in theme_data:
favicon_url = theme_data["favicon_url"]
verbose_proxy_logger.debug(f"Updating favicon_url: {favicon_url}")
if favicon_url and isinstance(favicon_url, str) and favicon_url.strip(): # Check if favicon_url is a valid non-empty string
config["environment_variables"]["UI_FAVICON_PATH"] = favicon_url
os.environ["UI_FAVICON_PATH"] = favicon_url
verbose_proxy_logger.debug(f"Set UI_FAVICON_PATH to: {favicon_url}")
else:
# Remove the environment variable to restore default favicon (when explicitly set to None/empty)
if "UI_FAVICON_PATH" in config.get("environment_variables", {}):
del config["environment_variables"]["UI_FAVICON_PATH"]
verbose_proxy_logger.debug("Removed UI_FAVICON_PATH from config")
if "UI_FAVICON_PATH" in os.environ:
del os.environ["UI_FAVICON_PATH"]
verbose_proxy_logger.debug("Removed UI_FAVICON_PATH from environment")
# Handle environment variable encryption if needed
stored_config = config.copy()
@ -709,9 +750,9 @@ async def update_ui_theme_settings(theme_config: UIThemeConfig):
await proxy_config.save_config(new_config=stored_config)
return {
"message": "Logo settings updated successfully.",
"message": "UI theme settings updated successfully.",
"status": "success",
"theme_config": theme_data,
"theme_config": merged_theme_config,
}

View file

@ -666,6 +666,113 @@ class TestProxySettingEndpoints:
assert "UI_LOGO_PATH" in updated_config["environment_variables"]
assert mock_proxy_config["save_call_count"]() == 1
def test_update_favicon_url_by_itself(self, mock_proxy_config, mock_auth, monkeypatch):
"""Test updating favicon_url by itself should successfully save"""
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
new_theme = {"favicon_url": "https://example.com/favicon.ico"}
response = client.patch("/update/ui_theme_settings", json=new_theme)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["theme_config"]["favicon_url"] == "https://example.com/favicon.ico"
# Verify config was updated
updated_config = mock_proxy_config["config"]
assert "UI_FAVICON_PATH" in updated_config["environment_variables"]
assert updated_config["environment_variables"]["UI_FAVICON_PATH"] == "https://example.com/favicon.ico"
# Verify favicon_url is stored in litellm_settings
assert "litellm_settings" in updated_config
assert "ui_theme_config" in updated_config["litellm_settings"]
assert updated_config["litellm_settings"]["ui_theme_config"]["favicon_url"] == "https://example.com/favicon.ico"
assert mock_proxy_config["save_call_count"]() == 1
def test_update_favicon_url_with_existing_logo_url(self, mock_proxy_config, mock_auth, monkeypatch):
"""Test updating favicon_url when logo_url exists should not modify logo_url"""
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
# First, set up an existing logo_url
initial_config = mock_proxy_config["config"]
if "litellm_settings" not in initial_config:
initial_config["litellm_settings"] = {}
if "ui_theme_config" not in initial_config["litellm_settings"]:
initial_config["litellm_settings"]["ui_theme_config"] = {}
initial_config["litellm_settings"]["ui_theme_config"]["logo_url"] = "https://example.com/existing-logo.png"
initial_config["environment_variables"]["UI_LOGO_PATH"] = "https://example.com/existing-logo.png"
# Now update only favicon_url
new_theme = {"favicon_url": "https://example.com/new-favicon.ico"}
response = client.patch("/update/ui_theme_settings", json=new_theme)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["theme_config"]["favicon_url"] == "https://example.com/new-favicon.ico"
# Verify logo_url is still present and unchanged
assert data["theme_config"]["logo_url"] == "https://example.com/existing-logo.png"
# Verify both are in config
updated_config = mock_proxy_config["config"]
assert "UI_FAVICON_PATH" in updated_config["environment_variables"]
assert updated_config["environment_variables"]["UI_FAVICON_PATH"] == "https://example.com/new-favicon.ico"
assert "UI_LOGO_PATH" in updated_config["environment_variables"]
assert updated_config["environment_variables"]["UI_LOGO_PATH"] == "https://example.com/existing-logo.png"
# Verify both are in litellm_settings
assert updated_config["litellm_settings"]["ui_theme_config"]["favicon_url"] == "https://example.com/new-favicon.ico"
assert updated_config["litellm_settings"]["ui_theme_config"]["logo_url"] == "https://example.com/existing-logo.png"
def test_remove_favicon_url_with_existing_logo_url(self, mock_proxy_config, mock_auth, monkeypatch):
"""Test removing favicon_url should not modify logo_url"""
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
# First, set up both logo_url and favicon_url
initial_config = mock_proxy_config["config"]
if "litellm_settings" not in initial_config:
initial_config["litellm_settings"] = {}
if "ui_theme_config" not in initial_config["litellm_settings"]:
initial_config["litellm_settings"]["ui_theme_config"] = {}
initial_config["litellm_settings"]["ui_theme_config"]["logo_url"] = "https://example.com/existing-logo.png"
initial_config["litellm_settings"]["ui_theme_config"]["favicon_url"] = "https://example.com/existing-favicon.ico"
initial_config["environment_variables"]["UI_LOGO_PATH"] = "https://example.com/existing-logo.png"
initial_config["environment_variables"]["UI_FAVICON_PATH"] = "https://example.com/existing-favicon.ico"
# Now remove favicon_url by setting it to null
new_theme = {"favicon_url": None}
response = client.patch("/update/ui_theme_settings", json=new_theme)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Verify favicon_url is removed
assert "favicon_url" not in data["theme_config"]
# Verify logo_url is still present and unchanged
assert data["theme_config"]["logo_url"] == "https://example.com/existing-logo.png"
# Verify favicon_url is removed from config but logo_url remains
updated_config = mock_proxy_config["config"]
assert "UI_FAVICON_PATH" not in updated_config.get("environment_variables", {})
assert "UI_LOGO_PATH" in updated_config["environment_variables"]
assert updated_config["environment_variables"]["UI_LOGO_PATH"] == "https://example.com/existing-logo.png"
# Verify favicon_url is removed from litellm_settings but logo_url remains
assert "favicon_url" not in updated_config["litellm_settings"]["ui_theme_config"]
assert updated_config["litellm_settings"]["ui_theme_config"]["logo_url"] == "https://example.com/existing-logo.png"
def test_get_ui_settings(self, mock_auth, monkeypatch):
"""Test retrieving UI settings with allowlist sanitization"""
from unittest.mock import AsyncMock, MagicMock