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} + /> { + 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 19478564b6..d109cddd25 100644 --- a/web/packages/studio/src/util/buildSuggestedModelOptions.ts +++ b/web/packages/studio/src/util/buildSuggestedModelOptions.ts @@ -11,6 +11,14 @@ const isSuggested = (name: string): boolean => { 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,30 @@ 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 seenValues = new Set(); + const base = models + .filter((m) => isLlmCandidate(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 })); - 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);