4ced049ff8
An agent chat will act on a machine you choose, so this is the screen where you choose it. User-owned like a note, not admin-owned like a connection: these are somebody's own machines and somebody's own keys, and "anyone in this group may log in to my server" is a different feature with a different blast radius. services/sharing.py is deliberately not involved either -- sharing grants reading, and a host somebody else can read is a host they can log in to. Trust on first use, made explicit rather than assumed. Adding a host does not connect to it. Check looks at its key and shows you the fingerprint; nothing is sent until you accept, because get_server_host_key completes the key exchange and stops -- no username, no credential. Accepting pins it, and a host that later presents a different key is refused with the reason rather than quietly trusted. Moving a profile to another host or port forgets the pin, since a key belongs to the machine it came from. Four asyncssh defaults are actively wrong here and all four are passed explicitly: every LLeMbas user shares one unix account, so `known_hosts` would be a shared trust store, `client_keys` would authenticate one person with another's key, `config` would let a ProxyCommand redirect the connection, and `agent_path` would silently use $SSH_AUTH_SOCK. There is a test for exactly that, and it needs no server. Files go over SFTP rather than through a shell. The SSH exec protocol carries one command *string* that the far side parses, with no argv form at all, so a model-supplied path in a command line is unavoidably a quoting problem. Over SFTP a path is a path. Chat gains its kind, connection, project directory and mode; the first three are fixed once a chat has a message, because a transcript whose earlier turns ran somewhere else is not one conversation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
376 lines
13 KiB
Python
376 lines
13 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 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.
|
|
PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools")
|
|
|
|
# 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_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_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,
|
|
# 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(""),
|
|
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
|
|
|
|
# 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"},
|
|
)
|