Self-hosted runtimes
Point Open WebUI at TokenTriage
Every chat turn writes a priced row.
Open WebUI takes a connection, not code. Add one in Admin Settings, and every chat turn lands in a priced row.
# 1 · Boot it with the connection already set.
docker run -d -p 3000:8080 \
-e OPENAI_API_BASE_URL=http://host.docker.internal:8787/v1 \
-e OPENAI_API_KEY=lab \
-v open-webui:/app/backend/data \
ghcr.io/open-webui/open-webui:main
# 2 · Or set it in the UI, which is the surface that always wins:
# Admin Settings → Connections → OpenAI → Add Connection → URL- The setting
OPENAI_API_BASE_URL, or Admin Settings → Connections → URL- The base URL
http://127.0.0.1:8787/v1 — one /v1 base, right for every path it builds
How to tell it worked
Send one chat turn. Then read the row it wrote. Dialect openai means parsed and priced. Empty means relayed and logged, and nothing else.
# names the empties, because on this page an empty field IS the finding
jq -r '[.http.path,
(.http.dialect | if . == "" then "(none)" else . end),
(.cost.pricing_key | if . == "" then "(none)" else . end)] | @tsv' \
~/.tokentriage/decisions.jsonl | tail -3What you should see
/v1/models (none) (none) · /v1/chat/completions openai openai/gpt-4o-mini
How sure we are
Reproduced
A person ran it by hand against a real gateway on a stated date, and the transcript is on the page.
Verified against Open WebUI v0.11.0 on
=== 4 . QUESTION B - what the model picker actually shows [LAB RUN · MOCK UPSTREAM] ===
$ GET /api/models # the picker
picker entries: 0 <- HTTP 200, no error shown to the user
ledger says the call really was made:
{"path": "/v1/models", "dialect": "openai", "stream": false, "status": 404, "model": "<EMPTY = never parsed>", "in": 0, "out": 0, "usage_src": "absent", "usd": 0, "key": "<EMPTY = no pricing key>", "snapshot": "litellm@2026-07-14#179affb"}
$ POST /openai/config/update # Admin Settings -> Connections -> Model IDs
picker entries: 2
(no ledger rows)
^ zero gateway rows: with model_ids set the list never leaves Open WebUI.
=== 5 . QUESTION C - a chat, costed [LAB RUN · MOCK UPSTREAM] ===
$ POST /api/chat/completions stream=false
<- status 200
$ POST /api/chat/completions stream=true
SSE frames delivered to Open WebUI : 9
frames mentioning "usage" : 0
last frame : data: [DONE]
ledger:
{"path": "/v1/chat/completions", "dialect": "openai", "stream": false, "status": 200, "model": "gpt-4o-mini", "in": 100, "out": 20, "usage_src": "provider", "usd": 2.7e-05, "key": "openai/gpt-4o-mini", "snapshot": "litellm@2026-07-14#179affb"}
{"path": "/v1/chat/completions", "dialect": "openai", "stream": true, "status": 200, "model": "gpt-4o-mini", "in": 100, "out": 20, "usage_src": "provider", "usd": 2.7e-05, "key": "openai/gpt-4o-mini", "snapshot": "litellm@2026-07-14#179affb"}The gotcha
The model picker is empty, and nothing says why
Open WebUI fills the picker from GET /v1/models. TokenTriage forwards that call and synthesizes no list, so the picker is only as good as your upstream’s. You get zero entries, HTTP 200, no error. Name the models by hand instead.
# Admin Settings → Connections → OpenAI → Model IDs (Filter).
# Or the route that form posts to. PUT /openai/config is gone.
curl -X POST http://localhost:3000/openai/config/update \
-H "Authorization: Bearer $OWUI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"ENABLE_OPENAI_API": true,
"OPENAI_API_BASE_URLS": ["http://host.docker.internal:8787/v1"],
"OPENAI_API_KEYS": ["lab"],
"OPENAI_API_CONFIGS": {"0": {"enable": true, "model_ids": ["gpt-4o-mini"]}}}'“The connection verification will fail with an error … This does not mean the provider is incompatible: chat completions will still work.”
docs.openwebui.com/getting-started/quick-start/connect-a-provider/starting-with-openai-compatible · read 2026-08-25
What you get now
What the ledger holds once chats flow.
Three things that start the moment a turn arrives. Each links the page that evidences it. Read those before you take this one on trust.
Every turn priced, streamed or not
One row per turn. It carries the model, the token counts, a price from a dated snapshot, and the snapshot’s own id. Streamed turns carry the provider’s counts, not an estimate.
What lands in the ledgerOne key, one budget, one instance
A connection carries one key, so a budget on that key caps the whole instance. Mint it here; the gateway keeps a hash, never the secret. Rate limits attach to the same node.
How governance worksNothing routes until you say so
Nothing routes on day one. The gateway starts in ledger mode, so what Open WebUI renders is the upstream answer, streaming included. Promote to shadow, then to route, when the ledger has shown you something.
Ledger, shadow, route
- 0 lines of code
- an environment variable, or a form in Admin Settings
- 1 base URL
- ending in /v1, and right for every path Open WebUI builds
- 2 connection families
- the OpenAI one is parsed and priced, the Ollama one is not
- 3 gateway paths it calls
- chat completions, the model list, and a token pre-count
What it will not do
Six things to know before you trust a number.
Five are traps you would otherwise meet at runtime. The sixth is about our evidence. Read all six first.
The Ollama connection is the wrong one
TokenTriage speaks no Ollama-native inbound dialect, so Open WebUI’s Ollama connection lands on the passthrough: relayed, logged, and not parsed, priced, routed or cached. The tell is http.dialect, empty on the row. Use the OpenAI connection.
Which dialects are served inboundOne Anthropic-shaped turn is two calls, one priced
Open WebUI pre-counts tokens against a /messages/count_tokens path. TokenTriage registers no such path, so it is forwarded to your upstream as written. The row it writes is uncosted. One turn, two calls, one price.
Which paths are registeredEdit the compose file, restart, nothing changes
OPENAI_API_BASE_URL is a ConfigVar. After first launch the stored value wins, and the environment stops taking effect. ENABLE_PERSISTENT_CONFIG=False hands your compose file back, and wipes OPENAI_API_CONFIGS with the Model IDs inside it.
host.docker.internal resolves to IPv6 first
Inside the container that name maps to an IPv4 and an IPv6 address, and the IPv6 one is returned first. A gateway bound only to 0.0.0.0 is unreachable. The symptom is ServerDisconnectedError, not connection refused.
The two listeners and what binds themYour upstream sees a body Open WebUI did not send
TokenTriage splices stream_options.include_usage into the outbound body. It then removes the resulting usage frame from your copy. Open WebUI asked for neither. The provider saw a request Open WebUI did not write.
What touches a streamThe run drove the HTTP surface, not a browser
Open WebUI is a server, so we drove the routes its shipped frontend bundle calls. Nobody clicked anything. There is no screenshot on this page, and nothing here is one.
Set the URL, then read the row.
Add the connection. Then read the row it wrote, because the picker will not tell you what the gateway saw. One turn is enough to find out.
Also: What a row recordsEvery field on the rowEvery integration
Names and trademarks
These are independent integrations. No product named here has endorsed, reviewed or sponsored TokenTriage; every name is its owner's trademark, used only to say what works with what.