diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index f5c8636..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "next/core-web-vitals", - "ignorePatterns": ["node_modules/", ".next/", "tests/e2e/", "playwright-report/", "test-results/"] -} diff --git a/app/error.tsx b/app/error.tsx index d265523..0f34651 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button"; export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { useEffect(() => { - // eslint-disable-next-line no-console console.error(error); }, [error]); diff --git a/components/onboard/model-picker-logic.ts b/components/onboard/model-picker-logic.ts new file mode 100644 index 0000000..14b8999 --- /dev/null +++ b/components/onboard/model-picker-logic.ts @@ -0,0 +1,227 @@ +/** + * The parts of the model picker that are decisions, not markup. + * + * They live outside the .tsx because they are the parts that can be WRONG in a + * way no rendering test would catch - which selection survives a whitelist + * fetch, whether a set fits the machine - and because the unit suite runs in a + * node environment that cannot import JSX at all. Anything here must stay pure: + * no hooks, no fetch, no React. + */ +import { modelRequirement, usableVramGb, UNKNOWN_MODEL_VRAM_GB } from "@/lib/hardware"; +import { lookupModel, residentVramGb, resolveModel, type CatalogEntry } from "@/lib/model-catalog"; + +/** + * A whitelist row exactly as the indexer returns it. + * + * `id` is the on-chain identity - keccak256 of the tag - and the only thing the + * registry actually stores. `name` is a label the indexer bolts on, and when a + * model was whitelisted without its tag string there is nothing to bolt on, so + * it echoes the id back into `name`. Carrying `id` is what lets us tell a real + * tag apart from a digest wearing one's clothes. + */ +export interface LiveModel { + id: string; + name: string; + fee: string; // wei + max_output_tokens: number; + /** + * The boundary's own verdict on `name`: true when it is a placeholder rather + * than a tag (see ModelInfo.unnamed in lib/subgraph.ts). Optional because a + * hand-built row - a fixture, an older cached response - carries no flag, and + * absence must not read as "unnamed". + */ + unnamed?: boolean; +} + +/** A live model resolved to an identity we can act on. */ +export interface Row { + /** Lowercase on-chain id. Unique per registry row, so it keys the list. */ + id: string; + /** The real Ollama tag. null = the id never resolved, so it is NOT servable. */ + tag: string | null; + label: string; + fee: string; // wei + maxOut: number; + embedding: boolean; + /** Resident GB to keep it warm, or null when we cannot size it honestly. */ + gb: number | null; + /** Short descriptor for the second line, when we have one. */ + note: string | null; +} + +export type ServableRow = Row & { tag: string }; + +export function isServable(r: Row): r is ServableRow { + return r.tag !== null; +} + +/** Sort key that keeps models we cannot size at the bottom of any ordering. */ +export function sizeKey(r: Row): number { + return r.gb ?? Number.MAX_SAFE_INTEGER; +} + +/** + * Size + descriptor for a model. + * + * The catalog is measured (summed manifest layers, or an observed inference + * peak), so it wins outright. A tag that is real but absent from the catalog - + * a model whitelisted after this build shipped - falls back to hardware.ts's + * name regex, which can only size a name that carries its parameter count. + * Everything else has NO size: the registry stores none, and a keccak digest + * says nothing about the weights behind it. null means "we do not know", and + * the UI prints that instead of inventing a comfortable 8GB. + */ +function describe(tag: string | null, entry?: CatalogEntry): { gb: number | null; note: string | null } { + if (entry) return { gb: residentVramGb(entry), note: entry.note ?? null }; + if (!tag) return { gb: null, note: null }; + const req = modelRequirement(tag); + // Gate on `source`, NOT on `known`: `known` is false for a name-estimated + // model too, so testing it here would throw away the very fallback this + // branch exists for. Only "unknown" means the tag carried no size signal at + // all, and that is the case we refuse to invent a number for. + if (req.source === "unknown") return { gb: null, note: null }; + return { gb: req.vramGb, note: req.tierLabel }; +} + +export function toRow(m: LiveModel): Row { + // Carry the boundary's verdict through rather than re-deriving it from the + // string it produced. `resolveModel` is idempotent (it proves a name against + // keccak(name) === id), so re-resolving a placeholder is safe TODAY - but the + // picker should not be the thing that breaks if that ever stops holding, and + // an explicit flag is a fact where a display string is only evidence. Feeding + // "" for an unnamed row is the same convention lib/subgraph.ts uses when it + // joins a worker's models: it asks the id, and nothing else. + const unnamed = m.unnamed === true; + const r = resolveModel(unnamed ? "" : m.name, m.id); + // A row the indexer already told us has no published tag is not servable, + // whatever a second resolution makes of it - and it gets no catalog data + // either, so the row cannot end up unservable and confidently sized at once. + const tag = unnamed ? null : r.tag; + const entry = unnamed ? undefined : r.entry; + const { gb, note } = describe(tag, entry); + return { + id: r.id ?? m.id.toLowerCase(), + tag, + label: r.label, + fee: m.fee, + maxOut: m.max_output_tokens, + // The registry's own tell for an embedding model: it answers with vectors, + // so its output cap is a single token. The catalog flags the ones we know. + embedding: entry?.embedding === true || m.max_output_tokens === 1, + gb, + note, + }; +} + +/** + * Does a stored selection name this row? Setup stores TAGS (they become the + * container's SUPPORTED_MODELS), but a record written before ids and tags were + * told apart can hold the id it was shown. Match on either identity so such a + * record still lights up the right row - we always hand the TAG back out. + */ +export function names(stored: string[], r: Row): boolean { + return stored.some((v) => { + const s = v.trim().toLowerCase(); + return s === r.id || (r.tag !== null && s === r.tag.toLowerCase()); + }); +} + +/** + * Decide the selection that should be in effect once the live whitelist lands. + * + * Rules, in order: + * 1. LOCKED models survive unconditionally. They are what the worker already + * serves, so dropping one is not tidying up - it turns the update panel's + * diff into a REMOVAL, and removals are refused there (you cannot stop + * serving a model live without deregistering), which disables Apply for + * good. A locked model that has since left the whitelist, or whose tag we + * cannot recover, is kept verbatim for exactly that reason. + * 2. A non-locked selection survives only if it maps to a servable live row, + * and is re-emitted as that row's TAG - healing a record that stored an + * on-chain id, and dropping one we could never `ollama pull`. + * 3. Only when that leaves nothing do we auto-pick, and only from servable + * rows: the lightest one that fits `room`, else the lightest overall. + * + * Returns the selection to apply. An empty array means "we have nothing safe to + * offer" - the caller must then leave the existing selection alone rather than + * clearing it. + */ +export function reconcileSelection(rows: Row[], value: string[], locked: string[], room: number): string[] { + const servable = rows.filter(isServable); + const kept: string[] = []; + const push = (t: string) => { + if (!kept.some((k) => k.toLowerCase() === t.toLowerCase())) kept.push(t); + }; + + for (const v of locked) { + // Prefer the live row's tag, then the catalog's, then the string itself - + // never nothing. The first two heal a stored id into something pullable; + // the last keeps a model we cannot name in the set it is already serving. + const row = rows.find((r) => names([v], r)); + push(row?.tag ?? lookupModel(v)?.tag ?? v); + } + for (const v of value) { + const row = servable.find((r) => names([v], r)); + if (row) push(row.tag); + } + + if (kept.length > 0) return kept; + if (servable.length === 0) return []; // nothing here is safe to pick - the UI says so + // Unsized models sort last: we won't volunteer a model we cannot measure + // over one we can. + const fits: ServableRow[] = room > 0 ? servable.filter((r) => r.gb !== null && r.gb <= room) : []; + const pool: ServableRow[] = fits.length ? fits : servable; + return [pool.slice().sort((a, b) => sizeKey(a) - sizeKey(b))[0].tag]; +} + +/** What a selection costs, and how much of that cost we actually know. */ +export interface Footprint { + /** Sum of the sizes we know. A FLOOR when `unsized > 0`, not an estimate. */ + total: number; + /** How many selected models publish no size at all. */ + unsized: number; + /** + * The number the fit check must use: every unsized model charged at + * UNKNOWN_MODEL_VRAM_GB. A keccak id could be a 0.6B embedder or a 120B MoE, + * so counting it as 0 - which is what totalling only known sizes does - is + * the one assumption that can quietly overcommit the machine. + */ + worst: number; +} + +export function selectionFootprint(rows: Row[], value: string[]): Footprint { + let total = 0; + let unsized = 0; + for (const v of value) { + // Prefer the live row; a selection that is no longer whitelisted (but is + // still served by the running worker) still costs memory, so size it from + // the catalog by name rather than dropping it from the sum. + const row = rows.find((r) => names([v], r)); + const gb = row ? row.gb : describe(v, lookupModel(v)).gb; + if (gb === null) unsized += 1; + else total += gb; + } + const round = (n: number) => Math.round(n * 10) / 10; + return { total: round(total), unsized, worst: round(total + unsized * UNKNOWN_MODEL_VRAM_GB) }; +} + +/** + * What we know about this machine's memory - three states, not two. + * + * "The user told us there is no dedicated GPU" is a FACT, and reporting it as + * "your memory could not be read" both misdescribes it and switches off every + * per-model size line the CPU-only user most needs to see. Only a detection + * that came back with nothing is genuinely unknown. + */ +export type MemoryState = + | { kind: "gpu"; avail: number; usable: number } + | { kind: "cpu"; avail: 0; usable: 0 } + | { kind: "unknown"; avail: 0; usable: 0 }; + +export function memoryStateOf(vramGb: number, vramKnown: boolean): MemoryState { + if (!vramKnown) return { kind: "unknown", avail: 0, usable: 0 }; + // A read that says 0 is a machine with no discrete GPU, not a failed read. + if (!(vramGb > 0)) return { kind: "cpu", avail: 0, usable: 0 }; + // What a model can actually have. See OS_VRAM_OVERHEAD_GB in lib/hardware.ts. + return { kind: "gpu", avail: vramGb, usable: usableVramGb(vramGb) }; +} diff --git a/components/onboard/model-picker.tsx b/components/onboard/model-picker.tsx index 9c84f1e..ce23ef4 100644 --- a/components/onboard/model-picker.tsx +++ b/components/onboard/model-picker.tsx @@ -1,15 +1,36 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Box, Check, CircleAlert, AlertTriangle } from "lucide-react"; -import { modelRequirement, modelsMemoryGb } from "@/lib/hardware"; +// OS_VRAM_OVERHEAD_GB is imported rather than redefined here: the desktop's +// claim on VRAM is one number, and a picker that reserved a different amount +// than the fit helpers would show a plan it then contradicts. +import { OS_VRAM_OVERHEAD_GB, UNKNOWN_MODEL_VRAM_GB } from "@/lib/hardware"; +// The decisions this picker makes live next door, in plain TypeScript: which +// selection survives a whitelist fetch and whether a set fits are the parts +// that can be silently wrong, and the unit suite cannot import JSX to test them. +import { + isServable, + memoryStateOf, + names, + reconcileSelection, + selectionFootprint, + sizeKey, + toRow, + type LiveModel, + type Row, +} from "@/components/onboard/model-picker-logic"; import { fromWei, cn } from "@/lib/utils"; import type { NetworkId } from "@/lib/network"; -interface LiveModel { - name: string; - fee: string; // wei - max_output_tokens: number; +/** One decimal, without dragging a ".0" onto whole numbers. */ +function fmtGb(n: number): string { + return String(Math.round(n * 10) / 10); +} + +/** 16384 -> "16,384". Rendered only after a client fetch, so the locale is fixed. */ +function fmtTokens(n: number): string { + return n.toLocaleString("en-US"); } /** @@ -18,16 +39,25 @@ interface LiveModel { * several at once, but every model it picks must stay resident in memory at the * same time, so we sum their rough footprints and warn when the set won't fit the * detected machine (a cold-load mid-job is what gets a worker slashed). + * + * Selection is keyed on the on-chain id, never on the displayed name: for most + * of the live registry the indexer's `name` IS the id, and treating that string + * as a tag is what would stake LCAI and register for a model this machine can + * never pull. What leaves through `onChange` is always a real tag. */ export function ModelPicker({ network, vramGb, + vramKnown = true, value, onChange, locked = [], }: { network: NetworkId; vramGb: number; + // False when VRAM could not be read at all. We then check nothing against it, + // rather than silently measuring every model against 0GB and flagging them all. + vramKnown?: boolean; value: string[]; onChange: (models: string[]) => void; // Models that are already committed and can't be unselected here (e.g. a @@ -37,6 +67,36 @@ export function ModelPicker({ const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); + const mem = useMemo(() => memoryStateOf(vramGb, vramKnown), [vramGb, vramKnown]); + const onGpu = mem.kind === "gpu"; + const { avail, usable } = mem; + + // Mirrors of the mutable props, for the reconcile below. + // + // That reconcile runs inside an async `.then`, and the effect around it + // deliberately re-runs only when the NETWORK changes - refetching the whole + // whitelist on every click would be absurd. A plain closure therefore pins + // the MOUNT render's props forever, and in the update panel those are + // `value: []` with `locked: []`: the fetch then found nothing to keep, + // auto-picked the lightest model, and emitted it as the whole selection. + // That reads downstream as one addition plus N removals of the models the + // worker is actually serving, and removals disable Apply permanently. Refs + // are the window onto the CURRENT render; adding the props to the dep array + // is not a fix, because it would re-fetch on every selection change. + // + // Assigned during render rather than in an effect on purpose: a promise + // resolving between render and the passive-effect flush would otherwise read + // one render behind. They are pure mirrors of props, so re-assigning them is + // idempotent and safe to repeat. + const valueRef = useRef(value); + const lockedRef = useRef(locked); + const onChangeRef = useRef(onChange); + const roomRef = useRef(usable); + valueRef.current = value; + lockedRef.current = locked; + onChangeRef.current = onChange; + roomRef.current = usable; + useEffect(() => { let on = true; setLoading(true); @@ -45,41 +105,66 @@ export function ModelPicker({ .then((j) => { if (!on || !j.ok) return; const live: LiveModel[] = (j.models ?? []) - .filter((m: { is_enabled: boolean; is_whitelisted: boolean }) => m.is_enabled && m.is_whitelisted) - .map((m: LiveModel) => ({ name: m.name, fee: m.fee, max_output_tokens: m.max_output_tokens })); + .filter((m: LiveModel & { is_enabled: boolean; is_whitelisted: boolean }) => m.is_enabled && m.is_whitelisted) + .map((m: LiveModel) => ({ + // Keep the id: it is the identity, and `name` may be a copy of it. + id: (m.id ?? "").toLowerCase(), + name: m.name, + fee: m.fee, + max_output_tokens: m.max_output_tokens, + // The boundary already decided whether `name` is a tag or a + // placeholder - carry that verdict instead of re-deriving it. + unnamed: m.unnamed, + })); setModels(live); - // Keep selections that are still live; if none remain, pick the lightest - // model that fits the machine. - if (live.length) { - const stillLive = value.filter((v) => live.some((m) => m.name === v)); - if (stillLive.length === 0) { - const fits = live.filter((m) => modelRequirement(m.name).vramGb <= (vramGb || 0)); - const best = (fits.length ? fits : live).sort((a, b) => modelRequirement(a.name).vramGb - modelRequirement(b.name).vramGb)[0]; - onChange([best.name]); - } else if (stillLive.length !== value.length) { - onChange(stillLive); - } - } + + // Reconcile the selection against what's live: heal a stored id into a + // pullable tag, drop anything we could never `ollama pull`, keep every + // locked model whatever happens. Reading the refs, not the closure. + const rows = live.map(toRow); + const next = reconcileSelection(rows, valueRef.current, lockedRef.current, roomRef.current); + // An empty result means nothing here was safe to offer - leave the + // caller's selection alone rather than clearing it. + if (next.length > 0 && next.join(",") !== valueRef.current.join(",")) onChangeRef.current(next); }) .catch(() => {}) .finally(() => on && setLoading(false)); return () => { on = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // No exhaustive-deps suppression any more: everything mutable this effect + // touches goes through a ref, so `network` really is the whole dependency + // set. The suppression is what used to hide the stale closure above. }, [network]); - const avail = vramGb || 0; - const total = modelsMemoryGb(value); - const over = avail > 0 && total > avail; + // Servable models first, then smallest first - so the rows a user can act on + // lead, and the ones we cannot name sink to the bottom where they belong. + const rows = useMemo( + () => + models.map(toRow).sort((a, b) => Number(isServable(b)) - Number(isServable(a)) || sizeKey(a) - sizeKey(b)), + [models], + ); + + const selection = useMemo(() => selectionFootprint(rows, value), [value, rows]); + + // Checked against the WORST case, not the known sum: a model we cannot size + // adds 0 to `total`, so gating on that total is how an unsized selection made + // itself invisible to this warning. `worst` charges each one the largest + // footprint we know of, which is what UNKNOWN_MODEL_VRAM_GB is for. + const over = onGpu && selection.worst > usable; + const noneServable = rows.length > 0 && !rows.some(isServable); - const toggle = (name: string) => { - if (locked.includes(name)) return; // committed - can't unselect here - if (value.includes(name)) { + const toggle = (r: Row) => { + // An id we could not name is not a tag. Selecting it would stake and + // register for a model that can never be pulled, so there is nothing here + // to toggle - the button is disabled too, this is the belt. + if (!isServable(r)) return; + if (names(locked, r)) return; // committed - can't unselect here + if (names(value, r)) { if (value.length === 1) return; // keep at least one selected - onChange(value.filter((m) => m !== name)); + onChange(value.filter((v) => !names([v], r))); } else { - onChange([...value, name]); + onChange([...value, r.tag]); } }; @@ -91,35 +176,50 @@ export function ModelPicker({ your worker serves every model you pick - {loading && models.length === 0 ? ( + {loading && rows.length === 0 ? (
- ) : models.length === 0 ? ( + ) : rows.length === 0 ? (

No live models on {network} right now. Setup will use the default once one is whitelisted.

) : ( <>
- {models.map((m) => { - const req = modelRequirement(m.name); - const fitsAlone = (vramGb || 0) >= req.vramGb; - const selected = value.includes(m.name); - const tooBig = avail > 0 && req.vramGb > avail; // can't even fit by itself - const isLocked = locked.includes(m.name); + {rows.map((r) => { + const servable = isServable(r); + const selected = names(value, r); + const isLocked = names(locked, r); + // Measured against USABLE memory, not the sticker total - a model + // that only fits the total is the one that gets evicted mid-job. + const tooBig = onGpu && r.gb !== null && r.gb > usable; + // Never let one click stake for a model this machine cannot hold, + // or for an id we cannot turn into a pullable tag. An oversized + // model that is ALREADY selected stays clickable so it can be dropped. + const blocked = !servable || (tooBig && !selected); return ( @@ -160,6 +294,16 @@ export function ModelPicker({ })}
+ {noneServable && ( +

+ + + Every model on {network} is registered by id only, with no published name. There is no tag to pull, so + none of them can be served from here yet. + +

+ )} + {/* memory gate */}
@@ -167,14 +311,62 @@ export function ModelPicker({ Memory to keep {value.length === 1 ? "it" : "them all"} warm - ~{total}GB{avail > 0 && ` of ~${avail}GB`} + {/* "at least" because an unsized model contributes nothing to + this sum - the figure is a floor, and saying "~" would sell + it as an estimate. */} + {selection.unsized > 0 && at least } + ~{fmtGb(selection.total)}GB{onGpu && ` of ~${fmtGb(avail)}GB`}
{over && (

- These models need about {total}GB resident at once, but this machine has about {avail}GB. They would - cold-load between jobs and risk a slash. Deselect one, or run them on a bigger machine. + {selection.unsized > 0 ? ( + + {selection.unsized === 1 ? "One selected model publishes" : `${selection.unsized} selected models publish`} no + size, so this set cannot be shown to fit: a model we cannot measure could need anything up to ~ + {fmtGb(UNKNOWN_MODEL_VRAM_GB)}GB, and only about {fmtGb(usable)}GB is usable here. Pick models with a + known footprint, or verify by hand before you install - a set that cold-loads mid-job risks a slash. + + ) : ( + + These models need about {fmtGb(selection.total)}GB resident at once, but only about {fmtGb(usable)}GB + is usable here. They would cold-load between jobs and risk a slash. Deselect one, or run them on a + bigger machine. + + )} +

+ )} + {/* The card's total is never all yours - be explicit about what is + left. The `over` warning already quotes the usable figure, so + only state it here when that warning isn't showing. */} + {onGpu && !over && ( +

+ Your desktop session (compositor, browser, this app) holds roughly {fmtGb(OS_VRAM_OVERHEAD_GB)}GB of + that, so plan against about {fmtGb(usable)}GB. +

+ )} + {/* Three states, not two: "you told us there is no GPU" is a fact + we should repeat back, not report as a failed reading. */} + {mem.kind === "cpu" && ( +

+ No dedicated GPU, so these run on the CPU out of system RAM - the sizes above are what they need there, + and there is no VRAM figure to check them against. Expect slow inference, which can miss a job deadline. +

+ )} + {mem.kind === "unknown" && ( +

+ This machine's memory could not be read, so nothing above is checked against it. Confirm the set + fits before you install. +

+ )} + {/* When `over` is showing it has already made this point, in stronger terms. */} + {selection.unsized > 0 && !over && ( +

+ {selection.unsized === 1 ? "One selected model publishes" : `${selection.unsized} selected models publish`} no + size, so {selection.unsized === 1 ? "it is" : "they are"} not in that total - treat it as a floor.{" "} + {selection.unsized === 1 ? "It" : "They"} could need up to ~{fmtGb(UNKNOWN_MODEL_VRAM_GB)}GB + {selection.unsized === 1 ? "" : " each"}, which is the largest model we know of.

)} {!over && value.length > 1 && ( diff --git a/components/update-models.tsx b/components/update-models.tsx index 555dafd..1a890d0 100644 --- a/components/update-models.tsx +++ b/components/update-models.tsx @@ -1,13 +1,15 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Boxes, Loader2, CheckCircle2, XCircle } from "lucide-react"; +import { Boxes, Loader2, CheckCircle2, XCircle, AlertTriangle } from "lucide-react"; import { privateKeyToAccount } from "viem/accounts"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { ModelPicker } from "@/components/onboard/model-picker"; import { InstallProgress } from "@/components/onboard/install-progress"; import { useNetwork } from "@/lib/network-context"; +import { autodetect, detectWebGpu } from "@/lib/hardware"; +import { isModelId, lookupModel } from "@/lib/model-catalog"; import { DEFAULT_MODEL, NETWORKS } from "@/lib/network"; import { addModelsCommand, desktopInstallCommand, type OS } from "@/lib/scriptgen"; import { appendCleanLog } from "@/lib/install-log"; @@ -17,6 +19,18 @@ import { getSecret, getWorkerAddr, resolveManagedWorkerAddr, getServedModels, se type Phase = "idle" | "running" | "done" | "failed"; +/** + * Fold a stored model reference back to its servable tag. + * + * Everything downstream - SUPPORTED_MODELS, `ollama pull`, add-models' keccak - + * consumes the TAG, but a record written before model ids and tags were told + * apart can hold the on-chain id instead. The catalog inverts the ones we know; + * anything it cannot invert is returned untouched, so callers can still spot it. + */ +function tagOf(model: string): string { + return lookupModel(model)?.tag ?? model; +} + function keyMatchesAddr(key: string, addr: string): boolean { if (!/^0x[a-fA-F0-9]{40}$/.test(addr)) return false; try { @@ -35,7 +49,10 @@ function keyMatchesAddr(key: string, addr: string): boolean { export function UpdateModels() { const { network } = useNetwork(); const [os, setOs] = useState("macos"); - const [vramGb, setVramGb] = useState(0); + // Memory available to keep models warm. `known` is separate from the number: + // an unread VRAM is 0, and a gate that measures every model against 0GB is a + // gate that lies. See the detection effect below. + const [vram, setVram] = useState<{ gb: number; known: boolean }>({ gb: 0, known: false }); const [sel, setSel] = useState([]); // The set the worker ACTUALLY serves right now (the locked/can't-remove base the // picker adds onto). Authoritative source is the running container's @@ -51,11 +68,34 @@ export function UpdateModels() { const d = detectClientOS(); setOs(d === "windows" ? "windows" : d === "linux" ? "linux" : "macos"); }, []); + // Same precedence the onboarding machine check uses: the desktop shell reads + // VRAM from the OS, and where that comes back empty (no discrete GPU reported, + // an older binary, a failed nvidia-smi) we fall back to the browser's GPU-name + // inference. If BOTH come back empty we record known:false so the picker drops + // its memory gate instead of showing a false one against 0GB. useEffect(() => { - detectNativeHardware().then((hw) => { - if (!hw) return; - setVramGb(hw.unified ? Math.max(hw.ram_gb || 0, hw.vram_gb || 0) : hw.vram_gb || 0); + let on = true; + detectNativeHardware().then(async (hw) => { + if (!on) return; + // Apple Silicon shares one pool, so the GPU can draw on system RAM. + const native = hw ? (hw.unified ? Math.max(hw.ram_gb || 0, hw.vram_gb || 0) : (hw.vram_gb ?? 0)) : 0; + if (native > 0) { + setVram({ gb: native, known: true }); + return; + } + const d = autodetect(); // WebGL renderer -> known-GPU table; unified already floors at 16 + let gb = d.input.vramGb ?? 0; + if (!gb) { + // Some webviews mask the WebGL renderer string but still expose a WebGPU adapter. + const w = await detectWebGpu(); + gb = w.unified ? Math.max(d.input.ramGb ?? 16, 16) : (w.vramGb ?? 0); + } + if (!on) return; + setVram(gb > 0 ? { gb, known: true } : { gb: 0, known: false }); }); + return () => { + on = false; + }; }, []); // Seed from this network's recorded set for an instant render, then reconcile // with the worker actually running here (its container SUPPORTED_MODELS is the @@ -97,11 +137,28 @@ export function UpdateModels() { const append = (line: string) => setLog((l) => appendCleanLog(l, line)); - const additions = sel.filter((m) => !current.includes(m)); - const removals = current.filter((m) => !sel.includes(m)); + // Compare on identity, not on the raw string: an id sitting next to its own + // tag would otherwise read as one addition plus one removal, blocking the + // whole panel over a naming artefact. Both sides compare - and `additions` is + // sent on chain - as real tags. + const additions = sel.filter((m) => !current.some((c) => tagOf(c) === tagOf(m))).map(tagOf); + const removals = current.filter((m) => !sel.some((s) => tagOf(s) === tagOf(m))); // You can ADD models live; removing one isn't safe live (the gateway could still // route its jobs to you), so a set that drops a current model is blocked here. - const canApply = additions.length > 0 && removals.length === 0; + // add-models hashes whatever string it is handed, so an id we could not fold + // back to a tag would register a second, meaningless model on chain and stake + // against it - refuse rather than sign that. + const canApply = additions.length > 0 && removals.length === 0 && !additions.some(isModelId); + // A disabled button with no reason is indistinguishable from a broken one - + // and this panel spent a release genuinely broken (the picker used to drop + // the locked set on mount, which made `removals` non-empty and left Apply + // dead with nothing on screen to explain it). Say which guard is holding. + const blockedReason = + removals.length > 0 + ? `Your selection drops ${removals.join(", ")}. Models can only be added here - deregister and reinstall to serve a smaller set.` + : additions.some(isModelId) + ? "One of the models you picked has no published name, only an on-chain id. It can't be pulled or registered from here." + : null; const apply = useCallback(async () => { if (!canApply) return; @@ -128,9 +185,13 @@ export function UpdateModels() { setPhase("failed"); return; } - setServedModels(network, sel); + // The container pulls SUPPORTED_MODELS verbatim, so fold any id the + // seeded set still carries back to its tag before it goes in (and + // before we record it as this network's served set). + const served = sel.map(tagOf); + setServedModels(network, served); stopRef.current = await runSetupStreamed( - desktopInstallCommand(os, network, sel), + desktopInstallCommand(os, network, served), env, append, (code2) => { @@ -157,7 +218,7 @@ export function UpdateModels() { {phase === "idle" || phase === "done" || phase === "failed" ? ( <> - + {phase === "done" && (

Updated. Give the worker about a minute to re-attest and go live. @@ -173,6 +234,11 @@ export function UpdateModels() { worker (Operations above), then reinstall and pick the model set you want. Removing one while registered isn't safe, so it can't be unselected here.

+ {blockedReason && ( +

+ {blockedReason} +

+ )} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..bb6e35d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,79 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { FlatCompat } from "@eslint/eslintrc"; + +/** + * ESLint flat config. + * + * WHY THIS REPLACED .eslintrc.json + `next lint` + * ---------------------------------------------- + * A dependabot dev-group bump (09c625b) took eslint 8 -> 10 and + * eslint-config-next 15 -> 16 in one go. The later revert (dbe1cff) rolled the + * PRODUCTION group back to Next 15 but left the dev group where it was, so the + * repo ended up on a combination that cannot run: + * + * - ESLint 10 removed eslintrc support outright, but `next lint` from Next 15 + * still drives that API - so lint died on "Unknown options: useEslintrc, + * extensions, ..." before checking a single file. CI has been red since. + * - ESLint 10 also removed `context.getFilename()`, which eslint-plugin-react + * 7.37.5 (the newest published, pulled in by eslint-config-next) still + * calls. Its peer range stops at ^9.7, so no version of it works on 10. + * + * Hence eslint ^9: it is the newest line eslint-config-next actually supports, + * and flat config is native there. `next lint` is not resurrected because it is + * deprecated and removed in Next 16; the ESLint CLI is the forward path. + * + * WHY eslint-config-next 15, NOT 16 + * --------------------------------- + * eslint-config-next tracks the Next major, and this app is on Next 15.5.18. + * The 16 line ships eslint-plugin-react-hooks v7, whose React-Compiler rules + * (set-state-in-effect, purity, preserve-manual-memoization, immutability) + * flagged 79 pre-existing patterns across the app - rules written for a compiler + * this build does not run. Pairing the config with the framework restores the + * rule set the code was actually written against. Adopting those rules is a + * deliberate refactor to do alongside a Next 16 upgrade, not a side effect of + * repairing lint. + * + * eslint-config-next 15 is eslintrc-format, so FlatCompat bridges it - this is + * the flat-config recipe from Next 15's own ESLint docs. + * + * SCOPE + * ----- + * `next lint` implicitly linted only app/, components/, lib/, src/ and pages/. + * `eslint .` would also walk sdk/, create-lightnode-app/ and desktop/, which are + * separate packages with their own toolchains and CI steps. The ignore list + * keeps the linted surface exactly what it was, so this migration changes the + * runner and not the verdict. + */ +const compat = new FlatCompat({ + baseDirectory: dirname(fileURLToPath(import.meta.url)), +}); + +const config = [ + { + ignores: [ + ".next/**", + "out/**", + "coverage/**", + "next-env.d.ts", + // Excluded by the old config too: the e2e specs run under Playwright's + // globals, not the app's. + "tests/e2e/**", + "playwright-report/**", + "test-results/**", + // Separate packages, each verified by its own CI step (`tsc -p + // sdk/tsconfig.json`, the scaffolder's own typecheck). sdk/dist is build + // output. + "sdk/**", + "create-lightnode-app/**", + "desktop/**", + "wallet/**", + "examples/**", + "scripts/**", + "public/**", + ], + }, + ...compat.extends("next/core-web-vitals"), +]; + +export default config; diff --git a/lib/hardware.ts b/lib/hardware.ts index 323929c..3faa25f 100644 --- a/lib/hardware.ts +++ b/lib/hardware.ts @@ -7,6 +7,7 @@ * stake (5,000 LCAI testnet / 50,000 mainnet), which we surface in the UI. */ import { HARDWARE } from "./network"; +import { MODEL_CATALOG, isModelId, lookupModel, residentVramGb, type CatalogEntry } from "./model-catalog"; export interface MachineInput { cores: number; @@ -151,32 +152,156 @@ export function inferGpu(renderer: string): { vramGb?: number; unified?: boolean export type ModelTier = "light" | "standard" | "large" | "server"; +/** + * Provenance of a size. Only `catalog` is a measurement; the other two are + * inferences the UI must label as such rather than rendering a confident number. + */ +export type ModelSizeSource = "catalog" | "name" | "unknown"; + export interface ModelRequirement { - paramsB: number; // estimated parameter count in billions (0 = unknown) - vramGb: number; // rough resident memory needed to serve it + paramsB: number; // param count in billions parsed from the tag (0 = unknown, incl. MoE) + vramGb: number; // resident memory needed to serve it - always a number, see `known` tier: ModelTier; tierLabel: string; + /** True only when the number is catalog-backed (measured). False = a guess. */ + known: boolean; + source: ModelSizeSource; + /** The catalog row behind a known requirement (note, embedding flag, download size). */ + entry?: CatalogEntry; +} + +/** + * VRAM the machine has already spent before a model loads: the desktop + * compositor, the browser's GPU process, anything else holding a context. On + * this project's reference box - X11 at 4K on a 16GB card - that is ~1.3-1.5GB, + * so a "16GB GPU" really offers ~14.5GB to Ollama. Headless servers pay close to + * zero, which is why this is an explicit subtraction the caller opts into + * (`usableVramGb`) and NOT something baked into the fit checks below. + */ +export const OS_VRAM_OVERHEAD_GB = 1.5; + +/** + * The sticker VRAM number minus the desktop's own claim on it - what a model can + * actually take. Returns 0 for a 0/unknown machine so it keeps the meaning the + * fit helpers give 0 ("we don't know this machine"), and never goes negative on + * a small GPU where the desktop eats most of the card. + */ +export function usableVramGb(totalVramGb: number, overheadGb = OS_VRAM_OVERHEAD_GB): number { + if (!(totalVramGb > 0)) return 0; + return Math.max(0, Math.round((totalVramGb - overheadGb) * 10) / 10); +} + +/** + * Stand-in footprint for a model we cannot size. keccak256 is one-way, so a + * registry id that isn't in the catalog could be ANY model - a 0.6B embedder or + * a 120B MoE. Assuming the largest one we know of is the only assumption that + * cannot quietly overcommit a machine; the old code assumed 8GB and understated + * gpt-oss:120b by 7x. Derived from the catalog, not a literal, so it tracks it. + */ +export const UNKNOWN_MODEL_VRAM_GB = MODEL_CATALOG.reduce((mx, e) => Math.max(mx, residentVramGb(e)), 0); + +/** Tier + human label for a resident footprint, in GPU classes people can buy. */ +function sizeBand(vramGb: number): { tier: ModelTier; tierLabel: string } { + if (vramGb <= 4) return { tier: "light", tierLabel: "Light - runs on most machines" }; + if (vramGb <= 8) return { tier: "standard", tierLabel: "Standard - needs an 8GB GPU / 16GB unified" }; + if (vramGb <= 12) return { tier: "standard", tierLabel: "Standard+ - needs a 12GB GPU" }; + if (vramGb <= 24) return { tier: "large", tierLabel: "Large - needs a 24GB GPU" }; + return { tier: "server", tierLabel: "Server-class - needs a 48GB+ GPU" }; +} + +/** + * Param count out of a tag: the "8" in "llama3-8b", the "2" in "gemma4:e2b". + * + * The right-hand guard is `(?![0-9a-z])` rather than `\b` so a quantization + * suffix like "8B_K_M" still reads as 8B (`_` is a word char, so `\b` missed it). + * There is deliberately NO left-hand guard: "gemma4:e2b" has its digit preceded + * by a letter, and requiring a boundary there would drop it to 0 params. + */ +function paramsFromTag(tag: string): number { + const m = /(\d+(?:\.\d+)?)\s*b(?![0-9a-z])/i.exec(tag); + return m ? parseFloat(m[1]) : 0; +} + +/** Footprint implied by a param count alone - the fallback when we have no measurement. */ +function vramFromParams(paramsB: number): number { + if (paramsB <= 4) return 4; + if (paramsB <= 9) return 8; + if (paramsB <= 15) return 12; + if (paramsB <= 34) return 24; + return 48; } /** - * Rough hardware requirement for a model, inferred from its name (the param - * count, e.g. the "8" in "llama3-8b" or the "2" in "gemma4:e2b"). Used only to - * label models and flag a fit vs the operator's machine - the network never - * gates on this. Unknown names fall back to a standard 8GB assumption. + * Hardware requirement for a model, by tag OR by on-chain id. + * + * Order matters: the measured catalog wins, always. A tag string is a terrible + * size oracle - "qwen3-coder-next" and "glm-4.7-flash" carry no number at all, + * and MoE names actively lie ("gemma4:e2b" reads as 2B but downloads 7.2GB) - so + * the regex is only ever a last resort for a model we've never measured. + * + * Three outcomes, and the caller must distinguish them: + * catalog -> known: true, a measured/derived number the UI can state plainly. + * name -> known: false, a guess from the param count in the tag. + * unknown -> known: false, UNKNOWN_MODEL_VRAM_GB and a label that says so. + * Ids are never regexed: a digest ending in "...0d9b" would otherwise parse as a + * 9B model and get asserted at 8GB, which is precisely the bug this replaces. + * The network never gates on any of this - it only labels models in the UI. */ -export function modelRequirement(name: string): ModelRequirement { - const m = /(\d+(?:\.\d+)?)\s*b\b/i.exec(name); - const paramsB = m ? parseFloat(m[1]) : 0; - if (paramsB > 0 && paramsB <= 4) return { paramsB, vramGb: 4, tier: "light", tierLabel: "Light - runs on most machines" }; - if (paramsB <= 9) return { paramsB, vramGb: 8, tier: "standard", tierLabel: "Standard - needs an 8GB GPU / 16GB unified" }; - if (paramsB <= 15) return { paramsB, vramGb: 12, tier: "standard", tierLabel: "Standard+ - needs a 12GB GPU" }; - if (paramsB <= 34) return { paramsB, vramGb: 24, tier: "large", tierLabel: "Large - needs a 24GB GPU" }; - return { paramsB, vramGb: 48, tier: "server", tierLabel: "Server-class - needs a 48GB+ GPU" }; +export function modelRequirement(nameOrId: string): ModelRequirement { + const raw = (nameOrId ?? "").trim(); + + const entry = lookupModel(raw); + if (entry) { + const vramGb = residentVramGb(entry); + return { paramsB: paramsFromTag(entry.tag), vramGb, ...sizeBand(vramGb), known: true, source: "catalog", entry }; + } + + const paramsB = isModelId(raw) ? 0 : paramsFromTag(raw); + if (paramsB > 0) { + const vramGb = vramFromParams(paramsB); + const band = sizeBand(vramGb); + // Honest about provenance: this is arithmetic on a string, not a measurement. + return { paramsB, vramGb, tier: band.tier, tierLabel: `${band.tierLabel} (estimated from the name)`, known: false, source: "name" }; + } + + return { + paramsB: 0, + vramGb: UNKNOWN_MODEL_VRAM_GB, + ...sizeBand(UNKNOWN_MODEL_VRAM_GB), + tierLabel: "Size unknown - not verified", + known: false, + source: "unknown", + }; } -/** Total resident memory (GB) needed to keep a set of models warm at once. */ +/* + * TWO WAYS TO ASK "does this machine fit these models" + * ---------------------------------------------------- + * `modelsMemoryGb`/`modelsFit` are the ALL-RESIDENT rule and stay the app's + * default. A worker advertises every model it selected, so a job for any of them + * can land at any moment; if that model isn't already in VRAM, Ollama cold-loads + * it (tens of seconds for a 20GB set, longer off a spinning disk) while the job + * clock runs, and a missed deadline is what gets a worker slashed. Requiring the + * whole set resident buys that risk away outright. + * + * `modelFitsAlone`/`largestModelGb` express the weaker SWAP rule: hold only the + * biggest model and page the others in between jobs. That is safe ONLY when + * cold-load time comfortably fits inside the job deadline, which we cannot + * verify from the browser (it depends on disk speed and the deadline the + * requester set). They exist so a caller can label a "could serve, with + * swapping" state - nothing here switches the default over to them. + * + * `availGb` is taken at face value in both. Pass `usableVramGb(total)` if you + * want the desktop's overhead subtracted; these helpers deliberately don't, so a + * headless server isn't charged for a compositor it doesn't run. + */ + +/** Total resident memory (GB) to keep a set of models warm at once (all-resident rule). */ export function modelsMemoryGb(names: string[]): number { - return names.reduce((sum, n) => sum + modelRequirement(n).vramGb, 0); + const sum = names.reduce((total, n) => total + modelRequirement(n).vramGb, 0); + // Measured sizes are decimals now, so sum float noise (6.1 + 46.7 = 52.800000000000004) + // instead of the old whole numbers. Round to the one decimal the catalog claims. + return Math.round(sum * 10) / 10; } /** Whether a machine with `availGb` (discrete VRAM, or the unified pool on Apple @@ -185,6 +310,23 @@ export function modelsFit(names: string[], availGb: number): boolean { return availGb > 0 && names.length > 0 && modelsMemoryGb(names) <= availGb; } +/** + * Whether ONE model fits on its own - the swap-mode question (see above). + * + * For an unsized model this compares against UNKNOWN_MODEL_VRAM_GB, so a true + * here means "clears the largest model we know of", which is the only fit claim + * that's defensible without knowing what the id actually is. Callers still must + * not paint an unsized model as a confident green "fits": check `known` first. + */ +export function modelFitsAlone(nameOrId: string, availGb: number): boolean { + return availGb > 0 && modelRequirement(nameOrId).vramGb <= availGb; +} + +/** The single heaviest model in a set - the floor for swap mode. 0 for an empty set. */ +export function largestModelGb(names: string[]): number { + return names.reduce((mx, n) => Math.max(mx, modelRequirement(n).vramGb), 0); +} + export interface Detected { input: Partial; vramInferred: boolean; diff --git a/lib/install-progress.ts b/lib/install-progress.ts index 0099df6..117079a 100644 --- a/lib/install-progress.ts +++ b/lib/install-progress.ts @@ -7,6 +7,7 @@ * Pure + deterministic so it's unit-tested: feed it the cleaned log lines and the * run phase, get back the milestones to render. */ +import { lookupModel } from "./model-catalog"; export type StepStatus = "pending" | "active" | "done" | "error"; export type RunPhase = "running" | "done" | "failed"; @@ -120,6 +121,25 @@ function explorerFor(net: "mainnet" | "testnet" | null): string { return `https://${net === "testnet" ? "testnet" : "mainnet"}.lightscan.app`; } +// Root/privilege refusals from the prerequisite stage. +// +// The sudo-flavoured alternatives below are no longer what fires on Linux: since the +// no-hang escalation ladder (`as_root` in lib/scriptgen.ts - root -> `sudo -n` -> pkexec) +// every rung SWALLOWS its own stderr (`sudo -n true 2>/dev/null`, and a dismissed pkexec +// dialog says nothing at all), so not one `sudo: …` line reaches the log on a machine +// where sudo needs a password. What DOES reach it is the installer's own aborts and +// polkit's wording, so those lead the alternation: +// "… needs administrator rights this app cannot obtain here …" (can_root false) +// "… the administrator prompt was declined, or no polkit agent is running …" +// "… (the admin prompt was declined or unavailable)" (the bind retry) +// pkexec/polkit themselves: "Request dismissed", "No authentication agent found", +// "Error executing command as another user: Not authorized". +// The sudo/tty alternatives are kept because macOS still elevates through brew + sudo. +// Anchored deliberately: a bare /sudo/ would also match the advice text the installer +// itself prints ("run once in a terminal: sudo mkdir -p …"), which is not a failure. +const PRIVILEGE_FAIL_RE = + /needs administrator rights this app cannot obtain|admin(?:istrator)? prompt was declined|no polkit agent is running|request dismissed|no authentication agent found|error executing command as another user|\bsudo:|superuser permissions|please re-run as root|a password is required|no tty present|a terminal is required to read the password|ability to run commands as root|\bpkexec\b/i; + /** * Turn a known install failure into one plain-English, actionable sentence (shown * above the technical log on failure). Reacts to the actual on-chain error text - @@ -128,6 +148,136 @@ function explorerFor(net: "mainnet" | "testnet" | null): string { */ export function diagnoseFailure(cleaned: string[]): string | null { const text = cleaned.join("\n"); + // Hoisted because several recognisers below have to be honest about the operator's + // money: every step up to 07-register is local setup, so "nothing was staked" is + // only a true thing to say while this is false. `online` guards the fallbacks. + const inRegisterPath = /phase\s*\.?\\?\/?0?7[- ]register|worker:latest\s+(?:status|register)|stopped at .*07-register/i.test(text); + const online = /worker online|✅\s*worker/i.test(text); + // FIRST, because it is the one failure where getting the message wrong costs the + // operator money: the installer's OWN gas-corrected on-chain model add failed. + // `add_selected_model_onchain` (bash) / `Add-SelectedModelOnchain` (ps1) print one + // of three aborts, and NONE of them contains the old "model add failed" wording + // this used to key on (that string now survives only as the PowerShell throw text, + // kept as the last alternative): + // "⛔ the on-chain model add would revert, so it was NOT sent: …" + // "⛔ the model-add tx failed to send: …" + // "⛔ the model-add tx landed but the registry still does not list this worker + // as serving the model - it reverted on-chain (receipt status 0)." + // Both callers only ever reach that function on a REGISTERED worker: the fresh path + // runs it after isWorkerRegistered() has come back true, and the re-run path enters + // it precisely BECAUSE the worker was already registered ("phase 07-register (already + // staked from a prior attempt; finishing the model-add the daemon failed - no + // re-stake)"). So the stake exists whenever these lines appear - which is exactly + // why this must win: that re-run branch `continue`s past gate_funding, so + // `fundingConfirmed` stays false and the generic register fallback at the bottom + // would tell an operator holding a full stake to "top up … and run install again". + const modelAdd = text.match( + /the on-chain model add would revert|the model-add tx failed to send|the model-add tx landed but the registry|model add failed/i, + ); + if (modelAdd) { + const why = /would revert/i.test(modelAdd[0]) + ? "The add was simulated first and would have reverted, so it was never sent and no gas was spent - which almost always means this network's registry doesn't whitelist that exact model. " + : /failed to send/i.test(modelAdd[0]) + ? "The add transaction never made it onto the network; the send error is quoted on that line in the log below. " + : /landed but the registry/i.test(modelAdd[0]) + ? "The add transaction was mined but reverted on-chain, so the registry still doesn't list this worker as serving the model. " + : ""; + return ( + "Your worker is already staked and registered on-chain - your stake is locked, not lost, and it does NOT need " + + "topping up to fix this. The only thing that didn't land is attaching the model to the worker. " + + why + + "Finish from the dashboard: open “Models this worker serves” and add it there - that path works on an " + + "already-registered worker, so there is no re-stake and no reinstall. (llama3-8b is listed on every network if " + + "you need a safe fallback.) The worker earns nothing until it serves at least one model, so do this before " + + "leaving it running." + ); + } + // Terminal bind gate (Linux only). The worker runs in Docker and reaches Ollama over + // the bridge gateway, so a loopback-only Ollama means EVERY job fails at inference - + // the installer refuses to stake into that. Matched on the clause that only the two + // ⛔ aborts carry ("…, so the Dockerized worker cannot reach it"): the preflight also + // prints a ⚠ "Ollama only listens on 127.0.0.1 - install will rebind it" line, which + // is a plan, not a failure, and must not trigger this. Both aborts happen inside + // SMART_PREREQS, which scriptgen emits before the phase loop, so nothing is staked. + if (/only listens on 127\.0\.0\.1, so the Dockerized worker cannot reach it/i.test(text)) { + // The auto-rebind asks for root; when that prompt is refused this is the aftermath, + // so name the real cause rather than leaving the operator to guess. + const declined = /could not rebind Ollama automatically/i.test(text) + ? "LightNode tried to fix this itself and the administrator prompt was declined or never appeared. " + : ""; + return ( + "Ollama is only listening on 127.0.0.1, so the worker - which runs inside Docker - cannot reach it and every " + + "job would fail at inference. " + + declined + + "Nothing was staked. Run these once in a terminal, then run install again: sudo mkdir -p " + + "/etc/systemd/system/ollama.service.d && printf '[Service]\\nEnvironment=\"OLLAMA_HOST=0.0.0.0:11434\"\\n' | " + + "sudo tee /etc/systemd/system/ollama.service.d/lightnode.conf && sudo systemctl daemon-reload && sudo " + + "systemctl restart ollama" + ); + } + // Terminal model-presence gate: the pull reported success (or only warned) but Ollama + // still doesn't have the tag under its exact on-chain name. Registering here would + // stake LCAI on a worker that fails - and can be slashed for - every job it wins, so + // the installer stops. This also runs before the phase loop: funds really are untouched. + const missingModels = text.match(/these selected model\(s\) are NOT on this machine after the download:(.*)$/im); + if (missingModels) { + const names = missingModels[1].trim().split(/\s+/).filter(Boolean); + const which = names.length ? ` (${names.join(", ")})` : ""; + // Size is the actionable part when the cause is disk, same reasoning as the + // download-failure branch below - quote it when the catalog knows the model. + const entry = names.length ? lookupModel(names[0]) : undefined; + const sizeBit = entry ? ` ${entry.tag} is a ${entry.downloadGb} GB download, so check you have that much free.` : ""; + return ( + `Ollama still doesn't have the selected model${which} after the download, so the install stopped instead of ` + + "staking a worker that would fail every job it wins. Nothing was staked or registered and your funds are " + + "untouched." + + sizeBit + + " Run `ollama pull` for it in a terminal followed by `ollama list` to see the real error (usually out of disk, " + + "out of memory, or a tag that doesn't exist in the Ollama registry), then run install again - or go back and " + + "pick a smaller model." + ); + } + // Prerequisite stage, and by far the most likely Linux failure: the vendor install + // scripts need root, and on a machine where sudo wants a password the ONLY prompt + // LightNode can raise is the graphical polkit one. Everything here is emitted from + // SMART_PREREQS, which scriptgen places before gate_funding and 07-register, and + // `set -e` aborts the whole run - so the reassurance is unconditionally true. + // Docker is installed before Ollama, so an Ollama marker means Docker's own install + // already succeeded - test that one first to attribute the failure correctly. + if (PRIVILEGE_FAIL_RE.test(text)) { + if (/installing Ollama|the Ollama install \(or the 0\.0\.0\.0 bind that follows it\) did not complete|Ollama is not installed, and installing it needs administrator rights/i.test(text)) { + return ( + "Installing Ollama needs administrator rights, and the prompt for them was declined or never appeared - " + + "LightNode can only ask through the graphical admin (polkit) dialog, never a password prompt you can't see. " + + "Open a terminal and run: curl -fsSL https://ollama.com/install.sh | sh - then run install again and " + + "LightNode will skip straight past this step. Nothing was staked." + ); + } + if (/installing Docker|the Docker install did not complete|Docker is not installed, and installing it needs administrator rights/i.test(text)) { + return ( + "Installing Docker needs administrator rights, and the prompt for them was declined or never appeared - " + + "LightNode can only ask through the graphical admin (polkit) dialog, never a password prompt you can't see. " + + "Open a terminal and run: curl -fsSL https://get.docker.com | sudo sh && sudo usermod -aG docker $(id -un) - " + + "then log out and back in (that is what lets LightNode drive Docker without root) and run install again. " + + "Nothing was staked." + ); + } + // Anything else privilege-shaped falls through to the catch-all at the very bottom + // of this function - NOT here. The Ollama rebind logs "⚠ could not rebind Ollama + // automatically (the admin prompt was declined or unavailable)" and then carries on, + // so a privilege string can be sitting in the log of a run that actually died of + // something else entirely; every specific recogniser has to get first refusal. + } + // Docker installed fine but the group membership it granted isn't live in this + // session, so the socket is still refused. A relog is the only fix; the installer + // stops here rather than dying four minutes later in the engine wait. + if (/only just added to the .?docker.? group/i.test(text)) { + return ( + "Docker is installed and running, but your user was only just added to the 'docker' group and Linux applies " + + "group changes at login - so this session still can't reach it. Log out and back in (or reboot), then run " + + "install again. Nothing was staked." + ); + } if (/AddSupportedModel\b.*\brevert/i.test(text)) { return ( "Your worker staked and registered on-chain (your stake is locked, not lost), but adding the model " + @@ -137,6 +287,20 @@ export function diagnoseFailure(cleaned: string[]): string | null { "or reinstall needed. (If it still won’t take, llama3-8b is the safe fallback.)" ); } + // The dashboard's add-model-on-chain run, not an install: "⛔ failed to add " + // / "one or more models failed to add". That script only ever runs against a worker + // that is ALREADY registered and it never stakes, so the honest framing is "nothing + // changed". Gated on the register path being absent so daemon output during a real + // install can't borrow this message and wrongly promise no stake was placed. + if (/failed to add\b/i.test(text) && !inRegisterPath) { + return ( + "Adding the model on-chain didn’t go through, so this worker still serves exactly the set it served before - " + + "nothing was staked and its registration is untouched. Two things cause this: the worker wallet has no LCAI " + + "left for gas (the add is a transaction the worker signs and pays for itself), or this network’s registry " + + "doesn’t list that exact model. Send the worker a little LCAI and try again; if it still fails, pick a model " + + "the network lists (llama3-8b is on every network)." + ); + } if (/stopped at 07-register/i.test(text) && /less than|insufficient|balance/i.test(text)) { return "Registration needs a little more LCAI for the stake plus gas. Top up the worker address shown above, then run install again."; } @@ -186,8 +350,7 @@ export function diagnoseFailure(cleaned: string[]): string | null { // network's native gas token, so funding exactly the minimum stake leaves // nothing left to pay for the register tx. Surface the worker address so the // operator can check + top up directly instead of guessing. - const inRegisterPath = /phase\s*\.?\\?\/?0?7[- ]register|worker:latest\s+(?:status|register)|stopped at .*07-register/i.test(text); - const online = /worker online|✅\s*worker/i.test(text); + // // If the pre-register funding gate already CONFIRMED the wallet held enough // LCAI ("✓ worker wallet funded (… LCAI)"), a later register failure is NOT a // balance problem. Telling a funded operator to "top up" wastes their money and @@ -221,6 +384,47 @@ export function diagnoseFailure(cleaned: string[]): string | null { "gas) and run install again - your existing worker key is reused, no reset needed." ); } + // A failed `ollama pull` reaches the log ONLY as the installer's own + // "⚠ download exited (continuing)" line - the pull runs detached with + // its output in a temp file that is then deleted, so the underlying reason (nearly + // always no free disk, sometimes a dropped connection) never gets here. We name the + // size from the catalog instead, since "you need 65.4 GB free" is the actionable + // part. Deliberately last: the install carries on after this line, so a download + // failure is usually the cause of a LATER stop, and whatever actually stopped the + // run should speak first. Reaching here means nothing on-chain was attempted. + const pullFail = text.match(/(\S+)[ \t]+download exited\b/i); + if (pullFail && !inRegisterPath && !online) { + const entry = lookupModel(pullFail[1]); + const sizeBit = entry + ? ` ${entry.tag} is a ${entry.downloadGb} GB download, so you need at least that much free on top of what Docker and the worker image take.` + : ""; + // The installer prints this itself when the disk is under 15 GB free; if it did, + // we already know the answer rather than listing space as one of two guesses. + const lowDisk = /Only ~?\s*\d+\s*GB free/i.test(text) + ? " The installer already flagged this machine as low on disk, so that is almost certainly why." + : ""; + return ( + "The AI model didn’t finish downloading, so there was nothing for the worker to serve." + + sizeBit + + lowDisk + + " Free up disk space - or go back and pick a smaller model - then run install again; Ollama resumes from " + + "where it stopped, so a retry doesn’t re-download what you already have. Nothing was staked." + ); + } + // Catch-all for a privilege refusal we couldn't attribute to Docker or Ollama by name + // (a pkexec/polkit line on its own, an unrecognised escalation abort). Runs dead last + // so it can never outrank a specific recogniser: the optional Ollama rebind leaves + // "⚠ could not rebind Ollama automatically (the admin prompt was declined…)" in the log + // of runs that then fail for a completely different reason. Same money guard as the + // download branch - `!inRegisterPath && !online` is what makes "nothing was staked" true. + if (PRIVILEGE_FAIL_RE.test(text) && !inRegisterPath && !online) { + return ( + "A setup step needed administrator rights and the prompt for them was declined or never appeared - on Linux " + + "LightNode can only ask through the graphical admin (polkit) dialog, never a password prompt you can't see. " + + "Approve it on the next run, or run the command the log suggests once in a terminal, then run install again. " + + "This is local setup, before anything on-chain - nothing was staked." + ); + } return null; } diff --git a/lib/model-catalog.ts b/lib/model-catalog.ts new file mode 100644 index 0000000..abbe40e --- /dev/null +++ b/lib/model-catalog.ts @@ -0,0 +1,168 @@ +/** + * Canonical model catalog + on-chain id recovery. + * + * WHY THIS EXISTS + * --------------- + * A model's on-chain identity is `id = keccak256(tag)` (see sdk/src/inference.ts). + * The registry stores only that digest - it carries no name, no size, and no + * quantization. When a model is whitelisted without its tag string, the indexer + * has nothing to put in `name`, so it echoes the id back: `name === id`. The UI + * then renders a raw 66-char hash, and `modelRequirement()` - which infers size + * by regex over the *name* - silently falls back to "8GB / Standard" for it. + * + * keccak256 is one-way, so a hash cannot be decoded back to a tag. What we CAN + * do is invert a *known* set: hash every tag we know and match. That is a + * dictionary lookup, not decoding - it recovers today's registrations and will + * not recover a future model whose tag we've never seen. Hence `known: false` + * stays a first-class state everywhere downstream; we never guess. + * + * SIZES + * ----- + * The registry exposes no size field, and the tag string alone cannot size a + * model (nothing in "glm-4.7-flash" or "qwen3-coder-next" implies a parameter + * count, and MoE models decouple params from footprint entirely). So sizes here + * are measured, with provenance recorded per entry: + * - `downloadGb` summed manifest layer bytes from registry.ollama.ai + * - `peakVramGb` observed resident peak during inference, where we have it + * Fit decisions use `peakVramGb` when measured, else a conservative estimate + * from `downloadGb` (weights + KV cache/context/runtime overhead). + */ +import { keccak256, toBytes } from "viem"; + +export interface CatalogEntry { + /** Exact tag as registered on chain - keccak256 of this is the model id. */ + tag: string; + /** Summed Ollama manifest layer bytes (the download). */ + downloadGb: number; + /** Measured peak resident VRAM during inference, when we have a real number. */ + peakVramGb?: number; + /** True for embedding models (max_output_tokens is 1, not a chat model). */ + embedding?: boolean; + /** Human note shown in the UI when useful. */ + note?: string; +} + +/** + * Known tags. Sizes are measured, not inferred. + * + * `downloadGb` = registry.ollama.ai manifest layer sum. + * `peakVramGb` = resident VRAM reported by Ollama's /api/ps with the model + * loaded and answering (size_vram), i.e. what actually occupies the card. + * + * DOWNLOAD SIZE IS NOT RESIDENT SIZE. Treating it as one is badly wrong for + * mixture-of-experts models, where only the active experts stay on the GPU: + * gemma4:e2b is a 7.2 GB download that sits at 1.7 GB resident - a 4x + * over-estimate if you scale the download. That is why `residentVramGb()` + * only falls back to a download-derived estimate when we have no measurement, + * and why adding a measurement is always preferable to trusting the fallback. + * + * Measured on an RTX 5060 Ti 16GB (Blackwell, driver 610.43.02, CUDA 13, + * Ollama 0.32.5). Figures are the model's own resident bytes; each loaded + * model additionally costs a few hundred MB of CUDA context, so a set's real + * GPU usage runs above the sum of these numbers - budget headroom accordingly. + */ +export const MODEL_CATALOG: CatalogEntry[] = [ + { tag: "qwen3-embedding:0.6b", downloadGb: 0.6, peakVramGb: 2.3, embedding: true, note: "Embedding model - returns vectors, not chat text" }, + { tag: "llama3-8b", downloadGb: 4.7 }, + { tag: "qwen3-vl:8b", downloadGb: 6.1, peakVramGb: 5.7, note: "Vision" }, + { tag: "gemma4:e2b", downloadGb: 7.2, peakVramGb: 1.7, note: "MoE - only the active experts stay resident, so it costs far less VRAM than its download suggests" }, + { tag: "gpt-oss:20b", downloadGb: 13.8, peakVramGb: 12.7, note: "Reasoning - MXFP4" }, + { tag: "glm-4.7-flash", downloadGb: 19.0, peakVramGb: 17.8, note: "Coding" }, + { tag: "qwen3-vl:30b", downloadGb: 19.6, peakVramGb: 17.9, note: "Vision" }, + { tag: "llama3-70b", downloadGb: 40.0 }, + { tag: "qwen3-coder-next", downloadGb: 51.7, note: "Coding - large context (16k output)" }, + { tag: "gpt-oss:120b", downloadGb: 65.4, peakVramGb: 59.9, note: "Reasoning - MXFP4, server-class" }, +]; + +/** modelId (lowercase 0x hash) -> catalog entry. Built once, from the tags. */ +export const ENTRY_BY_ID: ReadonlyMap = new Map( + MODEL_CATALOG.map((e) => [keccak256(toBytes(e.tag)).toLowerCase(), e]), +); + +/** tag -> catalog entry. */ +export const ENTRY_BY_TAG: ReadonlyMap = new Map( + MODEL_CATALOG.map((e) => [e.tag.toLowerCase(), e]), +); + +/** The on-chain id for a tag: keccak256(utf8 bytes of the exact tag). */ +export function modelIdForTag(tag: string): string { + return keccak256(toBytes(tag)).toLowerCase(); +} + +/** A 32-byte hex digest, i.e. what the registry uses as a model id. */ +export function isModelId(s: string): boolean { + return /^0x[0-9a-fA-F]{64}$/.test(s.trim()); +} + +/** + * Resolve whatever the indexer gave us into a display name + identity. + * + * `name` from the subgraph is either a real tag or (when the registration + * carried none) the id echoed back. We only ever treat the result as a servable + * tag when `known` is true - an unrecovered id is NOT a tag and must never be + * passed to `ollama pull` or hashed again into a second, bogus id. + */ +export interface ResolvedModel { + /** Safe to show. Either the real tag or a short, readable placeholder. */ + label: string; + /** The real Ollama/registry tag, or null when we could not recover it. */ + tag: string | null; + /** True when `tag` is a real tag we can serve. */ + known: boolean; + /** The on-chain model id, when we have it. */ + id?: string; + entry?: CatalogEntry; +} + +export function resolveModel(name: string, id?: string): ResolvedModel { + const rawId = (id ?? "").trim().toLowerCase(); + const trimmed = (name ?? "").trim(); + // The id is the identity, and because id = keccak256(tag) we can PROVE + // whether a claimed name is the real preimage rather than trusting it. That + // proof is what makes this function idempotent, which it has to be: the + // subgraph resolves once and stores the result, then the picker resolves the + // stored value again. Without the check, the placeholder produced by the + // first pass ("unnamed 0x1234abcd…") is not a digest, so a shape-only test + // would wave it through as a real tag on the second pass and hand back + // `{ tag: "unnamed 0x1234abcd…", known: true }` - a placeholder marked + // servable, which is exactly the failure this module exists to prevent. + const lookupId = isModelId(rawId) ? rawId : isModelId(trimmed) ? trimmed.toLowerCase() : ""; + + if (lookupId) { + // A name that hashes to this id is the genuine tag - including for models + // we have never seen, so a correctly-registered future model still works. + if (trimmed && !isModelId(trimmed) && modelIdForTag(trimmed) === lookupId) { + return { label: trimmed, tag: trimmed, known: true, id: lookupId, entry: ENTRY_BY_TAG.get(trimmed.toLowerCase()) }; + } + // Otherwise `name` proves nothing (it is the echoed id, one of our own + // placeholders, or junk). Fall back to inverting the id against the catalog. + const entry = ENTRY_BY_ID.get(lookupId); + if (entry) return { label: entry.tag, tag: entry.tag, known: true, id: lookupId, entry }; + return { label: `unnamed ${lookupId.slice(0, 10)}…`, tag: null, known: false, id: lookupId }; + } + + // No usable id to check against - trust the indexer's name, but never a bare + // digest, which is an echoed id with the id column missing. + if (trimmed && !isModelId(trimmed)) { + return { label: trimmed, tag: trimmed, known: true, id: undefined, entry: ENTRY_BY_TAG.get(trimmed.toLowerCase()) }; + } + return { label: "unnamed model", tag: null, known: false, id: rawId || undefined }; +} + +/** + * Resident VRAM (GB) needed to keep a model warm. + * + * Prefers a measured peak. Otherwise estimates from the download: weights plus + * KV cache, context and runtime overhead. Rounded to one decimal so the UI does + * not imply precision we do not have. + */ +export function residentVramGb(entry: CatalogEntry): number { + if (entry.peakVramGb != null) return entry.peakVramGb; + return Math.round((entry.downloadGb * 1.15 + 0.7) * 10) / 10; +} + +/** Catalog lookup by tag or id, whichever the caller has. */ +export function lookupModel(tagOrId: string): CatalogEntry | undefined { + const k = tagOrId.trim().toLowerCase(); + return isModelId(k) ? ENTRY_BY_ID.get(k) : ENTRY_BY_TAG.get(k); +} diff --git a/lib/scriptgen.ts b/lib/scriptgen.ts index b4a5474..f5b6e47 100644 --- a/lib/scriptgen.ts +++ b/lib/scriptgen.ts @@ -5,13 +5,16 @@ * operator runs locally, with the production gotchas already handled. */ import { NETWORKS, DEFAULT_MODEL, type NetworkId, type NetworkConfig } from "./network"; +// Sizes are MEASURED in the shared catalog (never inferred from a tag), so the +// generated script can gate a pull on real disk/VRAM numbers instead of guessing. +import { MODEL_CATALOG, lookupModel, residentVramGb } from "./model-catalog"; export type OS = "macos" | "linux" | "windows"; const TOOLKIT = "https://github.com/lightchain-protocol/lightchain-worker-toolkit"; // Bump on every install-script change so the log shows which version actually ran. -export const INSTALLER_REV = "2026-06-07.1"; +export const INSTALLER_REV = "2026-07-29.2"; export interface ScriptBundle { os: OS; @@ -125,6 +128,28 @@ function sortitionRunWin(net: NetworkConfig): string { ].join("\n"); } +/** + * Find OUR sleep-inhibitor holder by PID, without ever using a `-f` (full command + * line) pattern. + * + * pgrep/pkill -f match the whole command line, and the command line of the shell + * running any of these generated scripts IS the script text (the app runs them as + * `bash -lc "