diff --git a/packages/tools/CHANGELOG.md b/packages/tools/CHANGELOG.md new file mode 100644 index 00000000..92c0669d --- /dev/null +++ b/packages/tools/CHANGELOG.md @@ -0,0 +1,9 @@ +# @supermemory/tools changelog + +## 2.1.2 - 2026-07-18 + +- Fix OpenAI middleware to accept programmatic `apiKey` and `baseUrl` options. +- Make OpenAI middleware fail open when Supermemory memory retrieval fails for Chat Completions or Responses API calls. +- Align OpenAI middleware `addMemory` default to `never` and document explicit `always` opt-in for auto-save. +- Preserve query-mode search results that overlap profile memories. +- Add focused OpenAI middleware regression coverage. diff --git a/packages/tools/README.md b/packages/tools/README.md index 69856f69..904b7d7a 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -272,7 +272,7 @@ const openaiWithSupermemory = withSupermemory(openai, { containerTag: "user-123", // Required: identifies the user/container customId: "conversation-456", // Required: groups messages into the same document mode: "full", - addMemory: "always", // Default: "always" + addMemory: "always", // Default: "never"; set "always" to auto-save verbose: true, }) @@ -296,7 +296,7 @@ const openaiWithSupermemory = withSupermemory(openai, { containerTag: "user-123", // Required: identifies the user/container customId: "conversation-456", // Required: groups messages for contextual memory mode: "full", // "profile" | "query" | "full" - addMemory: "always", // "always" (default) | "never" + addMemory: "always", // "always" | "never" (default) verbose: true, // Enable detailed logging }) ``` @@ -651,7 +651,7 @@ interface WithSupermemoryOptions { customId: string // Required: groups messages into the same document verbose?: boolean mode?: "profile" | "query" | "full" - addMemory?: "always" | "never" // Default: "always" + addMemory?: "always" | "never" // OpenAI default: "never"; AI SDK default: "always" /** Optional Supermemory API key. Use this in browser environments. */ apiKey?: string baseUrl?: string @@ -664,7 +664,7 @@ interface WithSupermemoryOptions { - **customId**: Required. Custom ID to group messages into a single document for contextual memory generation - **verbose**: Enable detailed logging of memory search and injection process (default: false) - **mode**: Memory search mode - "profile" (default), "query", or "full" -- **addMemory**: Automatic memory storage mode - "always" (default) or "never" +- **addMemory**: Automatic memory storage mode - "always" or "never". OpenAI middleware defaults to "never"; AI SDK middleware defaults to "always". - **skipMemoryOnError**: If memory retrieval fails or hits the internal timeout, continue with the original prompt (default: true) ## Available Tools diff --git a/packages/tools/package.json b/packages/tools/package.json index 59289f3d..284dfeaa 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,7 +1,7 @@ { "name": "@supermemory/tools", "type": "module", - "version": "2.1.1", + "version": "2.1.2", "description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory", "scripts": { "build": "tsdown", diff --git a/packages/tools/src/openai/index.ts b/packages/tools/src/openai/index.ts index 8923b652..9780a798 100644 --- a/packages/tools/src/openai/index.ts +++ b/packages/tools/src/openai/index.ts @@ -20,7 +20,9 @@ import { * @param options.customId - Required. Custom ID to group messages into a single document for contextual memory generation * @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.addMemory - Optional mode for memory addition: "always", "never" (default) + * @param options.apiKey - Optional Supermemory API key to use instead of the environment variable + * @param options.baseUrl - Optional base URL for the Supermemory API (default: "https://api.supermemory.ai") * * @returns An OpenAI client with SuperMemory middleware injected for both Chat Completions and Responses APIs * @@ -56,15 +58,18 @@ import { * }) * ``` * - * @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set - * @throws {Error} When supermemory API request fails + * @throws {Error} When neither `options.apiKey` nor `process.env.SUPERMEMORY_API_KEY` are set */ export function withSupermemory( openaiClient: OpenAI, options: OpenAIMiddlewareOptions, ) { - if (!process.env.SUPERMEMORY_API_KEY) { - throw new Error("SUPERMEMORY_API_KEY is not set") + const apiKey = options.apiKey ?? process.env.SUPERMEMORY_API_KEY + + if (!apiKey) { + throw new Error( + "SUPERMEMORY_API_KEY is not set — provide it via `options.apiKey` or set `process.env.SUPERMEMORY_API_KEY`", + ) } if (!options.containerTag) { @@ -82,13 +87,14 @@ export function withSupermemory( const { containerTag } = options const verbose = options.verbose ?? false const mode = options.mode ?? "profile" - const addMemory = options.addMemory ?? "always" + const addMemory = options.addMemory ?? "never" const openaiWithSupermemory = createOpenAIMiddleware( openaiClient, containerTag, { ...options, + apiKey, verbose, mode, addMemory, diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index 7fc4267e..6a0323f1 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -19,6 +19,7 @@ export interface OpenAIMiddlewareOptions { verbose?: boolean mode?: "profile" | "query" | "full" addMemory?: "always" | "never" + apiKey?: string baseUrl?: string } @@ -32,6 +33,48 @@ interface SupermemoryProfileSearch { } } +const formatMemoriesForInjection = ( + memoriesResponse: SupermemoryProfileSearch, + mode: "profile" | "query" | "full", + context: "chat" | "responses", +) => { + const deduplicated = deduplicateMemories({ + static: memoriesResponse.profile.static, + dynamic: memoriesResponse.profile.dynamic, + searchResults: + mode === "query" ? [] : memoriesResponse.searchResults?.results, + }) + const searchResultsForPrompt = + mode === "query" + ? deduplicateMemories({ + searchResults: memoriesResponse.searchResults?.results, + }).searchResults + : deduplicated.searchResults + + const profileData = + mode !== "query" + ? convertProfileToMarkdown({ + profile: { + static: deduplicated.static, + dynamic: deduplicated.dynamic, + }, + searchResults: { results: [] }, + }) + : "" + const searchResultsMemories = + mode !== "profile" + ? `Search results for user's ${context === "chat" ? "recent message" : "input"}: \n${searchResultsForPrompt + .map((memory) => `- ${memory}`) + .join("\n")}` + : "" + + return { + memories: `${profileData}\n${searchResultsMemories}`.trim(), + deduplicated, + searchResultsForPrompt, + } +} + /** * Extracts the last user message from an array of chat completion messages. * @@ -91,6 +134,7 @@ const supermemoryProfileSearch = async ( containerTag: string, queryText: string, baseUrl: string, + apiKey: string, ): Promise => { const payload = queryText ? JSON.stringify({ @@ -106,7 +150,7 @@ const supermemoryProfileSearch = async ( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + Authorization: `Bearer ${apiKey}`, }, body: payload, }) @@ -161,6 +205,7 @@ const addSystemPrompt = async ( logger: Logger, mode: "profile" | "query" | "full", baseUrl: string, + apiKey: string, ) => { const systemPromptExists = messages.some((msg) => msg.role === "system") @@ -170,6 +215,7 @@ const addSystemPrompt = async ( containerTag, queryText, baseUrl, + apiKey, ) const memoryCountStatic = memoriesResponse.profile.static?.length || 0 @@ -184,11 +230,8 @@ const addSystemPrompt = async ( mode, }) - const deduplicated = deduplicateMemories({ - static: memoriesResponse.profile.static, - dynamic: memoriesResponse.profile.dynamic, - searchResults: memoriesResponse.searchResults?.results, - }) + const { memories, deduplicated, searchResultsForPrompt } = + formatMemoriesForInjection(memoriesResponse, mode, "chat") logger.debug("Memory deduplication completed for chat API", { static: { @@ -201,29 +244,10 @@ const addSystemPrompt = async ( }, searchResults: { original: memoriesResponse.searchResults?.results?.length, - deduplicated: deduplicated.searchResults.length, + deduplicated: searchResultsForPrompt.length, }, }) - const profileData = - mode !== "query" - ? convertProfileToMarkdown({ - profile: { - static: deduplicated.static, - dynamic: deduplicated.dynamic, - }, - searchResults: { results: [] }, - }) - : "" - const searchResultsMemories = - mode !== "profile" - ? `Search results for user's recent message: \n${deduplicated.searchResults - .map((memory) => `- ${memory}`) - .join("\n")}` - : "" - - const memories = `${profileData}\n${searchResultsMemories}`.trim() - if (memories) { logger.debug("Memory content preview for chat API", { content: memories, @@ -400,6 +424,8 @@ 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: "never") + * @param options.apiKey - Optional Supermemory API key to use instead of the environment variable + * @param options.baseUrl - Optional base URL for the Supermemory API (default: "https://api.supermemory.ai") * @returns Object with `wrapClient` and `createClient` methods * @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set * @@ -420,15 +446,23 @@ export function createOpenAIMiddleware( options?: OpenAIMiddlewareOptions, ) { const logger = createLogger(options?.verbose ?? false) + const apiKey = options?.apiKey ?? process.env.SUPERMEMORY_API_KEY + + if (!apiKey) { + throw new Error( + "SUPERMEMORY_API_KEY is not set — provide it via `options.apiKey` or set `process.env.SUPERMEMORY_API_KEY`", + ) + } + const baseUrl = normalizeBaseUrl(options?.baseUrl) const client = new Supermemory({ - apiKey: process.env.SUPERMEMORY_API_KEY, + apiKey, ...(baseUrl !== "https://api.supermemory.ai" ? { baseURL: baseUrl } : {}), }) const customId = options?.customId const mode = options?.mode ?? "profile" - const addMemory = options?.addMemory ?? "always" + const addMemory = options?.addMemory ?? "never" const originalCreate = openaiClient.chat.completions.create const originalResponsesCreate = openaiClient.responses?.create @@ -457,6 +491,7 @@ export function createOpenAIMiddleware( containerTag, queryText, baseUrl, + apiKey, ) const memoryCountStatic = memoriesResponse.profile.static?.length || 0 @@ -471,11 +506,8 @@ export function createOpenAIMiddleware( mode, }) - const deduplicated = deduplicateMemories({ - static: memoriesResponse.profile.static, - dynamic: memoriesResponse.profile.dynamic, - searchResults: memoriesResponse.searchResults?.results, - }) + const { memories, deduplicated, searchResultsForPrompt } = + formatMemoriesForInjection(memoriesResponse, mode, context) logger.debug(`Memory deduplication completed for ${context} API`, { static: { @@ -488,29 +520,10 @@ export function createOpenAIMiddleware( }, searchResults: { original: memoriesResponse.searchResults?.results?.length, - deduplicated: deduplicated.searchResults.length, + deduplicated: searchResultsForPrompt.length, }, }) - const profileData = - mode !== "query" - ? convertProfileToMarkdown({ - profile: { - static: deduplicated.static, - dynamic: deduplicated.dynamic, - }, - searchResults: { results: [] }, - }) - : "" - const searchResultsMemories = - mode !== "profile" - ? `Search results for user's ${context === "chat" ? "recent message" : "input"}: \n${deduplicated.searchResults - .map((memory) => `- ${memory}`) - .join("\n")}` - : "" - - const memories = `${profileData}\n${searchResultsMemories}`.trim() - if (memories) { logger.debug(`Memory content preview for ${context} API`, { content: memories, @@ -565,17 +578,25 @@ export function createOpenAIMiddleware( ), ) - const results = await Promise.all(operations) - const memories = results[results.length - 1] // Memory search result is always last + try { + const results = await Promise.all(operations) + const memories = results[results.length - 1] + if (memories) { + return originalResponsesCreate.call(openaiClient.responses, { + ...params, + instructions: `${params.instructions || ""}\n\n${memories}`.trim(), + }) + } + } catch (error) { + logger.warn( + "Supermemory retrieval failed; continuing without injected memories", + { + error: error instanceof Error ? error.message : "Unknown error", + }, + ) + } - const enhancedInstructions = memories - ? `${params.instructions || ""}\n\n${memories}`.trim() - : params.instructions - - return originalResponsesCreate.call(openaiClient.responses, { - ...params, - instructions: enhancedInstructions, - }) + return originalResponsesCreate.call(openaiClient.responses, params) } const createWithMemory = async ( @@ -615,7 +636,7 @@ export function createOpenAIMiddleware( memoryCustomId, logger, messages, - process.env.SUPERMEMORY_API_KEY, + apiKey, baseUrl, ), ) @@ -623,11 +644,23 @@ export function createOpenAIMiddleware( } operations.push( - addSystemPrompt(messages, containerTag, logger, mode, baseUrl), + addSystemPrompt(messages, containerTag, logger, mode, baseUrl, apiKey), ) - const results = await Promise.all(operations) - const enhancedMessages = results[results.length - 1] // Enhanced messages result is always last + let enhancedMessages = messages + try { + const results = await Promise.all(operations) + enhancedMessages = results[ + results.length - 1 + ] as OpenAI.Chat.Completions.ChatCompletionMessageParam[] + } catch (error) { + logger.warn( + "Supermemory retrieval failed; continuing without injected memories", + { + error: error instanceof Error ? error.message : "Unknown error", + }, + ) + } return originalCreate.call(openaiClient.chat.completions, { ...params, diff --git a/packages/tools/test/openai-middleware.unit.test.ts b/packages/tools/test/openai-middleware.unit.test.ts new file mode 100644 index 00000000..d30fab91 --- /dev/null +++ b/packages/tools/test/openai-middleware.unit.test.ts @@ -0,0 +1,248 @@ +import type OpenAI from "openai" +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from "vitest" +import { withSupermemory } from "../src/openai" + +const originalEnv = process.env.SUPERMEMORY_API_KEY +const originalFetch = globalThis.fetch + +const createMockProfileResponse = ({ + staticMemories = [], + dynamicMemories = [], + searchResults = [], +}: { + staticMemories?: string[] + dynamicMemories?: string[] + searchResults?: string[] +} = {}) => ({ + profile: { + static: staticMemories.map((memory) => ({ memory })), + dynamic: dynamicMemories.map((memory) => ({ memory })), + }, + searchResults: { + results: searchResults.map((memory) => ({ memory })), + }, +}) + +const createMockOpenAIClient = () => + ({ + chat: { + completions: { + create: vi.fn(async (params) => ({ params })), + }, + }, + responses: { + create: vi.fn(async (params) => ({ params })), + }, + }) as unknown as OpenAI & { + chat: { completions: { create: Mock } } + responses: { create: Mock } + } + +describe("OpenAI withSupermemory middleware", () => { + beforeEach(() => { + vi.clearAllMocks() + delete process.env.SUPERMEMORY_API_KEY + }) + + afterEach(() => { + if (originalEnv) { + process.env.SUPERMEMORY_API_KEY = originalEnv + } else { + delete process.env.SUPERMEMORY_API_KEY + } + globalThis.fetch = originalFetch + }) + + it("uses programmatic apiKey and baseUrl without requiring env auth", async () => { + const fetchMock = vi.fn(async () => + Response.json( + createMockProfileResponse({ searchResults: ["Custom API memory"] }), + ), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + const client = createMockOpenAIClient() + const originalCreate = client.chat.completions.create + + const wrapped = withSupermemory(client, { + containerTag: "user-123", + customId: "conversation-123", + mode: "query", + apiKey: "programmatic-key", + baseUrl: "https://api.example.com/", + }) + + await wrapped.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "what do I prefer?" }], + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ] + expect(url).toBe("https://api.example.com/v4/profile") + expect(init?.headers).toMatchObject({ + Authorization: "Bearer programmatic-key", + }) + expect(originalCreate).toHaveBeenCalledTimes(1) + const enhancedParams = originalCreate.mock.calls[0]?.[0] + expect(enhancedParams.messages[0]).toMatchObject({ + role: "system", + content: expect.stringContaining("Custom API memory"), + }) + }) + + it("uses programmatic apiKey and baseUrl for Responses API", async () => { + const fetchMock = vi.fn(async () => + Response.json( + createMockProfileResponse({ searchResults: ["Responses API memory"] }), + ), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + const client = createMockOpenAIClient() + const originalResponsesCreate = client.responses.create + + const wrapped = withSupermemory(client, { + containerTag: "user-123", + customId: "conversation-123", + mode: "query", + apiKey: "programmatic-key", + baseUrl: "https://api.example.com/", + }) + + await wrapped.responses.create({ + model: "gpt-4o-mini", + instructions: "Be helpful", + input: "what do I prefer?", + }) + + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ] + expect(url).toBe("https://api.example.com/v4/profile") + expect(init?.headers).toMatchObject({ + Authorization: "Bearer programmatic-key", + }) + expect(originalResponsesCreate).toHaveBeenCalledWith( + expect.objectContaining({ + instructions: expect.stringContaining("Responses API memory"), + }), + ) + }) + + it("defaults addMemory to never", async () => { + const fetchMock = vi.fn(async () => + Response.json( + createMockProfileResponse({ searchResults: ["Retrieved only"] }), + ), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + const client = createMockOpenAIClient() + + const wrapped = withSupermemory(client, { + containerTag: "user-123", + customId: "conversation-123", + mode: "query", + apiKey: "programmatic-key", + }) + + await wrapped.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "hello" }], + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const firstFetchCall = fetchMock.mock.calls[0] as unknown[] | undefined + expect(String(firstFetchCall?.[0])).toContain("/v4/profile") + }) + + it("fails open when profile retrieval fails", async () => { + const fetchMock = vi.fn(async () => new Response("down", { status: 503 })) + globalThis.fetch = fetchMock as unknown as typeof fetch + const client = createMockOpenAIClient() + const originalCreate = client.chat.completions.create + const originalParams = { + model: "gpt-4o-mini", + messages: [{ role: "user" as const, content: "hello" }], + } + + const wrapped = withSupermemory(client, { + containerTag: "user-123", + customId: "conversation-123", + mode: "query", + apiKey: "programmatic-key", + }) + + await expect( + wrapped.chat.completions.create(originalParams), + ).resolves.toEqual({ + params: originalParams, + }) + expect(originalCreate).toHaveBeenCalledWith(originalParams) + }) + + it("fails open for Responses API when profile retrieval fails", async () => { + globalThis.fetch = vi.fn( + async () => new Response("down", { status: 503 }), + ) as unknown as typeof fetch + const client = createMockOpenAIClient() + const originalResponsesCreate = client.responses.create + const originalParams = { + model: "gpt-4o-mini", + instructions: "Be helpful", + input: "hello", + } + + const wrapped = withSupermemory(client, { + containerTag: "user-123", + customId: "conversation-123", + mode: "query", + apiKey: "programmatic-key", + }) + + await expect(wrapped.responses.create(originalParams)).resolves.toEqual({ + params: originalParams, + }) + expect(originalResponsesCreate).toHaveBeenCalledWith(originalParams) + }) + + it("keeps query search hits that overlap profile memories", async () => { + globalThis.fetch = vi.fn(async () => + Response.json( + createMockProfileResponse({ + staticMemories: ["User likes TypeScript"], + searchResults: ["User likes TypeScript", "User uses Bun"], + }), + ), + ) as unknown as typeof fetch + const client = createMockOpenAIClient() + const originalCreate = client.chat.completions.create + + const wrapped = withSupermemory(client, { + containerTag: "user-123", + customId: "conversation-123", + mode: "query", + apiKey: "programmatic-key", + }) + + await wrapped.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "what language do I like?" }], + }) + + const enhancedParams = originalCreate.mock.calls[0]?.[0] + const systemPrompt = enhancedParams.messages[0].content + expect(systemPrompt).toContain("- User likes TypeScript") + expect(systemPrompt).toContain("- User uses Bun") + }) +})