The two things ped-ai's models page has that this one did not. Both were
already possible over the API — PUT /admin/models/{id} takes is_active,
and POST /admin/models never checked its model_id against the proxy —
so this is the page catching up with what the server already allowed.
Off, not gone. Taking a model out while the proxy misbehaved meant
removing it and then finding it in the catalogue and setting it up
again, losing whether it was the default and any key on it. The row
stays now, struck through and marked off, and comes back with one press.
get_model_for_task already filters on is_active, so off means off.
And a model id can be typed. Discovery reads /model/info, and a gateway
routes plenty it does not advertise there — a new alias, a provider
added by hand — so a roster limited to that list is a roster that cannot
hold what the gateway can actually serve.
Verified on the live page: twelve toggles on the speech job, the typed
field present, the six Kokoro and six Orpheus voices listed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
158 lines
7.8 KiB
JavaScript
158 lines
7.8 KiB
JavaScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import ModelsAdmin from './ModelsAdmin'
|
|
import api from '../api/client'
|
|
|
|
vi.mock('../api/client', () => ({
|
|
default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
|
}))
|
|
vi.mock('../hooks/useDialog', () => ({
|
|
useDialog: () => ({ dialogProps: {}, openConfirm: () => Promise.resolve(true) }),
|
|
}))
|
|
vi.mock('./Dialog', () => ({ default: () => null }))
|
|
|
|
const MODELS = [
|
|
{ id: 1, name: 'Haiku', model_id: 'claude-haiku-4-5', task: 'teach', is_active: true, is_default: true },
|
|
{ id: 2, name: 'Sonnet', model_id: 'claude-sonnet-5', task: 'teach', is_active: true, is_default: false },
|
|
{ id: 3, name: 'Opus', model_id: 'claude-opus-5', task: 'extraction', is_active: true, is_default: true },
|
|
]
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
api.get.mockResolvedValue({ data: MODELS })
|
|
api.put.mockResolvedValue({ data: {} })
|
|
api.delete.mockResolvedValue({})
|
|
api.post.mockImplementation(url => {
|
|
if (url === '/admin/litellm/models') {
|
|
return Promise.resolve({ data: { models: ['claude-haiku-4-5', 'gpt-5', 'gemini-3'] } })
|
|
}
|
|
if (url === '/admin/models') {
|
|
return Promise.resolve({ data: { id: 9, name: 'gpt-5', model_id: 'gpt-5', task: 'teach', is_active: true, is_default: false } })
|
|
}
|
|
return Promise.resolve({ data: { message: 'Answered' } })
|
|
})
|
|
})
|
|
|
|
describe('model selection', () => {
|
|
it('asks one question per job rather than showing rows of buttons', async () => {
|
|
render(<ModelsAdmin />)
|
|
// The old screen needed you to find a card, find a row, and press Set
|
|
// Default on it — four buttons per row to express one fact.
|
|
expect(await screen.findByRole('combobox', { name: 'Model for Tutor' })).toHaveValue('1')
|
|
expect(screen.queryByRole('button', { name: 'Set Default' })).toBeNull()
|
|
})
|
|
|
|
it('changes a job by choosing, and moves the choice off the old model', async () => {
|
|
render(<ModelsAdmin />)
|
|
const pick = await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
|
api.get.mockClear()
|
|
await userEvent.selectOptions(pick, '2')
|
|
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/admin/models/2',
|
|
{ is_default: true, is_active: true }))
|
|
expect(pick).toHaveValue('2')
|
|
// Updated in place, not by refetching the section.
|
|
expect(api.get).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('offers no choice for a job with one model, because there is none', async () => {
|
|
render(<ModelsAdmin />)
|
|
await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
|
expect(screen.queryByRole('combobox', { name: 'Model for Extraction' })).toBeNull()
|
|
expect(screen.getByText('Opus')).toBeInTheDocument()
|
|
})
|
|
|
|
it('says when a job has nothing configured instead of showing an empty box', async () => {
|
|
render(<ModelsAdmin />)
|
|
await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
|
expect(screen.getAllByText('Falls back to the site default').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('tests the model a job actually uses', async () => {
|
|
render(<ModelsAdmin />)
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Test the model for Tutor' }))
|
|
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models/1/test'))
|
|
expect(await screen.findByText(/Answered/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('names the tool model as a job of its own, and tests it like any other', async () => {
|
|
// The model that covers what another model cannot do — reading an image,
|
|
// today — is chosen the same way as the tutor's or the extractor's.
|
|
api.get.mockResolvedValue({ data: [...MODELS,
|
|
{ id: 4, name: 'gpt-4.1', model_id: 'gpt-4.1', task: 'tool', is_active: true, is_default: true }] })
|
|
render(<ModelsAdmin />)
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Test the model for Tool' }))
|
|
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models/4/test'))
|
|
})
|
|
|
|
it('keeps the allow-list folded away, since it is set once', async () => {
|
|
render(<ModelsAdmin />)
|
|
await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
|
expect(screen.queryByRole('button', { name: /List what the proxy offers/ })).toBeNull()
|
|
await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ }))
|
|
expect(screen.getByRole('button', { name: /List what the proxy offers/ })).toBeInTheDocument()
|
|
})
|
|
|
|
it('will not offer to allow a model that is already allowed', async () => {
|
|
render(<ModelsAdmin />)
|
|
await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
|
await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ }))
|
|
await userEvent.click(screen.getByRole('button', { name: /List what the proxy offers/ }))
|
|
const offered = await screen.findByRole('button', { name: 'Allow gpt-5' })
|
|
expect(offered).toBeInTheDocument()
|
|
// Already allowed for Tutor, so there is nothing to press.
|
|
expect(screen.queryByRole('button', { name: 'Allow claude-haiku-4-5' })).toBeNull()
|
|
})
|
|
|
|
it('adds an allowed model without reloading the section', async () => {
|
|
render(<ModelsAdmin />)
|
|
await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
|
await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ }))
|
|
await userEvent.click(screen.getByRole('button', { name: /List what the proxy offers/ }))
|
|
api.get.mockClear()
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Allow gpt-5' }))
|
|
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models', {
|
|
name: 'gpt-5', model_id: 'gpt-5', task: 'teach', is_active: true, is_default: false,
|
|
}))
|
|
expect(api.get).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('turns a model off without losing it, and allows an id the proxy never listed', async () => {
|
|
// Two things ped-ai's page has and this one did not. Taking a model out
|
|
// while the proxy misbehaves used to mean removing it and setting it up
|
|
// again afterwards, losing the default flag and any key on it. And the
|
|
// roster could only ever hold what /model/info happened to advertise,
|
|
// though the gateway will route aliases it does not list.
|
|
api.get.mockImplementation(url => Promise.resolve({
|
|
data: url === '/admin/models'
|
|
? [{ id: 1, name: 'ds-flash', model_id: 'ds-flash', task: 'extraction',
|
|
is_active: true, is_default: true }]
|
|
: {},
|
|
}))
|
|
api.put.mockResolvedValue({ data: {
|
|
id: 1, name: 'ds-flash', model_id: 'ds-flash', task: 'extraction',
|
|
is_active: false, is_default: true } })
|
|
api.post.mockResolvedValue({ data: {
|
|
id: 2, name: 'brand-new-alias', model_id: 'brand-new-alias',
|
|
task: 'extraction', is_active: true, is_default: false } })
|
|
|
|
render(<ModelsAdmin />)
|
|
// The roster is behind its own disclosure.
|
|
await userEvent.click(await screen.findByRole('button', { name: /Which models this site may use/ }))
|
|
// The roster shows one job at a time; ds-flash is an extraction model.
|
|
await userEvent.selectOptions(
|
|
await screen.findByLabelText('Job to allow a model for'), 'extraction')
|
|
await userEvent.click(await screen.findByRole('button', { name: /Turn off ds-flash/ }))
|
|
expect(api.put).toHaveBeenCalledWith('/admin/models/1', { is_active: false })
|
|
// Still listed, still the default — off, not gone.
|
|
expect(await screen.findByText('off')).toBeInTheDocument()
|
|
expect(screen.getByText('in use')).toBeInTheDocument()
|
|
|
|
await userEvent.type(
|
|
screen.getByLabelText('A model id the proxy does not list'), 'brand-new-alias')
|
|
await userEvent.click(screen.getByRole('button', { name: 'Allow it' }))
|
|
expect(api.post).toHaveBeenCalledWith('/admin/models', expect.objectContaining({
|
|
model_id: 'brand-new-alias', task: 'extraction', is_active: true,
|
|
}))
|
|
})
|