diff --git a/docs/inference/set-up-vllm.mdx b/docs/inference/set-up-vllm.mdx index 71cac0768a6..36d4893200e 100644 --- a/docs/inference/set-up-vllm.mdx +++ b/docs/inference/set-up-vllm.mdx @@ -119,6 +119,8 @@ $$nemoclaw onboard --profile ``` 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. diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 99d10b9491e..b515ac516d5 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -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"; @@ -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(); @@ -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 = {}; @@ -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", @@ -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-")); diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 8190d3b24f6..fad97fd85a5 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -314,8 +314,26 @@ 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 { @@ -323,6 +341,29 @@ function activeServingProfileId(provenance: ServingProfileProvenance | null): st 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, @@ -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; + } }; }