""" title: Live Progress Status author: owuitools version: 0.2.0 description: Shows what the model is doing right now - processing the prompt, thinking, and each tool call as it is written. """ # WHY THIS EXISTS # # Until the first token arrives, Open WebUI shows nothing at all. On this # deployment the prompt is ~9.4k tokens of system prompt plus 20 tool # schemas, and a forced re-prefill takes many seconds, so "nothing at all" # is the most common thing on screen. Worse, the model spends ~70% of its # generated characters thinking, and tool calls are written token by token # into a block that is collapsed by default - so even once it IS producing # output, there is no visible signal of which phase it is in. # # This filter emits Open WebUI status events on phase CHANGES only: # # Processing prompt <- inlet, before the model is called # Thinking <- first reasoning delta # Thought: <- when a reasoning block ends # Calling web_search <- a tool call is being written # Writing response <- first content delta # # Together those read as a narrative of the whole turn, which is what makes # the collapsed tool blocks acceptable: the chain of thought and the tool # sequence stay visible without any tool input or output on screen. # # WHERE THE NUMBERS COME FROM # # The status line used to claim "prompt processed in N s" for a figure that # was really inlet-to-first-token wall clock: Open WebUI middleware, queue # wait and model load included. Graded runs put it 2-3x either side of # Ollama's own prefill for the same turn, so the one number on screen was # the one number that was wrong; 35 of 40 rubrics flagged it. # # Ollama's real figures are available, and this filter now reads them # instead of guessing. Open WebUI's Ollama->OpenAI conversion builds a # usage object on the final chunk of each round: # # utils/response.py convert_ollama_usage_to_openai() # 'prompt_eval_count', 'prompt_eval_duration', (nanoseconds) # 'eval_count', 'eval_duration', 'total_duration', 'load_duration' # utils/misc.py openai_chat_chunk_message_template() # template['usage'] = usage <- TOP LEVEL, beside 'choices' # # and stream filters are handed that chunk verbatim, before any # normalisation (middleware.py, filter_type='stream'). The same numbers # reach outlet on the stored message's 'usage', so outlet can fall back to # it - with one caveat that is worth knowing when the two disagree: # merge_usage() sums only input_tokens/output_tokens and keeps the LATEST # value of every provider-specific key, so a fallback prefill is the last # round's warm re-prefill, while the stream path deliberately keeps the # FIRST one, which is the cold prefill the user actually waited through. # # WHY THIS CANNOT AFFECT THE MODEL'S OUTPUT # # Status events travel over the websocket and are stored on a SIBLING key # of the message ('statusHistory'), never in 'content'. The payload sent to # the model is rebuilt from scratch by an explicit allowlist: # # utils/payload.py convert_messages_openai_to_ollama() # new_message = {'role': message['role']} # # then only: thinking, content, tool_calls, tool_call_id, images # # 'statusHistory' is not in that list, and the backend never references it # outside persistence. The token sequence is therefore byte-identical with # this filter on or off - no prompt change, no prefix-cache perturbation. # The one thing that does reach the chat record is the empty-answer note # below, and only on a turn that produced no answer to disturb. # # TWO SHARP EDGES IN THE STREAM HOOK, both load-bearing: # # 1. The return value REPLACES the chunk, and a FALSY return DROPS it: # data = wrap_item(JSONCodec.dumps(event)) if event else None # So stream() must return `event` on every single path. Returning None # would silently delete the model's output. # 2. Exceptions from a stream filter are re-raised by the caller # (utils/filter.py logs then `raise`), which would break the response # mid-flight. So the whole body is wrapped and always falls through to # `return event`. # # The module object is cached across requests (load_from_db=False for # stream), so per-request state must be keyed by message id rather than # held on self - six users share this instance. import time from pydantic import BaseModel, Field # Per-message progress state, keyed by message id. Bounded below so a # request whose outlet never runs (client disconnect, cancelled generation) # cannot leak entries forever. _STATE: dict[str, dict] = {} _MAX_TRACKED = 64 # Shown in the message itself when the turn ended with no answer text. One # graded run spent 140 s and 8,201 tokens repeating a single sentence inside # its reasoning block, emitted nothing, and the indicator said "Done" - so # the failure looked exactly like a success that had scrolled off screen. # 0.11.3's filter API is inlet/stream/request/outlet only, with no hook that # re-enters the chat pipeline, so telling the user to regenerate is the most # this filter can do. _EMPTY_NOTE = ( "_No answer was produced: the model ended its turn while still reasoning. " "Regenerate to try again._" ) def _new_state() -> dict: """The per-message progress record. One factory, used by both creation sites - they were duplicated literals once and silently drifted.""" return { "started": time.monotonic(), "phase": None, "announced": set(), "round": 0, "prompt_reported": False, "tools": 0, "reasoning": [], # Ollama's own figures, filled in from the usage chunk. prefill is # first-wins (the cold one); generated tokens accumulate over every # round of a tool-calling turn. "prefill_s": None, "prompt_tokens": None, "gen_tokens": 0, } def _prune() -> None: while len(_STATE) > _MAX_TRACKED: # dicts are insertion-ordered, so this drops the oldest. _STATE.pop(next(iter(_STATE)), None) def _number(value) -> float | None: """Usage values arrive as whatever the provider sent. Anything that is not a real number (None, 'N/A', a string) is treated as absent.""" if isinstance(value, bool) or not isinstance(value, (int, float)): return None return float(value) class Filter: class Valves(BaseModel): enabled: bool = Field( default=True, description="Turn the whole filter off without deleting it.", ) show_prompt_processing: bool = Field( default=True, description="Show 'Processing prompt' before the first token. This is the phase " "that currently shows nothing at all, and on a cold prompt it is the longest.", ) show_thinking: bool = Field( default=True, description="Show 'Thinking' once the model starts producing reasoning tokens.", ) show_tool_calls: bool = Field( default=True, description="Show 'Calling ' as each tool call is written. Tool calls are " "still counted for the final line when this is off.", ) report_prompt_time: bool = Field( default=True, description="Append timing to the status line: how long the first token took to " "arrive, and - on the final line, from Ollama's own counters - how long the " "prefill took and how many tokens went in and came out.", ) thought_excerpt_chars: int = Field( default=160, description="When a block of reasoning ends, post an excerpt of it as a status so " "the timeline reads as a narrative of what the model was doing. 0 disables it. " "The renderer clamps a status to one line, so values much above ~180 are wasted.", ) clear_when_done: bool = Field( default=False, description="Hide the status line entirely once the response finishes. Off by " "default so the finished message keeps a short record of what it did, which " "matches how Open WebUI's own built-in tools behave.", ) def __init__(self): self.valves = self.Valves() # ---------------------------------------------------------------- utils async def _emit(self, emitter, description: str, *, done: bool = False, hidden: bool = False) -> None: if emitter is None: return try: await emitter( { "type": "status", "data": {"description": description, "done": done, "hidden": hidden}, } ) except Exception: # A status line is a nicety; never let it disturb the response. pass def _message_id(self, metadata) -> str | None: if isinstance(metadata, dict): return metadata.get("message_id") or metadata.get("chat_id") return None def _state(self, message_id: str | None) -> dict | None: if not message_id: return None state = _STATE.get(message_id) if state is None: # stream() can legitimately run without inlet having recorded # anything, so build a usable state rather than giving up. state = _new_state() _STATE[message_id] = state _prune() return state def _record_usage(self, state: dict, usage: dict) -> None: """Take Ollama's own prefill and token counts off a usage chunk. Prefill is first-wins because a tool-calling turn prefills once per round and only the first one is the wait the user sat through; every later round is served warm off the prefix cache. Generated tokens accumulate, because all of them were generated on this turn. """ if state["prefill_s"] is None: duration = _number(usage.get("prompt_eval_duration")) if duration and duration > 0: state["prefill_s"] = duration / 1e9 if state["prompt_tokens"] is None: count = _number(usage.get("prompt_eval_count")) if count and count > 0: state["prompt_tokens"] = int(count) generated = _number(usage.get("eval_count")) if generated and generated > 0: state["gen_tokens"] += int(generated) def _final_usage(self, state: dict | None, message: dict | None) -> tuple: """(prefill_s, prompt_tokens, gen_tokens) for the final line. The stream numbers are preferred; the stored message's usage is the fallback for the paths where stream() never saw a usage chunk - a non-streaming response, or a filter loaded after the stream began. """ prefill_s = state["prefill_s"] if state else None prompt_tokens = state["prompt_tokens"] if state else None gen_tokens = state["gen_tokens"] if state else 0 usage = message.get("usage") if isinstance(message, dict) else None if isinstance(usage, dict): if prefill_s is None: duration = _number(usage.get("prompt_eval_duration")) if duration and duration > 0: prefill_s = duration / 1e9 if prompt_tokens is None: count = _number(usage.get("prompt_eval_count")) or _number(usage.get("prompt_tokens")) if count and count > 0: prompt_tokens = int(count) if not gen_tokens: # output_tokens is the one Open WebUI sums across rounds; # the Ollama-native keys hold the last round only. generated = ( _number(usage.get("output_tokens")) or _number(usage.get("completion_tokens")) or _number(usage.get("eval_count")) ) if generated and generated > 0: gen_tokens = int(generated) return prefill_s, prompt_tokens, gen_tokens def _usage_clause(self, prefill_s, prompt_tokens, gen_tokens) -> str: """The measured half of the final line. Empty when nothing was measured - an absent clause is honest, an invented one is not.""" if not self.valves.report_prompt_time: return "" parts = [] if prefill_s is not None: if prompt_tokens: parts.append(f"prefill {prefill_s:.1f} s for {prompt_tokens:,} tokens") else: parts.append(f"prefill {prefill_s:.1f} s") if gen_tokens: parts.append(f"{gen_tokens:,} generated") return "".join(f" · {part}" for part in parts) async def _flush_thought(self, state: dict, emitter) -> None: """Post an excerpt of the reasoning block that just ended, then drop it. This is what makes the collapsed view usable: the tool payloads stay shut, but the status timeline still reads as a narrative of what the model was thinking between calls. The buffer is cleared either way, so a disabled excerpt cannot accumulate text for the life of the request. The excerpt shows both ends of the block, not just the opening. Head truncation once surfaced "…I should verify with actual execution since I have tools. Actually, I" - cutting the sentence exactly where the model reversed itself, so a user watching live would have believed it was about to run code it had in fact just decided not to run. The conclusion of a thought lives at its end. """ buffered = state["reasoning"] state["reasoning"] = [] limit = self.valves.thought_excerpt_chars if limit <= 0 or not buffered: return # Collapse to a single line - the renderer clamps to one line anyway, # and newlines in a status read as ragged whitespace. text = " ".join("".join(buffered).split()) if not text: return if len(text) > limit: head_budget = max(1, int(limit * 0.55)) tail_budget = max(1, int(limit * 0.40)) # Prefer breaking at a word boundary near each edge. cut = text.rfind(" ", int(head_budget * 0.6), head_budget) head = text[: cut if cut != -1 else head_budget].rstrip() start = len(text) - tail_budget space = text.find(" ", start) if space != -1 and space + 1 < len(text): start = space + 1 text = f"{head} … {text[start:].lstrip()}" await self._emit(emitter, f"Thought: {text}", done=True) def _suffix(self, state: dict) -> str: """Report, once, how long the first token took to arrive. Deliberately not called prefill: this is inlet-to-first-token wall clock and includes Open WebUI's own work, the queue and any model load. The real prefill is not known until the usage chunk at the end of the round, and it goes on the final line instead. """ if state["prompt_reported"] or not self.valves.report_prompt_time: return "" state["prompt_reported"] = True elapsed = time.monotonic() - state["started"] if elapsed < 0.5: return "" return f" · first token after {elapsed:.1f} s" def _mark_empty(self, message: dict) -> None: """Leave a visible note on a turn that produced no answer text. Both places, as inline_tool_images.py does it for its images: Open WebUI renders `output` when it is present and falls back to `content` only when it is not (misc.py get_output_text), so writing content alone stores the note and shows nothing. """ content = message.get("content") or "" message["content"] = (content.rstrip() + "\n\n" + _EMPTY_NOTE).lstrip() output_items = message.get("output") if not isinstance(output_items, list): return message_items = [ item for item in output_items if isinstance(item, dict) and item.get("type") == "message" ] if message_items: last = message_items[-1] parts = last.get("content") if not isinstance(parts, list): parts = [] texts = [p for p in parts if isinstance(p, dict) and p.get("type") == "output_text"] if texts: texts[-1]["text"] = (texts[-1].get("text") or "").rstrip() + "\n\n" + _EMPTY_NOTE else: parts.append({"type": "output_text", "text": _EMPTY_NOTE}) last["content"] = parts else: # An output list of nothing but reasoning and tool traffic renders # as an empty message, and the note in `content` would never be # reached. Give it the one item the renderer reads. output_items.append( { "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": _EMPTY_NOTE}], } ) # ---------------------------------------------------------------- hooks async def inlet(self, body: dict, __event_emitter__=None, __metadata__=None) -> dict: """Mark the start of prompt processing. `body` is returned untouched.""" if not self.valves.enabled: return body try: message_id = self._message_id(__metadata__) continued = False if message_id: state = _STATE.get(message_id) # inlet runs a SECOND time for the same assistant message when # the turn contains an interactive tool call - the native # ask_user widget re-enters the pipeline. Wiping the state # there zeroed the tool counter mid-turn, and a turn with # three real calls reported "Done · 2 tool calls". Keep what # has already been counted; reset only the per-round fields. if state and (state["tools"] or state["phase"]): continued = True state["round"] += 1 state["phase"] = None state["started"] = time.monotonic() state["prompt_reported"] = False else: _STATE[message_id] = _new_state() _prune() if self.valves.show_prompt_processing: await self._emit( __event_emitter__, "Processing prompt (continued)" if continued else "Processing prompt", done=False, ) except Exception: pass # Never modified. This filter must not touch the prompt. return body async def stream(self, event: dict, __event_emitter__=None, __metadata__=None) -> dict: """Announce phase changes. MUST return `event` on every path.""" if not self.valves.enabled: return event try: if not isinstance(event, dict): return event state = self._state(self._message_id(__metadata__)) if state is None: return event # Read before the `choices` checks below: the usage chunk carries # usage at the top level and an empty delta, and some providers # send it with no choices at all. usage = event.get("usage") if isinstance(usage, dict): self._record_usage(state, usage) choices = event.get("choices") if not isinstance(choices, list) or not choices: return event first = choices[0] if not isinstance(first, dict): return event delta = first.get("delta") if not isinstance(delta, dict): return event tool_calls = delta.get("tool_calls") if tool_calls: for call in tool_calls if isinstance(tool_calls, list) else []: if not isinstance(call, dict): continue function = call.get("function") name = function.get("name") if isinstance(function, dict) else None if not name: continue # A tool id is the reliable key; index plus round is the # fallback, so the same tool called twice still counts twice. key = call.get("id") or f"{state['round']}:{call.get('index', 0)}" if key in state["announced"]: continue state["announced"].add(key) # Counting, de-duplication and the phase change all sit # OUTSIDE the display valve: they used to be inside it, so # turning the announcements off silently zeroed the final # tool count as well, and stalled the round counter that # the de-duplication key depends on. Only the announcement # itself is optional. state["tools"] += 1 # The reasoning that led to this call belongs before it. await self._flush_thought(state, __event_emitter__) state["phase"] = "tool" if self.valves.show_tool_calls: await self._emit( __event_emitter__, f"Calling {name}{self._suffix(state)}", done=False, ) return event reasoning = delta.get("reasoning_content") if reasoning: if isinstance(reasoning, str) and self.valves.thought_excerpt_chars > 0: # Only keep what an excerpt could possibly need, so a long # think cannot grow this without bound. if sum(len(p) for p in state["reasoning"]) < 4096: state["reasoning"].append(reasoning) if self.valves.show_thinking and state["phase"] != "thinking": if state["phase"] == "tool": state["round"] += 1 state["phase"] = "thinking" await self._emit( __event_emitter__, f"Thinking{self._suffix(state)}", done=False ) elif delta.get("content"): if state["phase"] != "writing": if state["phase"] == "tool": state["round"] += 1 await self._flush_thought(state, __event_emitter__) state["phase"] = "writing" await self._emit( __event_emitter__, f"Writing response{self._suffix(state)}", done=False, ) except Exception: pass return event async def outlet(self, body: dict, __event_emitter__=None, __metadata__=None) -> dict: """Settle the status line and drop the per-message state.""" if not self.valves.enabled: return body try: message_id = self._message_id(__metadata__) state = _STATE.pop(message_id, None) if message_id else None # A response that ended while still reasoning still has something # worth showing. if state and not self.valves.clear_when_done: await self._flush_thought(state, __event_emitter__) messages = body.get("messages") message = None if isinstance(messages, list) and messages and isinstance(messages[-1], dict): message = messages[-1] prefill_s, prompt_tokens, gen_tokens = self._final_usage(state, message) # Open WebUI builds outlet's `content` as content OR the rendered # text of `output`, so blank here means the turn really produced # no answer - not that the answer lives somewhere else. empty = ( message is not None and message.get("role", "assistant") == "assistant" and not (message.get("content") or "").strip() ) if empty: self._mark_empty(message) if self.valves.clear_when_done and not empty: # StatusHistory hides the whole block when the LATEST status # is hidden, so this clears the line entirely. await self._emit(__event_emitter__, "", done=True, hidden=True) else: if empty: # Never hidden, even with clear_when_done: a turn that # produced nothing is the one turn whose status matters. # The token count is dropped rather than printed as 0 when # no usage arrived - "(0 tokens)" would be a measurement # nobody took. spent = f" ({gen_tokens:,} tokens)" if gen_tokens else "" summary = ( "Done · no answer produced — the model stopped inside its " f"reasoning{spent}. Press Regenerate." ) else: tools = state["tools"] if state else 0 if tools == 1: summary = "Done · 1 tool call" elif tools > 1: summary = f"Done · {tools} tool calls" else: summary = "Done" summary += self._usage_clause(prefill_s, prompt_tokens, gen_tokens) await self._emit(__event_emitter__, summary, done=True) except Exception: pass return body