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..02f8cc1da7d --- /dev/null +++ b/src/lib/onboard/nim-image-compat-warning.test.ts @@ -0,0 +1,94 @@ +// 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: { spark: true }, + 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..9cc37720659 --- /dev/null +++ b/test/onboard-nim-image-compat-warning.test.ts @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +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( + 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", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-arm64-nim-warning-")); + const fakeBin = path.join(tmpDir, "bin"); + + fs.mkdirSync(fakeBin, { recursive: true }); + writeAlwaysOkCurl(fakeBin); + + const originalArch = process.arch; + const originalPlatform = process.platform; + const originalEnv = { ...process.env }; + const lines: string[] = []; + const originalLog = console.log; + + 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(" ")); + + 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[]) => "", + })); + + try { + const { setupNim } = (await import("../src/lib/onboard.js")) as unknown as { + setupNim: SetupNim; + }; + const result = await setupNim({ + type: "nvidia", + name: "NVIDIA GB10", + count: 1, + totalMemoryMB: 124607, + perGpuMB: 124607, + nimCapable: true, + unifiedMemory: true, + spark: true, + platform: "spark", + }); + + 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"); + } +});