Swap the 3090 endpoint from Ternary-Bonsai-2-27B PQ2_0 (2 x 200,704,
fork build 10754) to Ternary-Bonsai-2-9B PQ2_0 with the MTP head IN the
file, three conversations at the trained 262,144 window
(--ctx-size 786432 --parallel 3), on vendor fork build 10777 -- the
minimum build that pairs MTP with a quantized V cache. q8_0 K + q8_0 V
is the only KV that fits: 13,056 MiB against a 26,112 MiB f16 pair.
Settled boot 17,876 used / 6,249 free.
MTP is armed and then DECLINED by measurement: the draft context bills
its own f16 KV over the whole pool (3,072 MiB) and at UBATCH_SIZE=2048
that halves decode (167.2 -> 109.2 t/s). The documented MTP arm
(SPEC_TYPE=draft-mtp + UBATCH_SIZE=1024, 213.5 t/s) is recorded in the
env header rather than left to rediscovery.
Two bugs the trial caught: ${SPEC_PRESENCE_PENALTY:=0.0} was a
default-assignment, so the env value always won and the no-presence-penalty
MTP arm was unreachable (now ${VAR=0.0}); and the Q8_0 mmproj is the
publisher's requirement for concurrent image requests, which matters at
--parallel 3.
contextWindow is now 262144 -- the widest in the fleet -- so :7771 is the
compaction hub. 27B runbook re-banned RETIRED with its bench-window
section closed; indexes, homepage card, OpenWebUI doc and the worklog
re-keyed.
24 KiB
type, description, tags, timestamp
| type | description | tags | timestamp | ||
|---|---|---|---|---|---|
| reference | OpenWebUI is a self-hosted chat interface for LLMs. |
|
2026-09-21 |
OpenWebUI
OpenWebUI is a self-hosted chat interface for LLMs. It connects to OpenAI-compatible endpoints (vLLM, llama.cpp, Ollama) and adds RAG, plugins, tool calling, and multi-user support.
Host: Dankclaw — Port: 3005 (host) → 8080 (container) — Vhost:
ai.franksdank.cloud routes straight to 192.168.0.143:3005 (unguarded —
OpenWebUI enforces its own auth) — Boot-persistent: Quadlet — Docs:
https://github.com/open-webui/open-webui
Facts
| Item | Value |
|---|---|
| Image | ghcr.io/open-webui/open-webui:main (verified from the live quadlet unit) |
| Deployed build | 0.11.4 — GET http://127.0.0.1:3005/api/version → {"version":"0.11.4"} (2026-10-18). :main is the rolling image, not a stable pin, so the running build lags upstream: releases 0.12.0 (2026-10-10) through 0.12.3 (2026-10-16) exist and are not deployed (https://github.com/open-webui/open-webui/releases). The 2026-09-21 0.11.4 security/access-control advisory is what pinned this era of the page. Imagegen endpoint is POST /api/v1/images/generations (the comfyui engine is still upstream). |
| Port | 3005 (host) → 8080 (container). Port 3000 is Gitea's HTTP, so it is not usable. |
| Data volume | /home/dank/Containers/open-webui/data → /app/backend/data |
| Backends | The picker's content is webui.db state, not repo state: as recorded on 2026-10-07 it exposes one model, frank-zealot, with frank-dragoon/frank-archon configured but disabled — unverified 2026-10-18 (the DB is the authority and is not readable from the repo). What the repo does fix is the container's own OpenAI target: OPENAI_API_BASE_URL=http://192.168.0.143:7770 (dankclaw/open-webui/quadlet/open-webui.container:32, same value in the live container env) — see The LLM backends |
| Network | Pinned bridge systemd-open-webui (10.89.3.0/24, gateway 10.89.3.1) — see History |
| Persistence | Quadlet unit open-webui.service; [Install] WantedBy=default.target |
| Deploy | scripts/deploy-open-webui-quadlet.sh dankclaw |
The LLM backends
The picker is built from the model table in webui.db (one row per model)
plus the model_ids of each enabled OpenAI connection, NOT from live
connection discovery — so the picker shows exactly what is seeded there.
As of 2026-10-07 the picker exposes exactly one model: frank-zealot (unverified
2026-10-18: webui.db is the authority; the fleet itself serves all three ids
frank-dragoon/frank-zealot/frank-archon, per the cards in
shared/homepage/services.json).
Connection (openai.api_configs idx) |
URL as recorded 2026-10-09 | Enabled | model_ids |
Runtime | Host |
|---|---|---|---|---|---|
| 0 | http://host.containers.internal:7770/v1 |
no (2026-10-07) | frank-dragoon |
halogen flash-server (Strix Halo iGPU) | Dankclaw |
| 1 | http://192.168.0.235:7770/v1 |
no (2026-10-07) | frank-archon |
radiance TP=2 (2× R9700) | Dankserver |
| 2 | http://host.containers.internal:7771/v1 |
yes | frank-zealot |
llama.cpp CUDA (RTX 3090; production shape 3×262,144 per scripts/frank-zealot.env) |
Dankclaw |
The URLs in the first column are the DB's recorded values and predate the 2026-08-22
bridge move; on this network they are wrong — see the reachability gotcha below, which
is the corrected law. Note also that the 2026-10-18 bench window on :7771
closed the same day (production unit stopped 03:38 per the repo Benchmarking
Policy while candidate arms held the port): the candidate — Ternary-Bonsai-2-9B
PQ2_0 with the MTP head, 3 × 262,144 — was deployed as production, so the
picker row below was re-keyed to it (see the next paragraph).
The model table carries exactly one decoration row, frank-zealot
(context length 262,144 = the per-slot window since 2026-10-18; description
re-keyed the same day to Ternary-Bonsai-2-9B PQ2_0 + MTP + Q8_0 vision mmproj —
the row's description is live DB state, so a payload swap must re-key it in
the database too, not only in the repo).
A third gate sits after the table, and it is the one that bites family
accounts (2026-10-07): a model row is only visible to a non-admin user when
its access permission grants them. A seeded row defaults to admin-only,
so the picker renders "No results found" for every regular user even though
the connection is enabled, model_ids is correct, and the endpoint answers
/v1/models — and GET /api/v1/models returns [] for that user while an
admin token returns the model. Fix: set the row's access_control to public
(the access column on the model row) via the admin UI (Admin Panel →
Models → visibility) or the DB. The verification battery below must therefore
run as a non-admin user, not only as an admin.
Two gotchas make the wiring non-obvious:
- llama.cpp
/v1/modelsis Ollama-shaped. llama.cpp returns{"models":[{"name":...}]}; OpenWebUI'sextract_data()only reads the OpenAI{"data":[{"id":...}]}shape, so connection discovery silently misses the llama.cpp endpoints (frank-dragoon, frank-zealot). The fix is to setmodel_idsin each connection'sapi_config— when non-empty, OpenWebUI builds the model list directly and never fetches/v1/models. The oldfrank-medium(vLLM) endpoint — retired, no longer in the fleet — served the OpenAI shape and would have discovered fine, but usedmodel_idstoo. - The host's own services are reached via the host LAN IP, not
host.containers.internal. The container sits on the pinned kernel bridgesystemd-open-webui(10.89.3.0/24, gateway 10.89.3.1, container IP 10.89.3.2 —podman inspect open-webui2026-10-18), andhost.containers.internal(169.254.1.2) is a slirp4netns/pasta-only name that does not resolve on this bridge (dankclaw/open-webui/quadlet/open-webui.network:15-19). The deployed unit therefore points at the host LAN IP:OPENAI_API_BASE_URL=http://192.168.0.143:7770andSEARXNG_QUERY_URL=http://192.168.0.143:8081/search(open-webui.container:32,78, both present in the live container env, 2026-10-18). Those ports are published on all interfaces, so the LAN IP answers:ss -ltnshows*:7770and0.0.0.0:7771, andcurl http://192.168.0.143:7771/health→ 200 (2026-10-18). The Dankserver backend is a different host and is reached through its LAN IP192.168.0.235:7770either way.
The Ollama connection is disabled (ollama.enable=false).
All three endpoints are bare-metal systemd services managed by this repository. Model swaps, context sizing, and spec tuning happen there. See the LLM knowledge base. Disabling a connection here only hides it from this picker — the endpoint itself keeps serving every other client (the harness, Hermes, API consumers).
Container introspection (DB and backend python)
-
Working dir for imports:
/app/backend; package nameopen_webui(underscore), NOTopenwebui.os.environ["WEBUI_SECRET_KEY"]MUST be set BEFORE importing anyopen_webuimodule (import-time hard requirement); the secret lives at/app/backend/.webui_secret_key. -
The
sqlite3CLI is NOT in the container — use Python'ssqlite3module (podman exec open-webui python3 -c "import sqlite3; conn = sqlite3.connect('/app/backend/data/webui.db'); …"). For config keys use parameterized LIKE queries (avoids shell quoting). -
Backend python:
podman exec -w /app/backend open-webui python3 -c "…"—Models.get_all_models()is a coroutine (await it); model objects are pydanticModelModelinstances (attribute access, not.get()), with NOstatus/enabledattribute — useis_activefrom the DB. -
API auth for verification:
create_tokenlives inopen_webui.utils.auth(NOTopen_webui.utils.utils);get_verified_userrequires anidclaim (not justsub);SESSION_SECRET = WEBUI_SECRET_KEY. Mint an admin token inside the container:podman exec -i open-webui sh -c 'export WEBUI_SECRET_KEY=$(cat /app/backend/.webui_secret_key); \ python -c "from open_webui.utils.auth import create_token; \ print(create_token({\"id\":\"<UID>\",\"sub\":\"<UID>\",\"email\":\"…\",\"role\":\"admin\"}))"'(admin UID from the
usertable).
Verification battery
GET /api/v1/models/list?limit=100→ returns EXACTLY the intended models. Response key isitems, notdata.POST /api/chat/completionsper model with a trivial prompt ("Reply with exactly one word: pong") → each returns contentpong. Reasoning models need a generous budget: frank-zealot (Qwen3.5 template) returns empty content +finish_reason: "length"atmax_tokens 200(the thinking block eats it) and answerspongat 1500.- A 500 "Server Connection Error" →
podman logs open-webuiforClientConnectorError: a network-reachability problem (host LAN IP vshost.containers.internal), not a model problem.
Durability: the model table + connections are DB-only (not git-managed); a fresh data volume loses them — including the public access grant above. For reproducibility, add an idempotent first-start seed script or document the setup in the service doc.
API access (auth-gated)
Every /api/* endpoint requires an authenticated session (SSO login JWT)
or a per-user API key (auth.enable_api_keys=true; keys are minted per
account under Profile → API Keys). Anonymous web visitors get the pending
role (ui.default_user_role=pending), which cannot chat. Verified
2026-10-07 from loopback :3005 and over the public vhost:
GET /api/v1/models/list, GET /api/v1/models and
POST /api/chat/completions all return 401 with no key (also with a
bogus bearer token); only /api/config, /api/version and /health
POST /api/chat/completions all return 401 with no key (also with a bogus bearer
token); only /api/config, /api/version and /health answer publicly, and none of
them expose the model list. An external program needs an account + API key — there is
no keyless access. Re-verified 2026-10-18 from loopback: /health → {"status":true},
/api/version → 0.11.4, /api/v1/models/list, /api/v1/models and
POST /api/chat/completions → 401; /api/config additionally publishes
features.enable_login_form=false, features.enable_signup=false and
oauth.providers.oidc="authentik" — the SSO-only posture, visible from outside.
Web search
Chat web search is served by SearXNG on this host, wired in the quadlet
(ENABLE_WEB_SEARCH, WEB_SEARCH_ENGINE=searxng, SEARXNG_QUERY_URL).
- Access path: the quadlet sets
SEARXNG_QUERY_URL=http://192.168.0.143:8081/search(dankclaw/open-webui/quadlet/open-webui.container:78, live in the container env) — gluetun's published port, NOT thesearch.franksdank.cloudvhost. The JSON format parameter is appended by OpenWebUI itself (unverified 2026-10-18: OpenWebUI-side behaviour; the env value carries noformat=json). The Anubis→Authentik guard chain is a browser mechanism (proof-of-work + session flow); a plain HTTP client cannot solve it, and the internal port bypasses Caddy entirely. - Limiter: SearXNG caps non-HTML (JSON) requests at 4/IP/hour (
API_MAX, a hardcoded Python constant — no config knob). Thepass_iplist is checked FIRST and short-circuits the whole botdetection chain, and OpenWebUI's bridge egress (10.89.x) is inpass_ip, so the cap never applies. Verified: an 8-request burst from the container returned 8×200. - Config storage: OpenWebUI keeps web-search settings per-key in its SQLite
config table (
webui.db,web.search.*). The quadlet env vars seed that table on first start (fill-missing only); afterwards the DB is authoritative and Admin-UI edits persist there.
SSO (authentik)
Login is authentik via the OIDC authorization-code flow (wired 2026-09-24).
The vhost stays exempt from the Anubis guard chain: the OAuth dance is
browser-driven and only touches the two exempt vhosts (ai. and auth.).
- authentik objects (created via the admin API, 2026-09-24): application
open-webui(slugopen-webui) with the OAuth2 providerOpen WebUI(confidential client,authorization_code+refresh_token, Strict redirect URIhttps://ai.franksdank.cloud/oauth/oidc/callback, implicit consent flow). The client ID is in the quadlet; the client secret lives in the git-ignored host file/home/dank/Containers/open-webui/env(mode 600, loaded viaEnvironmentFile=). - Roles: the scope mapping
Open WebUI roles(scoperoles) reads the application entitlementsOpen WebUI Users→userandOpen WebUI Admins→admin. The entitlements are bound to the groupsopenwebui_users/openwebui_admins.akadmin(frank8465@protonmail.com) is inopenwebui_admins;qa-e2e-adminis inopenwebui_users. - Account merge:
OAUTH_MERGE_ACCOUNTS_BY_EMAIL=truefolds an SSO login into the existing local account with the same email — the pre-SSO admin (frank8465@protonmail.com) keeps its data.ENABLE_OAUTH_SIGNUP=truelets otheropenwebui_userscreate accounts on first login. - First login mints role
pending.ui.default_user_role=pending(config DB, verified 2026-10-09), so a freshly-provisionedopenwebui_usersaccount completes SSO and then cannot chat (picker empty) until its role is flipped:UPDATE "user" SET role='user' WHERE email='<user>@franksdank.cloud';— run with hostpython3over ssh against/home/dank/Containers/open-webui/data/webui.db(nosqlite3CLI on Dankclaw; table isuser, singular; noactivecolumn). No per-user model grant is needed: the picker modelfrank-zealotcarries the globalaccess_grantrow(model, frank-zealot, user, *, read), so anyrole='user'account sees it. Flip on FIRST-LOGIN SIGHTING — the row does not exist until the account signs in (2026-10-09: five provisioned accounts had no row). - IdP reachability: the container is on its own bridge (10.89.3.0/24) and
cannot reach
authentik-server:9000by name. It reaches the IdP over the public URLhttps://auth.franksdank.cloud— Caddy publishes0.0.0.0:443(netavark DNATs published ports from other containers) and Pi-hole resolves the name to the host LAN IP. The server-side code exchange rides that path. - Config authority: the OAuth env vars in the quadlet seed the per-key config DB on first run (fill-missing only); on the live data dir the Admin Panel is authoritative. To rotate the client secret: create a new token in authentik, update the host env file, and set the new value in the Admin Panel (or the DB), then restart the container.
- SSO-only (2026-09-24): after the OIDC round-trip was verified, the local
username/password path was retired.
ENABLE_LOGIN_FORM=falsehides the form andENABLE_PASSWORD_AUTH=falserefuses password logins, so the only way in is the authentik OIDC flow. Theui.enable_login_formconfig-DB override was flipped tofalsein the same change — the per-key DB wins over env, so both must agree, and the DB value is the one to change on a live data dir. To restore the password form: setui.enable_login_formback totruein the Admin Panel (or DB) and drop the two env vars from the quadlet.
Boot persistence (Quadlet)
The unit lives at dankclaw/open-webui/quadlet/open-webui.container. On the host it installs to ~/.config/containers/systemd/open-webui.container. The generator turns it into open-webui.service. Because the unit sets [Install] WantedBy=default.target and user linger is on, it starts at boot. systemctl --user is-enabled open-webui.service reports generated (the quadlet boot-start state).
Volumes
| Container path | Host path |
|---|---|
/app/backend/data |
/home/dank/Containers/open-webui/data |
The data dir holds the database, RAG indexes, and uploads. The deploy script creates it if missing and preserves it across reruns.
Healthcheck
The quadlet carries a built-in healthcheck (30s interval, 3 retries, 300s start period). It checks two things from inside the container:
- App liveness —
GET http://127.0.0.1:8080/healthreturns 200 ({"status":true}). - Network liveness — a non-loopback interface exists in
/sys/class/net. The dead-tap zombie state (History) leaves onlylobehind, and this is the check that catches it.
podman ps shows the container as unhealthy if either fails. Probe manually at http://dankclaw:3005/health.
Image generation (ComfyUI)
Image generation rides the local ComfyUI integration, not the chat
picker — ComfyUI's models never appear in the chat model picker (that is
chat LLMs only), so ComfyUI needs its own image-generation connection
configured in OpenWebUI. ComfyUI itself runs GPU-enabled on Dankclaw and
serves :8188.
Endpoint: POST /api/v1/images/generations — measured on the running build
(0.11.4): the legacy POST /api/images/generations answers 405 while the /v1
route answers 401 without a token, i.e. only the /v1 path exists (2026-10-18).
The comfyui engine is still upstream. Call it with Authorization: Bearer <token>
and body
{"model":"imagegen","prompt":"…","n":1,"size":"1024x1024"}; the response
carries base64 image data or a URL.
Failure modes: connection refused ⇒ OpenWebUI or ComfyUI is down; timeouts on large sizes ⇒ check ComfyUI resources; 401 ⇒ the token is bad.
Retired instances
- The compose
open-webuiservice (port 3000) was never run: 3000 is Gitea's HTTP port, so it could not bind. - The bare-container
open-webuion Dankserver (port 3000) pointed at a dead backend (:9090) and was removed. Its data dir is preserved on Dankserver in case it is needed. - The bare-container
searxngon Dankserver (port 8081) was removed. SearXNG now runs only on Dankclaw behind Caddy.
Gotchas
- The image tracks upstream
main; pin a digest in the quadlet unit if reproducible rollback matters more than tracking. (The old "CPU-only host" framing is stale: this APU runs the gfx1151 ROCm build — see ComfyUI.) - The default admin account is created on first login; secure it immediately.
- If the backend is loading (3–8 minute model load), OpenWebUI shows connection errors until the model is ready.
- The on-host LLM services (Dankclaw
:7770,:7771) are reached via the host LAN IP192.168.0.143, which is exactly what the deployed unit does (open-webui.container:32). The older "usehost.containers.internal" advice is retired for this container: it is a slirp4netns/pasta-only name and the unit has been on the pinned bridgesystemd-open-webuisince 2026-08-22, where that name does not resolve (open-webui.network:15-19). Both endpoints are published on all interfaces today (ss -ltn→*:7770,0.0.0.0:7771;curl http://192.168.0.143:7771/health→ 200, 2026-10-18). The Dankserver backend is a different host, reached via its LAN IP192.168.0.235:7770.
History
-
2026-08-22 — slirp4netns → pinned bridge. The unit ran on
Network=slirp4netns. The slirp4netns helper process died (no OOM, no kill entry, no crash log found), and the container kept running with onlyloin its netns: published port 3005 accepted TCP and reset every request, so chat served empty responses for ~2 days. Nothing logged an error. Moving to a kernel bridge removes the per-container helper entirely, and the healthcheck's interface check makes the zombie state visible if it ever returns. -
2026-09-21 — model picker rebuilt for the 3-model fleet. The picker carried 39 stale rows (37 dead Ollama models + 2
frank-default-*) and was missingfrank-medium. Rebuilt themodeltable to exactlyfrank-dragoon/frank-medium/frank-zealotand setmodel_idson each connection (llama.cpp's Ollama-shaped/v1/modelsis invisible to OpenWebUI's discovery). Pointed the two on-host endpoints athost.containers.internal— the bridge refuses the host LAN IP for non-published ports. All three verified end-to-end via/openai/chat/completions. -
2026-09-24 — SSO via authentik. OIDC authorization-code flow wired per the SSO (authentik) section: application/provider/entitlements created in authentik, client secret in the host env file, roles from the
rolesscope claim. E2E coverage intests/e2e/specs/18-openwebui-sso.spec.ts. -
2026-09-24 — flipped to SSO-only. The OIDC round-trip verified in a real browser (login view renders no password form, only "Continue with authentik", and the flow lands on a signed-in session), so the local username/password path was retired:
ENABLE_LOGIN_FORM=false+ENABLE_PASSWORD_AUTH=falsein the quadlet andui.enable_login_formset tofalsein the config DB (the DB wins over env, so both were changed together).qa-e2e-adminwas demoted out of theauthentik Adminsgroup (it keepsopenwebui_users/homepage-adminsfor app access). -
2026-10-07 — picker narrowed to
frank-zealotonly. Per Frank: the only model accessible in OpenWebUI isfrank-zealot. Rebuilt themodeltable to a singlefrank-zealotrow (the old rowsfrank-dragoon/frank-archon/frank-smallare deleted), setopenai.api_configsindices 0 (frank-dragoon) and 1 (frank-archon) toenable: false, and retargeted index 2'smodel_idsfrom the stalefrank-smalllabel tofrank-zealot— llama.cpp serves by alias, so the old label addressed a model name the server does not know. Pre-change snapshot:/home/dank/Containers/open-webui/data/webui.db.bak-20261007(+-wal/-shm). Verified:/api/v1/models/listreturns exactly["frank-zealot"], chat completion answerspong(needsmax_tokens≥1500— the Qwen3.5 thinking block consumes a 200-token budget), and anonymous API probes are all 401 (see API access). -
2026-10-09 — re-keyed to the Protoss ids (wire rename). The endpoints began serving
frank-zealot/frank-dragoon/frank-archon, so three id-keyed objects had to move in the same window as the alias flip:model.id+name(frank-zealot→frank-zealot,params{"context":{"length":200704}}and themeta.descriptionkept),access_grant(resource_type='model',resource_id, principaluser/*, permissionread), andopenai.api_configsper-backendmodel_ids(index 2 =:7771is the enabled family backend; indices 0/1 =:7770and Dankserver:7770stayenable: false). Snapshot first:/home/dank/Containers/open-webui/data/webui.db.bak-20261009. Apply from inside the container (podman exec -i open-webui python3 -, sqlite3; the image has no sqlite3 binary) thensystemctl --user restart open-webui.service(it is a rootless quadlet) to drop the cached model list. Verify in a real browser as the family QA account: the welcome screen selectsfrank-zealotand answers. A stale id fails silently — the picker simply lists nothing, because OpenWebUI intersects the backend's/v1/modelswithmodel_ids.Law: the model id is a foreign key in three tables. Any future alias change on an endpoint OpenWebUI fronts must ship with this re-key, in the same deploy.
-
2026-10-09 — composer is
contenteditable, not<textarea>. A Playwright probe that waits fortextareaafter SSO times out on the welcome screen: OpenWebUI 0.6 renders the composer as a contenteditable div. The stable anchor is the model button (getByRole("button", { name: /selected model/i })) and the welcome text "How can I help you today?"; that snapshot is also the cheapest proof the picker is populated.
See Also
- Dankclaw Stack
- Hermes — the agent OpenWebUI can talk to
- SearXNG — the search engine on this host
- LLM knowledge base — the vLLM and llama.cpp endpoints