Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 241 additions & 0 deletions backend/src/lib/llm/controlCenter/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { buildProvider, getLlmConfig, isProviderLocal, type LlmRole, type ProviderConfig } from "../providers/registry";
import { classifyLlmError, type LlmErrorKind } from "../friendlyError";
import { findOllamaInstall, ollamaIsLocal, sameOllamaModel } from "./localRuntime";
import { providerHasKey, providerNeedsKey } from "./providers";

/**
* "Is the AI actually going to work?" — one check per role that goes past the
* saved config: is the runtime up, is the model there, is the key accepted.
* Every problem carries the one action that fixes it, for the UI to offer.
*/

export type FixAction =
| { action: "setup"; label: string }
| { action: "start-ollama"; label: string }
| { action: "get-ollama"; label: string }
| { action: "download-model"; label: string; model: string }
| { action: "add-key"; label: string; provider: string }
| { action: "turn-off-local-only"; label: string }
| { action: "edit-role"; label: string };

export type RoleHealth = {
role: LlmRole;
/** ok: works · warning: works with limits · error: won't work · off: not used (embedding only) */
status: "ok" | "warning" | "error" | "off";
binding: { provider: string; label: string; model: string; local: boolean; inherited: boolean } | null;
title: string;
detail?: string;
fix?: FixAction;
};

export type AiHealth = {
overall: "ready" | "attention" | "not-set-up";
localOnly: boolean;
roles: Record<LlmRole, RoleHealth>;
checkedAt: string;
};

type Listing = { models: string[] } | { error: { kind: LlmErrorKind; message: string } };

const LIST_TTL_MS = 20_000;
const LIST_TIMEOUT_MS = 8_000;
const listCache = new Map<string, { at: number; value: Promise<Listing> }>();

function listModels(pc: ProviderConfig, fresh: boolean): Promise<Listing> {
const key = JSON.stringify([pc.id, pc.kind, pc.baseUrl, pc.apiKey]);
const hit = listCache.get(key);
if (!fresh && hit && Date.now() - hit.at < LIST_TTL_MS) return hit.value;

const value = (async (): Promise<Listing> => {
try {
const provider = buildProvider(pc);
if (!provider.listModels) return { models: [] };
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(Object.assign(new Error("timed out"), { name: "TimeoutError" })), LIST_TIMEOUT_MS),
);
return { models: await Promise.race([provider.listModels(), timeout]) };
} catch (err) {
return { error: classifyLlmError(err) };
}
})();
listCache.set(key, { at: Date.now(), value });
return value;
}

/** Forget cached listings, e.g. right after a key or download changes. */
export function invalidateHealthCache(): void {
listCache.clear();
}

const ROLE_NAME: Record<LlmRole, string> = {
agent: "the assistant",
utility: "background tasks",
embedding: "document search",
};

function runtimeName(pc: ProviderConfig): string {
if (pc.id === "ollama") return "Ollama";
if (pc.id === "lmstudio") return "LM Studio";
if (pc.id === "llamacpp") return "the llama.cpp server";
return pc.label ?? pc.id;
}

function ollamaDownFix(): FixAction {
if (!ollamaIsLocal()) return { action: "edit-role", label: "Change model" };
return findOllamaInstall() ? { action: "start-ollama", label: "Start Ollama" } : { action: "get-ollama", label: "Install Ollama" };
}

async function checkRole(role: LlmRole, fresh: boolean): Promise<RoleHealth> {
const config = getLlmConfig();
const own = config.roles[role];
const binding = own ?? (role === "utility" ? config.roles.agent : undefined);

if (!binding) {
if (role === "embedding") {
return {
role,
status: "off",
binding: null,
title: "Keyword search",
detail: "No embedding model, so document search matches words rather than meaning. Optional.",
};
}
return {
role,
status: "error",
binding: null,
title: "Not set up",
detail: `Choose a model so ${ROLE_NAME[role]} can work.`,
fix: { action: "setup", label: "Set up AI" },
};
}

const pc = config.providers.find((p) => p.id === binding.provider);
const view = {
provider: binding.provider,
label: pc?.label ?? binding.provider,
model: binding.model,
local: pc ? isProviderLocal(pc) : false,
inherited: !own,
};
const fail = (title: string, detail: string, fix?: FixAction): RoleHealth => ({
role,
status: "error",
binding: view,
title,
detail,
fix,
});

if (!pc) {
return fail("Provider missing", `"${binding.provider}" was removed. Pick another model.`, { action: "edit-role", label: "Change model" });
}
if (config.localOnly && !view.local) {
return fail(
"Blocked by Local-only mode",
`${view.label} is a cloud provider, and Local-only mode blocks anything that leaves this computer.`,
{ action: "turn-off-local-only", label: "Allow cloud AI" },
);
}
if (providerNeedsKey(pc) && !providerHasKey(pc)) {
return fail("API key missing", `There's no API key saved for ${view.label}.`, {
action: "add-key",
label: "Add key",
provider: pc.id,
});
}
if (role === "embedding" && !buildProvider(pc).embed) {
return fail("Can't do document search", `${view.label} has no embedding models. Use an on-device model or turn document search off.`, {
action: "edit-role",
label: "Change model",
});
}

const listing = await listModels(pc, fresh);
if ("error" in listing) {
const { kind, message } = listing.error;
if (kind === "local-down" || (view.local && (kind === "offline" || kind === "unreachable" || kind === "timeout"))) {
return pc.id === "ollama"
? fail("Ollama isn't running", "Ollama runs the on-device models. Start it and this fixes itself.", ollamaDownFix())
: fail(`${runtimeName(pc)} isn't running`, `Open ${runtimeName(pc)} and start its server, then check again.`);
}
if (kind === "auth") {
return fail("API key rejected", `${view.label} didn't accept the saved key. It may have been revoked or mistyped.`, {
action: "add-key",
label: "Replace key",
provider: pc.id,
});
}
if (kind === "quota") {
return fail("Out of credit", message);
}
if (kind === "offline" || kind === "timeout") {
return fail(`Can't reach ${view.label}`, "Check your internet connection. Screening still works; AI features wait until you're back online.");
}
return fail(`${view.label} isn't responding`, message, { action: "edit-role", label: "Change model" });
}

const { models } = listing;
const installed =
!models.length || models.some((m) => (pc.kind === "ollama" ? sameOllamaModel(m, binding.model) : m === binding.model));
if (!installed) {
if (pc.kind === "ollama") {
return fail(`${binding.model} isn't downloaded`, "The model was removed from Ollama, or hasn't been downloaded yet.", {
action: "download-model",
label: "Download",
model: binding.model,
});
}
return fail(
"Model not available",
`${view.label} doesn't offer "${binding.model}" to your account. It may be retired — pick another model.`,
{ action: "edit-role", label: "Change model" },
);
}

if (role === "agent" && binding.supportsTools === false) {
return {
role,
status: "warning",
binding: view,
title: "Chat only",
detail: "This model can't use tools, so the assistant can answer questions but can't run screenings for you.",
fix: { action: "edit-role", label: "Change model" },
};
}

return { role, status: "ok", binding: view, title: "Ready" };
}

export async function checkAiHealth(opts: { fresh?: boolean } = {}): Promise<AiHealth> {
const fresh = !!opts.fresh;
let localOnly = false;
let roles: Record<LlmRole, RoleHealth>;
try {
localOnly = !!getLlmConfig().localOnly;
const [agent, utility, embedding] = await Promise.all(
(["agent", "utility", "embedding"] as const).map((r) => checkRole(r, fresh)),
);
roles = { agent, utility, embedding };
} catch (err) {
// A broken config file shouldn't take the page down with it.
const broken = (role: LlmRole): RoleHealth => ({
role,
status: "error",
binding: null,
title: "Settings couldn't be read",
detail: err instanceof Error ? err.message : String(err),
fix: { action: "setup", label: "Set up again" },
});
roles = { agent: broken("agent"), utility: broken("utility"), embedding: broken("embedding") };
}

const all = Object.values(roles);
const overall =
!roles.agent.binding && roles.agent.status === "error"
? "not-set-up"
: all.some((r) => r.status === "error" || r.status === "warning")
? "attention"
: "ready";
return { overall, localOnly, roles, checkedAt: new Date().toISOString() };
}
93 changes: 92 additions & 1 deletion backend/src/lib/llm/controlCenter/localRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { spawn } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import { isLoopbackUrl } from "../providers/registry";

/**
* On-device model runtimes (Ollama, LM Studio) — detection, hardware fit, and
Expand All @@ -17,11 +21,29 @@ export type Hardware = {
freeMemGB: number;
/** Apple Silicon shares RAM with the GPU, so most of it is usable for models. */
unifiedMemory: boolean;
/** Free space where Ollama stores models; null when it can't be read. */
freeDiskGB: number | null;
};

const gb = (n: number) => Math.round((n / 1024 ** 3) * 10) / 10;

function freeDiskGB(): number | null {
// Ollama keeps models under ~/.ollama/models unless OLLAMA_MODELS moves them.
const candidates = [process.env.OLLAMA_MODELS?.trim(), path.join(os.homedir(), ".ollama"), os.homedir()];
for (const dir of candidates) {
if (!dir) continue;
try {
const s = fs.statfsSync(dir);
return gb(s.bavail * s.bsize);
} catch {
// try the next one
}
}
return null;
}

export function detectHardware(): Hardware {
const cpus = os.cpus();
const gb = (n: number) => Math.round((n / 1024 ** 3) * 10) / 10;
return {
platform: process.platform,
arch: process.arch,
Expand All @@ -30,9 +52,13 @@ export function detectHardware(): Hardware {
totalMemGB: gb(os.totalmem()),
freeMemGB: gb(os.freemem()),
unifiedMemory: process.platform === "darwin" && process.arch === "arm64",
freeDiskGB: freeDiskGB(),
};
}

/** Disk headroom kept free beyond the model itself. */
export const DISK_HEADROOM_GB = 2;

export type CatalogModel = {
id: string;
label: string;
Expand Down Expand Up @@ -105,6 +131,71 @@ export async function ollamaStatus(): Promise<{ running: boolean; version: strin
};
}

type OllamaInstall = { kind: "app" | "binary"; path: string };

/** Where Ollama is installed on this computer, if anywhere. */
export function findOllamaInstall(): OllamaInstall | null {
const home = os.homedir();
const apps: string[] = [];
const bins: string[] = [];
if (process.platform === "darwin") {
apps.push("/Applications/Ollama.app", path.join(home, "Applications", "Ollama.app"));
bins.push("/opt/homebrew/bin/ollama", "/usr/local/bin/ollama");
} else if (process.platform === "win32") {
const local = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local");
apps.push(path.join(local, "Programs", "Ollama", "ollama app.exe"));
bins.push(path.join(local, "Programs", "Ollama", "ollama.exe"));
} else {
bins.push("/usr/local/bin/ollama", "/usr/bin/ollama", path.join(home, ".local", "bin", "ollama"));
}
for (const p of apps) if (fs.existsSync(p)) return { kind: "app", path: p };
for (const p of bins) if (fs.existsSync(p)) return { kind: "binary", path: p };
return null;
}

/** Ollama is only ours to start when it is meant to run on this machine. */
export function ollamaIsLocal(): boolean {
return isLoopbackUrl(OLLAMA_URL);
}

/**
* Launch the installed Ollama and wait for its API. The desktop app (macOS,
* Windows) also starts its menu-bar helper; a bare binary runs `ollama serve`.
*/
export async function startOllama(timeoutMs = 20_000): Promise<{ started: boolean; detail?: string }> {
if ((await ollamaStatus()).running) return { started: true };
if (!ollamaIsLocal()) return { started: false, detail: `Ollama is configured at ${OLLAMA_URL}, which isn't this computer.` };
const install = findOllamaInstall();
if (!install) return { started: false, detail: "Ollama isn't installed. Download it from ollama.com, then try again." };

const [cmd, args] =
install.kind === "app"
? process.platform === "darwin"
? ["open", ["-g", "-a", install.path]]
: [install.path, []]
: [install.path, ["serve"]];
try {
const child = spawn(cmd, args as string[], { detached: true, stdio: "ignore", windowsHide: true });
child.on("error", () => {});
child.unref();
} catch (err) {
return { started: false, detail: `Couldn't launch Ollama: ${err instanceof Error ? err.message : String(err)}` };
}

const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 700));
if ((await ollamaStatus()).running) return { started: true };
}
return { started: false, detail: "Ollama was launched but didn't respond yet. Give it a few seconds, then check again." };
}

/** Ollama treats "qwen3:4b" and "nomic-embed-text" (= ":latest") as tags of one name. */
export function sameOllamaModel(a: string, b: string): boolean {
const tag = (s: string) => (s.includes(":") ? s : `${s}:latest`);
return tag(a) === tag(b);
}

export async function lmStudioStatus(): Promise<{ running: boolean; models: string[] }> {
const models = await getJson<{ data: Array<{ id: string }> }>(`${LMSTUDIO_URL}/v1/models`, 1500);
return { running: !!models, models: models?.data.map((m) => m.id) ?? [] };
Expand Down
Loading
Loading