mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
332ee800a8
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.
333 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|