diff --git a/litellm/_redis.py b/litellm/_redis.py index 8e67bc66e5f..182e24afc2f 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -551,14 +551,22 @@ def _init_redis_sentinel(redis_kwargs) -> redis.Redis: return sentinel.master_for(service_name, **connection_kwargs) +def _sentinel_auth_kwargs(connection_kwargs: dict, sentinel_password: str | None) -> dict: + """The Sentinel monitors are separate servers with their own password, and redis-py refuses a + password passed alongside a credential provider, so the data node's provider stays behind once + a Sentinel password is configured.""" + superseded: Final = frozenset({"credential_provider"}) if sentinel_password else frozenset() + kept: Final = ((k, v) for k, v in connection_kwargs.items() if k not in superseded) + return dict(kept, password=sentinel_password) + + def _init_async_redis_sentinel(redis_kwargs) -> async_redis.Redis: sentinel_nodes: Final = redis_kwargs.get("sentinel_nodes") sentinel_password: Final = redis_kwargs.get("sentinel_password") service_name: Final = redis_kwargs.get("service_name") connection_kwargs: Final = _get_redis_sentinel_connection_kwargs(redis_kwargs) connection_kwargs.setdefault("socket_timeout", REDIS_SOCKET_TIMEOUT) - sentinel_kwargs: Final = dict(connection_kwargs) - sentinel_kwargs["password"] = sentinel_password + sentinel_kwargs: Final = _sentinel_auth_kwargs(connection_kwargs, sentinel_password) if not sentinel_nodes or not service_name: raise ValueError("Both 'sentinel_nodes' and 'service_name' are required for Redis Sentinel.") diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index d706c40767b..fd26df76ae4 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -1024,3 +1024,36 @@ def test_async_cluster_drops_a_connect_func_it_cannot_pass_on(): client = get_redis_async_client() assert isinstance(client, async_redis.RedisCluster) + + +@pytest.mark.parametrize( + "markers, provider_cls", + [ + (AZURE_AD_CONNECT_FUNC, AzureADCredentialProvider), + (GCP_IAM_CONNECT_FUNC, GCPIAMCredentialProvider), + ], + ids=["azure_ad", "gcp_iam"], +) +def test_async_sentinel_keeps_the_credential_provider_off_the_monitors(markers, provider_cls): + """The Sentinel monitors authenticate with their own password, and redis-py refuses a password + passed alongside a credential provider, so only the data node may carry the provider. + """ + redis_kwargs = { + "sentinel_nodes": [("sentinel-1", 26379)], + "sentinel_password": "sentinel-secret", + "service_name": "mymaster", + "redis_connect_func": SimpleNamespace(**markers), + } + + with patch("litellm._redis.async_redis.Sentinel") as mock_sentinel_cls: + with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs): + get_redis_async_client() + + sentinel_kwargs = mock_sentinel_cls.call_args[1]["sentinel_kwargs"] + assert sentinel_kwargs["password"] == "sentinel-secret" + assert "credential_provider" not in sentinel_kwargs + async_redis.Connection(host="sentinel-1", port=26379, **sentinel_kwargs) + + master_kwargs = mock_sentinel_cls.return_value.master_for.call_args[1] + assert isinstance(master_kwargs["credential_provider"], provider_cls) + assert "password" not in master_kwargs