From be9da4004c97681e310c188fb874b5aaac778dd0 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 2 Jan 2026 15:33:31 -0800 Subject: [PATCH] Allow adding favicon to ui theme config --- .../proxy_setting_endpoints.py | 89 +++++++++++---- .../test_proxy_setting_endpoints.py | 107 ++++++++++++++++++ 2 files changed, 172 insertions(+), 24 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index d9a41d38b22..d9bdb9bc23f 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -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, } 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 8fdfd6897a8..9fdcb1c5b3f 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 @@ -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