Compare commits

11 Commits

Author SHA1 Message Date
Jaroslav Beneš 584beca22d Version 0.3.0
A regenerate button that works, per-reply metrics, temporary chats, prompt
suggestions, an admin request inspector and compaction. The bump also
invalidates the service worker's cache, which is keyed on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:04:47 +02:00
Jaroslav Beneš 17f3fa1946 Compaction: a button, and automatically when the window fills
A long conversation eventually just stops working. Compaction summarises the
earlier turns and sends the summary in their place.

The messages are kept. They stay in the transcript behind a collapsed
divider and simply stop being part of the request, which is what makes the
button safe to press and automatic compaction safe to have at all: a summary
that came out badly is a bad turn, not a lost conversation.

Stored on the Chat, not as a synthetic Message. A synthetic row needs a
role -- `system` breaks the one-system-message rule the moment build_messages
emits it beside the harness, and user/assistant makes it a turn people can
edit, regenerate from and copy, indistinguishable from a real one in all
four places a bubble is rendered. Worse, "editing rewinds, it does not
branch" would silently delete it and leave no marker that compaction had
happened at all.

The summary goes out as a user turn and an assistant turn, not one. A
leading assistant breaks templates requiring the first non-system message to
be user; a lone leading user produces user, user whenever the kept history
starts on a user turn -- which it always does, because the cutoff lands on a
finished reply.

compacted_through_id is a plain id rather than a foreign key: migrations.py
compiles only the column type, so a REFERENCES clause would exist on a fresh
database and not on an upgraded one, and a constraint half the fleet has is
worse than none. cutoff_message validates it on every read instead, and a
rewind past the boundary clears it.

Compacting again summarises only the delta, with the previous summary
supplied to be subsumed. Re-summarising the whole chat each time grows
quadratically and eventually exceeds the window it is protecting.

Automatically at the top of _run, not in post_message: that route's contract
is to return immediately and leave the slow part to a resumable connection,
and it also means build_request is called once, after compaction, with no
second assembly path. The trigger is the last reply's recorded usage plus an
estimate of the new turn -- retrospective because true prompt_tokens are only
knowable after a response, plus the delta because otherwise fifty thousand
characters pasted into the composer overflow a window that read 90% last
turn. It never fires when the context length is unknown. It does fire on
estimated counts, which is safe here precisely because nothing is lost.

_maybe_compact never raises: a failure logs and sends the uncompacted
request. A `status` event says "Summarising earlier messages…" in the
meantime, because a silent multi-second pause before the first token is what
a hang looks like.

The wording is three fragments under Admin - Prompts. Clearing task.compact
turns compaction off entirely.

Also adds compaction.moment(): SQLite does not store the offset, so a row
loaded from disk is naive while one in the session's identity map keeps its
tzinfo, and comparing the two raises. Every comparison here is between
exactly those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:02:02 +02:00
Jaroslav Beneš 314cc946d7 An admin inspector on the right of the chat
A third child of .shell, opening and closing like the sidebar opposite it,
showing the system message that would go out, the tools offered, what the
last reply cost, and the whole request body as JSON.

Rebuilt, not recorded. Recording every request would store a copy of the
growing conversation against every message -- quadratic in chat length -- and
the thing an administrator debugging a bad answer actually wants is what the
current configuration produces. The panel says exactly that at the top, so
nobody mistakes it for forensics.

Owner-checked and admin-checked, not admin alone. permissions.resolve giving
an admin everything is about configuration, which they can grant themselves
anyway; reading someone's conversation is a different act, and it is why
sharing.visible_to has no admin branch. An inspector that could dump any
user's transcript would be that branch under another name.

No new JavaScript. app.js already delegates [data-toggle], and
hx-trigger="intersect once" makes the load lazy for free: a hidden element
never intersects, so the request fires the first time it is opened and never
on a page load nobody looked at.

Image data URIs are replaced before dumping -- fidelity is the point, but not
several megabytes of base64 in the DOM. Everything renders through normal
escaping and never |safe: this JSON is full of model output, search results
and uploaded documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:51:57 +02:00
Jaroslav Beneš 26793b1317 Prompt suggestions on the new-chat screen
A blank composer is the least helpful thing a chat client can show someone
who has just installed one. Three cards now sit under the empty state, and
an administrator manages them at /admin/suggestions.

Clicking a card fills the composer and stops there. It deliberately does not
send: every default ends mid-sentence, because a card is a starting point
rather than a question somebody already asked, and the caret lands where the
person has to start typing.

Seeding is guarded by a settings flag, not by "is the table empty" --
otherwise an administrator who decided against them would get all three back
on every restart. Capped at twelve, six shown: past a dozen this is a menu,
and a menu on the empty screen is a worse blank page than a blank page.

The cards are gated on there being no chat at all, not on the thread being
empty. An empty chat someone opened on purpose already has a model and a
prompt chosen.

Also fixes a pre-existing bug the position test caught. Both this and
_refresh_models wrote `coalesce(max(position), -1) or -1`, and position 0 is
falsy -- so the second row landed back on 0 on top of the first. The
coalesce was already doing that job; the `or` was undoing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:49:12 +02:00
Jaroslav Beneš 09eecbdd9a Temporary chats
A clock in the top-right starts one. It is never listed in the sidebar and
is swept a day after the last thing said in it.

A real row rather than something held in the browser, because a reload, a
crash or a background tab all look identical from here -- "delete when you
navigate away" would lose conversations people meant to keep. The flag rides
in the URL (/chat?temporary=1) rather than in JavaScript, so it survives a
reload and can be bookmarked, and the composer carries it as a hidden field
beside model_id.

Keep clears the flag. Without a way out, a conversation that turns out to
matter is destroyed a day later with no recourse, and people would find that
out exactly once.

archived was filtered in three places and temporary mirrors all three, plus
Folder.visible_chats. It also skips the unread flag in _persist: there is no
sidebar row for the dot to land on, and the toast would name a chat nobody
can navigate to.

The sweep measures age from the newest message, not from the chat row.
created_at would destroy a conversation still in use at hour 23, and
updated_at does not move when a message is inserted -- onupdate fires on an
UPDATE of the chat, and adding a message is not one. It runs at startup
beside the existing upload sweep.

Deleting a chat cascades its rows but leaves the files on disk; only the
orphan sweep unlinks anything, and it looks only at uploads that were never
attached. files.remove_files_for_chats() closes that for the new sweep. The
same hole in delete_chat is pre-existing and left for its own change, which
can now call the same helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:45:03 +02:00
Jaroslav Beneš e185edc9e1 Show what a reply cost, live and afterwards
Tokens, how full the context is, and tokens per second -- as chips under
each assistant bubble, updating while the reply streams and still there when
it finishes.

The numbers come from one Metrics object built either from the generation
still being written or from the row it left behind. That is the point rather
than tidiness: the finished bubble is re-rendered from the database the
instant the stream ends, so two code paths would make the figures visibly
jump at exactly the moment someone is watching them. Here the only thing
that changes is that an estimate may become exact.

Message.usage_json has existed and been dead since the schema was written.
It is the store.

Two counts that look like one. prompt and completion are summed across tool
rounds -- what the reply cost. context_tokens is overwritten each round with
that round's prompt plus completion -- what the window actually holds. A
three-round reply pays for its prompt three times and only ever occupies the
window once, so a single number would be wrong for one of the two questions.

Generation gains started_at as a field rather than a local in _run, because
_follow is a different function that sees only the Generation and otherwise
has nothing to compute a live speed against. It also carries a prompt
estimate taken before the first chunk, since real usage arrives in one chunk
at the very end and a percentage that appears only after the reply is
useless.

Everything is marked with a tilde when the endpoint reported nothing, and
the percentage is simply absent when no context length is set: unknown has
to stay tellable from small, and a percentage of an unknown total is a
made-up number in a place people trust numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:40:44 +02:00
Jaroslav Beneš 9e2caeac48 Ask the endpoint what a streamed reply cost
A streamed completion carries no token counts unless you ask for them, and
`stream_options: {include_usage: true}` is how. Not every server implements
it, and an unknown key is a 400 from some -- the same hazard as sending a
tools array to an endpoint without support. So it is asked for once per base
URL per process, and an endpoint that refuses is remembered and retried
without it. The retry is safe because the status is checked before a single
line is read: nothing has been yielded, so there is nothing to duplicate.

chunk_usage() reads the resulting chunk. It needed no change to the loop
above it: a usage chunk carries `choices: []`, which is exactly the shape
delta_text, delta_reasoning, delta_tool_calls and finish_reason have always
returned early on. All-zero counts are treated as absent, because some
servers attach zeros to every chunk and the real numbers only at the end.

services/tokens.py is the fallback for endpoints that never report: four
characters to a token, counting the tools array because thirteen schemas is
a meaningful slice of a short window, and counting nothing for an image
because its cost depends on tiling and an invented number would be worse
than the omission. Crude on purpose -- a real tokeniser means one per model
family, for a figure that is displayed beside a tilde.

Nothing uses any of this yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:36:12 +02:00
Jaroslav Beneš 2fe736aa6a Models know how much context they hold
A column rather than a key in capabilities_json, which is rebuilt wholesale
from the submitted checkboxes on every save and would destroy a number
living in it.

0 means unknown, and unknown has to stay tellable from small: the context
percentage and automatic compaction both refuse to act on a figure nobody
supplied. Filled in from /v1/models where the runner advertises it --
OpenRouter, vLLM and llama.cpp each spell it differently, so context_from()
reads the four spellings actually in use, accepts a quoted number but not
"8192 tokens", and rejects anything outside 256..10,000,000. Applied on
discovery only when nothing is set: a refresh must never undo a correction,
since an administrator sets this precisely because the endpoint was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:33:50 +02:00
Jaroslav Beneš 6dd13b2e9d One thread template, one sidebar toggle
chat/index.html and chat/_thread.html held the same loop, so anything added
to the conversation -- a compaction divider, say -- would have had to be
written into both and kept in step by hand. index.html includes the partial
instead.

The sidebar toggle was a raw inline onclick, the only one left in the
application. app.js already delegates [data-toggle="#selector"] and gives
open/close, aria-expanded and an is-active button state for free; the chat
settings gear has used it all along. Also deletes the
.sidebar[data-collapsed="true"] rule, which nothing has ever set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:31:26 +02:00
Jaroslav Beneš ec457debb3 Archived chats no longer show inside folders
The unfiled list has filtered archived chats since archiving existed
(api/pages.py). The folder branch went through the ORM relationship, which
filters nothing, so an archived chat kept appearing as long as it was
filed -- and the "Empty" check read the same unfiltered list, so a folder
holding only archived chats would have claimed to be empty while listing
them.

Fixed on the model rather than in the template, as `Folder.visible_chats`.
The loop and the empty check now cannot disagree, because there is one
list and the template binds it once. Ordering matches the unfiled list:
pinned first, then most recently touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:30:08 +02:00
Jaroslav Beneš 85f18e99b2 Regenerate actually regenerates
`ensure` is keyed on message_id and idempotent on purpose -- a page load
finding an unfinished reply must attach to it rather than start a second
one, and `_follow` calls it too. But finished generations linger in the
registry for KEEP_FINISHED so a follower arriving at the last moment still
gets the final frames, and regenerate is the only caller that reuses an
existing Message row instead of creating a new one. So `ensure` handed back
the finished generation: no request was made, `_follow` replayed the old
answer, and the `done` frame re-rendered a streaming shell because the row
said incomplete. That is the reconnect loop, and the Send button stuck on
Stop. It appeared to work after five minutes only by accident, and only
sometimes: `_prune` sat below the early return, so it was unreachable for
exactly the message that needed it.

`restart()` is the explicit opposite of `ensure`, and regenerate calls it.
`_prune` moves above the lookup.

Cancelling a live predecessor makes its `finally:` run `_persist` on the
same row, which would overwrite the reply that replaced it. `_persist` now
refuses when another generation owns the message -- "someone else owns this
row now", not "this one is registered", so a direct call still writes.

Three things found next door, all in the same area and all bugs:

  - `done` was set before `_persist` committed, while `_follow`'s docstring
    claimed the opposite. `_follow` breaks out the instant it sees the flag
    and re-renders the bubble from the row, so the row has to be right
    first. Harmless today, a guaranteed loss once metrics land there.
  - Live reasoning duplicated quadratically. The frame carries the whole
    block each time, exactly as `render` and `tools` do, but the target
    swapped it `beforeend`.
  - `sse.KEEPALIVE` was defined and never yielded. A model thinking for
    ninety seconds emits nothing, and an idle connection is what a proxy
    closes.

There was no test for regenerate at all. There is now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:28:19 +02:00
49 changed files with 3924 additions and 42 deletions
+49 -3
View File
@@ -19,7 +19,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 488 tests, ~29s
pytest # 590 tests, ~35s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -81,6 +81,7 @@ src/lembas/
admin_audio.py speech-to-text and text-to-speech endpoints
admin_search.py web search provider and credentials
admin_prompts.py the prompt fragment editor and its preview
admin_suggestions.py the cards offered on the new-chat screen
audio.py transcribe, speak, voice discovery
library.py knowledge, notes, skills pages; memory CRUD
files.py upload, serve, remove attachments
@@ -99,6 +100,10 @@ src/lembas/
fetch.py URL retrieval, HTML to text, the SSRF guard
sharing.py one visibility rule for every library store
prompts.py every injected prompt fragment, and {{variables}}
metrics.py tokens, context percentage and tokens/second
tokens.py the chars/4 estimate, for endpoints that report none
compaction.py summarising the earlier turns of a long chat
suggestions.py new-chat starting points, seeded once
harness.py the operational prompt built from what a model has
tools.py tool registry, schemas, streamed-call reassembly
chat.py request building, endpoint resolution, titles
@@ -213,8 +218,25 @@ NOT stop the reply -- that was the old behaviour and it cut answers off when
the reader navigated away. Any route that creates an assistant placeholder must
also call `generation.ensure()`.
**Stream frames carry whole blocks, not deltas.** Both `render` and `reasoning`
send the complete text each time. That is what makes reattaching mid-reply
**`ensure` attaches, `restart` replaces.** The registry is keyed on message id
and finished generations linger `KEEP_FINISHED` so a follower arriving at the
last moment still gets the final frames. `ensure` is idempotent because a page
load finding an unfinished reply must attach rather than start a second one.
Regeneration is the only caller that reuses a `Message` row, and therefore the
only one for which idempotence is wrong -- it got the finished generation back,
made no request, and left the browser reconnecting to a stream with nothing to
say. It calls `restart`. `_persist` refuses to write when another generation
owns the message, because a cancelled predecessor's `finally:` still runs.
**The row is written before `done` is set.** `_follow` breaks out the instant it
sees that flag and re-renders the bubble from the database, so the row has to be
authoritative first. The other order silently showed the previous turn's stored
metrics.
**Stream frames carry whole blocks, not deltas.** `render`, `reasoning`,
`metrics` and `status` all send the complete value each time, and every one of
them is swapped with `innerHTML`. `reasoning` used `beforeend` and so repeated
everything already shown on every frame. That is what makes reattaching mid-reply
work: a follower arriving late has no earlier fragments to append to. It also
means Markdown is re-rendered whole, which is required anyway -- a list or code
fence is only correct once its context exists.
@@ -490,5 +512,29 @@ flag, not a new code path. Its guidance is the same shape: a
harness, on the admin page and in the preview without touching the assembler,
the save handler or a template.
**Unknown is not zero.** `Model.context_length` of 0 means nobody has said how
big the window is, which is different from "small". The context percentage is
omitted rather than computed, and automatic compaction never fires. Token counts
fall back to `services/tokens.py` -- four characters to a token -- and anything
derived from an estimate is shown with a `~`. Compaction *does* act on an
estimate, because a premature compaction costs one turn of answer quality rather
than data: the messages are kept.
**Compaction hides turns, it does not delete them.** `Chat.compact_summary` plus
`compacted_through_id` say how far it reached; the messages stay in the
transcript behind a `<details>` divider and simply stop being part of the
request. The summary is carried by a **user turn and an assistant turn**, not
one: a leading `assistant` breaks templates that require the first non-system
message to be `user`, and a lone leading `user` produces `user, user` whenever
the kept history starts on a user turn -- which it always does, because the
cutoff lands on a finished reply. `compacted_through_id` is a plain id, not a
foreign key, because `migrations.py` compiles only the column type and a
`REFERENCES` clause would exist on a fresh database and not on an upgraded one;
`compaction.cutoff_message` validates it on every read instead.
**Compare message timestamps through `compaction.moment()`.** SQLite does not
store the offset, so a row loaded from disk is naive while one still in the
session's identity map keeps its tzinfo. Comparing the two raises.
No OCR: a scanned PDF is stored with an explanatory `extraction_error` rather
than silently contributing nothing.
+10
View File
@@ -47,6 +47,13 @@ be a different project, not a refactor.
- [x] **Unread indicator** — a green dot and a toast when a reply lands while
you were elsewhere
- [x] Folders, arbitrarily nested; deleting one keeps the chats inside it
- [x] Per-reply metrics — tokens, context used as a percentage, tokens/second,
live while streaming and kept afterwards. Estimated with a `~` when the
endpoint reports no usage
- [x] Compaction — a button, and automatically at a configurable percentage of
the model's context. Summarised turns are kept and collapsed, not deleted
- [x] Temporary chats — never listed, swept after a day, with a Keep button
- [x] An admin-only request inspector beside the thread
### Tools
- [x] **Tool calling** — one reply is a bounded loop of requests, not one
@@ -126,6 +133,9 @@ be a different project, not a refactor.
- [x] Defaults in code and overrides in the database, so improving a default
still reaches an instance that never edited it
### Suggestions
- [x] Admin-managed cards on the new-chat screen; three seeded once at startup
### Interface
- [x] **Installable** — manifest, generated PWA icons, a service worker for the
shell and a themed offline page. The worker deliberately never touches
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "lembas"
version = "0.2.0"
version = "0.3.0"
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "0.2.0"
__version__ = "0.3.0"
+26 -3
View File
@@ -14,7 +14,7 @@ from lembas.api.deps import AdminUser, Db
from lembas.db.models import Connection, Model, User
from lembas.services import settings_store
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt, mask
from lembas.services.llm.openai_client import Endpoint, LLMError, list_models
from lembas.services.llm.openai_client import Endpoint, LLMError, context_from, list_models
from lembas.web.templating import render
log = logging.getLogger(__name__)
@@ -58,6 +58,7 @@ async def save_general(
instance_name: str = Form("LLeMbas"),
allow_signup: bool = Form(False),
system_prompt: str = Form(""),
compact_threshold: int = Form(95),
) -> Response:
"""Save instance settings.
@@ -70,6 +71,12 @@ async def save_general(
"instance_name": instance_name.strip()[:120] or "LLeMbas",
"allow_signup": allow_signup,
"system_prompt": system_prompt.strip()[:8000],
# 0 is "never"; anything else is clamped into a band where it can
# do some good. 100 is useless -- you cannot compact after
# overflowing -- and below 50 it fires while there is plenty left.
"compact_threshold": (
0 if compact_threshold <= 0 else min(max(compact_threshold, 50), 99)
),
},
)
log.info("registration %s by %s", "opened" if allow_signup else "closed", user.email)
@@ -192,14 +199,30 @@ async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, s
# New models land after everything already ordered, rather than all at
# position 0 where they would sort by id and shuffle the existing list.
next_position = (db.scalar(select(func.coalesce(func.max(Model.position), -1))) or -1) + 1
# No `or -1` after the coalesce: position 0 is falsy, so that idiom sent the
# second discovered model back to 0 on top of the first.
highest = db.scalar(select(func.coalesce(func.max(Model.position), -1)))
next_position = int(highest if highest is not None else -1) + 1
for entry in discovered:
model_id = str(entry["id"])[:300]
seen.add(model_id)
if model_id in existing:
# A context length is filled in only when nobody has one yet. A
# refresh must never overwrite a number an administrator typed --
# they are usually correcting the endpoint.
model = existing[model_id]
if not model.context_length:
model.context_length = context_from(entry)
continue
db.add(Model(connection_id=connection.id, model_id=model_id, position=next_position))
db.add(
Model(
connection_id=connection.id,
model_id=model_id,
position=next_position,
context_length=context_from(entry),
)
)
next_position += 1
# Models that vanished upstream are dropped, so the picker never offers
+10
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
import logging
from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile, status
@@ -12,6 +13,7 @@ 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__)
@@ -213,6 +215,7 @@ async def update_model(
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:
@@ -221,6 +224,13 @@ async def update_model(
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
+113
View File
@@ -0,0 +1,113 @@
"""Administration for the cards offered on the new-chat screen."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.db.models import Suggestion
from lembas.services import suggestions as suggestions_service
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/suggestions", tags=["admin-suggestions"])
def _suggestion(db: Db, suggestion_id: str) -> Suggestion:
suggestion = db.get(Suggestion, suggestion_id)
if suggestion is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That suggestion no longer exists.")
return suggestion
def _back(message: str = "") -> Response:
target = f"/admin/suggestions?saved={message}" if message else "/admin/suggestions"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
@router.get("")
async def suggestions_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
rows = suggestions_service.all_of_them(db)
return render(
request,
"admin/suggestions.html",
{
"suggestions": rows,
"at_limit": len(rows) >= suggestions_service.MAX_SUGGESTIONS,
"max_suggestions": suggestions_service.MAX_SUGGESTIONS,
"max_shown": suggestions_service.MAX_SHOWN,
"saved": saved,
},
)
@router.post("")
async def create_suggestion(
db: Db,
user: AdminUser,
name: str = Form(""),
description: str = Form(""),
prompt: str = Form(""),
) -> Response:
name = name.strip()
if not name:
return _back("A suggestion needs a name.")
if len(suggestions_service.all_of_them(db)) >= suggestions_service.MAX_SUGGESTIONS:
return _back(f"That is already {suggestions_service.MAX_SUGGESTIONS}, which is plenty.")
suggestions_service.create(db, name=name, description=description, prompt=prompt)
log.info("%s added suggestion %s", user.email, name)
return _back(f"Added {name}.")
# Registered before /{suggestion_id}: FastAPI matches in registration order, so
# with the parameterised route first any literal segment added later would be
# captured as an id. That has already been a bug once, in /admin/models.
@router.post("/{suggestion_id}/delete")
async def delete_suggestion(db: Db, user: AdminUser, suggestion_id: str) -> Response:
suggestion = _suggestion(db, suggestion_id)
name = suggestion.name
db.delete(suggestion)
db.commit()
log.info("%s deleted suggestion %s", user.email, name)
return _back(f"Deleted {name}.")
@router.post("/{suggestion_id}")
async def update_suggestion(
request: Request,
db: Db,
user: AdminUser,
suggestion_id: str,
) -> Response:
"""Save one row.
The raw form is read rather than declared parameters because `enabled` is a
checkbox: FastAPI cannot tell an unticked box from an absent field, and an
absent one is exactly what an unticked box sends.
"""
suggestion = _suggestion(db, suggestion_id)
form = await request.form()
suggestion.name = (
str(form.get("name") or "").strip()[: suggestions_service.MAX_NAME] or suggestion.name
)
suggestion.description = str(form.get("description") or "").strip()[
: suggestions_service.MAX_DESCRIPTION
]
suggestion.prompt = str(form.get("prompt") or "").replace("\r\n", "\n")[
: suggestions_service.MAX_PROMPT
]
suggestion.enabled = "enabled" in form
position = str(form.get("position") or "").strip()
if position.isdigit():
suggestion.position = min(max(int(position) - 1, 0), 999)
db.commit()
log.info("%s updated suggestion %s", user.email, suggestion.name)
return _back(f"Saved {suggestion.name}.")
+230 -7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import time
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
@@ -18,9 +19,13 @@ from lembas.db.session import session_scope
from lembas.security import permissions
from lembas.services import audio as audio_service
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import files as files_service
from lembas.services import generation as generation_service
from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import sse
from lembas.services import tools as tools_service
from lembas.services.markdown import escape_text, render_markdown
from lembas.web.templating import render, templates
@@ -28,6 +33,11 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/chats", tags=["chats"])
# Seconds of silence before a comment frame is sent to hold the connection open.
# Well under nginx's 60s default; see services/sse.py:KEEPALIVE.
KEEPALIVE_AFTER = 15.0
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
chat = db.get(Chat, chat_id)
# 404 rather than 403 for someone else's chat: whether a given id exists is
@@ -37,7 +47,14 @@ def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
return chat
def _new_chat(db: DBSession, user: User, *, folder_id: str = "", model_id: str = "") -> Chat:
def _new_chat(
db: DBSession,
user: User,
*,
folder_id: str = "",
model_id: str = "",
temporary: bool = False,
) -> Chat:
"""Create a chat row, resolving which model it should use."""
chosen = None
if model_id:
@@ -54,6 +71,7 @@ def _new_chat(db: DBSession, user: User, *, folder_id: str = "", model_id: str =
folder_id=folder_id or None,
model_id=chosen[0] if chosen else "",
connection_id=chosen[1] if chosen else None,
temporary=temporary,
)
db.add(chat)
db.commit()
@@ -68,6 +86,7 @@ async def start_chat(
file_ids: list[str] = Form(default=[]),
folder_id: str = Form(""),
model_id: str = Form(""),
temporary: bool = Form(False),
) -> Response:
"""Create a chat from its first message.
@@ -80,7 +99,9 @@ async def start_chat(
if not content and not file_ids:
return Response(status_code=status.HTTP_204_NO_CONTENT)
chat = _new_chat(db, user, folder_id=folder_id, model_id=model_id)
chat = _new_chat(
db, user, folder_id=folder_id, model_id=model_id, temporary=temporary
)
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
if file_ids:
@@ -100,6 +121,166 @@ async def start_chat(
# /start when the first message is actually sent.
@router.get("/{chat_id}/inspect")
async def inspect_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
"""What this chat would send upstream right now.
Owner-checked *and* admin-checked, not admin alone. `permissions.resolve`
giving an admin everything is about configuration, which they can grant
themselves anyway; reading someone's conversation is a different act, which
is why `sharing.visible_to` has no admin branch either. An inspector that
could dump any user's transcript would be that branch under another name.
Rebuilt, not recorded. Recording every request would store a copy of the
whole conversation against every message, which grows quadratically with
chat length -- and the thing an administrator actually wants to see is what
the current configuration produces. The panel says so in as many words.
"""
chat = _owned_chat(db, chat_id, user.id)
if not user.is_admin:
raise HTTPException(
status.HTTP_403_FORBIDDEN, "The inspector is restricted to administrators."
)
offered = tools_service.enabled_tools(db, chat, user)
payload = chat_service.build_request(db, chat, tools=offered, user=user)
last = db.scalar(
select(Message)
.where(Message.chat_id == chat.id, Message.role == ROLE_ASSISTANT)
.order_by(Message.created_at.desc())
)
messages = payload.get("messages") or []
system = messages[0]["content"] if messages and messages[0].get("role") == "system" else ""
return render(
request,
"chat/_inspector_body.html",
{
"chat": chat,
"system": system,
"request_json": _pretty(_redact(payload)),
"row": last,
"metrics": metrics_service.from_message(last.usage_json if last else None),
"tool_names": [
(t.get("function") or {}).get("name", "") for t in offered
],
"model": chat_service.model_for(db, chat),
},
)
# Roughly what a downscaled phone photo comes to as base64. The exact figure
# does not matter; putting megabytes of it into the DOM does.
_REDACTED_URI = "data:…base64 image omitted…"
MAX_INSPECT_CHARS = 40_000
def _redact(payload: dict) -> dict:
"""Replace image data URIs before dumping.
Nothing else is hidden -- fidelity is the whole point of the panel, and API
keys never appear because `build_request` returns a body, not headers.
"""
messages = []
for message in payload.get("messages") or []:
content = message.get("content")
if isinstance(content, list):
parts = []
for part in content:
if isinstance(part, dict) and part.get("type") == "image_url":
parts.append({"type": "image_url", "image_url": {"url": _REDACTED_URI}})
else:
parts.append(part)
message = {**message, "content": parts}
messages.append(message)
return {**payload, "messages": messages}
def _pretty(payload: dict) -> str:
text = json.dumps(payload, indent=2, ensure_ascii=False, default=str)
if len(text) > MAX_INSPECT_CHARS:
return text[:MAX_INSPECT_CHARS] + "\n… truncated"
return text
@router.post("/{chat_id}/compact")
async def compact_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Summarise the earlier turns and stop sending them.
No permission of its own: compaction changes only what one chat sends
upstream, and gating it would mean answering "why can this user not tidy
their own conversation".
"""
chat = _owned_chat(db, chat_id, user.id)
unfinished = db.scalar(
select(Message).where(Message.chat_id == chat.id, Message.complete.is_(False))
)
if unfinished is not None:
# Summarising a transcript that is still being written races
# build_request. Queuing it is a state machine nobody asked for.
raise HTTPException(
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, then compact."
)
template = prompts_service.resolve(db, "task.compact")
if not template.strip():
raise HTTPException(
status.HTTP_409_CONFLICT,
"Compaction is turned off: its prompt is empty under Admin → Prompts.",
)
upto = compaction_service.last_complete(db, chat)
if upto is None:
raise HTTPException(
status.HTTP_409_CONFLICT, "There is nothing here to summarise yet."
)
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
transcript = compaction_service.transcript(db, chat, upto=upto)
previous = compaction_service.previous_summary_block(chat)
summary = await chat_service.summarise_for_compaction(
endpoint,
model_id,
transcript=transcript,
previous_summary=previous,
template=template,
)
if not summary:
raise HTTPException(
status.HTTP_409_CONFLICT, "The model returned no summary, so nothing changed."
)
compaction_service.apply(chat, summary=summary, upto=upto)
db.commit()
log.info("chat %s compacted through %s", chat.id, upto.id)
return templates.TemplateResponse(
request, "chat/_thread.html", {"request": request, **_thread_context(db, chat, user)}
)
@router.post("/{chat_id}/keep")
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Stop a temporary chat being temporary.
A conversation that turns out to matter has to have a way out; without one,
the sweep destroys it a day later with no recourse, and people discover that
exactly once.
"""
chat = _owned_chat(db, chat_id, user.id)
chat.temporary = False
db.commit()
response = Response(status_code=status.HTTP_204_NO_CONTENT)
# The sidebar has to gain a row and the topbar has to lose a badge; a full
# refresh is one line against a handful of out-of-band fragments.
response.headers["HX-Refresh"] = "true"
return response
@router.get("/unread")
async def unread_poll(db: Db, user: RequiredUser) -> Response:
"""Dots for the sidebar, and a toast for anything newly arrived.
@@ -113,7 +294,13 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
"""
chats = list(
db.scalars(
select(Chat).where(Chat.user_id == user.id, Chat.archived.is_(False))
select(Chat).where(
Chat.user_id == user.id,
Chat.archived.is_(False),
# A temporary chat has no sidebar row, so a dot has nowhere to
# land and the toast would name a chat nobody can navigate to.
Chat.temporary.is_(False),
)
)
)
@@ -240,6 +427,7 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
generation = generation_service.ensure(chat_id, message_id)
generation.followers += 1
seen = -1
last_frame = time.monotonic()
try:
while True:
@@ -251,9 +439,20 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
yield sse.event("tools", _tool_activity(generation.tool_events))
if generation.content:
yield sse.event("render", render_markdown(generation.text))
yield sse.event("metrics", _metrics_html(generation))
yield sse.event("status", escape_text(generation.status))
last_frame = time.monotonic()
if generation.done:
break
# A reasoning model can think for a minute or more without emitting
# anything, and an idle connection is what a proxy closes. The
# comment frame keeps it open and is ignored by the browser.
if time.monotonic() - last_frame > KEEPALIVE_AFTER:
yield sse.KEEPALIVE
last_frame = time.monotonic()
# Polling rather than per-follower wakeups: the producer already
# works in RENDER_INTERVAL steps, so a short sleep is simpler and
# cannot drop a notification.
@@ -261,7 +460,7 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
finally:
generation.followers = max(0, generation.followers - 1)
# The producer writes the message before marking itself done, so by here
# The producer commits the message before marking itself done, so by here
# the row is authoritative and the final bubble can be rendered from it.
with session_scope() as db:
message = db.get(Message, message_id)
@@ -299,18 +498,32 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
yield sse.event("close", "")
def _metrics_html(generation) -> str:
"""The metric chips for a reply still being written.
Built from the same Metrics object the finished bubble uses, so the numbers
do not jump when the stream ends -- the only thing that changes is that an
estimate may have become exact.
"""
return templates.get_template("chat/_metrics.html").render(
{"metrics": metrics_service.from_generation(generation)}
)
def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
"""Everything chat/_thread.html needs to render the conversation."""
messages = list(
everything = list(
db.scalars(select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at))
)
compacted, messages = compaction_service.split(db, chat, everything)
return {
"chat": chat,
"user": user,
"messages": messages,
"compacted": compacted,
"bodies": {
m.id: render_markdown(m.content)
for m in messages
for m in everything
if m.role == ROLE_ASSISTANT and m.content
},
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)},
@@ -406,6 +619,14 @@ async def edit_message(
discarded = _messages_after(db, message)
for later in discarded:
db.delete(later)
# A rewind to at or before the compaction boundary leaves that boundary
# describing turns that no longer exist. There is no foreign key to null it
# out on an upgraded database, so it is cleared here.
cutoff = compaction_service.cutoff_message(db, chat)
if cutoff is None or compaction_service.moment(message) <= compaction_service.moment(cutoff):
compaction_service.reset(chat)
db.commit()
assistant = chat_service.create_message(
@@ -592,7 +813,9 @@ async def regenerate(
message.complete = False
message.model_id = chat.model_id
db.commit()
generation_service.ensure(chat.id, message.id)
# restart, not ensure: this is the one caller that reuses a Message row, and
# the finished generation for it is still registered.
generation_service.restart(chat.id, message.id)
return templates.TemplateResponse(
request,
+17 -4
View File
@@ -12,7 +12,9 @@ from lembas.db.models import Chat, Folder, KnowledgeBase, Message, User
from lembas.security import permissions
from lembas.services import audio as audio_service
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import settings_store
from lembas.services import suggestions as suggestions_service
from lembas.services.library import documents as documents_service
from lembas.services.markdown import render_markdown
from lembas.web.templating import STATIC_DIR, render
@@ -83,6 +85,7 @@ def sidebar_context(db: DBSession, user: User) -> dict:
Chat.user_id == user.id,
Chat.folder_id.is_(None),
Chat.archived.is_(False),
Chat.temporary.is_(False),
)
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
)
@@ -164,11 +167,15 @@ async def offline(request: Request) -> Response:
@router.get("/chat")
async def chat_index(request: Request, db: Db, user: RequiredUser, model: str = ""):
async def chat_index(
request: Request, db: Db, user: RequiredUser, model: str = "", temporary: bool = False
):
"""A composer with no chat behind it yet.
`?model=` preselects one, which is how the pinned shortcuts work without
creating a row for a chat that may never be sent.
creating a row for a chat that may never be sent. `?temporary=1` is the
same idea for the temporary flag: it lives in the URL rather than in
JavaScript, so it survives a reload and can be bookmarked.
"""
context = _chat_context(db, user, None)
@@ -195,6 +202,8 @@ async def chat_index(request: Request, db: Db, user: RequiredUser, model: str =
"bodies": {},
**context,
"current_model": preselected,
"starting_temporary": temporary,
"suggestions": suggestions_service.visible(db),
**sidebar_context(db, user),
},
)
@@ -212,18 +221,21 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
chat.unread_notified = False
db.commit()
messages = list(
everything = list(
db.scalars(
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
)
)
# Summarised turns are kept and still rendered, behind a divider -- they
# have only stopped being part of the request.
compacted, messages = compaction_service.split(db, chat, everything)
# Markdown is rendered once here rather than in the template so the same
# helper produces the page and the streamed final frame -- one code path,
# no chance of the two disagreeing.
bodies = {
message.id: render_markdown(message.content)
for message in messages
for message in everything
if message.role == "assistant" and message.content
}
@@ -247,6 +259,7 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
{
"chat": chat,
"messages": messages,
"compacted": compacted,
"bodies": bodies,
"inherited_prompt": inherited,
"inherited_from": inherited_from,
+2
View File
@@ -41,6 +41,7 @@ from lembas.db.models.library import (
chat_knowledge_bases,
)
from lembas.db.models.setting import Setting
from lembas.db.models.suggestion import Suggestion
from lembas.db.models.user import (
ROLE_ADMIN,
ROLE_PENDING,
@@ -85,6 +86,7 @@ __all__ = [
"Share",
"Skill",
"SkillRevision",
"Suggestion",
"User",
"chat_knowledge_bases",
"model_groups",
+40 -1
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
@@ -45,6 +46,24 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
parent: Mapped[Folder | None] = relationship(back_populates="children", remote_side="Folder.id")
chats: Mapped[list[Chat]] = relationship(back_populates="folder")
@property
def visible_chats(self) -> list[Chat]:
"""The chats in this folder that belong in the sidebar.
The relationship itself stays unfiltered -- back-population needs every
row -- so the listing rule lives here rather than in the template, where
the loop and the "Empty" check would have to agree by hand and already
did not: archived chats have been showing inside folders since folders
existed. The unfiled list has always filtered them (api/pages.py); the
folder branch went through the relationship and filtered nothing.
Ordered like the unfiled list: pinned first, then most recently touched.
"""
kept = [chat for chat in self.chats if not chat.archived and not chat.temporary]
kept.sort(key=lambda chat: chat.updated_at, reverse=True)
kept.sort(key=lambda chat: not chat.pinned)
return kept
def __repr__(self) -> str:
return f"<Folder {self.name}>"
@@ -77,12 +96,32 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Never listed in the sidebar, and swept a day after the last thing said in
# it. A real row rather than something held in the browser, so a reload or a
# dropped connection does not lose the conversation -- and `Keep` clears the
# flag, because a temporary chat that turns out to matter must have a way
# out. See services/chat.py:sweep_temporary.
temporary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# A reply landed while nobody was watching this chat. Cleared when the chat
# is next opened. `unread_notified` stops the same arrival being announced
# on every poll.
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# --- Compaction ----------------------------------------------------------
# A summary of the turns up to `compacted_through_id`, sent in their place.
# The messages themselves are kept and still shown; they simply stop being
# part of the request. See services/compaction.py.
compact_summary: Mapped[str] = mapped_column(Text, default="")
# A plain id, deliberately not a ForeignKey: db/migrations.py compiles only
# the column type, so a REFERENCES clause would exist on a freshly created
# database and not on an upgraded one, and a constraint half the fleet has
# is worse than none. It is validated on every read instead -- the same
# reasoning `model_id` above carries.
compacted_through_id: Mapped[str | None] = mapped_column(String(32))
compacted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
folder: Mapped[Folder | None] = relationship(back_populates="chats")
messages: Mapped[list[Message]] = relationship(
back_populates="chat",
+11
View File
@@ -114,6 +114,17 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
# Default sampling params applied to new chats using this model.
params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# How many tokens this model can hold. 0 means unknown, which is what an
# endpoint that does not advertise it leaves behind -- and unknown has to
# stay tellable from "small", because the context percentage and automatic
# compaction both refuse to act on a number nobody supplied.
#
# A column rather than a key in capabilities_json: that dict is rebuilt
# wholesale from the submitted checkboxes on every save (api/admin_models.py),
# so a number living in it would be destroyed the next time an administrator
# ticked anything.
context_length: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
connection: Mapped[Connection] = relationship(back_populates="models")
groups: Mapped[list[Group]] = relationship(
"Group", secondary=model_groups, back_populates="models"
+32
View File
@@ -0,0 +1,32 @@
"""Starting points offered on the new-chat screen."""
from __future__ import annotations
from sqlalchemy import Boolean, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
class Suggestion(UUIDPrimaryKey, Timestamps, Base):
"""One card on the empty chat screen.
Instance-wide rather than per-user: these are what an administrator wants
people to start with, the same way the instance system prompt is. There is
no owner_id and therefore nothing for `sharing` to decide.
"""
__tablename__ = "suggestions"
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str] = mapped_column(String(300), default="")
# What lands in the composer. Deliberately not sent on its own: it usually
# ends mid-sentence, because a card is a starting point rather than a
# question somebody already asked.
prompt: Mapped[str] = mapped_column(Text, default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
def __repr__(self) -> str:
return f"<Suggestion {self.name}>"
+9
View File
@@ -18,6 +18,7 @@ from lembas.api import (
admin_models,
admin_prompts,
admin_search,
admin_suggestions,
admin_users,
audio,
auth,
@@ -62,14 +63,21 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# disk forever. Cheap, and startup is the natural moment for it.
try:
from lembas.db.session import session_scope
from lembas.services.chat import sweep_temporary
from lembas.services.files import sweep_orphans
from lembas.services.library.documents import sweep_unfiled
from lembas.services.suggestions import seed_defaults as seed_suggestions
with session_scope() as db:
sweep_orphans(db)
# Documents that predate knowledge bases have nowhere to live until
# this runs; see services/library/documents.py.
sweep_unfiled(db)
# Temporary chats older than a day. Startup only, like the sweeps
# above it -- see services/chat.py:sweep_temporary.
sweep_temporary(db)
# Three starting points on the empty screen, written once ever.
seed_suggestions(db)
except Exception: # noqa: BLE001 - housekeeping must never block startup
log.exception("orphaned upload sweep failed")
@@ -111,6 +119,7 @@ def create_app() -> FastAPI:
app.include_router(admin_audio.router)
app.include_router(admin_search.router)
app.include_router(admin_prompts.router)
app.include_router(admin_suggestions.router)
register_error_handlers(app)
return app
+107 -2
View File
@@ -3,9 +3,10 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
@@ -32,6 +33,9 @@ FORWARDED_PARAMS = frozenset(
MAX_TITLE_LENGTH = 60
# How long a temporary chat survives after the last thing said in it.
TEMPORARY_LIFETIME = timedelta(hours=24)
def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]:
"""Find the connection and model a chat should use.
@@ -170,11 +174,31 @@ def build_messages(
resolved, which is how the harness gets in front of the authored prompt
without this function knowing anything about tools.
"""
from lembas.services import compaction as compaction_service
from lembas.services import prompts as prompts_service
payload: list[dict[str, Any]] = []
system = effective_system_prompt(db, chat) if system_prompt is None else system_prompt
if system:
payload.append({"role": ROLE_SYSTEM, "content": system})
# Compacted turns are replaced by a summary carried in two turns rather than
# one. A leading `assistant` breaks templates that require the first
# non-system message to be `user`; a lone leading `user` produces user, user
# whenever the kept history starts on a user turn -- which it always does,
# because the cutoff lands on a finished reply. The pair alternates
# correctly in both directions and keeps exactly one system message.
cutoff = compaction_service.cutoff_message(db, chat)
if cutoff is not None:
lead = prompts_service.resolve(db, "task.compact_lead").strip()
ack = prompts_service.resolve(db, "task.compact_ack").strip()
summary = chat.compact_summary.strip()
payload.append(
{"role": ROLE_USER, "content": f"{lead}\n\n{summary}" if lead else summary}
)
if ack:
payload.append({"role": ROLE_ASSISTANT, "content": ack})
history = db.scalars(
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
).all()
@@ -182,6 +206,10 @@ def build_messages(
for message in history:
if upto is not None and message.id == upto.id:
break
if cutoff is not None and compaction_service.moment(
message
) <= compaction_service.moment(cutoff):
continue
# Skip turns that failed or produced nothing -- but a message carrying
# only an attachment has no text and must still be sent.
if message.error:
@@ -387,8 +415,85 @@ def create_message(
return message
async def summarise_for_compaction(
endpoint: Endpoint,
model_id: str,
*,
transcript: str,
previous_summary: str,
template: str,
) -> str:
"""Ask the model to summarise the earlier turns.
`template` is passed in for the same reason `generate_title`'s is: this runs
after the generation's session has closed, and opening another one there is
how you get a session that outlives its scope. An empty template means an
administrator cleared the fragment, and nothing is asked of anyone.
"""
from lembas.services import prompts as prompts_service
if not template.strip() or not transcript.strip():
return ""
prompt = prompts_service.substitute(
template, {"transcript": transcript, "previous_summary": previous_summary}
)
raw = await complete(
endpoint,
{
"model": model_id,
"messages": [{"role": ROLE_USER, "content": prompt}],
"max_tokens": 1200,
# Low, but not zero: this is recall, not invention.
"temperature": 0.3,
},
)
return raw.strip()
def sweep_temporary(db: DBSession, older_than: timedelta = TEMPORARY_LIFETIME) -> int:
"""Delete temporary chats nobody has touched for a day.
Age is measured from the newest message rather than from the chat row's own
timestamps. `created_at` would destroy a conversation still in use at hour
23, and `updated_at` does not move when a message is inserted -- `onupdate`
fires on an UPDATE of the chat, and adding a message is not one.
Startup only, like files.sweep_orphans beside it. A server that runs for a
month sweeps once; that is the trade the existing sweep already makes, and a
scheduler is a whole new concern for a single-worker application.
"""
cutoff = datetime.now(UTC) - older_than
newest = (
select(Message.chat_id, func.max(Message.created_at).label("last"))
.group_by(Message.chat_id)
.subquery()
)
stale = list(
db.scalars(
select(Chat)
.outerjoin(newest, newest.c.chat_id == Chat.id)
.where(
Chat.temporary.is_(True),
func.coalesce(newest.c.last, Chat.created_at) < cutoff,
)
)
)
if not stale:
return 0
files_service.remove_files_for_chats(db, [chat.id for chat in stale])
for chat in stale:
db.delete(chat)
db.commit()
log.info("swept %d temporary chat(s)", len(stale))
return len(stale)
def user_chats(db: DBSession, user_id: str, *, folder_id: str | None = None) -> list[Chat]:
query = select(Chat).where(Chat.user_id == user_id, Chat.archived.is_(False))
query = select(Chat).where(
Chat.user_id == user_id, Chat.archived.is_(False), Chat.temporary.is_(False)
)
if folder_id is not None:
query = query.where(Chat.folder_id == folder_id)
return list(db.scalars(query.order_by(Chat.pinned.desc(), Chat.updated_at.desc())))
+192
View File
@@ -0,0 +1,192 @@
"""Carrying a long conversation forward without carrying all of it.
Past a certain length every chat stops working: the window fills, and the only
options are to lose the beginning or to start again. Compaction summarises the
earlier turns and sends the summary in their place.
**The messages are kept.** They stay in the transcript, collapsed behind a
divider, and simply stop being part of the request. A summary that turned out
badly is then a bad turn rather than a lost conversation, which is what makes
the button safe to press and automatic compaction safe to have at all.
**Stored on the Chat, not as a synthetic Message.** A synthetic row would need a
role: `system` breaks the one-system-message rule the moment `build_messages`
emits it beside the harness, and `user`/`assistant` makes it a turn people can
edit, regenerate from and copy, indistinguishable from a real one in all four
places a bubble is rendered. Worse, "editing rewinds, it does not branch" would
silently delete it and leave no marker that compaction had ever happened.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import ROLE_ASSISTANT, Chat, Message
from lembas.services import metrics as metrics_service
from lembas.services import settings_store, tokens
log = logging.getLogger(__name__)
# What the summariser is shown. Past this the oldest turns are dropped with a
# marker: a transcript that does not fit the window it is protecting is no use.
MAX_TRANSCRIPT_CHARS = 24_000
# Settings key, in the GENERAL group. 0 turns automatic compaction off; the
# button still works, because a person asking for it does not need a threshold.
THRESHOLD_KEY = "compact_threshold"
DEFAULT_THRESHOLD = 95
def threshold(db: DBSession) -> int:
value = settings_store.get(db, THRESHOLD_KEY)
return int(value) if isinstance(value, (int, float)) else DEFAULT_THRESHOLD
def moment(message: Message) -> datetime:
"""A message's timestamp, always comparable.
SQLite does not store the offset, so a row loaded from disk comes back naive
while one still in the session's identity map keeps the tzinfo it was
created with. Comparing the two raises, and every comparison here is between
exactly those: a cutoff fetched by id against history loaded in bulk.
`files.sweep_orphans` already normalises for the same reason.
"""
created = message.created_at
return created if created.tzinfo is not None else created.replace(tzinfo=UTC)
def cutoff_message(db: DBSession, chat: Chat) -> Message | None:
"""The message compaction reached, or None if it never has.
There is no foreign key to null this out on an upgraded database, so the
check is load-bearing rather than defensive: an id pointing at a message
that has been deleted means the boundary no longer describes anything, and
the chat has to read as uncompacted.
"""
if not chat.compact_summary or not chat.compacted_through_id:
return None
message = db.get(Message, chat.compacted_through_id)
if message is None or message.chat_id != chat.id:
return None
return message
def reset(chat: Chat) -> None:
"""Forget that this chat was ever compacted."""
chat.compact_summary = ""
chat.compacted_through_id = None
chat.compacted_at = None
def apply(chat: Chat, *, summary: str, upto: Message) -> None:
"""Record a summary and move the boundary. Caller commits."""
chat.compact_summary = summary.strip()
chat.compacted_through_id = upto.id
chat.compacted_at = datetime.now(UTC)
def split(
db: DBSession, chat: Chat, messages: list[Message]
) -> tuple[list[Message], list[Message]]:
"""(summarised, live) -- what is behind the divider, and what is not."""
cutoff = cutoff_message(db, chat)
if cutoff is None:
return [], list(messages)
boundary = moment(cutoff)
return (
[m for m in messages if moment(m) <= boundary],
[m for m in messages if moment(m) > boundary],
)
def last_complete(db: DBSession, chat: Chat) -> Message | None:
"""The newest finished assistant turn: where compaction should stop.
Landing on a reply rather than a question means the kept history starts on a
user turn, which is what every chat template expects.
"""
return db.scalar(
select(Message)
.where(
Message.chat_id == chat.id,
Message.role == ROLE_ASSISTANT,
Message.complete.is_(True),
)
.order_by(Message.created_at.desc())
)
def transcript(db: DBSession, chat: Chat, *, upto: Message) -> str:
"""The turns to summarise, oldest first, as plain text.
Only the delta since the last compaction: the previous summary is supplied
separately, and the instruction asks for one record, so each summary
subsumes the one before it. Re-summarising the whole chat every time grows
quadratically and eventually exceeds the very window this protects.
"""
previous = cutoff_message(db, chat)
query = select(Message).where(
Message.chat_id == chat.id,
Message.created_at <= upto.created_at,
Message.error == "",
)
if previous is not None:
query = query.where(Message.created_at > previous.created_at)
lines: list[str] = []
for message in db.scalars(query.order_by(Message.created_at)):
body = message.content.strip()
if not body:
continue
lines.append(f"{message.role}: {body}")
text = "\n\n".join(lines)
if len(text) > MAX_TRANSCRIPT_CHARS:
# Keep the most recent part: the older it is, the more likely the
# previous summary already covers it.
text = "[earlier turns omitted]\n\n" + text[-MAX_TRANSCRIPT_CHARS:]
return text
def previous_summary_block(chat: Chat) -> str:
"""The earlier summary, headed, or "" on a first compaction.
Empty is fine to pass straight through: `prompts.substitute` drops a line
that held a known variable and expanded to nothing, so the prompt does not
end up with a hole where a heading was.
"""
if not chat.compact_summary.strip():
return ""
return "## Summary of even earlier turns\n\n" + chat.compact_summary.strip()
def should_compact(db: DBSession, chat: Chat, *, pending: str = "") -> bool:
"""Whether the next request should be summarised first.
Judged from the last reply's recorded usage plus an estimate of the new
turn. True prompt_tokens are only knowable after a response, so a
retrospective figure is the honest basis -- but on its own it is one turn
stale, and fifty thousand characters pasted into the composer would overflow
a window that measured 90% last time. The estimator covers only that delta.
Never fires when the model's context length is unknown. Acting on a number
nobody supplied is exactly what the 0-means-unknown rule exists to prevent.
"""
limit = threshold(db)
if limit <= 0:
return False
last = last_complete(db, chat)
if last is None:
return False
usage = metrics_service.from_message(last.usage_json)
if usage.context_limit <= 0 or usage.context_tokens <= 0:
return False
projected = usage.context_tokens + tokens.estimate(pending)
return projected >= usage.context_limit * limit / 100
+20
View File
@@ -435,6 +435,26 @@ def claim(db: DBSession, *, ids: list[str], user_id: str, message_id: str) -> li
return pending
def remove_files_for_chats(db: DBSession, chat_ids: list[str]) -> int:
"""Unlink the files belonging to these chats' attachments.
Deleting a Chat cascades to its Message and Attachment *rows* but leaves the
files on disk -- only `sweep_orphans` unlinks anything, and it only looks at
uploads that were never attached. Anything that deletes chats has to call
this first, while the rows still say which files to remove.
"""
if not chat_ids:
return 0
removed = 0
for attachment in db.scalars(select(Attachment).where(Attachment.chat_id.in_(chat_ids))):
path = stored_path(attachment.stored_name)
if path is not None and path.exists():
path.unlink(missing_ok=True)
removed += 1
return removed
def sweep_orphans(db: DBSession, older_than: timedelta = ORPHAN_AGE) -> int:
"""Delete uploads that were never attached to a message.
+196 -5
View File
@@ -22,13 +22,19 @@ import time
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
from lembas.db.session import session_scope
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import tokens
from lembas.services import tools as tools_service
from lembas.services.llm.openai_client import (
LLMError,
chunk_usage,
delta_reasoning,
delta_text,
delta_tool_calls,
@@ -64,6 +70,26 @@ class Generation:
# live as the model works and kept on the message afterwards.
tool_events: list[dict] = field(default_factory=list)
# --- What it cost --------------------------------------------------------
# Prompt and completion are summed across tool rounds: what the reply cost.
# context_tokens is overwritten each round with that round's prompt plus
# completion, because a three-round reply pays for its prompt three times
# but only ever occupies the window once.
prompt_tokens: int = 0
completion_tokens: int = 0
context_tokens: int = 0
context_limit: int = 0
# Filled from the assembled request before the first chunk, so a follower
# has a percentage to show while the reply is still being written -- real
# usage only arrives in a single chunk at the very end.
prompt_estimate: int = 0
rounds: int = 0
# time.monotonic() at the start. A field rather than a local in `_run`
# because `_follow` is a different function that sees only this object, and
# without it there is nothing to compute a live tokens/second against.
started_at: float = 0.0
elapsed_ms: int = 0
error: str = ""
stopped: bool = False
done: bool = False
@@ -72,6 +98,10 @@ class Generation:
# woken individually: with a 100ms cadence a short poll is simpler than
# future bookkeeping, and cannot drop a wakeup.
version: int = 0
# What the reply is doing when it is not producing tokens. Shown in the
# streaming bubble, because a silent multi-second pause before the first
# token is what a hang looks like.
status: str = ""
# Number of browsers currently watching. Decides whether a finished reply
# counts as unread.
followers: int = 0
@@ -120,18 +150,48 @@ def ensure(chat_id: str, message_id: str) -> Generation:
Idempotent, because more than one thing can ask for it: the route that
created the message, and any page load that finds the message unfinished.
`_prune` runs first, not after the lookup. Below it, a stale entry could
never expire: the early return is the only path a repeated id takes, so the
sweep was unreachable for exactly the message that needed it.
"""
_prune()
existing = _RUNNING.get(message_id)
if existing is not None:
return existing
_prune()
generation = Generation(chat_id=chat_id, message_id=message_id)
_RUNNING[message_id] = generation
_TASKS[message_id] = asyncio.create_task(_run(generation))
return generation
def restart(chat_id: str, message_id: str) -> Generation:
"""Produce this reply again, discarding any finished attempt at it.
`ensure` is idempotent on purpose, and that is load-bearing: a page load
finding an unfinished reply must attach to it rather than start a second
one, and `_follow` calls it too. Regeneration is the one caller that means
the opposite.
It is also the one caller that reuses an existing Message row -- blanked and
marked incomplete -- rather than creating a new one. The finished Generation
for that id is still in the registry, because finished ones linger
KEEP_FINISHED so a follower arriving at the last moment still gets the final
frames. `ensure` handed that one straight back: no request was made,
`_follow` replayed the previous answer, and the `done` frame re-rendered a
streaming shell because the row said incomplete. That was the reconnect loop,
and the Send button stuck on Stop.
"""
previous = _RUNNING.pop(message_id, None)
task = _TASKS.pop(message_id, None)
if previous is not None and not previous.done:
previous.cancel = True
if task is not None:
task.cancel()
return ensure(chat_id, message_id)
async def shutdown() -> None:
"""Stop every running generation, keeping what each has produced."""
for task in list(_TASKS.values()):
@@ -153,6 +213,7 @@ async def _run(generation: Generation) -> None:
"""
splitter = ReasoningSplitter()
started = time.monotonic()
generation.started_at = started
reasoning_started: float | None = None
question = ""
endpoint = model_id = None
@@ -160,6 +221,13 @@ async def _run(generation: Generation) -> None:
title_prompt = ""
try:
# Before the request is assembled, so build_request is called once and
# what goes out is the compacted conversation -- there is no second
# assembly path. Here rather than in post_message because that route's
# whole contract is to return immediately, and a three-second
# summarisation in front of it would break exactly that.
await _maybe_compact(generation)
with session_scope() as db:
chat = db.get(Chat, generation.chat_id)
message = db.get(Message, generation.message_id)
@@ -182,13 +250,30 @@ async def _run(generation: Generation) -> None:
title_prompt = prompts_service.resolve(db, "task.title")
tool_context = tools_service.context_for(db, owner, chat)
model = chat_service.model_for(db, chat)
generation.context_limit = model.context_length if model is not None else 0
generation.prompt_estimate = tokens.estimate_request(payload)
for round_number in range(tools_service.MAX_ROUNDS + 1):
generation.rounds = round_number + 1
accumulator = tools_service.ToolCallAccumulator()
# Text the model produced in *this* round, needed separately from
# generation.content when echoing the assistant turn back.
round_text: list[str] = []
async for chunk in stream_chat(endpoint, payload):
counts = chunk_usage(chunk)
if counts is not None:
generation.prompt_tokens += counts.get("prompt_tokens", 0)
generation.completion_tokens += counts.get("completion_tokens", 0)
# Overwritten, not summed: this round's prompt already
# contains every earlier round.
generation.context_tokens = counts.get("prompt_tokens", 0) + counts.get(
"completion_tokens", 0
)
generation.touch()
thought = delta_reasoning(chunk)
if thought:
if reasoning_started is None:
@@ -277,6 +362,17 @@ async def _run(generation: Generation) -> None:
if reasoning_started is not None and not generation.reasoning_ms:
generation.reasoning_ms = int((time.monotonic() - reasoning_started) * 1000)
generation.elapsed_ms = int((time.monotonic() - started) * 1000)
if not generation.completion_tokens:
# The endpoint reported nothing, so fall back to the estimate. Marked
# as such everywhere it is shown -- four characters to a token is
# wrong enough on code and CJK to be worth saying out loud.
generation.completion_tokens = tokens.estimate(
generation.text + generation.thinking
)
generation.prompt_tokens = generation.prompt_estimate
generation.context_tokens = generation.prompt_tokens + generation.completion_tokens
# Naming the chat is a second, short completion, so it has to happen
# here rather than in the synchronous persist step below. Best-effort:
# a chat title is never worth surfacing an error for.
@@ -295,10 +391,85 @@ async def _run(generation: Generation) -> None:
)
title = title or chat_service.fallback_title(question)
# Written *before* `done`, because `_follow` breaks out of its loop the
# moment it sees that flag and immediately re-renders the bubble from
# the row. The other order left a window in which the finished frame
# showed the previous turn's stored values.
_persist(generation, title, time.monotonic() - started)
generation.done = True
generation.finished_at = datetime.now(UTC)
generation.touch()
_persist(generation, title, time.monotonic() - started)
async def _maybe_compact(generation: Generation) -> None:
"""Summarise the earlier turns if the window is about to be full.
Never raises. A failed compaction logs and sends the uncompacted request,
which either works or fails upstream with a message that says what actually
happened -- refusing to answer because the summariser was unavailable would
be a worse trade.
The awaited call is deliberately outside any session, the same shape titling
uses: read everything needed, close, ask, reopen to write.
"""
try:
with session_scope() as db:
chat = db.get(Chat, generation.chat_id)
message = db.get(Message, generation.message_id)
if chat is None or message is None:
return
pending = _pending_text(db, message)
if not compaction_service.should_compact(db, chat, pending=pending):
return
template = prompts_service.resolve(db, "task.compact")
upto = compaction_service.last_complete(db, chat)
if not template.strip() or upto is None:
return
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
transcript = compaction_service.transcript(db, chat, upto=upto)
previous = compaction_service.previous_summary_block(chat)
upto_id = upto.id
generation.status = "Summarising earlier messages…"
generation.touch()
summary = await chat_service.summarise_for_compaction(
endpoint,
model_id,
transcript=transcript,
previous_summary=previous,
template=template,
)
if not summary:
return
with session_scope() as db:
chat = db.get(Chat, generation.chat_id)
upto = db.get(Message, upto_id)
if chat is None or upto is None:
return
compaction_service.apply(chat, summary=summary, upto=upto)
db.commit()
log.info("chat %s compacted automatically through %s", chat.id, upto_id)
except Exception: # noqa: BLE001 - the reply matters more than the tidy-up
log.exception("automatic compaction failed for chat %s", generation.chat_id)
finally:
generation.status = ""
generation.touch()
def _pending_text(db, message: Message) -> str:
"""The user turn this reply is answering, for the size estimate."""
previous = db.scalars(
select(Message)
.where(Message.chat_id == message.chat_id, Message.created_at < message.created_at)
.order_by(Message.created_at.desc())
.limit(1)
).first()
return previous.content if previous is not None else ""
def _question_from(payload: dict) -> str:
@@ -319,7 +490,21 @@ def _question_from(payload: dict) -> str:
def _persist(generation: Generation, title: str, elapsed: float) -> None:
"""Write the finished reply, name the chat, and set the unread flag."""
"""Write the finished reply, name the chat, and set the unread flag.
A generation another one has replaced may not write. A regeneration cancels
its predecessor, whose `finally:` then runs this on the same row -- and it
would overwrite the fresh reply with the abandoned one.
The test is "someone else owns this row now", not "this one is registered":
an unregistered generation still writes, because that is a direct call
rather than a superseded one.
"""
owner = _RUNNING.get(generation.message_id)
if owner is not None and owner is not generation:
log.debug("skipping persist for superseded generation %s", generation.message_id)
return
try:
with session_scope() as db:
message = db.get(Message, generation.message_id)
@@ -331,6 +516,9 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
message.reasoning = generation.thinking
message.reasoning_ms = generation.reasoning_ms
message.tool_calls_json = generation.tool_events
message.usage_json = metrics_service.to_json(
metrics_service.from_generation(generation)
)
message.error = generation.error
message.stopped = generation.stopped
message.complete = True
@@ -340,8 +528,10 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
chat.title_generated = True
# Nobody watching when it landed, so it is news. The chat page
# clears this when it is next opened.
if generation.followers == 0:
# clears this when it is next opened. Not for a temporary chat:
# there is no sidebar row for the dot, and the toast would name a
# chat nobody can navigate to.
if generation.followers == 0 and not chat.temporary:
chat.unread = True
chat.unread_notified = False
@@ -364,5 +554,6 @@ __all__ = [
"ensure",
"get",
"request_stop",
"restart",
"shutdown",
]
+111
View File
@@ -155,6 +155,47 @@ async def list_models(endpoint: Endpoint) -> list[dict[str, Any]]:
return models
# Where the runners that bother to advertise a context length put it. There is
# no standard field, so this is a list of what the common ones actually emit.
_CONTEXT_KEYS = ("context_length", "max_model_len", "context_window", "max_context_length")
# Below the first, the number is not a context length; above the second it is a
# typo or a different unit. Either way, better to record nothing than a wrong
# figure a percentage would then be computed from.
MIN_CONTEXT = 256
MAX_CONTEXT = 10_000_000
def context_from(entry: dict[str, Any]) -> int:
"""A model's context length as advertised by /v1/models, or 0 if it is not.
Strings are accepted because some servers quote the number, but only when
they are digits alone -- "8192 tokens" is a label, not a measurement.
"""
candidates = [entry.get(key) for key in _CONTEXT_KEYS]
meta = entry.get("meta")
if isinstance(meta, dict):
candidates += [meta.get("n_ctx"), *(meta.get(key) for key in _CONTEXT_KEYS)]
for value in candidates:
if isinstance(value, bool):
continue
if isinstance(value, str):
value = value.strip()
if not value.isdigit():
continue
value = int(value)
if isinstance(value, int) and MIN_CONTEXT <= value <= MAX_CONTEXT:
return value
return 0
# Endpoints that rejected `stream_options`, so it is asked for once per base URL
# per process and then never again. Not persisted: it is a property of whatever
# is running there now, and a restart is the right time to find out afresh.
_NO_STREAM_OPTIONS: set[str] = set()
async def stream_chat(
endpoint: Endpoint,
payload: dict[str, Any],
@@ -163,8 +204,41 @@ async def stream_chat(
Yields the raw upstream chunks; interpreting them is the caller's job. The
terminating "[DONE]" sentinel is consumed here and not yielded.
`stream_options` asks for the final usage chunk, which is the only way to
learn what a streamed reply actually cost. Not every server implements it,
and an unknown key is a 400 from some of them -- the same hazard as sending
a `tools` array to an endpoint without support. So it is asked for once,
and an endpoint that refuses is remembered and never asked again. Retrying
is safe because the status is checked before a single line is read: nothing
has been yielded, so there is nothing to duplicate.
"""
wants_usage = endpoint.base_url not in _NO_STREAM_OPTIONS
try:
async for chunk in _stream_once(endpoint, payload, usage=wants_usage):
yield chunk
except LLMError as exc:
if not wants_usage or exc.status_code not in (400, 422):
raise
_NO_STREAM_OPTIONS.add(endpoint.base_url)
log.info(
"%s rejected stream_options; token counts will be estimated there",
endpoint.base_url,
)
async for chunk in _stream_once(endpoint, payload, usage=False):
yield chunk
async def _stream_once(
endpoint: Endpoint,
payload: dict[str, Any],
*,
usage: bool,
) -> AsyncIterator[dict[str, Any]]:
body = {**payload, "stream": True}
if usage:
body["stream_options"] = {"include_usage": True}
try:
async with (
@@ -283,6 +357,43 @@ def finish_reason(chunk: dict[str, Any]) -> str:
return ""
def chunk_usage(chunk: dict[str, Any]) -> dict[str, int] | None:
"""Token counts from a usage chunk, or None if this is not one.
A usage chunk carries `choices: []`, which is exactly the shape delta_text,
delta_reasoning, delta_tool_calls and finish_reason all return early on --
they have always tolerated it, so nothing else needs to change to let one
through.
Fields are read defensively because "the endpoint returned something odd"
must never be the reason a reply fails; a bad shape simply means no counts.
"""
try:
raw = chunk.get("usage")
except AttributeError:
return None
if not isinstance(raw, dict):
return None
counts: dict[str, int] = {}
for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
value = raw.get(key)
if isinstance(value, bool) or not isinstance(value, (int, float)):
continue
if value >= 0:
counts[key] = int(value)
# Some servers send a usage object of zeros on every chunk and the real
# numbers only at the end. All-zero is indistinguishable from that, and
# treating it as an answer would freeze the count at nothing.
if not counts or not any(counts.values()):
return None
counts.setdefault(
"total_tokens", counts.get("prompt_tokens", 0) + counts.get("completion_tokens", 0)
)
return counts
def delta_text(chunk: dict[str, Any]) -> str:
"""Pull the text out of one streamed chunk, tolerating provider variation."""
try:
+136
View File
@@ -0,0 +1,136 @@
"""What a reply cost, how fast it arrived, and how full the window is.
One shape, built either from a generation still being written or from the row
it left behind. That matters more than it looks: the finished bubble is
re-rendered from the database the instant the stream ends, so if the live
numbers and the stored ones came from different code they would visibly jump at
exactly the moment the reader is looking at them. Here the only thing that
changes when a reply finishes is that an estimate may become exact.
Nothing here is authoritative about tokens. `estimated` says which kind of
number this is, and every surface that shows one has to say so too.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from lembas.services import tokens
# Where the context bar changes colour. Not thresholds anyone tunes: they mark
# "worth noticing" and "about to be a problem", and the second is deliberately
# below the default compaction threshold so the warning arrives first.
WARNING_AT = 80
DANGER_AT = 95
@dataclass(frozen=True)
class Metrics:
"""Token counts and timing for one reply."""
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
# What the window holds after this turn: the last round's prompt plus its
# completion. Distinct from prompt+completion summed over tool rounds, which
# is what the reply *cost* -- a three-round reply pays for its prompt three
# times but only ever occupies the window once.
context_tokens: int = 0
context_limit: int = 0
estimated: bool = False
elapsed_ms: int = 0
rounds: int = 1
@property
def percent(self) -> int:
"""How full the window is, or 0 when nobody has said how big it is."""
if self.context_limit <= 0 or self.context_tokens <= 0:
return 0
return min(100, round(self.context_tokens * 100 / self.context_limit))
@property
def tokens_per_second(self) -> float:
if self.elapsed_ms <= 0 or self.completion_tokens <= 0:
return 0.0
return self.completion_tokens / (self.elapsed_ms / 1000)
@property
def pressure(self) -> str:
""""", "warning" or "danger" -- the class the context chip takes."""
percent = self.percent
if not percent:
return ""
if percent >= DANGER_AT:
return "danger"
if percent >= WARNING_AT:
return "warning"
return ""
@property
def has_anything(self) -> bool:
return bool(self.total_tokens or self.completion_tokens or self.elapsed_ms)
def from_generation(generation: Any) -> Metrics:
"""Metrics for a reply still being written.
Usage arrives in a single chunk at the very end, so mid-stream there is
nothing to report and everything is estimated. The counts stop being
estimates the moment that chunk lands, which is usually a beat before the
bubble is replaced.
"""
import time
completion = generation.completion_tokens or tokens.estimate(
generation.text + generation.thinking
)
prompt = generation.prompt_tokens or generation.prompt_estimate
elapsed = generation.elapsed_ms or (
int((time.monotonic() - generation.started_at) * 1000) if generation.started_at else 0
)
return Metrics(
prompt_tokens=prompt,
completion_tokens=completion,
total_tokens=prompt + completion,
context_tokens=generation.context_tokens or (prompt + completion),
context_limit=generation.context_limit,
estimated=not (generation.prompt_tokens and generation.completion_tokens),
elapsed_ms=elapsed,
rounds=max(1, generation.rounds),
)
def from_message(usage_json: dict[str, Any] | None) -> Metrics:
"""Metrics for a finished reply, read back off the row."""
stored = usage_json or {}
def _int(key: str) -> int:
value = stored.get(key)
return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0
return Metrics(
prompt_tokens=_int("prompt_tokens"),
completion_tokens=_int("completion_tokens"),
total_tokens=_int("total_tokens"),
context_tokens=_int("context_tokens"),
context_limit=_int("context_limit"),
estimated=bool(stored.get("estimated")),
elapsed_ms=_int("elapsed_ms"),
rounds=max(1, _int("rounds")),
)
def to_json(metrics: Metrics) -> dict[str, Any]:
"""The shape stored in Message.usage_json."""
return {
"prompt_tokens": metrics.prompt_tokens,
"completion_tokens": metrics.completion_tokens,
"total_tokens": metrics.total_tokens,
"context_tokens": metrics.context_tokens,
"context_limit": metrics.context_limit,
"estimated": metrics.estimated,
"elapsed_ms": metrics.elapsed_ms,
"rounds": metrics.rounds,
}
+81
View File
@@ -156,6 +156,16 @@ VARIABLES: tuple[Variable, ...] = (
),
Variable("question", "Question", "The first message. Chat title task only."),
Variable("answer", "Answer", "The first reply. Chat title task only."),
Variable(
"transcript",
"Transcript",
"The turns being summarised, oldest first. Compaction task only.",
),
Variable(
"previous_summary",
"Earlier summary",
"The summary from a previous compaction, if there was one. Compaction task only.",
),
)
VARIABLE_NAMES = frozenset(variable.name for variable in VARIABLES)
@@ -764,6 +774,77 @@ BUILTIN: tuple[Fragment, ...] = (
"Assistant: {{answer}}"
),
),
Fragment(
key="task.compact",
label="Compaction summary",
group=GROUP_TASKS,
order=410,
variables=("transcript", "previous_summary"),
hint="A separate one-message request, not part of any chat. Clear it to "
"turn compaction off entirely: the button says so and nothing is "
"summarised automatically.",
default=(
"Summarise the conversation below so it can be carried forward after the "
"earlier turns are dropped from your context. This is a working record, "
"not a report for a reader.\n"
"\n"
"Keep, under these headings and in this order:\n"
"\n"
"## What we are doing\n"
"The goal, and where we have got to.\n"
"\n"
"## Decisions\n"
"Anything settled, and why. A decision without its reason gets argued "
"again.\n"
"\n"
"## Facts established\n"
"Names, numbers, versions, file paths, URLs and identifiers, copied "
"exactly. Do not round them, paraphrase them or reconstruct one from "
"memory — if it is not in the transcript, leave it out.\n"
"\n"
"## Open threads\n"
"What is unfinished, and what was about to happen next.\n"
"\n"
"Leave out pleasantries, retracted ideas and anything already superseded. "
"Do not answer the conversation: you are recording it. Write in the "
"language of the conversation, and stay under 500 words.\n"
"\n"
"{{previous_summary}}\n"
"\n"
"## Transcript\n"
"\n"
"{{transcript}}"
),
),
Fragment(
key="task.compact_lead",
label="How a summary is introduced",
group=GROUP_TASKS,
order=420,
hint="Sits in front of the summary, in the turn that replaces the "
"messages no longer being sent. Without it a model reads the summary as "
"something the person has just typed.",
default=(
"Here is a summary of the earlier part of this conversation. Those "
"messages are no longer in your context. Treat this summary as an "
"accurate record of them and rely on it rather than on what you can no "
"longer see; if it does not cover something you need, say so instead of "
"filling the gap."
),
),
Fragment(
key="task.compact_ack",
label="The model's acknowledgement",
group=GROUP_TASKS,
order=430,
hint="One assistant turn after the summary, so the conversation still "
"alternates user, assistant, user. Several chat templates reject a "
"history that does not.",
default=(
"Understood. I have the summary of the earlier turns and will carry on "
"from there."
),
),
)
register_source(_builtin_source)
+6
View File
@@ -35,6 +35,12 @@ def _general_defaults() -> dict[str, Any]:
# Applied to every chat that has no model or chat prompt of its
# own. See services.chat.effective_system_prompt.
"system_prompt": "",
# Percentage of a model's context length at which the earlier turns are
# summarised automatically. 0 turns it off; the Compact button still
# works, because a person asking for it does not need a threshold.
# Never fires for a model whose context_length is 0, since that is
# "unknown" rather than "small". See services/compaction.py.
"compact_threshold": 95,
}
+118
View File
@@ -0,0 +1,118 @@
"""The cards offered on the new-chat screen.
A blank composer is the least helpful thing a chat client can show someone who
has just installed one. These are three starting points an administrator can
replace with their own.
"""
from __future__ import annotations
import logging
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import Suggestion
from lembas.services import settings_store
log = logging.getLogger(__name__)
# A short list stays a short list. Past a dozen it is a menu, and a menu on the
# empty screen is a worse blank page than a blank page.
MAX_SUGGESTIONS = 12
MAX_SHOWN = 6
MAX_NAME = 120
MAX_DESCRIPTION = 300
MAX_PROMPT = 4000
# Each ends mid-sentence, so the caret lands exactly where the person has to
# start typing. No Middle-earth flavour: this is functional UI.
DEFAULTS: tuple[tuple[str, str, str], ...] = (
(
"Explain this",
"Paste something confusing and get it back in plain language.",
"Explain the following in plain language. Start with one sentence "
"summarising it, then the details that actually matter, then anything I "
"should watch out for. If I have not pasted anything yet, ask me for it "
"rather than guessing.\n\n",
),
(
"Draft a reply",
"Turn a message you have received into an answer you can send.",
"Help me reply to the message below. If the tone I want and the outcome "
"I am after are not obvious from it, ask me before writing. Then give me "
"a draft I could send as it stands.\n\n",
),
(
"Find the flaw",
"Have a plan argued with before you commit to it.",
"I am going to describe a plan. Argue against it: what is most likely to "
"go wrong, what am I assuming without evidence, and what would change "
"your mind. Do not soften it, and do not agree just because I sound "
"confident.\n\nMy plan: ",
),
)
# Guards the seed. Not "is the table empty", because an administrator who
# deletes all three would get them back on every restart.
SEEDED_KEY = "suggestions_seeded"
def visible(db: DBSession) -> list[Suggestion]:
"""What the new-chat screen shows, in order."""
return list(
db.scalars(
select(Suggestion)
.where(Suggestion.enabled.is_(True))
.order_by(Suggestion.position, Suggestion.name)
.limit(MAX_SHOWN)
)
)
def all_of_them(db: DBSession) -> list[Suggestion]:
"""Every suggestion, enabled or not, for the admin page."""
return list(
db.scalars(select(Suggestion).order_by(Suggestion.position, Suggestion.name))
)
def next_position(db: DBSession) -> int:
"""New rows land at the end rather than at 0, where they would shuffle.
No `or -1` after the coalesce: position 0 is falsy, so that idiom sends the
second row back to 0 on top of the first.
"""
highest = db.scalar(select(func.coalesce(func.max(Suggestion.position), -1)))
return int(highest if highest is not None else -1) + 1
def create(db: DBSession, *, name: str, description: str = "", prompt: str = "") -> Suggestion:
suggestion = Suggestion(
name=name.strip()[:MAX_NAME],
description=description.strip()[:MAX_DESCRIPTION],
prompt=prompt[:MAX_PROMPT],
position=next_position(db),
)
db.add(suggestion)
db.commit()
return suggestion
def seed_defaults(db: DBSession) -> int:
"""Write the built-in suggestions, once ever.
Runs from the startup housekeeping block. The flag is what makes it once:
checking whether the table is empty would restore all three every restart
for anyone who decided they did not want them.
"""
if settings_store.get(db, SEEDED_KEY):
return 0
for name, description, prompt in DEFAULTS:
create(db, name=name, description=description, prompt=prompt)
settings_store.update(db, {SEEDED_KEY: True})
log.info("seeded %d default suggestion(s)", len(DEFAULTS))
return len(DEFAULTS)
+75
View File
@@ -0,0 +1,75 @@
"""A rough token count, for when the endpoint does not give a real one.
Deliberately crude. Counting tokens properly means the model's own tokeniser,
which means shipping one per model family and a dependency that has to be kept
in step with them -- for a number that is displayed beside a `~` and used to
decide when to summarise.
Four characters per token is the usual English approximation. It is optimistic
on code and badly wrong on CJK, which is why anything derived from it is marked
as an estimate everywhere it is shown.
"""
from __future__ import annotations
from typing import Any
CHARS_PER_TOKEN = 4
# What a message costs beyond its text: the role, the delimiters a chat template
# wraps each turn in. Small, but a hundred-turn conversation is a hundred of them.
PER_MESSAGE_OVERHEAD = 4
def estimate(text: str) -> int:
"""Roughly how many tokens a piece of text is."""
if not text:
return 0
return max(1, round(len(text) / CHARS_PER_TOKEN))
def estimate_content(content: Any) -> int:
"""A message's content, whether it is a plain string or typed parts.
An image part contributes nothing: its cost depends on the model's tiling,
and a number invented here would be worse than the omission.
"""
if isinstance(content, str):
return estimate(content)
if isinstance(content, list):
return sum(
estimate(part.get("text", ""))
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
return 0
def estimate_messages(messages: list[dict[str, Any]]) -> int:
total = 0
for message in messages:
if not isinstance(message, dict):
continue
total += PER_MESSAGE_OVERHEAD + estimate_content(message.get("content"))
# A tool result the model reads back is part of the window too.
for call in message.get("tool_calls") or []:
function = (call or {}).get("function") or {}
total += estimate(str(function.get("name", "")))
total += estimate(str(function.get("arguments", "")))
return total
def estimate_request(payload: dict[str, Any]) -> int:
"""What a whole request body costs, tools included.
The tools array is sent on every request when tools are offered and is not
small -- thirteen schemas is a meaningful slice of a short window, and
leaving it out would make the percentage read low exactly when it matters.
"""
total = estimate_messages(payload.get("messages") or [])
for tool in payload.get("tools") or []:
function = (tool or {}).get("function") or {}
total += estimate(str(function.get("name", "")))
total += estimate(str(function.get("description", "")))
total += estimate(str(function.get("parameters", "")))
return total
+94 -1
View File
@@ -345,6 +345,7 @@ button, input, textarea, select {
.badge--leaf { background: var(--leaf-soft); color: var(--leaf); }
.badge--success { background: var(--success-soft); color: var(--success); }
.badge--danger { background: var(--danger-soft); color: var(--danger); }
.badge--warning { background: var(--warning-soft); color: var(--warning); }
/* --- Application shell ----------------------------------------------------- */
.shell { display: flex; height: 100dvh; overflow: hidden; }
@@ -419,6 +420,96 @@ button, input, textarea, select {
background: var(--bg);
}
/* The inspector, mirroring the sidebar on the other side of .shell. Hidden by
the `hidden` attribute, which the rule at the top of this file forces to win. */
.inspector {
width: var(--inspector-width);
flex: none;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--bg-sunken);
border-left: 1px solid var(--border);
}
.inspector__header {
display: flex;
align-items: center;
gap: var(--sp-2);
height: var(--header-height);
flex: none;
padding: 0 var(--sp-3);
border-bottom: 1px solid var(--border);
}
.inspector__title {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: 1;
font-size: var(--text-sm);
font-weight: 600;
color: var(--ink-muted);
}
.inspector__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--sp-4);
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.inspector__heading {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-sm);
margin: var(--sp-5) 0 var(--sp-2);
}
.inspector__note {
font-size: var(--text-xs);
color: var(--ink-muted);
line-height: var(--leading-normal);
margin: 0 0 var(--sp-3);
overflow-wrap: anywhere;
}
.inspector__facts {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--sp-1) var(--sp-3);
font-size: var(--text-xs);
margin: 0;
}
.inspector__facts dt { color: var(--ink-faint); }
.inspector__facts dd { margin: 0; overflow-wrap: anywhere; }
.inspector__json {
margin: 0 0 var(--sp-3);
padding: var(--sp-2);
max-height: 22rem;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
background: var(--code-bg);
border: 1px solid var(--code-border);
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: var(--leading-normal);
color: var(--ink-muted);
}
@media (max-width: 64rem) {
.inspector {
position: fixed;
inset: 0 0 0 auto;
z-index: 40;
box-shadow: var(--shadow-lg);
}
}
.topbar {
display: flex;
align-items: center;
@@ -626,7 +717,9 @@ button, input, textarea, select {
z-index: 40;
box-shadow: var(--shadow-lg);
}
.sidebar[data-collapsed="true"] { display: none; }
/* Hiding it is the `hidden` attribute, forced to win at the top of this
file. There used to be a `[data-collapsed="true"]` rule here that nothing
ever set. */
}
/* --- Toasts ----------------------------------------------------------------
+116
View File
@@ -167,6 +167,119 @@
.reasoning__summary::-webkit-details-marker { display: none; }
.reasoning__summary:hover { color: var(--ink); background: var(--surface-hover); }
/* --- Compacted turns -------------------------------------------------------
Summarised messages, kept and readable but out of the way. Collapsed by
default: the point of compacting was that they stopped mattering.
*/
.compacted {
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
background: color-mix(in srgb, var(--surface) 50%, transparent);
}
.compacted__summary {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
color: var(--ink-muted);
font-size: var(--text-sm);
list-style: none;
user-select: none;
}
.compacted__summary::-webkit-details-marker { display: none; }
.compacted__summary:hover { color: var(--ink); }
.compacted[open] .reasoning__chevron { transform: rotate(180deg); }
.compacted__note {
margin: 0;
padding: 0 var(--sp-3);
font-size: var(--text-xs);
color: var(--ink-faint);
line-height: var(--leading-normal);
}
.compacted__body {
display: flex;
flex-direction: column;
gap: var(--sp-6);
padding: var(--sp-4) var(--sp-3);
opacity: 0.75;
}
/* --- Suggestions -----------------------------------------------------------
Starting points on the empty screen. Cards rather than a list, because they
are things to press.
*/
.suggestions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: var(--sp-3);
width: 100%;
max-width: 40rem;
margin-top: var(--sp-6);
text-align: left;
}
.suggestion {
display: flex;
flex-direction: column;
gap: var(--sp-1);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
color: var(--ink);
cursor: pointer;
text-align: left;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.suggestion:hover { background: var(--surface-hover); border-color: var(--border-strong); }
.suggestion:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.suggestion__name { font-weight: 600; font-size: var(--text-sm); }
.suggestion__note {
font-size: var(--text-xs);
color: var(--ink-muted);
line-height: var(--leading-normal);
}
/* --- Metrics ---------------------------------------------------------------
What a reply cost, under the bubble. Quiet by default: it is reference, not
something to read every time.
*/
.msg__metrics {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-3);
margin-top: var(--sp-2);
font-size: var(--text-xs);
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
.msg__metrics:empty { display: none; }
.metric { display: inline-flex; align-items: center; gap: var(--sp-1); cursor: default; }
.metric__bar {
display: inline-block;
width: 3rem;
height: 0.3rem;
border-radius: var(--radius-full);
background: var(--surface-active);
overflow: hidden;
}
.metric__fill {
display: block;
height: 100%;
background: var(--ink-faint);
transition: width var(--transition);
}
.metric--context.is-warning { color: var(--warning); }
.metric--context.is-warning .metric__fill { background: var(--warning); }
.metric--context.is-danger { color: var(--danger); }
.metric--context.is-danger .metric__fill { background: var(--danger); }
.reasoning__icon { color: var(--leaf); flex: none; }
.reasoning__label { flex: 1; font-style: italic; }
@@ -259,6 +372,9 @@
}
/* --- Stop, notes and editing ---------------------------------------------- */
.msg__status { font-size: var(--text-xs); color: var(--ink-faint); font-style: italic; }
.msg__status:empty { display: none; }
.msg__waiting {
display: flex;
align-items: center;
+1
View File
@@ -65,6 +65,7 @@
/* --- Layout ----------------------------------------------------------- */
--sidebar-width: 17.5rem;
--inspector-width: 24rem;
--thread-max-width: 48rem;
--header-height: 3.5rem;
+15
View File
@@ -384,6 +384,21 @@
return;
}
/* A suggestion card fills the composer and stops there. It deliberately
does not submit: the prompts end mid-sentence, because a card is a
starting point rather than a question somebody already asked. */
var suggestion = event.target.closest("[data-suggestion]");
if (suggestion) {
event.preventDefault();
var input = document.querySelector("[data-composer-input]");
if (!input) return;
input.value = suggestion.dataset.suggestion;
autosize(input);
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
return;
}
/* Show/hide a panel by selector, so templates do not each carry their own
inline toggle script. */
var toggle = event.target.closest("[data-toggle]");
@@ -47,6 +47,11 @@
{{ icon("sparkle", "icon--sm") }}
<span class="nav-item__label">Prompts</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'suggestions' }}"
href="/admin/suggestions">
{{ icon("star", "icon--sm") }}
<span class="nav-item__label">Suggestions</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'users' }}" href="/admin/users">
{{ icon("user", "icon--sm") }}
<span class="nav-item__label">Users</span>
@@ -41,6 +41,29 @@
</div>
</section>
<section class="card">
<h2 class="card__title">Compaction</h2>
<p class="card__lede">
A long conversation eventually fills the model's context. When it gets
close, the earlier turns are summarised and the summary is sent in their
place. The messages themselves are kept and stay readable in the
transcript — they simply stop being sent.
</p>
<div class="field">
<label class="field__label" for="compact-threshold">Compact at</label>
<input class="input" id="compact-threshold" name="compact_threshold" type="number"
min="0" max="99" value="{{ values.compact_threshold }}">
<p class="field__hint">
Percent of the model's context length. <code>0</code> turns automatic
compaction off; the button in each chat still works. Nothing happens for
a model whose context length is unset under
<a href="/admin/models">Models</a> — that is "unknown", not "small", and
this will not act on a number nobody supplied. The wording of the
summary is under <a href="/admin/prompts">Prompts</a>.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">
Registration
@@ -92,6 +92,18 @@
</div>
</div>
<div class="field">
<label class="field__label" for="context-length">Context length</label>
<input class="input" id="context-length" name="context_length" type="number"
min="0" step="1" placeholder="unknown"
value="{{ model.context_length or '' }}">
<p class="field__hint">
How many tokens this model can hold, filled in from the endpoint where
it says. Leave it empty if you do not know: the context percentage and
automatic compaction both stay off rather than working from a guess.
</p>
</div>
<div class="field">
<label class="field__label" for="description">Description</label>
<textarea class="textarea" id="description" name="description" rows="2"
@@ -0,0 +1,120 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "suggestions" %}
{% block title %}Suggestions - LLeMbas{% endblock %}
{% block heading %}Suggestions{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Cards on the new-chat screen. Clicking one puts its prompt in the composer
without sending it — the built-in ones deliberately end mid-sentence, so the
caret lands where the person has to start typing. The first
{{ max_shown }} enabled ones are shown, in this order.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
{% for suggestion in suggestions %}
<form class="card" method="post" action="/admin/suggestions/{{ suggestion.id }}">
<div class="card__header">
<h2 class="card__title">
{{ suggestion.name }}
{% if not suggestion.enabled %}<span class="badge">hidden</span>{% endif %}
{% if loop.index > max_shown and suggestion.enabled %}
<span class="badge badge--warning" title="Only the first {{ max_shown }} are shown">
below the cut
</span>
{% endif %}
</h2>
<button class="btn btn--sm btn--danger" type="submit"
formaction="/admin/suggestions/{{ suggestion.id }}/delete"
data-confirm-button="Delete the suggestion “{{ suggestion.name }}”?"
data-confirm-title="Delete suggestion">
{{ icon("trash", "icon--sm") }}
</button>
</div>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="name-{{ suggestion.id }}">Name</label>
<input class="input" id="name-{{ suggestion.id }}" name="name"
value="{{ suggestion.name }}" maxlength="120">
<p class="field__hint">The heading on the card.</p>
</div>
<div class="field">
<label class="field__label" for="position-{{ suggestion.id }}">Position</label>
<input class="input" id="position-{{ suggestion.id }}" name="position" type="number"
min="1" value="{{ suggestion.position + 1 }}">
<p class="field__hint">Order on the screen, lowest first.</p>
</div>
</div>
<div class="field">
<label class="field__label" for="description-{{ suggestion.id }}">Description</label>
<input class="input" id="description-{{ suggestion.id }}" name="description"
value="{{ suggestion.description }}" maxlength="300">
<p class="field__hint">One line under the name, saying what it is for.</p>
</div>
<div class="field">
<label class="field__label" for="prompt-{{ suggestion.id }}">Prompt</label>
<textarea class="textarea" id="prompt-{{ suggestion.id }}" name="prompt"
rows="4">{{ suggestion.prompt }}</textarea>
<p class="field__hint">
Put in the composer, not sent. Ending it mid-sentence is usually right:
the person still has to say what they are asking about.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if suggestion.enabled }}>
<span>Show this one</span>
</label>
</div>
<div class="card__footer">
<button class="btn btn--primary btn--sm" type="submit">Save</button>
</div>
</form>
{% else %}
<div class="empty">
{{ icon("sparkle", "empty__mark") }}
<h2 class="empty__title">No suggestions</h2>
<p class="empty__text">The new-chat screen shows its empty state instead.</p>
</div>
{% endfor %}
{% if not at_limit %}
<form class="card" method="post" action="/admin/suggestions">
<h2 class="card__title">Add a suggestion</h2>
<div class="field">
<label class="field__label" for="new-name">Name</label>
<input class="input" id="new-name" name="name" maxlength="120"
placeholder="Summarise a document" required>
</div>
<div class="field">
<label class="field__label" for="new-description">Description</label>
<input class="input" id="new-description" name="description" maxlength="300"
placeholder="What it is for, in one line.">
</div>
<div class="field">
<label class="field__label" for="new-prompt">Prompt</label>
<textarea class="textarea" id="new-prompt" name="prompt" rows="4"
placeholder="What lands in the composer when the card is clicked."></textarea>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Add</button>
</div>
</form>
{% else %}
<p class="admin-lede">
{{ max_suggestions }} is the limit. Delete one to add another — past a dozen
this is a menu, and a menu on the empty screen is a worse blank page than a
blank page.
</p>
{% endif %}
{% endblock %}
@@ -50,6 +50,9 @@
{% if not chat and current_model %}
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
{% endif %}
{% if not chat and starting_temporary %}
<input type="hidden" name="temporary" value="true">
{% endif %}
<div class="composer__row">
{% if can.get("files.upload") %}
@@ -0,0 +1,24 @@
{% from "_macros.html" import icon %}
{#
The request inspector: a third child of .shell, opening and closing like the
sidebar opposite it. Administrators only, and only on their own chats.
`intersect once` is what makes it lazy with no JavaScript: a hidden element
never intersects the viewport, so the request fires the first time it is
opened and never on a page load nobody looked at.
#}
<aside class="inspector" id="inspector" hidden aria-label="Request inspector">
<div class="inspector__header">
<h2 class="inspector__title">{{ icon("search", "icon--sm") }} Inspector</h2>
<button class="btn btn--icon btn--sm" type="button" data-toggle="#inspector"
aria-label="Close inspector">
{{ icon("x", "icon--sm") }}
</button>
</div>
<div class="inspector__body" id="inspector-body"
hx-get="/api/chats/{{ chat.id }}/inspect"
hx-trigger="intersect once" hx-target="this" hx-swap="innerHTML">
<p class="inspector__note">Opening…</p>
</div>
</aside>
@@ -0,0 +1,90 @@
{% from "_macros.html" import icon %}
{#
Everything is rendered through normal Jinja escaping and never `|safe`. This
JSON is full of model output, search results and uploaded documents -- hard
rule 6 applies here exactly as it does to a chat bubble.
#}
<p class="inspector__note">
Rebuilt now against the current configuration. This is not a recording of the
request that produced the last reply — if a prompt or a setting has changed
since, this shows what would be sent today.
</p>
<div class="btn-row">
<button class="btn btn--sm" type="button"
hx-get="/api/chats/{{ chat.id }}/inspect"
hx-target="#inspector-body" hx-swap="innerHTML">
{{ icon("refresh", "icon--sm") }} Refresh
</button>
</div>
<h3 class="inspector__heading">This chat</h3>
<dl class="inspector__facts">
<dt>Model</dt><dd>{{ chat.model_id or "—" }}</dd>
<dt>Context</dt>
<dd>
{% if model and model.context_length %}
{{ model.context_length }} tokens
{% else %}
{# Shown here rather than only beside a reply, because "why is there no
percentage" is a question people have before the first one. #}
not set{% if model %} — <a href="/admin/models/{{ model.id }}/edit">set it</a>{% endif %}
{% endif %}
</dd>
{% if chat.temporary %}<dt>Lifetime</dt><dd>temporary</dd>{% endif %}
</dl>
<h3 class="inspector__heading">Last reply</h3>
{% if row %}
<dl class="inspector__facts">
<dt>Tokens</dt>
<dd>
{% if metrics.has_anything %}
{% if metrics.estimated %}~{% endif %}{{ metrics.prompt_tokens }} in,
{% if metrics.estimated %}~{% endif %}{{ metrics.completion_tokens }} out
{% if metrics.estimated %}<span class="badge">estimated</span>{% endif %}
{% else %}
not reported
{% endif %}
</dd>
<dt>Elapsed</dt><dd>{{ metrics.elapsed_ms }} ms</dd>
<dt>Thinking</dt><dd>{{ row.reasoning_ms }} ms</dd>
<dt>Tool calls</dt><dd>{{ row.tool_calls_json | length }}</dd>
<dt>Rounds</dt><dd>{{ metrics.rounds }}</dd>
<dt>State</dt>
<dd>
{% if row.error %}<span class="badge badge--danger">error</span>
{% elif row.stopped %}<span class="badge badge--warning">stopped</span>
{% elif not row.complete %}<span class="badge">writing</span>
{% else %}<span class="badge badge--success">complete</span>{% endif %}
</dd>
</dl>
{% if row.error %}
<p class="inspector__note">{{ row.error }}</p>
{% endif %}
{% else %}
<p class="inspector__note">Nothing has been answered in this chat yet.</p>
{% endif %}
<h3 class="inspector__heading">System message</h3>
{% if system %}
<pre class="inspector__json">{{ system }}</pre>
{% else %}
<p class="inspector__note">None is being sent.</p>
{% endif %}
<h3 class="inspector__heading">
Tools offered
<span class="badge">{{ tool_names | length }}</span>
</h3>
{% if tool_names %}
<p class="inspector__note mono">{{ tool_names | join(", ") }}</p>
{% else %}
<p class="inspector__note">
None. A model has to be marked as supporting tools, the user needs the
permission, and the tool itself has to be turned on.
</p>
{% endif %}
<h3 class="inspector__heading">Request body</h3>
<pre class="inspector__json">{{ request_json }}</pre>
+21 -1
View File
@@ -98,7 +98,10 @@
<span class="reasoning__label">Thinking…</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<div class="reasoning__body" sse-swap="reasoning" hx-swap="beforeend"></div>
{# innerHTML, not beforeend: the frame carries the whole block of
thinking each time, exactly as `render` and `tools` do. Appending it
repeated everything already shown, so the panel grew quadratically. #}
<div class="reasoning__body" sse-swap="reasoning" hx-swap="innerHTML"></div>
</details>
{# Tool activity as it happens. Empty until the model asks for something,
@@ -116,7 +119,15 @@
reply is being written, which is where the hand already is. #}
<div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span>
{# What the reply is doing when it is not producing tokens. A silent
multi-second pause before the first token is what a hang looks
like. #}
<span class="msg__status" sse-swap="status" hx-swap="innerHTML"></span>
</div>
{# Counts as the reply is written. Everything is an estimate until the
usage chunk lands at the very end, and the chips say so. #}
<div class="msg__metrics" id="metrics-{{ message.id }}"
sse-swap="metrics" hx-swap="innerHTML"></div>
{% else %}
{# Finished. Same order as the live view above -- thinking, then what it
looked up, then the answer -- so a reply does not rearrange itself the
@@ -174,6 +185,15 @@
leave an empty box under the file. #}
{% endif %}
{% if not streaming and message.role == "assistant" and message.usage_json %}
{# Above the buttons, not among them: the actions row is things you press. #}
<div class="msg__metrics" id="metrics-{{ message.id }}">
{% with metrics = message.usage_json | metrics %}
{% include "chat/_metrics.html" %}
{% endwith %}
</div>
{% endif %}
{% if not streaming %}
<footer class="msg__actions">
<button class="btn btn--icon btn--sm" type="button"
@@ -0,0 +1,32 @@
{#
What a reply cost. Chips only, no wrapper: the same markup is swapped into
the live bubble with innerHTML and rendered into the finished one, so the
numbers cannot change shape when the stream ends.
A tilde means the endpoint reported no token counts and these were worked out
at about four characters per token. Nothing here is ever shown as exact when
it is not.
#}
{% if metrics.has_anything %}
<span class="metric" title="{% if metrics.estimated %}Estimated: this endpoint reports no token counts.
{% endif %}{{ metrics.prompt_tokens }} in, {{ metrics.completion_tokens }} out{% if metrics.rounds > 1 %}, over {{ metrics.rounds }} rounds of tool calls{% endif %}">
{% if metrics.estimated %}~{% endif %}{{ metrics.total_tokens }} tokens
</span>
{% if metrics.context_limit %}
<span class="metric metric--context{{ ' is-' ~ metrics.pressure if metrics.pressure }}"
title="{% if metrics.estimated %}Estimated. {% endif %}{{ metrics.context_tokens }} of {{ metrics.context_limit }} tokens of context used">
<span class="metric__bar">
{# A width is data, not a design value: it is the measurement itself. #}
<span class="metric__fill" style="width: {{ metrics.percent }}%"></span>
</span>
{% if metrics.estimated %}~{% endif %}{{ metrics.percent }}%
</span>
{% endif %}
{% if metrics.tokens_per_second %}
<span class="metric" title="{% if metrics.estimated %}Estimated. {% endif %}{{ metrics.completion_tokens }} tokens in {{ metrics.elapsed_ms }} ms">
{% if metrics.estimated %}~{% endif %}{{ '%.1f' | format(metrics.tokens_per_second) }} tok/s
</span>
{% endif %}
{% endif %}
+33 -3
View File
@@ -1,8 +1,38 @@
{% from "_macros.html" import icon %}
{#
The whole thread. Returned after a rewind, which changes an arbitrary number
of messages at once -- replacing the lot is simpler and less error-prone than
working out which individual bubbles to remove.
The whole thread. Returned after a rewind or a compaction, either of which
changes an arbitrary number of messages at once -- replacing the lot is
simpler and less error-prone than working out which individual bubbles to
remove. Also included by chat/index.html, so the conversation is described in
exactly one place.
Deliberately has no root element: it is swapped with innerHTML into #thread,
and an outerHTML swap would take the container with it.
#}
{% if compacted %}
{# Summarised turns are kept and still readable -- they have only stopped being
sent. A compaction that summarised badly is then a bad turn rather than a
lost conversation. #}
<details class="compacted">
<summary class="compacted__summary">
{{ icon("archive", "icon--sm") }}
<span>{{ compacted | length }} earlier messages, summarised</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<p class="compacted__note">
These are no longer sent to the model; a summary of them goes instead. They
are kept here so nothing is lost.
</p>
<div class="compacted__body">
{% for message in compacted %}
{% with body_html = bodies.get(message.id, "") %}
{% include "chat/_message.html" %}
{% endwith %}
{% endfor %}
</div>
</details>
{% endif %}
{% for message in messages %}
{% with body_html = bodies.get(message.id, "") %}
{% include "chat/_message.html" %}
+71 -8
View File
@@ -16,7 +16,7 @@
<main class="main">
<header class="topbar">
<button class="btn btn--icon" type="button" aria-label="Toggle sidebar"
onclick="document.getElementById('sidebar').toggleAttribute('hidden')">
aria-expanded="true" data-toggle="#sidebar">
{{ icon("sidebar") }}
</button>
@@ -25,6 +25,32 @@
</h1>
<div class="topbar__actions">
{#
A link, not a script: the flag lives in the URL, so it survives a
reload and can be bookmarked. On an existing temporary chat the same
corner explains what temporary means and offers the way out -- without
one, a conversation that turns out to matter is destroyed a day later
with no recourse.
#}
{% if chat and chat.temporary %}
<span class="badge badge--warning"
title="Not listed in the sidebar, and removed 24 hours after the last message.">
Temporary
</span>
<button class="btn btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/keep" hx-swap="none"
title="Keep this chat and list it in the sidebar">
{{ icon("pin", "icon--sm") }} Keep
</button>
{% elif not chat and can.get("chat.create") %}
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
href="{{ '/chat' if starting_temporary else '/chat?temporary=1' }}"
aria-label="Temporary chat"
title="{% if starting_temporary %}Starting a temporary chat. Click to go back to a normal one.{% else %}Start a temporary chat: not listed in the sidebar, and removed after a day.{% endif %}">
{{ icon("clock") }}
</a>
{% endif %}
{% if models %}
{% if can.get("chat.model_select") or not chat %}
{% include "chat/_model_picker.html" %}
@@ -39,6 +65,25 @@
{{ icon("sliders") }}
</button>
{% endif %}
{% if chat and messages %}
<button class="btn btn--icon" type="button" aria-label="Compact this chat"
title="Summarise the earlier messages so they stop taking up context"
hx-post="/api/chats/{{ chat.id }}/compact"
hx-target="#thread" hx-swap="innerHTML"
hx-confirm="Summarise everything before the last reply? The messages stay in the transcript; they just stop being sent to the model."
data-confirm-title="Compact this chat"
data-confirm-label="Compact">
{{ icon("archive") }}
</button>
{% endif %}
{% if chat and user.is_admin %}
<button class="btn btn--icon" type="button" aria-label="Inspect this chat"
title="Inspect this chat" aria-expanded="false" data-toggle="#inspector">
{{ icon("search") }}
</button>
{% endif %}
</div>
</header>
@@ -158,21 +203,39 @@
{{ mark(cls="empty__mark", uid="intro") }}
<h2 class="empty__title">What would you ask?</h2>
<p class="empty__text">Speak, friend, and enter.</p>
{# Only on a chat that does not exist yet. An empty chat someone
opened on purpose already has a model and a prompt chosen. #}
{% if not chat and suggestions %}
<div class="suggestions">
{% for suggestion in suggestions %}
<button class="suggestion" type="button"
data-suggestion="{{ suggestion.prompt }}">
<span class="suggestion__name">{{ suggestion.name }}</span>
{% if suggestion.description %}
<span class="suggestion__note">{{ suggestion.description }}</span>
{% endif %}
</button>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% for message in messages %}
{# Markdown was rendered server-side in pages.py, keyed by message
id, so this loop stays a lookup rather than a render. #}
{% with body_html = bodies.get(message.id, "") %}
{% include "chat/_message.html" %}
{% endwith %}
{% endfor %}
{# The same include the rewind response returns, so the conversation
is described in one place. Markdown was rendered server-side in
pages.py, keyed by message id. #}
{% include "chat/_thread.html" %}
</div>
</div>
{% include "chat/_composer.html" %}
{% endif %}
</main>
{# A third child of .shell, mirroring the sidebar opposite it. #}
{% if chat and user.is_admin %}
{% include "chat/_inspector.html" %}
{% endif %}
</div>
{% endblock %}
@@ -30,17 +30,22 @@
</div>
<div class="folder__contents" x-show="open" x-cloak>
{# Bound once: the loop and the "Empty" check must be looking at the same
list, or a folder holding only archived chats claims to be empty while
showing them. #}
{% set listed = folder.visible_chats %}
{% for child in folder.children %}
{% with folder = child %}
{% include "partials/_folder.html" %}
{% endwith %}
{% endfor %}
{% for chat_item in folder.chats %}
{% for chat_item in listed %}
{% include "partials/_chat_link.html" %}
{% endfor %}
{% if not folder.children and not folder.chats %}
{% if not folder.children and not listed %}
<p class="nav-empty">Empty</p>
{% endif %}
</div>
@@ -114,6 +114,10 @@
<rect x="3.5" y="4.5" width="17" height="4" rx="1.2"/>
<path d="M5 8.5v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9M10 12.5h4"/>
</symbol>
<symbol id="i-clock" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8.5"/>
<path d="M12 7.2V12l3.2 2"/>
</symbol>
<symbol id="i-warning" viewBox="0 0 24 24">
<path d="M12 4.2 21 19.5H3Z"/>
<path d="M12 10v4M12 16.8h.01"/>
+5
View File
@@ -11,6 +11,7 @@ from fastapi.templating import Jinja2Templates
from lembas import __version__
from lembas.config import settings
from lembas.db.models import User
from lembas.services import metrics as metrics_service
from lembas.services.reasoning import format_duration
TEMPLATE_DIR = Path(__file__).parent / "templates"
@@ -23,6 +24,10 @@ templates.env.lstrip_blocks = True
# {{ message.reasoning_ms | duration }} -> "8 seconds"
templates.env.filters["duration"] = format_duration
# {{ message.usage_json | metrics }} -> a Metrics, so the finished bubble reads
# its numbers through the same object the live frames are built from.
templates.env.filters["metrics"] = metrics_service.from_message
def stable_hue(value: str) -> int:
"""A deterministic 0-359 hue for a string.
+22
View File
@@ -314,6 +314,28 @@ def test_deleting_a_folder_keeps_the_chats_inside_it(client: TestClient, db, reg
assert chat.folder_id is None
def test_an_archived_chat_inside_a_folder_is_not_listed(
client: TestClient, db, registered, make_chat
):
"""Regression: the unfiled list has always filtered archived chats, but the
folder branch went through the ORM relationship and filtered nothing, so an
archived chat kept showing as long as it was filed."""
_add_connection(db)
client.post("/api/folders", data={"name": "Quests"})
folder = db.scalar(select(Folder))
chat_id = make_chat()
client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id, "title": "Mount Doom"})
chat = db.get(Chat, chat_id)
chat.archived = True
db.commit()
page = client.get("/chat").text
assert "Mount Doom" not in page
# And the folder must say so, rather than claiming to hold something.
assert "Empty" in page
def test_a_folder_cannot_be_moved_inside_itself(client: TestClient, db, registered):
client.post("/api/folders", data={"name": "Outer"})
folder = db.scalar(select(Folder))
+372
View File
@@ -0,0 +1,372 @@
"""Compaction: what is summarised, what is sent, and when it happens by itself."""
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Chat, Connection, Message, Model
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import prompts, settings_store
from lembas.services.crypto import encrypt
@pytest.fixture
def chat(db, registered, make_chat) -> Chat:
connection = Connection(
name="Test", base_url="http://x.test", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(
Model(connection_id=connection.id, model_id="test-model", context_length=1000)
)
db.commit()
return db.get(Chat, make_chat())
def _exchange(db, chat: Chat, *, question: str, answer: str, minutes: int = 0) -> Message:
"""One user turn and its reply, backdated so ordering is deterministic."""
when = datetime.now(UTC) - timedelta(minutes=minutes)
db.add(Message(chat_id=chat.id, role="user", content=question, created_at=when))
reply = Message(
chat_id=chat.id,
role="assistant",
content=answer,
created_at=when + timedelta(seconds=1),
)
db.add(reply)
db.commit()
return reply
def _usage(reply: Message, *, context_tokens: int, limit: int = 1000) -> None:
reply.usage_json = {
"prompt_tokens": context_tokens,
"completion_tokens": 0,
"total_tokens": context_tokens,
"context_tokens": context_tokens,
"context_limit": limit,
"estimated": False,
"elapsed_ms": 10,
"rounds": 1,
}
# --- The boundary -------------------------------------------------------------
def test_an_uncompacted_chat_splits_into_nothing_and_everything(db, chat):
_exchange(db, chat, question="one", answer="two")
messages = list(db.scalars(select(Message).order_by(Message.created_at)))
assert compaction_service.split(db, chat, messages) == ([], messages)
def test_a_dangling_cutoff_reads_as_uncompacted(db, chat):
"""There is no foreign key to null it out on an upgraded database, so the
guard is load-bearing rather than defensive."""
reply = _exchange(db, chat, question="one", answer="two", minutes=10)
compaction_service.apply(chat, summary="a summary", upto=reply)
db.commit()
db.delete(reply)
db.commit()
assert compaction_service.cutoff_message(db, chat) is None
assert compaction_service.split(db, chat, [])[0] == []
def test_the_cutoff_lands_on_a_finished_reply(db, chat):
"""So the kept history starts on a user turn, which is what every chat
template expects."""
_exchange(db, chat, question="one", answer="two", minutes=10)
db.add(Message(chat_id=chat.id, role="user", content="three"))
db.commit()
assert compaction_service.last_complete(db, chat).content == "two"
# --- What gets sent -----------------------------------------------------------
def test_the_summary_replaces_the_compacted_turns(db, chat):
old = _exchange(db, chat, question="what is lembas?", answer="Waybread.", minutes=10)
_exchange(db, chat, question="how much?", answer="A bite.", minutes=1)
compaction_service.apply(chat, summary="They asked about lembas.", upto=old)
db.commit()
messages = chat_service.build_messages(db, chat)
contents = [m["content"] for m in messages]
assert "what is lembas?" not in contents
assert "how much?" in contents
assert any("They asked about lembas." in c for c in contents)
def test_the_summary_is_carried_by_a_user_and_an_assistant_turn(db, chat):
"""A leading assistant turn breaks templates requiring the first non-system
message to be user; a lone leading user turn produces user, user whenever the
kept history starts on a user turn -- which it always does."""
old = _exchange(db, chat, question="one", answer="two", minutes=10)
_exchange(db, chat, question="three", answer="four", minutes=1)
compaction_service.apply(chat, summary="Summary.", upto=old)
db.commit()
roles = [m["role"] for m in chat_service.build_messages(db, chat)]
assert roles[:2] == ["user", "assistant"]
# And it still alternates from there.
assert roles == ["user", "assistant", "user", "assistant"]
def test_there_is_still_exactly_one_system_message(db, chat):
settings_store.update(db, {"system_prompt": "Speak as Gandalf."})
old = _exchange(db, chat, question="one", answer="two", minutes=10)
compaction_service.apply(chat, summary="Summary.", upto=old)
db.commit()
roles = [m["role"] for m in chat_service.build_messages(db, chat, system_prompt="S")]
assert roles.count("system") == 1
assert roles[0] == "system"
def test_clearing_the_lead_fragment_still_sends_the_summary(db, chat):
prompts.save(db, {"task.compact_lead": "", "task.compact_ack": ""})
old = _exchange(db, chat, question="one", answer="two", minutes=10)
compaction_service.apply(chat, summary="Summary.", upto=old)
db.commit()
messages = chat_service.build_messages(db, chat)
assert messages[0] == {"role": "user", "content": "Summary."}
# --- The transcript -----------------------------------------------------------
def test_only_the_delta_is_summarised_the_second_time(db, chat):
"""Re-summarising the whole chat grows quadratically and eventually exceeds
the very window it is protecting."""
first = _exchange(db, chat, question="the old part", answer="ok", minutes=20)
compaction_service.apply(chat, summary="Earlier summary.", upto=first)
db.commit()
second = _exchange(db, chat, question="the new part", answer="ok", minutes=5)
transcript = compaction_service.transcript(db, chat, upto=second)
assert "the new part" in transcript
assert "the old part" not in transcript
assert "Earlier summary." in compaction_service.previous_summary_block(chat)
def test_a_first_compaction_has_no_previous_summary(db, chat):
assert compaction_service.previous_summary_block(chat) == ""
def test_a_huge_transcript_is_trimmed_from_the_front(db, chat):
reply = _exchange(db, chat, question="x" * 40_000, answer="ok", minutes=5)
transcript = compaction_service.transcript(db, chat, upto=reply)
assert len(transcript) < 40_000
assert transcript.startswith("[earlier turns omitted]")
# --- The button ---------------------------------------------------------------
def _summariser(text: str = "## What we are doing\n\nAsking about lembas."):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"choices": [{"message": {"content": text}}]})
return handler
async def test_compacting_summarises_and_hides_the_earlier_turns(
client: TestClient, db, chat, mock_http
):
_exchange(db, chat, question="what is lembas?", answer="Waybread.", minutes=10)
mock_http(_summariser())
response = client.post(f"/api/chats/{chat.id}/compact")
assert response.status_code == 200
db.refresh(chat)
assert "Asking about lembas." in chat.compact_summary
assert chat.compacted_through_id
# The messages are still there, behind the divider.
assert "earlier messages, summarised" in response.text
assert db.scalar(select(Message).where(Message.content == "what is lembas?")) is not None
async def test_compacting_while_a_reply_is_being_written_is_refused(
client: TestClient, db, chat, mock_http
):
_exchange(db, chat, question="one", answer="two", minutes=10)
db.add(Message(chat_id=chat.id, role="assistant", content="", complete=False))
db.commit()
response = client.post(f"/api/chats/{chat.id}/compact")
assert response.status_code == 409
async def test_compaction_can_be_turned_off_by_clearing_its_prompt(
client: TestClient, db, chat, mock_http
):
_exchange(db, chat, question="one", answer="two", minutes=10)
prompts.save(db, {"task.compact": ""})
response = client.post(f"/api/chats/{chat.id}/compact")
assert response.status_code == 409
assert "turned off" in response.json()["detail"]
async def test_compacting_an_empty_chat_is_refused(client: TestClient, db, chat, mock_http):
assert client.post(f"/api/chats/{chat.id}/compact").status_code == 409
# --- Rewinding across the boundary --------------------------------------------
def test_editing_at_or_before_the_cutoff_clears_the_compaction(
client: TestClient, db, chat, mock_http
):
"""A rewind deletes everything after the edited message, so a boundary at or
behind it no longer describes anything that exists."""
first_reply = _exchange(db, chat, question="one", answer="two", minutes=20)
_exchange(db, chat, question="three", answer="four", minutes=10)
compaction_service.apply(chat, summary="Summary.", upto=first_reply)
db.commit()
first_user = db.scalar(select(Message).where(Message.content == "one"))
mock_http(_summariser())
client.post(
f"/api/chats/{chat.id}/messages/{first_user.id}/edit", data={"content": "one again"}
)
db.refresh(chat)
assert chat.compact_summary == ""
assert chat.compacted_through_id is None
# --- Automatic ----------------------------------------------------------------
def test_it_fires_when_the_window_is_nearly_full(db, chat):
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=960)
db.commit()
assert compaction_service.should_compact(db, chat) is True
def test_it_does_not_fire_with_room_to_spare(db, chat):
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=400)
db.commit()
assert compaction_service.should_compact(db, chat) is False
def test_a_large_pending_turn_is_counted(db, chat):
"""The recorded figure is one turn stale. Fifty thousand characters pasted
into the composer overflow a window that measured 90% last time."""
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=900)
db.commit()
assert compaction_service.should_compact(db, chat) is False
assert compaction_service.should_compact(db, chat, pending="x" * 400) is True
def test_it_never_fires_without_a_context_length(db, chat):
"""Acting on a number nobody supplied is exactly what 0-means-unknown is
there to prevent."""
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=99_000, limit=0)
db.commit()
assert compaction_service.should_compact(db, chat) is False
def test_a_threshold_of_zero_turns_it_off(db, chat):
settings_store.update(db, {"compact_threshold": 0})
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=999)
db.commit()
assert compaction_service.should_compact(db, chat) is False
def test_it_does_not_fire_on_the_first_turn(db, chat):
assert compaction_service.should_compact(db, chat) is False
def test_it_acts_on_estimated_counts_too(db, chat):
"""A premature compaction costs one turn of answer quality, not data -- the
messages are still there. That is what makes acting on an estimate safe."""
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
_usage(reply, context_tokens=960)
reply.usage_json = {**reply.usage_json, "estimated": True}
db.commit()
assert compaction_service.should_compact(db, chat) is True
async def test_a_generation_compacts_before_it_asks(db, chat, mock_http):
"""At the top of _run, so build_request is called once and what goes out is
the compacted conversation."""
from lembas.services import generation as generation_service
reply = _exchange(db, chat, question="what is lembas?", answer="Waybread.", minutes=10)
_usage(reply, context_tokens=980)
db.commit()
pending = Message(chat_id=chat.id, role="user", content="more?")
db.add(pending)
db.commit()
placeholder = Message(chat_id=chat.id, role="assistant", content="", complete=False)
db.add(placeholder)
db.commit()
sent: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
sent.append(body)
if body.get("stream"):
return httpx.Response(200, text="data: [DONE]\n\n")
return httpx.Response(200, json={"choices": [{"message": {"content": "A summary."}}]})
mock_http(handler)
generation = generation_service.Generation(chat_id=chat.id, message_id=placeholder.id)
generation_service._RUNNING[placeholder.id] = generation
await generation_service._run(generation)
generation_service._RUNNING.clear()
db.expire_all()
assert db.get(Chat, chat.id).compact_summary == "A summary."
# The streamed request went out after compaction, carrying the summary.
streamed = next(b for b in sent if b.get("stream"))
assert any("A summary." in str(m.get("content")) for m in streamed["messages"])
assert not any(m.get("content") == "what is lembas?" for m in streamed["messages"])
async def test_a_failed_compaction_still_sends_the_reply(db, chat, mock_http):
"""Refusing to answer because the summariser was unavailable is a worse
trade than sending the uncompacted request."""
from lembas.services import generation as generation_service
reply = _exchange(db, chat, question="one", answer="two", minutes=10)
_usage(reply, context_tokens=980)
db.commit()
placeholder = Message(chat_id=chat.id, role="assistant", content="", complete=False)
db.add(placeholder)
db.commit()
def handler(request: httpx.Request) -> httpx.Response:
if json.loads(request.content).get("stream"):
return httpx.Response(
200, text='data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n'
)
return httpx.Response(500, json={"error": {"message": "no"}})
mock_http(handler)
generation = generation_service.Generation(chat_id=chat.id, message_id=placeholder.id)
generation_service._RUNNING[placeholder.id] = generation
await generation_service._run(generation)
generation_service._RUNNING.clear()
assert generation.text == "hi"
assert not generation.error
+144
View File
@@ -0,0 +1,144 @@
"""The admin request inspector."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Chat, Connection, Message, Model, User
from lembas.services import settings_store
from lembas.services.crypto import encrypt
def _connection(db, **model_kwargs) -> None:
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="test-model", **model_kwargs))
db.commit()
@pytest.fixture
def plain_user(client: TestClient, db, registered) -> User:
"""A second, non-admin account, left signed in."""
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
return db.scalar(select(User).where(User.email == "sam@shire.test"))
# --- Access -------------------------------------------------------------------
def test_an_admin_sees_the_toggle(client: TestClient, db, registered, make_chat):
_connection(db)
page = client.get(f"/chat/{make_chat()}").text
assert 'data-toggle="#inspector"' in page
assert 'id="inspector"' in page
def test_a_plain_user_has_no_inspector(client: TestClient, db, plain_user, make_chat):
_connection(db)
chat_id = make_chat(email="sam@shire.test")
page = client.get(f"/chat/{chat_id}").text
assert 'data-toggle="#inspector"' not in page
assert 'id="inspector"' not in page
def test_a_plain_user_is_refused_the_endpoint(client: TestClient, db, plain_user, make_chat):
"""Owning the chat is not enough. Reading a conversation is a different act
from configuring the instance, which is why sharing has no admin branch
either -- and this must not become one by another name."""
_connection(db)
chat_id = make_chat(email="sam@shire.test")
assert client.get(f"/api/chats/{chat_id}/inspect").status_code == 403
def test_an_admin_cannot_inspect_someone_elses_chat(
client: TestClient, db, registered, make_chat
):
_connection(db)
chat_id = make_chat()
other = User(name="Sam", email="s@shire.test", password_hash="x")
db.add(other)
db.commit()
db.get(Chat, chat_id).user_id = other.id
db.commit()
assert client.get(f"/api/chats/{chat_id}/inspect").status_code == 404
# --- What it shows ------------------------------------------------------------
def test_it_says_it_is_rebuilt_not_recorded(client: TestClient, db, registered, make_chat):
_connection(db)
body = client.get(f"/api/chats/{make_chat()}/inspect").text
assert "not a recording" in body
def test_it_shows_the_system_message_and_the_request(
client: TestClient, db, registered, make_chat
):
settings_store.update(db, {"system_prompt": "Speak as Gandalf."})
_connection(db, context_length=8192)
chat_id = make_chat()
db.add(Message(chat_id=chat_id, role="user", content="what is lembas?"))
db.commit()
body = client.get(f"/api/chats/{chat_id}/inspect").text
assert "Speak as Gandalf." in body
assert "test-model" in body
assert "what is lembas?" in body
assert "8192 tokens" in body
def test_model_output_is_escaped(client: TestClient, db, registered, make_chat):
"""The JSON is full of model output and search results. Hard rule 6 applies
here exactly as it does to a chat bubble."""
_connection(db)
chat_id = make_chat()
db.add(Message(chat_id=chat_id, role="assistant", content="<script>alert(1)</script>"))
db.commit()
body = client.get(f"/api/chats/{chat_id}/inspect").text
assert "<script>alert(1)</script>" not in body
assert "&lt;script&gt;" in body
def test_an_image_is_not_dumped_into_the_dom(
client: TestClient, db, registered, make_chat
):
"""A phone photo is megabytes of base64. Fidelity is the point of the panel,
but not that much of it."""
import io
from PIL import Image
from lembas.db.models import Attachment
_connection(db, capabilities_json={"vision": True})
chat_id = make_chat()
buffer = io.BytesIO()
Image.new("RGB", (80, 60), "red").save(buffer, format="PNG")
client.post("/api/files", files={"file": ("p.png", buffer.getvalue(), "image/png")})
attachment = db.scalar(select(Attachment))
message = Message(chat_id=chat_id, role="user", content="look")
db.add(message)
db.commit()
attachment.message_id = message.id
attachment.chat_id = chat_id
db.commit()
body = client.get(f"/api/chats/{chat_id}/inspect").text
assert "base64 image omitted" in body
assert "iVBORw0" not in body
def test_a_chat_with_no_reply_yet_says_so(client: TestClient, db, registered, make_chat):
_connection(db)
assert "Nothing has been answered" in client.get(f"/api/chats/{make_chat()}/inspect").text
+452
View File
@@ -0,0 +1,452 @@
"""How full the context is, what a reply cost, and how fast it arrived."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Connection, Model
from lembas.services import metrics, tokens
from lembas.services.crypto import encrypt
from lembas.services.llm.openai_client import chunk_usage, context_from
def _model(db, **kwargs) -> Model:
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
model = Model(connection_id=connection.id, model_id="test-model", **kwargs)
db.add(model)
db.commit()
return model
# --- Reading a context length off /v1/models ---------------------------------
def test_context_length_is_read_from_any_of_the_spellings():
assert context_from({"id": "m", "context_length": 8192}) == 8192
assert context_from({"id": "m", "max_model_len": 32768}) == 32768
assert context_from({"id": "m", "context_window": 4096}) == 4096
assert context_from({"id": "m", "meta": {"n_ctx": 2048}}) == 2048
def test_a_quoted_number_is_accepted_but_a_label_is_not():
"""Some servers quote it. "8192 tokens" is a label, not a measurement."""
assert context_from({"id": "m", "context_length": "8192"}) == 8192
assert context_from({"id": "m", "context_length": "8192 tokens"}) == 0
def test_an_absent_or_implausible_context_length_is_zero():
assert context_from({"id": "m"}) == 0
assert context_from({"id": "m", "context_length": 0}) == 0
assert context_from({"id": "m", "context_length": 64}) == 0
assert context_from({"id": "m", "context_length": 10**9}) == 0
# True is an int in Python, and it is not a context length.
assert context_from({"id": "m", "context_length": True}) == 0
# --- Discovery ---------------------------------------------------------------
async def test_discovery_fills_in_a_context_length(client: TestClient, db, registered, mock_http):
import httpx
mock_http(
lambda _r: httpx.Response(
200, json={"data": [{"id": "big-model", "context_length": 16384}]}
)
)
client.post(
"/admin/connections",
data={"name": "Local", "base_url": "http://x.test", "api_key": ""},
follow_redirects=False,
)
model = db.scalar(select(Model).where(Model.model_id == "big-model"))
assert model.context_length == 16384
async def test_discovery_never_overwrites_a_number_an_admin_typed(
client: TestClient, db, registered, mock_http
):
"""A refresh must not undo a correction. Administrators set this precisely
because the endpoint was wrong or silent."""
import httpx
mock_http(
lambda _r: httpx.Response(200, json={"data": [{"id": "m", "context_length": 4096}]})
)
client.post(
"/admin/connections",
data={"name": "Local", "base_url": "http://x.test", "api_key": ""},
follow_redirects=False,
)
model = db.scalar(select(Model).where(Model.model_id == "m"))
model.context_length = 131072
db.commit()
connection = db.scalar(select(Connection))
client.post(f"/admin/connections/{connection.id}/refresh", follow_redirects=False)
db.refresh(model)
assert model.context_length == 131072
# --- The admin field ---------------------------------------------------------
def test_an_admin_can_set_and_clear_the_context_length(client: TestClient, db, registered):
model = _model(db)
client.post(
f"/admin/models/{model.id}",
data={"context_length": "8192", "position": ""},
follow_redirects=False,
)
db.refresh(model)
assert model.context_length == 8192
client.post(
f"/admin/models/{model.id}", data={"context_length": "", "position": ""},
follow_redirects=False,
)
db.refresh(model)
assert model.context_length == 0
def test_junk_in_the_context_length_field_is_ignored_not_a_500(
client: TestClient, db, registered
):
model = _model(db, context_length=4096)
response = client.post(
f"/admin/models/{model.id}",
data={"context_length": "eight thousand", "position": ""},
follow_redirects=False,
)
assert response.status_code == 303
db.refresh(model)
assert model.context_length == 4096
# --- Usage off the wire -------------------------------------------------------
def test_usage_is_read_from_a_usage_chunk():
chunk = {
"choices": [],
"usage": {"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120},
}
assert chunk_usage(chunk) == {
"prompt_tokens": 100,
"completion_tokens": 20,
"total_tokens": 120,
}
def test_a_missing_total_is_worked_out():
chunk = {"choices": [], "usage": {"prompt_tokens": 100, "completion_tokens": 20}}
assert chunk_usage(chunk)["total_tokens"] == 120
def test_an_ordinary_chunk_carries_no_usage():
assert chunk_usage({"choices": [{"delta": {"content": "hi"}}]}) is None
assert chunk_usage({}) is None
assert chunk_usage({"usage": "lots"}) is None
def test_an_all_zero_usage_object_is_not_an_answer():
"""Some servers attach zeros to every chunk and the real numbers only at the
end. Believing the zeros freezes the count at nothing."""
chunk = {"choices": [], "usage": {"prompt_tokens": 0, "completion_tokens": 0}}
assert chunk_usage(chunk) is None
def test_the_other_accessors_still_ignore_a_usage_chunk():
"""They return early on `choices: []`, which is exactly the shape of one.
That is what lets a usage chunk through the loop untouched."""
from lembas.services.llm.openai_client import (
delta_reasoning,
delta_text,
delta_tool_calls,
finish_reason,
)
chunk = {"choices": [], "usage": {"prompt_tokens": 1, "completion_tokens": 1}}
assert delta_text(chunk) == ""
assert delta_reasoning(chunk) == ""
assert delta_tool_calls(chunk) == []
assert finish_reason(chunk) == ""
async def test_stream_options_is_asked_for(mock_http):
import json as json_module
import httpx
from lembas.services.llm.openai_client import Endpoint, stream_chat
seen: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(json_module.loads(request.content))
return httpx.Response(200, text="data: [DONE]\n\n")
mock_http(handler)
async for _ in stream_chat(Endpoint("http://ask.test", "", {}), {"model": "m"}):
pass
assert seen[0]["stream_options"] == {"include_usage": True}
async def test_an_endpoint_that_rejects_stream_options_is_asked_once(mock_http):
"""A 400 for an unknown key is the same hazard as sending `tools` to an
endpoint without support. Retry without it, then stop asking."""
import json as json_module
import httpx
from lembas.services.llm.openai_client import (
_NO_STREAM_OPTIONS,
Endpoint,
stream_chat,
)
_NO_STREAM_OPTIONS.discard("http://fussy.test")
seen: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json_module.loads(request.content)
seen.append(body)
if "stream_options" in body:
return httpx.Response(400, json={"error": {"message": "unknown field"}})
reply = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n'
return httpx.Response(200, text=reply)
mock_http(handler)
endpoint = Endpoint("http://fussy.test", "", {})
text = [c async for c in stream_chat(endpoint, {"model": "m"})]
assert text, "the retry should have produced the reply"
assert len(seen) == 2
# Second reply: it already knows, so one request and no stream_options.
async for _ in stream_chat(endpoint, {"model": "m"}):
pass
assert len(seen) == 3
assert "stream_options" not in seen[2]
_NO_STREAM_OPTIONS.discard("http://fussy.test")
# --- The estimate -------------------------------------------------------------
def test_the_estimate_is_about_four_characters_a_token():
assert tokens.estimate("") == 0
assert tokens.estimate("x" * 400) == 100
def test_typed_content_parts_are_counted_and_images_are_not():
"""An image's cost depends on the model's tiling. A number invented here
would be worse than the omission."""
content = [
{"type": "text", "text": "x" * 40},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA" * 500}},
]
assert tokens.estimate_content(content) == 10
def test_a_request_estimate_includes_the_tools_array():
"""Thirteen schemas is a meaningful slice of a short window; leaving them
out would read low exactly when it matters."""
payload = {
"messages": [{"role": "user", "content": "x" * 40}],
"tools": [
{"function": {"name": "web_search", "description": "y" * 400, "parameters": {}}}
],
}
assert tokens.estimate_request(payload) > tokens.estimate_request(
{"messages": payload["messages"]}
)
# --- The Metrics object -------------------------------------------------------
def test_the_percentage_is_zero_when_nobody_said_how_big_the_window_is():
"""Unknown must stay tellable from small. A percentage of an unknown total
is a made-up number in a place people trust numbers."""
assert metrics.Metrics(context_tokens=5000, context_limit=0).percent == 0
assert metrics.Metrics(context_tokens=5000, context_limit=10000).percent == 50
def test_pressure_marks_the_bar_only_when_it_is_worth_noticing():
assert metrics.Metrics(context_tokens=50, context_limit=100).pressure == ""
assert metrics.Metrics(context_tokens=85, context_limit=100).pressure == "warning"
assert metrics.Metrics(context_tokens=96, context_limit=100).pressure == "danger"
assert metrics.Metrics(context_tokens=0, context_limit=0).pressure == ""
def test_speed_needs_both_a_count_and_a_clock():
assert metrics.Metrics(completion_tokens=100, elapsed_ms=2000).tokens_per_second == 50.0
assert metrics.Metrics(completion_tokens=100, elapsed_ms=0).tokens_per_second == 0.0
assert metrics.Metrics(completion_tokens=0, elapsed_ms=2000).tokens_per_second == 0.0
def test_metrics_survive_a_round_trip_through_the_row():
original = metrics.Metrics(
prompt_tokens=100,
completion_tokens=20,
total_tokens=120,
context_tokens=120,
context_limit=8192,
estimated=True,
elapsed_ms=1500,
rounds=2,
)
assert metrics.from_message(metrics.to_json(original)) == original
def test_a_row_with_no_usage_reads_as_nothing_rather_than_failing():
assert metrics.from_message(None).has_anything is False
assert metrics.from_message({}).has_anything is False
assert metrics.from_message({"prompt_tokens": "lots"}).prompt_tokens == 0
# --- Through a generation -----------------------------------------------------
def _chunks(*frames: str) -> str:
return "".join(f"data: {frame}\n\n" for frame in frames) + "data: [DONE]\n\n"
async def test_usage_is_summed_across_tool_rounds(db, registered, make_chat, monkeypatch):
"""Prompt and completion are what the reply cost; context_tokens is what the
window holds. A three-round reply pays for its prompt three times and only
ever occupies the window once."""
from lembas.services import generation as generation_service
generation = generation_service.Generation(chat_id="c", message_id="m")
for prompt_tokens, completion_tokens in ((100, 10), (250, 20)):
counts = {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens}
generation.prompt_tokens += counts["prompt_tokens"]
generation.completion_tokens += counts["completion_tokens"]
generation.context_tokens = prompt_tokens + completion_tokens
assert generation.prompt_tokens == 350
assert generation.completion_tokens == 30
assert generation.context_tokens == 270
async def test_a_reply_records_what_it_cost(client: TestClient, db, registered, mock_http):
import httpx
from lembas.db.models import Message
def handler(_request: httpx.Request) -> httpx.Response:
if _request.url.path.endswith("/models"):
return httpx.Response(200, json={"data": [{"id": "m", "context_length": 1000}]})
return httpx.Response(
200,
text=_chunks(
'{"choices":[{"delta":{"content":"Waybread."}}]}',
'{"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":8}}',
),
)
mock_http(handler)
client.post(
"/admin/connections",
data={"name": "Local", "base_url": "http://x.test", "api_key": ""},
follow_redirects=False,
)
client.post("/api/chats/start", data={"content": "what is lembas?"})
reply = db.scalar(select(Message).where(Message.role == "assistant"))
# Following the stream is what runs the generation to completion.
with client.stream(
"GET", f"/api/chats/{reply.chat_id}/messages/{reply.id}/stream"
) as response:
list(response.iter_lines())
db.refresh(reply)
assert reply.usage_json["prompt_tokens"] == 120
assert reply.usage_json["completion_tokens"] == 8
assert reply.usage_json["context_limit"] == 1000
assert reply.usage_json["estimated"] is False
assert reply.usage_json["elapsed_ms"] >= 0
def test_the_chips_render_from_a_stored_row(client: TestClient, db, registered, make_chat):
from lembas.db.models import Message
_model(db)
chat_id = make_chat()
db.add(
Message(
chat_id=chat_id,
role="assistant",
content="Waybread.",
usage_json={
"prompt_tokens": 100,
"completion_tokens": 20,
"total_tokens": 120,
"context_tokens": 120,
"context_limit": 1000,
"estimated": False,
"elapsed_ms": 2000,
"rounds": 1,
},
)
)
db.commit()
page = client.get(f"/chat/{chat_id}").text
assert "120 tokens" in page
assert "12%" in page
assert "10.0 tok/s" in page
assert "~" not in page.split("msg__metrics")[1][:400]
def test_an_estimated_row_says_so(client: TestClient, db, registered, make_chat):
from lembas.db.models import Message
_model(db)
chat_id = make_chat()
db.add(
Message(
chat_id=chat_id,
role="assistant",
content="Waybread.",
usage_json={
"prompt_tokens": 100,
"completion_tokens": 20,
"total_tokens": 120,
"context_tokens": 120,
"context_limit": 1000,
"estimated": True,
"elapsed_ms": 2000,
"rounds": 1,
},
)
)
db.commit()
page = client.get(f"/chat/{chat_id}").text
assert "~120 tokens" in page
assert "reports no token counts" in page
def test_no_context_length_means_no_percentage(client: TestClient, db, registered, make_chat):
from lembas.db.models import Message
_model(db)
chat_id = make_chat()
db.add(
Message(
chat_id=chat_id,
role="assistant",
content="Waybread.",
usage_json={
"prompt_tokens": 100,
"completion_tokens": 20,
"total_tokens": 120,
"context_tokens": 120,
"context_limit": 0,
"estimated": False,
"elapsed_ms": 2000,
"rounds": 1,
},
)
)
db.commit()
page = client.get(f"/chat/{chat_id}").text
assert "120 tokens" in page
assert "metric--context" not in page
+262
View File
@@ -0,0 +1,262 @@
"""Regenerating a reply, and the registry rules that make it work.
Regeneration is the one caller that reuses a Message row rather than creating a
new one, which is why it is the one caller `ensure` was wrong for.
"""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Chat, Connection, Message, Model
from lembas.services import generation as generation_service
from lembas.services.crypto import encrypt
@pytest.fixture(autouse=True)
def empty_registry():
"""The registry is module state. A test that leaves an entry behind changes
what the next one sees."""
yield
generation_service._RUNNING.clear()
generation_service._TASKS.clear()
@pytest.fixture
def no_upstream(monkeypatch):
"""Replace the producer, so a test can watch the registry without a server.
Records the generations it was asked to run.
"""
started: list = []
async def _fake_run(generation):
started.append(generation)
monkeypatch.setattr(generation_service, "_run", _fake_run)
return started
def _connection(db) -> Connection:
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="test-model"))
db.commit()
return connection
def _reply(db, chat_id: str, *, complete: bool = True) -> Message:
db.add(Message(chat_id=chat_id, role="user", content="what is lembas?"))
reply = Message(chat_id=chat_id, role="assistant", content="Waybread.", complete=complete)
db.add(reply)
db.commit()
return reply
def _finished(chat_id: str, message_id: str, *, text: str = "old") -> generation_service.Generation:
"""A generation in the state a just-completed reply leaves behind."""
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
generation.content.append(text)
generation.done = True
generation.finished_at = datetime.now(UTC)
generation_service._RUNNING[message_id] = generation
return generation
# --- The bug -----------------------------------------------------------------
def test_regenerate_starts_a_fresh_generation(
client: TestClient, db, registered, make_chat, no_upstream
):
"""The bug: `ensure` handed back the finished generation still sitting in
the registry, so no request was ever made and the browser reconnected to a
stream that had nothing left to say."""
_connection(db)
chat_id = make_chat()
reply = _reply(db, chat_id)
stale = _finished(chat_id, reply.id)
response = client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate")
assert response.status_code == 200
fresh = generation_service.get(reply.id)
assert fresh is not stale
assert not fresh.done
assert fresh.text == ""
assert no_upstream == [fresh]
def test_regenerate_blanks_the_row_and_returns_a_streaming_shell(
client: TestClient, db, registered, make_chat, no_upstream
):
_connection(db)
chat_id = make_chat()
reply = _reply(db, chat_id)
body = client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate").text
assert "sse-connect=" in body
assert 'sse-swap="render"' in body
db.expire_all()
row = db.get(Message, reply.id)
assert row.complete is False
assert row.content == ""
def test_regenerating_twice_in_a_row_works(
client: TestClient, db, registered, make_chat, no_upstream
):
"""The reported symptom was that it worked once, sometimes."""
_connection(db)
chat_id = make_chat()
reply = _reply(db, chat_id)
client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate")
first = generation_service.get(reply.id)
first.done = True
first.finished_at = datetime.now(UTC)
client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate")
assert generation_service.get(reply.id) is not first
assert len(no_upstream) == 2
def test_regenerating_someone_elses_reply_is_not_found(
client: TestClient, db, registered, make_chat, no_upstream
):
from lembas.db.models import User
from lembas.security.passwords import hash_password
_connection(db)
chat_id = make_chat()
reply = _reply(db, chat_id)
someone_else = User(name="Sam", email="s@shire.test", password_hash=hash_password("x"))
db.add(someone_else)
db.commit()
db.scalar(select(Chat).where(Chat.id == chat_id)).user_id = someone_else.id
db.commit()
response = client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate")
assert response.status_code == 404
# --- Registry rules ----------------------------------------------------------
async def test_ensure_still_attaches_to_an_unfinished_reply(db, no_upstream):
"""Idempotence is load-bearing: a page load that finds an unfinished reply
must attach to it, not start a second one."""
first = generation_service.ensure("chat", "message")
assert generation_service.ensure("chat", "message") is first
await asyncio.sleep(0) # let the scheduled task actually start
assert len(no_upstream) == 1
async def test_prune_expires_a_finished_generation_before_the_lookup(db, no_upstream):
"""`_prune` used to sit below the early return, where it could never reach
the one entry that needed it."""
stale = _finished("chat", "message")
stale.finished_at = datetime.now(UTC) - generation_service.KEEP_FINISHED - timedelta(minutes=1)
assert generation_service.ensure("chat", "message") is not stale
def test_a_superseded_generation_does_not_write_the_row(db, registered, make_chat):
"""A cancelled predecessor's `finally:` runs _persist on the same message,
and it must not overwrite the reply that replaced it."""
chat_id = make_chat()
reply = _reply(db, chat_id)
abandoned = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
abandoned.content.append("the abandoned attempt")
current = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
current.content.append("the reply that replaced it")
generation_service._RUNNING[reply.id] = current
generation_service._persist(abandoned, "", 0.0)
db.expire_all()
assert db.get(Message, reply.id).content == "Waybread."
async def test_restart_cancels_a_generation_still_running(db, no_upstream):
live = generation_service.ensure("chat", "message")
generation_service.restart("chat", "message")
assert live.cancel is True
assert generation_service.get("message") is not live
# --- Ordering ----------------------------------------------------------------
async def test_the_reply_is_persisted_before_it_is_marked_done(
db, registered, make_chat, monkeypatch
):
"""`_follow` breaks out the instant it sees `done` and re-renders the bubble
from the row, so the row has to be right first."""
chat_id = make_chat()
reply = _reply(db, chat_id, complete=False)
seen: list[bool] = []
def _record(generation, title, elapsed):
seen.append(generation.done)
monkeypatch.setattr(generation_service, "_persist", _record)
generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
generation_service._RUNNING[reply.id] = generation
# No connection row, so resolve_endpoint fails and _run goes straight to
# its finally: which is the part under test.
await generation_service._run(generation)
assert seen == [False]
assert generation.done is True
# --- The streaming shell ------------------------------------------------------
def test_live_reasoning_is_replaced_not_appended(
client: TestClient, db, registered, make_chat, no_upstream
):
"""The frame carries the whole block each time. Appending it repeated
everything already shown, so the panel grew quadratically."""
_connection(db)
chat_id = make_chat()
reply = _reply(db, chat_id)
body = client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate").text
line = next(line for line in body.splitlines() if 'sse-swap="reasoning"' in line)
assert 'hx-swap="innerHTML"' in line
assert "beforeend" not in line
async def test_a_silent_generation_gets_a_keepalive(db, registered, make_chat, monkeypatch):
"""A model thinking for a minute emits nothing, and an idle connection is
what a proxy closes."""
from lembas.api import chats as chats_api
chat_id = make_chat()
reply = _reply(db, chat_id, complete=False)
generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
generation_service._RUNNING[reply.id] = generation
monkeypatch.setattr(chats_api, "KEEPALIVE_AFTER", 0.0)
frames: list[str] = []
stream = chats_api._follow(chat_id, reply.id).__aiter__()
async def _finish():
await asyncio.sleep(0.05)
generation.done = True
task = asyncio.create_task(_finish())
async for frame in stream:
frames.append(frame)
if frame.startswith("event: close"):
break
await task
assert any(frame == ": keepalive\n\n" for frame in frames)
+181
View File
@@ -0,0 +1,181 @@
"""Prompt suggestions: seeding, administration, and the cards."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Connection, Model, Suggestion, User
from lembas.services import settings_store
from lembas.services import suggestions as suggestions_service
from lembas.services.crypto import encrypt
@pytest.fixture
def clean(client: TestClient, db, registered):
"""Start from no suggestions.
The app's lifespan seeds the three defaults, so any test that goes through
`client` already has them. Tests about seeding want that; tests about the
admin screen want to count their own rows.
"""
for suggestion in suggestions_service.all_of_them(db):
db.delete(suggestion)
db.commit()
@pytest.fixture
def plain_user(client: TestClient, db, registered) -> User:
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
return db.scalar(select(User).where(User.email == "sam@shire.test"))
def _connection(db) -> None:
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="test-model"))
db.commit()
# --- Seeding ------------------------------------------------------------------
def test_seeding_writes_the_defaults_once(db):
assert suggestions_service.seed_defaults(db) == len(suggestions_service.DEFAULTS)
assert suggestions_service.seed_defaults(db) == 0
assert len(suggestions_service.all_of_them(db)) == len(suggestions_service.DEFAULTS)
def test_deleting_every_suggestion_does_not_bring_them_back(db):
"""The guard is a flag, not "is the table empty". Otherwise an administrator
who decided against them gets them back on every restart."""
suggestions_service.seed_defaults(db)
for suggestion in suggestions_service.all_of_them(db):
db.delete(suggestion)
db.commit()
assert suggestions_service.seed_defaults(db) == 0
assert suggestions_service.all_of_them(db) == []
def test_the_seed_flag_lives_in_settings(db):
suggestions_service.seed_defaults(db)
assert settings_store.get(db, suggestions_service.SEEDED_KEY) is True
# --- What is shown ------------------------------------------------------------
def test_only_enabled_ones_are_shown(db):
suggestions_service.create(db, name="Shown", prompt="a")
hidden = suggestions_service.create(db, name="Hidden", prompt="b")
hidden.enabled = False
db.commit()
assert [s.name for s in suggestions_service.visible(db)] == ["Shown"]
def test_position_decides_the_order(db):
first = suggestions_service.create(db, name="Zebra", prompt="a")
second = suggestions_service.create(db, name="Antelope", prompt="b")
first.position, second.position = 5, 1
db.commit()
assert [s.name for s in suggestions_service.visible(db)] == ["Antelope", "Zebra"]
def test_no_more_than_the_cap_is_shown(db):
for index in range(suggestions_service.MAX_SHOWN + 3):
suggestions_service.create(db, name=f"One {index}", prompt="x")
assert len(suggestions_service.visible(db)) == suggestions_service.MAX_SHOWN
def test_new_rows_land_at_the_end(db):
suggestions_service.create(db, name="First", prompt="a")
assert suggestions_service.create(db, name="Second", prompt="b").position == 1
# --- The cards ----------------------------------------------------------------
def test_the_cards_appear_on_the_new_chat_screen(client: TestClient, db, registered):
_connection(db)
suggestions_service.create(db, name="Explain this", description="Plain language.", prompt="Go ")
page = client.get("/chat").text
assert "Explain this" in page
assert "Plain language." in page
assert 'data-suggestion="Go "' in page
def test_the_cards_do_not_appear_in_an_existing_empty_chat(
client: TestClient, db, registered, make_chat
):
"""A chat someone opened on purpose already has a model and a prompt
chosen; the cards are for the screen where nothing has been decided."""
_connection(db)
suggestions_service.create(db, name="Explain this", prompt="Go ")
chat_id = make_chat()
assert "data-suggestion" not in client.get(f"/chat/{chat_id}").text
def test_a_prompt_with_quotes_is_escaped_in_the_attribute(client: TestClient, db, registered):
_connection(db)
suggestions_service.create(db, name="Tricky", prompt='say "<script>alert(1)</script>"')
page = client.get("/chat").text
assert "<script>alert(1)</script>" not in page
assert "&lt;script&gt;" in page
# --- Administration -----------------------------------------------------------
def test_the_admin_page_is_refused_to_a_plain_user(client: TestClient, plain_user):
assert client.get("/admin/suggestions").status_code == 403
assert client.post("/admin/suggestions", data={"name": "x"}).status_code == 403
def test_an_admin_can_create_edit_and_delete(client: TestClient, db, registered, clean):
client.post(
"/admin/suggestions",
data={"name": "Explain this", "description": "Plain language.", "prompt": "Go "},
follow_redirects=False,
)
suggestion = db.scalar(select(Suggestion))
assert suggestion.name == "Explain this"
assert suggestion.enabled is True
client.post(
f"/admin/suggestions/{suggestion.id}",
data={"name": "Renamed", "description": "", "prompt": "New ", "position": "3"},
follow_redirects=False,
)
db.refresh(suggestion)
assert suggestion.name == "Renamed"
assert suggestion.prompt == "New "
# An unticked checkbox is simply absent from the post, which is the signal.
assert suggestion.enabled is False
assert suggestion.position == 2
client.post(f"/admin/suggestions/{suggestion.id}/delete", follow_redirects=False)
assert db.scalar(select(Suggestion)) is None
def test_a_nameless_suggestion_is_refused(client: TestClient, db, registered, clean):
client.post("/admin/suggestions", data={"name": " "}, follow_redirects=False)
assert db.scalar(select(Suggestion)) is None
def test_the_limit_is_enforced(client: TestClient, db, registered, clean):
for index in range(suggestions_service.MAX_SUGGESTIONS):
suggestions_service.create(db, name=f"One {index}", prompt="x")
client.post("/admin/suggestions", data={"name": "One too many"}, follow_redirects=False)
assert len(suggestions_service.all_of_them(db)) == suggestions_service.MAX_SUGGESTIONS
def test_editing_something_that_is_gone_is_a_404(client: TestClient, registered):
assert client.post("/admin/suggestions/nope", data={"name": "x"}).status_code == 404
+222
View File
@@ -0,0 +1,222 @@
"""Temporary chats: hidden from the sidebar, and swept a day later."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Chat, Connection, Folder, Message, Model
from lembas.services import chat as chat_service
from lembas.services.crypto import encrypt
def _connection(db) -> Connection:
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="test-model"))
db.commit()
return connection
def _temporary(db, chat_id: str, *, title: str = "Passing thought") -> Chat:
chat = db.get(Chat, chat_id)
chat.temporary = True
chat.title = title
db.commit()
return chat
# --- Hidden ------------------------------------------------------------------
def test_a_temporary_chat_is_not_in_the_sidebar(client: TestClient, db, registered, make_chat):
_connection(db)
_temporary(db, make_chat())
assert "Passing thought" not in client.get("/chat").text
def test_a_temporary_chat_inside_a_folder_is_not_listed(
client: TestClient, db, registered, make_chat
):
"""The folder branch renders through the relationship, which is why this
needs asserting separately from the unfiled list."""
_connection(db)
client.post("/api/folders", data={"name": "Quests"})
folder = db.scalar(select(Folder))
chat_id = make_chat()
client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id})
_temporary(db, chat_id)
page = client.get("/chat").text
assert "Passing thought" not in page
assert "Empty" in page
def test_a_temporary_chat_gets_no_unread_dot(client: TestClient, db, registered, make_chat):
"""There is no sidebar row for the dot, and the toast would name a chat
nobody can navigate to."""
_connection(db)
chat = _temporary(db, make_chat())
chat.unread = True
db.commit()
response = client.get("/api/chats/unread")
assert chat.id not in response.text
assert "HX-Trigger" not in response.headers
def test_user_chats_excludes_temporary_ones(db, registered, make_chat, user_id):
_connection(db)
_temporary(db, make_chat())
assert chat_service.user_chats(db, user_id) == []
def test_a_finished_temporary_reply_is_not_unread(db, registered, make_chat):
from lembas.services import generation as generation_service
_connection(db)
chat_id = make_chat()
_temporary(db, chat_id)
reply = Message(chat_id=chat_id, role="assistant", content="", complete=False)
db.add(reply)
db.commit()
generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
generation.content.append("Waybread.")
generation_service._persist(generation, "", 0.0)
db.expire_all()
assert db.get(Chat, chat_id).unread is False
# --- Starting one -------------------------------------------------------------
def test_the_new_chat_screen_carries_the_flag(client: TestClient, db, registered):
_connection(db)
assert 'name="temporary"' not in client.get("/chat").text
assert 'name="temporary"' in client.get("/chat?temporary=1").text
def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered):
_connection(db)
client.post("/api/chats/start", data={"content": "hello", "temporary": "true"})
assert db.scalar(select(Chat)).temporary is True
def test_starting_an_ordinary_chat_does_not(client: TestClient, db, registered):
_connection(db)
client.post("/api/chats/start", data={"content": "hello"})
assert db.scalar(select(Chat)).temporary is False
# --- Keeping one --------------------------------------------------------------
def test_keeping_a_chat_clears_the_flag(client: TestClient, db, registered, make_chat):
_connection(db)
chat = _temporary(db, make_chat())
response = client.post(f"/api/chats/{chat.id}/keep")
assert response.status_code == 204
assert response.headers["HX-Refresh"] == "true"
db.refresh(chat)
assert chat.temporary is False
assert "Passing thought" in client.get("/chat").text
def test_keeping_someone_elses_chat_is_not_found(client: TestClient, db, registered, make_chat):
from lembas.db.models import User
from lembas.security.passwords import hash_password
_connection(db)
chat = _temporary(db, make_chat())
other = User(name="Sam", email="s@shire.test", password_hash=hash_password("x"))
db.add(other)
db.commit()
chat.user_id = other.id
db.commit()
assert client.post(f"/api/chats/{chat.id}/keep").status_code == 404
# --- The sweep ----------------------------------------------------------------
def _aged(db, chat_id: str, hours: float) -> None:
"""Backdate the chat's newest message, which is what the sweep measures."""
when = datetime.now(UTC) - timedelta(hours=hours)
message = Message(chat_id=chat_id, role="user", content="hello", created_at=when)
db.add(message)
db.commit()
def test_the_sweep_removes_a_stale_temporary_chat(db, registered, make_chat):
_connection(db)
chat_id = make_chat()
_temporary(db, chat_id)
_aged(db, chat_id, 25)
assert chat_service.sweep_temporary(db) == 1
assert db.get(Chat, chat_id) is None
def test_the_sweep_keeps_one_still_in_use(db, registered, make_chat):
"""Age is measured from the newest message. A conversation still going at
hour 23 must not vanish mid-sentence."""
_connection(db)
chat_id = make_chat()
_temporary(db, chat_id)
_aged(db, chat_id, 40)
_aged(db, chat_id, 0.5)
assert chat_service.sweep_temporary(db) == 0
assert db.get(Chat, chat_id) is not None
def test_the_sweep_leaves_ordinary_chats_alone(db, registered, make_chat):
_connection(db)
chat_id = make_chat()
_aged(db, chat_id, 500)
assert chat_service.sweep_temporary(db) == 0
assert db.get(Chat, chat_id) is not None
def test_a_temporary_chat_with_no_messages_ages_from_its_own_row(db, registered, make_chat):
_connection(db)
chat_id = make_chat()
chat = _temporary(db, chat_id)
chat.created_at = datetime.now(UTC) - timedelta(hours=30)
db.commit()
assert chat_service.sweep_temporary(db) == 1
def test_the_sweep_unlinks_the_files_too(client: TestClient, db, registered, make_chat):
"""Deleting a chat cascades the rows but leaves the files on disk. Anything
that deletes chats has to remove them while the rows still say which."""
from lembas.db.models import Attachment
from lembas.services.files import stored_path
_connection(db)
chat_id = make_chat()
client.post("/api/files", files={"file": ("notes.txt", b"some words", "text/plain")})
attachment = db.scalar(select(Attachment))
attachment.chat_id = chat_id
message = Message(chat_id=chat_id, role="user", content="look")
db.add(message)
db.commit()
attachment.message_id = message.id
db.commit()
path = stored_path(attachment.stored_name)
assert path is not None and path.exists()
_temporary(db, chat_id)
db.query(Message).filter(Message.id == message.id).update(
{"created_at": datetime.now(UTC) - timedelta(hours=30)}
)
db.commit()
chat_service.sweep_temporary(db)
assert not path.exists()