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
55 changes: 55 additions & 0 deletions src/lib/inference/serving/requested-profile-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import { loadServingCatalog } from "./catalog-loader";
import {
resolveRequestedServingProfileModel,
servingProfileModel,
} from "./requested-profile-model";

describe("requested serving profile model", () => {
it("reports both names the shipped catalog gives a profile", () => {
const catalog = loadServingCatalog();
const preset = catalog.presets[0]!;
const recipe = catalog.recipes.find(
({ metadata }) => metadata.id === preset.spec.plan.recipeRef,
)!;

expect(servingProfileModel(catalog, preset.metadata.id)).toEqual({
presetId: preset.metadata.id,
backend: recipe.spec.backend,
servedName: recipe.spec.model.servedName,
modelId: recipe.spec.model.id,
});
});

it("returns null for a profile the catalog does not carry", () => {
expect(servingProfileModel(loadServingCatalog(), "vllm.absent.profile")).toBeNull();
});

it("reads the preset the current run requested", () => {
const catalog = loadServingCatalog();
const preset = catalog.presets[0]!;

expect(
resolveRequestedServingProfileModel({ NEMOCLAW_SERVING_PRESET: preset.metadata.id }, catalog),
).toEqual(servingProfileModel(catalog, preset.metadata.id));
});

it("returns null when no profile was requested", () => {
expect(resolveRequestedServingProfileModel({}, loadServingCatalog())).toBeNull();
expect(resolveRequestedServingProfileModel({ NEMOCLAW_SERVING_PRESET: " " })).toBeNull();
});

it("does not fail a selection when the catalog cannot be read", () => {
expect(
resolveRequestedServingProfileModel({ NEMOCLAW_SERVING_PRESET: "any" }, {
get presets(): never {
throw new Error("catalog unreadable");
},
} as never),
).toBeNull();
});
});
58 changes: 58 additions & 0 deletions src/lib/inference/serving/requested-profile-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { loadServingCatalog } from "./catalog-loader.js";
import { NEMOCLAW_SERVING_PRESET_ENV } from "./managed-cluster-discovery.js";
import type { CompiledServingCatalog } from "./types.js";

/** The identifiers under which a running endpoint can report a requested profile's model. */
export interface RequestedServingProfileModel {
readonly presetId: string;
readonly backend: string;
/** Alias the recipe pins with --served-model-name, so what /v1/models reports. */
readonly servedName: string;
/** Weights the recipe downloads, so what a reporting endpoint gives as the root. */
readonly modelId: string;
}

export function servingProfileModel(
catalog: CompiledServingCatalog,
presetId: string,
): RequestedServingProfileModel | null {
const presets = catalog.presets.filter(({ metadata }) => metadata.id === presetId);
if (presets.length !== 1) return null;
const recipes = catalog.recipes.filter(
({ metadata }) => metadata.id === presets[0]!.spec.plan.recipeRef,
);
if (recipes.length !== 1) return null;
const spec = recipes[0]!.spec;
const servedName = typeof spec.model.servedName === "string" ? spec.model.servedName.trim() : "";
return servedName
? { presetId, backend: spec.backend, servedName, modelId: spec.model.id }
: null;
}

/**
* Model the serving profile requested for this run declares.
*
* Provider selection reads the preset from the environment rather than from the
* resolved provenance, so this reads the same place. `--profile` sets that
* variable for the run, but an operator can also export it directly, in which
* case no flag validation has run against it — hence the backend on the result,
* which callers use to reject a preset their own selection cannot serve.
*
* Returns null rather than throwing. An unreadable catalog then leaves the
* caller's model check unarmed instead of stopping onboarding.
*/
export function resolveRequestedServingProfileModel(
env: NodeJS.ProcessEnv = process.env,
catalog?: CompiledServingCatalog,
): RequestedServingProfileModel | null {
const presetId = String(env[NEMOCLAW_SERVING_PRESET_ENV] ?? "").trim();
if (!presetId) return null;
try {
return servingProfileModel(catalog ?? loadServingCatalog(), presetId);
} catch {
return null;
}
}
103 changes: 103 additions & 0 deletions src/lib/onboard/setup-nim-flow-serving-profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";

import type { VllmProfile } from "../inference/vllm";
import { makeDeps, makeHostState } from "./__test-helpers__/setup-nim-flow";
import { createSetupNim, type SetupNimFlowDeps } from "./setup-nim-flow";

const servingProfileModel = {
presetId: "vllm.dgx-spark-gb10.single.muse-glimmer-30b-nvfp4-w4a4",
backend: "vllm",
servedName: "muse-glimmer",
modelId: "Inferact/Muse-Glimmer-30B-NVFP4-W4A4",
};

const routeGuard = () => ({
requiredModel: null,
requiredEndpointUrl: null,
requiredInferenceApi: null,
});

function runningVllmHostState() {
return makeHostState({
vllmRunning: true,
vllmProfile: { name: "DGX Spark" } as VllmProfile,
hasVllmImage: true,
vllmEntries: [{ key: "vllm", label: "Local vLLM (localhost:8000) — running (suggested)" }],
});
}

function acceptVllmSelection() {
return vi.fn<SetupNimFlowDeps["handleVllmSelection"]>(async (state) => {
state.provider = "vllm";
state.model = "muse-glimmer";
state.endpointUrl = "http://127.0.0.1:8000/v1";
state.credentialEnv = null;
state.preferredInferenceApi = "openai-completions";
return "selected";
});
}

async function selectAgainstRunningVllm(
handleVllmSelection: ReturnType<typeof acceptVllmSelection>,
resolveRequestedServingProfileModel: SetupNimFlowDeps["resolveRequestedServingProfileModel"],
) {
const setupNim = createSetupNim(
makeDeps({
isNonInteractive: () => true,
getNonInteractiveProvider: () => "install-vllm",
detectInferenceProviderHostState: () => runningVllmHostState(),
handleVllmSelection,
resolveRequestedServingProfileModel,
}),
);
const sparkGpu = { platform: "spark" } as unknown as Parameters<typeof setupNim>[0];
return await setupNim(sparkGpu, null, null, true, null, "nemoclaw", routeGuard);
}

describe("serving profile onboarding against a running vLLM", () => {
it("passes the requested profile's model to the running-server selection (#9563)", async () => {
// `--profile` exports NEMOCLAW_PROVIDER=install-vllm, but a running server
// leaves only the `vllm` entry, so the request collapses onto a deployment
// the profile never selected. The test leaves `installVllm` at the shared
// `unexpected` guard, so a call would fail the test: nothing installs on
// this path, which is why the flow must pass the profile's model on.
const handleVllmSelection = acceptVllmSelection();

const result = await selectAgainstRunningVllm(handleVllmSelection, () => servingProfileModel);

expect(handleVllmSelection).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ managedInstall: false, servingProfileModel }),
);
expect(result).toMatchObject({ provider: "vllm", model: "muse-glimmer" });
});

it("passes no profile model when the run requested no profile", async () => {
const handleVllmSelection = acceptVllmSelection();

await selectAgainstRunningVllm(handleVllmSelection, () => null);

expect(handleVllmSelection).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ servingProfileModel: null }),
);
});

it("does not compare a preset that another backend serves (#9563)", async () => {
const handleVllmSelection = acceptVllmSelection();

await selectAgainstRunningVllm(handleVllmSelection, () => ({
...servingProfileModel,
presetId: "llama-cpp.dgx-spark-gb10.single.muse-glimmer-30b",
backend: "install-llama-cpp",
}));

expect(handleVllmSelection).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ servingProfileModel: null }),
);
});
});
4 changes: 2 additions & 2 deletions src/lib/onboard/setup-nim-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,7 @@ describe("createSetupNim", () => {
expect(handleVllmSelection).toHaveBeenCalledOnce();
expect(handleVllmSelection).toHaveBeenCalledWith(
expect.objectContaining({ model: "vllm-model" }),
{ managedInstall: true, sparkHost: false },
{ managedInstall: true, sparkHost: false, servingProfileModel: null },
);
expect(result).toMatchObject({
model: "vllm-model",
Expand Down Expand Up @@ -1046,7 +1046,7 @@ describe("createSetupNim", () => {
});
expect(handleVllmSelection).toHaveBeenCalledWith(
expect.objectContaining({ model: servedModel }),
{ managedInstall: true, sparkHost: false },
{ managedInstall: true, sparkHost: false, servingProfileModel: null },
);
expect(result).toMatchObject({
model: servedModel,
Expand Down
35 changes: 34 additions & 1 deletion src/lib/onboard/setup-nim-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import {
resolveManagedLlamaCppSelection,
} from "../inference/llama-cpp/managed-selection";
import { getOllamaContextWindowFloorForAgent } from "../inference/ollama-runtime-context";
import {
type RequestedServingProfileModel,
resolveRequestedServingProfileModel,
} from "../inference/serving/requested-profile-model";
import type { VllmProfile } from "../inference/vllm";
import { promptManualModelId } from "../inference/model-prompts";
import { isBackToSelection } from "../navigation";
Expand Down Expand Up @@ -204,8 +208,15 @@ export interface SetupNimFlowDeps {
): Promise<{ ok: boolean }>;
handleVllmSelection(
state: SetupNimSelectionState,
options?: { managedInstall?: boolean; sparkHost?: boolean },
options?: {
managedInstall?: boolean;
sparkHost?: boolean;
servingProfileModel?: RequestedServingProfileModel | null;
},
): Promise<SetupNimSelectionResult>;
resolveRequestedServingProfileModel?(
env?: NodeJS.ProcessEnv,
): RequestedServingProfileModel | null;
handleRoutedSelection(state: SetupNimSelectionState): Promise<SetupNimSelectionResult>;
coerceAgentInferenceApi(
agent: AgentDefinition | null,
Expand Down Expand Up @@ -521,6 +532,19 @@ function vllmPortConflictMessage(
return "vLLM is already running on this host. Select Local vLLM, or stop the existing server before selecting the managed install path.";
}

/**
* Model a requested serving profile declares, when a vLLM selection can serve it.
*
* A preset for another backend reaches this selection through the environment,
* and no vLLM server can answer it, so it is left out rather than compared.
*/
function requestedVllmServingProfileModel(
resolve: SetupNimFlowDeps["resolveRequestedServingProfileModel"],
): RequestedServingProfileModel | null {
const requested = (resolve ?? resolveRequestedServingProfileModel)();
return requested?.backend === "vllm" ? requested : null;
}

async function resolveFreshHermesPortableOllamaSelection(input: {
deps: SetupNimFlowDeps;
agent: AgentDefinition | null;
Expand Down Expand Up @@ -1082,9 +1106,18 @@ export function createSetupNim(
if (selected.key === "vllm") {
const state = preparedVllmState ?? createSelectionState();
state.model = preparedVllmState?.model ?? requestedModel ?? recoveredModel;
// A requested profile reaches this branch two ways: its own install
// finished, or `install-vllm` collapsed onto a server that was already
// listening. The second path runs no install, so nothing seeds a
// required model and the endpoint's own report becomes the route.
// Comparing the profile's model is the only check that the server
// serves what the profile declares.
const result = await deps.handleVllmSelection(state, {
managedInstall: preparedVllmState !== null,
sparkHost: gpu?.spark === true,
servingProfileModel: requestedVllmServingProfileModel(
deps.resolveRequestedServingProfileModel,
),
});
({
model,
Expand Down
Loading
Loading