Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
4 changes: 3 additions & 1 deletion frontend/src/app/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ function renderAt(path: string) {
describe('routes', () => {
it('renders a real lazily loaded registered view', async () => {
renderAt('/sessions')
expect(await screen.findByRole('heading', { name: 'Sessions' })).toBeInTheDocument()
expect(
await screen.findByRole('heading', { name: 'Sessions' }, { timeout: 5000 }),
).toBeInTheDocument()
})

it('registers every major view as a route-object lazy module', () => {
Expand Down
147 changes: 144 additions & 3 deletions frontend/src/views/setup/RouterSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -36,7 +41,61 @@ function catalogWithTiers(tiers: Record<string, Record<string, unknown>>): 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,
Expand All @@ -46,11 +105,19 @@ function renderSection(catalog: Catalog, onSave = vi.fn()) {
onNext: vi.fn(),
saving: false,
}
const result = render(<RouterSection {...props} />)
const result = render(
<QueryClientProvider client={queryClient}>
<RouterSection {...props} />
</QueryClientProvider>,
)
return {
...result,
rerenderCatalog: (nextCatalog: Catalog) =>
result.rerender(<RouterSection {...props} catalog={nextCatalog} />),
result.rerender(
<QueryClientProvider client={queryClient}>
<RouterSection {...props} catalog={nextCatalog} />
</QueryClientProvider>,
),
}
}

Expand Down Expand Up @@ -125,4 +192,78 @@ 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()
})
})
104 changes: 102 additions & 2 deletions frontend/src/views/setup/RouterSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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<ModelSpec[]>({
queryKey: ['setup', 'models'],
queryFn: async () => {
await rpc.waitForConnection()
const data = await rpc.call<unknown>('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)
Expand Down Expand Up @@ -118,6 +150,26 @@ 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,
Expand Down Expand Up @@ -219,6 +271,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 (
<div className="setup-tier-table__row" role="row" key={name}>
<div className="setup-tier-table__cell setup-tier-table__cell--tier" role="cell">
Expand All @@ -231,11 +291,25 @@ export function RouterSection({
<span className="setup-tier-table__mobile-label" aria-hidden="true">
Provider
</span>
<input
<SetupSelect
aria-label={`${name} provider`}
value={row.provider}
onChange={(e) => setRow(name, tier, { provider: e.target.value })}
/>
>
<option value="" disabled>
Choose a provider
</option>
{providers.map((p) => (
<option key={p.providerId} value={p.providerId}>
{p.label || p.providerId}
</option>
))}
{row.provider && !providers.some((p) => p.providerId === row.provider) && (
<option key={row.provider} value={row.provider}>
{row.provider}
</option>
)}
</SetupSelect>
</div>
<div className="setup-tier-table__cell setup-tier-table__cell--model" role="cell">
<span className="setup-tier-table__mobile-label" aria-hidden="true">
Expand All @@ -244,8 +318,34 @@ export function RouterSection({
<input
aria-label={`${name} model`}
value={row.model}
list={listId}
autoComplete="off"
onChange={(e) => setRow(name, tier, { model: e.target.value })}
/>
<datalist id={listId}>
{filteredModels.map((m) => {
const ctxText =
Number(m.contextWindow) > 0
? `${Math.round(Number(m.contextWindow) / 1000)}k ctx`
: ''
const input1M = m.pricing ? (Number(m.pricing.inputPer1k) || 0) * 1000 : 0
const output1M = m.pricing ? (Number(m.pricing.outputPer1k) || 0) * 1000 : 0
const pricingText =
input1M || output1M
? `$${input1M.toFixed(2)}/$${output1M.toFixed(2)} per 1M`
: ''
const labelParts = [ctxText, pricingText].filter(Boolean)
const optionLabel =
labelParts.length > 0
? `${m.name || m.id} (${labelParts.join(' · ')})`
: m.name || m.id
return (
<option key={m.id} value={m.id}>
{optionLabel}
</option>
)
})}
</datalist>
</div>
<div className="setup-tier-table__cell" role="cell">
<span className="setup-tier-table__mobile-label" aria-hidden="true">
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/views/setup/SetupPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,30 @@ const CONFIG = {
memory: { curated_memory_char_limit: 4000, curated_user_char_limit: 2000, inject_limit: 6400 },
}

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: 'dall-e',
name: 'Dall-E',
provider: 'openai',
contextWindow: 128000,
capabilities: ['chat', 'vision'],
},
]

function statusFor(overrides: Record<string, unknown> = {}) {
return {
needsOnboarding: false,
Expand All @@ -158,6 +182,7 @@ function wireCalls(status: Record<string, unknown> = statusFor()) {
if (method === 'onboarding.status') return Promise.resolve(status)
if (method === 'config.get') return Promise.resolve(CONFIG)
if (method === 'doctor.memory.status') return Promise.resolve(null)
if (method === 'models.list') return Promise.resolve(MOCK_MODELS)
return Promise.resolve({})
})
}
Expand Down
Loading