+ A self-hosted web UI for your language models, written in Python.
+ Talks to anything that speaks the OpenAI API. Themed after Middle-earth.
+
+
+
+
+
+
+
+
+---
+
+*Lembas* is the Elvish waybread — one bite sustains a traveller for a day's
+march. The capitals hide what it runs on: **LLeM**bas.
+
+## Why this exists
+
+Most self-hosted LLM front-ends are large JavaScript applications with a Python
+API bolted underneath. LLeMbas is the other way round: **server-rendered
+Python**, with htmx and a little Alpine for interactivity. There is no
+`package.json`, no bundler, no build step, and nothing is fetched from a CDN at
+runtime. Clone it, `pip install -e .`, run it.
+
+## Features
+
+**Working now**
+
+- **Chats** — streaming replies, Markdown with server-side syntax highlighting,
+ copy and regenerate, automatic chat titles
+- **Folders** — arbitrarily nested, delete a folder without losing the chats
+ inside it
+- **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp,
+ llama-swap, Ollama or OpenRouter; models are discovered and cached
+- **Accounts** — first account becomes the administrator, argon2 password
+ hashing, revocable server-side sessions
+- **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user
+
+**Planned**
+
+Users & groups with permissions · file upload, vision and PDFs · built-in tools
+with admin settings · custom tools and MCP servers · agentic execution (local
+and over SSH) · image generation.
+
+## Quick start
+
+```bash
+git clone https://git.houmeres.sk/Houmeres/LLeMbas.git
+cd LLeMbas
+
+python -m venv .venv && . .venv/bin/activate
+pip install -e ".[dev]"
+
+cp .env.example .env
+lembas secret-key # paste the result into LEMBAS_SECRET_KEY
+
+lembas serve # http://127.0.0.1:8080
+```
+
+Open the address and create the first account — it becomes the administrator.
+Then go to **Admin → Connections** and add an endpoint. For a local runner that
+is usually `http://localhost:1234/v1` with no API key. Press **Test & refresh**
+and its models appear in the chat model picker.
+
+> The vendored browser libraries (htmx, Alpine) are committed, so no network
+> access is needed to run. To re-fetch or bump them:
+> `python scripts/fetch_vendor.py --update`.
+
+## Configuration
+
+All variables are prefixed `LEMBAS_` and can live in `.env`. See
+[`.env.example`](.env.example) for the annotated list.
+
+| Variable | Default | Purpose |
+|---|---|---|
+| `LEMBAS_SECRET_KEY` | *generated* | Signs sessions and encrypts stored API keys. **Set this.** A generated key changes every restart, signing everyone out and making stored API keys unreadable. |
+| `LEMBAS_DATA_DIR` | `./data` | SQLite database and uploads. |
+| `LEMBAS_HOST` / `LEMBAS_PORT` | `127.0.0.1` / `8080` | Bind address. |
+| `LEMBAS_ALLOW_SIGNUP` | `true` | Let new users register themselves. The first account is always an admin regardless. |
+| `LEMBAS_DEFAULT_THEME` | `moria` | `moria` (dark) or `shire` (light). |
+| `LEMBAS_SESSION_TTL` | `2592000` | Session lifetime in seconds. |
+| `LEMBAS_REQUEST_TIMEOUT` | `300` | Seconds to wait on an upstream model. |
+
+### Commands
+
+```bash
+lembas serve # run the server
+lembas info # where data lives, what is configured
+lembas secret-key # generate a value for LEMBAS_SECRET_KEY
+lembas create-admin # create or promote an administrator
+```
+
+## How it fits together
+
+```
+Browser ──form POST──▶ FastAPI ──▶ SQLite
+ ▲ │
+ │ └──httpx──▶ any OpenAI-compatible endpoint
+ └──── server-sent events ◀───────────────┘ (streamed reply)
+```
+
+Sending a message stores the turn and returns two HTML fragments: the user's
+bubble and an empty assistant bubble carrying an `sse-connect`. That opens a
+server-sent event stream which appends tokens as they arrive, then replaces the
+whole bubble with the finished, Markdown-rendered version. Rendering and
+highlighting happen in Python, so the streamed and final views cannot disagree.
+
+```
+src/lembas/
+ api/ routes: auth, chats, folders, admin, pages
+ db/models/ SQLAlchemy schema
+ security/ password hashing, sessions
+ services/ llm client, chat orchestration, markdown, crypto, sse
+ web/ Jinja templates and static assets
+assets/ SVG artwork masters
+scripts/ artwork generator, vendored-JS fetcher
+deploy/ systemd unit and nginx vhost for a real install
+```
+
+## Development
+
+```bash
+pytest # test suite
+ruff check . # lint
+python scripts/build_artwork.py # regenerate the SVG artwork
+python scripts/fetch_vendor.py # verify vendored JS against the lockfile
+```
+
+There is no migration tool. The schema is SQLite-only and created at startup,
+so changing a column on a live database is a manual job — see `CLAUDE.md`.
+
+## Artwork
+
+The logo, favicon and banner are original vector work, generated by
+[`scripts/build_artwork.py`](scripts/build_artwork.py) so the mallorn leaf stays
+identical across every size it appears at. The wordmark is
+[Source Serif 4](https://github.com/adobe-fonts/source-serif) (SIL OFL 1.1)
+converted to outlines — a README banner cannot load a webfont, and ``
+would render in whatever serif the reader happens to have.
+
+## Licence
+
+[GPL-3.0](LICENSE).
+
+## A note on the theme
+
+This is an independent hobby project, themed as an affectionate nod to
+J.R.R. Tolkien's world. It is **not affiliated with, endorsed by, or connected
+to** the Tolkien Estate, Middle-earth Enterprises, or any related rights
+holder. All artwork here is original.
diff --git a/deploy/README.md b/deploy/README.md
new file mode 100644
index 0000000..1a5a080
--- /dev/null
+++ b/deploy/README.md
@@ -0,0 +1,80 @@
+# Deployment
+
+Installs LLeMbas as a **system** service behind nginx at `https://chat.lan`.
+
+Written for `gamebox` (Arch), and follows the conventions already used there
+for llama-swap and comfyui:
+
+| | |
+|---|---|
+| Service user | `lembas` (system account, `nologin`) |
+| Home | `/home/lembas`, bind-mounted to `/srv/lembas` |
+| Checkout | `/srv/lembas/app` (git clone of the Gitea remote) |
+| Virtualenv | `/srv/lembas/venv` |
+| Database | `/srv/lembas/data/lembas.db` |
+| Environment | `/srv/lembas/lembas.env` (mode 600) |
+| Unit | `/etc/systemd/system/lembas.service` |
+| Vhost | `/etc/nginx/conf.d/chat.lan.conf`, self-signed cert |
+| Listens | `127.0.0.1:8080` — reachable only through nginx |
+
+The home lives on `/home` rather than `/var/lib` because the root LV on that
+box is only 50 GB; `/srv/lembas` is the same bind-mount trick as `/srv/llama`
+and `/srv/comfyui`.
+
+## First install
+
+```bash
+./deploy/install.sh
+```
+
+Idempotent — safe to re-run. It creates the user and bind mount, clones the
+repo, builds the venv, generates `lembas.env` with a fresh
+`LEMBAS_SECRET_KEY`, installs the unit and vhost, issues a self-signed cert,
+adds a `/etc/hosts` entry, and enables the service.
+
+Then open , accept the self-signed certificate warning, and
+create the first account — it becomes the administrator.
+
+## Deploying a change
+
+```bash
+git push # from the working copy
+./deploy/update.sh
+```
+
+`update.sh` fetches, hard-resets `/srv/lembas/app` to `origin/main`, reinstalls
+dependencies and restarts the service, then prints what changed. The hard reset
+is deliberate: nothing is ever edited in place there, so there is no local work
+to preserve and no merge conflicts to resolve.
+
+## Operating it
+
+```bash
+systemctl status lembas
+journalctl -u lembas -f
+sudo -u lembas /srv/lembas/venv/bin/lembas info # paths and counts
+sudo systemctl restart lembas
+```
+
+Configuration lives in `/srv/lembas/lembas.env`. Edit it and restart.
+
+## Notes
+
+**The secret key is generated once.** `install.sh` will not overwrite an
+existing `lembas.env`. Rotating `LEMBAS_SECRET_KEY` signs every user out *and*
+makes stored upstream API keys unreadable — they would have to be re-entered.
+
+**nginx buffering is off for a reason.** Replies stream as server-sent events.
+With `proxy_buffering on` (the default) nginx holds the entire reply and
+delivers it in one lump at the end, which is indistinguishable from streaming
+being broken. `proxy_read_timeout` is raised to an hour because a model can
+think for minutes before the first token.
+
+**Name resolution.** `chat.lan` is in Pi-hole, but this box queries the router
+first and the router's dnsmasq is authoritative for `.lan` without forwarding
+those queries on — the same reason `comfy.lan` needs one. `install.sh` adds a
+`/etc/hosts` entry, which is harmless if DNS already answers.
+
+**Hardening is deliberately moderate.** `ProtectSystem=full`, not `strict`: the
+agentic features planned for later need to run commands, and a lockdown that
+has to be torn out again is worse than one that was never applied.
diff --git a/deploy/chat.lan.nginx.conf b/deploy/chat.lan.nginx.conf
new file mode 100644
index 0000000..be8d204
--- /dev/null
+++ b/deploy/chat.lan.nginx.conf
@@ -0,0 +1,57 @@
+# chat.lan - HTTPS reverse proxy to LLeMbas (127.0.0.1:8080).
+# Deployed to /etc/nginx/conf.d/chat.lan.conf. Self-signed cert (chat.lan).
+#
+# Mirrors the comfy.lan and llama.lan vhosts on this box.
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name chat.lan;
+ return 301 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name chat.lan;
+
+ ssl_certificate /etc/nginx/ssl/chat.lan.crt;
+ ssl_certificate_key /etc/nginx/ssl/chat.lan.key;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ # File uploads land here once that feature exists; 0 = no limit.
+ client_max_body_size 0;
+
+ location / {
+ proxy_pass http://127.0.0.1:8080;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Streamed replies are server-sent events. Every one of these matters:
+ # with buffering on, nginx holds the whole reply and delivers it in one
+ # lump at the end, which looks exactly like streaming being broken.
+ proxy_buffering off;
+ proxy_request_buffering off;
+ proxy_cache off;
+ # SSE is plain HTTP/1.1 chunked, so the connection header must not be
+ # the websocket upgrade dance -- it must simply stay open.
+ proxy_set_header Connection "";
+
+ # A model can think for minutes before the first token. The default
+ # 60s read timeout would cut long generations off mid-sentence.
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ }
+
+ # Static assets are immutable per release and never need revalidating.
+ location /static/ {
+ proxy_pass http://127.0.0.1:8080;
+ proxy_set_header Host $host;
+ expires 1h;
+ add_header Cache-Control "public";
+ }
+}
diff --git a/deploy/install.sh b/deploy/install.sh
new file mode 100755
index 0000000..93a6019
--- /dev/null
+++ b/deploy/install.sh
@@ -0,0 +1,115 @@
+#!/usr/bin/env bash
+# Install LLeMbas as a system service behind nginx at https://chat.lan.
+#
+# Follows the conventions already used on this box for llama-swap and comfyui:
+# a dedicated service user whose home lives on /home (the root LV is only
+# 50 GB) and is bind-mounted to /srv/, a system unit so it survives
+# logout, and an nginx vhost with a self-signed cert.
+#
+# Idempotent: safe to re-run. To deploy new code afterwards use update.sh,
+# which is what a `git push` should be followed by.
+set -euo pipefail
+
+REPO_URL="${LEMBAS_REPO_URL:-https://git.houmeres.sk/Houmeres/LLeMbas.git}"
+BRANCH="${LEMBAS_BRANCH:-main}"
+SERVICE_USER=lembas
+HOME_DIR=/home/lembas
+PREFIX=/srv/lembas
+APP="$PREFIX/app"
+VENV="$PREFIX/venv"
+ENV_FILE="$PREFIX/lembas.env"
+HERE="$(dirname "$(readlink -f "$0")")"
+
+echo "== service user =="
+# --system: no ageing, no mail spool. Home under /home, not /var/lib, so the
+# venv and database sit on the big volume.
+if ! getent passwd "$SERVICE_USER" >/dev/null; then
+ sudo useradd --system --create-home --home-dir "$HOME_DIR" \
+ --shell /usr/bin/nologin --comment "LLeMbas" "$SERVICE_USER"
+else
+ echo " user $SERVICE_USER already exists"
+fi
+sudo chmod 755 "$HOME_DIR"
+
+echo "== /srv/lembas bind-mount onto /home =="
+sudo mkdir -p "$PREFIX"
+grep -q "^$HOME_DIR[[:space:]]" /etc/fstab \
+ || echo "$HOME_DIR $PREFIX none bind 0 0" | sudo tee -a /etc/fstab >/dev/null
+sudo systemctl daemon-reload
+mountpoint -q "$PREFIX" || sudo mount "$PREFIX"
+
+echo "== checkout =="
+if [[ ! -d "$APP/.git" ]]; then
+ sudo -u "$SERVICE_USER" git clone --branch "$BRANCH" "$REPO_URL" "$APP"
+else
+ echo " already cloned; use update.sh to pull"
+fi
+
+echo "== virtualenv =="
+if [[ ! -x "$VENV/bin/python" ]]; then
+ sudo -u "$SERVICE_USER" python -m venv "$VENV"
+fi
+sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip
+sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP"
+
+echo "== environment =="
+# Generated once and never regenerated: rotating LEMBAS_SECRET_KEY would sign
+# every user out and make the stored API keys unreadable.
+if [[ ! -f "$ENV_FILE" ]]; then
+ KEY=$("$VENV/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))")
+ sudo tee "$ENV_FILE" >/dev/null </dev/null
+
+echo "== enable service =="
+sudo systemctl enable --now lembas
+sleep 2
+sudo systemctl --no-pager --lines=0 status lembas || true
+
+echo
+echo "LLeMbas is up at https://chat.lan (self-signed cert; accept the warning)"
+echo "Create the first account -- it becomes the administrator."
diff --git a/deploy/lembas.service b/deploy/lembas.service
new file mode 100644
index 0000000..a7c3c1b
--- /dev/null
+++ b/deploy/lembas.service
@@ -0,0 +1,49 @@
+# LLeMbas system service.
+#
+# Deployed to /etc/systemd/system/lembas.service by deploy/install.sh.
+#
+# A system unit, not a user unit, so it survives logout and comes up at boot
+# without anyone signing in -- matching llama-swap and comfyui on this box.
+#
+# /srv/lembas is a bind mount of /home/lembas: the root LV is only 50 GB and
+# the venv plus SQLite database belong on /home, same trick as /srv/llama.
+
+[Unit]
+Description=LLeMbas - web UI for language models
+Documentation=https://git.houmeres.sk/Houmeres/LLeMbas
+After=network-online.target
+Wants=network-online.target
+# The unit is useless without the bind mount: the venv and database live there.
+RequiresMountsFor=/srv/lembas
+# Not a hard dependency. LLeMbas starts fine with the endpoint down and shows a
+# readable error in the admin UI, which is better than refusing to boot.
+After=llama-swap.service
+
+[Service]
+Type=simple
+User=lembas
+Group=lembas
+WorkingDirectory=/srv/lembas/app
+EnvironmentFile=/srv/lembas/lembas.env
+ExecStart=/srv/lembas/venv/bin/lembas serve
+Restart=on-failure
+RestartSec=5
+
+# Reachable only through the nginx chat.lan vhost, never directly on the LAN.
+# The bind address is set by LEMBAS_HOST in the environment file.
+
+# --- Hardening -------------------------------------------------------------
+# Modest rather than maximal: the agentic features planned for later will need
+# to run commands, so ProtectSystem=strict would only be torn out again.
+NoNewPrivileges=yes
+PrivateTmp=yes
+ProtectSystem=full
+ProtectKernelTunables=yes
+ProtectControlGroups=yes
+RestrictSUIDSGID=yes
+# Only /srv/lembas needs to be writable; /home/lembas is the same inode.
+ReadWritePaths=/srv/lembas
+LimitNOFILE=65535
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/update.sh b/deploy/update.sh
new file mode 100755
index 0000000..db63397
--- /dev/null
+++ b/deploy/update.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Pull the latest LLeMbas and restart the service.
+#
+# This is what to run after pushing: it fetches, hard-resets the deployment
+# checkout to the remote branch, reinstalls dependencies if they changed, and
+# restarts. Nothing is ever edited in place at /srv/lembas/app, so a hard reset
+# is safe and avoids merge conflicts from a dirty deployment tree.
+set -euo pipefail
+
+SERVICE_USER=lembas
+PREFIX=/srv/lembas
+APP="$PREFIX/app"
+VENV="$PREFIX/venv"
+BRANCH="${LEMBAS_BRANCH:-main}"
+
+if [[ ! -d "$APP/.git" ]]; then
+ echo "No deployment at $APP. Run deploy/install.sh first." >&2
+ exit 1
+fi
+
+before=$(sudo -u "$SERVICE_USER" git -C "$APP" rev-parse HEAD)
+
+echo "== fetching =="
+sudo -u "$SERVICE_USER" git -C "$APP" fetch --quiet origin "$BRANCH"
+sudo -u "$SERVICE_USER" git -C "$APP" reset --hard --quiet "origin/$BRANCH"
+
+after=$(sudo -u "$SERVICE_USER" git -C "$APP" rev-parse HEAD)
+
+if [[ "$before" == "$after" ]]; then
+ echo " already at $(git -C "$APP" rev-parse --short HEAD), nothing to pull"
+else
+ echo " $(echo "$before" | cut -c1-7) -> $(echo "$after" | cut -c1-7)"
+ sudo -u "$SERVICE_USER" git -C "$APP" --no-pager log --oneline "$before..$after" | sed 's/^/ /'
+fi
+
+# Cheap and idempotent; catches a dependency added since the last deploy.
+echo "== dependencies =="
+sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP"
+
+echo "== restart =="
+sudo systemctl restart lembas
+sleep 2
+
+if systemctl is-active --quiet lembas; then
+ echo " lembas is running at https://chat.lan"
+else
+ echo " lembas FAILED to start:" >&2
+ sudo journalctl -u lembas -n 30 --no-pager >&2
+ exit 1
+fi
diff --git a/pyproject.toml b/pyproject.toml
index 60a6266..ba6dd0b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,6 +30,7 @@ dependencies = [
"cryptography>=43.0",
"markdown-it-py>=3.0",
"mdit-py-plugins>=0.4",
+ "linkify-it-py>=2.0", # bare URLs in model output become links
"pygments>=2.18",
"nh3>=0.2.18",
"typer>=0.12",
diff --git a/scripts/build_artwork.py b/scripts/build_artwork.py
old mode 100644
new mode 100755
index 548c0e8..547fc3e
--- a/scripts/build_artwork.py
+++ b/scripts/build_artwork.py
@@ -35,6 +35,11 @@ except ImportError: # pragma: no cover - design-time tool
ROOT = Path(__file__).resolve().parent.parent
ASSETS = ROOT / "assets"
+STATIC_IMG = ROOT / "src" / "lembas" / "web" / "static" / "img"
+
+# assets/ holds the design masters; the application serves its own copies from
+# static/. These are the few the running app actually needs.
+SERVED_BY_APP = ("favicon.svg", "logo-mark.svg", "banner.svg")
FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf")
FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf")
@@ -463,10 +468,18 @@ def main() -> None:
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
+ STATIC_IMG.mkdir(parents=True, exist_ok=True)
+
for filename in args.only or BUILDERS:
+ content = BUILDERS[filename]()
path = args.out / filename
- path.write_text(BUILDERS[filename](), encoding="utf-8")
- print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size:,} bytes)")
+ path.write_text(content, encoding="utf-8")
+ print(f"wrote {path.relative_to(ROOT)} ({len(content.encode()):,} bytes)")
+
+ if filename in SERVED_BY_APP:
+ served = STATIC_IMG / filename
+ served.write_text(content, encoding="utf-8")
+ print(f" -> {served.relative_to(ROOT)}")
if __name__ == "__main__":
diff --git a/scripts/fetch_vendor.py b/scripts/fetch_vendor.py
new file mode 100755
index 0000000..8b90db6
--- /dev/null
+++ b/scripts/fetch_vendor.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+"""Download the pinned browser libraries into the static vendor directory.
+
+LLeMbas has no Node toolchain and loads nothing from a CDN at runtime -- a
+self-hosted tool should keep working without internet access, and should not
+report every user's page view to a third party. The three libraries it does use
+are fetched once, here, and committed.
+
+Integrity is enforced with vendor.lock.json. A mismatched hash aborts rather
+than overwriting: that is the whole point of pinning.
+
+ python scripts/fetch_vendor.py # fetch and verify against the lock
+ python scripts/fetch_vendor.py --update # re-pin after a version bump
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+VENDOR_DIR = ROOT / "src" / "lembas" / "web" / "static" / "vendor"
+LOCKFILE = Path(__file__).resolve().parent / "vendor.lock.json"
+
+# Pinned deliberately. Bump the version, run with --update, review the diff.
+PACKAGES = {
+ "htmx.min.js": {
+ "version": "2.0.10",
+ "url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
+ "why": "Server-rendered interactivity: every swap in the app.",
+ },
+ "htmx-ext-sse.js": {
+ "version": "2.2.4",
+ "url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
+ "why": "Server-sent events, which is how streamed replies reach the page.",
+ },
+ "alpine.min.js": {
+ "version": "3.15.12",
+ "url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
+ "why": "Small client-only state: menus, theme toggle, composer autosize.",
+ },
+}
+
+
+def sha256(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def fetch(url: str) -> bytes:
+ request = urllib.request.Request(url, headers={"User-Agent": "lembas-vendor-fetch"})
+ with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
+ return response.read()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--update",
+ action="store_true",
+ help="rewrite vendor.lock.json with the hashes just downloaded",
+ )
+ args = parser.parse_args()
+
+ lock = json.loads(LOCKFILE.read_text()) if LOCKFILE.exists() else {}
+ VENDOR_DIR.mkdir(parents=True, exist_ok=True)
+
+ new_lock: dict[str, dict[str, str]] = {}
+ failed = False
+
+ for filename, spec in PACKAGES.items():
+ try:
+ payload = fetch(spec["url"])
+ except (urllib.error.URLError, TimeoutError) as exc:
+ print(f" FAIL {filename}: {exc}", file=sys.stderr)
+ failed = True
+ continue
+
+ digest = sha256(payload)
+ expected = lock.get(filename, {}).get("sha256")
+
+ if expected and digest != expected and not args.update:
+ print(
+ f" FAIL {filename}: hash mismatch\n"
+ f" expected {expected}\n"
+ f" received {digest}\n"
+ f" Refusing to overwrite. If the version was bumped "
+ f"deliberately, re-run with --update.",
+ file=sys.stderr,
+ )
+ failed = True
+ continue
+
+ (VENDOR_DIR / filename).write_bytes(payload)
+ new_lock[filename] = {
+ "version": spec["version"],
+ "url": spec["url"],
+ "sha256": digest,
+ }
+ status = "ok" if expected == digest else ("pinned" if args.update else "new")
+ print(f" {status:>6} {filename} {len(payload):>8,} bytes v{spec['version']}")
+
+ if failed:
+ print("\nOne or more downloads failed. Vendored files were not fully written.")
+ return 1
+
+ if args.update or not LOCKFILE.exists():
+ LOCKFILE.write_text(json.dumps(new_lock, indent=2, sort_keys=True) + "\n")
+ print(f"\nwrote {LOCKFILE.relative_to(ROOT)}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/vendor.lock.json b/scripts/vendor.lock.json
new file mode 100644
index 0000000..aadedf6
--- /dev/null
+++ b/scripts/vendor.lock.json
@@ -0,0 +1,17 @@
+{
+ "alpine.min.js": {
+ "sha256": "57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f",
+ "url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
+ "version": "3.15.12"
+ },
+ "htmx-ext-sse.js": {
+ "sha256": "3b5992a541619babefc4c169505af474df5c3039da51e59b96ccf9241ecd61d2",
+ "url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
+ "version": "2.2.4"
+ },
+ "htmx.min.js": {
+ "sha256": "71ea67185bfa8c98c39d31717c6fce5d852370fcdfd129db4543774d3145c0de",
+ "url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
+ "version": "2.0.10"
+ }
+}
diff --git a/src/lembas/api/admin.py b/src/lembas/api/admin.py
new file mode 100644
index 0000000..214c68a
--- /dev/null
+++ b/src/lembas/api/admin.py
@@ -0,0 +1,200 @@
+"""Administration: OpenAI-compatible connections and their models."""
+
+from __future__ import annotations
+
+import logging
+from datetime import UTC, datetime
+
+from fastapi import APIRouter, Form, HTTPException, Request, Response, status
+from fastapi.responses import RedirectResponse
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session as DBSession
+
+from lembas.api.deps import AdminUser, Db
+from lembas.db.models import Connection, Model
+from lembas.services.crypto import decrypt, encrypt, mask
+from lembas.services.llm.openai_client import Endpoint, LLMError, list_models
+from lembas.web.templating import render
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/admin", tags=["admin"])
+
+# Sent back in place of a stored key. If a submitted key still equals this, the
+# admin did not touch the field and the existing key must be kept -- otherwise
+# saving a name change would silently wipe the credential.
+UNCHANGED_SENTINEL = "•" * 12
+
+
+def _connection(db: DBSession, connection_id: str) -> Connection:
+ connection = db.get(Connection, connection_id)
+ if connection is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
+ return connection
+
+
+def _connections(db: DBSession) -> list[Connection]:
+ return list(db.scalars(select(Connection).order_by(Connection.position, Connection.name)))
+
+
+@router.get("")
+async def admin_home(user: AdminUser):
+ return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@router.get("/connections")
+async def connections_page(request: Request, db: Db, user: AdminUser, message: str = ""):
+ connections = _connections(db)
+ return render(
+ request,
+ "admin/connections.html",
+ {
+ "connections": connections,
+ "masked": {c.id: mask(decrypt(c.api_key_encrypted)) for c in connections},
+ "model_counts": {
+ c.id: sum(1 for m in c.models if m.enabled) for c in connections
+ },
+ "message": message,
+ "unchanged": UNCHANGED_SENTINEL,
+ },
+ )
+
+
+@router.post("/connections")
+async def create_connection(
+ db: Db,
+ user: AdminUser,
+ name: str = Form(...),
+ base_url: str = Form(...),
+ api_key: str = Form(""),
+) -> Response:
+ base_url = base_url.strip().rstrip("/")
+ if not base_url.startswith(("http://", "https://")):
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ "The base URL must start with http:// or https://",
+ )
+
+ position = db.scalar(select(func.coalesce(func.max(Connection.position), -1))) + 1
+ connection = Connection(
+ name=name.strip()[:120] or "Connection",
+ base_url=base_url,
+ api_key_encrypted=encrypt(api_key.strip()),
+ position=position,
+ )
+ db.add(connection)
+ db.commit()
+
+ # Discover models immediately: a connection that lists nothing is
+ # indistinguishable from a broken one, and finding out now is the point.
+ await _refresh_models(db, connection)
+ return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@router.post("/connections/{connection_id}")
+async def update_connection(
+ db: Db,
+ user: AdminUser,
+ connection_id: str,
+ name: str = Form(...),
+ base_url: str = Form(...),
+ api_key: str = Form(""),
+ enabled: bool = Form(False),
+) -> Response:
+ connection = _connection(db, connection_id)
+ connection.name = name.strip()[:120] or connection.name
+ connection.base_url = base_url.strip().rstrip("/")
+ connection.enabled = enabled
+
+ submitted = api_key.strip()
+ if submitted and submitted != UNCHANGED_SENTINEL:
+ connection.api_key_encrypted = encrypt(submitted)
+ elif not submitted:
+ # An explicitly emptied field means "this endpoint needs no key".
+ connection.api_key_encrypted = ""
+
+ db.commit()
+ return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@router.post("/connections/{connection_id}/test")
+async def test_connection(
+ request: Request, db: Db, user: AdminUser, connection_id: str
+) -> Response:
+ """Contact the endpoint and refresh its model list."""
+ connection = _connection(db, connection_id)
+ count, error = await _refresh_models(db, connection)
+
+ message = (
+ f"{connection.name}: {error}"
+ if error
+ else f"{connection.name}: found {count} model{'s' if count != 1 else ''}."
+ )
+ return render(
+ request,
+ "admin/_connection_row.html",
+ {
+ "connection": connection,
+ "masked": mask(decrypt(connection.api_key_encrypted)),
+ "message": message,
+ "message_kind": "error" if error else "success",
+ "unchanged": UNCHANGED_SENTINEL,
+ },
+ )
+
+
+async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, str]:
+ """Sync the cached model list. Returns (count, error message)."""
+ try:
+ discovered = await list_models(Endpoint.from_connection(connection))
+ except LLMError as exc:
+ connection.last_error = exc.message
+ connection.last_checked_at = datetime.now(UTC)
+ db.commit()
+ return 0, exc.message
+
+ existing = {model.model_id: model for model in connection.models}
+ seen: set[str] = set()
+
+ for entry in discovered:
+ model_id = str(entry["id"])[:300]
+ seen.add(model_id)
+ if model_id in existing:
+ continue
+ db.add(Model(connection_id=connection.id, model_id=model_id))
+
+ # Models that vanished upstream are dropped, so the picker never offers
+ # something the endpoint will reject.
+ for model_id, model in existing.items():
+ if model_id not in seen:
+ db.delete(model)
+
+ connection.last_error = ""
+ connection.last_checked_at = datetime.now(UTC)
+ db.commit()
+ log.info("connection %s: %d models", connection.name, len(seen))
+ return len(seen), ""
+
+
+@router.post("/connections/{connection_id}/delete")
+async def delete_connection(db: Db, user: AdminUser, connection_id: str) -> Response:
+ connection = _connection(db, connection_id)
+ db.delete(connection)
+ db.commit()
+ return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@router.get("/models")
+async def models_page(request: Request, db: Db, user: AdminUser):
+ connections = _connections(db)
+ return render(request, "admin/models.html", {"connections": connections})
+
+
+@router.post("/models/{model_id}/toggle")
+async def toggle_model(db: Db, user: AdminUser, model_id: str) -> Response:
+ model = db.get(Model, model_id)
+ if model is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
+ model.enabled = not model.enabled
+ db.commit()
+ return RedirectResponse("/admin/models", status_code=status.HTTP_303_SEE_OTHER)
diff --git a/src/lembas/api/auth.py b/src/lembas/api/auth.py
new file mode 100644
index 0000000..65d3677
--- /dev/null
+++ b/src/lembas/api/auth.py
@@ -0,0 +1,186 @@
+"""Registration, sign-in and sign-out."""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Form, Request, Response, status
+from fastapi.responses import RedirectResponse
+from sqlalchemy import func, select
+
+from lembas.api.deps import CurrentUser, Db
+from lembas.config import settings
+from lembas.db.models import ROLE_ADMIN, ROLE_USER, User
+from lembas.security.passwords import hash_password, validate_password, verify_password
+from lembas.security.sessions import COOKIE_NAME, create_session, revoke_session
+from lembas.web.templating import render
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+
+def _no_users_yet(db: Db) -> bool:
+ return db.scalar(select(func.count()).select_from(User)) == 0
+
+
+def _set_session_cookie(response: Response, token: str) -> None:
+ response.set_cookie(
+ COOKIE_NAME,
+ token,
+ max_age=settings.session_ttl,
+ httponly=True,
+ # Lax is what makes this application CSRF-safe without tokens: the
+ # cookie is not sent on cross-site POSTs, and every mutating route here
+ # is a POST. Do not relax to "none".
+ samesite="lax",
+ # Only over HTTPS when the deployment is not plain local http. Marking
+ # it secure on http would silently break sign-in for a LAN install.
+ secure=False,
+ path="/",
+ )
+
+
+def _safe_next(raw: str | None) -> str:
+ """Reject open redirects: only same-origin absolute paths are allowed."""
+ if not raw or not raw.startswith("/") or raw.startswith("//"):
+ return "/"
+ return raw
+
+
+@router.get("/login")
+async def login_form(request: Request, db: Db, user: CurrentUser, next: str = "/"):
+ if user is not None:
+ return RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
+ # An empty database means this install has never been set up. Send the
+ # first visitor straight to registration rather than to a login form they
+ # cannot possibly satisfy.
+ if _no_users_yet(db):
+ return RedirectResponse("/auth/register", status_code=status.HTTP_303_SEE_OTHER)
+ return render(request, "auth/login.html", {"next": _safe_next(next)})
+
+
+@router.post("/login")
+async def login(
+ request: Request,
+ db: Db,
+ email: str = Form(...),
+ password: str = Form(...),
+ next: str = Form("/"),
+):
+ email = email.strip().lower()
+ user = db.scalar(select(User).where(User.email == email))
+
+ # One message for "no such account" and "wrong password" alike, so the form
+ # cannot be used to discover which addresses are registered.
+ if user is None or not verify_password(password, user.password_hash):
+ log.info("failed sign-in for %s", email)
+ return render(
+ request,
+ "auth/login.html",
+ {"error": "That email and password do not match.", "email": email,
+ "next": _safe_next(next)},
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ )
+
+ if not user.active:
+ return render(
+ request,
+ "auth/login.html",
+ {"error": "This account has been deactivated. Ask an administrator.",
+ "email": email, "next": _safe_next(next)},
+ status_code=status.HTTP_403_FORBIDDEN,
+ )
+
+ token = create_session(
+ db,
+ user,
+ user_agent=request.headers.get("user-agent", ""),
+ ip_address=request.client.host if request.client else "",
+ )
+ response = RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
+ _set_session_cookie(response, token)
+ return response
+
+
+@router.get("/register")
+async def register_form(request: Request, db: Db, user: CurrentUser):
+ if user is not None:
+ return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
+ first_run = _no_users_yet(db)
+ if not first_run and not settings.allow_signup:
+ return render(
+ request,
+ "auth/login.html",
+ {"error": "Registration is closed. Ask an administrator for an account."},
+ status_code=status.HTTP_403_FORBIDDEN,
+ )
+ return render(request, "auth/register.html", {"first_run": first_run})
+
+
+@router.post("/register")
+async def register(
+ request: Request,
+ db: Db,
+ name: str = Form(...),
+ email: str = Form(...),
+ password: str = Form(...),
+):
+ first_run = _no_users_yet(db)
+ if not first_run and not settings.allow_signup:
+ return render(
+ request,
+ "auth/login.html",
+ {"error": "Registration is closed. Ask an administrator for an account."},
+ status_code=status.HTTP_403_FORBIDDEN,
+ )
+
+ name = name.strip()
+ email = email.strip().lower()
+
+ def fail(message: str) -> Response:
+ return render(
+ request,
+ "auth/register.html",
+ {"error": message, "name": name, "email": email, "first_run": first_run},
+ status_code=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if not name:
+ return fail("Please enter a name.")
+ if "@" not in email or "." not in email.split("@")[-1]:
+ return fail("Please enter a valid email address.")
+ if (problem := validate_password(password)) is not None:
+ return fail(problem)
+ if db.scalar(select(User).where(User.email == email)) is not None:
+ return fail("An account with that email already exists.")
+
+ # Whoever sets the instance up owns it. Everyone after that is a plain user
+ # until an admin says otherwise.
+ user = User(
+ name=name,
+ email=email,
+ password_hash=hash_password(password),
+ role=ROLE_ADMIN if first_run else ROLE_USER,
+ )
+ db.add(user)
+ db.commit()
+ log.info("registered %s as %s", email, user.role)
+
+ token = create_session(
+ db,
+ user,
+ user_agent=request.headers.get("user-agent", ""),
+ ip_address=request.client.host if request.client else "",
+ )
+ response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
+ _set_session_cookie(response, token)
+ return response
+
+
+@router.post("/logout")
+async def logout(request: Request, db: Db):
+ revoke_session(db, request.cookies.get(COOKIE_NAME))
+ response = RedirectResponse("/auth/login", status_code=status.HTTP_303_SEE_OTHER)
+ response.delete_cookie(COOKIE_NAME, path="/")
+ return response
diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py
new file mode 100644
index 0000000..1bdaf43
--- /dev/null
+++ b/src/lembas/api/chats.py
@@ -0,0 +1,290 @@
+"""Chat creation, messaging and the streaming reply endpoint."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import AsyncIterator
+
+from fastapi import APIRouter, Form, HTTPException, Request, status
+from fastapi.responses import HTMLResponse, Response, StreamingResponse
+from sqlalchemy.orm import Session as DBSession
+
+from lembas.api.deps import Db, RequiredUser
+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 sse
+from lembas.services.llm.openai_client import LLMError, delta_text, stream_chat
+from lembas.services.markdown import escape_text, render_markdown
+from lembas.web.templating import render, templates
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/chats", tags=["chats"])
+
+
+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
+ # not information this endpoint should hand out.
+ if chat is None or chat.user_id != user_id:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
+ return chat
+
+
+@router.post("")
+async def create_chat(db: Db, user: RequiredUser, folder_id: str = Form("")) -> Response:
+ chosen = chat_service.default_model(db)
+ chat = Chat(
+ user_id=user.id,
+ folder_id=folder_id or None,
+ model_id=chosen[0] if chosen else "",
+ connection_id=chosen[1] if chosen else None,
+ )
+ db.add(chat)
+ db.commit()
+
+ # HX-Redirect rather than a swap: a new chat is a new URL, and the address
+ # bar has to follow so the chat can be reloaded or bookmarked.
+ response = Response(status_code=status.HTTP_204_NO_CONTENT)
+ response.headers["HX-Redirect"] = f"/chat/{chat.id}"
+ return response
+
+
+@router.post("/{chat_id}/messages")
+async def post_message(
+ request: Request,
+ db: Db,
+ user: RequiredUser,
+ chat_id: str,
+ content: str = Form(...),
+) -> Response:
+ """Persist the user's turn and hand back the pair of bubbles.
+
+ The assistant bubble comes back empty, carrying the sse-connect attribute
+ that opens the stream below. Splitting it this way means the POST returns
+ immediately and the slow part is a separate, resumable connection.
+ """
+ chat = _owned_chat(db, chat_id, user.id)
+
+ content = content.strip()
+ if not content:
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+ user_message = chat_service.create_message(db, chat, ROLE_USER, content)
+ assistant_message = chat_service.create_message(
+ db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
+ )
+
+ # `user` is required by the shared message template, which renders both
+ # roles; without it the user bubble's initial blows up.
+ return templates.TemplateResponse(
+ request,
+ "chat/_turn.html",
+ {
+ "request": request,
+ "user_message": user_message,
+ "assistant_message": assistant_message,
+ "chat": chat,
+ "user": user,
+ },
+ )
+
+
+@router.get("/{chat_id}/messages/{message_id}/stream")
+async def stream_message(
+ db: Db,
+ user: RequiredUser,
+ chat_id: str,
+ message_id: str,
+) -> Response:
+ """Stream the assistant's reply as server-sent events.
+
+ Emits `token` events carrying escaped text, then a single `done` event
+ carrying the finished bubble rendered from Markdown, then `close`.
+ """
+ chat = _owned_chat(db, chat_id, user.id)
+ message = db.get(Message, message_id)
+ if message is None or message.chat_id != chat.id:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
+
+ return StreamingResponse(
+ _generate(chat.id, message.id),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache, no-transform",
+ "Connection": "keep-alive",
+ # nginx buffers proxied responses by default, which turns a stream
+ # into one delivery at the end. This is the documented opt-out.
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
+ """Drive one completion and frame it as SSE.
+
+ Opens its own database session rather than using the request's: streaming
+ outlives the request handler, and the dependency-scoped session may already
+ be closed by the time the first token arrives.
+ """
+ accumulated: list[str] = []
+ error: str | None = None
+
+ with session_scope() as db:
+ chat = db.get(Chat, chat_id)
+ message = db.get(Message, message_id)
+ if chat is None or message is None:
+ yield sse.event("close", "")
+ return
+
+ first_user_text = ""
+ try:
+ endpoint, model_id = chat_service.resolve_endpoint(db, chat)
+ payload = chat_service.build_request(db, chat, upto=message)
+ first_user_text = next(
+ (m["content"] for m in reversed(payload["messages"]) if m["role"] == ROLE_USER),
+ "",
+ )
+
+ async for chunk in stream_chat(endpoint, payload):
+ text = delta_text(chunk)
+ if not text:
+ continue
+ accumulated.append(text)
+ yield sse.event("token", escape_text(text))
+ # Hand control back so the event is flushed rather than
+ # batched behind a fast generator.
+ await asyncio.sleep(0)
+
+ except LLMError as exc:
+ error = exc.message
+ log.info("generation failed for chat %s: %s", chat_id, exc.message)
+ except asyncio.CancelledError:
+ # The reader navigated away or closed the tab. Keep whatever was
+ # produced so the partial reply is still there on reload.
+ message.content = "".join(accumulated)
+ message.complete = True
+ db.commit()
+ raise
+ except Exception as exc: # noqa: BLE001 - must not kill the stream silently
+ error = "Something went wrong while generating this reply."
+ log.exception("unexpected generation failure for chat %s: %s", chat_id, exc)
+
+ message.content = "".join(accumulated)
+ message.error = error or ""
+ message.complete = True
+
+ if not chat.title_generated and (accumulated or error):
+ chat.title = (
+ await chat_service.generate_title(
+ endpoint, model_id, first_user_text, message.content
+ )
+ if not error and first_user_text
+ else chat_service.fallback_title(first_user_text)
+ )
+ chat.title_generated = True
+
+ db.commit()
+
+ final_html = templates.get_template("chat/_message.html").render(
+ {
+ "message": message,
+ "body_html": render_markdown(message.content),
+ "chat": chat,
+ # Passed even though an assistant bubble never reads it: the
+ # template shares both roles, and a missing `user` would only
+ # blow up on whichever branch is not being exercised here.
+ "user": db.get(User, chat.user_id),
+ }
+ )
+ title_html = templates.get_template("chat/_title_oob.html").render(
+ {"chat": chat}
+ )
+
+ yield sse.event("done", final_html + title_html)
+ yield sse.event("close", "")
+
+
+@router.patch("/{chat_id}")
+async def update_chat(
+ db: Db,
+ user: RequiredUser,
+ chat_id: str,
+ title: str | None = Form(None),
+ folder_id: str | None = Form(None),
+ model_id: str | None = Form(None),
+) -> Response:
+ chat = _owned_chat(db, chat_id, user.id)
+
+ if title is not None:
+ cleaned = title.strip()[:300]
+ if cleaned:
+ chat.title = cleaned
+ # An explicit rename must not be overwritten by auto-titling later.
+ chat.title_generated = True
+
+ if folder_id is not None:
+ chat.folder_id = folder_id or None
+
+ if model_id is not None and model_id:
+ chat.model_id = model_id
+ match = next(
+ (m for m in chat_service.available_models(db) if m.model_id == model_id), None
+ )
+ chat.connection_id = match.connection_id if match else None
+
+ db.commit()
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+@router.delete("/{chat_id}")
+async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
+ chat = _owned_chat(db, chat_id, user.id)
+ db.delete(chat)
+ db.commit()
+
+ response = Response(status_code=status.HTTP_204_NO_CONTENT)
+ response.headers["HX-Redirect"] = "/chat"
+ return response
+
+
+@router.get("/{chat_id}/messages/{message_id}/raw")
+async def raw_message(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> HTMLResponse:
+ """The unrendered Markdown of a message, for the copy button."""
+ _owned_chat(db, chat_id, user.id)
+ message = db.get(Message, message_id)
+ if message is None or message.chat_id != chat_id:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
+ return HTMLResponse(escape_text(message.content))
+
+
+@router.post("/{chat_id}/messages/{message_id}/regenerate")
+async def regenerate(
+ request: Request,
+ db: Db,
+ user: RequiredUser,
+ chat_id: str,
+ message_id: str,
+) -> Response:
+ """Discard an assistant reply and produce a fresh one in its place."""
+ chat = _owned_chat(db, chat_id, user.id)
+ message = db.get(Message, message_id)
+ if message is None or message.chat_id != chat.id or message.role != ROLE_ASSISTANT:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "That reply no longer exists.")
+
+ message.content = ""
+ message.error = ""
+ message.complete = False
+ message.model_id = chat.model_id
+ db.commit()
+
+ return templates.TemplateResponse(
+ request,
+ "chat/_message.html",
+ {"request": request, "message": message, "chat": chat, "body_html": "", "user": user},
+ )
+
+
+__all__ = ["render", "router"]
diff --git a/src/lembas/api/folders.py b/src/lembas/api/folders.py
new file mode 100644
index 0000000..606da24
--- /dev/null
+++ b/src/lembas/api/folders.py
@@ -0,0 +1,118 @@
+"""Folder management."""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, Form, HTTPException, Response, status
+from sqlalchemy.orm import Session as DBSession
+
+from lembas.api.deps import Db, RequiredUser
+from lembas.db.models import Folder
+
+router = APIRouter(prefix="/api/folders", tags=["folders"])
+
+MAX_DEPTH = 8
+
+
+def _owned_folder(db: DBSession, folder_id: str, user_id: str) -> Folder:
+ folder = db.get(Folder, folder_id)
+ if folder is None or folder.user_id != user_id:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.")
+ return folder
+
+
+def _depth_of(db: DBSession, folder: Folder | None) -> int:
+ depth = 0
+ seen: set[str] = set()
+ while folder is not None and folder.id not in seen:
+ seen.add(folder.id)
+ depth += 1
+ folder = db.get(Folder, folder.parent_id) if folder.parent_id else None
+ return depth
+
+
+def _refresh_sidebar() -> Response:
+ """Tell the browser to reload so the tree re-renders.
+
+ The folder tree is recursive and a change can move any part of it, so
+ re-rendering the whole sidebar server-side is both simpler and less
+ error-prone than trying to patch individual nodes over the wire.
+ """
+ response = Response(status_code=status.HTTP_204_NO_CONTENT)
+ response.headers["HX-Refresh"] = "true"
+ return response
+
+
+@router.post("")
+async def create_folder(
+ db: Db,
+ user: RequiredUser,
+ name: str = Form("New folder"),
+ parent_id: str = Form(""),
+) -> Response:
+ parent = _owned_folder(db, parent_id, user.id) if parent_id else None
+
+ # A cap on nesting, so a runaway client cannot build a tree deep enough to
+ # blow the recursion limit in the template.
+ if parent is not None and _depth_of(db, parent) >= MAX_DEPTH:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ f"Folders cannot be nested more than {MAX_DEPTH} deep.",
+ )
+
+ db.add(
+ Folder(
+ user_id=user.id,
+ name=name.strip()[:200] or "New folder",
+ parent_id=parent.id if parent else None,
+ )
+ )
+ db.commit()
+ return _refresh_sidebar()
+
+
+@router.patch("/{folder_id}")
+async def update_folder(
+ db: Db,
+ user: RequiredUser,
+ folder_id: str,
+ name: str | None = Form(None),
+ parent_id: str | None = Form(None),
+ collapsed: bool | None = Form(None),
+) -> Response:
+ folder = _owned_folder(db, folder_id, user.id)
+
+ if name is not None and name.strip():
+ folder.name = name.strip()[:200]
+
+ if parent_id is not None:
+ new_parent = _owned_folder(db, parent_id, user.id) if parent_id else None
+ # Reparenting a folder into its own subtree would detach that subtree
+ # from the root and make it unreachable.
+ cursor = new_parent
+ while cursor is not None:
+ if cursor.id == folder.id:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ "A folder cannot be moved inside itself.",
+ )
+ cursor = db.get(Folder, cursor.parent_id) if cursor.parent_id else None
+ folder.parent_id = new_parent.id if new_parent else None
+
+ if collapsed is not None:
+ folder.collapsed = collapsed
+
+ db.commit()
+ return _refresh_sidebar()
+
+
+@router.delete("/{folder_id}")
+async def delete_folder(db: Db, user: RequiredUser, folder_id: str) -> Response:
+ """Delete a folder. Child folders go with it; chats do not.
+
+ Chats fall back to the unfiled list (the FK is ON DELETE SET NULL), because
+ losing a conversation to a mis-clicked folder delete is unforgivable.
+ """
+ folder = _owned_folder(db, folder_id, user.id)
+ db.delete(folder)
+ db.commit()
+ return _refresh_sidebar()
diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py
new file mode 100644
index 0000000..e9f9675
--- /dev/null
+++ b/src/lembas/api/pages.py
@@ -0,0 +1,109 @@
+"""Full-page routes: the chat shell and the user's own settings."""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException, Request, status
+from fastapi.responses import RedirectResponse
+from sqlalchemy import select
+from sqlalchemy.orm import Session as DBSession
+
+from lembas.api.deps import Db, RequiredUser
+from lembas.db.models import Chat, Folder, Message, User
+from lembas.services import chat as chat_service
+from lembas.services.markdown import render_markdown
+from lembas.web.templating import render
+
+router = APIRouter(tags=["pages"])
+
+
+def _sidebar_context(db: DBSession, user: User) -> dict:
+ """Folder tree plus the chats that belong to no folder.
+
+ Only root folders are queried; children come through the relationship and
+ render recursively in the template.
+ """
+ folders = list(
+ db.scalars(
+ select(Folder)
+ .where(Folder.user_id == user.id, Folder.parent_id.is_(None))
+ .order_by(Folder.position, Folder.name)
+ )
+ )
+ unfiled = list(
+ db.scalars(
+ select(Chat)
+ .where(
+ Chat.user_id == user.id,
+ Chat.folder_id.is_(None),
+ Chat.archived.is_(False),
+ )
+ .order_by(Chat.pinned.desc(), Chat.updated_at.desc())
+ )
+ )
+ return {"folders": folders, "unfiled_chats": unfiled}
+
+
+@router.get("/")
+async def home(user: RequiredUser):
+ return RedirectResponse("/chat", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@router.get("/chat")
+async def chat_index(request: Request, db: Db, user: RequiredUser):
+ return render(
+ request,
+ "chat/index.html",
+ {
+ "chat": None,
+ "messages": [],
+ "models": chat_service.available_models(db),
+ **_sidebar_context(db, user),
+ },
+ )
+
+
+@router.get("/chat/{chat_id}")
+async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str):
+ chat = db.get(Chat, chat_id)
+ if chat is None or chat.user_id != user.id:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
+
+ messages = list(
+ db.scalars(
+ select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
+ )
+ )
+
+ # 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
+ if message.role == "assistant" and message.content
+ }
+
+ return render(
+ request,
+ "chat/index.html",
+ {
+ "chat": chat,
+ "messages": messages,
+ "bodies": bodies,
+ "models": chat_service.available_models(db),
+ **_sidebar_context(db, user),
+ },
+ )
+
+
+@router.get("/settings")
+async def settings_page(request: Request, db: Db, user: RequiredUser):
+ return render(
+ request,
+ "settings.html",
+ {
+ "chat": None,
+ "models": chat_service.available_models(db),
+ **_sidebar_context(db, user),
+ },
+ )
diff --git a/src/lembas/api/preferences.py b/src/lembas/api/preferences.py
new file mode 100644
index 0000000..8f08639
--- /dev/null
+++ b/src/lembas/api/preferences.py
@@ -0,0 +1,29 @@
+"""Per-user preferences set from the browser."""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, Body
+
+from lembas.api.deps import Db, RequiredUser
+
+router = APIRouter(prefix="/api/preferences", tags=["preferences"])
+
+THEMES = ("moria", "shire")
+
+
+@router.post("/theme")
+async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=True)) -> dict:
+ """Mirror the browser's theme choice onto the account.
+
+ localStorage is the source of truth for the current tab; this is what makes
+ the choice follow the user to another browser, and what lets the server
+ render the right theme on first paint instead of flashing the default.
+ """
+ if theme not in THEMES:
+ return {"ok": False, "detail": "Unknown theme."}
+
+ # Replaced rather than mutated in place: SQLAlchemy only reliably detects
+ # a change to a JSON column when the whole value is reassigned.
+ user.settings_json = {**(user.settings_json or {}), "theme": theme}
+ db.commit()
+ return {"ok": True, "theme": theme}
diff --git a/src/lembas/cli.py b/src/lembas/cli.py
new file mode 100644
index 0000000..22c3df1
--- /dev/null
+++ b/src/lembas/cli.py
@@ -0,0 +1,112 @@
+"""Command line entry points."""
+
+from __future__ import annotations
+
+import secrets as secrets_module
+
+import typer
+import uvicorn
+from sqlalchemy import func, select
+
+from lembas import __version__
+from lembas.config import settings
+
+app = typer.Typer(
+ help="LLeMbas - a Middle-earth themed web UI for your language models.",
+ no_args_is_help=True,
+ add_completion=False,
+)
+
+
+@app.command()
+def serve(
+ host: str = typer.Option(None, help="Bind address. Defaults to LEMBAS_HOST."),
+ port: int = typer.Option(None, help="Port. Defaults to LEMBAS_PORT."),
+ reload: bool = typer.Option(None, "--reload/--no-reload", help="Autoreload on change."),
+) -> None:
+ """Run the web server."""
+ uvicorn.run(
+ "lembas.main:app",
+ host=host or settings.host,
+ port=port or settings.port,
+ reload=settings.reload if reload is None else reload,
+ log_level=settings.log_level,
+ # Access logs duplicate what the application already logs and drown out
+ # anything useful during development.
+ access_log=settings.log_level == "debug",
+ )
+
+
+@app.command("create-admin")
+def create_admin(
+ email: str = typer.Option(..., prompt=True),
+ name: str = typer.Option(..., prompt=True),
+ password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True),
+) -> None:
+ """Create an administrator, or promote an existing account to one.
+
+ The web sign-up already makes the first account an admin. This is the way
+ back in when that account is lost, or when scripting a deployment.
+ """
+ from lembas.db.models import ROLE_ADMIN, User
+ from lembas.db.session import init_db, session_scope
+ from lembas.security.passwords import hash_password, validate_password
+
+ if (problem := validate_password(password)) is not None:
+ typer.secho(problem, fg=typer.colors.RED)
+ raise typer.Exit(1)
+
+ init_db()
+ with session_scope() as db:
+ existing = db.scalar(select(User).where(User.email == email.strip().lower()))
+ if existing is not None:
+ existing.role = ROLE_ADMIN
+ existing.password_hash = hash_password(password)
+ existing.active = True
+ typer.secho(f"Promoted {existing.email} to administrator.", fg=typer.colors.GREEN)
+ return
+
+ db.add(
+ User(
+ email=email.strip().lower(),
+ name=name.strip(),
+ password_hash=hash_password(password),
+ role=ROLE_ADMIN,
+ )
+ )
+ typer.secho(f"Created administrator {email}.", fg=typer.colors.GREEN)
+
+
+@app.command("secret-key")
+def secret_key() -> None:
+ """Print a fresh value for LEMBAS_SECRET_KEY."""
+ typer.echo(secrets_module.token_urlsafe(48))
+
+
+@app.command()
+def info() -> None:
+ """Show where this instance keeps its data and what is configured."""
+ from lembas.db.models import Chat, Connection, User
+ from lembas.db.session import init_db, session_scope
+
+ init_db()
+ typer.echo(f"LLeMbas {__version__}")
+ typer.echo(f" data directory : {settings.data_dir.resolve()}")
+ typer.echo(f" database : {settings.db_path.resolve()}")
+ typer.echo(f" bind : {settings.host}:{settings.port}")
+ typer.echo(f" default theme : {settings.default_theme}")
+ typer.echo(f" signup open : {settings.allow_signup}")
+ if settings.secret_key_is_ephemeral:
+ typer.secho(
+ " secret key : GENERATED (set LEMBAS_SECRET_KEY for a real install)",
+ fg=typer.colors.YELLOW,
+ )
+
+ with session_scope() as db:
+ for label, model in (("users", User), ("connections", Connection), ("chats", Chat)):
+ count = db.scalar(select(func.count()).select_from(model))
+ typer.echo(f" {label:<15}: {count}")
+
+
+if __name__ == "__main__":
+ app()
diff --git a/src/lembas/config.py b/src/lembas/config.py
index 5517cc4..59e7a66 100644
--- a/src/lembas/config.py
+++ b/src/lembas/config.py
@@ -7,7 +7,7 @@ from functools import lru_cache
from pathlib import Path
from typing import Literal
-from pydantic import Field, field_validator
+from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -22,6 +22,10 @@ class Settings(BaseSettings):
)
secret_key: str = Field(default="")
+ # Set when no LEMBAS_SECRET_KEY was supplied and one had to be invented.
+ # main.py warns about it at startup; see the validator below.
+ secret_key_is_ephemeral: bool = Field(default=False, exclude=True)
+
data_dir: Path = Path("./data")
host: str = "127.0.0.1"
@@ -34,13 +38,15 @@ class Settings(BaseSettings):
session_ttl: int = 60 * 60 * 24 * 30
request_timeout: float = 300.0
- @field_validator("secret_key")
- @classmethod
- def _generate_secret_if_absent(cls, v: str) -> str:
- # A generated key lets `lembas serve` work out of the box, but it changes
- # on every restart: sessions drop and stored API keys become unreadable.
- # main.py warns loudly about this. Never rely on it in production.
- return v or secrets.token_urlsafe(48)
+ @model_validator(mode="after")
+ def _generate_secret_if_absent(self) -> Settings:
+ # A generated key lets `lembas serve` work with no configuration at all,
+ # but it changes on every restart: sessions drop and stored API keys
+ # become unreadable. Flagged so startup can warn. Never use in anger.
+ if not self.secret_key:
+ self.secret_key = secrets.token_urlsafe(48)
+ self.secret_key_is_ephemeral = True
+ return self
@property
def db_path(self) -> Path:
diff --git a/src/lembas/main.py b/src/lembas/main.py
new file mode 100644
index 0000000..7d17d0e
--- /dev/null
+++ b/src/lembas/main.py
@@ -0,0 +1,128 @@
+"""Application factory, lifespan and error handling."""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, Request, status
+from fastapi.responses import JSONResponse, Response
+from fastapi.staticfiles import StaticFiles
+from starlette.exceptions import HTTPException as StarletteHTTPException
+
+from lembas import __version__
+from lembas.api import admin, auth, chats, folders, pages, preferences
+from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect
+from lembas.config import settings
+from lembas.db.session import init_db
+from lembas.web.templating import STATIC_DIR, render
+
+log = logging.getLogger("lembas")
+
+
+def configure_logging() -> None:
+ logging.basicConfig(
+ level=settings.log_level.upper(),
+ format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
+ datefmt="%H:%M:%S",
+ )
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI) -> AsyncIterator[None]:
+ configure_logging()
+ settings.ensure_dirs()
+ init_db()
+
+ if settings.secret_key_is_ephemeral:
+ log.warning(
+ "No LEMBAS_SECRET_KEY set, so a temporary one was generated. Every "
+ "restart will sign all users out and make stored API keys "
+ "unreadable. Generate a permanent key with:\n"
+ ' python -c "import secrets; print(secrets.token_urlsafe(48))"'
+ )
+
+ log.info("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port)
+ log.info("data directory: %s", settings.data_dir.resolve())
+ yield
+ log.info("LLeMbas stopped")
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(
+ title="LLeMbas",
+ version=__version__,
+ lifespan=lifespan,
+ # The API is an implementation detail of the UI, not a product surface.
+ docs_url="/api/docs" if settings.log_level == "debug" else None,
+ redoc_url=None,
+ )
+
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+
+ app.include_router(pages.router)
+ app.include_router(auth.router)
+ app.include_router(preferences.router)
+ app.include_router(chats.router)
+ app.include_router(folders.router)
+ app.include_router(admin.router)
+
+ register_error_handlers(app)
+ return app
+
+
+def register_error_handlers(app: FastAPI) -> None:
+ @app.exception_handler(RedirectToLogin)
+ async def _not_signed_in(request: Request, exc: RedirectToLogin) -> Response:
+ # An htmx request must not swap a login page into a fragment of the
+ # chat UI, so tell the browser to navigate instead.
+ if is_htmx(request):
+ response = Response(status_code=status.HTTP_204_NO_CONTENT)
+ response.headers["HX-Redirect"] = "/auth/login"
+ return response
+ return login_redirect(exc.next_url)
+
+ @app.exception_handler(StarletteHTTPException)
+ async def _http_error(request: Request, exc: StarletteHTTPException) -> Response:
+ # JSON callers and htmx fragments want the bare status; humans loading a
+ # page want a themed page they can navigate away from.
+ wants_page = "text/html" in request.headers.get("accept", "") and not is_htmx(request)
+ if not wants_page:
+ return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
+
+ return render(
+ request,
+ "error.html",
+ {
+ "status_code": exc.status_code,
+ "detail": exc.detail,
+ "flavour": ERROR_FLAVOUR.get(exc.status_code, ERROR_FLAVOUR[500]),
+ },
+ status_code=exc.status_code,
+ )
+
+ @app.exception_handler(Exception)
+ async def _unhandled(request: Request, exc: Exception) -> Response:
+ log.exception("unhandled error at %s", request.url.path)
+ if is_htmx(request) or "text/html" not in request.headers.get("accept", ""):
+ return JSONResponse({"detail": "Internal server error"}, status_code=500)
+ return render(
+ request,
+ "error.html",
+ {"status_code": 500, "detail": "Something went wrong.",
+ "flavour": ERROR_FLAVOUR[500]},
+ status_code=500,
+ )
+
+
+# Flavour lives in error pages, empty states and theme names -- never in the
+# functional UI. See CLAUDE.md.
+ERROR_FLAVOUR = {
+ 403: "Speak, friend, and enter. This door is not yours to open.",
+ 404: "Not all those who wander are lost. This page, however, is.",
+ 500: "The Road goes ever on, but this stretch of it has washed out.",
+}
+
+
+app = create_app()
diff --git a/src/lembas/security/passwords.py b/src/lembas/security/passwords.py
index 6e8b651..1220e67 100644
--- a/src/lembas/security/passwords.py
+++ b/src/lembas/security/passwords.py
@@ -8,7 +8,7 @@ defaults tighten in a future release.
from __future__ import annotations
from argon2 import PasswordHasher
-from argon2.exceptions import InvalidHashError, VerifyMismatchError, VerificationError
+from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
_hasher = PasswordHasher()
diff --git a/src/lembas/services/chat.py b/src/lembas/services/chat.py
new file mode 100644
index 0000000..5e084e2
--- /dev/null
+++ b/src/lembas/services/chat.py
@@ -0,0 +1,225 @@
+"""Chat orchestration: building requests, streaming replies, naming chats."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session as DBSession
+
+from lembas.db.models import (
+ ROLE_ASSISTANT,
+ ROLE_SYSTEM,
+ ROLE_USER,
+ Chat,
+ Connection,
+ Message,
+ Model,
+)
+from lembas.services.llm.openai_client import Endpoint, LLMError, complete
+
+log = logging.getLogger(__name__)
+
+# Sampling keys forwarded upstream. Anything else a user puts in params_json is
+# ignored rather than passed through, so a typo cannot produce a 400 from the
+# provider that looks like a LLeMbas bug.
+FORWARDED_PARAMS = frozenset(
+ {"temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty",
+ "seed", "stop"}
+)
+
+MAX_TITLE_LENGTH = 60
+
+
+def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]:
+ """Find the connection and model a chat should use.
+
+ Chats store the model id as text rather than a foreign key so history
+ survives an admin deleting a connection, which means the mapping back to a
+ live connection has to be resolved at send time and can legitimately fail.
+ """
+ if not chat.model_id:
+ raise LLMError("This chat has no model selected.")
+
+ connection: Connection | None = None
+ if chat.connection_id:
+ connection = db.get(Connection, chat.connection_id)
+
+ if connection is None or not connection.enabled:
+ # The original connection is gone or disabled. Any enabled connection
+ # still offering this model id will do.
+ model = db.scalar(
+ select(Model)
+ .join(Connection)
+ .where(
+ Model.model_id == chat.model_id,
+ Model.enabled.is_(True),
+ Connection.enabled.is_(True),
+ )
+ .order_by(Connection.position)
+ )
+ if model is None:
+ raise LLMError(
+ f"No enabled connection currently offers the model "
+ f"'{chat.model_id}'. Pick another model for this chat."
+ )
+ connection = model.connection
+ chat.connection_id = connection.id
+ db.commit()
+
+ return Endpoint.from_connection(connection), chat.model_id
+
+
+def build_messages(db: DBSession, chat: Chat, *, upto: Message | None = None) -> list[dict]:
+ """Assemble the message list to send upstream.
+
+ `upto` excludes the placeholder assistant row being generated into, and
+ everything after it.
+ """
+ payload: list[dict[str, Any]] = []
+ if chat.system_prompt.strip():
+ payload.append({"role": ROLE_SYSTEM, "content": chat.system_prompt.strip()})
+
+ history = db.scalars(
+ select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
+ ).all()
+
+ for message in history:
+ if upto is not None and message.id == upto.id:
+ break
+ # Skip turns that failed or produced nothing: sending an empty
+ # assistant message upsets several providers.
+ if message.error or not message.content.strip():
+ continue
+ payload.append({"role": message.role, "content": message.content})
+
+ return payload
+
+
+def build_request(db: DBSession, chat: Chat, *, upto: Message | None = None) -> dict[str, Any]:
+ params = {
+ key: value
+ for key, value in (chat.params_json or {}).items()
+ if key in FORWARDED_PARAMS and value not in (None, "")
+ }
+ return {
+ "model": chat.model_id,
+ "messages": build_messages(db, chat, upto=upto),
+ **params,
+ }
+
+
+def default_model(db: DBSession) -> tuple[str, str] | None:
+ """First enabled model on the first enabled connection, or None."""
+ model = db.scalar(
+ select(Model)
+ .join(Connection)
+ .where(Model.enabled.is_(True), Connection.enabled.is_(True))
+ .order_by(Connection.position, Model.model_id)
+ )
+ if model is None:
+ return None
+ return model.model_id, model.connection_id
+
+
+def available_models(db: DBSession) -> list[Model]:
+ return list(
+ db.scalars(
+ select(Model)
+ .join(Connection)
+ .where(Model.enabled.is_(True), Connection.enabled.is_(True))
+ .order_by(Connection.position, Model.model_id)
+ )
+ )
+
+
+def fallback_title(text: str) -> str:
+ """Derive a chat title from the opening message, without calling a model."""
+ cleaned = " ".join(text.split())
+ if not cleaned:
+ return "New chat"
+ if len(cleaned) <= MAX_TITLE_LENGTH:
+ return cleaned
+ # Prefer a word boundary, but only if it does not cut the title in half.
+ clipped = cleaned[:MAX_TITLE_LENGTH]
+ space = clipped.rfind(" ")
+ if space > MAX_TITLE_LENGTH * 0.6:
+ clipped = clipped[:space]
+ return clipped.rstrip(" ,.;:-") + "…"
+
+
+async def generate_title(endpoint: Endpoint, model_id: str, question: str, answer: str) -> str:
+ """Ask the model for a short chat title.
+
+ Best-effort by design: any failure falls back to trimming the first
+ message. Naming a chat is never worth surfacing an error for.
+ """
+ prompt = (
+ "Summarise this exchange as a title of at most six words. "
+ "Reply with the title alone: no quotes, no punctuation at the end, "
+ "no preamble.\n\n"
+ f"User: {question[:500]}\n\nAssistant: {answer[:500]}"
+ )
+ try:
+ raw = await complete(
+ endpoint,
+ {
+ "model": model_id,
+ "messages": [{"role": ROLE_USER, "content": prompt}],
+ "max_tokens": 24,
+ "temperature": 0.2,
+ },
+ )
+ except LLMError as exc:
+ log.debug("auto-title failed, using fallback: %s", exc)
+ return fallback_title(question)
+
+ title = " ".join(raw.split()).strip().strip('"“”\'')
+ # Small models sometimes ignore the instruction and answer the question
+ # instead; an over-long reply is a better signal of that than anything else.
+ if not title or len(title) > MAX_TITLE_LENGTH * 1.5:
+ return fallback_title(question)
+ return title[:MAX_TITLE_LENGTH]
+
+
+def create_message(
+ db: DBSession,
+ chat: Chat,
+ role: str,
+ content: str = "",
+ *,
+ complete_: bool = True,
+ model_id: str = "",
+) -> Message:
+ message = Message(
+ chat_id=chat.id,
+ role=role,
+ content=content,
+ complete=complete_,
+ model_id=model_id,
+ )
+ db.add(message)
+ db.commit()
+ return message
+
+
+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))
+ 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())))
+
+
+__all__ = [
+ "ROLE_ASSISTANT",
+ "ROLE_USER",
+ "available_models",
+ "build_request",
+ "create_message",
+ "default_model",
+ "fallback_title",
+ "generate_title",
+ "resolve_endpoint",
+ "user_chats",
+]
diff --git a/src/lembas/services/llm/openai_client.py b/src/lembas/services/llm/openai_client.py
new file mode 100644
index 0000000..864c9c8
--- /dev/null
+++ b/src/lembas/services/llm/openai_client.py
@@ -0,0 +1,245 @@
+"""Client for OpenAI-compatible chat endpoints.
+
+Deliberately plain httpx rather than the official SDK. The target is not just
+api.openai.com but LM Studio, vLLM, llama.cpp, Ollama's compatibility layer,
+OpenRouter and anything else exposing /v1 -- and they differ in small ways. A
+thin client passes request parameters through untouched and is tolerant about
+what comes back, which is exactly what talking to all of them requires.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from collections.abc import AsyncIterator
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+
+from lembas.config import settings
+from lembas.db.models import Connection
+from lembas.services.crypto import decrypt
+
+log = logging.getLogger(__name__)
+
+
+class LLMError(Exception):
+ """An upstream failure with a message fit to show a user.
+
+ Every failure path in this module raises this rather than letting an httpx
+ or JSON exception escape, so callers have exactly one thing to catch and
+ the chat UI always has something intelligible to display.
+ """
+
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
+ super().__init__(message)
+ self.message = message
+ self.status_code = status_code
+
+
+@dataclass(frozen=True)
+class Endpoint:
+ """Everything needed to call a connection, with the key already decrypted.
+
+ A frozen snapshot rather than the ORM object because streaming outlives the
+ request that started it, and a detached SQLAlchemy instance is a trap.
+ """
+
+ base_url: str
+ api_key: str
+ extra_headers: dict[str, str]
+ name: str = ""
+
+ @classmethod
+ def from_connection(cls, connection: Connection) -> Endpoint:
+ return cls(
+ base_url=connection.base_url.rstrip("/"),
+ api_key=decrypt(connection.api_key_encrypted),
+ extra_headers=dict(connection.extra_headers_json or {}),
+ name=connection.name,
+ )
+
+ def url(self, path: str) -> str:
+ # Accept both "http://host:1234" and "http://host:1234/v1" so users do
+ # not have to guess which form this expects.
+ base = self.base_url
+ if not base.endswith("/v1") and "/v1/" not in base:
+ base = f"{base}/v1"
+ return f"{base}/{path.lstrip('/')}"
+
+ def headers(self) -> dict[str, str]:
+ headers = {"Content-Type": "application/json", **self.extra_headers}
+ # Local endpoints frequently need no key at all; sending an empty
+ # bearer token makes some of them reject the request outright.
+ if self.api_key:
+ headers["Authorization"] = f"Bearer {self.api_key}"
+ return headers
+
+
+def _describe_http_error(exc: httpx.HTTPStatusError) -> str:
+ """Turn an upstream error response into something worth reading.
+
+ Providers put the useful part in wildly different places, so try the common
+ shapes before falling back to the raw body.
+ """
+ status = exc.response.status_code
+ detail = ""
+ try:
+ payload = exc.response.json()
+ if isinstance(payload, dict):
+ error = payload.get("error")
+ if isinstance(error, dict):
+ detail = error.get("message", "")
+ elif isinstance(error, str):
+ detail = error
+ detail = detail or payload.get("message", "") or payload.get("detail", "")
+ except (ValueError, json.JSONDecodeError):
+ detail = exc.response.text[:300]
+
+ friendly = {
+ 401: "The API key was rejected.",
+ 403: "The API key is not permitted to use this model.",
+ 404: "The endpoint or model was not found.",
+ 429: "Rate limited by the provider.",
+ }.get(status)
+
+ if friendly and detail:
+ return f"{friendly} {detail}"
+ return friendly or detail or f"The endpoint returned HTTP {status}."
+
+
+def _wrap_transport_error(exc: httpx.RequestError, endpoint: Endpoint) -> LLMError:
+ if isinstance(exc, httpx.ConnectError):
+ return LLMError(
+ f"Could not reach {endpoint.base_url}. Is the endpoint running and "
+ f"the URL correct?"
+ )
+ if isinstance(exc, httpx.TimeoutException):
+ return LLMError(
+ f"{endpoint.base_url} did not respond within "
+ f"{settings.request_timeout:.0f}s."
+ )
+ return LLMError(f"Could not reach {endpoint.base_url}: {exc}")
+
+
+async def list_models(endpoint: Endpoint) -> list[dict[str, Any]]:
+ """Fetch the models a connection advertises via GET /v1/models."""
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(endpoint.url("models"), headers=endpoint.headers())
+ response.raise_for_status()
+ payload = response.json()
+ except httpx.HTTPStatusError as exc:
+ raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
+ except httpx.RequestError as exc:
+ raise _wrap_transport_error(exc, endpoint) from exc
+ except ValueError as exc:
+ raise LLMError("The endpoint returned a response that was not JSON.") from exc
+
+ # The spec says {"data": [...]}, but some servers return a bare list.
+ entries = payload.get("data", payload) if isinstance(payload, dict) else payload
+ if not isinstance(entries, list):
+ raise LLMError("The endpoint's model list was not in the expected format.")
+
+ models = []
+ for entry in entries:
+ if isinstance(entry, dict) and entry.get("id"):
+ models.append(entry)
+ elif isinstance(entry, str):
+ models.append({"id": entry})
+ return models
+
+
+async def stream_chat(
+ endpoint: Endpoint,
+ payload: dict[str, Any],
+) -> AsyncIterator[dict[str, Any]]:
+ """Stream a chat completion, yielding each parsed SSE data object.
+
+ Yields the raw upstream chunks; interpreting them is the caller's job. The
+ terminating "[DONE]" sentinel is consumed here and not yielded.
+ """
+ body = {**payload, "stream": True}
+
+ try:
+ async with (
+ httpx.AsyncClient(timeout=settings.request_timeout) as client,
+ client.stream(
+ "POST",
+ endpoint.url("chat/completions"),
+ headers=endpoint.headers(),
+ json=body,
+ ) as response,
+ ):
+ if response.status_code >= 400:
+ # The body has not been read yet on a streaming response, and
+ # the error detail is in it.
+ await response.aread()
+ response.raise_for_status()
+
+ async for line in response.aiter_lines():
+ line = line.strip()
+ if not line or line.startswith(":"):
+ continue # keep-alive comment
+ if not line.startswith("data:"):
+ continue
+ data = line[5:].strip()
+ if data == "[DONE]":
+ return
+ try:
+ yield json.loads(data)
+ except json.JSONDecodeError:
+ # A malformed frame is not worth killing a reply over.
+ log.warning("skipping unparseable SSE frame: %.120s", data)
+ continue
+
+ except httpx.HTTPStatusError as exc:
+ raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
+ except httpx.RequestError as exc:
+ raise _wrap_transport_error(exc, endpoint) from exc
+
+
+async def complete(endpoint: Endpoint, payload: dict[str, Any]) -> str:
+ """Non-streaming completion. Used for short internal calls like auto-titling."""
+ body = {**payload, "stream": False}
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ response = await client.post(
+ endpoint.url("chat/completions"), headers=endpoint.headers(), json=body
+ )
+ response.raise_for_status()
+ data = response.json()
+ except httpx.HTTPStatusError as exc:
+ raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
+ except httpx.RequestError as exc:
+ raise _wrap_transport_error(exc, endpoint) from exc
+ except ValueError as exc:
+ raise LLMError("The endpoint returned a response that was not JSON.") from exc
+
+ try:
+ return data["choices"][0]["message"]["content"] or ""
+ except (KeyError, IndexError, TypeError) as exc:
+ raise LLMError("The endpoint returned no completion.") from exc
+
+
+def delta_text(chunk: dict[str, Any]) -> str:
+ """Pull the text out of one streamed chunk, tolerating provider variation."""
+ try:
+ choices = chunk.get("choices") or []
+ if not choices:
+ return ""
+ delta = choices[0].get("delta") or {}
+ content = delta.get("content")
+ if isinstance(content, str):
+ return content
+ # Some providers send content as a list of typed parts even in deltas.
+ if isinstance(content, list):
+ return "".join(
+ part.get("text", "")
+ for part in content
+ if isinstance(part, dict) and part.get("type") == "text"
+ )
+ return ""
+ except (AttributeError, TypeError):
+ return ""
diff --git a/src/lembas/services/markdown.py b/src/lembas/services/markdown.py
new file mode 100644
index 0000000..9311ba8
--- /dev/null
+++ b/src/lembas/services/markdown.py
@@ -0,0 +1,134 @@
+"""Render assistant messages from Markdown to sanitised HTML.
+
+Rendering happens on the server, in Python, so there is no JavaScript Markdown
+library to vendor and the streamed and final views cannot disagree about how
+something should look.
+
+The output is sanitised with nh3 (Rust ammonia). Model output is untrusted
+input: it routinely contains HTML, and a model can be talked into emitting a
+script tag, so this is a real boundary and not a formality.
+"""
+
+from __future__ import annotations
+
+import functools
+import html
+
+import nh3
+from markdown_it import MarkdownIt
+from pygments import highlight
+from pygments.formatters import HtmlFormatter
+from pygments.lexers import get_lexer_by_name, guess_lexer
+from pygments.util import ClassNotFound
+
+# Class-based highlighting; the colours come from theme tokens in chat.css, so
+# code blocks follow the active theme instead of carrying their own palette.
+_FORMATTER = HtmlFormatter(nowrap=True, classprefix="pg-")
+
+ALLOWED_TAGS = {
+ "p", "br", "hr", "div", "span",
+ "strong", "em", "del", "sub", "sup", "mark",
+ "h1", "h2", "h3", "h4", "h5", "h6",
+ "ul", "ol", "li",
+ "blockquote", "pre", "code",
+ "table", "thead", "tbody", "tr", "th", "td",
+ "a", "img",
+}
+
+ALLOWED_ATTRIBUTES = {
+ # "rel" is intentionally absent: nh3 rejects it here when link_rel is set,
+ # because link_rel below is what writes it.
+ "a": {"href", "title", "target"},
+ "img": {"src", "alt", "title"},
+ "code": {"class"},
+ "pre": {"class"},
+ "span": {"class"},
+ "div": {"class"},
+ "td": {"align"},
+ "th": {"align"},
+}
+
+# javascript: and data: URLs are the obvious injection route through a link.
+ALLOWED_URL_SCHEMES = {"http", "https", "mailto"}
+
+
+def _render_fence(tokens, idx, _options, _env) -> str:
+ """Render a fenced code block.
+
+ This replaces the renderer's `fence` rule outright rather than using
+ markdown-it's `highlight` option, because that option re-wraps whatever it
+ is given in
unless the string already starts with "
inside the wrapper this returns.
+ """
+ token = tokens[idx]
+ code = token.content
+ language = (token.info or "").strip().split()[0] if token.info else ""
+
+ lexer = None
+ if language:
+ try:
+ lexer = get_lexer_by_name(language, stripall=False)
+ except (ClassNotFound, ValueError):
+ lexer = None
+ elif code.strip():
+ # Guessing is only worth it for a decent sample; on two lines of text
+ # Pygments guesses confidently and wrongly.
+ try:
+ lexer = guess_lexer(code) if len(code) > 80 else None
+ except (ClassNotFound, ValueError):
+ lexer = None
+
+ if lexer is None:
+ body = nh3.clean_text(code)
+ label = language
+ else:
+ body = highlight(code, lexer, _FORMATTER)
+ label = language or (lexer.aliases[0] if lexer.aliases else "")
+
+ label_html = (
+ f'
+
+
+ {{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
+
+ {% if message.model_id %}
+ {{ message.model_id }}
+ {% endif %}
+
+
+ {% if streaming %}
+ {# Tokens are appended here as they arrive. The cursor is a CSS
+ pseudo-element on the empty parent, so it disappears by itself once
+ the first token lands. #}
+
+
+ {% endif %}
+
+ {% if not streaming %}
+
+ {# The raw source, so the copy button yields Markdown rather than rendered
+ text. A hidden div and not a world")
+ assert "\n```")
+ assert "