Skip to content

Commit c0e32c6

Browse files
committed
fix: reconcile assistant output with edited content
1 parent 971ea03 commit c0e32c6

4 files changed

Lines changed: 220 additions & 11 deletions

File tree

backend/open_webui/routers/chats.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737

3838
from open_webui.utils.auth import get_admin_user, get_verified_user
3939
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
40+
from open_webui.utils.misc import reconcile_assistant_output
4041

4142
log = logging.getLogger(__name__)
4243

@@ -967,6 +968,16 @@ async def update_chat_by_id(
967968
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
968969
if chat:
969970
updated_chat = {**chat.chat, **form_data.chat}
971+
972+
# Reconcile assistant message output with edited content so that
973+
# stale structured output does not linger in stored history.
974+
messages = updated_chat.get('history', {}).get('messages', {})
975+
for msg in messages.values():
976+
if msg.get('role') == 'assistant' and msg.get('output'):
977+
effective = reconcile_assistant_output(msg)
978+
if effective is not None:
979+
msg['output'] = effective
980+
970981
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
971982
return ChatResponse(**chat.model_dump())
972983
else:

backend/open_webui/routers/openai.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
from open_webui.utils.auth import get_admin_user, get_verified_user
6565
from open_webui.utils.headers import include_user_info_headers, get_custom_headers
6666
from open_webui.utils.anthropic import is_anthropic_url, get_anthropic_models
67+
from open_webui.utils.misc import reconcile_assistant_output
6768

6869
log = logging.getLogger(__name__)
6970

@@ -921,7 +922,10 @@ def convert_to_responses_payload(payload: dict) -> dict:
921922
# Check for stored output items (from previous Responses API turn)
922923
stored_output = msg.get('output')
923924
if stored_output and isinstance(stored_output, list):
924-
input_items.extend(_normalize_stored_item(item) for item in stored_output)
925+
# Reconcile output with edited content before replay
926+
effective_output = reconcile_assistant_output(msg)
927+
active_output = effective_output if effective_output is not None else stored_output
928+
input_items.extend(_normalize_stored_item(item) for item in active_output)
925929
continue
926930

927931
if role == 'system':

backend/open_webui/utils/middleware.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@
9898
convert_logit_bias_input_to_json,
9999
get_content_from_message,
100100
convert_output_to_messages,
101+
output_id,
102+
reconcile_assistant_output,
101103
strip_empty_content_blocks,
102104
)
103105
from open_webui.utils.tools import (
@@ -164,11 +166,6 @@
164166
DEFAULT_CODE_INTERPRETER_TAGS = [('<code_interpreter>', '</code_interpreter>')]
165167

166168

167-
def output_id(prefix: str) -> str:
168-
"""Generate OR-style ID: prefix + 24-char hex UUID."""
169-
return f'{prefix}_{uuid4().hex[:24]}'
170-
171-
172169
def _split_tool_calls(
173170
tool_calls: list[dict],
174171
) -> list[dict]:
@@ -572,13 +569,16 @@ def serialize_output(output: list) -> str:
572569
)
573570
)
574571

572+
item_id = item.get('id', '')
573+
id_attr = f' id="{item_id}"' if item_id else ''
574+
575575
if status == 'completed' or duration is not None or not is_last_item:
576576
parts.append(
577-
f'<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>'
577+
f'<details type="reasoning"{id_attr} done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>'
578578
)
579579
else:
580580
parts.append(
581-
f'<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>'
581+
f'<details type="reasoning"{id_attr} done="false">\n<summary>Thinking…</summary>\n{display}\n</details>'
582582
)
583583

584584
elif item_type == 'open_webui:code_interpreter':
@@ -617,13 +617,16 @@ def serialize_output(output: list) -> str:
617617
output_json = json.dumps({'result': str(ci_output)}, ensure_ascii=False)
618618
output_attr = f' output="{html.escape(output_json)}"'
619619

620+
item_id = item.get('id', '')
621+
id_attr = f' id="{item_id}"' if item_id else ''
622+
620623
if status == 'completed' or duration is not None or not is_last_item:
621624
parts.append(
622-
f'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>'
625+
f'<details type="code_interpreter"{id_attr} done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>'
623626
)
624627
else:
625628
parts.append(
626-
f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>'
629+
f'<details type="code_interpreter"{id_attr} done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>'
627630
)
628631

629632
return '\n'.join(parts).strip()
@@ -2207,13 +2210,23 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
22072210
22082211
For assistant messages with 'output' field, produces properly formatted
22092212
OpenAI-style messages (tool_calls + tool results). Strips 'output' before LLM.
2213+
2214+
Output is reconciled with edited content before conversion so that
2215+
user edits (text changes, deleted <details> blocks) are respected.
22102216
"""
22112217
processed = []
22122218

22132219
for message in messages:
22142220
if message.get('role') == 'assistant' and message.get('output'):
2221+
# Reconcile output with edited content before conversion
2222+
effective_output = reconcile_assistant_output(message)
2223+
if effective_output is not None:
2224+
active_output = effective_output
2225+
else:
2226+
active_output = message['output']
2227+
22152228
# Use output items for clean OpenAI-format messages
2216-
output_messages = convert_output_to_messages(message['output'], raw=True)
2229+
output_messages = convert_output_to_messages(active_output, raw=True)
22172230
if output_messages:
22182231
processed.extend(output_messages)
22192232
continue

backend/open_webui/utils/misc.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from datetime import timedelta
88
from pathlib import Path
99
from typing import Callable, Optional, Sequence, Union
10+
from collections import defaultdict
1011
import json
1112
import aiohttp
1213
import mimeparse
@@ -129,6 +130,11 @@ def get_content_from_message(message: dict) -> Optional[str]:
129130
return None
130131

131132

133+
def output_id(prefix: str) -> str:
134+
"""Generate OR-style ID: prefix + 24-char hex UUID."""
135+
return f'{prefix}_{uuid.uuid4().hex[:24]}'
136+
137+
132138
def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
133139
"""
134140
Convert OR-aligned output items to OpenAI Chat Completion-format messages.
@@ -300,6 +306,181 @@ def flush_pending():
300306
return messages
301307

302308

309+
# ---------------------------------------------------------------------------
310+
# Assistant output reconciliation
311+
# ---------------------------------------------------------------------------
312+
313+
# Mapping from <details type="..."> values to structured output item types.
314+
DETAIL_TYPE_TO_OUTPUT_TYPES: dict[str, tuple[str, ...]] = {
315+
'tool_calls': ('function_call', 'function_call_output'),
316+
'reasoning': ('reasoning',),
317+
'code_interpreter': ('open_webui:code_interpreter',),
318+
}
319+
320+
# Regex to split content into alternating text / <details> segments.
321+
_DETAILS_SPLIT_RE = re.compile(r'(<details\b[^>]*>[\s\S]*?</details>)', re.IGNORECASE)
322+
323+
# Regexes to extract type and id attributes from a <details> opening tag.
324+
# Using two independent patterns so attribute order does not matter.
325+
_DETAIL_TYPE_RE = re.compile(r'\btype=["\']([^"\']*)["\']', re.IGNORECASE)
326+
_DETAIL_ID_RE = re.compile(r'\bid=["\']([^"\']*)["\']', re.IGNORECASE)
327+
328+
329+
def _parse_content_segments(content: str) -> tuple[list[tuple[str, Optional[str], Optional[str]]], list[str]]:
330+
"""Parse content into ordered segments of text and <details> blocks."""
331+
parts = _DETAILS_SPLIT_RE.split(content)
332+
segments = []
333+
for part in parts:
334+
if part and part.lstrip().startswith('<details'):
335+
# Search only the opening tag to avoid false positives from
336+
# id= or type= appearing inside code/body content.
337+
opening = re.match(r'<details\b[^>]*>', part.lstrip())
338+
tag = opening.group(0) if opening else ''
339+
tm = _DETAIL_TYPE_RE.search(tag)
340+
im = _DETAIL_ID_RE.search(tag)
341+
segments.append(('details', tm.group(1) if tm else '', im.group(1) if im else ''))
342+
else:
343+
segments.append(('text', None, None))
344+
return segments, parts
345+
346+
347+
def _build_lookup(output: list) -> tuple[dict, dict]:
348+
"""Build grouped lookup tables from structured output items."""
349+
lookup: dict[str, dict[str, list]] = {}
350+
ordinal: dict[str, list[list]] = defaultdict(list)
351+
352+
for detail_type in DETAIL_TYPE_TO_OUTPUT_TYPES:
353+
lookup[detail_type] = {}
354+
355+
fc_by_call_id: dict[str, dict] = {}
356+
fco_by_call_id: dict[str, dict] = {}
357+
for item in output:
358+
item_type = item.get('type', '')
359+
if item_type == 'function_call':
360+
call_id = item.get('call_id', '')
361+
if call_id:
362+
fc_by_call_id[call_id] = item
363+
elif item_type == 'function_call_output':
364+
call_id = item.get('call_id', '')
365+
if call_id:
366+
fco_by_call_id[call_id] = item
367+
368+
for call_id, fc_item in fc_by_call_id.items():
369+
bundle = [fc_item]
370+
fco = fco_by_call_id.get(call_id)
371+
if fco:
372+
bundle.append(fco)
373+
lookup['tool_calls'][call_id] = bundle
374+
ordinal['tool_calls'].append(bundle)
375+
376+
for item in output:
377+
item_type = item.get('type', '')
378+
item_id = item.get('id', '')
379+
380+
if item_type == 'reasoning':
381+
bundle = [item]
382+
if item_id:
383+
lookup['reasoning'][item_id] = bundle
384+
ordinal['reasoning'].append(bundle)
385+
386+
elif item_type == 'open_webui:code_interpreter':
387+
bundle = [item]
388+
if item_id:
389+
lookup['code_interpreter'][item_id] = bundle
390+
ordinal['code_interpreter'].append(bundle)
391+
392+
return lookup, ordinal
393+
394+
395+
def _make_message_item(text: str) -> dict:
396+
"""Create a message output item from plain text."""
397+
return {
398+
'type': 'message',
399+
'id': output_id('msg'),
400+
'status': 'completed',
401+
'role': 'assistant',
402+
'content': [{'type': 'output_text', 'text': text}],
403+
}
404+
405+
406+
def reconcile_assistant_output(message: dict) -> Optional[list]:
407+
"""Reconcile an assistant message's output with its edited content.
408+
409+
Returns None if no output exists, otherwise a list of effective output
410+
items that match the blocks still present in the edited content.
411+
"""
412+
output = message.get('output')
413+
if not output or not isinstance(output, list):
414+
return None
415+
416+
content = message.get('content')
417+
if not isinstance(content, str) or not content.strip():
418+
return None
419+
420+
segments, raw_parts = _parse_content_segments(content)
421+
422+
lookup, ordinal = _build_lookup(output)
423+
424+
emitted_ids: set[str] = set()
425+
ordinal_counters: dict[str, int] = defaultdict(int)
426+
427+
effective_output: list = []
428+
pending_text: list[str] = []
429+
raw_idx = 0
430+
431+
def flush_text():
432+
if pending_text:
433+
joined = '\n'.join(pending_text).strip()
434+
if joined:
435+
effective_output.append(_make_message_item(joined))
436+
pending_text.clear()
437+
438+
for seg_type, detail_type, detail_id in segments:
439+
raw_part = raw_parts[raw_idx] if raw_idx < len(raw_parts) else ''
440+
raw_idx += 1
441+
442+
if seg_type == 'text':
443+
text = raw_part.strip()
444+
if text:
445+
pending_text.append(text)
446+
continue
447+
448+
if detail_type not in DETAIL_TYPE_TO_OUTPUT_TYPES:
449+
pending_text.append(raw_part)
450+
continue
451+
452+
bundle = None
453+
match_key = None
454+
455+
if detail_id and detail_id in lookup.get(detail_type, {}):
456+
if detail_id not in emitted_ids:
457+
bundle = lookup[detail_type][detail_id]
458+
match_key = detail_id
459+
460+
if bundle is None and not detail_id:
461+
idx = ordinal_counters[detail_type]
462+
ordinal_list = ordinal.get(detail_type, [])
463+
if idx < len(ordinal_list):
464+
candidate = ordinal_list[idx]
465+
candidate_id = candidate[0].get('id', '') or candidate[0].get('call_id', '')
466+
if candidate_id not in emitted_ids:
467+
bundle = candidate
468+
match_key = candidate_id
469+
ordinal_counters[detail_type] = idx + 1
470+
471+
if bundle is not None:
472+
flush_text()
473+
effective_output.extend(bundle)
474+
if match_key:
475+
emitted_ids.add(match_key)
476+
else:
477+
pending_text.append(raw_part)
478+
479+
flush_text()
480+
481+
return effective_output
482+
483+
303484
def get_last_user_message(messages: list[dict]) -> Optional[str]:
304485
message = get_last_user_message_item(messages)
305486
if message is None:

0 commit comments

Comments
 (0)