diff --git a/packages/openai-sdk-python/README.md b/packages/openai-sdk-python/README.md index 9b455b3b..cc13c9c5 100644 --- a/packages/openai-sdk-python/README.md +++ b/packages/openai-sdk-python/README.md @@ -49,7 +49,9 @@ async def main(): custom_id="chat-123", # Required: groups messages into documents mode="full", # "profile", "query", or "full" verbose=True, # Enable logging - add_memory="always" # Automatically save conversations (default) + add_memory="always", # Automatically save conversations (default) + api_key="your-supermemory-api-key", # Or use SUPERMEMORY_API_KEY + # base_url="https://api.supermemory.ai", # Optional custom endpoint ) ) @@ -357,6 +359,8 @@ class OpenAIMiddlewareOptions: verbose: bool = False # Enable detailed logging mode: Literal["profile", "query", "full"] = "profile" # Memory injection mode add_memory: Literal["always", "never"] = "always" # Auto-save behavior + api_key: Optional[str] = None # Falls back to SUPERMEMORY_API_KEY + base_url: Optional[str] = None # Falls back to SUPERMEMORY_BASE_URL ``` ### SupermemoryTools @@ -436,7 +440,7 @@ All exceptions include the original error for debugging and have descriptive err Set these environment variables: -- `SUPERMEMORY_API_KEY` - Your Supermemory API key (required) +- `SUPERMEMORY_API_KEY` - Your Supermemory API key (unless passed in middleware options) - `OPENAI_API_KEY` - Your OpenAI API key (required for examples) Optional for testing: diff --git a/packages/openai-sdk-python/src/supermemory_openai/middleware.py b/packages/openai-sdk-python/src/supermemory_openai/middleware.py index 9cbad1b0..5191a6c8 100644 --- a/packages/openai-sdk-python/src/supermemory_openai/middleware.py +++ b/packages/openai-sdk-python/src/supermemory_openai/middleware.py @@ -28,6 +28,9 @@ from .utils import ( get_last_user_message, ) +DEFAULT_SUPERMEMORY_BASE_URL = "https://api.supermemory.ai" +PROFILE_REQUEST_TIMEOUT_SECONDS = 30.0 + @dataclass class OpenAIMiddlewareOptions: @@ -38,6 +41,8 @@ class OpenAIMiddlewareOptions: verbose: bool = False mode: Literal["profile", "query", "full"] = "profile" add_memory: Literal["always", "never"] = "always" + api_key: Optional[str] = None + base_url: Optional[str] = None class SupermemoryProfileSearch: @@ -52,6 +57,7 @@ async def supermemory_profile_search( container_tag: str, query_text: str, api_key: str, + base_url: str, ) -> SupermemoryProfileSearch: """Search for memories using the SuperMemory profile API.""" payload = { @@ -59,20 +65,23 @@ async def supermemory_profile_search( } if query_text: payload["q"] = query_text + profile_url = f"{base_url.rstrip('/')}/v4/profile" try: import aiohttp - async with aiohttp.ClientSession() as session: + timeout = aiohttp.ClientTimeout(total=PROFILE_REQUEST_TIMEOUT_SECONDS) + async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post( - "https://api.supermemory.ai/v4/profile", + profile_url, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", }, json=payload, + allow_redirects=False, ) as response: - if not response.ok: + if not 200 <= response.status < 300: error_text = await response.text() raise SupermemoryAPIError( "Supermemory profile search failed", @@ -88,15 +97,17 @@ async def supermemory_profile_search( import requests response = requests.post( - "https://api.supermemory.ai/v4/profile", + profile_url, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", }, json=payload, + timeout=PROFILE_REQUEST_TIMEOUT_SECONDS, + allow_redirects=False, ) - if not response.ok: + if not 200 <= response.status_code < 300: raise SupermemoryAPIError( "Supermemory profile search failed", status_code=response.status_code, @@ -112,6 +123,7 @@ async def add_system_prompt( logger: Logger, mode: Literal["profile", "query", "full"], api_key: str, + base_url: str, ) -> list[ChatCompletionMessageParam]: """Add memory-enhanced system prompts to chat completion messages.""" system_prompt_exists = any(msg.get("role") == "system" for msg in messages) @@ -119,7 +131,10 @@ async def add_system_prompt( query_text = get_last_user_message(messages) if mode != "profile" else "" memories_response = await supermemory_profile_search( - container_tag, query_text, api_key + container_tag, + query_text, + api_key, + base_url, ) profile = memories_response.profile or {} @@ -199,9 +214,11 @@ async def add_system_prompt( 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 + ( + {**msg, "content": f"{msg.get('content', '')} \n {memories}"} + if msg.get("role") == "system" + else msg + ) for msg in messages ] @@ -222,15 +239,17 @@ async def add_memory_tool( ) -> None: """Add a new memory to the SuperMemory system.""" try: - # Handle both sync and async supermemory clients - if custom_id is None: - result = client.add(content=content, container_tag=container_tag) + kwargs = {"content": content, "container_tag": container_tag} + if custom_id is not None: + kwargs["custom_id"] = custom_id + + # The wrapper currently constructs the synchronous Supermemory client for + # both OpenAI variants. Never execute that network call on an async event + # loop; mocks or future async clients can still return an awaitable. + if inspect.iscoroutinefunction(client.add): + result = client.add(**kwargs) else: - result = client.add( - content=content, - container_tag=container_tag, - custom_id=custom_id, - ) + result = await asyncio.to_thread(client.add, **kwargs) if inspect.isawaitable(result): response = await result else: @@ -273,6 +292,8 @@ class SupermemoryOpenAIWrapper: self._container_tag: str = options.container_tag self._options: OpenAIMiddlewareOptions = options self._logger: Logger = create_logger(self._options.verbose) + self._api_key = self._resolve_api_key(options.api_key) + self._base_url = self._resolve_base_url(options.base_url) # Track background tasks to ensure they complete self._background_tasks: set[asyncio.Task] = set() @@ -283,10 +304,10 @@ class SupermemoryOpenAIWrapper: ImportError("supermemory package not installed"), ) - api_key = self._get_api_key() try: self._supermemory_client: supermemory.Supermemory = supermemory.Supermemory( - api_key=api_key + api_key=self._api_key, + base_url=self._base_url, ) except Exception as e: raise SupermemoryConfigurationError( @@ -296,16 +317,28 @@ class SupermemoryOpenAIWrapper: # 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") + @staticmethod + def _resolve_api_key(configured_api_key: Optional[str]) -> str: + """Resolve the API key once when the middleware is constructed.""" + api_key = (configured_api_key or "").strip() or ( + os.getenv("SUPERMEMORY_API_KEY") or "" + ).strip() if not api_key: raise SupermemoryConfigurationError( - "SUPERMEMORY_API_KEY environment variable is required but not set" + "A Supermemory API key is required. Pass api_key to " + "OpenAIMiddlewareOptions or set SUPERMEMORY_API_KEY." ) - return api_key + return api_key.strip() + + @staticmethod + def _resolve_base_url(configured_base_url: Optional[str]) -> str: + """Resolve and normalize the API base URL once.""" + base_url = ( + (configured_base_url or "").strip() + or (os.getenv("SUPERMEMORY_BASE_URL") or "").strip() + or DEFAULT_SUPERMEMORY_BASE_URL + ) + return base_url.rstrip("/") def _wrap_chat_completions(self) -> None: """Wrap the chat completions create method with memory injection.""" @@ -317,6 +350,7 @@ class SupermemoryOpenAIWrapper: **kwargs: Any, ) -> Any: return await self._create_with_memory_async(original_create, **kwargs) + else: def create_with_memory( @@ -413,7 +447,8 @@ class SupermemoryOpenAIWrapper: self._container_tag, self._logger, self._options.mode, - self._get_api_key(), + self._api_key, + self._base_url, ) kwargs["messages"] = enhanced_messages @@ -500,7 +535,8 @@ class SupermemoryOpenAIWrapper: self._container_tag, self._logger, self._options.mode, - self._get_api_key(), + self._api_key, + self._base_url, ) ) except RuntimeError as e: @@ -516,7 +552,8 @@ class SupermemoryOpenAIWrapper: self._container_tag, self._logger, self._options.mode, - self._get_api_key(), + self._api_key, + self._base_url, ), ) enhanced_messages = future.result() @@ -558,7 +595,9 @@ class SupermemoryOpenAIWrapper: f"Background tasks did not complete within {timeout}s timeout" ) # Cancel remaining tasks - tasks_to_cancel = [task for task in self._background_tasks if not task.done()] + tasks_to_cancel = [ + task for task in self._background_tasks if not task.done() + ] for task in tasks_to_cancel: task.cancel() diff --git a/packages/tools/README.md b/packages/tools/README.md index be27c777..056ae59c 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -272,6 +272,8 @@ import { withSupermemory } from "@supermemory/tools/openai" const openaiWithSupermemory = withSupermemory(openai, { containerTag: "user-123", // Required: identifies the user/container customId: "conversation-456", // Required: groups messages into the same document + apiKey: process.env.SUPERMEMORY_API_KEY, // Optional env fallback + baseUrl: process.env.SUPERMEMORY_BASE_URL, mode: "full", addMemory: "always", // Default: "always" verbose: true, @@ -296,6 +298,8 @@ The middleware supports the same configuration options as the AI SDK version: const openaiWithSupermemory = withSupermemory(openai, { containerTag: "user-123", // Required: identifies the user/container customId: "conversation-456", // Required: groups messages for contextual memory + apiKey: process.env.SUPERMEMORY_API_KEY, // Optional; captured per client + baseUrl: process.env.SUPERMEMORY_BASE_URL, mode: "full", // "profile" | "query" | "full" addMemory: "always", // "always" (default) | "never" verbose: true, // Enable detailed logging @@ -320,6 +324,8 @@ export async function POST(req: Request) { const openaiWithSupermemory = withSupermemory(openai, { containerTag: "user-123", customId: conversationId, + apiKey: process.env.SUPERMEMORY_API_KEY, + baseUrl: process.env.SUPERMEMORY_BASE_URL, mode: "full", addMemory: "always", verbose: true, diff --git a/packages/tools/src/conversations-client.ts b/packages/tools/src/conversations-client.ts index 03dc94b5..92f067f3 100644 --- a/packages/tools/src/conversations-client.ts +++ b/packages/tools/src/conversations-client.ts @@ -45,6 +45,8 @@ export interface AddConversationResponse { status: string } +const CONVERSATION_REQUEST_TIMEOUT_MS = 30_000 + /** * Adds a conversation to Supermemory using the /v4/conversations endpoint * @@ -89,6 +91,8 @@ export async function addConversation( metadata: params.metadata, entityContext: params.entityContext, }), + redirect: "error", + signal: AbortSignal.timeout(CONVERSATION_REQUEST_TIMEOUT_MS), }) if (!response.ok) { diff --git a/packages/tools/src/openai/index.ts b/packages/tools/src/openai/index.ts index 8923b652..0567f197 100644 --- a/packages/tools/src/openai/index.ts +++ b/packages/tools/src/openai/index.ts @@ -21,6 +21,7 @@ import { * @param options.verbose - Optional flag to enable detailed logging of memory search and injection process (default: false) * @param options.mode - Optional mode for memory search: "profile" (default), "query", or "full" * @param options.addMemory - Optional mode for memory addition: "always" (default), "never" + * @param options.apiKey - Optional Supermemory API key; falls back to SUPERMEMORY_API_KEY * * @returns An OpenAI client with SuperMemory middleware injected for both Chat Completions and Responses APIs * @@ -56,15 +57,17 @@ import { * }) * ``` * - * @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set + * @throws {Error} When neither options.apiKey nor SUPERMEMORY_API_KEY is set * @throws {Error} When supermemory API request fails */ export function withSupermemory( openaiClient: OpenAI, options: OpenAIMiddlewareOptions, ) { - if (!process.env.SUPERMEMORY_API_KEY) { - throw new Error("SUPERMEMORY_API_KEY is not set") + if (!options.apiKey?.trim() && !process.env.SUPERMEMORY_API_KEY?.trim()) { + throw new Error( + "SUPERMEMORY_API_KEY is not set — provide it via options.apiKey or set the environment variable", + ) } if (!options.containerTag) { diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index c9b8b4b8..bc4d7108 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -7,10 +7,11 @@ import { convertProfileToMarkdown } from "../vercel/util" const normalizeBaseUrl = (url?: string): string => { const defaultUrl = "https://api.supermemory.ai" - if (!url) return defaultUrl - return url.endsWith("/") ? url.slice(0, -1) : url + return url?.trim().replace(/\/+$/, "") || defaultUrl } +const PROFILE_REQUEST_TIMEOUT_MS = 30_000 + export interface OpenAIMiddlewareOptions { /** Container tag/identifier for memory search (e.g., user ID, project ID). Required. */ containerTag: string @@ -19,6 +20,8 @@ export interface OpenAIMiddlewareOptions { verbose?: boolean mode?: "profile" | "query" | "full" addMemory?: "always" | "never" + /** Supermemory API key (falls back to SUPERMEMORY_API_KEY). */ + apiKey?: string baseUrl?: string } @@ -90,6 +93,7 @@ const getLastUserMessage = ( const supermemoryProfileSearch = async ( containerTag: string, queryText: string, + apiKey: string, baseUrl: string, ): Promise => { const payload = queryText @@ -106,9 +110,11 @@ const supermemoryProfileSearch = async ( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + Authorization: `Bearer ${apiKey}`, }, body: payload, + redirect: "error", + signal: AbortSignal.timeout(PROFILE_REQUEST_TIMEOUT_MS), }) if (!response.ok) { @@ -160,6 +166,7 @@ const addSystemPrompt = async ( containerTag: string, logger: Logger, mode: "profile" | "query" | "full", + apiKey: string, baseUrl: string, ) => { const systemPromptExists = messages.some((msg) => msg.role === "system") @@ -169,6 +176,7 @@ const addSystemPrompt = async ( const memoriesResponse = await supermemoryProfileSearch( containerTag, queryText, + apiKey, baseUrl, ) @@ -400,8 +408,9 @@ const addMemoryTool = async ( * @param options.verbose - Enable detailed logging of memory operations (default: false) * @param options.mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both) (default: "profile") * @param options.addMemory - Automatic memory storage mode: "always" or "never" (default: "always") + * @param options.apiKey - Supermemory API key (falls back to SUPERMEMORY_API_KEY) * @returns Object with `wrapClient` and `createClient` methods - * @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set + * @throws {Error} When neither options.apiKey nor SUPERMEMORY_API_KEY is set * * @example * ```typescript @@ -420,9 +429,16 @@ export function createOpenAIMiddleware( options?: OpenAIMiddlewareOptions, ) { const logger = createLogger(options?.verbose ?? false) + const apiKey = + options?.apiKey?.trim() || process.env.SUPERMEMORY_API_KEY?.trim() || "" + if (!apiKey) { + throw new Error( + "SUPERMEMORY_API_KEY is not set — provide it via options.apiKey or set the environment variable", + ) + } const baseUrl = normalizeBaseUrl(options?.baseUrl) const client = new Supermemory({ - apiKey: process.env.SUPERMEMORY_API_KEY, + apiKey, ...(baseUrl !== "https://api.supermemory.ai" ? { baseURL: baseUrl } : {}), }) @@ -456,6 +472,7 @@ export function createOpenAIMiddleware( const memoriesResponse = await supermemoryProfileSearch( containerTag, queryText, + apiKey, baseUrl, ) @@ -523,6 +540,7 @@ export function createOpenAIMiddleware( const createResponsesWithMemory = async ( params: Parameters[0], + requestOptions?: OpenAI.RequestOptions, ) => { if (!originalResponsesCreate) { throw new Error( @@ -534,7 +552,11 @@ export function createOpenAIMiddleware( if (mode !== "profile" && !input) { logger.debug("No input found for Responses API, skipping memory search") - return originalResponsesCreate.call(openaiClient.responses, params) + return originalResponsesCreate.call( + openaiClient.responses, + params, + requestOptions, + ) } logger.info("Starting memory search for Responses API", { @@ -572,14 +594,19 @@ export function createOpenAIMiddleware( ? `${params.instructions || ""}\n\n${memories}`.trim() : params.instructions - return originalResponsesCreate.call(openaiClient.responses, { - ...params, - instructions: enhancedInstructions, - }) + return originalResponsesCreate.call( + openaiClient.responses, + { + ...params, + instructions: enhancedInstructions, + }, + requestOptions, + ) } const createWithMemory = async ( params: OpenAI.Chat.Completions.ChatCompletionCreateParams, + requestOptions?: OpenAI.RequestOptions, ) => { const messages = Array.isArray(params.messages) ? params.messages : [] @@ -587,7 +614,11 @@ export function createOpenAIMiddleware( const userMessage = getLastUserMessage(messages) if (!userMessage) { logger.debug("No user message found, skipping memory search") - return originalCreate.call(openaiClient.chat.completions, params) + return originalCreate.call( + openaiClient.chat.completions, + params, + requestOptions, + ) } } @@ -615,7 +646,7 @@ export function createOpenAIMiddleware( memoryCustomId, logger, messages, - process.env.SUPERMEMORY_API_KEY, + apiKey, baseUrl, ), ) @@ -623,16 +654,20 @@ export function createOpenAIMiddleware( } operations.push( - addSystemPrompt(messages, containerTag, logger, mode, baseUrl), + addSystemPrompt(messages, containerTag, logger, mode, apiKey, baseUrl), ) const results = await Promise.all(operations) const enhancedMessages = results[results.length - 1] // Enhanced messages result is always last - return originalCreate.call(openaiClient.chat.completions, { - ...params, - messages: enhancedMessages, - }) + return originalCreate.call( + openaiClient.chat.completions, + { + ...params, + messages: enhancedMessages, + }, + requestOptions, + ) } openaiClient.chat.completions.create =