Skip to content

fix(redis): apply Azure AD and GCP IAM auth to every async client path - #37740

Merged
mateo-berri merged 8 commits into
litellm_internal_stagingfrom
litellm_redis_url_pool_credential_provider
Aug 21, 2026
Merged

fix(redis): apply Azure AD and GCP IAM auth to every async client path#37740
mateo-berri merged 8 commits into
litellm_internal_stagingfrom
litellm_redis_url_pool_credential_provider

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Async Redis clients from REDIS_URL never authenticated at all
  • Their connection pools failed the exact same way
  • So did async Sentinel clients
  • Azure AD and GCP IAM Redis caching was silently dead

How it solves it:

  • Build the credential provider once, before any branch
  • URL, pool, sentinel, and cluster paths all inherit it
  • Let credential_provider through the cluster kwargs filter
  • Leave a coroutine connect func for redis-py to await
  • Drop it where the async cluster client cannot accept it
  • Keep the data node's token off the Sentinel monitors
  • Regression tests cover every one of those paths

User Flow

Before: an admin running two proxy replicas that cache into a managed Redis with Microsoft Entra ID gets no shared cache at all, so every replica pays the provider separately

  1. They set REDIS_URL=rediss://<their-cache>.<region>.redis.azure.net:10000, REDIS_USERNAME=<the identity's object id>, and REDIS_AZURE_AD_TOKEN=true, turn on cache: true with cache_params.type: redis, and start two replicas
  2. They call GET /cache/ping on replica A and get 503 Service Unavailable with {"message": "Service Unhealthy"}
  3. They send POST /v1/chat/completions to replica A and are billed for it, x-litellm-response-cost: 0.000645
  4. They send the identical POST /v1/chat/completions to replica B and are billed a second time, x-litellm-response-cost: 0.000675, coming back with a different response id and no x-litellm-cache-key header
  5. Their proxy logs fill with AuthenticationError: Azure AD authentication failed for Redis, followed by Redis circuit breaker is open
  6. They connect to the cache themselves and find it empty, so nothing either replica did ever reached it

After: the same setup authenticates, so the second replica serves the answer out of Redis instead of buying it again

  1. They set the same three environment variables, turn on the same cache config, and start two replicas
  2. They call GET /cache/ping on replica A and get 200 OK with {"status":"healthy","cache_type":"redis","ping_response":true,"set_cache_response":"success"}
  3. They send POST /v1/chat/completions to replica A and are billed for it, x-litellm-response-cost: 0.00035
  4. They send the identical POST /v1/chat/completions to replica B and get back the same response id as replica A carrying x-litellm-cache-key: 6c2debe1..., so no second call to the provider happens
  5. Their proxy logs carry no Redis authentication errors at all
  6. They connect to the cache themselves and find the cache key sitting there next to the proxy's own config keys

Relevant issues

Linear ticket

Related to LIT-5889

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Everything below ran against a real Azure Managed Redis (Balanced_B0, TLS, access keys Disabled, so Microsoft Entra ID is the only way in) and the real OpenAI API. No mocks, real spend. Before is the merge base cb4eb82249 on ports 47311 and 47313, After is the PR tip c09643ac4c on ports 56981 and 56249

Shared setup, identical on both sides. Two proxy replicas point at that one cache, which is what an HA deployment looks like and what makes a shared cache observable at all: a single replica hides the whole bug behind its own in-memory layer, still answering "Cache Hit!" while Redis is unreachable

export REDIS_URL='rediss://litellm-lit5889-redis.eastus2.redis.azure.net:10000'
export REDIS_USERNAME='<object id of the Entra identity>'
export REDIS_AZURE_AD_TOKEN='true'

$ cat config.yaml
model_list:
  - model_name: gpt-5.6
    litellm_params:
      model: openai/gpt-5.6
      api_key: os.environ/OPENAI_API_KEY
litellm_settings:
  cache: true
  cache_params:
    type: redis

$ python litellm/proxy/proxy_cli.py --config config.yaml --port <replica A port> --detailed_debug
$ python litellm/proxy/proxy_cli.py --config config.yaml --port <replica B port> --detailed_debug

Shared background, and the reason the fix takes the shape it does. Three redis-py legs against that same cache, driven by LiteLLM's own Azure AD helpers, showing that an async connection cannot authenticate through a redis_connect_func no matter which code path hands it one

redis-py 5.3.1, server rediss://litellm-lit5889-redis.eastus2.redis.azure.net:10000
1. sync client + redis_connect_func      : PING -> True
2. async client + redis_connect_func     : AuthenticationError: Azure AD authentication failed for Redis
3. async client + credential_provider    : PING -> True

Leg 2 fails because that connect function runs the AUTH exchange with the blocking client API, so on an async connection send_command and read_response hand back coroutines nobody awaits. Python says so itself in the same run

litellm/_redis.py:314: RuntimeWarning: coroutine 'AbstractConnection.send_command' was never awaited
  self.send_command("AUTH", *auth_args, check_health=False)

Before (cb4eb82)

Cache health on replica A

  1. curl -s -i http://127.0.0.1:47311/cache/ping -H "Authorization: Bearer sk-1234"
HTTP/1.1 503 Service Unavailable
{"error":{"message":"{\"message\": \"Service Unhealthy\", ...

The same completion on two replicas sharing one cache

  1. curl -s -i http://127.0.0.1:47311/v1/chat/completions -H "Authorization: Bearer sk-1234" -H 'Content-Type: application/json' -d '{"model":"gpt-5.6","messages":[{"role":"user","content":"Reply with exactly: lit5889 before probe"}]}'
HTTP/1.1 200 OK
x-litellm-response-cost: 0.000645
x-litellm-cache-key: <absent>
body id: chatcmpl-EF5z2Zc8Riy9I3MYoCYnVwce8q8Bp
body content: lit5889 before probe
  1. The identical request against replica B on port 47313, billed all over again and answering with a different id
HTTP/1.1 200 OK
x-litellm-response-cost: 0.000675
x-litellm-cache-key: <absent>
body id: chatcmpl-EF5z4zm1mztUElYsqVpNgiz2Gi7JD
body content: lit5889 before probe

What actually reached the Redis server

  1. Connecting to the cache directly and listing its keys
keys matching '*': 0
  1. Replica A's log over that run
  14 Azure AD authentication failed for Redis
   4 circuit breaker is open — skipping async_get_cache
  17 circuit breaker is open — skipping async_set_cache

After (c09643a)

Cache health on replica A

  1. curl -s -i http://127.0.0.1:56981/cache/ping -H "Authorization: Bearer sk-1234"
HTTP/1.1 200 OK
{"status":"healthy","cache_type":"redis","ping_response":true,"set_cache_response":"success", ...

The same completion on two replicas sharing one cache

  1. curl -s -i http://127.0.0.1:56981/v1/chat/completions -H "Authorization: Bearer sk-1234" -H 'Content-Type: application/json' -d '{"model":"gpt-5.6","messages":[{"role":"user","content":"Reply with exactly: lit5889 after6 probe"}]}'
HTTP/1.1 200 OK
x-litellm-response-cost: 0.00035
x-litellm-cache-key: <absent>
body id: chatcmpl-EF6tzdlwAN7DkG6m0QfIAFyBSBL7L
body content: lit5889 after6 probe
  1. The identical request against replica B on port 56249, which now serves replica A's own answer with a cache key instead of calling the provider a second time
HTTP/1.1 200 OK
x-litellm-cache-key: bf79f7bcbbf817e07c2fceace95d8060e92b97c746bbc90eb234ab50fe3d31b9
body id: chatcmpl-EF6tzdlwAN7DkG6m0QfIAFyBSBL7L
body content: lit5889 after6 probe

What actually reached the Redis server

  1. Connecting to the cache directly and listing its keys, which is where replica A's write landed and where replica B's read found it
keys matching '*': 14
   86d4ac816220d30f86882fe349625d1b1a9a2b7b4821e2ae1c9e5ee050c5896a
   bf79f7bcbbf817e07c2fceace95d8060e92b97c746bbc90eb234ab50fe3d31b9
   litellm_config:param:anthropic_beta_headers_reload_config
   litellm_config:param:environment_variables
   litellm_config:param:general_settings
  1. Replica A's log over that run, with no Redis authentication errors and no circuit breaker trips at all
(no matches)

Type

🐛 Bug Fix

Caveats (if any)

  • Managed Redis wants REDIS_USERNAME set to the identity's object id
  • A blocking custom redis_connect_func still cannot run on async
  • The cluster and Sentinel paths are proven by construction, not live

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

  • c09643a passes /live-pr-risk

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.
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.
A caller-supplied redis_connect_func has no way to run on an async
connection, so log it instead of dropping it in silence.
@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR centralizes async Redis credential-provider translation so Azure AD and GCP IAM authentication is consistently applied across URL, pool, Sentinel, and cluster client paths.

  • Converts recognized async authentication hooks into renewable credential providers before selecting a client path.
  • Keeps data-node credentials away from Sentinel monitor connections.
  • Adds regression coverage for URL clients, connection pools, clusters, and Sentinel configurations.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported duplicated kwargs mutation has been replaced by centralized authentication translation that returns a transformed dictionary.

Important Files Changed

Filename Overview
litellm/_redis.py Centralizes async authentication translation in a copy-producing helper and applies it across all async Redis construction paths; the previously reported duplicated mutation is resolved.
tests/test_litellm/test_redis.py Adds focused mocked regression tests covering Azure AD and GCP IAM authentication behavior for async URL, pool, cluster, and Sentinel clients.

Reviews (4): Last reviewed commit: "fix(redis): never hand a data-node crede..." | Re-trigger Greptile

Comment thread litellm/_redis.py Outdated
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.
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

… twice

Both async entrypoints edited the kwargs dict in place with the same five
lines. One shared transform returns the swapped copy instead.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/_redis.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Sentinel monitors keep data credentials
    • Removed the sentinel_password gate so _sentinel_auth_kwargs always strips credential_provider from monitor kwargs, and added a no-password regression test for the Azure AD and GCP IAM cases.

Create PR

Or push these changes by commenting:

@cursor push 714e013386
Preview (714e013386)
diff --git a/litellm/_redis.py b/litellm/_redis.py
--- a/litellm/_redis.py
+++ b/litellm/_redis.py
@@ -552,10 +552,10 @@
 
 
 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()
+    """The Sentinel monitors are separate servers with their own password, so the data node's
+    credential provider never belongs on them, and redis-py additionally refuses a password
+    passed alongside a credential provider."""
+    superseded: Final = frozenset({"credential_provider"})
     kept: Final = ((k, v) for k, v in connection_kwargs.items() if k not in superseded)
     return dict(kept, password=sentinel_password)
 

diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py
--- a/tests/test_litellm/test_redis.py
+++ b/tests/test_litellm/test_redis.py
@@ -1057,3 +1057,34 @@
     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
+
+
+@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_drops_data_node_provider_when_monitors_have_no_password(markers, provider_cls):
+    """Without a sentinel password the monitors are unauthenticated, so the data node's
+    Entra/IAM credential provider still must not follow onto them or Sentinel discovery
+    will attempt to authenticate the monitors with a data-node token."""
+    redis_kwargs = {
+        "sentinel_nodes": [("sentinel-1", 26379)],
+        "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 "credential_provider" not in sentinel_kwargs
+    assert sentinel_kwargs.get("password") is None
+
+    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

You can send follow-ups to the cloud agent here.

Comment thread litellm/_redis.py
…l 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.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c09643a. Configure here.

@mateo-berri
mateo-berri merged commit 987478a into litellm_internal_staging Aug 21, 2026
72 of 73 checks passed
@mateo-berri
mateo-berri deleted the litellm_redis_url_pool_credential_provider branch August 21, 2026 00:24
@codspeed-hq

codspeed-hq Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_redis_url_pool_credential_provider (c09643a) with litellm_internal_staging (e07a712)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (2a863f8) during the generation of this report, so e07a712 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants