922 lines
30 KiB
TypeScript
922 lines
30 KiB
TypeScript
/// <reference types="jest" />
|
||
import { ConfigManager } from "../src/config/manager";
|
||
import { LLMFactory } from "../src/utils/factory";
|
||
|
||
describe("ConfigManager", () => {
|
||
describe("mergeConfig - dimension handling", () => {
|
||
const baseLlm = {
|
||
provider: "openai",
|
||
config: { apiKey: "test-key" },
|
||
};
|
||
|
||
it("should leave dimension undefined when no explicit dimension or embeddingDims provided", () => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: { provider: "openai", config: { apiKey: "test-key" } },
|
||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||
llm: baseLlm,
|
||
});
|
||
|
||
// Dimension should be undefined so Memory._autoInitialize() will
|
||
// auto-detect it via a probe embedding at runtime.
|
||
expect(config.vectorStore.config.dimension).toBeUndefined();
|
||
});
|
||
|
||
it("should use embeddingDims from embedder config when provided", () => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: {
|
||
provider: "ollama",
|
||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||
},
|
||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||
llm: baseLlm,
|
||
});
|
||
|
||
expect(config.vectorStore.config.dimension).toBe(768);
|
||
});
|
||
|
||
it("should prefer explicit vector store dimension over embedder dims", () => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: {
|
||
provider: "ollama",
|
||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||
},
|
||
vectorStore: {
|
||
provider: "qdrant",
|
||
config: { collectionName: "test", dimension: 1024 },
|
||
},
|
||
llm: baseLlm,
|
||
});
|
||
|
||
expect(config.vectorStore.config.dimension).toBe(1024);
|
||
});
|
||
|
||
it("should leave dimension undefined when using a custom client without explicit dims", () => {
|
||
const mockClient = { someMethod: () => {} };
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: {
|
||
provider: "ollama",
|
||
config: { model: "nomic-embed-text" },
|
||
},
|
||
vectorStore: {
|
||
provider: "qdrant",
|
||
config: { collectionName: "test", client: mockClient },
|
||
},
|
||
llm: baseLlm,
|
||
});
|
||
|
||
// No embeddingDims and no explicit dimension → should be undefined
|
||
// for auto-detection at runtime.
|
||
expect(config.vectorStore.config.dimension).toBeUndefined();
|
||
});
|
||
|
||
it("should use embeddingDims when using a custom client", () => {
|
||
const mockClient = { someMethod: () => {} };
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: {
|
||
provider: "ollama",
|
||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||
},
|
||
vectorStore: {
|
||
provider: "qdrant",
|
||
config: { collectionName: "test", client: mockClient },
|
||
},
|
||
llm: baseLlm,
|
||
});
|
||
|
||
expect(config.vectorStore.config.dimension).toBe(768);
|
||
});
|
||
});
|
||
|
||
describe("mergeConfig - LLM url passthrough for Ollama", () => {
|
||
const baseEmbedder = {
|
||
provider: "openai",
|
||
config: { apiKey: "test-key" },
|
||
};
|
||
const baseVectorStore = {
|
||
provider: "memory",
|
||
config: { collectionName: "test" },
|
||
};
|
||
|
||
it("should preserve url in LLM config when provided", () => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: baseVectorStore,
|
||
llm: {
|
||
provider: "ollama",
|
||
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
|
||
},
|
||
});
|
||
|
||
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
|
||
});
|
||
|
||
it("should prefer baseURL over url when both are provided", () => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: baseVectorStore,
|
||
llm: {
|
||
provider: "ollama",
|
||
config: {
|
||
model: "llama3.2:3b",
|
||
baseURL: "http://custom:11434",
|
||
url: "http://fallback:11434",
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(config.llm.config.baseURL).toBe("http://custom:11434");
|
||
expect(config.llm.config.url).toBe("http://fallback:11434");
|
||
});
|
||
|
||
it("should use url as baseURL fallback when no baseURL provided (issue #4715)", () => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: baseVectorStore,
|
||
llm: {
|
||
provider: "ollama",
|
||
config: { model: "llama3.1:8b", url: "http://my-ollama-host:11434" },
|
||
},
|
||
});
|
||
|
||
expect(config.llm.config.baseURL).toBe("http://my-ollama-host:11434");
|
||
expect(config.llm.config.url).toBe("http://my-ollama-host:11434");
|
||
});
|
||
|
||
it("should not fall back to the OpenAI default baseURL for a non-OpenAI provider", () => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: baseVectorStore,
|
||
llm: {
|
||
provider: "ollama",
|
||
config: { model: "llama3.2:3b" },
|
||
},
|
||
});
|
||
|
||
expect(config.llm.config.url).toBeUndefined();
|
||
// OllamaLLM defaults to http://localhost:11434. The OpenAI default used to be
|
||
// injected here, which pointed OllamaLLM at OpenAI instead.
|
||
expect(config.llm.config.baseURL).toBeUndefined();
|
||
});
|
||
|
||
it("normalizes vllm_base_url to baseURL for vLLM", () => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: baseVectorStore,
|
||
llm: {
|
||
provider: "vllm",
|
||
config: {
|
||
model: "Qwen/Qwen2.5-32B-Instruct",
|
||
vllm_base_url: "http://localhost:8000/v1",
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(config.llm.config.baseURL).toBe("http://localhost:8000/v1");
|
||
});
|
||
|
||
it("does not inject the OpenAI baseURL default for vLLM", () => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: baseVectorStore,
|
||
llm: {
|
||
provider: "vllm",
|
||
config: { model: "Qwen/Qwen2.5-32B-Instruct" },
|
||
},
|
||
});
|
||
|
||
expect(config.llm.config.baseURL).toBeUndefined();
|
||
});
|
||
|
||
it("should preserve url in embedder config (existing behavior)", () => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: {
|
||
provider: "ollama",
|
||
config: {
|
||
model: "nomic-embed-text",
|
||
url: "http://10.0.0.100:11434",
|
||
},
|
||
},
|
||
vectorStore: baseVectorStore,
|
||
llm: {
|
||
provider: "ollama",
|
||
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
|
||
},
|
||
});
|
||
|
||
expect(config.embedder.config.url).toBe("http://10.0.0.100:11434");
|
||
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 - FastEmbed defaults", () => {
|
||
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
|
||
|
||
it("does not inject the OpenAI default embedder model into fastembed", () => {
|
||
const cfg = ConfigManager.mergeConfig({
|
||
embedder: {
|
||
provider: "fastembed",
|
||
config: {},
|
||
},
|
||
vectorStore: { provider: "memory", config: {} },
|
||
llm: baseLlm,
|
||
});
|
||
|
||
expect(cfg.embedder.provider).toBe("fastembed");
|
||
expect(cfg.embedder.config.model).toBeUndefined();
|
||
});
|
||
|
||
it("treats FastEmbed provider casing the same way as the factory", () => {
|
||
const cfg = ConfigManager.mergeConfig({
|
||
embedder: {
|
||
provider: "FastEmbed",
|
||
config: {},
|
||
},
|
||
vectorStore: { provider: "memory", config: {} },
|
||
llm: baseLlm,
|
||
});
|
||
|
||
expect(cfg.embedder.provider).toBe("FastEmbed");
|
||
expect(cfg.embedder.config.model).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
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("does not inject the OpenAI baseURL default for a non-OpenAI provider", () => {
|
||
const cfg = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: { provider: "memory", config: {} },
|
||
llm: { provider: "lmstudio", config: { model: "test-model" } },
|
||
});
|
||
|
||
// The provider supplies its own baseURL (http://localhost:1234/v1) when none is
|
||
// given. Injecting OpenAI's here shadowed it and sent lmstudio traffic to OpenAI.
|
||
expect(cfg.llm.config.baseURL).toBeUndefined();
|
||
});
|
||
|
||
it("does not inject the OpenAI model default for a non-OpenAI provider", () => {
|
||
const cfg = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: { provider: "memory", config: {} },
|
||
llm: { provider: "deepseek", config: { apiKey: "k" } },
|
||
});
|
||
|
||
// DeepSeekLLM falls back to "deepseek-chat" when model is unset. Injecting
|
||
// "gpt-5-mini" here made that fallback unreachable.
|
||
expect(cfg.llm.config.model).toBeUndefined();
|
||
});
|
||
|
||
it("still applies the OpenAI defaults for the OpenAI providers", () => {
|
||
for (const provider of ["openai", "openai_structured"]) {
|
||
const cfg = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: { provider: "memory", config: {} },
|
||
llm: { provider, config: { apiKey: "k" } },
|
||
});
|
||
|
||
expect(cfg.llm.config.baseURL).toBe("https://api.openai.com/v1");
|
||
expect(cfg.llm.config.model).toBe("gpt-5-mini");
|
||
}
|
||
});
|
||
|
||
it("lets each non-OpenAI provider resolve its own endpoint", () => {
|
||
const cases: Array<[string, string]> = [
|
||
["deepseek", "https://api.deepseek.com"],
|
||
["xai", "https://api.x.ai/v1"],
|
||
["lmstudio", "http://localhost:1234/v1"],
|
||
];
|
||
|
||
for (const [provider, expected] of cases) {
|
||
const cfg = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: { provider: "memory", config: {} },
|
||
llm: { provider, config: { apiKey: "k" } },
|
||
});
|
||
|
||
const built = LLMFactory.create(provider, cfg.llm.config);
|
||
// The client the provider actually built must not point at OpenAI.
|
||
const baseURL =
|
||
(built as any).openai?.baseURL ?? (built as any).baseURL;
|
||
expect(String(baseURL)).toBe(expected);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("mergeConfig - AWS Bedrock credential passthrough", () => {
|
||
const baseEmbedder = { provider: "openai", config: { apiKey: "k" } };
|
||
const baseVectorStore = { provider: "memory", config: {} };
|
||
|
||
it("preserves explicit AWS Bedrock credentials through MemoryConfigSchema.parse", () => {
|
||
const cfg = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: baseVectorStore,
|
||
llm: {
|
||
provider: "aws_bedrock",
|
||
config: {
|
||
model: "anthropic.claude-3-sonnet-20240229-v1:0",
|
||
awsRegion: "us-east-1",
|
||
awsAccessKeyId: "AKIAEXAMPLE",
|
||
awsSecretAccessKey: "secret-example",
|
||
awsSessionToken: "session-example",
|
||
} as any,
|
||
},
|
||
});
|
||
|
||
// Regression: zod default parse strips undeclared keys, which silently
|
||
// dropped credentials before .passthrough() + typed fields were added.
|
||
expect(cfg.llm.config.awsRegion).toBe("us-east-1");
|
||
expect(cfg.llm.config.awsAccessKeyId).toBe("AKIAEXAMPLE");
|
||
expect(cfg.llm.config.awsSecretAccessKey).toBe("secret-example");
|
||
expect(cfg.llm.config.awsSessionToken).toBe("session-example");
|
||
});
|
||
|
||
it("normalizes snake_case AWS credentials from the raw config", () => {
|
||
const cfg = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: baseVectorStore,
|
||
llm: {
|
||
provider: "aws_bedrock",
|
||
config: {
|
||
model: "anthropic.claude-3-sonnet-20240229-v1:0",
|
||
aws_region: "eu-west-1",
|
||
aws_access_key_id: "AKIASNAKE",
|
||
aws_secret_access_key: "snake-secret",
|
||
} as any,
|
||
},
|
||
});
|
||
|
||
expect(cfg.llm.config.awsRegion).toBe("eu-west-1");
|
||
expect(cfg.llm.config.awsAccessKeyId).toBe("AKIASNAKE");
|
||
expect(cfg.llm.config.awsSecretAccessKey).toBe("snake-secret");
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe("mergeConfig - provider-specific embedder fields", () => {
|
||
// The embedder config used to be rebuilt from a fixed key list, which
|
||
// dropped every provider-specific field before the embedder was
|
||
// constructed. Vertex AI then authenticated against whatever ambient
|
||
// project ADC resolved to and ignored the configured task types.
|
||
it("preserves Vertex AI fields through the merge", () => {
|
||
const cfg = ConfigManager.mergeConfig({
|
||
embedder: {
|
||
provider: "vertexai",
|
||
config: {
|
||
model: "gemini-embedding-001",
|
||
googleProjectId: "my-proj",
|
||
location: "europe-west4",
|
||
vertexCredentialsJson: "/creds.json",
|
||
memoryAddEmbeddingType: "SEMANTIC_SIMILARITY",
|
||
},
|
||
},
|
||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||
llm: { provider: "openai", config: { apiKey: "test-key" } },
|
||
});
|
||
|
||
expect(cfg.embedder.config).toMatchObject({
|
||
model: "gemini-embedding-001",
|
||
googleProjectId: "my-proj",
|
||
location: "europe-west4",
|
||
vertexCredentialsJson: "/creds.json",
|
||
memoryAddEmbeddingType: "SEMANTIC_SIMILARITY",
|
||
});
|
||
});
|
||
|
||
it("still lets normalized values win over the raw user config", () => {
|
||
const cfg = ConfigManager.mergeConfig({
|
||
embedder: {
|
||
provider: "lmstudio",
|
||
config: {
|
||
lmstudio_base_url: "http://localhost:1234/v1",
|
||
embedding_dims: 768,
|
||
},
|
||
} as never,
|
||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||
llm: { provider: "openai", config: { apiKey: "test-key" } },
|
||
});
|
||
|
||
expect(cfg.embedder.config.baseURL).toBe("http://localhost:1234/v1");
|
||
expect(cfg.embedder.config.embeddingDims).toBe(768);
|
||
// Snake_case aliases are normalized, not passed through to the provider.
|
||
expect(cfg.embedder.config).not.toHaveProperty("lmstudio_base_url");
|
||
expect(cfg.embedder.config).not.toHaveProperty("embedding_dims");
|
||
});
|
||
});
|
||
|
||
describe("mergeConfig - vector store provider normalization", () => {
|
||
const baseLlm = { provider: "openai", config: { apiKey: "test-key" } };
|
||
const baseEmbedder = { provider: "openai", config: { apiKey: "test-key" } };
|
||
|
||
it.each(["Memory", "DATABRICKS", "QdRaNt"])(
|
||
"lowercases the vector store provider %p",
|
||
(provider) => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: { provider, config: { collectionName: "test" } },
|
||
llm: baseLlm,
|
||
});
|
||
|
||
expect(config.vectorStore.provider).toBe(provider.toLowerCase());
|
||
},
|
||
);
|
||
|
||
it("still falls back to the default provider when none is given", () => {
|
||
const config = ConfigManager.mergeConfig({
|
||
embedder: baseEmbedder,
|
||
vectorStore: { config: { collectionName: "test" } } as any,
|
||
llm: baseLlm,
|
||
});
|
||
|
||
expect(config.vectorStore.provider).toBe("memory");
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// 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),
|
||
isTelemetryEnabled: jest.fn(() => false),
|
||
}));
|
||
|
||
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({ filters: { user_id: "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({ filters: { user_id: "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({ filters: { user_id: "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?", {
|
||
filters: { user_id: "u1" },
|
||
});
|
||
|
||
expect(mockEmbedder.embed).toHaveBeenCalledWith(
|
||
"What does the user like?",
|
||
"search",
|
||
);
|
||
expect(mockVStore.search).toHaveBeenCalled();
|
||
expect(result.results).toHaveLength(1);
|
||
expect(result.results[0].memory).toBe("User likes hiking");
|
||
});
|
||
|
||
it("preserves the FastEmbed provider default model through the Memory config path", async () => {
|
||
const mem = new MemoryClass({
|
||
embedder: {
|
||
provider: "fastembed",
|
||
config: {},
|
||
},
|
||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||
llm: {
|
||
provider: "openai",
|
||
config: { apiKey: "test-key" },
|
||
},
|
||
disableHistory: true,
|
||
});
|
||
|
||
await mem.getAll({ filters: { user_id: "u1" } });
|
||
|
||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||
"fastembed",
|
||
expect.objectContaining({
|
||
model: undefined,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("passes AWS Bedrock credentials to LLMFactory through the Memory stack", async () => {
|
||
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: "aws_bedrock",
|
||
config: {
|
||
model: "anthropic.claude-3-sonnet-20240229-v1:0",
|
||
awsRegion: "us-east-1",
|
||
awsAccessKeyId: "AKIAEXAMPLE",
|
||
awsSecretAccessKey: "secret-example",
|
||
},
|
||
},
|
||
disableHistory: true,
|
||
});
|
||
|
||
await mem.getAll({ filters: { user_id: "u1" } });
|
||
|
||
// Regression: credentials must survive ConfigManager -> Memory -> LLMFactory,
|
||
// otherwise Bedrock silently falls back to the ambient AWS credential chain.
|
||
expect(mockLlmFactory.create).toHaveBeenCalledWith(
|
||
"aws_bedrock",
|
||
expect.objectContaining({
|
||
model: "anthropic.claude-3-sonnet-20240229-v1:0",
|
||
awsRegion: "us-east-1",
|
||
awsAccessKeyId: "AKIAEXAMPLE",
|
||
awsSecretAccessKey: "secret-example",
|
||
}),
|
||
);
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|