mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
99b4c5ed3e
commit
3ea7f98725
26 changed files with 3099 additions and 65 deletions
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
});
|
||||
};
|
||||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
293
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
293
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue