mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge a9923a7cec into 3f7b9667c6
This commit is contained in:
commit
6b0facdf4f
4 changed files with 398 additions and 24 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
* @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
|
||||
* @returns A distinct OpenAI middleware facade for Chat Completions and Responses APIs. The supplied client is not mutated.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
|
|
|
|||
317
packages/tools/src/openai/middleware.client-isolation.test.ts
Normal file
317
packages/tools/src/openai/middleware.client-isolation.test.ts
Normal file
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,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<OpenAI, OpenAI>()
|
||||
|
||||
const getBaseClient = (client: OpenAI) =>
|
||||
(Reflect.get(client, BASE_CLIENT_SYMBOL) as OpenAI | undefined) ??
|
||||
baseClientByWrapper.get(client) ??
|
||||
client
|
||||
|
||||
const cloneWithOverrides = <T extends object>(
|
||||
source: T,
|
||||
overrides: Partial<T>,
|
||||
): T => {
|
||||
const descriptors = Object.getOwnPropertyDescriptors(source)
|
||||
|
||||
for (const key of Reflect.ownKeys(overrides) as Array<keyof T>) {
|
||||
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
|
||||
|
|
@ -431,6 +459,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 apiKey = validateApiKey(options?.apiKey)
|
||||
|
|
@ -443,8 +476,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.
|
||||
|
|
@ -537,6 +570,7 @@ export function createOpenAIMiddleware(
|
|||
|
||||
const createResponsesWithMemory = async (
|
||||
params: Parameters<typeof originalResponsesCreate>[0],
|
||||
requestOptions?: OpenAI.RequestOptions,
|
||||
) => {
|
||||
if (!originalResponsesCreate) {
|
||||
throw new Error(
|
||||
|
|
@ -548,7 +582,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", {
|
||||
|
|
@ -586,14 +620,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 : []
|
||||
|
||||
|
|
@ -601,7 +640,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -641,22 +680,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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue