feat(mem0-ts): add LM Studio embedder and LLM support (#4354)

Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Utkarsh
2026-03-17 18:21:35 +05:30
committed by GitHub
parent 9eb5b9ed29
commit 336fbce60a
11 changed files with 861 additions and 6 deletions
+18 -3
View File
@@ -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}`);
}
}
}
+2
View File
@@ -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";
+41
View File
@@ -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}`);
}
}
}
+6
View File
@@ -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();
});
});
+24 -1
View File
@@ -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
View File
@@ -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 |