Skip to content
Merged
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
5 changes: 4 additions & 1 deletion docs/inference/use-local-inference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ $ nemoclaw onboard

Select **Local Ollama** from the provider list.
NemoClaw lists installed models or offers starter models if none are installed.
On hosts with at least 32 GiB of detected GPU memory, the starter list includes `qwen3.6:35b` and selects it by default.
On hosts where the larger starter models fit the currently available GPU memory, the starter list includes `qwen3.6:35b` and selects it by default.
When another GPU workload is using most of the memory at onboard time, NemoClaw downgrades the menu to the largest model that still fits.
It pulls the selected model, loads it into memory, and validates it before continuing.
If the selected model declares that it does not support tool calling, onboarding stops with guidance to choose a model whose `ollama show <model>` capabilities include `tools`.
The validation also requires structured chat-completions tool calls.
Expand Down Expand Up @@ -136,6 +137,8 @@ $ NEMOCLAW_PROVIDER=ollama \
```

If `NEMOCLAW_MODEL` is not set, NemoClaw selects a default model based on available memory.
If `NEMOCLAW_MODEL` names a known bootstrap model (for example `qwen3.6:35b`) that does not fit the host's currently available GPU memory, NemoClaw warns and falls back to the largest known model that does fit.
Unknown or custom tags (any value the bootstrap registry has not seen) are still passed through; the Ollama runner validates the choice itself.

`--yes` (or `NEMOCLAW_YES=1`) authorises the Ollama model download without an interactive confirmation prompt.
Under `--non-interactive`, `--yes` (or `NEMOCLAW_YES=1`) is required to authorise the download — onboard exits otherwise, since it cannot prompt.
Expand Down
136 changes: 125 additions & 11 deletions src/lib/inference/local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@ import os from "node:os";
import path from "node:path";

// Import from compiled dist/ for correct coverage attribution.
import { OLLAMA_MODEL_REGISTRY } from "../../../dist/lib/inference/ollama-model-registry";

// Derive the "large enough to fit every registry entry" memory threshold
// from the registry itself so adding or resizing a model in the registry
// does not require updating these tests.
const LARGE_OLLAMA_FIT_MEMORY_MB = Math.max(
...OLLAMA_MODEL_REGISTRY.map((entry) => entry.requiredMemoryMB),
);
import {
CONTAINER_REACHABILITY_IMAGE,
DEFAULT_OLLAMA_MODEL,
LARGE_OLLAMA_MIN_MEMORY_MB,
LOCAL_INFERENCE_SANDBOX_HOST_URL_ENV,
QWEN3_6_OLLAMA_MODEL,
getOllamaContainerPort,
Expand Down Expand Up @@ -554,39 +561,146 @@ describe("local inference helpers", () => {

it("falls back to bootstrap model options when no Ollama models are installed", () => {
expect(getBootstrapOllamaModelOptions(null)).toEqual(["qwen2.5:7b"]);
// Below every registry entry's required memory: small only.
expect(
getBootstrapOllamaModelOptions({
type: "nvidia",
totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB - 1,
totalMemoryMB: 10_000,
}),
).toEqual(["qwen2.5:7b"]);
// Comfortably above every registry entry's required memory: all options.
expect(
getBootstrapOllamaModelOptions({
type: "nvidia",
totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB,
totalMemoryMB: LARGE_OLLAMA_FIT_MEMORY_MB,
}),
).toEqual(["qwen2.5:7b", DEFAULT_OLLAMA_MODEL, QWEN3_6_OLLAMA_MODEL]);
expect(getDefaultOllamaModel({ type: "nvidia", totalMemoryMB: 16384 }, () => "")).toBe(
expect(getDefaultOllamaModel({ type: "nvidia", totalMemoryMB: 10_000 }, () => "")).toBe(
"qwen2.5:7b",
);
expect(
getDefaultOllamaModel(
{ type: "nvidia", totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB },
{ type: "nvidia", totalMemoryMB: LARGE_OLLAMA_FIT_MEMORY_MB },
() => "",
),
).toBe(QWEN3_6_OLLAMA_MODEL);
});

it("downgrades the bootstrap menu when currently available memory is low", () => {
// Unified-memory host (e.g. DGX Spark) with another GPU workload eating
// the system pool: 128 GiB total, ~12 GiB currently free. The 23 GiB
// qwen3.6:35b model would crash the runner mid-load, so the bootstrap
// menu must only offer the small model.
expect(
getBootstrapOllamaModelOptions({
type: "nvidia",
totalMemoryMB: 131_072,
availableMemoryMB: 12_000,
}),
).toEqual(["qwen2.5:7b"]);
expect(
getDefaultOllamaModel(
{ type: "nvidia", totalMemoryMB: 131_072, availableMemoryMB: 12_000 },
() => "",
),
).toBe("qwen2.5:7b");
});

it("filters installed-model selection by memory fit", async () => {
const { getDefaultOllamaModel: gdom } = await import("../../../dist/lib/inference/local");
// Even though nemotron-3-nano:30b is installed, it does not fit a host
// with only 12 GiB available — the selector must downgrade to a fitting
// installed model rather than blindly returning DEFAULT_OLLAMA_MODEL.
const installed = () => "qwen2.5:7b abc 4 GB now\nnemotron-3-nano:30b def 19 GB now";
expect(
gdom({ type: "nvidia", totalMemoryMB: 131_072, availableMemoryMB: 12_000 }, installed),
).toBe("qwen2.5:7b");
});

it("resolveNonInteractiveOllamaModel respects unknown tags and downgrades known oversize ones", async () => {
const { resolveNonInteractiveOllamaModel } = await import(
"../../../dist/lib/inference/local"
);
const messages: string[] = [];
const log = (m: string) => messages.push(m);

// Known model that does not fit → fallback + warning.
expect(
resolveNonInteractiveOllamaModel(
"qwen3.6:35b",
null,
{ type: "nvidia", totalMemoryMB: 131_072, availableMemoryMB: 12_000 },
log,
),
).toBe("qwen2.5:7b");
expect(messages.some((m) => m.includes("qwen3.6:35b"))).toBe(true);

// Unknown tag → respected as-is.
messages.length = 0;
expect(
resolveNonInteractiveOllamaModel(
"some-custom:model",
null,
{ type: "nvidia", totalMemoryMB: 131_072, availableMemoryMB: 12_000 },
log,
),
).toBe("some-custom:model");
expect(messages).toEqual([]);

// No explicit choice → falls through to getDefaultOllamaModel.
expect(
resolveNonInteractiveOllamaModel(
null,
null,
{ type: "nvidia", totalMemoryMB: 131_072, availableMemoryMB: 131_072 },
log,
),
).toBe(QWEN3_6_OLLAMA_MODEL);
});

it("resolveNonInteractiveOllamaModel surfaces the no-fit warning when even the smallest model exceeds available memory", async () => {
const { resolveNonInteractiveOllamaModel } = await import(
"../../../dist/lib/inference/local"
);
const messages: string[] = [];
const log = (m: string) => messages.push(m);

// Explicit oversize tag AND host has less than the smallest registry
// entry needs. The explicit-downgrade warning fires *and* the no-fit
// warning fires so the user sees both signals.
const result = resolveNonInteractiveOllamaModel(
"qwen3.6:35b",
null,
{ type: "nvidia", totalMemoryMB: 16_384, availableMemoryMB: 4_000 },
log,
);
expect(result).toBe("qwen2.5:7b");
expect(messages.some((m) => m.includes("qwen3.6:35b"))).toBe(true);
expect(messages.some((m) => m.includes("No known Ollama bootstrap model fits"))).toBe(true);

// No explicit choice + nothing fits: only the no-fit warning fires.
messages.length = 0;
expect(
resolveNonInteractiveOllamaModel(
null,
null,
{ type: "nvidia", totalMemoryMB: 16_384, availableMemoryMB: 4_000 },
log,
),
).toBe("qwen2.5:7b");
expect(messages.some((m) => m.includes("No known Ollama bootstrap model fits"))).toBe(true);
});

it("offers the large Ollama model on Apple Silicon with sufficient unified memory", () => {
expect(
getBootstrapOllamaModelOptions({
type: "apple",
totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB,
totalMemoryMB: LARGE_OLLAMA_FIT_MEMORY_MB,
}),
).toEqual(["qwen2.5:7b", DEFAULT_OLLAMA_MODEL, QWEN3_6_OLLAMA_MODEL]);
expect(
getDefaultOllamaModel(
{ type: "apple", totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB },
{ type: "apple", totalMemoryMB: LARGE_OLLAMA_FIT_MEMORY_MB },
() => "",
),
).toBe(QWEN3_6_OLLAMA_MODEL);
Expand All @@ -599,20 +713,20 @@ describe("local inference helpers", () => {
// where totalMemoryMB is set but the device type is "generic" or
// unspecified.
expect(
getBootstrapOllamaModelOptions({ totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB }),
getBootstrapOllamaModelOptions({ totalMemoryMB: LARGE_OLLAMA_FIT_MEMORY_MB }),
).toEqual(["qwen2.5:7b"]);
expect(
getDefaultOllamaModel({ totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB }, () => ""),
getDefaultOllamaModel({ totalMemoryMB: LARGE_OLLAMA_FIT_MEMORY_MB }, () => ""),
).toBe("qwen2.5:7b");
expect(
getBootstrapOllamaModelOptions({
type: "generic",
totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB * 4,
totalMemoryMB: LARGE_OLLAMA_FIT_MEMORY_MB * 4,
}),
).toEqual(["qwen2.5:7b"]);
expect(
getDefaultOllamaModel(
{ type: "generic", totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB * 4 },
{ type: "generic", totalMemoryMB: LARGE_OLLAMA_FIT_MEMORY_MB * 4 },
() => "",
),
).toBe("qwen2.5:7b");
Expand Down
127 changes: 101 additions & 26 deletions src/lib/inference/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ const { shellQuote, runCapture, runCaptureEx } = require("../runner");

import { OLLAMA_PORT, OLLAMA_PROXY_PORT, VLLM_PORT } from "../core/ports";
import { sleepSeconds } from "../core/wait";
import {
anyRegistryModelFits,
effectiveGpuMemoryMB,
fittableOllamaModelTags,
largestFittableOllamaModelTag,
modelFitsAvailableMemory,
OLLAMA_MODEL_REGISTRY,
SMALLEST_OLLAMA_MODEL_TAG,
} from "./ollama-model-registry";

const { containerCanReachHostLoopback, inferContainerRuntime, isWsl } = require("../platform");
const { dockerInfo } = require("../adapters/docker/info");
Expand Down Expand Up @@ -48,10 +57,24 @@ export function resetOllamaContainerPortCache(): void {
export const HOST_GATEWAY_URL = "http://host.openshell.internal";
export const LOCAL_INFERENCE_SANDBOX_HOST_URL_ENV = "NEMOCLAW_LOCAL_INFERENCE_SANDBOX_HOST_URL";
export const CONTAINER_REACHABILITY_IMAGE = "curlimages/curl:8.10.1";
export const DEFAULT_OLLAMA_MODEL = "nemotron-3-nano:30b";
export const QWEN3_6_OLLAMA_MODEL = "qwen3.6:35b";
export const SMALL_OLLAMA_MODEL = "qwen2.5:7b";
export const LARGE_OLLAMA_MIN_MEMORY_MB = 32768;
// These tags are convenience aliases for callers that want to refer to a
// specific bootstrap model by role rather than by string. The canonical
// metadata (memory requirements, download sizes) lives in
// `ollama-model-registry.ts`; the assertion below makes module load fail
// loudly if a registry edit drops a tag a caller still references by
// name, so the two stay in sync.
function assertRegistryTag(tag: string): string {
if (!OLLAMA_MODEL_REGISTRY.some((entry) => entry.tag === tag)) {
throw new Error(
`Tag '${tag}' is not in OLLAMA_MODEL_REGISTRY. Update the registry first.`,
);
}
return tag;
}

export const SMALL_OLLAMA_MODEL = SMALLEST_OLLAMA_MODEL_TAG;
export const DEFAULT_OLLAMA_MODEL = assertRegistryTag("nemotron-3-nano:30b");
export const QWEN3_6_OLLAMA_MODEL = assertRegistryTag("qwen3.6:35b");

export type RunCaptureFn = (cmd: string | string[], opts?: { ignoreError?: boolean }) => string;

Expand Down Expand Up @@ -125,6 +148,14 @@ export interface GpuInfo {
// does not get sized as if it were confirmed NVIDIA / Apple Silicon
// (#3510).
type?: string;
// Currently free GPU memory at probe time. Populated by `detectGpu` from
// `nvidia-smi memory.free`, `MemAvailable` on unified-memory hosts, or
// `vm_stat` reclaimable pages on macOS. Used by the bootstrap-model
// selector so an idle 128 GiB Spark and a 128 GiB Spark with another
// GPU workload eating 116 GiB do not get the same model recommendation.
// Absent => the selector falls back to `totalMemoryMB`, preserving the
// previous behaviour.
availableMemoryMB?: number;
}

export interface ValidationResult {
Expand Down Expand Up @@ -734,26 +765,59 @@ export function getOllamaModelOptions(runCaptureImpl?: RunCaptureFn): string[] {
return parseOllamaList(listOutput);
}

function isLargeOllamaCapableGpu(gpu: GpuInfo | null): boolean {
// Only confirmed-NVIDIA and Apple-Silicon devices get the large-model
// default. Other detection outcomes (null, missing `type`, or a partial
// result that fell through the NVIDIA path with type set to something
// else) fall back to the smaller model so we never download a 22 GB
// model onto a host whose acceleration is unconfirmed (#3510).
return (
!!gpu &&
(gpu.type === "nvidia" || gpu.type === "apple") &&
gpu.totalMemoryMB >= LARGE_OLLAMA_MIN_MEMORY_MB
);
export function getBootstrapOllamaModelOptions(gpu: GpuInfo | null): string[] {
// Delegate to the registry so the menu reflects what the host can
// actually load right now. Only confirmed-NVIDIA and Apple-Silicon
// devices get larger options; ambiguous device types fall back to the
// smallest model so a partial GPU detection cannot promote a host to a
// 22 GB model.
return fittableOllamaModelTags(gpu);
}

export function getBootstrapOllamaModelOptions(gpu: GpuInfo | null): string[] {
const options = [SMALL_OLLAMA_MODEL];
if (isLargeOllamaCapableGpu(gpu)) {
options.push(DEFAULT_OLLAMA_MODEL);
options.push(QWEN3_6_OLLAMA_MODEL);
/**
* Resolve the non-interactive Ollama model selection. When the caller has
* passed an explicit `NEMOCLAW_MODEL` / recovered-session model that the
* registry knows is too big for the host's currently available memory,
* log a warning and fall back to the largest fittable registry entry so
* onboarding does not pull a model the runner will crash on. Unknown
* model tags (user-supplied values the registry has never seen) are
* respected as-is — the runner's own validation surfaces the failure if
* the choice was wrong.
*/
export function resolveNonInteractiveOllamaModel(
requestedModel: string | null,
recoveredModel: string | null,
gpu: GpuInfo | null,
log: (message: string) => void = (m) => console.warn(m),
): string {
const explicit = requestedModel || recoveredModel;
if (explicit && !modelFitsAvailableMemory(explicit, gpu)) {
const fallback = largestFittableOllamaModelTag(gpu);
log(
` ! Requested Ollama model '${explicit}' is unlikely to fit currently available GPU memory; ` +
`falling back to '${fallback}'. Override by freeing memory and re-running, or unset NEMOCLAW_MODEL.`,
);
if (!anyRegistryModelFits(gpu)) {
warnNoBootstrapModelFits(gpu, log);
}
return fallback;
}
return options;
if (!explicit && !anyRegistryModelFits(gpu)) {
warnNoBootstrapModelFits(gpu, log);
}
return explicit || getDefaultOllamaModel(gpu);
}

function warnNoBootstrapModelFits(
gpu: GpuInfo | null,
log: (message: string) => void,
): void {
const memory = effectiveGpuMemoryMB(gpu);
log(
` ! No known Ollama bootstrap model fits the host's currently available GPU memory` +
`${memory ? ` (~${memory} MB free)` : ""}. Proceeding with the smallest known model; ` +
"the runner may still reject the load — free memory and re-run if it does.",
);
}

export function getDefaultOllamaModel(
Expand All @@ -762,12 +826,23 @@ export function getDefaultOllamaModel(
): string {
const models = getOllamaModelOptions(runCaptureImpl);
if (models.length === 0) {
if (isLargeOllamaCapableGpu(gpu)) {
return QWEN3_6_OLLAMA_MODEL;
}
return SMALL_OLLAMA_MODEL;
// No installed models — pick the largest registry entry that fits the
// host's currently available memory.
return largestFittableOllamaModelTag(gpu);
}
// Filter the installed list to entries we either don't know (unmanaged
// user pulls — let the runner validate) or that fit the registry's
// memory requirement at probe time. If everything has been filtered out,
// fall back to the largest registry entry that fits so the wizard never
// suggests a model the host can't load.
const fittingInstalled = models.filter((tag) => modelFitsAvailableMemory(tag, gpu));
const pool = fittingInstalled.length > 0 ? fittingInstalled : null;
if (pool === null) {
return largestFittableOllamaModelTag(gpu);
}
return models.includes(DEFAULT_OLLAMA_MODEL) ? DEFAULT_OLLAMA_MODEL : models[0];
return pool.includes(DEFAULT_OLLAMA_MODEL) && modelFitsAvailableMemory(DEFAULT_OLLAMA_MODEL, gpu)
? DEFAULT_OLLAMA_MODEL
: pool[0];
}

export function getOllamaWarmupCommand(model: string, keepAlive = "15m"): string[] {
Expand Down
Loading
Loading