feat(proxy): configure the coordination redis independently of the response cache (#32661)

* fix(proxy): build redis usage cache from REDIS_* env when cache backend is not Redis

Selecting a semantic (or any non-Redis-KV) response cache left
redis_usage_cache unset, silently downgrading cross-pod rate limits,
parallel-request limits, spend coordination, and the pod lock manager
to per-pod in-memory state. Fall back to a standalone RedisCache built
from REDIS_* environment variables, mirroring the existing
use_redis_transaction_buffer escape hatch, which now shares the same
helper.

Resolves LIT-3861

* feat(proxy): configure the coordination redis independently of the response cache

Adds general_settings.coordination_redis, an explicit block for the Redis
the proxy uses for cross-pod rate limits, parallel-request limits, spend
tracking, the pod lock manager, and shared health checks. Resolution order
is the explicit block, then a plain-Redis response-cache backend, then the
REDIS_* environment. Cluster and sentinel targets are supported, and a
cluster target now builds a RedisClusterCache so cluster-aware consumers
take the cluster path.

Admins can configure it from the Caching page of the dashboard via
/coordination_redis/settings, which reports which source is in effect,
redacts credentials on read, and offers a connection test. Settings saved
there are read back at startup so they take effect on restart.

Also fixes redis client construction so an explicitly configured host
outranks REDIS_URL in the environment. Previously the url branch stripped
the caller's host and port, so an explicit block, or a connection test
typed into the dashboard, silently targeted whatever REDIS_URL named

* fix(ui): move coordination_redis_settings into renamed _components directory

---------

Co-authored-by: Yucheng Zhu <yucheng@berri.ai>
This commit is contained in:
Yassin Kortam 2026-07-11 02:15:59 +03:00 • committed by GitHub
parent 99b4c5ed3e
commit 3ea7f98725
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 3099 additions and 65 deletions

View file

@ -325,8 +325,19 @@ def _get_redis_client_logic(**env_overrides):
value = get_secret(v) # type: ignore
env_overrides[k] = value
environment_kwargs = _redis_kwargs_from_environment()
# An explicitly configured connection target outranks REDIS_URL from the
# environment. Without this, the url branch below strips the caller's
# host/port/password and silently connects to whatever REDIS_URL names.
caller_named_a_target = any(
env_overrides.get(key) is not None for key in ("host", "startup_nodes", "sentinel_nodes")
)
if caller_named_a_target and env_overrides.get("url") is None:
environment_kwargs.pop("url", None)
redis_kwargs = {
**_redis_kwargs_from_environment(),
**environment_kwargs,
**env_overrides,
}

View file

@ -2151,6 +2151,41 @@ class PluginConfig(LiteLLMPydanticObjectBase):
)
class CoordinationRedisNode(LiteLLMPydanticObjectBase):
"""A single startup node of a cluster-mode Redis used for proxy coordination."""
host: str = Field(description="hostname of the cluster node")
port: int = Field(description="port of the cluster node")
class CoordinationRedisParams(LiteLLMPydanticObjectBase):
"""
Connection params for the proxy's coordination Redis (cross-pod tpm/rpm rate
limits, spend tracking, pod lock manager, shared health checks), configured
independently of the response-cache backend in `litellm_settings.cache_params`.
"""
model_config = ConfigDict(extra="allow", protected_namespaces=())
host: Optional[str] = Field(None, description="Redis hostname")
port: Optional[int] = Field(None, description="Redis port")
password: Optional[str] = Field(None, description="Redis password")
username: Optional[str] = Field(None, description="Redis username")
url: Optional[str] = Field(None, description="full Redis connection url, e.g. redis://:pass@host:6379")
ssl: Optional[bool] = Field(None, description="connect over TLS")
startup_nodes: Optional[List[CoordinationRedisNode]] = Field(
None, description="cluster-mode startup nodes; when set a cluster client is used"
)
sentinel_nodes: Optional[List[List[Union[str, int]]]] = Field(
None, description="sentinel [host, port] pairs; when set a sentinel-managed client is used"
)
sentinel_password: Optional[str] = Field(None, description="password for the sentinel nodes")
service_name: Optional[str] = Field(None, description="sentinel service name")
def has_connection_target(self) -> bool:
return any(value is not None for value in (self.host, self.url, self.startup_nodes, self.sentinel_nodes))
class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"""
Documents all the fields supported by `general_settings` in config.yaml
@ -2166,6 +2201,15 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
use_google_kms: Optional[bool] = Field(None, description="decrypt keys with google kms")
use_azure_key_vault: Optional[bool] = Field(None, description="load keys from azure key vault")
master_key: Optional[str] = Field(None, description="require a key for all calls to proxy")
coordination_redis: Optional[CoordinationRedisParams] = Field(
None,
description=(
"standalone Redis for cross-pod coordination (tpm/rpm rate limits, "
"spend tracking, pod lock manager, shared health checks), configured "
"independently of the response-cache backend; takes precedence over "
"borrowing the `cache_params` Redis and over the REDIS_* env fallback"
),
)
allow_cli_sso_verification_uri_complete: bool | None = Field(
None,
description="opt-in to RFC 8628 verification_uri_complete for the CLI SSO device flow, pre-filling the user_code in the browser. Off by default; intended for same-host clients where the device that starts the flow and the browser run on the same machine",

View file

@ -5,8 +5,9 @@ model_list:
api_key: my-fake-key
api_base: os.environ/FAKE_OPENAI_API_BASE
litellm_settings:
cache: True
cache_params:
type: redis
general_settings:
coordination_redis:
host: os.environ/REDIS_HOST
port: os.environ/REDIS_PORT
password: os.environ/REDIS_PASSWORD

View file

@ -7,9 +7,6 @@ model_list:
general_settings:
use_redis_transaction_buffer: true
litellm_settings:
cache: True
cache_params:
type: redis
supported_call_types: []
coordination_redis:
host: os.environ/REDIS_HOST
port: os.environ/REDIS_PORT

View file

@ -0,0 +1,431 @@
"""
COORDINATION REDIS SETTINGS MANAGEMENT
Endpoints for managing `general_settings.coordination_redis` - the standalone
Redis the proxy uses for cross-pod coordination (tpm/rpm rate limits, spend
tracking, pod lock manager, shared health checks), configured independently of
the response-cache backend.
GET /coordination_redis/settings - Get the coordination Redis settings, field metadata, and which source is active
POST /coordination_redis/settings - Save coordination Redis settings to the database
POST /coordination_redis/settings/test - Test a coordination Redis connection with the provided credentials
"""
import asyncio
import json
from collections.abc import Mapping
from contextlib import suppress
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.caching.caching import RedisCache
from litellm.caching.redis_cluster_cache import RedisClusterCache
from litellm.proxy._types import (
AUDIT_ACTIONS,
CoordinationRedisParams,
LiteLLM_AuditLogs,
LitellmTableNames,
LitellmUserRoles,
UserAPIKeyAuth,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.utils import invalidate_config_param
from litellm.repositories.config_repository import ConfigRepository
from litellm.secret_managers.main import get_secret_str
from litellm.types.management_endpoints import (
COORDINATION_REDIS_SETTINGS_FIELDS,
CoordinationRedisSettingsField,
CoordinationRedisSource,
)
router = APIRouter()
_GENERAL_SETTINGS_PARAM_NAME = "general_settings"
_COORDINATION_REDIS_KEY = "coordination_redis"
# Fields that carry credentials. Redacted on read so a plaintext Redis /
# Sentinel password never leaves the server, and scrubbed out of connection-test
# error strings. `url` is here because a Redis url can embed a password inline
# (e.g. redis://:secret@host:6379/1).
_SENSITIVE_FIELDS: frozenset[str] = frozenset({"password", "sentinel_password", "url"})
_REDACTED_VALUE = "***REDACTED***"
_ENV_REF_PREFIX = "os.environ/"
_PING_TIMEOUT_SECONDS = 5.0
_SETTINGS_ADAPTER: TypeAdapter[dict[str, object]] = TypeAdapter(dict[str, object])
def _enforce_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None:
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={"error": "Only proxy admins can manage coordination Redis settings"},
)
def _resolve_env_ref(value: object) -> object:
"""Resolve an `os.environ/VAR` reference to its value, passing anything else through."""
if isinstance(value, str) and value.startswith(_ENV_REF_PREFIX):
return get_secret_str(value)
return value
def _resolve_env_refs(settings: Mapping[str, object]) -> dict[str, object]:
return {key: _resolve_env_ref(value) for key, value in settings.items()}
def _redact_credentials(settings: Mapping[str, object]) -> dict[str, object]:
"""Replace credential-bearing values with a fixed marker, keeping the rest intact."""
return {
key: (_REDACTED_VALUE if key in _SENSITIVE_FIELDS and value is not None else value)
for key, value in settings.items()
}
def _redact_all_values(settings: Optional[Mapping[str, object]]) -> dict[str, object]:
"""Replace every value with a fixed marker, preserving the key set.
The audit row shows *which* fields changed without the audit table becoming
a credential-harvest sink.
"""
if not settings:
return {}
return {key: _REDACTED_VALUE for key in settings}
def _credential_values(settings: Mapping[str, object]) -> tuple[str, ...]:
return tuple(
str(value) for key, value in settings.items() if key in _SENSITIVE_FIELDS and isinstance(value, (str, int))
)
def _scrub_credentials(message: str, settings: Mapping[str, object]) -> str:
"""Strip any credential value the caller supplied out of an error string.
Redis client errors routinely echo the connection url (password inline) or
the auth error back to the caller.
"""
scrubbed = message
for secret in _credential_values(settings):
if secret:
scrubbed = scrubbed.replace(secret, _REDACTED_VALUE)
return scrubbed
def _merge_over_saved(
incoming: Mapping[str, object],
saved: Mapping[str, object],
) -> dict[str, object]:
"""Restore the real credential behind every value the caller echoed back redacted.
GET returns credentials as ``***REDACTED***``; an admin who edits the
non-secret fields and re-submits would otherwise test (and save) the marker
as the password.
"""
return {
key: (saved[key] if value == _REDACTED_VALUE and key in saved else value) for key, value in incoming.items()
}
def _validated_params(settings: Mapping[str, object]) -> CoordinationRedisParams:
"""Validate settings the way startup does: resolve env refs, then require a connection target."""
try:
params = CoordinationRedisParams(**_resolve_env_refs(settings))
except ValidationError as e:
invalid_fields = sorted({str(error["loc"][0]) for error in e.errors() if error["loc"]})
raise HTTPException(
status_code=400,
detail={"error": f"Invalid coordination_redis settings for fields: {invalid_fields}"},
)
if not params.has_connection_target():
raise HTTPException(
status_code=400,
detail={
"error": (
"coordination_redis needs a connection target: "
"set one of host, url, startup_nodes, or sentinel_nodes"
)
},
)
return params
async def _read_general_settings() -> dict[str, object]:
"""Read the persisted `general_settings` config row (empty when unset or no DB)."""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
return {}
config_param = await ConfigRepository(prisma_client).get_param(_GENERAL_SETTINGS_PARAM_NAME)
if config_param is None or config_param.param_value is None:
return {}
return _SETTINGS_ADAPTER.validate_python(config_param.param_value)
async def get_persisted_coordination_redis_settings() -> Optional[dict[str, object]]:
"""The coordination_redis block saved to the database, if any.
Read at startup so settings saved from the admin UI take effect on the next
boot, and used here so a read reports what the proxy would boot with.
"""
persisted = (await _read_general_settings()).get(_COORDINATION_REDIS_KEY)
if isinstance(persisted, dict):
return _SETTINGS_ADAPTER.validate_python(persisted)
return None
async def _current_coordination_redis_settings() -> Optional[dict[str, object]]:
"""The coordination_redis block the proxy would boot with.
The persisted row wins over the yaml-loaded config state because startup
applies the DB `general_settings` row over the file config.
"""
from litellm.proxy.proxy_server import proxy_config
persisted = await get_persisted_coordination_redis_settings()
if persisted is not None:
return persisted
config_state = _SETTINGS_ADAPTER.validate_python(proxy_config.get_config_state())
general_settings = config_state.get(_GENERAL_SETTINGS_PARAM_NAME)
if not isinstance(general_settings, dict):
return None
from_file = general_settings.get(_COORDINATION_REDIS_KEY)
if isinstance(from_file, dict):
return _SETTINGS_ADAPTER.validate_python(from_file)
return None
def _coordination_redis_source(settings: Optional[Mapping[str, object]]) -> Optional[CoordinationRedisSource]:
"""Which source the proxy's coordination Redis comes from, in startup precedence order.
Mirrors `ProxyConfig._init_coordination_redis` -> `ProxyConfig._init_cache`:
an explicit block wins, else a plain-Redis response-cache backend is
borrowed, else the REDIS_* environment fallback applies.
"""
from litellm.proxy.proxy_server import _environment_has_redis_connection_target
if settings:
return "coordination_redis"
cache_backend = litellm.cache.cache if litellm.cache is not None else None
if isinstance(cache_backend, (RedisCache, RedisClusterCache)):
return "cache_backend"
if _environment_has_redis_connection_target():
return "environment"
return None
def _log_audit_task_exception(task: "asyncio.Task[None]") -> None:
"""Surface a fire-and-forget audit-log task failure as a warning."""
if task.cancelled():
return
exc = task.exception()
if exc is not None:
verbose_proxy_logger.warning("Failed to write coordination-redis-settings audit log: %s", exc)
async def _emit_coordination_redis_audit_log(
*,
action: AUDIT_ACTIONS,
before_settings: Optional[Mapping[str, object]],
after_settings: Optional[Mapping[str, object]],
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: Optional[str],
) -> None:
"""Emit an audit-log row for a /coordination_redis/settings mutation."""
if litellm.store_audit_logs is not True:
return
from litellm.proxy.management_helpers.audit_logs import create_audit_log_for_update
from litellm.proxy.proxy_server import litellm_proxy_admin_name
task = asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by or user_api_key_dict.user_id or litellm_proxy_admin_name,
changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.CONFIG_TABLE_NAME,
object_id=_COORDINATION_REDIS_KEY,
action=action,
updated_values=json.dumps({"settings": _redact_all_values(after_settings)}, default=str),
before_value=json.dumps({"settings": _redact_all_values(before_settings)}, default=str),
)
)
)
task.add_done_callback(_log_audit_task_exception)
class CoordinationRedisSettingsResponse(BaseModel):
values: dict[str, object] = Field(description="Current coordination Redis settings, with credentials redacted")
fields: list[CoordinationRedisSettingsField] = Field(
description="List of all configurable coordination Redis settings with metadata"
)
source: Optional[CoordinationRedisSource] = Field(
description="Where the proxy's coordination Redis comes from; null when it has none"
)
class CoordinationRedisSettingsRequest(BaseModel):
settings: dict[str, object] = Field(description="Coordination Redis connection params")
class CoordinationRedisTestResponse(BaseModel):
status: str = Field(description="Connection status: 'healthy' or 'unhealthy'")
error: Optional[str] = Field(default=None, description="Error message if the connection failed")
@router.get(
"/coordination_redis/settings",
tags=["Coordination Redis Settings"],
dependencies=[Depends(user_api_key_auth)],
response_model=CoordinationRedisSettingsResponse,
)
async def get_coordination_redis_settings(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> CoordinationRedisSettingsResponse:
"""
Get the coordination Redis configuration and available settings.
Returns:
- values: current coordination Redis settings, with password/sentinel_password/url redacted
- fields: all configurable settings with their metadata (type, description, default, section)
- source: "coordination_redis" | "cache_backend" | "environment" | null
"""
_enforce_proxy_admin(user_api_key_dict)
settings = await _current_coordination_redis_settings()
source = _coordination_redis_source(settings)
values = _redact_credentials(settings or {})
fields = [field.model_copy(deep=True) for field in COORDINATION_REDIS_SETTINGS_FIELDS]
for field in fields:
if field.field_name in values:
field.field_value = values[field.field_name]
return CoordinationRedisSettingsResponse(values=values, fields=fields, source=source)
@router.post(
"/coordination_redis/settings",
tags=["Coordination Redis Settings"],
dependencies=[Depends(user_api_key_auth)],
)
async def update_coordination_redis_settings(
request: CoordinationRedisSettingsRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(
None,
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
),
) -> dict[str, object]:
"""
Save coordination Redis settings under `general_settings.coordination_redis`.
Parameters:
- settings: dict - Redis connection params (host, port, username, password, url, ssl, startup_nodes, sentinel_nodes, sentinel_password, service_name). Values may be `os.environ/VAR` references, which are stored as written and resolved at startup
The settings are written to the `general_settings` row of LiteLLM_Config,
which startup merges over the yaml config; the proxy picks them up on its
next restart.
"""
from litellm.proxy.proxy_server import prisma_client, store_model_in_db
_enforce_proxy_admin(user_api_key_dict)
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": "Database not connected. Please connect a database."},
)
if store_model_in_db is not True:
raise HTTPException(
status_code=500,
detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."},
)
saved_settings = await _current_coordination_redis_settings()
settings = _merge_over_saved(request.settings, saved_settings or {})
_validated_params(settings)
general_settings = await _read_general_settings()
before_settings = general_settings.get(_COORDINATION_REDIS_KEY)
action: AUDIT_ACTIONS = "updated" if isinstance(before_settings, dict) else "created"
await ConfigRepository(prisma_client).set_param(
param_name=_GENERAL_SETTINGS_PARAM_NAME,
param_value={**general_settings, _COORDINATION_REDIS_KEY: settings},
)
await invalidate_config_param(_GENERAL_SETTINGS_PARAM_NAME)
# coordination_redis carries Redis credentials and decides where cross-pod
# rate-limit and spend state lives; an admin repointing it is a
# data-routing pivot, so make the change traceable.
await _emit_coordination_redis_audit_log(
action=action,
before_settings=before_settings if isinstance(before_settings, dict) else None,
after_settings=settings,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
return {
"message": "Coordination Redis settings updated successfully. Restart the proxy to apply them.",
"status": "success",
"settings": _redact_credentials(settings),
}
@router.post(
"/coordination_redis/settings/test",
tags=["Coordination Redis Settings"],
dependencies=[Depends(user_api_key_auth)],
response_model=CoordinationRedisTestResponse,
)
async def check_coordination_redis_connection(
request: CoordinationRedisSettingsRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> CoordinationRedisTestResponse:
"""
Test a coordination Redis connection with the provided credentials.
Parameters:
- settings: dict - Redis connection params to test. Credential fields sent back as `***REDACTED***` fall back to the saved value
Builds a throwaway client (never touching global state) and pings it.
"""
from litellm.proxy.proxy_server import _build_redis_usage_cache
_enforce_proxy_admin(user_api_key_dict)
saved_settings = await _current_coordination_redis_settings()
settings = _merge_over_saved(request.settings, saved_settings or {})
params = _validated_params(settings)
redis_cache: Optional[RedisCache] = None
try:
redis_cache = _build_redis_usage_cache(params.model_dump(exclude_none=True))
await asyncio.wait_for(redis_cache.ping(), timeout=_PING_TIMEOUT_SECONDS)
return CoordinationRedisTestResponse(status="healthy")
except asyncio.TimeoutError:
return CoordinationRedisTestResponse(
status="unhealthy",
error=f"Connection timed out after {_PING_TIMEOUT_SECONDS}s",
)
except Exception as e: # noqa: BLE001 # any client/connection failure is a health verdict, not a 500
return CoordinationRedisTestResponse(status="unhealthy", error=_scrub_credentials(str(e), settings))
finally:
if redis_cache is not None:
with suppress(Exception):
await redis_cache.disconnect()

View file

@ -75,6 +75,7 @@ from litellm.proxy._types import (
ConfigGeneralSettings,
ConfigList,
ConfigYAML,
CoordinationRedisParams,
EnterpriseLicenseData,
FieldDetail,
InvitationClaim,
@ -362,6 +363,10 @@ from litellm.proxy.management_endpoints.cache_settings_endpoints import (
from litellm.proxy.management_endpoints.callback_management_endpoints import (
router as callback_management_endpoints_router,
)
from litellm.proxy.management_endpoints.coordination_redis_endpoints import (
get_persisted_coordination_redis_settings,
router as coordination_redis_settings_router,
)
from litellm.proxy.management_endpoints.common_utils import (
_user_has_admin_privileges,
_user_has_admin_view,
@ -926,6 +931,16 @@ async def proxy_startup_event(app: FastAPI):
asyncio.create_task(_run_pw_migration())
## A coordination_redis block saved from the admin UI lives in the database,
## which is only reachable once the prisma client exists. Apply it here, before
## the coordination Redis is published to its consumers below.
db_coordination_redis_cache = await ProxyStartupEvent._init_coordination_redis_from_db(
litellm_settings=proxy_config.get_config_state().get("litellm_settings") or {},
llm_router=llm_router,
)
if db_coordination_redis_cache is not None:
_set_redis_usage_cache(db_coordination_redis_cache)
## use_redis_transaction_buffer: fall back to a standalone Redis (REDIS_* env)
## when the proxy cache backend is not Redis ##
transaction_buffer_redis_cache = redis_usage_cache
@ -3550,20 +3565,99 @@ def _apply_ssrf_general_settings(settings: Mapping[str, object]) -> None:
)
def _set_redis_usage_cache(coordination_redis_cache: RedisCache | None) -> None:
"""Publish the resolved coordination Redis to the consumers that read it directly."""
global redis_usage_cache
redis_usage_cache = coordination_redis_cache
def _resolve_coordination_redis_env_refs(raw_params: Mapping[str, object]) -> dict[str, object]:
"""Resolve `os.environ/VAR` references in a coordination_redis block."""
return {
key: (get_secret(value) if isinstance(value, str) and value.startswith("os.environ/") else value)
for key, value in raw_params.items()
}
def _build_redis_usage_cache(redis_params: Mapping[str, object]) -> RedisCache:
"""
Builds the proxy's coordination Redis client from resolved connection
params. Cluster-mode targets (explicit `startup_nodes` or the
REDIS_CLUSTER_NODES env var) get a `RedisClusterCache`, so consumers that
branch on cluster mode (e.g. the v3 rate limiter) take the cluster path;
everything else (host/url/sentinel) gets a plain `RedisCache`.
"""
startup_nodes = redis_params.get("startup_nodes")
if startup_nodes is None:
env_cluster_nodes = get_secret_str("REDIS_CLUSTER_NODES")
if env_cluster_nodes is not None:
startup_nodes = json.loads(env_cluster_nodes)
non_node_params = {key: value for key, value in redis_params.items() if key != "startup_nodes"}
if startup_nodes:
return RedisClusterCache(startup_nodes=startup_nodes, **non_node_params)
return RedisCache(**non_node_params)
def _environment_has_redis_connection_target() -> bool:
"""
Whether the REDIS_* environment variables name a Redis to connect to (host,
url, cluster nodes, or sentinel nodes). Read-only: callers that only need to
know whether the env fallback would apply use this instead of building a
client.
"""
redis_env_kwargs = litellm._redis._redis_kwargs_from_environment()
return (
"host" in redis_env_kwargs
or "url" in redis_env_kwargs
or get_secret_str("REDIS_CLUSTER_NODES") is not None
or get_secret_str("REDIS_SENTINEL_NODES") is not None
)
def _build_redis_usage_cache_from_environment() -> RedisCache | None:
"""
Builds a standalone RedisCache from REDIS_* environment variables.
Builds a standalone coordination Redis from REDIS_* environment variables.
Lets the proxy's coordination Redis (cross-pod tpm/rpm rate limits, spend
tracking, pod lock manager) run when the response-cache backend is not a
plain Redis KV cache (e.g. a semantic cache, disk, or s3).
Returns None when no Redis host or url is set in the environment.
Returns None when the environment carries no connection target (host, url,
cluster nodes, or sentinel nodes).
"""
redis_env_kwargs = litellm._redis._redis_kwargs_from_environment()
if "host" not in redis_env_kwargs and "url" not in redis_env_kwargs:
if not _environment_has_redis_connection_target():
return None
return RedisCache(**redis_env_kwargs)
return _build_redis_usage_cache(litellm._redis._redis_kwargs_from_environment())
def _attach_redis_usage_cache(redis_cache: RedisCache, enable_redis_auth_cache: bool) -> None:
"""
Wires an established coordination Redis into the proxy-level caches that
consume it directly: the spend counter cache, the cluster-wide config
cache, and (only when opted in) the virtual-key auth cache.
"""
spend_counter_cache.attach_redis_cache(
redis_cache,
default_redis_ttl=litellm.default_redis_ttl,
)
if enable_redis_auth_cache is True:
user_api_key_cache.attach_redis_cache(
redis_cache,
default_redis_ttl=litellm.default_redis_ttl,
)
verbose_proxy_logger.info(
"enable_redis_auth_cache=True: attached Redis to "
"user_api_key_cache — virtual-key lookups are now "
"shared across all proxy workers."
)
else:
verbose_proxy_logger.info(
"enable_redis_auth_cache is not set: user_api_key_cache "
"remains in-memory only (per-worker). Set "
"litellm_settings.enable_redis_auth_cache: true to share "
"the auth cache across workers and reduce DB load."
)
litellm_config_cache.redis_cache = redis_cache
class ProxyConfig:
@ -3764,12 +3858,52 @@ class ProxyConfig:
team_config = self._get_team_config(team_id=team_id, all_teams_config=all_teams_config)
return team_config
def _init_coordination_redis(self, config: dict) -> RedisCache | None:
"""
Builds the coordination Redis from `general_settings.coordination_redis`
when present, attaching it to the proxy-level caches. Runs before cache
init, so an explicit block takes precedence over borrowing the
response-cache Redis and over the REDIS_* env fallback. Returns the
built client (None when the block is absent) for the caller to publish.
"""
settings = config.get("general_settings") or {}
litellm_settings = config.get("litellm_settings") or {}
raw_params = settings.get("coordination_redis")
if raw_params is None:
return None
if not isinstance(raw_params, dict):
raise ValueError("general_settings.coordination_redis must be a mapping of Redis connection params")
coordination_params = CoordinationRedisParams(**_resolve_coordination_redis_env_refs(raw_params))
if not coordination_params.has_connection_target():
raise ValueError(
"general_settings.coordination_redis needs a connection target: "
"set one of host, url, startup_nodes, or sentinel_nodes"
)
coordination_redis_cache = _build_redis_usage_cache(coordination_params.model_dump(exclude_none=True))
_attach_redis_usage_cache(
coordination_redis_cache,
enable_redis_auth_cache=litellm_settings.get("enable_redis_auth_cache", False) is True,
)
verbose_proxy_logger.info(
"coordination_redis: using a standalone Redis from general_settings "
"for usage tracking, rate limiting, and cross-pod coordination."
)
return coordination_redis_cache
def _init_cache(
self,
cache_params: dict,
enable_redis_auth_cache: bool = False,
):
global redis_usage_cache, llm_router, general_settings
) -> RedisCache | None:
"""
Initializes the response cache and resolves the coordination Redis.
Returns the coordination Redis for the caller to publish: an explicit
coordination_redis block already set wins, else a plain-Redis response
cache backend is borrowed, else the REDIS_* environment fallback applies.
"""
from litellm import Cache
if "default_in_memory_ttl" in cache_params:
@ -3780,49 +3914,29 @@ class ProxyConfig:
litellm.cache = Cache(**cache_params)
resolved_usage_cache = redis_usage_cache
cache_backend = litellm.cache.cache if litellm.cache is not None else None
if isinstance(cache_backend, (RedisCache, RedisClusterCache)):
## INIT PROXY REDIS USAGE CLIENT ##
redis_usage_cache = cache_backend
elif redis_usage_cache is None:
redis_usage_cache = _build_redis_usage_cache_from_environment()
if redis_usage_cache is not None:
verbose_proxy_logger.info(
"Cache backend %s is not a Redis KV cache; built a standalone "
"Redis from REDIS_* environment variables for usage tracking, "
"rate limiting, and cross-pod coordination.",
type(cache_backend).__name__,
)
if redis_usage_cache is not None:
spend_counter_cache.attach_redis_cache(
redis_usage_cache,
default_redis_ttl=litellm.default_redis_ttl,
)
# Note: PKCE verifier storage uses redis_usage_cache directly (not
# user_api_key_cache) to avoid routing all API-key lookups through Redis.
if enable_redis_auth_cache is True:
user_api_key_cache.attach_redis_cache(
redis_usage_cache,
default_redis_ttl=litellm.default_redis_ttl,
)
verbose_proxy_logger.info(
"enable_redis_auth_cache=True: attached Redis to "
"user_api_key_cache — virtual-key lookups are now "
"shared across all proxy workers."
)
if resolved_usage_cache is None:
if isinstance(cache_backend, (RedisCache, RedisClusterCache)):
## INIT PROXY REDIS USAGE CLIENT ##
resolved_usage_cache = cache_backend
else:
verbose_proxy_logger.info(
"enable_redis_auth_cache is not set: user_api_key_cache "
"remains in-memory only (per-worker). Set "
"litellm_settings.enable_redis_auth_cache: true to share "
"the auth cache across workers and reduce DB load."
)
litellm_config_cache.redis_cache = redis_usage_cache
resolved_usage_cache = _build_redis_usage_cache_from_environment()
if resolved_usage_cache is not None:
verbose_proxy_logger.info(
"Cache backend %s is not a Redis KV cache; built a standalone "
"Redis from REDIS_* environment variables for usage tracking, "
"rate limiting, and cross-pod coordination.",
type(cache_backend).__name__,
)
if resolved_usage_cache is not None:
# Note: PKCE verifier storage uses redis_usage_cache directly (not
# user_api_key_cache) to avoid routing all API-key lookups through Redis.
_attach_redis_usage_cache(resolved_usage_cache, enable_redis_auth_cache)
elif litellm_config_cache.redis_cache is None:
verbose_proxy_logger.info("litellm_config_cache: no Redis configured; cluster-wide cache sharing disabled.")
return resolved_usage_cache
def switch_on_llm_response_caching(self):
"""
@ -4067,6 +4181,11 @@ class ProxyConfig:
self._load_environment_variables(config=config)
## Coordination Redis (before cache init, so the explicit block wins)
coordination_redis_cache = self._init_coordination_redis(config=config)
if coordination_redis_cache is not None:
_set_redis_usage_cache(coordination_redis_cache)
## Callback settings
callback_settings = config.get("callback_settings", {})
if callback_settings:
@ -4147,9 +4266,11 @@ class ProxyConfig:
cache_params[key] = get_secret(value)
## to pass a complete url, or set ssl=True, etc. just set it as `os.environ[REDIS_URL] = <your-redis-url>`, _redis.py checks for REDIS specific environment variables
self._init_cache(
cache_params=cache_params,
enable_redis_auth_cache=litellm_settings.get("enable_redis_auth_cache", False) is True,
_set_redis_usage_cache(
self._init_cache(
cache_params=cache_params,
enable_redis_auth_cache=litellm_settings.get("enable_redis_auth_cache", False) is True,
)
)
if litellm.cache is not None:
verbose_proxy_logger.debug(f"{blue_color_code}Set Cache on LiteLLM Proxy{reset_color_code}")
@ -7294,6 +7415,46 @@ class ProxyStartupEvent:
"Redis for the transaction buffer."
)
@staticmethod
async def _init_coordination_redis_from_db(
litellm_settings: Mapping[str, object],
llm_router: Optional[Router],
) -> RedisCache | None:
"""
Applies a coordination_redis block saved to the database, which the admin
UI writes and the config file therefore never carries.
Returns None when nothing is persisted or the persisted block names no
connection target, leaving the file/env resolution untouched.
"""
try:
persisted = await get_persisted_coordination_redis_settings()
except Exception as e: # noqa: BLE001 # a config-row read failure must not block proxy startup
verbose_proxy_logger.warning("Could not read coordination_redis from the database: %s", e)
return None
if persisted is None:
return None
coordination_params = CoordinationRedisParams(**_resolve_coordination_redis_env_refs(persisted))
if not coordination_params.has_connection_target():
verbose_proxy_logger.warning(
"coordination_redis saved in the database names no connection target; ignoring it."
)
return None
coordination_redis_cache = _build_redis_usage_cache(coordination_params.model_dump(exclude_none=True))
_attach_redis_usage_cache(
coordination_redis_cache,
enable_redis_auth_cache=litellm_settings.get("enable_redis_auth_cache", False) is True,
)
if llm_router is not None and llm_router.cache.redis_cache is None:
llm_router._update_redis_cache(cache=coordination_redis_cache)
verbose_proxy_logger.info(
"coordination_redis: using the standalone Redis saved in the database "
"for usage tracking, rate limiting, and cross-pod coordination."
)
return coordination_redis_cache
@staticmethod
def _get_transaction_buffer_redis_cache(
general_settings: dict,
@ -15835,6 +15996,7 @@ app.include_router(cost_tracking_settings_router)
app.include_router(router_settings_router)
app.include_router(fallback_management_router)
app.include_router(cache_settings_router)
app.include_router(coordination_redis_settings_router)
app.include_router(user_agent_analytics_router)
app.include_router(enterprise_router)
app.include_router(ui_discovery_endpoints_router)

View file

@ -7,6 +7,12 @@ from .cache_settings_endpoints import (
REDIS_TYPE_DESCRIPTIONS,
CacheSettingsField,
)
from .coordination_redis_endpoints import (
COORDINATION_REDIS_SETTINGS_FIELDS,
CoordinationRedisSection,
CoordinationRedisSettingsField,
CoordinationRedisSource,
)
from .router_settings_endpoints import (
ROUTER_SETTINGS_FIELDS,
ROUTING_STRATEGY_DESCRIPTIONS,
@ -20,4 +26,8 @@ __all__ = [
"CACHE_SETTINGS_FIELDS",
"REDIS_TYPE_DESCRIPTIONS",
"CacheSettingsField",
"COORDINATION_REDIS_SETTINGS_FIELDS",
"CoordinationRedisSection",
"CoordinationRedisSettingsField",
"CoordinationRedisSource",
]

View file

@ -0,0 +1,105 @@
"""
Types and field definitions for coordination Redis settings management endpoints
"""
from typing import Literal, Optional
from pydantic import BaseModel
CoordinationRedisSection = Literal["connection", "cluster", "sentinel"]
CoordinationRedisSource = Literal["coordination_redis", "cache_backend", "environment"]
class CoordinationRedisSettingsField(BaseModel):
field_name: str
field_type: str
field_value: Optional[object] = None
field_description: str
field_default: Optional[object] = None
ui_field_name: str
section: CoordinationRedisSection
COORDINATION_REDIS_SETTINGS_FIELDS: list[CoordinationRedisSettingsField] = [
CoordinationRedisSettingsField(
field_name="host",
field_type="String",
field_description="Redis server hostname or IP address",
ui_field_name="Host",
section="connection",
),
CoordinationRedisSettingsField(
field_name="port",
field_type="Integer",
field_description="Redis server port number",
field_default=6379,
ui_field_name="Port",
section="connection",
),
CoordinationRedisSettingsField(
field_name="username",
field_type="String",
field_description="Redis server username (if required)",
ui_field_name="Username",
section="connection",
),
CoordinationRedisSettingsField(
field_name="password",
field_type="String",
field_description="Redis server password",
ui_field_name="Password",
section="connection",
),
CoordinationRedisSettingsField(
field_name="url",
field_type="String",
field_description=(
"Full Redis connection URL (e.g. redis://:password@host:6379/1). "
"Set this instead of the discrete host/port/username/password fields."
),
ui_field_name="Redis URL",
section="connection",
),
CoordinationRedisSettingsField(
field_name="ssl",
field_type="Boolean",
field_description="Connect to Redis over TLS",
field_default=False,
ui_field_name="SSL",
section="connection",
),
CoordinationRedisSettingsField(
field_name="startup_nodes",
field_type="List",
field_description=(
"Cluster-mode startup nodes (e.g. [{'host': '127.0.0.1', 'port': 7001}]). "
"When set, a Redis Cluster client is used."
),
ui_field_name="Cluster Startup Nodes",
section="cluster",
),
CoordinationRedisSettingsField(
field_name="sentinel_nodes",
field_type="List",
field_description=(
"Sentinel [host, port] pairs (e.g. [['localhost', 26379]]). When set, a Sentinel-managed client is used."
),
ui_field_name="Sentinel Nodes",
section="sentinel",
),
CoordinationRedisSettingsField(
field_name="sentinel_password",
field_type="String",
field_description="Password for the Redis Sentinel nodes",
ui_field_name="Sentinel Password",
section="sentinel",
),
CoordinationRedisSettingsField(
field_name="service_name",
field_type="String",
field_description="Master service name for Redis Sentinel",
ui_field_name="Service Name",
section="sentinel",
),
]

View file

@ -189,7 +189,7 @@ litellm_settings:
langfuse_host: https://us.cloud.langfuse.com
# cache: true # [OPTIONAL] use for caching responses
# enable_caching_on_provider_specific_optional_params: True # Include provider-specific params in cache keys
# cache_params: # And for shared health check
# cache_params:
# type: redis
# host: localhost
# port: 6379
@ -228,8 +228,11 @@ general_settings:
proxy_batch_write_at: 1
database_connection_pool_limit: 10
# background_health_checks: true
# use_shared_health_check: true
# use_shared_health_check: true # needs a coordination Redis (below)
# health_check_interval: 30
# coordination_redis: # standalone Redis for cross-pod coordination: rate limits, spend tracking, pod locks, shared health checks
# host: localhost
# port: 6379
# cancel_on_disconnect: true # cancel the in-flight upstream LLM request (non-streaming) when the client disconnects, freeing backend capacity (e.g. a vLLM GPU slot)
# database_url: "postgresql://<user>:<password>@<host>:<port>/<dbname>" # [OPTIONAL] use for token-based auth to proxy

View file

@ -0,0 +1,577 @@
"""
Unit tests for coordination Redis settings management endpoints
"""
import asyncio
import json
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
sys.path.insert(0, os.path.abspath("../../../..")) # Adds the parent directory to the system path
import litellm
from litellm.caching.caching import RedisCache
from litellm.caching.redis_cluster_cache import RedisClusterCache
from litellm.proxy._types import LitellmTableNames, LitellmUserRoles
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.proxy.management_endpoints.coordination_redis_endpoints import (
_REDACTED_VALUE,
CoordinationRedisSettingsRequest,
get_coordination_redis_settings,
check_coordination_redis_connection,
update_coordination_redis_settings,
)
from litellm.types.management_endpoints.coordination_redis_endpoints import (
COORDINATION_REDIS_SETTINGS_FIELDS,
)
_SAVED_SETTINGS = {
"host": "coord-redis.example.com",
"port": 6379,
"password": "super-secret-redis-pw",
"url": "redis://:super-secret-redis-pw@coord-redis.example.com:6379",
"sentinel_password": "super-secret-sentinel-pw",
}
def _admin_auth() -> UserAPIKeyAuth:
return UserAPIKeyAuth(
api_key="hashed",
user_id="admin-user",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
def _prisma_with_general_settings(general_settings: dict | None) -> MagicMock:
"""A prisma client whose LiteLLM_Config `general_settings` row holds ``general_settings``."""
row = None
if general_settings is not None:
row = MagicMock()
row.param_value = json.dumps(general_settings)
mock_prisma = MagicMock()
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=row)
mock_prisma.db.litellm_config.upsert = AsyncMock()
return mock_prisma
def _proxy_config(file_general_settings: dict | None = None) -> MagicMock:
proxy_config = MagicMock()
proxy_config.get_config_state = MagicMock(
return_value={"general_settings": file_general_settings or {}},
)
return proxy_config
# ── GET /coordination_redis/settings ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_redacts_every_credential_field():
"""password, sentinel_password and the (password-bearing) url never leave the
server in plaintext; non-credential fields come back untouched."""
with (
patch(
"litellm.proxy.proxy_server.prisma_client",
_prisma_with_general_settings({"coordination_redis": _SAVED_SETTINGS}),
),
patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()),
):
response = await get_coordination_redis_settings(user_api_key_dict=_admin_auth())
serialized = json.dumps(response.model_dump())
assert "super-secret-redis-pw" not in serialized
assert "super-secret-sentinel-pw" not in serialized
assert response.values["password"] == _REDACTED_VALUE
assert response.values["sentinel_password"] == _REDACTED_VALUE
assert response.values["url"] == _REDACTED_VALUE
assert response.values["host"] == "coord-redis.example.com"
assert response.values["port"] == 6379
# field metadata is hydrated with the same redacted values
by_name = {field.field_name: field for field in response.fields}
assert by_name["password"].field_value == _REDACTED_VALUE
assert by_name["host"].field_value == "coord-redis.example.com"
@pytest.mark.asyncio
async def test_get_source_is_coordination_redis_when_block_present(monkeypatch):
"""An explicit block wins even when a Redis cache backend and REDIS_* env both exist."""
monkeypatch.setattr(litellm, "cache", MagicMock(cache=MagicMock(spec=RedisCache)))
monkeypatch.setenv("REDIS_HOST", "env-redis")
with (
patch(
"litellm.proxy.proxy_server.prisma_client",
_prisma_with_general_settings({"coordination_redis": _SAVED_SETTINGS}),
),
patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()),
):
response = await get_coordination_redis_settings(user_api_key_dict=_admin_auth())
assert response.source == "coordination_redis"
@pytest.mark.asyncio
async def test_get_source_reads_block_from_yaml_config_when_db_row_absent(monkeypatch):
"""A block set in config.yaml (not the DB) still reports source=coordination_redis."""
monkeypatch.setattr(litellm, "cache", None)
monkeypatch.delenv("REDIS_HOST", raising=False)
with (
patch("litellm.proxy.proxy_server.prisma_client", _prisma_with_general_settings(None)),
patch(
"litellm.proxy.proxy_server.proxy_config",
_proxy_config({"coordination_redis": {"host": "yaml-redis"}}),
),
):
response = await get_coordination_redis_settings(user_api_key_dict=_admin_auth())
assert response.source == "coordination_redis"
assert response.values["host"] == "yaml-redis"
@pytest.mark.parametrize("cache_backend_cls", [RedisCache, RedisClusterCache])
@pytest.mark.asyncio
async def test_get_source_is_cache_backend_when_no_block(monkeypatch, cache_backend_cls):
"""With no explicit block, a plain-Redis response-cache backend is borrowed —
which beats the REDIS_* env fallback."""
monkeypatch.setattr(litellm, "cache", MagicMock(cache=MagicMock(spec=cache_backend_cls)))
monkeypatch.setenv("REDIS_HOST", "env-redis")
with (
patch("litellm.proxy.proxy_server.prisma_client", _prisma_with_general_settings({})),
patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()),
):
response = await get_coordination_redis_settings(user_api_key_dict=_admin_auth())
assert response.source == "cache_backend"
assert response.values == {}
@pytest.mark.asyncio
async def test_get_source_is_environment_when_no_block_and_non_redis_cache(monkeypatch):
"""A non-Redis cache backend falls through to the REDIS_* env fallback."""
monkeypatch.setattr(litellm, "cache", MagicMock(cache=MagicMock()))
monkeypatch.setenv("REDIS_HOST", "env-redis")
with (
patch("litellm.proxy.proxy_server.prisma_client", _prisma_with_general_settings({})),
patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()),
):
response = await get_coordination_redis_settings(user_api_key_dict=_admin_auth())
assert response.source == "environment"
@pytest.mark.asyncio
async def test_get_source_is_none_when_nothing_configured(monkeypatch):
monkeypatch.setattr(litellm, "cache", None)
for env_var in ("REDIS_HOST", "REDIS_URL", "REDIS_CLUSTER_NODES", "REDIS_SENTINEL_NODES"):
monkeypatch.delenv(env_var, raising=False)
with (
patch("litellm.proxy.proxy_server.prisma_client", _prisma_with_general_settings({})),
patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()),
):
response = await get_coordination_redis_settings(user_api_key_dict=_admin_auth())
assert response.source is None
@pytest.mark.asyncio
async def test_get_source_does_not_build_a_client(monkeypatch):
"""The env-fallback probe is read-only: no Redis client is constructed on GET."""
monkeypatch.setattr(litellm, "cache", None)
monkeypatch.setenv("REDIS_HOST", "env-redis")
with (
patch("litellm.proxy.proxy_server.prisma_client", _prisma_with_general_settings({})),
patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()),
patch("litellm.proxy.proxy_server._build_redis_usage_cache") as mock_build,
):
response = await get_coordination_redis_settings(user_api_key_dict=_admin_auth())
assert response.source == "environment"
mock_build.assert_not_called()
@pytest.mark.asyncio
async def test_get_rejects_non_admin():
with pytest.raises(HTTPException) as exc_info:
await get_coordination_redis_settings(
user_api_key_dict=UserAPIKeyAuth(api_key="hashed", user_role=LitellmUserRoles.INTERNAL_USER)
)
assert exc_info.value.status_code == 403
def test_fields_cover_every_coordination_redis_param():
"""The declarative field list drives the Admin UI form; it must stay in sync
with the model the backend validates against."""
from litellm.proxy._types import CoordinationRedisParams
assert {field.field_name for field in COORDINATION_REDIS_SETTINGS_FIELDS} == set(
CoordinationRedisParams.model_fields.keys()
)
by_name = {field.field_name: field for field in COORDINATION_REDIS_SETTINGS_FIELDS}
assert by_name["startup_nodes"].section == "cluster"
assert by_name["sentinel_nodes"].section == "sentinel"
assert by_name["host"].section == "connection"
# ── POST /coordination_redis/settings ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_update_rejects_settings_without_a_connection_target(monkeypatch):
"""A block with no host/url/startup_nodes/sentinel_nodes would blow up at
startup; reject it at write time and persist nothing."""
monkeypatch.setattr(litellm, "store_audit_logs", False)
mock_prisma = _prisma_with_general_settings({})
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),
):
with pytest.raises(HTTPException) as exc_info:
await update_coordination_redis_settings(
request=CoordinationRedisSettingsRequest(settings={"ssl": True, "service_name": "mymaster"}),
user_api_key_dict=_admin_auth(),
litellm_changed_by=None,
)
assert exc_info.value.status_code == 400
mock_prisma.db.litellm_config.upsert.assert_not_called()
@pytest.mark.asyncio
async def test_update_persists_into_the_general_settings_config_row(monkeypatch):
"""Settings land under `general_settings.coordination_redis` in LiteLLM_Config
(the row startup merges over the yaml config), and sibling general_settings
keys survive the write."""
monkeypatch.setattr(litellm, "store_audit_logs", False)
mock_prisma = _prisma_with_general_settings({"master_key": "sk-1234"})
invalidated: list[str] = []
async def _capture_invalidate(param_name: str) -> None:
invalidated.append(param_name)
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),
patch(
"litellm.proxy.management_endpoints.coordination_redis_endpoints.invalidate_config_param",
new=_capture_invalidate,
),
):
response = await update_coordination_redis_settings(
request=CoordinationRedisSettingsRequest(
settings={"host": "coord-redis.example.com", "port": 6379, "password": "pw"}
),
user_api_key_dict=_admin_auth(),
litellm_changed_by=None,
)
upsert_kwargs = mock_prisma.db.litellm_config.upsert.call_args.kwargs
assert upsert_kwargs["where"] == {"param_name": "general_settings"}
persisted = json.loads(upsert_kwargs["data"]["update"]["param_value"])
assert persisted["coordination_redis"] == {
"host": "coord-redis.example.com",
"port": 6379,
"password": "pw",
}
assert persisted["master_key"] == "sk-1234"
assert invalidated == ["general_settings"]
# the response echoes the saved settings back redacted
assert response["settings"]["password"] == _REDACTED_VALUE
assert response["settings"]["host"] == "coord-redis.example.com"
@pytest.mark.asyncio
async def test_update_persists_os_environ_refs_verbatim(monkeypatch):
"""`os.environ/VAR` refs are resolved only to validate; the ref itself is what
gets stored, so the credential never lands in the DB."""
monkeypatch.setattr(litellm, "store_audit_logs", False)
monkeypatch.setenv("MY_REDIS_HOST", "resolved-host")
mock_prisma = _prisma_with_general_settings({})
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),
patch(
"litellm.proxy.management_endpoints.coordination_redis_endpoints.invalidate_config_param",
new=AsyncMock(),
),
):
await update_coordination_redis_settings(
request=CoordinationRedisSettingsRequest(settings={"host": "os.environ/MY_REDIS_HOST"}),
user_api_key_dict=_admin_auth(),
litellm_changed_by=None,
)
persisted = json.loads(mock_prisma.db.litellm_config.upsert.call_args.kwargs["data"]["update"]["param_value"])
assert persisted["coordination_redis"] == {"host": "os.environ/MY_REDIS_HOST"}
@pytest.mark.asyncio
async def test_update_keeps_saved_credential_when_client_echoes_the_redaction_marker(monkeypatch):
"""The UI reads settings back redacted; re-submitting them must not persist
`***REDACTED***` as the password."""
monkeypatch.setattr(litellm, "store_audit_logs", False)
mock_prisma = _prisma_with_general_settings({"coordination_redis": _SAVED_SETTINGS})
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),
patch(
"litellm.proxy.management_endpoints.coordination_redis_endpoints.invalidate_config_param",
new=AsyncMock(),
),
):
await update_coordination_redis_settings(
request=CoordinationRedisSettingsRequest(
settings={"host": "new-host", "port": 6380, "password": _REDACTED_VALUE}
),
user_api_key_dict=_admin_auth(),
litellm_changed_by=None,
)
persisted = json.loads(mock_prisma.db.litellm_config.upsert.call_args.kwargs["data"]["update"]["param_value"])
assert persisted["coordination_redis"]["password"] == "super-secret-redis-pw"
assert persisted["coordination_redis"]["host"] == "new-host"
@pytest.mark.asyncio
async def test_update_emits_audit_log_with_values_redacted(monkeypatch):
monkeypatch.setattr(litellm, "store_audit_logs", True)
mock_prisma = _prisma_with_general_settings({})
audit_calls = []
async def capture(request_data):
audit_calls.append(request_data)
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),
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
patch(
"litellm.proxy.management_endpoints.coordination_redis_endpoints.invalidate_config_param",
new=AsyncMock(),
),
patch("litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update", new=capture),
):
await update_coordination_redis_settings(
request=CoordinationRedisSettingsRequest(
settings={"host": "coord-redis.example.com", "password": "super-secret-redis-pw"}
),
user_api_key_dict=_admin_auth(),
litellm_changed_by=None,
)
for _ in range(3):
await asyncio.sleep(0)
assert len(audit_calls) == 1
log = audit_calls[0]
assert log.table_name == LitellmTableNames.CONFIG_TABLE_NAME
assert log.object_id == "coordination_redis"
assert log.action == "created" # no prior block → create
after = json.loads(log.updated_values)
assert set(after["settings"].keys()) == {"host", "password"}
assert "super-secret-redis-pw" not in log.updated_values
assert "coord-redis.example.com" not in log.updated_values
@pytest.mark.asyncio
async def test_update_audit_action_is_updated_when_a_block_already_exists(monkeypatch):
monkeypatch.setattr(litellm, "store_audit_logs", True)
mock_prisma = _prisma_with_general_settings({"coordination_redis": {"host": "old-host"}})
audit_calls = []
async def capture(request_data):
audit_calls.append(request_data)
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),
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
patch(
"litellm.proxy.management_endpoints.coordination_redis_endpoints.invalidate_config_param",
new=AsyncMock(),
),
patch("litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update", new=capture),
):
await update_coordination_redis_settings(
request=CoordinationRedisSettingsRequest(settings={"host": "new-host"}),
user_api_key_dict=_admin_auth(),
litellm_changed_by=None,
)
for _ in range(3):
await asyncio.sleep(0)
assert audit_calls[0].action == "updated"
assert json.loads(audit_calls[0].before_value)["settings"] == {"host": _REDACTED_VALUE}
@pytest.mark.asyncio
async def test_update_rejects_non_admin():
with pytest.raises(HTTPException) as exc_info:
await update_coordination_redis_settings(
request=CoordinationRedisSettingsRequest(settings={"host": "coord-redis.example.com"}),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed", user_role=LitellmUserRoles.INTERNAL_USER),
litellm_changed_by=None,
)
assert exc_info.value.status_code == 403
# ── POST /coordination_redis/settings/test ────────────────────────────────────
@pytest.mark.asyncio
async def test_connection_test_returns_healthy_on_successful_ping():
mock_client = MagicMock()
mock_client.ping = AsyncMock(return_value=True)
mock_client.disconnect = AsyncMock()
with (
patch("litellm.proxy.proxy_server.prisma_client", _prisma_with_general_settings({})),
patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()),
patch("litellm.proxy.proxy_server._build_redis_usage_cache", return_value=mock_client) as mock_build,
):
response = await check_coordination_redis_connection(
request=CoordinationRedisSettingsRequest(
settings={"host": "coord-redis.example.com", "port": 6379, "password": "pw"}
),
user_api_key_dict=_admin_auth(),
)
assert response.status == "healthy"
assert response.error is None
assert mock_build.call_args.args[0] == {"host": "coord-redis.example.com", "port": 6379, "password": "pw"}
mock_client.ping.assert_awaited_once()
mock_client.disconnect.assert_awaited_once()
@pytest.mark.asyncio
async def test_connection_test_reports_unhealthy_without_leaking_the_password():
"""Redis client errors echo the connection url back; the password must be
scrubbed out of the error the admin sees."""
mock_client = MagicMock()
mock_client.ping = AsyncMock(
side_effect=ConnectionError("Error connecting to redis://:super-secret-redis-pw@coord-redis.example.com:6379")
)
mock_client.disconnect = AsyncMock()
with (
patch("litellm.proxy.proxy_server.prisma_client", _prisma_with_general_settings({})),
patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()),
patch("litellm.proxy.proxy_server._build_redis_usage_cache", return_value=mock_client),
):
response = await check_coordination_redis_connection(
request=CoordinationRedisSettingsRequest(
settings={
"host": "coord-redis.example.com",
"url": "redis://:super-secret-redis-pw@coord-redis.example.com:6379",
"password": "super-secret-redis-pw",
}
),
user_api_key_dict=_admin_auth(),
)
assert response.status == "unhealthy"
assert response.error is not None
assert "super-secret-redis-pw" not in response.error
assert _REDACTED_VALUE in response.error
mock_client.disconnect.assert_awaited_once()
@pytest.mark.asyncio
async def test_connection_test_uses_the_saved_password_for_a_redacted_field():
"""An admin re-testing settings read back from GET sends `***REDACTED***`;
the saved credential is what actually gets dialed."""
mock_client = MagicMock()
mock_client.ping = AsyncMock(return_value=True)
mock_client.disconnect = AsyncMock()
with (
patch(
"litellm.proxy.proxy_server.prisma_client",
_prisma_with_general_settings({"coordination_redis": _SAVED_SETTINGS}),
),
patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()),
patch("litellm.proxy.proxy_server._build_redis_usage_cache", return_value=mock_client) as mock_build,
):
response = await check_coordination_redis_connection(
request=CoordinationRedisSettingsRequest(
settings={"host": "coord-redis.example.com", "password": _REDACTED_VALUE}
),
user_api_key_dict=_admin_auth(),
)
assert response.status == "healthy"
assert mock_build.call_args.args[0]["password"] == "super-secret-redis-pw"
@pytest.mark.asyncio
async def test_connection_test_times_out_instead_of_hanging():
async def _never_returns():
await asyncio.sleep(60)
mock_client = MagicMock()
mock_client.ping = MagicMock(side_effect=lambda: _never_returns())
mock_client.disconnect = AsyncMock()
with (
patch("litellm.proxy.proxy_server.prisma_client", _prisma_with_general_settings({})),
patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()),
patch("litellm.proxy.proxy_server._build_redis_usage_cache", return_value=mock_client),
patch(
"litellm.proxy.management_endpoints.coordination_redis_endpoints._PING_TIMEOUT_SECONDS",
0.01,
),
):
response = await check_coordination_redis_connection(
request=CoordinationRedisSettingsRequest(settings={"host": "unreachable"}),
user_api_key_dict=_admin_auth(),
)
assert response.status == "unhealthy"
assert "timed out" in (response.error or "")
@pytest.mark.asyncio
async def test_connection_test_rejects_settings_without_a_connection_target():
with (
patch("litellm.proxy.proxy_server.prisma_client", _prisma_with_general_settings({})),
patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()),
):
with pytest.raises(HTTPException) as exc_info:
await check_coordination_redis_connection(
request=CoordinationRedisSettingsRequest(settings={"ssl": True}),
user_api_key_dict=_admin_auth(),
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_connection_test_rejects_non_admin():
with pytest.raises(HTTPException) as exc_info:
await check_coordination_redis_connection(
request=CoordinationRedisSettingsRequest(settings={"host": "coord-redis.example.com"}),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed", user_role=LitellmUserRoles.INTERNAL_USER),
)
assert exc_info.value.status_code == 403

View file

@ -26,6 +26,7 @@ sys.path.insert(
import litellm
import litellm.proxy.proxy_server as proxy_server_module
from litellm.caching.caching import RedisCache
from litellm.caching.redis_cluster_cache import RedisClusterCache
from litellm.caching.dual_cache import DualCache
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
@ -9396,11 +9397,9 @@ def _run_init_cache_with_backend(cache_backend, redis_env_kwargs):
patch("litellm.Cache", return_value=mock_litellm_cache),
):
litellm.cache = None
proxy_server_module.ProxyConfig()._init_cache(
cache_params={"type": "qdrant-semantic"}
)
resolved = proxy_server_module.ProxyConfig()._init_cache(cache_params={"type": "qdrant-semantic"})
return (
proxy_server_module.redis_usage_cache,
resolved,
fresh_spend_cache.redis_cache,
fresh_config_cache.redis_cache,
)
@ -9448,3 +9447,242 @@ def test_init_cache_redis_backend_reuses_cache_backend_over_environment():
assert usage_cache is redis_backend
assert usage_cache.init_kwargs["host"] == "cache-params-host"
assert spend_redis is redis_backend
class _EnvBuiltClusterCache(RedisClusterCache):
"""RedisClusterCache stand-in that records constructor kwargs and never
opens a network connection."""
def __init__(self, **kwargs):
self.init_kwargs = kwargs
def _run_init_coordination_redis(config, env=None):
"""Run ProxyConfig._init_coordination_redis against a stubbed module state,
returning (redis_usage_cache, spend_counter redis, config-cache redis)."""
fresh_spend_cache = DualCache()
fresh_config_cache = types.SimpleNamespace(redis_cache=None)
with (
patch.object(proxy_server_module, "redis_usage_cache", None),
patch.object(proxy_server_module, "spend_counter_cache", fresh_spend_cache),
patch.object(proxy_server_module, "user_api_key_cache", DualCache()),
patch.object(proxy_server_module, "litellm_config_cache", fresh_config_cache),
patch.object(proxy_server_module, "RedisCache", _EnvBuiltRedisCache),
patch.object(proxy_server_module, "RedisClusterCache", _EnvBuiltClusterCache),
mock.patch.dict(os.environ, env or {}, clear=False),
):
built = proxy_server_module.ProxyConfig()._init_coordination_redis(config=config)
return (
built,
fresh_spend_cache.redis_cache,
fresh_config_cache.redis_cache,
)
def test_init_coordination_redis_explicit_block_builds_standalone_client():
"""general_settings.coordination_redis must build the coordination Redis
even when no response cache is configured at all, and attach it to the
spend counter and config caches."""
usage_cache, spend_redis, config_redis = _run_init_coordination_redis(
config={"general_settings": {"coordination_redis": {"host": "coord-host", "port": 6380}}},
)
assert isinstance(usage_cache, _EnvBuiltRedisCache)
assert usage_cache.init_kwargs["host"] == "coord-host"
assert usage_cache.init_kwargs["port"] == 6380
assert spend_redis is usage_cache
assert config_redis is usage_cache
def test_init_coordination_redis_resolves_os_environ_references():
"""os.environ/ values inside the coordination_redis block must be resolved
the same way cache_params values are."""
usage_cache, _, _ = _run_init_coordination_redis(
config={"general_settings": {"coordination_redis": {"host": "os.environ/COORD_REDIS_HOST"}}},
env={"COORD_REDIS_HOST": "resolved-host"},
)
assert usage_cache.init_kwargs["host"] == "resolved-host"
def test_init_coordination_redis_startup_nodes_builds_cluster_client():
"""A coordination_redis block with startup_nodes must construct a cluster
client, so cluster-aware consumers (v3 rate limiter) take the cluster path."""
usage_cache, _, _ = _run_init_coordination_redis(
config={
"general_settings": {
"coordination_redis": {"startup_nodes": [{"host": "node-1", "port": 7000}]}
}
},
)
assert isinstance(usage_cache, _EnvBuiltClusterCache)
assert usage_cache.init_kwargs["startup_nodes"] == [{"host": "node-1", "port": 7000}]
def test_init_coordination_redis_without_connection_target_raises():
"""A coordination_redis block with no host, url, startup_nodes, or
sentinel_nodes is a config error and must fail startup loudly instead of
silently running without coordination."""
with pytest.raises(ValueError, match="connection target"):
_run_init_coordination_redis(
config={"general_settings": {"coordination_redis": {"ssl": True}}},
)
def test_init_coordination_redis_non_mapping_block_raises():
"""A scalar coordination_redis value is a config error."""
with pytest.raises(ValueError, match="mapping"):
_run_init_coordination_redis(
config={"general_settings": {"coordination_redis": "redis://host:6379"}},
)
def test_init_coordination_redis_absent_leaves_usage_cache_unset():
"""Without the block, nothing changes: the coordination Redis stays unset
for the downstream borrow / env fallback logic to decide."""
usage_cache, spend_redis, _ = _run_init_coordination_redis(
config={"general_settings": {}},
)
assert usage_cache is None
assert spend_redis is None
def test_explicit_coordination_redis_takes_precedence_over_cache_backend():
"""When both an explicit coordination_redis block and a plain-Redis
response cache are configured, the explicit block must win; the cache
backend must not overwrite it."""
fresh_spend_cache = DualCache()
fresh_config_cache = types.SimpleNamespace(redis_cache=None)
cache_backend = _EnvBuiltRedisCache(host="cache-backend-host")
mock_litellm_cache = MagicMock()
mock_litellm_cache.cache = cache_backend
with (
patch.object(proxy_server_module, "redis_usage_cache", None),
patch.object(proxy_server_module, "spend_counter_cache", fresh_spend_cache),
patch.object(proxy_server_module, "user_api_key_cache", DualCache()),
patch.object(proxy_server_module, "llm_router", None),
patch.object(proxy_server_module, "litellm_config_cache", fresh_config_cache),
patch.object(proxy_server_module, "RedisCache", _EnvBuiltRedisCache),
patch.object(proxy_server_module, "RedisClusterCache", _EnvBuiltClusterCache),
patch("litellm.Cache", return_value=mock_litellm_cache),
):
litellm.cache = None
proxy_config = proxy_server_module.ProxyConfig()
built = proxy_config._init_coordination_redis(
config={"general_settings": {"coordination_redis": {"host": "explicit-coord-host"}}}
)
assert built is not None
proxy_server_module.redis_usage_cache = built
usage_cache = proxy_config._init_cache(cache_params={"type": "redis"})
assert isinstance(usage_cache, _EnvBuiltRedisCache)
assert usage_cache is not cache_backend
assert usage_cache.init_kwargs["host"] == "explicit-coord-host"
assert fresh_spend_cache.redis_cache is usage_cache
def test_env_fallback_builds_cluster_client_from_cluster_nodes_env():
"""A deployment whose only Redis env is REDIS_CLUSTER_NODES must still get
a coordination Redis from the env fallback, and it must be a cluster
client so cluster-aware consumers take the cluster path."""
nodes = '[{"host": "cnode-1", "port": 7000}]'
with (
patch.object(proxy_server_module, "RedisCache", _EnvBuiltRedisCache),
patch.object(proxy_server_module, "RedisClusterCache", _EnvBuiltClusterCache),
patch("litellm._redis._redis_kwargs_from_environment", return_value={}),
mock.patch.dict(os.environ, {"REDIS_CLUSTER_NODES": nodes}, clear=False),
):
result = proxy_server_module._build_redis_usage_cache_from_environment()
assert isinstance(result, _EnvBuiltClusterCache)
assert result.init_kwargs["startup_nodes"] == [{"host": "cnode-1", "port": 7000}]
def test_env_fallback_builds_client_from_sentinel_nodes_env():
"""A sentinel-only environment (REDIS_SENTINEL_NODES, no host or url) must
also produce a coordination Redis from the env fallback."""
with (
patch.object(proxy_server_module, "RedisCache", _EnvBuiltRedisCache),
patch.object(proxy_server_module, "RedisClusterCache", _EnvBuiltClusterCache),
patch("litellm._redis._redis_kwargs_from_environment", return_value={}),
mock.patch.dict(os.environ, {"REDIS_SENTINEL_NODES": '[["s1", 26379]]'}, clear=False),
):
result = proxy_server_module._build_redis_usage_cache_from_environment()
assert isinstance(result, _EnvBuiltRedisCache)
@pytest.mark.asyncio
async def test_startup_applies_coordination_redis_saved_in_database():
"""A coordination_redis block saved from the admin UI lives only in the
database, so startup must read it and build the coordination Redis from it.
Without this the save endpoint's "restart to apply" promise is false and the
proxy silently coordinates in per-pod memory."""
fresh_spend_cache = DualCache()
fresh_config_cache = types.SimpleNamespace(redis_cache=None)
with (
patch.object(proxy_server_module, "spend_counter_cache", fresh_spend_cache),
patch.object(proxy_server_module, "user_api_key_cache", DualCache()),
patch.object(proxy_server_module, "litellm_config_cache", fresh_config_cache),
patch.object(proxy_server_module, "RedisCache", _EnvBuiltRedisCache),
patch.object(proxy_server_module, "RedisClusterCache", _EnvBuiltClusterCache),
patch.object(
proxy_server_module,
"get_persisted_coordination_redis_settings",
AsyncMock(return_value={"host": "db-host", "port": 6381}),
),
):
result = await proxy_server_module.ProxyStartupEvent._init_coordination_redis_from_db(
litellm_settings={},
llm_router=None,
)
assert isinstance(result, _EnvBuiltRedisCache)
assert result.init_kwargs["host"] == "db-host"
assert fresh_spend_cache.redis_cache is result
assert fresh_config_cache.redis_cache is result
@pytest.mark.asyncio
async def test_startup_ignores_database_coordination_redis_without_connection_target():
"""A persisted block with no host/url/cluster/sentinel must be ignored rather
than crashing startup or building a client that cannot connect."""
with (
patch.object(proxy_server_module, "spend_counter_cache", DualCache()),
patch.object(proxy_server_module, "litellm_config_cache", types.SimpleNamespace(redis_cache=None)),
patch.object(proxy_server_module, "RedisCache", _EnvBuiltRedisCache),
patch.object(
proxy_server_module,
"get_persisted_coordination_redis_settings",
AsyncMock(return_value={"ssl": True}),
),
):
result = await proxy_server_module.ProxyStartupEvent._init_coordination_redis_from_db(
litellm_settings={},
llm_router=None,
)
assert result is None
@pytest.mark.asyncio
async def test_startup_survives_database_read_failure_for_coordination_redis():
"""A config-row read failure must not block proxy startup."""
with (
patch.object(
proxy_server_module,
"get_persisted_coordination_redis_settings",
AsyncMock(side_effect=RuntimeError("db unreachable")),
),
):
result = await proxy_server_module.ProxyStartupEvent._init_coordination_redis_from_db(
litellm_settings={},
llm_router=None,
)
assert result is None

View file

@ -629,3 +629,46 @@ def test_sync_client_url_used_when_no_cluster(mock_from_url, monkeypatch):
get_redis_client()
mock_from_url.assert_called_once()
@patch("litellm._redis.redis.Redis.from_url")
def test_explicit_host_outranks_environment_redis_url(mock_from_url, monkeypatch):
"""
An explicitly configured host must win over REDIS_URL in the environment.
Otherwise the url branch strips the caller's host/port and the client
silently connects to whatever REDIS_URL names, so an explicit config block
(or a connection test typed into the admin UI) targets the wrong server.
"""
monkeypatch.setenv("REDIS_URL", "redis://env-host:6379")
monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
client = get_redis_client(host="explicit-host", port=6380)
mock_from_url.assert_not_called()
assert client.connection_pool.connection_kwargs["host"] == "explicit-host"
assert client.connection_pool.connection_kwargs["port"] == 6380
@patch("litellm._redis.redis.Redis.from_url")
def test_explicit_url_still_wins_over_environment_host(mock_from_url, monkeypatch):
"""An explicit url argument keeps taking the from_url path."""
monkeypatch.setenv("REDIS_HOST", "env-host")
monkeypatch.setenv("REDIS_PORT", "6379")
monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
get_redis_client(url="redis://explicit-host:6380")
mock_from_url.assert_called_once()
assert mock_from_url.call_args.kwargs["url"] == "redis://explicit-host:6380"
@patch("litellm._redis.redis.Redis.from_url")
def test_environment_redis_url_used_when_caller_names_no_target(mock_from_url, monkeypatch):
"""With no caller-supplied connection target, REDIS_URL still drives the client."""
monkeypatch.setenv("REDIS_URL", "redis://env-host:6379")
monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
get_redis_client()
mock_from_url.assert_called_once()

View file

@ -25,6 +25,7 @@ import { adminGlobalCacheActivity, cachingHealthCheckCall } from "@/components/n
// Import the new component
import { CacheHealthTab } from "./cache_health";
import CacheSettings from "./cache_settings";
import CoordinationRedisSettings from "./coordination_redis_settings";
const formatDateWithoutTZ = (date: Date | undefined) => {
if (!date) return undefined;
@ -264,6 +265,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
<Tab>Cache Analytics</Tab>
<Tab>Cache Health</Tab>
<Tab>Cache Settings</Tab>
<Tab>Coordination Redis</Tab>
</div>
<div className="flex items-center space-x-2">
@ -383,6 +385,9 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
<TabPanel>
<CacheSettings accessToken={accessToken} userRole={userRole} userID={userID} />
</TabPanel>
<TabPanel>
<CoordinationRedisSettings />
</TabPanel>
</TabPanels>
</TabGroup>
);

View file

@ -0,0 +1,46 @@
import React from "react";
import CoordinationRedisFormField from "./CoordinationRedisFormField";
import { fieldsForSection } from "./coordinationRedisUtils";
import { CoordinationRedisType, CoordinationSection } from "./coordinationRedisFields";
interface CoordinationRedisFieldSectionProps {
title: string;
section: CoordinationSection;
redisType: CoordinationRedisType;
configuredSecrets: ReadonlySet<string>;
gridCols?: string;
headingLevel?: "h4" | "h5";
}
const CoordinationRedisFieldSection: React.FC<CoordinationRedisFieldSectionProps> = ({
title,
section,
redisType,
configuredSecrets,
gridCols = "grid-cols-1 gap-6 sm:grid-cols-2",
headingLevel = "h4",
}) => {
const fields = fieldsForSection(section, redisType);
if (fields.length === 0) {
return null;
}
const Heading = headingLevel;
return (
<div className="space-y-6">
<Heading className="text-sm font-medium text-gray-900">{title}</Heading>
<div className={`grid ${gridCols}`}>
{fields.map((field) => (
<CoordinationRedisFormField
key={field.name}
field={field}
isSecretConfigured={configuredSecrets.has(field.name)}
/>
))}
</div>
</div>
);
};
export default CoordinationRedisFieldSection;

View file

@ -0,0 +1,39 @@
import { Form, Input, Switch } from "antd";
import React from "react";
import { CoordinationField } from "./coordinationRedisFields";
export const SECRET_ALREADY_SET_PLACEHOLDER = "Already set. Enter a new value to replace it.";
interface CoordinationRedisFormFieldProps {
field: CoordinationField;
isSecretConfigured: boolean;
}
const renderControl = (field: CoordinationField, placeholder: string): React.ReactNode => {
switch (field.type) {
case "boolean":
return <Switch />;
case "password":
return <Input.Password placeholder={placeholder} autoComplete="new-password" />;
case "integer":
return <Input inputMode="numeric" placeholder={placeholder} />;
case "list":
return <Input.TextArea rows={4} placeholder={placeholder} />;
default:
return <Input placeholder={placeholder} />;
}
};
const CoordinationRedisFormField: React.FC<CoordinationRedisFormFieldProps> = ({ field, isSecretConfigured }) => (
<Form.Item
name={field.name}
label={field.label}
extra={field.helpText}
rules={field.rules}
valuePropName={field.type === "boolean" ? "checked" : "value"}
>
{renderControl(field, isSecretConfigured ? SECRET_ALREADY_SET_PLACEHOLDER : field.helpText)}
</Form.Item>
);
export default CoordinationRedisFormField;

View file

@ -0,0 +1,33 @@
import React from "react";
import { Select } from "antd";
import {
COORDINATION_REDIS_TYPES,
COORDINATION_REDIS_TYPE_DESCRIPTIONS,
COORDINATION_REDIS_TYPE_LABELS,
CoordinationRedisType,
} from "./coordinationRedisFields";
interface CoordinationRedisTypeSelectorProps {
redisType: CoordinationRedisType;
onTypeChange: (type: CoordinationRedisType) => void;
}
const OPTIONS = COORDINATION_REDIS_TYPES.map((type) => ({ value: type, label: COORDINATION_REDIS_TYPE_LABELS[type] }));
const CoordinationRedisTypeSelector: React.FC<CoordinationRedisTypeSelectorProps> = ({ redisType, onTypeChange }) => (
<div className="space-y-2">
<label htmlFor="coordination-redis-type" className="text-sm font-medium text-gray-700">
Redis Type
</label>
<Select
id="coordination-redis-type"
value={redisType}
onChange={onTypeChange}
options={OPTIONS}
style={{ width: "100%" }}
/>
<p className="text-xs text-gray-500">{COORDINATION_REDIS_TYPE_DESCRIPTIONS[redisType]}</p>
</div>
);
export default CoordinationRedisTypeSelector;

View file

@ -0,0 +1,165 @@
import type { FormItemProps } from "antd";
export type CoordinationFieldType = "string" | "password" | "integer" | "boolean" | "list";
export type CoordinationRedisType = "node" | "cluster" | "sentinel";
export type CoordinationSection = "connection" | "cluster" | "sentinel" | "ssl";
export type CoordinationFieldRule = NonNullable<FormItemProps["rules"]>[number];
export interface CoordinationField {
readonly name: string;
readonly label: string;
readonly type: CoordinationFieldType;
readonly section: CoordinationSection;
readonly helpText: string;
readonly redisType: CoordinationRedisType | null;
readonly secret: boolean;
readonly defaultValue?: string | number | boolean;
readonly rules?: CoordinationFieldRule[];
}
export const COORDINATION_REDIS_TYPES: readonly CoordinationRedisType[] = ["node", "cluster", "sentinel"];
export const COORDINATION_REDIS_TYPE_DESCRIPTIONS: Readonly<Record<CoordinationRedisType, string>> = {
node: "Standard Redis node/single instance",
cluster: "Redis Cluster mode for high availability and horizontal scaling",
sentinel: "Redis Sentinel mode for high availability with automatic failover",
};
export const COORDINATION_REDIS_TYPE_LABELS: Readonly<Record<CoordinationRedisType, string>> = {
node: "Node (Single Instance)",
cluster: "Cluster",
sentinel: "Sentinel",
};
const portRule: CoordinationFieldRule = {
validator: (_rule, value) => {
if (value === undefined || value === null || String(value).trim() === "") {
return Promise.resolve();
}
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return Promise.reject(new Error("Port must be an integer between 1 and 65535"));
}
return Promise.resolve();
},
};
const jsonListRule: CoordinationFieldRule = {
validator: (_rule, value) => {
if (value === undefined || value === null || String(value).trim() === "") {
return Promise.resolve();
}
let parsed: unknown;
try {
parsed = JSON.parse(String(value));
} catch {
return Promise.reject(new Error("Must be a valid JSON array (use double quotes)"));
}
if (!Array.isArray(parsed)) {
return Promise.reject(new Error("Must be a JSON array"));
}
return Promise.resolve();
},
};
export const COORDINATION_FIELDS: readonly CoordinationField[] = [
{
name: "url",
label: "Redis URL",
type: "password",
section: "connection",
helpText:
"Full Redis/Valkey connection URL (e.g. redis://:password@host:6379/1). When set, it takes precedence over Host, Port, Username, and Password.",
redisType: null,
secret: true,
},
{
name: "host",
label: "Host",
type: "string",
section: "connection",
helpText: "Redis server hostname or IP address",
redisType: null,
secret: false,
},
{
name: "port",
label: "Port",
type: "integer",
section: "connection",
helpText: "Redis server port number",
redisType: null,
secret: false,
defaultValue: "6379",
rules: [portRule],
},
{
name: "username",
label: "Username",
type: "string",
section: "connection",
helpText: "Redis server username (if required)",
redisType: null,
secret: false,
},
{
name: "password",
label: "Password",
type: "password",
section: "connection",
helpText: "Redis server password",
redisType: null,
secret: true,
},
{
name: "startup_nodes",
label: "Startup Nodes",
type: "list",
section: "cluster",
helpText: 'List of startup nodes for Redis Cluster (e.g., [{"host": "127.0.0.1", "port": 7001}])',
redisType: "cluster",
secret: false,
rules: [jsonListRule],
},
{
name: "sentinel_nodes",
label: "Sentinel Nodes",
type: "list",
section: "sentinel",
helpText: 'List of Sentinel nodes (e.g., [["localhost", 26379]])',
redisType: "sentinel",
secret: false,
rules: [jsonListRule],
},
{
name: "service_name",
label: "Service Name",
type: "string",
section: "sentinel",
helpText: "Master service name for Redis Sentinel",
redisType: "sentinel",
secret: false,
},
{
name: "sentinel_password",
label: "Sentinel Password",
type: "password",
section: "sentinel",
helpText: "Password for Redis Sentinel authentication",
redisType: "sentinel",
secret: true,
},
{
name: "ssl",
label: "SSL",
type: "boolean",
section: "ssl",
helpText: "Enable SSL/TLS connection",
redisType: null,
secret: false,
defaultValue: false,
},
];

View file

@ -0,0 +1,154 @@
import { describe, it, expect } from "vitest";
import {
buildCoordinationPayload,
buildInitialValues,
configuredSecretFields,
fieldsForSection,
inferRedisType,
sourceBadge,
} from "./coordinationRedisUtils";
import { REDACTED_VALUE } from "./types";
describe("fieldsForSection", () => {
it("should only include a redis-type-specific field when that type is selected", () => {
expect(fieldsForSection("cluster", "cluster").map((f) => f.name)).toEqual(["startup_nodes"]);
expect(fieldsForSection("cluster", "node")).toEqual([]);
expect(fieldsForSection("sentinel", "sentinel").map((f) => f.name)).toEqual([
"sentinel_nodes",
"service_name",
"sentinel_password",
]);
});
it("should include connection fields for every redis type in schema order", () => {
expect(fieldsForSection("connection", "sentinel").map((f) => f.name)).toEqual([
"url",
"host",
"port",
"username",
"password",
]);
});
});
describe("inferRedisType", () => {
it("should infer sentinel when sentinel nodes are configured", () => {
expect(inferRedisType({ sentinel_nodes: [["localhost", 26379]] })).toBe("sentinel");
});
it("should infer cluster when startup nodes are configured", () => {
expect(inferRedisType({ startup_nodes: [{ host: "127.0.0.1", port: 7001 }] })).toBe("cluster");
});
it("should infer node when neither cluster nor sentinel nodes are configured", () => {
expect(inferRedisType({ host: "localhost" })).toBe("node");
expect(inferRedisType({ startup_nodes: [], sentinel_nodes: [] })).toBe("node");
});
});
describe("buildInitialValues", () => {
it("should apply defaults as strings for text inputs and coerce booleans", () => {
const values = buildInitialValues({});
expect(values.port).toBe("6379");
expect(values.ssl).toBe(false);
expect(values.host).toBe("");
});
it("should never load a redacted secret into the form, so typing cannot append to the marker", () => {
const values = buildInitialValues({ password: REDACTED_VALUE, url: REDACTED_VALUE });
expect(values.password).toBe("");
expect(values.url).toBe("");
});
it("should stringify list values so they render in a textarea", () => {
const nodes = [{ host: "127.0.0.1", port: 7001 }];
const values = buildInitialValues({ startup_nodes: nodes });
expect(values.startup_nodes).toBe(JSON.stringify(nodes, null, 2));
});
});
describe("configuredSecretFields", () => {
it("should report which secrets the backend already holds so the form can say so", () => {
expect(configuredSecretFields({ password: REDACTED_VALUE, host: "localhost" })).toEqual(new Set(["password"]));
});
it("should not report an unset secret", () => {
expect(configuredSecretFields({ password: "", sentinel_password: null })).toEqual(new Set());
});
});
describe("buildCoordinationPayload", () => {
it("should drop empty fields and send the port as a number", () => {
const payload = buildCoordinationPayload("node", { host: "localhost", port: "6379", username: "" });
expect(payload).toEqual({ host: "localhost", port: 6379, ssl: false });
expect(payload).not.toHaveProperty("username");
});
it("should not resubmit a secret that is still the redacted marker", () => {
const untouchedSecrets = {
host: "localhost",
password: REDACTED_VALUE,
url: REDACTED_VALUE,
sentinel_password: REDACTED_VALUE,
};
const payload = buildCoordinationPayload("sentinel", untouchedSecrets);
expect(payload).not.toHaveProperty("password");
expect(payload).not.toHaveProperty("url");
expect(payload).not.toHaveProperty("sentinel_password");
});
it("should submit a secret once the admin replaces the redacted marker", () => {
const payload = buildCoordinationPayload("node", { password: "hunter2" });
expect(payload.password).toBe("hunter2");
});
it("should parse cluster startup nodes from their textarea string into an array", () => {
const payload = buildCoordinationPayload("cluster", {
startup_nodes: '[{"host":"127.0.0.1","port":7001}]',
});
expect(payload.startup_nodes).toEqual([{ host: "127.0.0.1", port: 7001 }]);
});
it("should parse sentinel nodes from their textarea string into an array of pairs", () => {
const payload = buildCoordinationPayload("sentinel", {
sentinel_nodes: '[["localhost", 26379]]',
service_name: "mymaster",
});
expect(payload.sentinel_nodes).toEqual([["localhost", 26379]]);
expect(payload.service_name).toBe("mymaster");
});
it("should omit a list field whose textarea holds invalid JSON", () => {
const payload = buildCoordinationPayload("cluster", { startup_nodes: "not json" });
expect(payload).not.toHaveProperty("startup_nodes");
});
it("should exclude fields that do not belong to the selected redis type", () => {
const payload = buildCoordinationPayload("node", {
sentinel_nodes: '[["localhost",26379]]',
startup_nodes: '[{"host":"127.0.0.1","port":7001}]',
});
expect(payload).not.toHaveProperty("sentinel_nodes");
expect(payload).not.toHaveProperty("startup_nodes");
});
});
describe("sourceBadge", () => {
it("should label each backend source value", () => {
expect(sourceBadge("coordination_redis").label).toBe("Configured here");
expect(sourceBadge("cache_backend").label).toBe("Borrowed from response cache");
expect(sourceBadge("environment").label).toBe("From REDIS_* environment");
expect(sourceBadge(null).label).toBe("Not configured");
});
it("should tone only a dedicated coordination Redis as success", () => {
expect(sourceBadge("coordination_redis").tone).toBe("success");
expect(sourceBadge("cache_backend").tone).toBe("info");
expect(sourceBadge("environment").tone).toBe("info");
expect(sourceBadge(null).tone).toBe("neutral");
});
it("should fall back to not configured for an unrecognized source", () => {
expect(sourceBadge("something_new").label).toBe("Not configured");
});
});

View file

@ -0,0 +1,148 @@
import type { StatusTone } from "@/components/shared/table_cells/status_badge";
import {
COORDINATION_FIELDS,
CoordinationField,
CoordinationRedisType,
CoordinationSection,
} from "./coordinationRedisFields";
import { CoordinationRedisSettings, CoordinationRedisSource, REDACTED_VALUE } from "./types";
export type CoordinationFormValue = string | number | boolean | undefined;
export type CoordinationFormValues = Record<string, CoordinationFormValue>;
export const isFieldVisible = (field: CoordinationField, redisType: CoordinationRedisType): boolean =>
field.redisType === null || field.redisType === redisType;
export const fieldsForSection = (section: CoordinationSection, redisType: CoordinationRedisType): CoordinationField[] =>
COORDINATION_FIELDS.filter((field) => field.section === section && isFieldVisible(field, redisType));
const hasValue = (raw: unknown): boolean => {
const isEmptyArray = Array.isArray(raw) && raw.length === 0;
const isBlank = raw === undefined || raw === null || raw === "";
return !isBlank && !isEmptyArray;
};
export const inferRedisType = (values: Record<string, unknown>): CoordinationRedisType => {
if (hasValue(values.sentinel_nodes)) {
return "sentinel";
}
if (hasValue(values.startup_nodes)) {
return "cluster";
}
return "node";
};
export const configuredSecretFields = (values: Record<string, unknown>): ReadonlySet<string> =>
new Set(COORDINATION_FIELDS.filter((field) => field.secret && hasValue(values[field.name])).map((f) => f.name));
const initialValueForField = (field: CoordinationField, raw: unknown): CoordinationFormValue => {
if (field.secret) {
return "";
}
const source = raw ?? field.defaultValue;
if (field.type === "boolean") {
return source === true || source === "true";
}
if (field.type === "list") {
if (!hasValue(source)) {
return "";
}
return typeof source === "string" ? source : JSON.stringify(source, null, 2);
}
if (source === undefined || source === null) {
return "";
}
return String(source);
};
export const buildInitialValues = (values: Record<string, unknown>): CoordinationFormValues =>
Object.fromEntries(COORDINATION_FIELDS.map((field) => [field.name, initialValueForField(field, values[field.name])]));
const saveValueForField = (
field: CoordinationField,
raw: CoordinationFormValue,
): CoordinationRedisSettings[string] | undefined => {
if (field.secret && raw === REDACTED_VALUE) {
return undefined;
}
if (field.type === "boolean") {
return Boolean(raw);
}
if (field.type === "list") {
if (typeof raw !== "string" || raw.trim() === "") {
return undefined;
}
try {
return JSON.parse(raw) as unknown[];
} catch {
return undefined;
}
}
if (field.type === "integer") {
if (raw === undefined || raw === null || raw === "") {
return undefined;
}
const parsed = Number(raw);
return Number.isNaN(parsed) ? undefined : parsed;
}
if (typeof raw !== "string") {
return raw === undefined ? undefined : String(raw);
}
const trimmed = raw.trim();
return trimmed === "" ? undefined : trimmed;
};
export const buildCoordinationPayload = (
redisType: CoordinationRedisType,
values: CoordinationFormValues,
): CoordinationRedisSettings => {
const entries = COORDINATION_FIELDS.filter((field) => isFieldVisible(field, redisType)).flatMap((field) => {
const value = saveValueForField(field, values[field.name]);
return value === undefined ? [] : [[field.name, value] as const];
});
return Object.fromEntries(entries);
};
export interface SourceBadgeDescriptor {
readonly tone: StatusTone;
readonly label: string;
readonly tooltip: string;
}
const SOURCE_BADGES: Readonly<Record<CoordinationRedisSource, SourceBadgeDescriptor>> = {
coordination_redis: {
tone: "success",
label: "Configured here",
tooltip: "general_settings.coordination_redis is set, so coordination uses its own Redis connection.",
},
cache_backend: {
tone: "info",
label: "Borrowed from response cache",
tooltip: "No coordination Redis is configured; the proxy reuses the response cache's Redis connection.",
},
environment: {
tone: "info",
label: "From REDIS_* environment",
tooltip: "No coordination Redis is configured; the proxy falls back to the REDIS_* environment variables.",
},
};
const NOT_CONFIGURED_BADGE: SourceBadgeDescriptor = {
tone: "neutral",
label: "Not configured",
tooltip: "Cross-pod rate limits, spend tracking, and the pod lock manager have no Redis to coordinate through.",
};
export const sourceBadge = (source: string | null | undefined): SourceBadgeDescriptor => {
const known: Readonly<Record<string, SourceBadgeDescriptor>> = SOURCE_BADGES;
return (source && known[source]) || NOT_CONFIGURED_BADGE;
};

View file

@ -0,0 +1,246 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import CoordinationRedisSettings from "./index";
import { REDACTED_VALUE } from "./types";
import * as networking from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
vi.mock("@/components/networking", () => ({
getCoordinationRedisSettingsCall: vi.fn(),
testCoordinationRedisConnectionCall: vi.fn(),
updateCoordinationRedisSettingsCall: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "sk-test" }),
}));
vi.mock("@/components/molecules/notifications_manager", () => ({
default: { success: vi.fn(), fromBackend: vi.fn() },
}));
const getSettings = vi.mocked(networking.getCoordinationRedisSettingsCall);
const updateSettings = vi.mocked(networking.updateCoordinationRedisSettingsCall);
const testConnection = vi.mocked(networking.testCoordinationRedisConnectionCall);
const notifications = vi.mocked(NotificationsManager);
const settingsResponse = (
values: Record<string, unknown>,
source: "coordination_redis" | "cache_backend" | "environment" | null = null,
) => ({ values, fields: [], source });
const wrapper = ({ children }: { children: React.ReactNode }) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
const renderSettings = () => render(<CoordinationRedisSettings />, { wrapper });
const clickSave = async (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getByRole("button", { name: /save changes/i }));
describe("CoordinationRedisSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
getSettings.mockResolvedValue(settingsResponse({}));
updateSettings.mockResolvedValue(undefined);
testConnection.mockResolvedValue({ status: "healthy" });
});
describe("when the redis type is node", () => {
it("should show the connection fields and hide cluster/sentinel fields", async () => {
renderSettings();
expect(await screen.findByText("Connection Settings")).toBeInTheDocument();
expect(screen.getByText("Redis URL")).toBeInTheDocument();
expect(screen.getByText("SSL")).toBeInTheDocument();
expect(screen.queryByText("Startup Nodes")).not.toBeInTheDocument();
expect(screen.queryByText("Sentinel Nodes")).not.toBeInTheDocument();
});
it("should not offer semantic caching, which is a response-cache-only concern", async () => {
renderSettings();
await screen.findByText("Connection Settings");
expect(screen.queryByText(/semantic/i)).not.toBeInTheDocument();
});
});
describe("when the saved settings describe a cluster", () => {
it("should reveal the cluster startup nodes field", async () => {
getSettings.mockResolvedValue(settingsResponse({ startup_nodes: [{ host: "127.0.0.1", port: 7001 }] }));
renderSettings();
expect(await screen.findByText("Startup Nodes")).toBeInTheDocument();
expect(screen.queryByText("Sentinel Nodes")).not.toBeInTheDocument();
});
});
describe("when the saved settings describe a sentinel", () => {
it("should reveal the sentinel fields", async () => {
getSettings.mockResolvedValue(settingsResponse({ sentinel_nodes: [["localhost", 26379]] }));
renderSettings();
expect(await screen.findByText("Sentinel Nodes")).toBeInTheDocument();
expect(screen.getByText("Service Name")).toBeInTheDocument();
expect(screen.getByText("Sentinel Password")).toBeInTheDocument();
});
});
describe("the source badge", () => {
it.each([
["coordination_redis", "Configured here"],
["cache_backend", "Borrowed from response cache"],
["environment", "From REDIS_* environment"],
] as const)("should render %s as %s", async (source, label) => {
getSettings.mockResolvedValue(settingsResponse({}, source));
renderSettings();
expect(await screen.findByTestId("coordination-redis-source")).toHaveTextContent(label);
});
it("should render a null source as not configured", async () => {
getSettings.mockResolvedValue(settingsResponse({}, null));
renderSettings();
expect(await screen.findByTestId("coordination-redis-source")).toHaveTextContent("Not configured");
});
it("should tell the admin that saved changes need a proxy restart", async () => {
renderSettings();
expect(await screen.findByText(/take effect on proxy restart/i)).toBeInTheDocument();
});
});
describe("when a field fails inline validation", () => {
it("should block save and surface the port validation message", async () => {
const user = userEvent.setup();
renderSettings();
const port = await screen.findByLabelText("Port");
await user.clear(port);
await user.type(port, "99999");
await clickSave(user);
expect(await screen.findByText(/Port must be an integer between 1 and 65535/i)).toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
});
it("should block save when a list field holds malformed JSON instead of silently dropping it", async () => {
const user = userEvent.setup();
getSettings.mockResolvedValue(settingsResponse({ startup_nodes: [], sentinel_nodes: [["localhost", 26379]] }));
renderSettings();
const sentinelNodes = await screen.findByLabelText("Sentinel Nodes");
await user.clear(sentinelNodes);
await user.type(sentinelNodes, "not json");
await clickSave(user);
expect(await screen.findByText(/Must be a valid JSON array/i)).toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
});
});
describe("when saving", () => {
it("should send a node payload with a numeric port and no empty fields", async () => {
const user = userEvent.setup();
renderSettings();
await user.type(await screen.findByLabelText("Host"), "coord-redis");
await clickSave(user);
await waitFor(() =>
expect(updateSettings).toHaveBeenCalledWith("sk-test", { host: "coord-redis", port: 6379, ssl: false }),
);
});
it("should parse the cluster startup nodes textarea into a JSON array", async () => {
const user = userEvent.setup();
getSettings.mockResolvedValue(settingsResponse({ startup_nodes: [{ host: "127.0.0.1", port: 7001 }] }));
renderSettings();
await screen.findByLabelText("Startup Nodes");
await clickSave(user);
await waitFor(() => expect(updateSettings).toHaveBeenCalled());
expect(updateSettings.mock.calls[0][1]).toMatchObject({
startup_nodes: [{ host: "127.0.0.1", port: 7001 }],
});
});
it("should not resubmit a redacted secret the admin never touched", async () => {
const user = userEvent.setup();
getSettings.mockResolvedValue(
settingsResponse({ host: "coord-redis", password: REDACTED_VALUE, url: REDACTED_VALUE }),
);
renderSettings();
await waitFor(() => expect(screen.getByLabelText("Host")).toHaveValue("coord-redis"));
await clickSave(user);
await waitFor(() => expect(updateSettings).toHaveBeenCalled());
const payload = updateSettings.mock.calls[0][1];
expect(payload).not.toHaveProperty("password");
expect(payload).not.toHaveProperty("url");
expect(payload).toMatchObject({ host: "coord-redis" });
});
it("should leave an already-set secret blank and say so, rather than prefilling the redacted marker", async () => {
getSettings.mockResolvedValue(settingsResponse({ password: REDACTED_VALUE }));
renderSettings();
const password = await screen.findByLabelText("Password");
await waitFor(() => expect(password).toHaveValue(""));
expect(password).toHaveAttribute("placeholder", expect.stringMatching(/already set/i));
expect(screen.queryByDisplayValue(REDACTED_VALUE)).not.toBeInTheDocument();
});
it("should submit a secret the admin typed into the blank field", async () => {
const user = userEvent.setup();
getSettings.mockResolvedValue(settingsResponse({ host: "coord-redis", password: REDACTED_VALUE }));
renderSettings();
const password = await screen.findByLabelText("Password");
await waitFor(() => expect(password).toHaveValue(""));
await user.type(password, "new-secret");
await clickSave(user);
await waitFor(() => expect(updateSettings).toHaveBeenCalled());
expect(updateSettings.mock.calls[0][1]).toMatchObject({ password: "new-secret" });
});
it("should tell the admin a restart is needed once the save succeeds", async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByLabelText("Host");
await clickSave(user);
await waitFor(() => expect(notifications.success).toHaveBeenCalledWith(expect.stringMatching(/restart/i)));
});
});
describe("when testing the connection", () => {
it("should report a healthy backend response as a success", async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByLabelText("Host");
await user.click(screen.getByRole("button", { name: /test connection/i }));
await waitFor(() => expect(notifications.success).toHaveBeenCalledWith(expect.stringMatching(/successful/i)));
expect(testConnection).toHaveBeenCalledWith("sk-test", { port: 6379, ssl: false });
});
it("should surface the backend error when the connection is unhealthy", async () => {
const user = userEvent.setup();
testConnection.mockResolvedValue({ status: "unhealthy", error: "connection refused" });
renderSettings();
await screen.findByLabelText("Host");
await user.click(screen.getByRole("button", { name: /test connection/i }));
await waitFor(() =>
expect(notifications.fromBackend).toHaveBeenCalledWith(expect.stringContaining("connection refused")),
);
expect(notifications.success).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,161 @@
import React, { useEffect, useMemo, useState } from "react";
import { Button, Form } from "antd";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { StatusBadge } from "@/components/shared/table_cells/status_badge";
import {
useCoordinationRedisSettings,
useTestCoordinationRedisConnection,
useUpdateCoordinationRedisSettings,
} from "@/app/(dashboard)/hooks/coordinationRedis/useCoordinationRedisSettings";
import CoordinationRedisFieldSection from "./CoordinationRedisFieldSection";
import CoordinationRedisTypeSelector from "./CoordinationRedisTypeSelector";
import { CoordinationRedisType } from "./coordinationRedisFields";
import {
buildCoordinationPayload,
buildInitialValues,
configuredSecretFields,
CoordinationFormValues,
inferRedisType,
sourceBadge,
} from "./coordinationRedisUtils";
const CoordinationRedisSettings: React.FC = () => {
const [form] = Form.useForm<CoordinationFormValues>();
const [selectedRedisType, setSelectedRedisType] = useState<CoordinationRedisType | null>(null);
const { data, isLoading, isError } = useCoordinationRedisSettings();
const updateSettings = useUpdateCoordinationRedisSettings();
const testConnection = useTestCoordinationRedisConnection();
const redisType = selectedRedisType ?? inferRedisType(data?.values ?? {});
useEffect(() => {
if (data) {
form.setFieldsValue(buildInitialValues(data.values));
}
}, [data, form]);
useEffect(() => {
if (isError) {
NotificationsManager.fromBackend("Failed to load coordination Redis settings");
}
}, [isError]);
const validate = async (): Promise<CoordinationFormValues | null> => {
try {
return await form.validateFields();
} catch {
return null;
}
};
const handleTestConnection = async () => {
const values = await validate();
if (values === null) {
return;
}
try {
const result = await testConnection.mutateAsync(buildCoordinationPayload(redisType, values));
if (result.status === "healthy") {
NotificationsManager.success("Coordination Redis connection test successful!");
} else {
NotificationsManager.fromBackend(`Connection test failed: ${result.error ?? "Unknown error"}`);
}
} catch (error) {
NotificationsManager.fromBackend(
`Connection test failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
};
const handleSaveChanges = async () => {
const values = await validate();
if (values === null) {
return;
}
try {
await updateSettings.mutateAsync(buildCoordinationPayload(redisType, values));
NotificationsManager.success("Coordination Redis settings saved. Restart the proxy to apply them.");
} catch {
NotificationsManager.fromBackend("Failed to update coordination Redis settings");
}
};
const badge = sourceBadge(data?.source);
const configuredSecrets = useMemo(() => configuredSecretFields(data?.values ?? {}), [data]);
return (
<div className="w-full space-y-8 py-2">
<Form form={form} layout="vertical" requiredMark={false} className="space-y-6">
<div className="max-w-3xl space-y-2">
<div className="flex items-center gap-3">
<h3 className="text-sm font-medium text-gray-900">Coordination Redis</h3>
{!isLoading && <StatusBadge tone={badge.tone} label={badge.label} dataTestId="coordination-redis-source" />}
</div>
<p className="text-xs text-gray-500">
Redis used to coordinate work across proxy pods: cross-pod rate limits, spend tracking, and the pod lock
manager. It is configured independently of the response cache.
</p>
<p className="text-xs text-gray-500">{badge.tooltip}</p>
<p className="text-xs text-amber-600">Saved changes take effect on proxy restart.</p>
</div>
<CoordinationRedisTypeSelector redisType={redisType} onTypeChange={setSelectedRedisType} />
<div className="pt-4 border-t border-gray-200">
<CoordinationRedisFieldSection
title="Connection Settings"
section="connection"
redisType={redisType}
configuredSecrets={configuredSecrets}
/>
</div>
{redisType === "cluster" && (
<div className="pt-4 border-t border-gray-200">
<CoordinationRedisFieldSection
title="Cluster Configuration"
section="cluster"
redisType={redisType}
configuredSecrets={configuredSecrets}
gridCols="grid-cols-1 gap-6"
/>
</div>
)}
{redisType === "sentinel" && (
<div className="pt-4 border-t border-gray-200">
<CoordinationRedisFieldSection
title="Sentinel Configuration"
section="sentinel"
redisType={redisType}
configuredSecrets={configuredSecrets}
/>
</div>
)}
<div className="pt-4 border-t border-gray-200">
<CoordinationRedisFieldSection
title="SSL Settings"
section="ssl"
redisType={redisType}
configuredSecrets={configuredSecrets}
/>
</div>
</Form>
<div className="border-t border-gray-200 pt-6 flex justify-end gap-3">
<Button onClick={handleTestConnection} loading={testConnection.isPending}>
{testConnection.isPending ? "Testing..." : "Test Connection"}
</Button>
<Button type="primary" onClick={handleSaveChanges} loading={updateSettings.isPending}>
{updateSettings.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
);
};
export default CoordinationRedisSettings;

View file

@ -0,0 +1,30 @@
export const REDACTED_VALUE = "***REDACTED***";
export type CoordinationRedisSource = "coordination_redis" | "cache_backend" | "environment";
export type CoordinationRedisSettingValue = string | number | boolean | unknown[];
export type CoordinationRedisSettings = Record<string, CoordinationRedisSettingValue>;
export type CoordinationRedisSection = "connection" | "cluster" | "sentinel";
export interface CoordinationRedisSettingsField {
field_name: string;
field_type: string;
field_value: unknown;
field_description: string;
ui_field_name: string;
field_default?: unknown;
section: CoordinationRedisSection;
}
export interface CoordinationRedisSettingsResponse {
values: Record<string, unknown>;
fields: CoordinationRedisSettingsField[];
source: CoordinationRedisSource | null;
}
export interface CoordinationRedisTestResponse {
status: "healthy" | "unhealthy";
error?: string;
}

View file

@ -0,0 +1,46 @@
import { useMutation, UseMutationResult, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
import {
getCoordinationRedisSettingsCall,
testCoordinationRedisConnectionCall,
updateCoordinationRedisSettingsCall,
} from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import type {
CoordinationRedisSettings,
CoordinationRedisSettingsResponse,
CoordinationRedisTestResponse,
} from "@/app/(dashboard)/caching/_components/coordination_redis_settings/types";
import { createQueryKeys } from "../common/queryKeysFactory";
export const coordinationRedisKeys = createQueryKeys("coordinationRedis");
export const useCoordinationRedisSettings = (): UseQueryResult<CoordinationRedisSettingsResponse> => {
const { accessToken } = useAuthorized();
return useQuery<CoordinationRedisSettingsResponse>({
queryKey: coordinationRedisKeys.list({}),
queryFn: async () => getCoordinationRedisSettingsCall(accessToken!),
enabled: Boolean(accessToken),
});
};
export const useUpdateCoordinationRedisSettings = (): UseMutationResult<void, Error, CoordinationRedisSettings> => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
return useMutation<void, Error, CoordinationRedisSettings>({
mutationFn: async (settings) => updateCoordinationRedisSettingsCall(accessToken!, settings),
onSuccess: () => queryClient.invalidateQueries({ queryKey: coordinationRedisKeys.all }),
});
};
export const useTestCoordinationRedisConnection = (): UseMutationResult<
CoordinationRedisTestResponse,
Error,
CoordinationRedisSettings
> => {
const { accessToken } = useAuthorized();
return useMutation<CoordinationRedisTestResponse, Error, CoordinationRedisSettings>({
mutationFn: async (settings) => testCoordinationRedisConnectionCall(accessToken!, settings),
});
};

View file

@ -30,6 +30,11 @@ import type { SkillRegisterRequest } from "./claude_code_plugins/types";
import { jsonFields } from "./common_components/check_openapi_schema";
import NotificationsManager from "./molecules/notifications_manager";
import type { MCPUserEnvVarsStatus } from "./mcp_tools/types";
import type {
CoordinationRedisSettings,
CoordinationRedisSettingsResponse,
CoordinationRedisTestResponse,
} from "@/app/(dashboard)/caching/_components/coordination_redis_settings/types";
import { MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE } from "./mcp_tools/constants";
import { createApiClient, deriveErrorMessage } from "@/lib/http/client";
import { resolveApiBase } from "@/lib/http/resolveApiBase";
@ -3196,6 +3201,47 @@ export const updateCacheSettingsCall = async (accessToken: string, cacheSettings
}
};
export const getCoordinationRedisSettingsCall = async (
accessToken: string,
): Promise<CoordinationRedisSettingsResponse> => {
try {
return await apiClient.get<CoordinationRedisSettingsResponse>(`/coordination_redis/settings`, { accessToken });
} catch (error) {
console.error("Failed to get coordination redis settings:", error);
throw error;
}
};
export const testCoordinationRedisConnectionCall = async (
accessToken: string,
settings: CoordinationRedisSettings,
): Promise<CoordinationRedisTestResponse> => {
try {
return await apiClient.post<CoordinationRedisTestResponse>(`/coordination_redis/settings/test`, {
accessToken,
body: { settings },
});
} catch (error) {
console.error("Failed to test coordination redis connection:", error);
throw error;
}
};
export const updateCoordinationRedisSettingsCall = async (
accessToken: string,
settings: CoordinationRedisSettings,
): Promise<void> => {
try {
await apiClient.post(`/coordination_redis/settings`, {
accessToken,
body: { settings },
});
} catch (error) {
console.error("Failed to update coordination redis settings:", error);
throw error;
}
};
export const getPassThroughEndpointsCall = async (accessToken: string, teamId?: string | null) => {
try {
let path = `/config/pass_through_endpoint`;

View file

@ -31,7 +31,7 @@ export const pageDescriptions: Record<string, string> = {
api_ref: "Browse API documentation and endpoints",
"model-hub-table": "Explore available AI models and providers",
"learning-resources": "Access tutorials and documentation",
caching: "Configure response caching settings",
caching: "Configure response caching and coordination Redis settings",
"transform-request": "Set up request transformation rules",
"cost-tracking": "Track and analyze API costs",
"ui-theme": "Customize dashboard appearance",

View file

@ -2367,6 +2367,67 @@ export interface paths {
patch?: never;
trace?: never;
};
"/coordination_redis/settings": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Coordination Redis Settings
* @description Get the coordination Redis configuration and available settings.
*
* Returns:
* - values: current coordination Redis settings, with password/sentinel_password/url redacted
* - fields: all configurable settings with their metadata (type, description, default, section)
* - source: "coordination_redis" | "cache_backend" | "environment" | null
*/
get: operations["get_coordination_redis_settings_coordination_redis_settings_get"];
put?: never;
/**
* Update Coordination Redis Settings
* @description Save coordination Redis settings under `general_settings.coordination_redis`.
*
* Parameters:
* - settings: dict - Redis connection params (host, port, username, password, url, ssl, startup_nodes, sentinel_nodes, sentinel_password, service_name). Values may be `os.environ/VAR` references, which are stored as written and resolved at startup
*
* The settings are written to the `general_settings` row of LiteLLM_Config,
* which startup merges over the yaml config; the proxy picks them up on its
* next restart.
*/
post: operations["update_coordination_redis_settings_coordination_redis_settings_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/coordination_redis/settings/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Check Coordination Redis Connection
* @description Test a coordination Redis connection with the provided credentials.
*
* Parameters:
* - settings: dict - Redis connection params to test. Credential fields sent back as `***REDACTED***` fall back to the saved value
*
* Builds a throwaway client (never touching global state) and pings it.
*/
post: operations["check_coordination_redis_connection_coordination_redis_settings_test_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/cost/estimate": {
parameters: {
query?: never;
@ -22353,6 +22414,8 @@ export interface components {
* @description proxy level default model for all chat completion calls
*/
completion_model?: string | null;
/** @description standalone Redis for cross-pod coordination (tpm/rpm rate limits, spend tracking, pod lock manager, shared health checks), configured independently of the response-cache backend; takes precedence over borrowing the `cache_params` Redis and over the REDIS_* env fallback */
coordination_redis?: components["schemas"]["CoordinationRedisParams"] | null;
/**
* Custom Auth
* @description override user_api_key_auth with your own auth script - https://docs.litellm.ai/docs/proxy/virtual_keys#custom-auth
@ -22751,6 +22814,145 @@ export interface components {
*/
pattern_type: "prebuilt" | "regex";
};
/**
* CoordinationRedisNode
* @description A single startup node of a cluster-mode Redis used for proxy coordination.
*/
CoordinationRedisNode: {
/**
* Host
* @description hostname of the cluster node
*/
host: string;
/**
* Port
* @description port of the cluster node
*/
port: number;
};
/**
* CoordinationRedisParams
* @description Connection params for the proxy's coordination Redis (cross-pod tpm/rpm rate
* limits, spend tracking, pod lock manager, shared health checks), configured
* independently of the response-cache backend in `litellm_settings.cache_params`.
*/
CoordinationRedisParams: {
/**
* Host
* @description Redis hostname
*/
host?: string | null;
/**
* Password
* @description Redis password
*/
password?: string | null;
/**
* Port
* @description Redis port
*/
port?: number | null;
/**
* Sentinel Nodes
* @description sentinel [host, port] pairs; when set a sentinel-managed client is used
*/
sentinel_nodes?: (string | number)[][] | null;
/**
* Sentinel Password
* @description password for the sentinel nodes
*/
sentinel_password?: string | null;
/**
* Service Name
* @description sentinel service name
*/
service_name?: string | null;
/**
* Ssl
* @description connect over TLS
*/
ssl?: boolean | null;
/**
* Startup Nodes
* @description cluster-mode startup nodes; when set a cluster client is used
*/
startup_nodes?: components["schemas"]["CoordinationRedisNode"][] | null;
/**
* Url
* @description full Redis connection url, e.g. redis://:pass@host:6379
*/
url?: string | null;
/**
* Username
* @description Redis username
*/
username?: string | null;
} & {
[key: string]: unknown;
};
/** CoordinationRedisSettingsField */
CoordinationRedisSettingsField: {
/** Field Default */
field_default?: unknown | null;
/** Field Description */
field_description: string;
/** Field Name */
field_name: string;
/** Field Type */
field_type: string;
/** Field Value */
field_value?: unknown | null;
/**
* Section
* @enum {string}
*/
section: "connection" | "cluster" | "sentinel";
/** Ui Field Name */
ui_field_name: string;
};
/** CoordinationRedisSettingsRequest */
CoordinationRedisSettingsRequest: {
/**
* Settings
* @description Coordination Redis connection params
*/
settings: {
[key: string]: unknown;
};
};
/** CoordinationRedisSettingsResponse */
CoordinationRedisSettingsResponse: {
/**
* Fields
* @description List of all configurable coordination Redis settings with metadata
*/
fields: components["schemas"]["CoordinationRedisSettingsField"][];
/**
* Source
* @description Where the proxy's coordination Redis comes from; null when it has none
*/
source: ("coordination_redis" | "cache_backend" | "environment") | null;
/**
* Values
* @description Current coordination Redis settings, with credentials redacted
*/
values: {
[key: string]: unknown;
};
};
/** CoordinationRedisTestResponse */
CoordinationRedisTestResponse: {
/**
* Error
* @description Error message if the connection failed
*/
error?: string | null;
/**
* Status
* @description Connection status: 'healthy' or 'unhealthy'
*/
status: string;
};
/**
* CostEstimateRequest
* @description Request body for /cost/estimate endpoint.
@ -37210,6 +37412,97 @@ export interface operations {
};
};
};
get_coordination_redis_settings_coordination_redis_settings_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CoordinationRedisSettingsResponse"];
};
};
};
};
update_coordination_redis_settings_coordination_redis_settings_post: {
parameters: {
query?: never;
header?: {
/** @description The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability */
"litellm-changed-by"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CoordinationRedisSettingsRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
check_coordination_redis_connection_coordination_redis_settings_test_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CoordinationRedisSettingsRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CoordinationRedisTestResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
estimate_cost_cost_estimate_post: {
parameters: {
query?: never;