Skip to content

Commit 44a0514

Browse files
committed
fix: respect content edits for assistant messages with output
1 parent 2424bb9 commit 44a0514

2 files changed

Lines changed: 80 additions & 7 deletions

File tree

backend/open_webui/utils/middleware.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
convert_logit_bias_input_to_json,
9898
get_content_from_message,
9999
convert_output_to_messages,
100+
filter_output_by_content,
100101
strip_empty_content_blocks,
101102
)
102103
from open_webui.utils.tools import (
@@ -475,9 +476,9 @@ def serialize_output(output: list) -> str:
475476
)
476477

477478
if status == 'completed' or duration is not None or not is_last_item:
478-
content = f'{content}<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>\n'
479+
content = f'{content}<details type="reasoning" done="true" id="{item.get("id", "")}" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>\n'
479480
else:
480-
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
481+
content = f'{content}<details type="reasoning" done="false" id="{item.get("id", "")}">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
481482

482483
elif item_type == 'open_webui:code_interpreter':
483484
content_stripped, original_whitespace = split_content_and_whitespace(content)
@@ -513,9 +514,9 @@ def serialize_output(output: list) -> str:
513514
output_attr = f' output="{html.escape(output_json)}"'
514515

515516
if status == 'completed' or duration is not None or not is_last_item:
516-
content += f'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>\n'
517+
content += f'<details type="code_interpreter" done="true" id="{item.get("id", "")}" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>\n'
517518
else:
518-
content += f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>\n'
519+
content += f'<details type="code_interpreter" done="false" id="{item.get("id", "")}"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>\n'
519520

520521
return content.strip()
521522

@@ -2035,16 +2036,47 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
20352036
20362037
For assistant messages with 'output' field, produces properly formatted
20372038
OpenAI-style messages (tool_calls + tool results). Strips 'output' before LLM.
2039+
Respects content edits and dropped <details> blocks by filtering output items
2040+
against the stored content field before conversion.
20382041
"""
20392042
processed = []
20402043

20412044
for message in messages:
20422045
if message.get('role') == 'assistant' and message.get('output'):
2043-
# Use output items for clean OpenAI-format messages
2044-
output_messages = convert_output_to_messages(message['output'], raw=True)
2046+
# Drop output items for <details> blocks removed from content
2047+
filtered_output = filter_output_by_content(
2048+
message['output'], message.get('content', '')
2049+
)
2050+
2051+
# Use content for text (respects edits), strip <details> blocks
2052+
edited_text = re.sub(
2053+
r'<details\b[^>]*>.*?</details>', '',
2054+
message.get('content', ''), flags=re.S,
2055+
).strip()
2056+
2057+
# Replace the first message item's text with the edited content,
2058+
# preserving the natural order of structured items (reasoning before
2059+
# text, tool calls in sequence) via convert_output_to_messages.
2060+
used_edited_text = False
2061+
modified_output = []
2062+
for item in filtered_output:
2063+
if item.get('type') == 'message' and not used_edited_text:
2064+
modified_output.append({
2065+
**item,
2066+
'content': [{'type': 'output_text', 'text': edited_text}],
2067+
})
2068+
used_edited_text = True
2069+
else:
2070+
modified_output.append(item)
2071+
2072+
output_messages = convert_output_to_messages(modified_output, raw=True)
20452073
if output_messages:
2074+
if not used_edited_text and edited_text:
2075+
output_messages.append({'role': 'assistant', 'content': edited_text})
20462076
processed.extend(output_messages)
2047-
continue
2077+
elif edited_text:
2078+
processed.append({'role': 'assistant', 'content': edited_text})
2079+
continue
20482080

20492081
# Strip 'output' field before adding (LLM shouldn't see it)
20502082
clean_message = {k: v for k, v in message.items() if k != 'output'}

backend/open_webui/utils/misc.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,47 @@ def flush_pending():
258258
return messages
259259

260260

261+
def filter_output_by_content(output: list, content: str) -> list:
262+
"""
263+
Drop output items whose <details> block was removed from content.
264+
Matches by id attribute. Falls back to keeping all items for types
265+
that don't yet carry id= (legacy content compatibility).
266+
"""
267+
present_ids = set(re.findall(r'<details\b[^>]*\bid="([^"]+)"', content))
268+
types_present = set(re.findall(r'<details\b[^>]*\btype="([^"]+)"', content))
269+
270+
# Types that carry id= in content (vs legacy types without id=).
271+
# A type without id= in content is treated as legacy — keep all of that type.
272+
types_with_ids: set[str] = set()
273+
for m in re.finditer(r'<details\b(?=[^>]*\btype="([^"]+)")(?=[^>]*\bid=)', content):
274+
types_with_ids.add(m.group(1))
275+
276+
# Map output item type → <details> type attribute value
277+
DETAILS_TYPE = {
278+
'function_call': 'tool_calls',
279+
'function_call_output': 'tool_calls',
280+
'reasoning': 'reasoning',
281+
'open_webui:code_interpreter': 'code_interpreter',
282+
}
283+
284+
filtered = []
285+
for item in output:
286+
details_type = DETAILS_TYPE.get(item.get('type', ''))
287+
if details_type is None:
288+
filtered.append(item) # Not a details-backed type — pass through
289+
elif details_type not in types_present:
290+
pass # Type fully removed from content — drop
291+
elif details_type not in types_with_ids:
292+
filtered.append(item) # Legacy type (no id=) — keep all
293+
else:
294+
item_id = item.get('call_id') or item.get('id', '')
295+
if item_id in present_ids:
296+
filtered.append(item) # ID present — keep
297+
# else: user deleted the <details> block — drop
298+
299+
return filtered
300+
301+
261302
def get_last_user_message(messages: list[dict]) -> Optional[str]:
262303
message = get_last_user_message_item(messages)
263304
if message is None:

0 commit comments

Comments
 (0)