Three features sharing one idea: a model here started from nothing every
conversation and had no notion that anything else existed.
THE ROSTER. `chat.roster_block` builds one line per model this *person* can
reach -- through `permissions.models_visible_to`, never the table -- and
`{{model_roster}}` carries it, gated on the `friend` family for the reason the
memories block is gated on `memory`: a list of peers a model cannot talk to is
context spent on nothing, and one checkbox is then the whole switch. New
`Model.notes` column, a column and not a `capabilities_json` key for the reason
`context_length` and `reasoning_efforts` both carry.
ASKING A FRIEND. A second entry point in `services/subagent.py` rather than a
second module, so one place still owns the bounds and the lifecycle. `_create_
child` takes the friend's (model_id, connection_id) *pair*, because Model is
unique on both and an id alone does not say which endpoint. Three things differ
from a helper: the effort is the friend's own default and never the parent's (the
1.3.0 bug by another door -- the vocabularies differ and a level a model does not
take raises inside its chat template), the chat is ordinary even when the asker's
is an agent chat, and `scope_json["role"]` marks it so `core.friend` speaks
instead of `core.subagent`. `friend` joins the unattended withdrawal set: a
friend that could ask a friend is the same unbounded fan-out in politer clothes.
Budget, concurrency and quota are shared with helpers, so one reply cannot spend
the allowance twice.
PERSONALITY. One table, two roles, `owner_id IS NULL` the discriminator: the
model's own persona, and its read of one person. Keyed on the model's *text* id
with no foreign key, because "Test & refresh" deletes a model the endpoint has
stopped listing and a personality must not be collateral. `PersonaRevision`
copies SkillRevision, and so does the argument: the safety story for a model
rewriting itself is a record and a way back, not a gate. The reflection is shown
to the person it is about, in their own settings, which is the whole of why
keeping one is acceptable. `persona` is withdrawn from any unattended chat --
a helper's task, a friend's question and a schedule's instruction are all words
nobody watched being written.
Two bugs found while reading for this, both silent:
`review_model_id` stored a `Model` primary key, so a refresh taken while an
endpoint was not listing that model unset the administrator's choice -- and
`_reviewer` then fell back to the chat's own model, so pictures were judged by
a model nobody chose. Now the text id, with the primary key still accepted.
`_messages_after` used a bare `>` on `created_at`, so a row sharing the edited
turn's microsecond survived a rewind -- and `_send` writes a user turn and its
placeholder back to back, which is exactly that tie. Deliberately NOT
`thread_tail`'s `(created_at, id)` tiebreak: ids are random UUIDs, so that
settles a tie by coin toss. A tie now reads as "later", which is the safe
direction for an operation whose purpose is to discard what follows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
549 lines
20 KiB
Python
549 lines
20 KiB
Python
"""Model administration: ordering, defaults, images, access and capabilities."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
from urllib.parse import quote
|
|
|
|
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 AUTHOR_USER, Connection, Group, Model, PersonaRevision
|
|
from lembas.services import chat as chat_service
|
|
from lembas.services import personas as personas_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_friend", "Ask another model"),
|
|
("tool_persona", "Edit its own personality"),
|
|
("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 = "",
|
|
detected: str = "",
|
|
message: 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,
|
|
# Every effort this application understands, so an administrator
|
|
# can tick the ones their model actually takes -- and the model's
|
|
# current answer, which is the common three until somebody says.
|
|
"efforts": chat_service.EFFORTS,
|
|
"model_efforts": chat_service.efforts_for(model),
|
|
# What `detect-efforts` found, if it has just run. Escaped by the
|
|
# template like every other value; it is prose the endpoint or this
|
|
# application wrote, not markup.
|
|
"detected": detected if detected in ("success", "warning") else "",
|
|
"detected_message": message[:400],
|
|
# 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 "",
|
|
# Who this model is, and everything it has been before. Passed even
|
|
# when the capability is off: an administrator has to be able to read
|
|
# and undo what a model wrote *before* they switched it off, which is
|
|
# exactly when they would come looking.
|
|
"persona": personas_service.get(db, model.model_id, None),
|
|
"persona_limit": personas_service.MAX_PERSONA_CHARS,
|
|
"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(""),
|
|
notes: 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(""),
|
|
reasoning_efforts: list[str] = Form(default=[]),
|
|
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.notes = notes.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.
|
|
# Which efforts this model takes at all. Submitted as a list of ticked
|
|
# values; empty means "nobody has said", and `chat.efforts_for` answers with
|
|
# the common three. Stored in the order `EFFORTS` declares rather than the
|
|
# order a browser happened to send.
|
|
chosen = [value for value in chat_service.EFFORTS if value in (reasoning_efforts or [])]
|
|
model.reasoning_efforts = chosen
|
|
|
|
params = dict(model.params_json or {})
|
|
wanted = default_effort.strip().lower()
|
|
# Checked against what this model takes, not against everything this
|
|
# application has heard of -- a default of `high` on a model whose template
|
|
# refuses it is a chat that fails on its first turn.
|
|
if wanted in chat_service.efforts_for(model):
|
|
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}/persona")
|
|
async def update_persona(
|
|
db: Db,
|
|
user: AdminUser,
|
|
model_id: str,
|
|
content: str = Form(""),
|
|
) -> Response:
|
|
"""Write or clear this model's own personality.
|
|
|
|
Its own form and its own route rather than a field on the big save, for the
|
|
reason the effort detection has one: the text can be rewritten by the model
|
|
itself between two page loads, and a field carried along by an unrelated save
|
|
would put a stale copy back without anybody meaning to.
|
|
"""
|
|
model = _model(db, model_id)
|
|
text = content.strip()
|
|
existing = personas_service.get(db, model.model_id, None)
|
|
|
|
if not text:
|
|
if existing is not None:
|
|
personas_service.clear(db, existing)
|
|
log.info("persona for %s cleared by %s", model.model_id, user.email)
|
|
return RedirectResponse(
|
|
f"/admin/models/{model.id}/edit?saved=Personality+cleared.", status_code=303
|
|
)
|
|
|
|
personas_service.write(
|
|
db,
|
|
model_key=model.model_id,
|
|
owner=None,
|
|
content=text,
|
|
author=AUTHOR_USER,
|
|
note="edited here",
|
|
)
|
|
log.info("persona for %s written by %s", model.model_id, user.email)
|
|
return RedirectResponse(
|
|
f"/admin/models/{model.id}/edit?saved=Personality+saved.", status_code=303
|
|
)
|
|
|
|
|
|
@router.post("/admin/models/{model_id}/persona/revert")
|
|
async def revert_persona(
|
|
db: Db,
|
|
user: AdminUser,
|
|
model_id: str,
|
|
revision_id: str = Form(""),
|
|
) -> Response:
|
|
"""Put an earlier text back. The text being replaced is itself kept."""
|
|
model = _model(db, model_id)
|
|
row = personas_service.get(db, model.model_id, None)
|
|
revision = db.get(PersonaRevision, revision_id) if revision_id else None
|
|
# Checked against *this* persona rather than merely existing: a revision id
|
|
# from another model's history would otherwise transplant its personality.
|
|
if row is None or revision is None or revision.persona_id != row.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such version")
|
|
|
|
personas_service.revert(db, row, revision)
|
|
log.info("persona for %s reverted by %s", model.model_id, user.email)
|
|
return RedirectResponse(
|
|
f"/admin/models/{model.id}/edit?saved=Earlier+version+restored.", 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}/detect-efforts")
|
|
async def detect_efforts(db: Db, user: AdminUser, model_id: str) -> Response:
|
|
"""Ask the endpoint which reasoning efforts this model actually takes.
|
|
|
|
llama-server hands its loaded model's Jinja chat template over on `/props`,
|
|
and that template is the thing that rejects an effort it does not know -- so
|
|
the accepted set is written down in the one place that is authoritative,
|
|
rather than having to be guessed at or discovered by a failed reply.
|
|
|
|
Anything that is not a llama-server answers nothing here, and that is a
|
|
normal outcome: OpenAI and vLLM have no such route, and their models are
|
|
documented rather than introspectable. The result then says so instead of
|
|
claiming the model accepts nothing.
|
|
"""
|
|
from lembas.services.llm.openai_client import Endpoint, fetch_chat_template
|
|
|
|
model = _model(db, model_id)
|
|
connection = db.get(Connection, model.connection_id)
|
|
if connection is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
|
|
|
|
template = await fetch_chat_template(Endpoint.from_connection(connection))
|
|
found = chat_service.efforts_from_chat_template(template)
|
|
|
|
if found:
|
|
model.reasoning_efforts = found
|
|
db.commit()
|
|
message = "This model's template accepts: " + ", ".join(found) + "."
|
|
kind = "success"
|
|
elif template:
|
|
message = (
|
|
"The endpoint gave up its chat template, but nothing in it names a "
|
|
"set of reasoning efforts. Either this model does not take one, or "
|
|
"it accepts anything and never checks."
|
|
)
|
|
kind = "warning"
|
|
else:
|
|
message = (
|
|
"This endpoint does not publish its chat template, so there is "
|
|
"nothing to read. llama.cpp does; OpenAI and vLLM do not."
|
|
)
|
|
kind = "warning"
|
|
|
|
return RedirectResponse(
|
|
f"/admin/models/{model.id}/edit?detected={kind}&message={quote(message)}",
|
|
status_code=status.HTTP_303_SEE_OTHER,
|
|
)
|
|
|
|
|
|
@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"},
|
|
)
|