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
2 changes: 2 additions & 0 deletions docs/inference/set-up-vllm.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ $$nemoclaw onboard --profile <profile-id>
```

The same profile selector works with interactive and non-interactive onboarding.
NemoClaw selects the inference provider required by the profile's backend, so onboarding does not show the provider menu.
If the profile's backend has no corresponding inference provider, onboarding exits before it changes runtime resources.
NemoClaw rejects unknown, ambiguous, disabled, incompatible, or conflicting selections before image or model downloads begin.
Before confirmation, the review screen shows the resolved profile and recipe IDs, model, immutable runtime image, support state, and estimated image and model downloads.
Do not combine `--profile` with `NEMOCLAW_PROVIDER`, `NEMOCLAW_MODEL`, `NEMOCLAW_VLLM_MODEL`, `NEMOCLAW_MANAGED_CLUSTER_PEERS`, or `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` overrides.
Expand Down
190 changes: 149 additions & 41 deletions src/lib/onboard/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { getCredential } from "../credentials/store";
import { loadServingCatalog } from "../inference/serving/catalog-loader";
import { servingProfileProvenance } from "../inference/serving/profile-provenance";
import { resolveOnboardOptions, runOnboardCommand } from "./command";
import { resolveOnboardOptions, runOnboardCommand, servingProfileProviderKey } from "./command";
import type { OnboardFlags } from "./command-support";
import { PortableInferenceDescriptorError } from "./experimental/portable-inference-descriptor";
import { invalidGatewayManagementDeclarationError } from "./gateway-management";
Expand All @@ -20,6 +20,7 @@ import {
LOCAL_MODEL_PROFILE_RUNTIME_ENV,
} from "./local-model-profile/plan";
import { OnboardResumeIntentError, OnboardResumeIntentRaceError } from "./session-bootstrap";
import { MANAGED_VLLM_PROVIDER_KEY } from "./vllm-menu";

afterEach(() => {
vi.unstubAllEnvs();
Expand Down Expand Up @@ -686,49 +687,61 @@ describe("onboard command options", () => {
}
});

it("restores every scoped command value before exiting on a handled error (#9035)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-handled-error-environment-"));
const manifestPath = path.join(tmpDir, "agents.yaml");
fs.writeFileSync(manifestPath, "agents: []\n");
const env: NodeJS.ProcessEnv = {
NEMOCLAW_EXTRA_AGENTS_JSON: "previous-agents",
NEMOCLAW_OLLAMA_NO_AUTOSTART: "previous-autostart",
NEMOCLAW_SERVING_PRESET: "previous-serving",
NEMOCLAW_TOOL_DISCLOSURE: "previous-disclosure",
};
let environmentAtExit: NodeJS.ProcessEnv | null = null;

try {
await expect(
runOnboardCommand({
flags: {
agents: manifestPath,
"no-ollama-autostart": true,
profile: COMPATIBLE_NANO_PROFILE.id,
"tool-disclosure": "direct",
},
env,
listServingProfiles: () => [COMPATIBLE_NANO_PROFILE],
runOnboard: async () => {
throw invalidGatewayManagementDeclarationError("unsupported contract");
},
error: () => {},
exit: (code): never => {
environmentAtExit = { ...env };
throw new Error(`exit:${code}`);
},
}),
).rejects.toThrow("exit:1");
expect(environmentAtExit).toEqual({
it.each([
{ providerState: "unset", previousProvider: undefined },
{ providerState: "blank", previousProvider: "" },
])(
"restores every scoped command value before a handled-error exit when the provider is $providerState (#9035)",
async ({ previousProvider }) => {
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), "nemoclaw-handled-error-environment-"),
);
const manifestPath = path.join(tmpDir, "agents.yaml");
fs.writeFileSync(manifestPath, "agents: []\n");
const env: NodeJS.ProcessEnv = {
NEMOCLAW_EXTRA_AGENTS_JSON: "previous-agents",
NEMOCLAW_OLLAMA_NO_AUTOSTART: "previous-autostart",
NEMOCLAW_SERVING_PRESET: "previous-serving",
NEMOCLAW_SERVING_PRESET: COMPATIBLE_NANO_PROFILE.id,
NEMOCLAW_TOOL_DISCLOSURE: "previous-disclosure",
...(previousProvider === undefined ? {} : { NEMOCLAW_PROVIDER: previousProvider }),
};
let environmentAtExit: NodeJS.ProcessEnv | null = null;
const runOnboard = vi.fn(async () => {
throw invalidGatewayManagementDeclarationError("unsupported contract");
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

try {
await expect(
runOnboardCommand({
flags: {
agents: manifestPath,
"no-ollama-autostart": true,
profile: COMPATIBLE_NANO_PROFILE.id,
"tool-disclosure": "direct",
},
env,
listServingProfiles: () => [COMPATIBLE_NANO_PROFILE],
runOnboard,
error: () => {},
exit: (code): never => {
environmentAtExit = { ...env };
throw new Error(`exit:${code}`);
},
}),
).rejects.toThrow("exit:1");
expect(runOnboard).toHaveBeenCalledOnce();
expect(environmentAtExit).toEqual({
NEMOCLAW_EXTRA_AGENTS_JSON: "previous-agents",
NEMOCLAW_OLLAMA_NO_AUTOSTART: "previous-autostart",
NEMOCLAW_SERVING_PRESET: COMPATIBLE_NANO_PROFILE.id,
NEMOCLAW_TOOL_DISCLOSURE: "previous-disclosure",
...(previousProvider === undefined ? {} : { NEMOCLAW_PROVIDER: previousProvider }),
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
},
);

it("scopes the selected catalog preset to one onboarding run (#8384)", async () => {
const env: NodeJS.ProcessEnv = {};
Expand All @@ -747,6 +760,101 @@ describe("onboard command options", () => {
expect(env.NEMOCLAW_SERVING_PRESET).toBeUndefined();
});

it("selects the profile's inference provider so onboarding skips the menu (#9313)", async () => {
// The preset alone only picks the model once a provider is chosen. Without
// a provider the run fell through to the interactive provider menu with the
// requested profile never applied.
const vllmProfile = {
...COMPATIBLE_NANO_PROFILE,
id: "vllm.dgx-spark-gb10.single.muse-glimmer-30b-nvfp4-w4a4",
displayName: "Muse Glimmer 30B NVFP4 W4A4 on one DGX Spark",
backend: "vllm",
};
const env: NodeJS.ProcessEnv = {};
let observedProvider: string | undefined;
let observedPreset: string | undefined;
await runOnboardCommand({
flags: { profile: vllmProfile.id },
env,
listServingProfiles: () => [vllmProfile],
runOnboard: async () => {
observedProvider = env.NEMOCLAW_PROVIDER;
observedPreset = env.NEMOCLAW_SERVING_PRESET;
},
});

expect(observedProvider).toBe("install-vllm");
expect(observedPreset).toBe(vllmProfile.id);
// Scoped to the run, like the preset itself.
expect(env.NEMOCLAW_PROVIDER).toBeUndefined();
expect(env.NEMOCLAW_SERVING_PRESET).toBeUndefined();
});

it("selects the managed llama.cpp provider for a llama-cpp profile (#9313)", async () => {
const env: NodeJS.ProcessEnv = {};
let observedProvider: string | undefined;
await runOnboardCommand({
flags: { profile: COMPATIBLE_NANO_PROFILE.id },
env,
listServingProfiles: () => [COMPATIBLE_NANO_PROFILE],
runOnboard: async () => {
observedProvider = env.NEMOCLAW_PROVIDER;
},
});

expect(observedProvider).toBe("install-llama-cpp");
expect(env.NEMOCLAW_PROVIDER).toBeUndefined();
});

it("rejects an unmapped backend on the resume path too (#9313)", () => {
// Explicit --profile, the installer path, and resume all converge on the
// same environment application, so the check lives at the end of the
// lifecycle rather than on the explicit path alone. Resume replays a
// recorded profile: a backend with no provider must be reported instead of
// resuming into the provider menu.
const catalog = loadServingCatalog();
// Retarget preset and recipe together; provenance requires them to agree.
const patchedCatalog = {
...catalog,
presets: catalog.presets.map((preset) => ({
...preset,
spec: { ...preset.spec, plan: { ...preset.spec.plan, backend: "future-backend" } },
})),
recipes: catalog.recipes.map((recipe) => ({
...recipe,
spec: { ...recipe.spec, backend: "future-backend" },
})),
};
const recorded = servingProfileProvenance(
patchedCatalog as never,
catalog.presets[0]!.metadata.id,
);
const errors: string[] = [];

expect(() =>
resolve(
{ resume: true },
{
loadServingCatalog: () => patchedCatalog as never,
loadSession: () => ({ servingProfileProvenance: recorded }) as never,
error: (message = "") => errors.push(message),
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("which onboarding cannot configure");
});

it("maps each serving backend to the provider that can run it (#9313)", () => {
// A backend with no provider returns null, which `resolveServingProfile`
// reports instead of accepting the flag and then asking for a provider.
const withBackend = (backend: string) => ({ recipe: { backend } }) as never;

// Uses the provider menu's exported key so the two cannot drift.
expect(servingProfileProviderKey(withBackend("vllm"))).toBe(MANAGED_VLLM_PROVIDER_KEY);
expect(servingProfileProviderKey(withBackend("install-llama-cpp"))).toBe("install-llama-cpp");
expect(servingProfileProviderKey(withBackend("future-backend"))).toBeNull();
});

it("records an installer profile without activating the disabled generic preset", async () => {
const env: NodeJS.ProcessEnv = {
[LOCAL_MODEL_PROFILE_ENABLED_ENV]: "1",
Expand Down Expand Up @@ -965,7 +1073,7 @@ describe("onboard command options", () => {
name: "serving profile",
flags: { profile: COMPATIBLE_NANO_PROFILE.id } as OnboardFlags,
listServingProfiles: () => [COMPATIBLE_NANO_PROFILE],
keys: ["NEMOCLAW_SERVING_PRESET"],
keys: ["NEMOCLAW_PROVIDER", "NEMOCLAW_SERVING_PRESET"],
},
])("restores the $name environment when an agents manifest is invalid", async (testCase) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-invalid-agents-manifest-"));
Expand Down
59 changes: 57 additions & 2 deletions src/lib/onboard/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,15 +314,56 @@ function resolveServingProfileLifecycle(
);
}
const requested = explicit ?? installerProfile;
if (!resume) return requested;
return resolveResumedServingProfile(requested, deps);
const settled = resume ? resolveResumedServingProfile(requested, deps) : requested;
// Check the profile the run will actually apply, not just an explicit
// --profile: the installer and resume paths reach the same environment
// application, and an unmapped backend there would set the preset while
// leaving the provider unresolved — the silent fall-through to the provider
// menu this fixes (#9313).
return assertServingProfileProviderSupported(settled, deps);
}

function assertServingProfileProviderSupported(
provenance: ServingProfileProvenance | null,
deps: ResolveOnboardOptionsDeps,
): ServingProfileProvenance | null {
const unsupported = provenance !== null && servingProfileProviderKey(provenance) === null;
return unsupported
? fail(
deps,
` Serving profile '${provenance.preset.id}' uses backend '${provenance.recipe.backend}', which onboarding cannot configure.`,
)
: provenance;
}

function activeServingProfileId(provenance: ServingProfileProvenance | null): string | null {
if (!provenance || provenance.preset.supportState === "disabled") return null;
return provenance.preset.id;
}

/**
* Provider the requested serving profile has to run through.
*
* The preset alone only tells provider selection *which* profile to serve once
* a local-inference provider has been chosen; it never chooses the provider.
* Because `--profile` also rejects an explicit `NEMOCLAW_PROVIDER`, leaving
* this unset dropped onboarding into the interactive provider menu with the
* requested profile unusable (#9313). Returns null for a backend that has no
* provider wired up, which the caller reports rather than silently ignoring.
*/
export function servingProfileProviderKey(provenance: ServingProfileProvenance): string | null {
switch (provenance.recipe.backend) {
// Kept as literals so this module does not take a dependency on the
// provider menu; `command.test.ts` asserts they match its exported keys.
case "vllm":
return "install-vllm";
case "install-llama-cpp":
return "install-llama-cpp";
default:
return null;
}
}

function resolveResumedServingProfile(
requested: ServingProfileProvenance | null,
deps: ResolveOnboardOptionsDeps,
Expand Down Expand Up @@ -477,9 +518,23 @@ function applyServingProfileEnvironment(
if (!options.servingProfile) return () => {};
const previous = env[NEMOCLAW_SERVING_PRESET_ENV];
env[NEMOCLAW_SERVING_PRESET_ENV] = options.servingProfile;
// The preset selects the model once a provider is chosen; the profile's
// backend is what selects the provider. Setting only the former left the
// provider unresolved and onboarding fell back to the menu (#9313).
// `validateServingProfileConflicts` already rejected an operator-supplied
// NEMOCLAW_PROVIDER, so nothing of the caller's is being overwritten here.
const providerKey = options.servingProfileProvenance
? servingProfileProviderKey(options.servingProfileProvenance)
: null;
const previousProvider = env.NEMOCLAW_PROVIDER;
if (providerKey) env.NEMOCLAW_PROVIDER = providerKey;
return () => {
if (previous === undefined) delete env[NEMOCLAW_SERVING_PRESET_ENV];
else env[NEMOCLAW_SERVING_PRESET_ENV] = previous;
if (providerKey) {
if (previousProvider === undefined) delete env.NEMOCLAW_PROVIDER;
else env.NEMOCLAW_PROVIDER = previousProvider;
}
};
}

Expand Down
Loading