diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 07666068b4c..c1889b035c6 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -30,7 +30,6 @@ const { validateLocalProvider, } = require("./local-inference"); const { - CLOUD_MODEL_OPTIONS, DEFAULT_CLOUD_MODEL, getProviderSelectionConfig, parseGatewayInference, @@ -64,6 +63,7 @@ const urlUtils = require("../../dist/lib/url-utils"); const buildContext = require("../../dist/lib/build-context"); const dashboard = require("../../dist/lib/dashboard"); const httpProbe = require("../../dist/lib/http-probe"); +const modelPrompts = require("../../dist/lib/model-prompts"); const providerModels = require("../../dist/lib/provider-models"); const validationRecovery = require("../../dist/lib/validation-recovery"); const webSearch = require("../../dist/lib/web-search"); @@ -172,19 +172,6 @@ const REMOTE_PROVIDER_CONFIG = { }, }; -const REMOTE_MODEL_OPTIONS = { - openai: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.4-pro-2026-03-05"], - anthropic: ["claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-6"], - gemini: [ - "gemini-3.1-pro-preview", - "gemini-3.1-flash-lite-preview", - "gemini-3-flash-preview", - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - ], -}; - // Non-interactive mode: set by --non-interactive flag or env var. // When active, all prompts use env var overrides or sensible defaults. let NON_INTERACTIVE = false; @@ -1375,120 +1362,14 @@ async function validateCustomAnthropicSelection( return { ok: false, retry }; } -const { validateNvidiaEndpointModel, validateAnthropicModel, validateOpenAiLikeModel } = - providerModels; +const { promptManualModelId, promptCloudModel, promptRemoteModel, promptInputModel } = modelPrompts; +const { validateAnthropicModel, validateOpenAiLikeModel } = providerModels; -async function promptManualModelId(promptLabel, errorLabel, validator = null) { - while (true) { - const manual = await prompt(promptLabel); - const trimmed = manual.trim(); - const navigation = getNavigationChoice(trimmed); - if (navigation === "back") { - return BACK_TO_SELECTION; - } - if (navigation === "exit") { - exitOnboardFromPrompt(); - } - if (!trimmed || !isSafeModelId(trimmed)) { - console.error(` Invalid ${errorLabel} model id.`); - continue; - } - if (validator) { - const validation = validator(trimmed); - if (!validation.ok) { - console.error(` ${validation.message}`); - continue; - } - } - return trimmed; - } -} // Build context helpers — delegated to src/lib/build-context.ts const { shouldIncludeBuildContextPath, copyBuildContextDir, printSandboxCreateRecoveryHints } = buildContext; // classifySandboxCreateFailure — see validation import above -async function promptCloudModel() { - console.log(""); - console.log(" Cloud models:"); - CLOUD_MODEL_OPTIONS.forEach((option, index) => { - console.log(` ${index + 1}) ${option.label} (${option.id})`); - }); - console.log(` ${CLOUD_MODEL_OPTIONS.length + 1}) Other...`); - console.log(""); - - const choice = await prompt(" Choose model [1]: "); - const navigation = getNavigationChoice(choice); - if (navigation === "back") { - return BACK_TO_SELECTION; - } - if (navigation === "exit") { - exitOnboardFromPrompt(); - } - const index = parseInt(choice || "1", 10) - 1; - if (index >= 0 && index < CLOUD_MODEL_OPTIONS.length) { - return CLOUD_MODEL_OPTIONS[index].id; - } - - return promptManualModelId(" NVIDIA Endpoints model id: ", "NVIDIA Endpoints", (model) => - validateNvidiaEndpointModel(model, getCredential("NVIDIA_API_KEY")), - ); -} - -async function promptRemoteModel(label, providerKey, defaultModel, validator = null) { - const options = REMOTE_MODEL_OPTIONS[providerKey] || []; - const defaultIndex = Math.max(0, options.indexOf(defaultModel)); - - console.log(""); - console.log(` ${label} models:`); - options.forEach((option, index) => { - console.log(` ${index + 1}) ${option}`); - }); - console.log(` ${options.length + 1}) Other...`); - console.log(""); - - const choice = await prompt(` Choose model [${defaultIndex + 1}]: `); - const navigation = getNavigationChoice(choice); - if (navigation === "back") { - return BACK_TO_SELECTION; - } - if (navigation === "exit") { - exitOnboardFromPrompt(); - } - const index = parseInt(choice || String(defaultIndex + 1), 10) - 1; - if (index >= 0 && index < options.length) { - return options[index]; - } - - return promptManualModelId(` ${label} model id: `, label, validator); -} - -async function promptInputModel(label, defaultModel, validator = null) { - while (true) { - const value = await prompt(` ${label} model [${defaultModel}]: `); - const navigation = getNavigationChoice(value); - if (navigation === "back") { - return BACK_TO_SELECTION; - } - if (navigation === "exit") { - exitOnboardFromPrompt(); - } - const trimmed = (value || defaultModel).trim(); - if (!trimmed || !isSafeModelId(trimmed)) { - console.error(` Invalid ${label} model id.`); - continue; - } - if (validator) { - const validation = validator(trimmed); - if (!validation.ok) { - console.error(` ${validation.message}`); - continue; - } - } - return trimmed; - } -} - async function promptOllamaModel(gpu = null) { const installed = getOllamaModelOptions(runCapture); const options = installed.length > 0 ? installed : getBootstrapOllamaModelOptions(gpu); @@ -2958,6 +2839,11 @@ async function setupNim(gpu) { } else { model = await promptOllamaModel(gpu); } + if (model === BACK_TO_SELECTION) { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; + } const probe = prepareOllamaModel(model, installedModels); if (!probe.ok) { console.error(` ${probe.message}`); @@ -3010,6 +2896,11 @@ async function setupNim(gpu) { } else { model = await promptOllamaModel(gpu); } + if (model === BACK_TO_SELECTION) { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; + } const probe = prepareOllamaModel(model, installedModels); if (!probe.ok) { console.error(` ${probe.message}`); diff --git a/src/lib/model-prompts.test.ts b/src/lib/model-prompts.test.ts new file mode 100644 index 00000000000..13ebe4f1fa8 --- /dev/null +++ b/src/lib/model-prompts.test.ts @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + BACK_TO_SELECTION, + promptCloudModel, + promptInputModel, + promptManualModelId, + promptRemoteModel, +} from "./model-prompts"; + +function promptSequence(responses: string[]) { + const queue = [...responses]; + return vi.fn(async () => queue.shift() ?? ""); +} + +describe("model prompt helpers", () => { + it("returns the selected cloud model from the curated list", async () => { + const promptFn = promptSequence(["2"]); + const result = await promptCloudModel({ + promptFn, + writeLine: vi.fn(), + cloudModelOptions: [ + { id: "nemotron", label: "Nemotron" }, + { id: "llama", label: "Llama" }, + ], + }); + + expect(result).toBe("llama"); + }); + + it("validates manual cloud model ids against the saved NVIDIA key", async () => { + const promptFn = promptSequence(["9", "bad-model", "nemotron-custom"]); + const errorLine = vi.fn(); + const result = await promptCloudModel({ + promptFn, + errorLine, + writeLine: vi.fn(), + cloudModelOptions: [{ id: "nemotron", label: "Nemotron" }], + getCredentialFn: () => "nvapi-test", + validateNvidiaEndpointModelFn: (model) => ({ + ok: model === "nemotron-custom", + message: `Model '${model}' is not available from NVIDIA Endpoints. Checked https://integrate.api.nvidia.com/v1/models.`, + }), + }); + + expect(result).toBe("nemotron-custom"); + expect(errorLine).toHaveBeenCalledWith( + " Model 'bad-model' is not available from NVIDIA Endpoints. Checked https://integrate.api.nvidia.com/v1/models.", + ); + }); + + it("returns back-to-selection with a clear message when the NVIDIA key is missing", async () => { + const errorLine = vi.fn(); + const result = await promptCloudModel({ + promptFn: promptSequence(["abc"]), + errorLine, + writeLine: vi.fn(), + cloudModelOptions: [{ id: "nemotron", label: "Nemotron" }], + getCredentialFn: () => null, + }); + + expect(result).toBe(BACK_TO_SELECTION); + expect(errorLine).toHaveBeenCalledWith( + " NVIDIA_API_KEY is required before validating a custom NVIDIA Endpoints model.", + ); + }); + + it("defers transient manual validation failures back to the caller flow", async () => { + const errorLine = vi.fn(); + const result = await promptManualModelId( + " Model: ", + "Provider", + () => ({ ok: false, message: "Could not validate model against /models: timeout" }), + { promptFn: promptSequence(["custom-model"]), errorLine }, + ); + + expect(result).toBe("custom-model"); + expect(errorLine).toHaveBeenCalledWith( + " Could not validate model against /models: timeout", + ); + }); + + it("returns back-to-selection for manual ids and input prompts", async () => { + await expect( + promptManualModelId(" Model: ", "Provider", null, { promptFn: promptSequence(["back"]) }), + ).resolves.toBe(BACK_TO_SELECTION); + await expect( + promptInputModel("Provider", "default-model", null, { promptFn: promptSequence(["back"]) }), + ).resolves.toBe(BACK_TO_SELECTION); + }); + + it("uses the default remote model choice when the user presses enter", async () => { + const result = await promptRemoteModel("OpenAI", "openai", "gpt-5.4-mini", null, { + promptFn: promptSequence([""]), + writeLine: vi.fn(), + }); + + expect(result).toBe("gpt-5.4-mini"); + }); + + it("treats non-numeric curated selections as manual-entry fallback", async () => { + const result = await promptRemoteModel("OpenAI", "openai", "gpt-5.4-mini", null, { + promptFn: promptSequence(["abc", "custom-model"]), + writeLine: vi.fn(), + }); + + expect(result).toBe("custom-model"); + }); + + it("retries invalid input models until validation succeeds", async () => { + const promptFn = promptSequence(["bad model", "other", "candidate"]); + const errorLine = vi.fn(); + const result = await promptInputModel( + "Custom", + "default-model", + (model) => ({ ok: model === "candidate", message: "try again" }), + { promptFn, errorLine }, + ); + + expect(result).toBe("candidate"); + expect(errorLine).toHaveBeenCalledWith(" Invalid Custom model id."); + expect(errorLine).toHaveBeenCalledWith(" try again"); + }); + + it("returns input models immediately when validation should be deferred", async () => { + const errorLine = vi.fn(); + const result = await promptInputModel( + "Custom", + "default-model", + () => ({ ok: false, message: "Could not validate model against /models: auth failed" }), + { promptFn: promptSequence(["candidate"]), errorLine }, + ); + + expect(result).toBe("candidate"); + expect(errorLine).toHaveBeenCalledWith( + " Could not validate model against /models: auth failed", + ); + }); +}); diff --git a/src/lib/model-prompts.ts b/src/lib/model-prompts.ts new file mode 100644 index 00000000000..6ea65e29ed5 --- /dev/null +++ b/src/lib/model-prompts.ts @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CLOUD_MODEL_OPTIONS } from "./inference-config"; +import { isSafeModelId } from "./validation"; +import { validateNvidiaEndpointModel } from "./provider-models"; + +// credentials.js is CJS. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { getCredential, prompt } = require("../../bin/lib/credentials"); + +export const BACK_TO_SELECTION = "__NEMOCLAW_BACK_TO_SELECTION__"; + +export const REMOTE_MODEL_OPTIONS: Record = { + openai: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.4-pro-2026-03-05"], + anthropic: ["claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-6"], + gemini: [ + "gemini-3.1-pro-preview", + "gemini-3.1-flash-lite-preview", + "gemini-3-flash-preview", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ], +}; + +export interface PromptValidationResult { + ok: boolean; + message?: string; + deferValidation?: boolean; +} + +export interface ModelPromptOptions { + promptFn?: (question: string) => Promise; + errorLine?: (message: string) => void; + writeLine?: (message: string) => void; + exitFn?: () => never; + getNavigationChoiceFn?: (value?: string) => "back" | "exit" | null; + getCredentialFn?: (envName: string) => string | null; + validateNvidiaEndpointModelFn?: (model: string, apiKey: string) => PromptValidationResult; + cloudModelOptions?: Array<{ id: string; label: string }>; + remoteModelOptions?: Record; + backToSelection?: string; +} + +function getNavigationChoice(value = ""): "back" | "exit" | null { + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (normalized === "back") return "back"; + if (normalized === "exit" || normalized === "quit") return "exit"; + return null; +} + +function exitOnboardFromPrompt(): never { + console.log(" Exiting onboarding."); + process.exit(1); +} + +function shouldDeferValidationFailure(validation: PromptValidationResult): boolean { + return ( + validation.deferValidation === true || + /^Could not validate model against /i.test(String(validation.message || "")) + ); +} + +function resolvePromptOptions(options: ModelPromptOptions = {}) { + return { + promptFn: options.promptFn ?? prompt, + errorLine: options.errorLine ?? console.error, + writeLine: options.writeLine ?? console.log, + exitFn: options.exitFn ?? exitOnboardFromPrompt, + getNavigationChoiceFn: options.getNavigationChoiceFn ?? getNavigationChoice, + getCredentialFn: options.getCredentialFn ?? getCredential, + validateNvidiaEndpointModelFn: + options.validateNvidiaEndpointModelFn ?? validateNvidiaEndpointModel, + cloudModelOptions: options.cloudModelOptions ?? CLOUD_MODEL_OPTIONS, + remoteModelOptions: options.remoteModelOptions ?? REMOTE_MODEL_OPTIONS, + backToSelection: options.backToSelection ?? BACK_TO_SELECTION, + }; +} + +export async function promptManualModelId( + promptLabel: string, + errorLabel: string, + validator: ((model: string) => PromptValidationResult) | null = null, + options: ModelPromptOptions = {}, +): Promise { + const deps = resolvePromptOptions(options); + while (true) { + const manual = await deps.promptFn(promptLabel); + const trimmed = manual.trim(); + const navigation = deps.getNavigationChoiceFn(trimmed); + if (navigation === "back") { + return deps.backToSelection; + } + if (navigation === "exit") { + deps.exitFn(); + } + if (!trimmed || !isSafeModelId(trimmed)) { + deps.errorLine(` Invalid ${errorLabel} model id.`); + continue; + } + if (validator) { + const validation = validator(trimmed); + if (!validation.ok) { + if (validation.message) { + deps.errorLine(` ${validation.message}`); + } + if (shouldDeferValidationFailure(validation)) { + return trimmed; + } + continue; + } + } + return trimmed; + } +} + +export async function promptCloudModel(options: ModelPromptOptions = {}): Promise { + const deps = resolvePromptOptions(options); + + deps.writeLine(""); + deps.writeLine(" Cloud models:"); + deps.cloudModelOptions.forEach((option, index) => { + deps.writeLine(` ${index + 1}) ${option.label} (${option.id})`); + }); + deps.writeLine(` ${deps.cloudModelOptions.length + 1}) Other...`); + deps.writeLine(""); + + const choice = await deps.promptFn(" Choose model [1]: "); + const navigation = deps.getNavigationChoiceFn(choice); + if (navigation === "back") { + return deps.backToSelection; + } + if (navigation === "exit") { + deps.exitFn(); + } + const index = parseInt(choice || "1", 10) - 1; + if (Number.isFinite(index) && index >= 0 && index < deps.cloudModelOptions.length) { + return deps.cloudModelOptions[index].id; + } + + const nvidiaApiKey = deps.getCredentialFn("NVIDIA_API_KEY"); + if (!nvidiaApiKey) { + deps.errorLine(" NVIDIA_API_KEY is required before validating a custom NVIDIA Endpoints model."); + return deps.backToSelection; + } + + return promptManualModelId( + " NVIDIA Endpoints model id: ", + "NVIDIA Endpoints", + (model) => deps.validateNvidiaEndpointModelFn(model, nvidiaApiKey), + deps, + ); +} + +export async function promptRemoteModel( + label: string, + providerKey: string, + defaultModel: string, + validator: ((model: string) => PromptValidationResult) | null = null, + options: ModelPromptOptions = {}, +): Promise { + const deps = resolvePromptOptions(options); + const modelOptions = deps.remoteModelOptions[providerKey] || []; + const defaultIndex = Math.max(0, modelOptions.indexOf(defaultModel)); + + deps.writeLine(""); + deps.writeLine(` ${label} models:`); + modelOptions.forEach((option, index) => { + deps.writeLine(` ${index + 1}) ${option}`); + }); + deps.writeLine(` ${modelOptions.length + 1}) Other...`); + deps.writeLine(""); + + const choice = await deps.promptFn(` Choose model [${defaultIndex + 1}]: `); + const navigation = deps.getNavigationChoiceFn(choice); + if (navigation === "back") { + return deps.backToSelection; + } + if (navigation === "exit") { + deps.exitFn(); + } + const index = parseInt(choice || String(defaultIndex + 1), 10) - 1; + if (Number.isFinite(index) && index >= 0 && index < modelOptions.length) { + return modelOptions[index]; + } + + return promptManualModelId(` ${label} model id: `, label, validator, deps); +} + +export async function promptInputModel( + label: string, + defaultModel: string, + validator: ((model: string) => PromptValidationResult) | null = null, + options: ModelPromptOptions = {}, +): Promise { + const deps = resolvePromptOptions(options); + while (true) { + const value = await deps.promptFn(` ${label} model [${defaultModel}]: `); + const navigation = deps.getNavigationChoiceFn(value); + if (navigation === "back") { + return deps.backToSelection; + } + if (navigation === "exit") { + deps.exitFn(); + } + const trimmed = (value || defaultModel).trim(); + if (!trimmed || !isSafeModelId(trimmed)) { + deps.errorLine(` Invalid ${label} model id.`); + continue; + } + if (validator) { + const validation = validator(trimmed); + if (!validation.ok) { + if (validation.message) { + deps.errorLine(` ${validation.message}`); + } + if (shouldDeferValidationFailure(validation)) { + return trimmed; + } + continue; + } + } + return trimmed; + } +} diff --git a/test/onboard-selection.test.js b/test/onboard-selection.test.js index 2942e6c4453..c1b9232ae74 100644 --- a/test/onboard-selection.test.js +++ b/test/onboard-selection.test.js @@ -633,6 +633,96 @@ const { setupNim } = require(${onboardPath}); ); }); + it("returns to provider selection when Ollama manual entry chooses back", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-back-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "ollama-back-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"resp_123"}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["7", "2", "back", "1", ""]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => { process.env.NVIDIA_API_KEY = "nvapi-good"; }; +runner.run = () => ({ status: 0 }); +runner.runCapture = (command) => { + if (command.includes("command -v ollama")) return "/usr/bin/ollama"; + if (command.includes("localhost:11434/api/tags")) return JSON.stringify({ models: [{ name: "nemotron-3-nano:30b" }] }); + if (command.includes("ollama list")) return "nemotron-3-nano:30b abc 24 GB now"; + if (command.includes("localhost:8000/v1/models")) return ""; + if (command.includes("api/generate")) return '{"response":"hello"}'; + 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.ok(payload.lines.some((line) => line.includes("Returning to provider selection."))); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); + assert.equal(payload.messages.filter((message) => /Ollama model id: /.test(message)).length, 1); + }); + it("offers starter Ollama models when none are installed and pulls the selected model", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-bootstrap-"));