| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172 |
- /**
- * 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
- if (opts.kind === "local") {
- throw commercialApiHold('provider kind "local" is disabled; configure an approved commercial API');
- }
- if (opts.kind === "openai")
- return opts.kind;
- // 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;
- // 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") {
- throw commercialApiHold("embedProvider.kind=local is forbidden");
- }
- if (cfg.embedProvider?.kind === "openai") {
- return "openai";
- }
- 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);
- const kind = resolveProviderKind(opts);
- if (kind === "local") {
- throw commercialApiHold('provider kind "local" is disabled');
- }
- // 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(opts, 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(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;
- }
|