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
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,41 @@
// SPDX-License-Identifier: Apache-2.0

import { SliderWithTextInput } from '@nemo/common/src/components/SliderWithTextInput';
import type { HyperparameterFieldMetadata } from '@nemo/common/src/components/TrainingParameterSlider/types';
import { INFERENCE_HYPERPARAMETER_FIELD_METADATA } from '@nemo/common/src/constants/inferenceParameters';
import type { InferenceParams } from '@nemo/sdk/generated/platform/schema';
import { Stack } from '@nvidia/foundations-react-core';
import { type FC } from 'react';

export const INFERENCE_PARAMETER_FIELDS = [
'temperature',
'max_tokens',
'top_p',
] as const satisfies Array<keyof typeof INFERENCE_HYPERPARAMETER_FIELD_METADATA>;

export type InferenceParameterField = (typeof INFERENCE_PARAMETER_FIELDS)[number];

type FieldMetadata = HyperparameterFieldMetadata<
Record<InferenceParameterField, number>
>[InferenceParameterField];

export interface InferenceParametersProps {
value: Partial<InferenceParams>;
onChange: (value: Partial<InferenceParams>) => void;
disabled?: boolean;
/** Per-field overrides of the shared bounds and defaults, for callers whose backend differs. */
fieldMetadata?: Partial<Record<InferenceParameterField, Partial<FieldMetadata>>>;
}

const FIELDS = ['temperature', 'max_tokens', 'top_p'] as const satisfies Array<
keyof typeof INFERENCE_HYPERPARAMETER_FIELD_METADATA
>;

export const InferenceParameters: FC<InferenceParametersProps> = ({
value,
onChange,
disabled,
fieldMetadata,
}) => (
<Stack gap="4">
{FIELDS.map((key) => {
const param = INFERENCE_HYPERPARAMETER_FIELD_METADATA[key];
{INFERENCE_PARAMETER_FIELDS.map((key) => {
const param = { ...INFERENCE_HYPERPARAMETER_FIELD_METADATA[key], ...fieldMetadata?.[key] };
return (
<SliderWithTextInput
key={key}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { InferenceParameters } from '@nemo/common/src/components/ModelSelectV2/InferenceParameters';
import {
InferenceParameters,
type InferenceParametersProps,
} from '@nemo/common/src/components/ModelSelectV2/InferenceParameters';
import type { InferenceParams } from '@nemo/sdk/generated/platform/schema';
import {
Button,
Expand All @@ -21,6 +24,7 @@ export interface ParamsDropdownProps {
onOpenChange: (open: boolean) => void;
inferenceParams?: Partial<InferenceParams>;
onInferenceParamsChange?: (params: Partial<InferenceParams>) => void;
fieldMetadata?: InferenceParametersProps['fieldMetadata'];
}

export const ParamsDropdown: FC<ParamsDropdownProps> = ({
Expand All @@ -29,6 +33,7 @@ export const ParamsDropdown: FC<ParamsDropdownProps> = ({
onOpenChange,
inferenceParams = {},
onInferenceParamsChange,
fieldMetadata,
}) => (
<DropdownRoot open={open} onOpenChange={onOpenChange}>
<DropdownTrigger asChild showChevron={false}>
Expand Down Expand Up @@ -60,6 +65,7 @@ export const ParamsDropdown: FC<ParamsDropdownProps> = ({
value={inferenceParams}
onChange={onInferenceParamsChange ?? (() => {})}
disabled={disabled}
fieldMetadata={fieldMetadata}
/>
</Stack>
</DropdownContent>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ModelSettingsSection } from '@studio/routes/AnonymizerBuilderRoute/components/ModelSettingsSection';
import {
activeRolesForStrategy,
GLINER_ROLE,
STRATEGY_SUBSTITUTE,
} from '@studio/routes/AnonymizerBuilderRoute/constants';
import {
getAnonymizerFormDefaults,
type AnonymizerFormData,
} from '@studio/routes/AnonymizerBuilderRoute/schema';
import { render, screen, within } from '@testing-library/react';
import { type FC, type ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';

vi.mock('@studio/routes/AnonymizerBuilderRoute/useAnonymizerModels', () => ({
useAnonymizerModels: () => ({
models: [],
items: [{ label: 'nemotron-3-nano-30b-a3b', value: 'nvidia/nemotron-3-nano-30b-a3b' }],
isLoading: false,
applyModel: vi.fn(),
}),
}));

const TestWrapper: FC<{ children: ReactNode }> = ({ children }) => {
const methods = useForm<AnonymizerFormData>({ defaultValues: getAnonymizerFormDefaults() });
return <FormProvider {...methods}>{children}</FormProvider>;
};

describe('ModelSettingsSection', () => {
it('offers sampling params for every role except the GLiNER detector', async () => {
render(
<TestWrapper>
<ModelSettingsSection />
</TestWrapper>
);

const triggers = await screen.findAllByTestId('params-dropdown-trigger');
expect(triggers).toHaveLength(activeRolesForStrategy(STRATEGY_SUBSTITUTE).length - 1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

expect(
within(screen.getByTestId(`role-settings-${GLINER_ROLE}`)).queryByTestId(
'params-dropdown-trigger'
)
).not.toBeInTheDocument();
expect(
within(screen.getByTestId('role-settings-entity_validator')).getByTestId(
'params-dropdown-trigger'
)
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import type { InferenceParams } from '@nemo/sdk/generated/platform/schema';
import { Divider, Flex, Stack, Text } from '@nvidia/foundations-react-core';
import {
activeRolesForStrategy,
ANONYMIZER_PARAM_METADATA,
GLINER_ROLE,
ROLE_LABELS,
supportsSamplingParams,
} from '@studio/routes/AnonymizerBuilderRoute/constants';
import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema';
import { useAnonymizerModels } from '@studio/routes/AnonymizerBuilderRoute/useAnonymizerModels';
Expand All @@ -35,7 +37,7 @@ export const ModelSettingsSection: FC = () => {
return (
<Stack gap="density-2xl">
{roles.map((role, index) => (
<Stack key={role} gap="density-lg">
<Stack key={role} gap="density-lg" data-testid={`role-settings-${role}`}>
{index > 0 && <Divider orientation="horizontal" width="small" />}
<Text kind="label/bold/lg">{ROLE_LABELS[role] ?? role}</Text>
<Flex gap="density-md" align="end">
Expand Down Expand Up @@ -63,14 +65,17 @@ export const ModelSettingsSection: FC = () => {
}}
/>
</div>
<ParamsDropdown
open={openParamsRole === role}
onOpenChange={(next) => setOpenParamsRole(next ? role : null)}
inferenceParams={roleModelsValue?.[role]?.params as Partial<InferenceParams>}
onInferenceParamsChange={(params) =>
setValue(`roleModels.${role}.params`, params as Record<string, unknown>)
}
/>
{supportsSamplingParams(role) && (
<ParamsDropdown
open={openParamsRole === role}
onOpenChange={(next) => setOpenParamsRole(next ? role : null)}
inferenceParams={roleModelsValue?.[role]?.params as Partial<InferenceParams>}
onInferenceParamsChange={(params) =>
setValue(`roleModels.${role}.params`, params as Record<string, unknown>)
}
fieldMetadata={ANONYMIZER_PARAM_METADATA}
/>
)}
</Flex>
</Stack>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,17 @@ export const MAX_COLUMN_INTROSPECTION_BYTES = 50 * 1024 * 1024;
export const DEFAULT_MODEL_TIMEOUT_SECONDS = 500;

export const DEFAULT_MODEL_MAX_TOKENS = 16384;
export const MAX_MODEL_MAX_TOKENS = 32768;

/** Mirrors the LLM entries in the Anonymizer library's default `models.yaml`. */
export const DEFAULT_MODEL_TEMPERATURE = 0.3;
export const DEFAULT_MODEL_TOP_P = 0.95;

export const ANONYMIZER_PARAM_METADATA = {
temperature: { default: DEFAULT_MODEL_TEMPERATURE },
top_p: { default: DEFAULT_MODEL_TOP_P },
max_tokens: { default: DEFAULT_MODEL_MAX_TOKENS, max: MAX_MODEL_MAX_TOKENS },
} as const;

export const DETECTION_ROLES = [
'entity_detector',
Expand Down Expand Up @@ -286,6 +297,9 @@ export const ROLE_LABELS: Record<string, string> = {

export const GLINER_ROLE = 'entity_detector';

/** GLiNER is a token-classification NIM: it takes labels and a threshold, not sampling params. */
export const supportsSamplingParams = (role: string): boolean => role !== GLINER_ROLE;

export const activeRolesForStrategy = (strategy: Strategy): string[] => {
// rewrite reuses the replacement generator, so the backend validates that role too
if (strategy === STRATEGY_REWRITE) return [...DETECTION_ROLES, ...REWRITE_ROLES, REPLACE_ROLE];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@

import {
DETECTION_ROLES,
GLINER_ROLE,
MAX_PREVIEW_ROWS,
REPLACE_ROLE,
REWRITE_ROLES,
ROLE_LABELS,
} from '@studio/routes/AnonymizerBuilderRoute/constants';
import {
AnonymizerFormData,
anonymizerFormSchema,
buildAnonymizerJobRequest,
getAnonymizerFormDefaults,
type AnonymizerFormData,
} from '@studio/routes/AnonymizerBuilderRoute/schema';

const ALL_ROLES = [...DETECTION_ROLES, REPLACE_ROLE, ...REWRITE_ROLES];
Expand All @@ -24,7 +25,7 @@ const roleModels = (model: string, provider: string): AnonymizerFormData['roleMo
const form = (overrides: Partial<AnonymizerFormData> = {}): AnonymizerFormData => ({
...getAnonymizerFormDefaults(),
source: 'https://example.com/data.csv',
roleModels: roleModels('openai/gpt-oss-120b', 'default/nvidia'),
roleModels: roleModels('nvidia/nemotron-3-nano-30b-a3b', 'default/nvidia'),
...overrides,
});

Expand Down Expand Up @@ -131,20 +132,40 @@ describe('buildAnonymizerJobRequest', () => {
expect(req.spec.data.data_summary).toBeUndefined();
});

it('deduplicates identical role models into a single model_config with a default timeout', () => {
it('deduplicates identical role models into a single model_config per param set', () => {
const req = buildAnonymizerJobRequest(form({ strategy: 'substitute' }));
expect(req.spec.model_configs).toEqual([
{
alias: 'model-1',
model: 'openai/gpt-oss-120b',
model: 'nvidia/nemotron-3-nano-30b-a3b',
provider: 'default/nvidia',
inference_parameters: { timeout: 500, max_tokens: 16384 },
inference_parameters: { timeout: 500 },
},
{
alias: 'model-2',
model: 'nvidia/nemotron-3-nano-30b-a3b',
provider: 'default/nvidia',
inference_parameters: { timeout: 500, max_tokens: 16384, temperature: 0.3, top_p: 0.95 },
},
]);
});

it('sends no sampling params to the GLiNER detector, even when the form holds them', () => {
const models = roleModels('nvidia/nemotron-3-nano-30b-a3b', 'default/nvidia');
models[GLINER_ROLE] = {
modelId: 'gliner',
model: 'nvidia/gliner-pii',
provider: 'default/nvidia',
params: { temperature: 0.9, top_p: 0.5, max_tokens: 2048, max_parallel_requests: 1 },
};
const req = buildAnonymizerJobRequest(form({ strategy: 'substitute', roleModels: models }));
const detectorAlias = req.spec.selected_models?.detection?.[GLINER_ROLE];
const detector = req.spec.model_configs?.find((config) => config.alias === detectorAlias);
expect(detector?.inference_parameters).toEqual({ timeout: 500, max_parallel_requests: 1 });
});

it('emits one model_config per unique model+provider', () => {
const models = roleModels('openai/gpt-oss-120b', 'default/nvidia');
const models = roleModels('nvidia/nemotron-3-nano-30b-a3b', 'default/nvidia');
models[DETECTION_ROLES[0]] = {
modelId: 'gliner',
model: 'nvidia/gliner-pii',
Expand All @@ -160,17 +181,17 @@ describe('buildAnonymizerJobRequest', () => {
it('maps detection + replace roles for substitute, detection + rewrite for rewrite', () => {
const sub = buildAnonymizerJobRequest(form({ strategy: 'substitute' })).spec.selected_models;
expect(sub?.detection?.entity_detector).toBe('model-1');
expect(sub?.replace?.replacement_generator).toBe('model-1');
expect(sub?.replace?.replacement_generator).toBe('model-2');
expect(sub?.rewrite).toBeUndefined();

const rew = buildAnonymizerJobRequest(form({ strategy: 'rewrite' })).spec.selected_models;
expect(rew?.detection?.entity_detector).toBe('model-1');
expect(rew?.rewrite?.rewriter).toBe('model-1');
expect(rew?.rewrite?.rewriter).toBe('model-2');
});

it('maps the replacement generator for rewrite so the backend alias check passes', () => {
const rew = buildAnonymizerJobRequest(form({ strategy: 'rewrite' })).spec.selected_models;
expect(rew?.replace?.[REPLACE_ROLE]).toBe('model-1');
expect(rew?.replace?.[REPLACE_ROLE]).toBe('model-2');
});

it('maps only detection roles for redact/annotate/hash', () => {
Expand Down Expand Up @@ -241,22 +262,22 @@ describe('buildAnonymizerJobRequest', () => {
});

it('attaches inference_parameters and splits configs when params differ', () => {
const models = roleModels('openai/gpt-oss-120b', 'default/nvidia');
models[DETECTION_ROLES[0]] = {
modelId: 'gpt',
model: 'openai/gpt-oss-120b',
const models = roleModels('nvidia/nemotron-3-nano-30b-a3b', 'default/nvidia');
models['entity_validator'] = {
modelId: 'nemotron',
model: 'nvidia/nemotron-3-nano-30b-a3b',
provider: 'default/nvidia',
params: { temperature: 0.1 },
};
const req = buildAnonymizerJobRequest(form({ strategy: 'substitute', roleModels: models }));
expect(req.spec.model_configs).toHaveLength(2);
const withTemp = req.spec.model_configs?.find(
(c) => (c.inference_parameters as { temperature?: number })?.temperature != null
);
expect(withTemp?.inference_parameters).toEqual({
expect(req.spec.model_configs).toHaveLength(3);
const validatorAlias = req.spec.selected_models?.detection?.['entity_validator'];
const validator = req.spec.model_configs?.find((config) => config.alias === validatorAlias);
expect(validator?.inference_parameters).toEqual({
timeout: 500,
max_tokens: 16384,
temperature: 0.1,
top_p: 0.95,
});
});
});
Expand All @@ -266,7 +287,7 @@ describe('anonymizerFormSchema', () => {
anonymizerFormSchema.safeParse({
...getAnonymizerFormDefaults(),
source: 'https://example.com/data.csv',
roleModels: roleModels('openai/gpt-oss-120b', 'default/nvidia'),
roleModels: roleModels('nvidia/nemotron-3-nano-30b-a3b', 'default/nvidia'),
...overrides,
});

Expand Down
Loading