Files
serverAppConfigs/serverAppConfigs-docs/Dankclaw/OpenWebUI.md
T
Frank 64b06b9426 docs(openwebui): the browser-kokoro switch is the user's settings row, not browser storage
`wilwheaton` kept failing in OpenWebUI with the 29-name Kokoro enum even
after the dropdown was set to Default. The page's 2026-10-18 conclusion —
"no user.settings row carries an audio override, so the choice lives in that
browser's own storage" — was a probe-depth bug: the personal audio settings
live in table `user`, and the 0.12.x front end nests them under `ui`
(`settings.ui.audio.tts.engine`). The row did carry `browser-kokoro`, and the
SPA re-applies the server copy on every load, so the UI fix never stuck.

Corrected facts on the owner page:
- where the override really lives, and the version-dependent nesting;
- repair BOTH engine and voice: engine "" still sends the stored voice, so a
  Kokoro-era `voice: "alloy"` returns as frank-tts's own 400;
- the one-line tells: an `/wasm/ort-wasm-simd-threaded.jsep.wasm` fetch means
  Kokoro.js is live in that browser, and uvicorn logs every request so a
  missing `/api/v1/audio/speech` line is evidence;
- deployed build is 0.12.5 (pin `0b399a8b`, bumped 2026-10-30) — the page
  still claimed 0.11.4 — and the 401 paragraph's duplicated sentence is gone.

Verified 2026-11-04: with the row repaired, `POST /api/v1/audio/speech` as a
real user over the vhost returns 200 audio/mpeg and frank-tts logs
`speech voice=wilwheaton`.
2026-11-04 12:09:57 -08:00

28 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).

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 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.

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).

See Also