Merge pull request #32075 from BerriAI/litellm_/admiring-sammet-3b1831

feat(cache): back the Redis URL and Database Index UI fields end-to-end
This commit is contained in:
yuneng-jiang 2026-07-07 06:32:57 -07:00 • committed by GitHub
commit 4851cba831
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 261 additions and 41 deletions

View file

@ -38,13 +38,35 @@ from litellm.types.management_endpoints import (
router = APIRouter()
# Cache fields holding credentials. Masked on read so plaintext Redis /
# Sentinel passwords never leave the server in a GET response.
_CACHE_SENSITIVE_FIELDS: set = {"password", "sentinel_password"}
# Sentinel passwords never leave the server in a GET response. `url` is here
# because a Redis/Valkey URL can embed a password inline
# (e.g. redis://:secret@host:6379/1).
_CACHE_SENSITIVE_FIELDS: set = {"password", "sentinel_password", "url"}
_REDACTED_VALUE = "***REDACTED***"
_URL_OVERRIDDEN_CONNECTION_FIELDS: frozenset = frozenset({"host", "port", "db", "password", "username"})
def _resolve_cache_url_precedence(settings: Mapping[str, Any]) -> dict[str, Any]:
"""Return cache settings with the url-vs-discrete-fields ambiguity resolved.
When a full ``url`` is supplied it wins: the discrete
host/port/db/password/username fields are dropped so the persisted config
is unambiguous and matches runtime resolution in ``litellm._redis``
(``redis.Redis.from_url`` ignores them). Cluster mode
(``redis_startup_nodes``) is exempt because it authenticates via the
discrete fields rather than a url.
"""
url = settings.get("url")
has_url = isinstance(url, str) and url.strip() != ""
if not has_url or settings.get("redis_startup_nodes"):
return dict(settings)
return {k: v for k, v in settings.items() if k not in _URL_OVERRIDDEN_CONNECTION_FIELDS}
def _redact_settings(settings: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
"""Replace every value in a settings map with a fixed marker.
@ -311,7 +333,7 @@ async def test_cache_connection(
from litellm import Cache
try:
cache_settings = request.cache_settings.copy()
cache_settings = _resolve_cache_url_precedence(request.cache_settings)
verbose_proxy_logger.debug("Testing cache connection with settings: %s", cache_settings)
# Only support Redis for now
@ -378,7 +400,7 @@ async def update_cache_settings(
)
try:
cache_settings = request.cache_settings.copy()
cache_settings = _resolve_cache_url_precedence(request.cache_settings)
# Snapshot the prior settings (key set only — values get redacted in
# the audit row) so the audit-log entry shows which fields changed.

View file

@ -40,6 +40,15 @@ CACHE_SETTINGS_FIELDS: List[CacheSettingsField] = [
redis_type=None,
),
# Common fields for all Redis types
CacheSettingsField(
field_name="url",
field_type="String",
field_value=None,
field_description="Full Redis/Valkey connection URL (e.g. redis://:password@host:6379/1). When set, it takes precedence over Host, Port, Username, Password, and Database Index.",
field_default=None,
ui_field_name="Redis URL",
redis_type=None,
),
CacheSettingsField(
field_name="host",
field_type="String",
@ -58,6 +67,15 @@ CACHE_SETTINGS_FIELDS: List[CacheSettingsField] = [
ui_field_name="Port",
redis_type=None,
),
CacheSettingsField(
field_name="db",
field_type="Integer",
field_value=None,
field_description="Logical database index to isolate the cache (e.g. 1 for redis://host:6379/1)",
field_default=None,
ui_field_name="Database Index",
redis_type=None,
),
CacheSettingsField(
field_name="password",
field_type="String",

View file

@ -10,20 +10,24 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sys.path.insert(
0, os.path.abspath("../../../..")
) # Adds the parent directory to the system path
sys.path.insert(0, os.path.abspath("../../../..")) # Adds the parent directory to the system path
import litellm
from litellm.proxy._types import LitellmTableNames, LitellmUserRoles
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.proxy.management_endpoints.cache_settings_endpoints import (
_CACHE_SENSITIVE_FIELDS,
CacheSettingsManager,
CacheSettingsUpdateRequest,
CacheTestRequest,
_resolve_cache_url_precedence,
get_cache_settings,
test_cache_connection,
update_cache_settings,
)
from litellm.types.management_endpoints.cache_settings_endpoints import (
CACHE_SETTINGS_FIELDS,
)
@pytest.mark.asyncio
@ -41,9 +45,7 @@ async def test_test_cache_connection_calls_cache_test_connection_with_params():
}
request = CacheTestRequest(cache_settings=cache_settings)
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-test", user_id="test-user"
)
user_api_key_dict = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-test", user_id="test-user")
# Mock Cache class and its test_connection method
mock_cache_instance = MagicMock()
@ -60,9 +62,7 @@ async def test_test_cache_connection_calls_cache_test_connection_with_params():
mock_cache_class.return_value = mock_cache_instance
# Call the endpoint
result = await test_cache_connection(
request=request, user_api_key_dict=user_api_key_dict
)
result = await test_cache_connection(request=request, user_api_key_dict=user_api_key_dict)
# Verify Cache was instantiated with correct params
mock_cache_class.assert_called_once_with(**cache_settings)
@ -76,6 +76,205 @@ async def test_test_cache_connection_calls_cache_test_connection_with_params():
assert result.error is None
def test_cache_settings_fields_expose_url_and_db():
"""The dynamic UI form is driven by CACHE_SETTINGS_FIELDS; url + db must be
present (with the right types) so the Redis URL and logical database index
are configurable from the Admin UI."""
by_name = {f.field_name: f for f in CACHE_SETTINGS_FIELDS}
assert "url" in by_name
assert "db" in by_name
# db is a logical database index → integer
assert by_name["db"].field_type == "Integer"
# Both are common connection fields, shown for every Redis type
assert by_name["url"].redis_type is None
assert by_name["db"].redis_type is None
class TestResolveCacheUrlPrecedence:
"""url wins over the discrete host/port/db/password fields."""
def test_url_overrides_discrete_connection_fields(self):
settings = {
"type": "redis",
"url": "redis://user:pw@host:6379/1",
"host": "host",
"port": "6379",
"db": 1,
"username": "user",
"password": "pw",
"namespace": "ns",
"ttl": 60,
}
result = _resolve_cache_url_precedence(settings)
assert result["url"] == "redis://user:pw@host:6379/1"
assert "host" not in result
assert "port" not in result
assert "db" not in result
# username and password are both encodable in the url, so the discrete
# copies must not ride along and override it
assert "username" not in result
assert "password" not in result
# Non-connection fields survive
assert result["type"] == "redis"
assert result["namespace"] == "ns"
assert result["ttl"] == 60
def test_no_url_returns_copy_unchanged(self):
settings = {"type": "redis", "host": "host", "port": "6379", "db": 1}
result = _resolve_cache_url_precedence(settings)
assert result == settings
assert result is not settings
def test_blank_url_does_not_strip_discrete_fields(self):
settings = {"type": "redis", "url": " ", "host": "host", "db": 2}
result = _resolve_cache_url_precedence(settings)
assert result["host"] == "host"
assert result["db"] == 2
def test_cluster_mode_keeps_discrete_fields(self):
settings = {
"type": "redis",
"url": "redis://host:6379",
"redis_startup_nodes": [{"host": "127.0.0.1", "port": "7001"}],
"host": "host",
"password": "pw",
}
result = _resolve_cache_url_precedence(settings)
assert result["host"] == "host"
assert result["password"] == "pw"
@pytest.mark.asyncio
async def test_test_cache_connection_url_takes_precedence_over_discrete_fields():
"""When url + discrete fields are both sent, the tested Cache instance is
built from the url alone (host/port/db/password dropped)."""
cache_settings = {
"type": "redis",
"url": "redis://:pw@host:6379/1",
"host": "ignored-host",
"port": "6379",
"db": 1,
"password": "pw",
}
request = CacheTestRequest(cache_settings=cache_settings)
user_api_key_dict = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-test", user_id="test-user")
mock_cache_instance = MagicMock()
mock_cache_instance.cache = MagicMock()
mock_cache_instance.cache.test_connection = AsyncMock(return_value={"status": "success", "message": "ok"})
with patch("litellm.Cache") as mock_cache_class:
mock_cache_class.return_value = mock_cache_instance
result = await test_cache_connection(request=request, user_api_key_dict=user_api_key_dict)
called_kwargs = mock_cache_class.call_args.kwargs
assert called_kwargs["url"] == "redis://:pw@host:6379/1"
assert "host" not in called_kwargs
assert "port" not in called_kwargs
assert "db" not in called_kwargs
assert "password" not in called_kwargs
assert result.status == "success"
@pytest.mark.asyncio
async def test_update_cache_settings_persists_url_precedence(monkeypatch):
"""The persisted (source-of-truth) row and the reinitialized cache both use
the url-resolved settings, so a stored config never carries a contradictory
host+url pair."""
monkeypatch.setattr(litellm, "store_audit_logs", False)
mock_prisma = MagicMock()
mock_prisma.db.litellm_cacheconfig.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_cacheconfig.upsert = AsyncMock()
proxy_config = MagicMock()
proxy_config._encrypt_env_variables = MagicMock(
side_effect=lambda environment_variables: dict(environment_variables)
)
proxy_config._decrypt_db_variables = MagicMock(side_effect=lambda variables_dict: dict(variables_dict))
proxy_config._init_cache = MagicMock()
proxy_config.switch_on_llm_response_caching = MagicMock()
with (
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
patch("litellm.proxy.proxy_server.proxy_config", proxy_config),
patch("litellm.proxy.proxy_server.store_model_in_db", True),
):
await update_cache_settings(
request=CacheSettingsUpdateRequest(
cache_settings={
"type": "redis",
"url": "redis://:pw@host:6379/1",
"host": "ignored-host",
"port": "6379",
"db": 1,
"password": "pw",
"namespace": "ns",
}
),
user_api_key_dict=_admin_auth(),
litellm_changed_by=None,
)
persisted = proxy_config._encrypt_env_variables.call_args.kwargs["environment_variables"]
assert persisted["url"] == "redis://:pw@host:6379/1"
assert persisted["namespace"] == "ns"
assert "host" not in persisted
assert "port" not in persisted
assert "db" not in persisted
assert "password" not in persisted
init_params = proxy_config._init_cache.call_args.kwargs["cache_params"]
assert "host" not in init_params
assert init_params["url"] == "redis://:pw@host:6379/1"
def test_url_is_a_masked_field():
"""A Redis URL can carry an inline password, so it must be masked on read
alongside the discrete password fields."""
assert "url" in _CACHE_SENSITIVE_FIELDS
@pytest.mark.asyncio
async def test_get_cache_settings_masks_password_bearing_url():
"""GET /cache/settings must not leak an inline url password in plaintext,
while non-credential fields (e.g. namespace) come back untouched."""
stored_url = "redis://:supersecretpassword@host:6379/1"
stored_settings = {"type": "redis", "url": stored_url, "namespace": "ns"}
cache_row = MagicMock()
cache_row.cache_settings = json.dumps(stored_settings)
mock_prisma = MagicMock()
mock_prisma.db.litellm_cacheconfig.find_unique = AsyncMock(return_value=cache_row)
proxy_config = MagicMock()
proxy_config._decrypt_db_variables = MagicMock(side_effect=lambda variables_dict: dict(variables_dict))
with (
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
patch("litellm.proxy.proxy_server.proxy_config", proxy_config),
):
response = await get_cache_settings(user_api_key_dict=_admin_auth())
returned_url = response.current_values["url"]
assert returned_url != stored_url
assert "supersecretpassword" not in returned_url
# non-credential field is not masked
assert response.current_values["namespace"] == "ns"
class TestCacheSettingsManager:
"""Tests for CacheSettingsManager class"""
@ -182,12 +381,8 @@ class TestCacheSettingsManager:
# Mock prisma client
mock_prisma_client = MagicMock()
mock_cache_config = MagicMock()
mock_cache_config.cache_settings = (
'{"type": "redis", "host": "localhost", "port": "6379"}'
)
mock_prisma_client.db.litellm_cacheconfig.find_unique = AsyncMock(
return_value=mock_cache_config
)
mock_cache_config.cache_settings = '{"type": "redis", "host": "localhost", "port": "6379"}'
mock_prisma_client.db.litellm_cacheconfig.find_unique = AsyncMock(return_value=mock_cache_config)
# Mock proxy_config
mock_proxy_config = MagicMock()
@ -231,12 +426,8 @@ class TestCacheSettingsManager:
# Mock prisma client
mock_prisma_client = MagicMock()
mock_cache_config = MagicMock()
mock_cache_config.cache_settings = (
'{"type": "redis", "host": "localhost", "port": "6379"}'
)
mock_prisma_client.db.litellm_cacheconfig.find_unique = AsyncMock(
return_value=mock_cache_config
)
mock_cache_config.cache_settings = '{"type": "redis", "host": "localhost", "port": "6379"}'
mock_prisma_client.db.litellm_cacheconfig.find_unique = AsyncMock(return_value=mock_cache_config)
# Mock proxy_config
mock_proxy_config = MagicMock()
@ -274,9 +465,7 @@ class TestCacheSettingsManager:
return None # No config → function returns early after retry.
mock_prisma_client = MagicMock()
mock_prisma_client.db.litellm_cacheconfig.find_unique = AsyncMock(
side_effect=_flaky_find_unique
)
mock_prisma_client.db.litellm_cacheconfig.find_unique = AsyncMock(side_effect=_flaky_find_unique)
mock_prisma_client.attempt_db_reconnect = AsyncMock(return_value=True)
mock_prisma_client._db_auth_reconnect_timeout_seconds = 2.0
mock_prisma_client._db_auth_reconnect_lock_timeout_seconds = 0.1
@ -289,10 +478,7 @@ class TestCacheSettingsManager:
assert len(invocations) == 2
mock_prisma_client.attempt_db_reconnect.assert_awaited_once()
reconnect_kwargs = mock_prisma_client.attempt_db_reconnect.await_args.kwargs
assert (
reconnect_kwargs["reason"]
== "init_cache_settings_in_db_lookup_failure"
)
assert reconnect_kwargs["reason"] == "init_cache_settings_in_db_lookup_failure"
# ── Audit-log emission for /cache/settings ────────────────────────────────────
@ -320,9 +506,7 @@ async def test_update_cache_settings_emits_audit_log_when_enabled(monkeypatch):
proxy_config._encrypt_env_variables = MagicMock(
side_effect=lambda environment_variables: dict(environment_variables)
)
proxy_config._decrypt_db_variables = MagicMock(
side_effect=lambda variables_dict: dict(variables_dict)
)
proxy_config._decrypt_db_variables = MagicMock(side_effect=lambda variables_dict: dict(variables_dict))
proxy_config._init_cache = MagicMock()
proxy_config.switch_on_llm_response_caching = MagicMock()
@ -392,9 +576,7 @@ async def test_update_cache_settings_no_audit_when_disabled(monkeypatch):
proxy_config._encrypt_env_variables = MagicMock(
side_effect=lambda environment_variables: dict(environment_variables)
)
proxy_config._decrypt_db_variables = MagicMock(
side_effect=lambda variables_dict: dict(variables_dict)
)
proxy_config._decrypt_db_variables = MagicMock(side_effect=lambda variables_dict: dict(variables_dict))
proxy_config._init_cache = MagicMock()
proxy_config.switch_on_llm_response_caching = MagicMock()
@ -422,9 +604,7 @@ async def test_update_cache_settings_no_audit_when_disabled(monkeypatch):
),
):
await update_cache_settings(
request=CacheSettingsUpdateRequest(
cache_settings={"type": "redis", "host": "redis.example.com"}
),
request=CacheSettingsUpdateRequest(cache_settings={"type": "redis", "host": "redis.example.com"}),
user_api_key_dict=_admin_auth(),
litellm_changed_by=None,
)