From 89117a9d680cd7cf904258368fc583a714efc905 Mon Sep 17 00:00:00 2001 From: Alex-wuhu Date: Tue, 24 Mar 2026 20:02:44 +0800 Subject: [PATCH] Add Novita AI integration to @supermemory/tools Adds a dedicated Novita AI provider integration following the OpenAI SDK pattern: - New novita submodule with withSupermemory middleware - Supports OpenAI-compatible endpoint (https://api.novita.ai/openai) - Uses NOVITA_API_KEY env var for authentication - Exports NOVITA_MODELS constants with model IDs (kimi-k2.5, glm-5, minimax-m2.5) - Includes unit tests for the integration --- packages/tools/src/index.ts | 15 ++ packages/tools/src/novita/index.ts | 78 ++++++ packages/tools/src/novita/middleware.ts | 331 ++++++++++++++++++++++++ packages/tools/src/novita/types.ts | 86 ++++++ packages/tools/test/novita/unit.test.ts | 160 ++++++++++++ packages/tools/tsdown.config.ts | 1 + 6 files changed, 671 insertions(+) create mode 100644 packages/tools/src/novita/index.ts create mode 100644 packages/tools/src/novita/middleware.ts create mode 100644 packages/tools/src/novita/types.ts create mode 100644 packages/tools/test/novita/unit.test.ts diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 404e0943..434c53f7 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -1,3 +1,18 @@ export type { SupermemoryToolsConfig } from "./types" export type { OpenAIMiddlewareOptions } from "./openai" + +export type { + NovitaMiddlewareOptions, + NovitaClientOptions, + NovitaModel, + NovitaEmbeddingModel, +} from "./novita" +export { + NOVITA_MODELS, + NOVITA_EMBEDDING_MODELS, + NOVITA_ENDPOINTS, + withSupermemory as withSupermemoryNovita, + wrapNovitaClient, + createNovita, +} from "./novita" diff --git a/packages/tools/src/novita/index.ts b/packages/tools/src/novita/index.ts new file mode 100644 index 00000000..60a8e9ae --- /dev/null +++ b/packages/tools/src/novita/index.ts @@ -0,0 +1,78 @@ +import type OpenAI from "openai" +import { createNovitaClient, createNovitaMiddleware } from "./middleware" +import { + NOVITA_MODELS, + NOVITA_EMBEDDING_MODELS, + NOVITA_ENDPOINTS, + type NovitaMiddlewareOptions, + type NovitaClientOptions, + type NovitaModel, + type NovitaEmbeddingModel, +} from "./types" + +export { + createSearchMemoriesFunction, + createAddMemoryFunction, + createGetProfileFunction, + createDocumentListFunction, + createDocumentDeleteFunction, + createDocumentAddFunction, + createMemoryForgetFunction, + supermemoryTools, + getToolDefinitions, + createToolCallExecutor, + createToolCallsExecutor, + createSearchMemoriesTool, + createAddMemoryTool, + createGetProfileTool, + createDocumentListTool, + createDocumentDeleteTool, + createDocumentAddTool, + createMemoryForgetTool, + memoryToolSchemas, + type MemorySearchResult, + type MemoryAddResult, + type ProfileResult, + type DocumentListResult, + type DocumentDeleteResult, + type DocumentAddResult, + type MemoryForgetResult, +} from "../openai/tools" + +export type { + NovitaMiddlewareOptions as WithSupermemoryOptions, + NovitaClientOptions, + NovitaModel, + NovitaEmbeddingModel, +} + +export { NOVITA_MODELS, NOVITA_EMBEDDING_MODELS, NOVITA_ENDPOINTS } + +export function withSupermemory( + containerTag: string, + options?: NovitaMiddlewareOptions, + clientOptions?: NovitaClientOptions, +): OpenAI { + if (!process.env.SUPERMEMORY_API_KEY) { + throw new Error("SUPERMEMORY_API_KEY is not set") + } + + const novitaClient = createNovitaClient(clientOptions) + return createNovitaMiddleware(novitaClient, containerTag, options) +} + +export function wrapNovitaClient( + novitaClient: OpenAI, + containerTag: string, + options?: NovitaMiddlewareOptions, +): OpenAI { + if (!process.env.SUPERMEMORY_API_KEY) { + throw new Error("SUPERMEMORY_API_KEY is not set") + } + + return createNovitaMiddleware(novitaClient, containerTag, options) +} + +export function createNovita(options?: NovitaClientOptions): OpenAI { + return createNovitaClient(options) +} diff --git a/packages/tools/src/novita/middleware.ts b/packages/tools/src/novita/middleware.ts new file mode 100644 index 00000000..ffd391b9 --- /dev/null +++ b/packages/tools/src/novita/middleware.ts @@ -0,0 +1,331 @@ +import OpenAI from "openai" +import { + NOVITA_ENDPOINTS, + type NovitaClientOptions, + type NovitaMiddlewareOptions, +} from "./types" + +const NOVITA_API_KEY = process.env.NOVITA_API_KEY + +export function createNovitaClient(options?: NovitaClientOptions): OpenAI { + const apiKey = options?.apiKey ?? NOVITA_API_KEY + + if (!apiKey) { + throw new Error( + "NOVITA_API_KEY is not set — provide it via `options.apiKey` or set `process.env.NOVITA_API_KEY`", + ) + } + + return new OpenAI({ + apiKey, + baseURL: options?.baseURL ?? NOVITA_ENDPOINTS.OPENAI, + ...(options?.organization && { organization: options.organization }), + }) +} + +export function createNovitaMiddleware( + novitaClient: OpenAI, + containerTag: string, + options?: NovitaMiddlewareOptions, +): OpenAI { + const logger = { + info: (message: string, data?: Record) => { + if (options?.verbose) { + console.log(`[novita-supermemory] ${message}`, data ?? "") + } + }, + error: (message: string, data?: Record) => { + console.error(`[novita-supermemory] ${message}`, data ?? "") + }, + debug: (message: string, data?: Record) => { + if (options?.verbose) { + console.debug(`[novita-supermemory] ${message}`, data ?? "") + } + }, + } + + const conversationId = options?.conversationId + const mode = options?.mode ?? "profile" + const addMemory = options?.addMemory ?? "never" + const baseUrl = options?.baseUrl ?? "https://api.supermemory.ai" + + const originalCreate = novitaClient.chat.completions.create + const originalResponsesCreate = novitaClient.responses?.create + + const getLastUserMessage = ( + messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[], + ) => { + const lastUserMessage = messages + .slice() + .reverse() + .find((msg) => msg.role === "user") + + return typeof lastUserMessage?.content === "string" + ? lastUserMessage.content + : "" + } + + const supermemoryProfileSearch = async ( + containerTag: string, + queryText: string, + ): Promise<{ + profile: { + static?: Array<{ memory: string; metadata?: Record }> + dynamic?: Array<{ memory: string; metadata?: Record }> + } + searchResults: { + results: Array<{ memory: string; metadata?: Record }> + } + }> => { + const payload = queryText + ? JSON.stringify({ + q: queryText, + containerTag: containerTag, + }) + : JSON.stringify({ + containerTag: containerTag, + }) + + const response = await fetch(`${baseUrl}/v4/profile`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + }, + body: payload, + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error") + throw new Error( + `Supermemory profile search failed: ${response.status} ${response.statusText}. ${errorText}`, + ) + } + + return await response.json() + } + + const convertProfileToMarkdown = (data: { + profile: { + static?: Array<{ memory: string }> + dynamic?: Array<{ memory: string }> + } + searchResults: { results: Array<{ memory: string }> } + }): string => { + const parts: string[] = [] + + if (data.profile.static?.length) { + parts.push("### Static Profile (Facts about user)") + for (const item of data.profile.static) { + parts.push(`- ${item.memory}`) + } + } + + if (data.profile.dynamic?.length) { + parts.push("### Dynamic Context (Recent activity)") + for (const item of data.profile.dynamic) { + parts.push(`- ${item.memory}`) + } + } + + return parts.join("\n") + } + + const deduplicateMemories = (data: { + static?: Array<{ memory: string }> + dynamic?: Array<{ memory: string }> + searchResults?: Array<{ memory: string }> + }): { + static: string[] + dynamic: string[] + searchResults: string[] + } => { + const seen = new Set() + const result = { + static: [] as string[], + dynamic: [] as string[], + searchResults: [] as string[], + } + + for (const item of data.static ?? []) { + const normalized = item.memory.toLowerCase().trim() + if (!seen.has(normalized)) { + seen.add(normalized) + result.static.push(item.memory) + } + } + + for (const item of data.dynamic ?? []) { + const normalized = item.memory.toLowerCase().trim() + if (!seen.has(normalized)) { + seen.add(normalized) + result.dynamic.push(item.memory) + } + } + + for (const item of data.searchResults ?? []) { + const normalized = item.memory.toLowerCase().trim() + if (!seen.has(normalized)) { + seen.add(normalized) + result.searchResults.push(item.memory) + } + } + + return result + } + + const addSystemPrompt = async ( + messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[], + containerTag: string, + mode: "profile" | "query" | "full", + ): Promise => { + const systemPromptExists = messages.some((msg) => msg.role === "system") + const queryText = mode !== "profile" ? getLastUserMessage(messages) : "" + + const memoriesResponse = await supermemoryProfileSearch( + containerTag, + queryText, + ) + + const memoryCountStatic = memoriesResponse.profile.static?.length || 0 + const memoryCountDynamic = memoriesResponse.profile.dynamic?.length || 0 + + logger.info("Memory search completed", { + containerTag, + memoryCountStatic, + memoryCountDynamic, + mode, + }) + + const deduplicated = deduplicateMemories({ + static: memoriesResponse.profile.static, + dynamic: memoriesResponse.profile.dynamic, + searchResults: memoriesResponse.searchResults?.results, + }) + + const profileData = + mode !== "query" + ? convertProfileToMarkdown({ + profile: { + static: deduplicated.static.map((m) => ({ memory: m })), + dynamic: deduplicated.dynamic.map((m) => ({ memory: m })), + }, + 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) { + return messages + } + + if (systemPromptExists) { + return messages.map((msg) => + msg.role === "system" + ? { ...msg, content: `${msg.content} \n ${memories}` } + : msg, + ) + } + + return [{ role: "system" as const, content: memories }, ...messages] + } + + const createWithMemory = async ( + params: OpenAI.Chat.Completions.ChatCompletionCreateParams, + ) => { + const messages = Array.isArray(params.messages) ? params.messages : [] + + if (mode !== "profile") { + const userMessage = getLastUserMessage(messages) + if (!userMessage) { + logger.debug("No user message found, skipping memory search") + return originalCreate.call(novitaClient.chat.completions, params) + } + } + + logger.info("Starting memory search", { + containerTag, + conversationId, + mode, + }) + + const enhancedMessages = await addSystemPrompt(messages, containerTag, mode) + + return originalCreate.call(novitaClient.chat.completions, { + ...params, + messages: enhancedMessages, + }) + } + + novitaClient.chat.completions.create = + createWithMemory as typeof originalCreate + + if (originalResponsesCreate) { + const createResponsesWithMemory = async ( + params: Parameters[0], + ) => { + const input = typeof params.input === "string" ? params.input : "" + + if (mode !== "profile" && !input) { + logger.debug("No input found for Responses API, skipping memory search") + return originalResponsesCreate.call(novitaClient.responses, params) + } + + logger.info("Starting memory search for Responses API", { + containerTag, + conversationId, + mode, + }) + + const queryText = mode !== "profile" ? input : "" + const memoriesResponse = await supermemoryProfileSearch( + containerTag, + queryText, + ) + + const deduplicated = deduplicateMemories({ + static: memoriesResponse.profile.static, + dynamic: memoriesResponse.profile.dynamic, + searchResults: memoriesResponse.searchResults?.results, + }) + + const profileData = + mode !== "query" + ? convertProfileToMarkdown({ + profile: { + static: deduplicated.static.map((m) => ({ memory: m })), + dynamic: deduplicated.dynamic.map((m) => ({ memory: m })), + }, + searchResults: { results: [] }, + }) + : "" + + const searchResultsMemories = + mode !== "profile" + ? `Search results: \n${deduplicated.searchResults.map((memory) => `- ${memory}`).join("\n")}` + : "" + + const memories = `${profileData}\n${searchResultsMemories}`.trim() + + const enhancedInstructions = memories + ? `${params.instructions || ""}\n\n${memories}`.trim() + : params.instructions + + return originalResponsesCreate.call(novitaClient.responses, { + ...params, + instructions: enhancedInstructions, + }) + } + + novitaClient.responses.create = + createResponsesWithMemory as typeof originalResponsesCreate + } + + return novitaClient +} diff --git a/packages/tools/src/novita/types.ts b/packages/tools/src/novita/types.ts new file mode 100644 index 00000000..6a086fcd --- /dev/null +++ b/packages/tools/src/novita/types.ts @@ -0,0 +1,86 @@ +import type OpenAI from "openai" + +/** + * Novita AI configuration options + */ +export interface NovitaMiddlewareOptions { + /** Optional conversation ID to group messages for contextual memory generation */ + conversationId?: string + /** Enable detailed logging of memory operations (default: false) */ + verbose?: boolean + /** + * Memory retrieval mode: + * - "profile": Retrieves user profile memories (static + dynamic) without query filtering + * - "query": Searches memories based on semantic similarity to the user's message + * - "full": Combines both profile and query-based results + */ + mode?: "profile" | "query" | "full" + /** + * Memory persistence mode: + * - "always": Automatically save conversations as memories + * - "never": Only retrieve memories, don't store new ones + */ + addMemory?: "always" | "never" + /** Custom Supermemory API base URL */ + baseUrl?: string +} + +/** + * Novita AI client options + */ +export interface NovitaClientOptions { + /** Novita API key (falls back to NOVITA_API_KEY env var) */ + apiKey?: string + /** Custom base URL (default: https://api.novita.ai/openai) */ + baseURL?: string + /** Organization ID */ + organization?: string +} + +/** + * Available Novita AI models + * @see https://novita.ai/models + */ +export const NOVITA_MODELS = { + /** + * Default model - MoE architecture with function calling, structured output, reasoning, and vision + * Context: 262,144 tokens | Max Output: 262,144 tokens + */ + DEFAULT: "moonshotai/kimi-k2.5", + + /** + * GLM-5 - MoE architecture with function calling, structured output, reasoning + * Context: 202,800 tokens | Max Output: 131,072 tokens + */ + GLM_5: "zai-org/glm-5", + + /** + * MiniMax M2.5 - MoE architecture with function calling, structured output, reasoning + * Context: 204,800 tokens | Max Output: 131,100 tokens + */ + MINIMAX_M2_5: "minimax/minimax-m2.5", +} as const + +/** + * Novita AI embedding models + */ +export const NOVITA_EMBEDDING_MODELS = { + /** + * Qwen3 Embedding - 1024 dimensions, max 8,192 input tokens + */ + DEFAULT: "qwen/qwen3-embedding-0.6b", +} as const + +/** + * Novita AI API endpoints + */ +export const NOVITA_ENDPOINTS = { + /** OpenAI-compatible endpoint */ + OPENAI: "https://api.novita.ai/openai", + /** Anthropic-compatible endpoint */ + ANTHROPIC: "https://api.novita.ai/anthropic", +} as const + +export type NovitaModel = (typeof NOVITA_MODELS)[keyof typeof NOVITA_MODELS] +export type NovitaEmbeddingModel = + (typeof NOVITA_EMBEDDING_MODELS)[keyof typeof NOVITA_EMBEDDING_MODELS] diff --git a/packages/tools/test/novita/unit.test.ts b/packages/tools/test/novita/unit.test.ts new file mode 100644 index 00000000..085309ef --- /dev/null +++ b/packages/tools/test/novita/unit.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { + withSupermemory, + wrapNovitaClient, + createNovita, + NOVITA_MODELS, + NOVITA_ENDPOINTS, +} from "../../src/novita" +import OpenAI from "openai" +import "dotenv/config" + +const TEST_CONFIG = { + apiKey: process.env.SUPERMEMORY_API_KEY || "test-api-key", + novitaApiKey: process.env.NOVITA_API_KEY || "test-novita-key", + baseURL: process.env.SUPERMEMORY_BASE_URL || "https://api.supermemory.ai", + containerTag: "test-novita-wrapper", +} + +describe("Unit: Novita Integration", () => { + let originalEnv: string | undefined + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalEnv = process.env.SUPERMEMORY_API_KEY + originalFetch = globalThis.fetch + vi.clearAllMocks() + }) + + afterEach(() => { + if (originalEnv) { + process.env.SUPERMEMORY_API_KEY = originalEnv + } else { + delete process.env.SUPERMEMORY_API_KEY + } + globalThis.fetch = originalFetch + }) + + describe("Constants and Exports", () => { + it("should export NOVITA_MODELS with correct model IDs", () => { + expect(NOVITA_MODELS.DEFAULT).toBe("moonshotai/kimi-k2.5") + expect(NOVITA_MODELS.GLM_5).toBe("zai-org/glm-5") + expect(NOVITA_MODELS.MINIMAX_M2_5).toBe("minimax/minimax-m2.5") + }) + + it("should export NOVITA_ENDPOINTS with correct URLs", () => { + expect(NOVITA_ENDPOINTS.OPENAI).toBe("https://api.novita.ai/openai") + expect(NOVITA_ENDPOINTS.ANTHROPIC).toBe("https://api.novita.ai/anthropic") + }) + }) + + describe("Environment validation", () => { + it("should throw error if SUPERMEMORY_API_KEY is not set", () => { + delete process.env.SUPERMEMORY_API_KEY + + expect(() => { + withSupermemory(TEST_CONFIG.containerTag) + }).toThrow("SUPERMEMORY_API_KEY is not set") + }) + + it("should throw error if NOVITA_API_KEY is not set when creating client", () => { + delete process.env.NOVITA_API_KEY + + expect(() => { + createNovita() + }).toThrow("NOVITA_API_KEY is not set") + }) + + it("should successfully create client with valid API keys", () => { + process.env.SUPERMEMORY_API_KEY = "test-key" + process.env.NOVITA_API_KEY = "test-novita-key" + + const client = createNovita() + expect(client).toBeDefined() + expect(client).toBeInstanceOf(OpenAI) + }) + + it("should successfully wrap client with supermemory", () => { + process.env.SUPERMEMORY_API_KEY = "test-key" + process.env.NOVITA_API_KEY = "test-novita-key" + + const client = createNovita() + const wrappedClient = wrapNovitaClient(client, TEST_CONFIG.containerTag) + expect(wrappedClient).toBeDefined() + }) + + it("should successfully create wrapped client in one call", () => { + process.env.SUPERMEMORY_API_KEY = "test-key" + process.env.NOVITA_API_KEY = "test-novita-key" + + const wrappedClient = withSupermemory(TEST_CONFIG.containerTag) + expect(wrappedClient).toBeDefined() + }) + }) + + describe("Client configuration", () => { + it("should use custom baseURL when provided", () => { + process.env.NOVITA_API_KEY = "test-key" + + const client = createNovita({ baseURL: "https://custom.api.com" }) + expect(client).toBeDefined() + }) + + it("should use default Novita endpoint when baseURL not provided", () => { + process.env.NOVITA_API_KEY = "test-key" + + const client = createNovita() + expect(client.baseURL).toBe(NOVITA_ENDPOINTS.OPENAI) + }) + + it("should accept apiKey in options", () => { + const client = createNovita({ apiKey: "custom-key" }) + expect(client).toBeDefined() + }) + }) + + describe("withSupermemory options", () => { + it("should accept verbose option", () => { + process.env.SUPERMEMORY_API_KEY = "test-key" + process.env.NOVITA_API_KEY = "test-key" + + const wrappedClient = withSupermemory(TEST_CONFIG.containerTag, { + verbose: true, + }) + expect(wrappedClient).toBeDefined() + }) + + it("should accept mode option", () => { + process.env.SUPERMEMORY_API_KEY = "test-key" + process.env.NOVITA_API_KEY = "test-key" + + const modes = ["profile", "query", "full"] as const + for (const mode of modes) { + const wrappedClient = withSupermemory(TEST_CONFIG.containerTag, { + mode, + }) + expect(wrappedClient).toBeDefined() + } + }) + + it("should accept addMemory option", () => { + process.env.SUPERMEMORY_API_KEY = "test-key" + process.env.NOVITA_API_KEY = "test-key" + + const wrappedClient = withSupermemory(TEST_CONFIG.containerTag, { + addMemory: "always", + }) + expect(wrappedClient).toBeDefined() + }) + + it("should accept conversationId option", () => { + process.env.SUPERMEMORY_API_KEY = "test-key" + process.env.NOVITA_API_KEY = "test-key" + + const wrappedClient = withSupermemory(TEST_CONFIG.containerTag, { + conversationId: "conv-123", + }) + expect(wrappedClient).toBeDefined() + }) + }) +}) diff --git a/packages/tools/tsdown.config.ts b/packages/tools/tsdown.config.ts index 9b543ec8..3f8ca6af 100644 --- a/packages/tools/tsdown.config.ts +++ b/packages/tools/tsdown.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ "src/claude-memory.ts", "src/openai/index.ts", "src/mastra.ts", + "src/novita/index.ts", ], format: "esm", sourcemap: false,