From a9923a7cec7a57e10eb71041238f7aa256fe9f86 Mon Sep 17 00:00:00 2001 From: abhinav7x94 Date: Sun, 16 Aug 2026 07:46:43 +0530 Subject: [PATCH] fix(tools): isolate OpenAI middleware clients --- packages/tools/README.md | 2 +- packages/tools/src/openai/index.ts | 2 +- .../middleware.client-isolation.test.ts | 317 ++++++++++++++++++ packages/tools/src/openai/middleware.ts | 101 ++++-- 4 files changed, 398 insertions(+), 24 deletions(-) create mode 100644 packages/tools/src/openai/middleware.client-isolation.test.ts diff --git a/packages/tools/README.md b/packages/tools/README.md index 69856f69..ec2a77aa 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -262,7 +262,7 @@ The `MemoryPromptData` object provides: #### OpenAI Middleware with Supermemory -The `withSupermemory` function creates an OpenAI client with SuperMemory middleware automatically injected: +The `withSupermemory` function creates an isolated middleware facade around an OpenAI client. It does not mutate the supplied client, so a shared base client can safely be wrapped with different user or conversation options: ```typescript import { withSupermemory } from "@supermemory/tools/openai" diff --git a/packages/tools/src/openai/index.ts b/packages/tools/src/openai/index.ts index 8923b652..792d3170 100644 --- a/packages/tools/src/openai/index.ts +++ b/packages/tools/src/openai/index.ts @@ -22,7 +22,7 @@ import { * @param options.mode - Optional mode for memory search: "profile" (default), "query", or "full" * @param options.addMemory - Optional mode for memory addition: "always" (default), "never" * - * @returns An OpenAI client with SuperMemory middleware injected for both Chat Completions and Responses APIs + * @returns A distinct OpenAI middleware facade for Chat Completions and Responses APIs. The supplied client is not mutated. * * @example * ```typescript diff --git a/packages/tools/src/openai/middleware.client-isolation.test.ts b/packages/tools/src/openai/middleware.client-isolation.test.ts new file mode 100644 index 00000000..a0966f0d --- /dev/null +++ b/packages/tools/src/openai/middleware.client-isolation.test.ts @@ -0,0 +1,317 @@ +import OpenAI from "openai" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { withSupermemory } from "./index" + +const MEMORY_BASE_URL = "https://memory.test" + +type CapturedCall = { + params: unknown + requestOptions: unknown + receiver: unknown +} + +function createBaseClient() { + const client = new OpenAI({ apiKey: "openai-test-key" }) + const chatCalls: CapturedCall[] = [] + const responseCalls: CapturedCall[] = [] + + const chatCreate = function ( + this: unknown, + params: unknown, + requestOptions?: unknown, + ) { + chatCalls.push({ params, receiver: this, requestOptions }) + return Promise.resolve({ choices: [], id: "chat-response" }) + } + const responsesCreate = function ( + this: unknown, + params: unknown, + requestOptions?: unknown, + ) { + responseCalls.push({ params, receiver: this, requestOptions }) + return Promise.resolve({ id: "responses-response", output: [] }) + } + + Object.defineProperty(client.chat.completions, "create", { + configurable: true, + value: chatCreate, + writable: true, + }) + Object.defineProperty(client.responses, "create", { + configurable: true, + value: responsesCreate, + writable: true, + }) + + return { + chatCalls, + chatCreate, + client, + responseCalls, + responsesCreate, + } +} + +function wrapClient( + client: OpenAI, + containerTag: string, + customId = containerTag, + addMemory: "always" | "never" = "never", +) { + return withSupermemory(client, { + addMemory, + baseUrl: MEMORY_BASE_URL, + containerTag, + customId, + mode: "profile", + }) +} + +function systemContent(call: CapturedCall) { + const params = + call.params as OpenAI.Chat.Completions.ChatCompletionCreateParams + const systemMessage = params.messages.find( + (message) => message.role === "system", + ) + return typeof systemMessage?.content === "string" ? systemMessage.content : "" +} + +function callAt(calls: CapturedCall[], index: number) { + const call = calls[index] + if (!call) throw new Error(`Expected captured call at index ${index}`) + return call +} + +describe("OpenAI middleware client isolation", () => { + const originalApiKey = process.env.SUPERMEMORY_API_KEY + let profileTags: string[] + let conversations: Array<{ + containerTags?: string[] + conversationId?: string + }> + + beforeEach(() => { + process.env.SUPERMEMORY_API_KEY = "supermemory-test-key" + profileTags = [] + conversations = [] + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit) => { + const url = String(input) + const body = init?.body ? JSON.parse(String(init.body)) : {} + + if (url === `${MEMORY_BASE_URL}/v4/profile`) { + const containerTag = String(body.containerTag) + profileTags.push(containerTag) + return Response.json({ + profile: { + dynamic: [], + static: [{ memory: `secret-${containerTag}` }], + }, + searchResults: { results: [] }, + }) + } + + if (url === `${MEMORY_BASE_URL}/v4/conversations`) { + conversations.push(body) + return Response.json({ + conversationId: body.conversationId, + id: "memory-id", + status: "queued", + }) + } + + throw new Error(`Unexpected fetch: ${url}`) + }), + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (originalApiKey === undefined) { + delete process.env.SUPERMEMORY_API_KEY + } else { + process.env.SUPERMEMORY_API_KEY = originalApiKey + } + }) + + it("leaves prototype-provided SDK create methods on the base client", () => { + const client = new OpenAI({ apiKey: "openai-test-key" }) + const chatCreate = client.chat.completions.create + const responsesCreate = client.responses.create + + expect(Object.hasOwn(client.chat.completions, "create")).toBe(false) + expect(Object.hasOwn(client.responses, "create")).toBe(false) + + const wrapped = wrapClient(client, "tenant-a") + + expect(Object.hasOwn(client.chat.completions, "create")).toBe(false) + expect(Object.hasOwn(client.responses, "create")).toBe(false) + expect(client.chat.completions.create).toBe(chatCreate) + expect(client.responses.create).toBe(responsesCreate) + expect(Object.hasOwn(wrapped.chat.completions, "create")).toBe(true) + expect(Object.hasOwn(wrapped.responses, "create")).toBe(true) + }) + + it("wraps a frozen SDK client without mutating its resources", async () => { + const { chatCalls, chatCreate, client, responseCalls, responsesCreate } = + createBaseClient() + + Object.freeze(client.chat.completions) + Object.freeze(client.chat) + Object.freeze(client.responses) + Object.freeze(client) + const chatDescriptor = Object.getOwnPropertyDescriptor( + client.chat.completions, + "create", + ) + const responsesDescriptor = Object.getOwnPropertyDescriptor( + client.responses, + "create", + ) + + const wrapped = wrapClient(client, "tenant-a") + const requestOptions = { timeout: 321 } + + expect(wrapped).not.toBe(client) + expect(wrapped).toBeInstanceOf(OpenAI) + expect(wrapped.chat).not.toBe(client.chat) + expect(wrapped.chat.completions).not.toBe(client.chat.completions) + expect(wrapped.responses).not.toBe(client.responses) + expect(wrapped.files).toBe(client.files) + expect( + Object.getOwnPropertyDescriptor(client.chat.completions, "create"), + ).toEqual(chatDescriptor) + expect(Object.getOwnPropertyDescriptor(client.responses, "create")).toEqual( + responsesDescriptor, + ) + expect(client.chat.completions.create).toBe(chatCreate) + expect(client.responses.create).toBe(responsesCreate) + + await wrapped.chat.completions.create( + { + messages: [{ content: "hello", role: "user" }], + model: "gpt-4o-mini", + }, + requestOptions, + ) + await wrapped.responses.create( + { input: "hello", model: "gpt-4o-mini" }, + requestOptions, + ) + + expect(chatCalls[0]?.receiver).toBe(client.chat.completions) + expect(chatCalls[0]?.requestOptions).toBe(requestOptions) + expect(responseCalls[0]?.receiver).toBe(client.responses) + expect(responseCalls[0]?.requestOptions).toBe(requestOptions) + }) + + it("isolates Chat and Responses memory lookup across wrappers and rewraps", async () => { + const { chatCalls, chatCreate, client, responseCalls, responsesCreate } = + createBaseClient() + const tenantA = wrapClient(client, "tenant-a") + const tenantB = wrapClient(client, "tenant-b") + + expect(tenantA).not.toBe(tenantB) + expect(client.chat.completions.create).toBe(chatCreate) + expect(client.responses.create).toBe(responsesCreate) + + await tenantB.chat.completions.create({ + messages: [{ content: "hello", role: "user" }], + model: "gpt-4o-mini", + }) + await tenantA.chat.completions.create({ + messages: [{ content: "hello", role: "user" }], + model: "gpt-4o-mini", + }) + await tenantB.responses.create({ + input: "hello", + model: "gpt-4o-mini", + }) + + expect(profileTags).toEqual(["tenant-b", "tenant-a", "tenant-b"]) + const tenantBChat = systemContent(callAt(chatCalls, 0)) + const tenantAChat = systemContent(callAt(chatCalls, 1)) + expect(tenantBChat).toContain("secret-tenant-b") + expect(tenantBChat).not.toContain("secret-tenant-a") + expect(tenantAChat).toContain("secret-tenant-a") + expect(tenantAChat).not.toContain("secret-tenant-b") + expect( + (responseCalls[0]?.params as { instructions?: string }).instructions, + ).toContain("secret-tenant-b") + + const tenantC = wrapClient(tenantA, "tenant-c") + await tenantC.chat.completions.create({ + messages: [{ content: "hello", role: "user" }], + model: "gpt-4o-mini", + }) + await tenantA.chat.completions.create({ + messages: [{ content: "hello", role: "user" }], + model: "gpt-4o-mini", + }) + + expect(profileTags.slice(-2)).toEqual(["tenant-c", "tenant-a"]) + const tenantCChat = systemContent(callAt(chatCalls, 2)) + expect(tenantCChat).toContain("secret-tenant-c") + expect(tenantCChat).not.toContain("secret-tenant-a") + expect(systemContent(callAt(chatCalls, 3))).toContain("secret-tenant-a") + }) + + it("saves a shared client's conversation only for the selected wrapper", async () => { + const { client } = createBaseClient() + wrapClient(client, "tenant-a", "thread-a", "always") + const tenantB = wrapClient(client, "tenant-b", "thread-b", "always") + + await tenantB.chat.completions.create({ + messages: [{ content: "private tenant B message", role: "user" }], + model: "gpt-4o-mini", + }) + + expect(profileTags).toEqual(["tenant-b"]) + expect(conversations).toEqual([ + expect.objectContaining({ + containerTags: ["tenant-b"], + conversationId: "thread-b", + }), + ]) + }) + + it("unwraps a facade created by another middleware module instance", async () => { + const firstModule = await import("./middleware") + vi.resetModules() + const secondModule = await import("./middleware") + const { chatCalls, client } = createBaseClient() + const options = { + addMemory: "never" as const, + baseUrl: MEMORY_BASE_URL, + containerTag: "provided-separately", + customId: "thread", + mode: "profile" as const, + } + + expect(firstModule.createOpenAIMiddleware).not.toBe( + secondModule.createOpenAIMiddleware, + ) + const tenantA = firstModule.createOpenAIMiddleware( + client, + "tenant-a", + options, + ) + const tenantB = secondModule.createOpenAIMiddleware( + tenantA, + "tenant-b", + options, + ) + + await tenantB.chat.completions.create({ + messages: [{ content: "hello", role: "user" }], + model: "gpt-4o-mini", + }) + + expect(profileTags).toEqual(["tenant-b"]) + const content = systemContent(callAt(chatCalls, 0)) + expect(content).toContain("secret-tenant-b") + expect(content).not.toContain("secret-tenant-a") + }) +}) diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index c9b8b4b8..ac9f8f96 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -5,6 +5,34 @@ import { deduplicateMemoriesForMode } from "../tools-shared" import { createLogger, type Logger } from "../vercel/logger" import { convertProfileToMarkdown } from "../vercel/util" +// Keep canonicalization stable across duplicate package copies and hot reloads. +const BASE_CLIENT_SYMBOL = Symbol.for("@supermemory/tools/openai/base-client") +const baseClientByWrapper = new WeakMap() + +const getBaseClient = (client: OpenAI) => + (Reflect.get(client, BASE_CLIENT_SYMBOL) as OpenAI | undefined) ?? + baseClientByWrapper.get(client) ?? + client + +const cloneWithOverrides = ( + source: T, + overrides: Partial, +): T => { + const descriptors = Object.getOwnPropertyDescriptors(source) + + for (const key of Reflect.ownKeys(overrides) as Array) { + const current = Object.getOwnPropertyDescriptor(source, key) + Reflect.set(descriptors, key, { + configurable: current?.configurable ?? true, + enumerable: current?.enumerable ?? false, + value: overrides[key], + writable: current && "writable" in current ? current.writable : true, + }) + } + + return Object.create(Object.getPrototypeOf(source), descriptors) as T +} + const normalizeBaseUrl = (url?: string): string => { const defaultUrl = "https://api.supermemory.ai" if (!url) return defaultUrl @@ -419,6 +447,11 @@ export function createOpenAIMiddleware( containerTag: string, options?: OpenAIMiddlewareOptions, ) { + const baseClient = getBaseClient(openaiClient) + const baseChat = baseClient.chat + const baseCompletions = baseChat.completions + const baseResponses = baseClient.responses + const logger = createLogger(options?.verbose ?? false) const baseUrl = normalizeBaseUrl(options?.baseUrl) const client = new Supermemory({ @@ -430,8 +463,8 @@ export function createOpenAIMiddleware( const mode = options?.mode ?? "profile" const addMemory = options?.addMemory ?? "always" - const originalCreate = openaiClient.chat.completions.create - const originalResponsesCreate = openaiClient.responses?.create + const originalCreate = baseCompletions.create + const originalResponsesCreate = baseResponses?.create /** * Searches for memories and formats them for injection into API calls. @@ -523,6 +556,7 @@ export function createOpenAIMiddleware( const createResponsesWithMemory = async ( params: Parameters[0], + requestOptions?: OpenAI.RequestOptions, ) => { if (!originalResponsesCreate) { throw new Error( @@ -534,7 +568,7 @@ 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(baseResponses, params, requestOptions) } logger.info("Starting memory search for Responses API", { @@ -572,14 +606,19 @@ export function createOpenAIMiddleware( ? `${params.instructions || ""}\n\n${memories}`.trim() : params.instructions - return originalResponsesCreate.call(openaiClient.responses, { - ...params, - instructions: enhancedInstructions, - }) + return originalResponsesCreate.call( + baseResponses, + { + ...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 +626,7 @@ 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(baseCompletions, params, requestOptions) } } @@ -627,22 +666,40 @@ export function createOpenAIMiddleware( ) const results = await Promise.all(operations) - const enhancedMessages = results[results.length - 1] // Enhanced messages result is always last + const enhancedMessages = results[ + results.length - 1 + ] as OpenAI.Chat.Completions.ChatCompletionMessageParam[] // Enhanced messages result is always last - return originalCreate.call(openaiClient.chat.completions, { - ...params, - messages: enhancedMessages, - }) + return originalCreate.call( + baseCompletions, + { + ...params, + messages: enhancedMessages, + }, + requestOptions, + ) } - openaiClient.chat.completions.create = - createWithMemory as typeof originalCreate + const wrappedCompletions = cloneWithOverrides(baseCompletions, { + create: createWithMemory as typeof originalCreate, + }) + const wrappedChat = cloneWithOverrides(baseChat, { + completions: wrappedCompletions, + }) + const wrappedResponses = + baseResponses && originalResponsesCreate + ? cloneWithOverrides(baseResponses, { + create: createResponsesWithMemory as typeof originalResponsesCreate, + }) + : undefined + const wrappedClient = cloneWithOverrides(baseClient, { + chat: wrappedChat, + ...(wrappedResponses ? { responses: wrappedResponses } : {}), + }) - // Wrap Responses API if available - if (originalResponsesCreate) { - openaiClient.responses.create = - createResponsesWithMemory as typeof originalResponsesCreate - } - - return openaiClient + Object.defineProperty(wrappedClient, BASE_CLIENT_SYMBOL, { + value: baseClient, + }) + baseClientByWrapper.set(wrappedClient, baseClient) + return wrappedClient }