feat(headroom): add extras detection and install UI (#2403)
- add Headroom extras status + install endpoints - show Headroom version + code/ml extras in Token Saver UI - fix Windows interpreter selection to read from env with headroom-ai
This commit is contained in:
@@ -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.
|
||||
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,6 +64,9 @@ export function findHeadroomBinary() {
|
||||
}
|
||||
|
||||
// Find a Python interpreter >= 3.10 (headroom-ai requires it). Returns null if none.
|
||||
// On Windows, `python3` and `python` can point at different envs. Prefer the
|
||||
// first candidate that can also see the installed `headroom-ai` package so the
|
||||
// dashboard probes and install action operate on the same interpreter as the CLI.
|
||||
export function findPython310() {
|
||||
for (const candidate of PYTHON_CANDIDATES) {
|
||||
try {
|
||||
@@ -60,8 +78,18 @@ 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 (!IS_WIN) return 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 on Windows until an interpreter sees headroom-ai.
|
||||
}
|
||||
} catch {
|
||||
// candidate not present, try next
|
||||
@@ -98,5 +126,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 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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, getInstalledHeadroomExtras } from "./detect.js";
|
||||
|
||||
const HEADROOM_DIR = path.join(DATA_DIR, "headroom");
|
||||
const PID_FILE = path.join(HEADROOM_DIR, "proxy.pid");
|
||||
@@ -126,3 +126,52 @@ 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();
|
||||
const outFd = fs.openSync(path.join(HEADROOM_DIR, "install.log"), "a");
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user