Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions bin/lib/local-inference.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}

Expand All @@ -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) {
Expand Down Expand Up @@ -171,6 +177,7 @@ module.exports = {
getOllamaModelOptions,
getOllamaProbeCommand,
getOllamaWarmupCommand,
hasOllamaModels,
parseOllamaList,
validateOllamaModel,
validateLocalProvider,
Expand Down
39 changes: 31 additions & 8 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 <model>' 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() {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
23 changes: 21 additions & 2 deletions test/local-inference.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getOllamaModelOptions,
getOllamaProbeCommand,
getOllamaWarmupCommand,
hasOllamaModels,
parseOllamaList,
validateOllamaModel,
validateLocalProvider,
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
75 changes: 75 additions & 0 deletions test/onboard-selection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading