From 7bc63a4f9bbfe0374a145c0a6e1eb073e189e9d7 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 5 Apr 2026 13:39:55 -0700 Subject: [PATCH 1/5] refactor(cli): extract HTTP probe helpers from onboard.js --- bin/lib/onboard.js | 96 +------------------ src/lib/http-probe.test.ts | 92 ++++++++++++++++++ src/lib/http-probe.ts | 158 +++++++++++++++++++++++++++++++ test/credential-exposure.test.js | 14 ++- test/onboard.test.js | 17 +++- 5 files changed, 274 insertions(+), 103 deletions(-) create mode 100644 src/lib/http-probe.test.ts create mode 100644 src/lib/http-probe.ts diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 71263d02057..dc780293a1f 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -63,6 +63,7 @@ const validation = require("../../dist/lib/validation"); 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 webSearch = require("../../dist/lib/web-search"); /** @@ -625,23 +626,7 @@ function hydrateCredentialEnv(envName) { return value || null; } -function getCurlTimingArgs() { - return ["--connect-timeout", "10", "--max-time", "60"]; -} - -function summarizeCurlFailure(curlStatus = 0, stderr = "", body = "") { - const detail = compactText(stderr || body); - return detail - ? `curl failed (exit ${curlStatus}): ${detail.slice(0, 200)}` - : `curl failed (exit ${curlStatus})`; -} - -function summarizeProbeFailure(body = "", status = 0, curlStatus = 0, stderr = "") { - if (curlStatus) { - return summarizeCurlFailure(curlStatus, stderr, body); - } - return summarizeProbeError(body, status); -} +const { getCurlTimingArgs, summarizeCurlFailure, summarizeProbeFailure, runCurlProbe } = httpProbe; function getNavigationChoice(value = "") { const normalized = String(value || "") @@ -726,65 +711,6 @@ function getProbeRecovery(probe, options = {}) { return fallback; } -// eslint-disable-next-line complexity -function runCurlProbe(argv) { - const bodyFile = secureTempFile("nemoclaw-curl-probe", ".json"); - try { - const args = [...argv]; - const url = args.pop(); - const result = spawnSync("curl", [...args, "-o", bodyFile, "-w", "%{http_code}", url], { - cwd: ROOT, - encoding: "utf8", - timeout: 30_000, - env: { - ...process.env, - }, - }); - const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; - if (result.error) { - const spawnError = /** @type {NodeJS.ErrnoException} */ (result.error); - const rawErrorCode = spawnError.errno ?? spawnError.code; - const errorCode = typeof rawErrorCode === "number" ? rawErrorCode : 1; - const errorMessage = compactText( - `${spawnError.message || String(spawnError)} ${String(result.stderr || "")}`, - ); - return { - ok: false, - httpStatus: 0, - curlStatus: errorCode, - body, - stderr: errorMessage, - message: summarizeProbeFailure(body, 0, errorCode, errorMessage), - }; - } - const status = Number(String(result.stdout || "").trim()); - return { - ok: result.status === 0 && status >= 200 && status < 300, - httpStatus: Number.isFinite(status) ? status : 0, - curlStatus: result.status || 0, - body, - stderr: String(result.stderr || ""), - message: summarizeProbeFailure( - body, - status || 0, - result.status || 0, - String(result.stderr || ""), - ), - }; - } catch (error) { - return { - ok: false, - httpStatus: 0, - curlStatus: error?.status || 1, - body: "", - stderr: error?.message || String(error), - message: summarizeCurlFailure(error?.status || 1, error?.message || String(error)), - }; - } finally { - cleanupTempDir(bodyFile, "nemoclaw-curl-probe"); - } -} - // validateNvidiaApiKeyValue — see validation import above async function replaceNamedCredential(envName, label, helpUrl = null, validator = null) { @@ -1294,24 +1220,6 @@ function patchStagedDockerfile( fs.writeFileSync(dockerfilePath, dockerfile); } -function summarizeProbeError(body, status) { - if (!body) return `HTTP ${status} with no response body`; - try { - const parsed = JSON.parse(body); - const message = - parsed?.error?.message || - parsed?.error?.details || - parsed?.message || - parsed?.detail || - parsed?.details; - if (message) return `HTTP ${status}: ${String(message)}`; - } catch { - /* non-JSON body — fall through to raw text */ - } - const compact = String(body).replace(/\s+/g, " ").trim(); - return `HTTP ${status}: ${compact.slice(0, 200)}`; -} - function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { const probes = [ { diff --git a/src/lib/http-probe.test.ts b/src/lib/http-probe.test.ts new file mode 100644 index 00000000000..8ea5ec7f6c2 --- /dev/null +++ b/src/lib/http-probe.test.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import { describe, expect, it } from "vitest"; + +import { + getCurlTimingArgs, + runCurlProbe, + summarizeCurlFailure, + summarizeProbeError, + summarizeProbeFailure, +} from "./http-probe"; + +describe("http-probe helpers", () => { + it("returns explicit curl timeouts", () => { + expect(getCurlTimingArgs()).toEqual(["--connect-timeout", "10", "--max-time", "60"]); + }); + + it("summarizes curl failures from stderr or body", () => { + expect(summarizeCurlFailure(28, " timed out while connecting ")).toBe( + "curl failed (exit 28): timed out while connecting", + ); + expect(summarizeCurlFailure(7, "", " connection refused ")).toBe( + "curl failed (exit 7): connection refused", + ); + }); + + it("summarizes JSON and text HTTP probe failures", () => { + expect(summarizeProbeError('{"error":{"message":"bad key"}}', 401)).toBe( + "HTTP 401: bad key", + ); + expect(summarizeProbeError(" plain text body ", 500)).toBe("HTTP 500: plain text body"); + expect(summarizeProbeFailure("", 0, 28, "timeout")).toBe("curl failed (exit 28): timeout"); + }); + + it("captures successful curl output and cleans up the temp file", () => { + const countProbeDirs = () => + fs + .readdirSync(os.tmpdir()) + .filter((entry) => entry.startsWith("nemoclaw-curl-probe-")) + .sort(); + + const before = countProbeDirs(); + const result = runCurlProbe(["-sS", "https://example.test/models"], { + spawnSyncImpl: (_command, args) => { + const outputPath = args[args.indexOf("-o") + 1]; + fs.writeFileSync(outputPath, JSON.stringify({ data: [{ id: "foo" }] })); + return { + pid: 1, + output: [], + stdout: "200", + stderr: "", + status: 0, + signal: null, + }; + }, + }); + const after = countProbeDirs(); + + expect(result).toMatchObject({ + ok: true, + httpStatus: 200, + curlStatus: 0, + body: '{"data":[{"id":"foo"}]}', + }); + expect(after).toEqual(before); + }); + + it("reports spawn errors as curl failures", () => { + const result = runCurlProbe(["-sS", "https://example.test/models"], { + spawnSyncImpl: () => { + const error = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); + return { + pid: 1, + output: [], + stdout: "", + stderr: "curl missing", + status: null, + signal: null, + error, + }; + }, + }); + + expect(result.ok).toBe(false); + expect(result.curlStatus).toBe(1); + expect(result.message).toContain("curl failed"); + expect(result.stderr).toContain("spawn ENOENT"); + }); +}); diff --git a/src/lib/http-probe.ts b/src/lib/http-probe.ts new file mode 100644 index 00000000000..4c9c98cf0cd --- /dev/null +++ b/src/lib/http-probe.ts @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + spawnSync, + type SpawnSyncOptionsWithStringEncoding, + type SpawnSyncReturns, +} from "node:child_process"; + +import { compactText } from "./url-utils"; + +// runner.js is CJS — use require so we don't pull it into the TS build. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { ROOT } = require("../../bin/lib/runner"); + +export interface CurlProbeResult { + ok: boolean; + httpStatus: number; + curlStatus: number; + body: string; + stderr: string; + message: string; +} + +export interface CurlProbeOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + spawnSyncImpl?: ( + command: string, + args: readonly string[], + options: SpawnSyncOptionsWithStringEncoding, + ) => SpawnSyncReturns; +} + +function secureTempFile(prefix: string, ext = ""): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + return path.join(dir, `${prefix}${ext}`); +} + +function cleanupTempDir(filePath: string, expectedPrefix: string): void { + const parentDir = path.dirname(filePath); + if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith(`${expectedPrefix}-`)) { + fs.rmSync(parentDir, { recursive: true, force: true }); + } +} + +export function getCurlTimingArgs(): string[] { + return ["--connect-timeout", "10", "--max-time", "60"]; +} + +export function summarizeCurlFailure(curlStatus = 0, stderr = "", body = ""): string { + const detail = compactText(stderr || body); + return detail + ? `curl failed (exit ${curlStatus}): ${detail.slice(0, 200)}` + : `curl failed (exit ${curlStatus})`; +} + +export function summarizeProbeError(body = "", status = 0): string { + if (!body) return `HTTP ${status} with no response body`; + try { + const parsed = JSON.parse(body) as { + error?: { message?: unknown; details?: unknown }; + message?: unknown; + detail?: unknown; + details?: unknown; + }; + const message = + parsed?.error?.message || + parsed?.error?.details || + parsed?.message || + parsed?.detail || + parsed?.details; + if (message) return `HTTP ${status}: ${String(message)}`; + } catch { + /* non-JSON body — fall through to raw text */ + } + const compact = String(body).replace(/\s+/g, " ").trim(); + return `HTTP ${status}: ${compact.slice(0, 200)}`; +} + +export function summarizeProbeFailure( + body = "", + status = 0, + curlStatus = 0, + stderr = "", +): string { + if (curlStatus) { + return summarizeCurlFailure(curlStatus, stderr, body); + } + return summarizeProbeError(body, status); +} + +// eslint-disable-next-line complexity +export function runCurlProbe(argv: string[], opts: CurlProbeOptions = {}): CurlProbeResult { + const bodyFile = secureTempFile("nemoclaw-curl-probe", ".json"); + try { + const args = [...argv]; + const url = args.pop(); + const spawnSyncImpl = opts.spawnSyncImpl ?? spawnSync; + const result = spawnSyncImpl( + "curl", + [...args, "-o", bodyFile, "-w", "%{http_code}", String(url || "")], + { + cwd: opts.cwd ?? ROOT, + encoding: "utf8", + timeout: 30_000, + env: { + ...process.env, + ...opts.env, + }, + }, + ); + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + if (result.error) { + const spawnError = result.error as NodeJS.ErrnoException; + const rawErrorCode = spawnError.errno ?? spawnError.code; + const errorCode = typeof rawErrorCode === "number" ? rawErrorCode : 1; + const errorMessage = compactText( + `${spawnError.message || String(spawnError)} ${String(result.stderr || "")}`, + ); + return { + ok: false, + httpStatus: 0, + curlStatus: errorCode, + body, + stderr: errorMessage, + message: summarizeProbeFailure(body, 0, errorCode, errorMessage), + }; + } + const status = Number(String(result.stdout || "").trim()); + return { + ok: result.status === 0 && status >= 200 && status < 300, + httpStatus: Number.isFinite(status) ? status : 0, + curlStatus: result.status || 0, + body, + stderr: String(result.stderr || ""), + message: summarizeProbeFailure(body, status || 0, result.status || 0, String(result.stderr || "")), + }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { + ok: false, + httpStatus: 0, + curlStatus: typeof error === "object" && error && "status" in error ? Number(error.status) || 1 : 1, + body: "", + stderr: detail, + message: summarizeCurlFailure( + typeof error === "object" && error && "status" in error ? Number(error.status) || 1 : 1, + detail, + ), + }; + } finally { + cleanupTempDir(bodyFile, "nemoclaw-curl-probe"); + } +} diff --git a/test/credential-exposure.test.js b/test/credential-exposure.test.js index ca148ba1cf9..764b95df0a9 100644 --- a/test/credential-exposure.test.js +++ b/test/credential-exposure.test.js @@ -82,11 +82,15 @@ describe("credential exposure in process arguments", () => { expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("SLACK_BOT_TOKEN"/); }); - it("onboard.js curl probes use explicit timeouts", () => { - const src = fs.readFileSync(ONBOARD_JS, "utf-8"); + it("onboard curl probes use explicit timeouts", () => { + const onboardSrc = fs.readFileSync(ONBOARD_JS, "utf-8"); + const probeSrc = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "http-probe.ts"), + "utf-8", + ); - expect(src).toMatch(/function getCurlTimingArgs\(\)/); - expect(src).toMatch(/"--connect-timeout", "10"/); - expect(src).toMatch(/"--max-time", "60"/); + expect(onboardSrc).toMatch(/http-probe/); + expect(probeSrc).toMatch(/"--connect-timeout", "10"/); + expect(probeSrc).toMatch(/"--max-time", "60"/); }); }); diff --git a/test/onboard.test.js b/test/onboard.test.js index cf2ea37e166..2124977107b 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1240,14 +1240,23 @@ const { setupInference } = require(${onboardPath}); }); it("uses split curl timeout args and does not mislabel curl usage errors as timeouts", () => { - const source = fs.readFileSync( + const onboardSource = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + const probeSource = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "http-probe.ts"), + "utf-8", + ); + const recoverySource = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), "utf-8", ); - assert.match(source, /return \["--connect-timeout", "10", "--max-time", "60"\];/); - assert.match(source, /failure\.curlStatus === 2/); - assert.match(source, /local curl invocation error/); + assert.match(onboardSource, /http-probe/); + assert.match(probeSource, /return \["--connect-timeout", "10", "--max-time", "60"\];/); + assert.match(recoverySource, /failure\.curlStatus === 2/); + assert.match(recoverySource, /local curl invocation error/); }); it("suppresses expected provider-create AlreadyExists noise when update succeeds", () => { From 6e47348d2533e7327f07b02106f25090cc2cdb01 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 5 Apr 2026 13:42:35 -0700 Subject: [PATCH 2/5] refactor(cli): extract validation recovery helpers from onboard.js --- bin/lib/onboard.js | 61 +------------------ src/lib/validation-recovery.test.ts | 55 +++++++++++++++++ src/lib/validation-recovery.ts | 92 +++++++++++++++++++++++++++++ test/onboard.test.js | 2 +- 4 files changed, 150 insertions(+), 60 deletions(-) create mode 100644 src/lib/validation-recovery.test.ts create mode 100644 src/lib/validation-recovery.ts diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index dc780293a1f..c75dcbbf7bf 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -64,6 +64,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 validationRecovery = require("../../dist/lib/validation-recovery"); const webSearch = require("../../dist/lib/web-search"); /** @@ -642,34 +643,7 @@ function exitOnboardFromPrompt() { process.exit(1); } -function getTransportRecoveryMessage(failure = {}) { - const text = compactText(`${failure.message || ""} ${failure.stderr || ""}`).toLowerCase(); - if (failure.curlStatus === 2 || /option .* is unknown|curl --help|curl --manual/.test(text)) { - return " Validation hit a local curl invocation error. Retry after updating NemoClaw or use a different provider temporarily."; - } - if (failure.httpStatus === 429) { - return " The provider is rate limiting validation requests right now."; - } - if (failure.httpStatus >= 500 && failure.httpStatus < 600) { - return " The provider endpoint is reachable but currently failing upstream."; - } - if (failure.curlStatus === 6 || /could not resolve host|name or service not known/.test(text)) { - return " Validation could not resolve the provider hostname. Check DNS, VPN, or the endpoint URL."; - } - if (failure.curlStatus === 7 || /connection refused|failed to connect/.test(text)) { - return " Validation could not connect to the provider endpoint. Check the URL, proxy, or that the service is up."; - } - if (failure.curlStatus === 28 || /timed out|timeout/.test(text)) { - return " Validation timed out before the provider replied. Retry, or check network/proxy health."; - } - if (failure.curlStatus === 35 || failure.curlStatus === 60 || /ssl|tls|certificate/.test(text)) { - return " Validation hit a TLS/certificate error. Check HTTPS trust and whether the endpoint URL is correct."; - } - if (/proxy/.test(text)) { - return " Validation hit a proxy/connectivity error. Check proxy environment settings and endpoint reachability."; - } - return " Validation hit a network or transport error."; -} +const { getTransportRecoveryMessage, getProbeRecovery } = validationRecovery; // Validation functions — delegated to src/lib/validation.ts const { @@ -680,37 +654,6 @@ const { isSafeModelId, } = validation; -function getProbeRecovery(probe, options = {}) { - const allowModelRetry = options.allowModelRetry === true; - const failures = Array.isArray(probe?.failures) ? probe.failures : []; - if (failures.length === 0) { - return { kind: "unknown", retry: "selection" }; - } - if (failures.some((failure) => classifyValidationFailure(failure).kind === "credential")) { - return { kind: "credential", retry: "credential" }; - } - const transportFailure = failures.find( - (failure) => classifyValidationFailure(failure).kind === "transport", - ); - if (transportFailure) { - return { kind: "transport", retry: "retry", failure: transportFailure }; - } - if ( - allowModelRetry && - failures.some((failure) => classifyValidationFailure(failure).kind === "model") - ) { - return { kind: "model", retry: "model" }; - } - if (failures.some((failure) => classifyValidationFailure(failure).kind === "endpoint")) { - return { kind: "endpoint", retry: "selection" }; - } - const fallback = classifyValidationFailure(failures[0]); - if (!allowModelRetry && fallback.kind === "model") { - return { kind: "unknown", retry: "selection" }; - } - return fallback; -} - // validateNvidiaApiKeyValue — see validation import above async function replaceNamedCredential(envName, label, helpUrl = null, validator = null) { diff --git a/src/lib/validation-recovery.test.ts b/src/lib/validation-recovery.test.ts new file mode 100644 index 00000000000..df8d33ef0d1 --- /dev/null +++ b/src/lib/validation-recovery.test.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { getProbeRecovery, getTransportRecoveryMessage } from "./validation-recovery"; + +describe("validation-recovery helpers", () => { + it("classifies local curl invocation errors separately from network timeouts", () => { + expect(getTransportRecoveryMessage({ curlStatus: 2, message: "curl --manual" })).toContain( + "local curl invocation error", + ); + expect(getTransportRecoveryMessage({ curlStatus: 28, message: "operation timed out" })).toContain( + "timed out", + ); + }); + + it("returns targeted transport guidance for DNS and TLS failures", () => { + expect(getTransportRecoveryMessage({ curlStatus: 6, message: "Could not resolve host" })).toContain( + "could not resolve", + ); + expect(getTransportRecoveryMessage({ curlStatus: 60, message: "SSL certificate problem" })).toContain( + "TLS/certificate", + ); + }); + + it("prefers credential failures over endpoint and model issues", () => { + expect( + getProbeRecovery({ + failures: [ + { httpStatus: 404, message: "not found" }, + { httpStatus: 401, message: "invalid api key" }, + ], + }), + ).toEqual({ kind: "credential", retry: "credential" }); + }); + + it("returns the first transport failure for retry guidance", () => { + const failure = { curlStatus: 7, message: "failed to connect" }; + expect(getProbeRecovery({ failures: [{ httpStatus: 404 }, failure] })).toEqual({ + kind: "transport", + retry: "retry", + failure, + }); + }); + + it("only allows model-specific retry when explicitly enabled", () => { + const probe = { failures: [{ httpStatus: 400, message: "unknown model" }] }; + expect(getProbeRecovery(probe)).toEqual({ kind: "unknown", retry: "selection" }); + expect(getProbeRecovery(probe, { allowModelRetry: true })).toEqual({ + kind: "model", + retry: "model", + }); + }); +}); diff --git a/src/lib/validation-recovery.ts b/src/lib/validation-recovery.ts new file mode 100644 index 00000000000..5d7757eb5cf --- /dev/null +++ b/src/lib/validation-recovery.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { compactText } from "./url-utils"; +import { classifyValidationFailure, type ValidationClassification } from "./validation"; + +export interface ValidationFailureLike { + httpStatus?: number; + curlStatus?: number; + message?: string; + stderr?: string; +} + +export interface ProbeRecoveryOptions { + allowModelRetry?: boolean; +} + +export interface ProbeLike { + failures?: ValidationFailureLike[]; +} + +export type ProbeRecovery = + | ValidationClassification + | { + kind: "transport"; + retry: "retry"; + failure: ValidationFailureLike; + }; + +export function getTransportRecoveryMessage(failure: ValidationFailureLike = {}): string { + const text = compactText(`${failure.message || ""} ${failure.stderr || ""}`).toLowerCase(); + if (failure.curlStatus === 2 || /option .* is unknown|curl --help|curl --manual/.test(text)) { + return " Validation hit a local curl invocation error. Retry after updating NemoClaw or use a different provider temporarily."; + } + if (failure.httpStatus === 429) { + return " The provider is rate limiting validation requests right now."; + } + if (failure.httpStatus && failure.httpStatus >= 500 && failure.httpStatus < 600) { + return " The provider endpoint is reachable but currently failing upstream."; + } + if (failure.curlStatus === 6 || /could not resolve host|name or service not known/.test(text)) { + return " Validation could not resolve the provider hostname. Check DNS, VPN, or the endpoint URL."; + } + if (failure.curlStatus === 7 || /connection refused|failed to connect/.test(text)) { + return " Validation could not connect to the provider endpoint. Check the URL, proxy, or that the service is up."; + } + if (failure.curlStatus === 28 || /timed out|timeout/.test(text)) { + return " Validation timed out before the provider replied. Retry, or check network/proxy health."; + } + if ( + failure.curlStatus === 35 || + failure.curlStatus === 60 || + /ssl|tls|certificate/.test(text) + ) { + return " Validation hit a TLS/certificate error. Check HTTPS trust and whether the endpoint URL is correct."; + } + if (/proxy/.test(text)) { + return " Validation hit a proxy/connectivity error. Check proxy environment settings and endpoint reachability."; + } + return " Validation hit a network or transport error."; +} + +export function getProbeRecovery( + probe: ProbeLike, + options: ProbeRecoveryOptions = {}, +): ProbeRecovery { + const allowModelRetry = options.allowModelRetry === true; + const failures = Array.isArray(probe?.failures) ? probe.failures : []; + if (failures.length === 0) { + return { kind: "unknown", retry: "selection" }; + } + if (failures.some((failure) => classifyValidationFailure(failure).kind === "credential")) { + return { kind: "credential", retry: "credential" }; + } + const transportFailure = failures.find( + (failure) => classifyValidationFailure(failure).kind === "transport", + ); + if (transportFailure) { + return { kind: "transport", retry: "retry", failure: transportFailure }; + } + if (allowModelRetry && failures.some((failure) => classifyValidationFailure(failure).kind === "model")) { + return { kind: "model", retry: "model" }; + } + if (failures.some((failure) => classifyValidationFailure(failure).kind === "endpoint")) { + return { kind: "endpoint", retry: "selection" }; + } + const fallback = classifyValidationFailure(failures[0]); + if (!allowModelRetry && fallback.kind === "model") { + return { kind: "unknown", retry: "selection" }; + } + return fallback; +} diff --git a/test/onboard.test.js b/test/onboard.test.js index 2124977107b..b77cab26a51 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1249,7 +1249,7 @@ const { setupInference } = require(${onboardPath}); "utf-8", ); const recoverySource = fs.readFileSync( - path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + path.join(import.meta.dirname, "..", "src", "lib", "validation-recovery.ts"), "utf-8", ); From f0072e47077abfe816193019768eb4e954059af1 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 5 Apr 2026 13:45:20 -0700 Subject: [PATCH 3/5] refactor(cli): extract provider model helpers from onboard.js --- bin/lib/onboard.js | 143 +------------------- src/lib/provider-models.test.ts | 115 +++++++++++++++++ src/lib/provider-models.ts | 222 ++++++++++++++++++++++++++++++++ 3 files changed, 340 insertions(+), 140 deletions(-) create mode 100644 src/lib/provider-models.test.ts create mode 100644 src/lib/provider-models.ts diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index c75dcbbf7bf..a3846aafda8 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -64,6 +64,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 providerModels = require("../../dist/lib/provider-models"); const validationRecovery = require("../../dist/lib/validation-recovery"); const webSearch = require("../../dist/lib/web-search"); @@ -1376,146 +1377,8 @@ async function validateCustomAnthropicSelection( return { ok: false, retry }; } -function fetchNvidiaEndpointModels(apiKey) { - try { - const result = runCurlProbe([ - "-sS", - ...getCurlTimingArgs(), - "-H", - "Content-Type: application/json", - "-H", - `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`, - `${BUILD_ENDPOINT_URL}/models`, - ]); - if (!result.ok) { - return { - ok: false, - message: result.message, - status: result.httpStatus, - curlStatus: result.curlStatus, - }; - } - const parsed = JSON.parse(result.body); - const ids = Array.isArray(parsed?.data) - ? parsed.data.map((item) => item && item.id).filter(Boolean) - : []; - return { ok: true, ids }; - } catch (error) { - return { ok: false, message: error.message || String(error) }; - } -} - -function validateNvidiaEndpointModel(model, apiKey) { - const available = fetchNvidiaEndpointModels(apiKey); - if (!available.ok) { - return { - ok: false, - message: `Could not validate model against ${BUILD_ENDPOINT_URL}/models: ${available.message}`, - }; - } - if (available.ids.includes(model)) { - return { ok: true }; - } - return { - ok: false, - message: `Model '${model}' is not available from NVIDIA Endpoints. Checked ${BUILD_ENDPOINT_URL}/models.`, - }; -} - -function fetchOpenAiLikeModels(endpointUrl, apiKey) { - try { - const result = runCurlProbe([ - "-sS", - ...getCurlTimingArgs(), - ...(apiKey ? ["-H", `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`] : []), - `${String(endpointUrl).replace(/\/+$/, "")}/models`, - ]); - if (!result.ok) { - return { - ok: false, - status: result.httpStatus, - curlStatus: result.curlStatus, - message: result.message, - }; - } - const parsed = JSON.parse(result.body); - const ids = Array.isArray(parsed?.data) - ? parsed.data.map((item) => item && item.id).filter(Boolean) - : []; - return { ok: true, ids }; - } catch (error) { - return { ok: false, status: 0, message: error.message || String(error) }; - } -} - -function fetchAnthropicModels(endpointUrl, apiKey) { - try { - const result = runCurlProbe([ - "-sS", - ...getCurlTimingArgs(), - "-H", - `x-api-key: ${normalizeCredentialValue(apiKey)}`, - "-H", - "anthropic-version: 2023-06-01", - `${String(endpointUrl).replace(/\/+$/, "")}/v1/models`, - ]); - if (!result.ok) { - return { - ok: false, - status: result.httpStatus, - curlStatus: result.curlStatus, - message: result.message, - }; - } - const parsed = JSON.parse(result.body); - const ids = Array.isArray(parsed?.data) - ? parsed.data.map((item) => item && (item.id || item.name)).filter(Boolean) - : []; - return { ok: true, ids }; - } catch (error) { - return { ok: false, status: 0, message: error.message || String(error) }; - } -} - -function validateAnthropicModel(endpointUrl, model, apiKey) { - const available = fetchAnthropicModels(endpointUrl, apiKey); - if (!available.ok) { - if (available.status === 404 || available.status === 405) { - return { ok: true, validated: false }; - } - return { - ok: false, - message: `Could not validate model against ${String(endpointUrl).replace(/\/+$/, "")}/v1/models: ${available.message}`, - }; - } - if (available.ids.includes(model)) { - return { ok: true, validated: true }; - } - return { - ok: false, - message: `Model '${model}' is not available from Anthropic. Checked ${String(endpointUrl).replace(/\/+$/, "")}/v1/models.`, - }; -} - -function validateOpenAiLikeModel(label, endpointUrl, model, apiKey) { - const available = fetchOpenAiLikeModels(endpointUrl, apiKey); - if (!available.ok) { - if (available.status === 404 || available.status === 405) { - return { ok: true, validated: false }; - } - return { - ok: false, - message: `Could not validate model against ${String(endpointUrl).replace(/\/+$/, "")}/models: ${available.message}`, - }; - } - if (available.ids.includes(model)) { - return { ok: true, validated: true }; - } - return { - ok: false, - message: `Model '${model}' is not available from ${label}. Checked ${String(endpointUrl).replace(/\/+$/, "")}/models.`, - }; -} +const { validateNvidiaEndpointModel, validateAnthropicModel, validateOpenAiLikeModel } = + providerModels; async function promptManualModelId(promptLabel, errorLabel, validator = null) { while (true) { diff --git a/src/lib/provider-models.test.ts b/src/lib/provider-models.test.ts new file mode 100644 index 00000000000..999b990daaa --- /dev/null +++ b/src/lib/provider-models.test.ts @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + BUILD_ENDPOINT_URL, + fetchAnthropicModels, + fetchNvidiaEndpointModels, + fetchOpenAiLikeModels, + validateAnthropicModel, + validateNvidiaEndpointModel, + validateOpenAiLikeModel, +} from "./provider-models"; + +describe("provider model helpers", () => { + it("fetches NVIDIA endpoint model ids", () => { + const result = fetchNvidiaEndpointModels("nvapi-x", { + runCurlProbeImpl: (argv) => { + expect(argv.at(-1)).toBe(`${BUILD_ENDPOINT_URL}/models`); + expect(argv).toContain("Authorization: Bearer nvapi-x"); + return { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: JSON.stringify({ data: [{ id: "nemotron" }, { id: "llama" }] }), + stderr: "", + message: "", + }; + }, + }); + + expect(result).toEqual({ ok: true, ids: ["nemotron", "llama"] }); + }); + + it("reports NVIDIA validation failures with the checked endpoint", () => { + const result = validateNvidiaEndpointModel("missing", "nvapi-x", { + runCurlProbeImpl: () => ({ + ok: true, + httpStatus: 200, + curlStatus: 0, + body: JSON.stringify({ data: [{ id: "nemotron" }] }), + stderr: "", + message: "", + }), + }); + + expect(result).toEqual({ + ok: false, + message: `Model 'missing' is not available from NVIDIA Endpoints. Checked ${BUILD_ENDPOINT_URL}/models.`, + }); + }); + + it("fetches OpenAI-compatible model ids without an auth header when no key is provided", () => { + const result = fetchOpenAiLikeModels("https://example.test/v1/", "", { + runCurlProbeImpl: (argv) => { + expect(argv.at(-1)).toBe("https://example.test/v1/models"); + expect(argv.join(" ")).not.toContain("Authorization: Bearer"); + return { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: JSON.stringify({ data: [{ id: "gpt-4.1" }] }), + stderr: "", + message: "", + }; + }, + }); + + expect(result).toEqual({ ok: true, ids: ["gpt-4.1"] }); + }); + + it("treats unsupported /models endpoints as non-blocking validation gaps", () => { + expect( + validateOpenAiLikeModel("Example", "https://example.test/v1", "gpt-4.1", "sk-x", { + runCurlProbeImpl: () => ({ + ok: false, + httpStatus: 404, + curlStatus: 0, + body: "", + stderr: "", + message: "HTTP 404", + }), + }), + ).toEqual({ ok: true, validated: false }); + + expect( + validateAnthropicModel("https://example.test", "claude-sonnet", "sk-ant-x", { + runCurlProbeImpl: () => ({ + ok: false, + httpStatus: 405, + curlStatus: 0, + body: "", + stderr: "", + message: "HTTP 405", + }), + }), + ).toEqual({ ok: true, validated: false }); + }); + + it("accepts Anthropic model ids from either id or name fields", () => { + const result = fetchAnthropicModels("https://example.test", "sk-ant-x", { + runCurlProbeImpl: () => ({ + ok: true, + httpStatus: 200, + curlStatus: 0, + body: JSON.stringify({ data: [{ name: "claude-sonnet-4-6" }, { id: "claude-haiku-4-5" }] }), + stderr: "", + message: "", + }), + }); + + expect(result).toEqual({ ok: true, ids: ["claude-sonnet-4-6", "claude-haiku-4-5"] }); + }); +}); diff --git a/src/lib/provider-models.ts b/src/lib/provider-models.ts new file mode 100644 index 00000000000..44c9db7ca4c --- /dev/null +++ b/src/lib/provider-models.ts @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getCurlTimingArgs, runCurlProbe, type CurlProbeResult } from "./http-probe"; + +// credentials.js is CJS. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { normalizeCredentialValue } = require("../../bin/lib/credentials"); + +export const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; + +export interface FetchModelsSuccess { + ok: true; + ids: string[]; +} + +export interface FetchModelsFailure { + ok: false; + message: string; + status?: number; + curlStatus?: number; +} + +export type FetchModelsResult = FetchModelsSuccess | FetchModelsFailure; + +export interface ValidateModelResult { + ok: boolean; + message?: string; + validated?: boolean; +} + +export interface ProviderModelOptions { + runCurlProbeImpl?: (argv: string[]) => CurlProbeResult; + buildEndpointUrl?: string; +} + +function parseModelIds(body: string, itemKeys: string[] = ["id"]): string[] { + const parsed = JSON.parse(body) as { data?: Array | null> }; + if (!Array.isArray(parsed?.data)) return []; + return parsed.data + .map((item) => { + if (!item) return null; + for (const key of itemKeys) { + const value = item[key]; + if (typeof value === "string" && value) { + return value; + } + } + return null; + }) + .filter((value): value is string => Boolean(value)); +} + +export function fetchNvidiaEndpointModels( + apiKey: string, + options: ProviderModelOptions = {}, +): FetchModelsResult { + const runCurlProbeImpl = options.runCurlProbeImpl ?? runCurlProbe; + const buildEndpointUrl = options.buildEndpointUrl ?? BUILD_ENDPOINT_URL; + try { + const result = runCurlProbeImpl([ + "-sS", + ...getCurlTimingArgs(), + "-H", + "Content-Type: application/json", + "-H", + `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`, + `${buildEndpointUrl}/models`, + ]); + if (!result.ok) { + return { + ok: false, + message: result.message, + status: result.httpStatus, + curlStatus: result.curlStatus, + }; + } + return { ok: true, ids: parseModelIds(result.body) }; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } +} + +export function validateNvidiaEndpointModel( + model: string, + apiKey: string, + options: ProviderModelOptions = {}, +): ValidateModelResult { + const buildEndpointUrl = options.buildEndpointUrl ?? BUILD_ENDPOINT_URL; + const available = fetchNvidiaEndpointModels(apiKey, options); + if (!available.ok) { + return { + ok: false, + message: `Could not validate model against ${buildEndpointUrl}/models: ${available.message}`, + }; + } + if (available.ids.includes(model)) { + return { ok: true }; + } + return { + ok: false, + message: `Model '${model}' is not available from NVIDIA Endpoints. Checked ${buildEndpointUrl}/models.`, + }; +} + +export function fetchOpenAiLikeModels( + endpointUrl: string, + apiKey: string, + options: ProviderModelOptions = {}, +): FetchModelsResult { + const runCurlProbeImpl = options.runCurlProbeImpl ?? runCurlProbe; + try { + const result = runCurlProbeImpl([ + "-sS", + ...getCurlTimingArgs(), + ...(apiKey ? ["-H", `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`] : []), + `${String(endpointUrl).replace(/\/+$/, "")}/models`, + ]); + if (!result.ok) { + return { + ok: false, + status: result.httpStatus, + curlStatus: result.curlStatus, + message: result.message, + }; + } + return { ok: true, ids: parseModelIds(result.body) }; + } catch (error) { + return { + ok: false, + status: 0, + message: error instanceof Error ? error.message : String(error), + }; + } +} + +export function fetchAnthropicModels( + endpointUrl: string, + apiKey: string, + options: ProviderModelOptions = {}, +): FetchModelsResult { + const runCurlProbeImpl = options.runCurlProbeImpl ?? runCurlProbe; + try { + const result = runCurlProbeImpl([ + "-sS", + ...getCurlTimingArgs(), + "-H", + `x-api-key: ${normalizeCredentialValue(apiKey)}`, + "-H", + "anthropic-version: 2023-06-01", + `${String(endpointUrl).replace(/\/+$/, "")}/v1/models`, + ]); + if (!result.ok) { + return { + ok: false, + status: result.httpStatus, + curlStatus: result.curlStatus, + message: result.message, + }; + } + return { ok: true, ids: parseModelIds(result.body, ["id", "name"]) }; + } catch (error) { + return { + ok: false, + status: 0, + message: error instanceof Error ? error.message : String(error), + }; + } +} + +export function validateAnthropicModel( + endpointUrl: string, + model: string, + apiKey: string, + options: ProviderModelOptions = {}, +): ValidateModelResult { + const normalizedEndpointUrl = String(endpointUrl).replace(/\/+$/, ""); + const available = fetchAnthropicModels(endpointUrl, apiKey, options); + if (!available.ok) { + if (available.status === 404 || available.status === 405) { + return { ok: true, validated: false }; + } + return { + ok: false, + message: `Could not validate model against ${normalizedEndpointUrl}/v1/models: ${available.message}`, + }; + } + if (available.ids.includes(model)) { + return { ok: true, validated: true }; + } + return { + ok: false, + message: `Model '${model}' is not available from Anthropic. Checked ${normalizedEndpointUrl}/v1/models.`, + }; +} + +export function validateOpenAiLikeModel( + label: string, + endpointUrl: string, + model: string, + apiKey: string, + options: ProviderModelOptions = {}, +): ValidateModelResult { + const normalizedEndpointUrl = String(endpointUrl).replace(/\/+$/, ""); + const available = fetchOpenAiLikeModels(endpointUrl, apiKey, options); + if (!available.ok) { + if (available.status === 404 || available.status === 405) { + return { ok: true, validated: false }; + } + return { + ok: false, + message: `Could not validate model against ${normalizedEndpointUrl}/models: ${available.message}`, + }; + } + if (available.ids.includes(model)) { + return { ok: true, validated: true }; + } + return { + ok: false, + message: `Model '${model}' is not available from ${label}. Checked ${normalizedEndpointUrl}/models.`, + }; +} From 94b9430fc5a56f3ee8b4ae84d8b5672d68526fe9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 5 Apr 2026 13:50:45 -0700 Subject: [PATCH 4/5] refactor(cli): extract model prompt helpers from onboard.js --- bin/lib/onboard.js | 125 +-------------------- src/lib/model-prompts.test.ts | 87 +++++++++++++++ src/lib/model-prompts.ts | 205 ++++++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 122 deletions(-) create mode 100644 src/lib/model-prompts.test.ts create mode 100644 src/lib/model-prompts.ts diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index a3846aafda8..c390abb8747 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; @@ -1377,120 +1364,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); diff --git a/src/lib/model-prompts.test.ts b/src/lib/model-prompts.test.ts new file mode 100644 index 00000000000..e629d413324 --- /dev/null +++ b/src/lib/model-prompts.test.ts @@ -0,0 +1,87 @@ +// 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 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("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"); + }); +}); diff --git a/src/lib/model-prompts.ts b/src/lib/model-prompts.ts new file mode 100644 index 00000000000..e22c05d9bab --- /dev/null +++ b/src/lib/model-prompts.ts @@ -0,0 +1,205 @@ +// 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; +} + +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 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) { + deps.errorLine(` ${validation.message}`); + 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 (index >= 0 && index < deps.cloudModelOptions.length) { + return deps.cloudModelOptions[index].id; + } + + return promptManualModelId( + " NVIDIA Endpoints model id: ", + "NVIDIA Endpoints", + (model) => + deps.validateNvidiaEndpointModelFn(model, deps.getCredentialFn("NVIDIA_API_KEY") || ""), + 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 (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) { + deps.errorLine(` ${validation.message}`); + continue; + } + } + return trimmed; + } +} From 07f1f70b413ef2ac180881f76e40a5d95efbba89 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 5 Apr 2026 16:39:05 -0700 Subject: [PATCH 5/5] fix(cli): defer transient model validation failures --- src/lib/model-prompts.test.ts | 30 ++++++++++++++++++++++++++++++ src/lib/model-prompts.ts | 22 ++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/lib/model-prompts.test.ts b/src/lib/model-prompts.test.ts index 2569c576527..13ebe4f1fa8 100644 --- a/src/lib/model-prompts.test.ts +++ b/src/lib/model-prompts.test.ts @@ -68,6 +68,21 @@ describe("model prompt helpers", () => { ); }); + 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"]) }), @@ -109,4 +124,19 @@ describe("model prompt helpers", () => { 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 index 39957da0639..6ea65e29ed5 100644 --- a/src/lib/model-prompts.ts +++ b/src/lib/model-prompts.ts @@ -27,6 +27,7 @@ export const REMOTE_MODEL_OPTIONS: Record = { export interface PromptValidationResult { ok: boolean; message?: string; + deferValidation?: boolean; } export interface ModelPromptOptions { @@ -56,6 +57,13 @@ function exitOnboardFromPrompt(): never { 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, @@ -96,7 +104,12 @@ export async function promptManualModelId( if (validator) { const validation = validator(trimmed); if (!validation.ok) { - deps.errorLine(` ${validation.message}`); + if (validation.message) { + deps.errorLine(` ${validation.message}`); + } + if (shouldDeferValidationFailure(validation)) { + return trimmed; + } continue; } } @@ -201,7 +214,12 @@ export async function promptInputModel( if (validator) { const validation = validator(trimmed); if (!validation.ok) { - deps.errorLine(` ${validation.message}`); + if (validation.message) { + deps.errorLine(` ${validation.message}`); + } + if (shouldDeferValidationFailure(validation)) { + return trimmed; + } continue; } }