From 043f93008127b2c3ffc9fcb3a2f06f58c86f0090 Mon Sep 17 00:00:00 2001 From: Deepak Jain Date: Fri, 26 Jun 2026 08:46:28 -0700 Subject: [PATCH 1/2] fix(onboard): warn on arm64 NIM image compatibility Refs #5772 Signed-off-by: Deepak Jain --- .../onboard/nim-image-compat-warning.test.ts | 86 ++++++++++++++ src/lib/onboard/nim-image-compat-warning.ts | 52 ++++++++ src/lib/onboard/provider-host-state.ts | 10 +- test/onboard-nim-image-compat-warning.test.ts | 112 ++++++++++++++++++ 4 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 src/lib/onboard/nim-image-compat-warning.test.ts create mode 100644 src/lib/onboard/nim-image-compat-warning.ts create mode 100644 test/onboard-nim-image-compat-warning.test.ts diff --git a/src/lib/onboard/nim-image-compat-warning.test.ts b/src/lib/onboard/nim-image-compat-warning.test.ts new file mode 100644 index 00000000000..d26fbf5b9bc --- /dev/null +++ b/src/lib/onboard/nim-image-compat-warning.test.ts @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + formatArm64NimImageCompatibilityWarning, + shouldWarnAboutArm64NimImageCompatibility, + warnAboutArm64NimImageCompatibility, +} from "./nim-image-compat-warning"; + +describe("arm64 NIM image compatibility warning", () => { + it("warns only when Local NIM is available on Linux arm64 DGX platforms", () => { + expect( + shouldWarnAboutArm64NimImageCompatibility({ + arch: "arm64", + platform: "linux", + gpu: { platform: "spark" }, + nimLocalAvailable: true, + }), + ).toBe(true); + expect( + shouldWarnAboutArm64NimImageCompatibility({ + arch: "arm64", + platform: "linux", + gpu: { platform: "station" }, + nimLocalAvailable: true, + }), + ).toBe(true); + expect( + shouldWarnAboutArm64NimImageCompatibility({ + arch: "x64", + platform: "linux", + gpu: { platform: "spark" }, + nimLocalAvailable: true, + }), + ).toBe(false); + expect( + shouldWarnAboutArm64NimImageCompatibility({ + arch: "arm64", + platform: "linux", + gpu: { platform: "linux" }, + nimLocalAvailable: true, + }), + ).toBe(false); + expect( + shouldWarnAboutArm64NimImageCompatibility({ + arch: "arm64", + platform: "linux", + gpu: { platform: "spark" }, + nimLocalAvailable: false, + }), + ).toBe(false); + }); + + it("describes image/platform compatibility without claiming Local NIM will fail", () => { + const lines = formatArm64NimImageCompatibilityWarning({ gpu: { platform: "station" } }); + + expect(lines.join("\n")).toContain("Linux arm64 DGX Station"); + expect(lines.join("\n")).toContain("linux/arm64 manifests"); + expect(lines.join("\n")).toContain("will try the selected image/platform digest"); + expect(lines.join("\n")).not.toMatch(/will fail|does not work/i); + }); + + it("prints the warning once through the logger", () => { + const log = vi.fn(); + + expect( + warnAboutArm64NimImageCompatibility({ + arch: "arm64", + platform: "linux", + gpu: { platform: "spark" }, + nimLocalAvailable: true, + log, + }), + ).toBe(true); + + expect(log.mock.calls.map((call) => call[0])).toEqual([ + "", + " Warning: Local NVIDIA NIM is experimental on Linux arm64 DGX Spark hosts.", + " Some NIM images may not publish linux/arm64 manifests.", + " NemoClaw will try the selected image/platform digest when possible; if Docker reports no matching platform, choose NVIDIA Endpoints, vLLM, or another provider.", + "", + ]); + }); +}); diff --git a/src/lib/onboard/nim-image-compat-warning.ts b/src/lib/onboard/nim-image-compat-warning.ts new file mode 100644 index 00000000000..f4d46a8bcbf --- /dev/null +++ b/src/lib/onboard/nim-image-compat-warning.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { GpuDetection } from "../inference/nim"; + +type Logger = (message?: string) => void; + +export interface NimImageCompatibilityWarningInput { + arch?: NodeJS.Architecture; + gpu: Pick | null | undefined; + nimLocalAvailable: boolean; + platform?: NodeJS.Platform; +} + +const ARM64_DGX_NIM_PLATFORMS = new Set(["spark", "station"]); + +export function shouldWarnAboutArm64NimImageCompatibility({ + arch = process.arch, + gpu, + nimLocalAvailable, + platform = process.platform, +}: NimImageCompatibilityWarningInput): boolean { + if (!nimLocalAvailable || platform !== "linux" || arch !== "arm64") return false; + return gpu?.spark === true || (gpu?.platform ? ARM64_DGX_NIM_PLATFORMS.has(gpu.platform) : false); +} + +function dgxPlatformLabel(gpu: NimImageCompatibilityWarningInput["gpu"]): string { + if (gpu?.platform === "station") return "DGX Station"; + return "DGX Spark"; +} + +export function formatArm64NimImageCompatibilityWarning( + input: Pick, +): string[] { + const hostLabel = dgxPlatformLabel(input.gpu); + return [ + ` Warning: Local NVIDIA NIM is experimental on Linux arm64 ${hostLabel} hosts.`, + " Some NIM images may not publish linux/arm64 manifests.", + " NemoClaw will try the selected image/platform digest when possible; if Docker reports no matching platform, choose NVIDIA Endpoints, vLLM, or another provider.", + ]; +} + +export function warnAboutArm64NimImageCompatibility( + input: NimImageCompatibilityWarningInput & { log?: Logger }, +): boolean { + if (!shouldWarnAboutArm64NimImageCompatibility(input)) return false; + const log = input.log ?? console.log; + log(""); + for (const line of formatArm64NimImageCompatibilityWarning(input)) log(line); + log(""); + return true; +} diff --git a/src/lib/onboard/provider-host-state.ts b/src/lib/onboard/provider-host-state.ts index 8b50e5c9984..ea7ea688c9f 100644 --- a/src/lib/onboard/provider-host-state.ts +++ b/src/lib/onboard/provider-host-state.ts @@ -17,6 +17,7 @@ import { getWindowsHostOllamaDockerRequirement, type WindowsHostOllamaDockerRequirement, } from "./local-inference-topology"; +import { warnAboutArm64NimImageCompatibility } from "./nim-image-compat-warning"; import { resolveOllamaInstallMenuEntry, type OllamaInstallMenuResult } from "./ollama-install-menu"; import { buildVllmMenuEntries, type VllmMenuEntry } from "./vllm-menu"; import { detectWindowsHostOllama, type WindowsHostOllamaState } from "./windows-host-ollama"; @@ -183,6 +184,13 @@ export function detectInferenceProviderHostState( runCapture: deps.runCapture, log, }); + const gpuNimCapable = Boolean(input.gpu?.nimCapable); + warnAboutArm64NimImageCompatibility({ + gpu: input.gpu, + nimLocalAvailable: input.experimental && gpuNimCapable, + platform, + log, + }); const ollamaInstallMenu = resolveOllamaInstallMenuEntry({ hasOllama, @@ -219,6 +227,6 @@ export function detectInferenceProviderHostState( log: (message) => log(message), }), ollamaInstallMenu, - gpuNimCapable: Boolean(input.gpu?.nimCapable), + gpuNimCapable, }; } diff --git a/test/onboard-nim-image-compat-warning.test.ts b/test/onboard-nim-image-compat-warning.test.ts new file mode 100644 index 00000000000..3ffb4ef3de6 --- /dev/null +++ b/test/onboard-nim-image-compat-warning.test.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { it } from "vitest"; + +function writeAlwaysOkCurl(fakeBin: string): void { + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' '{"id":"ok"}' > "$outfile" +printf '%s' "200" +`, + { mode: 0o755 }, + ); +} + +it("warns about arm64 NIM image compatibility when Local NIM is offered on DGX Spark", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-arm64-nim-warning-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "arm64-nim-warning-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const credentialsPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "credentials", "store.js"), + ); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + writeAlwaysOkCurl(fakeBin); + const script = String.raw` +Object.defineProperty(process, "arch", { value: "arm64", configurable: true }); +Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +credentials.prompt = async () => ""; +credentials.ensureApiKey = async () => {}; +runner.runCapture = (command) => { + const cmd = Array.isArray(command) ? command.join(" ") : command; + if (cmd.includes("command -v ollama")) return ""; + if (cmd.includes("127.0.0.1:11434")) return ""; + if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; + return ""; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim({ + type: "nvidia", + name: "NVIDIA GB10", + count: 1, + totalMemoryMB: 124607, + perGpuMB: 124607, + nimCapable: true, + unifiedMemory: true, + spark: true, + platform: "spark", + }); + originalLog(JSON.stringify({ result, lines })); + } finally { + console.log = originalLog; + } +})().catch((error) => { + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_EXPERIMENTAL: "1", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_PROVIDER: "build", + NVIDIA_INFERENCE_API_KEY: "nvapi-test", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "nvidia-prod"); + assert.equal(payload.result.model, "nvidia/nemotron-3-super-120b-a12b"); + assert.ok( + payload.lines.some((line: string) => + line.includes("Local NVIDIA NIM is experimental on Linux arm64 DGX Spark hosts"), + ), + ); + assert.ok(payload.lines.some((line: string) => line.includes("linux/arm64 manifests"))); +}); From e5098dd2e5da831ddd8271ae9614dee8507cec3d Mon Sep 17 00:00:00 2001 From: Deepak Jain Date: Fri, 26 Jun 2026 08:54:37 -0700 Subject: [PATCH 2/2] test(onboard): bound arm64 NIM warning subprocess Signed-off-by: Deepak Jain --- .../onboard/nim-image-compat-warning.test.ts | 8 ++ test/onboard-nim-image-compat-warning.test.ts | 119 +++++++++--------- 2 files changed, 66 insertions(+), 61 deletions(-) diff --git a/src/lib/onboard/nim-image-compat-warning.test.ts b/src/lib/onboard/nim-image-compat-warning.test.ts index d26fbf5b9bc..02f8cc1da7d 100644 --- a/src/lib/onboard/nim-image-compat-warning.test.ts +++ b/src/lib/onboard/nim-image-compat-warning.test.ts @@ -19,6 +19,14 @@ describe("arm64 NIM image compatibility warning", () => { nimLocalAvailable: true, }), ).toBe(true); + expect( + shouldWarnAboutArm64NimImageCompatibility({ + arch: "arm64", + platform: "linux", + gpu: { spark: true }, + nimLocalAvailable: true, + }), + ).toBe(true); expect( shouldWarnAboutArm64NimImageCompatibility({ arch: "arm64", diff --git a/test/onboard-nim-image-compat-warning.test.ts b/test/onboard-nim-image-compat-warning.test.ts index 3ffb4ef3de6..9cc37720659 100644 --- a/test/onboard-nim-image-compat-warning.test.ts +++ b/test/onboard-nim-image-compat-warning.test.ts @@ -2,11 +2,22 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { it } from "vitest"; +import { it, vi } from "vitest"; + +type SetupNim = (gpu: { + type: string; + name: string; + count: number; + totalMemoryMB: number; + perGpuMB: number; + nimCapable: boolean; + unifiedMemory: boolean; + spark: boolean; + platform: string; +}) => Promise<{ provider: string; model: string }>; function writeAlwaysOkCurl(fakeBin: string): void { fs.writeFileSync( @@ -26,43 +37,47 @@ printf '%s' "200" ); } -it("warns about arm64 NIM image compatibility when Local NIM is offered on DGX Spark", () => { - const repoRoot = path.join(import.meta.dirname, ".."); +it("warns about arm64 NIM image compatibility when Local NIM is offered on DGX Spark", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-arm64-nim-warning-")); const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "arm64-nim-warning-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "dist", "lib", "credentials", "store.js"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); fs.mkdirSync(fakeBin, { recursive: true }); writeAlwaysOkCurl(fakeBin); - const script = String.raw` -Object.defineProperty(process, "arch", { value: "arm64", configurable: true }); -Object.defineProperty(process, "platform", { value: "linux", configurable: true }); -const credentials = require(${credentialsPath}); -const runner = require(${runnerPath}); + const originalArch = process.arch; + const originalPlatform = process.platform; + const originalEnv = { ...process.env }; + const lines: string[] = []; + const originalLog = console.log; -credentials.prompt = async () => ""; -credentials.ensureApiKey = async () => {}; -runner.runCapture = (command) => { - const cmd = Array.isArray(command) ? command.join(" ") : command; - if (cmd.includes("command -v ollama")) return ""; - if (cmd.includes("127.0.0.1:11434")) return ""; - if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; - return ""; -}; + vi.resetModules(); + Object.defineProperty(process, "arch", { value: "arm64", configurable: true }); + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + process.env = { + ...originalEnv, + HOME: tmpDir, + PATH: `${fakeBin}:${originalEnv.PATH || ""}`, + NEMOCLAW_EXPERIMENTAL: "1", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_PROVIDER: "build", + NVIDIA_INFERENCE_API_KEY: "nvapi-test", + }; + console.log = (...args: unknown[]) => lines.push(args.join(" ")); -const { setupNim } = require(${onboardPath}); + vi.doMock("../src/lib/credentials/store.js", async (importOriginal) => ({ + ...(await importOriginal()), + prompt: async () => "", + ensureApiKey: async () => {}, + })); + vi.doMock("../src/lib/runner.js", async (importOriginal) => ({ + ...(await importOriginal()), + runCapture: (_command: readonly string[]) => "", + })); -(async () => { - const originalLog = console.log; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); try { + const { setupNim } = (await import("../src/lib/onboard.js")) as unknown as { + setupNim: SetupNim; + }; const result = await setupNim({ type: "nvidia", name: "NVIDIA GB10", @@ -74,39 +89,21 @@ const { setupNim } = require(${onboardPath}); spark: true, platform: "spark", }); - originalLog(JSON.stringify({ result, lines })); + + assert.equal(result.provider, "nvidia-prod"); + assert.equal(result.model, "nvidia/nemotron-3-super-120b-a12b"); + assert.ok( + lines.some((line) => + line.includes("Local NVIDIA NIM is experimental on Linux arm64 DGX Spark hosts"), + ), + ); + assert.ok(lines.some((line) => line.includes("linux/arm64 manifests"))); } finally { console.log = originalLog; + process.env = originalEnv; + Object.defineProperty(process, "arch", { value: originalArch, configurable: true }); + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + vi.doUnmock("../src/lib/credentials/store.js"); + vi.doUnmock("../src/lib/runner.js"); } -})().catch((error) => { - console.error(error && error.stack ? error.stack : String(error)); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - NEMOCLAW_EXPERIMENTAL: "1", - NEMOCLAW_NON_INTERACTIVE: "1", - NEMOCLAW_PROVIDER: "build", - NVIDIA_INFERENCE_API_KEY: "nvapi-test", - }, - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.result.provider, "nvidia-prod"); - assert.equal(payload.result.model, "nvidia/nemotron-3-super-120b-a12b"); - assert.ok( - payload.lines.some((line: string) => - line.includes("Local NVIDIA NIM is experimental on Linux arm64 DGX Spark hosts"), - ), - ); - assert.ok(payload.lines.some((line: string) => line.includes("linux/arm64 manifests"))); });