diff --git a/web/packages/common/src/components/EntityEmptyState/registry.ts b/web/packages/common/src/components/EntityEmptyState/registry.ts index 0f22d21953..d5c4799f00 100644 --- a/web/packages/common/src/components/EntityEmptyState/registry.ts +++ b/web/packages/common/src/components/EntityEmptyState/registry.ts @@ -83,8 +83,8 @@ export const ENTITY_EMPTY_STATES: Record.json --workspace ', skillPrompt: 'Help me create my first custom model with the nemo-customizer skill', }, diff --git a/web/packages/studio/src/components/BaseModelCard/BaseModelCard.stories.tsx b/web/packages/studio/src/components/BaseModelCard/BaseModelCard.stories.tsx index 116a3d8044..74c2074632 100644 --- a/web/packages/studio/src/components/BaseModelCard/BaseModelCard.stories.tsx +++ b/web/packages/studio/src/components/BaseModelCard/BaseModelCard.stories.tsx @@ -54,7 +54,7 @@ const makeModel = ( }); /** - * A well-known Meta model — shows creator icon, description, customizable badge, + * A well-known Meta model — shows creator icon, description, fine-tunable badge, * and all spec fields. */ export const Default: Story = { @@ -285,9 +285,9 @@ const customizationSpec = { }; const fineTuneableOnlyModel = makeModel({ - name: 'fine-tuneable-only', + name: 'fine-tunable-only', workspace: 'meta', - description: 'Has a fileset, so the Fine-Tuneable badge renders.', + description: 'Has a fileset, so the Fine-tunable badge renders.', spec: customizationSpec, model_providers: ['default/nvidia-build'], fileset: 'meta/llama-checkpoint', @@ -299,7 +299,7 @@ export const CustomizationBadges: Story = { render: () => (
-

Fine-Tuneable only

+

Fine-tunable only

diff --git a/web/packages/studio/src/components/BaseModelCard/BaseModelCard.test.tsx b/web/packages/studio/src/components/BaseModelCard/BaseModelCard.test.tsx index 8c9eafa08d..a918b3553f 100644 --- a/web/packages/studio/src/components/BaseModelCard/BaseModelCard.test.tsx +++ b/web/packages/studio/src/components/BaseModelCard/BaseModelCard.test.tsx @@ -36,7 +36,7 @@ describe('BaseModelCard', () => { ); diff --git a/web/packages/studio/src/components/BaseModelCard/index.tsx b/web/packages/studio/src/components/BaseModelCard/index.tsx index 59adb9c0e4..451c4badc5 100644 --- a/web/packages/studio/src/components/BaseModelCard/index.tsx +++ b/web/packages/studio/src/components/BaseModelCard/index.tsx @@ -16,13 +16,14 @@ import { creatorToIcon } from '@nemo/common/src/constants/modelMetadata'; import { getPartsFromReference } from '@nemo/common/src/namedEntity'; import type { ModelEntity } from '@nemo/sdk/generated/platform/schema'; import { Badge, Button, Card, Flex, Stack, Tag, Text } from '@nvidia/foundations-react-core'; +import { canFineTuneModel } from '@studio/hooks/useModelCustomizationEligibility'; import { MessagesSquare, File, Globe } from 'lucide-react'; import React, { useMemo } from 'react'; export interface BaseModelCardProps { model: ModelEntity; isChatAvailable?: boolean; - showCustomizationBadges?: boolean; + showFineTuningBadges?: boolean; onClick?: () => void; } @@ -46,7 +47,7 @@ const formatContextSize = (contextSize: number): string => { export const BaseModelCard = ({ model, isChatAvailable = false, - showCustomizationBadges = true, + showFineTuningBadges = true, onClick, }: BaseModelCardProps) => { const metadata = useMemo(() => getModelMetadata(model), [model]); @@ -59,7 +60,7 @@ export const BaseModelCard = ({ const contextSize = model.spec?.context_size ? formatContextSize(model.spec.context_size) : undefined; - const isFineTuneable = Boolean(model.fileset); + const isFineTunable = canFineTuneModel(model); const providers = model.model_providers ?? []; return ( @@ -95,10 +96,10 @@ export const BaseModelCard = ({ {description} )} - {showCustomizationBadges && ( + {showFineTuningBadges && ( {/* Capabilities */} - {isFineTuneable && ( + {isFineTunable && ( Fine-tunable diff --git a/web/packages/studio/src/components/NewCustomizationForm/GrpoParametersSection.tsx b/web/packages/studio/src/components/NewCustomizationForm/GrpoParametersSection.tsx index 584dd84bfa..27c315a59e 100644 --- a/web/packages/studio/src/components/NewCustomizationForm/GrpoParametersSection.tsx +++ b/web/packages/studio/src/components/NewCustomizationForm/GrpoParametersSection.tsx @@ -573,7 +573,7 @@ export const GrpoParametersSection = () => { - Finetuning Type + Fine-tuning Type { const user = userEvent.setup(); renderRoute(); - await user.click(await screen.findByRole('button', { name: /Start Fine-Tuning/i })); + await user.click(await screen.findByRole('button', { name: /Start Fine-tuning/i })); expect(await screen.findByText(/Please fix the following errors/i)).toBeInTheDocument(); expect(mutateAutomodel).not.toHaveBeenCalled(); @@ -115,7 +115,7 @@ describe('NewCustomizationForm', () => { renderRoute(); await user.click(await screen.findByRole('radio', { name: /Unsloth/i })); - await user.click(await screen.findByRole('button', { name: /Start Fine-Tuning/i })); + await user.click(await screen.findByRole('button', { name: /Start Fine-tuning/i })); // The errors shown must be about the unsloth fields, never automodel ones. const banner = await screen.findByText(/Please fix the following errors/i); diff --git a/web/packages/studio/src/components/NewCustomizationForm/index.tsx b/web/packages/studio/src/components/NewCustomizationForm/index.tsx index bdc5cbe3ec..bbab15a20a 100644 --- a/web/packages/studio/src/components/NewCustomizationForm/index.tsx +++ b/web/packages/studio/src/components/NewCustomizationForm/index.tsx @@ -196,7 +196,7 @@ export const NewCustomizationForm: FC = ({ slotFooter={ } diff --git a/web/packages/studio/src/components/dataViews/CustomModelsDataView/CustomizeModelButton/index.test.tsx b/web/packages/studio/src/components/dataViews/CustomModelsDataView/CustomizeModelButton/index.test.tsx index 324ea0f00c..3291e990bc 100644 --- a/web/packages/studio/src/components/dataViews/CustomModelsDataView/CustomizeModelButton/index.test.tsx +++ b/web/packages/studio/src/components/dataViews/CustomModelsDataView/CustomizeModelButton/index.test.tsx @@ -25,7 +25,6 @@ const setEligibility = (overrides: { canFineTune?: boolean; isLoading?: boolean const canFineTune = overrides.canFineTune ?? false; mockedUseEligibility.mockReturnValue({ canFineTune, - canCustomize: canFineTune, isLoading: overrides.isLoading ?? false, }); }; @@ -60,15 +59,15 @@ describe('CustomizeModelButton', () => { }); describe('workspace-level (no model)', () => { - it('renders "Customize a Model"', () => { + it('renders "Fine-tune a Model"', () => { renderRoute(); - expect(screen.getByRole('button', { name: 'Customize a Model' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Fine-tune a Model' })).toBeInTheDocument(); }); it('navigates straight to the fine-tuning form on click', async () => { const user = userEvent.setup(); renderRoute(); - await user.click(screen.getByRole('button', { name: 'Customize a Model' })); + await user.click(screen.getByRole('button', { name: 'Fine-tune a Model' })); expect(await screen.findByTestId(LOCATION_DISPLAY_TEST_ID)).toHaveTextContent( `/workspaces/${workspace1.workspace}/customizations/fine-tuned/new` ); @@ -77,20 +76,20 @@ describe('CustomizeModelButton', () => { it('stays enabled regardless of eligibility', () => { setEligibility({ canFineTune: false }); renderRoute(); - expect(screen.getByRole('button', { name: 'Customize a Model' })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: 'Fine-tune a Model' })).not.toBeDisabled(); }); }); describe('per-model', () => { - it('renders "Customize this Model" when a model is provided', () => { + it('renders "Fine-tune this Model" when a model is provided', () => { renderRoute({ model: testModel }); - expect(screen.getByRole('button', { name: /Customize this Model/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Fine-tune this Model/ })).toBeInTheDocument(); }); it('disables the button while eligibility is loading', () => { setEligibility({ isLoading: true }); renderRoute({ model: testModel }); - expect(screen.getByRole('button', { name: /Customize this Model/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Fine-tune this Model/ })).toBeDisabled(); }); it('shows a spinner while eligibility is loading', () => { @@ -102,13 +101,13 @@ describe('CustomizeModelButton', () => { it('disables the button when the model cannot be fine-tuned', () => { setEligibility({ canFineTune: false }); renderRoute({ model: testModel }); - expect(screen.getByRole('button', { name: /Customize this Model/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Fine-tune this Model/ })).toBeDisabled(); }); it('navigates to the fine-tuning form with the model preselected', async () => { const user = userEvent.setup(); renderRoute({ model: testModel }); - await user.click(screen.getByRole('button', { name: /Customize this Model/ })); + await user.click(screen.getByRole('button', { name: /Fine-tune this Model/ })); expect(await screen.findByTestId(LOCATION_DISPLAY_TEST_ID)).toHaveTextContent( `/workspaces/${workspace1.workspace}/customizations/fine-tuned/new` ); diff --git a/web/packages/studio/src/components/dataViews/CustomModelsDataView/CustomizeModelButton/index.tsx b/web/packages/studio/src/components/dataViews/CustomModelsDataView/CustomizeModelButton/index.tsx index 6a74cc21f2..96ffcdabec 100644 --- a/web/packages/studio/src/components/dataViews/CustomModelsDataView/CustomizeModelButton/index.tsx +++ b/web/packages/studio/src/components/dataViews/CustomModelsDataView/CustomizeModelButton/index.tsx @@ -14,7 +14,7 @@ export interface CustomizeModelButtonProps { workspace: string; /** * When provided, the button is shown in the per-model context: label becomes - * "Customize this Model", a loading spinner is shown while eligibility is + * "Fine-tune this Model", a loading spinner is shown while eligibility is * being checked, and the button is disabled if the model has no fileset to * fine-tune from. */ @@ -38,9 +38,9 @@ export const CustomizeModelButton: FC = ({ workspace, loading={isLoading} disabled={!canFineTune} > - Customize this Model + Fine-tune this Model ) : ( - Customize a Model + Fine-tune a Model ); }; diff --git a/web/packages/studio/src/components/dataViews/CustomModelsDataView/KindTag.stories.tsx b/web/packages/studio/src/components/dataViews/CustomModelsDataView/KindTag.stories.tsx index 6d174ffdea..a168e79a34 100644 --- a/web/packages/studio/src/components/dataViews/CustomModelsDataView/KindTag.stories.tsx +++ b/web/packages/studio/src/components/dataViews/CustomModelsDataView/KindTag.stories.tsx @@ -43,7 +43,7 @@ export const Default: Story = { }; export const AllFinetuningTypes: Story = { - name: 'All Finetuning Types', + name: 'All Fine-tuning Types', args: { finetuningType: FinetuningType.lora }, render: (args) => ( diff --git a/web/packages/studio/src/components/dataViews/CustomModelsDataView/index.tsx b/web/packages/studio/src/components/dataViews/CustomModelsDataView/index.tsx index 23be7ed6dc..5a59e35e71 100644 --- a/web/packages/studio/src/components/dataViews/CustomModelsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/CustomModelsDataView/index.tsx @@ -295,7 +295,7 @@ export const CustomModelsDataView: FC = ({ meta: { filter: { type: 'single-select', - label: 'Finetuning Type', + label: 'Fine-tuning Type', options: FINETUNING_TYPE_FILTER_OPTIONS, }, }, diff --git a/web/packages/studio/src/components/sidePanels/CustomizationConfigSidePanel/index.test.tsx b/web/packages/studio/src/components/sidePanels/CustomizationConfigSidePanel/index.test.tsx index 3c23a0b6d5..d2bb8d02dd 100644 --- a/web/packages/studio/src/components/sidePanels/CustomizationConfigSidePanel/index.test.tsx +++ b/web/packages/studio/src/components/sidePanels/CustomizationConfigSidePanel/index.test.tsx @@ -72,7 +72,7 @@ describe('CustomizationConfigSidePanel', () => { expect(screen.getByText('Training Type')).toBeInTheDocument(); expect(screen.getByText(getFormattedTrainingType('sft'))).toBeInTheDocument(); - expect(screen.getByText('Finetuning Type')).toBeInTheDocument(); + expect(screen.getByText('Fine-tuning Type')).toBeInTheDocument(); expect(screen.getByText(getFormattedTrainingType('lora'))).toBeInTheDocument(); expect(screen.getByText('Training Options')).toBeInTheDocument(); diff --git a/web/packages/studio/src/components/sidePanels/CustomizationConfigSidePanel/index.tsx b/web/packages/studio/src/components/sidePanels/CustomizationConfigSidePanel/index.tsx index 2300ac649b..640f633bee 100644 --- a/web/packages/studio/src/components/sidePanels/CustomizationConfigSidePanel/index.tsx +++ b/web/packages/studio/src/components/sidePanels/CustomizationConfigSidePanel/index.tsx @@ -109,7 +109,7 @@ export const CustomizationConfigSidePanel: FC = ({ Configuration Snapshot - + { useModelCustomizationEligibility(buildModel({ fileset: 'ws/my-fs' })) ); expect(result.current.canFineTune).toBe(true); - expect(result.current.canCustomize).toBe(true); }); it('canFineTune=false when model has no fileset', () => { const { result } = renderHook(() => useModelCustomizationEligibility(buildModel())); expect(result.current.canFineTune).toBe(false); - expect(result.current.canCustomize).toBe(false); }); it('canFineTune=false when no model is given', () => { diff --git a/web/packages/studio/src/hooks/useModelCustomizationEligibility/index.ts b/web/packages/studio/src/hooks/useModelCustomizationEligibility/index.ts index c7b6a885e7..d20625c74e 100644 --- a/web/packages/studio/src/hooks/useModelCustomizationEligibility/index.ts +++ b/web/packages/studio/src/hooks/useModelCustomizationEligibility/index.ts @@ -13,7 +13,6 @@ export const canFineTuneModel = (model: ModelEntity | null | undefined): boolean export interface ModelCustomizationEligibility { canFineTune: boolean; - canCustomize: boolean; isLoading: boolean; } @@ -30,7 +29,6 @@ export const useModelCustomizationEligibility = ( return { canFineTune, - canCustomize: canFineTune, isLoading: false, }; }; diff --git a/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.test.tsx b/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.test.tsx index e2fc45a18b..6f3d23ef41 100644 --- a/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.test.tsx +++ b/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.test.tsx @@ -356,17 +356,17 @@ describe('WorkspaceBaseModelsRoute customizable filter gating', () => { suppressConsoleError('was not wrapped in act'); }); - it('hides the Customizable checkbox when customizer is disabled', () => { + it('hides the Fine-tunable checkbox when customizer is disabled', () => { render( ); - expect(screen.queryByRole('checkbox', { name: 'Customizable' })).not.toBeInTheDocument(); + expect(screen.queryByRole('checkbox', { name: 'Fine-tunable' })).not.toBeInTheDocument(); }); - it('shows the Customizable checkbox when customizer is enabled', async () => { + it('shows the Fine-tunable checkbox when customizer is enabled', async () => { mockEnvironment.customizerEnabled = true; render( @@ -375,7 +375,7 @@ describe('WorkspaceBaseModelsRoute customizable filter gating', () => { ); - expect(await screen.findByRole('checkbox', { name: 'Customizable' })).toBeInTheDocument(); + expect(await screen.findByRole('checkbox', { name: 'Fine-tunable' })).toBeInTheDocument(); }); }); diff --git a/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.tsx b/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.tsx index 92174d97e2..bbfdde891e 100644 --- a/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.tsx +++ b/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.tsx @@ -41,6 +41,7 @@ import { CustomizeModelButton } from '@studio/components/dataViews/CustomModelsD import { ModelPanel, ModelPanelTab } from '@studio/components/sidePanels/ModelPanels/ModelPanel'; import { VirtualizedCardGrid } from '@studio/components/VirtualizedCardGrid'; import { CUSTOMIZER_ENABLED } from '@studio/constants/environment'; +import { canFineTuneModel } from '@studio/hooks/useModelCustomizationEligibility'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; import { getWorkspaceBaseModelsRoute } from '@studio/routes/utils'; @@ -57,10 +58,15 @@ const SORT_OPTIONS = [ const TAB_SEARCH_PARAM = 'tab'; -const CUSTOMIZABLE_FILTER_ID = 'customizable'; +/** + * Serialized into the `filters` search param, so the value is frozen for URL + * compatibility even though the UI now says "fine-tunable" throughout. Renaming + * it would break links users have already bookmarked or shared. + */ +const FINE_TUNABLE_FILTER_ID = 'customizable'; const FINE_TUNABLE_KEY = 'fine_tunable'; -type CustomizableFilterState = Partial>; +type FineTunableFilterState = Partial>; /** * Column definitions used solely for filter metadata. The columns are never rendered as a table; @@ -70,18 +76,18 @@ type CustomizableFilterState = Partial>; const makeFilterColumns: ComponentProps>['makeColumns'] = ({ accessor, }) => [ - // Customizable filtering depends on Customizer capabilities, so hide both the + // Fine-tunable filtering depends on Customizer capabilities, so hide both the // column filter and toolbar checkbox while Customizer is launch-disabled. ...(CUSTOMIZER_ENABLED ? [ accessor(() => '', { - id: CUSTOMIZABLE_FILTER_ID, - header: 'Customizable', + id: FINE_TUNABLE_FILTER_ID, + header: 'Fine-tunable', enableSorting: false, meta: { filter: { type: 'multi-select', - label: 'Customizable', + label: 'Fine-tunable', options: [{ value: FINE_TUNABLE_KEY, label: 'Fine-tunable' }], }, }, @@ -114,7 +120,7 @@ export const WorkspaceBaseModelsRoute: FC = () => { }); const dataViewState = useStudioDataViewState< - Partial & { [CUSTOMIZABLE_FILTER_ID]?: CustomizableFilterState } + Partial & { [FINE_TUNABLE_FILTER_ID]?: FineTunableFilterState } >({ defaultSort: [{ id: 'name', desc: false }], }); @@ -127,23 +133,23 @@ export const WorkspaceBaseModelsRoute: FC = () => { const nameSearch = dataViewState.apiFilter.searchText; const allColumnFilters = dataViewState.apiFilter.filter; - const customizableFilter = CUSTOMIZER_ENABLED - ? allColumnFilters?.[CUSTOMIZABLE_FILTER_ID] + const fineTunableFilter = CUSTOMIZER_ENABLED + ? allColumnFilters?.[FINE_TUNABLE_FILTER_ID] : undefined; // Strip the synthetic `customizable` filter from the API filter — the backend doesn't know about it. const apiColumnFilters = useMemo(() => { if (!allColumnFilters) return undefined; const rest = { ...allColumnFilters }; - delete rest[CUSTOMIZABLE_FILTER_ID]; + delete rest[FINE_TUNABLE_FILTER_ID]; return Object.keys(rest).length > 0 ? (rest as Partial) : undefined; }, [allColumnFilters]); - const customizableFilterActive = !!( - customizableFilter && Object.keys(customizableFilter).length > 0 + const fineTunableFilterActive = !!( + fineTunableFilter && Object.keys(fineTunableFilter).length > 0 ); - const hasActiveFilters = !!nameSearch || !!apiColumnFilters || customizableFilterActive; + const hasActiveFilters = !!nameSearch || !!apiColumnFilters || fineTunableFilterActive; const filter = useMemo(() => { if (!nameSearch && !apiColumnFilters) return undefined; @@ -172,41 +178,41 @@ export const WorkspaceBaseModelsRoute: FC = () => { }); const visibleModels = useMemo(() => { - if (!customizableFilter?.[FINE_TUNABLE_KEY]) return models; - return models.filter((model) => Boolean(model.fileset)); - }, [models, customizableFilter]); + if (!fineTunableFilter?.[FINE_TUNABLE_KEY]) return models; + return models.filter(canFineTuneModel); + }, [models, fineTunableFilter]); - const liveCustomizableFilter = CUSTOMIZER_ENABLED - ? (dataViewState.columnFiltering.state.find((f) => f.id === CUSTOMIZABLE_FILTER_ID)?.value as - | CustomizableFilterState + const liveFineTunableFilter = CUSTOMIZER_ENABLED + ? (dataViewState.columnFiltering.state.find((f) => f.id === FINE_TUNABLE_FILTER_ID)?.value as + | FineTunableFilterState | undefined) : undefined; - const customizableChecked = !!liveCustomizableFilter?.[FINE_TUNABLE_KEY]; + const fineTunableChecked = !!liveFineTunableFilter?.[FINE_TUNABLE_KEY]; - const handleCustomizableToggle = (checked: boolean) => { + const handleFineTunableToggle = (checked: boolean) => { dataViewState.columnFiltering.set((prev) => { - const others = prev.filter((f) => f.id !== CUSTOMIZABLE_FILTER_ID); + const others = prev.filter((f) => f.id !== FINE_TUNABLE_FILTER_ID); if (!checked) return others; return [ ...others, { - id: CUSTOMIZABLE_FILTER_ID, + id: FINE_TUNABLE_FILTER_ID, value: { [FINE_TUNABLE_KEY]: true }, }, ]; }); }; - const isSweepingForCustomizable = - customizableChecked && visibleModels.length === 0 && hasNextPage && !isFetchNextPageError; + const isSweepingForFineTunable = + fineTunableChecked && visibleModels.length === 0 && hasNextPage && !isFetchNextPageError; // In the rare case where the user is filtering for customizable models and there are no visible models on the first page, // fetch the next page here because the table won't render the virutalized cards, preventing a refetch from happening. useEffect(() => { - if (isSweepingForCustomizable && !isFetchingNextPage) { + if (isSweepingForFineTunable && !isFetchingNextPage) { void fetchNextPage(); } - }, [fetchNextPage, isSweepingForCustomizable, isFetchingNextPage]); + }, [fetchNextPage, isSweepingForFineTunable, isFetchingNextPage]); const modelInList = useMemo( () => !!modelNameFromPath && models.some((m) => m.name === modelNameFromPath), @@ -326,12 +332,12 @@ export const WorkspaceBaseModelsRoute: FC = () => { > handleCustomizableToggle(!!checked)} + checked={fineTunableChecked} + slotLabel="Fine-tunable" + onCheckedChange={(checked) => handleFineTunableToggle(!!checked)} /> @@ -352,7 +358,7 @@ export const WorkspaceBaseModelsRoute: FC = () => { data: visibleModels, totalCount: visibleModels.length, requestStatus: - isLoading || isSweepingForCustomizable + isLoading || isSweepingForFineTunable ? 'loading' : isError || isFetchNextPageError ? 'error' @@ -391,7 +397,7 @@ export const WorkspaceBaseModelsRoute: FC = () => { handleOpenPanel(model)} /> )}