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: