factory.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. /**
  2. * factory.ts - EmbeddingProvider factory with config precedence.
  3. *
  4. * Resolution order (first match wins):
  5. * 1. Explicit `kind` argument or `--provider` CLI flag → forces a kind
  6. * 2. `QMD_EMBED_ENDPOINT` env var present and non-empty → "openai"
  7. * 3. Config file (`~/.config/qmd/config.json`) `embedProvider.kind` → that kind
  8. * 4. Otherwise → "local" (legacy / backward-compat)
  9. *
  10. * Backward compat invariant: when neither `QMD_EMBED_ENDPOINT` nor
  11. * `~/.config/qmd/config.json` mentions a provider, callers get the same
  12. * `LocalLlamaCppProvider` they had before this change.
  13. */
  14. import { existsSync, readFileSync } from "node:fs";
  15. import { homedir } from "node:os";
  16. import { join } from "node:path";
  17. import { LocalLlamaCppProvider } from "./local.js";
  18. import { OpenAIEmbeddingsProvider, } from "./openai.js";
  19. import { AutoFallbackEmbeddingProvider, } from "./autofallback.js";
  20. export function defaultConfigPath() {
  21. const xdg = process.env.XDG_CONFIG_HOME;
  22. const base = xdg ? xdg : join(homedir(), ".config");
  23. return join(base, "qmd", "config.json");
  24. }
  25. /**
  26. * Load `~/.config/qmd/config.json` if present. Returns an empty object on
  27. * any read/parse error so we silently fall back to env/local.
  28. */
  29. export function loadConfigFile(path = defaultConfigPath()) {
  30. if (!existsSync(path))
  31. return {};
  32. try {
  33. const raw = readFileSync(path, "utf-8");
  34. const parsed = JSON.parse(raw);
  35. if (parsed && typeof parsed === "object")
  36. return parsed;
  37. }
  38. catch {
  39. // Ignore — invalid JSON, missing read perm, etc.
  40. }
  41. return {};
  42. }
  43. /**
  44. * Resolve the provider kind without instantiating anything. Useful for
  45. * logging and tests.
  46. */
  47. export function resolveProviderKind(opts = {}) {
  48. const env = opts.env ?? process.env;
  49. const cfg = loadConfigFile(opts.configPath);
  50. // 1. Explicit kind argument
  51. if (opts.kind)
  52. return opts.kind;
  53. // 2a. Explicit env override
  54. const envKind = env.QMD_EMBED_PROVIDER?.trim().toLowerCase();
  55. if (envKind === "local" || envKind === "openai")
  56. return envKind;
  57. // 2b. Endpoint env present → openai
  58. if (env.QMD_EMBED_ENDPOINT && env.QMD_EMBED_ENDPOINT.trim() !== "") {
  59. return "openai";
  60. }
  61. // 3. Config file
  62. if (cfg.embedProvider?.kind === "local" || cfg.embedProvider?.kind === "openai") {
  63. return cfg.embedProvider.kind;
  64. }
  65. if (cfg.embedProvider?.endpoint && cfg.embedProvider.endpoint.trim() !== "") {
  66. return "openai";
  67. }
  68. // 4. Default
  69. return "local";
  70. }
  71. /**
  72. * Factory entry point — returns the appropriate `EmbeddingProvider`.
  73. * Throws if `openai` kind is requested but no endpoint is configured.
  74. */
  75. export function createEmbeddingProvider(opts = {}) {
  76. const env = opts.env ?? process.env;
  77. const cfg = loadConfigFile(opts.configPath);
  78. const kind = resolveProviderKind(opts);
  79. if (kind === "local") {
  80. return new LocalLlamaCppProvider(opts.local ?? {});
  81. }
  82. // OpenAI
  83. const endpoint = opts.openai?.endpoint ??
  84. env.QMD_EMBED_ENDPOINT ??
  85. cfg.embedProvider?.endpoint;
  86. if (!endpoint || endpoint.trim() === "") {
  87. throw new Error('createEmbeddingProvider: kind="openai" requires an endpoint. ' +
  88. "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " +
  89. "~/.config/qmd/config.json, or pass `openai.endpoint`.");
  90. }
  91. const apiKey = opts.openai?.apiKey ??
  92. env.QMD_EMBED_API_KEY ??
  93. cfg.embedProvider?.apiKey;
  94. const modelId = opts.openai?.modelId ??
  95. env.QMD_EMBED_MODEL_ID ??
  96. cfg.embedProvider?.modelId ??
  97. "embeddinggemma";
  98. const upstreamModel = opts.openai?.upstreamModel ??
  99. env.QMD_EMBED_UPSTREAM_MODEL ??
  100. cfg.embedProvider?.upstreamModel;
  101. const batchSizeRaw = opts.openai?.batchSize ??
  102. parsePositiveInt(env.QMD_EMBED_BATCH_SIZE) ??
  103. cfg.embedProvider?.batchSize;
  104. const timeoutMsRaw = opts.openai?.timeoutMs ??
  105. parsePositiveInt(env.QMD_EMBED_TIMEOUT_MS) ??
  106. cfg.embedProvider?.timeoutMs;
  107. const openaiProvider = new OpenAIEmbeddingsProvider({
  108. endpoint,
  109. apiKey,
  110. modelId,
  111. upstreamModel,
  112. batchSize: batchSizeRaw,
  113. timeoutMs: timeoutMsRaw,
  114. fetchImpl: opts.openai?.fetchImpl,
  115. retryBackoffsMs: opts.openai?.retryBackoffsMs,
  116. sleep: opts.openai?.sleep,
  117. now: opts.openai?.now,
  118. });
  119. // Should we wrap with AutoFallback? Resolution: arg → env → config → false.
  120. const autoFallback = resolveAutoFallback(opts, env, cfg);
  121. if (!autoFallback)
  122. return openaiProvider;
  123. return new AutoFallbackEmbeddingProvider({
  124. primary: openaiProvider,
  125. fallback: new LocalLlamaCppProvider(opts.local ?? { modelId }),
  126. ...(opts.autoFallbackOverrides ?? {}),
  127. });
  128. }
  129. function resolveAutoFallback(opts, env, cfg) {
  130. if (typeof opts.autoFallback === "boolean")
  131. return opts.autoFallback;
  132. const envVal = env.QMD_EMBED_AUTO_FALLBACK?.trim().toLowerCase();
  133. if (envVal === "1" || envVal === "true" || envVal === "yes")
  134. return true;
  135. if (envVal === "0" || envVal === "false" || envVal === "no")
  136. return false;
  137. if (typeof cfg.embedProvider?.autoFallback === "boolean") {
  138. return cfg.embedProvider.autoFallback;
  139. }
  140. return false;
  141. }
  142. // ─────────────────────────── Helpers ────────────────────────────────────────
  143. function parsePositiveInt(v) {
  144. if (!v)
  145. return undefined;
  146. const parsed = Number.parseInt(v, 10);
  147. if (!Number.isFinite(parsed) || parsed <= 0)
  148. return undefined;
  149. return parsed;
  150. }