diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 37ddb4d050..2259dd22bf 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -217,9 +217,11 @@ const { const { checkOllamaPortsOrWarn, resolveOllamaInstallMenuEntry, - resolveRunningOllamaMenuEntry, assertOllamaUpgradeApplied, } = require("./onboard/ollama-install-menu"); +const { + buildInferenceProviderMenu, +}: typeof import("./onboard/provider-menu") = require("./onboard/provider-menu"); const { ensureOllamaAuthProxy, getOllamaProxyToken, @@ -3651,7 +3653,7 @@ async function createSandbox( // ── Step 3: Inference selection ────────────────────────────────── -type ProviderChoice = { key: string; label: string }; +type ProviderChoice = import("./onboard/provider-menu").ProviderMenuChoice; const { readRecordedProvider, readRecordedNimContainer, readRecordedModel } = providerRecovery.createProviderRecoveryHelpers({ @@ -3837,77 +3839,46 @@ async function setupNim( ? getNonInteractiveModel(requestedProvider || "build") : null; const agentProviderOptions = getAgentInferenceProviderOptions(agent); - const hermesProviderAvailable = agentProviderOptions.includes("hermesProvider"); - const options: Array<{ key: string; label: string }> = []; - options.push({ key: "build", label: "NVIDIA Endpoints" }); - options.push({ key: "openai", label: "OpenAI" }); - options.push({ key: "custom", label: "Other OpenAI-compatible endpoint" }); - options.push({ key: "anthropic", label: "Anthropic" }); - options.push({ key: "anthropicCompatible", label: "Other Anthropic-compatible endpoint" }); - options.push({ key: "gemini", label: "Google Gemini" }); - const runningOllamaMenu = resolveRunningOllamaMenuEntry({ + const ollamaInstallMenu = resolveOllamaInstallMenuEntry({ hasOllama, ollamaRunning, + hasWindowsOllama, ollamaHost, + platform: process.platform, isWsl: isWsl(), + }); + + // Model Router: complexity-based routing via blueprint config. + const blueprintRouterCfg = loadBlueprintProfile("routed"); + const { options, hermesProviderAvailable } = buildInferenceProviderMenu({ + remoteProviderConfig: REMOTE_PROVIDER_CONFIG, + agentProviderOptions, + experimental: EXPERIMENTAL, + gpuNimCapable: Boolean(gpu && gpu.nimCapable), + hasOllama, + ollamaRunning, + ollamaHost, ollamaPort: OLLAMA_PORT, + isWsl: isWsl(), + hasWindowsOllama, + isWindowsHostOllama, windowsHostLabelSuffix: windowsHostOllamaDockerRequirement.supported ? "" : windowsHostOllamaDockerRequirement.labelSuffix, - }); - if (runningOllamaMenu) options.push(runningOllamaMenu); - if (EXPERIMENTAL && gpu && gpu.nimCapable) { - options.push({ key: "nim-local", label: "Local NVIDIA NIM [experimental]" }); - } - options.push( - ...buildVllmMenuEntries({ + windowsHostInstallLabel: windowsHostOllamaDockerRequirement.installLabel, + windowsHostStartLabel: windowsHostOllamaDockerRequirement.startLabel, + windowsOllamaReachable, + winOllamaLoopbackOnly, + ollamaInstallEntry: ollamaInstallMenu.entry, + vllmEntries: buildVllmMenuEntries({ vllmRunning, vllmProfile, experimental: EXPERIMENTAL, platform: gpu?.platform, hasVllmImage, }), - ); - // Skipped when Windows-host already won the cache: the running entry - // above already covers that case. - if (hasWindowsOllama && !isWindowsHostOllama) { - options.push({ - key: "start-windows-ollama", - label: windowsHostOllamaDockerRequirement.startLabel({ - reachable: windowsOllamaReachable, - loopbackOnly: winOllamaLoopbackOnly, - }), - }); - } - // On WSL, always offer to install Ollama on the Windows host when not - // already installed, regardless of WSL Ollama state — users may prefer the - // Windows-host instance (GPU access) even with WSL Ollama running. - if (isWsl() && !hasWindowsOllama) { - options.push({ - key: "install-windows-ollama", - label: windowsHostOllamaDockerRequirement.installLabel, - }); - } - const ollamaInstallMenu = resolveOllamaInstallMenuEntry({ - hasOllama, - ollamaRunning, - hasWindowsOllama, - ollamaHost, - platform: process.platform, - isWsl: isWsl(), + routedEnabled: blueprintRouterCfg?.router?.enabled === true, }); - if (ollamaInstallMenu.entry) options.push(ollamaInstallMenu.entry); - - // Model Router: complexity-based routing via blueprint config. - const blueprintRouterCfg = loadBlueprintProfile("routed"); - if (blueprintRouterCfg && blueprintRouterCfg.router?.enabled === true) { - options.push({ key: "routed", label: "Model Router (experimental)" }); - } - for (const providerKey of agentProviderOptions) { - const remoteConfig = REMOTE_PROVIDER_CONFIG[providerKey]; - if (!remoteConfig || options.some((option) => option.key === providerKey)) continue; - options.push({ key: providerKey, label: remoteConfig.label }); - } function rejectWindowsHostOllama(providerKey: string, windowsHostSelected: boolean): boolean { return rejectUnsupportedWindowsHostOllama( diff --git a/src/lib/onboard/provider-menu.test.ts b/src/lib/onboard/provider-menu.test.ts new file mode 100644 index 0000000000..fc0744773f --- /dev/null +++ b/src/lib/onboard/provider-menu.test.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { buildInferenceProviderMenu } from "../../../dist/lib/onboard/provider-menu"; + +const REMOTE_PROVIDER_CONFIG = { + build: { label: "NVIDIA Endpoints" }, + openai: { label: "OpenAI" }, + custom: { label: "Other OpenAI-compatible endpoint" }, + anthropic: { label: "Anthropic" }, + anthropicCompatible: { label: "Other Anthropic-compatible endpoint" }, + gemini: { label: "Google Gemini" }, + hermesProvider: { label: "Hermes Provider" }, +}; + +function buildMenu(overrides: Partial[0]> = {}) { + return buildInferenceProviderMenu({ + remoteProviderConfig: REMOTE_PROVIDER_CONFIG, + agentProviderOptions: [], + experimental: false, + gpuNimCapable: false, + hasOllama: false, + ollamaRunning: false, + ollamaHost: null, + ollamaPort: 11434, + isWsl: false, + hasWindowsOllama: false, + isWindowsHostOllama: false, + windowsHostLabelSuffix: "", + windowsHostInstallLabel: "Install Ollama on Windows host (recommended)", + windowsHostStartLabel: () => "Start Ollama on Windows host (suggested)", + windowsOllamaReachable: false, + winOllamaLoopbackOnly: false, + ollamaInstallEntry: null, + vllmEntries: [], + routedEnabled: false, + ...overrides, + }); +} + +describe("buildInferenceProviderMenu", () => { + it("returns the base remote providers in the existing prompt order", () => { + const result = buildMenu(); + + expect(result.hermesProviderAvailable).toBe(false); + expect(result.options.map((option) => option.key)).toEqual([ + "build", + "openai", + "custom", + "anthropic", + "anthropicCompatible", + "gemini", + ]); + }); + + it("adds local, routed, and agent-scoped providers after the base remote entries", () => { + const result = buildMenu({ + agentProviderOptions: ["hermesProvider", "build"], + experimental: true, + gpuNimCapable: true, + hasOllama: true, + ollamaRunning: true, + ollamaHost: "127.0.0.1", + isWsl: false, + ollamaInstallEntry: { key: "install-ollama", label: "Install Ollama (Linux)" }, + vllmEntries: [{ key: "install-vllm", label: "Install vLLM (DGX Spark)" }], + routedEnabled: true, + }); + + expect(result.hermesProviderAvailable).toBe(true); + expect(result.options.map((option) => option.key)).toEqual([ + "build", + "openai", + "custom", + "anthropic", + "anthropicCompatible", + "gemini", + "ollama", + "nim-local", + "install-vllm", + "install-ollama", + "routed", + "hermesProvider", + ]); + expect(result.options.find((option) => option.key === "build")?.label).toBe("NVIDIA Endpoints"); + expect(result.options.find((option) => option.key === "hermesProvider")?.label).toBe( + "Hermes Provider", + ); + }); + + it("offers Windows-host Ollama install when WSL has no Windows Ollama", () => { + const result = buildMenu({ + isWsl: true, + hasWindowsOllama: false, + windowsHostInstallLabel: "Install Ollama on Windows host (requires Docker Desktop)", + }); + + expect(result.options.at(-1)).toEqual({ + key: "install-windows-ollama", + label: "Install Ollama on Windows host (requires Docker Desktop)", + }); + }); + + it("offers Windows-host Ollama start when detected but not currently selected", () => { + const result = buildMenu({ + isWsl: true, + hasWindowsOllama: true, + isWindowsHostOllama: false, + windowsOllamaReachable: true, + windowsHostStartLabel: ({ reachable }) => + reachable ? "Use Ollama on Windows host - running" : "Start Ollama on Windows host", + }); + + expect(result.options.at(-1)).toEqual({ + key: "start-windows-ollama", + label: "Use Ollama on Windows host - running", + }); + }); + + it("does not add a separate Windows-host start entry when running Ollama already resolves there", () => { + const result = buildMenu({ + isWsl: true, + hasOllama: false, + ollamaRunning: true, + ollamaHost: "host.docker.internal", + hasWindowsOllama: true, + isWindowsHostOllama: true, + }); + + expect(result.options.map((option) => option.key)).toContain("ollama"); + expect(result.options.map((option) => option.key)).not.toContain("start-windows-ollama"); + }); +}); diff --git a/src/lib/onboard/provider-menu.ts b/src/lib/onboard/provider-menu.ts new file mode 100644 index 0000000000..19b390d1f6 --- /dev/null +++ b/src/lib/onboard/provider-menu.ts @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resolveRunningOllamaMenuEntry } from "./ollama-install-menu"; + +export interface ProviderMenuChoice { + key: string; + label: string; +} + +interface RemoteProviderMenuConfig { + label: string; +} + +type WindowsHostOllamaStartLabel = (opts: { reachable: boolean; loopbackOnly: boolean }) => string; + +export interface BuildInferenceProviderMenuInput { + remoteProviderConfig: Record; + agentProviderOptions: readonly string[]; + experimental: boolean; + gpuNimCapable: boolean; + hasOllama: boolean; + ollamaRunning: boolean; + ollamaHost: string | null; + ollamaPort: number; + isWsl: boolean; + hasWindowsOllama: boolean; + isWindowsHostOllama: boolean; + windowsHostLabelSuffix: string; + windowsHostInstallLabel: string; + windowsHostStartLabel: WindowsHostOllamaStartLabel; + windowsOllamaReachable: boolean; + winOllamaLoopbackOnly: boolean; + ollamaInstallEntry: ProviderMenuChoice | null; + vllmEntries: readonly ProviderMenuChoice[]; + routedEnabled: boolean; +} + +export interface InferenceProviderMenu { + options: ProviderMenuChoice[]; + hermesProviderAvailable: boolean; +} + +const BASE_REMOTE_PROVIDER_OPTIONS: readonly ProviderMenuChoice[] = [ + { key: "build", label: "NVIDIA Endpoints" }, + { key: "openai", label: "OpenAI" }, + { key: "custom", label: "Other OpenAI-compatible endpoint" }, + { key: "anthropic", label: "Anthropic" }, + { key: "anthropicCompatible", label: "Other Anthropic-compatible endpoint" }, + { key: "gemini", label: "Google Gemini" }, +]; + +function configuredRemoteOption( + config: Record, + fallback: ProviderMenuChoice, +): ProviderMenuChoice { + return { + key: fallback.key, + label: config[fallback.key]?.label ?? fallback.label, + }; +} + +function pushUniqueRemoteProviderOption( + options: ProviderMenuChoice[], + config: Record, + providerKey: string, +): void { + const remoteConfig = config[providerKey]; + if (!remoteConfig || options.some((option) => option.key === providerKey)) return; + options.push({ key: providerKey, label: remoteConfig.label }); +} + +export function buildInferenceProviderMenu( + input: BuildInferenceProviderMenuInput, +): InferenceProviderMenu { + const options: ProviderMenuChoice[] = BASE_REMOTE_PROVIDER_OPTIONS.map((option) => + configuredRemoteOption(input.remoteProviderConfig, option), + ); + + const runningOllamaMenu = resolveRunningOllamaMenuEntry({ + hasOllama: input.hasOllama, + ollamaRunning: input.ollamaRunning, + ollamaHost: input.ollamaHost, + isWsl: input.isWsl, + ollamaPort: input.ollamaPort, + windowsHostLabelSuffix: input.windowsHostLabelSuffix, + }); + if (runningOllamaMenu) options.push(runningOllamaMenu); + + if (input.experimental && input.gpuNimCapable) { + options.push({ key: "nim-local", label: "Local NVIDIA NIM [experimental]" }); + } + + options.push(...input.vllmEntries); + + if (input.hasWindowsOllama && !input.isWindowsHostOllama) { + options.push({ + key: "start-windows-ollama", + label: input.windowsHostStartLabel({ + reachable: input.windowsOllamaReachable, + loopbackOnly: input.winOllamaLoopbackOnly, + }), + }); + } + + if (input.isWsl && !input.hasWindowsOllama) { + options.push({ + key: "install-windows-ollama", + label: input.windowsHostInstallLabel, + }); + } + + if (input.ollamaInstallEntry) options.push(input.ollamaInstallEntry); + + if (input.routedEnabled) { + options.push({ key: "routed", label: "Model Router (experimental)" }); + } + + for (const providerKey of input.agentProviderOptions) { + pushUniqueRemoteProviderOption(options, input.remoteProviderConfig, providerKey); + } + + return { + options, + hermesProviderAvailable: input.agentProviderOptions.includes("hermesProvider"), + }; +}