Files
serverAppConfigs/serverAppConfigs-docs/Dankclaw/OpenWebUI.md
T
Frank 695a5f3281 fix(frank-zealot): key the ledger to the production boot; record the arm drop-in hijack
The cutover deploy's FIRST boot was not the shipped payload: a stale
frank-zealot.service.d/ptq1.conf from the bench window still pointed
FRANK_ZEALOT_ENV_FILE at /tmp/zealot-arm-current.env, so the unit booted the
unmeasured 27B PTQ1_0 arm (3 slots of 133,888, 21,188 MiB) while /health
answered 200, /v1/models echoed the alias and the e2e gate passed 33/33.
Drop-in retired to ~/.AGENTTRASH/20261018-zealot-ptq1-arm/; the 13:19 restart
is the shape the docs now quote (n_ctx_slot 262144, KV 13,056.00 MiB,
settled 17,922/6,203).

Corrections the production ledger forced:
- RS buffer is 150.75 MiB (3 cells) on the shipped spec-off shape; 452.25 MiB
  belongs to the SPEC-ARMED arms, where MTP depth 2 bills three seqs per slot.
  The drafter triples the recurrent state on top of its 3,072 MiB of f16 KV.
- Decode measured 168.4-168.7 t/s single-stream and ~300 t/s aggregate over
  three streams (102.8 each). Wall-clock timing of a generation that stops on
  its own reads 4x low (72 t/s for a 24-token countdown) - measure with
  ignore_eos and read the engine's timings.
- OpenWebUI stores both decoration values inside JSON columns (params/meta),
  and frank-dragoon's context.length had drifted to 262,144 against its own
  200,704 ceiling; both re-keyed in the live DB.

New law in AGENTS.md: after deploying a recently-benched LLM service, check
DropInPaths, then /proc/<pid>/cmdline, then the ledger. Health checks cannot
see the payload.
2026-10-18 13:27:27 -07:00

24 KiB
Raw Blame History

type, description, tags, timestamp
type description tags timestamp
reference OpenWebUI is a self-hosted chat interface for LLMs.
dankclaw
openwebui
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 two decoration rows, frank-zealot and frank-dragoon. Both the context length and the description live inside JSON columns — params holds {"context": {"length": …}} and meta holds {"description": …}; there is no context_length or description column, so sqlite3 one-liners against those names fail. Re-keyed 2026-10-18: frank-zealot → 262,144 with the Bonsai-2-9B description, and frank-dragoon → 200,704 (its params had kept the pre-lowering 262,144 window from 2026-10-17 while its own description already said 200,704). These rows are live DB state, so a payload swap must re-key the database too, not only the repo. There is no sqlite3 binary on Dankclaw — use python3 -c with the stdlib sqlite3 module, and take a Connection.backup() snapshot first (the DB is in WAL and the app is live).

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/models is Ollama-shaped. llama.cpp returns {"models":[{"name":...}]}; OpenWebUI's extract_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 set model_ids in each connection's api_config — when non-empty, OpenWebUI builds the model list directly and never fetches /v1/models. The old frank-medium (vLLM) endpoint — retired, no longer in the fleet — served the OpenAI shape and would have discovered fine, but used model_ids too.
  • The host's own services are reached via the host LAN IP, not host.containers.internal. The container sits on the pinned kernel bridge systemd-open-webui (10.89.3.0/24, gateway 10.89.3.1, container IP 10.89.3.2 — podman inspect open-webui 2026-10-18), and host.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:7770 and SEARXNG_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 -ltn shows *:7770 and 0.0.0.0:7771, and curl http://192.168.0.143:7771/health → 200 (2026-10-18). The Dankserver backend is a different host and is reached through its LAN IP 192.168.0.235:7770 either 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 name open_webui (underscore), NOT openwebui. os.environ["WEBUI_SECRET_KEY"] MUST be set BEFORE importing any open_webui module (import-time hard requirement); the secret lives at /app/backend/.webui_secret_key.

  • The sqlite3 CLI is NOT in the container — use Python's sqlite3 module (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 pydantic ModelModel instances (attribute access, not .get()), with NO status/enabled attribute — use is_active from the DB.

  • API auth for verification: create_token lives in open_webui.utils.auth (NOT open_webui.utils.utils); get_verified_user requires an id claim (not just sub); 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 user table).

Verification battery

  1. GET /api/v1/models/list?limit=100 → returns EXACTLY the intended models. Response key is items, not data.
  2. POST /api/chat/completions per model with a trivial prompt ("Reply with exactly one word: pong") → each returns content pong. Reasoning models need a generous budget: frank-zealot (Qwen3.5 template) returns empty content + finish_reason: "length" at max_tokens 200 (the thinking block eats it) and answers pong at 1500.
  3. A 500 "Server Connection Error" → podman logs open-webui for ClientConnectorError: a network-reachability problem (host LAN IP vs host.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.

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 the search.franksdank.cloud vhost. The JSON format parameter is appended by OpenWebUI itself (unverified 2026-10-18: OpenWebUI-side behaviour; the env value carries no format=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). The pass_ip list is checked FIRST and short-circuits the whole botdetection chain, and OpenWebUI's bridge egress (10.89.x) is in pass_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 (slug open-webui) with the OAuth2 provider Open WebUI (confidential client, authorization_code + refresh_token, Strict redirect URI https://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 via EnvironmentFile=).
  • Roles: the scope mapping Open WebUI roles (scope roles) reads the application entitlements Open WebUI Users → user and Open WebUI Admins → admin. The entitlements are bound to the groups openwebui_users / openwebui_admins. akadmin (frank8465@protonmail.com) is in openwebui_admins; qa-e2e-admin is in openwebui_users.
  • Account merge: OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true folds 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=true lets other openwebui_users create accounts on first login.
  • First login mints role pending. ui.default_user_role=pending (config DB, verified 2026-10-09), so a freshly-provisioned openwebui_users account 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 host python3 over ssh against /home/dank/Containers/open-webui/data/webui.db (no sqlite3 CLI on Dankclaw; table is user, singular; no active column). No per-user model grant is needed: the picker model frank-zealot carries the global access_grant row (model, frank-zealot, user, *, read), so any role='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:9000 by name. It reaches the IdP over the public URL https://auth.franksdank.cloud — Caddy publishes 0.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=false hides the form and ENABLE_PASSWORD_AUTH=false refuses password logins, so the only way in is the authentik OIDC flow. The ui.enable_login_form config-DB override was flipped to false in 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: set ui.enable_login_form back to true in 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:

  1. App liveness — GET http://127.0.0.1:8080/health returns 200 ({"status":true}).
  2. Network liveness — a non-loopback interface exists in /sys/class/net. The dead-tap zombie state (History) leaves only lo behind, 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-webui service (port 3000) was never run: 3000 is Gitea's HTTP port, so it could not bind.
  • The bare-container open-webui on 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 searxng on 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 IP 192.168.0.143, which is exactly what the deployed unit does (open-webui.container:32). The older "use host.containers.internal" advice is retired for this container: it is a slirp4netns/pasta-only name and the unit has been on the pinned bridge systemd-open-webui since 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 IP 192.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 only lo in 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 missing frank-medium. Rebuilt the model table to exactly frank-dragoon/frank-medium/frank-zealot and set model_ids on each connection (llama.cpp's Ollama-shaped /v1/models is invisible to OpenWebUI's discovery). Pointed the two on-host endpoints at host.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 roles scope claim. E2E coverage in tests/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=false in the quadlet and ui.enable_login_form set to false in the config DB (the DB wins over env, so both were changed together). qa-e2e-admin was demoted out of the authentik Admins group (it keeps openwebui_users / homepage-admins for app access).

  • 2026-10-07 — picker narrowed to frank-zealot only. Per Frank: the only model accessible in OpenWebUI is frank-zealot. Rebuilt the model table to a single frank-zealot row (the old rows frank-dragoon/frank-archon/frank-small are deleted), set openai.api_configs indices 0 (frank-dragoon) and 1 (frank-archon) to enable: false, and retargeted index 2's model_ids from the stale frank-small label to frank-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/list returns exactly ["frank-zealot"], chat completion answers pong (needs max_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 the meta.description kept), access_grant (resource_type='model', resource_id, principal user/*, permission read), and openai.api_configs per-backend model_ids (index 2 = :7771 is the enabled family backend; indices 0/1 = :7770 and Dankserver :7770 stay enable: 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) then systemctl --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 selects frank-zealot and answers. A stale id fails silently — the picker simply lists nothing, because OpenWebUI intersects the backend's /v1/models with model_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 for textarea after 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