feat: turn a model off without losing it, and allow one the proxy never listed
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Successful in 27s
Tests / e2e (push) Failing after 29s

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
This commit is contained in:
Daniel 2026-09-13 16:38:25 +02:00
parent 7cd2fa5cf4
commit d896239690
3 changed files with 102 additions and 1 deletions

View file

@ -113,3 +113,21 @@
}
@media (min-width: 700px) { .mdl-embed-row input, .mdl-embed-row select { font-size: 0.84rem; } }
.mdl-embed-note { margin: 10px 0 0; font-size: 0.78rem; color: var(--text-muted); }
/* Typing an id the proxy does not list. */
.mdl-add { display: flex; gap: 8px; margin: 10px 0; }
.mdl-add input {
flex: 1; min-width: 0; padding: 6px 10px; font: inherit; font-size: .85rem;
color: var(--text); background: var(--input-bg);
border: 1px solid var(--border); border-radius: 6px;
}
/* Off, not gone: the row stays, dimmed, with its settings intact. */
.mdl-current li.is-off code { opacity: .55; text-decoration: line-through; }
.mdl-badge.is-off { background: var(--border); color: var(--text-muted); }
.mdl-toggle {
padding: 3px 10px; font-size: .78rem; font-weight: 600; cursor: pointer;
color: var(--text-muted); background: none;
border: 1px solid var(--border); border-radius: 999px;
}
.mdl-toggle:hover { color: var(--primary); border-color: var(--primary); }

View file

@ -56,6 +56,7 @@ export default function ModelsAdmin() {
const [finding, setFinding] = useState(false)
const [findError, setFindError] = useState('')
const [filter, setFilter] = useState('')
const [typed, setTyped] = useState('')
const [allowFor, setAllowFor] = useState('teach')
// Embeddings: one model for the whole site.
@ -112,6 +113,28 @@ export default function ModelsAdmin() {
finally { setFinding(false) }
}
//: Off rather than gone. The row keeps its task, its default flag and any
//: key on it, so turning a model back on is one press rather than finding
//: it in the proxy list again and setting it up from scratch.
const setActive = (model, on) => act(`active:${model.id}`,
async () => {
const res = await api.put(`/admin/models/${model.id}`, { is_active: on })
setModels(prev => prev.map(row => (row.id === model.id ? res.data : row)))
},
on ? 'Could not turn that model on' : 'Could not turn that model off',
`${model.model_id} is ${on ? 'on' : 'off'} for ${JOBS.find(j => j.key === allowFor)?.label}.`)
//: A model id the proxy does not list. Discovery reads /model/info, and a
//: gateway can route a model it does not advertise there a new alias, a
//: provider added by hand so the roster must not be limited to what the
//: list happens to say.
const allowTyped = () => {
const id = typed.trim()
if (!id) return
setTyped('')
allow(id)
}
const allow = (modelId) => act(`allow:${modelId}`,
async () => {
const res = await api.post('/admin/models',
@ -289,9 +312,19 @@ export default function ModelsAdmin() {
<ul className="mdl-current">
{(byJob[allowFor] || []).map(model => (
<li key={model.id}>
<li key={model.id} className={truthy(model.is_active) ? undefined : 'is-off'}>
<code>{model.model_id}</code>
{truthy(model.is_default) && <span className="mdl-badge">in use</span>}
{!truthy(model.is_active) && <span className="mdl-badge is-off">off</span>}
{/* Off, not gone. A model taken out while the proxy is
misbehaving used to have to be removed and then found
and allowed again afterwards, losing whether it was the
default and any key set on it. */}
<button type="button" className="mdl-toggle" disabled={busy === `active:${model.id}`}
aria-label={`${truthy(model.is_active) ? 'Turn off' : 'Turn on'} ${model.name}`}
onClick={() => setActive(model, !truthy(model.is_active))}>
{truthy(model.is_active) ? 'Turn off' : 'Turn on'}
</button>
<button type="button" className="mdl-remove" disabled={busy === `allow:${model.id}`}
aria-label={`Remove ${model.name}`} onClick={() => disallow(model)}>Remove</button>
</li>
@ -301,6 +334,17 @@ export default function ModelsAdmin() {
)}
</ul>
{/* Typed, for anything the list does not carry. */}
<div className="mdl-add">
<input value={typed} aria-label="A model id the proxy does not list"
placeholder="Or type a model id the list does not carry"
onChange={e => setTyped(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); allowTyped() } }} />
<button type="button" className="btn btn-secondary btn-sm"
disabled={!typed.trim() || busy === `allow:${typed.trim()}`}
onClick={allowTyped}>Allow it</button>
</div>
{offered.length > 0 && (
<>
<input type="search" value={filter} aria-label="Filter offered models"

View file

@ -117,3 +117,42 @@ describe('model selection', () => {
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,
}))
})