diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 7cf125f7c7..6d576e0d40 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -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: diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 8a7c3aca72..aa64a48997 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -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': diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 8d3b6dd267..c862234040 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -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 = [('', '')] -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'
\nThought for {duration or 0} seconds\n{display}\n
' + f'
\nThought for {duration or 0} seconds\n{display}\n
' ) else: parts.append( - f'
\nThinking…\n{display}\n
' + f'
\nThinking…\n{display}\n
' ) 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'
\nAnalyzed\n{display}\n
' + f'
\nAnalyzed\n{display}\n
' ) else: parts.append( - f'
\nAnalyzing…\n{display}\n
' + f'
\nAnalyzing…\n{display}\n
' ) 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
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 diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 670a94b512..4dc7a197d0 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -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
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 /
segments. +_DETAILS_SPLIT_RE = re.compile(r'(]*>[\s\S]*?
)', re.IGNORECASE) + +# Regexes to extract type and id attributes from a
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
blocks.""" + parts = _DETAILS_SPLIT_RE.split(content) + segments = [] + for part in parts: + if part and part.lstrip().startswith(']*>', 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: