From cba4fa403dba0540ec3c69db1466b0419d4bb126 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:42:48 -0700 Subject: [PATCH 1/8] fix(redis): keep Azure AD and GCP IAM auth on URL and pool clients REDIS_URL-based async clients and every async connection pool dropped the managed-identity credential the caller configured, so they connected unauthenticated against an auth-enforcing Redis. The conversion from redis_connect_func to a CredentialProvider now happens once, before any branch, and covers the url, sentinel, cluster, and pool paths alike. Also adds credential_provider to the cluster kwargs allowlist, which silently filtered it out. --- litellm/_redis.py | 68 ++++++++++++----------------- tests/test_litellm/test_redis.py | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 42 deletions(-) diff --git a/litellm/_redis.py b/litellm/_redis.py index 0acc01fa14f..b78ab285ac5 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -17,6 +17,7 @@ from typing import Final import redis import redis.asyncio as async_redis +from redis.credentials import CredentialProvider from litellm import get_secret, get_secret_str from litellm._redis_credential_provider import ( @@ -134,6 +135,7 @@ def _get_redis_cluster_kwargs(client=None): "ssl_check_hostname", "ssl_ca_certs", "redis_connect_func", # Needed for sync clusters and IAM detection + "credential_provider", "gcp_service_account", "gcp_ssl_ca_certs", "azure_redis_ad_token", @@ -574,6 +576,20 @@ def _init_async_redis_sentinel(redis_kwargs) -> async_redis.Redis: return sentinel.master_for(service_name, **connection_kwargs) +def _async_credential_provider(redis_connect_func: object | None) -> CredentialProvider | None: + """Async redis-py never calls ``redis_connect_func``; it authenticates through a + ``CredentialProvider``, which it consults per connection so the token refreshes.""" + gcp_service_account: Final = getattr(redis_connect_func, "_gcp_service_account", None) + if gcp_service_account is not None: + return GCPIAMCredentialProvider(gcp_service_account) + + azure_credential: Final = getattr(redis_connect_func, "_azure_credential", None) + if azure_credential is not None: + return AzureADCredentialProvider(azure_credential, username=os.environ.get("REDIS_USERNAME") or None) + + return None + + def get_redis_client(**env_overrides): redis_kwargs: Final = _get_redis_client_logic(**env_overrides) @@ -601,6 +617,11 @@ def get_redis_async_client( **env_overrides, ) -> async_redis.Redis | async_redis.RedisCluster: redis_kwargs: Final = _get_redis_client_logic(**env_overrides) + credential_provider: Final = _async_credential_provider(redis_kwargs.pop("redis_connect_func", None)) + if credential_provider is not None: + redis_kwargs["credential_provider"] = credential_provider + redis_kwargs.pop("username", None) + redis_kwargs.pop("password", None) if "startup_nodes" in redis_kwargs: from redis.cluster import ClusterNode @@ -611,23 +632,6 @@ def get_redis_async_client( 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) - # Handle Azure AD authentication for async clusters via CredentialProvider - # so the credential's internal cache + silent refresh runs per connection - # (mirrors GCP IAM above; avoids static-token-baked-in-pool expiry). - elif redis_connect_func and hasattr(redis_connect_func, "_azure_credential"): - cluster_kwargs["credential_provider"] = AzureADCredentialProvider( - redis_connect_func._azure_credential, - username=os.environ.get("REDIS_USERNAME") or None, - ) - new_startup_nodes: Final[list[ClusterNode]] = [] for item in redis_kwargs["startup_nodes"]: @@ -667,19 +671,6 @@ def get_redis_async_client( if "sentinel_nodes" in redis_kwargs and "service_name" in redis_kwargs: return _init_async_redis_sentinel(redis_kwargs) - # Wrap GCP / Azure AD auth in a CredentialProvider for the standard async - # Redis client. The async client doesn't support redis_connect_func, but it - # does honour credential_provider — which is called per connection, so the - # underlying SDK can refresh tokens silently before they expire. - redis_connect_func = redis_kwargs.pop("redis_connect_func", None) - if redis_connect_func and hasattr(redis_connect_func, "_azure_credential"): - redis_kwargs["credential_provider"] = AzureADCredentialProvider( - redis_connect_func._azure_credential, - username=os.environ.get("REDIS_USERNAME") or None, - ) - elif redis_connect_func and hasattr(redis_connect_func, "_gcp_service_account"): - redis_kwargs["credential_provider"] = GCPIAMCredentialProvider(redis_connect_func._gcp_service_account) - _pretty_print_redis_config(redis_kwargs=redis_kwargs) if connection_pool is not None: @@ -694,6 +685,11 @@ def get_redis_connection_pool( **env_overrides, ) -> async_redis.BlockingConnectionPool | None: redis_kwargs: Final = _get_redis_client_logic(**env_overrides) + credential_provider: Final = _async_credential_provider(redis_kwargs.pop("redis_connect_func", None)) + if credential_provider is not None: + redis_kwargs["credential_provider"] = credential_provider + redis_kwargs.pop("username", None) + redis_kwargs.pop("password", None) verbose_logger.debug("get_redis_connection_pool: redis_kwargs", redis_kwargs) if "startup_nodes" in redis_kwargs: @@ -714,18 +710,6 @@ def get_redis_connection_pool( ) return async_redis.BlockingConnectionPool.from_url(**pool_kwargs) - # Wrap GCP / Azure AD auth in a CredentialProvider so pool-managed - # connections re-fetch tokens via the SDK's internal cache + silent refresh - # rather than reusing a single token captured at pool creation. - redis_connect_func: Final = redis_kwargs.pop("redis_connect_func", None) - if redis_connect_func and hasattr(redis_connect_func, "_azure_credential"): - redis_kwargs["credential_provider"] = AzureADCredentialProvider( - redis_connect_func._azure_credential, - username=os.environ.get("REDIS_USERNAME") or None, - ) - elif redis_connect_func and hasattr(redis_connect_func, "_gcp_service_account"): - redis_kwargs["credential_provider"] = GCPIAMCredentialProvider(redis_connect_func._gcp_service_account) - if redis_kwargs.pop("ssl", None): redis_kwargs["connection_class"] = async_redis.SSLConnection return async_redis.BlockingConnectionPool(timeout=REDIS_CONNECTION_POOL_TIMEOUT, **redis_kwargs) diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index 896ca2de399..5c02b1f786f 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -1,4 +1,5 @@ import json +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -14,6 +15,7 @@ from litellm._redis import ( ) from litellm.constants import REDIS_CLUSTER_HEALTH_CHECK_INTERVAL from litellm._redis_credential_provider import ( + AzureADCredentialProvider, GCPIAMCredentialProvider, _token_cache, ) @@ -910,3 +912,74 @@ def test_url_allowlist_always_carries_socket_timeouts(): allowed = _get_redis_url_kwargs() assert "socket_timeout" in allowed assert "socket_connect_timeout" in allowed + + +AZURE_AD_CONNECT_FUNC = {"_azure_credential": object()} +GCP_IAM_CONNECT_FUNC = {"_gcp_service_account": "projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com"} + + +@pytest.mark.parametrize( + "markers, provider_cls", + [ + (AZURE_AD_CONNECT_FUNC, AzureADCredentialProvider), + (GCP_IAM_CONNECT_FUNC, GCPIAMCredentialProvider), + ], + ids=["azure_ad", "gcp_iam"], +) +def test_async_url_client_authenticates_through_credential_provider(markers, provider_cls): + """A REDIS_URL config with Azure AD or GCP IAM must still reach the server with a credential. + + The async client accepts redis_connect_func as a kwarg but never calls it, so the url + branch has to hand the connection a CredentialProvider or it authenticates with nothing. + """ + redis_kwargs = { + "url": "rediss://redis-host:6380", + "redis_connect_func": SimpleNamespace(**markers), + } + + with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs): + client = get_redis_async_client() + + connection_kwargs = client.connection_pool.connection_kwargs + assert isinstance(connection_kwargs.get("credential_provider"), provider_cls) + assert "redis_connect_func" not in connection_kwargs + + +@pytest.mark.parametrize( + "markers, provider_cls", + [ + (AZURE_AD_CONNECT_FUNC, AzureADCredentialProvider), + (GCP_IAM_CONNECT_FUNC, GCPIAMCredentialProvider), + ], + ids=["azure_ad", "gcp_iam"], +) +def test_async_url_connection_pool_authenticates_through_credential_provider(markers, provider_cls): + """Same for the pool-based path: every connection the pool hands out needs the provider.""" + redis_kwargs = { + "url": "rediss://redis-host:6380", + "redis_connect_func": SimpleNamespace(**markers), + } + + with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs): + pool = get_redis_connection_pool() + + assert isinstance(pool.connection_kwargs.get("credential_provider"), provider_cls) + assert "redis_connect_func" not in pool.connection_kwargs + + +def test_async_url_client_drops_username_alongside_credential_provider(): + """redis-py refuses a connection given both a username and a credential_provider, and + AzureADCredentialProvider already carries REDIS_USERNAME, so the username must be dropped. + """ + redis_kwargs = { + "url": "rediss://redis-host:6380", + "username": "redis-user", + "redis_connect_func": SimpleNamespace(**AZURE_AD_CONNECT_FUNC), + } + + with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs): + client = get_redis_async_client() + + pool = client.connection_pool + assert "username" not in pool.connection_kwargs + pool.connection_class(**pool.connection_kwargs) From bfe54eb013c6cae14e12bccf3ee0674e7fcc573a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:46:03 -0700 Subject: [PATCH 2/8] docs(redis): say why async paths cannot reuse redis_connect_func The AUTH exchange it runs is the blocking client API, so on an async connection send_command and read_response hand back coroutines nobody awaits and the connect fails outright. --- litellm/_redis.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/_redis.py b/litellm/_redis.py index b78ab285ac5..8c0ce1e7a3b 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -577,8 +577,10 @@ def _init_async_redis_sentinel(redis_kwargs) -> async_redis.Redis: def _async_credential_provider(redis_connect_func: object | None) -> CredentialProvider | None: - """Async redis-py never calls ``redis_connect_func``; it authenticates through a - ``CredentialProvider``, which it consults per connection so the token refreshes.""" + """``redis_connect_func`` runs the AUTH exchange with the blocking client API, so on an + async connection its ``send_command``/``read_response`` calls return coroutines nobody + awaits and every connect fails. Async paths authenticate through a ``CredentialProvider`` + instead, which redis-py consults per connection so the token stays fresh.""" gcp_service_account: Final = getattr(redis_connect_func, "_gcp_service_account", None) if gcp_service_account is not None: return GCPIAMCredentialProvider(gcp_service_account) From 307ca1bcc74ac8815d054b8fce44684746bd3b45 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:53:00 -0700 Subject: [PATCH 3/8] fix(redis): say when an async client drops an unusable connect func A caller-supplied redis_connect_func has no way to run on an async connection, so log it instead of dropping it in silence. --- litellm/_redis.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/litellm/_redis.py b/litellm/_redis.py index 8c0ce1e7a3b..f6fd031142a 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -589,6 +589,12 @@ def _async_credential_provider(redis_connect_func: object | None) -> CredentialP if azure_credential is not None: return AzureADCredentialProvider(azure_credential, username=os.environ.get("REDIS_USERNAME") or None) + if redis_connect_func is not None: + verbose_logger.warning( + "REDIS: dropping redis_connect_func, which an async connection cannot run. " + "Configure Azure AD or GCP IAM auth so a credential provider handles the token instead." + ) + return None From 14faec9bc4e95affcc5a28307d268bf985996952 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:10:53 -0700 Subject: [PATCH 4/8] fix(redis): keep a coroutine redis_connect_func on async clients redis-py awaits a redis_connect_func that is a coroutine function, so dropping every connect func the async paths cannot convert took away an auth path that worked. --- litellm/_redis.py | 28 ++++++++++++---------------- tests/test_litellm/test_redis.py | 26 ++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/litellm/_redis.py b/litellm/_redis.py index f6fd031142a..e67dee0621d 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -577,10 +577,12 @@ def _init_async_redis_sentinel(redis_kwargs) -> async_redis.Redis: def _async_credential_provider(redis_connect_func: object | None) -> CredentialProvider | None: - """``redis_connect_func`` runs the AUTH exchange with the blocking client API, so on an - async connection its ``send_command``/``read_response`` calls return coroutines nobody - awaits and every connect fails. Async paths authenticate through a ``CredentialProvider`` - instead, which redis-py consults per connection so the token stays fresh.""" + """The Azure AD and GCP IAM connect funcs run their AUTH exchange with the blocking client + API, so on an async connection their ``send_command``/``read_response`` calls return + coroutines nobody awaits and every connect fails. Async paths authenticate through a + ``CredentialProvider`` instead, which redis-py consults per connection so the token stays + fresh. Any other ``redis_connect_func`` is left where it is, since redis-py awaits it + itself when it is a coroutine function.""" gcp_service_account: Final = getattr(redis_connect_func, "_gcp_service_account", None) if gcp_service_account is not None: return GCPIAMCredentialProvider(gcp_service_account) @@ -589,12 +591,6 @@ def _async_credential_provider(redis_connect_func: object | None) -> CredentialP if azure_credential is not None: return AzureADCredentialProvider(azure_credential, username=os.environ.get("REDIS_USERNAME") or None) - if redis_connect_func is not None: - verbose_logger.warning( - "REDIS: dropping redis_connect_func, which an async connection cannot run. " - "Configure Azure AD or GCP IAM auth so a credential provider handles the token instead." - ) - return None @@ -625,11 +621,11 @@ def get_redis_async_client( **env_overrides, ) -> async_redis.Redis | async_redis.RedisCluster: redis_kwargs: Final = _get_redis_client_logic(**env_overrides) - credential_provider: Final = _async_credential_provider(redis_kwargs.pop("redis_connect_func", None)) + credential_provider: Final = _async_credential_provider(redis_kwargs.get("redis_connect_func")) if credential_provider is not None: redis_kwargs["credential_provider"] = credential_provider - redis_kwargs.pop("username", None) - redis_kwargs.pop("password", None) + for superseded in ("redis_connect_func", "username", "password"): + redis_kwargs.pop(superseded, None) if "startup_nodes" in redis_kwargs: from redis.cluster import ClusterNode @@ -693,11 +689,11 @@ def get_redis_connection_pool( **env_overrides, ) -> async_redis.BlockingConnectionPool | None: redis_kwargs: Final = _get_redis_client_logic(**env_overrides) - credential_provider: Final = _async_credential_provider(redis_kwargs.pop("redis_connect_func", None)) + credential_provider: Final = _async_credential_provider(redis_kwargs.get("redis_connect_func")) if credential_provider is not None: redis_kwargs["credential_provider"] = credential_provider - redis_kwargs.pop("username", None) - redis_kwargs.pop("password", None) + for superseded in ("redis_connect_func", "username", "password"): + redis_kwargs.pop(superseded, None) verbose_logger.debug("get_redis_connection_pool: redis_kwargs", redis_kwargs) if "startup_nodes" in redis_kwargs: diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index 5c02b1f786f..5d03eb6d660 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -929,8 +929,9 @@ GCP_IAM_CONNECT_FUNC = {"_gcp_service_account": "projects/-/serviceAccounts/sa@p def test_async_url_client_authenticates_through_credential_provider(markers, provider_cls): """A REDIS_URL config with Azure AD or GCP IAM must still reach the server with a credential. - The async client accepts redis_connect_func as a kwarg but never calls it, so the url - branch has to hand the connection a CredentialProvider or it authenticates with nothing. + The url branch forwards redis_connect_func straight to the async connection, which runs + its AUTH exchange with the blocking client API and dies, so the branch has to hand the + connection a CredentialProvider instead. """ redis_kwargs = { "url": "rediss://redis-host:6380", @@ -983,3 +984,24 @@ def test_async_url_client_drops_username_alongside_credential_provider(): pool = client.connection_pool assert "username" not in pool.connection_kwargs pool.connection_class(**pool.connection_kwargs) + + +@pytest.mark.parametrize("build_pool", [False, True], ids=["client", "pool"]) +def test_async_url_keeps_a_coroutine_connect_func(build_pool): + """redis-py awaits a coroutine redis_connect_func on an async connection, so one we cannot + turn into a credential provider has to be left where it is rather than dropped. + """ + + async def connect(connection): + return None + + redis_kwargs = { + "url": "rediss://redis-host:6380", + "redis_connect_func": connect, + } + + with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs): + pool = get_redis_connection_pool() if build_pool else get_redis_async_client().connection_pool + + assert pool.connection_kwargs["redis_connect_func"] is connect + assert "credential_provider" not in pool.connection_kwargs From 2417613b5f6fb7ff2a4aebf9f1cda8d75ad7246a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:24:20 -0700 Subject: [PATCH 5/8] refactor(redis): build the async auth kwargs instead of mutating them twice Both async entrypoints edited the kwargs dict in place with the same five lines. One shared transform returns the swapped copy instead. --- litellm/_redis.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/litellm/_redis.py b/litellm/_redis.py index e67dee0621d..ebbc191dfec 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -594,6 +594,18 @@ def _async_credential_provider(redis_connect_func: object | None) -> CredentialP return None +def _async_auth_kwargs(redis_kwargs: dict) -> dict: + """Swaps a connect func an async path cannot run for the equivalent credential provider, + which supersedes any static username or password redis-py would otherwise reject it with.""" + credential_provider: Final = _async_credential_provider(redis_kwargs.get("redis_connect_func")) + if credential_provider is None: + return redis_kwargs + + superseded: Final = frozenset({"redis_connect_func", "username", "password"}) + kept: Final = ((k, v) for k, v in redis_kwargs.items() if k not in superseded) + return dict(kept, credential_provider=credential_provider) # mutable-ok: the branches below mutate these kwargs + + def get_redis_client(**env_overrides): redis_kwargs: Final = _get_redis_client_logic(**env_overrides) @@ -620,12 +632,7 @@ def get_redis_async_client( connection_pool: async_redis.BlockingConnectionPool | None = None, **env_overrides, ) -> async_redis.Redis | async_redis.RedisCluster: - redis_kwargs: Final = _get_redis_client_logic(**env_overrides) - credential_provider: Final = _async_credential_provider(redis_kwargs.get("redis_connect_func")) - if credential_provider is not None: - redis_kwargs["credential_provider"] = credential_provider - for superseded in ("redis_connect_func", "username", "password"): - redis_kwargs.pop(superseded, None) + redis_kwargs: Final = _async_auth_kwargs(_get_redis_client_logic(**env_overrides)) if "startup_nodes" in redis_kwargs: from redis.cluster import ClusterNode @@ -688,12 +695,7 @@ def get_redis_async_client( def get_redis_connection_pool( **env_overrides, ) -> async_redis.BlockingConnectionPool | None: - redis_kwargs: Final = _get_redis_client_logic(**env_overrides) - credential_provider: Final = _async_credential_provider(redis_kwargs.get("redis_connect_func")) - if credential_provider is not None: - redis_kwargs["credential_provider"] = credential_provider - for superseded in ("redis_connect_func", "username", "password"): - redis_kwargs.pop(superseded, None) + redis_kwargs: Final = _async_auth_kwargs(_get_redis_client_logic(**env_overrides)) verbose_logger.debug("get_redis_connection_pool: redis_kwargs", redis_kwargs) if "startup_nodes" in redis_kwargs: From 308c906cdd941d8577664fd0e8b1a4cf9a68d4f3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:37:28 -0700 Subject: [PATCH 6/8] fix(redis): drop a connect func the async cluster client cannot accept --- litellm/_redis.py | 1 + tests/test_litellm/test_redis.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/litellm/_redis.py b/litellm/_redis.py index ebbc191dfec..8e67bc66e5f 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -648,6 +648,7 @@ def get_redis_async_client( for item in redis_kwargs["startup_nodes"]: new_startup_nodes.append(ClusterNode(**item)) cluster_kwargs.pop("startup_nodes", None) + cluster_kwargs.pop("redis_connect_func", None) # Default to a periodic health check + TCP keepalive so a connection silently dropped # by a cluster restart (e.g. ElastiCache Serverless maintenance) is revalidated and diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index 5d03eb6d660..d706c40767b 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -1005,3 +1005,22 @@ def test_async_url_keeps_a_coroutine_connect_func(build_pool): assert pool.connection_kwargs["redis_connect_func"] is connect assert "credential_provider" not in pool.connection_kwargs + + +def test_async_cluster_drops_a_connect_func_it_cannot_pass_on(): + """redis-py's async RedisCluster has no redis_connect_func parameter, so a connect func that + is not translated into a credential provider has to be dropped rather than forwarded. + """ + + async def connect(connection): + return None + + redis_kwargs = { + "startup_nodes": [{"host": "cluster-node", "port": 6379}], + "redis_connect_func": connect, + } + + with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs): + client = get_redis_async_client() + + assert isinstance(client, async_redis.RedisCluster) From 09b391d7b3775cd7e8ef8a9df45a99100aadcbbb Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:42:57 -0700 Subject: [PATCH 7/8] fix(redis): keep the credential provider off the Sentinel monitors --- litellm/_redis.py | 12 ++++++++++-- tests/test_litellm/test_redis.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) 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 From c09643ac4c0053c4d5514ecf199041c6013070b5 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:55:16 -0700 Subject: [PATCH 8/8] fix(redis): never hand a data-node credential provider to the Sentinel monitors The monitors are separate servers with their own password, so the data node's Entra or IAM token has no standing there. Dropping the provider only when a Sentinel password was configured left it in place for unauthenticated monitors, where redis-py sends it as an AUTH the monitor rejects and async Sentinel discovery fails. --- litellm/_redis.py | 10 +++++----- tests/test_litellm/test_redis.py | 21 +++++++++++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/litellm/_redis.py b/litellm/_redis.py index 182e24afc2f..f3f3c4424de 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -552,11 +552,11 @@ def _init_redis_sentinel(redis_kwargs) -> redis.Redis: 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) + """The Sentinel monitors are separate servers that authenticate with their own password, so the + data node's credential provider never belongs on them: leaving it there makes redis-py send the + data node's token to a monitor, which fails whether the monitor is unauthenticated or has its + own password.""" + kept: Final = ((k, v) for k, v in connection_kwargs.items() if k != "credential_provider") return dict(kept, password=sentinel_password) diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index fd26df76ae4..3aa4bc58f13 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -1034,13 +1034,19 @@ def test_async_cluster_drops_a_connect_func_it_cannot_pass_on(): ], 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. +@pytest.mark.parametrize( + "sentinel_password", + [None, "sentinel-secret"], + ids=["unauthenticated_monitors", "password_protected_monitors"], +) +def test_async_sentinel_keeps_the_credential_provider_off_the_monitors(markers, provider_cls, sentinel_password): + """The Sentinel monitors are separate servers with their own password, so the data node's token + never belongs on them: redis-py refuses it next to a Sentinel password, and sends it to an + unauthenticated monitor as an AUTH the monitor rejects. """ redis_kwargs = { "sentinel_nodes": [("sentinel-1", 26379)], - "sentinel_password": "sentinel-secret", + "sentinel_password": sentinel_password, "service_name": "mymaster", "redis_connect_func": SimpleNamespace(**markers), } @@ -1050,9 +1056,12 @@ def test_async_sentinel_keeps_the_credential_provider_off_the_monitors(markers, get_redis_async_client() sentinel_kwargs = mock_sentinel_cls.call_args[1]["sentinel_kwargs"] - assert sentinel_kwargs["password"] == "sentinel-secret" + assert sentinel_kwargs["password"] == sentinel_password assert "credential_provider" not in sentinel_kwargs - async_redis.Connection(host="sentinel-1", port=26379, **sentinel_kwargs) + + monitor_connection = async_redis.Connection(host="sentinel-1", port=26379, **sentinel_kwargs) + assert monitor_connection.credential_provider is None + assert bool(monitor_connection.username or monitor_connection.password) is bool(sentinel_password) master_kwargs = mock_sentinel_cls.return_value.master_for.call_args[1] assert isinstance(master_kwargs["credential_provider"], provider_cls)