Skip to content

Commit eae046d

Browse files
Algorithm5838github-actions[bot]
authored andcommitted
fix: respect content edits for assistant messages with output
1 parent a54f87d commit eae046d

2 files changed

Lines changed: 90 additions & 11 deletions

File tree

backend/open_webui/utils/middleware.py

Lines changed: 37 additions & 11 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

@@ -2040,11 +2041,40 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
20402041

20412042
for message in messages:
20422043
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)
2044+
# Drop output items for <details> blocks removed from content
2045+
output = filter_output_by_content(
2046+
message['output'], message.get('content', '')
2047+
)
2048+
2049+
# Use content for text (respects edits), output for structured items
2050+
content = re.sub(
2051+
r'<details\b[^>]*>.*?</details>', '',
2052+
message.get('content', ''), flags=re.S,
2053+
).strip()
2054+
non_message_items = [
2055+
i for i in output if i.get('type') != 'message'
2056+
]
2057+
output_messages = convert_output_to_messages(non_message_items, raw=True)
2058+
20452059
if output_messages:
2060+
# Prepend edited text to first assistant message
2061+
for om in output_messages:
2062+
if om.get('role') == 'assistant':
2063+
om['content'] = (
2064+
(content + '\n' + om['content']).strip()
2065+
if om.get('content')
2066+
else content
2067+
)
2068+
content = ''
2069+
break
2070+
if content:
2071+
output_messages.insert(
2072+
0, {'role': 'assistant', 'content': content}
2073+
)
20462074
processed.extend(output_messages)
2047-
continue
2075+
elif content:
2076+
processed.append({'role': 'assistant', 'content': content})
2077+
continue
20482078

20492079
# Strip 'output' field before adding (LLM shouldn't see it)
20502080
clean_message = {k: v for k, v in message.items() if k != 'output'}
@@ -2635,10 +2665,6 @@ async def tool_function(**kwargs):
26352665
}
26362666
)
26372667

2638-
# Strip empty text content blocks from multimodal messages
2639-
# to prevent errors from providers like Gemini and Claude
2640-
form_data['messages'] = strip_empty_content_blocks(form_data.get('messages', []))
2641-
26422668
return form_data, metadata, events
26432669

26442670

backend/open_webui/utils/misc.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,59 @@ 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 for all details-backed types.
265+
For legacy content that lacks id= on a given type, all items of
266+
that type are kept (safe default).
267+
"""
268+
# All IDs present in remaining <details> blocks
269+
present_ids = set(re.findall(r'<details\s[^>]*\bid="([^"]+)"', content))
270+
271+
# Which <details> types exist in content at all
272+
types_present = set(
273+
m.group(1) for m in re.finditer(r'<details\s[^>]*\btype="(\w+)"', content)
274+
)
275+
276+
# Of those, which carry id= attributes (post-fix content).
277+
# Legacy content without id= on a given type → keep all items of that type.
278+
types_with_ids = set()
279+
for m in re.finditer(r'<details\s[^>]*\btype="(\w+)"[^>]*\bid=', content):
280+
types_with_ids.add(m.group(1))
281+
for m in re.finditer(r'<details\s[^>]*\bid=[^>]*\btype="(\w+)"', content):
282+
types_with_ids.add(m.group(1))
283+
284+
# Map output item type → <details> type attribute value
285+
DETAILS_TYPE = {
286+
'function_call': 'tool_calls',
287+
'function_call_output': 'tool_calls',
288+
'reasoning': 'reasoning',
289+
'open_webui:code_interpreter': 'code_interpreter',
290+
}
291+
292+
filtered = []
293+
for item in output:
294+
item_type = item.get('type', '')
295+
details_type = DETAILS_TYPE.get(item_type)
296+
297+
if details_type is None:
298+
# Not a details-backed type (e.g. message) — pass through
299+
filtered.append(item)
300+
elif details_type not in types_present:
301+
pass # type completely gone from content — drop
302+
elif details_type not in types_with_ids:
303+
# Legacy content — has <details> but no id= — keep all
304+
filtered.append(item)
305+
else:
306+
item_id = item.get('call_id') or item.get('id', '')
307+
if item_id in present_ids:
308+
filtered.append(item)
309+
# else: user deleted the <details> block — drop
310+
311+
return filtered
312+
313+
261314
def get_last_user_message(messages: list[dict]) -> Optional[str]:
262315
message = get_last_user_message_item(messages)
263316
if message is None:

0 commit comments

Comments
 (0)