Files
socraticode/tests/unit/embedding-provider.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

302 lines
11 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 { resetEmbeddingConfig } from "../../src/services/embedding-config.js";
import { getEmbeddingProvider, resetEmbeddingProvider } from "../../src/services/embedding-provider.js";
describe("embedding-provider", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
resetEmbeddingConfig();
resetEmbeddingProvider();
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();
resetEmbeddingProvider();
process.env = { ...originalEnv };
});
describe("factory", () => {
it("defaults to OllamaEmbeddingProvider", async () => {
const provider = await getEmbeddingProvider();
expect(provider.name).toBe("ollama");
});
it("creates OpenAIEmbeddingProvider when configured", async () => {
process.env.EMBEDDING_PROVIDER = "openai";
const provider = await getEmbeddingProvider();
expect(provider.name).toBe("openai");
});
it("creates GoogleEmbeddingProvider when configured", async () => {
process.env.EMBEDDING_PROVIDER = "google";
const provider = await getEmbeddingProvider();
expect(provider.name).toBe("google");
});
it("creates LMStudioEmbeddingProvider when configured", async () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_MODEL = "nomic-embed-text-v1.5";
process.env.EMBEDDING_DIMENSIONS = "768";
const provider = await getEmbeddingProvider();
expect(provider.name).toBe("lmstudio");
});
it("creates LiteLLMEmbeddingProvider when configured", async () => {
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 provider = await getEmbeddingProvider();
expect(provider.name).toBe("litellm");
});
it("caches provider instance", async () => {
const p1 = await getEmbeddingProvider();
const p2 = await getEmbeddingProvider();
expect(p1).toBe(p2);
});
it("recreates provider when config changes", async () => {
const p1 = await getEmbeddingProvider();
expect(p1.name).toBe("ollama");
resetEmbeddingConfig();
resetEmbeddingProvider();
process.env.EMBEDDING_PROVIDER = "openai";
const p2 = await getEmbeddingProvider();
expect(p2.name).toBe("openai");
expect(p2).not.toBe(p1);
});
});
});
describe("OpenAIEmbeddingProvider", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
resetEmbeddingConfig();
resetEmbeddingProvider();
delete process.env.EMBEDDING_PROVIDER;
delete process.env.OPENAI_API_KEY;
delete process.env.EMBEDDING_MODEL;
delete process.env.EMBEDDING_DIMENSIONS;
});
afterEach(() => {
resetEmbeddingConfig();
resetEmbeddingProvider();
process.env = { ...originalEnv };
});
it("throws when OPENAI_API_KEY is not set", async () => {
process.env.EMBEDDING_PROVIDER = "openai";
const provider = await getEmbeddingProvider();
await expect(provider.ensureReady()).rejects.toThrow("OPENAI_API_KEY");
});
it("reports missing API key in health check", async () => {
process.env.EMBEDDING_PROVIDER = "openai";
const provider = await getEmbeddingProvider();
const health = await provider.healthCheck();
expect(health.available).toBe(false);
expect(health.modelReady).toBe(false);
expect(health.statusLines.some((l) => l.includes("Missing"))).toBe(true);
});
});
describe("GoogleEmbeddingProvider", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
resetEmbeddingConfig();
resetEmbeddingProvider();
delete process.env.EMBEDDING_PROVIDER;
delete process.env.GOOGLE_API_KEY;
delete process.env.EMBEDDING_MODEL;
delete process.env.EMBEDDING_DIMENSIONS;
});
afterEach(() => {
resetEmbeddingConfig();
resetEmbeddingProvider();
process.env = { ...originalEnv };
});
it("throws when GOOGLE_API_KEY is not set", async () => {
process.env.EMBEDDING_PROVIDER = "google";
const provider = await getEmbeddingProvider();
await expect(provider.ensureReady()).rejects.toThrow("GOOGLE_API_KEY");
});
it("reports missing API key in health check", async () => {
process.env.EMBEDDING_PROVIDER = "google";
const provider = await getEmbeddingProvider();
const health = await provider.healthCheck();
expect(health.available).toBe(false);
expect(health.modelReady).toBe(false);
expect(health.statusLines.some((l) => l.includes("Missing"))).toBe(true);
});
});
describe("LMStudioEmbeddingProvider", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
resetEmbeddingConfig();
resetEmbeddingProvider();
delete process.env.EMBEDDING_PROVIDER;
delete process.env.EMBEDDING_MODEL;
delete process.env.EMBEDDING_DIMENSIONS;
delete process.env.EMBEDDING_CONTEXT_LENGTH;
delete process.env.LMSTUDIO_URL;
delete process.env.LMSTUDIO_API_KEY;
});
afterEach(() => {
resetEmbeddingConfig();
resetEmbeddingProvider();
process.env = { ...originalEnv };
});
it("ensureReady throws an actionable error when LM Studio is unreachable", async () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_MODEL = "nomic-embed-text-v1.5";
process.env.EMBEDDING_DIMENSIONS = "768";
// Point at a deliberately closed port so the request fails fast.
process.env.LMSTUDIO_URL = "http://127.0.0.1:1/v1";
const provider = await getEmbeddingProvider();
await expect(provider.ensureReady()).rejects.toThrow(
/LM Studio is not reachable at http:\/\/127\.0\.0\.1:1\/v1/,
);
});
it("healthCheck reports unreachable LM Studio without throwing", async () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_MODEL = "nomic-embed-text-v1.5";
process.env.EMBEDDING_DIMENSIONS = "768";
process.env.LMSTUDIO_URL = "http://127.0.0.1:1/v1";
const provider = await getEmbeddingProvider();
const health = await provider.healthCheck();
expect(health.available).toBe(false);
expect(health.modelReady).toBe(false);
expect(health.statusLines.some((l) => l.includes("LM Studio") && l.includes("Not reachable"))).toBe(true);
});
it("does not require LMSTUDIO_API_KEY to construct the provider", async () => {
process.env.EMBEDDING_PROVIDER = "lmstudio";
process.env.EMBEDDING_MODEL = "nomic-embed-text-v1.5";
process.env.EMBEDDING_DIMENSIONS = "768";
// Intentionally no LMSTUDIO_API_KEY.
const provider = await getEmbeddingProvider();
expect(provider.name).toBe("lmstudio");
});
});
describe("LiteLLMEmbeddingProvider", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
resetEmbeddingConfig();
resetEmbeddingProvider();
delete process.env.EMBEDDING_PROVIDER;
delete process.env.EMBEDDING_MODEL;
delete process.env.EMBEDDING_DIMENSIONS;
delete process.env.EMBEDDING_CONTEXT_LENGTH;
delete process.env.LITELLM_URL;
delete process.env.LITELLM_API_KEY;
delete process.env.LITELLM_SEND_DIMENSIONS;
});
afterEach(() => {
resetEmbeddingConfig();
resetEmbeddingProvider();
process.env = { ...originalEnv };
});
it("config validation rejects construction when LITELLM_API_KEY is missing", async () => {
// The API key is checked at config-load time (loadEmbeddingConfig), not at
// factory invocation, so the throw surfaces inside getEmbeddingProvider().
process.env.EMBEDDING_PROVIDER = "litellm";
process.env.EMBEDDING_MODEL = "text-embedding-3-small";
process.env.EMBEDDING_DIMENSIONS = "1536";
// Intentionally no LITELLM_API_KEY.
await expect(getEmbeddingProvider()).rejects.toThrow(/LITELLM_API_KEY is required/);
});
it("ensureReady throws an actionable error when the proxy is unreachable", async () => {
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";
// Closed port → SDK fails fast with a connection error, not an auth error.
process.env.LITELLM_URL = "http://127.0.0.1:1/v1";
const provider = await getEmbeddingProvider();
await expect(provider.ensureReady()).rejects.toThrow(
/LiteLLM proxy is not reachable at http:\/\/127\.0\.0\.1:1\/v1/,
);
});
it("healthCheck reports missing API key without making any network call", async () => {
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";
// Even with a deliberately closed port, missing-key path must short-circuit
// before the SDK attempts a connection, so the test stays deterministic.
process.env.LITELLM_URL = "http://127.0.0.1:1/v1";
const provider = await getEmbeddingProvider();
// Now drop the key for the health-check call only — the provider re-reads
// process.env on each invocation (intentional, see provider-litellm.ts).
delete process.env.LITELLM_API_KEY;
const health = await provider.healthCheck();
expect(health.available).toBe(false);
expect(health.modelReady).toBe(false);
expect(health.statusLines.some((l) => l.includes("LiteLLM API key") && l.includes("Missing"))).toBe(true);
});
it("healthCheck reports unreachable proxy without throwing", async () => {
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";
process.env.LITELLM_URL = "http://127.0.0.1:1/v1";
const provider = await getEmbeddingProvider();
const health = await provider.healthCheck();
expect(health.available).toBe(false);
expect(health.modelReady).toBe(false);
expect(
health.statusLines.some(
(l) => l.includes("LiteLLM") && l.includes("Not reachable"),
),
).toBe(true);
});
});