/** * 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`) commercial endpoint * 4. Otherwise → typed HOLD (there is no local or self-hosted fallback) */ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { OpenAIEmbeddingsProvider, } from "./openai.js"; import { commercialApiHold } from "../model-policy.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; provider construction then fails closed without an endpoint. */ 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 const explicitKind = opts.kind; if (explicitKind === "local") { throw commercialApiHold('provider kind "local" is disabled; configure an approved commercial API'); } if (explicitKind === "openai") return "openai"; if (explicitKind) { throw commercialApiHold(`unsupported commercial provider kind "${explicitKind}"`); } // 2a. Explicit env override const envKind = env.QMD_EMBED_PROVIDER?.trim().toLowerCase(); if (envKind === "local") { throw commercialApiHold("QMD_EMBED_PROVIDER=local is forbidden"); } if (envKind === "openai") return envKind; if (envKind) { throw commercialApiHold(`unsupported QMD_EMBED_PROVIDER=${envKind}`); } // 2b. Endpoint env present → openai if (env.QMD_EMBED_ENDPOINT && env.QMD_EMBED_ENDPOINT.trim() !== "") { return "openai"; } // 3. Config file const configKind = cfg.embedProvider?.kind; if (configKind === "local") { throw commercialApiHold("embedProvider.kind=local is forbidden"); } if (configKind === "openai") { return "openai"; } if (configKind) { throw commercialApiHold(`unsupported embedProvider.kind=${configKind}`); } if (cfg.embedProvider?.endpoint && cfg.embedProvider.endpoint.trim() !== "") { return "openai"; } // Commercial-only default. Missing endpoint is handled as typed HOLD by the factory. return "openai"; } /** * 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); resolveProviderKind(opts); // OpenAI const endpoint = opts.openai?.endpoint ?? env.QMD_EMBED_ENDPOINT ?? cfg.embedProvider?.endpoint; if (!endpoint || endpoint.trim() === "") { throw commercialApiHold('commercial provider requires an endpoint. ' + "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " + "~/.config/qmd/config.json, or pass `openai.endpoint`."); } assertCommercialEndpoint(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 concurrencyRaw = opts.openai?.concurrency ?? parsePositiveInt(env.QMD_EMBED_CONCURRENCY) ?? cfg.embedProvider?.concurrency; const openaiProvider = new OpenAIEmbeddingsProvider({ endpoint, apiKey, modelId, upstreamModel, batchSize: batchSizeRaw, concurrency: concurrencyRaw, timeoutMs: timeoutMsRaw, fetchImpl: opts.openai?.fetchImpl, retryBackoffsMs: opts.openai?.retryBackoffsMs, sleep: opts.openai?.sleep, now: opts.openai?.now, }); // Historical fallback inputs are rejected instead of silently weakening policy. const autoFallback = resolveAutoFallback(env, cfg); if (autoFallback) { throw commercialApiHold("local auto-fallback is forbidden; commercial API failures must remain HOLD"); } return openaiProvider; } export function assertCommercialEndpoint(endpoint) { let parsed; try { parsed = new URL(endpoint); } catch { throw commercialApiHold("commercial provider endpoint is malformed"); } const host = parsed.hostname.toLowerCase(); const privateIpv4 = /^(?:10\.|127\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/; const localHost = host === "localhost" || host === "models" || host.endsWith(".local"); const localIpv6 = host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd"); if (parsed.protocol !== "https:" || localHost || privateIpv4.test(host) || localIpv6) { throw commercialApiHold(`endpoint ${parsed.protocol}//${host} is local, private, or non-TLS; use an approved commercial HTTPS API`); } } function resolveAutoFallback(env, cfg) { 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; const configAutoFallback = cfg.embedProvider?.autoFallback; if (typeof configAutoFallback === "boolean") { return configAutoFallback; } 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; }