Skip to content

Commit 512db07

Browse files
authored
feat(studio): let the deployment wizard choose an inference engine (#1765)
The create-deployment wizard hardcoded `engine: nim` for every source, so a deployment could not be created with vLLM even though it is what the LoRA customization docs use. `lora_enabled` was collected by the form but only sent on the NGC path, and the Workspace and HuggingFace paths dropped it — the two paths a user reaches a fine-tuned checkpoint through. Add an engine picker to the HuggingFace and Workspace sources, defaulting to vLLM, and send `lora_enabled` on all three. NGC keeps `nim` and its required image fields, since that source is a NIM container by definition. Require an image for `nim` and `generic`. A blank image with `nim` silently resolves to `nvcr.io/nim/meta/llama-3.1-8b-instruct`, so an arbitrary checkpoint would be served by a Llama-specific NIM; `generic` has no compiler and cannot run without one. Blank image fields are omitted entirely so vLLM resolves its own model-agnostic default. Also correct the side panel copy, which told the user that Workspace and HuggingFace deployments use the multi-LLM NIM. They did not: no image was sent, and the platform default is Llama-specific. Signed-off-by: Alex Ray <alray@nvidia.com>
1 parent f32a862 commit 512db07

9 files changed

Lines changed: 329 additions & 80 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect';
7+
import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput';
8+
import { Engine } from '@nemo/sdk/generated/platform/schema';
9+
import {
10+
engineRequiresImage,
11+
type WizardFormValues,
12+
} from '@studio/routes/DeploymentsListRoute/CreateDeploymentSidePanel/schema';
13+
import type { FC } from 'react';
14+
import { useWatch, type Control, type FieldErrors } from 'react-hook-form';
15+
16+
const ENGINE_ITEMS = [
17+
{ value: Engine.vllm, children: 'vLLM' },
18+
{ value: Engine.nim, children: 'NIM' },
19+
{ value: Engine.generic, children: 'Generic container' },
20+
];
21+
22+
const ENGINE_INFO: Record<Engine, string> = {
23+
[Engine.vllm]:
24+
'Serves any supported architecture from a model-agnostic vLLM image. Leave the image blank to use the platform default.',
25+
[Engine.nim]:
26+
'Runs a prebuilt NIM container. The image must match the model architecture, so it is required here.',
27+
[Engine.generic]:
28+
'Runs your image as-is, with no inference-engine compilation. The image is required.',
29+
};
30+
31+
export type EngineFieldsProps = {
32+
control: Control<WizardFormValues>;
33+
errors: FieldErrors<WizardFormValues>;
34+
};
35+
36+
/**
37+
* Engine picker plus the image fields it governs.
38+
*
39+
* Shared by the HuggingFace and Workspace sources. The NGC source is a NIM
40+
* container by definition and collects its image in `NgcSourceFields`.
41+
*/
42+
export const EngineFields: FC<EngineFieldsProps> = ({ control, errors }) => {
43+
const engine = useWatch({ control, name: 'engine' });
44+
const imageRequired = engineRequiresImage(engine);
45+
46+
return (
47+
<>
48+
<ControlledSelect
49+
useControllerProps={{ control, name: 'engine' }}
50+
items={ENGINE_ITEMS}
51+
formFieldProps={{
52+
slotLabel: 'Engine',
53+
slotInfo: ENGINE_INFO[engine],
54+
slotError: errors.engine?.message,
55+
}}
56+
/>
57+
<ControlledTextInput
58+
useControllerProps={{ control, name: 'imageName' }}
59+
name="imageName"
60+
label={imageRequired ? 'Image name' : 'Image name (optional)'}
61+
formFieldProps={{
62+
slotInfo: imageRequired
63+
? 'Fully qualified image repository, e.g. nvcr.io/nim/meta/llama-3.1-8b-instruct.'
64+
: 'Override the default vLLM image. Leave blank unless you need a specific build.',
65+
slotError: errors.imageName?.message,
66+
}}
67+
/>
68+
<ControlledTextInput
69+
useControllerProps={{ control, name: 'imageTag' }}
70+
name="imageTag"
71+
label={imageRequired ? 'Image tag' : 'Image tag (optional)'}
72+
formFieldProps={{
73+
slotInfo: 'Pin a specific tag rather than relying on a floating one.',
74+
slotError: errors.imageTag?.message,
75+
}}
76+
/>
77+
</>
78+
);
79+
};

‎web/packages/studio/src/routes/DeploymentsListRoute/CreateDeploymentSidePanel/GPULoraFields.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const GPULoraFields = ({
2323
label="GPUs"
2424
type="number"
2525
formFieldProps={{
26-
slotInfo: 'GPU count for this NIM (TP×PP for multi-LLM NIM; see docs).',
26+
slotInfo: 'GPU count for this deployment (TP×PP where the engine supports it).',
2727
slotError: errors.gpu?.message,
2828
}}
2929
/>
@@ -34,7 +34,8 @@ export const GPULoraFields = ({
3434
attributes={{ Flex: { justify: 'start' } }}
3535
formFieldProps={{
3636
slotLabel: 'LoRA Enabled',
37-
slotInfo: 'Enable when serving LoRA adapters on this base image.',
37+
slotInfo:
38+
'Serve LoRA adapters alongside this base model. Adapters trained against it are picked up automatically and addressed as “workspace--adapter-name”.',
3839
}}
3940
/>
4041
</Flex>

‎web/packages/studio/src/routes/DeploymentsListRoute/CreateDeploymentSidePanel/HuggingFaceSourceFields.tsx‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
*/
1212

1313
import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput';
14+
import { EngineFields } from '@studio/routes/DeploymentsListRoute/CreateDeploymentSidePanel/EngineFields';
15+
import { GPULoraFields } from '@studio/routes/DeploymentsListRoute/CreateDeploymentSidePanel/GPULoraFields';
1416
import type { WizardFormValues } from '@studio/routes/DeploymentsListRoute/CreateDeploymentSidePanel/schema';
1517
import { CreateSecretModal } from '@studio/routes/SecretsListRoute/CreateSecretModal';
1618
import { SecretSearchableSelect } from '@studio/routes/SecretsListRoute/SecretSearchableSelect';
@@ -55,17 +57,8 @@ export const HuggingFaceSourceFields: FC<HuggingFaceSourceFieldsProps> = ({
5557
slotError: errors.hfTokenSecret?.message,
5658
}}
5759
/>
58-
<ControlledTextInput
59-
useControllerProps={{ control, name: 'gpu' }}
60-
name="gpu"
61-
label="GPUs"
62-
type="number"
63-
formFieldProps={{
64-
slotInfo:
65-
'Typically uses multi-LLM NIM; see supported architectures in deploy-models docs.',
66-
slotError: errors.gpu?.message,
67-
}}
68-
/>
60+
<EngineFields control={control} errors={errors} />
61+
<GPULoraFields control={control} errors={errors} />
6962
<CreateSecretModal
7063
workspace={workspace}
7164
open={createSecretModalOpen}

‎web/packages/studio/src/routes/DeploymentsListRoute/CreateDeploymentSidePanel/WorkspaceSourceFields.tsx‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55

66
import { useModelSearch } from '@nemo/common/src/api/models/useModelSearch';
77
import { FilesetSearchableSelect } from '@nemo/common/src/components/FilesetSearchableSelect';
8-
import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput';
98
import { type ModelSelection, ModelSelectV2 } from '@nemo/common/src/components/ModelSelectV2';
109
import { RadioCard } from '@nemo/common/src/components/RadioCard';
1110
import { Flex, FormField, RadioGroupRoot, Stack } from '@nvidia/foundations-react-core';
11+
import { EngineFields } from '@studio/routes/DeploymentsListRoute/CreateDeploymentSidePanel/EngineFields';
12+
import { GPULoraFields } from '@studio/routes/DeploymentsListRoute/CreateDeploymentSidePanel/GPULoraFields';
1213
import {
1314
WORKSPACE_PICKER_FILESET,
1415
WORKSPACE_PICKER_MODEL,
@@ -81,16 +82,9 @@ export const WorkspaceSourceFields: FC<WorkspaceSourceFieldsProps> = ({
8182
/>
8283
)}
8384

84-
<ControlledTextInput
85-
useControllerProps={{ control, name: 'gpu' }}
86-
name="gpu"
87-
label="GPUs"
88-
type="number"
89-
formFieldProps={{
90-
slotInfo: 'Uses the multi-LLM NIM; verify model architecture is supported.',
91-
slotError: errors.gpu?.message,
92-
}}
93-
/>
85+
<EngineFields control={control} errors={errors} />
86+
87+
<GPULoraFields control={control} errors={errors} />
9488
</Stack>
9589
);
9690
};

‎web/packages/studio/src/routes/DeploymentsListRoute/CreateDeploymentSidePanel/index.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,8 @@ export const CreateDeploymentSidePanel: FC<CreateDeploymentSidePanelProps> = ({
200200
/>
201201
{(source === SOURCE_HF || source === SOURCE_WORKSPACE) && (
202202
<Text kind="body/regular/md">
203-
{source === SOURCE_HF
204-
? 'HuggingFace deployments support specific model architectures—verify compatibility before deploying or use a model-specific NIM image if required.'
205-
: 'Workspace deployments use the multi-LLM NIM by default—verify model architecture is supported.'}
203+
Choose the engine that serves this model. vLLM runs any supported architecture from a
204+
default image; NIM needs an image built for the specific architecture.
206205
</Text>
207206
)}
208207

‎web/packages/studio/src/routes/DeploymentsListRoute/CreateDeploymentSidePanel/schema.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { Engine } from '@nemo/sdk/generated/platform/schema';
45
import {
56
additionalEnvsFormToApi,
67
configNameFromWizardBaseName,
78
createDeploymentWizardSchema,
89
defaultWizardValues,
910
deploymentNameFromWizardBaseName,
11+
engineRequiresImage,
1012
WORKSPACE_PICKER_FILESET,
1113
WORKSPACE_PICKER_MODEL,
1214
SOURCE_HF,
@@ -67,6 +69,69 @@ describe('defaultWizardValues', () => {
6769
expect(typeof vals.name).toBe('string');
6870
expect(vals.name.length).toBeGreaterThan(0);
6971
});
72+
73+
it('defaults the engine to vLLM, which needs no image', () => {
74+
expect(defaultWizardValues().engine).toBe(Engine.vllm);
75+
expect(engineRequiresImage(Engine.vllm)).toBe(false);
76+
});
77+
});
78+
79+
describe('engine image requirements', () => {
80+
const workspaceValues = (overrides: Record<string, unknown>) => ({
81+
...defaultWizardValues(),
82+
source: SOURCE_WORKSPACE,
83+
name: 'my-deploy',
84+
workspacePickerType: WORKSPACE_PICKER_MODEL,
85+
modelRef: 'ws/model',
86+
...overrides,
87+
});
88+
89+
it.each([Engine.nim, Engine.generic])('requires an image for the %s engine', (engine) => {
90+
expect(engineRequiresImage(engine)).toBe(true);
91+
92+
const result = createDeploymentWizardSchema.safeParse(
93+
workspaceValues({ engine, imageName: '', imageTag: '' })
94+
);
95+
96+
expect(result.success).toBe(false);
97+
const paths = result.success ? [] : result.error.issues.map((i) => i.path.join('.'));
98+
expect(paths).toContain('imageName');
99+
expect(paths).toContain('imageTag');
100+
});
101+
102+
it('accepts a blank image for the vLLM engine', () => {
103+
const result = createDeploymentWizardSchema.safeParse(
104+
workspaceValues({ engine: Engine.vllm, imageName: '', imageTag: '' })
105+
);
106+
107+
expect(result.success).toBe(true);
108+
});
109+
110+
it('accepts NIM once an image is supplied', () => {
111+
const result = createDeploymentWizardSchema.safeParse(
112+
workspaceValues({
113+
engine: Engine.nim,
114+
imageName: 'nvcr.io/nim/meta/llama-3.1-8b-instruct',
115+
imageTag: '1.8.5',
116+
})
117+
);
118+
119+
expect(result.success).toBe(true);
120+
});
121+
122+
it('applies the rule to the HuggingFace source too', () => {
123+
const result = createDeploymentWizardSchema.safeParse({
124+
...defaultWizardValues(),
125+
source: SOURCE_HF,
126+
name: 'my-deploy',
127+
repoId: 'Qwen/Qwen3-0.6B',
128+
engine: Engine.nim,
129+
imageName: '',
130+
imageTag: '',
131+
});
132+
133+
expect(result.success).toBe(false);
134+
});
70135
});
71136

72137
describe('createDeploymentWizardSchema', () => {

‎web/packages/studio/src/routes/DeploymentsListRoute/CreateDeploymentSidePanel/schema.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import { resourceRefSchema } from '@nemo/common/src/types';
1414
import { generateDefaultName } from '@nemo/common/src/utils/generateDefaultName';
15+
import { Engine } from '@nemo/sdk/generated/platform/schema';
1516
import {
1617
modelsCreateDeploymentBodyNameMax,
1718
modelsCreateDeploymentBodyNameRegExp,
@@ -52,16 +53,66 @@ export const SOURCE_WORKSPACE = 'workspace' as const;
5253
export const WORKSPACE_PICKER_MODEL = 'model' as const;
5354
export const WORKSPACE_PICKER_FILESET = 'fileset' as const;
5455

56+
/**
57+
* Engines that need an explicit image.
58+
*
59+
* `vllm` resolves to a model-agnostic default server image, so leaving the image
60+
* blank is safe. `nim` falls back to a model-specific NIM (Llama-3.1-8B today),
61+
* which silently mismatches any other architecture, and `generic` has no
62+
* compiler at all — both need the user to say which image to run.
63+
*/
64+
export const ENGINES_REQUIRING_IMAGE: readonly Engine[] = [Engine.nim, Engine.generic];
65+
66+
export function engineRequiresImage(engine: Engine): boolean {
67+
return ENGINES_REQUIRING_IMAGE.includes(engine);
68+
}
69+
70+
/** Sources where the user picks the engine; NGC is a NIM container by definition. */
71+
export function sourceSupportsEngineChoice(source: WizardFormValues['source']): boolean {
72+
return source === SOURCE_HF || source === SOURCE_WORKSPACE;
73+
}
74+
5575
const additionalEnvRowSchema = z.object({
5676
key: z.string(),
5777
value: z.string().optional(),
5878
});
5979

80+
/**
81+
* Require an explicit image for engines whose default would otherwise be wrong.
82+
*
83+
* Only applies to the sources that expose an engine picker — the NGC source
84+
* already requires an image unconditionally.
85+
*/
86+
function requireImageForEngine(
87+
data: { engine: Engine; imageName?: string; imageTag?: string },
88+
ctx: z.RefinementCtx
89+
): void {
90+
if (!engineRequiresImage(data.engine)) return;
91+
92+
const label = data.engine === Engine.nim ? 'NIM' : 'generic';
93+
if (!data.imageName?.trim()) {
94+
ctx.addIssue({
95+
code: z.ZodIssueCode.custom,
96+
message: `Image name is required for the ${label} engine`,
97+
path: ['imageName'],
98+
});
99+
}
100+
if (!data.imageTag?.trim()) {
101+
ctx.addIssue({
102+
code: z.ZodIssueCode.custom,
103+
message: `Image tag is required for the ${label} engine`,
104+
path: ['imageTag'],
105+
});
106+
}
107+
}
108+
60109
export const createDeploymentWizardSchema = z
61110
.object({
62111
source: z.enum([SOURCE_NGC, SOURCE_HF, SOURCE_WORKSPACE]),
63112
/** Base name: NGC NIM `model_name`, and API deployment/config become `<name>-deployment` / `<name>-config`. */
64113
name: wizardBaseNameSchema,
114+
/** Inference engine. Ignored for the NGC source, which is always a NIM container. */
115+
engine: z.nativeEnum(Engine),
65116
imageName: z.string().optional(),
66117
imageTag: z.string().optional(),
67118
gpu: z.coerce.number().int().min(1, 'At least 1 GPU'),
@@ -102,7 +153,9 @@ export const createDeploymentWizardSchema = z
102153
path: ['repoId'],
103154
});
104155
}
156+
requireImageForEngine(data, ctx);
105157
} else {
158+
requireImageForEngine(data, ctx);
106159
if (data.workspacePickerType === WORKSPACE_PICKER_MODEL) {
107160
if (!data.modelRef?.trim()) {
108161
ctx.addIssue({
@@ -140,6 +193,10 @@ export type WizardFormValues = z.infer<typeof createDeploymentWizardSchema>;
140193
export const defaultWizardValues = (): WizardFormValues => ({
141194
source: SOURCE_NGC,
142195
name: generateDefaultName(),
196+
// vLLM serves any architecture from a model-agnostic default image, so it is
197+
// the safe default for the sources that expose the picker. The NGC source
198+
// overrides this to `nim` when building its request.
199+
engine: Engine.vllm,
143200
imageName: '',
144201
imageTag: '',
145202
gpu: 1,

0 commit comments

Comments
 (0)