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
2 changes: 1 addition & 1 deletion src/lib/inference/local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -960,7 +960,7 @@ describe("local inference helpers", () => {
expect(commands[1]).toMatch(/--max-time.*300|300.*--max-time/);
});

it("does not retry on non-Spark hosts when first probe returns empty", () => {
it("does not retry on any host when probe fails fast (connection refused, not a timeout)", () => {
let callCount = 0;
const captureEx = () => { callCount++; return { stdout: "", exitCode: 7, timedOut: false }; };
const result = validateOllamaModel("nemotron-3-nano:30b", () => "", () => false, captureEx);
Expand Down
19 changes: 15 additions & 4 deletions src/lib/inference/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,14 @@ export interface GpuInfo {
// Absent => the selector falls back to `totalMemoryMB`, preserving the
// previous behaviour.
availableMemoryMB?: number;
/**
* `true` for integrated/iGPU class devices whose token-generation throughput
* is too low to clear agent-loop timeouts on 30B-class models, even when
* advertised memory ostensibly fits. Populated for Jetson (Tegra/Thor/Orin)
* platforms. Drives the `computeIntensive` exclusion in the bootstrap-model
* selector so compute-constrained hosts are not steered onto 30B+ tags.
*/
computeConstrained?: boolean;
}

export interface ValidationResult {
Expand Down Expand Up @@ -965,10 +973,13 @@ export function validateOllamaModel(
const probeCmd = getOllamaProbeCommand(model);
const probeResult = captureEx(probeCmd);
let output = probeResult.stdout;
// On DGX Spark (128 GB unified memory), loading a large model from disk can take >2 min.
// Only retry with a 300 s timeout when the initial probe genuinely timed out — fast
// failures (connection refused, Ollama not running) surface immediately. (#3251)
if (sparkHost && probeResult.timedOut) {
// Cold-loading a large model from disk can routinely exceed the default 120 s
// probe window — on DGX Spark unified-memory hosts (#3251) and also on
// tight-VRAM dGPU hosts (e.g. NVIDIA L4 23 GB) where the runner spills GPU→CPU
// during warm-up. Retry once with a 300 s budget whenever the initial probe
// genuinely timed out. Fast failures (connection refused, Ollama not running)
// keep `timedOut === false` and surface immediately.
if (probeResult.timedOut) {
const retryResult = captureEx(getOllamaProbeCommand(model, 300));
output = retryResult.stdout;
}
Expand Down
9 changes: 9 additions & 0 deletions src/lib/inference/nim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ export interface GpuDetection {
unifiedMemory?: boolean;
spark?: boolean;
platform?: NvidiaPlatform;
// `true` for integrated/iGPU class NVIDIA platforms (Jetson Tegra/Thor/Orin)
// whose token-generation throughput on 30B+ class Ollama models cannot clear
// agent-loop timeouts even when advertised memory ostensibly fits. Mirrored
// onto `GpuInfo.computeConstrained` so the Ollama bootstrap-model selector
// skips `computeIntensive` registry entries on these hosts.
computeConstrained?: boolean;
// Set when a denylisted `JMJWOA-Generic-*` placeholder name was accepted only
// because a bounded Docker `--gpus` CUDA proof passed (Windows-ARM N1X + WSL2
// + Docker Desktop, #4565). Diagnostic marker that this detection cleared a
Expand Down Expand Up @@ -465,6 +471,7 @@ export function detectGpu(deps: DetectGpuDeps = {}): GpuDetection | null {
nimCapable: canRunNimWithMemory(totalMemoryMB),
platform,
spark: platform === "spark",
...(platform === "jetson" ? { computeConstrained: true } : {}),
...(wslDockerDesktopGpuProofPassed ? { wslDockerDesktopGpuProofPassed: true } : {}),
};
}
Expand Down Expand Up @@ -553,6 +560,7 @@ export function detectGpu(deps: DetectGpuDeps = {}): GpuDetection | null {
nimCapable: canRunNimWithMemory(totalMemoryMB),
unifiedMemory: true,
spark: platform === "spark",
...(platform === "jetson" ? { computeConstrained: true } : {}),
platform,
};
}
Expand All @@ -577,6 +585,7 @@ export function detectGpu(deps: DetectGpuDeps = {}): GpuDetection | null {
nimCapable: canRunNimWithMemory(totalMemoryMB),
unifiedMemory: true,
spark: false,
computeConstrained: tegraGpu.platform === "jetson",
platform: tegraGpu.platform,
};
}
Expand Down
62 changes: 62 additions & 0 deletions src/lib/inference/ollama-model-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,65 @@ describe("OLLAMA_DOWNLOAD_SIZE_FALLBACK_BYTES", () => {
).toEqual([SMALLEST_OLLAMA_MODEL_TAG]);
});
});

describe("L4-class dGPU bootstrap fit (23 GB VRAM)", () => {
// NVIDIA L4 reports ~23034 MiB. The 30B-class entry's `requiredMemoryMB`
// budget must leave enough headroom for KV cache + activations that L4
// is excluded from the fittable list — otherwise the wizard offers a
// model the runner spills GPU→CPU on, with cold-load timing past the
// probe window and dead-looping the model selection menu.
const l4Gpu = { type: "nvidia", totalMemoryMB: 23_034, availableMemoryMB: 21_800 };

it("excludes the 30B-class compute-intensive entry on L4", () => {
const tags = fittableOllamaModelTags(l4Gpu);
expect(tags).toContain(SMALLEST_OLLAMA_MODEL_TAG);
expect(tags).not.toContain("nemotron-3-nano:30b");
expect(tags).not.toContain("qwen3.6:35b");
});

it("returns the smallest tag as the largest-fittable default on L4", () => {
expect(largestFittableOllamaModelTag(l4Gpu)).toBe(SMALLEST_OLLAMA_MODEL_TAG);
});

it("rejects modelFitsAvailableMemory for the 30B-class entry on L4", () => {
expect(modelFitsAvailableMemory("nemotron-3-nano:30b", l4Gpu)).toBe(false);
expect(modelFitsAvailableMemory("qwen3.5:9b", l4Gpu)).toBe(true);
});
});

describe("compute-constrained iGPU filter", () => {
// Jetson-class integrated GPUs advertise unified memory that easily covers
// a 30B-class model's `requiredMemoryMB`, but token-generation throughput
// is too low to clear agent-loop timeouts. `computeConstrained` excludes
// `computeIntensive` registry entries regardless of available memory.
const jetsonGpu = {
type: "nvidia",
totalMemoryMB: 65_536,
availableMemoryMB: 60_000,
computeConstrained: true,
};

it("drops compute-intensive entries even when memory ostensibly fits", () => {
const tags = fittableOllamaModelTags(jetsonGpu);
expect(tags).toEqual([SMALLEST_OLLAMA_MODEL_TAG]);
});

it("modelFitsAvailableMemory returns false for compute-intensive tags on iGPU", () => {
expect(modelFitsAvailableMemory("nemotron-3-nano:30b", jetsonGpu)).toBe(false);
expect(modelFitsAvailableMemory("qwen3.6:35b", jetsonGpu)).toBe(false);
});

it("does not gate the smallest entry on iGPU", () => {
expect(modelFitsAvailableMemory("qwen3.5:9b", jetsonGpu)).toBe(true);
});

it("dGPU hosts with the same memory are not gated", () => {
const dGpu = {
type: "nvidia",
totalMemoryMB: 65_536,
availableMemoryMB: 60_000,
};
expect(modelFitsAvailableMemory("nemotron-3-nano:30b", dGpu)).toBe(true);
expect(fittableOllamaModelTags(dGpu)).toContain("nemotron-3-nano:30b");
});
});
35 changes: 31 additions & 4 deletions src/lib/inference/ollama-model-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,31 @@ export interface OllamaModelEntry {
tag: string;
requiredMemoryMB: number;
downloadSizeBytes: number;
/**
* `true` for entries whose token-generation throughput on integrated GPUs
* (Jetson, Windows-on-ARM iGPU) is too low to be useful under agent-loop
* timeouts even when memory ostensibly fits. Compute-constrained hosts
* skip these entries during bootstrap-model selection regardless of the
* `requiredMemoryMB` headroom check.
*/
computeIntensive?: boolean;
}

// Largest first. The selector walks this list, filters by available memory,
// and reverses the result so menus render smallest-first.
export const OLLAMA_MODEL_REGISTRY: readonly OllamaModelEntry[] = [
{ tag: "qwen3.6:35b", requiredMemoryMB: 26_000, downloadSizeBytes: 24_000_000_000 },
{ tag: "nemotron-3-nano:30b", requiredMemoryMB: 22_000, downloadSizeBytes: 19_000_000_000 },
{
tag: "qwen3.6:35b",
requiredMemoryMB: 30_000,
downloadSizeBytes: 24_000_000_000,
computeIntensive: true,
},
{
tag: "nemotron-3-nano:30b",
requiredMemoryMB: 26_000,
downloadSizeBytes: 19_000_000_000,
computeIntensive: true,
},
{ tag: "qwen3.5:9b", requiredMemoryMB: 12_000, downloadSizeBytes: 6_600_000_000 },
];

Expand Down Expand Up @@ -76,6 +94,7 @@ export function effectiveGpuMemoryMB(gpu: GpuInfo | null): number | null {
export function modelFitsAvailableMemory(tag: string, gpu: GpuInfo | null): boolean {
const entry = findOllamaModelEntry(tag);
if (!entry) return true;
if (entry.computeIntensive && gpu?.computeConstrained === true) return false;
const memory = effectiveGpuMemoryMB(gpu);
if (memory == null) return true;
return entry.requiredMemoryMB <= memory;
Expand All @@ -101,8 +120,12 @@ export function fittableOllamaModelTags(gpu: GpuInfo | null): string[] {
}
const memory = effectiveGpuMemoryMB(gpu);
if (memory == null) return fallback;
const computeConstrained = gpu.computeConstrained === true;
const fitting = OLLAMA_MODEL_REGISTRY.filter(
(entry) => entry.requiredMemoryMB <= memory && entry.tag !== SMALLEST_OLLAMA_MODEL_TAG,
(entry) =>
entry.requiredMemoryMB <= memory &&
entry.tag !== SMALLEST_OLLAMA_MODEL_TAG &&
!(computeConstrained && entry.computeIntensive),
);
if (fitting.length === 0) return fallback;
return [SMALLEST_OLLAMA_MODEL_TAG, ...fitting.map((entry) => entry.tag).reverse()];
Expand All @@ -120,7 +143,11 @@ export function anyRegistryModelFits(gpu: GpuInfo | null): boolean {
if (!gpu || (gpu.type !== "nvidia" && gpu.type !== "apple")) return true;
const memory = effectiveGpuMemoryMB(gpu);
if (memory == null) return true;
return OLLAMA_MODEL_REGISTRY.some((entry) => entry.requiredMemoryMB <= memory);
const computeConstrained = gpu.computeConstrained === true;
return OLLAMA_MODEL_REGISTRY.some(
(entry) =>
entry.requiredMemoryMB <= memory && !(computeConstrained && entry.computeIntensive),
);
}

/**
Expand Down
25 changes: 25 additions & 0 deletions src/lib/inference/ollama-probe-timeout.test.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 { describe, expect, it } from "vitest";

import { validateOllamaModel } from "../../../dist/lib/inference/local";

describe("Ollama probe timeout retry", () => {
it("retries with extended timeout on non-Spark hosts when first probe times out", () => {
const commands: string[] = [];
let captureExCallCount = 0;
const captureEx = (cmd: string[]) => {
captureExCallCount++;
commands.push(cmd.join(" "));
if (captureExCallCount === 1) return { stdout: "", exitCode: 28, timedOut: true };
return { stdout: JSON.stringify({ response: "Hi" }), exitCode: 0, timedOut: false };
};

const result = validateOllamaModel("nemotron-3-nano:30b", () => "", () => false, captureEx);

expect(result.ok).toBe(true);
expect(captureExCallCount).toBe(2);
expect(commands[1]).toMatch(/--max-time.*300|300.*--max-time/);
});
});
41 changes: 41 additions & 0 deletions src/lib/inference/ollama-runtime-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";

import {
applyOllamaRuntimeContextWindow,
MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
parseOllamaRuntimeContextLength,
probeOllamaRuntimeModelStatus,
resetOllamaRuntimeContextWindowAutoState,
Expand Down Expand Up @@ -106,6 +107,46 @@ describe("Ollama runtime context helpers", () => {
).toBeNull();
});

it("raises the auto-adopted context window to the agent floor when the daemon reports below it", () => {
const env: NodeJS.ProcessEnv = {};
const messages: string[] = [];
const options = {
env,
logger: {
log: (message: string) => messages.push(message),
warn: (message: string) => messages.push(message),
},
runCaptureImpl: () =>
JSON.stringify({
models: [{ name: "llama3.2:3b", context_length: 4096, processor: "100% GPU" }],
}),
};

applyOllamaRuntimeContextWindow("llama3.2:3b", getOllamaHost, options);
expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe(String(MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW));
expect(messages.some((m) => m.includes("Raising Ollama runtime context window"))).toBe(true);
});

it("preserves a daemon-reported context window above the agent floor", () => {
const env: NodeJS.ProcessEnv = {};
const messages: string[] = [];
const options = {
env,
logger: {
log: (message: string) => messages.push(message),
warn: (message: string) => messages.push(message),
},
runCaptureImpl: () =>
JSON.stringify({
models: [{ name: "qwen3.5:9b", context_length: 32_768, processor: "100% GPU" }],
}),
};

applyOllamaRuntimeContextWindow("qwen3.5:9b", getOllamaHost, options);
expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("32768");
expect(messages.some((m) => m.includes("Raising Ollama runtime context window"))).toBe(false);
});

it("applies and clears only auto-detected context window state", () => {
const env: NodeJS.ProcessEnv = {};
const messages: string[] = [];
Expand Down
22 changes: 20 additions & 2 deletions src/lib/inference/ollama-runtime-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export interface ApplyOllamaRuntimeContextWindowOptions {
// context windows while still rejecting obviously broken daemon responses.
export const MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW = 4_194_304;

// Floor for auto-adopted runtime context windows. Ollama's stock daemon serves
// `num_ctx=4096` until OLLAMA_CONTEXT_LENGTH is set host-side, which cannot fit
// an agent base prompt + tool catalogue (~7.4 k tokens) plus a single user turn.
// When the probed runtime length is below this floor and the user has not set
// an explicit override, NemoClaw raises NEMOCLAW_CONTEXT_WINDOW to the floor so
// downstream prompt budgeting reflects a workable window.
export const MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW = 16_384;

function normalizeOllamaModelName(value: unknown): string {
return String(value || "").trim();
}
Expand Down Expand Up @@ -205,10 +213,20 @@ export function applyOllamaRuntimeContextWindow(
logger.warn(` ⚠ ${runtimeStatus.contextLengthWarning}`);
}
if (runtimeStatus.loaded && runtimeStatus.contextLength) {
const value = String(runtimeStatus.contextLength);
const detected = runtimeStatus.contextLength;
const adopted = Math.max(detected, MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW);
const value = String(adopted);
env.NEMOCLAW_CONTEXT_WINDOW = value;
autoDetectedOllamaContextWindow = value;
logger.log(` ✓ Using Ollama runtime context length: ${value} tokens`);
if (adopted > detected) {
logger.log(
` ✓ Raising Ollama runtime context window to ${adopted} tokens ` +
`(daemon reported ${detected}, below the ${MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW}-token agent floor). ` +
`Set OLLAMA_CONTEXT_LENGTH host-side to raise the daemon default and silence this autoset.`,
);
} else {
logger.log(` ✓ Using Ollama runtime context length: ${value} tokens`);
}
return;
}

Expand Down
42 changes: 42 additions & 0 deletions src/lib/inference/ollama/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,46 @@ describe("promptOllamaModel installed-model fit filter", () => {
});
expect(result).toBe("my-custom:model");
});

it("drops excludeModels entries from the installed-fitting menu so a repeat probe-fail does not loop", async () => {
// Caller (selectAndValidateOllamaModel) records `nemotron-3-nano:30b` as a
// probe-fail and excludes it. Without this filter, pressing Enter on the
// installed-fitting list would re-select the broken model and dead-loop.
const setup = loadProxyWithMocks({
installed: ["nemotron-3-nano:30b", "qwen3.5:9b"],
promptValues: [""],
});
active = setup;
const result = await setup.proxy.promptOllamaModel(
{
type: "nvidia",
totalMemoryMB: 131_072,
availableMemoryMB: 131_072,
},
{ excludeModels: new Set(["nemotron-3-nano:30b"]) },
);
expect(result).toBe("qwen3.5:9b");
});

it("falls back to bootstrap options and never re-offers excluded entries", async () => {
const setup = loadProxyWithMocks({
installed: ["nemotron-3-nano:30b"],
// Pick the first menu entry explicitly. With nemotron-3-nano:30b
// excluded, the bootstrap fall-back menu lists [qwen3.5:9b, qwen3.6:35b]
// smallest-first; option 1 must resolve to qwen3.5:9b, never the
// excluded tag.
promptValues: ["1"],
});
active = setup;
const result = await setup.proxy.promptOllamaModel(
{
type: "nvidia",
totalMemoryMB: 131_072,
availableMemoryMB: 131_072,
},
{ excludeModels: new Set(["nemotron-3-nano:30b"]) },
);
expect(result).toBe("qwen3.5:9b");
expect(result).not.toBe("nemotron-3-nano:30b");
});
});
Loading