mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
* Litellm ishaan march23 - MCP Toolsets + GCP Caching fix (#25146) * feat(mcp): MCP Toolsets — curated tool subsets from one or more MCP servers (#24335) * feat(mcp): add LiteLLM_MCPToolsetTable and mcp_toolsets to ObjectPermissionTable * feat(mcp): add prisma migration for MCPToolset table * feat(mcp): add MCPToolset Python types * feat(mcp): add toolset_db.py with CRUD helpers for MCPToolset * feat(mcp): add toolset CRUD endpoints to mcp_management_endpoints * fix(mcp): skip allow_all_keys servers when explicit mcp_servers permission is set (toolset scope fix) * feat(mcp): add _apply_toolset_scope and toolset route handling in server.py * fix(mcp): resolve toolset names in responses API before fetching tools * feat(mcp): add mcp_toolsets field to LiteLLM_ObjectPermissionTable type * feat(mcp): register LiteLLM_MCPToolsetTable in prisma client initialization * feat(mcp): validate mcp_toolsets in key-vs-team permission check * feat(mcp): register toolset routes in proxy_server.py * feat(mcp): add MCPToolset and MCPToolsetTool TypeScript types * feat(mcp): add fetchMCPToolsets, createMCPToolset, updateMCPToolset, deleteMCPToolset API functions * feat(mcp): add useMCPToolsets React Query hook * feat(mcp): add toolsets (purple) as third option type in MCPServerSelector * feat(mcp): extract toolsets from combined MCP field in key form * feat(mcp): extract toolsets from combined MCP field in team form * feat(mcp): show toolsets section in MCPServerPermissions read view * feat(mcp): pass mcp_toolsets through object_permissions_view * feat(mcp): add MCPToolsetsTab component for creating and managing toolsets * feat(mcp): add Toolsets tab to mcp_servers.tsx * feat(mcp): pass mcpToolsets to playground chat and responses API calls * feat(mcp): generate correct server_url for toolsets in playground API calls * docs(mcp): add MCP Toolsets documentation * docs(mcp): add mcp_toolsets to sidebar * fix(mcp): replace x-mcp-toolset-id header with ContextVar to prevent client forgery * fix(mcp): use ContextVar + StreamingResponse for toolset MCP routes (fixes SSE streaming) * fix(mcp): cache toolset permission lookups to avoid per-request DB calls * test(mcp): add tests for toolset scope enforcement, ContextVar isolation, and access control * fix(mcp): cache toolset name lookups in MCPServerManager to avoid per-request DB calls * fix(mcp): prevent body_iter deadlock + use cached toolset lookup in responses API - _stream_mcp_asgi_response: add done callback to handler_task that puts the EOF sentinel on body_queue when the task exits, preventing body_iter from hanging forever if the handler raises after headers are sent. - litellm_proxy_mcp_handler: replace raw get_mcp_toolset_by_name() DB call with global_mcp_server_manager.get_toolset_by_name_cached() so toolset resolution uses the 60s TTL cache added for this purpose instead of hitting the DB on every responses-API request. * fix(mcp): toolset access control, asyncio fix, and real unit tests - server.py: _apply_toolset_scope now enforces that non-admin keys must have the requested toolset_id in their mcp_toolsets grant list; admin keys always bypass the check. - mcp_management_endpoints.py: three access-control fixes: * fetch_mcp_toolsets: non-admin keys with mcp_toolsets=None now return [] instead of all toolsets (only admins get 'all' when the field is absent) * fetch_mcp_toolset: non-admin keys that haven't been granted the requested toolset_id now get 403 instead of the full result * add_mcp_toolset: duplicate toolset_name now returns 409 Conflict instead of an opaque 500 - proxy_server.py: use asyncio.get_running_loop() instead of get_event_loop() inside an already-running coroutine (Python 3.10+). - test_mcp_toolset_scope.py: replace four hollow tests that only asserted local variable properties with real tests that call the production fetch_mcp_toolsets() and handle_streamable_http_mcp() functions with mocked dependencies. * fix(mcp): add mcp_toolsets to ObjectPermissionBase, fix multi-toolset overwrite, fix delete 404, allow standalone key toolsets * fix(mcp): add auth check on toolset resolution in responses API; union mcp_servers in _merge_toolset_permissions * fix(mcp): handle RecordNotFoundError in update_mcp_toolset; union direct servers with toolset servers * fix(mcp): use _user_has_admin_view; deny None mcp_toolsets for non-admin; use direct RecordNotFoundError import; fix docstring * fix(mcp): add @default(now()) to MCPToolsetTable.updated_at; fix test for non-admin toolset access * fix: use UniqueViolationError import; guard _ensure_eof for error/cancel only * fix(mcp): preserve mcp_access_groups in toolset scope, use shared Redis cache for toolset perms - Remove mcp_access_groups=[] from _apply_toolset_scope (server.py) and the responses API toolset path (litellm_proxy_mcp_handler.py). A key's access-group grants remain valid even when the request is scoped to a single toolset; clearing them silently revoked legitimate entitlements. - Switch resolve_toolset_tool_permissions and get_toolset_by_name_cached to use user_api_key_cache (Redis-backed DualCache in production) instead of per-instance in-memory dicts. Cache entries are now shared across workers, eliminating the per-worker stale-toolset-permission window flagged as a P1 by Greptile. - Use union merge (set union of tool names per server) when applying toolset permissions in the responses API path so direct-server tool restrictions are not overwritten by toolset permissions. * fix(mcp): return 404 when edit_mcp_toolset target does not exist * fix(mcp): align mcp_toolsets default to None in LiteLLM_ObjectPermissionTable * fix(mcp): admin toolset visibility, in-place tool name mutation, test helper coercion * fix(mcp): treat None/[] team mcp_toolsets as no restriction in key validation * fix(mcp): allow_all_keys backward compat, blocked_tools API write-path, efficient startup query * fix(mcp): use _mcp_active_toolset_id ContextVar to detect toolset scope, avoiding DB-default false-positive * fix(mcp): remove dead toolset cache stubs, log invalidation failures, align schema updated_at defaults * fix(mcp): deserialise MCPToolset from Redis cache hit, replace fastapi import in test * fix(mcp): evict name-cache on toolset mutation, 409 on rename conflict, warning-level list errors * fix(redis): regenerate GCP IAM token per connection for async cluster (#24426) * fix(redis): regenerate GCP IAM token per connection for async cluster clients Async RedisCluster was generating the IAM token once at startup and storing it as a static password. After the 1-hour GCP token TTL, any new connection (including to newly-discovered cluster nodes) would fail to authenticate. Fix: introduce GCPIAMCredentialProvider that implements redis-py's CredentialProvider protocol. It calls _generate_gcp_iam_access_token() on every new connection, matching what the sync redis_connect_func already does. async_redis.RedisCluster accepts a credential_provider kwarg which is invoked per-connection. * refactor(redis): move GCPIAMCredentialProvider to its own file Extract GCPIAMCredentialProvider and _generate_gcp_iam_access_token into litellm/_redis_credential_provider.py. _redis.py imports them from there, keeping the public API unchanged. * fix: address Greptile review issues - GCPIAMCredentialProvider now inherits from redis.credentials.CredentialProvider so redis-py's async path calls get_credentials_async() properly - move _redis_credential_provider import to top of _redis.py (PEP 8) - remove dead else-branch that silently no-oped (gcp_service_account from redis_kwargs.get() was always None since it's popped by _get_redis_client_logic) - remove mid-function 'from litellm import get_secret_str' inline import - remove unused 'call' import from test_redis.py * chore: retrigger CI/review * chore: sync schema.prisma copies from root * chore: sync schema.prisma copies from root * fix(proxy_server): use bounded asyncio.Queue with maxsize to prevent unbounded growth * fix(a2a/pydantic_ai): make api_base Optional to match base class signature * fix(a2a/pydantic_ai): make api_base Optional in handler and guard against None * fix(mcp): remove unused get_all_mcp_servers import * fix(mcp): remove unused MCPToolset import * refactor(mcp): extract toolset permission logic to reduce statement count below PLR0915 limit * fix(tests): update reload_servers_from_database tests to mock prisma directly --------- Co-authored-by: Ishaan Jaff <ishaanjaffer0324@gmail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fix(toolset_db): lazy-import prisma to avoid ImportError when prisma not installed * fix(tests): update UI tests for toolset tab and updated empty state text * fix(tests): add get_mcp_server_by_name to fake_manager stub --------- Co-authored-by: Ishaan Jaff <ishaanjaffer0324@gmail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
562 lines
20 KiB
Python
562 lines
20 KiB
Python
# +-----------------------------------------------+
|
|
# | |
|
|
# | Give Feedback / Get Help |
|
|
# | https://github.com/BerriAI/litellm/issues/new |
|
|
# | |
|
|
# +-----------------------------------------------+
|
|
#
|
|
# Thank you users! We ❤️ you! - Krrish & Ishaan
|
|
|
|
import inspect
|
|
import json
|
|
|
|
# s/o [@Frank Colson](https://www.linkedin.com/in/frank-colson-422b9b183/) for this redis implementation
|
|
import os
|
|
from typing import Callable, List, Optional, Union
|
|
|
|
import redis # type: ignore
|
|
import redis.asyncio as async_redis # type: ignore
|
|
|
|
from litellm import get_secret, get_secret_str
|
|
from litellm._redis_credential_provider import (
|
|
GCPIAMCredentialProvider,
|
|
_generate_gcp_iam_access_token,
|
|
)
|
|
from litellm.constants import REDIS_CONNECTION_POOL_TIMEOUT, REDIS_SOCKET_TIMEOUT
|
|
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
|
|
|
|
from ._logging import verbose_logger
|
|
|
|
|
|
def _get_redis_kwargs():
|
|
arg_spec = inspect.getfullargspec(redis.Redis)
|
|
|
|
# Only allow primitive arguments
|
|
exclude_args = {
|
|
"self",
|
|
"connection_pool",
|
|
"retry",
|
|
}
|
|
|
|
include_args = [
|
|
"url",
|
|
"redis_connect_func",
|
|
"gcp_service_account",
|
|
"gcp_ssl_ca_certs",
|
|
]
|
|
|
|
available_args = [x for x in arg_spec.args if x not in exclude_args] + include_args
|
|
|
|
return available_args
|
|
|
|
|
|
def _get_redis_url_kwargs(client=None):
|
|
if client is None:
|
|
client = redis.Redis.from_url
|
|
arg_spec = inspect.getfullargspec(redis.Redis.from_url)
|
|
|
|
# Only allow primitive arguments
|
|
exclude_args = {
|
|
"self",
|
|
"connection_pool",
|
|
"retry",
|
|
}
|
|
|
|
include_args = ["url"]
|
|
|
|
available_args = [x for x in arg_spec.args if x not in exclude_args] + include_args
|
|
|
|
return available_args
|
|
|
|
|
|
def _get_redis_cluster_kwargs(client=None):
|
|
if client is None:
|
|
client = redis.Redis.from_url
|
|
arg_spec = inspect.getfullargspec(redis.RedisCluster)
|
|
|
|
# Only allow primitive arguments
|
|
exclude_args = {"self", "connection_pool", "retry", "host", "port", "startup_nodes"}
|
|
|
|
available_args = [x for x in arg_spec.args if x not in exclude_args]
|
|
available_args.append("password")
|
|
available_args.append("username")
|
|
available_args.append("ssl")
|
|
available_args.append("ssl_cert_reqs")
|
|
available_args.append("ssl_check_hostname")
|
|
available_args.append("ssl_ca_certs")
|
|
available_args.append(
|
|
"redis_connect_func"
|
|
) # Needed for sync clusters and IAM detection
|
|
available_args.append("gcp_service_account")
|
|
available_args.append("gcp_ssl_ca_certs")
|
|
available_args.append("max_connections")
|
|
|
|
return available_args
|
|
|
|
|
|
def _get_redis_env_kwarg_mapping():
|
|
PREFIX = "REDIS_"
|
|
|
|
return {f"{PREFIX}{x.upper()}": x for x in _get_redis_kwargs()}
|
|
|
|
|
|
def _redis_kwargs_from_environment():
|
|
mapping = _get_redis_env_kwarg_mapping()
|
|
|
|
return_dict = {}
|
|
for k, v in mapping.items():
|
|
value = get_secret(k, default_value=None) # type: ignore
|
|
if value is not None:
|
|
return_dict[v] = value
|
|
return return_dict
|
|
|
|
|
|
def create_gcp_iam_redis_connect_func(
|
|
service_account: str,
|
|
ssl_ca_certs: Optional[str] = None,
|
|
) -> Callable:
|
|
"""
|
|
Creates a custom Redis connection function for GCP IAM authentication.
|
|
|
|
Args:
|
|
service_account: GCP service account in format 'projects/-/serviceAccounts/name@project.iam.gserviceaccount.com'
|
|
ssl_ca_certs: Path to SSL CA certificate file for secure connections
|
|
|
|
Returns:
|
|
A connection function that can be used with Redis clients
|
|
"""
|
|
|
|
def iam_connect(self):
|
|
"""Initialize the connection and authenticate using GCP IAM"""
|
|
from redis.exceptions import (
|
|
AuthenticationError,
|
|
AuthenticationWrongNumberOfArgsError,
|
|
)
|
|
from redis.utils import str_if_bytes
|
|
|
|
self._parser.on_connect(self)
|
|
|
|
auth_args = (_generate_gcp_iam_access_token(service_account),)
|
|
self.send_command("AUTH", *auth_args, check_health=False)
|
|
|
|
try:
|
|
auth_response = self.read_response()
|
|
except AuthenticationWrongNumberOfArgsError:
|
|
# Fallback to password auth if IAM fails
|
|
if hasattr(self, "password") and self.password:
|
|
self.send_command("AUTH", self.password, check_health=False)
|
|
auth_response = self.read_response()
|
|
else:
|
|
raise
|
|
|
|
if str_if_bytes(auth_response) != "OK":
|
|
raise AuthenticationError("GCP IAM authentication failed")
|
|
|
|
return iam_connect
|
|
|
|
|
|
def get_redis_url_from_environment():
|
|
if "REDIS_URL" in os.environ:
|
|
return os.environ["REDIS_URL"]
|
|
|
|
if "REDIS_HOST" not in os.environ or "REDIS_PORT" not in os.environ:
|
|
raise ValueError(
|
|
"Either 'REDIS_URL' or both 'REDIS_HOST' and 'REDIS_PORT' must be specified for Redis."
|
|
)
|
|
|
|
if "REDIS_SSL" in os.environ and os.environ["REDIS_SSL"].lower() == "true":
|
|
redis_protocol = "rediss"
|
|
else:
|
|
redis_protocol = "redis"
|
|
|
|
# Build authentication part of URL
|
|
auth_part = ""
|
|
if "REDIS_USERNAME" in os.environ and "REDIS_PASSWORD" in os.environ:
|
|
auth_part = f"{os.environ['REDIS_USERNAME']}:{os.environ['REDIS_PASSWORD']}@"
|
|
elif "REDIS_PASSWORD" in os.environ:
|
|
auth_part = f"{os.environ['REDIS_PASSWORD']}@"
|
|
|
|
return f"{redis_protocol}://{auth_part}{os.environ['REDIS_HOST']}:{os.environ['REDIS_PORT']}"
|
|
|
|
|
|
def _get_redis_client_logic(**env_overrides):
|
|
"""
|
|
Common functionality across sync + async redis client implementations
|
|
"""
|
|
### check if "os.environ/<key-name>" passed in
|
|
for k, v in env_overrides.items():
|
|
if isinstance(v, str) and v.startswith("os.environ/"):
|
|
v = v.replace("os.environ/", "")
|
|
value = get_secret(v) # type: ignore
|
|
env_overrides[k] = value
|
|
|
|
redis_kwargs = {
|
|
**_redis_kwargs_from_environment(),
|
|
**env_overrides,
|
|
}
|
|
|
|
_startup_nodes: Optional[Union[str, list]] = redis_kwargs.get("startup_nodes", None) or get_secret( # type: ignore
|
|
"REDIS_CLUSTER_NODES"
|
|
)
|
|
|
|
# If startup_nodes resolved to None (not set by kwarg or env), remove the key
|
|
# entirely so callers can rely on key presence as a reliable cluster-mode signal.
|
|
if _startup_nodes is not None and isinstance(_startup_nodes, str):
|
|
redis_kwargs["startup_nodes"] = json.loads(_startup_nodes)
|
|
elif _startup_nodes is None:
|
|
redis_kwargs.pop("startup_nodes", None)
|
|
|
|
_sentinel_nodes: Optional[Union[str, list]] = redis_kwargs.get("sentinel_nodes", None) or get_secret( # type: ignore
|
|
"REDIS_SENTINEL_NODES"
|
|
)
|
|
|
|
if _sentinel_nodes is not None and isinstance(_sentinel_nodes, str):
|
|
redis_kwargs["sentinel_nodes"] = json.loads(_sentinel_nodes)
|
|
|
|
_sentinel_password: Optional[str] = redis_kwargs.get(
|
|
"sentinel_password", None
|
|
) or get_secret_str("REDIS_SENTINEL_PASSWORD")
|
|
|
|
if _sentinel_password is not None:
|
|
redis_kwargs["sentinel_password"] = _sentinel_password
|
|
|
|
_service_name: Optional[str] = redis_kwargs.get("service_name", None) or get_secret( # type: ignore
|
|
"REDIS_SERVICE_NAME"
|
|
)
|
|
|
|
if _service_name is not None:
|
|
redis_kwargs["service_name"] = _service_name
|
|
|
|
# Handle GCP IAM authentication
|
|
_gcp_service_account = redis_kwargs.get("gcp_service_account") or get_secret_str(
|
|
"REDIS_GCP_SERVICE_ACCOUNT"
|
|
)
|
|
_gcp_ssl_ca_certs = redis_kwargs.get("gcp_ssl_ca_certs") or get_secret_str(
|
|
"REDIS_GCP_SSL_CA_CERTS"
|
|
)
|
|
|
|
if _gcp_service_account is not None:
|
|
verbose_logger.debug(
|
|
"Setting up GCP IAM authentication for Redis with service account."
|
|
)
|
|
redis_kwargs["redis_connect_func"] = create_gcp_iam_redis_connect_func(
|
|
service_account=_gcp_service_account, ssl_ca_certs=_gcp_ssl_ca_certs
|
|
)
|
|
# Store GCP service account in redis_connect_func for async cluster access
|
|
redis_kwargs["redis_connect_func"]._gcp_service_account = _gcp_service_account # type: ignore[attr-defined]
|
|
|
|
# Remove GCP-specific kwargs that shouldn't be passed to Redis client
|
|
redis_kwargs.pop("gcp_service_account", None)
|
|
redis_kwargs.pop("gcp_ssl_ca_certs", None)
|
|
|
|
# Only enable SSL if explicitly requested AND SSL CA certs are provided
|
|
if _gcp_ssl_ca_certs and redis_kwargs.get("ssl", False):
|
|
redis_kwargs["ssl_ca_certs"] = _gcp_ssl_ca_certs
|
|
|
|
if "url" in redis_kwargs and redis_kwargs["url"] is not None:
|
|
# Only strip host/port/db/password when not routing to a cluster.
|
|
# When startup_nodes is also present the cluster path takes priority and
|
|
# needs the password for authentication.
|
|
if not redis_kwargs.get("startup_nodes"):
|
|
redis_kwargs.pop("host", None)
|
|
redis_kwargs.pop("port", None)
|
|
redis_kwargs.pop("db", None)
|
|
redis_kwargs.pop("password", None)
|
|
elif "startup_nodes" in redis_kwargs and redis_kwargs["startup_nodes"] is not None:
|
|
pass
|
|
elif (
|
|
"sentinel_nodes" in redis_kwargs and redis_kwargs["sentinel_nodes"] is not None
|
|
):
|
|
pass
|
|
elif "host" not in redis_kwargs or redis_kwargs["host"] is None:
|
|
raise ValueError("Either 'host' or 'url' must be specified for redis.")
|
|
|
|
# litellm.print_verbose(f"redis_kwargs: {redis_kwargs}")
|
|
return redis_kwargs
|
|
|
|
|
|
def init_redis_cluster(redis_kwargs) -> redis.RedisCluster:
|
|
_redis_cluster_nodes_in_env: Optional[str] = get_secret("REDIS_CLUSTER_NODES") # type: ignore
|
|
if _redis_cluster_nodes_in_env is not None:
|
|
try:
|
|
redis_kwargs["startup_nodes"] = json.loads(_redis_cluster_nodes_in_env)
|
|
except json.JSONDecodeError:
|
|
raise ValueError(
|
|
"REDIS_CLUSTER_NODES environment variable is not valid JSON. Please ensure it's properly formatted."
|
|
)
|
|
|
|
verbose_logger.debug("init_redis_cluster: startup nodes are being initialized.")
|
|
from redis.cluster import ClusterNode
|
|
|
|
args = _get_redis_cluster_kwargs()
|
|
cluster_kwargs = {}
|
|
for arg in redis_kwargs:
|
|
if arg in args:
|
|
cluster_kwargs[arg] = redis_kwargs[arg]
|
|
|
|
new_startup_nodes: List[ClusterNode] = []
|
|
|
|
for item in redis_kwargs["startup_nodes"]:
|
|
new_startup_nodes.append(ClusterNode(**item))
|
|
|
|
cluster_kwargs.pop("startup_nodes", None)
|
|
return redis.RedisCluster(startup_nodes=new_startup_nodes, **cluster_kwargs) # type: ignore
|
|
|
|
|
|
def _init_redis_sentinel(redis_kwargs) -> redis.Redis:
|
|
sentinel_nodes = redis_kwargs.get("sentinel_nodes")
|
|
sentinel_password = redis_kwargs.get("sentinel_password")
|
|
service_name = redis_kwargs.get("service_name")
|
|
|
|
if not sentinel_nodes or not service_name:
|
|
raise ValueError(
|
|
"Both 'sentinel_nodes' and 'service_name' are required for Redis Sentinel."
|
|
)
|
|
|
|
verbose_logger.debug("init_redis_sentinel: sentinel nodes are being initialized.")
|
|
|
|
# Set up the Sentinel client
|
|
sentinel = redis.Sentinel(
|
|
sentinel_nodes,
|
|
socket_timeout=REDIS_SOCKET_TIMEOUT,
|
|
password=sentinel_password,
|
|
)
|
|
|
|
# Return the master instance for the given service
|
|
|
|
return sentinel.master_for(service_name)
|
|
|
|
|
|
def _init_async_redis_sentinel(redis_kwargs) -> async_redis.Redis:
|
|
sentinel_nodes = redis_kwargs.get("sentinel_nodes")
|
|
sentinel_password = redis_kwargs.get("sentinel_password")
|
|
service_name = redis_kwargs.get("service_name")
|
|
|
|
if not sentinel_nodes or not service_name:
|
|
raise ValueError(
|
|
"Both 'sentinel_nodes' and 'service_name' are required for Redis Sentinel."
|
|
)
|
|
|
|
verbose_logger.debug("init_redis_sentinel: sentinel nodes are being initialized.")
|
|
|
|
# Set up the Sentinel client
|
|
sentinel = async_redis.Sentinel(
|
|
sentinel_nodes,
|
|
socket_timeout=REDIS_SOCKET_TIMEOUT,
|
|
password=sentinel_password,
|
|
)
|
|
|
|
# Return the master instance for the given service
|
|
|
|
return sentinel.master_for(service_name)
|
|
|
|
|
|
def get_redis_client(**env_overrides):
|
|
redis_kwargs = _get_redis_client_logic(**env_overrides)
|
|
|
|
if "startup_nodes" in redis_kwargs:
|
|
return init_redis_cluster(redis_kwargs)
|
|
|
|
if "url" in redis_kwargs and redis_kwargs["url"] is not None:
|
|
args = _get_redis_url_kwargs()
|
|
url_kwargs = {}
|
|
for arg in redis_kwargs:
|
|
if arg in args:
|
|
url_kwargs[arg] = redis_kwargs[arg]
|
|
|
|
return redis.Redis.from_url(**url_kwargs)
|
|
|
|
# Check for Redis Sentinel
|
|
if "sentinel_nodes" in redis_kwargs and "service_name" in redis_kwargs:
|
|
return _init_redis_sentinel(redis_kwargs)
|
|
|
|
return redis.Redis(**redis_kwargs)
|
|
|
|
|
|
def get_redis_async_client(
|
|
connection_pool: Optional[async_redis.BlockingConnectionPool] = None,
|
|
**env_overrides,
|
|
) -> Union[async_redis.Redis, async_redis.RedisCluster]:
|
|
redis_kwargs = _get_redis_client_logic(**env_overrides)
|
|
|
|
if "startup_nodes" in redis_kwargs:
|
|
from redis.cluster import ClusterNode
|
|
|
|
args = _get_redis_cluster_kwargs()
|
|
cluster_kwargs = {}
|
|
for arg in redis_kwargs:
|
|
if arg in args:
|
|
cluster_kwargs[arg] = redis_kwargs[arg]
|
|
|
|
# Handle GCP IAM authentication for async clusters
|
|
redis_connect_func = cluster_kwargs.pop("redis_connect_func", None)
|
|
|
|
# Use a CredentialProvider so the IAM token is regenerated on every new
|
|
# connection — mirrors the sync path where redis_connect_func is invoked
|
|
# per connection. Without this, the token would expire after ~1 hour.
|
|
if redis_connect_func and hasattr(redis_connect_func, "_gcp_service_account"):
|
|
cluster_kwargs["credential_provider"] = GCPIAMCredentialProvider(
|
|
redis_connect_func._gcp_service_account
|
|
)
|
|
|
|
new_startup_nodes: List[ClusterNode] = []
|
|
|
|
for item in redis_kwargs["startup_nodes"]:
|
|
new_startup_nodes.append(ClusterNode(**item))
|
|
cluster_kwargs.pop("startup_nodes", None)
|
|
|
|
# Create async RedisCluster with IAM token as password if available
|
|
cluster_client = async_redis.RedisCluster(
|
|
startup_nodes=new_startup_nodes, **cluster_kwargs # type: ignore
|
|
)
|
|
|
|
return cluster_client
|
|
|
|
if "url" in redis_kwargs and redis_kwargs["url"] is not None:
|
|
if connection_pool is not None:
|
|
return async_redis.Redis(connection_pool=connection_pool)
|
|
args = _get_redis_url_kwargs(client=async_redis.Redis.from_url)
|
|
url_kwargs = {}
|
|
for arg in redis_kwargs:
|
|
if arg in args:
|
|
url_kwargs[arg] = redis_kwargs[arg]
|
|
else:
|
|
verbose_logger.debug(
|
|
"REDIS: ignoring argument: {}. Not an allowed async_redis.Redis.from_url arg.".format(
|
|
arg
|
|
)
|
|
)
|
|
return async_redis.Redis.from_url(**url_kwargs)
|
|
|
|
# Check for Redis Sentinel
|
|
if "sentinel_nodes" in redis_kwargs and "service_name" in redis_kwargs:
|
|
return _init_async_redis_sentinel(redis_kwargs)
|
|
_pretty_print_redis_config(redis_kwargs=redis_kwargs)
|
|
|
|
if connection_pool is not None:
|
|
redis_kwargs["connection_pool"] = connection_pool
|
|
|
|
return async_redis.Redis(
|
|
**redis_kwargs,
|
|
)
|
|
|
|
|
|
def get_redis_connection_pool(
|
|
**env_overrides,
|
|
) -> Optional[async_redis.BlockingConnectionPool]:
|
|
redis_kwargs = _get_redis_client_logic(**env_overrides)
|
|
verbose_logger.debug("get_redis_connection_pool: redis_kwargs", redis_kwargs)
|
|
|
|
if "startup_nodes" in redis_kwargs:
|
|
return None
|
|
|
|
if "url" in redis_kwargs and redis_kwargs["url"] is not None:
|
|
pool_kwargs = {
|
|
"timeout": REDIS_CONNECTION_POOL_TIMEOUT,
|
|
"url": redis_kwargs["url"],
|
|
}
|
|
if "max_connections" in redis_kwargs:
|
|
try:
|
|
pool_kwargs["max_connections"] = int(redis_kwargs["max_connections"])
|
|
except (TypeError, ValueError):
|
|
verbose_logger.warning(
|
|
"REDIS: invalid max_connections value %r, ignoring",
|
|
redis_kwargs["max_connections"],
|
|
)
|
|
return async_redis.BlockingConnectionPool.from_url(**pool_kwargs)
|
|
connection_class = async_redis.Connection
|
|
if "ssl" in redis_kwargs:
|
|
connection_class = async_redis.SSLConnection
|
|
redis_kwargs.pop("ssl", None)
|
|
redis_kwargs["connection_class"] = connection_class
|
|
return async_redis.BlockingConnectionPool(
|
|
timeout=REDIS_CONNECTION_POOL_TIMEOUT, **redis_kwargs
|
|
)
|
|
|
|
|
|
def _pretty_print_redis_config(redis_kwargs: dict) -> None:
|
|
"""Pretty print the Redis configuration using rich with sensitive data masking"""
|
|
try:
|
|
import logging
|
|
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
|
|
if not verbose_logger.isEnabledFor(logging.DEBUG):
|
|
return
|
|
|
|
console = Console()
|
|
|
|
# Initialize the sensitive data masker
|
|
masker = SensitiveDataMasker()
|
|
|
|
# Mask sensitive data in redis_kwargs
|
|
masked_redis_kwargs = masker.mask_dict(redis_kwargs)
|
|
|
|
# Create main panel title
|
|
title = Text("Redis Configuration", style="bold blue")
|
|
|
|
# Create configuration table
|
|
config_table = Table(
|
|
title="🔧 Redis Connection Parameters",
|
|
show_header=True,
|
|
header_style="bold magenta",
|
|
title_justify="left",
|
|
)
|
|
config_table.add_column("Parameter", style="cyan", no_wrap=True)
|
|
config_table.add_column("Value", style="yellow")
|
|
|
|
# Add rows for each configuration parameter
|
|
for key, value in masked_redis_kwargs.items():
|
|
if value is not None:
|
|
# Special handling for complex objects
|
|
if isinstance(value, list):
|
|
if key == "startup_nodes" and value:
|
|
# Special handling for cluster nodes
|
|
value_str = f"[{len(value)} cluster nodes]"
|
|
elif key == "sentinel_nodes" and value:
|
|
# Special handling for sentinel nodes
|
|
value_str = f"[{len(value)} sentinel nodes]"
|
|
else:
|
|
value_str = str(value)
|
|
else:
|
|
value_str = str(value)
|
|
|
|
config_table.add_row(key, value_str)
|
|
|
|
# Determine connection type
|
|
connection_type = "Standard Redis"
|
|
if masked_redis_kwargs.get("startup_nodes"):
|
|
connection_type = "Redis Cluster"
|
|
elif masked_redis_kwargs.get("sentinel_nodes"):
|
|
connection_type = "Redis Sentinel"
|
|
elif masked_redis_kwargs.get("url"):
|
|
connection_type = "Redis (URL-based)"
|
|
|
|
# Create connection type info
|
|
info_table = Table(
|
|
title="📊 Connection Info",
|
|
show_header=True,
|
|
header_style="bold green",
|
|
title_justify="left",
|
|
)
|
|
info_table.add_column("Property", style="cyan", no_wrap=True)
|
|
info_table.add_column("Value", style="yellow")
|
|
info_table.add_row("Connection Type", connection_type)
|
|
|
|
# Print everything in a nice panel
|
|
console.print("\n")
|
|
console.print(Panel(title, border_style="blue"))
|
|
console.print(info_table)
|
|
console.print(config_table)
|
|
console.print("\n")
|
|
|
|
except ImportError:
|
|
# Fallback to simple logging if rich is not available
|
|
masker = SensitiveDataMasker()
|
|
masked_redis_kwargs = masker.mask_dict(redis_kwargs)
|
|
verbose_logger.info(f"Redis configuration: {masked_redis_kwargs}")
|
|
except Exception as e:
|
|
verbose_logger.error(f"Error pretty printing Redis configuration: {e}")
|