diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 7e9ac2af4a..efcebb4d81 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -87,8 +87,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"); @@ -3801,93 +3801,62 @@ 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 "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..03164ce359 --- /dev/null +++ b/src/lib/onboard/provider-selection.ts @@ -0,0 +1,170 @@ +// 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: "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 { + 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, + }, + }; +}