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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>)?.lmstudio_base_url as string | undefined ??
|
||||
userConf?.url;
|
||||
const embeddingDims =
|
||||
userConf?.embeddingDims ??
|
||||
(userConf as Record<string, unknown>)?.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<string, unknown>)?.lmstudio_base_url as string | undefined ??
|
||||
defaultConf.baseURL;
|
||||
|
||||
return {
|
||||
baseURL: userConf?.baseURL || defaultConf.baseURL,
|
||||
baseURL: llmBaseURL,
|
||||
url: userConf?.url,
|
||||
apiKey:
|
||||
userConf?.apiKey !== undefined
|
||||
|
||||
@@ -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<number[]> {
|
||||
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<number[][]> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string | LLMResponse> {
|
||||
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<LLMResponse> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
/// <reference types="jest" />
|
||||
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", () => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* 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)}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -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 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user