From abcb215096357e358d3e7ceb884b74dfeec9126e Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 12 Jun 2026 13:12:19 -0700 Subject: [PATCH 1/2] feat(studio): choose model when creating an example agent The Create Example Agent button now opens a modal with a searchable model picker (Suggested/All groups) defaulting to the suggested Nemotron model, instead of silently auto-picking. CreateExampleAgentModal owns the model fetch, the picker, and creation. - buildSuggestedModelOptions no longer lists suggested models twice (duplicate Select option values caused a KUI key collision). - Example-agent NAT config matches the canonical calculator-agent.yml in plugins/nemo-agents (dropped the extra nemo_trace telemetry block). ASTD-245 Signed-off-by: mschwab --- .../CreateExampleAgentModal/index.tsx | 205 ++++++++++++++++++ .../agents/AgentsListRoute/index.spec.tsx | 163 +++++++------- .../routes/agents/AgentsListRoute/index.tsx | 139 ++---------- .../src/util/buildSuggestedModelOptions.ts | 31 +-- 4 files changed, 326 insertions(+), 212 deletions(-) create mode 100644 web/packages/studio/src/routes/agents/AgentsListRoute/CreateExampleAgentModal/index.tsx diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/CreateExampleAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/CreateExampleAgentModal/index.tsx new file mode 100644 index 0000000000..9f4de4953e --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/CreateExampleAgentModal/index.tsx @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { zodResolver } from '@hookform/resolvers/zod'; +import { ControlledSearchableSelect } from '@nemo/common/src/components/form/ControlledSearchableSelect'; +import { FormModal, type FormModalProps } from '@nemo/common/src/components/FormModal'; +import { useToast } from '@nemo/common/src/providers/toast/useToast'; +import { useAgentsCreateAgent } from '@nemo/sdk/generated/agents/api'; +import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; +import { useModelsListModels } from '@nemo/sdk/generated/platform/api'; +import { getErrorMessage } from '@studio/api/common/utils'; +import { + hasShownExampleAgentIntro, + markAgentWalkthroughPending, + markExampleAgentIntroShown, +} from '@studio/components/sidePanels/AgentPanels/AgentPanel/walkthroughStorage'; +import { DEFAULT_LARGE_PAGE_SIZE } from '@studio/constants/constants'; +import { getAgentDetailRoute, getAgentsListRoute } from '@studio/routes/utils'; +import { + buildSuggestedModelOptions, + pickDefaultModelName, + SUGGESTED_MODEL_GROUP_LABELS, +} from '@studio/util/buildSuggestedModelOptions'; +import { type FC, useEffect, useRef } from 'react'; +import { type SubmitHandler, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import { z } from 'zod'; + +const EXAMPLE_AGENT_DESCRIPTION = 'A ReAct agent with a calculator and datetime tool.'; + +const EXAMPLE_AGENT_NAME_PREFIX = 'calculator-demo-agent'; + +const buildExampleAgentName = (): string => + `${EXAMPLE_AGENT_NAME_PREFIX}-${Math.random().toString(36).slice(2, 8)}`; + +const isExampleAgentName = (name: string): boolean => name.startsWith(EXAMPLE_AGENT_NAME_PREFIX); + +// model_name is concrete: the service doesn't resolve ${NEMO_DEFAULT_MODEL} (only the CLI does). +const buildExampleAgentConfig = (modelName: string): Record => ({ + function_groups: { + calculator: { _type: 'calculator' }, + }, + functions: { + current_datetime: { _type: 'current_datetime' }, + }, + llms: { + llm: { + _type: 'openai', + api_key: 'not-used', // platform overrides at deploy time + model_name: modelName, + temperature: 0, + }, + }, + workflow: { + _type: 'react_agent', + tool_names: ['calculator', 'current_datetime'], + llm_name: 'llm', + verbose: false, + parse_agent_response_max_retries: 3, + use_native_tool_calling: true, + }, +}); + +const exampleAgentFormSchema = z.object({ + modelName: z.string().min(1, 'Model is required'), +}); + +type ExampleAgentFormData = z.infer; + +interface CreateExampleAgentModalProps extends Pick { + workspace: string; + existingAgents: Agent[]; +} + +export const CreateExampleAgentModal: FC = ({ + open, + onClose, + workspace, + existingAgents, +}) => { + const toast = useToast(); + const navigate = useNavigate(); + + const { data: modelsPage, isLoading: isLoadingModels } = useModelsListModels( + workspace, + { page_size: DEFAULT_LARGE_PAGE_SIZE }, + { query: { enabled: open && !!workspace } } + ); + const models = modelsPage?.data ?? []; + const modelOptions = buildSuggestedModelOptions(models); + + const { + mutateAsync: createAgent, + error: createError, + isPending, + reset: resetMutation, + } = useAgentsCreateAgent({ + mutation: { + onSuccess: (agent) => { + toast.success(`Agent "${agent.name}" created`); + const priorExampleAgentExists = existingAgents.some( + (existing) => + !!existing.name && existing.name !== agent.name && isExampleAgentName(existing.name) + ); + const onboard = !!agent.name && !hasShownExampleAgentIntro() && !priorExampleAgentExists; + if (onboard && agent.name) { + markExampleAgentIntroShown(); + markAgentWalkthroughPending(agent.name); + } + resetAndClose(); + navigate( + onboard && agent.name + ? getAgentDetailRoute(workspace, agent.name) + : getAgentsListRoute(workspace) + ); + }, + }, + }); + + const { + control, + reset: resetForm, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(exampleAgentFormSchema), + defaultValues: { modelName: '' }, + disabled: isPending, + mode: 'onChange', + }); + + const seededRef = useRef(false); + useEffect(() => { + if (!open) { + seededRef.current = false; + resetForm({ modelName: '' }); + return; + } + if (seededRef.current) return; + const defaultModel = pickDefaultModelName(models); + if (defaultModel) { + resetForm({ modelName: defaultModel }); + seededRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, modelsPage, resetForm]); + + const reset = () => { + resetMutation(); + resetForm({ modelName: '' }); + }; + + const resetAndClose = () => { + reset(); + onClose(); + }; + + const onSubmit: SubmitHandler = async (formData) => { + try { + await createAgent({ + workspace, + data: { + name: buildExampleAgentName(), + description: EXAMPLE_AGENT_DESCRIPTION, + config: buildExampleAgentConfig(formData.modelName), + }, + }); + } catch { + // surfaced via errorText + } + }; + + const errorMessage = createError + ? getErrorMessage(createError as Error, 'Failed to create example agent') + : undefined; + + return ( + + + + ); +}; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/index.spec.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/index.spec.tsx index 3742c35301..6516ea2766 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/index.spec.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/index.spec.tsx @@ -11,6 +11,7 @@ import { server } from '@studio/mocks/node'; import { AgentsListRoute } from '@studio/routes/agents/AgentsListRoute'; import { getAgentsListRoute } from '@studio/routes/utils'; import { renderRoute, screen, waitFor } from '@studio/tests/util/render'; +import { within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -46,15 +47,14 @@ const renderList = () => ], }); -// Click the button as soon as it's in the DOM; the handler queues the create if models -// are still loading and executes once they settle. -const clickCreateOnceReady = async (user: ReturnType) => { - const button = await screen.findByRole('button', { name: 'Create Example Agent' }); - await user.click(button); +const openModal = async (user: ReturnType): Promise => { + await user.click(await screen.findByRole('button', { name: 'Create Example Agent' })); + const dialog = await screen.findByRole('dialog'); + await within(dialog).findByRole('combobox'); + return dialog; }; describe('AgentsListRoute', () => { - // Opening the sidepanel + intro tour is gated on a per-session flag; isolate it. beforeEach(() => sessionStorage.clear()); it('renders the page shell', async () => { @@ -65,9 +65,20 @@ describe('AgentsListRoute', () => { ).toBeInTheDocument(); }); - it('creates the calculator example agent in one click and navigates to it', async () => { + it('opens the modal with the suggested model preselected', async () => { const user = userEvent.setup(); mockModels(['nvidia-nemotron-nano-9b-v2']); + renderList(); + + const dialog = await openModal(user); + await waitFor(() => + expect(within(dialog).getByRole('combobox')).toHaveTextContent('nvidia-nemotron-nano-9b-v2') + ); + }); + + it('creates the example agent with the default suggested model and onboards (navigates)', async () => { + const user = userEvent.setup(); + mockModels(['meta-llama-3-1-70b-instruct', 'nvidia-nemotron-super-49b']); let captured: { name?: string; description?: string; config?: Record } = {}; server.use( @@ -78,17 +89,16 @@ describe('AgentsListRoute', () => { ); renderList(); + const dialog = await openModal(user); + await waitFor(() => + expect(within(dialog).getByRole('combobox')).toHaveTextContent('nvidia-nemotron-super-49b') + ); + await user.click(within(dialog).getByRole('button', { name: 'Create' })); - await clickCreateOnceReady(user); - - // Navigated to the new agent's detail page on success. expect(await screen.findByText('Agent detail page')).toBeInTheDocument(); - // Unique, registry-safe name so repeated clicks don't collide. expect(captured.name).toMatch(/^calculator-demo-agent-[a-z0-9]{6}$/); expect(captured.description).toBeTruthy(); - - // Calculator NAT config: ReAct workflow + calculator function group + datetime tool. const config = captured.config as { workflow: { _type: string; tool_names: string[]; use_native_tool_calling: boolean }; function_groups: Record; @@ -100,14 +110,62 @@ describe('AgentsListRoute', () => { expect(config.workflow.use_native_tool_calling).toBe(true); expect(config.function_groups.calculator._type).toBe('calculator'); expect(config.functions.current_datetime._type).toBe('current_datetime'); - // A concrete workspace model is chosen (service does not resolve ${NEMO_DEFAULT_MODEL}). - expect(config.llms.llm.model_name).toBe('nvidia-nemotron-nano-9b-v2'); + expect(config.llms.llm.model_name).toBe('nvidia-nemotron-super-49b'); + }); + + it('lets the user pick a different model', async () => { + const user = userEvent.setup(); + mockModels(['nvidia-nemotron-super-49b', 'meta-llama-3-1-70b-instruct']); + + let modelName: string | undefined; + server.use( + http.post(CREATE_AGENT_URL, async ({ request, params }) => { + const body = (await request.json()) as { + config: { llms: { llm: { model_name: string } } }; + }; + modelName = body.config.llms.llm.model_name; + return HttpResponse.json({ + name: 'calculator-demo-agent-abc123', + workspace: params['workspace'], + }); + }) + ); + + renderList(); + const dialog = await openModal(user); + await waitFor(() => + expect(within(dialog).getByRole('combobox')).toHaveTextContent('nvidia-nemotron-super-49b') + ); + + await user.click(within(dialog).getByRole('combobox')); + await user.click(await screen.findByRole('option', { name: 'meta-llama-3-1-70b-instruct' })); + await user.click(within(dialog).getByRole('button', { name: 'Create' })); + + await waitFor(() => expect(modelName).toBe('meta-llama-3-1-70b-instruct')); + }); + + it('excludes non-chat models from the picker', async () => { + const user = userEvent.setup(); + mockModels(['nvidia-nv-embedqa-e5-v5', 'nvidia-nemotron-nano-9b-v2']); + renderList(); + + const dialog = await openModal(user); + await waitFor(() => + expect(within(dialog).getByRole('combobox')).toHaveTextContent('nvidia-nemotron-nano-9b-v2') + ); + await user.click(within(dialog).getByRole('combobox')); + + expect( + await screen.findByRole('option', { name: 'nvidia-nemotron-nano-9b-v2' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('option', { name: 'nvidia-nv-embedqa-e5-v5' }) + ).not.toBeInTheDocument(); }); - it('does not reopen the sidepanel/intro for a later example agent in the same session', async () => { + it('does not onboard for a later example agent in the same session', async () => { const user = userEvent.setup(); mockModels(['nvidia-nemotron-nano-9b-v2']); - // An example agent intro was already shown earlier this session. markExampleAgentIntroShown(); let created = false; @@ -120,18 +178,20 @@ describe('AgentsListRoute', () => { ); renderList(); - await clickCreateOnceReady(user); + const dialog = await openModal(user); + await waitFor(() => + expect(within(dialog).getByRole('combobox')).toHaveTextContent('nvidia-nemotron-nano-9b-v2') + ); + await user.click(within(dialog).getByRole('button', { name: 'Create' })); - // Creation still happens, but we stay on the list — no navigation to the detail panel. await waitFor(() => expect(created).toBe(true)); expect(screen.queryByText('Agent detail page')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Create Example Agent' })).toBeInTheDocument(); }); - it('skips onboarding when an example agent already exists in the workspace', async () => { + it('does not onboard when an example agent already exists in the workspace', async () => { const user = userEvent.setup(); mockModels(['nvidia-nemotron-nano-9b-v2']); - // Fresh session, but a calculator-demo agent already exists — a returning user. server.use( http.get(CREATE_AGENT_URL, () => HttpResponse.json({ @@ -159,63 +219,19 @@ describe('AgentsListRoute', () => { ); renderList(); - // Wait until the existing example agent is visible so the agents query has settled. await screen.findByText('calculator-demo-agent-abc123'); - await clickCreateOnceReady(user); + const dialog = await openModal(user); + await waitFor(() => + expect(within(dialog).getByRole('combobox')).toHaveTextContent('nvidia-nemotron-nano-9b-v2') + ); + await user.click(within(dialog).getByRole('button', { name: 'Create' })); - // No onboarding: stays on the list despite the fresh session. await waitFor(() => expect(created).toBe(true)); expect(screen.queryByText('Agent detail page')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Create Example Agent' })).toBeInTheDocument(); }); - it('prefers a suggested Nemotron model over a non-suggested one', async () => { - const user = userEvent.setup(); - // "All Models" order puts the non-Nemotron model first; selection must still - // prefer the suggested Nemotron model rather than blindly taking the first. - mockModels(['meta-llama-3-1-70b-instruct', 'nvidia-nemotron-super-49b']); - - let modelName: string | undefined; - server.use( - http.post(CREATE_AGENT_URL, async ({ request, params }) => { - const body = (await request.json()) as { - config: { llms: { llm: { model_name: string } } }; - }; - modelName = body.config.llms.llm.model_name; - return HttpResponse.json({ - name: 'calculator-demo-agent-abc123', - workspace: params['workspace'], - }); - }) - ); - - renderList(); - await clickCreateOnceReady(user); - - await waitFor(() => expect(modelName).toBe('nvidia-nemotron-super-49b')); - }); - - it('does not auto-select a non-LLM model; surfaces an error instead', async () => { - const user = userEvent.setup(); - // Workspace has only an embedding model — not a usable agent LLM. - mockModels(['nvidia-nv-embedqa-e5-v5']); - - let createCalled = false; - server.use( - http.post(CREATE_AGENT_URL, () => { - createCalled = true; - return HttpResponse.json({ name: 'unexpected', workspace }); - }) - ); - - renderList(); - await clickCreateOnceReady(user); - - expect(await screen.findByText(/No usable chat model in this workspace/i)).toBeInTheDocument(); - expect(createCalled).toBe(false); - }); - - it('does not create an agent and surfaces an error when the workspace has no models', async () => { + it('does not create when the workspace has no models', async () => { const user = userEvent.setup(); mockModels([]); @@ -228,9 +244,10 @@ describe('AgentsListRoute', () => { ); renderList(); - await clickCreateOnceReady(user); + const dialog = await openModal(user); + await user.click(within(dialog).getByRole('button', { name: 'Create' })); - expect(await screen.findByText(/No usable chat model in this workspace/i)).toBeInTheDocument(); + await waitFor(() => expect(within(dialog).getByRole('combobox')).toBeInTheDocument()); expect(createCalled).toBe(false); }); }); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx index 672b606049..5836a16872 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx @@ -1,84 +1,32 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { LoadingButton } from '@nemo/common/src/components/LoadingButton'; -import { useToast } from '@nemo/common/src/providers/toast/useToast'; -import { useAgentsCreateAgent } from '@nemo/sdk/generated/agents/api'; import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; -import { useModelsListModels } from '@nemo/sdk/generated/platform/api'; -import { PageHeader, Stack } from '@nvidia/foundations-react-core'; -import { getErrorMessage } from '@studio/api/common/utils'; +import { Button, PageHeader, Stack } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; import { AgentsTable, type AgentTableRow } from '@studio/components/dataViews/AgentsDataView'; import { AgentPanel, type AgentPanelTab, } from '@studio/components/sidePanels/AgentPanels/AgentPanel'; -import { - hasShownExampleAgentIntro, - markAgentWalkthroughPending, - markExampleAgentIntroShown, -} from '@studio/components/sidePanels/AgentPanels/AgentPanel/walkthroughStorage'; -import { DEFAULT_LARGE_PAGE_SIZE } from '@studio/constants/constants'; import { ROUTE_PARAMS } from '@studio/constants/routes'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; import { CreateDeploymentModal } from '@studio/routes/agents/AgentDeploymentsListRoute/CreateDeploymentModal'; +import { CreateExampleAgentModal } from '@studio/routes/agents/AgentsListRoute/CreateExampleAgentModal'; import { getAgentDetailRoute, getAgentsListRoute } from '@studio/routes/utils'; -import { pickDefaultModelName } from '@studio/util/buildSuggestedModelOptions'; -import { type FC, useCallback, useEffect, useState } from 'react'; +import { type FC, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; const TAB_SEARCH_PARAM = 'tab'; -const EXAMPLE_AGENT_DESCRIPTION = 'A ReAct agent with a calculator and datetime tool.'; - -const EXAMPLE_AGENT_NAME_PREFIX = 'calculator-demo-agent'; - -const buildExampleAgentName = (): string => - `${EXAMPLE_AGENT_NAME_PREFIX}-${Math.random().toString(36).slice(2, 8)}`; - -const isExampleAgentName = (name: string): boolean => name.startsWith(EXAMPLE_AGENT_NAME_PREFIX); - -// model_name is concrete: the service doesn't resolve ${NEMO_DEFAULT_MODEL} (only the CLI does). -const buildExampleAgentConfig = (modelName: string): Record => ({ - function_groups: { - calculator: { _type: 'calculator' }, - }, - functions: { - current_datetime: { _type: 'current_datetime' }, - }, - llms: { - llm: { - _type: 'openai', - api_key: 'not-used', // platform overrides at deploy time - model_name: modelName, - temperature: 0, - }, - }, - workflow: { - _type: 'react_agent', - tool_names: ['calculator', 'current_datetime'], - llm_name: 'llm', - verbose: false, - parse_agent_response_max_retries: 3, - use_native_tool_calling: true, - }, - general: { - telemetry: { - tracing: { - nemo_trace: { _type: 'nemo_files', batch_size: 128 }, - }, - }, - }, -}); - export const AgentsListRoute: FC = () => { const workspace = useWorkspaceFromPath(); const navigate = useNavigate(); - const toast = useToast(); const [searchParams, setSearchParams] = useSearchParams(); const [createDeploymentAgent, setCreateDeploymentAgent] = useState(null); + const [isCreateExampleOpen, setCreateExampleOpen] = useState(false); + const [loadedAgents, setLoadedAgents] = useState([]); const { [ROUTE_PARAMS.agentName]: agentNameParam } = useParams<{ agentName?: string }>(); const tabFromUrl: AgentPanelTab = (searchParams.get(TAB_SEARCH_PARAM) as AgentPanelTab) || 'agent-details'; @@ -87,69 +35,6 @@ export const AgentsListRoute: FC = () => { items: [{ slotLabel: 'Agents' }], }); - const { data: modelsPage, isLoading: isLoadingModels } = useModelsListModels( - workspace, - { page_size: DEFAULT_LARGE_PAGE_SIZE }, - { query: { enabled: !!workspace } } - ); - - const [loadedAgents, setLoadedAgents] = useState([]); - - const { mutateAsync: createAgent, isPending } = useAgentsCreateAgent({ - mutation: { - onSuccess: (agent) => { - toast.success(`Agent "${agent.name}" created`); - const priorExampleAgentExists = loadedAgents.some( - (existing) => - !!existing.name && existing.name !== agent.name && isExampleAgentName(existing.name) - ); - if (agent.name && !hasShownExampleAgentIntro() && !priorExampleAgentExists) { - markExampleAgentIntroShown(); - markAgentWalkthroughPending(agent.name); - navigate(getAgentDetailRoute(workspace, agent.name)); - } else { - navigate(getAgentsListRoute(workspace)); - } - }, - onError: (err) => { - toast.error(getErrorMessage(err as Error, 'Failed to create example agent')); - }, - }, - }); - - const [pendingCreate, setPendingCreate] = useState(false); - - const doCreate = useCallback(() => { - const modelName = pickDefaultModelName(modelsPage?.data ?? []); - if (!modelName) { - toast.error('No usable chat model in this workspace. Add a model before creating an agent.'); - return; - } - void createAgent({ - workspace, - data: { - name: buildExampleAgentName(), - description: EXAMPLE_AGENT_DESCRIPTION, - config: buildExampleAgentConfig(modelName), - }, - }).catch(() => {}); - }, [modelsPage, workspace, createAgent, toast]); - - // If models finished loading while a create was queued, execute it now. - useEffect(() => { - if (!pendingCreate || isLoadingModels) return; - setPendingCreate(false); - doCreate(); - }, [pendingCreate, isLoadingModels, doCreate]); - - const handleCreateExample = () => { - if (isLoadingModels) { - setPendingCreate(true); - return; - } - doCreate(); - }; - const handleOpenPanel = (agent: AgentTableRow) => { navigate(`${getAgentDetailRoute(workspace, agent.name)}?${TAB_SEARCH_PARAM}=agent-details`, { replace: true, @@ -168,13 +53,9 @@ export const AgentsListRoute: FC = () => { slotHeading="Agents" slotDescription="View and manage AI agents and their deployments." slotActions={ - + } /> { onAgentsLoaded={setLoadedAgents} /> + setCreateExampleOpen(false)} + workspace={workspace} + existingAgents={loadedAgents} + /> { return lower.includes('nvidia') && lower.includes('nemotron'); }; +// Not chat LLMs — never valid as an agent's LLM, so excluded from the picker entirely. +const NON_LLM_TERMS = ['embed', 'rerank', 'reward', 'safeguard', 'safety', 'parse', 'vl', 'guard']; + +const isLlmCandidate = (name: string): boolean => { + const lower = name.toLowerCase(); + return !NON_LLM_TERMS.some((term) => lower.includes(term)); +}; + export interface ModelListEntry { name: string; } @@ -22,27 +30,24 @@ export const SUGGESTED_MODEL_GROUP_LABELS = { /** * Build the option set for the Studio model picker with a Suggested / All split. - * Suggested = NVIDIA Nemotron models, excluding llama/safety/embed/vl/reward/parse - * variants. Same model can appear in both groups; the picker dedupes by composite - * key, so the Suggested row stays clickable. + * Only chat-LLM candidates are listed (embedding/rerank/safety/vl/... are excluded). + * Suggested = NVIDIA Nemotron models. Each model appears once (suggested models are + * not repeated under "All") so option values stay unique. */ export const buildSuggestedModelOptions = (models: ModelListEntry[]): SelectItemOption[] => { - const base = models.map((m) => ({ value: m.name, label: m.name })); + const base = models + .filter((m) => isLlmCandidate(m.name)) + .map((m) => ({ value: m.name, label: m.name })); const suggested = base .filter((o) => isSuggested(o.value)) .map((o) => ({ ...o, group: 'suggested' as const })); - const all = base.map((o) => ({ ...o, group: 'all' as const })); + const suggestedValues = new Set(suggested.map((o) => o.value)); + const all = base + .filter((o) => !suggestedValues.has(o.value)) + .map((o) => ({ ...o, group: 'all' as const })); return [...suggested, ...all]; }; -// Not chat LLMs — must never be auto-selected as an agent's LLM. -const NON_LLM_TERMS = ['embed', 'rerank', 'reward', 'safeguard', 'safety', 'parse', 'vl', 'guard']; - -const isLlmCandidate = (name: string): boolean => { - const lower = name.toLowerCase(); - return !NON_LLM_TERMS.some((term) => lower.includes(term)); -}; - export const pickDefaultModelName = (models: ModelListEntry[]): string | undefined => { const names = models.map((m) => m.name); return names.find(isSuggested) ?? names.find(isLlmCandidate); From 43b02114d8ab8a2ce0562fb81a1a90d4b3302b5a Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 12 Jun 2026 14:31:25 -0700 Subject: [PATCH 2/2] fix(studio): dedupe duplicate model names in suggested model options buildSuggestedModelOptions mapped option values straight from the model list, so duplicate model names produced duplicate option values and React key collisions within the suggested/all groups. Keep only the first occurrence of each name. Add unit coverage for the suggested/all split, non-LLM exclusion, dedup, and default-model selection. Addresses CodeRabbit review on PR #317. Signed-off-by: mschwab --- .../util/buildSuggestedModelOptions.spec.ts | 68 +++++++++++++++++++ .../src/util/buildSuggestedModelOptions.ts | 8 ++- 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 web/packages/studio/src/util/buildSuggestedModelOptions.spec.ts diff --git a/web/packages/studio/src/util/buildSuggestedModelOptions.spec.ts b/web/packages/studio/src/util/buildSuggestedModelOptions.spec.ts new file mode 100644 index 0000000000..70bee11bfe --- /dev/null +++ b/web/packages/studio/src/util/buildSuggestedModelOptions.spec.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + buildSuggestedModelOptions, + pickDefaultModelName, +} from '@studio/util/buildSuggestedModelOptions'; + +describe('buildSuggestedModelOptions', () => { + it('splits NVIDIA Nemotron models into the suggested group and the rest into all', () => { + const options = buildSuggestedModelOptions([ + { name: 'nvidia/nemotron-super' }, + { name: 'openai/gpt-4o' }, + ]); + expect(options).toEqual([ + { value: 'nvidia/nemotron-super', label: 'nvidia/nemotron-super', group: 'suggested' }, + { value: 'openai/gpt-4o', label: 'openai/gpt-4o', group: 'all' }, + ]); + }); + + it('excludes non-chat-LLM candidates (embedding/rerank/safety/vl/...)', () => { + const options = buildSuggestedModelOptions([ + { name: 'nvidia/embed-qa' }, + { name: 'nvidia/llama-guard' }, + { name: 'some/reranker' }, + { name: 'openai/gpt-4o' }, + ]); + expect(options.map((o) => o.value)).toEqual(['openai/gpt-4o']); + }); + + it('dedupes duplicate model names so option values stay unique', () => { + const options = buildSuggestedModelOptions([ + { name: 'nvidia/nemotron-super' }, + { name: 'nvidia/nemotron-super' }, + { name: 'openai/gpt-4o' }, + { name: 'openai/gpt-4o' }, + ]); + expect(options).toEqual([ + { value: 'nvidia/nemotron-super', label: 'nvidia/nemotron-super', group: 'suggested' }, + { value: 'openai/gpt-4o', label: 'openai/gpt-4o', group: 'all' }, + ]); + }); + + it('does not repeat a suggested model under all', () => { + const options = buildSuggestedModelOptions([{ name: 'nvidia/nemotron-super' }]); + expect(options).toEqual([ + { value: 'nvidia/nemotron-super', label: 'nvidia/nemotron-super', group: 'suggested' }, + ]); + }); +}); + +describe('pickDefaultModelName', () => { + it('prefers a suggested model when one exists', () => { + expect( + pickDefaultModelName([{ name: 'openai/gpt-4o' }, { name: 'nvidia/nemotron-super' }]) + ).toBe('nvidia/nemotron-super'); + }); + + it('falls back to the first chat-LLM candidate when none are suggested', () => { + expect(pickDefaultModelName([{ name: 'nvidia/embed-qa' }, { name: 'openai/gpt-4o' }])).toBe( + 'openai/gpt-4o' + ); + }); + + it('returns undefined when there are no usable models', () => { + expect(pickDefaultModelName([{ name: 'nvidia/embed-qa' }])).toBeUndefined(); + }); +}); diff --git a/web/packages/studio/src/util/buildSuggestedModelOptions.ts b/web/packages/studio/src/util/buildSuggestedModelOptions.ts index 8772767457..d109cddd25 100644 --- a/web/packages/studio/src/util/buildSuggestedModelOptions.ts +++ b/web/packages/studio/src/util/buildSuggestedModelOptions.ts @@ -35,9 +35,15 @@ export const SUGGESTED_MODEL_GROUP_LABELS = { * not repeated under "All") so option values stay unique. */ export const buildSuggestedModelOptions = (models: ModelListEntry[]): SelectItemOption[] => { + const seenValues = new Set(); const base = models .filter((m) => isLlmCandidate(m.name)) - .map((m) => ({ value: m.name, label: m.name })); + .map((m) => ({ value: m.name, label: m.name })) + .filter((o) => { + if (seenValues.has(o.value)) return false; + seenValues.add(o.value); + return true; + }); const suggested = base .filter((o) => isSuggested(o.value)) .map((o) => ({ ...o, group: 'suggested' as const }));