add openai middleware functionality for python sdk (#546)

add openai middleware functionality

fix critical type errors and linting issues

update readme with middleware documentation
This commit is contained in:
nexxeln 2025-11-11 02:16:34 +00:00
parent b257524024
commit ea9bf13d31
10 changed files with 3705 additions and 22 deletions

View file

@ -1,8 +1,8 @@
# Supermemory OpenAI Python SDK
Memory tools for OpenAI function calling with Supermemory integration.
Memory tools and middleware for OpenAI with Supermemory integration.
This package provides memory management tools for the official [OpenAI Python SDK](https://github.com/openai/openai-python) using [Supermemory](https://supermemory.ai) capabilities.
This package provides both **automatic memory injection middleware** and **manual memory tools** for the official [OpenAI Python SDK](https://github.com/openai/openai-python) using [Supermemory](https://supermemory.ai) capabilities.
## Installation
@ -18,8 +18,53 @@ Or with pip:
pip install supermemory-openai-sdk
```
For async HTTP support (recommended):
```bash
uv add supermemory-openai-sdk[async]
# or
pip install supermemory-openai-sdk[async]
```
## Quick Start
### Automatic Memory Injection (Recommended)
The easiest way to add memory capabilities to your OpenAI client is using the `with_supermemory()` wrapper:
```python
import asyncio
from openai import AsyncOpenAI
from supermemory_openai import with_supermemory, OpenAIMiddlewareOptions
async def main():
# Create OpenAI client
openai = AsyncOpenAI(api_key="your-openai-api-key")
# Wrap with Supermemory middleware
openai_with_memory = with_supermemory(
openai,
container_tag="user-123", # Unique identifier for user's memories
options=OpenAIMiddlewareOptions(
mode="full", # "profile", "query", or "full"
verbose=True, # Enable logging
add_memory="always" # Automatically save conversations
)
)
# Use normally - memories are automatically injected!
response = await openai_with_memory.chat.completions.create(
model="gpt-4",
messages=[
{"role": "user", "content": "What's my favorite programming language?"}
]
)
print(response.choices[0].message.content)
asyncio.run(main())
```
### Using Memory Tools with OpenAI
```python
@ -67,9 +112,110 @@ async def main():
asyncio.run(main())
```
## Configuration
### Sync Client Support
## Memory Tools
The middleware also works with synchronous OpenAI clients:
```python
from openai import OpenAI
from supermemory_openai import with_supermemory
# Sync client
openai = OpenAI(api_key="your-openai-api-key")
openai_with_memory = with_supermemory(openai, "user-123")
# Works the same way
response = openai_with_memory.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello!"}]
)
```
**Event Loop Management**: The middleware properly handles event loops using `asyncio.run()` for sync clients. If called from within an existing async context, it automatically runs in a separate thread to avoid conflicts.
**Background Task Management**: When `add_memory="always"`, memory storage happens in background tasks. Use context managers or manual cleanup to ensure tasks complete:
```python
# Async context manager (recommended)
async with with_supermemory(openai, "user-123") as client:
response = await client.chat.completions.create(...)
# Background tasks automatically waited for on exit
# Manual cleanup
client = with_supermemory(openai, "user-123")
response = await client.chat.completions.create(...)
await client.wait_for_background_tasks() # Ensure memory is saved
```
## Middleware Configuration
### Memory Modes
The middleware supports three different modes for memory injection:
#### `"profile"` mode (default)
Injects all static and dynamic profile memories into every request. Best for maintaining consistent user context.
```python
openai_with_memory = with_supermemory(
openai,
"user-123",
OpenAIMiddlewareOptions(mode="profile")
)
```
#### `"query"` mode
Only searches for memories relevant to the current user message. More efficient for large memory stores.
```python
openai_with_memory = with_supermemory(
openai,
"user-123",
OpenAIMiddlewareOptions(mode="query")
)
```
#### `"full"` mode
Combines both profile and query modes - includes all profile memories plus relevant search results.
```python
openai_with_memory = with_supermemory(
openai,
"user-123",
OpenAIMiddlewareOptions(mode="full")
)
```
### Memory Storage
Control when conversations are automatically saved as memories:
```python
# Always save conversations as memories
OpenAIMiddlewareOptions(add_memory="always")
# Never save conversations (default)
OpenAIMiddlewareOptions(add_memory="never")
```
### Complete Configuration Example
```python
from supermemory_openai import with_supermemory, OpenAIMiddlewareOptions
openai_with_memory = with_supermemory(
openai_client,
container_tag="user-123",
options=OpenAIMiddlewareOptions(
conversation_id="chat-session-456", # Group messages into conversations
verbose=True, # Enable detailed logging
mode="full", # Use both profile and query
add_memory="always" # Auto-save conversations
)
)
```
## Manual Memory Tools
### SupermemoryTools Class
@ -136,6 +282,38 @@ if response.choices[0].message.tool_calls:
## API Reference
### Middleware Functions
#### `with_supermemory()`
Wraps an OpenAI client with automatic memory injection middleware.
```python
def with_supermemory(
openai_client: Union[OpenAI, AsyncOpenAI],
container_tag: str,
options: Optional[OpenAIMiddlewareOptions] = None
) -> Union[OpenAI, AsyncOpenAI]
```
**Parameters:**
- `openai_client`: OpenAI or AsyncOpenAI client instance
- `container_tag`: Unique identifier for memory storage (e.g., user ID)
- `options`: Configuration options (see `OpenAIMiddlewareOptions`)
#### `OpenAIMiddlewareOptions`
Configuration dataclass for middleware behavior.
```python
@dataclass
class OpenAIMiddlewareOptions:
conversation_id: Optional[str] = None # Group messages into conversations
verbose: bool = False # Enable detailed logging
mode: Literal["profile", "query", "full"] = "profile" # Memory injection mode
add_memory: Literal["always", "never"] = "never" # Auto-save behavior
```
### SupermemoryTools
Memory management tools for function calling.
@ -154,29 +332,77 @@ SupermemoryTools(
- `get_tool_definitions()` - Get OpenAI function definitions
- `search_memories()` - Search user memories
- `add_memory()` - Add new memory
- `fetch_memory()` - Fetch specific memory by ID
- `execute_tool_call()` - Execute individual tool call
## Error Handling
The package provides specific exception types for better error handling:
```python
from supermemory_openai import (
with_supermemory,
SupermemoryConfigurationError,
SupermemoryAPIError,
SupermemoryNetworkError,
SupermemoryMemoryOperationError,
)
try:
response = await client.chat_completion(
# This will raise SupermemoryConfigurationError if API key is missing
client = with_supermemory(openai_client, "user-123")
response = await client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
model="gpt-5"
model="gpt-4"
)
except SupermemoryConfigurationError as e:
print(f"Configuration issue: {e}")
except SupermemoryAPIError as e:
print(f"Supermemory API error: {e} (Status: {e.status_code})")
except SupermemoryNetworkError as e:
print(f"Network error: {e}")
except SupermemoryMemoryOperationError as e:
print(f"Memory operation failed: {e}")
except Exception as e:
print(f"Error: {e}")
print(f"Unexpected error: {e}")
```
### Exception Types
- **`SupermemoryError`** - Base class for all Supermemory exceptions
- **`SupermemoryConfigurationError`** - Missing API keys, invalid configuration
- **`SupermemoryAPIError`** - API request failures (includes status codes)
- **`SupermemoryNetworkError`** - Network connectivity issues
- **`SupermemoryMemoryOperationError`** - Memory search/add operation failures
- **`SupermemoryTimeoutError`** - Operation timeouts
All exceptions include the original error for debugging and have descriptive error messages.
## Environment Variables
Set these environment variables for testing:
Set these environment variables:
- `SUPERMEMORY_API_KEY` - Your Supermemory API key
- `OPENAI_API_KEY` - Your OpenAI API key
- `MODEL_NAME` - Model to use (default: "gpt-5-nano")
- `SUPERMEMORY_BASE_URL` - Custom Supermemory base URL (optional)
- `SUPERMEMORY_API_KEY` - Your Supermemory API key (required)
- `OPENAI_API_KEY` - Your OpenAI API key (required for examples)
Optional for testing:
- `MODEL_NAME` - Model to use (default: "gpt-4")
- `SUPERMEMORY_BASE_URL` - Custom Supermemory base URL
## Dependencies
### Required
- `openai>=1.102.0` - Official OpenAI Python SDK
- `supermemory>=3.1.0` - Supermemory client
- `requests>=2.25.0` - HTTP requests (fallback)
### Optional
- `aiohttp>=3.8.0` - Async HTTP requests (recommended for async clients)
Install with async support:
```bash
pip install supermemory-openai-sdk[async]
```
## Development

View file

@ -28,8 +28,12 @@ dependencies = [
"openai>=1.102.0",
"supermemory>=3.1.0",
"typing-extensions>=4.0.0",
"requests>=2.25.0",
]
[project.optional-dependencies]
async = ["aiohttp>=3.8.0"]
[dependency-groups]
dev = [
"black>=24.8.0",

View file

@ -1,4 +1,4 @@
"""Supermemory OpenAI SDK - Memory tools for OpenAI function calling."""
"""Supermemory OpenAI SDK - Memory tools and middleware for OpenAI function calling."""
from .tools import (
SupermemoryTools,
@ -16,6 +16,29 @@ from .tools import (
create_add_memory_tool,
)
from .middleware import (
with_supermemory,
OpenAIMiddlewareOptions,
SupermemoryOpenAIWrapper,
)
from .utils import (
Logger,
create_logger,
get_last_user_message,
get_conversation_content,
convert_profile_to_markdown,
)
from .exceptions import (
SupermemoryError,
SupermemoryConfigurationError,
SupermemoryAPIError,
SupermemoryMemoryOperationError,
SupermemoryTimeoutError,
SupermemoryNetworkError,
)
__all__ = [
# Tools
"SupermemoryTools",
@ -31,4 +54,21 @@ __all__ = [
"execute_memory_tool_calls",
"create_search_memories_tool",
"create_add_memory_tool",
# Middleware
"with_supermemory",
"OpenAIMiddlewareOptions",
"SupermemoryOpenAIWrapper",
# Utils
"Logger",
"create_logger",
"get_last_user_message",
"get_conversation_content",
"convert_profile_to_markdown",
# Exceptions
"SupermemoryError",
"SupermemoryConfigurationError",
"SupermemoryAPIError",
"SupermemoryMemoryOperationError",
"SupermemoryTimeoutError",
"SupermemoryNetworkError",
]

View file

@ -0,0 +1,62 @@
"""Custom exceptions for Supermemory OpenAI middleware."""
from typing import Optional
class SupermemoryError(Exception):
"""Base exception for all Supermemory-related errors."""
def __init__(self, message: str, original_error: Optional[Exception] = None):
super().__init__(message)
self.message = message
self.original_error = original_error
def __str__(self) -> str:
if self.original_error:
return f"{self.message}: {self.original_error}"
return self.message
class SupermemoryConfigurationError(SupermemoryError):
"""Raised when there are configuration issues (e.g., missing API key)."""
pass
class SupermemoryAPIError(SupermemoryError):
"""Raised when Supermemory API requests fail."""
def __init__(
self,
message: str,
status_code: Optional[int] = None,
response_text: Optional[str] = None,
original_error: Optional[Exception] = None,
):
super().__init__(message, original_error)
self.status_code = status_code
self.response_text = response_text
def __str__(self) -> str:
parts = [self.message]
if self.status_code:
parts.append(f"Status: {self.status_code}")
if self.response_text:
parts.append(f"Response: {self.response_text}")
if self.original_error:
parts.append(f"Cause: {self.original_error}")
return " | ".join(parts)
class SupermemoryMemoryOperationError(SupermemoryError):
"""Raised when memory operations (search, add) fail."""
pass
class SupermemoryTimeoutError(SupermemoryError):
"""Raised when operations timeout."""
pass
class SupermemoryNetworkError(SupermemoryError):
"""Raised when network operations fail."""
pass

View file

@ -0,0 +1,640 @@
"""Supermemory middleware for OpenAI clients."""
from dataclasses import dataclass
from typing import Optional, Union, Any, Literal, cast
import asyncio
import os
from openai import OpenAI, AsyncOpenAI
from openai.types.chat import (
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam,
)
import supermemory
from .utils import (
Logger,
create_logger,
get_last_user_message,
get_conversation_content,
convert_profile_to_markdown,
)
from .exceptions import (
SupermemoryConfigurationError,
SupermemoryAPIError,
SupermemoryMemoryOperationError,
SupermemoryNetworkError,
)
@dataclass
class OpenAIMiddlewareOptions:
"""Configuration options for OpenAI middleware."""
conversation_id: Optional[str] = None
verbose: bool = False
mode: Literal["profile", "query", "full"] = "profile"
add_memory: Literal["always", "never"] = "never"
class SupermemoryProfileSearch:
"""Type for Supermemory profile search response."""
def __init__(self, data: dict[str, Any]):
self.profile: dict[str, Any] = data.get("profile", {})
self.search_results: dict[str, Any] = data.get("searchResults", {})
async def supermemory_profile_search(
container_tag: str,
query_text: str,
api_key: str,
) -> SupermemoryProfileSearch:
"""Search for memories using the SuperMemory profile API."""
payload = {
"containerTag": container_tag,
}
if query_text:
payload["q"] = query_text
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.supermemory.ai/v4/profile",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
json=payload,
) as response:
if not response.ok:
error_text = await response.text()
raise SupermemoryAPIError(
"Supermemory profile search failed",
status_code=response.status,
response_text=error_text
)
data = await response.json()
return SupermemoryProfileSearch(data)
except ImportError:
# Fallback to requests if aiohttp not available
import requests
response = requests.post(
"https://api.supermemory.ai/v4/profile",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
json=payload,
)
if not response.ok:
raise SupermemoryAPIError(
"Supermemory profile search failed",
status_code=response.status_code,
response_text=response.text
)
return SupermemoryProfileSearch(response.json())
async def add_system_prompt(
messages: list[ChatCompletionMessageParam],
container_tag: str,
logger: Logger,
mode: Literal["profile", "query", "full"],
api_key: str,
) -> list[ChatCompletionMessageParam]:
"""Add memory-enhanced system prompts to chat completion messages."""
system_prompt_exists = any(msg.get("role") == "system" for msg in messages)
query_text = get_last_user_message(messages) if mode != "profile" else ""
memories_response = await supermemory_profile_search(
container_tag, query_text, api_key
)
memory_count_static = len(memories_response.profile.get("static", []))
memory_count_dynamic = len(memories_response.profile.get("dynamic", []))
logger.info(
"Memory search completed",
{
"container_tag": container_tag,
"memory_count_static": memory_count_static,
"memory_count_dynamic": memory_count_dynamic,
"query_text": query_text[:100] + ("..." if len(query_text) > 100 else ""),
"mode": mode,
},
)
profile_data = ""
if mode != "query":
profile_data = convert_profile_to_markdown(
{
"profile": {
"static": [
item.get("memory", "") if isinstance(item, dict) else str(item)
for item in memories_response.profile.get("static", [])
],
"dynamic": [
item.get("memory", "") if isinstance(item, dict) else str(item)
for item in memories_response.profile.get("dynamic", [])
],
},
"searchResults": {
"results": [
{"memory": item.get("memory", "") if isinstance(item, dict) else str(item)}
for item in memories_response.search_results.get("results", [])
],
},
}
)
search_results_memories = ""
if mode != "profile":
search_results = memories_response.search_results.get("results", [])
if search_results:
search_results_memories = (
f"Search results for user's recent message: \n"
+ "\n".join(
f"- {result.get('memory', '') if isinstance(result, dict) else str(result)}" for result in search_results
)
)
memories = f"{profile_data}\n{search_results_memories}".strip()
if memories:
logger.debug(
"Memory content preview",
{
"content": memories,
"full_length": len(memories),
},
)
if system_prompt_exists:
logger.debug("Added memories to existing system prompt")
return [
{**msg, "content": f"{msg.get('content', '')} \n {memories}"}
if msg.get("role") == "system"
else msg
for msg in messages
]
logger.debug("System prompt does not exist, created system prompt with memories")
system_message: ChatCompletionSystemMessageParam = {
"role": "system",
"content": memories,
}
return [system_message] + messages
async def add_memory_tool(
client: supermemory.Supermemory,
container_tag: str,
content: str,
custom_id: Optional[str],
logger: Logger,
) -> None:
"""Add a new memory to the SuperMemory system."""
try:
add_params = {
"content": content,
"container_tags": [container_tag],
}
if custom_id is not None:
add_params["custom_id"] = custom_id
# Handle both sync and async supermemory clients
try:
response = await client.memories.add(**add_params)
except TypeError:
# If it's not awaitable, call it synchronously
response = client.memories.add(**add_params)
logger.info(
"Memory saved successfully",
{
"container_tag": container_tag,
"custom_id": custom_id,
"content_length": len(content),
"memory_id": response.id,
},
)
except (OSError, ConnectionError) as network_error:
logger.error(
"Network error while saving memory",
{"error": str(network_error)},
)
raise SupermemoryNetworkError(
"Failed to save memory due to network error",
network_error
)
except Exception as error:
logger.error(
"Error saving memory",
{"error": str(error)},
)
raise SupermemoryMemoryOperationError(
"Failed to save memory",
error
)
class SupermemoryOpenAIWrapper:
"""Wrapper for OpenAI client with Supermemory middleware."""
def __init__(
self,
openai_client: Union[OpenAI, AsyncOpenAI],
container_tag: str,
options: Optional[OpenAIMiddlewareOptions] = None,
):
self._client: Union[OpenAI, AsyncOpenAI] = openai_client
self._container_tag: str = container_tag
self._options: OpenAIMiddlewareOptions = options or OpenAIMiddlewareOptions()
self._logger: Logger = create_logger(self._options.verbose)
# Track background tasks to ensure they complete
self._background_tasks: set[asyncio.Task] = set()
if not hasattr(supermemory, "Supermemory"):
raise SupermemoryConfigurationError(
"supermemory package is required but not found",
ImportError("supermemory package not installed")
)
api_key = self._get_api_key()
try:
self._supermemory_client: supermemory.Supermemory = supermemory.Supermemory(api_key=api_key)
except Exception as e:
raise SupermemoryConfigurationError(
f"Failed to initialize Supermemory client: {e}",
e
)
# Wrap the chat completions create method
self._wrap_chat_completions()
def _get_api_key(self) -> str:
"""Get Supermemory API key from environment."""
import os
api_key = os.getenv("SUPERMEMORY_API_KEY")
if not api_key:
raise SupermemoryConfigurationError(
"SUPERMEMORY_API_KEY environment variable is required but not set"
)
return api_key
def _wrap_chat_completions(self) -> None:
"""Wrap the chat completions create method with memory injection."""
original_create = self._client.chat.completions.create
if asyncio.iscoroutinefunction(original_create):
async def create_with_memory(
**kwargs: Any,
) -> Any:
return await self._create_with_memory_async(original_create, **kwargs)
else:
def create_with_memory(
**kwargs: Any,
) -> Any:
return self._create_with_memory_sync(original_create, **kwargs)
# Replace the create method with our wrapper
setattr(self._client.chat.completions, "create", create_with_memory)
async def _create_with_memory_async(
self,
original_create: Any,
**kwargs: Any,
) -> Any:
"""Async version of create with memory injection."""
messages = kwargs.get("messages", [])
if self._options.add_memory == "always":
user_message = get_last_user_message(messages)
if user_message and user_message.strip():
content = (
get_conversation_content(messages)
if self._options.conversation_id
else user_message
)
custom_id = (
f"conversation:{self._options.conversation_id}"
if self._options.conversation_id
else None
)
# Create background task for memory storage
task = asyncio.create_task(
add_memory_tool(
self._supermemory_client,
self._container_tag,
content,
custom_id,
self._logger,
)
)
# Track the task and set up cleanup
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
# Log any exceptions but don't fail the main request
def handle_task_exception(task_obj):
try:
if task_obj.exception() is not None:
exception = task_obj.exception()
if isinstance(exception, (SupermemoryNetworkError, SupermemoryAPIError)):
self._logger.warn(
"Background memory storage failed",
{"error": str(exception), "type": type(exception).__name__}
)
else:
self._logger.error(
"Unexpected error in background memory storage",
{"error": str(exception), "type": type(exception).__name__}
)
except asyncio.CancelledError:
self._logger.debug("Memory storage task was cancelled")
task.add_done_callback(handle_task_exception)
if self._options.mode != "profile":
user_message = get_last_user_message(messages)
if not user_message:
self._logger.debug("No user message found, skipping memory search")
return await original_create(**kwargs)
self._logger.info(
"Starting memory search",
{
"container_tag": self._container_tag,
"conversation_id": self._options.conversation_id,
"mode": self._options.mode,
},
)
enhanced_messages = await add_system_prompt(
messages,
self._container_tag,
self._logger,
self._options.mode,
self._get_api_key(),
)
kwargs["messages"] = enhanced_messages
return await original_create(**kwargs)
def _create_with_memory_sync(
self,
original_create: Any,
**kwargs: Any,
) -> Any:
"""Sync version of create with memory injection."""
# For sync clients, we implement a simplified version without background tasks
messages = kwargs.get("messages", [])
# Handle memory addition synchronously if needed
if self._options.add_memory == "always":
user_message = get_last_user_message(messages)
if user_message and user_message.strip():
content = (
get_conversation_content(messages)
if self._options.conversation_id
else user_message
)
custom_id = (
f"conversation:{self._options.conversation_id}"
if self._options.conversation_id
else None
)
# Use asyncio.run() for the memory addition
try:
asyncio.run(
add_memory_tool(
self._supermemory_client,
self._container_tag,
content,
custom_id,
self._logger,
)
)
except RuntimeError as e:
if "cannot be called from a running event loop" in str(e):
# We're in an async context, log warning and skip memory saving
self._logger.warn(
"Cannot save memory in sync client from async context",
{"error": str(e)}
)
else:
raise
except SupermemoryNetworkError as e:
# Network errors are expected, log as warning
self._logger.warn("Network error saving memory", {"error": str(e)})
except (SupermemoryAPIError, SupermemoryMemoryOperationError) as e:
# API/memory errors are concerning, log as error
self._logger.error("Failed to save memory", {"error": str(e)})
except Exception as e:
# Unexpected errors should be investigated
self._logger.error(
"Unexpected error saving memory",
{"error": str(e), "type": type(e).__name__}
)
# Handle memory search and injection
if self._options.mode != "profile":
user_message = get_last_user_message(messages)
if not user_message:
self._logger.debug("No user message found, skipping memory search")
return original_create(**kwargs)
self._logger.info("Starting memory search", {
"container_tag": self._container_tag,
"conversation_id": self._options.conversation_id,
"mode": self._options.mode,
})
# Use asyncio.run() for memory search and injection
try:
enhanced_messages = asyncio.run(
add_system_prompt(
messages,
self._container_tag,
self._logger,
self._options.mode,
self._get_api_key(),
)
)
except RuntimeError as e:
if "cannot be called from a running event loop" in str(e):
# We're in an async context, run in a separate thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(
asyncio.run,
add_system_prompt(
messages,
self._container_tag,
self._logger,
self._options.mode,
self._get_api_key(),
)
)
enhanced_messages = future.result()
else:
raise
kwargs["messages"] = enhanced_messages
return original_create(**kwargs)
async def wait_for_background_tasks(self, timeout: Optional[float] = 10.0) -> None:
"""
Wait for all background memory storage tasks to complete.
Args:
timeout: Maximum time to wait in seconds. None for no timeout.
Raises:
asyncio.TimeoutError: If tasks don't complete within timeout
"""
if not self._background_tasks:
return
self._logger.debug(f"Waiting for {len(self._background_tasks)} background tasks to complete")
try:
if timeout is not None:
await asyncio.wait_for(
asyncio.gather(*self._background_tasks, return_exceptions=True),
timeout=timeout
)
else:
await asyncio.gather(*self._background_tasks, return_exceptions=True)
self._logger.debug("All background tasks completed")
except asyncio.TimeoutError:
self._logger.warn(f"Background tasks did not complete within {timeout}s timeout")
# Cancel remaining tasks
for task in self._background_tasks:
if not task.done():
task.cancel()
raise
def cancel_background_tasks(self) -> None:
"""Cancel all pending background tasks."""
cancelled_count = 0
for task in self._background_tasks:
if not task.done():
task.cancel()
cancelled_count += 1
if cancelled_count > 0:
self._logger.debug(f"Cancelled {cancelled_count} pending background tasks")
async def __aenter__(self):
"""Async context manager entry."""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit - wait for background tasks."""
try:
await self.wait_for_background_tasks(timeout=5.0)
except asyncio.TimeoutError:
self._logger.warn("Some background memory tasks did not complete on exit")
def __enter__(self):
"""Sync context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Sync context manager exit - attempt to wait for background tasks."""
if self._background_tasks:
try:
# Try to wait for background tasks in sync context
asyncio.run(self.wait_for_background_tasks(timeout=5.0))
except RuntimeError as e:
if "cannot be called from a running event loop" in str(e):
# In async context, just cancel the tasks
self._logger.warn(
"Cannot wait for background tasks in sync context from async environment. "
"Use async context manager or call wait_for_background_tasks() manually."
)
self.cancel_background_tasks()
else:
raise
except asyncio.TimeoutError:
self._logger.warn("Some background memory tasks did not complete on exit")
self.cancel_background_tasks()
def __getattr__(self, name: str) -> Any:
"""Delegate all other attributes to the wrapped client."""
return getattr(self._client, name)
def with_supermemory(
openai_client: Union[OpenAI, AsyncOpenAI],
container_tag: str,
options: Optional[OpenAIMiddlewareOptions] = None,
) -> Union[OpenAI, AsyncOpenAI]:
"""
Wraps an OpenAI client with SuperMemory middleware to automatically inject relevant memories
into the system prompt based on the user's message content.
This middleware searches the supermemory API for relevant memories using the container tag
and user message, then either appends memories to an existing system prompt or creates
a new system prompt with the memories.
Args:
openai_client: The OpenAI client to wrap with SuperMemory middleware
container_tag: The container tag/identifier for memory search (e.g., user ID, project ID)
options: Optional configuration options for the middleware
Returns:
An OpenAI client with SuperMemory middleware injected
Example:
```python
from supermemory_openai import with_supermemory, OpenAIMiddlewareOptions
from openai import OpenAI
# Create OpenAI client with supermemory middleware
openai = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
openai_with_supermemory = with_supermemory(
openai,
"user-123",
OpenAIMiddlewareOptions(
conversation_id="conversation-456",
mode="full",
add_memory="always"
)
)
# Use normally - memories will be automatically injected
response = await openai_with_supermemory.chat.completions.create(
model="gpt-4",
messages=[
{"role": "user", "content": "What's my favorite programming language?"}
]
)
```
Raises:
ValueError: When SUPERMEMORY_API_KEY environment variable is not set
Exception: When supermemory API request fails
"""
wrapper = SupermemoryOpenAIWrapper(openai_client, container_tag, options)
# Return the wrapper, which delegates all attributes to the original client
return cast(Union[OpenAI, AsyncOpenAI], wrapper)

View file

@ -16,6 +16,12 @@ from supermemory.types import (
)
from supermemory.types.search_execute_response import Result
from .exceptions import (
SupermemoryConfigurationError,
SupermemoryMemoryOperationError,
SupermemoryNetworkError,
)
class SupermemoryToolsConfig(TypedDict, total=False):
"""Configuration for Supermemory tools.
@ -194,10 +200,15 @@ class SupermemoryTools:
results=response.results,
count=len(response.results),
)
except (OSError, ConnectionError) as network_error:
return MemorySearchResult(
success=False,
error=f"Network error: {network_error}",
)
except Exception as error:
return MemorySearchResult(
success=False,
error=str(error),
error=f"Memory search failed: {error}",
)
async def add_memory(self, memory: str) -> MemoryAddResult:
@ -225,10 +236,15 @@ class SupermemoryTools:
success=True,
memory=response,
)
except (OSError, ConnectionError) as network_error:
return MemoryAddResult(
success=False,
error=f"Network error: {network_error}",
)
except Exception as error:
return MemoryAddResult(
success=False,
error=str(error),
error=f"Memory add failed: {error}",
)

View file

@ -0,0 +1,230 @@
"""Utility functions for Supermemory OpenAI middleware."""
import json
from typing import Optional, Any, Protocol
from openai.types.chat import ChatCompletionMessageParam
class Logger(Protocol):
"""Logger protocol for type safety."""
def debug(self, message: str, data: Optional[dict[str, Any]] = None) -> None:
"""Log debug message."""
...
def info(self, message: str, data: Optional[dict[str, Any]] = None) -> None:
"""Log info message."""
...
def warn(self, message: str, data: Optional[dict[str, Any]] = None) -> None:
"""Log warning message."""
...
def error(self, message: str, data: Optional[dict[str, Any]] = None) -> None:
"""Log error message."""
...
class SimpleLogger:
"""Simple logger implementation."""
def __init__(self, verbose: bool = False):
self.verbose: bool = verbose
def _log(self, level: str, message: str, data: Optional[dict[str, Any]] = None) -> None:
"""Internal logging method."""
if not self.verbose:
return
log_message = f"[supermemory] {message}"
if data:
log_message += f" {json.dumps(data, indent=2)}"
if level == "error":
print(f"ERROR: {log_message}", flush=True)
elif level == "warn":
print(f"WARN: {log_message}", flush=True)
else:
print(log_message, flush=True)
def debug(self, message: str, data: Optional[dict[str, Any]] = None) -> None:
"""Log debug message."""
self._log("debug", message, data)
def info(self, message: str, data: Optional[dict[str, Any]] = None) -> None:
"""Log info message."""
self._log("info", message, data)
def warn(self, message: str, data: Optional[dict[str, Any]] = None) -> None:
"""Log warning message."""
self._log("warn", message, data)
def error(self, message: str, data: Optional[dict[str, Any]] = None) -> None:
"""Log error message."""
self._log("error", message, data)
def create_logger(verbose: bool) -> Logger:
"""Create a logger instance.
Args:
verbose: Whether to enable verbose logging
Returns:
Logger instance
"""
return SimpleLogger(verbose)
def get_last_user_message(
messages: list[ChatCompletionMessageParam],
) -> str:
"""
Extract the last user message from an array of chat completion messages.
Searches through the messages array in reverse order to find the most recent
message with role "user" and returns its content as a string.
Args:
messages: Array of chat completion message parameters
Returns:
The content of the last user message, or empty string if none found
Example:
```python
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello there!"},
{"role": "assistant", "content": "Hi! How can I help you?"},
{"role": "user", "content": "What's the weather like?"}
]
last_message = get_last_user_message(messages)
# Returns: "What's the weather like?"
```
"""
for message in reversed(messages):
if message.get("role") == "user":
content = message.get("content", "")
if isinstance(content, str):
return content
elif isinstance(content, list):
# Handle content that is an array of content parts
text_parts = []
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
text_parts.append(part.get("text", ""))
elif isinstance(part, str):
text_parts.append(part)
return " ".join(text_parts)
return ""
def get_conversation_content(
messages: list[ChatCompletionMessageParam],
) -> str:
"""
Convert an array of chat completion messages into a formatted conversation string.
Transforms the messages array into a readable conversation format where each
message is prefixed with its role (User/Assistant) and messages are separated
by double newlines.
Args:
messages: Array of chat completion message parameters
Returns:
Formatted conversation string with role prefixes
Example:
```python
messages = [
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "How are you?"}
]
conversation = get_conversation_content(messages)
# Returns: "User: Hello!\n\nAssistant: Hi there!\n\nUser: How are you?"
```
"""
conversation_parts = []
for message in messages:
role = message.get("role", "")
content = message.get("content", "")
# Format role
if role == "user":
role_display = "User"
elif role == "assistant":
role_display = "Assistant"
elif role == "system":
role_display = "System"
else:
role_display = role.capitalize()
# Extract content text
if isinstance(content, str):
content_text = content
elif isinstance(content, list):
# Handle content that is an array of content parts
text_parts = []
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
text_parts.append(part.get("text", ""))
elif isinstance(part, str):
text_parts.append(part)
content_text = " ".join(text_parts)
else:
content_text = str(content)
if content_text:
conversation_parts.append(f"{role_display}: {content_text}")
return "\n\n".join(conversation_parts)
def convert_profile_to_markdown(data: dict[str, Any]) -> str:
"""
Convert profile data to markdown based on profile.static and profile.dynamic properties.
Args:
data: Profile structure data
Returns:
Markdown string
Example:
```python
data = {
"profile": {
"static": ["User prefers Python", "Lives in San Francisco"],
"dynamic": ["Recently asked about AI"]
},
"searchResults": {
"results": [{"memory": "Likes coffee"}]
}
}
markdown = convert_profile_to_markdown(data)
# Returns formatted markdown with sections
```
"""
sections = []
profile = data.get("profile", {})
static_memories = profile.get("static", [])
dynamic_memories = profile.get("dynamic", [])
if static_memories:
sections.append("## Static Profile")
sections.append("\n".join(f"- {item}" for item in static_memories))
if dynamic_memories:
sections.append("## Dynamic Profile")
sections.append("\n".join(f"- {item}" for item in dynamic_memories))
return "\n\n".join(sections)

View file

@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""
Integration test for Supermemory OpenAI middleware.
This script demonstrates how to test the middleware with real API calls.
Set your API keys as environment variables to run this test.
"""
import asyncio
import os
from openai import AsyncOpenAI, OpenAI
from supermemory_openai import (
with_supermemory,
OpenAIMiddlewareOptions,
SupermemoryConfigurationError,
SupermemoryAPIError,
)
async def test_async_middleware():
"""Test async middleware functionality."""
print("🔄 Testing Async Middleware...")
try:
# Check for required environment variables
if not os.getenv("OPENAI_API_KEY"):
print("❌ OPENAI_API_KEY not set - skipping OpenAI test")
return
if not os.getenv("SUPERMEMORY_API_KEY"):
print("❌ SUPERMEMORY_API_KEY not set - skipping Supermemory test")
return
# Create OpenAI client
openai_client = AsyncOpenAI()
# Wrap with Supermemory middleware
openai_with_memory = with_supermemory(
openai_client,
container_tag="test-user-123",
options=OpenAIMiddlewareOptions(
mode="profile",
verbose=True,
add_memory="never" # Don't save test messages
)
)
# Test context manager
async with openai_with_memory as client:
print("✅ Context manager works")
# Make a test request
response = await client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "user", "content": "Hello! This is a test message."}
],
max_tokens=50
)
print(f"✅ API call successful: {response.choices[0].message.content[:50]}...")
except SupermemoryConfigurationError as e:
print(f"⚠️ Configuration error: {e}")
except SupermemoryAPIError as e:
print(f"⚠️ Supermemory API error: {e}")
except Exception as e:
print(f"❌ Unexpected error: {e}")
def test_sync_middleware():
"""Test sync middleware functionality."""
print("\n🔄 Testing Sync Middleware...")
try:
if not os.getenv("OPENAI_API_KEY"):
print("❌ OPENAI_API_KEY not set - skipping OpenAI test")
return
if not os.getenv("SUPERMEMORY_API_KEY"):
print("❌ SUPERMEMORY_API_KEY not set - skipping Supermemory test")
return
# Create sync OpenAI client
openai_client = OpenAI()
# Wrap with Supermemory middleware
openai_with_memory = with_supermemory(
openai_client,
container_tag="test-user-sync-123",
options=OpenAIMiddlewareOptions(
mode="profile",
verbose=True
)
)
# Test context manager
with openai_with_memory as client:
print("✅ Sync context manager works")
# Make a test request
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "user", "content": "This is a sync test message."}
],
max_tokens=50
)
print(f"✅ Sync API call successful: {response.choices[0].message.content[:50]}...")
except SupermemoryConfigurationError as e:
print(f"⚠️ Configuration error: {e}")
except SupermemoryAPIError as e:
print(f"⚠️ Supermemory API error: {e}")
except Exception as e:
print(f"❌ Unexpected error: {e}")
def test_error_handling():
"""Test error handling without API keys."""
print("\n🔄 Testing Error Handling...")
try:
# Test with missing API key
openai_client = OpenAI(api_key="fake-key")
# This should raise SupermemoryConfigurationError
with_supermemory(openai_client, "test-user")
print("❌ Should have raised SupermemoryConfigurationError")
except SupermemoryConfigurationError as e:
print(f"✅ Correctly caught configuration error: {e}")
except Exception as e:
print(f"❌ Wrong exception type: {type(e).__name__}: {e}")
def test_background_tasks():
"""Test background task management."""
print("\n🔄 Testing Background Task Management...")
try:
if not os.getenv("SUPERMEMORY_API_KEY"):
print("❌ SUPERMEMORY_API_KEY not set - skipping background task test")
return
# Create a fake OpenAI client for testing
from unittest.mock import Mock, AsyncMock
openai_client = Mock()
openai_client.chat = Mock()
openai_client.chat.completions = Mock()
openai_client.chat.completions.create = AsyncMock(return_value=Mock())
# Wrap with memory storage enabled
wrapped_client = with_supermemory(
openai_client,
container_tag="test-background-tasks",
options=OpenAIMiddlewareOptions(
add_memory="always",
verbose=True
)
)
print(f"✅ Background tasks tracking: {len(wrapped_client._background_tasks)} tasks")
except Exception as e:
print(f"❌ Background task test error: {e}")
async def main():
"""Run all tests."""
print("🧪 Supermemory OpenAI Middleware Integration Tests")
print("=" * 60)
# Test async middleware
await test_async_middleware()
# Test sync middleware
test_sync_middleware()
# Test error handling
test_error_handling()
# Test background tasks
test_background_tasks()
print("\n" + "=" * 60)
print("🎉 Integration tests completed!")
print("\n💡 To run with real API calls, set these environment variables:")
print(" export OPENAI_API_KEY='your-openai-key'")
print(" export SUPERMEMORY_API_KEY='your-supermemory-key'")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,725 @@
"""Tests for middleware module."""
import os
import pytest
import asyncio
from unittest.mock import AsyncMock, Mock, patch, MagicMock
from typing import Dict, Any
from dotenv import load_dotenv
load_dotenv()
# Import from the installed package or src directly
try:
from supermemory_openai import (
with_supermemory,
OpenAIMiddlewareOptions,
SupermemoryOpenAIWrapper,
)
except ImportError:
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), "src"))
from supermemory_openai import (
with_supermemory,
OpenAIMiddlewareOptions,
SupermemoryOpenAIWrapper,
)
from openai import OpenAI, AsyncOpenAI
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types import CompletionUsage
@pytest.fixture
def mock_openai_client():
"""Create a mock OpenAI client."""
client = Mock(spec=OpenAI)
client.chat = Mock()
client.chat.completions = Mock()
return client
@pytest.fixture
def mock_async_openai_client():
"""Create a mock async OpenAI client."""
client = Mock(spec=AsyncOpenAI)
client.chat = Mock()
client.chat.completions = Mock()
return client
@pytest.fixture
def mock_openai_response():
"""Create a mock OpenAI response."""
return ChatCompletion(
id="chatcmpl-test",
object="chat.completion",
created=1234567890,
model="gpt-4",
choices=[
{
"index": 0,
"message": ChatCompletionMessage(
role="assistant",
content="Hello! How can I help you today?"
),
"finish_reason": "stop"
}
],
usage=CompletionUsage(
prompt_tokens=10,
completion_tokens=10,
total_tokens=20
)
)
@pytest.fixture
def mock_supermemory_response():
"""Create a mock Supermemory API response."""
return {
"profile": {
"static": [
{"memory": "User prefers Python for development"},
{"memory": "Lives in San Francisco"}
],
"dynamic": [
{"memory": "Recently asked about AI frameworks"}
]
},
"searchResults": {
"results": [
{"memory": "User likes machine learning projects"},
{"memory": "Has experience with FastAPI"}
]
}
}
class TestMiddlewareInitialization:
"""Test middleware initialization."""
def test_with_supermemory_basic(self, mock_openai_client):
"""Test basic middleware initialization."""
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
wrapped_client = with_supermemory(mock_openai_client, "user-123")
assert isinstance(wrapped_client, SupermemoryOpenAIWrapper)
assert wrapped_client._container_tag == "user-123"
assert wrapped_client._options.mode == "profile"
assert wrapped_client._options.verbose is False
def test_with_supermemory_with_options(self, mock_openai_client):
"""Test middleware initialization with options."""
options = OpenAIMiddlewareOptions(
conversation_id="conv-456",
verbose=True,
mode="full",
add_memory="always"
)
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
wrapped_client = with_supermemory(mock_openai_client, "user-123", options)
assert wrapped_client._options.conversation_id == "conv-456"
assert wrapped_client._options.verbose is True
assert wrapped_client._options.mode == "full"
assert wrapped_client._options.add_memory == "always"
def test_missing_api_key_raises_error(self, mock_openai_client):
"""Test that missing API key raises error."""
from supermemory_openai.exceptions import SupermemoryConfigurationError
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(SupermemoryConfigurationError, match="SUPERMEMORY_API_KEY"):
with_supermemory(mock_openai_client, "user-123")
def test_wrapper_delegates_attributes(self, mock_openai_client):
"""Test that wrapper delegates attributes to wrapped client."""
mock_openai_client.models = Mock()
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
wrapped_client = with_supermemory(mock_openai_client, "user-123")
# Should delegate to the original client
assert wrapped_client.models is mock_openai_client.models
class TestMemoryInjection:
"""Test memory injection functionality."""
@pytest.mark.asyncio
async def test_memory_injection_profile_mode(
self, mock_async_openai_client, mock_openai_response, mock_supermemory_response
):
"""Test memory injection in profile mode."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
mock_search.return_value = Mock()
mock_search.return_value.profile = mock_supermemory_response["profile"]
mock_search.return_value.search_results = mock_supermemory_response["searchResults"]
wrapped_client = with_supermemory(
mock_async_openai_client,
"user-123",
OpenAIMiddlewareOptions(mode="profile")
)
messages = [
{"role": "user", "content": "What's my favorite programming language?"}
]
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=messages
)
# Verify the original create was called
original_create.assert_called_once()
call_args = original_create.call_args[1]
# Should have injected memories as system prompt
enhanced_messages = call_args["messages"]
assert len(enhanced_messages) >= len(messages)
# First message should be system prompt with memories
system_message = enhanced_messages[0]
assert system_message["role"] == "system"
assert "User prefers Python" in system_message["content"]
@pytest.mark.asyncio
async def test_memory_injection_query_mode(
self, mock_async_openai_client, mock_openai_response, mock_supermemory_response
):
"""Test memory injection in query mode."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
mock_search.return_value = Mock()
mock_search.return_value.profile = {"static": [], "dynamic": []}
mock_search.return_value.search_results = mock_supermemory_response["searchResults"]
wrapped_client = with_supermemory(
mock_async_openai_client,
"user-123",
OpenAIMiddlewareOptions(mode="query")
)
messages = [
{"role": "user", "content": "What machine learning frameworks do I like?"}
]
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=messages
)
# Verify search was called with the user message
mock_search.assert_called_once()
search_args = mock_search.call_args[0]
assert search_args[1] == "What machine learning frameworks do I like?"
@pytest.mark.asyncio
async def test_memory_injection_full_mode(
self, mock_async_openai_client, mock_openai_response, mock_supermemory_response
):
"""Test memory injection in full mode."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
mock_search.return_value = Mock()
mock_search.return_value.profile = mock_supermemory_response["profile"]
mock_search.return_value.search_results = mock_supermemory_response["searchResults"]
wrapped_client = with_supermemory(
mock_async_openai_client,
"user-123",
OpenAIMiddlewareOptions(mode="full")
)
messages = [
{"role": "user", "content": "Tell me about my preferences"}
]
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=messages
)
original_create.assert_called_once()
call_args = original_create.call_args[1]
enhanced_messages = call_args["messages"]
# Should include both profile and search results
system_content = enhanced_messages[0]["content"]
assert "Static Profile" in system_content
assert "Dynamic Profile" in system_content
assert "Search results" in system_content
@pytest.mark.asyncio
async def test_existing_system_prompt_enhancement(
self, mock_async_openai_client, mock_openai_response, mock_supermemory_response
):
"""Test that existing system prompts are enhanced with memories."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
mock_search.return_value = Mock()
mock_search.return_value.profile = mock_supermemory_response["profile"]
mock_search.return_value.search_results = mock_supermemory_response["searchResults"]
wrapped_client = with_supermemory(mock_async_openai_client, "user-123")
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What do you know about me?"}
]
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=messages
)
original_create.assert_called_once()
call_args = original_create.call_args[1]
enhanced_messages = call_args["messages"]
# Should still have same number of messages
assert len(enhanced_messages) == len(messages)
# System message should be enhanced
system_message = enhanced_messages[0]
assert system_message["role"] == "system"
assert "You are a helpful assistant." in system_message["content"]
assert "User prefers Python" in system_message["content"]
class TestMemoryStorage:
"""Test memory storage functionality."""
@pytest.mark.asyncio
async def test_add_memory_always_mode(
self, mock_async_openai_client, mock_openai_response
):
"""Test memory storage in always mode."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
with patch("supermemory_openai.middleware.add_memory_tool") as mock_add_memory:
mock_search.return_value = Mock()
mock_search.return_value.profile = {"static": [], "dynamic": []}
mock_search.return_value.search_results = {"results": []}
wrapped_client = with_supermemory(
mock_async_openai_client,
"user-123",
OpenAIMiddlewareOptions(add_memory="always")
)
messages = [
{"role": "user", "content": "I really love Python programming"}
]
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=messages
)
# Should attempt to add memory (but not wait for it)
# We can't easily test the background task, but we can verify
# the main flow still works
original_create.assert_called_once()
@pytest.mark.asyncio
async def test_add_memory_never_mode(
self, mock_async_openai_client, mock_openai_response
):
"""Test that memory is not stored in never mode."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
with patch("supermemory_openai.middleware.add_memory_tool") as mock_add_memory:
mock_search.return_value = Mock()
mock_search.return_value.profile = {"static": [], "dynamic": []}
mock_search.return_value.search_results = {"results": []}
wrapped_client = with_supermemory(
mock_async_openai_client,
"user-123",
OpenAIMiddlewareOptions(add_memory="never")
)
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Test message"}]
)
# add_memory_tool should never be called
mock_add_memory.assert_not_called()
class TestSyncAsyncCompatibility:
"""Test sync and async client compatibility."""
def test_sync_client_compatibility(self, mock_openai_client, mock_openai_response):
"""Test that sync clients work with middleware."""
original_create = Mock(return_value=mock_openai_response)
mock_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
mock_search.return_value = Mock()
mock_search.return_value.profile = {"static": [], "dynamic": []}
mock_search.return_value.search_results = {"results": []}
wrapped_client = with_supermemory(mock_openai_client, "user-123")
# This should work for sync clients too
wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
original_create.assert_called_once()
def test_sync_client_in_async_context(self, mock_openai_client, mock_openai_response):
"""Test sync client behavior when called from async context."""
import asyncio
async def test_in_async():
original_create = Mock(return_value=mock_openai_response)
mock_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
mock_search.return_value = Mock()
mock_search.return_value.profile = {"static": [], "dynamic": []}
mock_search.return_value.search_results = {"results": []}
wrapped_client = with_supermemory(mock_openai_client, "user-123")
# This should work even when called from async context
result = wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
assert result == mock_openai_response
original_create.assert_called_once()
# Run the async test
asyncio.run(test_in_async())
def test_sync_client_memory_addition_error_handling(self, mock_openai_client, mock_openai_response):
"""Test error handling in sync client memory addition."""
original_create = Mock(return_value=mock_openai_response)
mock_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
with patch("supermemory_openai.middleware.add_memory_tool") as mock_add_memory:
mock_search.return_value = Mock()
mock_search.return_value.profile = {"static": [], "dynamic": []}
mock_search.return_value.search_results = {"results": []}
# Simulate memory addition failure
mock_add_memory.side_effect = Exception("Memory API error")
wrapped_client = with_supermemory(
mock_openai_client,
"user-123",
OpenAIMiddlewareOptions(add_memory="always")
)
# Should not raise exception, should continue with main request
result = wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
assert result == mock_openai_response
original_create.assert_called_once()
class TestErrorHandling:
"""Test error handling scenarios."""
@pytest.mark.asyncio
async def test_supermemory_api_error_handling(
self, mock_async_openai_client, mock_openai_response
):
"""Test handling of Supermemory API errors."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
mock_search.side_effect = Exception("API Error")
wrapped_client = with_supermemory(mock_async_openai_client, "user-123")
# Should not raise exception, should fall back gracefully
with pytest.raises(Exception):
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
@pytest.mark.asyncio
async def test_no_user_message_handling(
self, mock_async_openai_client, mock_openai_response
):
"""Test handling when no user message is present in query mode."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
wrapped_client = with_supermemory(
mock_async_openai_client,
"user-123",
OpenAIMiddlewareOptions(mode="query")
)
messages = [
{"role": "system", "content": "You are a helpful assistant."}
]
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=messages
)
# Should skip memory search and call original create
original_create.assert_called_once()
call_args = original_create.call_args[1]
assert call_args["messages"] == messages # No modification
class TestLogging:
"""Test logging functionality."""
@pytest.mark.asyncio
async def test_verbose_logging(
self, mock_async_openai_client, mock_openai_response, mock_supermemory_response
):
"""Test verbose logging output."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
with patch("builtins.print") as mock_print:
mock_search.return_value = Mock()
mock_search.return_value.profile = mock_supermemory_response["profile"]
mock_search.return_value.search_results = mock_supermemory_response["searchResults"]
wrapped_client = with_supermemory(
mock_async_openai_client,
"user-123",
OpenAIMiddlewareOptions(verbose=True)
)
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
# Should have printed log messages
assert mock_print.called
@pytest.mark.asyncio
async def test_silent_logging(
self, mock_async_openai_client, mock_openai_response, mock_supermemory_response
):
"""Test that logging is silent when verbose=False."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
with patch("builtins.print") as mock_print:
mock_search.return_value = Mock()
mock_search.return_value.profile = mock_supermemory_response["profile"]
mock_search.return_value.search_results = mock_supermemory_response["searchResults"]
wrapped_client = with_supermemory(
mock_async_openai_client,
"user-123",
OpenAIMiddlewareOptions(verbose=False)
)
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
# Should not have printed anything
mock_print.assert_not_called()
class TestBackgroundTaskManagement:
"""Test background task management and cleanup."""
@pytest.mark.asyncio
async def test_background_task_tracking(
self, mock_async_openai_client, mock_openai_response, mock_supermemory_response
):
"""Test that background tasks are properly tracked."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
with patch("supermemory_openai.middleware.add_memory_tool") as mock_add_memory:
mock_search.return_value = Mock()
mock_search.return_value.profile = {"static": [], "dynamic": []}
mock_search.return_value.search_results = {"results": []}
# Make add_memory_tool take some time
async def slow_add_memory(*args, **kwargs):
await asyncio.sleep(0.1)
mock_add_memory.side_effect = slow_add_memory
wrapped_client = with_supermemory(
mock_async_openai_client,
"user-123",
OpenAIMiddlewareOptions(add_memory="always")
)
# Make a request that should create a background task
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
# Should have one background task
assert len(wrapped_client._background_tasks) == 1
# Wait for background tasks to complete
await wrapped_client.wait_for_background_tasks()
# Task should be removed from set after completion
assert len(wrapped_client._background_tasks) == 0
mock_add_memory.assert_called_once()
@pytest.mark.asyncio
async def test_context_manager_cleanup(
self, mock_async_openai_client, mock_openai_response
):
"""Test that async context manager waits for background tasks."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
with patch("supermemory_openai.middleware.add_memory_tool") as mock_add_memory:
mock_search.return_value = Mock()
mock_search.return_value.profile = {"static": [], "dynamic": []}
mock_search.return_value.search_results = {"results": []}
task_completed = False
async def slow_add_memory(*args, **kwargs):
nonlocal task_completed
await asyncio.sleep(0.05)
task_completed = True
mock_add_memory.side_effect = slow_add_memory
# Use async context manager
async with with_supermemory(
mock_async_openai_client,
"user-123",
OpenAIMiddlewareOptions(add_memory="always")
) as wrapped_client:
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
# Task should still be running
assert not task_completed
# After context exit, task should have completed
assert task_completed
@pytest.mark.asyncio
async def test_background_task_timeout(
self, mock_async_openai_client, mock_openai_response
):
"""Test timeout handling for background tasks."""
original_create = AsyncMock(return_value=mock_openai_response)
mock_async_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
with patch("supermemory_openai.middleware.add_memory_tool") as mock_add_memory:
mock_search.return_value = Mock()
mock_search.return_value.profile = {"static": [], "dynamic": []}
mock_search.return_value.search_results = {"results": []}
# Make add_memory_tool hang
async def hanging_add_memory(*args, **kwargs):
await asyncio.sleep(10) # Longer than timeout
mock_add_memory.side_effect = hanging_add_memory
wrapped_client = with_supermemory(
mock_async_openai_client,
"user-123",
OpenAIMiddlewareOptions(add_memory="always")
)
await wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
# Should timeout and cancel tasks
with pytest.raises(asyncio.TimeoutError):
await wrapped_client.wait_for_background_tasks(timeout=0.1)
# Tasks should be cancelled
for task in wrapped_client._background_tasks:
assert task.cancelled()
def test_sync_context_manager_cleanup(
self, mock_openai_client, mock_openai_response
):
"""Test that sync context manager attempts cleanup."""
original_create = Mock(return_value=mock_openai_response)
mock_openai_client.chat.completions.create = original_create
with patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "test-key"}):
with patch("supermemory_openai.middleware.supermemory_profile_search") as mock_search:
with patch("supermemory_openai.middleware.add_memory_tool"):
mock_search.return_value = Mock()
mock_search.return_value.profile = {"static": [], "dynamic": []}
mock_search.return_value.search_results = {"results": []}
# Use sync context manager
with with_supermemory(
mock_openai_client,
"user-123",
OpenAIMiddlewareOptions(add_memory="always")
) as wrapped_client:
wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
# Should complete without error

File diff suppressed because it is too large Load diff