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
31 changes: 31 additions & 0 deletions web/packages/common/src/api/models/useModelEntity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { getPartsFromReference } from '@nemo/common/src/namedEntity';
import { useModelsGetModel } from '@nemo/sdk/generated/platform/api';
import type { ModelEntity } from '@nemo/sdk/generated/platform/schema';

export interface UseModelEntityOptions {
enabled?: boolean;
}

/**
* Resolves a single model URN to its entity.
*
* Companion to `useModelSearch`: a paged dropdown only holds the models it has loaded, so a
* selection restored from a URL or a form default has no entity attached. Callers that need
* fields off the entity — `model_providers`, adapters, deployment state — fetch just that one
* model instead of walking the catalogue to find it.
*
* Endpoint: GET /apis/models/v2/workspaces/{workspace}/models/{name}
*/
export const useModelEntity = (
modelUrn: string | null | undefined,
{ enabled = true }: UseModelEntityOptions = {}
): ModelEntity | undefined => {
const parts = modelUrn ? getPartsFromReference(modelUrn) : undefined;
const { data } = useModelsGetModel(parts?.workspace ?? '', parts?.name ?? '', undefined, {
query: { enabled: enabled && !!parts?.workspace && !!parts?.name },
});
return data;
};
118 changes: 118 additions & 0 deletions web/packages/common/src/api/models/useModelSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { useModelSearch } from '@nemo/common/src/api/models/useModelSearch';
import { modelsListModels } from '@nemo/sdk/generated/platform/api';
import type { ModelEntity, ModelEntitysPage } from '@nemo/sdk/generated/platform/schema';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';

vi.mock('@nemo/sdk/generated/platform/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nemo/sdk/generated/platform/api')>();
return { ...actual, modelsListModels: vi.fn() };
});

const mockListModels = vi.mocked(modelsListModels);

const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};

const makeModel = (name: string, overrides: Partial<ModelEntity> = {}): ModelEntity =>
({ id: name, name, workspace: 'ws1', ...overrides }) as ModelEntity;

const makePage = (data: ModelEntity[], page: number, totalPages: number): ModelEntitysPage =>
({ data, pagination: { page, total_pages: totalPages } }) as ModelEntitysPage;

const renderSearch = (options: Partial<Parameters<typeof useModelSearch>[0]> = {}) =>
renderHook(() => useModelSearch({ workspace: 'ws1', ...options }), { wrapper: createWrapper() });

beforeEach(() => {
mockListModels.mockReset();
});

describe('useModelSearch', () => {
it('stays idle while disabled', () => {
renderSearch({ enabled: false });
expect(mockListModels).not.toHaveBeenCalled();
});

it('stays idle without a workspace', () => {
renderHook(() => useModelSearch({ workspace: null }), { wrapper: createWrapper() });
expect(mockListModels).not.toHaveBeenCalled();
});

it('groups the first page and reports that more remain', async () => {
mockListModels.mockResolvedValue(makePage([makeModel('a'), makeModel('b')], 1, 2));

const { result } = renderSearch();

await waitFor(() => expect(result.current.groups).toHaveLength(1));
expect(result.current.groups[0].models.map((m) => m.name)).toEqual(['a', 'b']);
expect(result.current.hasMore).toBe(true);
});

it('sends the search term as a case-insensitive substring filter', async () => {
mockListModels.mockResolvedValue(makePage([makeModel('a')], 1, 1));
const { result } = renderSearch();
await waitFor(() => expect(mockListModels).toHaveBeenCalled());

act(() => result.current.onSearchChange(' llama '));

await waitFor(() =>
expect(mockListModels).toHaveBeenLastCalledWith(
'ws1',
expect.objectContaining({ filter: expect.objectContaining({ name: { $like: 'llama' } }) })
)
);
});

it('merges caller filters with the search term', async () => {
mockListModels.mockResolvedValue(makePage([makeModel('a')], 1, 1));

renderSearch({ filter: { lora_enabled: true } });

await waitFor(() =>
expect(mockListModels).toHaveBeenCalledWith(
'ws1',
expect.objectContaining({ filter: { lora_enabled: true } })
)
);
});

it('keeps paging when include filters a whole page down to nothing', async () => {
mockListModels
.mockResolvedValueOnce(makePage([makeModel('no-provider')], 1, 2))
.mockResolvedValueOnce(
makePage([makeModel('served', { model_providers: ['ws1/build'] })], 2, 2)
);

const { result } = renderSearch({ include: (model) => !!model.model_providers?.length });

await waitFor(() => expect(result.current.models.map((m) => m.name)).toEqual(['served']));
expect(mockListModels).toHaveBeenCalledTimes(2);
expect(result.current.hasMore).toBe(false);
});

it('appends the next page on demand', async () => {
mockListModels
.mockResolvedValueOnce(makePage([makeModel('a')], 1, 2))
.mockResolvedValueOnce(makePage([makeModel('b')], 2, 2));

const { result } = renderSearch();
await waitFor(() => expect(result.current.hasMore).toBe(true));

await act(async () => {
await result.current.onLoadMore();
});

await waitFor(() => expect(result.current.models.map((m) => m.name)).toEqual(['a', 'b']));
expect(result.current.hasMore).toBe(false);
});
});
131 changes: 131 additions & 0 deletions web/packages/common/src/api/models/useModelSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { WithFilterOperators } from '@nemo/common/src/api/filterOperators';
import { useModelsInfinite, type ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels';
import { groupModelsByWorkspace } from '@nemo/common/src/utils/models';
import {
type ModelEntity,
ModelEntitySortField,
type ModelEntityFilter,
} from '@nemo/sdk/generated/platform/schema';
import { useCallback, useEffect, useMemo, useState } from 'react';

/**
* Page size for search-as-you-type model lists. Small on purpose: the dropdown pulls the next
* page as the user scrolls, so the first page needs to arrive fast, not be complete.
*/
export const MODEL_SEARCH_PAGE_SIZE = 25;

export type ModelSearchFilter = WithFilterOperators<ModelEntityFilter>;

export interface UseModelSearchOptions {
/** Workspace to search. The query stays idle while this is null. */
workspace: string | null;
/** Extra filters merged into the request (e.g. `lora_enabled`, `base_model`). */
filter?: ModelSearchFilter;
sort?: ModelEntitySortField;
pageSize?: number;
enabled?: boolean;
/**
* Client-side predicate applied to every page — for conditions the API cannot express, such as
* "has a ready deployment" (`model_providers.length > 0`). The hook keeps paging while a page
* filters down to nothing, so an excluded page never stalls the list.
*/
include?: (model: ModelEntity) => boolean;
}

/**
* Props for `ModelSelectV2`, ready to spread. Every field lines up with a prop name so a caller
* that needs nothing custom is a single line.
*/
export interface ModelSearchProps {
groups: ModelWorkspaceGroup[];
loading: boolean;
onSearchChange: (search: string) => void;
onLoadMore: () => Promise<void>;
hasMore: boolean;
isLoadingMore: boolean;
}

export interface UseModelSearchResult extends ModelSearchProps {
models: ModelEntity[];
search: string;
error: Error | null;
}

/**
* Server-side model search with progressive paging — the counterpart to `useAllModels`, which
* walks every page up front. Filtering happens in the API and pages arrive as the user scrolls,
* so a workspace with thousands of models costs one small request at a time.
*
* @example
* const [open, setOpen] = useState(false);
* const models = useModelSearch({ workspace, enabled: open });
* return <ModelSelectV2 {...models} value={value} onValueChange={onChange} onOpenChange={setOpen} />;
*/
export const useModelSearch = ({
workspace,
filter,
sort = ModelEntitySortField.name,
pageSize = MODEL_SEARCH_PAGE_SIZE,
enabled = true,
include,
}: UseModelSearchOptions): UseModelSearchResult => {
const [search, setSearch] = useState('');

const query = useMemo(() => {
const trimmed = search.trim();
const merged: ModelSearchFilter = {
...filter,
...(trimmed ? { name: { $like: trimmed } } : {}),
};
return {
page_size: pageSize,
sort,
...(Object.keys(merged).length > 0 ? { filter: merged as ModelEntityFilter } : {}),
};
}, [filter, pageSize, search, sort]);

const isEnabled = enabled && !!workspace;
const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
useModelsInfinite({
workspace: workspace ?? undefined,
query,
queryOptions: { enabled: isEnabled },
});

const models = useMemo(() => {
const loaded = data?.pages.flatMap((page) => page.data ?? []) ?? [];
return include ? loaded.filter(include) : loaded;
}, [data?.pages, include]);

const groups = useMemo(() => groupModelsByWorkspace(models, { sort: true }), [models]);

const hasMore = !!hasNextPage;

const onLoadMore = useCallback(async () => {
if (!hasNextPage || isFetchingNextPage) return;
await fetchNextPage();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);

// `include` can empty a whole page, leaving the list with no rows to scroll and therefore no way
// to ask for the next one. Keep paging until something survives the filter.
useEffect(() => {
if (isEnabled && models.length === 0 && hasNextPage && !isFetchingNextPage && !isLoading) {
void fetchNextPage();
}
}, [fetchNextPage, hasNextPage, isEnabled, isFetchingNextPage, isLoading, models.length]);

return {
models,
groups,
search,
error,
loading: isLoading,
onSearchChange: setSearch,
onLoadMore,
hasMore,
isLoadingMore: isFetchingNextPage,
};
};
Loading
Loading