Skip to content

Commit 37b4cfd

Browse files
authored
Python: Add Azure Managed Redis Support with Credential Provider (#2887)
* azure redis support * small fixes * azure managed redis sample * fixes
1 parent ff9343d commit 37b4cfd

File tree

5 files changed

+298
-17
lines changed

5 files changed

+298
-17
lines changed

python/packages/redis/agent_framework_redis/_chat_message_store.py

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import redis.asyncio as redis
1010
from agent_framework import ChatMessage
1111
from agent_framework._serialization import SerializationMixin
12+
from redis.credentials import CredentialProvider
1213

1314

1415
class RedisStoreState(SerializationMixin):
@@ -55,6 +56,11 @@ class RedisChatMessageStore:
5556
def __init__(
5657
self,
5758
redis_url: str | None = None,
59+
credential_provider: CredentialProvider | None = None,
60+
host: str | None = None,
61+
port: int = 6380,
62+
ssl: bool = True,
63+
username: str | None = None,
5864
thread_id: str | None = None,
5965
key_prefix: str = "chat_messages",
6066
max_messages: int | None = None,
@@ -63,12 +69,19 @@ def __init__(
6369
"""Initialize the Redis chat message store.
6470
6571
Creates a Redis-backed chat message store for a specific conversation thread.
66-
The store will automatically create a Redis connection and manage message
67-
persistence using Redis List operations.
72+
Supports both traditional URL-based authentication and Azure Managed Redis
73+
with credential provider.
6874
6975
Args:
7076
redis_url: Redis connection URL (e.g., "redis://localhost:6379").
71-
Required for establishing Redis connection.
77+
Used for traditional authentication. Mutually exclusive with credential_provider.
78+
credential_provider: Redis credential provider (redis.credentials.CredentialProvider) for
79+
Azure AD authentication. Requires host parameter. Mutually exclusive with redis_url.
80+
host: Redis host name (e.g., "myredis.redis.cache.windows.net").
81+
Required when using credential_provider.
82+
port: Redis port number. Defaults to 6380 (Azure Redis SSL port).
83+
ssl: Enable SSL/TLS connection. Defaults to True.
84+
username: Redis username. Defaults to None.
7285
thread_id: Unique identifier for this conversation thread.
7386
If not provided, a UUID will be auto-generated.
7487
This becomes part of the Redis key: {key_prefix}:{thread_id}
@@ -82,23 +95,58 @@ def __init__(
8295
Useful for resuming conversations or seeding with context.
8396
8497
Raises:
85-
ValueError: If redis_url is None (Redis connection is required).
86-
redis.ConnectionError: If unable to connect to Redis server.
98+
ValueError: If neither redis_url nor credential_provider is provided.
99+
ValueError: If both redis_url and credential_provider are provided.
100+
ValueError: If credential_provider is used without host parameter.
101+
102+
Examples:
103+
Traditional connection:
104+
store = RedisChatMessageStore(
105+
redis_url="redis://localhost:6379",
106+
thread_id="conversation_123"
107+
)
108+
109+
Azure Managed Redis with credential provider:
110+
from redis.credentials import CredentialProvider
111+
from azure.identity.aio import DefaultAzureCredential
112+
113+
store = RedisChatMessageStore(
114+
credential_provider=CredentialProvider(DefaultAzureCredential()),
115+
host="myredis.redis.cache.windows.net",
116+
thread_id="conversation_123"
117+
)
118+
"""
119+
# Validate connection parameters
120+
if redis_url is None and credential_provider is None:
121+
raise ValueError("Either redis_url or credential_provider must be provided")
87122

123+
if redis_url is not None and credential_provider is not None:
124+
raise ValueError("redis_url and credential_provider are mutually exclusive")
88125

89-
"""
90-
# Validate required parameters
91-
if redis_url is None:
92-
raise ValueError("redis_url is required for Redis connection")
126+
if credential_provider is not None and host is None:
127+
raise ValueError("host is required when using credential_provider")
93128

94129
# Store configuration
95-
self.redis_url = redis_url
96130
self.thread_id = thread_id or f"thread_{uuid4()}"
97131
self.key_prefix = key_prefix
98132
self.max_messages = max_messages
99133

100-
# Initialize Redis client with connection pooling and async support
101-
self._redis_client = redis.from_url(redis_url, decode_responses=True) # type: ignore[no-untyped-call]
134+
# Initialize Redis client based on authentication method
135+
if credential_provider is not None and host is not None:
136+
# Azure AD authentication with credential provider
137+
self.redis_url = None # Not using URL-based auth
138+
self._redis_client = redis.Redis(
139+
host=host,
140+
port=port,
141+
ssl=ssl,
142+
username=username,
143+
credential_provider=credential_provider,
144+
decode_responses=True,
145+
)
146+
else:
147+
# Traditional URL-based authentication
148+
self.redis_url = redis_url
149+
self._redis_client = redis.from_url(redis_url, decode_responses=True) # type: ignore[no-untyped-call]
102150

103151
# Handle initial messages (will be moved to Redis on first access)
104152
self._initial_messages = list(messages) if messages else []

python/packages/redis/tests/test_redis_chat_message_store.py

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,118 @@ def test_init_with_max_messages(self):
9393
assert store.max_messages == 100
9494

9595
def test_init_with_redis_url_required(self):
96-
"""Test that redis_url is required for initialization."""
97-
with pytest.raises(ValueError, match="redis_url is required for Redis connection"):
98-
# Should raise an exception since redis_url is required
96+
"""Test that either redis_url or credential_provider is required."""
97+
with pytest.raises(ValueError, match="Either redis_url or credential_provider must be provided"):
9998
RedisChatMessageStore(thread_id="test123")
10099

100+
def test_init_with_credential_provider(self):
101+
"""Test initialization with credential_provider."""
102+
mock_credential_provider = MagicMock()
103+
104+
with patch("agent_framework_redis._chat_message_store.redis.Redis") as mock_redis_class:
105+
mock_redis_instance = MagicMock()
106+
mock_redis_class.return_value = mock_redis_instance
107+
108+
store = RedisChatMessageStore(
109+
credential_provider=mock_credential_provider,
110+
host="myredis.redis.cache.windows.net",
111+
thread_id="test123",
112+
)
113+
114+
# Verify Redis.Redis was called with correct parameters
115+
mock_redis_class.assert_called_once_with(
116+
host="myredis.redis.cache.windows.net",
117+
port=6380,
118+
ssl=True,
119+
username=None,
120+
credential_provider=mock_credential_provider,
121+
decode_responses=True,
122+
)
123+
# Verify store instance is properly initialized
124+
assert store.thread_id == "test123"
125+
assert store.redis_url is None # Should be None for credential provider auth
126+
assert store.key_prefix == "chat_messages"
127+
assert store.max_messages is None
128+
129+
def test_init_with_credential_provider_custom_port(self):
130+
"""Test initialization with credential_provider and custom port."""
131+
mock_credential_provider = MagicMock()
132+
133+
with patch("agent_framework_redis._chat_message_store.redis.Redis") as mock_redis_class:
134+
mock_redis_instance = MagicMock()
135+
mock_redis_class.return_value = mock_redis_instance
136+
137+
store = RedisChatMessageStore(
138+
credential_provider=mock_credential_provider,
139+
host="myredis.redis.cache.windows.net",
140+
port=6379,
141+
ssl=False,
142+
username="admin",
143+
thread_id="test123",
144+
)
145+
146+
# Verify custom parameters were passed
147+
mock_redis_class.assert_called_once_with(
148+
host="myredis.redis.cache.windows.net",
149+
port=6379,
150+
ssl=False,
151+
username="admin",
152+
credential_provider=mock_credential_provider,
153+
decode_responses=True,
154+
)
155+
# Verify store instance is properly initialized
156+
assert store.thread_id == "test123"
157+
assert store.redis_url is None # Should be None for credential provider auth
158+
assert store.key_prefix == "chat_messages"
159+
160+
def test_init_credential_provider_requires_host(self):
161+
"""Test that credential_provider requires host parameter."""
162+
mock_credential_provider = MagicMock()
163+
164+
with pytest.raises(ValueError, match="host is required when using credential_provider"):
165+
RedisChatMessageStore(
166+
credential_provider=mock_credential_provider,
167+
thread_id="test123",
168+
)
169+
170+
def test_init_mutually_exclusive_params(self):
171+
"""Test that redis_url and credential_provider are mutually exclusive."""
172+
mock_credential_provider = MagicMock()
173+
174+
with pytest.raises(ValueError, match="redis_url and credential_provider are mutually exclusive"):
175+
RedisChatMessageStore(
176+
redis_url="redis://localhost:6379",
177+
credential_provider=mock_credential_provider,
178+
host="myredis.redis.cache.windows.net",
179+
thread_id="test123",
180+
)
181+
182+
async def test_serialize_with_credential_provider(self):
183+
"""Test that serialization works correctly with credential provider authentication."""
184+
mock_credential_provider = MagicMock()
185+
186+
with patch("agent_framework_redis._chat_message_store.redis.Redis") as mock_redis_class:
187+
mock_redis_instance = MagicMock()
188+
mock_redis_class.return_value = mock_redis_instance
189+
190+
store = RedisChatMessageStore(
191+
credential_provider=mock_credential_provider,
192+
host="myredis.redis.cache.windows.net",
193+
thread_id="test123",
194+
key_prefix="custom_prefix",
195+
max_messages=100,
196+
)
197+
198+
# Serialize the store state
199+
state = await store.serialize()
200+
201+
# Verify serialization includes correct values
202+
assert state["thread_id"] == "test123"
203+
assert state["redis_url"] is None # Should be None for credential provider auth
204+
assert state["key_prefix"] == "custom_prefix"
205+
assert state["max_messages"] == 100
206+
assert state["type"] == "redis_store_state"
207+
101208
def test_init_with_initial_messages(self, sample_messages):
102209
"""Test initialization with initial messages."""
103210
with patch("agent_framework_redis._chat_message_store.redis.from_url"):

python/samples/getting_started/context_providers/redis/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ This folder contains an example demonstrating how to use the Redis context provi
88

99
| File | Description |
1010
|------|-------------|
11+
| [`azure_redis_conversation.py`](azure_redis_conversation.py) | Demonstrates conversation persistence with RedisChatMessageStore and Azure Redis with Azure AD (Entra ID) authentication using credential provider. |
1112
| [`redis_basics.py`](redis_basics.py) | Shows standalone provider usage and agent integration. Demonstrates writing messages to Redis, retrieving context via full‑text or hybrid vector search, and persisting preferences across threads. Also includes a simple tool example whose outputs are remembered. |
12-
| [`redis_threads.py`](redis_threads.py) | Demonstrates thread scoping. Includes: (1) global thread scope with a fixed `thread_id` shared across operations; (2) per‑operation thread scope where `scope_to_per_operation_thread_id=True` binds memory to a single thread for the provider’s lifetime; and (3) multiple agents with isolated memory via different `agent_id` values. |
13+
| [`redis_conversation.py`](redis_conversation.py) | Simple example showing conversation persistence with RedisChatMessageStore using traditional connection string authentication. |
14+
| [`redis_threads.py`](redis_threads.py) | Demonstrates thread scoping. Includes: (1) global thread scope with a fixed `thread_id` shared across operations; (2) per‑operation thread scope where `scope_to_per_operation_thread_id=True` binds memory to a single thread for the provider's lifetime; and (3) multiple agents with isolated memory via different `agent_id` values. |
15+
1316

1417
## Prerequisites
1518

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
"""Azure Managed Redis Chat Message Store with Azure AD Authentication
4+
5+
This example demonstrates how to use Azure Managed Redis with Azure AD authentication
6+
to persist conversational details using RedisChatMessageStore.
7+
8+
Requirements:
9+
- Azure Managed Redis instance with Azure AD authentication enabled
10+
- Azure credentials configured (az login or managed identity)
11+
- agent-framework-redis: pip install agent-framework-redis
12+
- azure-identity: pip install azure-identity
13+
14+
Environment Variables:
15+
- AZURE_REDIS_HOST: Your Azure Managed Redis host (e.g., myredis.redis.cache.windows.net)
16+
- OPENAI_API_KEY: Your OpenAI API key
17+
- OPENAI_CHAT_MODEL_ID: OpenAI model (e.g., gpt-4o-mini)
18+
- AZURE_USER_OBJECT_ID: Your Azure AD User Object ID for authentication
19+
"""
20+
21+
import asyncio
22+
import os
23+
24+
from agent_framework.openai import OpenAIChatClient
25+
from agent_framework.redis import RedisChatMessageStore
26+
from azure.identity.aio import AzureCliCredential
27+
from redis.credentials import CredentialProvider
28+
29+
30+
class AzureCredentialProvider(CredentialProvider):
31+
"""Credential provider for Azure AD authentication with Redis Enterprise."""
32+
33+
def __init__(self, azure_credential: AzureCliCredential, user_object_id: str):
34+
self.azure_credential = azure_credential
35+
self.user_object_id = user_object_id
36+
37+
async def get_credentials_async(self) -> tuple[str] | tuple[str, str]:
38+
"""Get Azure AD token for Redis authentication.
39+
40+
Returns (username, token) where username is the Azure user's Object ID.
41+
"""
42+
token = await self.azure_credential.get_token("https://redis.azure.com/.default")
43+
return (self.user_object_id, token.token)
44+
45+
46+
async def main() -> None:
47+
redis_host = os.environ.get("AZURE_REDIS_HOST")
48+
if not redis_host:
49+
print("ERROR: Set AZURE_REDIS_HOST environment variable")
50+
return
51+
52+
# For Azure Redis with Entra ID, username must be your Object ID
53+
user_object_id = os.environ.get("AZURE_USER_OBJECT_ID")
54+
if not user_object_id:
55+
print("ERROR: Set AZURE_USER_OBJECT_ID environment variable")
56+
print("Get your Object ID from the Azure Portal")
57+
return
58+
59+
# Create Azure CLI credential provider (uses 'az login' credentials)
60+
azure_credential = AzureCliCredential()
61+
credential_provider = AzureCredentialProvider(azure_credential, user_object_id)
62+
63+
thread_id = "azure_test_thread"
64+
65+
# Factory for creating Azure Redis chat message store
66+
chat_message_store_factory = lambda: RedisChatMessageStore(
67+
credential_provider=credential_provider,
68+
host=redis_host,
69+
port=10000,
70+
ssl=True,
71+
thread_id=thread_id,
72+
key_prefix="chat_messages",
73+
max_messages=100,
74+
)
75+
76+
# Create chat client
77+
client = OpenAIChatClient()
78+
79+
# Create agent with Azure Redis store
80+
agent = client.create_agent(
81+
name="AzureRedisAssistant",
82+
instructions="You are a helpful assistant.",
83+
chat_message_store_factory=chat_message_store_factory,
84+
)
85+
86+
# Conversation
87+
query = "Remember that I enjoy gumbo"
88+
result = await agent.run(query)
89+
print("User: ", query)
90+
print("Agent: ", result)
91+
92+
# Ask the agent to recall the stored preference; it should retrieve from memory
93+
query = "What do I enjoy?"
94+
result = await agent.run(query)
95+
print("User: ", query)
96+
print("Agent: ", result)
97+
98+
query = "What did I say to you just now?"
99+
result = await agent.run(query)
100+
print("User: ", query)
101+
print("Agent: ", result)
102+
103+
query = "Remember that I have a meeting at 3pm tomorrow"
104+
result = await agent.run(query)
105+
print("User: ", query)
106+
print("Agent: ", result)
107+
108+
query = "Tulips are red"
109+
result = await agent.run(query)
110+
print("User: ", query)
111+
print("Agent: ", result)
112+
113+
query = "What was the first thing I said to you this conversation?"
114+
result = await agent.run(query)
115+
print("User: ", query)
116+
print("Agent: ", result)
117+
118+
# Cleanup
119+
await azure_credential.close()
120+
121+
122+
if __name__ == "__main__":
123+
asyncio.run(main())

python/samples/getting_started/context_providers/redis/redis_conversation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ async def main() -> None:
9191
print("User: ", query)
9292
print("Agent: ", result)
9393

94-
query = "Remember that anyone who does not clean shrimp will be eaten by a shark"
94+
query = "Remember that I have a meeting at 3pm tomorro"
9595
result = await agent.run(query)
9696
print("User: ", query)
9797
print("Agent: ", result)

0 commit comments

Comments
 (0)