mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
1708510c16
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 + commitbb141a0) 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 frombb141a0ports 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>
302 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|