diff --git a/bin/lib/local-inference.js b/bin/lib/local-inference.js index 1065a70e3..1e4eaa7a7 100644 --- a/bin/lib/local-inference.js +++ b/bin/lib/local-inference.js @@ -104,15 +104,12 @@ function parseOllamaList(output) { function getOllamaModelOptions(runCapture) { const output = runCapture("ollama list 2>/dev/null", { ignoreError: true }); - const parsed = parseOllamaList(output); - if (parsed.length > 0) { - return parsed; - } - return [DEFAULT_OLLAMA_MODEL]; + return parseOllamaList(output); } function getDefaultOllamaModel(runCapture) { const models = getOllamaModelOptions(runCapture); + if (models.length === 0) return DEFAULT_OLLAMA_MODEL; return models.includes(DEFAULT_OLLAMA_MODEL) ? DEFAULT_OLLAMA_MODEL : models[0]; } @@ -136,6 +133,15 @@ function getOllamaProbeCommand(model, timeoutSeconds = 120, keepAlive = "15m") { return `curl -sS --max-time ${timeoutSeconds} http://localhost:11434/api/generate -H 'Content-Type: application/json' -d ${shellQuote(payload)} 2>/dev/null`; } +function hasOllamaModels(tagsOutput) { + try { + const data = JSON.parse(tagsOutput); + return Array.isArray(data.models) && data.models.length > 0; + } catch { + return false; + } +} + function validateOllamaModel(model, runCapture) { const output = runCapture(getOllamaProbeCommand(model), { ignoreError: true }); if (!output) { @@ -171,6 +177,7 @@ module.exports = { getOllamaModelOptions, getOllamaProbeCommand, getOllamaWarmupCommand, + hasOllamaModels, parseOllamaList, validateOllamaModel, validateLocalProvider, diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index ace8d6e97..67efcace2 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -11,10 +11,12 @@ const path = require("path"); const { spawn, spawnSync } = require("child_process"); const { ROOT, SCRIPTS, run, runCapture, shellQuote } = require("./runner"); const { + DEFAULT_OLLAMA_MODEL, getDefaultOllamaModel, getLocalProviderBaseUrl, getOllamaModelOptions, getOllamaWarmupCommand, + hasOllamaModels, validateOllamaModel, validateLocalProvider, } = require("./local-inference"); @@ -211,20 +213,38 @@ async function promptCloudModel() { } async function promptOllamaModel() { - const options = getOllamaModelOptions(runCapture); - const defaultModel = getDefaultOllamaModel(runCapture); - const defaultIndex = Math.max(0, options.indexOf(defaultModel)); + const installed = getOllamaModelOptions(runCapture); + + if (installed.length === 0) { + console.log(""); + console.log(" No local Ollama models found. A model must be pulled first."); + console.log(""); + console.log(" Ollama models:"); + console.log(` 1) ${DEFAULT_OLLAMA_MODEL}`); + console.log(""); + const choice = await prompt(" Pull and use this model? [Y/n]: "); + if (choice && choice.toLowerCase() === "n") { + console.error(" Cannot continue without a model. Run 'ollama pull ' and try again."); + process.exit(1); + } + console.log(` Pulling ${DEFAULT_OLLAMA_MODEL} (this may take a while)...`); + run(`ollama pull ${shellQuote(DEFAULT_OLLAMA_MODEL)}`); + return DEFAULT_OLLAMA_MODEL; + } + + const defaultModel = installed.includes(DEFAULT_OLLAMA_MODEL) ? DEFAULT_OLLAMA_MODEL : installed[0]; + const defaultIndex = Math.max(0, installed.indexOf(defaultModel)); console.log(""); console.log(" Ollama models:"); - options.forEach((option, index) => { + installed.forEach((option, index) => { console.log(` ${index + 1}) ${option}`); }); console.log(""); const choice = await prompt(` Choose model [${defaultIndex + 1}]: `); const index = parseInt(choice || String(defaultIndex + 1), 10) - 1; - return options[index] || options[defaultIndex] || defaultModel; + return installed[index] || installed[defaultIndex] || defaultModel; } function isDockerRunning() { @@ -650,7 +670,9 @@ async function setupNim(sandboxName, gpu) { // Detect local inference options const hasOllama = !!runCapture("command -v ollama", { ignoreError: true }); - const ollamaRunning = !!runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { ignoreError: true }); + const ollamaTagsResponse = runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { ignoreError: true }); + const ollamaRunning = !!ollamaTagsResponse; + const ollamaHasModels = ollamaRunning && hasOllamaModels(ollamaTagsResponse); const vllmRunning = !!runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); const requestedProvider = isNonInteractive() ? getNonInteractiveProvider() : null; const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "cloud") : null; @@ -663,14 +685,15 @@ async function setupNim(sandboxName, gpu) { key: "cloud", label: "NVIDIA Endpoint API (build.nvidia.com)" + - (!ollamaRunning && !(EXPERIMENTAL && vllmRunning) ? " (recommended)" : ""), + (!ollamaHasModels && !(EXPERIMENTAL && vllmRunning) ? " (recommended)" : ""), }); if (hasOllama || ollamaRunning) { options.push({ key: "ollama", label: `Local Ollama (localhost:11434)${ollamaRunning ? " — running" : ""}` + - (ollamaRunning ? " (suggested)" : ""), + (ollamaRunning && !ollamaHasModels ? " (no models pulled)" : "") + + (ollamaHasModels ? " (suggested)" : ""), }); } if (EXPERIMENTAL && vllmRunning) { diff --git a/test/local-inference.test.js b/test/local-inference.test.js index 671d9c657..65dc9acfc 100644 --- a/test/local-inference.test.js +++ b/test/local-inference.test.js @@ -13,6 +13,7 @@ import { getOllamaModelOptions, getOllamaProbeCommand, getOllamaWarmupCommand, + hasOllamaModels, parseOllamaList, validateOllamaModel, validateLocalProvider, @@ -86,8 +87,8 @@ describe("local inference helpers", () => { ).toEqual(["nemotron-3-nano:30b", "qwen3:32b"]); }); - it("falls back to the default ollama model when list output is empty", () => { - expect(getOllamaModelOptions(() => "")).toEqual([DEFAULT_OLLAMA_MODEL]); + it("returns an empty array when no ollama models are installed", () => { + expect(getOllamaModelOptions(() => "")).toEqual([]); }); it("prefers the default ollama model when present", () => { @@ -137,4 +138,22 @@ describe("local inference helpers", () => { ); expect(result).toEqual({ ok: true }); }); + + it("detects models present in /api/tags response", () => { + expect(hasOllamaModels(JSON.stringify({ models: [{ name: "nemotron-3-nano:30b" }] }))).toBe(true); + }); + + it("returns false when /api/tags response has an empty models array", () => { + expect(hasOllamaModels(JSON.stringify({ models: [] }))).toBe(false); + }); + + it("returns false when /api/tags response is not valid JSON", () => { + expect(hasOllamaModels("not json")).toBe(false); + }); + + it("returns false when /api/tags response is empty", () => { + expect(hasOllamaModels("")).toBe(false); + expect(hasOllamaModels(null)).toBe(false); + expect(hasOllamaModels(undefined)).toBe(false); + }); }); diff --git a/test/onboard-selection.test.js b/test/onboard-selection.test.js index 9f252a116..9b5fde8dd 100644 --- a/test/onboard-selection.test.js +++ b/test/onboard-selection.test.js @@ -84,4 +84,79 @@ const { setupNim } = require(${onboardPath}); ).toBeTruthy(); expect(payload.lines.some((line) => line.includes("Cloud models:"))).toBeTruthy(); }); + + it("shows '(no models pulled)' and keeps cloud '(recommended)' when Ollama is running but has no models", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-nomodels-")); + const scriptPath = path.join(tmpDir, "no-models-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const registry = require(${registryPath}); + +let promptCalls = 0; +const messages = []; +const updates = []; + +credentials.prompt = async (message) => { + promptCalls += 1; + messages.push(message); + return ""; +}; +credentials.ensureApiKey = async () => {}; +runner.runCapture = (command) => { + if (command.includes("command -v ollama")) return "/usr/bin/ollama"; + if (command.includes("localhost:11434/api/tags")) return JSON.stringify({ models: [] }); + if (command.includes("ollama list")) return ""; + if (command.includes("localhost:8000/v1/models")) return ""; + return ""; +}; +registry.updateSandbox = (_name, update) => updates.push(update); + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim("no-models-test", null); + originalLog(JSON.stringify({ result, promptCalls, messages, updates, lines })); + } finally { + console.log = originalLog; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + }, + }); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).not.toBe(""); + const payload = JSON.parse(result.stdout.trim()); + expect(payload.result.provider).toBe("nvidia-nim"); + expect( + payload.lines.some((line) => line.includes("(recommended)")) + ).toBeTruthy(); + expect( + payload.lines.some((line) => line.includes("no models pulled")) + ).toBeTruthy(); + expect( + payload.lines.every((line) => !line.includes("(suggested)")) + ).toBeTruthy(); + }); });