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
98 changes: 98 additions & 0 deletions src/lib/inference/ollama/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";

const require = createRequire(import.meta.url);
Expand Down Expand Up @@ -34,6 +36,7 @@ function loadProxyWithMocks(setup: MockSetup): {
const originalPrompt = creds.prompt;
const originalProbeOllamaModelCapabilities = local.probeOllamaModelCapabilities;
const originalRun = runner.run;
const originalRunCapture = runner.runCapture;
const originalValidateOllamaModel = local.validateOllamaModel;
const spawnSync =
setup.pullStatus === undefined
Expand Down Expand Up @@ -77,6 +80,9 @@ function loadProxyWithMocks(setup: MockSetup): {
runCalls.push({ command, options });
return { status: 0 };
};
// pullOllamaModel asks whether a local `ollama` binary exists before choosing
// the CLI or the HTTP pull path (#7472). These cases exercise the CLI path.
runner.runCapture = () => "/usr/bin/ollama";

delete require.cache[PROXY_DIST];
const proxy = require(PROXY_DIST);
Expand All @@ -93,6 +99,7 @@ function loadProxyWithMocks(setup: MockSetup): {
creds.prompt = originalPrompt;
local.probeOllamaModelCapabilities = originalProbeOllamaModelCapabilities;
runner.run = originalRun;
runner.runCapture = originalRunCapture;
local.validateOllamaModel = originalValidateOllamaModel;
spawnSync?.mockRestore();
},
Expand Down Expand Up @@ -363,3 +370,94 @@ describe("prepareOllamaModel post-pull discovery", () => {
expect(sleeps).toEqual([250, 500, 1_000, 2_000, 2_000, 2_000, 2_000]);
});
});

describe("pullOllamaModel CLI-vs-HTTP dispatch", () => {
function loadProxyForDispatch(setup: { host: string; hasLocalCli: boolean }) {
const local = require(LOCAL_DIST);
const runner = require(RUNNER_DIST);
const childProcess = require(CHILD_PROCESS_DIST) as typeof import("node:child_process");
const originalRunCapture = runner.runCapture;
const cliCommands: string[][] = [];
const httpCommands: string[][] = [];

runner.runCapture = () => (setup.hasLocalCli ? "/usr/bin/ollama" : "");

const spawnSync = vi
.spyOn(childProcess, "spawnSync")
.mockImplementation((file: unknown, args: unknown) => {
cliCommands.push([String(file), ...(((args as string[]) ?? []) as string[]).map(String)]);
return { status: 0, signal: null, output: [], pid: 1, stdout: "", stderr: "" } as never;
});
const spawn = vi.spyOn(childProcess, "spawn").mockImplementation((file: unknown, args) => {
httpCommands.push([String(file), ...(((args as string[]) ?? []) as string[]).map(String)]);
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough;
stderr: PassThrough;
};
child.stdout = new PassThrough();
child.stderr = new PassThrough();
process.nextTick(() => {
child.stdout.end('{"status":"success"}\n', () => {
setImmediate(() => child.emit("close", 0));
});
});
return child as never;
});

local.setResolvedOllamaHost(setup.host);
delete require.cache[PROXY_DIST];
const proxy = require(PROXY_DIST) as typeof import("./proxy");
return {
proxy,
cliCommands,
httpCommands,
restore() {
delete require.cache[PROXY_DIST];
runner.runCapture = originalRunCapture;
spawnSync.mockRestore();
spawn.mockRestore();
local.setResolvedOllamaHost(null);
},
};
}

let active: ReturnType<typeof loadProxyForDispatch> | null = null;

afterEach(() => {
active?.restore();
active = null;
vi.restoreAllMocks();
});

it("pulls over HTTP when the daemon resolves on the Windows host", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
active = loadProxyForDispatch({ host: "host.docker.internal", hasLocalCli: true });

await active.proxy.pullOllamaModel("qwen3.5:9b");

expect(active.httpCommands.map((command) => command[0])).toContain("curl");
expect(active.cliCommands.map((command) => command[0])).not.toContain("bash");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("pulls over HTTP when a loopback daemon has no local ollama binary (#7472)", async () => {
// WSL mirrored networking: the Windows daemon answers on 127.0.0.1, so the
// resolved host reads local while WSL still has no `ollama` to shell out to.
vi.spyOn(console, "log").mockImplementation(() => {});
active = loadProxyForDispatch({ host: "127.0.0.1", hasLocalCli: false });

await active.proxy.pullOllamaModel("qwen3.5:9b");

expect(active.httpCommands.map((command) => command[0])).toContain("curl");
expect(active.cliCommands.map((command) => command[0])).not.toContain("bash");
});

it("keeps the CLI pull when a local ollama binary is installed", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
active = loadProxyForDispatch({ host: "127.0.0.1", hasLocalCli: true });

await active.proxy.pullOllamaModel("qwen3.5:9b");

expect(active.cliCommands.map((command) => command[0])).toContain("bash");
expect(active.httpCommands.map((command) => command[0])).not.toContain("curl");
});
});
12 changes: 10 additions & 2 deletions src/lib/inference/ollama/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,9 +859,17 @@ function pullOllamaModelViaHttp(model: string): Promise<boolean> {
});
}

// Dispatch to HTTP pull when Ollama was resolved on the Windows host.
function hasLocalOllamaCli(): boolean {
return !!runCapture(["sh", "-c", "command -v ollama"], { ignoreError: true }).trim();
}

// Dispatch to HTTP pull whenever there is no local `ollama` binary to invoke.
// Keying on the resolved host alone missed the WSL mirrored networking case,
// where the Windows-host daemon answers on 127.0.0.1 and no Linux binary
// exists — the CLI branch then failed with "ollama: command not found" against
// a daemon that answered `/api/tags` (#7472).
async function pullOllamaModel(model: string): Promise<boolean> {
if (getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL) {
if (getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL || !hasLocalOllamaCli()) {
return pullOllamaModelViaHttp(model);
}
return pullOllamaModelViaCli(model);
Expand Down
42 changes: 42 additions & 0 deletions src/lib/onboard/provider-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function resolve(overrides: Partial<Parameters<typeof resolveRequestedProviderSe
isWindowsHostOllama: false,
windowsHostOllamaSupported: false,
hermesProviderAvailable: false,
ollamaRunning: false,
readRecordedProvider: () => null,
readRecordedNimContainer: () => null,
readRecordedModel: () => null,
Expand All @@ -50,6 +51,47 @@ describe("resolveRequestedProviderSelection", () => {
}
});

it("reuses a running Ollama daemon instead of reinstalling on the Windows host (#7472)", () => {
// WSL mirrored networking: the Windows daemon answers on loopback first, so
// the probe reads isWindowsHostOllama false and the menu keeps the install
// entry. Express still requests it; the running daemon must win anyway.
const result = resolve({
options: [option("build"), option("ollama"), option("install-windows-ollama")],
requestedProvider: "install-windows-ollama",
isWsl: true,
isWindowsHostOllama: false,
windowsHostOllamaSupported: true,
ollamaRunning: true,
});

assert.equal(selectedKey(result), "ollama");
});

it("still installs on the Windows host when no daemon responds (#7472)", () => {
const result = resolve({
options: [option("build"), option("install-windows-ollama")],
requestedProvider: "install-windows-ollama",
isWsl: true,
windowsHostOllamaSupported: true,
ollamaRunning: false,
});

assert.equal(selectedKey(result), "install-windows-ollama");
});

it("still installs WSL-local Ollama when a daemon is already running (#7472)", () => {
// Guards the narrow scope: widening the collapse to install-ollama would
// skip the upgrade entry resolveOllamaInstallMenuEntry keeps for a
// running-but-stale daemon.
const result = resolve({
options: [option("build"), option("ollama"), option("install-ollama")],
requestedProvider: "install-ollama",
ollamaRunning: true,
});

assert.equal(selectedKey(result), "install-ollama");
});

it("recovers the recorded provider and model when no provider was requested", () => {
const result = resolve({
options: [option("build"), option("openai")],
Expand Down
40 changes: 40 additions & 0 deletions src/lib/onboard/provider-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export interface ResolveRequestedProviderSelectionInput<T extends ProviderOption
isWindowsHostOllama: boolean;
windowsHostOllamaSupported: boolean;
hermesProviderAvailable: boolean;
/**
* True when the onboard probe already reached a live Ollama daemon, on
* whichever candidate host answered first. Absent means the caller has no
* probe result, which leaves an install request untouched.
*/
ollamaRunning?: boolean;
/**
* On a platform where managed vLLM is the approved non-interactive default,
* an onboard with no requested/recorded provider should auto-select local
Expand Down Expand Up @@ -99,6 +105,35 @@ function isWindowsHostOllamaRequest(providerKey: string): boolean {
return providerKey === "start-windows-ollama" || providerKey === "install-windows-ollama";
}

/**
* A daemon that already answers on the Ollama port makes a Windows-host install
* request unnecessary. Express emits `install-windows-ollama` from a Docker-only
* check that never probes Ollama (`scripts/install.sh`), so the key arrives even
* while the daemon is running. Under WSL mirrored networking the Windows daemon
* answers on loopback, `isWindowsHostOllama` reads false, the menu keeps the
* install entry, and onboarding reinstalls through PowerShell interop — the same
* interop whose failure produced the false "no Windows Ollama" reading (#7472).
*
* Keyed on the observed daemon rather than on the networking mode, so a future
* WSL networking mode needs no new condition here.
*
* Scoped to the Windows-host key on purpose. This helper does not touch
* `install-ollama`: `resolveOllamaInstallMenuEntry` keeps that entry for a
* running-but-stale daemon, and collapsing it would skip the Ollama upgrade
* path.
*/
function collapseWindowsInstallToRunningDaemon<T extends ProviderOption>(
input: ResolveRequestedProviderSelectionInput<T>,
providerKey: string,
): T | undefined {
if (providerKey !== "install-windows-ollama" || !input.ollamaRunning) return undefined;
// A daemon reached on the Windows host still needs Docker Desktop WSL
// integration for the sandbox to reach it. Leave that request to the
// unsupported-runtime rejection below instead of silently reusing it.
if (input.isWindowsHostOllama && !input.windowsHostOllamaSupported) return undefined;
return findOption(input.options, "ollama");
}

export function resolveRequestedProviderSelection<T extends ProviderOption>(
input: ResolveRequestedProviderSelectionInput<T>,
): ProviderSelectionResolution<T> {
Expand Down Expand Up @@ -146,6 +181,11 @@ export function resolveRequestedProviderSelection<T extends ProviderOption>(
}
}

const runningDaemon = collapseWindowsInstallToRunningDaemon(input, providerKey);
if (runningDaemon) {
return { kind: "selected", selected: runningDaemon, recoveredFromSandbox, recoveredModel };
}

const selected = findOption(input.options, providerKey);
if (selected) {
return { kind: "selected", selected, recoveredFromSandbox, recoveredModel };
Expand Down
46 changes: 46 additions & 0 deletions src/lib/onboard/setup-nim-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,52 @@ describe("createSetupNim", () => {
expect(handleRunningOllamaSelection).toHaveBeenCalledTimes(1);
});

it("reuses the running daemon when mirrored networking exposes the Windows host on WSL loopback (#7472)", async () => {
const model = "qwen3.6:35b";
const handleRunningOllamaSelection = vi.fn<SetupNimFlowDeps["handleRunningOllamaSelection"]>(
async (_gpu, requestedModel, _recoveredModel, ollamaRunning, state) => {
expect(requestedModel).toBe(model);
expect(ollamaRunning).toBe(true);
state.model = model;
state.provider = "ollama-local";
state.endpointUrl = "http://127.0.0.1:11434/v1";
state.credentialEnv = null;
state.preferredInferenceApi = "openai-completions";
return "selected";
},
);
const handleWindowsHostOllamaSelection = vi.fn<
SetupNimFlowDeps["handleWindowsHostOllamaSelection"]
>(async () => unexpected("Windows-host Ollama selection"));
const setupNim = createSetupNim(
makeDeps({
isNonInteractive: () => true,
getNonInteractiveProvider: () => "install-windows-ollama",
getNonInteractiveModel: () => model,
detectInferenceProviderHostState: () =>
makeHostState({
// Mirrored networking puts the Windows daemon on the distro's own
// loopback, so the first probe candidate answers and the host reads
// as local even though the daemon is the Windows one.
ollamaHost: "127.0.0.1",
ollamaRunning: true,
isWindowsHostOllama: false,
isWsl: true,
hasWindowsOllama: false,
windowsHostOllamaDockerRequirement:
getWindowsHostOllamaDockerRequirement("docker-desktop"),
}),
handleRunningOllamaSelection,
handleWindowsHostOllamaSelection,
}),
);

await setupNim(null, null);

expect(handleRunningOllamaSelection).toHaveBeenCalledTimes(1);
expect(handleWindowsHostOllamaSelection).not.toHaveBeenCalled();
});

it("applies same-gateway discovery constraints before a provider probe (#6315)", async () => {
const providerProbe = vi.fn();
const routeGuard = vi.fn(
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/setup-nim-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ export function createSetupNim(
remoteProviderConfig: deps.remoteProviderConfig,
isWsl: isWslHost,
isWindowsHostOllama,
ollamaRunning,
windowsHostOllamaSupported: windowsHostOllamaDockerRequirement.supported,
hermesProviderAvailable,
preferManagedVllmDefault: gpu?.platform === "spark",
Expand Down
Loading