From e2d75b3d3a0bff7973f4ee16a2d2a7095a064473 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Mon, 16 Mar 2026 22:56:44 -0700 Subject: [PATCH 1/7] test: add GPU detection tests with dependency injection Add dependency injection to detectGpu() via an optional opts parameter, enabling deterministic tests for all 4 code paths: standard NVIDIA, DGX Spark GB10 unified memory, Apple Silicon, and no-GPU fallback. Signed-off-by: Brian Taylor Signed-off-by: Brian Taylor --- bin/lib/nim.js | 17 ++++---- test/nim.test.js | 104 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 4f2233e435..054678c950 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -23,10 +23,13 @@ function listModels() { })); } -function detectGpu() { +function detectGpu(opts) { + const runCmd = (opts && opts.runCapture) || runCapture; + const platform = (opts && opts.platform) || process.platform; + // Try NVIDIA first — query VRAM try { - const output = runCapture( + const output = runCmd( "nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits", { ignoreError: true } ); @@ -48,7 +51,7 @@ function detectGpu() { // Fallback: DGX Spark (GB10) — VRAM not queryable due to unified memory architecture try { - const nameOutput = runCapture( + const nameOutput = runCmd( "nvidia-smi --query-gpu=name --format=csv,noheader,nounits", { ignoreError: true } ); @@ -56,7 +59,7 @@ function detectGpu() { // GB10 has 128GB unified memory shared with Grace CPU — use system RAM let totalMemoryMB = 0; try { - const memLine = runCapture("free -m | awk '/Mem:/ {print $2}'", { ignoreError: true }); + const memLine = runCmd("free -m | awk '/Mem:/ {print $2}'", { ignoreError: true }); if (memLine) totalMemoryMB = parseInt(memLine.trim(), 10) || 0; } catch {} return { @@ -71,9 +74,9 @@ function detectGpu() { } catch {} // macOS: detect Apple Silicon or discrete GPU - if (process.platform === "darwin") { + if (platform === "darwin") { try { - const spOutput = runCapture( + const spOutput = runCmd( "system_profiler SPDisplaysDataType 2>/dev/null", { ignoreError: true } ); @@ -92,7 +95,7 @@ function detectGpu() { } else { // Apple Silicon shares system RAM — read total memory try { - const memBytes = runCapture("sysctl -n hw.memsize", { ignoreError: true }); + const memBytes = runCmd("sysctl -n hw.memsize", { ignoreError: true }); if (memBytes) memoryMB = Math.floor(parseInt(memBytes, 10) / 1024 / 1024); } catch {} } diff --git a/test/nim.test.js b/test/nim.test.js index 8166cf6c43..dee2ec8508 100644 --- a/test/nim.test.js +++ b/test/nim.test.js @@ -74,4 +74,108 @@ describe("nim", () => { assert.equal(st.running, false); }); }); + + describe("detectGpu (injected)", () => { + function mockRunCapture(responses) { + return function (cmd) { + for (const [pattern, response] of responses) { + if (cmd.includes(pattern)) { + if (response instanceof Error) throw response; + return response; + } + } + throw new Error("mock: no match for " + cmd); + }; + } + + it("detects standard NVIDIA GPU", () => { + const gpu = nim.detectGpu({ + runCapture: mockRunCapture([ + ["memory.total", "8192"], + ]), + }); + assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.count, 1); + assert.equal(gpu.totalMemoryMB, 8192); + assert.equal(gpu.perGpuMB, 8192); + assert.equal(gpu.nimCapable, true); + assert.equal(gpu.spark, undefined); + }); + + it("detects multiple NVIDIA GPUs", () => { + const gpu = nim.detectGpu({ + runCapture: mockRunCapture([ + ["memory.total", "8192\n8192"], + ]), + }); + assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.count, 2); + assert.equal(gpu.totalMemoryMB, 16384); + assert.equal(gpu.perGpuMB, 8192); + }); + + it("detects DGX Spark GB10", () => { + const gpu = nim.detectGpu({ + runCapture: mockRunCapture([ + ["memory.total", ""], + ["name", "NVIDIA GB10"], + ["free -m", "122880"], + ]), + }); + assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.spark, true); + assert.equal(gpu.count, 1); + assert.equal(gpu.totalMemoryMB, 122880); + }); + + it("handles Spark with free -m failure", () => { + const gpu = nim.detectGpu({ + runCapture: mockRunCapture([ + ["memory.total", ""], + ["name", "NVIDIA GB10"], + ["free -m", new Error("command failed")], + ]), + }); + assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.spark, true); + assert.equal(gpu.totalMemoryMB, 0); + }); + + it("detects Apple Silicon", () => { + const gpu = nim.detectGpu({ + platform: "darwin", + runCapture: mockRunCapture([ + ["memory.total", new Error("no nvidia-smi")], + ["name", new Error("no nvidia-smi")], + ["system_profiler", "Chipset Model: Apple M2 Pro\n VRAM (Total): 16 GB\n Total Number of Cores: 19"], + ]), + }); + assert.equal(gpu.type, "apple"); + assert.equal(gpu.name, "Apple M2 Pro"); + assert.equal(gpu.nimCapable, false); + assert.equal(gpu.totalMemoryMB, 16384); + assert.equal(gpu.cores, 19); + }); + + it("returns null when no GPU detected", () => { + const gpu = nim.detectGpu({ + platform: "linux", + runCapture: mockRunCapture([ + ["memory.total", new Error("no nvidia-smi")], + ["name", new Error("no nvidia-smi")], + ]), + }); + assert.equal(gpu, null); + }); + + it("non-GB10 NVIDIA has no spark property", () => { + const gpu = nim.detectGpu({ + runCapture: mockRunCapture([ + ["memory.total", "24576"], + ]), + }); + assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.spark, undefined); + }); + }); }); From d676613032e22b12af11dd642a0553f57b8b774c Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Tue, 17 Mar 2026 17:27:01 -0700 Subject: [PATCH 2/7] test: add Apple Silicon unified memory path coverage - Add test for the sysctl hw.memsize fallback when system_profiler reports no VRAM (the actual Apple Silicon code path) - Rename existing Apple test to clarify it covers the discrete VRAM parsing branch - Use more specific mock pattern "query-gpu=name" to avoid substring collisions --- test/nim.test.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/nim.test.js b/test/nim.test.js index dee2ec8508..322490e9ee 100644 --- a/test/nim.test.js +++ b/test/nim.test.js @@ -141,7 +141,7 @@ describe("nim", () => { assert.equal(gpu.totalMemoryMB, 0); }); - it("detects Apple Silicon", () => { + it("detects macOS discrete GPU via VRAM", () => { const gpu = nim.detectGpu({ platform: "darwin", runCapture: mockRunCapture([ @@ -157,6 +157,23 @@ describe("nim", () => { assert.equal(gpu.cores, 19); }); + it("detects Apple Silicon with unified memory", () => { + const gpu = nim.detectGpu({ + platform: "darwin", + runCapture: mockRunCapture([ + ["memory.total", new Error("no nvidia-smi")], + ["query-gpu=name", new Error("no nvidia-smi")], + ["system_profiler", "Chipset Model: Apple M4\n Total Number of Cores: 10"], + ["hw.memsize", "17179869184"], + ]), + }); + assert.equal(gpu.type, "apple"); + assert.equal(gpu.name, "Apple M4"); + assert.equal(gpu.nimCapable, false); + assert.equal(gpu.totalMemoryMB, 16384); + assert.equal(gpu.cores, 10); + }); + it("returns null when no GPU detected", () => { const gpu = nim.detectGpu({ platform: "linux", From 174f4a164971cdb7f71dfb6ba31ab56cbdb05d51 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Tue, 17 Mar 2026 18:04:52 -0700 Subject: [PATCH 3/7] docs: add JSDoc to detectGpu for docstring coverage check --- bin/lib/nim.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 054678c950..a0d555cb3a 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -23,6 +23,14 @@ function listModels() { })); } +/** + * Detect GPU hardware. Returns an object describing the GPU (type, count, + * memory, capabilities) or null if no GPU is found. + * @param {object} [opts] - Optional overrides for dependency injection. + * @param {Function} [opts.runCapture] - Command runner (default: runner.runCapture). + * @param {string} [opts.platform] - OS platform (default: process.platform). + * @returns {{ type: string, count: number, totalMemoryMB: number, perGpuMB: number, nimCapable: boolean, spark?: boolean, name?: string, cores?: number } | null} + */ function detectGpu(opts) { const runCmd = (opts && opts.runCapture) || runCapture; const platform = (opts && opts.platform) || process.platform; From cdfee7e300ef52413bfb47247f5628a6aecf2300 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Tue, 17 Mar 2026 18:13:21 -0700 Subject: [PATCH 4/7] fix: omit cores property when unknown instead of returning null Aligns runtime behavior with JSDoc contract (cores?: number). When system_profiler does not report core count, the property is now omitted entirely rather than set to null. --- bin/lib/nim.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index a0d555cb3a..448a5bd55f 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -112,7 +112,7 @@ function detectGpu(opts) { type: "apple", name, count: 1, - cores: coresMatch ? parseInt(coresMatch[1], 10) : null, + ...(coresMatch ? { cores: parseInt(coresMatch[1], 10) } : {}), totalMemoryMB: memoryMB, perGpuMB: memoryMB, nimCapable: false, From 74ad92d061e273029072618b8a87f912bfee93f8 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Tue, 17 Mar 2026 18:25:47 -0700 Subject: [PATCH 5/7] docs: add JSDoc to all nim.js functions for docstring coverage --- bin/lib/nim.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 448a5bd55f..71fb434a99 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -6,15 +6,18 @@ const { run, runCapture } = require("./runner"); const nimImages = require("./nim-images.json"); +/** @param {string} sandboxName @returns {string} Docker container name. */ function containerName(sandboxName) { return `nemoclaw-nim-${sandboxName}`; } +/** @param {string} modelName @returns {string|null} NIM container image or null. */ function getImageForModel(modelName) { const entry = nimImages.models.find((m) => m.name === modelName); return entry ? entry.image : null; } +/** @returns {Array<{name: string, image: string, minGpuMemoryMB: number}>} */ function listModels() { return nimImages.models.map((m) => ({ name: m.name, @@ -125,6 +128,7 @@ function detectGpu(opts) { return null; } +/** @param {string} model - Model name to pull. @returns {string} Image tag. */ function pullNimImage(model) { const image = getImageForModel(model); if (!image) { @@ -136,6 +140,7 @@ function pullNimImage(model) { return image; } +/** @param {string} sandboxName @param {string} model @param {number} [port=8000] @returns {string} Container name. */ function startNimContainer(sandboxName, model, port = 8000) { const name = containerName(sandboxName); const image = getImageForModel(model); @@ -154,6 +159,7 @@ function startNimContainer(sandboxName, model, port = 8000) { return name; } +/** @param {number} [port=8000] @param {number} [timeout=300] @returns {boolean} True if healthy. */ function waitForNimHealth(port = 8000, timeout = 300) { const start = Date.now(); const interval = 5000; @@ -176,6 +182,7 @@ function waitForNimHealth(port = 8000, timeout = 300) { return false; } +/** @param {string} sandboxName - Stop and remove the NIM container. */ function stopNimContainer(sandboxName) { const name = containerName(sandboxName); console.log(` Stopping NIM container: ${name}`); @@ -183,6 +190,7 @@ function stopNimContainer(sandboxName) { run(`docker rm ${name} 2>/dev/null || true`, { ignoreError: true }); } +/** @param {string} sandboxName @returns {{running: boolean, healthy?: boolean, container: string, state?: string}} */ function nimStatus(sandboxName) { const name = containerName(sandboxName); try { From 9fbea7e074e0de98f533665e83e22b980233943f Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Tue, 17 Mar 2026 18:47:18 -0700 Subject: [PATCH 6/7] feat(nim): add GPU model pre-selector with recommended model Adds suggestModelsForGpu() that ranks NIM models by VRAM fit and marks the optimal model as recommended. Also surfaces GPU name in NVIDIA detection for better display during onboarding. --- bin/lib/nim.js | 34 ++++++++++++++++++++++++++++++++ bin/lib/onboard.js | 8 +++++--- test/nim.test.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 71fb434a99..e70b7013c1 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -49,8 +49,18 @@ function detectGpu(opts) { const perGpuMB = lines.map((l) => parseInt(l.trim(), 10)).filter((n) => !isNaN(n)); if (perGpuMB.length > 0) { const totalMemoryMB = perGpuMB.reduce((a, b) => a + b, 0); + // Query GPU name for display + let name; + try { + name = runCmd( + "nvidia-smi --query-gpu=name --format=csv,noheader,nounits", + { ignoreError: true } + ); + if (name) name = name.split("\n")[0].trim(); + } catch {} return { type: "nvidia", + name, count: perGpuMB.length, totalMemoryMB, perGpuMB: perGpuMB[0], @@ -129,6 +139,29 @@ function detectGpu(opts) { } /** @param {string} model - Model name to pull. @returns {string} Image tag. */ +/** + * Suggest NIM models ranked by fit for a given GPU. + * Returns models sorted by VRAM requirement (descending), with the largest + * model that uses <=90% of available VRAM marked as recommended. + * @param {{ totalMemoryMB: number, nimCapable: boolean } | null} gpu + * @returns {Array<{ name: string, image: string, minGpuMemoryMB: number, recommended: boolean }>} + */ +function suggestModelsForGpu(gpu) { + if (!gpu || !gpu.nimCapable) return []; + const vram = gpu.totalMemoryMB; + const fits = listModels() + .filter((m) => m.minGpuMemoryMB <= vram) + .sort((a, b) => b.minGpuMemoryMB - a.minGpuMemoryMB); + + const threshold = vram * 0.9; + let recommended = false; + return fits.map((m) => { + const rec = !recommended && m.minGpuMemoryMB <= threshold; + if (rec) recommended = true; + return { ...m, recommended: rec }; + }); +} + function pullNimImage(model) { const image = getImageForModel(model); if (!image) { @@ -218,6 +251,7 @@ module.exports = { getImageForModel, listModels, detectGpu, + suggestModelsForGpu, pullNimImage, startNimContainer, waitForNimHealth, diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 23f19b01a8..47832beec3 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -314,7 +314,8 @@ async function preflight() { // GPU const gpu = nim.detectGpu(); if (gpu && gpu.type === "nvidia") { - console.log(` ✓ NVIDIA GPU detected: ${gpu.count} GPU(s), ${gpu.totalMemoryMB} MB VRAM`); + const label = gpu.name ? `${gpu.name}, ` : ""; + console.log(` ✓ NVIDIA GPU detected: ${label}${gpu.count} GPU(s), ${gpu.totalMemoryMB} MB VRAM`); } else if (gpu && gpu.type === "apple") { console.log(` ✓ Apple GPU detected: ${gpu.name}${gpu.cores ? ` (${gpu.cores} cores)` : ""}, ${gpu.totalMemoryMB} MB unified memory`); console.log(" ⓘ NIM requires NVIDIA GPU — will use cloud inference"); @@ -540,7 +541,7 @@ async function setupNim(sandboxName, gpu) { if (selected.key === "nim") { // List models that fit GPU VRAM - const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); + const models = nim.suggestModelsForGpu(gpu); if (models.length === 0) { console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); } else { @@ -560,7 +561,8 @@ async function setupNim(sandboxName, gpu) { console.log(""); console.log(" Models that fit your GPU:"); models.forEach((m, i) => { - console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`); + const tag = m.recommended ? " (recommended)" : ""; + console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)${tag}`); }); console.log(""); diff --git a/test/nim.test.js b/test/nim.test.js index 322490e9ee..ebd765eb69 100644 --- a/test/nim.test.js +++ b/test/nim.test.js @@ -195,4 +195,52 @@ describe("nim", () => { assert.equal(gpu.spark, undefined); }); }); + + describe("suggestModelsForGpu", () => { + it("returns empty for null GPU", () => { + assert.deepEqual(nim.suggestModelsForGpu(null), []); + }); + + it("returns empty for non-nimCapable GPU", () => { + assert.deepEqual(nim.suggestModelsForGpu({ totalMemoryMB: 16384, nimCapable: false }), []); + }); + + it("filters models that exceed VRAM", () => { + const models = nim.suggestModelsForGpu({ totalMemoryMB: 8000, nimCapable: true }); + for (const m of models) { + assert.ok(m.minGpuMemoryMB <= 8000, `${m.name} requires ${m.minGpuMemoryMB} MB`); + } + }); + + it("sorts by VRAM descending", () => { + const models = nim.suggestModelsForGpu({ totalMemoryMB: 200000, nimCapable: true }); + for (let i = 1; i < models.length; i++) { + assert.ok(models[i - 1].minGpuMemoryMB >= models[i].minGpuMemoryMB, + "models should be sorted by VRAM descending"); + } + }); + + it("marks exactly one model as recommended", () => { + const models = nim.suggestModelsForGpu({ totalMemoryMB: 200000, nimCapable: true }); + const recommended = models.filter((m) => m.recommended); + assert.equal(recommended.length, 1, "exactly one model should be recommended"); + }); + + it("recommended model fits within 90% VRAM", () => { + const vram = 32000; + const models = nim.suggestModelsForGpu({ totalMemoryMB: vram, nimCapable: true }); + const rec = models.find((m) => m.recommended); + if (rec) { + assert.ok(rec.minGpuMemoryMB <= vram * 0.9, + `recommended model (${rec.minGpuMemoryMB} MB) should fit within 90% of ${vram} MB`); + } + }); + + it("each entry has recommended boolean", () => { + const models = nim.suggestModelsForGpu({ totalMemoryMB: 200000, nimCapable: true }); + for (const m of models) { + assert.equal(typeof m.recommended, "boolean"); + } + }); + }); }); From a990641db61670a01acced7c0be902778de1f490 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Wed, 18 Mar 2026 16:42:24 -0700 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20default=20to=20recommended=20model,=20include=20GB1?= =?UTF-8?q?0=20name,=20remove=20stray=20JSDoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default interactive and non-interactive model selection to the recommended model instead of models[0] - Include GPU name in the GB10 fallback return so Spark users see their GPU name during onboarding - Remove stray pullNimImage JSDoc attached to suggestModelsForGpu - Add name assertion to DGX Spark GB10 test --- bin/lib/nim.js | 3 ++- bin/lib/onboard.js | 10 ++++++---- test/nim.test.js | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index e70b7013c1..a0ed10160d 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -77,6 +77,7 @@ function detectGpu(opts) { { ignoreError: true } ); if (nameOutput && nameOutput.includes("GB10")) { + const name = nameOutput.split("\n")[0].trim(); // GB10 has 128GB unified memory shared with Grace CPU — use system RAM let totalMemoryMB = 0; try { @@ -85,6 +86,7 @@ function detectGpu(opts) { } catch {} return { type: "nvidia", + name, count: 1, totalMemoryMB, perGpuMB: totalMemoryMB, @@ -138,7 +140,6 @@ function detectGpu(opts) { return null; } -/** @param {string} model - Model name to pull. @returns {string} Image tag. */ /** * Suggest NIM models ranked by fit for a given GPU. * Returns models sorted by VRAM requirement (descending), with the largest diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 47832beec3..b370561865 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -546,6 +546,7 @@ async function setupNim(sandboxName, gpu) { console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); } else { let sel; + const defaultModelIndex = Math.max(0, models.findIndex((m) => m.recommended)); if (isNonInteractive()) { if (requestedModel) { sel = models.find((m) => m.name === requestedModel); @@ -554,7 +555,7 @@ async function setupNim(sandboxName, gpu) { process.exit(1); } } else { - sel = models[0]; + sel = models[defaultModelIndex]; } console.log(` [non-interactive] NIM model: ${sel.name}`); } else { @@ -566,9 +567,10 @@ async function setupNim(sandboxName, gpu) { }); console.log(""); - const modelChoice = await prompt(` Choose model [1]: `); - const midx = parseInt(modelChoice || "1", 10) - 1; - sel = models[midx] || models[0]; + const defaultChoice = String(defaultModelIndex + 1); + const modelChoice = await prompt(` Choose model [${defaultChoice}]: `); + const midx = parseInt(modelChoice || defaultChoice, 10) - 1; + sel = models[midx] || models[defaultModelIndex]; } model = sel.name; diff --git a/test/nim.test.js b/test/nim.test.js index ebd765eb69..4da1596d07 100644 --- a/test/nim.test.js +++ b/test/nim.test.js @@ -123,6 +123,7 @@ describe("nim", () => { ]), }); assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.name, "NVIDIA GB10"); assert.equal(gpu.spark, true); assert.equal(gpu.count, 1); assert.equal(gpu.totalMemoryMB, 122880);