diff --git a/docs/inference/set-up-ollama.mdx b/docs/inference/set-up-ollama.mdx index ce39ab898d1..1169c79fae8 100644 --- a/docs/inference/set-up-ollama.mdx +++ b/docs/inference/set-up-ollama.mdx @@ -180,10 +180,12 @@ Onboarding exits when a download requires confirmation and the run cannot prompt ## Understand Model Selection When `NEMOCLAW_MODEL` is unset, NemoClaw selects a starter model based on currently available memory. +A non-interactive run reports the model it auto-selects and points to `NEMOCLAW_MODEL` for onboarding a specific installed model. If a known bootstrap model does not fit, NemoClaw warns and falls back to the largest known model that does fit. Unknown or custom tags pass through to the Ollama runner for validation. Interactive onboarding filters installed registry-known tags that do not fit current GPU memory. +When `NEMOCLAW_MODEL` names one of the offered models, the interactive menu pre-selects it as the default choice. If no installed known tag fits, NemoClaw displays starter choices and warns when even the smallest tag might not fit. After a model fails validation, NemoClaw excludes it from the next installed-model menu. diff --git a/src/lib/inference/local.test.ts b/src/lib/inference/local.test.ts index 74d472c70fb..e95eccfa589 100644 --- a/src/lib/inference/local.test.ts +++ b/src/lib/inference/local.test.ts @@ -725,6 +725,23 @@ describe("local inference helpers", () => { ).toBe(QWEN3_6_OLLAMA_MODEL); }); + it("announces the auto-selected model when no model is requested", async () => { + const { resolveNonInteractiveOllamaModel } = await import("./local"); + const messages: string[] = []; + const log = (m: string) => messages.push(m); + + const result = resolveNonInteractiveOllamaModel( + null, + null, + { type: "nvidia", totalMemoryMB: 131_072, availableMemoryMB: 131_072 }, + log, + () => "", + ); + expect(result).toBe(QWEN3_6_OLLAMA_MODEL); + expect(messages.some((m) => m.includes("No Ollama model requested"))).toBe(true); + expect(messages.some((m) => m.includes("NEMOCLAW_MODEL"))).toBe(true); + }); + it("resolveNonInteractiveOllamaModel surfaces the no-fit warning when even the smallest model exceeds available memory", async () => { const { resolveNonInteractiveOllamaModel } = await import("./local"); const messages: string[] = []; diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index 3f839927005..3072ec8fb91 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -923,10 +923,18 @@ export function resolveNonInteractiveOllamaModel( } return fallback; } - if (!explicit && !anyRegistryModelFits(gpu)) { + if (explicit) { + return explicit; + } + if (!anyRegistryModelFits(gpu)) { warnNoBootstrapModelFits(gpu, log); } - return explicit || getDefaultOllamaModel(gpu, runCaptureImpl); + const autoSelected = getDefaultOllamaModel(gpu, runCaptureImpl); + log( + ` No Ollama model requested; auto-selected '${autoSelected}'. ` + + "Set NEMOCLAW_MODEL to onboard a specific installed model.", + ); + return autoSelected; } function warnNoBootstrapModelFits(gpu: GpuInfo | null, log: (message: string) => void): void { diff --git a/src/lib/inference/ollama/proxy.test.ts b/src/lib/inference/ollama/proxy.test.ts index 387dea19c0c..8b63e7ccd71 100644 --- a/src/lib/inference/ollama/proxy.test.ts +++ b/src/lib/inference/ollama/proxy.test.ts @@ -191,6 +191,67 @@ describe("promptOllamaModel installed-model fit filter", () => { expect(result).toBe("qwen3.5:9b"); expect(result).not.toBe("nemotron-3-nano:30b"); }); + + it("defaults the menu to the requested model rather than a fixed computed default", async () => { + const gpu = { type: "nvidia", totalMemoryMB: 131_072, availableMemoryMB: 131_072 }; + + const lower = loadProxyWithMocks({ + installed: ["qwen2.5:0.5b", "qwen3.6:35b"], + promptValues: [""], + }); + active = lower; + const lowerResult = await lower.proxy.promptOllamaModel(gpu, { + preferredModel: "qwen2.5:0.5b", + }); + expect(lowerResult).toBe("qwen2.5:0.5b"); + expect(lower.promptArgs.at(-1)).toContain("[1]"); + lower.restore(); + active = null; + + const higher = loadProxyWithMocks({ + installed: ["qwen2.5:0.5b", "qwen3.6:35b"], + promptValues: [""], + }); + active = higher; + const higherResult = await higher.proxy.promptOllamaModel(gpu, { + preferredModel: "qwen3.6:35b", + }); + expect(higherResult).toBe("qwen3.6:35b"); + expect(higher.promptArgs.at(-1)).toContain("[2]"); + }); + + it("keeps the computed default when the requested model is not installed", async () => { + const setup = loadProxyWithMocks({ + installed: ["qwen2.5:0.5b", "qwen3.6:35b"], + promptValues: [""], + }); + active = setup; + const result = await setup.proxy.promptOllamaModel( + { type: "nvidia", totalMemoryMB: 131_072, availableMemoryMB: 131_072 }, + { preferredModel: "not-installed:1b" }, + ); + expect(result).toBe("qwen3.6:35b"); + expect(setup.promptArgs.at(-1)).toContain("[2]"); + }); + + it("ignores a preferred model that is only present in the bootstrap fallback, never the installed menu", async () => { + // nemotron-3-nano:30b is the only installed entry but is excluded, so the + // menu falls back to bootstrap options [qwen3.5:9b, qwen3.6:35b]. The + // preference must not promote qwen3.5:9b to the default here — matching + // a preference against an uninstalled bootstrap option would make an + // unavailable model the Enter-key default and trigger an unrequested pull. + const setup = loadProxyWithMocks({ + installed: ["nemotron-3-nano:30b"], + promptValues: [""], + }); + active = setup; + const result = await setup.proxy.promptOllamaModel( + { type: "nvidia", totalMemoryMB: 131_072, availableMemoryMB: 131_072 }, + { excludeModels: new Set(["nemotron-3-nano:30b"]), preferredModel: "qwen3.5:9b" }, + ); + expect(result).toBe("qwen3.6:35b"); + expect(result).not.toBe("qwen3.5:9b"); + }); }); describe("prepareOllamaModel post-pull discovery", () => { diff --git a/src/lib/inference/ollama/proxy.ts b/src/lib/inference/ollama/proxy.ts index 3f84e6a1ecc..3b39efe281f 100644 --- a/src/lib/inference/ollama/proxy.ts +++ b/src/lib/inference/ollama/proxy.ts @@ -14,7 +14,7 @@ const { OLLAMA_PORT, OLLAMA_PROXY_PORT } = require("../../core/ports"); const { isNonInteractiveEnv }: typeof import("../../core/non-interactive") = require("../../core/non-interactive"); const { waitForPort } = require("../../core/wait"); -const { ensurePulledOllamaModel }: typeof import("./model-discovery") = +const { ensurePulledOllamaModel, ollamaModelRefsMatch }: typeof import("./model-discovery") = require("./model-discovery"); const { getDefaultOllamaModel, @@ -491,7 +491,7 @@ function probeOllamaAuthProxyHealth(): { ok: boolean; endpoint: string; detail: async function promptOllamaModel( gpu: GpuInfo | null = null, - promptOptions: { excludeModels?: ReadonlySet } = {}, + promptOptions: { excludeModels?: ReadonlySet; preferredModel?: string | null } = {}, ) { const excludeModels = promptOptions.excludeModels; const isExcluded = (tag: string): boolean => @@ -514,7 +514,13 @@ async function promptOllamaModel( const defaultModel = isExcluded(defaultModelCandidate) ? (options[0] ?? defaultModelCandidate) : defaultModelCandidate; - const defaultIndex = Math.max(0, options.indexOf(defaultModel)); + const preferred = promptOptions.preferredModel; + const preferredIndex = + usingInstalled && preferred != null && preferred !== "" + ? options.findIndex((option: string) => ollamaModelRefsMatch(option, preferred)) + : -1; + const defaultIndex = + preferredIndex >= 0 ? preferredIndex : Math.max(0, options.indexOf(defaultModel)); console.log(""); console.log(usingInstalled ? " Ollama models:" : " Ollama starter models:"); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a7d2703428f..489d5ac9670 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -154,6 +154,7 @@ const { const { OllamaProbeFailureTracker, }: typeof import("./onboard/ollama-probe-failure-tracker") = require("./onboard/ollama-probe-failure-tracker"); +const { promptOllamaModelWithPreference } = require("./onboard/ollama-model-preference"); const crypto = require("node:crypto"); const fs = require("fs"); const os = require("os"); @@ -238,7 +239,6 @@ const { persistAndProbeOllamaProxy, prepareOllamaModel, printOllamaExposureWarning, - promptOllamaModel, startOllamaAuthProxy, } = require("./inference/ollama/proxy"); const { @@ -3019,7 +3019,7 @@ async function selectAndValidateOllamaModel( } else if (isNonInteractive()) { model = localInference.resolveNonInteractiveOllamaModel(requestedModel, recoveredModel, gpu); } else { - model = await promptOllamaModel(gpu, { excludeModels: probeFailures.excludedModels() }); + model = await promptOllamaModelWithPreference(gpu, defaults, probeFailures); } if (isBackToSelection(model)) { console.log(" Returning to provider selection."); diff --git a/src/lib/onboard/ollama-model-preference.ts b/src/lib/onboard/ollama-model-preference.ts new file mode 100644 index 00000000000..caa6b4d2af0 --- /dev/null +++ b/src/lib/onboard/ollama-model-preference.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { GpuInfo } from "../inference/local"; +import { promptOllamaModel } from "../inference/ollama/proxy"; +import type { OllamaProbeFailureTracker } from "./ollama-probe-failure-tracker"; + +export function resolvePreferredOllamaModel( + requestedModel: string | null, + recoveredModel: string | null, +): string | null { + return requestedModel || (process.env.NEMOCLAW_MODEL || "").trim() || recoveredModel || null; +} + +export function promptOllamaModelWithPreference( + gpu: GpuInfo | null, + defaults: { requestedModel: string | null; recoveredModel: string | null }, + probeFailures: OllamaProbeFailureTracker, +): ReturnType { + const preferredModel = resolvePreferredOllamaModel( + defaults.requestedModel, + defaults.recoveredModel, + ); + return promptOllamaModel(gpu, { excludeModels: probeFailures.excludedModels(), preferredModel }); +}