Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 39 additions & 40 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,7 @@ const {
pullAndResolveBaseImageDigest,
}: typeof import("./onboard/base-image") = require("./onboard/base-image");
const { requireValue }: typeof import("./core/require-value") = require("./core/require-value");
const {
logMissingNvidiaApiKeyHelp,
}: typeof import("./onboard/missing-credential-hints") = require("./onboard/missing-credential-hints");
const buildCredentialReuse: typeof import("./onboard/build-credential-reuse") = require("./onboard/build-credential-reuse");

type RunnerOptions = {
env?: NodeJS.ProcessEnv;
Expand Down Expand Up @@ -3793,18 +3791,12 @@ async function handleRemoteProviderSelection(
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);
}
state.skipHostInferenceSmoke = buildCredentialReuse.resolveNonInteractiveBuildCredential({
provider: state.provider,
helpUrl: REMOTE_PROVIDER_CONFIG.build.helpUrl,
recoveredFromSandbox,
providerExistsInGateway,
});
} else {
await ensureApiKey();
}
Expand Down Expand Up @@ -3934,29 +3926,30 @@ async function handleRemoteProviderSelection(
}

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";
}
const buildModel = requireValue(
isBackToSelection(state.model) ? null : state.model,
`Missing model for ${remoteConfig.label}`,
);
const buildValidation = await buildCredentialReuse.resolveBuildPreferredInferenceApi({
reuseGatewayCredentialWithoutLocalKey: state.skipHostInferenceSmoke === true,
note,
probe: () =>
validateOpenAiLikeSelection(
remoteConfig.label,
requireValue(state.endpointUrl, `Missing endpoint URL for ${remoteConfig.label}`),
buildModel,
state.credentialEnv,
"Please choose a provider/model again.",
remoteConfig.helpUrl,
{
requireResponsesToolCalling: shouldRequireResponsesToolCalling(state.provider),
skipResponsesProbe: shouldSkipResponsesProbe(state.provider),
authMode: getProbeAuthMode(state.provider),
},
),
});
if (buildValidation.retrySelection) return "retry-selection";
state.preferredInferenceApi = buildValidation.preferredInferenceApi;
}

console.log(` Using ${remoteConfig.label} with model: ${state.model}`);
Expand All @@ -3977,6 +3970,7 @@ async function setupNim(
preferredInferenceApi: string | null;
nimContainer: string | null;
allowToolsIncompatible: boolean;
skipHostInferenceSmoke: boolean;
}> {
step(3, 8, "Configuring inference provider");

Expand All @@ -3989,6 +3983,7 @@ async function setupNim(
let hermesToolGateways: string[] = [];
let preferredInferenceApi: string | null = null;
let allowToolsIncompatible = false;
let skipHostInferenceSmoke = false;

const providerHostState = detectInferenceProviderHostState({
gpu,
Expand Down Expand Up @@ -4137,6 +4132,7 @@ async function setupNim(
preferredInferenceApi,
allowToolsIncompatible,
} = state);
skipHostInferenceSmoke = state.skipHostInferenceSmoke === true;
if (result === "retry-selection") continue selectionLoop;
break;
} else if (selected.key === "nim-local") {
Expand Down Expand Up @@ -4350,6 +4346,7 @@ async function setupNim(
preferredInferenceApi,
nimContainer,
allowToolsIncompatible,
skipHostInferenceSmoke,
};
}

Expand All @@ -4363,7 +4360,7 @@ async function setupInference(
credentialEnv: string | null = null,
hermesAuthMethod: HermesAuthMethod | string | null = null,
hermesToolGateways: string[] = [],
options: { allowToolsIncompatible?: boolean } = {},
options: { allowToolsIncompatible?: boolean; skipHostInferenceSmoke?: boolean } = {},
): Promise<{ ok: true; retry?: undefined } | { retry: "selection" }> {
step(4, 8, "Setting up inference provider");
runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true });
Expand Down Expand Up @@ -4475,7 +4472,9 @@ async function setupInference(
}

verifyInferenceRoute(provider, model);
verifyOnboardInferenceSmoke({ provider, model, endpointUrl, credentialEnv });
if (options.skipHostInferenceSmoke === true)
console.log(" Reusing existing gateway credential; skipping host inference smoke.");
else verifyOnboardInferenceSmoke({ provider, model, endpointUrl, credentialEnv });
if (sandboxName) {
registry.updateSandbox(sandboxName, { model, provider });
}
Expand Down
79 changes: 79 additions & 0 deletions src/lib/onboard/build-credential-reuse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { resolveProviderCredential } from "../credentials/store";
import { validateNvidiaApiKeyValue } from "../validation";
import type { EndpointValidationResult } from "./inference-selection-validation";
import { logMissingNvidiaApiKeyHelp } from "./missing-credential-hints";

/**
* Non-interactive credential handling for the NVIDIA Endpoints ("build")
* provider.
*
* Returns whether the OpenShell gateway already holds a validated credential
* that must be reused without a local key. The gateway is the system of record
* and nothing is written to host disk, so only the recovered-sandbox path (for
* example `onboard --recreate-sandbox`) may rely on the existing gateway
* credential. Explicit non-interactive provider selections still require a
* local key so NemoClaw can validate the endpoint before continuing.
*
* Reuse skips endpoint re-validation rather than probing unauthenticated, which
* would fail at stage [3/8]. See issue #5441.
*
* Exits the process when the credential is missing/invalid and unrecoverable.
*/
export function resolveNonInteractiveBuildCredential(opts: {
provider: string;
helpUrl: string | null | undefined;
recoveredFromSandbox: boolean;
providerExistsInGateway: (name: string) => boolean;
}): boolean {
const { provider, helpUrl, recoveredFromSandbox, providerExistsInGateway } = opts;
const resolvedNvidiaKey = resolveProviderCredential("NVIDIA_INFERENCE_API_KEY");
if (resolvedNvidiaKey) {
const keyError = validateNvidiaApiKeyValue(resolvedNvidiaKey);
if (keyError) {
console.error(keyError);
console.error(` Get a key from ${helpUrl}`);
process.exit(1);
}
return false;
}
if (!recoveredFromSandbox || !providerExistsInGateway(provider)) {
logMissingNvidiaApiKeyHelp(helpUrl);
process.exit(1);
}
return true;
}

/**
* Resolve the preferred inference API for the NVIDIA Endpoints ("build")
* provider. When the gateway credential is reused without a local key, skip the
* endpoint re-validation probe — it would run unauthenticated and fail (#5441) —
* and reuse the already-validated credential. Otherwise run the existing
* validation probe loop, returning a retry-selection signal when the user backs
* out so the caller can re-enter provider selection.
*/
export async function resolveBuildPreferredInferenceApi(opts: {
reuseGatewayCredentialWithoutLocalKey: boolean;
note: (message: string) => void;
probe: () => Promise<EndpointValidationResult>;
}): Promise<
{ retrySelection: true } | { retrySelection: false; preferredInferenceApi: string | null }
> {
const { reuseGatewayCredentialWithoutLocalKey, note, probe } = opts;
if (reuseGatewayCredentialWithoutLocalKey) {
note(" Reusing existing gateway credential; skipping endpoint re-validation.");
return { retrySelection: false, preferredInferenceApi: "openai-completions" };
}
while (true) {
const validation = await probe();
if (validation.ok) {
return { retrySelection: false, preferredInferenceApi: validation.api };
}
if (validation.retry === "credential" || validation.retry === "retry") {
continue;
}
return { retrySelection: true };
}
}
15 changes: 12 additions & 3 deletions src/lib/onboard/machine/handlers/provider-inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ProviderSelectionResult {
preferredInferenceApi: string | null;
nimContainer: string | null;
allowToolsIncompatible?: boolean;
skipHostInferenceSmoke?: boolean;
}

export interface ProviderInferenceStateOptions<Gpu, Agent, Host> {
Expand Down Expand Up @@ -56,7 +57,7 @@ export interface ProviderInferenceStateOptions<Gpu, Agent, Host> {
credentialEnv: string | null,
hermesAuthMethod: string | null,
hermesToolGateways: string[],
options?: { allowToolsIncompatible?: boolean },
options?: { allowToolsIncompatible?: boolean; skipHostInferenceSmoke?: boolean },
): Promise<ProviderInferenceRetry>;
startRecordedStep(
stepName: string,
Expand Down Expand Up @@ -193,6 +194,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
const webSearchConfig = initial.webSearchConfig;
let forceProviderSelection = initialForceProviderSelection;
let allowToolsIncompatible = false;
let skipHostInferenceSmoke = false;
const stateResults: OnboardStateTransitionResult[] = [];
const retryStateResults: OnboardStateTransitionResult[] = [];

Expand Down Expand Up @@ -255,6 +257,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
preferredInferenceApi = selection.preferredInferenceApi;
nimContainer = selection.nimContainer;
allowToolsIncompatible = selection.allowToolsIncompatible === true;
skipHostInferenceSmoke = selection.skipHostInferenceSmoke === true;
shouldRecordProviderSelection = true;
}

Expand Down Expand Up @@ -297,6 +300,9 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
try {
if (!sandboxName) sandboxName = await deps.promptValidatedSandboxName(agent);
const confirmedSandboxName = sandboxName;
const inferenceOptions = skipHostInferenceSmoke
? { allowToolsIncompatible, skipHostInferenceSmoke }
: { allowToolsIncompatible };
await deps.startRecordedStep("inference", { provider, model });
inferenceResult = await withInferenceTrace(
confirmedSandboxName,
Expand All @@ -312,7 +318,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
credentialEnv,
hermesAuthMethod,
hermesToolGateways,
{ allowToolsIncompatible },
inferenceOptions,
),
);
} finally {
Expand Down Expand Up @@ -411,6 +417,9 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
}
}

const inferenceOptions = skipHostInferenceSmoke
? { allowToolsIncompatible, skipHostInferenceSmoke }
: { allowToolsIncompatible };
await deps.startRecordedStep("inference", { provider, model });
inferenceResult = await withInferenceTrace(
confirmedSandboxName,
Expand All @@ -426,7 +435,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
credentialEnv,
hermesAuthMethod,
hermesToolGateways,
{ allowToolsIncompatible },
inferenceOptions,
),
);
} finally {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/onboard/setup-nim-ollama.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function makeState(): SetupNimSelectionState {
preferredInferenceApi: null,
nimContainer: null,
allowToolsIncompatible: false,
skipHostInferenceSmoke: false,
};
}

Expand Down Expand Up @@ -181,6 +182,7 @@ describe("createSetupNimOllamaHandlers", () => {
preferredInferenceApi: "openai-completions",
nimContainer: null,
allowToolsIncompatible: false,
skipHostInferenceSmoke: false,
});
assert.equal(startProxy.mock.calls.length, 0);
assert.equal(selectModel.mock.calls.length, 0);
Expand Down
2 changes: 2 additions & 0 deletions src/lib/onboard/setup-nim-ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
state.provider = "ollama-local";
state.credentialEnv = null;
state.endpointUrl = deps.getLocalProviderBaseUrl(state.provider);
state.skipHostInferenceSmoke = false;
if (!state.endpointUrl) {
console.error(" Local Ollama base URL could not be determined.");
deps.process.exit(1);
Expand All @@ -127,6 +128,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
state.preferredInferenceApi = result.preferredInferenceApi;
state.nimContainer = null;
state.allowToolsIncompatible = false;
state.skipHostInferenceSmoke = false;
}

async function handleWindowsHostOllamaSelection(
Expand Down
2 changes: 2 additions & 0 deletions src/lib/onboard/setup-nim-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function makeState(): SetupNimSelectionState {
preferredInferenceApi: "openai-completions",
nimContainer: "nemoclaw-nim-test",
allowToolsIncompatible: false,
skipHostInferenceSmoke: false,
};
}

Expand All @@ -48,6 +49,7 @@ describe("setupNim selection state helpers", () => {
preferredInferenceApi: null,
nimContainer: null,
allowToolsIncompatible: false,
skipHostInferenceSmoke: false,
});
});

Expand Down
2 changes: 2 additions & 0 deletions src/lib/onboard/setup-nim-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type SetupNimSelectionState<THermesAuthMethod = unknown> = {
preferredInferenceApi: string | null;
nimContainer: string | null;
allowToolsIncompatible: boolean;
skipHostInferenceSmoke?: boolean;
};

export type CloudFallbackConfig = {
Expand All @@ -36,6 +37,7 @@ export function applyCloudFallbackSelection(
state.preferredInferenceApi = null;
state.nimContainer = null;
state.allowToolsIncompatible = false;
state.skipHostInferenceSmoke = false;
}

export function clearNimContainerBeforeRetry(state: SetupNimSelectionState): void {
Expand Down
Loading
Loading