Split the model admin into a list and a page per model

/admin/models rendered a full edit form for every model. With eight that
was merely long; with a hundred it was unusable, which is the report.

The list is now compact rows only -- avatar, name, badges, position,
reorder buttons, Edit link -- with search across id and display name,
filter tabs (All / Enabled / Disabled / Pinned / Restricted, each with a
count), a connection filter, and pagination at 40. Filters are links, so
a filtered view is a real URL you can keep. Editing moved to
/admin/models/{id}/edit, one model per page, with Previous/Next links so
a freshly imported connection can be tidied without returning to the
list each time.

Measured with 128 models: the list is 73 KB showing 40 rows over 4
pages, and a detail page is 17 KB. The old page would have rendered all
128 forms into one response.

Reordering needed rethinking at that size too. Up/down is fine for
nudging a model one place but hopeless for moving it sixty, so the
detail page has a position field you type into; the value is clamped and
a non-numeric one is ignored rather than throwing. The move buttons take
a `back` field so they return to whatever filtered, paginated view they
were pressed on instead of dumping you at page 1.

Also adds a select-all checkbox for the bulk bar, scoped to a container
selector rather than the page so a future list can carry more than one.

212 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 13:43:00 +02:00
parent 29db54960e
commit 75edae039b
7 changed files with 702 additions and 162 deletions
+3 -2
View File
@@ -45,8 +45,9 @@ runtime. Clone it, `pip install -e .`, run it.
inside it inside it
- **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp, - **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp,
llama-swap, Ollama or OpenRouter; models are discovered and cached llama-swap, Ollama or OpenRouter; models are discovered and cached
- **Model settings** — ordering, pinned models, an instance default and a - **Model settings** — searchable, filterable list with a page per model:
per-user default, custom names and descriptions, uploaded model images ordering, pinned models, an instance default and a per-user default, custom
names, descriptions and images. Scales to hundreds of models
- **Users, groups & permissions** — per-group grants that union rather than - **Users, groups & permissions** — per-group grants that union rather than
override, and model access restricted to chosen groups override, and model access restricted to chosen groups
- **Accounts** — first account becomes the administrator, argon2 password - **Accounts** — first account becomes the administrator, argon2 password
+130 -14
View File
@@ -47,20 +47,104 @@ def _renumber(db: DBSession) -> None:
db.commit() db.commit()
# --- The admin page ---------------------------------------------------------- # --- Listing -----------------------------------------------------------------
PAGE_SIZE = 40
# Filters offered as tabs above the list. Each is a predicate over a Model.
FILTERS: dict[str, tuple[str, object]] = {
"all": ("All", lambda m: True),
"enabled": ("Enabled", lambda m: m.enabled),
"disabled": ("Disabled", lambda m: not m.enabled),
"pinned": ("Pinned", lambda m: m.pinned),
"restricted": ("Restricted", lambda m: not m.public),
}
@router.get("/admin/models") @router.get("/admin/models")
async def models_page(request: Request, db: Db, user: AdminUser, saved: str = ""): async def models_page(
models = _ordered(db) request: Request,
db: Db,
user: AdminUser,
saved: str = "",
q: str = "",
filter: str = "all",
connection: str = "",
page: int = 1,
):
"""The model list.
Compact rows only -- editing happens on a page of its own. A connection can
advertise a hundred models, and a list that renders a full form for each of
them is unusable at that size.
"""
everything = _ordered(db)
predicate = FILTERS.get(filter, FILTERS["all"])[1]
needle = q.strip().lower()
matching = [
model
for model in everything
if predicate(model)
and (not connection or model.connection_id == connection)
and (
not needle
or needle in model.model_id.lower()
or needle in (model.display_name or "").lower()
)
]
pages = max(1, -(-len(matching) // PAGE_SIZE))
page = max(1, min(page, pages))
start = (page - 1) * PAGE_SIZE
visible = matching[start : start + PAGE_SIZE]
return render( return render(
request, request,
"admin/models.html", "admin/models.html",
{ {
"models": models, "models": visible,
"groups": list(db.scalars(select(Group).order_by(Group.name))), "total": len(everything),
"matched": len(matching),
"page": page,
"pages": pages,
"page_start": start,
"connections": list(db.scalars(select(Connection).order_by(Connection.name))), "connections": list(db.scalars(select(Connection).order_by(Connection.name))),
"default_model": settings_store.get(db, "default_model") or "", "default_model": settings_store.get(db, "default_model") or "",
"instance_prompt": settings_store.get(db, "system_prompt") or "", "counts": {
key: sum(1 for m in everything if test(m)) for key, (_, test) in FILTERS.items()
},
"filters": {key: label for key, (label, _) in FILTERS.items()},
"active_filter": filter if filter in FILTERS else "all",
"q": q,
"connection_id": connection,
"saved": saved,
},
)
@router.get("/admin/models/{model_id}/edit")
async def model_detail(
request: Request, db: Db, user: AdminUser, model_id: str, saved: str = ""
):
"""Everything about one model, on its own page."""
model = _model(db, model_id)
ordered = _ordered(db)
index = next((i for i, m in enumerate(ordered) if m.id == model.id), 0)
return render(
request,
"admin/model_detail.html",
{
"model": model,
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"capabilities": CAPABILITIES, "capabilities": CAPABILITIES,
"default_model": settings_store.get(db, "default_model") or "",
"instance_prompt": settings_store.get(db, "system_prompt") or "",
"position_of": index + 1,
"total": len(ordered),
"previous": ordered[index - 1] if index > 0 else None,
"next": ordered[index + 1] if index + 1 < len(ordered) else None,
"saved": saved, "saved": saved,
}, },
) )
@@ -107,6 +191,7 @@ async def update_model(
enabled: bool = Form(False), enabled: bool = Form(False),
pinned: bool = Form(False), pinned: bool = Form(False),
public: bool = Form(False), public: bool = Form(False),
position: str = Form(""),
group_ids: list[str] = Form(default=[]), group_ids: list[str] = Form(default=[]),
capability: list[str] = Form(default=[]), capability: list[str] = Form(default=[]),
) -> Response: ) -> Response:
@@ -130,13 +215,34 @@ async def update_model(
model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or [])))) model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
db.commit() db.commit()
# Typing a position is the only workable way to reorder a long list; the
# up/down buttons are for nudging a model one place.
if position.strip():
try:
wanted = max(1, int(position)) - 1
except ValueError:
wanted = None
if wanted is not None:
ordered = [m for m in _ordered(db) if m.id != model.id]
ordered.insert(min(wanted, len(ordered)), model)
for index, item in enumerate(ordered):
item.position = index
db.commit()
log.info("model %s updated by %s", model.model_id, user.email) log.info("model %s updated by %s", model.model_id, user.email)
return RedirectResponse("/admin/models?saved=Model+saved.", status_code=303) return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Saved.", status_code=303
)
@router.post("/admin/models/{model_id}/move") @router.post("/admin/models/{model_id}/move")
async def move_model( async def move_model(
db: Db, user: AdminUser, model_id: str, direction: str = Form(...) db: Db,
user: AdminUser,
model_id: str,
direction: str = Form(...),
back: str = Form(""),
) -> Response: ) -> Response:
"""Swap a model with its neighbour.""" """Swap a model with its neighbour."""
model = _model(db, model_id) model = _model(db, model_id)
@@ -153,17 +259,21 @@ async def move_model(
item.position = position item.position = position
db.commit() db.commit()
return RedirectResponse("/admin/models", status_code=303) # Back to whichever filtered, paginated view the button was pressed on.
return RedirectResponse(back or "/admin/models", status_code=303)
@router.post("/admin/models/{model_id}/default") @router.post("/admin/models/{model_id}/default")
async def set_default_model(db: Db, user: AdminUser, model_id: str) -> Response: async def set_default_model(
db: Db, user: AdminUser, model_id: str, back: str = Form("")
) -> Response:
"""Make a model the instance default for new chats.""" """Make a model the instance default for new chats."""
model = _model(db, model_id) model = _model(db, model_id)
settings_store.update(db, {"default_model": model.model_id}) settings_store.update(db, {"default_model": model.model_id})
log.info("default model set to %s by %s", model.model_id, user.email) log.info("default model set to %s by %s", model.model_id, user.email)
return RedirectResponse( return RedirectResponse(
f"/admin/models?saved={model.label}+is+now+the+default.", status_code=303 back or f"/admin/models/{model.id}/edit?saved=Now+the+default+model.",
status_code=303,
) )
@@ -177,7 +287,9 @@ async def upload_model_image(
try: try:
filename = uploads.save_model_image(payload, image.content_type or "") filename = uploads.save_model_image(payload, image.content_type or "")
except uploads.UploadError as exc: except uploads.UploadError as exc:
return RedirectResponse(f"/admin/models?saved={exc}", status_code=303) return RedirectResponse(
f"/admin/models/{model.id}/edit?saved={exc}", status_code=303
)
# Remove the old file rather than orphaning it in the uploads directory. # Remove the old file rather than orphaning it in the uploads directory.
if model.image_path: if model.image_path:
@@ -185,7 +297,9 @@ async def upload_model_image(
model.image_path = filename model.image_path = filename
db.commit() db.commit()
return RedirectResponse("/admin/models?saved=Image+updated.", status_code=303) return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Image+updated.", status_code=303
)
@router.post("/admin/models/{model_id}/image/delete") @router.post("/admin/models/{model_id}/image/delete")
@@ -195,7 +309,9 @@ async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response
uploads.delete_model_image(model.image_path) uploads.delete_model_image(model.image_path)
model.image_path = "" model.image_path = ""
db.commit() db.commit()
return RedirectResponse("/admin/models?saved=Image+removed.", status_code=303) return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Image+removed.", status_code=303
)
# --- Serving model images ---------------------------------------------------- # --- Serving model images ----------------------------------------------------
+101 -11
View File
@@ -162,23 +162,70 @@
.status-dot.is-bad { background: var(--danger); } .status-dot.is-bad { background: var(--danger); }
.status-dot.is-off { background: var(--ink-faint); } .status-dot.is-off { background: var(--ink-faint); }
/* --- Model list ------------------------------------------------------------ */ /* --- Filter bar ------------------------------------------------------------ */
.filter-bar {
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.filter-tabs {
display: flex;
gap: var(--sp-1);
flex-wrap: wrap;
border-bottom: 1px solid var(--border);
}
.filter-tab {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
height: var(--control-h);
padding: 0 var(--sp-3);
border-bottom: 2px solid transparent;
color: var(--ink-muted);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
white-space: nowrap;
}
.filter-tab:hover { color: var(--ink); }
.filter-tab.is-active { color: var(--ink); border-bottom-color: var(--accent); }
.filter-tab__count {
font-size: var(--text-xs);
color: var(--ink-faint);
background: var(--surface-active);
border-radius: var(--radius-full);
padding: 0 0.4rem;
min-width: 1.4rem;
text-align: center;
}
.filter-tab.is-active .filter-tab__count { background: var(--accent-soft); color: var(--accent); }
.filter-form { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.filter-form .input { width: auto; flex: 1; min-width: 12rem; }
.filter-form .select { width: auto; min-width: 10rem; }
/* --- Model list ------------------------------------------------------------
Compact rows only. Editing is a page of its own -- a connection can advertise
a hundred models, and a list that renders a form for each is unusable.
*/
.bulk-bar { .bulk-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
flex-wrap: wrap; flex-wrap: wrap;
padding: var(--sp-3); padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: var(--surface); border-bottom: 0;
margin-bottom: var(--sp-4); background: var(--bg-sunken);
} }
.bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); margin-right: var(--sp-1); } .bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); }
.model-rows { .model-rows {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: 0 0 var(--radius-lg) var(--radius-lg);
overflow: hidden; overflow: hidden;
background: var(--surface); background: var(--surface);
} }
@@ -186,21 +233,36 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-3); gap: var(--sp-3);
padding: var(--sp-3); padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.model-row:last-child { border-bottom: 0; } .model-row:last-child { border-bottom: 0; }
.model-row.is-off { opacity: 0.5; } .model-row:hover { background: var(--surface-hover); }
.model-row.is-off { opacity: 0.55; }
.model-row__check { accent-color: var(--accent); width: 1rem; height: 1rem; flex: none; } .model-row__check { accent-color: var(--accent); width: 1rem; height: 1rem; flex: none; }
.model-row__avatar { flex: none; width: 2rem; height: 2rem; } .model-row__pos {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-faint);
min-width: 1.75rem;
text-align: right;
flex: none;
}
.model-row__avatar { flex: none; width: 1.75rem; height: 1.75rem; }
.model-row__main { flex: 1; min-width: 0; } .model-row__main { flex: 1; min-width: 0; }
.model-row__title { .model-row__title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 0.1rem;
} }
.model-row__name {
color: var(--ink);
font-weight: 500;
font-size: var(--text-sm);
text-decoration: none;
}
.model-row__name:hover { color: var(--accent); text-decoration: underline; }
.model-row__id { .model-row__id {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -212,6 +274,34 @@
} }
.model-row__actions { display: flex; align-items: center; gap: var(--sp-1); flex: none; } .model-row__actions { display: flex; align-items: center; gap: var(--sp-1); flex: none; }
.list-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-top: var(--sp-3);
flex-wrap: wrap;
}
/* --- Model detail ---------------------------------------------------------- */
.crumbs {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-4);
font-size: var(--text-sm);
}
.crumbs > a:first-child {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--ink-muted);
text-decoration: none;
}
.crumbs > a:first-child:hover { color: var(--ink); }
.crumbs__back { transform: rotate(180deg); }
.model-detail__avatar { width: 3rem; height: 3rem; flex: none; }
.model-list { list-style: none; margin: 0; padding: 0; } .model-list { list-style: none; margin: 0; padding: 0; }
.model-list__item { .model-list__item {
display: flex; display: flex;
+12
View File
@@ -232,6 +232,18 @@
if (event.target.matches("[data-autosize]")) autosize(event.target); if (event.target.matches("[data-autosize]")) autosize(event.target);
}); });
/* A "select all" box driving every checkbox inside a container. Scoped to a
selector rather than the whole page, so a list can carry more than one. */
document.addEventListener("change", function (event) {
var master = event.target.closest("[data-select-all]");
if (!master) return;
var scope = document.querySelector(master.dataset.selectAll);
if (!scope) return;
scope.querySelectorAll('input[type="checkbox"]').forEach(function (box) {
box.checked = master.checked;
});
});
/* Enter sends, Shift+Enter inserts a newline -- the convention every chat /* Enter sends, Shift+Enter inserts a newline -- the convention every chat
application uses. Left alone on touch devices, where there is no easy application uses. Left alone on touch devices, where there is no easy
Shift and Enter should mean "new line". */ Shift and Enter should mean "new line". */
@@ -0,0 +1,196 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon, model_avatar %}
{% set section = "models" %}
{% block title %}{{ model.label }} - Models - LLeMbas{% endblock %}
{% block heading %}{{ model.label }}{% endblock %}
{% block admin_content %}
<nav class="crumbs">
<a href="/admin/models">{{ icon("chevron-right", "icon--sm crumbs__back") }} All models</a>
<span class="spacer"></span>
{# Walking the list without going back to it, which is what you want when
tidying up a freshly imported connection. #}
{% if previous %}
<a class="btn btn--sm" href="/admin/models/{{ previous.id }}/edit"
title="{{ previous.label }}">{{ icon("arrow-up", "icon--sm") }} Previous</a>
{% endif %}
<span class="text-xs faint">{{ position_of }} of {{ total }}</span>
{% if next %}
<a class="btn btn--sm" href="/admin/models/{{ next.id }}/edit"
title="{{ next.label }}">Next {{ icon("arrow-down", "icon--sm") }}</a>
{% endif %}
</nav>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
<section class="card">
<div class="card__header">
<div class="row" style="gap: var(--sp-3); min-width: 0">
{{ model_avatar(model, cls="model-detail__avatar") }}
<div style="min-width: 0">
<strong>{{ model.label }}</strong>
<div><code class="text-xs faint">{{ model.model_id }}</code></div>
<div class="text-xs faint">via {{ model.connection.name }}</div>
</div>
</div>
<div class="btn-row">
{% if model.model_id == default_model %}
<span class="badge badge--gold">{{ icon("star", "icon--sm") }} default model</span>
{% else %}
<form method="post" action="/admin/models/{{ model.id }}/default">
<button class="btn btn--sm" type="submit">
{{ icon("star", "icon--sm") }} Make default
</button>
</form>
{% endif %}
</div>
</div>
<div class="btn-row">
<form method="post" action="/admin/models/{{ model.id }}/image"
enctype="multipart/form-data" class="btn-row">
<input class="input input--file" type="file" name="image"
accept="image/png,image/jpeg,image/webp,image/gif" required
aria-label="Model image">
<button class="btn btn--sm" type="submit">{{ icon("image", "icon--sm") }} Upload image</button>
</form>
{% if model.image_path %}
<form method="post" action="/admin/models/{{ model.id }}/image/delete">
<button class="btn btn--sm btn--danger" type="submit">Remove image</button>
</form>
{% endif %}
</div>
<p class="field__hint">
PNG, JPEG, WEBP or GIF, under 2 MB. Without one, the model gets a generated
badge whose colour is derived from its id.
</p>
</section>
<form method="post" action="/admin/models/{{ model.id }}">
<section class="card">
<h2 class="card__title">Presentation</h2>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="display-name">Display name</label>
<input class="input" id="display-name" name="display_name"
value="{{ model.display_name }}" placeholder="{{ model.model_id }}">
<p class="field__hint">Shown instead of the raw id. Empty uses the id.</p>
</div>
<div class="field">
<label class="field__label" for="position">Position</label>
<input class="input" id="position" name="position" type="number" min="1"
max="{{ total }}" value="{{ position_of }}">
<p class="field__hint">
Place in the picker, 1{{ total }}. Typing a number is the workable way
to move a model a long distance.
</p>
</div>
</div>
<div class="field">
<label class="field__label" for="description">Description</label>
<textarea class="textarea" id="description" name="description" rows="2"
placeholder="What is this model good at?">{{ model.description }}</textarea>
<p class="field__hint">Shown in the chat settings panel and your users' settings.</p>
</div>
</section>
<section class="card">
<h2 class="card__title">System prompt</h2>
<p class="card__lede">
Used by chats on this model that have no prompt of their own. A chat's own
prompt overrides this; this overrides the instance prompt.
</p>
<div class="field">
<label class="field__label visually-hidden" for="system-prompt">System prompt</label>
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="5"
placeholder="{{ instance_prompt or 'No instance prompt is set.' }}"
>{{ model.system_prompt }}</textarea>
<p class="field__hint">
{% if instance_prompt %}
Leave empty to fall back to the instance prompt shown above.
{% else %}
Leave empty to send no system prompt.
{% endif %}
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Capabilities</h2>
<p class="card__lede">
Endpoints rarely advertise these reliably, so they are your call.
<strong>reasoning</strong> shows the thinking block,
<strong>vision</strong> lets images be sent to this model, and
<strong>tools</strong> is reserved for a feature not built yet.
</p>
<div class="checkbox-row">
{% for name in capabilities %}
<label class="checkbox">
<input type="checkbox" name="capability" value="{{ name }}"
{{ 'checked' if (model.capabilities_json or {}).get(name) }}>
<span>{{ name }}</span>
</label>
{% endfor %}
</div>
</section>
<section class="card">
<h2 class="card__title">Availability</h2>
<div class="field">
<div class="checkbox-row">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if model.enabled }}>
<span>Enabled — offered in chats</span>
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
<span>Pinned — shortcut in the chat sidebar</span>
</label>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
<span>Available to everyone</span>
</label>
<p class="field__hint">
Uncheck to restrict this model to chosen groups. Administrators always
have access.
</p>
</div>
<div class="field">
<span class="field__label">Groups with access</span>
{% if groups %}
<div class="checkbox-row">
{% for group in groups %}
<label class="checkbox">
<input type="checkbox" name="group_ids" value="{{ group.id }}"
{{ 'checked' if group in model.groups }}>
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
<p class="field__hint">Ignored while the model is available to everyone.</p>
{% else %}
<p class="field__hint">
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
</p>
{% endif %}
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save changes</button>
<a class="btn btn--ghost" href="/admin/models">Back to all models</a>
</div>
</form>
{% endblock %}
+77 -135
View File
@@ -17,7 +17,7 @@
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div> <div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %} {% endif %}
{% if not models %} {% if not total %}
<div class="empty" style="padding: var(--sp-10) 0"> <div class="empty" style="padding: var(--sp-10) 0">
{{ icon("server", "empty__mark") }} {{ icon("server", "empty__mark") }}
<p class="empty__text"> <p class="empty__text">
@@ -27,8 +27,50 @@
</div> </div>
{% else %} {% else %}
{# Filters are links, so a filtered view is a real URL you can keep or share. #}
<div class="filter-bar">
<div class="filter-tabs">
{% for key, label in filters.items() %}
<a class="filter-tab {{ 'is-active' if key == active_filter }}"
href="/admin/models?filter={{ key }}{% if q %}&q={{ q|urlencode }}{% endif %}{% if connection_id %}&connection={{ connection_id }}{% endif %}">
{{ label }} <span class="filter-tab__count">{{ counts[key] }}</span>
</a>
{% endfor %}
</div>
<form class="filter-form" method="get" action="/admin/models">
<input type="hidden" name="filter" value="{{ active_filter }}">
<input class="input" type="search" name="q" value="{{ q }}"
placeholder="Search models…" aria-label="Search models">
<select class="select" name="connection" aria-label="Connection">
<option value="">All connections</option>
{% for conn in connections %}
<option value="{{ conn.id }}" {{ 'selected' if conn.id == connection_id }}>
{{ conn.name }}
</option>
{% endfor %}
</select>
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Filter</button>
{% if q or connection_id or active_filter != "all" %}
<a class="btn btn--ghost" href="/admin/models">Clear</a>
{% endif %}
</form>
</div>
{% if not models %}
<div class="empty" style="padding: var(--sp-8) 0">
<p class="empty__text">Nothing matches that filter.</p>
</div>
{% else %}
<form method="post" action="/admin/models/bulk"> <form method="post" action="/admin/models/bulk">
<div class="bulk-bar"> <div class="bulk-bar">
<label class="checkbox">
<input type="checkbox" data-select-all="#model-rows"
aria-label="Select every model on this page">
<span class="bulk-bar__label">Select all</span>
</label>
<span class="spacer"></span>
<span class="bulk-bar__label">With selected:</span> <span class="bulk-bar__label">With selected:</span>
<button class="btn btn--sm" name="action" value="enable" type="submit">Enable</button> <button class="btn btn--sm" name="action" value="enable" type="submit">Enable</button>
<button class="btn btn--sm" name="action" value="disable" type="submit">Disable</button> <button class="btn btn--sm" name="action" value="disable" type="submit">Disable</button>
@@ -36,174 +78,74 @@
<button class="btn btn--sm" name="action" value="private" type="submit">Restrict</button> <button class="btn btn--sm" name="action" value="private" type="submit">Restrict</button>
</div> </div>
<div class="model-rows"> <div class="model-rows" id="model-rows">
{% for model in models %} {% for model in models %}
<div class="model-row {{ 'is-off' if not model.enabled }}"> <div class="model-row {{ 'is-off' if not model.enabled }}">
<input class="model-row__check" type="checkbox" name="model_ids" value="{{ model.id }}" <input class="model-row__check" type="checkbox" name="model_ids" value="{{ model.id }}"
aria-label="Select {{ model.label }}"> aria-label="Select {{ model.label }}">
<span class="model-row__pos">{{ page_start + loop.index }}</span>
{{ model_avatar(model, cls="model-row__avatar") }} {{ model_avatar(model, cls="model-row__avatar") }}
<div class="model-row__main"> <div class="model-row__main">
<div class="model-row__title"> <div class="model-row__title">
<strong>{{ model.label }}</strong> <a class="model-row__name" href="/admin/models/{{ model.id }}/edit">{{ model.label }}</a>
{% if model.model_id == default_model %} {% if model.model_id == default_model %}
<span class="badge badge--gold">{{ icon("star", "icon--sm") }} default</span> <span class="badge badge--gold">default</span>
{% endif %} {% endif %}
{% if model.pinned %}<span class="badge">pinned</span>{% endif %} {% if model.pinned %}<span class="badge">pinned</span>{% endif %}
{% if not model.enabled %}<span class="badge badge--danger">disabled</span>{% endif %} {% if not model.enabled %}<span class="badge badge--danger">disabled</span>{% endif %}
{% if not model.public %} {% if not model.public %}<span class="badge">restricted</span>{% endif %}
<span class="badge">{{ model.groups|length }} group{{ '' if model.groups|length == 1 else 's' }}</span>
{% endif %}
{% for name, on in (model.capabilities_json or {}).items() %} {% for name, on in (model.capabilities_json or {}).items() %}
{% if on %}<span class="badge badge--gold">{{ name }}</span>{% endif %} {% if on %}<span class="badge badge--gold">{{ name }}</span>{% endif %}
{% endfor %} {% endfor %}
</div> </div>
<code class="model-row__id">{{ model.model_id }}</code> <code class="model-row__id">{{ model.model_id }} · {{ model.connection.name }}</code>
<span class="text-xs faint">via {{ model.connection.name }}</span>
</div> </div>
<div class="model-row__actions"> <div class="model-row__actions">
{# formaction lets these post elsewhere without nesting a second form. #}
<button class="btn btn--icon btn--sm" type="submit" aria-label="Move up" <button class="btn btn--icon btn--sm" type="submit" aria-label="Move up"
formaction="/admin/models/{{ model.id }}/move" formmethod="post" formaction="/admin/models/{{ model.id }}/move" name="direction" value="up"
name="direction" value="up" {{ 'disabled' if loop.first }}> {{ 'disabled' if page == 1 and loop.first }}>
{{ icon("arrow-up", "icon--sm") }} {{ icon("arrow-up", "icon--sm") }}
</button> </button>
<button class="btn btn--icon btn--sm" type="submit" aria-label="Move down" <button class="btn btn--icon btn--sm" type="submit" aria-label="Move down"
formaction="/admin/models/{{ model.id }}/move" formmethod="post" formaction="/admin/models/{{ model.id }}/move" name="direction" value="down"
name="direction" value="down" {{ 'disabled' if loop.last }}> {{ 'disabled' if page == pages and loop.last }}>
{{ icon("arrow-down", "icon--sm") }} {{ icon("arrow-down", "icon--sm") }}
</button> </button>
<a class="btn btn--sm" href="#model-{{ model.id }}">Edit</a> <a class="btn btn--sm" href="/admin/models/{{ model.id }}/edit">Edit</a>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</form> </form>
<h2 class="admin-section-title">Model settings</h2> <div class="list-footer">
<span class="text-xs faint">
Showing {{ page_start + 1 }}{{ page_start + models|length }} of {{ matched }}
{%- if matched != total %} (filtered from {{ total }}){% endif %}
</span>
{% for model in models %} {% if pages > 1 %}
<section class="card" id="model-{{ model.id }}"> {% set base = "/admin/models?filter=" ~ active_filter ~ ("&q=" ~ q|urlencode if q else "") ~ ("&connection=" ~ connection_id if connection_id else "") %}
<div class="card__header"> <div class="btn-row">
<div class="row" style="gap: var(--sp-3); min-width: 0"> {% if page > 1 %}
{{ model_avatar(model, cls="model-row__avatar") }} <a class="btn btn--sm" href="{{ base }}&page={{ page - 1 }}">Previous</a>
<div style="min-width: 0"> {% else %}
<strong class="truncate">{{ model.label }}</strong> <span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Previous</span>
<div><code class="text-xs faint">{{ model.model_id }}</code></div> {% endif %}
</div> <span class="text-xs faint">Page {{ page }} of {{ pages }}</span>
</div> {% if page < pages %}
{% if model.model_id != default_model %} <a class="btn btn--sm" href="{{ base }}&page={{ page + 1 }}">Next</a>
<form method="post" action="/admin/models/{{ model.id }}/default"> {% else %}
<button class="btn btn--sm" type="submit">{{ icon("star", "icon--sm") }} Make default</button> <span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Next</span>
</form>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<form method="post" action="/admin/models/{{ model.id }}"> </div>
<div class="field"> {% endif %}
<label class="field__label" for="dn-{{ model.id }}">Display name</label>
<input class="input" id="dn-{{ model.id }}" name="display_name"
value="{{ model.display_name }}" placeholder="{{ model.model_id }}">
<p class="field__hint">Shown instead of the raw model id. Leave empty to use the id.</p>
</div>
<div class="field">
<label class="field__label" for="desc-{{ model.id }}">Description</label>
<textarea class="textarea" id="desc-{{ model.id }}" name="description" rows="2"
placeholder="What is this model good at?">{{ model.description }}</textarea>
</div>
<div class="field">
<label class="field__label" for="sp-{{ model.id }}">System prompt</label>
<textarea class="textarea" id="sp-{{ model.id }}" name="system_prompt" rows="3"
placeholder="{{ instance_prompt or 'No instance prompt is set.' }}"
>{{ model.system_prompt }}</textarea>
<p class="field__hint">
Used by chats on this model that have no prompt of their own.
{% if instance_prompt %}Leave empty to fall back to the instance prompt.
{% else %}Leave empty to send no system prompt.{% endif %}
</p>
</div>
<div class="field">
<span class="field__label">Capabilities</span>
<div class="checkbox-row">
{% for name in capabilities %}
<label class="checkbox">
<input type="checkbox" name="capability" value="{{ name }}"
{{ 'checked' if (model.capabilities_json or {}).get(name) }}>
<span>{{ name }}</span>
</label>
{% endfor %}
</div>
<p class="field__hint">
Endpoints rarely advertise these reliably, so they are your call.
<strong>reasoning</strong> shows the thinking block;
<strong>vision</strong> and <strong>tools</strong> are used by features
not built yet.
</p>
</div>
<div class="field">
<div class="checkbox-row">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if model.enabled }}>
<span>Enabled</span>
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
<span>Pinned — shortcut in the chat sidebar</span>
</label>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
<span>Available to everyone</span>
</label>
<p class="field__hint">
Uncheck to restrict this model to specific groups. Administrators always
have access.
</p>
{% if groups %}
<div class="checkbox-row" style="margin-top: var(--sp-3)">
{% for group in groups %}
<label class="checkbox">
<input type="checkbox" name="group_ids" value="{{ group.id }}"
{{ 'checked' if group in model.groups }}>
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
{% else %}
<p class="field__hint">
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
</p>
{% endif %}
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save {{ model.label }}</button>
</div>
</form>
<div class="card__footer">
<form method="post" action="/admin/models/{{ model.id }}/image"
enctype="multipart/form-data" class="btn-row">
<input class="input input--file" type="file" name="image"
accept="image/png,image/jpeg,image/webp,image/gif" required
aria-label="Model image">
<button class="btn btn--sm" type="submit">{{ icon("image", "icon--sm") }} Upload</button>
</form>
{% if model.image_path %}
<form method="post" action="/admin/models/{{ model.id }}/image/delete">
<button class="btn btn--sm btn--danger" type="submit">Remove image</button>
</form>
{% endif %}
</div>
</section>
{% endfor %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+183
View File
@@ -477,3 +477,186 @@ def test_bulk_with_nothing_selected_is_harmless(client: TestClient, db, register
assert client.post( assert client.post(
"/admin/models/bulk", data={"action": "disable"}, follow_redirects=False "/admin/models/bulk", data={"action": "disable"}, follow_redirects=False
).status_code == 303 ).status_code == 303
# --- The model admin list ----------------------------------------------------
def _many_models(db, count: int) -> None:
connection = _connection(db)
db.add_all(
[
Model(connection_id=connection.id, model_id=f"model-{i:03d}", position=i)
for i in range(count)
]
)
db.commit()
def test_the_list_paginates_rather_than_rendering_everything(
client: TestClient, db, registered
):
"""A hundred models must not become a hundred forms on one page."""
from lembas.api.admin_models import PAGE_SIZE
_many_models(db, PAGE_SIZE + 15)
page = client.get("/admin/models").text
assert page.count('name="model_ids"') == PAGE_SIZE
assert f"of {PAGE_SIZE + 15}" in page
assert "Page 1 of 2" in page
def test_the_second_page_shows_the_remainder(client: TestClient, db, registered):
from lembas.api.admin_models import PAGE_SIZE
_many_models(db, PAGE_SIZE + 15)
page = client.get("/admin/models?page=2").text
assert page.count('name="model_ids"') == 15
def test_an_out_of_range_page_is_clamped(client: TestClient, db, registered):
_many_models(db, 5)
assert "Page 1 of 1" not in client.get("/admin/models?page=99").text
assert client.get("/admin/models?page=99").status_code == 200
def test_the_list_does_not_render_edit_forms(client: TestClient, db, registered):
"""The whole point of the split: rows link to a page, they are not forms."""
_many_models(db, 3)
page = client.get("/admin/models").text
assert 'name="system_prompt"' not in page
assert 'name="display_name"' not in page
assert page.count("/edit") >= 3
def test_search_narrows_the_list(client: TestClient, db, registered):
connection = _connection(db)
db.add_all(
[
Model(connection_id=connection.id, model_id="llama-large", position=0),
Model(connection_id=connection.id, model_id="qwen-small", position=1),
]
)
db.commit()
page = client.get("/admin/models?q=qwen").text
assert "qwen-small" in page
assert "llama-large" not in page
def test_search_matches_the_display_name_too(client: TestClient, db, registered):
connection = _connection(db)
db.add(
Model(connection_id=connection.id, model_id="abc-123", display_name="Friendly Name")
)
db.commit()
assert "abc-123" in client.get("/admin/models?q=friendly").text
def test_filter_tabs_narrow_the_list(client: TestClient, db, registered):
connection = _connection(db)
db.add_all(
[
Model(connection_id=connection.id, model_id="on-model", position=0),
Model(connection_id=connection.id, model_id="off-model", position=1, enabled=False),
]
)
db.commit()
disabled = client.get("/admin/models?filter=disabled").text
assert "off-model" in disabled
assert ">on-model" not in disabled
def test_filtering_by_connection(client: TestClient, db, registered):
first = _connection(db)
second = _connection(db)
db.add_all(
[
Model(connection_id=first.id, model_id="from-first", position=0),
Model(connection_id=second.id, model_id="from-second", position=1),
]
)
db.commit()
page = client.get(f"/admin/models?connection={second.id}").text
assert "from-second" in page
assert "from-first" not in page
# --- The model detail page ---------------------------------------------------
def test_the_detail_page_carries_the_full_form(client: TestClient, db, registered):
_many_models(db, 3)
db.add(Group(name="Insiders"))
db.commit()
model = db.scalar(select(Model).where(Model.model_id == "model-001"))
page = client.get(f"/admin/models/{model.id}/edit").text
for field in ('name="display_name"', 'name="system_prompt"', 'name="capability"',
'name="group_ids"', 'name="position"'):
assert field in page, field
def test_the_detail_page_links_to_its_neighbours(client: TestClient, db, registered):
_many_models(db, 3)
first, middle, last = db.scalars(select(Model).order_by(Model.position)).all()
page = client.get(f"/admin/models/{middle.id}/edit").text
assert f"/admin/models/{first.id}/edit" in page
assert f"/admin/models/{last.id}/edit" in page
assert "2 of 3" in page
def test_an_unknown_model_detail_is_404(client: TestClient, db, registered):
assert client.get("/admin/models/nope/edit").status_code == 404
def test_saving_from_the_detail_page_returns_to_it(client: TestClient, db, registered):
_many_models(db, 2)
model = db.scalar(select(Model).where(Model.model_id == "model-000"))
response = client.post(
f"/admin/models/{model.id}",
data={"display_name": "Renamed", "enabled": "true", "public": "true"},
follow_redirects=False,
)
assert response.headers["location"].startswith(f"/admin/models/{model.id}/edit")
db.refresh(model)
assert model.display_name == "Renamed"
def test_typing_a_position_moves_the_model(client: TestClient, db, registered):
"""Up/down is unusable for moving a model 60 places."""
_many_models(db, 5)
last = db.scalar(select(Model).where(Model.model_id == "model-004"))
client.post(
f"/admin/models/{last.id}",
data={"display_name": "", "enabled": "true", "public": "true", "position": "1"},
)
order = [m.model_id for m in db.scalars(select(Model).order_by(Model.position))]
assert order[0] == "model-004"
def test_a_nonsense_position_is_ignored(client: TestClient, db, registered):
_many_models(db, 3)
model = db.scalar(select(Model).where(Model.model_id == "model-000"))
client.post(
f"/admin/models/{model.id}",
data={"display_name": "", "enabled": "true", "public": "true", "position": "abc"},
)
db.refresh(model)
assert model.position == 0
def test_moving_returns_to_the_filtered_view(client: TestClient, db, registered):
_many_models(db, 3)
model = db.scalar(select(Model).where(Model.model_id == "model-001"))
response = client.post(
f"/admin/models/{model.id}/move",
data={"direction": "up", "back": "/admin/models?filter=enabled&page=2"},
follow_redirects=False,
)
assert response.headers["location"] == "/admin/models?filter=enabled&page=2"