From 280df684e97f13210e4cf427c042041594f418c2 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 12:06:48 -0700 Subject: [PATCH 1/7] refactor(onboard): extract provider menu builder --- src/lib/onboard.ts | 87 ++++++----------- src/lib/onboard/provider-menu.test.ts | 135 ++++++++++++++++++++++++++ src/lib/onboard/provider-menu.ts | 127 ++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 58 deletions(-) create mode 100644 src/lib/onboard/provider-menu.test.ts create mode 100644 src/lib/onboard/provider-menu.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index dc8a62eb04..a718421fdb 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -216,9 +216,11 @@ const { const { checkOllamaPortsOrWarn, resolveOllamaInstallMenuEntry, - resolveRunningOllamaMenuEntry, assertOllamaUpgradeApplied, } = require("./onboard/ollama-install-menu"); +const { + buildInferenceProviderMenu, +}: typeof import("./onboard/provider-menu") = require("./onboard/provider-menu"); const { ensureOllamaAuthProxy, getOllamaProxyToken, @@ -3944,7 +3946,7 @@ async function createSandbox( // ── Step 3: Inference selection ────────────────────────────────── -type ProviderChoice = { key: string; label: string }; +type ProviderChoice = import("./onboard/provider-menu").ProviderMenuChoice; const { readRecordedProvider, readRecordedNimContainer, readRecordedModel } = providerRecovery.createProviderRecoveryHelpers({ @@ -4130,77 +4132,46 @@ async function setupNim( ? getNonInteractiveModel(requestedProvider || "build") : null; const agentProviderOptions = getAgentInferenceProviderOptions(agent); - const hermesProviderAvailable = agentProviderOptions.includes("hermesProvider"); - const options: Array<{ key: string; label: string }> = []; - options.push({ key: "build", label: "NVIDIA Endpoints" }); - options.push({ key: "openai", label: "OpenAI" }); - options.push({ key: "custom", label: "Other OpenAI-compatible endpoint" }); - options.push({ key: "anthropic", label: "Anthropic" }); - options.push({ key: "anthropicCompatible", label: "Other Anthropic-compatible endpoint" }); - options.push({ key: "gemini", label: "Google Gemini" }); - const runningOllamaMenu = resolveRunningOllamaMenuEntry({ + const ollamaInstallMenu = resolveOllamaInstallMenuEntry({ hasOllama, ollamaRunning, + hasWindowsOllama, ollamaHost, + platform: process.platform, isWsl: isWsl(), + }); + + // Model Router: complexity-based routing via blueprint config. + const blueprintRouterCfg = loadBlueprintProfile("routed"); + const { options, hermesProviderAvailable } = buildInferenceProviderMenu({ + remoteProviderConfig: REMOTE_PROVIDER_CONFIG, + agentProviderOptions, + experimental: EXPERIMENTAL, + gpuNimCapable: Boolean(gpu && gpu.nimCapable), + hasOllama, + ollamaRunning, + ollamaHost, ollamaPort: OLLAMA_PORT, + isWsl: isWsl(), + hasWindowsOllama, + isWindowsHostOllama, windowsHostLabelSuffix: windowsHostOllamaDockerRequirement.supported ? "" : windowsHostOllamaDockerRequirement.labelSuffix, - }); - if (runningOllamaMenu) options.push(runningOllamaMenu); - if (EXPERIMENTAL && gpu && gpu.nimCapable) { - options.push({ key: "nim-local", label: "Local NVIDIA NIM [experimental]" }); - } - options.push( - ...buildVllmMenuEntries({ + windowsHostInstallLabel: windowsHostOllamaDockerRequirement.installLabel, + windowsHostStartLabel: windowsHostOllamaDockerRequirement.startLabel, + windowsOllamaReachable, + winOllamaLoopbackOnly, + ollamaInstallEntry: ollamaInstallMenu.entry, + vllmEntries: buildVllmMenuEntries({ vllmRunning, vllmProfile, experimental: EXPERIMENTAL, platform: gpu?.platform, hasVllmImage, }), - ); - // Skipped when Windows-host already won the cache: the running entry - // above already covers that case. - if (hasWindowsOllama && !isWindowsHostOllama) { - options.push({ - key: "start-windows-ollama", - label: windowsHostOllamaDockerRequirement.startLabel({ - reachable: windowsOllamaReachable, - loopbackOnly: winOllamaLoopbackOnly, - }), - }); - } - // On WSL, always offer to install Ollama on the Windows host when not - // already installed, regardless of WSL Ollama state — users may prefer the - // Windows-host instance (GPU access) even with WSL Ollama running. - if (isWsl() && !hasWindowsOllama) { - options.push({ - key: "install-windows-ollama", - label: windowsHostOllamaDockerRequirement.installLabel, - }); - } - const ollamaInstallMenu = resolveOllamaInstallMenuEntry({ - hasOllama, - ollamaRunning, - hasWindowsOllama, - ollamaHost, - platform: process.platform, - isWsl: isWsl(), + routedEnabled: blueprintRouterCfg?.router?.enabled === true, }); - if (ollamaInstallMenu.entry) options.push(ollamaInstallMenu.entry); - - // Model Router: complexity-based routing via blueprint config. - const blueprintRouterCfg = loadBlueprintProfile("routed"); - if (blueprintRouterCfg && blueprintRouterCfg.router?.enabled === true) { - options.push({ key: "routed", label: "Model Router (experimental)" }); - } - for (const providerKey of agentProviderOptions) { - const remoteConfig = REMOTE_PROVIDER_CONFIG[providerKey]; - if (!remoteConfig || options.some((option) => option.key === providerKey)) continue; - options.push({ key: providerKey, label: remoteConfig.label }); - } function rejectWindowsHostOllama(providerKey: string, windowsHostSelected: boolean): boolean { return rejectUnsupportedWindowsHostOllama( diff --git a/src/lib/onboard/provider-menu.test.ts b/src/lib/onboard/provider-menu.test.ts new file mode 100644 index 0000000000..fc0744773f --- /dev/null +++ b/src/lib/onboard/provider-menu.test.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { buildInferenceProviderMenu } from "../../../dist/lib/onboard/provider-menu"; + +const REMOTE_PROVIDER_CONFIG = { + build: { label: "NVIDIA Endpoints" }, + openai: { label: "OpenAI" }, + custom: { label: "Other OpenAI-compatible endpoint" }, + anthropic: { label: "Anthropic" }, + anthropicCompatible: { label: "Other Anthropic-compatible endpoint" }, + gemini: { label: "Google Gemini" }, + hermesProvider: { label: "Hermes Provider" }, +}; + +function buildMenu(overrides: Partial[0]> = {}) { + return buildInferenceProviderMenu({ + remoteProviderConfig: REMOTE_PROVIDER_CONFIG, + agentProviderOptions: [], + experimental: false, + gpuNimCapable: false, + hasOllama: false, + ollamaRunning: false, + ollamaHost: null, + ollamaPort: 11434, + isWsl: false, + hasWindowsOllama: false, + isWindowsHostOllama: false, + windowsHostLabelSuffix: "", + windowsHostInstallLabel: "Install Ollama on Windows host (recommended)", + windowsHostStartLabel: () => "Start Ollama on Windows host (suggested)", + windowsOllamaReachable: false, + winOllamaLoopbackOnly: false, + ollamaInstallEntry: null, + vllmEntries: [], + routedEnabled: false, + ...overrides, + }); +} + +describe("buildInferenceProviderMenu", () => { + it("returns the base remote providers in the existing prompt order", () => { + const result = buildMenu(); + + expect(result.hermesProviderAvailable).toBe(false); + expect(result.options.map((option) => option.key)).toEqual([ + "build", + "openai", + "custom", + "anthropic", + "anthropicCompatible", + "gemini", + ]); + }); + + it("adds local, routed, and agent-scoped providers after the base remote entries", () => { + const result = buildMenu({ + agentProviderOptions: ["hermesProvider", "build"], + experimental: true, + gpuNimCapable: true, + hasOllama: true, + ollamaRunning: true, + ollamaHost: "127.0.0.1", + isWsl: false, + ollamaInstallEntry: { key: "install-ollama", label: "Install Ollama (Linux)" }, + vllmEntries: [{ key: "install-vllm", label: "Install vLLM (DGX Spark)" }], + routedEnabled: true, + }); + + expect(result.hermesProviderAvailable).toBe(true); + expect(result.options.map((option) => option.key)).toEqual([ + "build", + "openai", + "custom", + "anthropic", + "anthropicCompatible", + "gemini", + "ollama", + "nim-local", + "install-vllm", + "install-ollama", + "routed", + "hermesProvider", + ]); + expect(result.options.find((option) => option.key === "build")?.label).toBe("NVIDIA Endpoints"); + expect(result.options.find((option) => option.key === "hermesProvider")?.label).toBe( + "Hermes Provider", + ); + }); + + it("offers Windows-host Ollama install when WSL has no Windows Ollama", () => { + const result = buildMenu({ + isWsl: true, + hasWindowsOllama: false, + windowsHostInstallLabel: "Install Ollama on Windows host (requires Docker Desktop)", + }); + + expect(result.options.at(-1)).toEqual({ + key: "install-windows-ollama", + label: "Install Ollama on Windows host (requires Docker Desktop)", + }); + }); + + it("offers Windows-host Ollama start when detected but not currently selected", () => { + const result = buildMenu({ + isWsl: true, + hasWindowsOllama: true, + isWindowsHostOllama: false, + windowsOllamaReachable: true, + windowsHostStartLabel: ({ reachable }) => + reachable ? "Use Ollama on Windows host - running" : "Start Ollama on Windows host", + }); + + expect(result.options.at(-1)).toEqual({ + key: "start-windows-ollama", + label: "Use Ollama on Windows host - running", + }); + }); + + it("does not add a separate Windows-host start entry when running Ollama already resolves there", () => { + const result = buildMenu({ + isWsl: true, + hasOllama: false, + ollamaRunning: true, + ollamaHost: "host.docker.internal", + hasWindowsOllama: true, + isWindowsHostOllama: true, + }); + + expect(result.options.map((option) => option.key)).toContain("ollama"); + expect(result.options.map((option) => option.key)).not.toContain("start-windows-ollama"); + }); +}); diff --git a/src/lib/onboard/provider-menu.ts b/src/lib/onboard/provider-menu.ts new file mode 100644 index 0000000000..19b390d1f6 --- /dev/null +++ b/src/lib/onboard/provider-menu.ts @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resolveRunningOllamaMenuEntry } from "./ollama-install-menu"; + +export interface ProviderMenuChoice { + key: string; + label: string; +} + +interface RemoteProviderMenuConfig { + label: string; +} + +type WindowsHostOllamaStartLabel = (opts: { reachable: boolean; loopbackOnly: boolean }) => string; + +export interface BuildInferenceProviderMenuInput { + remoteProviderConfig: Record; + agentProviderOptions: readonly string[]; + experimental: boolean; + gpuNimCapable: boolean; + hasOllama: boolean; + ollamaRunning: boolean; + ollamaHost: string | null; + ollamaPort: number; + isWsl: boolean; + hasWindowsOllama: boolean; + isWindowsHostOllama: boolean; + windowsHostLabelSuffix: string; + windowsHostInstallLabel: string; + windowsHostStartLabel: WindowsHostOllamaStartLabel; + windowsOllamaReachable: boolean; + winOllamaLoopbackOnly: boolean; + ollamaInstallEntry: ProviderMenuChoice | null; + vllmEntries: readonly ProviderMenuChoice[]; + routedEnabled: boolean; +} + +export interface InferenceProviderMenu { + options: ProviderMenuChoice[]; + hermesProviderAvailable: boolean; +} + +const BASE_REMOTE_PROVIDER_OPTIONS: readonly ProviderMenuChoice[] = [ + { key: "build", label: "NVIDIA Endpoints" }, + { key: "openai", label: "OpenAI" }, + { key: "custom", label: "Other OpenAI-compatible endpoint" }, + { key: "anthropic", label: "Anthropic" }, + { key: "anthropicCompatible", label: "Other Anthropic-compatible endpoint" }, + { key: "gemini", label: "Google Gemini" }, +]; + +function configuredRemoteOption( + config: Record, + fallback: ProviderMenuChoice, +): ProviderMenuChoice { + return { + key: fallback.key, + label: config[fallback.key]?.label ?? fallback.label, + }; +} + +function pushUniqueRemoteProviderOption( + options: ProviderMenuChoice[], + config: Record, + providerKey: string, +): void { + const remoteConfig = config[providerKey]; + if (!remoteConfig || options.some((option) => option.key === providerKey)) return; + options.push({ key: providerKey, label: remoteConfig.label }); +} + +export function buildInferenceProviderMenu( + input: BuildInferenceProviderMenuInput, +): InferenceProviderMenu { + const options: ProviderMenuChoice[] = BASE_REMOTE_PROVIDER_OPTIONS.map((option) => + configuredRemoteOption(input.remoteProviderConfig, option), + ); + + const runningOllamaMenu = resolveRunningOllamaMenuEntry({ + hasOllama: input.hasOllama, + ollamaRunning: input.ollamaRunning, + ollamaHost: input.ollamaHost, + isWsl: input.isWsl, + ollamaPort: input.ollamaPort, + windowsHostLabelSuffix: input.windowsHostLabelSuffix, + }); + if (runningOllamaMenu) options.push(runningOllamaMenu); + + if (input.experimental && input.gpuNimCapable) { + options.push({ key: "nim-local", label: "Local NVIDIA NIM [experimental]" }); + } + + options.push(...input.vllmEntries); + + if (input.hasWindowsOllama && !input.isWindowsHostOllama) { + options.push({ + key: "start-windows-ollama", + label: input.windowsHostStartLabel({ + reachable: input.windowsOllamaReachable, + loopbackOnly: input.winOllamaLoopbackOnly, + }), + }); + } + + if (input.isWsl && !input.hasWindowsOllama) { + options.push({ + key: "install-windows-ollama", + label: input.windowsHostInstallLabel, + }); + } + + if (input.ollamaInstallEntry) options.push(input.ollamaInstallEntry); + + if (input.routedEnabled) { + options.push({ key: "routed", label: "Model Router (experimental)" }); + } + + for (const providerKey of input.agentProviderOptions) { + pushUniqueRemoteProviderOption(options, input.remoteProviderConfig, providerKey); + } + + return { + options, + hermesProviderAvailable: input.agentProviderOptions.includes("hermesProvider"), + }; +} From 1b99e5d49c69bb10ccc2e3020eafb8681ff206e0 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 12:19:07 -0700 Subject: [PATCH 2/7] test(e2e): narrow onboard inference smoke registry stub --- test/e2e/test-onboard-inference-smoke.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/e2e/test-onboard-inference-smoke.sh b/test/e2e/test-onboard-inference-smoke.sh index b63919a5ed..157a654b46 100755 --- a/test/e2e/test-onboard-inference-smoke.sh +++ b/test/e2e/test-onboard-inference-smoke.sh @@ -55,6 +55,14 @@ const Module = require("module"); const originalLoad = Module._load; const calls = []; +function isRootRegistryRequest(request, parent) { + if (request.endsWith("/dist/lib/registry") || request.endsWith("/dist/lib/registry.js")) { + return true; + } + if (request !== "./registry") return false; + return parent && parent.filename && parent.filename.endsWith("/dist/lib/onboard.js"); +} + Module._load = function patchedLoad(request, parent, isMain) { if (request === "./adapters/openshell/resolve" || request.endsWith("/adapters/openshell/resolve")) { return { resolveOpenshell: () => "/usr/bin/openshell" }; @@ -113,7 +121,7 @@ Module._load = function patchedLoad(request, parent, isMain) { }, }; } - if (request === "./registry" || request.endsWith("/registry")) { + if (isRootRegistryRequest(request, parent)) { return { updateSandbox: (_name, patch) => calls.push(["registry.updateSandbox", patch]), getSandbox: () => null, From d1e02e22ef437ecb5f1169bc585abceeba8a9687 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 12:45:36 -0700 Subject: [PATCH 3/7] refactor(onboard): extract provider host state Signed-off-by: Carlos Villela --- src/lib/onboard.ts | 118 +++-------- src/lib/onboard/provider-host-state.test.ts | 187 +++++++++++++++++ src/lib/onboard/provider-host-state.ts | 220 ++++++++++++++++++++ 3 files changed, 437 insertions(+), 88 deletions(-) create mode 100644 src/lib/onboard/provider-host-state.test.ts create mode 100644 src/lib/onboard/provider-host-state.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a718421fdb..533fad0d99 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -122,12 +122,6 @@ const { }: typeof import("./onboard/model-router-process") = require("./onboard/model-router-process"); const bedrockRuntimeOnboard: typeof import("./onboard/bedrock-runtime") = require("./onboard/bedrock-runtime"); -const { - buildVllmMenuEntries, -}: typeof import("./onboard/vllm-menu") = require("./onboard/vllm-menu"); -const { - detectWindowsHostOllama, -}: typeof import("./onboard/windows-host-ollama") = require("./onboard/windows-host-ollama"); const { installOllamaOnLinux, }: typeof import("./onboard/install-ollama-linux") = require("./onboard/install-ollama-linux"); @@ -203,24 +197,24 @@ const { } = require("./core/ports"); const localInference: typeof import("./inference/local") = require("./inference/local"); const { - findReachableOllamaHost, resetOllamaHostCache, getLocalProviderBaseUrl, getLocalProviderHealthCheck, getLocalProviderValidationBaseUrl, getOllamaModelOptions, getOllamaWarmupCommand, - OLLAMA_HOST_DOCKER_INTERNAL, validateLocalProvider, } = localInference; const { checkOllamaPortsOrWarn, - resolveOllamaInstallMenuEntry, assertOllamaUpgradeApplied, } = require("./onboard/ollama-install-menu"); const { buildInferenceProviderMenu, }: typeof import("./onboard/provider-menu") = require("./onboard/provider-menu"); +const { + detectInferenceProviderHostState, +}: typeof import("./onboard/provider-host-state") = require("./onboard/provider-host-state"); const { ensureOllamaAuthProxy, getOllamaProxyToken, @@ -235,7 +229,7 @@ const { switchToWindowsOllamaHost, printWindowsOllamaTimeoutDiagnostics, } = require("./inference/ollama/windows"); -const { detectVllmProfile, installVllm } = require("./inference/vllm"); +const { installVllm } = require("./inference/vllm"); const inferenceConfig: typeof import("./inference/config") = require("./inference/config"); const { DEFAULT_CLOUD_MODEL, getProviderSelectionConfig, parseGatewayInference } = inferenceConfig; @@ -309,7 +303,6 @@ const platformUtils: typeof import("./platform") = require("./platform"); const { isWsl, shouldPatchCoredns } = platformUtils; const { getContainerRuntime, - getWindowsHostOllamaDockerRequirement, repairLocalInferenceSystemdOverrideOrExit, rejectUnsupportedWindowsHostOllama, shouldFrontOllamaWithProxy, @@ -1371,12 +1364,6 @@ function buildGatewayClusterExecArgv(script: string): string[] { return dockerExecArgv(getGatewayClusterContainerName(), ["sh", "-lc", script]); } -function hostCommandExists(commandName: string): boolean { - return !!runCapture(["sh", "-c", 'command -v "$1"', "--", commandName], { - ignoreError: true, - }); -} - function captureProcessArgs(pid: number): string { return runCapture(["ps", "-p", String(pid), "-o", "args="], { ignoreError: true, @@ -4074,72 +4061,33 @@ async function setupNim( let preferredInferenceApi: string | null = null; let allowToolsIncompatible = false; - const localProbeCurlArgs = ["--connect-timeout", "2", "--max-time", "5"] as const; - const hasOllama = hostCommandExists("ollama"); - const ollamaHost = findReachableOllamaHost(); - const ollamaRunning = ollamaHost !== null; - const isWindowsHostOllama = ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL; - const vllmRunning = !!runCapture( - ["curl", "-sf", ...localProbeCurlArgs, `http://127.0.0.1:${VLLM_PORT}/v1/models`], - { ignoreError: true }, - ); - // Pick a vLLM install recipe for this host. Profiles live in inference/vllm.ts; - // null means "no supported platform" (vLLM stays behind EXPERIMENTAL). - const vllmProfile = detectVllmProfile(gpu); - // If the profile's image is already cached, the install path is really a - // "start" — docker pull is a no-op and the container can come up in seconds. - const hasVllmImage = !!( - vllmProfile && - docker.dockerCapture(["images", "-q", vllmProfile.image], { ignoreError: true }).trim() - ); - const windowsHostOllamaDockerRequirement = getWindowsHostOllamaDockerRequirement( - isWsl() ? getContainerRuntime() : null, - ); - // Probed even when WSL has its own Ollama: users may prefer the Windows - // instance for GPU access and a unified model cache. See - // src/lib/onboard/windows-host-ollama.ts for process/path fallback details. - const winOllamaState = detectWindowsHostOllama(); - const hasWindowsOllama = winOllamaState.installed; - const winOllamaInstalledPath = winOllamaState.installedPath; - const winOllamaLoopbackOnly = winOllamaState.loopbackOnly; - - // Independent of findReachableOllamaHost: when WSL Ollama wins the cache - // on 127.0.0.1, Windows-host may also be running on 0.0.0.0 and we want - // to offer a "switch" without restarting anything. - let windowsOllamaReachable = false; - if (isWsl() && !isWindowsHostOllama) { - windowsOllamaReachable = !!runCapture( - ["curl", "-sf", ...localProbeCurlArgs, `http://host.docker.internal:${OLLAMA_PORT}/api/tags`], - { ignoreError: true }, - ); - } - - // Mirrored mode shares loopback so both probes hit the same instance; - // only NAT mode actually has two separate daemons to warn about. - if (isWsl() && ollamaHost === "127.0.0.1" && windowsOllamaReachable) { - const networkingMode = runCapture(["wslinfo", "--networking-mode"], { - ignoreError: true, - }).trim(); - if (networkingMode !== "mirrored") { - console.log(""); - console.log(" ⚠ Ollama is running on both WSL and the Windows host."); - console.log(" Stop one to avoid duplicated GPU memory and model caches."); - console.log(""); - } - } + const providerHostState = detectInferenceProviderHostState({ + gpu, + experimental: EXPERIMENTAL, + }); + const { + hasOllama, + ollamaHost, + ollamaRunning, + isWindowsHostOllama, + isWsl: isWslHost, + hasWindowsOllama, + winOllamaInstalledPath, + winOllamaLoopbackOnly, + windowsOllamaReachable, + windowsHostOllamaDockerRequirement, + vllmRunning, + vllmProfile, + hasVllmImage, + vllmEntries, + ollamaInstallMenu, + gpuNimCapable, + } = providerHostState; const requestedProvider = getNonInteractiveProvider(); const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "build") : null; const agentProviderOptions = getAgentInferenceProviderOptions(agent); - const ollamaInstallMenu = resolveOllamaInstallMenuEntry({ - hasOllama, - ollamaRunning, - hasWindowsOllama, - ollamaHost, - platform: process.platform, - isWsl: isWsl(), - }); // Model Router: complexity-based routing via blueprint config. const blueprintRouterCfg = loadBlueprintProfile("routed"); @@ -4147,12 +4095,12 @@ async function setupNim( remoteProviderConfig: REMOTE_PROVIDER_CONFIG, agentProviderOptions, experimental: EXPERIMENTAL, - gpuNimCapable: Boolean(gpu && gpu.nimCapable), + gpuNimCapable, hasOllama, ollamaRunning, ollamaHost, ollamaPort: OLLAMA_PORT, - isWsl: isWsl(), + isWsl: isWslHost, hasWindowsOllama, isWindowsHostOllama, windowsHostLabelSuffix: windowsHostOllamaDockerRequirement.supported @@ -4163,13 +4111,7 @@ async function setupNim( windowsOllamaReachable, winOllamaLoopbackOnly, ollamaInstallEntry: ollamaInstallMenu.entry, - vllmEntries: buildVllmMenuEntries({ - vllmRunning, - vllmProfile, - experimental: EXPERIMENTAL, - platform: gpu?.platform, - hasVllmImage, - }), + vllmEntries, routedEnabled: blueprintRouterCfg?.router?.enabled === true, }); @@ -4211,7 +4153,7 @@ async function setupNim( // (so the menu's "ollama" key points there), the availability // check below would pass and silently swap the daemon. Detect // and fail-loud with a hint. - if (isWsl() && recordedProvider === "ollama-local" && isWindowsHostOllama) { + if (isWslHost && recordedProvider === "ollama-local" && isWindowsHostOllama) { console.error( ` Recorded provider '${recordedProvider}' (WSL Ollama) is not available in this environment.`, ); diff --git a/src/lib/onboard/provider-host-state.test.ts b/src/lib/onboard/provider-host-state.test.ts new file mode 100644 index 0000000000..26bd0c7be5 --- /dev/null +++ b/src/lib/onboard/provider-host-state.test.ts @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + detectInferenceProviderHostState, + type DetectInferenceProviderHostStateDeps, + type InferenceProviderHostGpu, +} from "../../../dist/lib/onboard/provider-host-state"; + +const SUPPORTED_WINDOWS_OLLAMA = { + supported: true, + detectedRuntime: "Docker Desktop", + installLabel: "Install Ollama on Windows host (recommended)", + startLabel: ({ reachable }: { reachable: boolean; loopbackOnly: boolean }) => + reachable ? "Use Ollama on Windows host - running (suggested)" : "Start Ollama on Windows host", +} as const; + +function buildDeps( + overrides: Partial = {}, +): DetectInferenceProviderHostStateDeps { + return { + runCapture: vi.fn(() => ""), + dockerCapture: vi.fn(() => ""), + hostCommandExists: vi.fn(() => false), + findReachableOllamaHost: vi.fn(() => null), + isWsl: vi.fn(() => false), + getContainerRuntime: vi.fn( + () => "docker-desktop", + ), + detectWindowsHostOllama: vi.fn(() => ({ + installed: false, + installedPath: "", + loopbackOnly: false, + })), + getWindowsHostOllamaDockerRequirement: vi.fn(() => SUPPORTED_WINDOWS_OLLAMA), + detectVllmProfile: vi.fn(() => null), + ...overrides, + }; +} + +function detectWithDeps( + deps: DetectInferenceProviderHostStateDeps, + gpu: InferenceProviderHostGpu | null = null, +) { + return detectInferenceProviderHostState({ + gpu, + experimental: true, + platform: "linux", + env: {}, + log: () => {}, + installedOllamaVersion: "0.24.0", + runningOllamaVersion: "0.24.0", + deps, + }); +} + +describe("detectInferenceProviderHostState", () => { + it("collects local Ollama and vLLM state into one provider host snapshot", () => { + const deps = buildDeps({ + hostCommandExists: vi.fn((command) => command === "ollama"), + findReachableOllamaHost: vi.fn(() => "127.0.0.1"), + runCapture: vi.fn((command) => + command.join(" ").includes(`http://127.0.0.1:8000/v1/models`) ? "{}" : "", + ), + dockerCapture: vi.fn(() => "sha256:cached-image\n"), + detectVllmProfile: vi.fn(() => ({ + name: "Linux + NVIDIA GPU", + platform: "linux" as const, + image: "nvcr.io/nvidia/vllm:test", + defaultModel: {} as never, + containerName: "nemoclaw-vllm", + dockerRunFlags: [], + pullTimeoutSec: 1, + loadTimeoutSec: 1, + })), + }); + + const state = detectWithDeps(deps, { nimCapable: true, type: "nvidia", platform: "linux" }); + + expect(state.hasOllama).toBe(true); + expect(state.ollamaRunning).toBe(true); + expect(state.ollamaHost).toBe("127.0.0.1"); + expect(state.isWindowsHostOllama).toBe(false); + expect(state.vllmRunning).toBe(true); + expect(state.hasVllmImage).toBe(true); + expect(state.vllmEntries.map((entry) => entry.key)).toEqual(["vllm"]); + expect(state.gpuNimCapable).toBe(true); + expect(state.ollamaInstallMenu.entry).toBeNull(); + expect(deps.getWindowsHostOllamaDockerRequirement).toHaveBeenCalledWith(null); + }); + + it("detects a reachable Windows-host Ollama beside WSL-local Ollama and warns outside mirrored networking", () => { + const logs: string[] = []; + const deps = buildDeps({ + isWsl: vi.fn(() => true), + findReachableOllamaHost: vi.fn(() => "127.0.0.1"), + detectWindowsHostOllama: vi.fn(() => ({ + installed: true, + installedPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Ollama\\ollama.exe", + loopbackOnly: false, + })), + runCapture: vi.fn((command) => { + const joined = command.join(" "); + if (joined.includes("host.docker.internal:11434/api/tags")) return "{}"; + if (joined.includes("wslinfo --networking-mode")) return "nat\n"; + return ""; + }), + }); + + const state = detectInferenceProviderHostState({ + gpu: null, + experimental: false, + platform: "linux", + env: {}, + log: (message = "") => logs.push(message), + installedOllamaVersion: "0.24.0", + runningOllamaVersion: "0.24.0", + deps, + }); + + expect(state.isWsl).toBe(true); + expect(state.hasWindowsOllama).toBe(true); + expect(state.windowsOllamaReachable).toBe(true); + expect(state.winOllamaInstalledPath).toMatch(/ollama\.exe$/); + expect(logs.join("\n")).toContain("Ollama is running on both WSL and the Windows host"); + expect(deps.getWindowsHostOllamaDockerRequirement).toHaveBeenCalledWith("docker-desktop"); + }); + + it("suppresses the duplicate-daemon warning when WSL mirrored networking makes the probes equivalent", () => { + const logs: string[] = []; + const deps = buildDeps({ + isWsl: vi.fn(() => true), + findReachableOllamaHost: vi.fn(() => "127.0.0.1"), + detectWindowsHostOllama: vi.fn(() => ({ + installed: true, + installedPath: "C:\\Ollama\\ollama.exe", + loopbackOnly: false, + })), + runCapture: vi.fn((command) => { + const joined = command.join(" "); + if (joined.includes("host.docker.internal:11434/api/tags")) return "{}"; + if (joined.includes("wslinfo --networking-mode")) return "mirrored\n"; + return ""; + }), + }); + + const state = detectInferenceProviderHostState({ + gpu: null, + experimental: false, + platform: "linux", + env: {}, + log: (message = "") => logs.push(message), + installedOllamaVersion: "0.24.0", + runningOllamaVersion: "0.24.0", + deps, + }); + + expect(state.windowsOllamaReachable).toBe(true); + expect(logs).toEqual([]); + }); + + it("does not probe the Windows-host switch path when running Ollama already resolves to the Windows host", () => { + const runCapture = vi.fn(() => ""); + const deps = buildDeps({ + isWsl: vi.fn(() => true), + findReachableOllamaHost: vi.fn(() => "host.docker.internal"), + detectWindowsHostOllama: vi.fn(() => ({ + installed: true, + installedPath: "C:\\Ollama\\ollama.exe", + loopbackOnly: true, + })), + runCapture, + }); + + const state = detectWithDeps(deps); + + expect(state.isWindowsHostOllama).toBe(true); + expect(state.windowsOllamaReachable).toBe(false); + expect( + runCapture.mock.calls.some(([command]) => + command.join(" ").includes("host.docker.internal:11434/api/tags"), + ), + ).toBe(false); + }); +}); diff --git a/src/lib/onboard/provider-host-state.ts b/src/lib/onboard/provider-host-state.ts new file mode 100644 index 0000000000..9f0df3de35 --- /dev/null +++ b/src/lib/onboard/provider-host-state.ts @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { dockerCapture as defaultDockerCapture } from "../adapters/docker"; +import { OLLAMA_PORT, VLLM_PORT } from "../core/ports"; +import { findReachableOllamaHost, OLLAMA_HOST_DOCKER_INTERNAL } from "../inference/local"; +import type { NvidiaPlatform } from "../inference/nim"; +import { detectVllmProfile, type VllmProfile } from "../inference/vllm"; +import { type ContainerRuntime, isWsl as defaultIsWsl } from "../platform"; +import { runCapture as defaultRunCapture } from "../runner"; +import { + getContainerRuntime as defaultGetContainerRuntime, + getWindowsHostOllamaDockerRequirement, + type WindowsHostOllamaDockerRequirement, +} from "./local-inference-topology"; +import { resolveOllamaInstallMenuEntry, type OllamaInstallMenuResult } from "./ollama-install-menu"; +import { buildVllmMenuEntries, type VllmMenuEntry } from "./vllm-menu"; +import { detectWindowsHostOllama, type WindowsHostOllamaState } from "./windows-host-ollama"; + +type RunCapture = (args: string[], options?: { ignoreError?: boolean }) => string; +type DockerCapture = (args: string[], options?: { ignoreError?: boolean }) => string; + +export interface InferenceProviderHostGpu { + nimCapable?: boolean; + spark?: boolean; + type?: string; + platform?: NvidiaPlatform; +} + +export interface InferenceProviderHostState { + hasOllama: boolean; + ollamaHost: string | null; + ollamaRunning: boolean; + isWindowsHostOllama: boolean; + isWsl: boolean; + hasWindowsOllama: boolean; + winOllamaInstalledPath: string; + winOllamaLoopbackOnly: boolean; + windowsOllamaReachable: boolean; + windowsHostOllamaDockerRequirement: WindowsHostOllamaDockerRequirement; + vllmRunning: boolean; + vllmProfile: VllmProfile | null; + hasVllmImage: boolean; + vllmEntries: VllmMenuEntry[]; + ollamaInstallMenu: OllamaInstallMenuResult; + gpuNimCapable: boolean; +} + +export interface DetectInferenceProviderHostStateInput { + gpu: InferenceProviderHostGpu | null | undefined; + experimental: boolean; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + log?: (message?: string) => void; + installedOllamaVersion?: string | null; + runningOllamaVersion?: string | null; + deps?: Partial; +} + +export interface DetectInferenceProviderHostStateDeps { + runCapture: RunCapture; + dockerCapture: DockerCapture; + hostCommandExists: (commandName: string) => boolean; + findReachableOllamaHost: () => string | null; + isWsl: () => boolean; + getContainerRuntime: () => ContainerRuntime; + detectWindowsHostOllama: () => WindowsHostOllamaState; + getWindowsHostOllamaDockerRequirement: ( + runtime: ContainerRuntime | null, + ) => WindowsHostOllamaDockerRequirement; + detectVllmProfile: (gpu: InferenceProviderHostGpu | null | undefined) => VllmProfile | null; +} + +const LOCAL_PROVIDER_PROBE_CURL_ARGS = ["--connect-timeout", "2", "--max-time", "5"] as const; + +function hostCommandExists(commandName: string, runCapture: RunCapture): boolean { + return !!runCapture(["sh", "-c", 'command -v "$1"', "--", commandName], { + ignoreError: true, + }); +} + +function buildDeps( + overrides: Partial = {}, +): DetectInferenceProviderHostStateDeps { + const runCapture = overrides.runCapture ?? defaultRunCapture; + return { + runCapture, + dockerCapture: overrides.dockerCapture ?? defaultDockerCapture, + hostCommandExists: + overrides.hostCommandExists ?? ((command) => hostCommandExists(command, runCapture)), + findReachableOllamaHost: overrides.findReachableOllamaHost ?? findReachableOllamaHost, + isWsl: overrides.isWsl ?? defaultIsWsl, + getContainerRuntime: overrides.getContainerRuntime ?? defaultGetContainerRuntime, + detectWindowsHostOllama: overrides.detectWindowsHostOllama ?? detectWindowsHostOllama, + getWindowsHostOllamaDockerRequirement: + overrides.getWindowsHostOllamaDockerRequirement ?? getWindowsHostOllamaDockerRequirement, + detectVllmProfile: + overrides.detectVllmProfile ?? + ((gpu) => detectVllmProfile(gpu as Parameters[0])), + }; +} + +function probeVllmRunning(runCapture: RunCapture): boolean { + return !!runCapture( + ["curl", "-sf", ...LOCAL_PROVIDER_PROBE_CURL_ARGS, `http://127.0.0.1:${VLLM_PORT}/v1/models`], + { ignoreError: true }, + ); +} + +function probeWindowsOllamaReachable(input: { + isWsl: boolean; + isWindowsHostOllama: boolean; + runCapture: RunCapture; +}): boolean { + if (!input.isWsl || input.isWindowsHostOllama) return false; + return !!input.runCapture( + [ + "curl", + "-sf", + ...LOCAL_PROVIDER_PROBE_CURL_ARGS, + `http://host.docker.internal:${OLLAMA_PORT}/api/tags`, + ], + { ignoreError: true }, + ); +} + +function maybeWarnAboutDuplicateOllamaDaemons(input: { + isWsl: boolean; + ollamaHost: string | null; + windowsOllamaReachable: boolean; + runCapture: RunCapture; + log: (message?: string) => void; +}): void { + if (!input.isWsl || input.ollamaHost !== "127.0.0.1" || !input.windowsOllamaReachable) return; + const networkingMode = input + .runCapture(["wslinfo", "--networking-mode"], { + ignoreError: true, + }) + .trim(); + if (networkingMode === "mirrored") return; + input.log(""); + input.log(" ⚠ Ollama is running on both WSL and the Windows host."); + input.log(" Stop one to avoid duplicated GPU memory and model caches."); + input.log(""); +} + +export function detectInferenceProviderHostState( + input: DetectInferenceProviderHostStateInput, +): InferenceProviderHostState { + const deps = buildDeps(input.deps); + const log = input.log ?? console.log; + const platform = input.platform ?? process.platform; + const isWsl = deps.isWsl(); + const hasOllama = deps.hostCommandExists("ollama"); + const ollamaHost = deps.findReachableOllamaHost(); + const ollamaRunning = ollamaHost !== null; + const isWindowsHostOllama = ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL; + const vllmRunning = probeVllmRunning(deps.runCapture); + const vllmProfile = deps.detectVllmProfile(input.gpu); + const hasVllmImage = !!( + vllmProfile && + deps.dockerCapture(["images", "-q", vllmProfile.image], { ignoreError: true }).trim() + ); + const windowsHostOllamaDockerRequirement = deps.getWindowsHostOllamaDockerRequirement( + isWsl ? deps.getContainerRuntime() : null, + ); + const winOllamaState = deps.detectWindowsHostOllama(); + const hasWindowsOllama = winOllamaState.installed; + const windowsOllamaReachable = probeWindowsOllamaReachable({ + isWsl, + isWindowsHostOllama, + runCapture: deps.runCapture, + }); + + maybeWarnAboutDuplicateOllamaDaemons({ + isWsl, + ollamaHost, + windowsOllamaReachable, + runCapture: deps.runCapture, + log, + }); + + const ollamaInstallMenu = resolveOllamaInstallMenuEntry({ + hasOllama, + ollamaRunning, + hasWindowsOllama, + ollamaHost, + platform, + isWsl, + installedOllamaVersion: input.installedOllamaVersion, + runningOllamaVersion: input.runningOllamaVersion, + }); + + return { + hasOllama, + ollamaHost, + ollamaRunning, + isWindowsHostOllama, + isWsl, + hasWindowsOllama, + winOllamaInstalledPath: winOllamaState.installedPath, + winOllamaLoopbackOnly: winOllamaState.loopbackOnly, + windowsOllamaReachable, + windowsHostOllamaDockerRequirement, + vllmRunning, + vllmProfile, + hasVllmImage, + vllmEntries: buildVllmMenuEntries({ + vllmRunning, + vllmProfile, + experimental: input.experimental, + platform: input.gpu?.platform, + hasVllmImage, + env: input.env, + log: (message) => log(message), + }), + ollamaInstallMenu, + gpuNimCapable: Boolean(input.gpu?.nimCapable), + }; +} From 5a8281a56d30e0aee36ed7d68306ed5abaad9e57 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 13:07:52 -0700 Subject: [PATCH 4/7] refactor(onboard): extract provider selection resolver Signed-off-by: Carlos Villela --- src/lib/onboard.ts | 119 ++++++-------- src/lib/onboard/provider-selection.test.ts | 131 +++++++++++++++ src/lib/onboard/provider-selection.ts | 178 +++++++++++++++++++++ 3 files changed, 356 insertions(+), 72 deletions(-) create mode 100644 src/lib/onboard/provider-selection.test.ts create mode 100644 src/lib/onboard/provider-selection.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 533fad0d99..74066a3e7f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -86,8 +86,8 @@ const { getSelectionDrift, }: typeof import("./onboard/selection-drift") = require("./onboard/selection-drift"); const { - resolveProviderKeyFallback, -}: typeof import("./onboard/provider-key-fallback") = require("./onboard/provider-key-fallback"); + resolveRequestedProviderSelection, +}: typeof import("./onboard/provider-selection") = require("./onboard/provider-selection"); const { isLinuxDockerDriverGatewayEnabled, }: typeof import("./onboard/docker-driver-platform") = require("./onboard/docker-driver-platform"); @@ -4135,93 +4135,68 @@ async function setupNim( hermesAuthMethod = null; if (isNonInteractive() || requestedProvider) { - let providerKey = requestedProvider; - if (!providerKey) { - const recordedProvider = readRecordedProvider(sandboxName); - const hasNimContainer = !!readRecordedNimContainer(sandboxName); - const recoveredKey = providerRecovery.providerNameToOptionKey( - REMOTE_PROVIDER_CONFIG, - recordedProvider, - { hasNimContainer }, - ); - if (recoveredKey) { - // Refuse to silently switch providers behind the user's back; if - // the previously-recorded one is gone, surface the recorded value - // so the user can fix the dependency or override via env var. - // Special case: on WSL, recorded ollama-local was WSL Ollama at - // record time. If the only reachable Ollama is now Windows-host - // (so the menu's "ollama" key points there), the availability - // check below would pass and silently swap the daemon. Detect - // and fail-loud with a hint. - if (isWslHost && recordedProvider === "ollama-local" && isWindowsHostOllama) { + const providerSelection = resolveRequestedProviderSelection({ + options, + requestedProvider, + sandboxName, + remoteProviderConfig: REMOTE_PROVIDER_CONFIG, + isWsl: isWslHost, + isWindowsHostOllama, + windowsHostOllamaSupported: windowsHostOllamaDockerRequirement.supported, + hermesProviderAvailable, + readRecordedProvider, + readRecordedNimContainer, + readRecordedModel, + }); + if (providerSelection.kind === "failure") { + switch (providerSelection.reason.kind) { + case "wsl-recorded-ollama-windows-host": console.error( - ` Recorded provider '${recordedProvider}' (WSL Ollama) is not available in this environment.`, + ` Recorded provider '${providerSelection.reason.recordedProvider}' (WSL Ollama) is not available in this environment.`, ); console.error( " Hint: Windows-host Ollama is reachable here; re-run with NEMOCLAW_PROVIDER=ollama to use it explicitly.", ); - process.exit(1); - } - if (!options.some((o) => o.key === recoveredKey)) { + break; + case "recorded-provider-unavailable": console.error( - ` Recorded provider '${recordedProvider}' is not available in this environment.`, + ` Recorded provider '${providerSelection.reason.recordedProvider}' is not available in this environment.`, ); console.error( " Set NEMOCLAW_PROVIDER explicitly, or restore the missing local-inference dependency.", ); - if (recoveredKey === "ollama") { - const winHostKey = options.find( - (o) => o.key === "start-windows-ollama" || o.key === "install-windows-ollama", - )?.key; - if (winHostKey) { - console.error( - ` Hint: Windows-host Ollama is available here — re-run with NEMOCLAW_PROVIDER=${winHostKey} to use it.`, - ); - } + if (providerSelection.reason.windowsHostKey) { + console.error( + ` Hint: Windows-host Ollama is available here — re-run with NEMOCLAW_PROVIDER=${providerSelection.reason.windowsHostKey} to use it.`, + ); } - process.exit(1); - } - providerKey = recoveredKey; - recoveredFromSandbox = true; - recoveredModel = readRecordedModel(sandboxName); - } else if (recordedProvider === "vllm-local") { - // vllm-local without a nimContainer marker is ambiguous — could be - // standalone vLLM or Local NIM. Don't guess; require an override. - console.error( - " Recorded provider 'vllm-local' is ambiguous (could be standalone vLLM or Local NIM).", - ); - console.error(" Set NEMOCLAW_PROVIDER explicitly (vllm or nim-local) and re-run."); - process.exit(1); - } else { - providerKey = "build"; - } - } - selected = options.find((o) => o.key === providerKey); - if (!selected) { - if ( - (providerKey === "start-windows-ollama" || providerKey === "install-windows-ollama") && - rejectWindowsHostOllama(providerKey, isWindowsHostOllama) - ) { - process.exit(1); - } - selected = resolveProviderKeyFallback(options, providerKey, { - canUseWindowsHostOllama: - isWindowsHostOllama && windowsHostOllamaDockerRequirement.supported, - }); - if (!selected) { - if (providerKey === "hermesProvider" && !hermesProviderAvailable) { + break; + case "ambiguous-recorded-vllm": + console.error( + " Recorded provider 'vllm-local' is ambiguous (could be standalone vLLM or Local NIM).", + ); + console.error(" Set NEMOCLAW_PROVIDER explicitly (vllm or nim-local) and re-run."); + break; + case "unsupported-windows-host-ollama": + rejectWindowsHostOllama(providerSelection.reason.providerKey, isWindowsHostOllama); + break; + case "hermes-provider-unavailable": console.error(" Hermes Provider is only available when onboarding Hermes Agent."); console.error( " Re-run with `nemohermes onboard` or `nemoclaw onboard --agent hermes`.", ); - process.exit(1); - } - console.error( - ` Requested provider '${providerKey}' is not available in this environment.`, - ); - process.exit(1); + break; + case "requested-provider-unavailable": + console.error( + ` Requested provider '${providerSelection.reason.providerKey}' is not available in this environment.`, + ); + break; } + process.exit(1); } + selected = providerSelection.selected; + recoveredFromSandbox = providerSelection.recoveredFromSandbox; + recoveredModel = providerSelection.recoveredModel; note( recoveredFromSandbox ? ` [non-interactive] Provider: ${selected.key} (recovered from sandbox '${sandboxName}')` diff --git a/src/lib/onboard/provider-selection.test.ts b/src/lib/onboard/provider-selection.test.ts new file mode 100644 index 0000000000..a4d0b32b43 --- /dev/null +++ b/src/lib/onboard/provider-selection.test.ts @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { resolveRequestedProviderSelection } from "../../../dist/lib/onboard/provider-selection"; + +const option = (key: string) => ({ key, label: key }); + +const remoteProviderConfig = { + build: { providerName: "nvidia-prod" }, + openai: { providerName: "openai-api" }, + hermesProvider: { providerName: "hermes-provider" }, +}; + +function resolve(overrides: Partial[0]> = {}) { + return resolveRequestedProviderSelection({ + options: [option("build")], + requestedProvider: null, + sandboxName: "sandbox", + remoteProviderConfig, + isWsl: false, + isWindowsHostOllama: false, + windowsHostOllamaSupported: false, + hermesProviderAvailable: false, + readRecordedProvider: () => null, + readRecordedNimContainer: () => null, + readRecordedModel: () => null, + ...overrides, + }); +} + +describe("resolveRequestedProviderSelection", () => { + it("falls back install action keys to currently available providers", () => { + const result = resolve({ + options: [option("build"), option("ollama")], + requestedProvider: "install-ollama", + }); + + assert.equal(result.kind, "selected"); + if (result.kind === "selected") { + assert.equal(result.selected.key, "ollama"); + assert.equal(result.recoveredFromSandbox, false); + assert.equal(result.recoveredModel, null); + } + }); + + it("recovers the recorded provider and model when no provider was requested", () => { + const result = resolve({ + options: [option("build"), option("openai")], + readRecordedProvider: () => "openai-api", + readRecordedModel: () => "gpt-example", + }); + + assert.equal(result.kind, "selected"); + if (result.kind === "selected") { + assert.equal(result.selected.key, "openai"); + assert.equal(result.recoveredFromSandbox, true); + assert.equal(result.recoveredModel, "gpt-example"); + } + }); + + it("does not silently map a recorded WSL Ollama provider to Windows-host Ollama", () => { + const result = resolve({ + options: [option("build"), option("ollama")], + isWsl: true, + isWindowsHostOllama: true, + windowsHostOllamaSupported: true, + readRecordedProvider: () => "ollama-local", + }); + + assert.equal(result.kind, "failure"); + if (result.kind === "failure") { + assert.equal(result.reason.kind, "wsl-recorded-ollama-windows-host"); + } + }); + + it("returns a Windows-host hint when recorded Ollama is unavailable but a host action exists", () => { + const result = resolve({ + options: [option("build"), option("start-windows-ollama")], + readRecordedProvider: () => "ollama-local", + }); + + assert.equal(result.kind, "failure"); + if (result.kind === "failure") { + assert.equal(result.reason.kind, "recorded-provider-unavailable"); + if (result.reason.kind === "recorded-provider-unavailable") { + assert.equal(result.reason.recoveredKey, "ollama"); + assert.equal(result.reason.windowsHostKey, "start-windows-ollama"); + } + } + }); + + it("reports Hermes Provider as agent-gated when it is requested for another agent", () => { + const result = resolve({ + requestedProvider: "hermesProvider", + hermesProviderAvailable: false, + }); + + assert.equal(result.kind, "failure"); + if (result.kind === "failure") { + assert.equal(result.reason.kind, "hermes-provider-unavailable"); + } + }); + + it("reports unsupported Windows-host Ollama before applying compatible fallbacks", () => { + const result = resolve({ + requestedProvider: "start-windows-ollama", + isWindowsHostOllama: true, + windowsHostOllamaSupported: false, + }); + + assert.equal(result.kind, "failure"); + if (result.kind === "failure") { + assert.equal(result.reason.kind, "unsupported-windows-host-ollama"); + } + }); + + it("defaults to NVIDIA Endpoints when no requested or recorded provider is available", () => { + const result = resolve({ + options: [option("build"), option("openai")], + }); + + assert.equal(result.kind, "selected"); + if (result.kind === "selected") { + assert.equal(result.selected.key, "build"); + assert.equal(result.recoveredFromSandbox, false); + } + }); +}); diff --git a/src/lib/onboard/provider-selection.ts b/src/lib/onboard/provider-selection.ts new file mode 100644 index 0000000000..ccae4a590d --- /dev/null +++ b/src/lib/onboard/provider-selection.ts @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type ProviderOption, resolveProviderKeyFallback } from "./provider-key-fallback"; +import { providerNameToOptionKey, type RemoteProviderConfigEntryLike } from "./provider-recovery"; + +export type ProviderSelectionFailureReason = + | { + kind: "wsl-recorded-ollama-windows-host"; + recordedProvider: string; + } + | { + kind: "recorded-provider-unavailable"; + recordedProvider: string; + recoveredKey: string; + windowsHostKey: string | null; + } + | { + kind: "ambiguous-recorded-vllm"; + } + | { + kind: "unsupported-windows-host-ollama"; + providerKey: string; + } + | { + kind: "hermes-provider-unavailable"; + } + | { + kind: "requested-provider-unavailable"; + providerKey: string; + }; + +export interface ProviderSelectionSuccess { + kind: "selected"; + selected: T; + recoveredFromSandbox: boolean; + recoveredModel: string | null; +} + +export interface ProviderSelectionFailure { + kind: "failure"; + reason: ProviderSelectionFailureReason; +} + +export type ProviderSelectionResolution = + | ProviderSelectionSuccess + | ProviderSelectionFailure; + +export interface ProviderSelectionRecoveryReaders { + readRecordedProvider(sandboxName: string | null | undefined): string | null; + readRecordedNimContainer(sandboxName: string | null | undefined): string | null; + readRecordedModel(sandboxName: string | null | undefined): string | null; +} + +export interface ResolveRequestedProviderSelectionInput + extends ProviderSelectionRecoveryReaders { + options: T[]; + requestedProvider: string | null; + sandboxName: string | null; + remoteProviderConfig: Record; + isWsl: boolean; + isWindowsHostOllama: boolean; + windowsHostOllamaSupported: boolean; + hermesProviderAvailable: boolean; +} + +function findOption(options: T[], key: string): T | undefined { + return options.find((option) => option.key === key); +} + +function findWindowsHostKey(options: ProviderOption[]): string | null { + return ( + options.find((option) => option.key === "start-windows-ollama")?.key || + options.find((option) => option.key === "install-windows-ollama")?.key || + null + ); +} + +function isWindowsHostOllamaRequest(providerKey: string): boolean { + return providerKey === "start-windows-ollama" || providerKey === "install-windows-ollama"; +} + +export function resolveRequestedProviderSelection( + input: ResolveRequestedProviderSelectionInput, +): ProviderSelectionResolution { + let providerKey = input.requestedProvider; + let recoveredFromSandbox = false; + let recoveredModel: string | null = null; + + if (!providerKey) { + const recordedProvider = input.readRecordedProvider(input.sandboxName); + const hasNimContainer = !!input.readRecordedNimContainer(input.sandboxName); + const recoveredKey = providerNameToOptionKey(input.remoteProviderConfig, recordedProvider, { + hasNimContainer, + }); + + if (recoveredKey) { + if (input.isWsl && recordedProvider === "ollama-local" && input.isWindowsHostOllama) { + return { + kind: "failure", + reason: { + kind: "wsl-recorded-ollama-windows-host", + recordedProvider, + }, + }; + } + + if (!findOption(input.options, recoveredKey)) { + return { + kind: "failure", + reason: { + kind: "recorded-provider-unavailable", + recordedProvider: recordedProvider || "", + recoveredKey, + windowsHostKey: recoveredKey === "ollama" ? findWindowsHostKey(input.options) : null, + }, + }; + } + + providerKey = recoveredKey; + recoveredFromSandbox = true; + recoveredModel = input.readRecordedModel(input.sandboxName); + } else if (recordedProvider === "vllm-local") { + return { + kind: "failure", + reason: { kind: "ambiguous-recorded-vllm" }, + }; + } else { + providerKey = "build"; + } + } + + const selected = findOption(input.options, providerKey); + if (selected) { + return { kind: "selected", selected, recoveredFromSandbox, recoveredModel }; + } + + if ( + isWindowsHostOllamaRequest(providerKey) && + input.isWindowsHostOllama && + !input.windowsHostOllamaSupported + ) { + return { + kind: "failure", + reason: { + kind: "unsupported-windows-host-ollama", + providerKey, + }, + }; + } + + const fallback = resolveProviderKeyFallback(input.options, providerKey, { + canUseWindowsHostOllama: input.isWindowsHostOllama && input.windowsHostOllamaSupported, + }); + if (fallback) { + return { + kind: "selected", + selected: fallback, + recoveredFromSandbox, + recoveredModel, + }; + } + + if (providerKey === "hermesProvider" && !input.hermesProviderAvailable) { + return { + kind: "failure", + reason: { kind: "hermes-provider-unavailable" }, + }; + } + + return { + kind: "failure", + reason: { + kind: "requested-provider-unavailable", + providerKey, + }, + }; +} From 7bffadf2530fbb0ff71f886bcb81ebf69c0cf827 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 13:15:23 -0700 Subject: [PATCH 5/7] refactor(onboard): drop unreachable provider recovery branch Signed-off-by: Carlos Villela --- src/lib/onboard.ts | 6 ------ src/lib/onboard/provider-selection.ts | 8 -------- 2 files changed, 14 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 74066a3e7f..e6b3ea3fc5 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4171,12 +4171,6 @@ async function setupNim( ); } break; - case "ambiguous-recorded-vllm": - console.error( - " Recorded provider 'vllm-local' is ambiguous (could be standalone vLLM or Local NIM).", - ); - console.error(" Set NEMOCLAW_PROVIDER explicitly (vllm or nim-local) and re-run."); - break; case "unsupported-windows-host-ollama": rejectWindowsHostOllama(providerSelection.reason.providerKey, isWindowsHostOllama); break; diff --git a/src/lib/onboard/provider-selection.ts b/src/lib/onboard/provider-selection.ts index ccae4a590d..03164ce359 100644 --- a/src/lib/onboard/provider-selection.ts +++ b/src/lib/onboard/provider-selection.ts @@ -15,9 +15,6 @@ export type ProviderSelectionFailureReason = recoveredKey: string; windowsHostKey: string | null; } - | { - kind: "ambiguous-recorded-vllm"; - } | { kind: "unsupported-windows-host-ollama"; providerKey: string; @@ -120,11 +117,6 @@ export function resolveRequestedProviderSelection( providerKey = recoveredKey; recoveredFromSandbox = true; recoveredModel = input.readRecordedModel(input.sandboxName); - } else if (recordedProvider === "vllm-local") { - return { - kind: "failure", - reason: { kind: "ambiguous-recorded-vllm" }, - }; } else { providerKey = "build"; } From 5ed50a80a4036a2e5ac4ea324dd08ed8d0ec4e70 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 13:37:03 -0700 Subject: [PATCH 6/7] refactor(onboard): extract provider prompt selection Signed-off-by: Carlos Villela --- src/lib/onboard.ts | 34 ++--- .../onboard/provider-selection-prompt.test.ts | 120 ++++++++++++++++++ src/lib/onboard/provider-selection-prompt.ts | 58 +++++++++ 3 files changed, 188 insertions(+), 24 deletions(-) create mode 100644 src/lib/onboard/provider-selection-prompt.test.ts create mode 100644 src/lib/onboard/provider-selection-prompt.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e6b3ea3fc5..1b62b5a60e 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -88,6 +88,9 @@ const { const { resolveRequestedProviderSelection, }: typeof import("./onboard/provider-selection") = require("./onboard/provider-selection"); +const { + promptForInferenceProviderSelection, +}: typeof import("./onboard/provider-selection-prompt") = require("./onboard/provider-selection-prompt"); const { isLinuxDockerDriverGatewayEnabled, }: typeof import("./onboard/docker-driver-platform") = require("./onboard/docker-driver-platform"); @@ -4197,31 +4200,14 @@ async function setupNim( : ` [non-interactive] Provider: ${selected.key}`, ); } else { - const suggestions: string[] = []; - if (vllmRunning) suggestions.push("vLLM"); - if (ollamaRunning) suggestions.push("Ollama"); - if (suggestions.length > 0) { - console.log( - ` Detected local inference option${suggestions.length > 1 ? "s" : ""}: ${suggestions.join(", ")}`, - ); - console.log(""); - } - - console.log(""); - console.log(" Select your inference provider:"); - options.forEach((o, i) => { - console.log(` ${i + 1}) ${o.label}`); + selected = await promptForInferenceProviderSelection({ + options, + vllmRunning, + ollamaRunning, + prompt, + log: console.log, + selectFromNumberedMenu: selectFromNumberedMenuOrExit, }); - console.log(""); - - const envProviderHint = (process.env.NEMOCLAW_PROVIDER || "").trim().toLowerCase(); - const envProviderIdx = envProviderHint - ? options.findIndex((o) => o.key.toLowerCase() === envProviderHint) - : -1; - const defaultIdx = - (envProviderIdx >= 0 ? envProviderIdx : options.findIndex((o) => o.key === "build")) + 1; - const choice = await prompt(` Choose [${defaultIdx}]: `); - selected = selectFromNumberedMenuOrExit(choice, defaultIdx, options); } if (!selected) { diff --git a/src/lib/onboard/provider-selection-prompt.test.ts b/src/lib/onboard/provider-selection-prompt.test.ts new file mode 100644 index 0000000000..1dcf1d8040 --- /dev/null +++ b/src/lib/onboard/provider-selection-prompt.test.ts @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { describe, it, vi } from "vitest"; + +import { promptForInferenceProviderSelection } from "../../../dist/lib/onboard/provider-selection-prompt"; + +const options = [ + { key: "build", label: "NVIDIA Endpoints" }, + { key: "openai", label: "OpenAI" }, + { key: "custom", label: "Other OpenAI-compatible endpoint" }, +]; + +function makeSelectSpy() { + return vi.fn((_: string, defaultIdx: number, entries: typeof options) => entries[defaultIdx - 1]); +} + +function makePrompt(reply: string) { + return vi.fn(async (_question: string) => reply); +} + +function makeLog() { + return vi.fn((_message?: string) => {}); +} + +describe("promptForInferenceProviderSelection", () => { + it("renders the provider menu and defaults to the build provider", async () => { + const prompt = makePrompt(""); + const log = makeLog(); + const selectFromNumberedMenu = makeSelectSpy(); + + const selected = await promptForInferenceProviderSelection({ + options, + vllmRunning: false, + ollamaRunning: false, + env: {}, + prompt, + log, + selectFromNumberedMenu, + }); + + assert.equal(selected.key, "build"); + assert.deepEqual( + log.mock.calls.map((call) => call[0]), + [ + "", + " Select your inference provider:", + " 1) NVIDIA Endpoints", + " 2) OpenAI", + " 3) Other OpenAI-compatible endpoint", + "", + ], + ); + assert.equal(prompt.mock.calls[0]?.[0], " Choose [1]: "); + assert.deepEqual(selectFromNumberedMenu.mock.calls[0], ["", 1, options]); + }); + + it("prints detected local inference suggestions before the menu", async () => { + const prompt = makePrompt("2"); + const log = makeLog(); + const selectFromNumberedMenu = makeSelectSpy(); + + await promptForInferenceProviderSelection({ + options, + vllmRunning: true, + ollamaRunning: true, + env: {}, + prompt, + log, + selectFromNumberedMenu, + }); + + assert.deepEqual( + log.mock.calls.slice(0, 3).map((call) => call[0]), + [" Detected local inference options: vLLM, Ollama", "", ""], + ); + }); + + it("uses NEMOCLAW_PROVIDER as the default choice when it matches an option", async () => { + const prompt = makePrompt(""); + const log = makeLog(); + const selectFromNumberedMenu = makeSelectSpy(); + + const selected = await promptForInferenceProviderSelection({ + options, + vllmRunning: false, + ollamaRunning: false, + env: { NEMOCLAW_PROVIDER: "OPENAI" }, + prompt, + log, + selectFromNumberedMenu, + }); + + assert.equal(selected.key, "openai"); + assert.equal(prompt.mock.calls[0]?.[0], " Choose [2]: "); + assert.deepEqual(selectFromNumberedMenu.mock.calls[0], ["", 2, options]); + }); + + it("falls back to the build option when the env hint is unavailable", async () => { + const prompt = makePrompt(""); + const log = makeLog(); + const selectFromNumberedMenu = makeSelectSpy(); + const reorderedOptions = [options[1], options[2], options[0]]; + + const selected = await promptForInferenceProviderSelection({ + options: reorderedOptions, + vllmRunning: false, + ollamaRunning: false, + env: { NEMOCLAW_PROVIDER: "missing-provider" }, + prompt, + log, + selectFromNumberedMenu, + }); + + assert.equal(selected.key, "build"); + assert.equal(prompt.mock.calls[0]?.[0], " Choose [3]: "); + assert.deepEqual(selectFromNumberedMenu.mock.calls[0], ["", 3, reorderedOptions]); + }); +}); diff --git a/src/lib/onboard/provider-selection-prompt.ts b/src/lib/onboard/provider-selection-prompt.ts new file mode 100644 index 0000000000..a1de4f1cc2 --- /dev/null +++ b/src/lib/onboard/provider-selection-prompt.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ProviderMenuChoice } from "./provider-menu"; + +export interface PromptForInferenceProviderSelectionInput { + options: T[]; + vllmRunning: boolean; + ollamaRunning: boolean; + env?: NodeJS.ProcessEnv; + prompt(question: string): Promise; + log(message?: string): void; + selectFromNumberedMenu(rawChoice: string, defaultIdx: number, options: T[]): T; +} + +function getDefaultProviderIndex(options: ProviderMenuChoice[], env: NodeJS.ProcessEnv): number { + const envProviderHint = (env.NEMOCLAW_PROVIDER || "").trim().toLowerCase(); + const envProviderIdx = envProviderHint + ? options.findIndex((option) => option.key.toLowerCase() === envProviderHint) + : -1; + return ( + (envProviderIdx >= 0 ? envProviderIdx : options.findIndex((option) => option.key === "build")) + + 1 + ); +} + +function getDetectedLocalInferenceSuggestions(input: { + vllmRunning: boolean; + ollamaRunning: boolean; +}): string[] { + const suggestions: string[] = []; + if (input.vllmRunning) suggestions.push("vLLM"); + if (input.ollamaRunning) suggestions.push("Ollama"); + return suggestions; +} + +export async function promptForInferenceProviderSelection( + input: PromptForInferenceProviderSelectionInput, +): Promise { + const suggestions = getDetectedLocalInferenceSuggestions(input); + if (suggestions.length > 0) { + input.log( + ` Detected local inference option${suggestions.length > 1 ? "s" : ""}: ${suggestions.join(", ")}`, + ); + input.log(""); + } + + input.log(""); + input.log(" Select your inference provider:"); + input.options.forEach((option, index) => { + input.log(` ${index + 1}) ${option.label}`); + }); + input.log(""); + + const defaultIdx = getDefaultProviderIndex(input.options, input.env ?? process.env); + const choice = await input.prompt(` Choose [${defaultIdx}]: `); + return input.selectFromNumberedMenu(choice, defaultIdx, input.options); +} From 20bf2b1378d9de7936196fd2d744b462fdb4027d Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 13:55:37 -0700 Subject: [PATCH 7/7] refactor(onboard): extract provider selection failure reporting Signed-off-by: Carlos Villela --- src/lib/onboard.ts | 46 ++------- .../provider-selection-failure.test.ts | 98 +++++++++++++++++++ src/lib/onboard/provider-selection-failure.ts | 49 ++++++++++ 3 files changed, 156 insertions(+), 37 deletions(-) create mode 100644 src/lib/onboard/provider-selection-failure.test.ts create mode 100644 src/lib/onboard/provider-selection-failure.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1b62b5a60e..361df2c43c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -88,6 +88,9 @@ const { const { resolveRequestedProviderSelection, }: typeof import("./onboard/provider-selection") = require("./onboard/provider-selection"); +const { + reportProviderSelectionFailure, +}: typeof import("./onboard/provider-selection-failure") = require("./onboard/provider-selection-failure"); const { promptForInferenceProviderSelection, }: typeof import("./onboard/provider-selection-prompt") = require("./onboard/provider-selection-prompt"); @@ -4152,43 +4155,12 @@ async function setupNim( readRecordedModel, }); if (providerSelection.kind === "failure") { - switch (providerSelection.reason.kind) { - case "wsl-recorded-ollama-windows-host": - console.error( - ` Recorded provider '${providerSelection.reason.recordedProvider}' (WSL Ollama) is not available in this environment.`, - ); - console.error( - " Hint: Windows-host Ollama is reachable here; re-run with NEMOCLAW_PROVIDER=ollama to use it explicitly.", - ); - break; - case "recorded-provider-unavailable": - console.error( - ` Recorded provider '${providerSelection.reason.recordedProvider}' is not available in this environment.`, - ); - console.error( - " Set NEMOCLAW_PROVIDER explicitly, or restore the missing local-inference dependency.", - ); - if (providerSelection.reason.windowsHostKey) { - console.error( - ` Hint: Windows-host Ollama is available here — re-run with NEMOCLAW_PROVIDER=${providerSelection.reason.windowsHostKey} to use it.`, - ); - } - break; - case "unsupported-windows-host-ollama": - rejectWindowsHostOllama(providerSelection.reason.providerKey, isWindowsHostOllama); - break; - case "hermes-provider-unavailable": - console.error(" Hermes Provider is only available when onboarding Hermes Agent."); - console.error( - " Re-run with `nemohermes onboard` or `nemoclaw onboard --agent hermes`.", - ); - break; - case "requested-provider-unavailable": - console.error( - ` Requested provider '${providerSelection.reason.providerKey}' is not available in this environment.`, - ); - break; - } + reportProviderSelectionFailure({ + reason: providerSelection.reason, + isWindowsHostOllama, + rejectWindowsHostOllama, + writeError: (message) => console.error(message), + }); process.exit(1); } selected = providerSelection.selected; diff --git a/src/lib/onboard/provider-selection-failure.test.ts b/src/lib/onboard/provider-selection-failure.test.ts new file mode 100644 index 0000000000..51d0b739a8 --- /dev/null +++ b/src/lib/onboard/provider-selection-failure.test.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { reportProviderSelectionFailure } from "../../../dist/lib/onboard/provider-selection-failure"; + +function report(overrides: Partial[0]>): { + errors: string[]; + rejected: Array<{ providerKey: string; windowsHostSelected: boolean }>; +} { + const errors: string[] = []; + const rejected: Array<{ providerKey: string; windowsHostSelected: boolean }> = []; + + reportProviderSelectionFailure({ + reason: { kind: "requested-provider-unavailable", providerKey: "missing" }, + isWindowsHostOllama: false, + rejectWindowsHostOllama: (providerKey, windowsHostSelected) => { + rejected.push({ providerKey, windowsHostSelected }); + return true; + }, + writeError: (message) => errors.push(message), + ...overrides, + }); + + return { errors, rejected }; +} + +describe("reportProviderSelectionFailure", () => { + it("reports recorded WSL Ollama recovery as unavailable on Windows host", () => { + const { errors, rejected } = report({ + reason: { + kind: "wsl-recorded-ollama-windows-host", + recordedProvider: "ollama-local", + }, + }); + + assert.deepEqual(rejected, []); + assert.deepEqual(errors, [ + " Recorded provider 'ollama-local' (WSL Ollama) is not available in this environment.", + " Hint: Windows-host Ollama is reachable here; re-run with NEMOCLAW_PROVIDER=ollama to use it explicitly.", + ]); + }); + + it("adds a Windows-host hint when recorded provider recovery has a host action", () => { + const { errors, rejected } = report({ + reason: { + kind: "recorded-provider-unavailable", + recordedProvider: "ollama-local", + recoveredKey: "ollama", + windowsHostKey: "start-windows-ollama", + }, + }); + + assert.deepEqual(rejected, []); + assert.deepEqual(errors, [ + " Recorded provider 'ollama-local' is not available in this environment.", + " Set NEMOCLAW_PROVIDER explicitly, or restore the missing local-inference dependency.", + " Hint: Windows-host Ollama is available here — re-run with NEMOCLAW_PROVIDER=start-windows-ollama to use it.", + ]); + }); + + it("delegates unsupported Windows-host Ollama failures to the rejection helper", () => { + const { errors, rejected } = report({ + reason: { kind: "unsupported-windows-host-ollama", providerKey: "start-windows-ollama" }, + isWindowsHostOllama: true, + }); + + assert.deepEqual(errors, []); + assert.deepEqual(rejected, [ + { providerKey: "start-windows-ollama", windowsHostSelected: true }, + ]); + }); + + it("reports Hermes Provider when requested for an unsupported agent", () => { + const { errors, rejected } = report({ + reason: { kind: "hermes-provider-unavailable" }, + }); + + assert.deepEqual(rejected, []); + assert.deepEqual(errors, [ + " Hermes Provider is only available when onboarding Hermes Agent.", + " Re-run with `nemohermes onboard` or `nemoclaw onboard --agent hermes`.", + ]); + }); + + it("reports unavailable requested providers", () => { + const { errors, rejected } = report({ + reason: { kind: "requested-provider-unavailable", providerKey: "missing-provider" }, + }); + + assert.deepEqual(rejected, []); + assert.deepEqual(errors, [ + " Requested provider 'missing-provider' is not available in this environment.", + ]); + }); +}); diff --git a/src/lib/onboard/provider-selection-failure.ts b/src/lib/onboard/provider-selection-failure.ts new file mode 100644 index 0000000000..a5501fcd12 --- /dev/null +++ b/src/lib/onboard/provider-selection-failure.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ProviderSelectionFailureReason } from "./provider-selection"; + +export interface ReportProviderSelectionFailureInput { + reason: ProviderSelectionFailureReason; + isWindowsHostOllama: boolean; + rejectWindowsHostOllama(providerKey: string, windowsHostSelected: boolean): boolean; + writeError(message: string): void; +} + +export function reportProviderSelectionFailure(input: ReportProviderSelectionFailureInput): void { + switch (input.reason.kind) { + case "wsl-recorded-ollama-windows-host": + input.writeError( + ` Recorded provider '${input.reason.recordedProvider}' (WSL Ollama) is not available in this environment.`, + ); + input.writeError( + " Hint: Windows-host Ollama is reachable here; re-run with NEMOCLAW_PROVIDER=ollama to use it explicitly.", + ); + break; + case "recorded-provider-unavailable": + input.writeError( + ` Recorded provider '${input.reason.recordedProvider}' is not available in this environment.`, + ); + input.writeError( + " Set NEMOCLAW_PROVIDER explicitly, or restore the missing local-inference dependency.", + ); + if (input.reason.windowsHostKey) { + input.writeError( + ` Hint: Windows-host Ollama is available here — re-run with NEMOCLAW_PROVIDER=${input.reason.windowsHostKey} to use it.`, + ); + } + break; + case "unsupported-windows-host-ollama": + input.rejectWindowsHostOllama(input.reason.providerKey, input.isWindowsHostOllama); + break; + case "hermes-provider-unavailable": + input.writeError(" Hermes Provider is only available when onboarding Hermes Agent."); + input.writeError(" Re-run with `nemohermes onboard` or `nemoclaw onboard --agent hermes`."); + break; + case "requested-provider-unavailable": + input.writeError( + ` Requested provider '${input.reason.providerKey}' is not available in this environment.`, + ); + break; + } +}