mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
82d3116be9
commit
1aba849af2
5 changed files with 275 additions and 6 deletions
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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([]) == []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue