Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
105 changes: 105 additions & 0 deletions src/lib/inference/vllm-fixed-catalog-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,111 @@ describe("fixed catalog vLLM installs", () => {
expect(mocks.dockerRunDetached).toHaveBeenCalledOnce();
});

/**
* Resume replays NemoClaw's own checkpoint, so the preset-driven install
* paths below run the real selection guard rather than a canned result.
*/
async function withActualSelectionGuard(
readinessReports: ReturnType<typeof vllmInstallTestReadiness>,
): Promise<void> {
const actualSelection = await vi.importActual<
typeof import("./serving/host-local-vllm-selection")
>("./serving/host-local-vllm-selection");
mocks.resolveHostLocalVllmSelection.mockImplementation((base, env, options) =>
actualSelection.resolveHostLocalVllmSelection(base, env, {
...options,
readinessReports,
}),
);
}

it("resumes a checkpointed model under an explicitly selected serving preset", async () => {
const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
const modelIntent = "muse-glimmer-30b";
const selected = await resolveActualHostLocalSelection(
profile,
{ NEMOCLAW_VLLM_MODEL: modelIntent },
modelIntent,
);
process.env.NEMOCLAW_SERVING_PRESET = selected.presetId;
const readinessReports = vllmInstallTestReadiness(profile, modelIntent);
await withActualSelectionGuard(readinessReports);
mockSuccessfulVllmInstall(mocks, selected.profile.containerName);
mockSuccessfulAuthenticatedReadiness(selected.model.servedModelId ?? selected.model.id);

const result = await installVllm(profile, {
hasImage: true,
nonInteractive: true,
promptFn: vi.fn(),
modelIntent,
readinessReports,
resolveManagedBridgeHost: () => "172.18.0.1",
});

expect(spies.errSpy).not.toHaveBeenCalledWith(
expect.stringContaining("NEMOCLAW_SERVING_PRESET conflicts with NEMOCLAW_VLLM_MODEL"),
);
expect(result).toEqual({ ok: true });
// The preset stays the model authority: the checkpoint must not reappear
// as an operator override in the environment the selection reads.
const firstCall = mocks.resolveHostLocalVllmSelection.mock.calls[0];
expect(firstCall?.[1]?.NEMOCLAW_VLLM_MODEL).toBeUndefined();
});

it("rejects a resumed model the serving preset does not select", async () => {
const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
const presetModel = "muse-glimmer-30b";
const selected = await resolveActualHostLocalSelection(
profile,
{ NEMOCLAW_VLLM_MODEL: presetModel },
presetModel,
);
process.env.NEMOCLAW_SERVING_PRESET = selected.presetId;
const readinessReports = vllmInstallTestReadiness(profile, presetModel);
await withActualSelectionGuard(readinessReports);

const result = await installVllm(profile, {
hasImage: true,
nonInteractive: true,
promptFn: vi.fn(),
modelIntent: "qwen3.6-35b-a3b-nvfp4",
readinessReports,
});

expect(result).toEqual({ ok: false });
expect(spies.errSpy).toHaveBeenCalledWith(
expect.stringContaining("the resumed model 'qwen3.6-35b-a3b-nvfp4' does not match"),
);
expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled();
});

it("still rejects an operator model override against a serving preset", async () => {
const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
const presetModel = "muse-glimmer-30b";
const selected = await resolveActualHostLocalSelection(
profile,
{ NEMOCLAW_VLLM_MODEL: presetModel },
presetModel,
);
process.env.NEMOCLAW_SERVING_PRESET = selected.presetId;
process.env.NEMOCLAW_VLLM_MODEL = "qwen3.6-35b-a3b-nvfp4";
const readinessReports = vllmInstallTestReadiness(profile, presetModel);
await withActualSelectionGuard(readinessReports);

const result = await installVllm(profile, {
hasImage: true,
nonInteractive: true,
promptFn: vi.fn(),
readinessReports,
});

expect(result).toEqual({ ok: false });
expect(spies.errSpy).toHaveBeenCalledWith(
expect.stringContaining("NEMOCLAW_SERVING_PRESET conflicts with NEMOCLAW_VLLM_MODEL"),
);
expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled();
});

it("defers non-interactive custom arguments to the established installer", async () => {
process.env.NEMOCLAW_VLLM_MODEL = "qwen3.6-35b-a3b-nvfp4";
process.env.NEMOCLAW_VLLM_EXTRA_ARGS_JSON = '["--max-model-len","32768"]';
Expand Down
22 changes: 13 additions & 9 deletions src/lib/inference/vllm-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,20 +577,24 @@ export function modelsForPlatform(platform: VllmPlatform): readonly VllmModelDef
const HF_TOKEN_ENV_KEYS = ["HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"] as const;
export const VLLM_EXTRA_ARGS_ENV = "NEMOCLAW_VLLM_EXTRA_ARGS_JSON";

/** Resolve any unique model name owned by the managed inference catalog. */
export function resolveVllmModelAlias(value: string): VllmModelDef | null {
/** True when `value` names this model by slug, Hugging Face ID, or served name. */
export function vllmModelMatchesAlias(model: VllmModelDef, value: string): boolean {
const requested = value.trim().toLowerCase();
if (!requested) return null;
if (!requested) return false;
return (
VLLM_MODELS.find(
(model) =>
model.envValue.toLowerCase() === requested ||
model.id.toLowerCase() === requested ||
model.servedModelId?.toLowerCase() === requested,
) ?? null
model.envValue.toLowerCase() === requested ||
model.id.toLowerCase() === requested ||
model.servedModelId?.toLowerCase() === requested
);
}

/** Resolve any unique model name owned by the managed inference catalog. */
export function resolveVllmModelAlias(value: string): VllmModelDef | null {
const requested = value.trim();
if (!requested) return null;
return VLLM_MODELS.find((model) => vllmModelMatchesAlias(model, requested)) ?? null;
}

/**
* Look up the requested express-vLLM model from `NEMOCLAW_VLLM_MODEL`.
* Returns `null` when the env var is empty so the caller can fall back to
Expand Down
33 changes: 31 additions & 2 deletions src/lib/inference/vllm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
VLLM_EXTRA_ARGS_ENV,
VLLM_MODELS,
vllmModelForOrchestration,
vllmModelMatchesAlias,
vllmModelUsesOrchestration,
vllmPlatformSpecificity,
type VllmModelDef,
Expand Down Expand Up @@ -1846,7 +1847,12 @@ interface ServingPortProbe {
}

type VllmInstallSelectionEnv =
| { readonly ok: true; readonly env: NodeJS.ProcessEnv; readonly explicitModel: string }
| {
readonly ok: true;
readonly env: NodeJS.ProcessEnv;
readonly explicitModel: string;
readonly resumedPresetModel: string;
}
| { readonly ok: false };

function resolveVllmInstallSelectionEnv(
Expand All @@ -1862,11 +1868,20 @@ function resolveVllmInstallSelectionEnv(
) {
return { ok: false };
}
const selectionEnv = resumedModel ? { ...env, NEMOCLAW_VLLM_MODEL: resumedModel } : env;
// A serving preset already names the model to install, so the preset stays
// authoritative and the resumed checkpoint is only verified against it.
// Feeding that checkpoint back through NEMOCLAW_VLLM_MODEL made NemoClaw's
// own record look like a competing operator override, and resuming a
// preset-driven install was refused as a preset/model conflict even though
// the operator had set neither variable (#11148).
const presetSelected = String(env[NEMOCLAW_SERVING_PRESET_ENV] ?? "").trim().length > 0;
const selectionEnv =
resumedModel && !presetSelected ? { ...env, NEMOCLAW_VLLM_MODEL: resumedModel } : env;
return {
ok: true,
env: selectionEnv,
explicitModel: String(selectionEnv.NEMOCLAW_VLLM_MODEL ?? "").trim(),
resumedPresetModel: presetSelected ? resumedModel : "",
};
}

Expand All @@ -1875,6 +1890,7 @@ type VllmInstallRequestEnv =
readonly ok: true;
readonly env: NodeJS.ProcessEnv;
readonly explicitModel: string;
readonly resumedPresetModel: string;
readonly requestedGpuDevice: string | null;
readonly configuredPeer: string;
readonly configuredManagedClusterPeers: string;
Expand Down Expand Up @@ -2009,6 +2025,7 @@ async function runVllmInstall(
const {
env: selectionEnv,
explicitModel,
resumedPresetModel,
requestedGpuDevice,
configuredPeer,
configuredManagedClusterPeers,
Expand Down Expand Up @@ -2150,6 +2167,18 @@ async function runVllmInstall(
});
}
if (!resolved) return { ok: false };
// The preset chose the model above; this is the receipt check that the
// interrupted run had committed to the same one. It covers every branch that
// produced `resolved` — preset selection, a fixed catalog profile, and the
// Station pair — because each of them is reachable on resume (#11148).
if (resumedPresetModel && !vllmModelMatchesAlias(resolved.model, resumedPresetModel)) {
console.error(
` vLLM install failed: the resumed model '${resumedPresetModel}' does not match ` +
`'${resolved.model.envValue}', which ${NEMOCLAW_SERVING_PRESET_ENV} selects. ` +
`Re-run onboarding with --fresh to discard the interrupted session.`,
);
return { ok: false };
}
if (
!hostLocalSelection &&
resolved.source === "picker" &&
Expand Down
11 changes: 3 additions & 8 deletions src/lib/onboard/local-model-profile/onboarder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { materializeHostLocalVllmSelection } from "../../inference/serving/host-local-vllm-selection";
import type { ResolvedHostLocalInferenceSelection } from "../../inference/serving/types";
import type { VllmProfile } from "../../inference/vllm";
import { VLLM_EXTRA_ARGS_ENV } from "../../inference/vllm-models";
import { VLLM_EXTRA_ARGS_ENV, vllmModelMatchesAlias } from "../../inference/vllm-models";
import type { SetupNimSelectionResult, SetupNimSelectionState } from "../setup-nim-flow";
import { vllmInstallRecoveryOptions } from "../provider-recovery";
import type { LocalModelProfilePlan } from "./plan";
Expand Down Expand Up @@ -87,13 +87,8 @@ export function createLocalModelProfileOnboarder(deps: LocalModelProfileOnboarde
return "retry-selection";
}
const recovery = vllmInstallRecoveryOptions(deps);
const resumedModel = recovery.modelIntent?.trim().toLowerCase();
if (
resumedModel &&
![materialized.model.id, materialized.model.envValue, materialized.model.servedModelId].some(
(candidate) => candidate?.toLowerCase() === resumedModel,
)
) {
const resumedModel = recovery.modelIntent?.trim();
if (resumedModel && !vllmModelMatchesAlias(materialized.model, resumedModel)) {
deps.error(
` The resumed vLLM model conflicts with the ${materialized.model.envValue} local model profile.`,
);
Expand Down
Loading