Files
socraticode/tests/unit/embedding-config.test.ts
Tomasz Szuster 332ee800a8 feat(embeddings): add LM Studio as a first-class embedding provider (#42)
LM Studio's Local Server speaks the OpenAI-compatible /v1/embeddings
protocol, so users running it as their model host (chat plus embedding in
one desktop app, GGUF model management) had no clean integration path.

Changes:

- src/services/provider-lmstudio.ts: new LMStudioEmbeddingProvider wrapping
  the OpenAI SDK with a custom baseURL (default http://localhost:1234/v1).
  Sends a placeholder API key to satisfy the OpenAI SDK while LM Studio's
  Local Server runs without auth by default. Skips the dimensions parameter
  because LM Studio models have no Matryoshka projection. Forces
  encoding_format=float to defeat the OpenAI SDK 6.x base64 default, which
  would otherwise mangle LM Studio's plain-array responses into 1024 zeros.
- src/services/embedding-config.ts: extends the EmbeddingProvider union,
  reads LMSTUDIO_URL and LMSTUDIO_API_KEY, fail-fast validation when
  EMBEDDING_PROVIDER=lmstudio without EMBEDDING_MODEL or EMBEDDING_DIMENSIONS.
- src/services/embedding-provider.ts: factory case for lmstudio with a
  dynamic import to avoid loading the OpenAI SDK at startup for ollama users.
- ensureReady distinguishes "LM Studio unreachable" from "reachable but
  embedding model not loaded" so the operator knows whether to start the
  Local Server or load the configured model.
- src/services/qdrant.ts: minor refactor to extract the hybrid-search query
  payload to a local const for readability.
- README.md: dedicated LM Studio section, MCP host config example, env-var
  table entries.
- tests/unit/embedding-config.test.ts: 8 new cases (required-env validation,
  URL default and override, optional API key, context-length override).
- tests/unit/embedding-provider.test.ts: 3 new cases (factory wiring,
  ensureReady error format against a closed port, healthCheck unreachable
  output).

Backward compatible. The lmstudio provider is opt-in via
EMBEDDING_PROVIDER=lmstudio. Existing ollama, openai, and google paths are
untouched.
2026-05-04 12:47:26 +01:00

333 lines
12 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
getEmbeddingConfig,
loadEmbeddingConfig,
resetEmbeddingConfig,
} from "../../src/services/embedding-config.js";
describe("embedding-config", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
resetEmbeddingConfig();
// Clear all embedding-related env vars
delete process.env.EMBEDDING_PROVIDER;
delete process.env.OLLAMA_MODE;
delete process.env.OLLAMA_URL;
delete process.env.EMBEDDING_MODEL;
delete process.env.EMBEDDING_DIMENSIONS;
delete process.env.EMBEDDING_CONTEXT_LENGTH;
delete process.env.OLLAMA_API_KEY;
delete process.env.OPENAI_API_KEY;
delete process.env.GOOGLE_API_KEY;
delete process.env.LMSTUDIO_URL;
delete process.env.LMSTUDIO_API_KEY;
});
afterEach(() => {
resetEmbeddingConfig();
// Restore original env
process.env = { ...originalEnv };
});
describe("defaults (no env vars)", () => {
it("defaults to ollama provider", () => {
const config = loadEmbeddingConfig();
expect(config.embeddingProvider).toBe("ollama");
});
it("defaults to auto mode", () => {
const config = loadEmbeddingConfig();
expect(config.ollamaMode).toBe("auto");
});
it("defaults URL to localhost:11434 in auto mode", () => {
const config = loadEmbeddingConfig();
expect(config.ollamaUrl).toBe("http://localhost:11434");
});
it("defaults model to nomic-embed-text", () => {
const config = loadEmbeddingConfig();
expect(config.embeddingModel).toBe("nomic-embed-text");
});
it("defaults dimensions to 768", () => {
const config = loadEmbeddingConfig();
expect(config.embeddingDimensions).toBe(768);
});
it("has no API key by default", () => {
const config = loadEmbeddingConfig();
expect(config.ollamaApiKey).toBeUndefined();
});
});
describe("docker mode", () => {
it("uses docker URL default when OLLAMA_MODE=docker", () => {
process.env.OLLAMA_MODE = "docker";
const config = loadEmbeddingConfig();
expect(config.ollamaMode).toBe("docker");
expect(config.ollamaUrl).toBe("http://localhost:11435");
});
it("allows overriding URL in docker mode", () => {
process.env.OLLAMA_MODE = "docker";
process.env.OLLAMA_URL = "http://custom:9999";
const config = loadEmbeddingConfig();
expect(config.ollamaUrl).toBe("http://custom:9999");
});
});
describe("external mode", () => {
it("uses external URL default when OLLAMA_MODE=external", () => {
process.env.OLLAMA_MODE = "external";
const config = loadEmbeddingConfig();
expect(config.ollamaMode).toBe("external");
expect(config.ollamaUrl).toBe("http://localhost:11434");
});
it("allows overriding URL in external mode", () => {
process.env.OLLAMA_MODE = "external";
process.env.OLLAMA_URL = "http://gpu-server:11434";
const config = loadEmbeddingConfig();
expect(config.ollamaUrl).toBe("http://gpu-server:11434");
});
});
describe("model and dimensions overrides", () => {
it("allows custom model", () => {
process.env.EMBEDDING_MODEL = "mxbai-embed-large";
const config = loadEmbeddingConfig();
expect(config.embeddingModel).toBe("mxbai-embed-large");
});
it("allows custom dimensions", () => {
process.env.EMBEDDING_DIMENSIONS = "1024";
const config = loadEmbeddingConfig();
expect(config.embeddingDimensions).toBe(1024);
});
it("parses dimensions as integer", () => {
process.env.EMBEDDING_DIMENSIONS = "512";
const config = loadEmbeddingConfig();
expect(config.embeddingDimensions).toBe(512);
expect(typeof config.embeddingDimensions).toBe("number");
});
});
describe("API key", () => {
it("reads OLLAMA_API_KEY when set", () => {
process.env.OLLAMA_API_KEY = "test-key-123";
const config = loadEmbeddingConfig();
expect(config.ollamaApiKey).toBe("test-key-123");
});
it("is undefined when not set", () => {
const config = loadEmbeddingConfig();
expect(config.ollamaApiKey).toBeUndefined();
});
});
describe("invalid mode", () => {
it("throws for invalid OLLAMA_MODE", () => {
process.env.OLLAMA_MODE = "invalid";
expect(() => loadEmbeddingConfig()).toThrow(
'Invalid OLLAMA_MODE: "invalid". Must be "docker", "external", or "auto".',
);
});
});
describe("singleton behavior", () => {
it("caches config after first load", () => {
const first = loadEmbeddingConfig();
// Change env — should NOT affect cached config
process.env.OLLAMA_MODE = "external";
const second = loadEmbeddingConfig();
expect(second).toBe(first); // same reference
expect(second.ollamaMode).toBe("auto");
});
it("getEmbeddingConfig returns same as loadEmbeddingConfig", () => {
const loaded = loadEmbeddingConfig();
const got = getEmbeddingConfig();
expect(got).toBe(loaded);
});
it("resetEmbeddingConfig clears cache", () => {
const first = loadEmbeddingConfig();
expect(first.ollamaMode).toBe("auto");
resetEmbeddingConfig();
process.env.OLLAMA_MODE = "external";
const second = loadEmbeddingConfig();
expect(second.ollamaMode).toBe("external");
expect(second).not.toBe(first);
});
});
describe("full external config", () => {
it("handles complete external config with all options", () => {
process.env.OLLAMA_MODE = "external";
process.env.OLLAMA_URL = "http://remote-gpu:11434";
process.env.EMBEDDING_MODEL = "mxbai-embed-large";
process.env.EMBEDDING_DIMENSIONS = "1024";
process.env.OLLAMA_API_KEY = "secret";
const config = loadEmbeddingConfig();
expect(config).toEqual({
embeddingProvider: "ollama",
ollamaMode: "external",
ollamaUrl: "http://remote-gpu:11434",
lmstudioUrl: "http://localhost:1234/v1",
embeddingModel: "mxbai-embed-large",
embeddingDimensions: 1024,
embeddingContextLength: 512,
ollamaApiKey: "secret",
});
});
});
describe("embedding provider selection", () => {
it("defaults to ollama when EMBEDDING_PROVIDER is not set", () => {
const config = loadEmbeddingConfig();
expect(config.embeddingProvider).toBe("ollama");
expect(config.embeddingModel).toBe("nomic-embed-text");
expect(config.embeddingDimensions).toBe(768);
});
it("selects openai provider with correct defaults", () => {
process.env.EMBEDDING_PROVIDER = "openai";
const config = loadEmbeddingConfig();
expect(config.embeddingProvider).toBe("openai");
expect(config.embeddingModel).toBe("text-embedding-3-small");
expect(config.embeddingDimensions).toBe(1536);
expect(config.embeddingContextLength).toBe(8191);
});
it("selects google provider with correct defaults", () => {
process.env.EMBEDDING_PROVIDER = "google";
const config = loadEmbeddingConfig();
expect(config.embeddingProvider).toBe("google");
expect(config.embeddingModel).toBe("gemini-embedding-001");
expect(config.embeddingDimensions).toBe(3072);
expect(config.embeddingContextLength).toBe(2048);
});
it("throws for invalid EMBEDDING_PROVIDER", () => {
process.env.EMBEDDING_PROVIDER = "anthropic";
expect(() => loadEmbeddingConfig()).toThrow(
'Invalid EMBEDDING_PROVIDER: "anthropic". Must be "ollama", "openai", "google", or "lmstudio".',
);
});
it("allows overriding model for openai provider", () => {
process.env.EMBEDDING_PROVIDER = "openai";
process.env.EMBEDDING_MODEL = "text-embedding-3-large";
const config = loadEmbeddingConfig();
expect(config.embeddingModel).toBe("text-embedding-3-large");
expect(config.embeddingContextLength).toBe(8191);
});
it("allows overriding dimensions for openai provider", () => {
process.env.EMBEDDING_PROVIDER = "openai";
process.env.EMBEDDING_DIMENSIONS = "256";
const config = loadEmbeddingConfig();
expect(config.embeddingDimensions).toBe(256);
});
it("uses context length for unknown model", () => {
process.env.EMBEDDING_PROVIDER = "openai";
process.env.EMBEDDING_MODEL = "custom-embed-model";
const config = loadEmbeddingConfig();
expect(config.embeddingContextLength).toBe(0);
});
it("allows explicit context length override for any provider", () => {
process.env.EMBEDDING_PROVIDER = "google";
process.env.EMBEDDING_CONTEXT_LENGTH = "4096";
const config = loadEmbeddingConfig();
expect(config.embeddingContextLength).toBe(4096);
});
});
describe("lmstudio provider", () => {
it("loads when EMBEDDING_MODEL and EMBEDDING_DIMENSIONS are set", () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_MODEL = "nomic-embed-text-v1.5";
process.env.EMBEDDING_DIMENSIONS = "768";
const config = loadEmbeddingConfig();
expect(config.embeddingProvider).toBe("lmstudio");
expect(config.embeddingModel).toBe("nomic-embed-text-v1.5");
expect(config.embeddingDimensions).toBe(768);
});
it("defaults LMSTUDIO_URL to http://localhost:1234/v1", () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_MODEL = "nomic-embed-text-v1.5";
process.env.EMBEDDING_DIMENSIONS = "768";
const config = loadEmbeddingConfig();
expect(config.lmstudioUrl).toBe("http://localhost:1234/v1");
});
it("respects LMSTUDIO_URL override", () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_MODEL = "nomic-embed-text-v1.5";
process.env.EMBEDDING_DIMENSIONS = "768";
process.env.LMSTUDIO_URL = "http://gpu-rig.local:5678/v1";
const config = loadEmbeddingConfig();
expect(config.lmstudioUrl).toBe("http://gpu-rig.local:5678/v1");
});
it("throws when EMBEDDING_MODEL is missing", () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_DIMENSIONS = "768";
expect(() => loadEmbeddingConfig()).toThrow(
/EMBEDDING_MODEL is required when EMBEDDING_PROVIDER=lmstudio/,
);
});
it("throws when EMBEDDING_DIMENSIONS is missing", () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_MODEL = "nomic-embed-text-v1.5";
expect(() => loadEmbeddingConfig()).toThrow(
/EMBEDDING_DIMENSIONS is required when EMBEDDING_PROVIDER=lmstudio/,
);
});
it("includes example dimensions in the error message for discoverability", () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_MODEL = "nomic-embed-text-v1.5";
expect(() => loadEmbeddingConfig()).toThrow(
/768 for nomic-embed-text-v1\.5/,
);
});
it("does not require LMSTUDIO_API_KEY", () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_MODEL = "nomic-embed-text-v1.5";
process.env.EMBEDDING_DIMENSIONS = "768";
// Intentionally no LMSTUDIO_API_KEY — LM Studio's Local Server has no auth by default.
expect(() => loadEmbeddingConfig()).not.toThrow();
});
it("respects EMBEDDING_CONTEXT_LENGTH override", () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_MODEL = "qwen3-embedding-8b";
process.env.EMBEDDING_DIMENSIONS = "4096";
process.env.EMBEDDING_CONTEXT_LENGTH = "32768";
const config = loadEmbeddingConfig();
expect(config.embeddingContextLength).toBe(32768);
});
});
});