Files
socraticode/tests/unit/embedding-config.test.ts
T
Tomasz Szuster 1708510c16 feat(embeddings): add LiteLLM as a first-class embedding provider
LiteLLM Proxy Server (https://docs.litellm.ai/docs/simple_proxy) exposes an
OpenAI-compatible /v1/embeddings endpoint and fans out to 100+ underlying
providers (OpenAI, Anthropic, Cohere, Voyage, HuggingFace, Bedrock, Vertex AI,
Ollama, ...). Mirroring the lmstudio strategy (PR #42 + commit bb141a0) but
with three meaningful differences that justify a dedicated provider rather
than a flag on provider-openai:

- Authentication is mandatory. LiteLLM gates /v1/models with the master key
  or a virtual key, unlike LM Studio (no auth by default) and OpenAI (cloud
  key). LITELLM_API_KEY is checked at config-load time; the provider also
  duck-types 401/403 in ensureReady/healthCheck via err.status to surface a
  distinct "auth rejected" message vs. "proxy unreachable".
- Model aliases come from the proxy's config.yaml, so EMBEDDING_MODEL and
  EMBEDDING_DIMENSIONS have no sensible defaults. Fail-fast in
  loadEmbeddingConfig with provider-specific error messages pointing at
  litellm_params.model in the proxy config and at the underlying alias's
  output dim.
- Whether dimensions can be forwarded depends on the underlying provider:
  Matryoshka-aware models (text-embedding-3-*, voyage-3) accept it,
  non-Matryoshka backends (BGE, nomic, Cohere v3) reject. Made opt-in via
  LITELLM_SEND_DIMENSIONS=true rather than hardcoded like provider-openai
  does for text-embedding-3-*, since LiteLLM aliases are user-defined.

Encoding-format=float fix from bb141a0 ports verbatim — the OpenAI SDK 6.x
base64-decode path corrupts any backend that returns plain JSON float arrays
(many LiteLLM aliases do, including Ollama-routed and tei-wrapped ones).

Files:

- src/services/provider-litellm.ts: new LiteLLMEmbeddingProvider with the
  same OpenAI-SDK + custom baseURL pattern. Default baseURL
  http://localhost:4000/v1 (LiteLLM's default port, /v1 prefix required).
  Batch size 256 — between OpenAI's 512 and LM Studio's 64, since the
  practical ceiling depends on whichever provider the alias resolves to.
  ensureReady distinguishes proxy-unreachable / auth-rejected /
  alias-not-registered. Lists up to 10 currently-registered models in the
  alias-missing error so the operator can sanity-check their config.yaml
  without leaving the log.
- src/services/embedding-config.ts: extends EmbeddingProvider union with
  "litellm", adds litellmUrl to EmbeddingConfig, fail-fast validation for
  LITELLM_API_KEY + EMBEDDING_MODEL + EMBEDDING_DIMENSIONS (key first so a
  virtual-key user fixes the easy problem before touching the proxy
  config), updates Invalid EMBEDDING_PROVIDER message and hasApiKey log
  expression.
- src/services/embedding-provider.ts: factory case for litellm with dynamic
  import to avoid loading the OpenAI SDK at startup for non-litellm users.
- README.md: dedicated LiteLLM section, MCP host config example, env-var
  table entries for EMBEDDING_PROVIDER / EMBEDDING_MODEL /
  EMBEDDING_DIMENSIONS / EMBEDDING_CONTEXT_LENGTH (clarifying which require
  manual values for litellm), new LiteLLM Configuration table.
- tests/unit/embedding-config.test.ts: 9 new cases (model + dim + key
  required, error-ordering, URL default + override, dimensions parsing,
  EMBEDDING_CONTEXT_LENGTH override for unknown aliases, auto-detection
  when alias matches a known model name) plus updated "full external
  config" expected object and updated invalid-provider error message.
- tests/unit/embedding-provider.test.ts: factory test for litellm, plus 4
  cases against a deliberately-closed port (config rejects construction
  without API_KEY, ensureReady unreachable error format, healthCheck
  short-circuits on missing key without a network call, healthCheck
  reaches "Not reachable" path without throwing).

Backward compatible. The litellm provider is opt-in via
EMBEDDING_PROVIDER=litellm. Existing ollama, openai, google, and lmstudio
paths are untouched.

Verified: 64/64 unit tests pass on the touched suites; biome lint clean;
tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:58:51 +02:00

446 lines
16 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;
delete process.env.LITELLM_URL;
delete process.env.LITELLM_API_KEY;
delete process.env.LITELLM_SEND_DIMENSIONS;
});
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",
litellmUrl: "http://localhost:4000/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", "lmstudio", or "litellm".',
);
});
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);
});
});
describe("litellm provider", () => {
it("loads when API key, model, and dimensions are all set", () => {
process.env.EMBEDDING_PROVIDER = "litellm";
process.env.LITELLM_API_KEY = "sk-master-test";
process.env.EMBEDDING_MODEL = "text-embedding-3-small";
process.env.EMBEDDING_DIMENSIONS = "1536";
const config = loadEmbeddingConfig();
expect(config.embeddingProvider).toBe("litellm");
expect(config.embeddingModel).toBe("text-embedding-3-small");
expect(config.embeddingDimensions).toBe(1536);
});
it("defaults LITELLM_URL to http://localhost:4000/v1", () => {
process.env.EMBEDDING_PROVIDER = "litellm";
process.env.LITELLM_API_KEY = "sk-master-test";
process.env.EMBEDDING_MODEL = "text-embedding-3-small";
process.env.EMBEDDING_DIMENSIONS = "1536";
const config = loadEmbeddingConfig();
expect(config.litellmUrl).toBe("http://localhost:4000/v1");
});
it("respects LITELLM_URL override", () => {
process.env.EMBEDDING_PROVIDER = "litellm";
process.env.LITELLM_API_KEY = "sk-master-test";
process.env.EMBEDDING_MODEL = "voyage-2";
process.env.EMBEDDING_DIMENSIONS = "1024";
process.env.LITELLM_URL = "https://litellm.internal:4001/v1";
const config = loadEmbeddingConfig();
expect(config.litellmUrl).toBe("https://litellm.internal:4001/v1");
});
it("throws when LITELLM_API_KEY is missing", () => {
process.env.EMBEDDING_PROVIDER = "litellm";
process.env.EMBEDDING_MODEL = "text-embedding-3-small";
process.env.EMBEDDING_DIMENSIONS = "1536";
expect(() => loadEmbeddingConfig()).toThrow(
/LITELLM_API_KEY is required when EMBEDDING_PROVIDER=litellm/,
);
});
it("throws when EMBEDDING_MODEL is missing", () => {
process.env.EMBEDDING_PROVIDER = "litellm";
process.env.LITELLM_API_KEY = "sk-master-test";
process.env.EMBEDDING_DIMENSIONS = "1536";
expect(() => loadEmbeddingConfig()).toThrow(
/EMBEDDING_MODEL is required when EMBEDDING_PROVIDER=litellm/,
);
});
it("throws when EMBEDDING_DIMENSIONS is missing", () => {
process.env.EMBEDDING_PROVIDER = "litellm";
process.env.LITELLM_API_KEY = "sk-master-test";
process.env.EMBEDDING_MODEL = "text-embedding-3-small";
expect(() => loadEmbeddingConfig()).toThrow(
/EMBEDDING_DIMENSIONS is required when EMBEDDING_PROVIDER=litellm/,
);
});
it("validates the API_KEY check before model/dimension checks", () => {
// All three are missing — the API key error must surface first because it's
// the only one a virtual-key user can fix without touching the proxy config.
process.env.EMBEDDING_PROVIDER = "litellm";
expect(() => loadEmbeddingConfig()).toThrow(
/LITELLM_API_KEY is required/,
);
});
it("includes example dimensions in the error message for discoverability", () => {
process.env.EMBEDDING_PROVIDER = "litellm";
process.env.LITELLM_API_KEY = "sk-master-test";
process.env.EMBEDDING_MODEL = "text-embedding-3-small";
expect(() => loadEmbeddingConfig()).toThrow(
/1536 for text-embedding-3-small/,
);
});
it("respects EMBEDDING_CONTEXT_LENGTH override for unknown proxy aliases", () => {
process.env.EMBEDDING_PROVIDER = "litellm";
process.env.LITELLM_API_KEY = "sk-master-test";
process.env.EMBEDDING_MODEL = "team-internal-bge-large";
process.env.EMBEDDING_DIMENSIONS = "1024";
process.env.EMBEDDING_CONTEXT_LENGTH = "8192";
const config = loadEmbeddingConfig();
expect(config.embeddingContextLength).toBe(8192);
});
it("auto-detects context length when alias matches a well-known model name", () => {
// LiteLLM aliases are usually named after the underlying model; the context-length
// table lookup uses the alias verbatim, so the operator gets free auto-detection
// when they keep names aligned.
process.env.EMBEDDING_PROVIDER = "litellm";
process.env.LITELLM_API_KEY = "sk-master-test";
process.env.EMBEDDING_MODEL = "text-embedding-3-small";
process.env.EMBEDDING_DIMENSIONS = "1536";
const config = loadEmbeddingConfig();
expect(config.embeddingContextLength).toBe(8191);
});
});
});