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
2 changes: 2 additions & 0 deletions docs/inference/set-up-ollama.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 17 additions & 0 deletions src/lib/inference/local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
12 changes: 10 additions & 2 deletions src/lib/inference/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions src/lib/inference/ollama/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

describe("prepareOllamaModel post-pull discovery", () => {
Expand Down
12 changes: 9 additions & 3 deletions src/lib/inference/ollama/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -491,7 +491,7 @@ function probeOllamaAuthProxyHealth(): { ok: boolean; endpoint: string; detail:

async function promptOllamaModel(
gpu: GpuInfo | null = null,
promptOptions: { excludeModels?: ReadonlySet<string> } = {},
promptOptions: { excludeModels?: ReadonlySet<string>; preferredModel?: string | null } = {},
) {
const excludeModels = promptOptions.excludeModels;
const isExcluded = (tag: string): boolean =>
Expand All @@ -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:");
Expand Down
4 changes: 2 additions & 2 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -238,7 +239,6 @@ const {
persistAndProbeOllamaProxy,
prepareOllamaModel,
printOllamaExposureWarning,
promptOllamaModel,
startOllamaAuthProxy,
} = require("./inference/ollama/proxy");
const {
Expand Down Expand Up @@ -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.");
Expand Down
25 changes: 25 additions & 0 deletions src/lib/onboard/ollama-model-preference.ts
Original file line number Diff line number Diff line change
@@ -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<typeof promptOllamaModel> {
const preferredModel = resolvePreferredOllamaModel(
defaults.requestedModel,
defaults.recoveredModel,
);
return promptOllamaModel(gpu, { excludeModels: probeFailures.excludedModels(), preferredModel });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading