# Open WebUI DB-Level Config Management

Open WebUI stores OpenAI API provider config in its SQLite `config` table (`/app/backend/data/webui.db`), **not just env vars**. The env vars (`OPENAI_API_BASE_URL`, `HERMES_API_URL`, `TERMINAL_SERVER_CONNECTIONS`) only apply on **first container start** — after that, the DB caches everything and env var changes have no effect.

This means:
- Recreating a container with different env vars → old config persists from the volume
- Removing an integration (like Hermes Agent) requires cleaning BOTH env vars AND the DB
- Adding/removing models from a provider often requires editing the DB or using the API

## Config Structure

The `config` table has a single row with a `data` JSON column. Key path: `$.openai`.

```json
{
  "openai": {
    "enable": true,
    "api_base_urls": [
      "http://127.0.0.1:9292/v1"
    ],
    "api_keys": [
      "sk-no-key-required"
    ],
    "api_configs": {
      "0": {
        "name": "llama-swap",
        "model_ids": ["bonsai-8b", "gemma-12b"],
        "api": "openai",
        "keys": []
      }
    }
  }
}
```

Each entry in `api_configs`:
- **`name`** — display name in the Admin Panel
- **`model_ids`** — list of model IDs to show. `[]` usually disables auto-discovery; you need to list them explicitly.
- **`api`** — always `"openai"`
- **`keys`** — per-provider key overrides (usually empty when using shared `api_keys`)
- The key name (`"0"`, `"1"`, etc.) is the index into `api_base_urls` and `api_keys` arrays

## Common Operations

### List all configured providers and their models

```python
import sqlite3, json

conn = sqlite3.connect("/app/backend/data/webui.db")
c = conn.cursor()
c.execute("SELECT data FROM config")
config = json.loads(c.fetchone()[0])
openai_cfg = config.get("openai", {})
urls = openai_cfg.get("api_base_urls", [])
cfgs = openai_cfg.get("api_configs", {})
for idx, url in enumerate(urls):
    entry = cfgs.get(str(idx), {"name": f"index-{idx}", "model_ids": []})
    print(f"[{idx}] {entry['name']}  → {url}")
    print(f"      models: {entry['model_ids']}")
conn.close()
```

### Remove a provider (e.g. Hermes Agent)

```python
import sqlite3, json

conn = sqlite3.connect("/app/backend/data/webui.db")
c = conn.cursor()
c.execute("SELECT id, data FROM config")
row_id, data_str = c.fetchone()
config = json.loads(data_str)

# Remove URL and key at index 1 (e.g. Hermes provider)
urls = config["openai"]["api_base_urls"]
keys = config["openai"]["api_keys"]
configs = config["openai"]["api_configs"]

# Find and remove Hermes entries
hermes_idx = [i for i, u in enumerate(urls) if "18789" in u]
for idx in reversed(hermes_idx):
    urls.pop(idx)
    keys.pop(idx)
    if str(idx) in configs:
        del configs[str(idx)]

# Renumber api_configs keys to match new indices
# (they should match array positions)
new_configs = {}
for i, u in enumerate(urls):
    orig = configs.get(str(i), {})
    new_configs[str(i)] = orig
config["openai"]["api_configs"] = new_configs

# Save
c.execute("UPDATE config SET data = ?, updated_at = datetime('now') WHERE id = ?",
          (json.dumps(config), row_id))
conn.commit()
conn.close()
```

### Set models for a provider

```python
import sqlite3, json

conn = sqlite3.connect("/app/backend/data/webui.db")
c = conn.cursor()
c.execute("SELECT id, data FROM config")
row_id, data_str = c.fetchone()
config = json.loads(data_str)

# Set model_ids for provider index 0
all_models = ["qwen36-35b-mtp", "gemma-26b-200k", "nemotron-term-14b",
              "glm-4.7-flash", "gpt-oss-20b"]
config["openai"]["api_configs"]["0"]["model_ids"] = all_models

c.execute("UPDATE config SET data = ?, updated_at = datetime('now') WHERE id = ?",
          (json.dumps(config), row_id))
conn.commit()
conn.close()
```

### Fix host.docker.internal → 127.0.0.1 (for host networking)

```python
import sqlite3, json

conn = sqlite3.connect("/app/backend/data/webui.db")
c = conn.cursor()
c.execute("SELECT id, data FROM config")
row_id, data_str = c.fetchone()
config = json.loads(data_str)

urls = config.get("openai", {}).get("api_base_urls", [])
config["openai"]["api_base_urls"] = [
    u.replace("host.docker.internal", "127.0.0.1") for u in urls
]

c.execute("UPDATE config SET data = ?, updated_at = datetime('now') WHERE id = ?",
          (json.dumps(config), row_id))
conn.commit()
conn.close()
```

## Pitfalls

- **`host.docker.internal` doesn't resolve with `network_mode: host`** — always use `127.0.0.1` directly when the container shares the host network stack
- **API config survives container recreate** — `docker stop + rm + run` with new env vars does NOT overwrite the DB config. You must clean the DB separately, or delete the volume entirely
- **`model_ids: []` does NOT auto-discover** — it means show nothing. You MUST explicitly list every model ID in the array. The SKILL.md claim that `[]` enables auto-discovery is only true when the provider was configured through the Admin UI (which does a separate discovery step). For raw DB configs, empty `model_ids` = no models visible.
- **DB edits take effect without container restart** on recent Open WebUI versions — but `docker restart` is safer to force cache invalidation
- **api_configs keys are string integers** matching array indices in `api_base_urls`. If you remove an entry from the middle, the indices shift and the remaining configs need renumbering
- **Multiple model entries in DB for same provider** — when providers are added/removed, old model entries can linger in the `model` table (the one with `id`, `name`, `base_model_id`, `meta`, etc.). After removing a provider, also delete orphaned rows: `DELETE FROM model WHERE name LIKE '%hermes%'`. The `model` table is separate from the `config` table's `api_configs.model_ids` list.
- **After removing env vars but NOT the DB** → old config persists. When removing Hermes (or any OpenAI provider), clean BOTH: container env vars AND the `config` table's `$.openai` path. See "Remove a provider" above for the DB portion.
- **`WEB_SEARCH_TRUST_ENV=true` is essential** — without it, admin panel settings (which may have empty/incorrect values) override env vars at runtime. This applies to `SEARXNG_QUERY_URL`, `BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL`, and other web search configuration. Check via `/api/v1/retrieval/config` at runtime to see if env vars are actually in effect.
- **After removing a provider from the config, also clean the `model` table** — orphaned model entries (e.g. `hermes-agent`) linger in the `model` table after a provider is removed. They don't break anything but show stale entries in the API. Run `DELETE FROM model WHERE name LIKE '%hermes%'` after removal.
- **`CORS_ALLOW_ORIGIN=*` required for LAN/Tailscale access to streaming** — Socket.IO's WebSocket handshake rejects connections from origins not in the allowlist. Without this, streaming responses arrive (HTTP 200, valid SSE) but the Svelte store never updates because the `events` (content updates) never reach the frontend via WebSocket. The user must refresh to see output. This is the single most common cause of "streaming not working" on self-hosted instances.

## Known Issues

### Streaming responses received but not rendered in UI

**Most common root cause:** `CORS_ALLOW_ORIGIN` is too restrictive. Socket.IO's WebSocket handshake rejects connections from LAN/Tailscale origins. The HTTP streaming response arrives (200, valid SSE chunks) but the `events` (chat content updates) never reach the frontend via WebSocket.

**Fix:** Set `CORS_ALLOW_ORIGIN=*` in container env vars. Verify: `docker logs open-webui 2>&1 | grep -i 'accepted origin'` should show no rejection messages.

**Other possible causes (if CORS is already `*`):**

This Open WebUI build (`02dc3e6`, mid-2026) has a frontend bug where streaming SSE responses arrive (HTTP 200, valid `text/event-stream` chunks, no JS errors) but the Svelte store never updates — the assistant bubble appears with just the model label and empty content. Non-streaming (POST with `"stream": false`) works perfectly.

**Symptoms:**
- Message sent → user message appears in chat log
- Assistant bubble appears with model name label but empty content
- No JS errors in browser console
- Chat is NOT saved to DB (only user message persists)
- Reloading the page shows the user message only (response lost)
- A hard refresh (`Ctrl+Shift+R`) may temporarily fix it (cache bust)

**Debugging approach:**
1. Check the browser console for the `/api/chat/completions` response — inspect status and body via `performance.getEntriesByType('resource')`
2. Intercept the request body: `window.fetch` override to log `opts.body` (note: body is a JSON string, not a ReadableStream)
3. Check the DB: `docker exec open-webui python3 -c "..."` to see if the assistant response was saved
4. Check backend logs: `docker logs open-webui --tail 100 2>&1 | grep -E 'process_chat|chat/completions|Error'`
5. `docker exec open-webui env | grep WEBUI_BUILD_VERSION` — note the build hash

**Workarounds (if non-streaming works):**
- Set `"stream": false` per-model in Admin Panel → Models → model params
- Or patch the frontend to disable streaming (not recommended)
- Or update Open WebUI to a newer build

**Does the streaming issue affect all models?** Test by comparing two models from the same provider. If one works and the other doesn't, the issue is model-specific (e.g., bad chat template). If neither works, it's a frontend or proxy issue.
