/** * factory.ts - EmbeddingProvider factory with config precedence. * * Resolution order (first match wins): * 1. Explicit `kind` argument or `--provider` CLI flag → forces a kind * 2. `QMD_EMBED_ENDPOINT` env var present and non-empty → "openai" * 3. Config file (`~/.config/qmd/config.json`) `embedProvider.kind` → that kind * 4. Otherwise → "local" (legacy / backward-compat) * * Backward compat invariant: when neither `QMD_EMBED_ENDPOINT` nor * `~/.config/qmd/config.json` mentions a provider, callers get the same * `LocalLlamaCppProvider` they had before this change. */ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { LocalLlamaCppProvider } from "./local.js"; import { OpenAIEmbeddingsProvider, } from "./openai.js"; import { AutoFallbackEmbeddingProvider, } from "./autofallback.js"; export function defaultConfigPath() { const xdg = process.env.XDG_CONFIG_HOME; const base = xdg ? xdg : join(homedir(), ".config"); return join(base, "qmd", "config.json"); } /** * Load `~/.config/qmd/config.json` if present. Returns an empty object on * any read/parse error so we silently fall back to env/local. */ export function loadConfigFile(path = defaultConfigPath()) { if (!existsSync(path)) return {}; try { const raw = readFileSync(path, "utf-8"); const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") return parsed; } catch { // Ignore — invalid JSON, missing read perm, etc. } return {}; } /** * Resolve the provider kind without instantiating anything. Useful for * logging and tests. */ export function resolveProviderKind(opts = {}) { const env = opts.env ?? process.env; const cfg = loadConfigFile(opts.configPath); // 1. Explicit kind argument if (opts.kind) return opts.kind; // 2a. Explicit env override const envKind = env.QMD_EMBED_PROVIDER?.trim().toLowerCase(); if (envKind === "local" || envKind === "openai") return envKind; // 2b. Endpoint env present → openai if (env.QMD_EMBED_ENDPOINT && env.QMD_EMBED_ENDPOINT.trim() !== "") { return "openai"; } // 3. Config file if (cfg.embedProvider?.kind === "local" || cfg.embedProvider?.kind === "openai") { return cfg.embedProvider.kind; } if (cfg.embedProvider?.endpoint && cfg.embedProvider.endpoint.trim() !== "") { return "openai"; } // 4. Default return "local"; } /** * Factory entry point — returns the appropriate `EmbeddingProvider`. * Throws if `openai` kind is requested but no endpoint is configured. */ export function createEmbeddingProvider(opts = {}) { const env = opts.env ?? process.env; const cfg = loadConfigFile(opts.configPath); const kind = resolveProviderKind(opts); if (kind === "local") { return new LocalLlamaCppProvider(opts.local ?? {}); } // OpenAI const endpoint = opts.openai?.endpoint ?? env.QMD_EMBED_ENDPOINT ?? cfg.embedProvider?.endpoint; if (!endpoint || endpoint.trim() === "") { throw new Error('createEmbeddingProvider: kind="openai" requires an endpoint. ' + "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " + "~/.config/qmd/config.json, or pass `openai.endpoint`."); } const apiKey = opts.openai?.apiKey ?? env.QMD_EMBED_API_KEY ?? cfg.embedProvider?.apiKey; const modelId = opts.openai?.modelId ?? env.QMD_EMBED_MODEL_ID ?? cfg.embedProvider?.modelId ?? "embeddinggemma"; const upstreamModel = opts.openai?.upstreamModel ?? env.QMD_EMBED_UPSTREAM_MODEL ?? cfg.embedProvider?.upstreamModel; const batchSizeRaw = opts.openai?.batchSize ?? parsePositiveInt(env.QMD_EMBED_BATCH_SIZE) ?? cfg.embedProvider?.batchSize; const timeoutMsRaw = opts.openai?.timeoutMs ?? parsePositiveInt(env.QMD_EMBED_TIMEOUT_MS) ?? cfg.embedProvider?.timeoutMs; const openaiProvider = new OpenAIEmbeddingsProvider({ endpoint, apiKey, modelId, upstreamModel, batchSize: batchSizeRaw, timeoutMs: timeoutMsRaw, fetchImpl: opts.openai?.fetchImpl, retryBackoffsMs: opts.openai?.retryBackoffsMs, sleep: opts.openai?.sleep, now: opts.openai?.now, }); // Should we wrap with AutoFallback? Resolution: arg → env → config → false. const autoFallback = resolveAutoFallback(opts, env, cfg); if (!autoFallback) return openaiProvider; return new AutoFallbackEmbeddingProvider({ primary: openaiProvider, fallback: new LocalLlamaCppProvider(opts.local ?? { modelId }), ...(opts.autoFallbackOverrides ?? {}), }); } function resolveAutoFallback(opts, env, cfg) { if (typeof opts.autoFallback === "boolean") return opts.autoFallback; const envVal = env.QMD_EMBED_AUTO_FALLBACK?.trim().toLowerCase(); if (envVal === "1" || envVal === "true" || envVal === "yes") return true; if (envVal === "0" || envVal === "false" || envVal === "no") return false; if (typeof cfg.embedProvider?.autoFallback === "boolean") { return cfg.embedProvider.autoFallback; } return false; } // ─────────────────────────── Helpers ──────────────────────────────────────── function parsePositiveInt(v) { if (!v) return undefined; const parsed = Number.parseInt(v, 10); if (!Number.isFinite(parsed) || parsed <= 0) return undefined; return parsed; }