Project Files
src / llama-server-manager.ts
/**
* Lifecycle manager for the native `llama-server` GGUF embedding engine.
*
* This replaces the previous Python/FastAPI GGUF sidecar
* (`qwen_embedding_server_gguf`, driven by `qwen-embedding-gguf-server-manager.ts`)
* entirely. There is no Python process and no intermediate HTTP wrapper
* anymore: the plugin spawns the `llama-server` binary directly and talks to
* its native `/health`, `/props`, and `/embeddings` HTTP routes.
*
* `find_image` reuses the process only for embedding work within its current
* tool call, then explicitly stops it before reranking or tool completion.
* See docs/development/vision-embedding-architecture.md.
*
* The exact request shape (`POST /embeddings` with a `content` array of
* `{ prompt_string, multimodal_data }` items, and the `LLAMA_MEDIA_MARKER`
* env var) and the Qwen chat-template prompt construction it depends on are
* built in `src/embeddings/multimodalEmbeddingClientGguf.ts`; this module
* only owns the server process lifecycle.
*/
import fs from "fs";
import os from "os";
import path from "path";
import { spawn } from "child_process";
import { localTimestamp } from "./helpers/localTimestamp.js";
/**
* Fixed media-marker placeholder substituted into the rendered chat-template
* prompt in place of Qwen's native `<|vision_start|><|image_pad|><|vision_end|>`
* marker. Must be passed to the spawned `llama-server` process via the
* `LLAMA_MEDIA_MARKER` env var β without it, `llama-server` generates a
* random per-process marker for security reasons, and any prompt built
* against a different value fails with "number of media markers in text (0)
* does not match number of bitmaps (1)".
*/
export const LLAMA_MEDIA_MARKER = "<__media__>";
const HEALTH_POLL_INTERVAL_MS = 1_000;
const HEALTH_POLL_TIMEOUT_MS = 90_000;
const HEALTH_FETCH_TIMEOUT_MS = 2_000;
const STOP_POLL_INTERVAL_MS = 250;
const STOP_TIMEOUT_MS = 10_000;
let activePort: number | null = null;
let spawnedPid: number | null = null;
let idleStopTimer: NodeJS.Timeout | null = null;
function cancelIdleStop(): void {
if (idleStopTimer !== null) {
clearTimeout(idleStopTimer);
idleStopTimer = null;
}
}
function pluginRoot(): string {
return process.cwd();
}
function logsDir(): string {
return path.join(pluginRoot(), "logs");
}
function pidFilePath(): string {
return path.join(logsDir(), "llama-server-embedding.pid");
}
function logFilePath(): string {
return path.join(logsDir(), "llama-server-embedding.log");
}
function lifecycleLogFilePath(): string {
return path.join(logsDir(), "find-image-plugin-lifecycle.log");
}
/**
* Lines emitted by `llama-server` itself that are pure noise. The embedding
* warning fires twice for every single embed call, while the warmup dump can
* contain hundreds of unsupported-operator lines for one process start.
* Filtered out of the piped stdout/stderr before it reaches the log file, see
* `pipeFilteredToLog()`.
*/
const NOISY_LOG_LINE_PATTERN = /embeddings required but some input tokens were not marked as outputs -> overriding/;
const WARMUP_LOG_LINE_PATTERN = /\bwarmup:/;
/**
* Pipes a child process stream into the log file line-by-line, dropping known
* noise and recording the local time received. Warmup lines are summarized when
* the stream closes. A plain `.pipe()` can't filter content, so chunks are
* buffered and split on newlines instead.
*/
function pipeFilteredToLog(stream: NodeJS.ReadableStream, logStream: fs.WriteStream): void {
let buffer = "";
let suppressedWarmupLines = 0;
stream.on("data", (chunk: Buffer) => {
buffer += chunk.toString("utf-8");
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (NOISY_LOG_LINE_PATTERN.test(line)) continue;
if (WARMUP_LOG_LINE_PATTERN.test(line)) {
suppressedWarmupLines++;
continue;
}
logStream.write(line ? `[llama-server] ${localTimestamp()} ${line}\n` : "\n");
}
});
stream.on("end", () => {
if (buffer && !NOISY_LOG_LINE_PATTERN.test(buffer) && !WARMUP_LOG_LINE_PATTERN.test(buffer)) {
logStream.write(`[llama-server] ${localTimestamp()} ${buffer}`);
} else if (buffer && WARMUP_LOG_LINE_PATTERN.test(buffer)) {
suppressedWarmupLines++;
}
if (suppressedWarmupLines > 0) {
logStream.write(`[llama-server] ${localTimestamp()} Suppressed ${suppressedWarmupLines} repetitive warmup diagnostic lines\n`);
}
buffer = "";
});
}
/**
* Appends a single line to the same log file `llama-server`'s own
* stdout/stderr is piped into β used by the indexer to surface the one
* piece of genuinely useful information missing from the raw engine log:
* which file was actually embedded. Best-effort; a logging failure must
* never interrupt indexing.
*/
export function appendEmbeddingLogLine(message: string): void {
try {
fs.mkdirSync(logsDir(), { recursive: true });
const lines = message.endsWith("\n") ? message.slice(0, -1).split("\n") : message.split("\n");
fs.appendFileSync(
logFilePath(),
lines.map((line) => `[indexer] ${localTimestamp()} ${line}\n`).join("")
);
} catch (error) {
console.warn(`[mgr-llama-server] Failed to append log line:`, error);
}
}
function unlinkIfExists(filePath: string): void {
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch (error) {
console.warn(`[mgr-llama-server] Failed to unlink ${filePath}:`, error);
}
}
function readPid(): number | null {
try {
const raw = fs.readFileSync(pidFilePath(), "utf-8").trim();
const pid = parseInt(raw, 10);
return Number.isFinite(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer));
}
async function pollHealth(port: number, onAttempt: (attempt: number) => void): Promise<boolean> {
const url = `http://127.0.0.1:${port}/health`;
const deadline = Date.now() + HEALTH_POLL_TIMEOUT_MS;
let attempt = 0;
while (Date.now() < deadline) {
attempt++;
onAttempt(attempt);
try {
const res = await fetchWithTimeout(url, {}, HEALTH_FETCH_TIMEOUT_MS);
if (res.ok) return true;
} catch (error) {
void error;
}
await new Promise<void>((resolve) => setTimeout(resolve, HEALTH_POLL_INTERVAL_MS));
}
return false;
}
interface LlamaServerProps {
model_path?: string;
media_marker?: string;
}
async function readProps(port: number): Promise<LlamaServerProps | null> {
try {
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/props`, {}, HEALTH_FETCH_TIMEOUT_MS);
if (!res.ok) return null;
return (await res.json()) as LlamaServerProps;
} catch {
return null;
}
}
async function waitUntil(predicate: () => boolean | Promise<boolean>, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) return true;
await new Promise<void>((resolve) => setTimeout(resolve, STOP_POLL_INTERVAL_MS));
}
return await predicate();
}
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
return waitUntil(() => !isProcessAlive(pid), timeoutMs);
}
async function waitForPortClosed(port: number, timeoutMs: number): Promise<boolean> {
return waitUntil(async () => {
try {
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/health`, {}, HEALTH_FETCH_TIMEOUT_MS);
return !res.ok;
} catch {
return true;
}
}, timeoutMs);
}
function normalizePath(value: string | undefined): string {
if (!value) return "";
const trimmed = value.trim();
if (!trimmed) return "";
return trimmed.startsWith("~") ? path.join(os.homedir(), trimmed.slice(1)) : trimmed;
}
function resolveSingleMmprojFile(dir: string): string {
let entries: string[];
try {
entries = fs.readdirSync(dir);
} catch {
throw new Error(`GGUF model directory is not readable: ${dir}`);
}
const mmprojCandidates = entries.filter((entry) => entry.toLowerCase().endsWith(".gguf") && entry.toLowerCase().includes("mmproj"));
if (mmprojCandidates.length === 0) {
throw new Error(`No mmproj .gguf file found in GGUF model directory: ${dir}`);
}
if (mmprojCandidates.length > 1) {
throw new Error(
`Multiple candidate mmproj .gguf files found in ${dir}: ${mmprojCandidates.join(", ")}. ` +
"Keep exactly one mmproj file next to the selected model file."
);
}
return path.join(dir, mmprojCandidates[0]);
}
/**
* Resolves a GGUF model path into explicit main-model / mmproj file paths.
* A directory is accepted only when it contains exactly one main `.gguf` file.
* A direct `.gguf` file path is preferred when a directory contains multiple
* quantizations; the matching mmproj file is then resolved from the same dir.
*/
export function resolveGgufModelFiles(modelPath: string): { modelFile: string; mmprojFile: string } {
const resolved = normalizePath(modelPath);
if (!resolved) {
throw new Error("GGUF model path is not configured.");
}
let stat: fs.Stats;
try {
stat = fs.statSync(resolved);
} catch {
throw new Error(`GGUF model path does not exist: ${resolved}`);
}
if (stat.isFile()) {
const basename = path.basename(resolved);
const lower = basename.toLowerCase();
if (!lower.endsWith(".gguf")) {
throw new Error(`GGUF model path is not a .gguf file: ${resolved}`);
}
if (lower.includes("mmproj")) {
throw new Error(`GGUF model path points to an mmproj file, not the main model file: ${resolved}`);
}
return {
modelFile: resolved,
mmprojFile: resolveSingleMmprojFile(path.dirname(resolved)),
};
}
if (!stat.isDirectory()) {
throw new Error(`GGUF model path is not a directory or .gguf file: ${resolved}`);
}
let entries: string[];
try {
entries = fs.readdirSync(resolved);
} catch {
throw new Error(`GGUF model directory is not readable: ${resolved}`);
}
const ggufFiles = entries.filter((entry) => entry.toLowerCase().endsWith(".gguf"));
const mainCandidates = ggufFiles.filter((entry) => !entry.toLowerCase().includes("mmproj"));
const mmprojCandidates = ggufFiles.filter((entry) => entry.toLowerCase().includes("mmproj"));
if (mainCandidates.length === 0) {
throw new Error(`No main .gguf model file found in: ${resolved}`);
}
if (mainCandidates.length > 1) {
throw new Error(
`Multiple candidate main .gguf files found in ${resolved}: ${mainCandidates.join(", ")}. ` +
"Set multimodalEmbeddingModelPath to the exact .gguf file you want to use."
);
}
if (mmprojCandidates.length === 0) {
throw new Error(`No mmproj .gguf file found in GGUF model directory: ${resolved}`);
}
return {
modelFile: path.join(resolved, mainCandidates[0]),
mmprojFile: resolveSingleMmprojFile(resolved),
};
}
function validateBinaryPath(binaryPath: string | undefined): string {
const resolved = normalizePath(binaryPath);
if (!resolved) {
throw new Error("llama-server binary path is not configured.");
}
if (!fs.existsSync(resolved)) {
throw new Error(`llama-server binary does not exist: ${resolved}`);
}
try {
fs.accessSync(resolved, fs.constants.X_OK);
} catch {
throw new Error(`llama-server binary is not executable: ${resolved}`);
}
return resolved;
}
export interface LlamaServerEmbeddingConfig {
port: number;
/** Absolute path to the llama-server executable. */
binaryPath: string;
/** Directory containing exactly one main .gguf file and exactly one mmproj .gguf file, or a direct .gguf file path. */
modelDir: string;
contextSize: number;
/**
* GPU offload layers. Defaults to full offload (999). Requires a
* llama-server binary built from a patched llama.cpp that synchronizes
* after each mtmd decode() call β otherwise image input on GPU corrupts
* the heap (SIGTRAP). See
* docs/development/vision-embedding-architecture.md, "GGUF GPU crash root cause".
*/
nGpuLayers?: number;
}
async function killAndWait(pid: number, logFile: string, isoNow: () => string): Promise<void> {
if (!isProcessAlive(pid)) return;
try {
process.kill(pid, "SIGTERM");
} catch (error) {
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} Failed to SIGTERM PID ${pid}: ${error instanceof Error ? error.message : String(error)}\n`);
}
await waitForProcessExit(pid, 5_000);
if (isProcessAlive(pid)) {
try {
process.kill(pid, "SIGKILL");
} catch (error) {
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} Failed to SIGKILL PID ${pid}: ${error instanceof Error ? error.message : String(error)}\n`);
}
await waitForProcessExit(pid, 2_000);
}
}
export async function ensureLlamaServerRunning(
config: LlamaServerEmbeddingConfig,
onStatus: (msg: string) => void
): Promise<void> {
const port = config.port;
cancelIdleStop();
activePort = port;
fs.mkdirSync(logsDir(), { recursive: true });
const logFile = logFilePath();
const isoNow = localTimestamp;
const validationDiagnostic = `embedding validation port=${port} configured binary=${JSON.stringify(config.binaryPath)} model=${JSON.stringify(config.modelDir)}`;
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} ${validationDiagnostic}\n`);
fs.appendFileSync(lifecycleLogFilePath(), `${isoNow()} pid=${process.pid} ${validationDiagnostic}\n`);
const binaryPath = validateBinaryPath(config.binaryPath);
const { modelFile, mmprojFile } = resolveGgufModelFiles(config.modelDir);
const nGpuLayers = config.nGpuLayers ?? 999;
if (!Number.isInteger(nGpuLayers) || nGpuLayers < 1 || nGpuLayers > 999) {
throw new Error(`multimodalEmbeddingGgufGpuLayers must be an integer between 1 and 999; got ${String(config.nGpuLayers)}.`);
}
const contextSize = config.contextSize;
const existingHealthy = await (async () => {
try {
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/health`, {}, HEALTH_FETCH_TIMEOUT_MS);
return res.ok;
} catch {
return false;
}
})();
if (existingHealthy) {
const props = await readProps(port);
const runningModelPath = normalizePath(props?.model_path);
const matches = props !== null && runningModelPath === modelFile && props.media_marker === LLAMA_MEDIA_MARKER;
if (matches) {
const pid = readPid();
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} Adopted [${port}]${pid !== null ? ` β PID ${pid}` : ""} β model: ${modelFile}\n`);
return;
}
// Something is listening on the configured port but does not match our
// configuration. Only stop it if it is a process we ourselves spawned
// (tracked via our own PID file) β otherwise refuse, since it may be an
// unrelated service.
const stalePid = readPid();
if (stalePid === null || !isProcessAlive(stalePid)) {
throw new Error(
`Port ${port} is occupied by a server that does not match the configured GGUF model/binary, ` +
`and no plugin-managed process was found to stop. Refusing to adopt or stop it. ` +
`Configured model: ${modelFile}`
);
}
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} Config changed β stopping PID ${stalePid} to restart\n`);
onStatus("Embedding settings changed β restarting serviceβ¦");
await killAndWait(stalePid, logFile, isoNow);
unlinkIfExists(pidFilePath());
} else {
const stalePid = readPid();
if (stalePid !== null) {
if (isProcessAlive(stalePid)) {
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} Killing stale unresponsive PID ${stalePid}\n`);
await killAndWait(stalePid, logFile, isoNow);
}
unlinkIfExists(pidFilePath());
}
}
const args = [
"-m", modelFile,
"--mmproj", mmprojFile,
"--host", "127.0.0.1",
"--port", String(port),
"-c", String(contextSize),
"-ngl", String(nGpuLayers),
"--embeddings",
"--pooling", "last",
"--embd-normalize", "2",
"--no-webui",
];
onStatus("Loading embedding model...");
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} Starting [${port}] binary=${binaryPath} model=${modelFile} mmproj=${mmprojFile}\n`);
const logStream = fs.createWriteStream(logFile, { flags: "a" });
const child = spawn(binaryPath, args, {
cwd: pluginRoot(),
detached: true,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, HOME: os.homedir(), LLAMA_MEDIA_MARKER },
});
pipeFilteredToLog(child.stdout, logStream);
pipeFilteredToLog(child.stderr, logStream);
child.once("exit", (code, signal) => {
if (spawnedPid === child.pid) spawnedPid = null;
fs.appendFileSync(
logFile,
`[mgr-llama-server] ${isoNow()} Exited [${port}] β PID ${child.pid} β code=${code ?? "none"} signal=${signal ?? "none"}\n`
);
logStream.end();
});
child.once("error", (error) => {
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} Process error [${port}] β PID ${child.pid}: ${error.message}\n`);
});
child.unref();
if (!child.pid) {
throw new Error(`Failed to spawn llama-server process for embeddings. Check logs: ${logFile}`);
}
spawnedPid = child.pid;
fs.writeFileSync(pidFilePath(), String(child.pid));
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} Spawned [${port}] β PID ${child.pid}\n`);
const ready = await pollHealth(port, () => undefined);
if (!ready) {
throw new Error(`Embedding service did not become healthy within ${HEALTH_POLL_TIMEOUT_MS / 1000}s. Check logs: ${logFile}`);
}
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} Started [${port}] β PID ${child.pid} β model: ${modelFile}\n`);
onStatus("Embedding service ready...");
}
export async function stopLlamaServerEmbeddingServer(port: number): Promise<void> {
cancelIdleStop();
const logFile = logFilePath();
const isoNow = localTimestamp;
fs.mkdirSync(logsDir(), { recursive: true });
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} Stopping [${port}]...\n`);
const pid = readPid();
if (pid !== null) {
await killAndWait(pid, logFile, isoNow);
}
if (!(await waitForPortClosed(port, STOP_TIMEOUT_MS))) {
throw new Error(`Port ${port} is still responding after stopping the llama-server embedding service.`);
}
fs.appendFileSync(logFile, `[mgr-llama-server] ${isoNow()} Stopped [${port}]\n`);
unlinkIfExists(pidFilePath());
}
/** Stops the server only when this Node.js process started it. */
export async function releaseOwnedLlamaServerEmbeddingServer(port: number, ttlMinutes = 0): Promise<void> {
cancelIdleStop();
if (spawnedPid === null) return;
const timeoutMs = Math.max(0, Math.floor(ttlMinutes)) * 60_000;
if (timeoutMs === 0) {
await stopLlamaServerEmbeddingServer(port);
return;
}
idleStopTimer = setTimeout(() => {
idleStopTimer = null;
if (activePort !== port || spawnedPid === null) return;
void stopLlamaServerEmbeddingServer(port).catch((error) => {
console.warn("[mgr-llama-server] Failed to stop idle embedding server:", error);
});
}, timeoutMs);
idleStopTimer.unref();
}
export async function stopActiveLlamaServerEmbeddingServer(): Promise<void> {
if (activePort !== null) {
await stopLlamaServerEmbeddingServer(activePort);
}
}