mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
b257524024
commit
ea9bf13d31
10 changed files with 3705 additions and 22 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
640
packages/openai-sdk-python/src/supermemory_openai/middleware.py
Normal file
640
packages/openai-sdk-python/src/supermemory_openai/middleware.py
Normal 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)
|
||||
|
|
@ -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}",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
230
packages/openai-sdk-python/src/supermemory_openai/utils.py
Normal file
230
packages/openai-sdk-python/src/supermemory_openai/utils.py
Normal 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)
|
||||
197
packages/openai-sdk-python/test_integration.py
Normal file
197
packages/openai-sdk-python/test_integration.py
Normal 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())
|
||||
725
packages/openai-sdk-python/tests/test_middleware.py
Normal file
725
packages/openai-sdk-python/tests/test_middleware.py
Normal 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
|
||||
1555
packages/openai-sdk-python/uv.lock
generated
1555
packages/openai-sdk-python/uv.lock
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue