Files
LLeMbas/src/lembas/api/admin_models.py
T
Jaroslav Beneš 757ab305ee Finding a thing that does not use your words
Three pieces, and the first one is that they are all optional.

Extraction stops being constants. Upload size, image edge, JPEG quality, PDF
pages, extracted characters, orphan age and the text-extension list are settings
now, read through a process-level snapshot rather than a session -- `prepare` and
everything under it are called from routes, tool runners and the startup sweep,
and several of those have no session in hand. Two things deliberately stayed
constants: the decompression-bomb guard, which is a guard and not a preference,
and ORPHAN_AGE, which would have been evaluated at import if it stayed in the
signature and pinned the shipped 24 hours whatever anybody set.

An embedding model is picked from the models an administrator flagged for it, and
one that has since lost its flag is *named* rather than dropped from the picker:
a setting that vanishes is one nobody can tell from a setting never made. Nothing
here is required. Choosing none means no chunk rows, no requests, and
retrieval.search returning exactly what fts.search_ids returns in exactly that
order -- asserted, because it is what makes this safe to land on an instance that
never asked for it.

The two rankings are fused by reciprocal rank fusion: ranks and not scores,
because bm25 is a corpus-dependent negative and cosine is 0..1, and normalising
them onto one scale means picking a constant nobody can tune without a labelled
set they do not have. RRF's one constant is famously insensitive and degrades to
whichever list is non-empty -- which is what turns "no embedding model" into a
branch that does not exist.

A record scores as its best chunk rather than its average, or a long document
about something else outranks a short one that says the thing. Width and model
are stored beside every vector and a mismatch is skipped, because vectors from
two spaces score against each other perfectly happily and mean nothing -- a
search that works and is wrong is the worst failure this can have, and a model
change now leaves stale rows ignored rather than trusted.

Indexing is fired and forgotten, and how a change is noticed is a session event
rather than a call in each of the ten library writers. That is a departure from
this codebase's taste for explicit seams, for the reason tool_label is a Jinja
global: a step every writer has to remember is one that gets forgotten, and here
forgetting is silent -- the record saves, keyword search still finds it, and only
its recall goes stale. Chunks are embedded before anything is deleted, so a
failure leaves the old index rather than half a new one.

Also: `embeddings` joins the model capabilities, and the three tool flags that
had shipped with no checkbox -- canvas, scheduling and helpers -- have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:15:21 +02:00

399 lines
14 KiB
Python

"""Model administration: ordering, defaults, images, access and capabilities."""
from __future__ import annotations
import contextlib
import logging
from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile, status
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db, RequiredUser
from lembas.db.models import Connection, Group, Model
from lembas.services import chat as chat_service
from lembas.services import settings_store, uploads
from lembas.services.llm.openai_client import MAX_CONTEXT
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(tags=["admin-models"])
# What the endpoint can do. Endpoints do not advertise any of this reliably, so
# these are an administrator's assertion.
# `embeddings` is the odd one out and is worth naming as such: the other three
# say what a model can do in a *chat*, and this one says it is not for chatting
# at all. It is what /admin/extraction picks from, and nothing else reads it.
PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools", "embeddings")
# Which tools this model is given. Distinct from the above: `tools` is whether a
# tools array may be sent at all, these are what goes in it. Every one of them is
# meaningless unless `tools` is on.
#
# The last two are gates rather than single tools: one covers every custom HTTP
# tool an administrator has defined, the other every MCP server. Which of those
# a particular person gets is the tool's own group list, not a flag here -- a
# server can advertise forty tools, and a model page listing all of them is a
# page nobody can read.
TOOL_CAPABILITIES = (
("tool_web_search", "Web search"),
("tool_fetch", "Fetch a page"),
("tool_knowledge", "Knowledge"),
("tool_notes", "Notes"),
("tool_memory", "Memory"),
("tool_skills", "Skills"),
("tool_custom", "Custom tools"),
("tool_mcp", "MCP servers"),
("tool_ask", "Ask the reader"),
("tool_report", "Reports"),
("tool_image", "Image generation"),
("tool_scratch", "Canvas"),
("tool_schedule", "Scheduling"),
("tool_subagent", "Helpers"),
("tool_agent", "Agent execution"),
)
CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES)
def _model(db: DBSession, model_id: str) -> Model:
model = db.get(Model, model_id)
if model is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
return model
def _ordered(db: DBSession) -> list[Model]:
return list(
db.scalars(
select(Model).join(Connection).order_by(Model.position, Model.model_id)
)
)
def _renumber(db: DBSession) -> None:
"""Rewrite positions to 0..n-1.
Keeps the numbers dense so a move is always a swap with a neighbour, and
stops repeated reordering drifting into large sparse values.
"""
for index, model in enumerate(_ordered(db)):
model.position = index
db.commit()
# --- 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")
async def models_page(
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(
request,
"admin/models.html",
{
"models": visible,
"total": len(everything),
"matched": len(matching),
"page": page,
"pages": pages,
"page_start": start,
"connections": list(db.scalars(select(Connection).order_by(Connection.name))),
"default_model": settings_store.get(db, "default_model") 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": PROTOCOL_CAPABILITIES,
"tool_capabilities": TOOL_CAPABILITIES,
"efforts": chat_service.EFFORTS,
# Rows predating the split have no tool_* keys at all. Showing them
# unticked would be a lie: tools.enabled_tools treats absent as on
# when `tools` is on, so that an upgrade does not silently take web
# search away from every model already configured for it.
"tool_default": bool((model.capabilities_json or {}).get("tools")),
"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,
},
)
# Registered BEFORE /{model_id}: FastAPI matches in registration order, so
# with the parameterised route first, "bulk" is captured as a model id and
# the handler 404s on a model that does not exist.
@router.post("/admin/models/bulk")
async def bulk_models(
db: Db, user: AdminUser, action: str = Form(...), model_ids: list[str] = Form(default=[])
) -> Response:
"""Enable or disable several models at once.
A freshly refreshed connection can advertise dozens of models; turning them
off one at a time is not a reasonable way to spend an afternoon.
"""
models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
for model in models:
if action == "enable":
model.enabled = True
elif action == "disable":
model.enabled = False
elif action == "public":
model.public = True
model.groups = []
elif action == "private":
model.public = False
db.commit()
_renumber(db)
return RedirectResponse(
f"/admin/models?saved={len(models)}+model(s)+updated.", status_code=303
)
@router.post("/admin/models/{model_id}")
async def update_model(
db: Db,
user: AdminUser,
model_id: str,
display_name: str = Form(""),
description: str = Form(""),
system_prompt: str = Form(""),
enabled: bool = Form(False),
pinned: bool = Form(False),
public: bool = Form(False),
position: str = Form(""),
context_length: str = Form(""),
default_effort: str = Form(""),
group_ids: list[str] = Form(default=[]),
capability: list[str] = Form(default=[]),
) -> Response:
model = _model(db, model_id)
model.display_name = display_name.strip()[:300]
model.description = description.strip()[:2000]
model.system_prompt = system_prompt.strip()[:8000]
# A string, so an emptied field is distinguishable and junk can be ignored
# rather than becoming a 422 -- the same shape `position` uses below.
if context_length.strip():
with contextlib.suppress(ValueError):
model.context_length = min(max(int(context_length), 0), MAX_CONTEXT)
else:
model.context_length = 0
model.enabled = enabled
model.pinned = pinned
model.public = public
# Merged rather than rebuilt, unlike the capabilities below: params_json
# holds whatever sampling defaults an administrator has set and this form
# only carries one of them.
params = dict(model.params_json or {})
wanted = default_effort.strip().lower()
if wanted in chat_service.EFFORTS:
params["reasoning_effort"] = wanted
else:
params.pop("reasoning_effort", None)
model.params_json = params
# Absent checkboxes are simply missing from a form post, so the submitted
# list IS the complete new state -- rebuild rather than merge.
model.capabilities_json = {name: (name in capability) for name in CAPABILITIES}
if public:
# Group rows would be dead weight and misleading in the UI.
model.groups = []
else:
model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
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)
return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Saved.", status_code=303
)
@router.post("/admin/models/{model_id}/move")
async def move_model(
db: Db,
user: AdminUser,
model_id: str,
direction: str = Form(...),
back: str = Form(""),
) -> Response:
"""Swap a model with its neighbour."""
model = _model(db, model_id)
ordered = _ordered(db)
index = next((i for i, m in enumerate(ordered) if m.id == model.id), None)
if index is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
target = index - 1 if direction == "up" else index + 1
if 0 <= target < len(ordered):
ordered[index], ordered[target] = ordered[target], ordered[index]
for position, item in enumerate(ordered):
item.position = position
db.commit()
# 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")
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."""
model = _model(db, 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)
return RedirectResponse(
back or f"/admin/models/{model.id}/edit?saved=Now+the+default+model.",
status_code=303,
)
@router.post("/admin/models/{model_id}/image")
async def upload_model_image(
db: Db, user: AdminUser, model_id: str, image: UploadFile = File(...)
) -> Response:
model = _model(db, model_id)
payload = await image.read()
try:
filename = uploads.save_model_image(payload, image.content_type or "")
except uploads.UploadError as exc:
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.
if model.image_path:
uploads.delete_model_image(model.image_path)
model.image_path = filename
db.commit()
return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Image+updated.", status_code=303
)
@router.post("/admin/models/{model_id}/image/delete")
async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response:
model = _model(db, model_id)
if model.image_path:
uploads.delete_model_image(model.image_path)
model.image_path = ""
db.commit()
return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Image+removed.", status_code=303
)
# --- Serving model images ----------------------------------------------------
@router.get("/uploads/models/{filename}")
async def model_image(user: RequiredUser, filename: str) -> Response:
"""Serve a stored model avatar.
Behind the auth guard: these are instance assets, not public files, and
the path resolution in uploads refuses anything outside the directory.
"""
path = uploads.model_image_path(filename)
if path is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such image.")
return FileResponse(
path,
media_type=uploads.media_type_for(filename),
# Filenames are random and content-addressed in practice, so a long
# cache is safe: a new image gets a new name.
headers={"Cache-Control": "private, max-age=604800"},
)