| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263 |
- /**
- * embedding-factory.test.ts - Tests for createEmbeddingProvider factory.
- *
- * Verifies the resolution precedence:
- * 1. explicit `kind` argument
- * 2. QMD_EMBED_PROVIDER env
- * 3. QMD_EMBED_ENDPOINT env (forces openai)
- * 4. config file `embedProvider.kind` / `embedProvider.endpoint`
- * 5. fallback: local
- */
- import { describe, test, expect, beforeEach, afterEach } from "vitest";
- import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
- import { tmpdir } from "node:os";
- import { join } from "node:path";
- import {
- resolveProviderKind,
- createEmbeddingProvider,
- loadConfigFile,
- } from "../src/embedding/factory.js";
- import { OpenAIEmbeddingsProvider } from "../src/embedding/openai.js";
- import { LocalLlamaCppProvider } from "../src/embedding/local.js";
- let workDir: string;
- let configPath: string;
- beforeEach(() => {
- workDir = mkdtempSync(join(tmpdir(), "qmd-factory-test-"));
- mkdirSync(join(workDir, "qmd"), { recursive: true });
- configPath = join(workDir, "qmd", "config.json");
- });
- afterEach(() => {
- rmSync(workDir, { recursive: true, force: true });
- });
- // ─────────────────────────── Helpers ─────────────────────────────────────────
- function writeConfig(obj: Record<string, unknown>) {
- writeFileSync(configPath, JSON.stringify(obj));
- }
- const EMPTY_ENV: Record<string, string | undefined> = {};
- // ─────────────────────────── resolveProviderKind ─────────────────────────────
- describe("resolveProviderKind", () => {
- test("explicit kind argument wins", () => {
- expect(
- resolveProviderKind({
- kind: "local",
- env: { QMD_EMBED_ENDPOINT: "https://x" },
- configPath,
- }),
- ).toBe("local");
- expect(
- resolveProviderKind({
- kind: "openai",
- env: EMPTY_ENV,
- configPath,
- }),
- ).toBe("openai");
- });
- test("QMD_EMBED_PROVIDER env wins over QMD_EMBED_ENDPOINT", () => {
- expect(
- resolveProviderKind({
- env: { QMD_EMBED_PROVIDER: "local", QMD_EMBED_ENDPOINT: "https://x" },
- configPath,
- }),
- ).toBe("local");
- });
- test("QMD_EMBED_ENDPOINT presence → openai", () => {
- expect(
- resolveProviderKind({
- env: { QMD_EMBED_ENDPOINT: "https://ai.example.com" },
- configPath,
- }),
- ).toBe("openai");
- });
- test("QMD_EMBED_ENDPOINT empty string ignored", () => {
- expect(
- resolveProviderKind({
- env: { QMD_EMBED_ENDPOINT: "" },
- configPath,
- }),
- ).toBe("local");
- });
- test("config file embedProvider.kind respected", () => {
- writeConfig({ embedProvider: { kind: "openai", endpoint: "https://ai.example.com" } });
- expect(resolveProviderKind({ env: EMPTY_ENV, configPath })).toBe("openai");
- });
- test("config file embedProvider.endpoint alone → openai", () => {
- writeConfig({ embedProvider: { endpoint: "https://ai.example.com" } });
- expect(resolveProviderKind({ env: EMPTY_ENV, configPath })).toBe("openai");
- });
- test("no signal anywhere → local fallback", () => {
- expect(resolveProviderKind({ env: EMPTY_ENV, configPath })).toBe("local");
- });
- test("invalid env QMD_EMBED_PROVIDER is ignored", () => {
- expect(
- resolveProviderKind({
- env: { QMD_EMBED_PROVIDER: "garbage" },
- configPath,
- }),
- ).toBe("local");
- });
- test("uppercase env QMD_EMBED_PROVIDER normalized", () => {
- expect(
- resolveProviderKind({
- env: { QMD_EMBED_PROVIDER: "OPENAI", QMD_EMBED_ENDPOINT: "https://x" },
- configPath,
- }),
- ).toBe("openai");
- });
- });
- // ─────────────────────────── createEmbeddingProvider ─────────────────────────
- describe("createEmbeddingProvider", () => {
- test("openai kind w/ endpoint env → OpenAIEmbeddingsProvider", () => {
- const p = createEmbeddingProvider({
- env: { QMD_EMBED_ENDPOINT: "https://ai.example.com" },
- configPath,
- });
- expect(p).toBeInstanceOf(OpenAIEmbeddingsProvider);
- expect(p.kind).toBe("openai");
- expect(p.getModelId()).toBe("embeddinggemma");
- });
- test("openai kind w/ explicit options merges over env", () => {
- const p = createEmbeddingProvider({
- env: { QMD_EMBED_ENDPOINT: "https://env.example.com", QMD_EMBED_API_KEY: "env-key" },
- configPath,
- openai: { endpoint: "https://override.example.com" },
- });
- // Cast to access internal properties for verification
- const inner = p as OpenAIEmbeddingsProvider & { endpoint: string; apiKey: string };
- expect(inner["endpoint"]).toBe("https://override.example.com");
- // apiKey should still come from env since we didn't override it
- expect(inner["apiKey"]).toBe("env-key");
- });
- test("openai kind reads modelId from env", () => {
- const p = createEmbeddingProvider({
- env: {
- QMD_EMBED_ENDPOINT: "https://ai.example.com",
- QMD_EMBED_MODEL_ID: "custom-model",
- },
- configPath,
- });
- expect(p.getModelId()).toBe("custom-model");
- });
- test("openai kind reads upstream model from env", () => {
- const p = createEmbeddingProvider({
- env: {
- QMD_EMBED_ENDPOINT: "https://ai.example.com",
- QMD_EMBED_UPSTREAM_MODEL: "embeddinggemma:300m",
- },
- configPath,
- }) as OpenAIEmbeddingsProvider & { upstreamModel: string };
- expect(p["upstreamModel"]).toBe("embeddinggemma:300m");
- });
- test("openai kind reads batch size and timeout from env", () => {
- const p = createEmbeddingProvider({
- env: {
- QMD_EMBED_ENDPOINT: "https://ai.example.com",
- QMD_EMBED_BATCH_SIZE: "32",
- QMD_EMBED_TIMEOUT_MS: "5000",
- },
- configPath,
- }) as OpenAIEmbeddingsProvider & { batchSize: number; timeoutMs: number };
- expect(p["batchSize"]).toBe(32);
- expect(p["timeoutMs"]).toBe(5000);
- });
- test("openai kind merges config file values", () => {
- writeConfig({
- embedProvider: {
- kind: "openai",
- endpoint: "https://config.example.com",
- apiKey: "config-key",
- modelId: "config-model",
- batchSize: 16,
- },
- });
- const p = createEmbeddingProvider({
- env: EMPTY_ENV,
- configPath,
- }) as OpenAIEmbeddingsProvider & {
- endpoint: string;
- apiKey: string;
- batchSize: number;
- };
- expect(p["endpoint"]).toBe("https://config.example.com");
- expect(p["apiKey"]).toBe("config-key");
- expect(p.getModelId()).toBe("config-model");
- expect(p["batchSize"]).toBe(16);
- });
- test("env wins over config file", () => {
- writeConfig({
- embedProvider: {
- endpoint: "https://config.example.com",
- },
- });
- const p = createEmbeddingProvider({
- env: { QMD_EMBED_ENDPOINT: "https://env.example.com" },
- configPath,
- }) as OpenAIEmbeddingsProvider & { endpoint: string };
- expect(p["endpoint"]).toBe("https://env.example.com");
- });
- test("openai kind without endpoint throws", () => {
- expect(() =>
- createEmbeddingProvider({ kind: "openai", env: EMPTY_ENV, configPath }),
- ).toThrow(/endpoint/);
- });
- test("local kind explicitly requested → LocalLlamaCppProvider", () => {
- const p = createEmbeddingProvider({
- kind: "local",
- env: EMPTY_ENV,
- configPath,
- });
- expect(p).toBeInstanceOf(LocalLlamaCppProvider);
- expect(p.kind).toBe("local");
- });
- test("default fallback → LocalLlamaCppProvider", () => {
- const p = createEmbeddingProvider({ env: EMPTY_ENV, configPath });
- expect(p).toBeInstanceOf(LocalLlamaCppProvider);
- });
- });
- // ─────────────────────────── loadConfigFile ──────────────────────────────────
- describe("loadConfigFile", () => {
- test("missing file → empty object", () => {
- expect(loadConfigFile(join(workDir, "missing.json"))).toEqual({});
- });
- test("invalid JSON → empty object (no throw)", () => {
- writeFileSync(configPath, "not json");
- expect(loadConfigFile(configPath)).toEqual({});
- });
- test("valid JSON parsed", () => {
- writeConfig({ embedProvider: { kind: "openai" } });
- expect(loadConfigFile(configPath)).toEqual({
- embedProvider: { kind: "openai" },
- });
- });
- });
|