diff --git a/biome.json b/biome.json index e990b87412c..99455513804 100644 --- a/biome.json +++ b/biome.json @@ -86,7 +86,7 @@ "noExcessiveCognitiveComplexity": { "level": "error", "options": { - "maxAllowedComplexity": 255 + "maxAllowedComplexity": 245 } } }, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index d7a146c0a6b..b7f787e1a34 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -21,6 +21,11 @@ const { const { createInferenceSelectionValidationHelpers, }: typeof import("./onboard/inference-selection-validation") = require("./onboard/inference-selection-validation"); +const { + applyCloudFallbackSelection, + clearNimContainerBeforeRetry, + createRemoteModelValidator, +}: typeof import("./onboard/setup-nim-selection") = require("./onboard/setup-nim-selection"); const inferenceInputCapability = require("./onboard/inference-input-capability"); const { cleanupTempDir }: typeof import("./onboard/temp-files") = require("./onboard/temp-files"); const { @@ -1027,6 +1032,19 @@ const { agentProductName, promptValidationRecovery, }); +const { validateSelectedRemoteModel } = createRemoteModelValidator({ + OPENAI_ENDPOINT_URL, + ANTHROPIC_ENDPOINT_URL, + requireValue, + isBackToSelection, + validateCustomOpenAiLikeSelection, + validateCustomAnthropicSelection, + validateAnthropicSelectionWithRetryMessage, + validateOpenAiLikeSelection, + shouldRequireResponsesToolCalling, + shouldSkipResponsesProbe, + getProbeAuthMode, +}); const { promptCloudModel, promptRemoteModel, promptInputModel } = modelPrompts; const { validateAnthropicModel, validateOpenAiLikeModel } = providerModels; @@ -3329,6 +3347,588 @@ async function selectAndValidateOllamaModel( } } +type SetupNimSelectionState = + import("./onboard/setup-nim-selection").SetupNimSelectionState; + +type SetupNimSelectionResult = "selected" | "retry-selection"; + +type RemoteProviderSelectionArgs = { + selected: ProviderChoice; + requestedModel: string | null; + recoveredFromSandbox: boolean; + recoveredModel: string | null; + sandboxName: string | null; +}; + +async function handleVllmSelection( + state: SetupNimSelectionState, +): Promise { + console.log(` ✓ Using existing vLLM on localhost:${VLLM_PORT}`); + state.provider = "vllm-local"; + // Local vLLM uses an internal credential env, no user API key. + state.credentialEnv = null; + state.endpointUrl = getLocalProviderBaseUrl(state.provider); + if (!state.endpointUrl) { + console.error(" Local vLLM base URL could not be determined."); + process.exit(1); + } + + // Source boundary: local vLLM is an external process, so /v1/models can be + // unreachable, malformed, empty, or return an unsafe served id. setupNim is + // the last safe point before writing provider state, so fail closed here + // rather than returning a partially configured local provider. Remove this + // local guard only if the vLLM manager owns a typed, validated model probe. + const vllmModelsRaw = runCapture(["curl", "-sf", `http://127.0.0.1:${VLLM_PORT}/v1/models`], { + ignoreError: true, + }); + let vllmModels: { data?: Array<{ id?: unknown }> } = {}; + try { + vllmModels = JSON.parse(vllmModelsRaw); + if (vllmModels.data && vllmModels.data.length > 0) { + const detectedModel = + typeof vllmModels.data[0]?.id === "string" ? vllmModels.data[0].id : null; + state.model = detectedModel; + if (!detectedModel || !isSafeModelId(detectedModel)) { + console.error(` Detected model ID contains invalid characters: ${state.model}`); + process.exit(1); + } + console.log(` Detected model: ${state.model}`); + } else { + console.error(" Could not detect model from vLLM. Please specify manually."); + process.exit(1); + } + } catch { + console.error( + ` Could not query vLLM models endpoint. Is vLLM running on localhost:${VLLM_PORT}?`, + ); + process.exit(1); + } + + const validationBaseUrl = getLocalProviderValidationBaseUrl(state.provider); + if (!validationBaseUrl) { + console.error(" Local vLLM validation URL could not be determined."); + process.exit(1); + } + const validation = await validateOpenAiLikeSelection( + "Local vLLM", + validationBaseUrl, + requireValue(state.model as string | null | undefined, "Expected a detected vLLM model"), + null, + ); + if (validation.retry === "selection" || validation.retry === "model") { + return "retry-selection"; + } + if (!validation.ok) return "retry-selection"; + + localInference.applyVllmRuntimeContextWindow(vllmModels, state.model as string); + state.preferredInferenceApi = validation.api; + // Force chat completions — vLLM's /v1/responses endpoint does not run the + // --tool-call-parser, so tool calls arrive as raw text (#976). + if (state.preferredInferenceApi !== "openai-completions") { + console.log(" ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)"); + } + state.preferredInferenceApi = "openai-completions"; + return "selected"; +} + +async function handleRoutedSelection( + state: SetupNimSelectionState, +): Promise { + const bp = loadBlueprintProfile("routed"); + if (!bp || bp.router?.enabled !== true) { + console.error(" Router is not enabled in nemoclaw-blueprint/blueprint.yaml."); + if (isNonInteractive()) process.exit(1); + return "retry-selection"; + } + + const routerCredentialEnv = + bp.router?.credential_env || bp.credential_env || DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV; + state.credentialEnv = routerCredentialEnv; + const routedCredential = + hydrateCredentialEnv(routerCredentialEnv) || + normalizeCredentialValue(bp.credential_default || ""); + if (routedCredential) { + saveCredential(routerCredentialEnv, routedCredential); + } + + const _providerKeyHint = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim(); + if (_providerKeyHint && !resolveProviderCredential(routerCredentialEnv)) { + saveCredential(routerCredentialEnv, _providerKeyHint); + } + if (isNonInteractive()) { + if (!resolveProviderCredential(routerCredentialEnv)) { + console.error( + ` ${routerCredentialEnv} (or NEMOCLAW_PROVIDER_KEY) is required for Model Router in non-interactive mode.`, + ); + process.exit(1); + } + } else if (!resolveProviderCredential(routerCredentialEnv)) { + console.log(""); + console.log(" Model Router accepts NVIDIA API keys (nvapi-...)."); + console.log(" Get one at https://build.nvidia.com"); + console.log(""); + const routerCredentialResult = await ensureNamedCredential( + routerCredentialEnv, + "Model Router API key", + null, + ); + if (credentialPrompt.returningToProviderSelection(routerCredentialResult)) { + return "retry-selection"; + } + } + + state.provider = bp.provider_name || "nvidia-router"; + state.model = bp.model; + const { HOST_GATEWAY_URL } = require("./inference/local"); + const routerEndpointUrl = bp.endpoint || ""; + state.endpointUrl = routerEndpointUrl; + if (routerEndpointUrl.match(/localhost|127\.0\.0\.1/)) { + const u = new URL(routerEndpointUrl); + state.endpointUrl = `${HOST_GATEWAY_URL}:${u.port}${u.pathname}`; + } + state.preferredInferenceApi = "openai-completions"; + console.log(` ✓ Using Model Router: ${state.provider} / ${state.model}`); + return "selected"; +} + +async function handleNimLocalSelection( + gpu: ReturnType, + args: Pick< + RemoteProviderSelectionArgs, + "requestedModel" | "recoveredFromSandbox" | "recoveredModel" + >, + state: SetupNimSelectionState, +): Promise { + const localGpu = requireValue(gpu, "GPU details are required for local NIM model selection"); + const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= localGpu.totalMemoryMB); + if (models.length === 0) { + console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); + applyCloudFallbackSelection(state, REMOTE_PROVIDER_CONFIG.build); + return "selected"; + } + + let sel; + if (isNonInteractive()) { + const targetModel = + args.requestedModel || (args.recoveredFromSandbox ? args.recoveredModel : null); + if (targetModel) { + sel = models.find((m) => m.name === targetModel); + if (!sel) { + const label = args.requestedModel ? "NEMOCLAW_MODEL for NIM" : "Recorded NIM model"; + console.error(` Unsupported ${label}: ${targetModel}`); + process.exit(1); + } + } else { + sel = models[0]; + } + note(` [non-interactive] NIM model: ${sel.name}`); + } else { + console.log(""); + console.log(" Models that fit your GPU:"); + models.forEach((m, i) => { + console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`); + }); + console.log(""); + + const modelChoice = await prompt(` Choose model [1]: `); + sel = selectFromNumberedMenuOrExit(modelChoice, 1, models); + } + state.model = sel.name; + + let ngcApiKey: string | null = null; + if (!nim.isNgcLoggedIn()) { + if (isNonInteractive()) { + console.error( + " Docker is not logged in to nvcr.io. In non-interactive mode, run `docker login nvcr.io` first and retry.", + ); + process.exit(1); + } + console.log(""); + console.log(" NGC API Key required to pull NIM images."); + console.log(" Get one from: https://org.ngc.nvidia.com/setup/api-key"); + console.log(""); + let ngcKey = await credentialPrompt.readValue(" NGC API Key: "); + if (credentialPrompt.returningToProviderSelection(ngcKey)) return "retry-selection"; + if (!ngcKey) { + console.error(" NGC API Key is required for Local NIM."); + process.exit(1); + } + if (!nim.dockerLoginNgc(ngcKey)) { + console.error(" Failed to login to NGC registry. Check your API key and try again."); + console.log(""); + ngcKey = await credentialPrompt.readValue(" NGC API Key: "); + if (credentialPrompt.returningToProviderSelection(ngcKey)) return "retry-selection"; + if (!ngcKey || !nim.dockerLoginNgc(ngcKey)) { + console.error(" NGC login failed. Cannot pull NIM images."); + process.exit(1); + } + } + ngcApiKey = ngcKey; + } else { + ngcApiKey = + hydrateCredentialEnv("NGC_API_KEY") || hydrateCredentialEnv("NVIDIA_INFERENCE_API_KEY"); + if (!ngcApiKey && !isNonInteractive()) { + console.log(""); + console.log(" NGC API Key required to download NIM model weights at runtime."); + console.log(" (Docker is logged in to nvcr.io, but the key was not saved.)"); + const ngcKey = await credentialPrompt.readValue(" NGC API Key: "); + if (credentialPrompt.returningToProviderSelection(ngcKey)) return "retry-selection"; + ngcApiKey = ngcKey || null; + } + } + + console.log(` Pulling NIM image for ${state.model}...`); + nim.pullNimImage(state.model); + + console.log(" Starting NIM container..."); + const nimContainerNameLocal = nim.containerName(GATEWAY_NAME); + state.nimContainer = nim.startNimContainerByName(nimContainerNameLocal, state.model, undefined, { + ngcApiKey: ngcApiKey ?? undefined, + }); + + console.log(" Waiting for NIM to become healthy..."); + if (!nim.waitForNimHealth(undefined, undefined, { container: nimContainerNameLocal })) { + console.error(" NIM failed to start. Falling back to cloud API."); + applyCloudFallbackSelection(state, REMOTE_PROVIDER_CONFIG.build); + return "selected"; + } + + state.provider = "vllm-local"; + state.credentialEnv = null; + state.endpointUrl = getLocalProviderBaseUrl(state.provider); + if (!state.endpointUrl) { + console.error(" Local NVIDIA NIM base URL could not be determined."); + process.exit(1); + } + state.model = nim.adoptServedModelId(state.model); + const nimValidationUrl = getLocalProviderValidationBaseUrl(state.provider) || state.endpointUrl; + const validation = await validateOpenAiLikeSelection( + "Local NVIDIA NIM", + nimValidationUrl, + requireValue(state.model, "Expected a Local NVIDIA NIM model after startup"), + null, + ); + if (validation.retry === "selection" || validation.retry === "model") { + clearNimContainerBeforeRetry(state); + return "retry-selection"; + } + if (!validation.ok) { + clearNimContainerBeforeRetry(state); + return "retry-selection"; + } + if (validation.api !== "openai-completions") { + console.log(" ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)"); + } + state.preferredInferenceApi = "openai-completions"; + return "selected"; +} + +async function handleRemoteProviderSelection( + args: RemoteProviderSelectionArgs, + state: SetupNimSelectionState, +): Promise { + const { selected, requestedModel, recoveredFromSandbox, recoveredModel, sandboxName } = args; + const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; + state.provider = remoteConfig.providerName; + state.credentialEnv = remoteConfig.credentialEnv; + state.endpointUrl = remoteConfig.endpointUrl; + state.preferredInferenceApi = null; + + if (selected.key === "custom" || selected.key === "anthropicCompatible") { + const kind = selected.key === "custom" ? "openai" : "anthropic"; + const _envUrl = (process.env.NEMOCLAW_ENDPOINT_URL || "").trim(); + const endpointInput = isNonInteractive() + ? _envUrl + : (await prompt( + _envUrl + ? ` ${kind === "openai" ? "OpenAI" : "Anthropic"}-compatible base URL [${_envUrl}]: ` + : kind === "openai" + ? " OpenAI-compatible base URL (e.g., https://openrouter.ai): " + : " Anthropic-compatible base URL (e.g., https://proxy.example.com): ", + )) || _envUrl; + const navigation = getNavigationChoice(endpointInput); + if (navigation === "back") { + console.log(" Returning to provider selection."); + console.log(""); + return "retry-selection"; + } + if (navigation === "exit") { + exitOnboardFromPrompt(); + } + state.endpointUrl = normalizeProviderBaseUrl(endpointInput, kind); + if (!state.endpointUrl) { + console.error( + selected.key === "custom" + ? " Endpoint URL is required for Other OpenAI-compatible endpoint." + : " Endpoint URL is required for Other Anthropic-compatible endpoint.", + ); + if (isNonInteractive()) { + process.exit(1); + } + console.log(""); + return "retry-selection"; + } + if (selected.key === "anthropicCompatible") { + state.endpointUrl = bedrockRuntimeOnboard.normalizeCustomAnthropicEndpointUrl( + state.endpointUrl, + ); + } + } + + if (selected.key === "hermesProvider") { + const selectedHermesAuthMethod = await promptHermesAuthMethod(); + if (isBackToSelection(selectedHermesAuthMethod)) { + state.hermesAuthMethod = null; + console.log(" Returning to provider selection."); + console.log(""); + return "retry-selection"; + } + state.hermesAuthMethod = normalizeHermesAuthMethod( + selectedHermesAuthMethod as string | null | undefined, + ); + if (state.hermesAuthMethod === HERMES_AUTH_METHOD_API_KEY) { + state.credentialEnv = HERMES_NOUS_API_KEY_CREDENTIAL_ENV; + stageNousApiKeyProviderEnv(); + if (isNonInteractive()) { + if (!resolveHermesNousApiKey()) { + console.error(" Hermes Provider Nous API Key is required in non-interactive mode."); + process.exit(1); + } + } else { + const hermesKeyResult = await ensureHermesNousApiKeyEnv(); + if (credentialPrompt.returningToProviderSelection(hermesKeyResult)) { + return "retry-selection"; + } + } + } else { + state.credentialEnv = remoteConfig.credentialEnv; + } + const recordedHermesToolGateways = sandboxName + ? normalizeHermesToolGatewaySelections(registry.getSandbox(sandboxName)?.hermesToolGateways) + : null; + state.hermesToolGateways = await setupHermesToolGateways( + state.provider, + state.hermesAuthMethod, + recordedHermesToolGateways, + { prompt, note, isNonInteractive }, + ); + + const defaultModel = + requestedModel || (recoveredFromSandbox && recoveredModel) || remoteConfig.defaultModel; + if (isNonInteractive()) { + state.model = defaultModel; + } else { + let hermesProviderModels: string[] = []; + try { + hermesProviderModels = await nousModels.getHermesProviderModelOptions(); + } catch (err) { + // Source boundary: Nous model recommendations are advisory network data, + // while the user's requested/default model remains the source of truth + // for onboarding. Keep Hermes auth/tool-gateway state and continue with + // fallback model prompting. Remove this fallback only when the provider + // registry can supply recommendations without network failure modes. + const detail = err instanceof Error ? err.message : String(err); + console.warn( + ` Warning: failed to load Nous model recommendations; falling back to the current/default model (${detail}).`, + ); + } + state.model = await promptRemoteModel(remoteConfig.label, selected.key, defaultModel, null, { + otherShowsFullList: true, + remoteModelOptions: { [selected.key]: hermesProviderModels }, + topLevelModelLimit: 10, + }); + } + if (isBackToSelection(state.model)) { + console.log(" Returning to provider selection."); + console.log(""); + return "retry-selection"; + } + state.preferredInferenceApi = "openai-completions"; + console.log(` Using ${remoteConfig.label} with model: ${state.model}`); + return "selected"; + } + + hydrateCredentialEnv(state.credentialEnv); + + if (selected.key === "build") { + const _nvProviderKey = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim(); + const existingNvidiaKey = ["NVIDIA_INFERENCE_API_KEY", "NVIDIA_API_KEY"] + .map((envName) => normalizeCredentialValue(process.env[envName] ?? "")) + .find(Boolean); + if (_nvProviderKey && !existingNvidiaKey) { + process.env.NVIDIA_INFERENCE_API_KEY = _nvProviderKey; + } + if (isNonInteractive()) { + const resolvedNvidiaKey = resolveProviderCredential("NVIDIA_INFERENCE_API_KEY"); + if (resolvedNvidiaKey) { + const keyError = validateNvidiaApiKeyValue(resolvedNvidiaKey); + if (keyError) { + console.error(keyError); + console.error(` Get a key from ${REMOTE_PROVIDER_CONFIG.build.helpUrl}`); + process.exit(1); + } + } else if (!providerExistsInGateway(state.provider)) { + logMissingNvidiaApiKeyHelp(REMOTE_PROVIDER_CONFIG.build.helpUrl); + process.exit(1); + } + } else { + await ensureApiKey(); + } + const _envModel = (process.env.NEMOCLAW_MODEL || "").trim(); + state.model = + requestedModel || + (recoveredFromSandbox && recoveredModel) || + (isNonInteractive() + ? DEFAULT_CLOUD_MODEL + : await promptCloudModel({ defaultModelId: _envModel || undefined })) || + DEFAULT_CLOUD_MODEL; + if (isBackToSelection(state.model)) { + console.log(" Returning to provider selection."); + console.log(""); + return "retry-selection"; + } + } else { + const _providerKeyHint = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim(); + if (_providerKeyHint && state.credentialEnv) { + const existingCredentialKey = normalizeCredentialValue( + process.env[state.credentialEnv] ?? "", + ); + if (!existingCredentialKey) { + process.env[state.credentialEnv] = _providerKeyHint; + } + } + + const _envModelRemote = (process.env.NEMOCLAW_MODEL || "").trim(); + const defaultModel = + requestedModel || + _envModelRemote || + (recoveredFromSandbox && recoveredModel) || + remoteConfig.defaultModel; + const selectedCredentialEnv = requireValue( + state.credentialEnv, + `Missing credential env for ${remoteConfig.label}`, + ); + const bedrockSelection = await bedrockRuntimeOnboard.selectBedrockRuntimeCustomAnthropic({ + selectedKey: selected.key, + endpointUrl: state.endpointUrl, + credentialEnv: selectedCredentialEnv, + label: remoteConfig.label, + helpUrl: remoteConfig.helpUrl, + defaultModel, + backToSelection: BACK_TO_SELECTION, + isNonInteractive, + promptInputModel, + replaceNamedCredential, + }); + if (bedrockSelection.action === "retry-selection") { + console.log(" Returning to provider selection."); + console.log(""); + return "retry-selection"; + } + if (bedrockSelection.action === "selected") { + state.model = bedrockSelection.model; + state.preferredInferenceApi = bedrockSelection.preferredInferenceApi; + return "selected"; + } + if (isNonInteractive()) { + if ( + !resolveProviderCredential(selectedCredentialEnv) && + !providerExistsInGateway(state.provider) + ) { + console.error( + ` Provider credential (or NEMOCLAW_PROVIDER_KEY) is required for ${remoteConfig.label} in non-interactive mode.`, + ); + process.exit(1); + } + } else { + const credentialResult = await ensureNamedCredential( + selectedCredentialEnv, + `${remoteConfig.label} API key`, + remoteConfig.helpUrl, + ); + if (credentialPrompt.returningToProviderSelection(credentialResult)) { + return "retry-selection"; + } + } + let modelValidator: ((candidate: string) => ModelValidationResult) | null = null; + if (selected.key === "openai" || selected.key === "gemini") { + const modelAuthMode = getProbeAuthMode(state.provider); + modelValidator = (candidate) => + validateOpenAiLikeModel( + remoteConfig.label, + state.endpointUrl || remoteConfig.endpointUrl, + candidate, + getCredential(selectedCredentialEnv) || "", + ...(modelAuthMode ? [{ authMode: modelAuthMode }] : []), + ); + } else if (selected.key === "anthropic") { + modelValidator = (candidate) => + validateAnthropicModel( + state.endpointUrl || ANTHROPIC_ENDPOINT_URL, + candidate, + getCredential(selectedCredentialEnv) || "", + ); + } + while (true) { + if (isNonInteractive()) { + state.model = defaultModel; + } else if (remoteConfig.modelMode === "curated") { + state.model = await promptRemoteModel( + remoteConfig.label, + selected.key, + defaultModel, + modelValidator, + ); + } else { + state.model = await promptInputModel(remoteConfig.label, defaultModel, modelValidator); + } + if (isBackToSelection(state.model)) { + console.log(" Returning to provider selection."); + console.log(""); + return "retry-selection"; + } + + const validationResult = await validateSelectedRemoteModel({ + selected, + remoteConfig, + state, + selectedCredentialEnv, + }); + if (validationResult === "selected") break; + if (validationResult === "retry-selection") return "retry-selection"; + } + } + + if (selected.key === "build") { + while (true) { + const validation = await validateOpenAiLikeSelection( + remoteConfig.label, + requireValue(state.endpointUrl, `Missing endpoint URL for ${remoteConfig.label}`), + state.model, + state.credentialEnv, + "Please choose a provider/model again.", + remoteConfig.helpUrl, + { + requireResponsesToolCalling: shouldRequireResponsesToolCalling(state.provider), + skipResponsesProbe: shouldSkipResponsesProbe(state.provider), + authMode: getProbeAuthMode(state.provider), + }, + ); + if (validation.ok) { + state.preferredInferenceApi = validation.api; + break; + } + if (validation.retry === "credential" || validation.retry === "retry") { + continue; + } + return "retry-selection"; + } + } + + console.log(` Using ${remoteConfig.label} with model: ${state.model}`); + return "selected"; +} + async function setupNim( gpu: ReturnType, sandboxName: string | null = null, @@ -3481,575 +4081,58 @@ async function setupNim( } if (REMOTE_PROVIDER_CONFIG[selected.key]) { - const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; - provider = remoteConfig.providerName; - credentialEnv = remoteConfig.credentialEnv; - endpointUrl = remoteConfig.endpointUrl; - preferredInferenceApi = null; - - if (selected.key === "custom") { - const _envUrl = (process.env.NEMOCLAW_ENDPOINT_URL || "").trim(); - const endpointInput = isNonInteractive() - ? _envUrl - : (await prompt( - _envUrl - ? ` OpenAI-compatible base URL [${_envUrl}]: ` - : " OpenAI-compatible base URL (e.g., https://openrouter.ai): ", - )) || _envUrl; - const navigation = getNavigationChoice(endpointInput); - if (navigation === "back") { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } - if (navigation === "exit") { - exitOnboardFromPrompt(); - } - endpointUrl = normalizeProviderBaseUrl(endpointInput, "openai"); - if (!endpointUrl) { - console.error(" Endpoint URL is required for Other OpenAI-compatible endpoint."); - if (isNonInteractive()) { - process.exit(1); - } - console.log(""); - continue selectionLoop; - } - } else if (selected.key === "anthropicCompatible") { - const _envUrl = (process.env.NEMOCLAW_ENDPOINT_URL || "").trim(); - const endpointInput = isNonInteractive() - ? _envUrl - : (await prompt( - _envUrl - ? ` Anthropic-compatible base URL [${_envUrl}]: ` - : " Anthropic-compatible base URL (e.g., https://proxy.example.com): ", - )) || _envUrl; - const navigation = getNavigationChoice(endpointInput); - if (navigation === "back") { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } - if (navigation === "exit") { - exitOnboardFromPrompt(); - } - endpointUrl = normalizeProviderBaseUrl(endpointInput, "anthropic"); - if (!endpointUrl) { - console.error(" Endpoint URL is required for Other Anthropic-compatible endpoint."); - if (isNonInteractive()) { - process.exit(1); - } - console.log(""); - continue selectionLoop; - } - endpointUrl = bedrockRuntimeOnboard.normalizeCustomAnthropicEndpointUrl(endpointUrl); - } - - if (selected.key === "hermesProvider") { - const selectedHermesAuthMethod = await promptHermesAuthMethod(); - if (isBackToSelection(selectedHermesAuthMethod)) { - hermesAuthMethod = null; - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } - hermesAuthMethod = normalizeHermesAuthMethod( - selectedHermesAuthMethod as string | null | undefined, - ); - if (hermesAuthMethod === HERMES_AUTH_METHOD_API_KEY) { - credentialEnv = HERMES_NOUS_API_KEY_CREDENTIAL_ENV; - stageNousApiKeyProviderEnv(); - if (isNonInteractive()) { - if (!resolveHermesNousApiKey()) { - console.error( - " Hermes Provider Nous API Key is required in non-interactive mode.", - ); - process.exit(1); - } - } else { - const hermesKeyResult = await ensureHermesNousApiKeyEnv(); - if (credentialPrompt.returningToProviderSelection(hermesKeyResult)) - continue selectionLoop; - } - } else { - credentialEnv = remoteConfig.credentialEnv; - } - const recordedHermesToolGateways = sandboxName - ? normalizeHermesToolGatewaySelections( - registry.getSandbox(sandboxName)?.hermesToolGateways, - ) - : null; - hermesToolGateways = await setupHermesToolGateways( - provider, - hermesAuthMethod, - recordedHermesToolGateways, - { prompt, note, isNonInteractive }, - ); - - const defaultModel = - requestedModel || (recoveredFromSandbox && recoveredModel) || remoteConfig.defaultModel; - if (isNonInteractive()) { - model = defaultModel; - } else { - let hermesProviderModels: string[] = []; - try { - hermesProviderModels = await nousModels.getHermesProviderModelOptions(); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - console.warn( - ` Warning: failed to load Nous model recommendations; falling back to the current/default model (${detail}).`, - ); - } - model = await promptRemoteModel(remoteConfig.label, selected.key, defaultModel, null, { - otherShowsFullList: true, - remoteModelOptions: { [selected.key]: hermesProviderModels }, - topLevelModelLimit: 10, - }); - } - if (isBackToSelection(model)) { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } - preferredInferenceApi = "openai-completions"; - console.log(` Using ${remoteConfig.label} with model: ${model}`); - break; - } - - // Hydrate from credential env vars set earlier in this process - // before checking env, so rebuild and other non-interactive callers - // can resolve keys stored during the original interactive onboard. - // See #2273. - hydrateCredentialEnv(credentialEnv); - - if (selected.key === "build") { - // Let NEMOCLAW_PROVIDER_KEY fill the NVIDIA key without overriding explicit env. - const _nvProviderKey = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim(); - const existingNvidiaKey = ["NVIDIA_INFERENCE_API_KEY", "NVIDIA_API_KEY"] - .map((envName) => normalizeCredentialValue(process.env[envName] ?? "")) - .find(Boolean); - if (_nvProviderKey && !existingNvidiaKey) { - process.env.NVIDIA_INFERENCE_API_KEY = _nvProviderKey; - } - if (isNonInteractive()) { - const resolvedNvidiaKey = resolveProviderCredential("NVIDIA_INFERENCE_API_KEY"); - if (resolvedNvidiaKey) { - const keyError = validateNvidiaApiKeyValue(resolvedNvidiaKey); - if (keyError) { - console.error(keyError); - console.error(` Get a key from ${REMOTE_PROVIDER_CONFIG.build.helpUrl}`); - process.exit(1); - } - } else if (!providerExistsInGateway(provider)) { - logMissingNvidiaApiKeyHelp(REMOTE_PROVIDER_CONFIG.build.helpUrl); - process.exit(1); - } - } else { - await ensureApiKey(); - } - const _envModel = (process.env.NEMOCLAW_MODEL || "").trim(); - model = - requestedModel || - (recoveredFromSandbox && recoveredModel) || - (isNonInteractive() - ? DEFAULT_CLOUD_MODEL - : await promptCloudModel({ defaultModelId: _envModel || undefined })) || - DEFAULT_CLOUD_MODEL; - if (isBackToSelection(model)) { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } - } else { - // NEMOCLAW_PROVIDER_KEY is a universal alias: if the specific credential env - // isn't already set, use NEMOCLAW_PROVIDER_KEY as the API key for this provider. - // Check raw process.env — the override must apply before resolving from credentials.json. - const _providerKeyHint = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim(); - if (_providerKeyHint && credentialEnv) { - const existingCredentialKey = normalizeCredentialValue( - // check-direct-credential-env-ignore -- intentional: checking if env is already set before applying NEMOCLAW_PROVIDER_KEY override - process.env[credentialEnv] ?? "", - ); - if (!existingCredentialKey) { - process.env[credentialEnv] = _providerKeyHint; - } - } - - const _envModelRemote = (process.env.NEMOCLAW_MODEL || "").trim(); - const defaultModel = - requestedModel || - _envModelRemote || - (recoveredFromSandbox && recoveredModel) || - remoteConfig.defaultModel; - const selectedCredentialEnv = requireValue( - credentialEnv, - `Missing credential env for ${remoteConfig.label}`, - ); - const bedrockSelection = await bedrockRuntimeOnboard.selectBedrockRuntimeCustomAnthropic({ - selectedKey: selected.key, - endpointUrl, - credentialEnv: selectedCredentialEnv, - label: remoteConfig.label, - helpUrl: remoteConfig.helpUrl, - defaultModel, - backToSelection: BACK_TO_SELECTION, - isNonInteractive, - promptInputModel, - replaceNamedCredential, - }); - if (bedrockSelection.action === "retry-selection") { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } - if (bedrockSelection.action === "selected") { - model = bedrockSelection.model; - preferredInferenceApi = bedrockSelection.preferredInferenceApi; - break; - } - if (isNonInteractive()) { - if ( - !resolveProviderCredential(selectedCredentialEnv) && - !providerExistsInGateway(provider) - ) { - console.error( - ` ${selectedCredentialEnv} (or NEMOCLAW_PROVIDER_KEY) is required for ${remoteConfig.label} in non-interactive mode.`, - ); - process.exit(1); - } - } else { - const credentialResult = await ensureNamedCredential( - selectedCredentialEnv, - remoteConfig.label + " API key", - remoteConfig.helpUrl, - ); - if (credentialPrompt.returningToProviderSelection(credentialResult)) - continue selectionLoop; - } - let modelValidator: ((candidate: string) => ModelValidationResult) | null = null; - if (selected.key === "openai" || selected.key === "gemini") { - const modelAuthMode = getProbeAuthMode(provider); - modelValidator = (candidate) => - validateOpenAiLikeModel( - remoteConfig.label, - endpointUrl || remoteConfig.endpointUrl, - candidate, - getCredential(selectedCredentialEnv) || "", - ...(modelAuthMode ? [{ authMode: modelAuthMode }] : []), - ); - } else if (selected.key === "anthropic") { - modelValidator = (candidate) => - validateAnthropicModel( - endpointUrl || ANTHROPIC_ENDPOINT_URL, - candidate, - getCredential(selectedCredentialEnv) || "", - ); - } - while (true) { - if (isNonInteractive()) { - model = defaultModel; - } else if (remoteConfig.modelMode === "curated") { - model = await promptRemoteModel( - remoteConfig.label, - selected.key, - defaultModel, - modelValidator, - ); - } else { - model = await promptInputModel(remoteConfig.label, defaultModel, modelValidator); - } - if (isBackToSelection(model)) { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } - - if (selected.key === "custom") { - const validation = await validateCustomOpenAiLikeSelection( - remoteConfig.label, - endpointUrl || OPENAI_ENDPOINT_URL, - model, - selectedCredentialEnv, - remoteConfig.helpUrl, - ); - if (validation.ok) { - // Force chat completions for all OpenAI-compatible endpoints - // unless the user explicitly opted in to responses via env var. - // Many backends (Ollama, vLLM, LiteLLM) expose /v1/responses - // but do not correctly handle the `developer` role used by the - // Responses API — messages with that role are silently dropped, - // causing the model to receive no system prompt or tool - // definitions. Chat completions uses the `system` role which - // is universally supported. - // See: https://github.com/NVIDIA/NemoClaw/issues/1932 - const explicitApi = (process.env.NEMOCLAW_PREFERRED_API || "").trim().toLowerCase(); - if ( - explicitApi && - explicitApi !== "openai-completions" && - explicitApi !== "chat-completions" - ) { - preferredInferenceApi = validation.api; - } else { - if (validation.api !== "openai-completions") { - console.log( - " ℹ Using chat completions API (compatible endpoints may not support the Responses API developer role)", - ); - } - preferredInferenceApi = "openai-completions"; - } - break; - } - if ( - validation.retry === "credential" || - validation.retry === "retry" || - validation.retry === "model" - ) { - continue; - } - if (validation.retry === "selection") { - continue selectionLoop; - } - } else if (selected.key === "anthropicCompatible") { - const validation = await validateCustomAnthropicSelection( - remoteConfig.label, - endpointUrl || ANTHROPIC_ENDPOINT_URL, - model, - selectedCredentialEnv, - remoteConfig.helpUrl, - ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if ( - validation.retry === "credential" || - validation.retry === "retry" || - validation.retry === "model" - ) { - continue; - } - if (validation.retry === "selection") { - continue selectionLoop; - } - } else { - const retryMessage = "Please choose a provider/model again."; - if (selected.key === "anthropic") { - const validation = await validateAnthropicSelectionWithRetryMessage( - remoteConfig.label, - endpointUrl || ANTHROPIC_ENDPOINT_URL, - model, - selectedCredentialEnv, - retryMessage, - remoteConfig.helpUrl, - ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if ( - validation.retry === "credential" || - validation.retry === "retry" || - validation.retry === "model" - ) { - continue; - } - } else { - const validation = await validateOpenAiLikeSelection( - remoteConfig.label, - requireValue(endpointUrl, `Missing endpoint URL for ${remoteConfig.label}`), - model, - selectedCredentialEnv, - retryMessage, - remoteConfig.helpUrl, - { - requireResponsesToolCalling: shouldRequireResponsesToolCalling(provider), - skipResponsesProbe: shouldSkipResponsesProbe(provider), - authMode: getProbeAuthMode(provider), - }, - ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if ( - validation.retry === "credential" || - validation.retry === "retry" || - validation.retry === "model" - ) { - continue; - } - } - continue selectionLoop; - } - } - } - - if (selected.key === "build") { - while (true) { - const validation = await validateOpenAiLikeSelection( - remoteConfig.label, - requireValue(endpointUrl, `Missing endpoint URL for ${remoteConfig.label}`), - model, - credentialEnv, - "Please choose a provider/model again.", - remoteConfig.helpUrl, - { - requireResponsesToolCalling: shouldRequireResponsesToolCalling(provider), - skipResponsesProbe: shouldSkipResponsesProbe(provider), - authMode: getProbeAuthMode(provider), - }, - ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if (validation.retry === "credential" || validation.retry === "retry") { - continue; - } - continue selectionLoop; - } - } - - console.log(` Using ${remoteConfig.label} with model: ${model}`); + const state: SetupNimSelectionState = { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + }; + const result = await handleRemoteProviderSelection( + { selected, requestedModel, recoveredFromSandbox, recoveredModel, sandboxName }, + state, + ); + ({ + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + } = state); + if (result === "retry-selection") continue selectionLoop; break; } else if (selected.key === "nim-local") { - const localGpu = requireValue( + const state: SetupNimSelectionState = { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + }; + const result = await handleNimLocalSelection( gpu, - "GPU details are required for local NIM model selection", + { requestedModel, recoveredFromSandbox, recoveredModel }, + state, ); - // List models that fit GPU VRAM - const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= localGpu.totalMemoryMB); - if (models.length === 0) { - console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); - } else { - let sel; - if (isNonInteractive()) { - const targetModel = requestedModel || (recoveredFromSandbox ? recoveredModel : null); - if (targetModel) { - sel = models.find((m) => m.name === targetModel); - if (!sel) { - const label = requestedModel ? "NEMOCLAW_MODEL for NIM" : "Recorded NIM model"; - console.error(` Unsupported ${label}: ${targetModel}`); - process.exit(1); - } - } else { - sel = models[0]; - } - note(` [non-interactive] NIM model: ${sel.name}`); - } else { - console.log(""); - console.log(" Models that fit your GPU:"); - models.forEach((m, i) => { - console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`); - }); - console.log(""); - - const modelChoice = await prompt(` Choose model [1]: `); - sel = selectFromNumberedMenuOrExit(modelChoice, 1, models); - } - model = sel.name; - - // Ensure Docker is logged in to NGC registry before pulling NIM images. - // The key is also forwarded into the NIM container at runtime (#3333), - // so we hoist it out of the not-logged-in branch. - let ngcApiKey: string | null = null; - if (!nim.isNgcLoggedIn()) { - if (isNonInteractive()) { - console.error( - " Docker is not logged in to nvcr.io. In non-interactive mode, run `docker login nvcr.io` first and retry.", - ); - process.exit(1); - } - console.log(""); - console.log(" NGC API Key required to pull NIM images."); - console.log(" Get one from: https://org.ngc.nvidia.com/setup/api-key"); - console.log(""); - let ngcKey = await credentialPrompt.readValue(" NGC API Key: "); - if (credentialPrompt.returningToProviderSelection(ngcKey)) continue selectionLoop; - if (!ngcKey) { - console.error(" NGC API Key is required for Local NIM."); - process.exit(1); - } - if (!nim.dockerLoginNgc(ngcKey)) { - console.error(" Failed to login to NGC registry. Check your API key and try again."); - console.log(""); - ngcKey = await credentialPrompt.readValue(" NGC API Key: "); - if (credentialPrompt.returningToProviderSelection(ngcKey)) continue selectionLoop; - if (!ngcKey || !nim.dockerLoginNgc(ngcKey)) { - console.error(" NGC login failed. Cannot pull NIM images."); - process.exit(1); - } - } - ngcApiKey = ngcKey; - } else { - // Docker is already logged in, but NIM still needs the key in its - // container env to download model manifests. Users hit by the - // original #3333 bug typically have a cached docker login from - // the earlier broken attempt while the NGC key was never saved - // anywhere, so a passive lookup would silently reproduce the - // failure. Try env first, then prompt interactively; an empty - // answer falls through to startNimContainerByName's warning so - // we don't double-fail in non-interactive callers. - ngcApiKey = - hydrateCredentialEnv("NGC_API_KEY") || - hydrateCredentialEnv("NVIDIA_INFERENCE_API_KEY"); - if (!ngcApiKey && !isNonInteractive()) { - console.log(""); - console.log(" NGC API Key required to download NIM model weights at runtime."); - console.log(" (Docker is logged in to nvcr.io, but the key was not saved.)"); - const ngcKey = await credentialPrompt.readValue(" NGC API Key: "); - if (credentialPrompt.returningToProviderSelection(ngcKey)) continue selectionLoop; - ngcApiKey = ngcKey || null; - } - } - - console.log(` Pulling NIM image for ${model}...`); - nim.pullNimImage(model); - - console.log(" Starting NIM container..."); - const nimContainerNameLocal = nim.containerName(GATEWAY_NAME); - nimContainer = nim.startNimContainerByName(nimContainerNameLocal, model, undefined, { - ngcApiKey: ngcApiKey ?? undefined, - }); - - console.log(" Waiting for NIM to become healthy..."); - if (!nim.waitForNimHealth(undefined, undefined, { container: nimContainerNameLocal })) { - console.error(" NIM failed to start. Falling back to cloud API."); - model = null; - nimContainer = null; - } else { - provider = "vllm-local"; - // Local NIM (vLLM under the hood) does not require a host API key — - // setupInference registers the gateway provider with an internal - // credential env (NEMOCLAW_VLLM_LOCAL_TOKEN). See GH #2519. - credentialEnv = null; - endpointUrl = getLocalProviderBaseUrl(provider); - if (!endpointUrl) { - console.error(" Local NVIDIA NIM base URL could not be determined."); - process.exit(1); - } - model = nim.adoptServedModelId(model); - const nimValidationUrl = getLocalProviderValidationBaseUrl(provider) || endpointUrl; - const validation = await validateOpenAiLikeSelection( - "Local NVIDIA NIM", - nimValidationUrl, - requireValue(model, "Expected a Local NVIDIA NIM model after startup"), - null, - ); - if (validation.retry === "selection" || validation.retry === "model") { - continue selectionLoop; - } - if (!validation.ok) { - continue selectionLoop; - } - // NIM (vLLM) mishandles the /v1/responses developer role; force chat completions. - if (validation.api !== "openai-completions") { - console.log( - " ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)", - ); - } - preferredInferenceApi = "openai-completions"; - } - } + ({ + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + } = state); + if (result === "retry-selection") continue selectionLoop; break; } else if (selected.key === "ollama") { if (rejectWindowsHostOllama(selected.key, isWindowsHostOllama)) { @@ -4258,123 +4341,36 @@ async function setupNim( // intentional fall-through to the next branch } if (selected.key === "vllm") { - console.log(` ✓ Using existing vLLM on localhost:${VLLM_PORT}`); - provider = "vllm-local"; - // See NIM branch above — internal credential env, no user API key. - credentialEnv = null; - endpointUrl = getLocalProviderBaseUrl(provider); - if (!endpointUrl) { - console.error(" Local vLLM base URL could not be determined."); - process.exit(1); - } - // Query vLLM for the actual model ID - const vllmModelsRaw = runCapture( - ["curl", "-sf", `http://127.0.0.1:${VLLM_PORT}/v1/models`], - { - ignoreError: true, - }, - ); - let vllmModels: { data?: Array<{ id?: unknown }> } = {}; - try { - vllmModels = JSON.parse(vllmModelsRaw); - if (vllmModels.data && vllmModels.data.length > 0) { - const detectedModel = - typeof vllmModels.data[0]?.id === "string" ? vllmModels.data[0].id : null; - model = detectedModel; - if (!detectedModel || !isSafeModelId(detectedModel)) { - console.error(` Detected model ID contains invalid characters: ${model}`); - process.exit(1); - } - console.log(` Detected model: ${model}`); - } else { - console.error(" Could not detect model from vLLM. Please specify manually."); - process.exit(1); - } - } catch { - console.error( - ` Could not query vLLM models endpoint. Is vLLM running on localhost:${VLLM_PORT}?`, - ); - process.exit(1); - } - const validationBaseUrl = getLocalProviderValidationBaseUrl(provider); - if (!validationBaseUrl) { - console.error(" Local vLLM validation URL could not be determined."); - process.exit(1); - } - const validation = await validateOpenAiLikeSelection( - "Local vLLM", - validationBaseUrl, - requireValue(model as string | null | undefined, "Expected a detected vLLM model"), - null, - ); - if (validation.retry === "selection" || validation.retry === "model") { - continue selectionLoop; - } - if (!validation.ok) continue selectionLoop; - localInference.applyVllmRuntimeContextWindow(vllmModels, model as string); - preferredInferenceApi = validation.api; - // Force chat completions — vLLM's /v1/responses endpoint does not - // run the --tool-call-parser, so tool calls arrive as raw text (#976). - if (preferredInferenceApi !== "openai-completions") { - console.log( - " ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)", - ); - } - preferredInferenceApi = "openai-completions"; + const state: SetupNimSelectionState = { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + }; + const result = await handleVllmSelection(state); + ({ model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer } = + state); + if (result === "retry-selection") continue selectionLoop; break; } else if (selected.key === "routed") { - const bp = loadBlueprintProfile("routed"); - if (!bp || bp.router?.enabled !== true) { - console.error(" Router is not enabled in nemoclaw-blueprint/blueprint.yaml."); - if (isNonInteractive()) process.exit(1); - continue selectionLoop; - } - const routerCredentialEnv = - bp.router?.credential_env || bp.credential_env || DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV; - credentialEnv = routerCredentialEnv; - const routedCredential = - hydrateCredentialEnv(routerCredentialEnv) || - normalizeCredentialValue(bp.credential_default || ""); - if (routedCredential) { - saveCredential(routerCredentialEnv, routedCredential); - } - const _providerKeyHint = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim(); - if (_providerKeyHint && !resolveProviderCredential(routerCredentialEnv)) { - saveCredential(routerCredentialEnv, _providerKeyHint); - } - if (isNonInteractive()) { - if (!resolveProviderCredential(routerCredentialEnv)) { - console.error( - ` ${routerCredentialEnv} (or NEMOCLAW_PROVIDER_KEY) is required for Model Router in non-interactive mode.`, - ); - process.exit(1); - } - } else { - if (!resolveProviderCredential(routerCredentialEnv)) { - console.log(""); - console.log(" Model Router accepts NVIDIA API keys (nvapi-...)."); - console.log(" Get one at https://build.nvidia.com"); - console.log(""); - const routerCredentialResult = await ensureNamedCredential( - routerCredentialEnv, - "Model Router API key", - null, - ); - if (credentialPrompt.returningToProviderSelection(routerCredentialResult)) - continue selectionLoop; - } - } - provider = bp.provider_name || "nvidia-router"; - model = bp.model; - const { HOST_GATEWAY_URL } = require("./inference/local"); - const routerEndpointUrl = bp.endpoint || ""; - endpointUrl = routerEndpointUrl; - if (routerEndpointUrl.match(/localhost|127\.0\.0\.1/)) { - const u = new URL(routerEndpointUrl); - endpointUrl = `${HOST_GATEWAY_URL}:${u.port}${u.pathname}`; - } - preferredInferenceApi = "openai-completions"; - console.log(` ✓ Using Model Router: ${provider} / ${model}`); + const state: SetupNimSelectionState = { + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + preferredInferenceApi, + nimContainer, + }; + const result = await handleRoutedSelection(state); + ({ model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer } = + state); + if (result === "retry-selection") continue selectionLoop; break; } } diff --git a/src/lib/onboard/setup-nim-selection.test.ts b/src/lib/onboard/setup-nim-selection.test.ts new file mode 100644 index 00000000000..7d4cab8bef9 --- /dev/null +++ b/src/lib/onboard/setup-nim-selection.test.ts @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; + +import { describe, it } from "vitest"; + +import { + applyCloudFallbackSelection, + clearNimContainerBeforeRetry, + createRemoteModelValidator, + type SetupNimSelectionState, +} from "./setup-nim-selection"; + +function makeState(): SetupNimSelectionState { + return { + model: "nvidia/local-nim", + provider: "vllm-local", + endpointUrl: "http://127.0.0.1:8000/v1", + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + preferredInferenceApi: "openai-completions", + nimContainer: "nemoclaw-nim-test", + }; +} + +describe("setupNim selection state helpers", () => { + it("applies a complete cloud fallback and clears stale NIM state", () => { + const state = makeState(); + + applyCloudFallbackSelection(state, { + providerName: "nvidia-prod", + endpointUrl: "https://integrate.api.nvidia.com/v1", + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + defaultModel: "meta/llama-3.3-70b-instruct", + }); + + assert.deepEqual(state, { + model: "meta/llama-3.3-70b-instruct", + provider: "nvidia-prod", + endpointUrl: "https://integrate.api.nvidia.com/v1", + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + hermesAuthMethod: null, + hermesToolGateways: [], + preferredInferenceApi: null, + nimContainer: null, + }); + }); + + it("clears stale NIM containers before retrying provider selection", () => { + const state = makeState(); + + clearNimContainerBeforeRetry(state); + + assert.equal(state.nimContainer, null); + assert.equal(state.model, "nvidia/local-nim"); + assert.equal(state.provider, "vllm-local"); + }); +}); + +describe("createRemoteModelValidator", () => { + it("forces custom compatible endpoints to chat completions unless the API is explicit", async () => { + const state = makeState(); + state.provider = "openai-compatible"; + state.endpointUrl = "https://compatible.example/v1"; + state.model = "model-a"; + let calledEndpoint: string | null = null; + const { validateSelectedRemoteModel } = createRemoteModelValidator({ + OPENAI_ENDPOINT_URL: "https://default-openai.example/v1", + ANTHROPIC_ENDPOINT_URL: "https://default-anthropic.example/v1", + requireValue: (value, message) => { + if (value === null || value === undefined) throw new Error(message); + return value; + }, + isBackToSelection: (_value): _value is never => false, + validateCustomOpenAiLikeSelection: async (_label, endpointUrl) => { + calledEndpoint = endpointUrl; + return { ok: true, api: "responses" }; + }, + validateCustomAnthropicSelection: async () => ({ ok: false, retry: "selection" }), + validateAnthropicSelectionWithRetryMessage: async () => ({ ok: false, retry: "selection" }), + validateOpenAiLikeSelection: async () => ({ ok: false, retry: "selection" }), + shouldRequireResponsesToolCalling: () => false, + shouldSkipResponsesProbe: () => false, + getProbeAuthMode: () => undefined, + }); + + const result = await validateSelectedRemoteModel({ + selected: { key: "custom" }, + remoteConfig: { + label: "Other OpenAI-compatible endpoint", + endpointUrl: "https://remote-config.example/v1", + helpUrl: null, + }, + state, + selectedCredentialEnv: "OPENAI_API_KEY", + }); + + assert.equal(result, "selected"); + assert.equal(calledEndpoint, "https://compatible.example/v1"); + assert.equal(state.preferredInferenceApi, "openai-completions"); + }); + + it("maps provider validation model retries without mutating selected model state", async () => { + const state = makeState(); + const { validateSelectedRemoteModel } = createRemoteModelValidator({ + OPENAI_ENDPOINT_URL: "https://default-openai.example/v1", + ANTHROPIC_ENDPOINT_URL: "https://default-anthropic.example/v1", + requireValue: (value, message) => { + if (value === null || value === undefined) throw new Error(message); + return value; + }, + isBackToSelection: (_value): _value is never => false, + validateCustomOpenAiLikeSelection: async () => ({ ok: false, retry: "selection" }), + validateCustomAnthropicSelection: async () => ({ ok: false, retry: "model" }), + validateAnthropicSelectionWithRetryMessage: async () => ({ ok: false, retry: "selection" }), + validateOpenAiLikeSelection: async () => ({ ok: false, retry: "selection" }), + shouldRequireResponsesToolCalling: () => false, + shouldSkipResponsesProbe: () => false, + getProbeAuthMode: () => undefined, + }); + + const result = await validateSelectedRemoteModel({ + selected: { key: "anthropicCompatible" }, + remoteConfig: { + label: "Other Anthropic-compatible endpoint", + endpointUrl: "https://anthropic.example/v1", + helpUrl: null, + }, + state, + selectedCredentialEnv: "ANTHROPIC_API_KEY", + }); + + assert.equal(result, "retry-model"); + assert.equal(state.model, "nvidia/local-nim"); + assert.equal(state.nimContainer, "nemoclaw-nim-test"); + }); +}); diff --git a/src/lib/onboard/setup-nim-selection.ts b/src/lib/onboard/setup-nim-selection.ts new file mode 100644 index 00000000000..1a825e9251f --- /dev/null +++ b/src/lib/onboard/setup-nim-selection.ts @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type SetupNimSelectionBackNavigation = Readonly<{ kind: "NEMOCLAW_BACK_TO_SELECTION" }>; + +export type SetupNimSelectionState = { + model: string | SetupNimSelectionBackNavigation | null; + provider: string; + endpointUrl: string | null; + credentialEnv: string | null; + hermesAuthMethod: THermesAuthMethod | null; + hermesToolGateways: string[]; + preferredInferenceApi: string | null; + nimContainer: string | null; +}; + +export type CloudFallbackConfig = { + providerName: string; + endpointUrl: string | null; + credentialEnv: string | null; + defaultModel: string; +}; + +export function applyCloudFallbackSelection( + state: SetupNimSelectionState, + cloudConfig: CloudFallbackConfig, +): void { + state.provider = cloudConfig.providerName; + state.endpointUrl = cloudConfig.endpointUrl; + state.credentialEnv = cloudConfig.credentialEnv; + state.model = cloudConfig.defaultModel; + state.preferredInferenceApi = null; + state.nimContainer = null; +} + +export function clearNimContainerBeforeRetry(state: SetupNimSelectionState): void { + state.nimContainer = null; +} + +type ProviderChoice = { + key: string; +}; + +type RemoteProviderConfig = { + label: string; + endpointUrl: string; + helpUrl: string | null; +}; + +type ProbeAuthMode = "bearer" | "query-param" | undefined; + +type ProbeOptions = { + requireResponsesToolCalling?: boolean; + skipResponsesProbe?: boolean; + authMode?: ProbeAuthMode; +}; + +type ValidationResult = + | { ok: true; api: string | null; retry?: never } + | { ok: false; api?: string; retry?: "credential" | "retry" | "model" | "selection" | string }; + +type RemoteModelValidationResult = "selected" | "retry-model" | "retry-selection"; + +type RemoteModelValidatorDeps = { + OPENAI_ENDPOINT_URL: string; + ANTHROPIC_ENDPOINT_URL: string; + requireValue: (value: T | null | undefined, message: string) => T; + isBackToSelection: (value: unknown) => value is SetupNimSelectionBackNavigation; + validateCustomOpenAiLikeSelection: ( + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, + helpUrl: string | null, + ) => Promise; + validateCustomAnthropicSelection: ( + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, + helpUrl: string | null, + ) => Promise; + validateAnthropicSelectionWithRetryMessage: ( + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, + retryMessage: string, + helpUrl: string | null, + ) => Promise; + validateOpenAiLikeSelection: ( + label: string, + endpointUrl: string, + model: string, + credentialEnv: string | null, + retryMessage?: string, + helpUrl?: string | null, + options?: ProbeOptions, + ) => Promise; + shouldRequireResponsesToolCalling: (provider: string) => boolean; + shouldSkipResponsesProbe: (provider: string) => boolean; + getProbeAuthMode: (provider: string) => ProbeAuthMode; +}; + +type ValidateSelectedRemoteModelArgs = { + selected: ProviderChoice; + remoteConfig: RemoteProviderConfig; + state: SetupNimSelectionState; + selectedCredentialEnv: string; +}; + +function shouldRetryModel(validation: ValidationResult): boolean { + return ( + !validation.ok && + (validation.retry === "credential" || + validation.retry === "retry" || + validation.retry === "model") + ); +} + +export function createRemoteModelValidator(deps: RemoteModelValidatorDeps): { + validateSelectedRemoteModel: ( + args: ValidateSelectedRemoteModelArgs, + ) => Promise; +} { + return { + validateSelectedRemoteModel: async ({ + selected, + remoteConfig, + state, + selectedCredentialEnv, + }) => { + const selectedModel = deps.requireValue( + deps.isBackToSelection(state.model) ? null : state.model, + `Missing model for ${remoteConfig.label}`, + ); + if (selected.key === "custom") { + const validation = await deps.validateCustomOpenAiLikeSelection( + remoteConfig.label, + state.endpointUrl || deps.OPENAI_ENDPOINT_URL, + selectedModel, + selectedCredentialEnv, + remoteConfig.helpUrl, + ); + if (validation.ok) { + const explicitApi = (process.env.NEMOCLAW_PREFERRED_API || "").trim().toLowerCase(); + if ( + explicitApi && + explicitApi !== "openai-completions" && + explicitApi !== "chat-completions" + ) { + state.preferredInferenceApi = validation.api; + } else { + if (validation.api !== "openai-completions") { + console.log( + " ℹ Using chat completions API (compatible endpoints may not support the Responses API developer role)", + ); + } + state.preferredInferenceApi = "openai-completions"; + } + return "selected"; + } + if (shouldRetryModel(validation)) { + return "retry-model"; + } + return validation.retry === "selection" ? "retry-selection" : "retry-model"; + } + + if (selected.key === "anthropicCompatible") { + const validation = await deps.validateCustomAnthropicSelection( + remoteConfig.label, + state.endpointUrl || deps.ANTHROPIC_ENDPOINT_URL, + selectedModel, + selectedCredentialEnv, + remoteConfig.helpUrl, + ); + if (validation.ok) { + state.preferredInferenceApi = validation.api; + return "selected"; + } + if (shouldRetryModel(validation)) { + return "retry-model"; + } + return validation.retry === "selection" ? "retry-selection" : "retry-model"; + } + + const retryMessage = "Please choose a provider/model again."; + if (selected.key === "anthropic") { + const validation = await deps.validateAnthropicSelectionWithRetryMessage( + remoteConfig.label, + state.endpointUrl || deps.ANTHROPIC_ENDPOINT_URL, + selectedModel, + selectedCredentialEnv, + retryMessage, + remoteConfig.helpUrl, + ); + if (validation.ok) { + state.preferredInferenceApi = validation.api; + return "selected"; + } + if (shouldRetryModel(validation)) { + return "retry-model"; + } + return "retry-selection"; + } + + const validation = await deps.validateOpenAiLikeSelection( + remoteConfig.label, + deps.requireValue(state.endpointUrl, `Missing endpoint URL for ${remoteConfig.label}`), + selectedModel, + selectedCredentialEnv, + retryMessage, + remoteConfig.helpUrl, + { + requireResponsesToolCalling: deps.shouldRequireResponsesToolCalling(state.provider), + skipResponsesProbe: deps.shouldSkipResponsesProbe(state.provider), + authMode: deps.getProbeAuthMode(state.provider), + }, + ); + if (validation.ok) { + state.preferredInferenceApi = validation.api; + return "selected"; + } + if (shouldRetryModel(validation)) { + return "retry-model"; + } + return "retry-selection"; + }, + }; +}