571 lines
26 KiB
Python
571 lines
26 KiB
Python
"""
|
|
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: <opening of it> <- 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 <tool>' 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
|