Files
serverAppConfigs/serverAppConfigs-docs/Dankclaw/OpenWebUI.md
T
Frank 1b5c11fb60 docs(openwebui): re-key the model rows at swap time; default is frank-archon
The picker's decoration rows carried two payload-swap drifts: frank-dragoon
still named halogen 0.16.2 (live 0.18.20) and frank-zealot build 10777 /
3x262,144 / MTP off (live build 12118 / 3x200,704 / draft-mtp armed). Worse,
their params.context.length values had swapped, so OpenWebUI advertised more
window than the zealot slot grants and gave up 61k tokens on dragoon. Live
rows re-keyed through the admin API and ui.default_models set to
frank-archon; the swap procedure in AGENTS.md now names the re-key step.
2026-11-04 13:06:54 -08:00

32 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.12.5 — GET http://127.0.0.1:3005/api/version → {"version":"0.12.5"} (re-measured 2026-11-04). The repo pin @sha256:0b399a8b… (dankclaw/open-webui/quadlet/open-webui.container, bumped 2026-10-30) is what makes that build reproducible; :main is a rolling tag, so re-measure /api/version instead of quoting a build from this 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. Since 2026-10-21 the picker exposes all three fleet models to every account: frank-dragoon, frank-archon, frank-zealot (per Frank: open frank-archon to everyone; the other two were already public). Proven with a role=user token: /api/models lists all three ids and a chat turn to frank-archon answers end-to-end.

Connection (openai.api_configs idx) openai.api_base_urls (live 2026-10-21) Enabled model_ids Endpoint page
0 http://host.containers.internal:7770/v1 yes frank-dragoon frank-dragoon
1 http://192.168.0.235:7770/v1 yes frank-archon frank-archon
2 http://host.containers.internal:7771/v1 yes frank-zealot frank-zealot

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. The picker row for frank-zealot was re-keyed on 2026-10-18 after that day's payload swap (see frank-zealot for what was deployed and when).

The model table carries three decoration rows — frank-zealot, frank-dragoon, frank-archon (200,000 context, added 2026-10-21). 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. params.context.length is a copy of the endpoint's per-slot window, so the authoritative number lives on the endpoint page (frank-zealot, frank-dragoon) and the row must follow it — the 2026-10-18 re-key existed because frank-dragoon's row still carried the window its payload had before that day's swap. 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; their shape, swaps and tuning live on frank-dragoon / frank-zealot / frank-archon. Disabling a connection here only hides it from this picker — the endpoint itself keeps serving every other client (the harness, Hermes, API consumers).

Default model, descriptions, and the admin API door

The default model is ui.default_models — one row of the config table, a comma-separated string, not a list. Live value frank-archon since 2026-11-04 (per Frank). The front end resolves the picker as the browser's own stored pick, else config.default_models.split(","), so the value decides what a fresh profile or a cleared-storage session lands on; after a user picks something themselves, their pick keeps winning. Verified 2026-11-04 in a clean browser: signed in with localStorage empty → the pill reads Selected model: frank-archon.

Two admin-token doors, both driven against loopback :3005 — service-to-service traffic never rides the public vhost:

  • Config: POST /api/v1/configs/import with {"config":{"ui.default_models":"frank-archon"}} — the general per-key setter (no PATCH /configs/{key} route exists in 0.12.5; verified against routers/ + main.py), and it returns the whole config so the write reads back in the same call.
  • A model row: POST /api/v1/models/model/update with a full ModelForm (id, name, meta, params, is_active). meta and params REPLACE, they do not merge: read the row first, change one field, send everything back — a partial payload silently drops meta.capabilities and the profile/background images.

An API key is not accepted everywhere. Routes guarded by get_human_user (/api/v1/auths/, utils/auth.py) answer 403 A human session is required. for any token whose typ is not session — that is not a dead key. The two mutation doors above take get_admin_user / get_verified_user, which do accept the key. Prove a key on GET /api/v1/models with no trailing slash: /api/v1/models/ falls through to the SPA and answers 200 + text/html, which looks like success and parses as nothing.

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 endpoints need a generous budget: thinking consumes max_tokens, so a short one returns empty content + finish_reason: "length" (see frank-zealot); frank-zealot 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 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 answer publicly, and none of them expose the model list. An external program needs an account + API key — there is no keyless access. Re-measured 2026-11-04 from loopback: /api/version → 0.12.5, and /api/config 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 the role from the claim, not from ui.default_user_role. The quadlet sets ENABLE_OAUTH_ROLE_MANAGEMENT=true and OAUTH_ROLES_CLAIM defaults to roles, so the roles: ["user"] claim decides the role. Measured 2026-11-04: a freshly-provisioned openwebui_users account completed SSO in a clean browser and the row appeared as role='user' — chat answered and Read Aloud synthesized, with no manual step. ui.default_user_role=pending (config DB) is the fallback for an account the claim does not describe, and 0.12.5's get_user_role refuses the login with 403 when role management is on and no roles claim is readable — so "SSO succeeded but I cannot chat" points at a missing openwebui_users group/entitlement, not at a row waiting to be flipped. Flip only a row that is actually stuck: 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). The row does not exist until the account signs in, so census it after a first-login sighting (2026-10-09: five provisioned accounts had no row). No per-user model grant is needed: the picker models carry the global access_grant row (model, <id>, user, *, read), so any role='user' account sees them.
  • 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.

Text-to-speech (frank-tts)

TTS is the fleet's one OpenAI-compatible speech endpoint. The config DB audio.tts.* is set to engine openai, audio.tts.openai.api_base_url http://192.168.0.143:7772/v1, audio.tts.voice wilwheaton (re-keyed 2026-10-20 from the retired narrator1; 7772 is a published port so host.containers.internal also reaches it, but the LAN IP is the canonical on-host form). That port is the rootless quadlet frank-tts (this repo: dankclaw/voiceservice/quadlet/frank-tts.container scripts/frank-tts.env), and its shim resolves voice=<name> against /home/dank/voices/<name>/reference.wav — a name without a reference is a 400 listing the voices that exist, never a silent fallback.

The 29-voice af_* … bm_fable list is NOT frank-tts. That enum belongs to Kokoro.js running in the browser: the deployed front end ships an in-page Kokoro implementation (frozen voice table plus _validate_voice, model onnx-community/Kokoro-82M-v1.0-ONNX, and a personal Kokoro.js dtype setting). Its engine select takes "" (Default) or browser-kokoro; with browser-kokoro the page synthesizes locally and rejects any voice outside its fixed pack, so wilwheaton fails with Voice: "wilwheaton" not found. Should be one of: af_heart, … without a single request leaving the browser. Kokoro cannot clone — fixed pack only.

The switch is a DB row, not browser storage. Personal audio settings are the settings JSON column of table user, and in 0.12.x they nest under ui: ui.audio.tts.engine, .voice, .defaultVoice. In 0.11.x the same keys sat at the top level (audio.tts.*), and the 2026-10-18 probe listed only top-level keys, found no audio key, and concluded the choice lived in that browser's own storage — a probe-depth bug. The row carried the override the whole way; the 2026-10-30 pin bump to 0.12.5 moved the keys, not the bug. The SPA reads that row at load (GET /api/v1/users/user/settings) and posts the whole blob back whenever any preference changes (POST …/settings/update, body {...settings}). So the dropdown can fix it — but a second tab still holding the old copy writes browser-kokoro straight back, which is the loop that makes the toast return after it was "fixed". The user row is the authority here; the admin config table was never the problem (its audio.tts.engine has been openai throughout). Repair settings.ui.audio.tts with the image's stdlib sqlite3 (mode=ro; quote identifiers, user and group are reserved words — there is no sqlite3 CLI in the image or on the host), then reload every tab of that account.

Repair the engine and the voice. With engine "" the front end still sends its stored voice in the request body, so a Kokoro-era voice/ defaultVoice survives the engine fix as frank-tts's own rejection — measured 2026-11-04: voice: "alloy" → 400 unknown voice 'alloy'; available voices: dr.kern, garrickhagon, jeffhays, vaati, wilwheaton. Set ui.audio.tts to {"engine": "", "voice": "wilwheaton", "defaultVoice": "wilwheaton"} and reload. Engine "" means "the admin config decides", and the handler fills voice from audio.tts.voice only when the body omits it.

Two tells settle the case in one line:

  • a session that fetched /wasm/ort-wasm-simd-threaded.jsep.wasm loaded onnxruntime, so Kokoro.js is running in that browser — the server path never asks for that file;
  • a server-path failure leaves a uvicorn request line for /api/v1/audio/speech and a frank-tts log line; a browser-kokoro failure leaves neither. uvicorn logs every request, so silence is evidence.

Verified 2026-11-04: the handler's own outbound shape ({"model":"frank-tts","input":…,"voice":"wilwheaton"} with Authorization: Bearer none, the configured audio.tts.openai.api_key) → 200 audio/wav — a body without response_format comes back WAV and either MIME is browser-playable, so OpenWebUI accepts it. The full route as a real user over the vhost, POST /api/v1/audio/speech → 200 audio/mpeg, with frank-tts logging speech voice=wilwheaton.

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, OpenWebUI shows connection errors until the endpoint is ready; each endpoint's boot budget is on its own page (frank-dragoon, frank-zealot, frank-archon).
  • Repeated from The LLM backends: the on-host endpoints are reached via the host LAN IP, never host.containers.internal.

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.

  • 2026-10-21 — picker opened to all three models, for everyone. Per Frank: frank-archon accessible to everyone. Three-leg repair on webui.db (snapshot webui.db.bak-20261021 first): connection idx 1 enable: true, a model row frank-archon (params {"context":{"length":200000}}, meta description from the endpoint card), and an access_grant row (resource_type='model', resource_id='frank-archon', principal_id='*', 'read') — principal_id='*' is the public grant, not a role row. Verified as a plain role=user token: /api/models lists all three ids, chat to frank-archon answers. Token-mint gotchas: the backend package imports only with cwd /app/backend, and WEBUI_SECRET_KEY must come from PID 1's environ (podman exec sessions do not inherit the app process env; the .webui_secret_key file happens to equal it — use it explicitly, podman exec -e).

  • 2026-11-04 — decoration rows drifted again; default model set to frank-archon. Two payload swaps had gone by without anyone touching the picker: frank-dragoon's row still advertised halogen 0.16.2, and frank-zealot's still said build 10777 / 3×262,144 / MTP off — live is 0.18.20, build 12118, 3 × 200,704, MTP armed. The prose is cosmetic; the params.context.length values had swapped, and that one bites: frank-zealot advertised 262,144 while the endpoint refuses anything past its 200,704 slot, so a long zealot chat rides the endpoint's 400 instead of compacting, and frank-dragoon advertised 200,704 against a 262,144 ceiling, giving up 61k tokens of window for nothing. Both rows re-keyed through the API door in Default model, descriptions, and the admin API door, descriptions rewritten from the env files, and ui.default_models set to frank-archon. Law: a model swap is not finished until that endpoint's model row is re-keyed as well — no deployer step touches webui.db.

See Also