mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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:
commit
4851cba831
3 changed files with 261 additions and 41 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue