factory.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  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. const explicitKind = opts.kind;
  47. if (explicitKind === "local") {
  48. throw commercialApiHold('provider kind "local" is disabled; configure an approved commercial API');
  49. }
  50. if (explicitKind === "openai")
  51. return "openai";
  52. if (explicitKind) {
  53. throw commercialApiHold(`unsupported commercial provider kind "${explicitKind}"`);
  54. }
  55. // 2a. Explicit env override
  56. const envKind = env.QMD_EMBED_PROVIDER?.trim().toLowerCase();
  57. if (envKind === "local") {
  58. throw commercialApiHold("QMD_EMBED_PROVIDER=local is forbidden");
  59. }
  60. if (envKind === "openai")
  61. return envKind;
  62. if (envKind) {
  63. throw commercialApiHold(`unsupported QMD_EMBED_PROVIDER=${envKind}`);
  64. }
  65. // 2b. Endpoint env present → openai
  66. if (env.QMD_EMBED_ENDPOINT && env.QMD_EMBED_ENDPOINT.trim() !== "") {
  67. return "openai";
  68. }
  69. // 3. Config file
  70. const configKind = cfg.embedProvider?.kind;
  71. if (configKind === "local") {
  72. throw commercialApiHold("embedProvider.kind=local is forbidden");
  73. }
  74. if (configKind === "openai") {
  75. return "openai";
  76. }
  77. if (configKind) {
  78. throw commercialApiHold(`unsupported embedProvider.kind=${configKind}`);
  79. }
  80. if (cfg.embedProvider?.endpoint && cfg.embedProvider.endpoint.trim() !== "") {
  81. return "openai";
  82. }
  83. // Commercial-only default. Missing endpoint is handled as typed HOLD by the factory.
  84. return "openai";
  85. }
  86. /** Google Gemini OpenAI-compat embeddings base (qmd appends `/v1/embeddings`). */
  87. export const GEMINI_OPENAI_EMBED_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/openai";
  88. export const GEMINI_EMBED_MODEL = "gemini-embedding-001";
  89. export function isGeminiEmbeddingsHost(endpoint) {
  90. try {
  91. return new URL(endpoint).hostname.toLowerCase().endsWith("generativelanguage.googleapis.com");
  92. }
  93. catch {
  94. return false;
  95. }
  96. }
  97. function nativeGeminiKey(env) {
  98. const key = (env.GEMINI_API_KEY || env.GOOGLE_API_KEY || "").trim();
  99. return key || undefined;
  100. }
  101. /**
  102. * Factory entry point — returns the appropriate `EmbeddingProvider`.
  103. * Throws if `openai` kind is requested but no endpoint is configured.
  104. */
  105. export function createEmbeddingProvider(opts = {}) {
  106. const env = opts.env ?? process.env;
  107. const cfg = loadConfigFile(opts.configPath);
  108. resolveProviderKind(opts);
  109. const geminiKey = nativeGeminiKey(env);
  110. // OpenAI (or Gemini's OpenAI-compat layer, using the native Gemini key)
  111. let endpoint = opts.openai?.endpoint ??
  112. env.QMD_EMBED_ENDPOINT ??
  113. cfg.embedProvider?.endpoint;
  114. if (!endpoint || endpoint.trim() === "") {
  115. if (geminiKey) {
  116. endpoint = GEMINI_OPENAI_EMBED_ENDPOINT;
  117. }
  118. else {
  119. throw commercialApiHold('commercial provider requires an endpoint. ' +
  120. "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " +
  121. "~/.config/qmd/config.json, or pass `openai.endpoint`. " +
  122. "A GEMINI_API_KEY / GOOGLE_API_KEY is also enough — qmd then uses Gemini native embeddings.");
  123. }
  124. }
  125. assertCommercialEndpoint(endpoint);
  126. let apiKey = opts.openai?.apiKey ??
  127. env.QMD_EMBED_API_KEY ??
  128. cfg.embedProvider?.apiKey;
  129. if (!apiKey && isGeminiEmbeddingsHost(endpoint)) {
  130. apiKey = geminiKey;
  131. }
  132. let modelId = opts.openai?.modelId ??
  133. env.QMD_EMBED_MODEL_ID ??
  134. cfg.embedProvider?.modelId ??
  135. "embeddinggemma";
  136. if (isGeminiEmbeddingsHost(endpoint) && modelId === "embeddinggemma") {
  137. modelId = GEMINI_EMBED_MODEL;
  138. }
  139. const upstreamModel = opts.openai?.upstreamModel ??
  140. env.QMD_EMBED_UPSTREAM_MODEL ??
  141. cfg.embedProvider?.upstreamModel;
  142. const batchSizeRaw = opts.openai?.batchSize ??
  143. parsePositiveInt(env.QMD_EMBED_BATCH_SIZE) ??
  144. cfg.embedProvider?.batchSize;
  145. const timeoutMsRaw = opts.openai?.timeoutMs ??
  146. parsePositiveInt(env.QMD_EMBED_TIMEOUT_MS) ??
  147. cfg.embedProvider?.timeoutMs;
  148. const concurrencyRaw = opts.openai?.concurrency ??
  149. parsePositiveInt(env.QMD_EMBED_CONCURRENCY) ??
  150. cfg.embedProvider?.concurrency;
  151. const openaiProvider = new OpenAIEmbeddingsProvider({
  152. endpoint,
  153. apiKey,
  154. modelId,
  155. upstreamModel,
  156. batchSize: batchSizeRaw,
  157. concurrency: concurrencyRaw,
  158. timeoutMs: timeoutMsRaw,
  159. fetchImpl: opts.openai?.fetchImpl,
  160. retryBackoffsMs: opts.openai?.retryBackoffsMs,
  161. sleep: opts.openai?.sleep,
  162. now: opts.openai?.now,
  163. });
  164. // Historical fallback inputs are rejected instead of silently weakening policy.
  165. const autoFallback = resolveAutoFallback(env, cfg);
  166. if (autoFallback) {
  167. throw commercialApiHold("local auto-fallback is forbidden; commercial API failures must remain HOLD");
  168. }
  169. return openaiProvider;
  170. }
  171. export function assertCommercialEndpoint(endpoint) {
  172. let parsed;
  173. try {
  174. parsed = new URL(endpoint);
  175. }
  176. catch {
  177. throw commercialApiHold("commercial provider endpoint is malformed");
  178. }
  179. const host = parsed.hostname.toLowerCase();
  180. const privateIpv4 = /^(?:10\.|127\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/;
  181. const localHost = host === "localhost" || host === "models" || host.endsWith(".local");
  182. const localIpv6 = host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd");
  183. if (parsed.protocol !== "https:" || localHost || privateIpv4.test(host) || localIpv6) {
  184. throw commercialApiHold(`endpoint ${parsed.protocol}//${host} is local, private, or non-TLS; use an approved commercial HTTPS API`);
  185. }
  186. }
  187. function resolveAutoFallback(env, cfg) {
  188. const envVal = env.QMD_EMBED_AUTO_FALLBACK?.trim().toLowerCase();
  189. if (envVal === "1" || envVal === "true" || envVal === "yes")
  190. return true;
  191. if (envVal === "0" || envVal === "false" || envVal === "no")
  192. return false;
  193. const configAutoFallback = cfg.embedProvider?.autoFallback;
  194. if (typeof configAutoFallback === "boolean") {
  195. return configAutoFallback;
  196. }
  197. return false;
  198. }
  199. // ─────────────────────────── Helpers ────────────────────────────────────────
  200. function parsePositiveInt(v) {
  201. if (!v)
  202. return undefined;
  203. const parsed = Number.parseInt(v, 10);
  204. if (!Number.isFinite(parsed) || parsed <= 0)
  205. return undefined;
  206. return parsed;
  207. }