mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
Merge remote-tracking branch 'upstream/master'
# Conflicts: # .gitignore # open-sse/handlers/chatCore.js
This commit is contained in:
+41
-1
@@ -1,9 +1,20 @@
|
||||
// DB safety backups — taken ONLY before a schema change (see migrate.js).
|
||||
//
|
||||
// ⚠️ AGENT/DEV NOTES:
|
||||
// - Backups are a best-effort safety net before schema migrations. There is NO
|
||||
// automated restore path; recovery is manual (copy a backup file back).
|
||||
// - Backups intentionally EXCLUDE the `requestDetails` table (observability log,
|
||||
// auto-pruned, non-critical) so a multi-hundred-MB DB backs up as a few MB.
|
||||
// - Only the newest KEEP_BACKUPS are kept; older ones are pruned automatically.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { BACKUPS_DIR, ensureDirs } from "./paths.js";
|
||||
import { timestampSlug, getAppVersion } from "./version.js";
|
||||
|
||||
const KEEP_BACKUPS = 5;
|
||||
const KEEP_BACKUPS = 3;
|
||||
|
||||
// Tables excluded from safety backups (large, non-critical, reproducible).
|
||||
const BACKUP_EXCLUDE_TABLES = ["requestDetails"];
|
||||
|
||||
export function makeBackupDir(label) {
|
||||
ensureDirs();
|
||||
@@ -22,6 +33,35 @@ export function backupFile(srcPath, destDir, destName = null) {
|
||||
return dest;
|
||||
}
|
||||
|
||||
// Lightweight DB backup via ATTACH: create an empty sqlite file, copy every
|
||||
// table EXCEPT the excluded ones into it. Avoids duplicating the huge
|
||||
// observability log, so the backup stays small regardless of DB size.
|
||||
export function backupDbLite(adapter, destDir, destName = "data.sqlite") {
|
||||
const dest = path.join(destDir, destName);
|
||||
try { fs.rmSync(dest, { force: true }); } catch {}
|
||||
const escaped = dest.replace(/'/g, "''");
|
||||
|
||||
adapter.exec(`ATTACH DATABASE '${escaped}' AS bak`);
|
||||
try {
|
||||
const excluded = new Set(BACKUP_EXCLUDE_TABLES);
|
||||
const tables = adapter
|
||||
.all(`SELECT name, sql FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
||||
.filter((t) => !excluded.has(t.name));
|
||||
|
||||
adapter.transaction(() => {
|
||||
for (const t of tables) {
|
||||
// Recreate table structure in backup DB, then copy rows.
|
||||
const createSql = t.sql.replace(/CREATE TABLE\s+/i, "CREATE TABLE bak.");
|
||||
adapter.exec(createSql);
|
||||
adapter.exec(`INSERT INTO bak.${t.name} SELECT * FROM main.${t.name}`);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
try { adapter.exec("DETACH DATABASE bak"); } catch {}
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
export function pruneOldBackups() {
|
||||
if (!fs.existsSync(BACKUPS_DIR)) return;
|
||||
const entries = fs.readdirSync(BACKUPS_DIR, { withFileTypes: true })
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ export {
|
||||
|
||||
// Request details
|
||||
export {
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById,
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||
} from "./repos/requestDetailsRepo.js";
|
||||
|
||||
// Export/import full DB
|
||||
|
||||
+33
-22
@@ -1,10 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { LEGACY_FILES, DB_DIR, DATA_FILE } from "./paths.js";
|
||||
import { TABLES, buildCreateTableSql } from "./schema.js";
|
||||
import { LEGACY_FILES, DB_DIR } from "./paths.js";
|
||||
import { TABLES, buildCreateTableSql, SCHEMA_VERSION } from "./schema.js";
|
||||
import { MIGRATIONS, latestVersion } from "./migrations/index.js";
|
||||
import { getMetaSync, setMetaSync } from "./helpers/metaStore.js";
|
||||
import { makeBackupDir, backupFile, pruneOldBackups } from "./backup.js";
|
||||
import { makeBackupDir, backupFile, backupDbLite, pruneOldBackups } from "./backup.js";
|
||||
import { getAppVersion } from "./version.js";
|
||||
import { stringifyJson } from "./helpers/jsonCol.js";
|
||||
|
||||
@@ -221,12 +221,37 @@ export async function runMigrationOnce(adapter) {
|
||||
// a brand-new DB as non-fresh once schemaVersion is written).
|
||||
const fresh = isFreshDb(adapter);
|
||||
|
||||
// Prune stale backups every boot so old oversized backups shrink to KEEP.
|
||||
pruneOldBackups();
|
||||
|
||||
// Bootstrap _meta so we can read the stored backup schema version below
|
||||
// (runVersionedMigrations also ensures this, but we need it earlier here).
|
||||
adapter.exec(buildCreateTableSql("_meta", TABLES._meta));
|
||||
|
||||
// Detect a pending schema change via the central SCHEMA_VERSION const.
|
||||
// A lightweight backup is taken BEFORE any schema mutation below.
|
||||
const storedSchemaVer = parseInt(getMetaSync(adapter, "backupSchemaVersion", "0"), 10) || 0;
|
||||
const schemaChanging = !fresh && storedSchemaVer < SCHEMA_VERSION;
|
||||
if (schemaChanging) {
|
||||
try {
|
||||
const backupDir = makeBackupDir(`schema-${storedSchemaVer}-to-${SCHEMA_VERSION}`);
|
||||
backupDbLite(adapter, backupDir);
|
||||
pruneOldBackups();
|
||||
console.log(`[DB][migrate] pre-schema backup ${storedSchemaVer} → ${SCHEMA_VERSION}: ${backupDir}`);
|
||||
} catch (e) {
|
||||
console.warn(`[DB][migrate] pre-schema backup failed (continuing): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Always run versioned migrations chain (skip-version safe)
|
||||
const migInfo = runVersionedMigrations(adapter);
|
||||
|
||||
// 2. Additive sync (auto add missing columns/indexes declared in TABLES)
|
||||
syncSchemaFromTables(adapter);
|
||||
|
||||
// Stamp the schema version we just reached so future boots skip re-backup.
|
||||
setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION);
|
||||
|
||||
// 3. One-time legacy JSON import (only if DB was fresh on entry)
|
||||
const alreadyImported = fs.existsSync(MIGRATED_MARKER);
|
||||
const legacyMain = readJsonSafe(LEGACY_FILES.main);
|
||||
@@ -247,6 +272,7 @@ export async function runMigrationOnce(adapter) {
|
||||
importLegacyDisabled(adapter, legacyDisabled);
|
||||
importLegacyDetails(adapter, legacyDetails);
|
||||
setMetaSync(adapter, "appVersion", getAppVersion());
|
||||
setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION);
|
||||
setMetaSync(adapter, "migratedAt", new Date().toISOString());
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -263,24 +289,9 @@ export async function runMigrationOnce(adapter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fresh) {
|
||||
setMetaSync(adapter, "appVersion", getAppVersion());
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. App version bump → backup data.sqlite (safety net before user-side upgrade)
|
||||
const oldVer = getMetaSync(adapter, "appVersion", null);
|
||||
// Track app version for informational purposes only. App version bumps no
|
||||
// longer trigger a DB backup — only real schema changes (SCHEMA_VERSION) do.
|
||||
const newVer = getAppVersion();
|
||||
if (oldVer && oldVer !== newVer) {
|
||||
const backupDir = makeBackupDir(`upgrade-${oldVer}-to-${newVer}`);
|
||||
try { backupFile(DATA_FILE, backupDir); } catch {}
|
||||
setMetaSync(adapter, "appVersion", newVer);
|
||||
pruneOldBackups();
|
||||
console.log(`[DB][migrate] App ${oldVer} → ${newVer} | schema ${migInfo.from} → ${migInfo.to} | backup: ${backupDir}`);
|
||||
} else if (migInfo.applied > 0) {
|
||||
// Schema upgrade without app version bump — still backup
|
||||
const backupDir = makeBackupDir(`schema-${migInfo.from}-to-${migInfo.to}`);
|
||||
try { backupFile(DATA_FILE, backupDir); } catch {}
|
||||
pruneOldBackups();
|
||||
}
|
||||
const oldVer = getMetaSync(adapter, "appVersion", null);
|
||||
if (oldVer !== newVer) setMetaSync(adapter, "appVersion", newVer);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,17 @@ function upsert(db, c) {
|
||||
);
|
||||
}
|
||||
|
||||
function deriveConnectionName(data, fallbackName) {
|
||||
if (data.provider === "github") {
|
||||
return data.providerSpecificData?.githubLogin
|
||||
|| data.providerSpecificData?.githubEmail
|
||||
|| data.email
|
||||
|| data.providerSpecificData?.githubName
|
||||
|| fallbackName;
|
||||
}
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
export async function getProviderConnections(filter = {}) {
|
||||
const db = await getAdapter();
|
||||
const where = [];
|
||||
@@ -102,7 +113,18 @@ export async function createProviderConnection(data) {
|
||||
const incomingWs = data.providerSpecificData?.chatgptAccountId;
|
||||
existing = all.find(c => {
|
||||
if (c.authType !== "oauth" || c.email !== data.email) return false;
|
||||
// Workspace providers (Codex) use workspace ID when both sides have it
|
||||
|
||||
// Codex/OpenAI can issue multiple OAuth grants for the same email.
|
||||
// Refresh tokens are rotated single-use; collapsing a new login onto an
|
||||
// existing bare-email row overwrites the first account's token pair and
|
||||
// makes it look "invalid" after adding a second account. Only update an
|
||||
// existing Codex row when both rows expose the same ChatGPT account ID.
|
||||
if (data.provider === "codex") {
|
||||
const existingWs = c.providerSpecificData?.chatgptAccountId;
|
||||
return !!incomingWs && !!existingWs && incomingWs === existingWs;
|
||||
}
|
||||
|
||||
// Workspace providers use workspace ID when both sides have it
|
||||
const existingWs = c.providerSpecificData?.chatgptAccountId;
|
||||
if (incomingWs && existingWs) return incomingWs === existingWs;
|
||||
if (incomingWs && !existingWs) return false;
|
||||
@@ -133,7 +155,7 @@ export async function createProviderConnection(data) {
|
||||
|
||||
let connectionName = data.name || null;
|
||||
if (!connectionName && (data.authType === "oauth" || data.authType === "access_token")) {
|
||||
connectionName = data.email || `Account ${all.length + 1}`;
|
||||
connectionName = deriveConnectionName(data, data.email || `Account ${all.length + 1}`);
|
||||
}
|
||||
let connectionPriority = data.priority;
|
||||
if (!connectionPriority) {
|
||||
|
||||
@@ -98,6 +98,7 @@ async function flushToDatabase() {
|
||||
providerRequest: truncateField(item.providerRequest, config.maxJsonSize),
|
||||
providerResponse: truncateField(item.providerResponse, config.maxJsonSize),
|
||||
response: truncateField(item.response, config.maxJsonSize),
|
||||
pxpipe: item.pxpipe || undefined,
|
||||
};
|
||||
|
||||
db.run(
|
||||
@@ -174,6 +175,12 @@ export async function getRequestDetails(filter = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDistinctProviders() {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT DISTINCT provider FROM requestDetails WHERE provider IS NOT NULL ORDER BY provider ASC`);
|
||||
return rows.map((r) => r.provider);
|
||||
}
|
||||
|
||||
export async function getRequestDetailById(id) {
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]);
|
||||
|
||||
@@ -43,6 +43,10 @@ const DEFAULT_SETTINGS = {
|
||||
cavemanLevel: "full",
|
||||
ponytailEnabled: false,
|
||||
ponytailLevel: "full",
|
||||
pxpipeEnabled: false,
|
||||
pxpipeAutoInstall: true,
|
||||
pxpipeMinChars: 25000,
|
||||
pxpipeTimeoutMs: 15000,
|
||||
};
|
||||
|
||||
async function readRaw() {
|
||||
|
||||
@@ -189,8 +189,7 @@ export function trackPendingRequest(model, provider, connectionId, started, erro
|
||||
lastErrorProvider.ts = Date.now();
|
||||
}
|
||||
|
||||
const t = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
console.log(`[${t}] [PENDING] ${started ? "START" : "END"}${error ? " (ERROR)" : ""} | provider=${provider} | model=${model}`);
|
||||
// [PENDING] console line removed; lifecycle is visible via "▶" and "📊 done" lines
|
||||
scheduleStatsEvent("pending");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Latest schema version — bumped when a migration is added in ./migrations/
|
||||
// ⚠️ AGENT/DEV: Bump this by +1 EVERY TIME you change the schema below
|
||||
// (add/remove/alter a table, column, or index in TABLES). It drives the
|
||||
// pre-change safety backup in migrate.js: when the stored version is lower,
|
||||
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
||||
// to bump only skips that backup — it does NOT break the additive auto-sync.
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
export const PRAGMA_SQL = `
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { execSync } from "child_process";
|
||||
import { execFileSync, execSync } from "child_process";
|
||||
import path from "path";
|
||||
|
||||
// Extras that improve headroom compression quality. `proxy` is the base;
|
||||
// `code` adds tree-sitter AST compression; `ml` adds Kompress-v2 HF model.
|
||||
// Other `[all]` extras (image, voice, otel, reports, evals, ...) are not
|
||||
// useful for the 9router proxy use case, so we don't track them here.
|
||||
export const HEADROOM_COMPRESSION_EXTRAS = ["code", "ml"];
|
||||
|
||||
// Marker packages that each extra pulls in. Detected from `pip list --format=json`
|
||||
// so one call can answer both the installed version and active extras.
|
||||
export const EXTRA_MARKERS = {
|
||||
code: ["tree-sitter", "tree-sitter-language-pack"],
|
||||
ml: ["torch", "huggingface-hub"],
|
||||
};
|
||||
|
||||
const HEADROOM_PIP_TIMEOUT_MS = 8000;
|
||||
|
||||
const IS_WIN = process.platform === "win32";
|
||||
const WHICH_CMD = IS_WIN ? "where" : "which";
|
||||
|
||||
@@ -49,8 +64,33 @@ export function findHeadroomBinary() {
|
||||
}
|
||||
|
||||
// Find a Python interpreter >= 3.10 (headroom-ai requires it). Returns null if none.
|
||||
// `python3`, `python3.13`, `python` can point at different envs on any OS. Prefer
|
||||
// the interpreter that can also see the installed `headroom-ai` package so the
|
||||
// dashboard probes and install action operate on the same interpreter as the CLI.
|
||||
// Falls back to the first version-eligible candidate when headroom-ai is not yet
|
||||
// installed anywhere (needed for the initial install).
|
||||
// Interpreters to probe, most specific first: the python next to the headroom
|
||||
// binary (guaranteed to have headroom-ai), then full paths from EXTRA_BINS, then
|
||||
// bare names resolved via PATH.
|
||||
function pythonCandidates() {
|
||||
const list = [];
|
||||
const bin = findHeadroomBinary();
|
||||
if (bin) {
|
||||
const dir = path.dirname(bin);
|
||||
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3", "python3.13", "python"];
|
||||
for (const n of names) list.push(path.join(dir, n));
|
||||
}
|
||||
for (const dir of EXTRA_BINS) {
|
||||
if (!dir) continue;
|
||||
for (const n of PYTHON_CANDIDATES) list.push(path.join(dir, IS_WIN ? `${n}.exe` : n));
|
||||
}
|
||||
list.push(...PYTHON_CANDIDATES);
|
||||
return list;
|
||||
}
|
||||
|
||||
export function findPython310() {
|
||||
for (const candidate of PYTHON_CANDIDATES) {
|
||||
let fallback = null;
|
||||
for (const candidate of pythonCandidates()) {
|
||||
try {
|
||||
const ver = execSync(`${candidate} --version`, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
@@ -60,14 +100,24 @@ export function findPython310() {
|
||||
const match = ver.match(/(\d+)\.(\d+)/);
|
||||
if (!match) continue;
|
||||
const [major, minor] = [parseInt(match[1], 10), parseInt(match[2], 10)];
|
||||
if (major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1])) {
|
||||
if (!(major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1]))) continue;
|
||||
if (!fallback) fallback = candidate;
|
||||
try {
|
||||
execFileSync(candidate, ["-m", "pip", "show", "headroom-ai"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
timeout: HEADROOM_PIP_TIMEOUT_MS,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
});
|
||||
return candidate;
|
||||
} catch {
|
||||
// Keep scanning until an interpreter that sees headroom-ai is found.
|
||||
}
|
||||
} catch {
|
||||
// candidate not present, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Probe whether a Headroom proxy is reachable at the given URL by hitting /health.
|
||||
@@ -98,5 +148,45 @@ export async function getHeadroomStatus(url) {
|
||||
const installed = Boolean(path);
|
||||
const running = await probeProxyRunning(url);
|
||||
const localUrl = isLoopbackHeadroomUrl(url);
|
||||
return { installed, path, running, python, localUrl, canStart: installed && localUrl };
|
||||
const extrasStatus = installed ? getInstalledHeadroomExtras(python) : { installed: false, version: null, extras: { code: false, ml: false } };
|
||||
return {
|
||||
installed,
|
||||
path,
|
||||
running,
|
||||
python,
|
||||
localUrl,
|
||||
canStart: installed && localUrl,
|
||||
version: extrasStatus.version,
|
||||
extras: extrasStatus.extras,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse installed headroom-ai version + which compression extras are
|
||||
// actually installed (detected via marker package presence). One `pip list`
|
||||
// call is enough to answer both questions.
|
||||
//
|
||||
// Returns: { installed: bool, version: string|null, extras: { code, ml } }
|
||||
export function getInstalledHeadroomExtras(python) {
|
||||
const py = python || findPython310();
|
||||
if (!py) return { installed: false, version: null, extras: { code: false, ml: false } };
|
||||
try {
|
||||
const out = execFileSync(py, ["-m", "pip", "list", "--format=json", "--disable-pip-version-check"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
timeout: HEADROOM_PIP_TIMEOUT_MS,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
}).toString();
|
||||
const packages = JSON.parse(out);
|
||||
const names = new Set(packages.map((p) => String(p.name || "").toLowerCase()));
|
||||
const installed = names.has("headroom-ai");
|
||||
if (!installed) return { installed: false, version: null, extras: { code: false, ml: false } };
|
||||
const version = packages.find((p) => p.name?.toLowerCase() === "headroom-ai")?.version || null;
|
||||
const extras = {};
|
||||
for (const extra of HEADROOM_COMPRESSION_EXTRAS) {
|
||||
extras[extra] = EXTRA_MARKERS[extra].some((m) => names.has(m));
|
||||
}
|
||||
return { installed: true, version, extras };
|
||||
} catch {
|
||||
return { installed: false, version: null, extras: { code: false, ml: false } };
|
||||
}
|
||||
}
|
||||
|
||||
+135
-3
@@ -2,11 +2,12 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { spawn } from "child_process";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
import { findHeadroomBinary } from "./detect.js";
|
||||
import { findHeadroomBinary, findPython310, HEADROOM_COMPRESSION_EXTRAS, EXTRA_MARKERS, getInstalledHeadroomExtras } from "./detect.js";
|
||||
|
||||
const HEADROOM_DIR = path.join(DATA_DIR, "headroom");
|
||||
const PID_FILE = path.join(HEADROOM_DIR, "proxy.pid");
|
||||
const LOG_FILE = path.join(HEADROOM_DIR, "proxy.log");
|
||||
const INSTALL_LOG_FILE = path.join(HEADROOM_DIR, "install.log");
|
||||
const DEFAULT_PORT = 8787;
|
||||
const STARTUP_TIMEOUT_MS = 8000;
|
||||
|
||||
@@ -41,7 +42,17 @@ export function getManagedPid() {
|
||||
return pid && isPidAlive(pid) ? pid : null;
|
||||
}
|
||||
|
||||
export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) {
|
||||
// Build proxy CLI flags for the active compression extras. `[code]` (AST
|
||||
// compression) is off by default in headroom → pass --code-aware to turn it on;
|
||||
// `[ml]` (Kompress) is on by default → pass --disable-kompress to turn it off.
|
||||
function extrasProxyArgs({ codeAware, kompress } = {}) {
|
||||
const args = [];
|
||||
if (codeAware) args.push("--code-aware");
|
||||
if (kompress === false) args.push("--disable-kompress");
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function startHeadroomProxy({ port = DEFAULT_PORT, codeAware = false, kompress = true } = {}) {
|
||||
const safePort = Number(port) > 0 && Number(port) < 65536 ? Number(port) : DEFAULT_PORT;
|
||||
const binary = findHeadroomBinary();
|
||||
if (!binary) {
|
||||
@@ -57,7 +68,8 @@ export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) {
|
||||
// spawn stdio requires fd numbers, not WriteStream objects.
|
||||
const outFd = fs.openSync(LOG_FILE, "a");
|
||||
|
||||
const child = spawn(binary, ["proxy", "--port", String(safePort)], {
|
||||
const args = ["proxy", "--port", String(safePort), ...extrasProxyArgs({ codeAware, kompress })];
|
||||
const child = spawn(binary, args, {
|
||||
stdio: ["ignore", outFd, outFd],
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
@@ -118,6 +130,25 @@ export function stopHeadroomProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the managed proxy (if any), wait for the pid to die, then start again
|
||||
// with the given flags. Used when toggling active extras that require a restart.
|
||||
export async function restartHeadroomProxy(opts = {}) {
|
||||
const pid = getManagedPid();
|
||||
if (pid) {
|
||||
try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
|
||||
// Wait up to ~3s for graceful exit, force-kill if still alive.
|
||||
for (let i = 0; i < 30 && isPidAlive(pid); i++) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (isPidAlive(pid)) {
|
||||
try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
clearPid();
|
||||
}
|
||||
return startHeadroomProxy(opts);
|
||||
}
|
||||
|
||||
export function getHeadroomLogTail(maxLines = 200) {
|
||||
try {
|
||||
if (!fs.existsSync(LOG_FILE)) return "";
|
||||
@@ -126,3 +157,104 @@ export function getHeadroomLogTail(maxLines = 200) {
|
||||
return lines.slice(-maxLines).join("\n");
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
// Install (or upgrade) headroom-ai with the requested compression extras.
|
||||
// `extras` is a whitelist from HEADROOM_COMPRESSION_EXTRAS — anything else
|
||||
// is rejected to keep the install surface predictable. Always installs the
|
||||
// `proxy` base + whatever extras the user picked, regardless of what is
|
||||
// already present.
|
||||
export async function installHeadroomExtras(extras = []) {
|
||||
const requested = Array.isArray(extras) ? extras.filter((e) => HEADROOM_COMPRESSION_EXTRAS.includes(e)) : [];
|
||||
const py = findPython310();
|
||||
if (!py) {
|
||||
const err = new Error("Python >= 3.10 not found");
|
||||
err.code = "NO_PYTHON";
|
||||
throw err;
|
||||
}
|
||||
if (!findHeadroomBinary()) {
|
||||
const err = new Error("headroom-ai not installed (run `pip install headroom-ai[proxy]` first)");
|
||||
err.code = "NOT_INSTALLED";
|
||||
throw err;
|
||||
}
|
||||
// pip install string is built from a closed set (HEADROOM_COMPRESSION_EXTRAS),
|
||||
// so it cannot be poisoned by caller input — the comma-list is a fixed
|
||||
// ['proxy', ...requested]. No shell interpolation.
|
||||
const extrasList = ["proxy", ...requested].join(",");
|
||||
const spec = `headroom-ai[${extrasList}]`;
|
||||
const args = ["-m", "pip", "install", "--upgrade", spec];
|
||||
|
||||
ensureDir();
|
||||
// Truncate ("w") so the log reflects only the current install for live progress.
|
||||
const outFd = fs.openSync(INSTALL_LOG_FILE, "w");
|
||||
const child = spawn(py, args, {
|
||||
stdio: ["ignore", outFd, outFd],
|
||||
windowsHide: true,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once("error", (e) => { fs.closeSync(outFd); reject(e); });
|
||||
child.once("exit", (code) => {
|
||||
fs.closeSync(outFd);
|
||||
if (code === 0) {
|
||||
const status = getInstalledHeadroomExtras(py);
|
||||
resolve({ success: true, code, spec, extras: requested, ...status });
|
||||
} else {
|
||||
const err = new Error(`pip install exited with code=${code} — see headroom/install.log`);
|
||||
err.code = "INSTALL_FAILED";
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Uninstall the marker packages that back a single extra (e.g. `ml` → torch,
|
||||
// huggingface-hub). `headroom-ai` base and the `proxy` extra are never removed.
|
||||
export async function uninstallHeadroomExtras(extras = []) {
|
||||
const requested = Array.isArray(extras) ? extras.filter((e) => HEADROOM_COMPRESSION_EXTRAS.includes(e)) : [];
|
||||
const py = findPython310();
|
||||
if (!py) {
|
||||
const err = new Error("Python >= 3.10 not found");
|
||||
err.code = "NO_PYTHON";
|
||||
throw err;
|
||||
}
|
||||
const pkgs = [...new Set(requested.flatMap((e) => EXTRA_MARKERS[e] || []))];
|
||||
if (pkgs.length === 0) {
|
||||
const err = new Error("No valid extras to remove");
|
||||
err.code = "INVALID_EXTRAS";
|
||||
throw err;
|
||||
}
|
||||
const args = ["-m", "pip", "uninstall", "-y", ...pkgs];
|
||||
|
||||
ensureDir();
|
||||
const outFd = fs.openSync(INSTALL_LOG_FILE, "w");
|
||||
const child = spawn(py, args, {
|
||||
stdio: ["ignore", outFd, outFd],
|
||||
windowsHide: true,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once("error", (e) => { fs.closeSync(outFd); reject(e); });
|
||||
child.once("exit", (code) => {
|
||||
fs.closeSync(outFd);
|
||||
if (code === 0) {
|
||||
const status = getInstalledHeadroomExtras(py);
|
||||
resolve({ success: true, code, removed: pkgs, extras: requested, ...status });
|
||||
} else {
|
||||
const err = new Error(`pip uninstall exited with code=${code} — see headroom/install.log`);
|
||||
err.code = "UNINSTALL_FAILED";
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Read the tail of the install/uninstall log for live progress in the UI.
|
||||
export function getInstallLogTail(maxLines = 15) {
|
||||
try {
|
||||
if (!fs.existsSync(INSTALL_LOG_FILE)) return "";
|
||||
const lines = fs.readFileSync(INSTALL_LOG_FILE, "utf8").split(/\r?\n/).filter(Boolean);
|
||||
return lines.slice(-maxLines).join("\n");
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
@@ -153,6 +153,20 @@ function unregisterSession(name, sid) {
|
||||
const entry = getStore().get(name);
|
||||
if (!entry) return;
|
||||
entry.sessions.delete(sid);
|
||||
// No sessions left → kill child to avoid idle orphan process leak.
|
||||
if (entry.sessions.size === 0) {
|
||||
try { entry.proc.kill(); } catch { /* ignore */ }
|
||||
getStore().delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Kill all spawned MCP children — called on app shutdown to prevent orphans.
|
||||
function killAllBridges() {
|
||||
const store = getStore();
|
||||
for (const [name, entry] of store) {
|
||||
try { entry.proc.kill(); } catch { /* ignore */ }
|
||||
store.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
function sendToChild(name, jsonRpc) {
|
||||
@@ -166,4 +180,4 @@ function isRunning(name) {
|
||||
return !!(entry?.proc && !entry.proc.killed && entry.proc.exitCode === null);
|
||||
}
|
||||
|
||||
module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin };
|
||||
module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin, killAllBridges };
|
||||
|
||||
@@ -6,6 +6,33 @@ function normalizeString(value) {
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
// ─── Proxy pool rotation state (in-memory) ─────────────────────────
|
||||
const rotateState = new Map(); // providerId → { index }
|
||||
|
||||
/**
|
||||
* Pick one proxy pool ID from a list based on strategy.
|
||||
* round-robin: cycle sequentially (in-memory, resets on restart)
|
||||
* random: uniform random pick
|
||||
* none/single: return first entry
|
||||
*/
|
||||
export function pickProxyPoolId(poolIds, strategy, providerId) {
|
||||
if (!poolIds || poolIds.length === 0) return null;
|
||||
if (poolIds.length === 1) return poolIds[0];
|
||||
|
||||
if (strategy === "round-robin") {
|
||||
const state = rotateState.get(providerId) || { index: -1 };
|
||||
state.index = (state.index + 1) % poolIds.length;
|
||||
rotateState.set(providerId, state);
|
||||
return poolIds[state.index];
|
||||
}
|
||||
|
||||
if (strategy === "random") {
|
||||
return poolIds[Math.floor(Math.random() * poolIds.length)];
|
||||
}
|
||||
|
||||
return poolIds[0]; // "none" or unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize legacy proxy configuration.
|
||||
*/
|
||||
|
||||
@@ -114,6 +114,10 @@ export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] };
|
||||
// Kimchi OAuth Configuration (Browser token callback flow)
|
||||
export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] };
|
||||
|
||||
// Grok CLI / Grok Build OAuth Configuration (Device Code Flow)
|
||||
// Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes
|
||||
export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] };
|
||||
|
||||
// OAuth timeout (5 minutes)
|
||||
export const OAUTH_TIMEOUT = 300000;
|
||||
|
||||
@@ -137,4 +141,5 @@ export const PROVIDERS = {
|
||||
GITLAB: "gitlab",
|
||||
CODEBUDDY: "codebuddy-cn",
|
||||
KIMCHI: "kimchi",
|
||||
GROK_CLI: "grok-cli",
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
GITLAB_CONFIG,
|
||||
CODEBUDDY_CONFIG,
|
||||
KIMCHI_CONFIG,
|
||||
GROK_CLI_CONFIG,
|
||||
getOAuthClientMetadata,
|
||||
} from "./constants/oauth";
|
||||
import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai";
|
||||
@@ -255,6 +256,122 @@ const PROVIDERS = {
|
||||
},
|
||||
},
|
||||
|
||||
// Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com
|
||||
"grok-cli": {
|
||||
config: GROK_CLI_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const body = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
scope: config.scope,
|
||||
});
|
||||
// Official CLI sends referrer=grok-build
|
||||
if (config.referrer) body.set("referrer", config.referrer);
|
||||
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Grok CLI device code request failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: deviceCode,
|
||||
client_id: config.clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
const text = await response.text();
|
||||
data = { error: "invalid_response", error_description: text };
|
||||
}
|
||||
|
||||
// Device flow: 400 + authorization_pending is expected while user authorizes
|
||||
const pending =
|
||||
data?.error === "authorization_pending" ||
|
||||
data?.error === "slow_down";
|
||||
return {
|
||||
ok: response.ok || pending,
|
||||
data,
|
||||
};
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Best-effort user profile from cli-chat-proxy (non-fatal)
|
||||
try {
|
||||
const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-grok-client-version": "0.2.93",
|
||||
},
|
||||
});
|
||||
if (res.ok) return { user: await res.json() };
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { user: null };
|
||||
},
|
||||
mapTokens: (tokens, extra) => {
|
||||
const email =
|
||||
decodeXaiIdTokenEmail(tokens.id_token) ||
|
||||
extractEmailFromAccessToken(tokens.access_token) ||
|
||||
extra?.user?.email ||
|
||||
null;
|
||||
const userId =
|
||||
extra?.user?.userId ||
|
||||
extra?.user?.principalId ||
|
||||
null;
|
||||
const displayName = [extra?.user?.firstName, extra?.user?.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim() || null;
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || null,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
// Top-level for dashboard connection cards
|
||||
email: email || undefined,
|
||||
displayName: displayName || undefined,
|
||||
// Mirror identity into providerSpecificData so GrokCliExecutor can set
|
||||
// x-email / x-userid without depending on top-level credential shape.
|
||||
providerSpecificData: {
|
||||
authMethod: "device_code",
|
||||
idToken: tokens.id_token || null,
|
||||
email: email || null,
|
||||
userId,
|
||||
hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null,
|
||||
subscriptionTier: extra?.user?.subscriptionTier ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
"gemini-cli": {
|
||||
config: GEMINI_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
@@ -777,6 +894,9 @@ const PROVIDERS = {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
name: extra?.userInfo?.login || extra?.userInfo?.name,
|
||||
displayName: extra?.userInfo?.name || extra?.userInfo?.login,
|
||||
email: extra?.userInfo?.email || null,
|
||||
providerSpecificData: {
|
||||
copilotToken: extra?.copilotToken?.token,
|
||||
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { PXPIPE_DIR } from "./install.js";
|
||||
|
||||
const EVENTS_FILE = path.join(PXPIPE_DIR, "events.jsonl");
|
||||
const ROTATED_FILE = path.join(PXPIPE_DIR, "events.jsonl.1");
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function ensureDir() {
|
||||
if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Fire-and-forget: stats must never break the request path.
|
||||
export function appendPxpipeEvent(event) {
|
||||
try {
|
||||
ensureDir();
|
||||
try {
|
||||
const stat = fs.statSync(EVENTS_FILE);
|
||||
if (stat.size > MAX_FILE_BYTES) fs.renameSync(EVENTS_FILE, ROTATED_FILE);
|
||||
} catch { /* no file yet */ }
|
||||
fs.appendFile(EVENTS_FILE, JSON.stringify({ ts: Date.now(), ...event }) + "\n", () => {});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function readPxpipeEvents({ sinceMs = null, limit = null } = {}) {
|
||||
const events = [];
|
||||
for (const file of [ROTATED_FILE, EVENTS_FILE]) {
|
||||
try {
|
||||
if (!fs.existsSync(file)) continue;
|
||||
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const ev = JSON.parse(line);
|
||||
if (sinceMs && ev.ts < sinceMs) continue;
|
||||
events.push(ev);
|
||||
} catch { /* skip corrupt line */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
events.sort((a, b) => a.ts - b.ts);
|
||||
return limit ? events.slice(-limit) : events;
|
||||
}
|
||||
|
||||
function emptyTotals() {
|
||||
return {
|
||||
requests: 0, compressed: 0, bypassed: 0, errors: 0,
|
||||
tokensBeforeEst: 0, tokensAfterEst: 0, tokensSavedEst: 0, savedPct: 0,
|
||||
imagesGenerated: 0, compressionTimeMs: 0, avgCompressionMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function accumulate(totals, ev) {
|
||||
totals.requests++;
|
||||
if (ev.applied) {
|
||||
totals.compressed++;
|
||||
totals.tokensBeforeEst += ev.tokensBeforeEst || 0;
|
||||
totals.tokensAfterEst += ev.tokensAfterEst || 0;
|
||||
totals.tokensSavedEst += ev.tokensSavedEst || 0;
|
||||
totals.imagesGenerated += ev.imageCount || 0;
|
||||
totals.compressionTimeMs += ev.durationMs || 0;
|
||||
} else if (ev.reason === "transform_error" || ev.reason === "timeout") {
|
||||
totals.errors++;
|
||||
} else {
|
||||
totals.bypassed++;
|
||||
}
|
||||
}
|
||||
|
||||
function finalize(totals) {
|
||||
totals.savedPct = totals.tokensBeforeEst > 0
|
||||
? +((totals.tokensSavedEst / totals.tokensBeforeEst) * 100).toFixed(2)
|
||||
: 0;
|
||||
totals.avgCompressionMs = totals.compressed > 0
|
||||
? Math.round(totals.compressionTimeMs / totals.compressed)
|
||||
: 0;
|
||||
return totals;
|
||||
}
|
||||
|
||||
// Aggregated stats for the dashboard: all-time + windowed totals, a daily
|
||||
// tokens-saved timeline (last `timelineDays`), and the most recent events.
|
||||
export function getPxpipeStats({ timelineDays = 30, recentLimit = 100 } = {}) {
|
||||
const events = readPxpipeEvents();
|
||||
const now = Date.now();
|
||||
const startOfToday = new Date(new Date(now).setHours(0, 0, 0, 0)).getTime();
|
||||
|
||||
const windows = {
|
||||
all: emptyTotals(),
|
||||
today: emptyTotals(),
|
||||
yesterday: emptyTotals(),
|
||||
last7d: emptyTotals(),
|
||||
last30d: emptyTotals(),
|
||||
};
|
||||
|
||||
const timeline = new Map();
|
||||
for (let i = timelineDays - 1; i >= 0; i--) {
|
||||
const day = new Date(startOfToday - i * DAY_MS);
|
||||
timeline.set(day.toISOString().slice(0, 10), { date: day.toISOString().slice(0, 10), tokensSavedEst: 0, compressed: 0, requests: 0 });
|
||||
}
|
||||
|
||||
for (const ev of events) {
|
||||
accumulate(windows.all, ev);
|
||||
if (ev.ts >= startOfToday) accumulate(windows.today, ev);
|
||||
else if (ev.ts >= startOfToday - DAY_MS) accumulate(windows.yesterday, ev);
|
||||
if (ev.ts >= now - 7 * DAY_MS) accumulate(windows.last7d, ev);
|
||||
if (ev.ts >= now - 30 * DAY_MS) accumulate(windows.last30d, ev);
|
||||
|
||||
const key = new Date(ev.ts).toISOString().slice(0, 10);
|
||||
const bucket = timeline.get(key);
|
||||
if (bucket) {
|
||||
bucket.requests++;
|
||||
if (ev.applied) {
|
||||
bucket.compressed++;
|
||||
bucket.tokensSavedEst += ev.tokensSavedEst || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const w of Object.values(windows)) finalize(w);
|
||||
|
||||
return {
|
||||
windows,
|
||||
timeline: [...timeline.values()],
|
||||
recent: events.slice(-recentLimit).reverse(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { spawn, execSync } from "child_process";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
|
||||
export const PXPIPE_DIR = path.join(DATA_DIR, "pxpipe");
|
||||
export const PXPIPE_PACKAGE = "pxpipe-proxy";
|
||||
const INSTALL_LOG = path.join(PXPIPE_DIR, "install.log");
|
||||
const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
const IS_WIN = process.platform === "win32";
|
||||
const NPM_CMD = IS_WIN ? "npm.cmd" : "npm";
|
||||
|
||||
// Same PATH extension trick as headroom/detect.js: packaged/launchd environments
|
||||
// often miss the Node bin dirs.
|
||||
const EXTRA_BINS = IS_WIN
|
||||
? [`${process.env.ProgramFiles || ""}\\nodejs`, `${process.env.APPDATA || ""}\\npm`]
|
||||
: ["/usr/local/bin", "/opt/homebrew/bin", `${process.env.HOME || ""}/.local/bin`, "/usr/bin", "/bin"];
|
||||
const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(path.delimiter);
|
||||
|
||||
let installInFlight = null;
|
||||
|
||||
function ensureDir() {
|
||||
if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export function packageRoot() {
|
||||
return path.join(PXPIPE_DIR, "node_modules", PXPIPE_PACKAGE);
|
||||
}
|
||||
|
||||
export function libraryEntry() {
|
||||
return path.join(packageRoot(), "dist", "core", "library.js");
|
||||
}
|
||||
|
||||
export function findNpm() {
|
||||
try {
|
||||
const out = execSync(`${IS_WIN ? "where" : "which"} npm`, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
}).toString().trim();
|
||||
return out ? out.split(/\r?\n/)[0].trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// { installed, version, path } — installed means the library entry exists on disk.
|
||||
export function getInstallInfo() {
|
||||
try {
|
||||
const pkgJson = path.join(packageRoot(), "package.json");
|
||||
if (!fs.existsSync(pkgJson) || !fs.existsSync(libraryEntry())) {
|
||||
return { installed: false, version: null, path: null };
|
||||
}
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8"));
|
||||
return { installed: true, version: pkg.version || null, path: packageRoot() };
|
||||
} catch {
|
||||
return { installed: false, version: null, path: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function isInstalling() {
|
||||
return installInFlight !== null;
|
||||
}
|
||||
|
||||
// Install (or repair by reinstalling) pxpipe-proxy into DATA_DIR/pxpipe.
|
||||
// Serialized: concurrent calls await the same run.
|
||||
export function installPxpipe() {
|
||||
if (installInFlight) return installInFlight;
|
||||
installInFlight = runInstall().finally(() => { installInFlight = null; });
|
||||
return installInFlight;
|
||||
}
|
||||
|
||||
async function runInstall() {
|
||||
const npm = findNpm();
|
||||
if (!npm) {
|
||||
const err = new Error("npm not found on PATH — Node.js/npm is required to install PXPIPE");
|
||||
err.code = "NPM_NOT_FOUND";
|
||||
throw err;
|
||||
}
|
||||
|
||||
ensureDir();
|
||||
const pkgJson = path.join(PXPIPE_DIR, "package.json");
|
||||
if (!fs.existsSync(pkgJson)) {
|
||||
fs.writeFileSync(pkgJson, JSON.stringify({ name: "9router-pxpipe-host", private: true }, null, 2));
|
||||
}
|
||||
|
||||
const outFd = fs.openSync(INSTALL_LOG, "a");
|
||||
fs.writeSync(outFd, `\n[${new Date().toISOString()}] npm install ${PXPIPE_PACKAGE}@latest\n`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(npm, ["install", `${PXPIPE_PACKAGE}@latest`, "--no-audit", "--no-fund", "--omit=dev"], {
|
||||
cwd: PXPIPE_DIR,
|
||||
stdio: ["ignore", outFd, outFd],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("npm install timed out after 5 minutes — see install.log"));
|
||||
}, INSTALL_TIMEOUT_MS);
|
||||
child.once("error", (e) => { clearTimeout(timer); reject(e); });
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`npm install exited with code ${code} — see install.log`));
|
||||
});
|
||||
}).finally(() => fs.closeSync(outFd));
|
||||
|
||||
const info = getInstallInfo();
|
||||
if (!info.installed) throw new Error("install finished but package is missing — see install.log");
|
||||
return info;
|
||||
}
|
||||
|
||||
export function getInstallLogTail(maxLines = 200) {
|
||||
try {
|
||||
if (!fs.existsSync(INSTALL_LOG)) return "";
|
||||
const lines = fs.readFileSync(INSTALL_LOG, "utf8").split(/\r?\n/).filter(Boolean);
|
||||
return lines.slice(-maxLines).join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { pathToFileURL } from "url";
|
||||
import { getInstallInfo, libraryEntry } from "./install.js";
|
||||
|
||||
// Module cache: pxpipe is loaded once per process ("started") and dropped on
|
||||
// "stop". In library mode start/stop govern the in-process module, not a daemon.
|
||||
let cached = null; // { module, version, loadedAt }
|
||||
let loadPromise = null;
|
||||
|
||||
export function getLoadedInfo() {
|
||||
return cached ? { loaded: true, version: cached.version, loadedAt: cached.loadedAt } : { loaded: false };
|
||||
}
|
||||
|
||||
export async function loadPxpipe() {
|
||||
if (cached) return cached;
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = doLoad().finally(() => { loadPromise = null; });
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
async function doLoad() {
|
||||
const info = getInstallInfo();
|
||||
if (!info.installed) {
|
||||
const err = new Error("PXPIPE is not installed");
|
||||
err.code = "NOT_INSTALLED";
|
||||
throw err;
|
||||
}
|
||||
// Cache-bust per version so Repair/upgrade takes effect without a server restart.
|
||||
const url = `${pathToFileURL(libraryEntry()).href}?v=${encodeURIComponent(info.version || "0")}`;
|
||||
const mod = await import(/* webpackIgnore: true */ url);
|
||||
if (typeof mod.transformAnthropicMessages !== "function") {
|
||||
throw new Error("installed pxpipe package does not export transformAnthropicMessages");
|
||||
}
|
||||
cached = { module: mod, version: info.version, loadedAt: Date.now() };
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function unloadPxpipe() {
|
||||
const wasLoaded = !!cached;
|
||||
cached = null;
|
||||
return wasLoaded;
|
||||
}
|
||||
|
||||
// Transform function for the request pipeline; null when unavailable (fail-open).
|
||||
// autoLoad controls whether a cold cache triggers a load (first request warms it).
|
||||
export async function getTransform({ autoLoad = true } = {}) {
|
||||
try {
|
||||
if (!cached && !autoLoad) return null;
|
||||
const { module: mod } = await loadPxpipe();
|
||||
return mod.transformAnthropicMessages;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Health self-test: run a tiny synthetic Claude request through the transformer.
|
||||
// A healthy module parses it and answers with a machine-readable reason.
|
||||
export async function selfTest() {
|
||||
const startedAt = Date.now();
|
||||
const { module: mod } = await loadPxpipe();
|
||||
const body = new TextEncoder().encode(JSON.stringify({
|
||||
model: "claude-fable-5",
|
||||
max_tokens: 16,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
}));
|
||||
const result = await mod.transformAnthropicMessages({ body, model: "claude-fable-5" });
|
||||
if (!result || typeof result.applied !== "boolean" || !(result.body instanceof Uint8Array)) {
|
||||
throw new Error("transform returned an unexpected shape");
|
||||
}
|
||||
return { ok: true, reason: result.reason, durationMs: Date.now() - startedAt };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getInstallInfo, isInstalling, findNpm } from "./install.js";
|
||||
import { getLoadedInfo, loadPxpipe, selfTest } from "./loader.js";
|
||||
|
||||
// Aggregate status for the Token Saver card and /api/pxpipe/status.
|
||||
// "running" in library mode = module loaded into this process.
|
||||
export function getPxpipeStatus() {
|
||||
const install = getInstallInfo();
|
||||
const loaded = getLoadedInfo();
|
||||
return {
|
||||
installed: install.installed,
|
||||
installing: isInstalling(),
|
||||
version: install.version,
|
||||
path: install.path,
|
||||
running: loaded.loaded,
|
||||
loadedAt: loaded.loadedAt || null,
|
||||
uptimeMs: loaded.loaded ? Date.now() - loaded.loadedAt : 0,
|
||||
npmAvailable: !!findNpm(),
|
||||
mode: "library", // in-process transform, not an external proxy
|
||||
};
|
||||
}
|
||||
|
||||
// PRD health checklist, adapted to library mode: installed? → module loads
|
||||
// (the "executable found / port listening" equivalent) → test request transforms.
|
||||
export async function runHealthCheck() {
|
||||
const checks = [];
|
||||
const fail = (error) => ({ healthy: false, checks, error });
|
||||
|
||||
const install = getInstallInfo();
|
||||
checks.push({ id: "installed", label: "PXPIPE installed", ok: install.installed, detail: install.version ? `v${install.version}` : null });
|
||||
if (!install.installed) return fail("pxpipe not installed");
|
||||
|
||||
try {
|
||||
await loadPxpipe();
|
||||
checks.push({ id: "module", label: "Transform module loads", ok: true, detail: `v${install.version}` });
|
||||
} catch (e) {
|
||||
checks.push({ id: "module", label: "Transform module loads", ok: false, detail: e.message });
|
||||
return fail(`Cannot load module: ${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const test = await selfTest();
|
||||
checks.push({ id: "transform", label: "Test request transforms", ok: true, detail: `${test.durationMs}ms (${test.reason})` });
|
||||
} catch (e) {
|
||||
checks.push({ id: "transform", label: "Test request transforms", ok: false, detail: e.message });
|
||||
return fail(`Self-test failed: ${e.message}`);
|
||||
}
|
||||
|
||||
return { healthy: true, checks, error: null };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
|
||||
export {
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById,
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||
} from "@/lib/db/index.js";
|
||||
|
||||
Reference in New Issue
Block a user