fix: reconcile assistant output with edited content

This commit is contained in:
Algorithm5838 2026-04-21 19:36:02 +03:00
parent f162d4de90
commit 9022b6df7b
4 changed files with 220 additions and 11 deletions

View file

@ -37,6 +37,7 @@ from pydantic import BaseModel
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.utils.misc import reconcile_assistant_output
log = logging.getLogger(__name__)
@ -930,6 +931,16 @@ async def update_chat_by_id(
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if chat:
updated_chat = {**chat.chat, **form_data.chat}
# Reconcile assistant message output with edited content so that
# stale structured output does not linger in stored history.
messages = updated_chat.get('history', {}).get('messages', {})
for msg in messages.values():
if msg.get('role') == 'assistant' and msg.get('output'):
effective = reconcile_assistant_output(msg)
if effective is not None:
msg['output'] = effective
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
return ChatResponse(**chat.model_dump())
else:

View file

@ -64,6 +64,7 @@ from open_webui.utils.session_pool import (
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.anthropic import is_anthropic_url, get_anthropic_models
from open_webui.utils.misc import reconcile_assistant_output
log = logging.getLogger(__name__)
@ -866,7 +867,10 @@ def convert_to_responses_payload(payload: dict) -> dict:
# Check for stored output items (from previous Responses API turn)
stored_output = msg.get('output')
if stored_output and isinstance(stored_output, list):
input_items.extend(_normalize_stored_item(item) for item in stored_output)
# Reconcile output with edited content before replay
effective_output = reconcile_assistant_output(msg)
active_output = effective_output if effective_output is not None else stored_output
input_items.extend(_normalize_stored_item(item) for item in active_output)
continue
if role == 'system':

View file

@ -98,6 +98,8 @@ from open_webui.utils.misc import (
convert_logit_bias_input_to_json,
get_content_from_message,
convert_output_to_messages,
output_id,
reconcile_assistant_output,
strip_empty_content_blocks,
)
from open_webui.utils.tools import (
@ -164,11 +166,6 @@ DEFAULT_SOLUTION_TAGS = [('<|begin_of_solution|>', '<|end_of_solution|>')]
DEFAULT_CODE_INTERPRETER_TAGS = [('<code_interpreter>', '</code_interpreter>')]
def output_id(prefix: str) -> str:
"""Generate OR-style ID: prefix + 24-char hex UUID."""
return f'{prefix}_{uuid4().hex[:24]}'
def _split_tool_calls(
tool_calls: list[dict],
) -> list[dict]:
@ -536,13 +533,16 @@ def serialize_output(output: list) -> str:
)
)
item_id = item.get('id', '')
id_attr = f' id="{item_id}"' if item_id else ''
if status == 'completed' or duration is not None or not is_last_item:
parts.append(
f'<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>'
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>'
)
else:
parts.append(
f'<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>'
f'<details type="reasoning"{id_attr} done="false">\n<summary>Thinking…</summary>\n{display}\n</details>'
)
elif item_type == 'open_webui:code_interpreter':
@ -581,13 +581,16 @@ def serialize_output(output: list) -> str:
output_json = json.dumps({'result': str(ci_output)}, ensure_ascii=False)
output_attr = f' output="{html.escape(output_json)}"'
item_id = item.get('id', '')
id_attr = f' id="{item_id}"' if item_id else ''
if status == 'completed' or duration is not None or not is_last_item:
parts.append(
f'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>'
f'<details type="code_interpreter"{id_attr} done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>'
)
else:
parts.append(
f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>'
f'<details type="code_interpreter"{id_attr} done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>'
)
return '\n'.join(parts).strip()
@ -2162,13 +2165,23 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
For assistant messages with 'output' field, produces properly formatted
OpenAI-style messages (tool_calls + tool results). Strips 'output' before LLM.
Output is reconciled with edited content before conversion so that
user edits (text changes, deleted <details> blocks) are respected.
"""
processed = []
for message in messages:
if message.get('role') == 'assistant' and message.get('output'):
# Reconcile output with edited content before conversion
effective_output = reconcile_assistant_output(message)
if effective_output is not None:
active_output = effective_output
else:
active_output = message['output']
# Use output items for clean OpenAI-format messages
output_messages = convert_output_to_messages(message['output'], raw=True)
output_messages = convert_output_to_messages(active_output, raw=True)
if output_messages:
processed.extend(output_messages)
continue

View file

@ -7,6 +7,7 @@ import logging
from datetime import timedelta
from pathlib import Path
from typing import Callable, Optional, Sequence, Union
from collections import defaultdict
import json
import aiohttp
import mimeparse
@ -129,6 +130,11 @@ def get_content_from_message(message: dict) -> Optional[str]:
return None
def output_id(prefix: str) -> str:
"""Generate OR-style ID: prefix + 24-char hex UUID."""
return f'{prefix}_{uuid.uuid4().hex[:24]}'
def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
"""
Convert OR-aligned output items to OpenAI Chat Completion-format messages.
@ -282,6 +288,181 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
return messages
# ---------------------------------------------------------------------------
# Assistant output reconciliation
# ---------------------------------------------------------------------------
# Mapping from <details type="..."> values to structured output item types.
DETAIL_TYPE_TO_OUTPUT_TYPES: dict[str, tuple[str, ...]] = {
'tool_calls': ('function_call', 'function_call_output'),
'reasoning': ('reasoning',),
'code_interpreter': ('open_webui:code_interpreter',),
}
# Regex to split content into alternating text / <details> segments.
_DETAILS_SPLIT_RE = re.compile(r'(<details\b[^>]*>[\s\S]*?</details>)', re.IGNORECASE)
# Regexes to extract type and id attributes from a <details> opening tag.
# Using two independent patterns so attribute order does not matter.
_DETAIL_TYPE_RE = re.compile(r'\btype=["\']([^"\']*)["\']', re.IGNORECASE)
_DETAIL_ID_RE = re.compile(r'\bid=["\']([^"\']*)["\']', re.IGNORECASE)
def _parse_content_segments(content: str) -> tuple[list[tuple[str, Optional[str], Optional[str]]], list[str]]:
"""Parse content into ordered segments of text and <details> blocks."""
parts = _DETAILS_SPLIT_RE.split(content)
segments = []
for part in parts:
if part and part.lstrip().startswith('<details'):
# Search only the opening tag to avoid false positives from
# id= or type= appearing inside code/body content.
opening = re.match(r'<details\b[^>]*>', part.lstrip())
tag = opening.group(0) if opening else ''
tm = _DETAIL_TYPE_RE.search(tag)
im = _DETAIL_ID_RE.search(tag)
segments.append(('details', tm.group(1) if tm else '', im.group(1) if im else ''))
else:
segments.append(('text', None, None))
return segments, parts
def _build_lookup(output: list) -> tuple[dict, dict]:
"""Build grouped lookup tables from structured output items."""
lookup: dict[str, dict[str, list]] = {}
ordinal: dict[str, list[list]] = defaultdict(list)
for detail_type in DETAIL_TYPE_TO_OUTPUT_TYPES:
lookup[detail_type] = {}
fc_by_call_id: dict[str, dict] = {}
fco_by_call_id: dict[str, dict] = {}
for item in output:
item_type = item.get('type', '')
if item_type == 'function_call':
call_id = item.get('call_id', '')
if call_id:
fc_by_call_id[call_id] = item
elif item_type == 'function_call_output':
call_id = item.get('call_id', '')
if call_id:
fco_by_call_id[call_id] = item
for call_id, fc_item in fc_by_call_id.items():
bundle = [fc_item]
fco = fco_by_call_id.get(call_id)
if fco:
bundle.append(fco)
lookup['tool_calls'][call_id] = bundle
ordinal['tool_calls'].append(bundle)
for item in output:
item_type = item.get('type', '')
item_id = item.get('id', '')
if item_type == 'reasoning':
bundle = [item]
if item_id:
lookup['reasoning'][item_id] = bundle
ordinal['reasoning'].append(bundle)
elif item_type == 'open_webui:code_interpreter':
bundle = [item]
if item_id:
lookup['code_interpreter'][item_id] = bundle
ordinal['code_interpreter'].append(bundle)
return lookup, ordinal
def _make_message_item(text: str) -> dict:
"""Create a message output item from plain text."""
return {
'type': 'message',
'id': output_id('msg'),
'status': 'completed',
'role': 'assistant',
'content': [{'type': 'output_text', 'text': text}],
}
def reconcile_assistant_output(message: dict) -> Optional[list]:
"""Reconcile an assistant message's output with its edited content.
Returns None if no output exists, otherwise a list of effective output
items that match the blocks still present in the edited content.
"""
output = message.get('output')
if not output or not isinstance(output, list):
return None
content = message.get('content')
if not isinstance(content, str) or not content.strip():
return None
segments, raw_parts = _parse_content_segments(content)
lookup, ordinal = _build_lookup(output)
emitted_ids: set[str] = set()
ordinal_counters: dict[str, int] = defaultdict(int)
effective_output: list = []
pending_text: list[str] = []
raw_idx = 0
def flush_text():
if pending_text:
joined = '\n'.join(pending_text).strip()
if joined:
effective_output.append(_make_message_item(joined))
pending_text.clear()
for seg_type, detail_type, detail_id in segments:
raw_part = raw_parts[raw_idx] if raw_idx < len(raw_parts) else ''
raw_idx += 1
if seg_type == 'text':
text = raw_part.strip()
if text:
pending_text.append(text)
continue
if detail_type not in DETAIL_TYPE_TO_OUTPUT_TYPES:
pending_text.append(raw_part)
continue
bundle = None
match_key = None
if detail_id and detail_id in lookup.get(detail_type, {}):
if detail_id not in emitted_ids:
bundle = lookup[detail_type][detail_id]
match_key = detail_id
if bundle is None and not detail_id:
idx = ordinal_counters[detail_type]
ordinal_list = ordinal.get(detail_type, [])
if idx < len(ordinal_list):
candidate = ordinal_list[idx]
candidate_id = candidate[0].get('id', '') or candidate[0].get('call_id', '')
if candidate_id not in emitted_ids:
bundle = candidate
match_key = candidate_id
ordinal_counters[detail_type] = idx + 1
if bundle is not None:
flush_text()
effective_output.extend(bundle)
if match_key:
emitted_ids.add(match_key)
else:
pending_text.append(raw_part)
flush_text()
return effective_output
def get_last_user_message(messages: list[dict]) -> Optional[str]:
message = get_last_user_message_item(messages)
if message is None: