From 6111da4b6ec288a9ce4bd4492863f221a458ee5b Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 6 Jul 2026 11:23:46 -0700 Subject: [PATCH 1/3] perf(test): reduce provider-selection process isolation Signed-off-by: Carlos Villela --- ci/test-file-size-budget.json | 2 +- src/lib/onboard.ts | 447 ++------------ src/lib/onboard/setup-nim-flow.test.ts | 273 +++++++++ src/lib/onboard/setup-nim-flow.ts | 559 +++++++++++++++++ test/onboard-selection.test.ts | 793 ++++++++----------------- 5 files changed, 1121 insertions(+), 953 deletions(-) create mode 100644 src/lib/onboard/setup-nim-flow.test.ts create mode 100644 src/lib/onboard/setup-nim-flow.ts diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index efa6090688e..ba082790d1e 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -10,7 +10,7 @@ "test/install-preflight.test.ts": 3934, "test/nemoclaw-start.test.ts": 4827, "test/onboard-messaging.test.ts": 2062, - "test/onboard-selection.test.ts": 6146, + "test/onboard-selection.test.ts": 5835, "test/onboard.test.ts": 4057, "test/policies.test.ts": 2332 } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0faf51df834..5b66ff2fea4 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -9,8 +9,6 @@ const { envInt, LOCAL_INFERENCE_TIMEOUT_SECS, }: typeof import("./onboard/env") = require("./onboard/env"); -type ProviderSelectionResult = - import("./onboard/machine/handlers/provider-inference").ProviderSelectionResult; const { agentProductName, cliDisplayName, @@ -28,9 +26,9 @@ const { clearNimContainerBeforeRetry, createNvidiaFeaturedModelSession, createRemoteModelValidator, - requireProviderChoice, resolveCompatibleEndpointInput, }: typeof import("./onboard/setup-nim-selection") = require("./onboard/setup-nim-selection"); +const setupNimFlow: typeof import("./onboard/setup-nim-flow") = require("./onboard/setup-nim-flow"); const setupNimOllama: typeof import("./onboard/setup-nim-ollama") = require("./onboard/setup-nim-ollama"); const inferenceInputCapability = require("./onboard/inference-input-capability"); const reasoningMode: typeof import("./onboard/reasoning-mode") = require("./onboard/reasoning-mode"); @@ -107,16 +105,7 @@ const { const { getSelectionDrift, }: typeof import("./onboard/selection-drift") = require("./onboard/selection-drift"); -const { - resolveRequestedProviderSelection, -}: typeof import("./onboard/provider-selection") = require("./onboard/provider-selection"); const providerKeyBridge: typeof import("./onboard/provider-key-bridge") = require("./onboard/provider-key-bridge"); -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"); const { isLinuxDockerDriverGatewayEnabled, }: typeof import("./onboard/docker-driver-platform") = require("./onboard/docker-driver-platform"); @@ -231,9 +220,6 @@ const { checkOllamaPortsOrWarn, 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"); @@ -3760,400 +3746,61 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs, return "selected"; } -// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. -async function setupNim(gpu: ReturnType, sandboxName: string | null = null, agent: AgentDefinition | null = null, recoverProvider = true, rebuildRegistryInferenceRoute: OnboardOptions["rebuildRegistryInferenceRoute"] = null): Promise { - step(3, 8, "Configuring inference provider"); - - let model: string | typeof BACK_TO_SELECTION | null = null; - let provider: string = REMOTE_PROVIDER_CONFIG.build.providerName; - let nimContainer: string | null = null; - let endpointUrl: string | null = REMOTE_PROVIDER_CONFIG.build.endpointUrl; - let credentialEnv: string | null = REMOTE_PROVIDER_CONFIG.build.credentialEnv; - let hermesAuthMethod: HermesAuthMethod | null = null; - let hermesToolGateways: string[] = []; - let preferredInferenceApi: string | null = null; - let compatibleEndpointReasoning: string | null = null; - let allowToolsIncompatible = false; - let reuseGatewayCredential = false; - const nvidiaFeaturedModels = createNvidiaFeaturedModelSession(); - - 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; - // biome-ignore format: keep the monolithic entrypoint net-neutral; route logic lives in rebuild-route-handoff.ts. - const recoveredRegistryRoute = rebuildRegistryInferenceRoute?.sandboxName === sandboxName && rebuildRegistryInferenceRoute.route.source === "registry" ? rebuildRegistryInferenceRoute.route : null; - const agentProviderOptions = getAgentInferenceProviderOptions(agent); +export type SetupNimDeps = import("./onboard/setup-nim-flow").SetupNimFlowDeps; +export type SetupNim = import("./onboard/setup-nim-flow").SetupNim; - const blueprintRouterCfg = loadBlueprintProfile("routed"); - const { options, hermesProviderAvailable } = buildInferenceProviderMenu({ +function getSetupNimDeps(): SetupNimDeps { + return { remoteProviderConfig: REMOTE_PROVIDER_CONFIG, - agentProviderOptions, experimental: EXPERIMENTAL, - gpuNimCapable, - hasOllama, - ollamaRunning, - ollamaHost, ollamaPort: OLLAMA_PORT, - isWsl: isWslHost, - hasWindowsOllama, - isWindowsHostOllama, - windowsHostLabelSuffix: windowsHostOllamaDockerRequirement.supported - ? "" - : windowsHostOllamaDockerRequirement.labelSuffix, - windowsHostInstallLabel: windowsHostOllamaDockerRequirement.installLabel, - windowsHostStartLabel: windowsHostOllamaDockerRequirement.startLabel, - windowsOllamaReachable, - winOllamaLoopbackOnly, - ollamaInstallEntry: ollamaInstallMenu.entry, - vllmEntries, - routedEnabled: blueprintRouterCfg?.router?.enabled === true, - }); - - function rejectWindowsHostOllama(providerKey: string, windowsHostSelected: boolean): boolean { - return rejectUnsupportedWindowsHostOllama( - windowsHostOllamaDockerRequirement, - providerKey, - windowsHostSelected, - isNonInteractive, - abortNonInteractive, - ); - } - - if (options.length > 1) { - selectionLoop: while (true) { - let selected: ProviderChoice | undefined; - // Hoisted so downstream model-selection branches can fall back to a - // recorded model from the same recovery decision. - let recoveredFromSandbox = false; - let recoveredModel: string | null = null; - hermesAuthMethod = null; - - if (isNonInteractive() || requestedProvider) { - const providerSelection = resolveRequestedProviderSelection({ - options, - requestedProvider, - sandboxName, - remoteProviderConfig: REMOTE_PROVIDER_CONFIG, - isWsl: isWslHost, - isWindowsHostOllama, - windowsHostOllamaSupported: windowsHostOllamaDockerRequirement.supported, - hermesProviderAvailable, - // biome-ignore format: the pre-delete route remains authoritative after its registry row is removed. - readRecordedProvider: recoverProvider ? (name) => recoveredRegistryRoute?.provider ?? readRecordedProvider(name) : () => null, - readRecordedNimContainer: recoverProvider ? readRecordedNimContainer : () => null, - // biome-ignore format: provider and model must come from the same validated rebuild handoff. - readRecordedModel: recoverProvider ? (name) => recoveredRegistryRoute?.model ?? readRecordedModel(name) : () => null, - }); - if (providerSelection.kind === "failure") { - reportProviderSelectionFailure({ - reason: providerSelection.reason, - isWindowsHostOllama, - rejectWindowsHostOllama, - writeError: (message) => console.error(message), - }); - process.exit(1); - } - selected = providerSelection.selected; - recoveredFromSandbox = providerSelection.recoveredFromSandbox; - recoveredModel = providerSelection.recoveredModel; - note( - recoveredFromSandbox - ? ` [non-interactive] Provider: ${selected.key} (recovered from sandbox '${sandboxName}')` - : ` [non-interactive] Provider: ${selected.key}`, - ); - } else { - selected = await promptForInferenceProviderSelection({ - options, - vllmRunning, - ollamaRunning, - prompt, - log: console.log, - selectFromNumberedMenu: selectFromNumberedMenuOrExit, - }); - } - - selected = requireProviderChoice(selected); - if (selected.key !== "hermesProvider") { - hermesAuthMethod = null; - hermesToolGateways = []; - } - - if (REMOTE_PROVIDER_CONFIG[selected.key]) { - const state: SetupNimSelectionState = { - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - preferredInferenceApi, - compatibleEndpointReasoning, - nimContainer, - allowToolsIncompatible, - nvidiaFeaturedModels, - }; - // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. - const result = await handleRemoteProviderSelection( - { selected, requestedModel, recoveredFromSandbox, recoveredModel, sandboxName }, - state, - recoveredRegistryRoute, - ); - ({ - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - preferredInferenceApi, - allowToolsIncompatible, - } = state); - compatibleEndpointReasoning = state.compatibleEndpointReasoning ?? null; - reuseGatewayCredential = state.reuseGatewayCredentialWithoutLocalKey === true; - if (result === "retry-selection") continue selectionLoop; - break; - } else if (selected.key === "nim-local") { - const state: SetupNimSelectionState = { - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - preferredInferenceApi, - nimContainer, - allowToolsIncompatible, - }; - const result = await handleNimLocalSelection( - gpu, - { requestedModel, recoveredFromSandbox, recoveredModel }, - state, - ); - ({ - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - preferredInferenceApi, - nimContainer, - } = state); - if (result === "retry-selection") continue selectionLoop; - break; - } else if (selected.key === "ollama") { - if (rejectWindowsHostOllama(selected.key, isWindowsHostOllama)) { - continue selectionLoop; - } - const state: SetupNimSelectionState = { - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - preferredInferenceApi, - nimContainer, - allowToolsIncompatible, - }; - const result = await handleRunningOllamaSelection( - gpu, - requestedModel, - recoveredFromSandbox ? recoveredModel : null, - ollamaRunning, - state, - ); - ({ - model, - provider, - endpointUrl, - credentialEnv, - preferredInferenceApi, - allowToolsIncompatible, - } = state); - if (result === "retry-selection") continue selectionLoop; - break; - } else if (["start-windows-ollama", "install-windows-ollama"].includes(selected.key)) { - if (rejectWindowsHostOllama(selected.key, true)) { - continue selectionLoop; - } - const state: SetupNimSelectionState = { - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - preferredInferenceApi, - nimContainer, - allowToolsIncompatible, - }; - const result = await handleWindowsHostOllamaSelection( - gpu, - selected.key, - requestedModel, - windowsOllamaReachable, - winOllamaLoopbackOnly, - winOllamaInstalledPath, - state, - ); - ({ - model, - provider, - endpointUrl, - credentialEnv, - preferredInferenceApi, - allowToolsIncompatible, - } = state); - if (result === "retry-selection") continue selectionLoop; - break; - } else if (selected.key === "install-ollama") { - const state: SetupNimSelectionState = { - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - preferredInferenceApi, - nimContainer, - allowToolsIncompatible, - }; - const result = await handleInstallOllamaSelection( - gpu, - requestedModel, - recoveredFromSandbox ? recoveredModel : null, - state, - ollamaInstallMenu, - ); - ({ - model, - provider, - endpointUrl, - credentialEnv, - preferredInferenceApi, - allowToolsIncompatible, - } = state); - if (result === "retry-selection") continue selectionLoop; - break; - } else if (selected.key === "install-vllm") { - if (!vllmProfile) { - console.error(" No vLLM install profile available for this host."); - if (isNonInteractive()) process.exit(1); - continue selectionLoop; - } - const result = await installVllm(vllmProfile, { - hasImage: hasVllmImage, - nonInteractive: isNonInteractive(), - promptFn: prompt, - }); - if (!result.ok) { - if (isNonInteractive()) abortNonInteractive("vLLM install failed. See errors above."); - continue selectionLoop; - } - // Fall through to the same provider/model setup as the running-vLLM - // branch. Mutate selected.key so the existing "vllm" branch picks up. - selected = { key: "vllm", label: `Local vLLM (localhost:${VLLM_PORT}) — running` }; - // intentional fall-through to the next branch - } - if (selected.key === "vllm") { - const state: SetupNimSelectionState = { - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - preferredInferenceApi, - nimContainer, - allowToolsIncompatible, - }; - const result = await handleVllmSelection(state); - ({ - model, - provider, - endpointUrl, - credentialEnv, - preferredInferenceApi, - nimContainer, - allowToolsIncompatible, - } = state); - if (result === "retry-selection") continue selectionLoop; - break; - } else if (selected.key === "routed") { - const state: SetupNimSelectionState = { - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - preferredInferenceApi, - nimContainer, - allowToolsIncompatible, - }; - const result = await handleRoutedSelection(state); - ({ - model, - provider, - endpointUrl, - credentialEnv, - preferredInferenceApi, - nimContainer, - allowToolsIncompatible, - } = state); - if (result === "retry-selection") continue selectionLoop; - break; - } - } - } - - if (provider !== "compatible-endpoint") - compatibleEndpointReasoning = reasoningMode.clearCompatibleEndpointReasoning(); - const selectedModel = isBackToSelection(model) ? null : model; - await inferenceInputCapability.maybePromptForInferenceInputCapability(selectedModel, { + vllmPort: VLLM_PORT, + step, isNonInteractive, + getNonInteractiveProvider, + getNonInteractiveModel, + createNvidiaFeaturedModelSession, + detectInferenceProviderHostState, + getAgentInferenceProviderOptions, + loadRoutedProfile: () => loadBlueprintProfile("routed"), + readRecordedProvider, + readRecordedNimContainer, + readRecordedModel, prompt, - }); - return { - model: selectedModel, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - preferredInferenceApi: inferenceConfig.coerceAgentInferenceApi(agent, preferredInferenceApi), - compatibleEndpointReasoning, - nimContainer, - allowToolsIncompatible, - skipHostInferenceSmoke: reuseGatewayCredential, - reuseGatewayCredentialWithoutLocalKey: reuseGatewayCredential, + selectFromNumberedMenu: selectFromNumberedMenuOrExit, + note, + log: (message = "") => console.log(message), + error: (message) => console.error(message), + exitProcess: (code): never => process.exit(code), + abortNonInteractive, + rejectWindowsHostOllama: (requirement, providerKey, windowsHostSelected) => + rejectUnsupportedWindowsHostOllama( + requirement, + providerKey, + windowsHostSelected, + isNonInteractive, + abortNonInteractive, + ), + handleRemoteProviderSelection, + handleNimLocalSelection, + handleRunningOllamaSelection, + handleWindowsHostOllamaSelection, + handleInstallOllamaSelection, + installVllm, + handleVllmSelection, + handleRoutedSelection, + coerceAgentInferenceApi: inferenceConfig.coerceAgentInferenceApi, + clearCompatibleEndpointReasoning: reasoningMode.clearCompatibleEndpointReasoning, + maybePromptForInferenceInputCapability: (model) => + inferenceInputCapability.maybePromptForInferenceInputCapability(model, { + isNonInteractive, + prompt, + }), }; } +const setupNim = setupNimFlow.createSetupNim(getSetupNimDeps()); + // ── Step 4: Inference provider ─────────────────────────────────── function getSetupInferenceDeps(): SetupInferenceDeps { diff --git a/src/lib/onboard/setup-nim-flow.test.ts b/src/lib/onboard/setup-nim-flow.test.ts new file mode 100644 index 00000000000..78126f76edc --- /dev/null +++ b/src/lib/onboard/setup-nim-flow.test.ts @@ -0,0 +1,273 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getWindowsHostOllamaDockerRequirement } from "./local-inference-topology"; +import type { InferenceProviderHostState } from "./provider-host-state"; +import { createSetupNim, type SetupNimFlowDeps } from "./setup-nim-flow"; + +const REMOTE_PROVIDER_CONFIG: SetupNimFlowDeps["remoteProviderConfig"] = { + build: { + label: "NVIDIA Endpoints", + providerName: "nvidia-prod", + endpointUrl: "https://integrate.api.nvidia.com/v1", + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + }, + openai: { + label: "OpenAI", + providerName: "openai-api", + endpointUrl: "https://api.openai.com/v1", + credentialEnv: "OPENAI_API_KEY", + }, + custom: { + label: "Other OpenAI-compatible endpoint", + providerName: "compatible-endpoint", + endpointUrl: "", + credentialEnv: "COMPATIBLE_API_KEY", + }, + anthropic: { + label: "Anthropic", + providerName: "anthropic-api", + endpointUrl: "https://api.anthropic.com", + credentialEnv: "ANTHROPIC_API_KEY", + }, + anthropicCompatible: { + label: "Other Anthropic-compatible endpoint", + providerName: "compatible-anthropic-endpoint", + endpointUrl: "", + credentialEnv: "ANTHROPIC_COMPATIBLE_API_KEY", + }, + gemini: { + label: "Google Gemini", + providerName: "gemini-api", + endpointUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + credentialEnv: "GEMINI_API_KEY", + }, +}; + +function makeHostState( + overrides: Partial = {}, +): InferenceProviderHostState { + return { + hasOllama: false, + ollamaHost: null, + ollamaRunning: false, + isWindowsHostOllama: false, + isWsl: false, + hasWindowsOllama: false, + winOllamaInstalledPath: "", + winOllamaLoopbackOnly: false, + windowsOllamaReachable: false, + windowsHostOllamaDockerRequirement: getWindowsHostOllamaDockerRequirement(null), + vllmRunning: false, + vllmProfile: null, + hasVllmImage: false, + vllmEntries: [], + ollamaInstallMenu: { entry: null, hasUpgradableOllama: false }, + gpuNimCapable: false, + ...overrides, + }; +} + +function unexpected(name: string): never { + throw new Error(`Unexpected ${name} call`); +} + +function selectFromNumberedMenu( + rawChoice: string, + defaultIndex: number, + options: Parameters[2], +) { + const selectedIndex = rawChoice.trim() ? Number(rawChoice) : defaultIndex; + const selected = options[selectedIndex - 1]; + expect(selected, `Invalid test provider selection: ${rawChoice}`).toBeDefined(); + return selected!; +} + +function makeDeps(overrides: Partial = {}): SetupNimFlowDeps { + const defaults: SetupNimFlowDeps = { + remoteProviderConfig: REMOTE_PROVIDER_CONFIG, + experimental: false, + ollamaPort: 11434, + vllmPort: 8000, + step: vi.fn(), + isNonInteractive: () => false, + getNonInteractiveProvider: () => null, + getNonInteractiveModel: () => null, + createNvidiaFeaturedModelSession: () => ({ + select: async () => unexpected("featured model selection"), + }), + detectInferenceProviderHostState: () => makeHostState(), + getAgentInferenceProviderOptions: () => [], + loadRoutedProfile: () => null, + readRecordedProvider: () => null, + readRecordedNimContainer: () => null, + readRecordedModel: () => null, + rejectWindowsHostOllama: () => false, + prompt: async () => "", + selectFromNumberedMenu, + note: vi.fn(), + log: vi.fn(), + error: vi.fn(), + exitProcess: (code) => unexpected(`exitProcess(${code})`), + abortNonInteractive: (message) => unexpected(`abortNonInteractive(${message})`), + handleRemoteProviderSelection: async () => unexpected("remote provider selection"), + handleNimLocalSelection: async () => unexpected("local NIM selection"), + handleRunningOllamaSelection: async () => unexpected("running Ollama selection"), + handleWindowsHostOllamaSelection: async () => unexpected("Windows Ollama selection"), + handleInstallOllamaSelection: async () => unexpected("Ollama install selection"), + installVllm: async () => unexpected("vLLM install"), + handleVllmSelection: async () => unexpected("vLLM selection"), + handleRoutedSelection: async () => unexpected("routed selection"), + coerceAgentInferenceApi: (_agent, preferredInferenceApi) => preferredInferenceApi, + clearCompatibleEndpointReasoning: () => null, + maybePromptForInferenceInputCapability: vi.fn(async () => {}), + }; + return { ...defaults, ...overrides }; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("createSetupNim", () => { + it("announces detected Ollama but still prompts and defaults to NVIDIA Endpoints", async () => { + vi.stubEnv("NEMOCLAW_PROVIDER", ""); + const step = vi.fn(); + const log = vi.fn(); + const prompt = vi.fn(async () => ""); + const maybePromptForInferenceInputCapability = vi.fn(async () => {}); + const handleRemoteProviderSelection = vi.fn( + async ({ selected }, state) => { + expect(selected.key).toBe("build"); + state.model = "nvidia/nemotron-3-super-120b-a12b"; + state.provider = "nvidia-prod"; + state.endpointUrl = "https://integrate.api.nvidia.com/v1"; + state.credentialEnv = "NVIDIA_INFERENCE_API_KEY"; + state.preferredInferenceApi = "openai-completions"; + return "selected"; + }, + ); + const setupNim = createSetupNim( + makeDeps({ + step, + log, + prompt, + maybePromptForInferenceInputCapability, + detectInferenceProviderHostState: () => + makeHostState({ + hasOllama: true, + ollamaHost: "127.0.0.1", + ollamaRunning: true, + }), + handleRemoteProviderSelection, + }), + ); + + const result = await setupNim(null); + + expect(step).toHaveBeenCalledWith(3, 8, "Configuring inference provider"); + expect(log).toHaveBeenCalledWith(" Detected local inference option: Ollama"); + expect(prompt).toHaveBeenCalledOnce(); + expect(prompt).toHaveBeenCalledWith(" Choose [1]: "); + expect(handleRemoteProviderSelection).toHaveBeenCalledOnce(); + expect(maybePromptForInferenceInputCapability).toHaveBeenCalledWith( + "nvidia/nemotron-3-super-120b-a12b", + ); + expect(result).toEqual({ + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-prod", + endpointUrl: "https://integrate.api.nvidia.com/v1", + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + hermesAuthMethod: null, + hermesToolGateways: [], + preferredInferenceApi: "openai-completions", + compatibleEndpointReasoning: null, + nimContainer: null, + allowToolsIncompatible: false, + skipHostInferenceSmoke: false, + reuseGatewayCredentialWithoutLocalKey: false, + }); + }); + + it("re-enters provider selection when a handler requests a retry", async () => { + vi.stubEnv("NEMOCLAW_PROVIDER", ""); + const prompt = vi.fn(async () => ""); + const handleRemoteProviderSelection = vi.fn( + async (_args, state) => { + state.model = "final-model"; + state.provider = "nvidia-prod"; + state.endpointUrl = "https://integrate.api.nvidia.com/v1"; + state.credentialEnv = "NVIDIA_INFERENCE_API_KEY"; + return "selected"; + }, + ); + handleRemoteProviderSelection.mockResolvedValueOnce("retry-selection"); + const setupNim = createSetupNim( + makeDeps({ + prompt, + handleRemoteProviderSelection, + }), + ); + + const result = await setupNim(null); + + expect(prompt).toHaveBeenCalledTimes(2); + expect(handleRemoteProviderSelection).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ model: "final-model", provider: "nvidia-prod" }); + }); + + it("recovers a recorded provider and model without prompting in non-interactive mode", async () => { + const prompt = vi.fn(async () => unexpected("interactive provider prompt")); + const note = vi.fn(); + const readRecordedProvider = vi.fn(() => "openai-api"); + const readRecordedNimContainer = vi.fn(() => null); + const readRecordedModel = vi.fn(() => "gpt-4.1"); + const handleRemoteProviderSelection = vi.fn( + async (args, state) => { + expect(args).toMatchObject({ + selected: { key: "openai", label: "OpenAI" }, + requestedModel: null, + recoveredFromSandbox: true, + recoveredModel: "gpt-4.1", + sandboxName: "existing-sandbox", + }); + state.model = args.recoveredModel; + state.provider = "openai-api"; + state.endpointUrl = "https://api.openai.com/v1"; + state.credentialEnv = "OPENAI_API_KEY"; + state.preferredInferenceApi = "openai-responses"; + return "selected"; + }, + ); + const setupNim = createSetupNim( + makeDeps({ + isNonInteractive: () => true, + prompt, + note, + readRecordedProvider, + readRecordedNimContainer, + readRecordedModel, + handleRemoteProviderSelection, + }), + ); + + const result = await setupNim(null, "existing-sandbox"); + + expect(prompt).not.toHaveBeenCalled(); + expect(readRecordedProvider).toHaveBeenCalledWith("existing-sandbox"); + expect(readRecordedNimContainer).toHaveBeenCalledWith("existing-sandbox"); + expect(readRecordedModel).toHaveBeenCalledWith("existing-sandbox"); + expect(note).toHaveBeenCalledWith( + " [non-interactive] Provider: openai (recovered from sandbox 'existing-sandbox')", + ); + expect(result).toMatchObject({ + model: "gpt-4.1", + provider: "openai-api", + endpointUrl: "https://api.openai.com/v1", + credentialEnv: "OPENAI_API_KEY", + preferredInferenceApi: "openai-responses", + }); + }); +}); diff --git a/src/lib/onboard/setup-nim-flow.ts b/src/lib/onboard/setup-nim-flow.ts new file mode 100644 index 00000000000..490ef8a9922 --- /dev/null +++ b/src/lib/onboard/setup-nim-flow.ts @@ -0,0 +1,559 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentDefinition } from "../agent/defs"; +import type { VllmProfile } from "../inference/vllm"; +import { isBackToSelection } from "../navigation"; +import type { HermesAuthMethod } from "./hermes-auth"; +import type { ProviderSelectionResult } from "./machine/handlers/provider-inference"; +import type { NvidiaFeaturedModelSession } from "./nvidia-featured-model-selection"; +import type { InferenceProviderHostGpu, InferenceProviderHostState } from "./provider-host-state"; +import { buildInferenceProviderMenu, type ProviderMenuChoice } from "./provider-menu"; +import { resolveRequestedProviderSelection } from "./provider-selection"; +import { reportProviderSelectionFailure } from "./provider-selection-failure"; +import { promptForInferenceProviderSelection } from "./provider-selection-prompt"; +import type { RebuildRouteHandoff, RegistryInferenceRoute } from "./rebuild-route-handoff"; +import type { SetupNimSelectionState as BaseSetupNimSelectionState } from "./setup-nim-selection"; + +export type SetupNimGpu = ReturnType; +export type SetupNimSelectionState = BaseSetupNimSelectionState; +export type SetupNimSelectionResult = "selected" | "retry-selection"; + +export interface SetupNimRemoteProviderConfigEntry { + label: string; + providerName: string; + endpointUrl: string; + credentialEnv: string; +} + +export interface SetupNimRemoteSelectionArgs { + selected: ProviderMenuChoice; + requestedModel: string | null; + recoveredFromSandbox: boolean; + recoveredModel: string | null; + sandboxName: string | null; +} + +export type SetupNim = ( + gpu: SetupNimGpu, + sandboxName?: string | null, + agent?: AgentDefinition | null, + recoverProvider?: boolean, + rebuildRegistryInferenceRoute?: RebuildRouteHandoff | null, +) => Promise; + +export interface SetupNimFlowDeps { + remoteProviderConfig: Record; + experimental: boolean; + ollamaPort: number; + vllmPort: number; + step(current: number, total: number, label: string): void; + isNonInteractive(): boolean; + getNonInteractiveProvider(): string | null; + getNonInteractiveModel(providerKey: string): string | null; + createNvidiaFeaturedModelSession(): NvidiaFeaturedModelSession; + detectInferenceProviderHostState(input: { + gpu: InferenceProviderHostGpu | null | undefined; + experimental: boolean; + }): InferenceProviderHostState; + getAgentInferenceProviderOptions(agent: AgentDefinition | null | undefined): string[]; + loadRoutedProfile(): { router?: { enabled?: boolean } } | null | undefined; + readRecordedProvider(sandboxName: string | null | undefined): string | null; + readRecordedNimContainer(sandboxName: string | null | undefined): string | null; + readRecordedModel(sandboxName: string | null | undefined): string | null; + rejectWindowsHostOllama( + requirement: InferenceProviderHostState["windowsHostOllamaDockerRequirement"], + providerKey: string, + windowsHostSelected: boolean, + ): boolean; + prompt(message: string): Promise; + selectFromNumberedMenu( + rawChoice: string, + defaultIndex: number, + options: ProviderMenuChoice[], + ): ProviderMenuChoice; + note(message: string): void; + log(message?: string): void; + error(message: string): void; + exitProcess(code: number): never; + abortNonInteractive(message: string): never; + handleRemoteProviderSelection( + args: SetupNimRemoteSelectionArgs, + state: SetupNimSelectionState, + recoveredRegistryRoute: RegistryInferenceRoute | null, + ): Promise; + handleNimLocalSelection( + gpu: SetupNimGpu, + args: Pick< + SetupNimRemoteSelectionArgs, + "requestedModel" | "recoveredFromSandbox" | "recoveredModel" + >, + state: SetupNimSelectionState, + ): Promise; + handleRunningOllamaSelection( + gpu: SetupNimGpu, + requestedModel: string | null, + recoveredModel: string | null, + ollamaRunning: boolean, + state: SetupNimSelectionState, + ): Promise; + handleWindowsHostOllamaSelection( + gpu: SetupNimGpu, + selectedKey: string, + requestedModel: string | null, + windowsOllamaReachable: boolean, + winOllamaLoopbackOnly: boolean, + winOllamaInstalledPath: string | null, + state: SetupNimSelectionState, + ): Promise; + handleInstallOllamaSelection( + gpu: SetupNimGpu, + requestedModel: string | null, + recoveredModel: string | null, + state: SetupNimSelectionState, + ollamaInstallMenu: InferenceProviderHostState["ollamaInstallMenu"], + ): Promise; + installVllm( + profile: VllmProfile, + options: { + hasImage: boolean; + nonInteractive: boolean; + promptFn: (question: string) => Promise; + }, + ): Promise<{ ok: boolean }>; + handleVllmSelection(state: SetupNimSelectionState): Promise; + handleRoutedSelection(state: SetupNimSelectionState): Promise; + coerceAgentInferenceApi( + agent: AgentDefinition | null, + preferredInferenceApi: string | null, + ): string | null; + clearCompatibleEndpointReasoning(): null; + maybePromptForInferenceInputCapability(model: string | null): Promise; +} + +function requireSelectedProvider( + selected: ProviderMenuChoice | undefined, + deps: Pick, +): ProviderMenuChoice { + if (!selected) { + deps.error(" No provider was selected."); + deps.exitProcess(1); + } + return selected; +} + +function clearReasoningUnlessCompatible( + provider: string, + current: string | null, + deps: Pick, +): string | null { + if (provider === "compatible-endpoint") return current; + return deps.clearCompatibleEndpointReasoning(); +} + +export function createSetupNim( + defaults: SetupNimFlowDeps, + overrides: Partial = {}, +): SetupNim { + const deps: SetupNimFlowDeps = { ...defaults, ...overrides }; + + return async function setupNimWithDeps( + gpu: SetupNimGpu, + sandboxName: string | null = null, + agent: AgentDefinition | null = null, + recoverProvider = true, + rebuildRegistryInferenceRoute: RebuildRouteHandoff | null = null, + ): Promise { + deps.step(3, 8, "Configuring inference provider"); + + let model: string | BaseSetupNimSelectionState["model"] = null; + let provider = deps.remoteProviderConfig.build.providerName; + let nimContainer: string | null = null; + let endpointUrl: string | null = deps.remoteProviderConfig.build.endpointUrl; + let credentialEnv: string | null = deps.remoteProviderConfig.build.credentialEnv; + let hermesAuthMethod: HermesAuthMethod | null = null; + let hermesToolGateways: string[] = []; + let preferredInferenceApi: string | null = null; + let compatibleEndpointReasoning: string | null = null; + let allowToolsIncompatible = false; + let reuseGatewayCredential = false; + const nvidiaFeaturedModels = deps.createNvidiaFeaturedModelSession(); + + const providerHostState = deps.detectInferenceProviderHostState({ + gpu, + experimental: deps.experimental, + }); + const { + hasOllama, + ollamaHost, + ollamaRunning, + isWindowsHostOllama, + isWsl: isWslHost, + hasWindowsOllama, + winOllamaInstalledPath, + winOllamaLoopbackOnly, + windowsOllamaReachable, + windowsHostOllamaDockerRequirement, + vllmRunning, + vllmProfile, + hasVllmImage, + vllmEntries, + ollamaInstallMenu, + gpuNimCapable, + } = providerHostState; + const requestedProvider = deps.getNonInteractiveProvider(); + const requestedModel = deps.isNonInteractive() + ? deps.getNonInteractiveModel(requestedProvider || "build") + : null; + const recoveredRegistryRoute = + rebuildRegistryInferenceRoute?.sandboxName === sandboxName && + rebuildRegistryInferenceRoute.route.source === "registry" + ? rebuildRegistryInferenceRoute.route + : null; + const agentProviderOptions = deps.getAgentInferenceProviderOptions(agent); + + const blueprintRouterCfg = deps.loadRoutedProfile(); + const { options, hermesProviderAvailable } = buildInferenceProviderMenu({ + remoteProviderConfig: deps.remoteProviderConfig, + agentProviderOptions, + experimental: deps.experimental, + gpuNimCapable, + hasOllama, + ollamaRunning, + ollamaHost, + ollamaPort: deps.ollamaPort, + isWsl: isWslHost, + hasWindowsOllama, + isWindowsHostOllama, + windowsHostLabelSuffix: windowsHostOllamaDockerRequirement.supported + ? "" + : windowsHostOllamaDockerRequirement.labelSuffix, + windowsHostInstallLabel: windowsHostOllamaDockerRequirement.installLabel, + windowsHostStartLabel: windowsHostOllamaDockerRequirement.startLabel, + windowsOllamaReachable, + winOllamaLoopbackOnly, + ollamaInstallEntry: ollamaInstallMenu.entry, + vllmEntries, + routedEnabled: blueprintRouterCfg?.router?.enabled === true, + }); + + function rejectWindowsHostOllama(providerKey: string, windowsHostSelected: boolean): boolean { + return deps.rejectWindowsHostOllama( + windowsHostOllamaDockerRequirement, + providerKey, + windowsHostSelected, + ); + } + + if (options.length > 1) { + selectionLoop: while (true) { + let selected: ProviderMenuChoice | undefined; + let recoveredFromSandbox = false; + let recoveredModel: string | null = null; + hermesAuthMethod = null; + + if (deps.isNonInteractive() || requestedProvider) { + const providerSelection = resolveRequestedProviderSelection({ + options, + requestedProvider, + sandboxName, + remoteProviderConfig: deps.remoteProviderConfig, + isWsl: isWslHost, + isWindowsHostOllama, + windowsHostOllamaSupported: windowsHostOllamaDockerRequirement.supported, + hermesProviderAvailable, + readRecordedProvider: recoverProvider + ? (name) => recoveredRegistryRoute?.provider ?? deps.readRecordedProvider(name) + : () => null, + readRecordedNimContainer: recoverProvider ? deps.readRecordedNimContainer : () => null, + readRecordedModel: recoverProvider + ? (name) => recoveredRegistryRoute?.model ?? deps.readRecordedModel(name) + : () => null, + }); + if (providerSelection.kind === "failure") { + reportProviderSelectionFailure({ + reason: providerSelection.reason, + isWindowsHostOllama, + rejectWindowsHostOllama, + writeError: deps.error, + }); + deps.exitProcess(1); + } + selected = providerSelection.selected; + recoveredFromSandbox = providerSelection.recoveredFromSandbox; + recoveredModel = providerSelection.recoveredModel; + deps.note( + recoveredFromSandbox + ? ` [non-interactive] Provider: ${selected.key} (recovered from sandbox '${sandboxName}')` + : ` [non-interactive] Provider: ${selected.key}`, + ); + } else { + selected = await promptForInferenceProviderSelection({ + options, + vllmRunning, + ollamaRunning, + prompt: deps.prompt, + log: deps.log, + selectFromNumberedMenu: deps.selectFromNumberedMenu, + }); + } + + selected = requireSelectedProvider(selected, deps); + if (selected.key !== "hermesProvider") { + hermesAuthMethod = null; + hermesToolGateways = []; + } + + if (deps.remoteProviderConfig[selected.key]) { + const state: SetupNimSelectionState = { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + compatibleEndpointReasoning, + nimContainer, + allowToolsIncompatible, + nvidiaFeaturedModels, + }; + const result = await deps.handleRemoteProviderSelection( + { selected, requestedModel, recoveredFromSandbox, recoveredModel, sandboxName }, + state, + recoveredRegistryRoute, + ); + ({ + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + allowToolsIncompatible, + } = state); + compatibleEndpointReasoning = state.compatibleEndpointReasoning ?? null; + reuseGatewayCredential = state.reuseGatewayCredentialWithoutLocalKey === true; + if (result === "retry-selection") continue selectionLoop; + break; + } else if (selected.key === "nim-local") { + const state: SetupNimSelectionState = { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + allowToolsIncompatible, + }; + const result = await deps.handleNimLocalSelection( + gpu, + { requestedModel, recoveredFromSandbox, recoveredModel }, + state, + ); + ({ + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + } = state); + if (result === "retry-selection") continue selectionLoop; + break; + } else if (selected.key === "ollama") { + if (rejectWindowsHostOllama(selected.key, isWindowsHostOllama)) { + continue selectionLoop; + } + const state: SetupNimSelectionState = { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + allowToolsIncompatible, + }; + const result = await deps.handleRunningOllamaSelection( + gpu, + requestedModel, + recoveredFromSandbox ? recoveredModel : null, + ollamaRunning, + state, + ); + ({ + model, + provider, + endpointUrl, + credentialEnv, + preferredInferenceApi, + allowToolsIncompatible, + } = state); + if (result === "retry-selection") continue selectionLoop; + break; + } else if (["start-windows-ollama", "install-windows-ollama"].includes(selected.key)) { + if (rejectWindowsHostOllama(selected.key, true)) { + continue selectionLoop; + } + const state: SetupNimSelectionState = { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + allowToolsIncompatible, + }; + const result = await deps.handleWindowsHostOllamaSelection( + gpu, + selected.key, + requestedModel, + windowsOllamaReachable, + winOllamaLoopbackOnly, + winOllamaInstalledPath, + state, + ); + ({ + model, + provider, + endpointUrl, + credentialEnv, + preferredInferenceApi, + allowToolsIncompatible, + } = state); + if (result === "retry-selection") continue selectionLoop; + break; + } else if (selected.key === "install-ollama") { + const state: SetupNimSelectionState = { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + allowToolsIncompatible, + }; + const result = await deps.handleInstallOllamaSelection( + gpu, + requestedModel, + recoveredFromSandbox ? recoveredModel : null, + state, + ollamaInstallMenu, + ); + ({ + model, + provider, + endpointUrl, + credentialEnv, + preferredInferenceApi, + allowToolsIncompatible, + } = state); + if (result === "retry-selection") continue selectionLoop; + break; + } else if (selected.key === "install-vllm") { + if (!vllmProfile) { + deps.error(" No vLLM install profile available for this host."); + if (deps.isNonInteractive()) deps.exitProcess(1); + continue selectionLoop; + } + const result = await deps.installVllm(vllmProfile, { + hasImage: hasVllmImage, + nonInteractive: deps.isNonInteractive(), + promptFn: deps.prompt, + }); + if (!result.ok) { + if (deps.isNonInteractive()) + deps.abortNonInteractive("vLLM install failed. See errors above."); + continue selectionLoop; + } + selected = { + key: "vllm", + label: `Local vLLM (localhost:${deps.vllmPort}) — running`, + }; + } + if (selected.key === "vllm") { + const state: SetupNimSelectionState = { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + allowToolsIncompatible, + }; + const result = await deps.handleVllmSelection(state); + ({ + model, + provider, + endpointUrl, + credentialEnv, + preferredInferenceApi, + nimContainer, + allowToolsIncompatible, + } = state); + if (result === "retry-selection") continue selectionLoop; + break; + } else if (selected.key === "routed") { + const state: SetupNimSelectionState = { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + allowToolsIncompatible, + }; + const result = await deps.handleRoutedSelection(state); + ({ + model, + provider, + endpointUrl, + credentialEnv, + preferredInferenceApi, + nimContainer, + allowToolsIncompatible, + } = state); + if (result === "retry-selection") continue selectionLoop; + break; + } + } + } + + compatibleEndpointReasoning = clearReasoningUnlessCompatible( + provider, + compatibleEndpointReasoning, + deps, + ); + const selectedModel = isBackToSelection(model) ? null : model; + await deps.maybePromptForInferenceInputCapability(selectedModel); + return { + model: selectedModel, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi: deps.coerceAgentInferenceApi(agent, preferredInferenceApi), + compatibleEndpointReasoning, + nimContainer, + allowToolsIncompatible, + skipHostInferenceSmoke: reuseGatewayCredential, + reuseGatewayCredentialWithoutLocalKey: reuseGatewayCredential, + }; + }; +} diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 7a9bdca0c7e..506028c2f53 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -9,7 +9,12 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { normalizeProviderBaseUrl } from "../src/lib/core/url-utils.js"; -import { promptInputModel, promptRemoteModel } from "../src/lib/inference/model-prompts.js"; +import { + promptCloudModel, + promptInputModel, + promptRemoteModel, +} from "../src/lib/inference/model-prompts.js"; +import { parseNvidiaFeaturedModels } from "../src/lib/inference/nvidia-featured-models.js"; import { validateAnthropicModel, validateOpenAiLikeModel, @@ -83,6 +88,23 @@ const TEST_ANTHROPIC_CONFIG = { endpointUrl: TEST_ANTHROPIC_ENDPOINT_URL, helpUrl: null, }; +const TEST_NVIDIA_FEATURED_MODELS = parseNvidiaFeaturedModels( + JSON.stringify({ + "featured-models": [ + { + model: "nvidia/nemotron-3-ultra-550b-a55b", + "model-name": "Nemotron 3 Ultra 550B", + }, + { + model: "nemotron-3-super-120b-a12b", + "model-name": "Nemotron 3 Super 120B", + }, + { model: "z-ai/glm-5.1", "model-name": "GLM 5.1" }, + { model: "moonshotai/kimi-k2.6", "model-name": "Kimi K2.6" }, + { model: "minimaxai/minimax-m2.7", "model-name": "Minimax M2.7" }, + ], + }), +); function makeRemoteSelectionState( overrides: Partial = {}, @@ -164,10 +186,7 @@ async function captureConsoleOutput(callback: () => Promise): Promise<{ } } -function buildWindowsProviderMenu( - requirement: WindowsRequirement, - overrides: ProviderMenuOverrides = {}, -) { +function buildProviderMenu(overrides: ProviderMenuOverrides = {}) { return buildInferenceProviderMenu({ remoteProviderConfig: TEST_REMOTE_PROVIDER_CONFIG, agentProviderOptions: [], @@ -177,12 +196,12 @@ function buildWindowsProviderMenu( ollamaRunning: false, ollamaHost: null, ollamaPort: 11434, - isWsl: true, + isWsl: false, hasWindowsOllama: false, isWindowsHostOllama: false, - windowsHostLabelSuffix: requirement.supported ? "" : requirement.labelSuffix, - windowsHostInstallLabel: requirement.installLabel, - windowsHostStartLabel: requirement.startLabel, + windowsHostLabelSuffix: "", + windowsHostInstallLabel: "Install Ollama on Windows host (recommended)", + windowsHostStartLabel: () => "Start Ollama on Windows host (suggested)", windowsOllamaReachable: false, winOllamaLoopbackOnly: false, ollamaInstallEntry: null, @@ -192,6 +211,19 @@ function buildWindowsProviderMenu( }); } +function buildWindowsProviderMenu( + requirement: WindowsRequirement, + overrides: ProviderMenuOverrides = {}, +) { + return buildProviderMenu({ + isWsl: true, + windowsHostLabelSuffix: requirement.supported ? "" : requirement.labelSuffix, + windowsHostInstallLabel: requirement.installLabel, + windowsHostStartLabel: requirement.startLabel, + ...overrides, + }); +} + function resolveWindowsProvider( options: Array<{ key: string; label: string }>, requestedProvider: string, @@ -598,570 +630,227 @@ const agent = ${JSON.stringify(scenario.agent || null)} } describe("onboard provider selection UX", { timeout: PROVIDER_SELECTION_TEST_TIMEOUT_MS }, () => { - it("prompts explicitly instead of silently auto-selecting detected Ollama", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-selection-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "selection-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - const registryPath = JSON.stringify(path.join(repoRoot, "src", "lib", "state", "registry.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); - writeAlwaysOkCurl(fakeBin, '{"id":"ok"}'); - const script = String.raw` -const credentials = require(${credentialsPath}); -const runner = require(${runnerPath}); -const registry = require(${registryPath}); - -let promptCalls = 0; -const messages = []; -const updates = []; - -credentials.prompt = async (message) => { - promptCalls += 1; - messages.push(message); - return ""; -}; -credentials.ensureApiKey = async () => {}; -runner.runCapture = (command) => { - // Normalize: onboard.ts still sends strings, local-inference.ts sends arrays. - // Once onboard.ts is migrated to argv (#1889), these mocks can assert Array.isArray. - const cmd = Array.isArray(command) ? command.join(" ") : command; - if (cmd.includes("command -v ollama")) return "/usr/bin/ollama"; - if (cmd.includes("127.0.0.1:11434/api/tags")) return JSON.stringify({ models: [{ name: "nemotron-3-nano:30b" }] }); - if (cmd.includes("ollama list")) return "nemotron-3-nano:30b abc 24 GB now\\nqwen3:32b def 20 GB now"; - if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; - return ""; -}; -registry.updateSandbox = (_name, update) => updates.push(update); - -const { setupNim } = require(${onboardPath}); + it("does not label NVIDIA Endpoints as recommended in the provider list", () => { + const buildOption = buildProviderMenu().options.find((option) => option.key === "build"); -(async () => { - const originalLog = console.log; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim("selection-test", null); - originalLog(JSON.stringify({ result, promptCalls, messages, updates, lines })); - } finally { - console.log = originalLog; - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); + assert.equal(buildOption?.label, "NVIDIA Endpoints"); + assert.doesNotMatch(buildOption?.label || "", /recommended/i); + }); - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, + it("selects Kimi K2.6 from the filtered NVIDIA Endpoints featured model list", async () => { + const answers = ["3"]; + const messages: string[] = []; + const lines: string[] = []; + const model = await promptCloudModel({ + defaultModelId: "nvidia/nemotron-3-super-120b-a12b", + cloudModelOptions: TEST_NVIDIA_FEATURED_MODELS, + promptFn: async (message) => { + messages.push(message); + return answers.shift() || ""; }, + writeLine: (line) => lines.push(line), }); - - expect(result.status).toBe(0); - expect(result.stdout.trim()).not.toBe(""); - const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.result.provider, "nvidia-prod"); - assert.equal(payload.result.model, "nvidia/nemotron-3-super-120b-a12b"); - assert.equal(payload.result.preferredInferenceApi, "openai-completions"); - assert.equal(payload.promptCalls, 2); - assert.match(payload.messages[0], /Choose \[/); - assert.match(payload.messages[1], /Choose model \[2\]/); - assert.ok( - payload.lines.some((line: string) => line.includes("Detected local inference option")), - ); - assert.ok(payload.lines.some((line: string) => line.includes("Cloud models:"))); - assert.ok( - payload.lines.some((line: string) => line.includes("Chat Completions API available")), + const probeOpenAiLikeEndpoint = vi.fn(() => ({ + ok: true, + api: "openai-completions", + label: "Chat Completions API", + })); + const validation = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => false, + agentProductName: () => "OpenClaw", + getCredential: () => "nvapi-test", + probeOpenAiLikeEndpoint, + promptValidationRecovery: makeInteractiveValidationRecovery().promptValidationRecovery, + }); + const state = makeRemoteSelectionState({ + model, + provider: "nvidia-prod", + endpointUrl: "https://integrate.api.nvidia.com/v1", + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + }); + const { validateSelectedRemoteModel } = createRemoteModelValidator( + makeRemoteModelValidatorDeps({ + validateOpenAiLikeSelection: validation.validateOpenAiLikeSelection, + }), ); - // #3951: step 3 banner must be provider-agnostic — selecting a non-NIM - // provider (here, NVIDIA Endpoints) must not be labeled "(NIM)". - assert.ok( - payload.lines.some((line: string) => /\[3\/8\] Configuring inference provider\b/.test(line)), - "expected provider-agnostic [3/8] banner", + const validated = await captureConsoleOutput(() => + validateSelectedRemoteModel({ + selected: { key: "build" }, + remoteConfig: { + label: "NVIDIA Endpoints", + endpointUrl: "https://integrate.api.nvidia.com/v1", + helpUrl: null, + }, + state, + selectedCredentialEnv: "NVIDIA_INFERENCE_API_KEY", + }), ); - assert.ok( - !payload.lines.some((line: string) => line.includes("Configuring inference (NIM)")), - 'step 3 banner must not be labeled "Configuring inference (NIM)" for non-NIM providers', + + assert.equal(model, "moonshotai/kimi-k2.6"); + assert.equal(validated.result, "selected"); + assert.equal(state.provider, "nvidia-prod"); + assert.equal(state.preferredInferenceApi, "openai-completions"); + assert.match(messages[0], /Choose model \[2\]/); + assert.ok(lines.some((line) => line.includes("Kimi K2.6"))); + assert.ok(!lines.some((line) => line.includes("GLM 5.1"))); + assert.ok(validated.lines.some((line) => line.includes("Chat Completions API available"))); + expect(probeOpenAiLikeEndpoint).toHaveBeenCalledWith( + "https://integrate.api.nvidia.com/v1", + "moonshotai/kimi-k2.6", + "nvapi-test", + expect.any(Object), ); }); - it("does not label NVIDIA Endpoints as recommended in the provider list", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-no-recommended-label-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "no-recommended-label-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), + it("accepts a manually entered NVIDIA Endpoints model after validating it against /models", async () => { + const answers = ["5", "custom/provider-model"]; + const messages: string[] = []; + const lines: string[] = []; + const validateNvidiaEndpointModelFn = vi.fn((model: string) => ({ + ok: model === "custom/provider-model", + })); + const model = await promptCloudModel({ + defaultModelId: "nvidia/nemotron-3-super-120b-a12b", + cloudModelOptions: TEST_NVIDIA_FEATURED_MODELS, + getCredentialFn: () => "nvapi-test", + validateNvidiaEndpointModelFn, + promptFn: async (message) => { + messages.push(message); + return answers.shift() || ""; + }, + writeLine: (line) => lines.push(line), + }); + const state = makeRemoteSelectionState({ + model, + provider: "nvidia-prod", + endpointUrl: "https://integrate.api.nvidia.com/v1", + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + }); + const { validateSelectedRemoteModel } = createRemoteModelValidator( + makeRemoteModelValidatorDeps({ + validateOpenAiLikeSelection: async () => ({ + ok: true, + api: "openai-completions", + }), + }), ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); - writeAlwaysOkCurl(fakeBin, '{"id":"ok"}'); - - const script = String.raw` -const credentials = require(${credentialsPath}); -const runner = require(${runnerPath}); - -const messages = []; -credentials.prompt = async (message) => { - messages.push(message); - return ""; -}; -credentials.ensureApiKey = async () => {}; -runner.runCapture = () => ""; - -const { setupNim } = require(${onboardPath}); -(async () => { - const originalLog = console.log; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - try { - await setupNim(null); - originalLog(JSON.stringify({ messages, lines })); - } finally { - console.log = originalLog; - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); + assert.equal( + await validateSelectedRemoteModel({ + selected: { key: "build" }, + remoteConfig: { + label: "NVIDIA Endpoints", + endpointUrl: "https://integrate.api.nvidia.com/v1", + helpUrl: null, + }, + state, + selectedCredentialEnv: "NVIDIA_INFERENCE_API_KEY", + }), + "selected", + ); + assert.equal(state.provider, "nvidia-prod"); + assert.equal(state.model, "custom/provider-model"); + assert.equal(state.preferredInferenceApi, "openai-completions"); + assert.match(messages[0], /Choose model \[2\]/); + assert.match(messages[1], /NVIDIA Endpoints model id:/); + assert.ok(lines.some((line) => line.includes("Other..."))); + expect(validateNvidiaEndpointModelFn).toHaveBeenCalledWith( + "custom/provider-model", + "nvapi-test", + ); + }); - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, + it("reprompts for a manual NVIDIA Endpoints model when /models validation rejects it", async () => { + const answers = ["5", "bad/model", "custom/provider-model"]; + const messages: string[] = []; + const lines: string[] = []; + const model = await promptCloudModel({ + defaultModelId: "nvidia/nemotron-3-super-120b-a12b", + cloudModelOptions: TEST_NVIDIA_FEATURED_MODELS, + getCredentialFn: () => "nvapi-test", + validateNvidiaEndpointModelFn: (candidate) => ({ + ok: candidate === "custom/provider-model", + message: `Model '${candidate}' is not available from NVIDIA Endpoints.`, + }), + promptFn: async (message) => { + messages.push(message); + return answers.shift() || ""; }, + errorLine: (line) => lines.push(line), + writeLine: (line) => lines.push(line), }); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(payload.lines.some((line: string) => line.includes("NVIDIA Endpoints"))); - assert.ok( - !payload.lines.some((line: string) => line.includes("NVIDIA Endpoints (recommended)")), + assert.equal(model, "custom/provider-model"); + assert.equal( + messages.filter((message) => /NVIDIA Endpoints model id:/.test(message)).length, + 2, ); + assert.ok(lines.some((line) => line.includes("is not available from NVIDIA Endpoints"))); }); - it("selects Kimi K2.6 from the filtered NVIDIA Endpoints featured model list", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-build-kimi-selection-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "build-kimi-selection-check.js"); - const curlArgsLog = path.join(tmpDir, "kimi-curl-args.log"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), + it("shows curated Gemini models and supports Other for manual entry", async () => { + const answers = ["7", "gemini-custom"]; + const messages: string[] = []; + const lines: string[] = []; + const model = await promptRemoteModel("Google Gemini", "gemini", "gemini-2.5-flash", null, { + promptFn: async (message) => { + messages.push(message); + return answers.shift() || ""; + }, + writeLine: (line) => lines.push(line), + }); + const probeOpenAiLikeEndpoint = vi.fn(() => ({ + ok: true, + api: "openai-completions", + label: "Chat Completions API", + })); + const validation = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => false, + agentProductName: () => "OpenClaw", + getCredential: () => "gemini-secret", + probeOpenAiLikeEndpoint, + promptValidationRecovery: makeInteractiveValidationRecovery().promptValidationRecovery, + }); + const state = makeRemoteSelectionState({ + model, + provider: "gemini-api", + endpointUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + credentialEnv: "GEMINI_API_KEY", + }); + const { validateSelectedRemoteModel } = createRemoteModelValidator( + makeRemoteModelValidatorDeps({ + validateOpenAiLikeSelection: validation.validateOpenAiLikeSelection, + getProbeAuthMode: () => "query-param", + }), ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync( - path.join(fakeBin, "curl"), - `#!/usr/bin/env bash -args_log=${JSON.stringify(curlArgsLog)} -printf '%s\\n' "$*" >> "$args_log" -body='{"id":"ok"}' -status="200" -outfile="" streaming="" -while [ "$#" -gt 0 ]; do - case "$1" in - -o) outfile="$2"; shift 2 ;; - -N) streaming="1"; shift ;; - -w) shift 2 ;; - *) url="$1"; shift ;; - esac -done -if echo "$url" | grep -q 'featured-models.json$'; then - body='{"featured-models":[{"model":"nvidia/nemotron-3-ultra-550b-a55b","model-name":"Nemotron 3 Ultra 550B"},{"model":"nemotron-3-super-120b-a12b","model-name":"Nemotron 3 Super 120B"},{"model":"z-ai/glm-5.1","model-name":"GLM 5.1"},{"model":"moonshotai/kimi-k2.6","model-name":"Kimi K2.6"},{"model":"minimaxai/minimax-m2.7","model-name":"Minimax M2.7"}]}' -elif [ "$streaming" = "1" ]; then - body='data: {"id":"chatcmpl-test","choices":[{"delta":{"content":"OK"}}]}'$'\\n\\n''data: [DONE]'$'\\n' -fi -printf '%s' "$body" > "$outfile" -printf '%s' "$status" -`, - { mode: 0o755 }, + const validated = await captureConsoleOutput(() => + validateSelectedRemoteModel({ + selected: { key: "gemini" }, + remoteConfig: { + label: "Google Gemini", + endpointUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + helpUrl: null, + }, + state, + selectedCredentialEnv: "GEMINI_API_KEY", + }), ); - const script = String.raw` -const credentials = require(${credentialsPath}); -const runner = require(${runnerPath}); - -const answers = ["1", "3"]; -const messages = []; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; -credentials.ensureApiKey = async () => { process.env.NVIDIA_INFERENCE_API_KEY = "nvapi-test"; }; -runner.runCapture = (command) => { - const cmd = Array.isArray(command) ? command.join(" ") : command; - if (cmd.includes("command -v ollama")) return ""; - if (cmd.includes("127.0.0.1:11434/api/tags")) return ""; - if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; - return ""; -}; - -const { setupNim } = require(${onboardPath}); - -(async () => { - const originalLog = console.log; - const originalError = console.error; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - console.error = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines })); - } finally { - console.log = originalLog; - console.error = originalError; - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - }, - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.result.provider, "nvidia-prod"); - assert.equal(payload.result.model, "moonshotai/kimi-k2.6"); - assert.equal(payload.result.preferredInferenceApi, "openai-completions"); - assert.match(payload.messages[1], /Choose model \[2\]/); - assert.ok(payload.lines.some((line: string) => line.includes("Loading NVIDIA"))); - assert.ok(payload.lines.some((line: string) => line.includes("Kimi K2.6"))); - assert.ok(!payload.lines.some((line: string) => line.includes("GLM 5.1"))); - assert.ok( - payload.lines.some((line: string) => line.includes("Chat Completions API available")), - ); - const curlInvocations = fs.readFileSync(curlArgsLog, "utf-8"); - assert.match(curlInvocations, /chat\/completions/); - }); - - it("accepts a manually entered NVIDIA Endpoints model after validating it against /models", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-onboard-build-model-selection-"), - ); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "build-model-selection-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync( - path.join(fakeBin, "curl"), - `#!/usr/bin/env bash -body='{"id":"ok"}' -status="200" -outfile="" -url="" -while [ "$#" -gt 0 ]; do - case "$1" in - -o) outfile="$2"; shift 2 ;; - --config) auth="$(cat "$2" 2>/dev/null)"; shift 2 ;; *) url="$1"; shift ;; - esac -done -if echo "$url" | grep -q '/v1/models$'; then - body='{"data":[{"id":"nvidia/nemotron-3-super-120b-a12b"},{"id":"custom/provider-model"}]}' -fi -printf '%s' "$body" > "$outfile" -printf '%s' "$status" -`, - { mode: 0o755 }, - ); - - const script = String.raw` -const credentials = require(${credentialsPath}); -const runner = require(${runnerPath}); - -const answers = ["1", "5", "custom/provider-model"]; -const messages = []; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; -credentials.ensureApiKey = async () => { process.env.NVIDIA_INFERENCE_API_KEY = "nvapi-test"; }; -runner.runCapture = (command) => { - // Normalize: onboard.ts still sends strings, local-inference.ts sends arrays. - // Once onboard.ts is migrated to argv (#1889), these mocks can assert Array.isArray. - const cmd = Array.isArray(command) ? command.join(" ") : command; - if (cmd.includes("command -v ollama")) return ""; - if (cmd.includes("127.0.0.1:11434/api/tags")) return ""; - if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; - return ""; -}; - -const { setupNim } = require(${onboardPath}); - -(async () => { - const originalLog = console.log; - const originalError = console.error; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - console.error = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines })); - } finally { - console.log = originalLog; - console.error = originalError; - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - }, - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.result.provider, "nvidia-prod"); - assert.equal(payload.result.model, "custom/provider-model"); - assert.equal(payload.result.preferredInferenceApi, "openai-completions"); - assert.match(payload.messages[1], /Choose model \[2\]/); - assert.match(payload.messages[2], /NVIDIA Endpoints model id:/); - assert.ok(payload.lines.some((line: string) => line.includes("Other..."))); - }); - - it("reprompts for a manual NVIDIA Endpoints model when /models validation rejects it", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-build-model-retry-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "build-model-retry-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync( - path.join(fakeBin, "curl"), - `#!/usr/bin/env bash -body='{"id":"ok"}' -status="200" -outfile="" -url="" -while [ "$#" -gt 0 ]; do - case "$1" in - -o) outfile="$2"; shift 2 ;; - --config) auth="$(cat "$2" 2>/dev/null)"; shift 2 ;; *) url="$1"; shift ;; - esac -done -if echo "$url" | grep -q '/v1/models$'; then - body='{"data":[{"id":"nvidia/nemotron-3-super-120b-a12b"},{"id":"custom/provider-model"}]}' -fi -printf '%s' "$body" > "$outfile" -printf '%s' "$status" -`, - { mode: 0o755 }, - ); - - const script = String.raw` -const credentials = require(${credentialsPath}); -const runner = require(${runnerPath}); - -const answers = ["1", "5", "bad/model", "custom/provider-model"]; -const messages = []; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; -credentials.ensureApiKey = async () => { process.env.NVIDIA_INFERENCE_API_KEY = "nvapi-test"; }; -runner.runCapture = (command) => { - // Normalize: onboard.ts still sends strings, local-inference.ts sends arrays. - // Once onboard.ts is migrated to argv (#1889), these mocks can assert Array.isArray. - const cmd = Array.isArray(command) ? command.join(" ") : command; - if (cmd.includes("command -v ollama")) return ""; - if (cmd.includes("127.0.0.1:11434/api/tags")) return ""; - if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; - return ""; -}; - -const { setupNim } = require(${onboardPath}); - -(async () => { - const originalLog = console.log; - const originalError = console.error; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - console.error = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines })); - } finally { - console.log = originalLog; - console.error = originalError; - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - }, - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.result.model, "custom/provider-model"); - assert.equal( - payload.messages.filter((message: string) => /NVIDIA Endpoints model id:/.test(message)) - .length, - 2, - ); - assert.ok( - payload.lines.some((line: string) => line.includes("is not available from NVIDIA Endpoints")), - ); - }); - - it("shows curated Gemini models and supports Other for manual entry", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-gemini-selection-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "gemini-selection-check.js"); - const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); - const credentialsPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "credentials", "store.ts"), - ); - const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); - - fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync( - path.join(fakeBin, "curl"), - `#!/usr/bin/env bash -body="" -status="404" -outfile="" -while [ "$#" -gt 0 ]; do - case "$1" in - -o) outfile="$2"; shift 2 ;; - -d) body="$2"; shift 2 ;; - *) - url="$1" - shift - ;; - esac -done -if echo "$url" | grep -q '/chat/completions'; then - status="200" - body='{"choices":[{"message":{"content":"OK"}}]}' -fi -printf '%s' "$body" > "$outfile" -printf '%s' "$status" -`, - { mode: 0o755 }, - ); - - const script = String.raw` -const credentials = require(${credentialsPath}); -const runner = require(${runnerPath}); - - const answers = ["6", "7", "gemini-custom"]; -const messages = []; - -credentials.prompt = async (message) => { - messages.push(message); - return answers.shift() || ""; -}; -runner.runCapture = () => ""; - -const { setupNim } = require(${onboardPath}); - -(async () => { - process.env.GEMINI_API_KEY = "gemini-secret"; - const originalLog = console.log; - const lines = []; - console.log = (...args) => lines.push(args.join(" ")); - try { - const result = await setupNim(null); - originalLog(JSON.stringify({ result, messages, lines })); - } finally { - console.log = originalLog; - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - fs.writeFileSync(scriptPath, script); - - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - }, - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.equal(payload.result.provider, "gemini-api"); - assert.equal(payload.result.model, "gemini-custom"); - assert.equal(payload.result.preferredInferenceApi, "openai-completions"); - assert.match(payload.messages[0], /Choose \[/); - assert.match(payload.messages[1], /Choose model \[5\]/); - assert.match(payload.messages[2], /Google Gemini model id:/); - assert.ok(payload.lines.some((line: string) => line.includes("Google Gemini models:"))); - assert.ok(payload.lines.some((line: string) => line.includes("gemini-2.5-flash"))); - assert.ok(payload.lines.some((line: string) => line.includes("Other..."))); - assert.ok( - payload.lines.some((line: string) => line.includes("Chat Completions API available")), + assert.equal(validated.result, "selected"); + assert.equal(state.provider, "gemini-api"); + assert.equal(state.model, "gemini-custom"); + assert.equal(state.preferredInferenceApi, "openai-completions"); + assert.match(messages[0], /Choose model \[5\]/); + assert.match(messages[1], /Google Gemini model id:/); + assert.ok(lines.some((line) => line.includes("Google Gemini models:"))); + assert.ok(lines.some((line) => line.includes("gemini-2.5-flash"))); + assert.ok(lines.some((line) => line.includes("Other..."))); + assert.ok(validated.lines.some((line) => line.includes("Chat Completions API available"))); + expect(probeOpenAiLikeEndpoint).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta/openai", + "gemini-custom", + "gemini-secret", + expect.objectContaining({ authMode: "query-param" }), ); }); From 7439b97d2e7d627fb959c88e0e4558dec6b0458d Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 6 Jul 2026 11:30:36 -0700 Subject: [PATCH 2/3] test(onboard): tag direct selection cases Signed-off-by: Carlos Villela --- src/lib/onboard/setup-nim-flow.test.ts | 6 +++--- test/onboard-selection.test.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/lib/onboard/setup-nim-flow.test.ts b/src/lib/onboard/setup-nim-flow.test.ts index 78126f76edc..e0d3a5f8e8c 100644 --- a/src/lib/onboard/setup-nim-flow.test.ts +++ b/src/lib/onboard/setup-nim-flow.test.ts @@ -132,7 +132,7 @@ afterEach(() => { }); describe("createSetupNim", () => { - it("announces detected Ollama but still prompts and defaults to NVIDIA Endpoints", async () => { + it("announces detected Ollama but still prompts and defaults to NVIDIA Endpoints (#6245)", async () => { vi.stubEnv("NEMOCLAW_PROVIDER", ""); const step = vi.fn(); const log = vi.fn(); @@ -191,7 +191,7 @@ describe("createSetupNim", () => { }); }); - it("re-enters provider selection when a handler requests a retry", async () => { + it("re-enters provider selection when a handler requests a retry (#6245)", async () => { vi.stubEnv("NEMOCLAW_PROVIDER", ""); const prompt = vi.fn(async () => ""); const handleRemoteProviderSelection = vi.fn( @@ -218,7 +218,7 @@ describe("createSetupNim", () => { expect(result).toMatchObject({ model: "final-model", provider: "nvidia-prod" }); }); - it("recovers a recorded provider and model without prompting in non-interactive mode", async () => { + it("recovers a recorded provider and model without prompting in non-interactive mode (#6245)", async () => { const prompt = vi.fn(async () => unexpected("interactive provider prompt")); const note = vi.fn(); const readRecordedProvider = vi.fn(() => "openai-api"); diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 506028c2f53..bc1d84b486d 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -630,14 +630,14 @@ const agent = ${JSON.stringify(scenario.agent || null)} } describe("onboard provider selection UX", { timeout: PROVIDER_SELECTION_TEST_TIMEOUT_MS }, () => { - it("does not label NVIDIA Endpoints as recommended in the provider list", () => { + it("does not label NVIDIA Endpoints as recommended in the provider list (#6245)", () => { const buildOption = buildProviderMenu().options.find((option) => option.key === "build"); assert.equal(buildOption?.label, "NVIDIA Endpoints"); assert.doesNotMatch(buildOption?.label || "", /recommended/i); }); - it("selects Kimi K2.6 from the filtered NVIDIA Endpoints featured model list", async () => { + it("selects Kimi K2.6 from the filtered NVIDIA Endpoints featured model list (#6245)", async () => { const answers = ["3"]; const messages: string[] = []; const lines: string[] = []; @@ -702,7 +702,7 @@ describe("onboard provider selection UX", { timeout: PROVIDER_SELECTION_TEST_TIM ); }); - it("accepts a manually entered NVIDIA Endpoints model after validating it against /models", async () => { + it("accepts a manually entered NVIDIA Endpoints model after validating it against /models (#6245)", async () => { const answers = ["5", "custom/provider-model"]; const messages: string[] = []; const lines: string[] = []; @@ -760,7 +760,7 @@ describe("onboard provider selection UX", { timeout: PROVIDER_SELECTION_TEST_TIM ); }); - it("reprompts for a manual NVIDIA Endpoints model when /models validation rejects it", async () => { + it("reprompts for a manual NVIDIA Endpoints model when /models validation rejects it (#6245)", async () => { const answers = ["5", "bad/model", "custom/provider-model"]; const messages: string[] = []; const lines: string[] = []; @@ -788,7 +788,7 @@ describe("onboard provider selection UX", { timeout: PROVIDER_SELECTION_TEST_TIM assert.ok(lines.some((line) => line.includes("is not available from NVIDIA Endpoints"))); }); - it("shows curated Gemini models and supports Other for manual entry", async () => { + it("shows curated Gemini models and supports Other for manual entry (#6245)", async () => { const answers = ["7", "gemini-custom"]; const messages: string[] = []; const lines: string[] = []; From 8b015535acd01d272917a4f28a59289f56684156 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 6 Jul 2026 11:33:58 -0700 Subject: [PATCH 3/3] test(onboard): cover setup flow contracts Signed-off-by: Carlos Villela --- src/lib/onboard/setup-nim-flow.test.ts | 113 +++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/lib/onboard/setup-nim-flow.test.ts b/src/lib/onboard/setup-nim-flow.test.ts index e0d3a5f8e8c..31693ad5a61 100644 --- a/src/lib/onboard/setup-nim-flow.test.ts +++ b/src/lib/onboard/setup-nim-flow.test.ts @@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentDefinition } from "../agent/defs"; +import type { VllmProfile } from "../inference/vllm"; import { getWindowsHostOllamaDockerRequirement } from "./local-inference-topology"; import type { InferenceProviderHostState } from "./provider-host-state"; import { createSetupNim, type SetupNimFlowDeps } from "./setup-nim-flow"; @@ -270,4 +272,115 @@ describe("createSetupNim", () => { preferredInferenceApi: "openai-responses", }); }); + + it("honors a rebuild route and preserves credential-reuse return contracts (#6245)", async () => { + const agent = { name: "langchain-deepagents-code" } as AgentDefinition; + const recoveredRegistryRoute = { + provider: "openai-api", + model: "handoff-model", + endpointUrl: "https://handoff.example.com/v1", + preferredInferenceApi: "openai-responses", + source: "registry", + } as const; + const readRecordedProvider = vi.fn(() => "nvidia-prod"); + const readRecordedModel = vi.fn(() => "stale-model"); + const clearCompatibleEndpointReasoning = vi.fn(() => null); + const coerceAgentInferenceApi = vi.fn( + () => "openai-completions", + ); + const handleRemoteProviderSelection = vi.fn( + async (args, state, recoveredRoute) => { + expect(args).toMatchObject({ + selected: { key: "openai", label: "OpenAI" }, + recoveredFromSandbox: true, + recoveredModel: "handoff-model", + sandboxName: "target-sandbox", + }); + expect(recoveredRoute).toBe(recoveredRegistryRoute); + state.model = args.recoveredModel; + state.provider = "openai-api"; + state.endpointUrl = recoveredRoute?.endpointUrl ?? null; + state.credentialEnv = "OPENAI_API_KEY"; + state.preferredInferenceApi = recoveredRoute?.preferredInferenceApi ?? null; + state.compatibleEndpointReasoning = "stale-compatible-reasoning"; + state.reuseGatewayCredentialWithoutLocalKey = true; + return "selected"; + }, + ); + const setupNim = createSetupNim( + makeDeps({ + isNonInteractive: () => true, + readRecordedProvider, + readRecordedModel, + clearCompatibleEndpointReasoning, + coerceAgentInferenceApi, + handleRemoteProviderSelection, + }), + ); + + const result = await setupNim(null, "target-sandbox", agent, true, { + sandboxName: "target-sandbox", + route: recoveredRegistryRoute, + }); + + expect(readRecordedProvider).not.toHaveBeenCalled(); + expect(readRecordedModel).not.toHaveBeenCalled(); + expect(clearCompatibleEndpointReasoning).toHaveBeenCalledOnce(); + expect(coerceAgentInferenceApi).toHaveBeenCalledWith(agent, "openai-responses"); + expect(result).toMatchObject({ + model: "handoff-model", + provider: "openai-api", + endpointUrl: "https://handoff.example.com/v1", + preferredInferenceApi: "openai-completions", + compatibleEndpointReasoning: null, + skipHostInferenceSmoke: true, + reuseGatewayCredentialWithoutLocalKey: true, + }); + }); + + it("continues from a successful managed vLLM install into provider selection (#6245)", async () => { + const profile = { name: "DGX Spark" } as VllmProfile; + const prompt = vi.fn(async () => unexpected("provider prompt")); + const installVllm = vi.fn(async () => ({ ok: true })); + const handleVllmSelection = vi.fn(async (state) => { + state.model = "vllm-model"; + state.provider = "vllm"; + state.endpointUrl = "http://127.0.0.1:8000/v1"; + state.credentialEnv = null; + state.preferredInferenceApi = "openai-completions"; + return "selected"; + }); + const setupNim = createSetupNim( + makeDeps({ + isNonInteractive: () => true, + getNonInteractiveProvider: () => "install-vllm", + prompt, + detectInferenceProviderHostState: () => + makeHostState({ + vllmProfile: profile, + hasVllmImage: true, + vllmEntries: [{ key: "install-vllm", label: "Start vLLM (DGX Spark)" }], + }), + installVllm, + handleVllmSelection, + }), + ); + + const result = await setupNim(null); + + expect(installVllm).toHaveBeenCalledWith(profile, { + hasImage: true, + nonInteractive: true, + promptFn: prompt, + }); + expect(prompt).not.toHaveBeenCalled(); + expect(handleVllmSelection).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ + model: "vllm-model", + provider: "vllm", + endpointUrl: "http://127.0.0.1:8000/v1", + credentialEnv: null, + preferredInferenceApi: "openai-completions", + }); + }); });