From 252a47d2f6a4249bb1a4abab6a319a2747bcdd39 Mon Sep 17 00:00:00 2001 From: Bukee Date: Sun, 2 Aug 2026 11:04:44 -0700 Subject: [PATCH 1/5] feat(setup): convert router tier provider and model fields to pickers/comboboxes --- .../src/views/setup/RouterSection.test.tsx | 125 +++++++++++++++++- frontend/src/views/setup/RouterSection.tsx | 106 ++++++++++++++- frontend/src/views/setup/SetupPage.test.tsx | 7 + 3 files changed, 233 insertions(+), 5 deletions(-) diff --git a/frontend/src/views/setup/RouterSection.test.tsx b/frontend/src/views/setup/RouterSection.test.tsx index de9d7a9e..7b537d10 100644 --- a/frontend/src/views/setup/RouterSection.test.tsx +++ b/frontend/src/views/setup/RouterSection.test.tsx @@ -1,8 +1,13 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' import { RouterSection } from './RouterSection' import type { Catalog } from './logic' +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn(), info: vi.fn() }, +})) + const STATUS = { hasConfig: true, llmConfigured: true, @@ -36,7 +41,37 @@ function catalogWithTiers(tiers: Record>): Catal } } +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const MOCK_MODELS = [ + { id: 'gpt-4o', name: 'GPT-4o', provider: 'openai', contextWindow: 128000, capabilities: ['chat', 'tools'] }, + { id: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'openai', contextWindow: 128000, capabilities: ['chat', 'tools'] }, + { id: 'gpt-image-1', name: 'GPT Image 1', provider: 'openai', contextWindow: 128000, capabilities: ['chat', 'vision'] }, + { id: 'claude-3-opus', name: 'Claude 3 Opus', provider: 'anthropic', contextWindow: 200000, capabilities: ['chat'] }, +] + +const mockRpc = { + waitForConnection: vi.fn().mockResolvedValue(undefined), + call: vi.fn((method) => { + if (method === 'models.list') { + return Promise.resolve(MOCK_MODELS) + } + return Promise.resolve({}) + }), +} + +vi.mock('@/app/providers', () => ({ + useRpc: () => mockRpc, +})) + function renderSection(catalog: Catalog, onSave = vi.fn()) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) const props = { catalog, status: STATUS, @@ -46,11 +81,19 @@ function renderSection(catalog: Catalog, onSave = vi.fn()) { onNext: vi.fn(), saving: false, } - const result = render() + const result = render( + + + , + ) return { ...result, rerenderCatalog: (nextCatalog: Catalog) => - result.rerender(), + result.rerender( + + + , + ), } } @@ -125,4 +168,80 @@ describe('RouterSection', () => { expect(screen.getByLabelText('image_model supports image')).toBeChecked() expect(screen.getByLabelText('image_model supports image')).toBeDisabled() }) + + it('updates datalist options when provider changes and filters image_model for vision capability', async () => { + const catalog = catalogWithTiers({ + c0: { provider: 'openai', model: 'gpt-4o-mini' }, + image_model: { provider: 'openai', model: 'gpt-image-1' }, + }) + catalog.providers = [ + { providerId: 'openai', label: 'OpenAI', runtimeSupported: true }, + { providerId: 'anthropic', label: 'Anthropic', runtimeSupported: true }, + ] + + renderSection(catalog) + + const c0ProviderSelect = screen.getByLabelText('c0 provider') + expect(c0ProviderSelect).toHaveValue('openai') + + // Wait for the datalist options to be loaded from the RPC call + await waitFor(() => { + const c0Datalist = document.getElementById('datalist-c0') as HTMLDataListElement + expect(c0Datalist).toBeInTheDocument() + const c0Options = Array.from(c0Datalist.options).map((opt) => opt.value) + expect(c0Options).toContain('gpt-4o') + }) + + const c0Datalist = document.getElementById('datalist-c0') as HTMLDataListElement + let c0Options = Array.from(c0Datalist.options).map((opt) => opt.value) + expect(c0Options).toContain('gpt-4o-mini') + expect(c0Options).toContain('gpt-image-1') + expect(c0Options).not.toContain('claude-3-opus') + + fireEvent.change(c0ProviderSelect, { target: { value: 'anthropic' } }) + expect(c0ProviderSelect).toHaveValue('anthropic') + + // Wait for options to update for the new provider + await waitFor(() => { + const c0Options = Array.from(c0Datalist.options).map((opt) => opt.value) + expect(c0Options).toContain('claude-3-opus') + }) + + c0Options = Array.from(c0Datalist.options).map((opt) => opt.value) + expect(c0Options).not.toContain('gpt-4o') + + const imageDatalist = document.getElementById('datalist-image_model') as HTMLDataListElement + expect(imageDatalist).toBeInTheDocument() + const imageOptions = Array.from(imageDatalist.options).map((opt) => opt.value) + expect(imageOptions).toContain('gpt-image-1') + expect(imageOptions).not.toContain('gpt-4o') + expect(imageOptions).not.toContain('gpt-4o-mini') + }) + + it('warns on unknown model ID on save', async () => { + const onSave = vi.fn() + const catalog = catalogWithTiers({ + c0: { provider: 'openai', model: 'unknown-model-id-123' }, + }) + catalog.providers = [ + { providerId: 'openai', label: 'OpenAI', runtimeSupported: true }, + ] + + renderSection(catalog, onSave) + + // Wait for the query to populate allModels so that validation runs on a loaded list + await waitFor(() => { + const c0Datalist = document.getElementById('datalist-c0') as HTMLDataListElement + expect(c0Datalist).toBeInTheDocument() + expect(c0Datalist.options.length).toBeGreaterThan(0) + }) + + fireEvent.click(screen.getByRole('button', { name: 'Save Router' })) + + expect(toast.warning).toHaveBeenCalledWith( + expect.stringContaining('Warning: Model ID not in catalog: unknown-model-id-123'), + expect.any(Object), + ) + expect(onSave).toHaveBeenCalled() + }) }) diff --git a/frontend/src/views/setup/RouterSection.tsx b/frontend/src/views/setup/RouterSection.tsx index 54fb3fc1..be290a60 100644 --- a/frontend/src/views/setup/RouterSection.tsx +++ b/frontend/src/views/setup/RouterSection.tsx @@ -3,6 +3,9 @@ // editable tier table. Save via onboarding.router.configure, gated on the // provider being saved (effective === configured). import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { toast } from 'sonner' +import { useRpc } from '@/app/providers' import { Button } from '@/components/ui/button' import { PanelHead, SetupCheckbox, SetupSelect } from './parts' import { @@ -25,6 +28,18 @@ import { const THINKING_LEVELS = ['', 'off', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh'] +interface ModelSpec { + id: string + name: string + provider: string + contextWindow: number + capabilities: string[] + pricing?: { + inputPer1k: number + outputPer1k: number + } +} + interface TierRowState { provider: string model: string @@ -54,6 +69,23 @@ export function RouterSection({ saving: boolean }) { const router = config.agentos_router || {} + const rpc = useRpc() + const providers = useMemo( + () => (catalog.providers || []).filter((p) => p.runtimeSupported), + [catalog.providers], + ) + + const modelsQuery = useQuery({ + queryKey: ['setup', 'models'], + queryFn: async () => { + await rpc.waitForConnection() + const data = await rpc.call('models.list', {}) + return (data as ModelSpec[]) ?? [] + }, + refetchOnWindowFocus: false, + }) + const allModels = Array.isArray(modelsQuery.data) ? modelsQuery.data : [] + const provider = effectiveProviderFn(status, config, draftProvider) const configured = configuredProviderFn(status, config) const canSave = Boolean(provider && provider === configured) @@ -118,6 +150,28 @@ export function RouterSection({ const collectAndSave = () => { if (!canSave) return + + const unknownModels: string[] = [] + if (allModels.length > 0) { + visibleTiers.forEach(([name, tier]) => { + const row = rowFor(name, tier) + if (row.model) { + const match = allModels.find( + (m) => m.provider === row.provider && m.id === row.model, + ) + if (!match) { + unknownModels.push(row.model) + } + } + }) + } + if (unknownModels.length > 0) { + toast.warning( + `Warning: Model ID${unknownModels.length > 1 ? 's' : ''} not in catalog: ${unknownModels.join(', ')}`, + { id: 'setup-router-warning' } + ) + } + const judgeModel = resolveJudgeModelParam(judge, judgeLoaded, judgeIsLocal) const params = buildRouterConfigureParams({ sel: mode, @@ -219,6 +273,14 @@ export function RouterSection({ const row = rowFor(name, tier) const isImageModel = name === 'image_model' const supportsImage = isImageModel || row.supportsImage + const listId = `datalist-${name}` + const filteredModels = allModels.filter((m) => { + if (m.provider !== row.provider) return false + if (isImageModel) { + return m.capabilities?.includes('vision') + } + return true + }) return (
@@ -231,11 +293,25 @@ export function RouterSection({ - setRow(name, tier, { provider: e.target.value })} - /> + > + + {providers.map((p) => ( + + ))} + {row.provider && !providers.some((p) => p.providerId === row.provider) && ( + + )} +