From 9aff112a73ce191d281f53f001fb5f9ed27e4c4e Mon Sep 17 00:00:00 2001 From: utkarsh240799 Date: Mon, 16 Mar 2026 16:12:08 +0530 Subject: [PATCH] feat(mem0-ts): add LM Studio embedder and LLM support (fixes #4235) Add LM Studio as a supported provider for both embeddings and LLM in the TypeScript SDK, bringing it to parity with the Python SDK. Co-Authored-By: Claude Opus 4.6 (1M context) --- mem0-ts/src/oss/src/config/manager.ts | 21 +- mem0-ts/src/oss/src/embeddings/lmstudio.ts | 53 +++ mem0-ts/src/oss/src/index.ts | 2 + mem0-ts/src/oss/src/llms/lmstudio.ts | 41 ++ mem0-ts/src/oss/src/utils/factory.ts | 6 + mem0-ts/src/oss/tests/config-manager.test.ts | 366 ++++++++++++++++++ mem0-ts/src/oss/tests/factory.test.ts | 25 +- .../src/oss/tests/lmstudio-embedder.test.ts | 83 ++++ .../oss/tests/lmstudio-integration.test.ts | 170 ++++++++ mem0-ts/src/oss/tests/lmstudio-llm.test.ts | 96 +++++ openclaw/README.md | 4 +- 11 files changed, 861 insertions(+), 6 deletions(-) create mode 100644 mem0-ts/src/oss/src/embeddings/lmstudio.ts create mode 100644 mem0-ts/src/oss/src/llms/lmstudio.ts create mode 100644 mem0-ts/src/oss/tests/lmstudio-embedder.test.ts create mode 100644 mem0-ts/src/oss/tests/lmstudio-integration.test.ts create mode 100644 mem0-ts/src/oss/tests/lmstudio-llm.test.ts diff --git a/mem0-ts/src/oss/src/config/manager.ts b/mem0-ts/src/oss/src/config/manager.ts index 1ef692ed4..5e2eb9c56 100644 --- a/mem0-ts/src/oss/src/config/manager.ts +++ b/mem0-ts/src/oss/src/config/manager.ts @@ -20,15 +20,24 @@ export class ConfigManager { finalModel = userConf.model; } + // Normalize snake_case keys from Python SDK / OpenClaw configs + const baseURL = + userConf?.baseURL ?? + (userConf as Record)?.lmstudio_base_url as string | undefined ?? + userConf?.url; + const embeddingDims = + userConf?.embeddingDims ?? + (userConf as Record)?.embedding_dims as number | undefined; + return { apiKey: userConf?.apiKey !== undefined ? userConf.apiKey : defaultConf.apiKey, model: finalModel, - baseURL: userConf?.baseURL, + baseURL, url: userConf?.url, - embeddingDims: userConf?.embeddingDims, + embeddingDims, modelProperties: userConf?.modelProperties !== undefined ? userConf.modelProperties @@ -91,8 +100,14 @@ export class ConfigManager { finalModel = userConf.model; } + // Normalize snake_case keys from Python SDK / OpenClaw configs + const llmBaseURL = + userConf?.baseURL ?? + (userConf as Record)?.lmstudio_base_url as string | undefined ?? + defaultConf.baseURL; + return { - baseURL: userConf?.baseURL || defaultConf.baseURL, + baseURL: llmBaseURL, url: userConf?.url, apiKey: userConf?.apiKey !== undefined diff --git a/mem0-ts/src/oss/src/embeddings/lmstudio.ts b/mem0-ts/src/oss/src/embeddings/lmstudio.ts new file mode 100644 index 000000000..ff33733c4 --- /dev/null +++ b/mem0-ts/src/oss/src/embeddings/lmstudio.ts @@ -0,0 +1,53 @@ +import OpenAI from "openai"; +import { Embedder } from "./base"; +import { EmbeddingConfig } from "../types"; + +const DEFAULT_BASE_URL = "http://localhost:1234/v1"; +const DEFAULT_MODEL = + "nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.f16.gguf"; +const DEFAULT_LMSTUDIO_API_KEY = "lm-studio"; + +export class LMStudioEmbedder implements Embedder { + private openai: OpenAI; + private model: string; + + constructor(config: EmbeddingConfig) { + const baseURL = config.baseURL ?? config.url ?? DEFAULT_BASE_URL; + const apiKey = config.apiKey || DEFAULT_LMSTUDIO_API_KEY; + this.openai = new OpenAI({ apiKey, baseURL: String(baseURL) }); + this.model = config.model || DEFAULT_MODEL; + } + + async embed(text: string): Promise { + const normalized = + typeof text === "string" ? text.replace(/\n/g, " ") : String(text); + try { + const response = await this.openai.embeddings.create({ + model: this.model, + input: normalized, + encoding_format: "float", + }); + return response.data[0].embedding; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`LM Studio embedder failed: ${message}`); + } + } + + async embedBatch(texts: string[]): Promise { + const normalized = texts.map((t) => + typeof t === "string" ? t.replace(/\n/g, " ") : String(t), + ); + try { + const response = await this.openai.embeddings.create({ + model: this.model, + input: normalized, + encoding_format: "float", + }); + return response.data.map((item) => item.embedding); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`LM Studio embedder failed: ${message}`); + } + } +} diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index c6a3022ea..e9c9af7c7 100644 --- a/mem0-ts/src/oss/src/index.ts +++ b/mem0-ts/src/oss/src/index.ts @@ -4,6 +4,7 @@ export * from "./types"; export * from "./embeddings/base"; export * from "./embeddings/openai"; export * from "./embeddings/ollama"; +export * from "./embeddings/lmstudio"; export * from "./embeddings/google"; export * from "./embeddings/azure"; export * from "./embeddings/langchain"; @@ -14,6 +15,7 @@ export * from "./llms/openai_structured"; export * from "./llms/anthropic"; export * from "./llms/groq"; export * from "./llms/ollama"; +export * from "./llms/lmstudio"; export * from "./llms/mistral"; export * from "./llms/langchain"; export * from "./vector_stores/base"; diff --git a/mem0-ts/src/oss/src/llms/lmstudio.ts b/mem0-ts/src/oss/src/llms/lmstudio.ts new file mode 100644 index 000000000..eb8c09109 --- /dev/null +++ b/mem0-ts/src/oss/src/llms/lmstudio.ts @@ -0,0 +1,41 @@ +import { OpenAILLM } from "./openai"; +import { LLMConfig, Message } from "../types"; +import { LLMResponse } from "./base"; + +const DEFAULT_BASE_URL = "http://localhost:1234/v1"; +const DEFAULT_MODEL = + "lmstudio-community/Meta-Llama-3.1-70B-Instruct-GGUF/Meta-Llama-3.1-70B-Instruct-IQ2_M.gguf"; +const DEFAULT_LMSTUDIO_API_KEY = "lm-studio"; + +export class LMStudioLLM extends OpenAILLM { + constructor(config: LLMConfig) { + super({ + ...config, + apiKey: config.apiKey || DEFAULT_LMSTUDIO_API_KEY, + baseURL: config.baseURL ?? DEFAULT_BASE_URL, + model: config.model || DEFAULT_MODEL, + }); + } + + async generateResponse( + messages: Message[], + responseFormat?: { type: string }, + tools?: any[], + ): Promise { + try { + return await super.generateResponse(messages, responseFormat, tools); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`LM Studio LLM failed: ${message}`); + } + } + + async generateChat(messages: Message[]): Promise { + try { + return await super.generateChat(messages); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`LM Studio LLM failed: ${message}`); + } + } +} diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 8c461d761..208d31b09 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -1,5 +1,6 @@ import { OpenAIEmbedder } from "../embeddings/openai"; import { OllamaEmbedder } from "../embeddings/ollama"; +import { LMStudioEmbedder } from "../embeddings/lmstudio"; import { OpenAILLM } from "../llms/openai"; import { OpenAIStructuredLLM } from "../llms/openai_structured"; import { AnthropicLLM } from "../llms/anthropic"; @@ -19,6 +20,7 @@ import { Qdrant } from "../vector_stores/qdrant"; import { VectorizeDB } from "../vector_stores/vectorize"; import { RedisDB } from "../vector_stores/redis"; import { OllamaLLM } from "../llms/ollama"; +import { LMStudioLLM } from "../llms/lmstudio"; import { SupabaseDB } from "../vector_stores/supabase"; import { SQLiteManager } from "../storage/SQLiteManager"; import { MemoryHistoryManager } from "../storage/MemoryHistoryManager"; @@ -40,6 +42,8 @@ export class EmbedderFactory { return new OpenAIEmbedder(config); case "ollama": return new OllamaEmbedder(config); + case "lmstudio": + return new LMStudioEmbedder(config); case "google": case "gemini": return new GoogleEmbedder(config); @@ -66,6 +70,8 @@ export class LLMFactory { return new GroqLLM(config); case "ollama": return new OllamaLLM(config); + case "lmstudio": + return new LMStudioLLM(config); case "google": case "gemini": return new GoogleLLM(config); diff --git a/mem0-ts/src/oss/tests/config-manager.test.ts b/mem0-ts/src/oss/tests/config-manager.test.ts index 1adba20a7..f821e7de0 100644 --- a/mem0-ts/src/oss/tests/config-manager.test.ts +++ b/mem0-ts/src/oss/tests/config-manager.test.ts @@ -161,4 +161,370 @@ describe("ConfigManager", () => { expect(config.llm.config.url).toBe("http://10.0.0.100:11434"); }); }); + + // ───────────────────────────────────────────────────────────────────── + // LM Studio snake_case normalization + // ───────────────────────────────────────────────────────────────────── + describe("mergeConfig - LM Studio embedder config", () => { + const baseLlm = { provider: "openai", config: { apiKey: "k" } }; + + it("normalizes lmstudio_base_url to baseURL for embedder", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: { + provider: "lmstudio", + config: { + model: "nomic-embed-text-v1.5", + lmstudio_base_url: "http://192.168.1.1:1234/v1", + } as any, + }, + vectorStore: { provider: "memory", config: {} }, + llm: baseLlm, + }); + + expect(cfg.embedder.provider).toBe("lmstudio"); + expect(cfg.embedder.config.baseURL).toBe("http://192.168.1.1:1234/v1"); + expect(cfg.embedder.config.model).toBe("nomic-embed-text-v1.5"); + }); + + it("normalizes embedding_dims to embeddingDims for embedder", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: { + provider: "lmstudio", + config: { model: "nomic-embed-text-v1.5", embedding_dims: 768 } as any, + }, + vectorStore: { provider: "memory", config: {} }, + llm: baseLlm, + }); + + expect(cfg.embedder.config.embeddingDims).toBe(768); + }); + + it("prefers camelCase baseURL over snake_case lmstudio_base_url", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: { + provider: "lmstudio", + config: { + model: "test", + baseURL: "http://camel:1234/v1", + lmstudio_base_url: "http://snake:1234/v1", + } as any, + }, + vectorStore: { provider: "memory", config: {} }, + llm: baseLlm, + }); + + expect(cfg.embedder.config.baseURL).toBe("http://camel:1234/v1"); + }); + + it("prefers camelCase embeddingDims over snake_case embedding_dims", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: { + provider: "lmstudio", + config: { + model: "test", + embeddingDims: 1536, + embedding_dims: 768, + } as any, + }, + vectorStore: { provider: "memory", config: {} }, + llm: baseLlm, + }); + + expect(cfg.embedder.config.embeddingDims).toBe(1536); + }); + + it("passes through camelCase config without issues", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: { + provider: "lmstudio", + config: { + model: "nomic-embed-text-v1.5", + baseURL: "http://localhost:1234/v1", + embeddingDims: 768, + }, + }, + vectorStore: { provider: "memory", config: {} }, + llm: baseLlm, + }); + + expect(cfg.embedder.config.baseURL).toBe("http://localhost:1234/v1"); + expect(cfg.embedder.config.embeddingDims).toBe(768); + }); + }); + + describe("mergeConfig - LM Studio LLM config", () => { + const baseEmbedder = { provider: "openai", config: { apiKey: "k" } }; + + it("normalizes lmstudio_base_url to baseURL for LLM", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: { provider: "memory", config: {} }, + llm: { + provider: "lmstudio", + config: { + model: "meta-llama-3.1", + lmstudio_base_url: "http://192.168.1.1:1234/v1", + } as any, + }, + }); + + expect(cfg.llm.provider).toBe("lmstudio"); + expect(cfg.llm.config.baseURL).toBe("http://192.168.1.1:1234/v1"); + expect(cfg.llm.config.model).toBe("meta-llama-3.1"); + }); + + it("prefers camelCase baseURL over lmstudio_base_url for LLM", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: { provider: "memory", config: {} }, + llm: { + provider: "lmstudio", + config: { + baseURL: "http://camel:1234/v1", + lmstudio_base_url: "http://snake:1234/v1", + } as any, + }, + }); + + expect(cfg.llm.config.baseURL).toBe("http://camel:1234/v1"); + }); + + it("falls back to default baseURL when neither is provided for LLM", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: { provider: "memory", config: {} }, + llm: { provider: "lmstudio", config: { model: "test-model" } }, + }); + + expect(cfg.llm.config.baseURL).toBe("https://api.openai.com/v1"); + }); + }); + + describe("mergeConfig - full OpenClaw-style LM Studio config", () => { + it("handles the exact config from issue #4235", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: { + provider: "lmstudio", + config: { + model: "text-embedding-gte-qwen2-1.5b-instruct", + embedding_dims: 1536, + lmstudio_base_url: "http://192.168.200.83:1234/v1", + } as any, + }, + vectorStore: { + provider: "qdrant", + config: { host: "192.168.200.12", port: 6333, checkCompatibility: false }, + }, + llm: { + provider: "lmstudio", + config: { + model: "openai/gpt-oss-20b", + lmstudio_base_url: "http://192.168.200.83:1234/v1", + } as any, + }, + }); + + expect(cfg.embedder.provider).toBe("lmstudio"); + expect(cfg.embedder.config.baseURL).toBe("http://192.168.200.83:1234/v1"); + expect(cfg.embedder.config.model).toBe("text-embedding-gte-qwen2-1.5b-instruct"); + expect(cfg.embedder.config.embeddingDims).toBe(1536); + + expect(cfg.llm.provider).toBe("lmstudio"); + expect(cfg.llm.config.baseURL).toBe("http://192.168.200.83:1234/v1"); + expect(cfg.llm.config.model).toBe("openai/gpt-oss-20b"); + + expect(cfg.vectorStore.provider).toBe("qdrant"); + expect(cfg.vectorStore.config.host).toBe("192.168.200.12"); + expect(cfg.vectorStore.config.port).toBe(6333); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// Memory class – LM Studio end-to-end flow (mocked factories) +// ───────────────────────────────────────────────────────────────────────── +describe("Memory – LM Studio end-to-end flow", () => { + let MemoryClass: any; + let mockEmbedderFactory: any; + let mockVectorStoreFactory: any; + let mockLlmFactory: any; + let mockHistoryFactory: any; + let mockEmbedder: any; + let mockVStore: any; + let mockLlm: any; + + beforeEach(() => { + jest.resetModules(); + + mockEmbedder = { + embed: jest.fn().mockResolvedValue(new Array(768).fill(0.1)), + embedBatch: jest.fn().mockResolvedValue([new Array(768).fill(0.1)]), + }; + mockVStore = { + insert: jest.fn().mockResolvedValue(undefined), + search: jest.fn().mockResolvedValue([]), + get: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + deleteCol: jest.fn().mockResolvedValue(undefined), + list: jest.fn().mockResolvedValue([[], 0]), + getUserId: jest.fn().mockResolvedValue("test-user-id"), + setUserId: jest.fn().mockResolvedValue(undefined), + initialize: jest.fn().mockResolvedValue(undefined), + }; + mockLlm = { + generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'), + }; + + mockEmbedderFactory = { create: jest.fn().mockReturnValue(mockEmbedder) }; + mockVectorStoreFactory = { create: jest.fn().mockReturnValue(mockVStore) }; + mockLlmFactory = { create: jest.fn().mockReturnValue(mockLlm) }; + mockHistoryFactory = { + create: jest.fn().mockReturnValue({ + addHistory: jest.fn().mockResolvedValue(undefined), + getHistory: jest.fn().mockResolvedValue([]), + reset: jest.fn().mockResolvedValue(undefined), + }), + }; + + jest.doMock("../src/utils/factory", () => ({ + EmbedderFactory: mockEmbedderFactory, + VectorStoreFactory: mockVectorStoreFactory, + LLMFactory: mockLlmFactory, + HistoryManagerFactory: mockHistoryFactory, + })); + jest.doMock("../src/utils/telemetry", () => ({ + captureClientEvent: jest.fn().mockResolvedValue(undefined), + })); + + MemoryClass = require("../src/memory").Memory; + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it("creates Memory with lmstudio embedder and llm providers", async () => { + const mem = new MemoryClass({ + embedder: { + provider: "lmstudio", + config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" }, + }, + vectorStore: { provider: "memory", config: { collectionName: "test" } }, + llm: { + provider: "lmstudio", + config: { model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" }, + }, + disableHistory: true, + }); + + await mem.getAll({ userId: "u1" }); + + expect(mockEmbedderFactory.create).toHaveBeenCalledWith( + "lmstudio", + expect.objectContaining({ model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" }), + ); + expect(mockLlmFactory.create).toHaveBeenCalledWith( + "lmstudio", + expect.objectContaining({ model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" }), + ); + }); + + it("auto-detects embedding dimension via probe with lmstudio", async () => { + const mem = new MemoryClass({ + embedder: { + provider: "lmstudio", + config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" }, + }, + vectorStore: { provider: "qdrant", config: { collectionName: "test" } }, + llm: { provider: "lmstudio", config: { baseURL: "http://localhost:1234/v1" } }, + disableHistory: true, + }); + + await mem.getAll({ userId: "u1" }); + + expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe"); + const vsCall = mockVectorStoreFactory.create.mock.calls[0]; + expect(vsCall[1].dimension).toBe(768); + }); + + it("handles snake_case OpenClaw config through full Memory stack", async () => { + const mem = new MemoryClass({ + embedder: { + provider: "lmstudio", + config: { + model: "text-embedding-gte-qwen2-1.5b-instruct", + embedding_dims: 1536, + lmstudio_base_url: "http://192.168.200.83:1234/v1", + } as any, + }, + vectorStore: { provider: "memory", config: { collectionName: "test" } }, + llm: { + provider: "lmstudio", + config: { model: "openai/gpt-oss-20b", lmstudio_base_url: "http://192.168.200.83:1234/v1" } as any, + }, + disableHistory: true, + }); + + await mem.getAll({ userId: "u1" }); + + expect(mockEmbedderFactory.create).toHaveBeenCalledWith( + "lmstudio", + expect.objectContaining({ model: "text-embedding-gte-qwen2-1.5b-instruct", baseURL: "http://192.168.200.83:1234/v1" }), + ); + expect(mockLlmFactory.create).toHaveBeenCalledWith( + "lmstudio", + expect.objectContaining({ model: "openai/gpt-oss-20b", baseURL: "http://192.168.200.83:1234/v1" }), + ); + }); + + it("search flow works with lmstudio embedder", async () => { + mockVStore.search.mockResolvedValueOnce([{ + id: "mem-1", + payload: { data: "User likes hiking", user_id: "u1", hash: "abc123", created_at: "2026-01-01" }, + score: 0.95, + }]); + + const mem = new MemoryClass({ + embedder: { + provider: "lmstudio", + config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1", embeddingDims: 768 }, + }, + vectorStore: { provider: "memory", config: { collectionName: "test", dimension: 768 } }, + llm: { provider: "lmstudio", config: { baseURL: "http://localhost:1234/v1" } }, + disableHistory: true, + }); + + const result = await mem.search("What does the user like?", { userId: "u1" }); + + expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?"); + expect(mockVStore.search).toHaveBeenCalled(); + expect(result.results).toHaveLength(1); + expect(result.results[0].memory).toBe("User likes hiking"); + }); + + it("add flow works with lmstudio LLM for fact extraction", async () => { + mockLlm.generateResponse.mockResolvedValueOnce('{"facts":["User loves sushi"]}'); + mockVStore.search.mockResolvedValue([]); + mockVStore.list.mockResolvedValue([[], 0]); + + const mem = new MemoryClass({ + embedder: { + provider: "lmstudio", + config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1", embeddingDims: 768 }, + }, + vectorStore: { provider: "memory", config: { collectionName: "test", dimension: 768 } }, + llm: { + provider: "lmstudio", + config: { model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" }, + }, + disableHistory: true, + }); + + await mem.add("I love sushi", { userId: "u1" }); + + expect(mockLlm.generateResponse).toHaveBeenCalled(); + expect(mockEmbedder.embed).toHaveBeenCalled(); + }); }); diff --git a/mem0-ts/src/oss/tests/factory.test.ts b/mem0-ts/src/oss/tests/factory.test.ts index 45b9538e3..9f513e000 100644 --- a/mem0-ts/src/oss/tests/factory.test.ts +++ b/mem0-ts/src/oss/tests/factory.test.ts @@ -1,7 +1,30 @@ /// -import { VectorStoreFactory } from "../src/utils/factory"; +import { + EmbedderFactory, + VectorStoreFactory, +} from "../src/utils/factory"; +import { LMStudioEmbedder } from "../src/embeddings/lmstudio"; import { AzureAISearch } from "../src/vector_stores/azure_ai_search"; +describe("EmbedderFactory", () => { + describe("create", () => { + it("should create LM Studio embedder with baseURL", () => { + const embedder = EmbedderFactory.create("lmstudio", { + model: "text-embedding-gte-qwen2-1.5b-instruct", + baseURL: "http://localhost:1234/v1", + }); + + expect(embedder).toBeInstanceOf(LMStudioEmbedder); + }); + + it("should throw error for unsupported embedder provider", () => { + expect(() => { + EmbedderFactory.create("unsupported-embedder", {}); + }).toThrow("Unsupported embedder provider: unsupported-embedder"); + }); + }); +}); + describe("VectorStoreFactory", () => { describe("create", () => { it("should create Azure AI Search vector store", () => { diff --git a/mem0-ts/src/oss/tests/lmstudio-embedder.test.ts b/mem0-ts/src/oss/tests/lmstudio-embedder.test.ts new file mode 100644 index 000000000..cb5d0442f --- /dev/null +++ b/mem0-ts/src/oss/tests/lmstudio-embedder.test.ts @@ -0,0 +1,83 @@ +/// +/** + * LM Studio Embedder — unit tests (mocked OpenAI). + */ + +import { LMStudioEmbedder } from "../src/embeddings/lmstudio"; + +const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5]; +const mockCreate = jest.fn().mockResolvedValue({ + data: [{ embedding: mockEmbedding }], +}); + +jest.mock("openai", () => { + return jest.fn().mockImplementation(() => ({ + embeddings: { create: mockCreate }, + })); +}); + +describe("LMStudioEmbedder (unit)", () => { + beforeEach(() => mockCreate.mockClear()); + + it("embed() calls OpenAI with encoding_format float and returns vector", async () => { + const embedder = new LMStudioEmbedder({ + model: "nomic-embed-text-v1.5-GGUF", + baseURL: "http://localhost:1234/v1", + }); + + const result = await embedder.embed("Sample text to embed."); + + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(mockCreate.mock.calls[0][0]).toEqual({ + model: "nomic-embed-text-v1.5-GGUF", + input: "Sample text to embed.", + encoding_format: "float", + }); + expect(result).toEqual(mockEmbedding); + }); + + it("embed() normalizes newlines", async () => { + const embedder = new LMStudioEmbedder({ + model: "test-model", + baseURL: "http://localhost:1234/v1", + }); + + await embedder.embed("Line one\nLine two"); + + expect(mockCreate.mock.calls[0][0].input).toBe("Line one Line two"); + }); + + it("embed() wraps API errors with a clear message", async () => { + mockCreate.mockRejectedValueOnce(new Error("Connection refused")); + + const embedder = new LMStudioEmbedder({ + model: "test-model", + baseURL: "http://localhost:1234/v1", + }); + + await expect(embedder.embed("text")).rejects.toThrow( + "LM Studio embedder failed: Connection refused", + ); + }); + + it("embedBatch() returns vectors for multiple inputs", async () => { + const mockBatch = [[0.1, 0.2], [0.3, 0.4]]; + mockCreate.mockResolvedValueOnce({ + data: [ + { embedding: mockBatch[0] }, + { embedding: mockBatch[1] }, + ], + }); + + const embedder = new LMStudioEmbedder({ + model: "test-model", + baseURL: "http://localhost:1234/v1", + }); + + const result = await embedder.embedBatch(["text1", "text2"]); + + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(mockCreate.mock.calls[0][0].input).toEqual(["text1", "text2"]); + expect(result).toEqual(mockBatch); + }); +}); diff --git a/mem0-ts/src/oss/tests/lmstudio-integration.test.ts b/mem0-ts/src/oss/tests/lmstudio-integration.test.ts new file mode 100644 index 000000000..9a145ee98 --- /dev/null +++ b/mem0-ts/src/oss/tests/lmstudio-integration.test.ts @@ -0,0 +1,170 @@ +/// +/** + * LM Studio integration tests against a real local server. + * Skipped by default. Enable with: LMSTUDIO_INTEGRATION=1 + * + * Prerequisites: + * 1. LM Studio installed with `lms` CLI + * 2. Server running: lms server start + * 3. Embedding model loaded: lms load text-embedding-nomic-embed-text-v1.5 + * 4. (Optional) Chat model loaded for LLM tests + */ + +import { LMStudioEmbedder } from "../src/embeddings/lmstudio"; +import { LMStudioLLM } from "../src/llms/lmstudio"; + +const LMSTUDIO_BASE_URL = + process.env.LMSTUDIO_BASE_URL || "http://localhost:1234/v1"; +const RUN_INTEGRATION = process.env.LMSTUDIO_INTEGRATION === "1"; +const describeIf = RUN_INTEGRATION ? describe : describe.skip; + +jest.setTimeout(120_000); + +async function listModels(): Promise<{ embedding: string | null; chat: string | null }> { + const res = await fetch(`${LMSTUDIO_BASE_URL}/models`); + const body = await res.json(); + const models: any[] = body.data || []; + const embedding = models.find((m) => m.id.includes("embed") || m.id.includes("nomic")); + const chat = models.find((m) => !m.id.includes("embed") && !m.id.includes("nomic")); + return { embedding: embedding?.id ?? null, chat: chat?.id ?? null }; +} + +function cosineSim(a: number[], b: number[]): number { + let dot = 0, normA = 0, normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom === 0 ? 0 : dot / denom; +} + +describeIf("LM Studio Integration", () => { + it("server is reachable and lists models", async () => { + const res = await fetch(`${LMSTUDIO_BASE_URL}/models`); + expect(res.ok).toBe(true); + const body = await res.json(); + expect(body.data).toBeDefined(); + console.log("Loaded models:", body.data.map((m: any) => m.id)); + }); + + // ─── Embedder ──────────────────────────────────────────────────────── + describe("LMStudioEmbedder (real server)", () => { + let embedder: LMStudioEmbedder; + let modelId: string; + + beforeAll(async () => { + const models = await listModels(); + if (!models.embedding) throw new Error("No embedding model loaded"); + modelId = models.embedding; + embedder = new LMStudioEmbedder({ baseURL: LMSTUDIO_BASE_URL, model: modelId }); + }); + + it("embed() returns a numeric vector", async () => { + const vector = await embedder.embed("Hello world"); + expect(Array.isArray(vector)).toBe(true); + expect(vector.length).toBeGreaterThan(0); + vector.forEach((v) => expect(typeof v).toBe("number")); + console.log(` Model: ${modelId}, dimension: ${vector.length}`); + }); + + it("embed() produces identical output for newline-normalized text", async () => { + const v1 = await embedder.embed("hello world"); + const v2 = await embedder.embed("hello\nworld"); + expect(v1.length).toBe(v2.length); + const totalDiff = v1.reduce((s, val, i) => s + Math.abs(val - v2[i]), 0); + expect(totalDiff).toBeLessThan(0.001); + }); + + it("embedBatch() returns correct number of vectors", async () => { + const vectors = await embedder.embedBatch(["first", "second", "third"]); + expect(vectors).toHaveLength(3); + vectors.forEach((v) => { + expect(v.length).toBe(vectors[0].length); + v.forEach((val) => expect(typeof val).toBe("number")); + }); + }); + + it("semantically similar texts have higher cosine similarity", async () => { + const [v1, v2, v3] = await Promise.all([ + embedder.embed("I love hiking in the mountains"), + embedder.embed("I enjoy trekking through mountain trails"), + embedder.embed("The stock market crashed yesterday"), + ]); + const simSimilar = cosineSim(v1, v2); + const simDifferent = cosineSim(v1, v3); + console.log(` Similar: ${simSimilar.toFixed(4)}, Different: ${simDifferent.toFixed(4)}`); + expect(Number.isFinite(simSimilar)).toBe(true); + expect(Number.isFinite(simDifferent)).toBe(true); + expect(simSimilar).toBeGreaterThan(simDifferent); + }); + + it("embed() handles empty string", async () => { + const vector = await embedder.embed(""); + expect(Array.isArray(vector)).toBe(true); + expect(vector.length).toBeGreaterThan(0); + }); + + it("embed() handles long text", async () => { + const longText = "This is a test sentence. ".repeat(200); + const vector = await embedder.embed(longText); + expect(Array.isArray(vector)).toBe(true); + expect(vector.length).toBeGreaterThan(0); + }); + }); + + // ─── LLM ───────────────────────────────────────────────────────────── + describe("LMStudioLLM (real server)", () => { + let llm: LMStudioLLM; + let chatModelId: string | null; + + beforeAll(async () => { + const models = await listModels(); + chatModelId = models.chat; + if (!chatModelId) { + console.warn("No chat model loaded — LLM tests will be skipped"); + return; + } + llm = new LMStudioLLM({ baseURL: LMSTUDIO_BASE_URL, model: chatModelId }); + }); + + it("generateResponse() returns a response", async () => { + if (!chatModelId) return; + const result = await llm.generateResponse([ + { role: "user", content: "Say hello in exactly 3 words." }, + ]); + if (typeof result === "string") { + expect(result.length).toBeGreaterThan(0); + console.log(` Response (string): ${result.slice(0, 100)}`); + } else { + expect(result).toHaveProperty("content"); + expect(result.content.length).toBeGreaterThan(0); + console.log(` Response (object): ${result.content.slice(0, 100)}`); + } + }); + + it("generateChat() returns LLMResponse with content and role", async () => { + if (!chatModelId) return; + const result = await llm.generateChat([ + { role: "user", content: "What is 2+2?" }, + ]); + expect(result).toHaveProperty("content"); + expect(result).toHaveProperty("role"); + expect(result.role).toBe("assistant"); + expect(result.content.length).toBeGreaterThan(0); + console.log(` Chat: ${result.content.slice(0, 100)}`); + }); + + it("generateChat() handles multi-turn conversation", async () => { + if (!chatModelId) return; + const result = await llm.generateChat([ + { role: "user", content: "My name is Alice." }, + { role: "assistant", content: "Hello Alice!" }, + { role: "user", content: "What is my name?" }, + ]); + expect(result.content.length).toBeGreaterThan(0); + console.log(` Multi-turn: ${result.content.slice(0, 100)}`); + }); + }); +}); diff --git a/mem0-ts/src/oss/tests/lmstudio-llm.test.ts b/mem0-ts/src/oss/tests/lmstudio-llm.test.ts new file mode 100644 index 000000000..e3df824d1 --- /dev/null +++ b/mem0-ts/src/oss/tests/lmstudio-llm.test.ts @@ -0,0 +1,96 @@ +/// +/** + * LM Studio LLM — unit tests (mocked OpenAI). + */ + +import { LMStudioLLM } from "../src/llms/lmstudio"; + +const mockCreate = jest.fn(); + +jest.mock("openai", () => { + return jest.fn().mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })); +}); + +describe("LMStudioLLM (unit)", () => { + beforeEach(() => mockCreate.mockClear()); + + it("generateResponse() returns a text response", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [{ + message: { content: "Hello, world!", role: "assistant", tool_calls: null }, + }], + }); + + const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" }); + const result = await llm.generateResponse([ + { role: "user", content: "Hi" }, + ]); + + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(result).toBe("Hello, world!"); + }); + + it("generateResponse() handles tool calls", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [{ + message: { + content: "", + role: "assistant", + tool_calls: [{ + function: { name: "get_weather", arguments: '{"city": "London"}' }, + }], + }, + }], + }); + + const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" }); + const result = await llm.generateResponse( + [{ role: "user", content: "What is the weather?" }], + undefined, + [{ type: "function", function: { name: "get_weather" } }], + ); + + expect(result).toEqual({ + content: "", + role: "assistant", + toolCalls: [{ name: "get_weather", arguments: '{"city": "London"}' }], + }); + }); + + it("generateResponse() wraps API errors with a clear message", async () => { + mockCreate.mockRejectedValueOnce(new Error("Connection refused")); + + const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" }); + + await expect( + llm.generateResponse([{ role: "user", content: "Hi" }]), + ).rejects.toThrow("LM Studio LLM failed: Connection refused"); + }); + + it("generateChat() returns LLMResponse shape", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [{ + message: { content: "I can help with that.", role: "assistant" }, + }], + }); + + const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" }); + const result = await llm.generateChat([ + { role: "user", content: "Help me" }, + ]); + + expect(result).toEqual({ content: "I can help with that.", role: "assistant" }); + }); + + it("generateChat() wraps API errors with a clear message", async () => { + mockCreate.mockRejectedValueOnce(new Error("Timeout")); + + const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" }); + + await expect( + llm.generateChat([{ role: "user", content: "Hi" }]), + ).rejects.toThrow("LM Studio LLM failed: Timeout"); + }); +}); diff --git a/openclaw/README.md b/openclaw/README.md index a5b6d8d3d..517b2ca6b 100644 --- a/openclaw/README.md +++ b/openclaw/README.md @@ -180,11 +180,11 @@ Works with zero extra config. The `oss` block lets you swap out any component: | Key | Type | Default | | |-----|------|---------|---| | `customPrompt` | `string` | *(built-in)* | Extraction prompt for memory processing | -| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, etc.) | +| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, `"lmstudio"`, etc.) | | `oss.embedder.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL` | | `oss.vectorStore.provider` | `string` | `"memory"` | Vector store (`"memory"`, `"qdrant"`, `"chroma"`, etc.) | | `oss.vectorStore.config` | `object` | — | Provider config: `host`, `port`, `collectionName`, `dimension` | -| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, etc.) | +| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, `"lmstudio"`, etc.) | | `oss.llm.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL`, `temperature` | | `oss.historyDbPath` | `string` | — | SQLite path for memory edit history |