From d8962396909e74f7b09f76ea5542e8543ba8a5db Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 16:38:25 +0200 Subject: [PATCH] feat: turn a model off without losing it, and allow one the proxy never listed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/ModelsAdmin.css | 18 ++++++++ frontend/src/components/ModelsAdmin.jsx | 46 +++++++++++++++++++- frontend/src/components/ModelsAdmin.test.jsx | 39 +++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ModelsAdmin.css b/frontend/src/components/ModelsAdmin.css index 0692cf1..2fe96d9 100644 --- a/frontend/src/components/ModelsAdmin.css +++ b/frontend/src/components/ModelsAdmin.css @@ -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); } diff --git a/frontend/src/components/ModelsAdmin.jsx b/frontend/src/components/ModelsAdmin.jsx index e594ae9..4fcd6a8 100644 --- a/frontend/src/components/ModelsAdmin.jsx +++ b/frontend/src/components/ModelsAdmin.jsx @@ -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() { + {/* Typed, for anything the list does not carry. */} +
+ setTyped(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); allowTyped() } }} /> + +
+ {offered.length > 0 && ( <> { 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() + // 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, + })) +})