2026-06-22 OWUI Per-Message Cost Tracking
What I set out to do
Get an info toggle in Open WebUI showing per-message token usage and cost in chat. Started as a simple “where’s the ⓘ icon” question; turned into a deep debugging arc through OWUI internals, LiteLLM, and SigNoz.
What I actually did
- Found why no ⓘ icon: OWUI only renders it when the message has a
usageobject, and the frontend only requests usage (stream_options.include_usage) when the model advertisescapabilities.usage(default off). OWUI also discards LiteLLM’sx-litellm-response-costheader — so cost has to be captured ourselves. - Shipped a global Filter (seeded declaratively into OWUI’s
functiontable via sqliteSeed, hex-embedded to dodge SQL/Nix quoting):inletforcesinclude_usagefor every model;outletemits a status line with tokens + LiteLLM cost + round-trip count. - Fixed tool-call undercounting: OWUI persists only the final LLM call’s usage per message. The filter’s
streammethod (runs once per round-trip insidestream_body_handler) now accumulates the per-turn total and rewrites each usage chunk, so OWUI stores the full total — fixing the chat line, native popup, and analytics. - Folded image-generation cost, then image-edit cost — via a container entrypoint shim (
patch-and-start.sh+owui_image_cost_patch.py) that applies marker-guarded, drift-safe string patches on every boot (image gen has no chat-filter hook). - Enabled image editing (
ENABLE_IMAGE_EDIT+IMAGE_EDIT_*config) and verified end-to-end.
What was striking
- “Telemetry miss” was the key insight that wasn’t. The cost was in SigNoz all along — as the
gen_ai.client.token.costmetric, just not a span attribute (metrics flow on a separate path by design). That breakdown revealed the image is ~95% of turn cost (0.003 chat), and that my “image fold works” claim was wrong — the displayed cost was chat-only. - The real bugs only surfaced by driving the actual UI (Playwright — the API needs a token even with
WEBUI_AUTH=False, and forging a JWT from the secret was correctly blocked by the classifier): (1) images are generated via thegenerate_imagebuiltin tool (native function-calling), not thefeatures.image_generationhandler I’d patched; (2) the per-turn__metadata__dict differs between payload and response phases, so cost folded there never reached the outlet → moved the accumulator torequest.state(one object shared across tool, stream, outlet); (3)image_generationsdoesmetadata = metadata or {}, silently discarding a falsy empty dict — had to pass a truthy one. - Verified live:
14,937 in / 152 out | $0.069253 | 3 callson an edit turn.
Lessons worth keeping
- Verify against the live system before claiming success — I asserted the image fold worked twice before it actually did. The SigNoz metric cross-check and UI-driven e2e were what finally proved it.
- Cost is not in the OTel GenAI semconv (only
gen_ai.usage.input/output_tokens); LiteLLMcostis a proprietary extension, surfaced only on the metric / response header.
Top 3 next
- Watch upstream open-webui#25617 / PR #25659 — when a pre-summed per-turn usage lands, remove our stream-accumulation and image patches (the outlet already handles a usage list for forward-compat).
- Non-streaming turns still won’t capture image cost (the stream filter never runs) — acceptable since chats stream by default, but note it if non-stream ever matters.
- After OWUI image updates, check the entrypoint patcher log for any
SKIP (anchor not found)— drift means a fold silently no-ops.
Related
Open WebUI · LiteLLM · SigNoz