fix(ui): surface env-var-sourced theme and logging-callback settings (#34156)

The UI theme and logging-callback read endpoints reported only stored
config while the features resolve their values from the process
environment, so a gateway configured purely through env vars showed
blank settings pages even though branding rendered and callbacks fired.

/get/ui_theme_settings read only litellm_settings.ui_theme_config;
logo_url and favicon_url now fall back to UI_LOGO_PATH and
LITELLM_FAVICON_URL when the stored config leaves them blank.

process_callback (the logging-callbacks block of /get/config/callbacks)
reported every callback env var as unset unless it lived in the config
environment_variables overlay; it now falls back to os.getenv, matching
the slack block. Secret values stay redacted for non-admins via the
existing callback role gate.

Stored values keep winning over the environment, so the UI-driven flow
is unchanged.

Resolves LIT-4667
This commit is contained in:
Yassin Kortam 2026-07-21 18:36:31 -07:00 • committed by GitHub
parent 82d3116be9
commit 1aba849af2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 275 additions and 6 deletions

View file

@ -1,4 +1,5 @@
import copy
import os
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Literal, Optional
import litellm
@ -564,11 +565,8 @@ def process_callback(_callback: str, callback_type: str, environment_variables:
env_vars_dict: dict[str, str | None] = {}
for _var in env_vars:
env_variable = environment_variables.get(_var, None)
if env_variable is None:
env_vars_dict[_var] = None
else:
env_vars_dict[_var] = env_variable
stored_value = environment_variables.get(_var, None)
env_vars_dict[_var] = stored_value if stored_value is not None else os.getenv(_var)
return {"name": _callback, "variables": env_vars_dict, "type": callback_type}

View file

@ -1,6 +1,8 @@
#### CRUD ENDPOINTS for UI Settings #####
import asyncio
import json
import os
from collections.abc import Mapping
from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union
from urllib.parse import urlparse
@ -35,6 +37,44 @@ _SSO_SENSITIVE_FIELDS: Set[str] = {
"generic_client_secret",
}
# Maps each UIThemeConfig field to the env var the UI branding path reads it
# from. /update/ui_theme_settings writes both the stored ui_theme_config and
# these env vars, so /get/ui_theme_settings resolves the same env vars to
# reflect a deployment branded purely through process env.
_UI_THEME_FIELD_ENV_VARS: dict[str, str] = {
"logo_url": "UI_LOGO_PATH",
"favicon_url": "LITELLM_FAVICON_URL",
}
def _is_public_http_url(value: str | None) -> bool:
"""Whether a value is a plain http(s) URL with a host, safe to disclose publicly."""
if not isinstance(value, str) or not value.strip():
return False
parsed = urlparse(value.strip())
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _resolve_ui_theme_field(stored_values: Mapping[str, Any], field_name: str) -> str | None:
"""Resolve one UI theme field to the value the branding path actually uses.
The stored ui_theme_config wins; a field absent or blank there falls back to
the process environment. The branding path reads the env var, and stored
settings reach it by being pushed into the environment on save, so a value
supplied only as a process env var is live even though no stored entry exists.
This endpoint is unauthenticated, so the env fallback only surfaces a public
http(s) URL: an operator can point UI_LOGO_PATH at a local filesystem path
(the branding path serves it server-side), and that path must not be
disclosed to anonymous callers. A stored value is already validated as a
public URL on write, so it passes through.
"""
stored = stored_values.get(field_name)
if isinstance(stored, str) and stored.strip():
return stored
env_value = os.environ.get(_UI_THEME_FIELD_ENV_VARS[field_name])
return env_value if _is_public_http_url(env_value) else None
class IPAddress(BaseModel):
ip: str
@ -977,12 +1017,19 @@ async def get_ui_theme_settings():
# Load existing config
config = await proxy_config.get_config()
return await _get_settings_with_schema(
result = await _get_settings_with_schema(
settings_key="ui_theme_config",
settings_class=UIThemeConfig,
config=config,
)
stored_values = result.get("values", {})
result["values"] = {
**stored_values,
**{field: _resolve_ui_theme_field(stored_values, field) for field in _UI_THEME_FIELD_ENV_VARS},
}
return result
def _validate_public_image_url(value: Optional[str], field_name: str) -> None:
"""

View file

@ -84,6 +84,52 @@ def test_process_callback_with_no_required_env_vars(mock_get_env_vars):
assert result["variables"] == {}
@patch(
"litellm.proxy.common_utils.callback_utils.CustomLogger.get_callback_env_vars",
return_value=["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"],
)
def test_process_callback_falls_back_to_process_env(mock_get_env_vars, monkeypatch):
"""A callback env var set only in the process env must be surfaced.
The logging integrations read their config from the process environment, so a
callback configured purely via env vars (IaC) is live even with no stored
entry. Reporting it as unset makes a working callback read as unconfigured.
"""
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "env-public-key")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "env-secret-key")
# stored config only carries the public key; the secret is env-only
environment_variables = {"LANGFUSE_PUBLIC_KEY": "db-public-key"}
result = process_callback(
_callback="langfuse",
callback_type="success",
environment_variables=environment_variables,
)
# stored value wins; the env-only var is resolved rather than reported None
assert result["variables"] == {
"LANGFUSE_PUBLIC_KEY": "db-public-key",
"LANGFUSE_SECRET_KEY": "env-secret-key",
}
@patch(
"litellm.proxy.common_utils.callback_utils.CustomLogger.get_callback_env_vars",
return_value=["LANGFUSE_SECRET_KEY"],
)
def test_process_callback_reports_none_when_absent_everywhere(mock_get_env_vars, monkeypatch):
"""A var set in neither the stored config nor the process env stays None."""
monkeypatch.delenv("LANGFUSE_SECRET_KEY", raising=False)
result = process_callback(
_callback="langfuse",
callback_type="success",
environment_variables={},
)
assert result["variables"] == {"LANGFUSE_SECRET_KEY": None}
def test_normalize_callback_names_none_returns_empty_list():
assert normalize_callback_names(None) == []
assert normalize_callback_names([]) == []

View file

@ -1015,6 +1015,102 @@ def test_get_config_custom_callback_api_env_vars(monkeypatch):
}
@patch(
"litellm.proxy.common_utils.callback_utils.CustomLogger.get_callback_env_vars",
return_value=["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST"],
)
def test_get_config_callbacks_fall_back_to_process_env(mock_env_vars, monkeypatch):
"""A callback configured purely via process env vars is surfaced.
An IaC deployment sets LANGFUSE_* on the gateway and never touches the UI,
so nothing is stored in the config environment_variables overlay. The read
endpoint must still report the live values instead of blanks.
"""
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-env-only")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-env-only")
monkeypatch.setenv("LANGFUSE_HOST", "https://cloud.langfuse.com")
config_data = {
"litellm_settings": {"success_callback": ["langfuse"]},
"general_settings": {},
"environment_variables": {},
}
mock_router = MagicMock()
mock_router.get_settings.return_value = {}
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data))
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234"
)
client = TestClient(app)
try:
response = client.get("/get/config/callbacks")
finally:
app.dependency_overrides = original_overrides
assert response.status_code == 200
langfuse_cb = next(
(cb for cb in response.json()["callbacks"] if cb["name"] == "langfuse"), None
)
assert langfuse_cb is not None
assert langfuse_cb["variables"] == {
"LANGFUSE_PUBLIC_KEY": "pk-env-only",
"LANGFUSE_SECRET_KEY": "sk-env-only",
"LANGFUSE_HOST": "https://cloud.langfuse.com",
}
@patch(
"litellm.proxy.common_utils.callback_utils.CustomLogger.get_callback_env_vars",
return_value=["LANGFUSE_SECRET_KEY", "LANGFUSE_HOST"],
)
def test_get_config_callback_env_secrets_redacted_for_non_admin(mock_env_vars, monkeypatch):
"""Surfacing env vars must not widen who can read secret values.
The callback role gate redacts sensitive keys for anyone below full admin,
and that must hold whether the value came from the stored config or the
process env. A non-secret var (LANGFUSE_HOST) still resolves for context.
"""
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-env-only-secret")
monkeypatch.setenv("LANGFUSE_HOST", "https://cloud.langfuse.com")
config_data = {
"litellm_settings": {"success_callback": ["langfuse"]},
"general_settings": {},
"environment_variables": {},
}
mock_router = MagicMock()
mock_router.get_settings.return_value = {}
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data))
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-user"
)
client = TestClient(app)
try:
response = client.get("/get/config/callbacks")
finally:
app.dependency_overrides = original_overrides
assert response.status_code == 200
langfuse_cb = next(
(cb for cb in response.json()["callbacks"] if cb["name"] == "langfuse"), None
)
assert langfuse_cb is not None
assert langfuse_cb["variables"]["LANGFUSE_SECRET_KEY"] == "REDACTED"
assert langfuse_cb["variables"]["LANGFUSE_HOST"] == "https://cloud.langfuse.com"
def test_get_config_returns_email_settings(monkeypatch):
"""
Regression for https://github.com/BerriAI/litellm/issues/19221

View file

@ -944,6 +944,88 @@ class TestProxySettingEndpoints:
assert data["values"]["logo_url"] == "https://example.com/logo.png"
assert data["values"]["favicon_url"] == "https://example.com/favicon.ico"
def test_get_ui_theme_settings_falls_back_to_process_env(
self, mock_proxy_config, monkeypatch
):
"""Branding supplied only as process env vars must surface in the read.
A deployment that sets UI_LOGO_PATH / LITELLM_FAVICON_URL via IaC and
never touches the UI has no stored ui_theme_config, yet the branding is
live, so the settings page must reflect it rather than reading blank.
"""
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
monkeypatch.delenv("LITELLM_FAVICON_URL", raising=False)
monkeypatch.setenv("UI_LOGO_PATH", "https://cdn.example.com/logo.png")
monkeypatch.setenv("LITELLM_FAVICON_URL", "https://cdn.example.com/favicon.ico")
response = client.get("/get/ui_theme_settings")
assert response.status_code == 200
values = response.json()["values"]
assert values["logo_url"] == "https://cdn.example.com/logo.png"
assert values["favicon_url"] == "https://cdn.example.com/favicon.ico"
def test_get_ui_theme_settings_stored_value_wins_over_env(
self, mock_auth, monkeypatch
):
"""A stored ui_theme_config field outranks the env var for that field.
The env fallback only fills fields the stored config leaves blank, so the
UI-driven flow is unchanged while an unstored field still resolves.
"""
from litellm.proxy.proxy_server import proxy_config
stored_config = {
"litellm_settings": {
"ui_theme_config": {"logo_url": "https://db.example.com/logo.png"}
}
}
async def mock_get_config():
return stored_config
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
monkeypatch.setenv("UI_LOGO_PATH", "https://env.example.com/logo.png")
monkeypatch.setenv("LITELLM_FAVICON_URL", "https://env.example.com/favicon.ico")
response = client.get("/get/ui_theme_settings")
assert response.status_code == 200
values = response.json()["values"]
assert values["logo_url"] == "https://db.example.com/logo.png"
assert values["favicon_url"] == "https://env.example.com/favicon.ico"
def test_get_ui_theme_settings_reports_unset_when_absent_everywhere(
self, mock_proxy_config, monkeypatch
):
"""A field set in neither the stored config nor the env stays null."""
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
monkeypatch.delenv("LITELLM_FAVICON_URL", raising=False)
response = client.get("/get/ui_theme_settings")
assert response.status_code == 200
values = response.json()["values"]
assert values["logo_url"] is None
assert values["favicon_url"] is None
def test_get_ui_theme_settings_does_not_disclose_local_path_env_value(
self, mock_proxy_config, monkeypatch
):
"""This endpoint is public, so an env-configured local filesystem branding
path must never be surfaced to anonymous callers; only public http(s) URLs.
"""
monkeypatch.setenv("UI_LOGO_PATH", "/mnt/secret/internal/logo.png")
monkeypatch.setenv("LITELLM_FAVICON_URL", "file:///etc/favicon.ico")
response = client.get("/get/ui_theme_settings")
assert response.status_code == 200
values = response.json()["values"]
# the local path / file scheme is withheld rather than disclosed
assert values["logo_url"] is None
assert values["favicon_url"] is None
def test_get_ui_settings(self, mock_auth, monkeypatch):
"""Test retrieving UI settings with allowlist sanitization"""
from unittest.mock import AsyncMock, MagicMock