factory.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  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`) commercial endpoint
  8. * 4. Otherwise → typed HOLD (there is no local or self-hosted fallback)
  9. */
  10. import { existsSync, readFileSync } from "node:fs";
  11. import { homedir } from "node:os";
  12. import { join } from "node:path";
  13. import { OpenAIEmbeddingsProvider, } from "./openai.js";
  14. import { commercialApiHold } from "../model-policy.js";
  15. export function defaultConfigPath() {
  16. const xdg = process.env.XDG_CONFIG_HOME;
  17. const base = xdg ? xdg : join(homedir(), ".config");
  18. return join(base, "qmd", "config.json");
  19. }
  20. /**
  21. * Load `~/.config/qmd/config.json` if present. Returns an empty object on
  22. * any read/parse error; provider construction then fails closed without an endpoint.
  23. */
  24. export function loadConfigFile(path = defaultConfigPath()) {
  25. if (!existsSync(path))
  26. return {};
  27. try {
  28. const raw = readFileSync(path, "utf-8");
  29. const parsed = JSON.parse(raw);
  30. if (parsed && typeof parsed === "object")
  31. return parsed;
  32. }
  33. catch {
  34. // Ignore — invalid JSON, missing read perm, etc.
  35. }
  36. return {};
  37. }
  38. /**
  39. * Resolve the provider kind without instantiating anything. Useful for
  40. * logging and tests.
  41. */
  42. export function resolveProviderKind(opts = {}) {
  43. const env = opts.env ?? process.env;
  44. const cfg = loadConfigFile(opts.configPath);
  45. // 1. Explicit kind argument
  46. if (opts.kind === "local") {
  47. throw commercialApiHold('provider kind "local" is disabled; configure an approved commercial API');
  48. }
  49. if (opts.kind === "openai")
  50. return opts.kind;
  51. // 2a. Explicit env override
  52. const envKind = env.QMD_EMBED_PROVIDER?.trim().toLowerCase();
  53. if (envKind === "local") {
  54. throw commercialApiHold("QMD_EMBED_PROVIDER=local is forbidden");
  55. }
  56. if (envKind === "openai")
  57. return envKind;
  58. // 2b. Endpoint env present → openai
  59. if (env.QMD_EMBED_ENDPOINT && env.QMD_EMBED_ENDPOINT.trim() !== "") {
  60. return "openai";
  61. }
  62. // 3. Config file
  63. if (cfg.embedProvider?.kind === "local") {
  64. throw commercialApiHold("embedProvider.kind=local is forbidden");
  65. }
  66. if (cfg.embedProvider?.kind === "openai") {
  67. return "openai";
  68. }
  69. if (cfg.embedProvider?.endpoint && cfg.embedProvider.endpoint.trim() !== "") {
  70. return "openai";
  71. }
  72. // Commercial-only default. Missing endpoint is handled as typed HOLD by the factory.
  73. return "openai";
  74. }
  75. /**
  76. * Factory entry point — returns the appropriate `EmbeddingProvider`.
  77. * Throws if `openai` kind is requested but no endpoint is configured.
  78. */
  79. export function createEmbeddingProvider(opts = {}) {
  80. const env = opts.env ?? process.env;
  81. const cfg = loadConfigFile(opts.configPath);
  82. const kind = resolveProviderKind(opts);
  83. if (kind === "local") {
  84. throw commercialApiHold('provider kind "local" is disabled');
  85. }
  86. // OpenAI
  87. const endpoint = opts.openai?.endpoint ??
  88. env.QMD_EMBED_ENDPOINT ??
  89. cfg.embedProvider?.endpoint;
  90. if (!endpoint || endpoint.trim() === "") {
  91. throw commercialApiHold('commercial provider requires an endpoint. ' +
  92. "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " +
  93. "~/.config/qmd/config.json, or pass `openai.endpoint`.");
  94. }
  95. assertCommercialEndpoint(endpoint);
  96. const apiKey = opts.openai?.apiKey ??
  97. env.QMD_EMBED_API_KEY ??
  98. cfg.embedProvider?.apiKey;
  99. const modelId = opts.openai?.modelId ??
  100. env.QMD_EMBED_MODEL_ID ??
  101. cfg.embedProvider?.modelId ??
  102. "embeddinggemma";
  103. const upstreamModel = opts.openai?.upstreamModel ??
  104. env.QMD_EMBED_UPSTREAM_MODEL ??
  105. cfg.embedProvider?.upstreamModel;
  106. const batchSizeRaw = opts.openai?.batchSize ??
  107. parsePositiveInt(env.QMD_EMBED_BATCH_SIZE) ??
  108. cfg.embedProvider?.batchSize;
  109. const timeoutMsRaw = opts.openai?.timeoutMs ??
  110. parsePositiveInt(env.QMD_EMBED_TIMEOUT_MS) ??
  111. cfg.embedProvider?.timeoutMs;
  112. const concurrencyRaw = opts.openai?.concurrency ??
  113. parsePositiveInt(env.QMD_EMBED_CONCURRENCY) ??
  114. cfg.embedProvider?.concurrency;
  115. const openaiProvider = new OpenAIEmbeddingsProvider({
  116. endpoint,
  117. apiKey,
  118. modelId,
  119. upstreamModel,
  120. batchSize: batchSizeRaw,
  121. concurrency: concurrencyRaw,
  122. timeoutMs: timeoutMsRaw,
  123. fetchImpl: opts.openai?.fetchImpl,
  124. retryBackoffsMs: opts.openai?.retryBackoffsMs,
  125. sleep: opts.openai?.sleep,
  126. now: opts.openai?.now,
  127. });
  128. // Historical fallback inputs are rejected instead of silently weakening policy.
  129. const autoFallback = resolveAutoFallback(opts, env, cfg);
  130. if (autoFallback) {
  131. throw commercialApiHold("local auto-fallback is forbidden; commercial API failures must remain HOLD");
  132. }
  133. return openaiProvider;
  134. }
  135. export function assertCommercialEndpoint(endpoint) {
  136. let parsed;
  137. try {
  138. parsed = new URL(endpoint);
  139. }
  140. catch {
  141. throw commercialApiHold("commercial provider endpoint is malformed");
  142. }
  143. const host = parsed.hostname.toLowerCase();
  144. const privateIpv4 = /^(?:10\.|127\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/;
  145. const localHost = host === "localhost" || host === "models" || host.endsWith(".local");
  146. const localIpv6 = host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd");
  147. if (parsed.protocol !== "https:" || localHost || privateIpv4.test(host) || localIpv6) {
  148. throw commercialApiHold(`endpoint ${parsed.protocol}//${host} is local, private, or non-TLS; use an approved commercial HTTPS API`);
  149. }
  150. }
  151. function resolveAutoFallback(opts, env, cfg) {
  152. if (typeof opts.autoFallback === "boolean")
  153. return opts.autoFallback;
  154. const envVal = env.QMD_EMBED_AUTO_FALLBACK?.trim().toLowerCase();
  155. if (envVal === "1" || envVal === "true" || envVal === "yes")
  156. return true;
  157. if (envVal === "0" || envVal === "false" || envVal === "no")
  158. return false;
  159. if (typeof cfg.embedProvider?.autoFallback === "boolean") {
  160. return cfg.embedProvider.autoFallback;
  161. }
  162. return false;
  163. }
  164. // ─────────────────────────── Helpers ────────────────────────────────────────
  165. function parsePositiveInt(v) {
  166. if (!v)
  167. return undefined;
  168. const parsed = Number.parseInt(v, 10);
  169. if (!Number.isFinite(parsed) || parsed <= 0)
  170. return undefined;
  171. return parsed;
  172. }