Uploading an image showed the chip and then did nothing: the file was stored but never reached the model. Two causes, both in the composer template. The chips live in #attachments, and each carries the hidden file_ids input that binds it to the message. That container sat OUTSIDE the <form>, with an `hx-include="#attachments"` on a hidden <div> inside the form meant to pull it back in. That attribute only has an effect on the element issuing the request -- on a child of it, it does nothing. So the form serialised content and nothing else, and post_message saw no file_ids at all. Fixed by putting #attachments inside the form, where the inputs are submitted because they are in the form, rather than because of an attribute that has to be wired correctly. The file input stays outside, since inside it would submit an empty file part on every message. Second: /chat preselected models[0] rather than the model a new chat would actually use. With a vision model set as the default and a non-vision one first in the admin ordering, the composer showed the wrong model, sent the wrong model, and told the user images *would* be sent when they would not. It now resolves through default_model(), the same path /start uses. Every server-side test passed throughout, because the bug was entirely in the wiring between template and browser. Added tests that serialise the rendered form the way a browser does -- every named input inside <form> -- and assert file_ids is among them and the image reaches the model as a content part. Verified they fail with the old markup restored, then pass again. Confirmed end to end against gemma4-e4b-q8: given a drawing, it replied "Left: Green Circle / Right: Orange Triangle". 220 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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: LLeMbas.
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. Chats are created when you send the first message, so an abandoned one never clutters the sidebar
- System prompts — instance-wide, per-model and per-chat, with the most specific winning outright
- Reasoning display — thinking from reasoning models streams into its own collapsible block, labelled with how long it took, and is never replayed as context
- Attachments — drag, paste or pick images, PDFs and text files. Images are downscaled and sent to vision models; PDF and text content is extracted and put in the prompt
- 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
- Model settings — searchable, filterable list with a page per model: ordering, pinned models, an instance default and a per-user default, custom names, descriptions and images. Scales to hundreds of models
- Users, groups & permissions — per-group grants that union rather than override, and model access restricted to chosen groups
- Accounts — first account becomes the administrator, argon2 password hashing, revocable server-side sessions, self-service password change, admin-managed accounts
- Admin settings — open or close registration from the UI, stored in the database and effective immediately
- Two themes — Moria (dark) and Shire (light), switchable per user
Planned
Built-in tools with admin settings · custom tools and MCP servers · agentic execution (local and over SSH) · image generation · OCR for scanned PDFs.
Quick start
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 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 |
Whether new users may register themselves — the initial value only. Once set under Admin → General the stored setting wins. 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
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
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 Alembic. The schema is SQLite-only and synchronised at startup:
missing tables and missing columns are added automatically, so adding a field to
a model needs nothing but a restart. Renames, drops and retypes are still manual
— see CLAUDE.md.
Artwork
The logo, favicon and banner are original vector work, generated by
scripts/build_artwork.py so the mallorn leaf stays
identical across every size it appears at. The wordmark is
Source Serif 4 (SIL OFL 1.1)
converted to outlines — a README banner cannot load a webfont, and <text>
would render in whatever serif the reader happens to have.
Licence
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.