diff --git a/packages/tools/src/openai/index.ts b/packages/tools/src/openai/index.ts index 8923b652c..e8808d1ea 100644 --- a/packages/tools/src/openai/index.ts +++ b/packages/tools/src/openai/index.ts @@ -1,4 +1,5 @@ import type OpenAI from "openai" +import { validateApiKey } from "../shared" import { createOpenAIMiddleware, type OpenAIMiddlewareOptions, @@ -21,6 +22,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 to use instead of the SUPERMEMORY_API_KEY environment variable * * @returns An OpenAI client with SuperMemory middleware injected for both Chat Completions and Responses APIs * @@ -56,16 +58,16 @@ import { * }) * ``` * - * @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set + * @throws {Error} When neither `options.apiKey` nor `process.env.SUPERMEMORY_API_KEY` are 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") - } + // Validated here (rather than only inside the middleware) so a missing key + // fails fast, before any client is wrapped. + validateApiKey(options.apiKey) if (!options.containerTag) { throw new Error( diff --git a/packages/tools/src/openai/middleware.test.ts b/packages/tools/src/openai/middleware.test.ts new file mode 100644 index 000000000..5380cf27c --- /dev/null +++ b/packages/tools/src/openai/middleware.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +// Mock the Supermemory SDK (same pattern as tool-operations.test.ts) so the +// middleware can be exercised without network access. +vi.mock("supermemory", () => { + return { + default: class MockSupermemory { + add = vi.fn() + search = { execute: vi.fn() } + documents = { add: vi.fn(), delete: vi.fn(), list: vi.fn() } + }, + } +}) + +import { withSupermemory } from "./index" + +const OPTIONS_API_KEY = "sm_key_from_options" + +/** Minimal stand-in for an OpenAI client — the middleware only reassigns `create`. */ +function createFakeOpenAIClient() { + const create = vi.fn().mockResolvedValue({ id: "chatcmpl_1" }) + return { + chat: { completions: { create } }, + } as never +} + +/** Stubs `/v4/profile` and records the requests the middleware makes. */ +function mockProfileFetch() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + profile: { static: [{ memory: "User likes TypeScript" }], dynamic: [] }, + searchResults: { results: [] }, + }), + }) + vi.stubGlobal("fetch", fetchMock) + return fetchMock +} + +let originalEnvKey: string | undefined + +beforeEach(() => { + originalEnvKey = process.env.SUPERMEMORY_API_KEY + delete process.env.SUPERMEMORY_API_KEY +}) + +afterEach(() => { + if (originalEnvKey === undefined) { + delete process.env.SUPERMEMORY_API_KEY + } else { + process.env.SUPERMEMORY_API_KEY = originalEnvKey + } + vi.unstubAllGlobals() +}) + +describe("withSupermemory (openai) API key", () => { + it("accepts an API key from options when the env var is not set", () => { + mockProfileFetch() + + expect(() => + withSupermemory(createFakeOpenAIClient(), { + containerTag: "user-123", + customId: "conversation-456", + apiKey: OPTIONS_API_KEY, + }), + ).not.toThrow() + }) + + it("authenticates the profile request with the API key from options", async () => { + const fetchMock = mockProfileFetch() + + const client = withSupermemory(createFakeOpenAIClient(), { + containerTag: "user-123", + customId: "conversation-456", + apiKey: OPTIONS_API_KEY, + addMemory: "never", + }) + + await client.chat.completions.create({ + model: "gpt-4", + messages: [{ role: "user", content: "what do I like?" }], + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain("/v4/profile") + expect((init.headers as Record).Authorization).toBe( + `Bearer ${OPTIONS_API_KEY}`, + ) + }) + + it("falls back to the SUPERMEMORY_API_KEY env var", async () => { + const fetchMock = mockProfileFetch() + process.env.SUPERMEMORY_API_KEY = "sm_key_from_env" + + const client = withSupermemory(createFakeOpenAIClient(), { + containerTag: "user-123", + customId: "conversation-456", + addMemory: "never", + }) + + await client.chat.completions.create({ + model: "gpt-4", + messages: [{ role: "user", content: "what do I like?" }], + }) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect((init.headers as Record).Authorization).toBe( + "Bearer sm_key_from_env", + ) + }) + + it("throws when neither options.apiKey nor the env var is set", () => { + expect(() => + withSupermemory(createFakeOpenAIClient(), { + containerTag: "user-123", + customId: "conversation-456", + }), + ).toThrow("SUPERMEMORY_API_KEY is not set") + }) +}) diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index c9b8b4b88..01dc33982 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -1,6 +1,7 @@ import type OpenAI from "openai" import Supermemory from "supermemory" import { addConversation } from "../conversations-client" +import { validateApiKey } from "../shared" import { deduplicateMemoriesForMode } from "../tools-shared" import { createLogger, type Logger } from "../vercel/logger" import { convertProfileToMarkdown } from "../vercel/util" @@ -20,6 +21,8 @@ export interface OpenAIMiddlewareOptions { mode?: "profile" | "query" | "full" addMemory?: "always" | "never" baseUrl?: string + /** Supermemory API key (falls back to SUPERMEMORY_API_KEY env var) */ + apiKey?: string } interface SupermemoryProfileSearch { @@ -75,22 +78,25 @@ const getLastUserMessage = ( * * @param containerTag - The container tag/identifier for memory search (e.g., user ID, project ID) * @param queryText - Optional query text to search for specific memories. If empty, returns all profile memories + * @param baseUrl - The Supermemory API base URL + * @param apiKey - The Supermemory API key used to authenticate the request * @returns Promise that resolves to the SuperMemory profile search response * @throws {Error} When the API request fails or returns an error status * * @example * ```typescript * // Search with query - * const results = await supermemoryProfileSearch("user-123", "favorite programming language") + * const results = await supermemoryProfileSearch("user-123", "favorite programming language", baseUrl, apiKey) * * // Get all profile memories - * const profile = await supermemoryProfileSearch("user-123", "") + * const profile = await supermemoryProfileSearch("user-123", "", baseUrl, apiKey) * ``` */ const supermemoryProfileSearch = async ( containerTag: string, queryText: string, baseUrl: string, + apiKey: string, ): Promise => { const payload = queryText ? JSON.stringify({ @@ -106,7 +112,7 @@ const supermemoryProfileSearch = async ( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + Authorization: `Bearer ${apiKey}`, }, body: payload, }) @@ -138,6 +144,8 @@ const supermemoryProfileSearch = async ( * @param containerTag - The container tag/identifier for memory search * @param logger - Logger instance for debugging and info output * @param mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both) + * @param baseUrl - The Supermemory API base URL + * @param apiKey - The Supermemory API key used to authenticate the request * @returns Promise that resolves to enhanced messages with memory-injected system prompt * * @example @@ -150,7 +158,9 @@ const supermemoryProfileSearch = async ( * messages, * "user-123", * logger, - * "full" + * "full", + * baseUrl, + * apiKey * ) * // Returns messages with system prompt containing relevant memories * ``` @@ -161,6 +171,7 @@ const addSystemPrompt = async ( logger: Logger, mode: "profile" | "query" | "full", baseUrl: string, + apiKey: string, ) => { const systemPromptExists = messages.some((msg) => msg.role === "system") @@ -170,6 +181,7 @@ const addSystemPrompt = async ( containerTag, queryText, baseUrl, + apiKey, ) const memoryCountStatic = memoriesResponse.profile.static?.length || 0 @@ -400,8 +412,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 to use instead of the SUPERMEMORY_API_KEY environment variable * @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 `process.env.SUPERMEMORY_API_KEY` are set * * @example * ```typescript @@ -421,8 +434,9 @@ export function createOpenAIMiddleware( ) { const logger = createLogger(options?.verbose ?? false) const baseUrl = normalizeBaseUrl(options?.baseUrl) + const apiKey = validateApiKey(options?.apiKey) const client = new Supermemory({ - apiKey: process.env.SUPERMEMORY_API_KEY, + apiKey, ...(baseUrl !== "https://api.supermemory.ai" ? { baseURL: baseUrl } : {}), }) @@ -457,6 +471,7 @@ export function createOpenAIMiddleware( containerTag, queryText, baseUrl, + apiKey, ) const memoryCountStatic = memoriesResponse.profile.static?.length || 0 @@ -615,7 +630,7 @@ export function createOpenAIMiddleware( memoryCustomId, logger, messages, - process.env.SUPERMEMORY_API_KEY, + apiKey, baseUrl, ), ) @@ -623,7 +638,7 @@ export function createOpenAIMiddleware( } operations.push( - addSystemPrompt(messages, containerTag, logger, mode, baseUrl), + addSystemPrompt(messages, containerTag, logger, mode, baseUrl, apiKey), ) const results = await Promise.all(operations)