Skip to content

Commit 987478a

Browse files
authored
Merge pull request #37740 from BerriAI/litellm_redis_url_pool_credential_provider
fix(redis): apply Azure AD and GCP IAM auth to every async client path
2 parents c008d5e + c09643a commit 987478a

2 files changed

Lines changed: 201 additions & 46 deletions

File tree

litellm/_redis.py

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import redis
1919
import redis.asyncio as async_redis
20+
from redis.credentials import CredentialProvider
2021

2122
from litellm import get_secret, get_secret_str
2223
from litellm._redis_credential_provider import (
@@ -134,6 +135,7 @@ def _get_redis_cluster_kwargs(client=None):
134135
"ssl_check_hostname",
135136
"ssl_ca_certs",
136137
"redis_connect_func", # Needed for sync clusters and IAM detection
138+
"credential_provider",
137139
"gcp_service_account",
138140
"gcp_ssl_ca_certs",
139141
"azure_redis_ad_token",
@@ -549,14 +551,22 @@ def _init_redis_sentinel(redis_kwargs) -> redis.Redis:
549551
return sentinel.master_for(service_name, **connection_kwargs)
550552

551553

554+
def _sentinel_auth_kwargs(connection_kwargs: dict, sentinel_password: str | None) -> dict:
555+
"""The Sentinel monitors are separate servers that authenticate with their own password, so the
556+
data node's credential provider never belongs on them: leaving it there makes redis-py send the
557+
data node's token to a monitor, which fails whether the monitor is unauthenticated or has its
558+
own password."""
559+
kept: Final = ((k, v) for k, v in connection_kwargs.items() if k != "credential_provider")
560+
return dict(kept, password=sentinel_password)
561+
562+
552563
def _init_async_redis_sentinel(redis_kwargs) -> async_redis.Redis:
553564
sentinel_nodes: Final = redis_kwargs.get("sentinel_nodes")
554565
sentinel_password: Final = redis_kwargs.get("sentinel_password")
555566
service_name: Final = redis_kwargs.get("service_name")
556567
connection_kwargs: Final = _get_redis_sentinel_connection_kwargs(redis_kwargs)
557568
connection_kwargs.setdefault("socket_timeout", REDIS_SOCKET_TIMEOUT)
558-
sentinel_kwargs: Final = dict(connection_kwargs)
559-
sentinel_kwargs["password"] = sentinel_password
569+
sentinel_kwargs: Final = _sentinel_auth_kwargs(connection_kwargs, sentinel_password)
560570

561571
if not sentinel_nodes or not service_name:
562572
raise ValueError("Both 'sentinel_nodes' and 'service_name' are required for Redis Sentinel.")
@@ -574,6 +584,36 @@ def _init_async_redis_sentinel(redis_kwargs) -> async_redis.Redis:
574584
return sentinel.master_for(service_name, **connection_kwargs)
575585

576586

587+
def _async_credential_provider(redis_connect_func: object | None) -> CredentialProvider | None:
588+
"""The Azure AD and GCP IAM connect funcs run their AUTH exchange with the blocking client
589+
API, so on an async connection their ``send_command``/``read_response`` calls return
590+
coroutines nobody awaits and every connect fails. Async paths authenticate through a
591+
``CredentialProvider`` instead, which redis-py consults per connection so the token stays
592+
fresh. Any other ``redis_connect_func`` is left where it is, since redis-py awaits it
593+
itself when it is a coroutine function."""
594+
gcp_service_account: Final = getattr(redis_connect_func, "_gcp_service_account", None)
595+
if gcp_service_account is not None:
596+
return GCPIAMCredentialProvider(gcp_service_account)
597+
598+
azure_credential: Final = getattr(redis_connect_func, "_azure_credential", None)
599+
if azure_credential is not None:
600+
return AzureADCredentialProvider(azure_credential, username=os.environ.get("REDIS_USERNAME") or None)
601+
602+
return None
603+
604+
605+
def _async_auth_kwargs(redis_kwargs: dict) -> dict:
606+
"""Swaps a connect func an async path cannot run for the equivalent credential provider,
607+
which supersedes any static username or password redis-py would otherwise reject it with."""
608+
credential_provider: Final = _async_credential_provider(redis_kwargs.get("redis_connect_func"))
609+
if credential_provider is None:
610+
return redis_kwargs
611+
612+
superseded: Final = frozenset({"redis_connect_func", "username", "password"})
613+
kept: Final = ((k, v) for k, v in redis_kwargs.items() if k not in superseded)
614+
return dict(kept, credential_provider=credential_provider) # mutable-ok: the branches below mutate these kwargs
615+
616+
577617
def get_redis_client(**env_overrides):
578618
redis_kwargs: Final = _get_redis_client_logic(**env_overrides)
579619

@@ -600,7 +640,7 @@ def get_redis_async_client(
600640
connection_pool: async_redis.BlockingConnectionPool | None = None,
601641
**env_overrides,
602642
) -> async_redis.Redis | async_redis.RedisCluster:
603-
redis_kwargs: Final = _get_redis_client_logic(**env_overrides)
643+
redis_kwargs: Final = _async_auth_kwargs(_get_redis_client_logic(**env_overrides))
604644

605645
if "startup_nodes" in redis_kwargs:
606646
from redis.cluster import ClusterNode
@@ -611,28 +651,12 @@ def get_redis_async_client(
611651
if arg in args:
612652
cluster_kwargs[arg] = redis_kwargs[arg]
613653

614-
# Handle GCP IAM authentication for async clusters
615-
redis_connect_func = cluster_kwargs.pop("redis_connect_func", None)
616-
617-
# Use a CredentialProvider so the IAM token is regenerated on every new
618-
# connection — mirrors the sync path where redis_connect_func is invoked
619-
# per connection. Without this, the token would expire after ~1 hour.
620-
if redis_connect_func and hasattr(redis_connect_func, "_gcp_service_account"):
621-
cluster_kwargs["credential_provider"] = GCPIAMCredentialProvider(redis_connect_func._gcp_service_account)
622-
# Handle Azure AD authentication for async clusters via CredentialProvider
623-
# so the credential's internal cache + silent refresh runs per connection
624-
# (mirrors GCP IAM above; avoids static-token-baked-in-pool expiry).
625-
elif redis_connect_func and hasattr(redis_connect_func, "_azure_credential"):
626-
cluster_kwargs["credential_provider"] = AzureADCredentialProvider(
627-
redis_connect_func._azure_credential,
628-
username=os.environ.get("REDIS_USERNAME") or None,
629-
)
630-
631654
new_startup_nodes: Final[list[ClusterNode]] = []
632655

633656
for item in redis_kwargs["startup_nodes"]:
634657
new_startup_nodes.append(ClusterNode(**item))
635658
cluster_kwargs.pop("startup_nodes", None)
659+
cluster_kwargs.pop("redis_connect_func", None)
636660

637661
# Default to a periodic health check + TCP keepalive so a connection silently dropped
638662
# by a cluster restart (e.g. ElastiCache Serverless maintenance) is revalidated and
@@ -667,19 +691,6 @@ def get_redis_async_client(
667691
if "sentinel_nodes" in redis_kwargs and "service_name" in redis_kwargs:
668692
return _init_async_redis_sentinel(redis_kwargs)
669693

670-
# Wrap GCP / Azure AD auth in a CredentialProvider for the standard async
671-
# Redis client. The async client doesn't support redis_connect_func, but it
672-
# does honour credential_provider — which is called per connection, so the
673-
# underlying SDK can refresh tokens silently before they expire.
674-
redis_connect_func = redis_kwargs.pop("redis_connect_func", None)
675-
if redis_connect_func and hasattr(redis_connect_func, "_azure_credential"):
676-
redis_kwargs["credential_provider"] = AzureADCredentialProvider(
677-
redis_connect_func._azure_credential,
678-
username=os.environ.get("REDIS_USERNAME") or None,
679-
)
680-
elif redis_connect_func and hasattr(redis_connect_func, "_gcp_service_account"):
681-
redis_kwargs["credential_provider"] = GCPIAMCredentialProvider(redis_connect_func._gcp_service_account)
682-
683694
_pretty_print_redis_config(redis_kwargs=redis_kwargs)
684695

685696
if connection_pool is not None:
@@ -693,7 +704,7 @@ def get_redis_async_client(
693704
def get_redis_connection_pool(
694705
**env_overrides,
695706
) -> async_redis.BlockingConnectionPool | None:
696-
redis_kwargs: Final = _get_redis_client_logic(**env_overrides)
707+
redis_kwargs: Final = _async_auth_kwargs(_get_redis_client_logic(**env_overrides))
697708
verbose_logger.debug("get_redis_connection_pool: redis_kwargs", redis_kwargs)
698709

699710
if "startup_nodes" in redis_kwargs:
@@ -714,18 +725,6 @@ def get_redis_connection_pool(
714725
)
715726
return async_redis.BlockingConnectionPool.from_url(**pool_kwargs)
716727

717-
# Wrap GCP / Azure AD auth in a CredentialProvider so pool-managed
718-
# connections re-fetch tokens via the SDK's internal cache + silent refresh
719-
# rather than reusing a single token captured at pool creation.
720-
redis_connect_func: Final = redis_kwargs.pop("redis_connect_func", None)
721-
if redis_connect_func and hasattr(redis_connect_func, "_azure_credential"):
722-
redis_kwargs["credential_provider"] = AzureADCredentialProvider(
723-
redis_connect_func._azure_credential,
724-
username=os.environ.get("REDIS_USERNAME") or None,
725-
)
726-
elif redis_connect_func and hasattr(redis_connect_func, "_gcp_service_account"):
727-
redis_kwargs["credential_provider"] = GCPIAMCredentialProvider(redis_connect_func._gcp_service_account)
728-
729728
if redis_kwargs.pop("ssl", None):
730729
redis_kwargs["connection_class"] = async_redis.SSLConnection
731730
return async_redis.BlockingConnectionPool(timeout=REDIS_CONNECTION_POOL_TIMEOUT, **redis_kwargs)

tests/test_litellm/test_redis.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from types import SimpleNamespace
23
from unittest.mock import MagicMock, patch
34

45
import pytest
@@ -14,6 +15,7 @@
1415
)
1516
from litellm.constants import REDIS_CLUSTER_HEALTH_CHECK_INTERVAL
1617
from litellm._redis_credential_provider import (
18+
AzureADCredentialProvider,
1719
GCPIAMCredentialProvider,
1820
_token_cache,
1921
)
@@ -910,3 +912,157 @@ def test_url_allowlist_always_carries_socket_timeouts():
910912
allowed = _get_redis_url_kwargs()
911913
assert "socket_timeout" in allowed
912914
assert "socket_connect_timeout" in allowed
915+
916+
917+
AZURE_AD_CONNECT_FUNC = {"_azure_credential": object()}
918+
GCP_IAM_CONNECT_FUNC = {"_gcp_service_account": "projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com"}
919+
920+
921+
@pytest.mark.parametrize(
922+
"markers, provider_cls",
923+
[
924+
(AZURE_AD_CONNECT_FUNC, AzureADCredentialProvider),
925+
(GCP_IAM_CONNECT_FUNC, GCPIAMCredentialProvider),
926+
],
927+
ids=["azure_ad", "gcp_iam"],
928+
)
929+
def test_async_url_client_authenticates_through_credential_provider(markers, provider_cls):
930+
"""A REDIS_URL config with Azure AD or GCP IAM must still reach the server with a credential.
931+
932+
The url branch forwards redis_connect_func straight to the async connection, which runs
933+
its AUTH exchange with the blocking client API and dies, so the branch has to hand the
934+
connection a CredentialProvider instead.
935+
"""
936+
redis_kwargs = {
937+
"url": "rediss://redis-host:6380",
938+
"redis_connect_func": SimpleNamespace(**markers),
939+
}
940+
941+
with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs):
942+
client = get_redis_async_client()
943+
944+
connection_kwargs = client.connection_pool.connection_kwargs
945+
assert isinstance(connection_kwargs.get("credential_provider"), provider_cls)
946+
assert "redis_connect_func" not in connection_kwargs
947+
948+
949+
@pytest.mark.parametrize(
950+
"markers, provider_cls",
951+
[
952+
(AZURE_AD_CONNECT_FUNC, AzureADCredentialProvider),
953+
(GCP_IAM_CONNECT_FUNC, GCPIAMCredentialProvider),
954+
],
955+
ids=["azure_ad", "gcp_iam"],
956+
)
957+
def test_async_url_connection_pool_authenticates_through_credential_provider(markers, provider_cls):
958+
"""Same for the pool-based path: every connection the pool hands out needs the provider."""
959+
redis_kwargs = {
960+
"url": "rediss://redis-host:6380",
961+
"redis_connect_func": SimpleNamespace(**markers),
962+
}
963+
964+
with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs):
965+
pool = get_redis_connection_pool()
966+
967+
assert isinstance(pool.connection_kwargs.get("credential_provider"), provider_cls)
968+
assert "redis_connect_func" not in pool.connection_kwargs
969+
970+
971+
def test_async_url_client_drops_username_alongside_credential_provider():
972+
"""redis-py refuses a connection given both a username and a credential_provider, and
973+
AzureADCredentialProvider already carries REDIS_USERNAME, so the username must be dropped.
974+
"""
975+
redis_kwargs = {
976+
"url": "rediss://redis-host:6380",
977+
"username": "redis-user",
978+
"redis_connect_func": SimpleNamespace(**AZURE_AD_CONNECT_FUNC),
979+
}
980+
981+
with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs):
982+
client = get_redis_async_client()
983+
984+
pool = client.connection_pool
985+
assert "username" not in pool.connection_kwargs
986+
pool.connection_class(**pool.connection_kwargs)
987+
988+
989+
@pytest.mark.parametrize("build_pool", [False, True], ids=["client", "pool"])
990+
def test_async_url_keeps_a_coroutine_connect_func(build_pool):
991+
"""redis-py awaits a coroutine redis_connect_func on an async connection, so one we cannot
992+
turn into a credential provider has to be left where it is rather than dropped.
993+
"""
994+
995+
async def connect(connection):
996+
return None
997+
998+
redis_kwargs = {
999+
"url": "rediss://redis-host:6380",
1000+
"redis_connect_func": connect,
1001+
}
1002+
1003+
with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs):
1004+
pool = get_redis_connection_pool() if build_pool else get_redis_async_client().connection_pool
1005+
1006+
assert pool.connection_kwargs["redis_connect_func"] is connect
1007+
assert "credential_provider" not in pool.connection_kwargs
1008+
1009+
1010+
def test_async_cluster_drops_a_connect_func_it_cannot_pass_on():
1011+
"""redis-py's async RedisCluster has no redis_connect_func parameter, so a connect func that
1012+
is not translated into a credential provider has to be dropped rather than forwarded.
1013+
"""
1014+
1015+
async def connect(connection):
1016+
return None
1017+
1018+
redis_kwargs = {
1019+
"startup_nodes": [{"host": "cluster-node", "port": 6379}],
1020+
"redis_connect_func": connect,
1021+
}
1022+
1023+
with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs):
1024+
client = get_redis_async_client()
1025+
1026+
assert isinstance(client, async_redis.RedisCluster)
1027+
1028+
1029+
@pytest.mark.parametrize(
1030+
"markers, provider_cls",
1031+
[
1032+
(AZURE_AD_CONNECT_FUNC, AzureADCredentialProvider),
1033+
(GCP_IAM_CONNECT_FUNC, GCPIAMCredentialProvider),
1034+
],
1035+
ids=["azure_ad", "gcp_iam"],
1036+
)
1037+
@pytest.mark.parametrize(
1038+
"sentinel_password",
1039+
[None, "sentinel-secret"],
1040+
ids=["unauthenticated_monitors", "password_protected_monitors"],
1041+
)
1042+
def test_async_sentinel_keeps_the_credential_provider_off_the_monitors(markers, provider_cls, sentinel_password):
1043+
"""The Sentinel monitors are separate servers with their own password, so the data node's token
1044+
never belongs on them: redis-py refuses it next to a Sentinel password, and sends it to an
1045+
unauthenticated monitor as an AUTH the monitor rejects.
1046+
"""
1047+
redis_kwargs = {
1048+
"sentinel_nodes": [("sentinel-1", 26379)],
1049+
"sentinel_password": sentinel_password,
1050+
"service_name": "mymaster",
1051+
"redis_connect_func": SimpleNamespace(**markers),
1052+
}
1053+
1054+
with patch("litellm._redis.async_redis.Sentinel") as mock_sentinel_cls:
1055+
with patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs):
1056+
get_redis_async_client()
1057+
1058+
sentinel_kwargs = mock_sentinel_cls.call_args[1]["sentinel_kwargs"]
1059+
assert sentinel_kwargs["password"] == sentinel_password
1060+
assert "credential_provider" not in sentinel_kwargs
1061+
1062+
monitor_connection = async_redis.Connection(host="sentinel-1", port=26379, **sentinel_kwargs)
1063+
assert monitor_connection.credential_provider is None
1064+
assert bool(monitor_connection.username or monitor_connection.password) is bool(sentinel_password)
1065+
1066+
master_kwargs = mock_sentinel_cls.return_value.master_for.call_args[1]
1067+
assert isinstance(master_kwargs["credential_provider"], provider_cls)
1068+
assert "password" not in master_kwargs

0 commit comments

Comments
 (0)