Skip to content

Commit 7830bd7

Browse files
committed
fix: preserve reasoning_details for multi-turn continuity
1 parent 62d6f1b commit 7830bd7

2 files changed

Lines changed: 92 additions & 16 deletions

File tree

backend/open_webui/utils/middleware.py

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,38 @@ def split_json_objects(raw: str) -> list[str]:
219219
return expanded
220220

221221

222+
def _merge_reasoning_details(details: list, chunk: object) -> None:
223+
"""Merge reasoning_details delta items into a list in-place, keyed by index.
224+
225+
Items sharing the same index are merged: text and summary fields are
226+
concatenated; all other fields are overwritten with the latest value.
227+
chunk may be a list of items or a single dict.
228+
"""
229+
items = chunk if isinstance(chunk, list) else ([chunk] if isinstance(chunk, dict) else [])
230+
for item in items:
231+
if not isinstance(item, dict):
232+
continue
233+
idx = item.get('index', 0)
234+
if not isinstance(idx, int) or idx < 0:
235+
continue
236+
while len(details) <= idx:
237+
details.append({})
238+
existing = details[idx]
239+
existing['type'] = item.get('type', existing.get('type', 'reasoning.text'))
240+
if 'format' in item:
241+
existing['format'] = item['format']
242+
if 'id' in item:
243+
existing['id'] = item['id']
244+
if item.get('text'):
245+
existing['text'] = existing.get('text', '') + item['text']
246+
if item.get('summary'):
247+
existing['summary'] = existing.get('summary', '') + item['summary']
248+
if 'data' in item:
249+
existing['data'] = item['data']
250+
if item.get('signature'):
251+
existing['signature'] = item['signature']
252+
253+
222254
def get_citation_source_from_tool_result(
223255
tool_name: str, tool_params: dict, tool_result: str, tool_id: str = ''
224256
) -> list[dict]:
@@ -3361,15 +3393,43 @@ async def non_streaming_chat_response_handler(response, ctx):
33613393
# otherwise generate from response content
33623394
response_output = response_data.get('output')
33633395
if not response_output:
3364-
response_output = [
3396+
message_obj = choices[0].get('message', {})
3397+
reasoning_text = (
3398+
message_obj.get('reasoning_content')
3399+
or message_obj.get('reasoning')
3400+
)
3401+
reasoning_details = message_obj.get('reasoning_details')
3402+
3403+
response_output = []
3404+
3405+
if reasoning_text or reasoning_details:
3406+
r_item = {
3407+
'type': 'reasoning',
3408+
'id': output_id('r'),
3409+
'status': 'completed',
3410+
'start_tag': '<think>',
3411+
'end_tag': '</think>',
3412+
'attributes': {'type': 'reasoning_content'},
3413+
'content': [{'type': 'output_text', 'text': reasoning_text}] if reasoning_text else [],
3414+
'summary': None,
3415+
}
3416+
if reasoning_details:
3417+
r_item['reasoning_details'] = (
3418+
reasoning_details
3419+
if isinstance(reasoning_details, list)
3420+
else [reasoning_details]
3421+
)
3422+
response_output.append(r_item)
3423+
3424+
response_output.append(
33653425
{
33663426
'type': 'message',
33673427
'id': output_id('msg'),
33683428
'status': 'completed',
33693429
'role': 'assistant',
33703430
'content': [{'type': 'output_text', 'text': content}],
33713431
}
3372-
]
3432+
)
33733433

33743434
await event_emitter(
33753435
{
@@ -4075,9 +4135,10 @@ async def flush_pending_delta_data(threshold: int = 0):
40754135
or delta.get('reasoning')
40764136
or delta.get('thinking')
40774137
)
4138+
reasoning_details_chunk = delta.get('reasoning_details')
40784139
if reasoning_content:
40794140
if not output or output[-1].get('type') != 'reasoning':
4080-
reasoning_item = {
4141+
output.append({
40814142
'type': 'reasoning',
40824143
'id': output_id('r'),
40834144
'status': 'in_progress',
@@ -4087,23 +4148,24 @@ async def flush_pending_delta_data(threshold: int = 0):
40874148
'content': [],
40884149
'summary': None,
40894150
'started_at': time.time(),
4090-
}
4091-
output.append(reasoning_item)
4092-
else:
4093-
reasoning_item = output[-1]
4151+
})
40944152

4095-
# Append to reasoning content
4153+
reasoning_item = output[-1]
40964154
parts = reasoning_item.get('content', [])
40974155
if parts and parts[-1].get('type') == 'output_text':
40984156
parts[-1]['text'] += reasoning_content
40994157
else:
4100-
reasoning_item['content'] = [
4101-
{
4102-
'type': 'output_text',
4103-
'text': reasoning_content,
4104-
}
4105-
]
4158+
reasoning_item['content'] = [{'type': 'output_text', 'text': reasoning_content}]
4159+
4160+
# Accumulate raw structured reasoning_details for provider round-trip.
4161+
# Only attach to an existing reasoning item to avoid blank reasoning blocks in the UI.
4162+
if reasoning_details_chunk and output and output[-1].get('type') == 'reasoning':
4163+
_merge_reasoning_details(
4164+
output[-1].setdefault('reasoning_details', []),
4165+
reasoning_details_chunk,
4166+
)
41064167

4168+
if reasoning_content or reasoning_details_chunk:
41074169
data = {'content': serialize_output(full_output())}
41084170

41094171
if value:

backend/open_webui/utils/misc.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,21 +149,24 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
149149
pending_tool_calls = []
150150
pending_content = []
151151
pending_reasoning = ''
152+
pending_reasoning_details = None
152153

153154
def flush_pending():
154-
nonlocal pending_content, pending_tool_calls, pending_reasoning
155-
if pending_content or pending_tool_calls or pending_reasoning:
155+
nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details
156+
if pending_content or pending_tool_calls or pending_reasoning or pending_reasoning_details:
156157
messages.append(
157158
{
158159
'role': 'assistant',
159160
'content': '\n'.join(pending_content) if pending_content else '',
160161
**({'tool_calls': pending_tool_calls} if pending_tool_calls else {}),
161162
**({'reasoning_content': pending_reasoning} if pending_reasoning else {}),
163+
**({'reasoning_details': pending_reasoning_details} if pending_reasoning_details else {}),
162164
}
163165
)
164166
pending_content = []
165167
pending_tool_calls = []
166168
pending_reasoning = ''
169+
pending_reasoning_details = None
167170

168171
for item in output:
169172
item_type = item.get('type', '')
@@ -252,6 +255,17 @@ def flush_pending():
252255
# providers that require it on assistant tool-call messages
253256
# (e.g. Moonshot/Kimi K2.5).
254257
pending_reasoning += reasoning_text
258+
259+
# Preserve raw structured reasoning_details for provider round-trip
260+
raw_details = item.get('reasoning_details')
261+
if raw_details:
262+
if pending_reasoning_details is None:
263+
pending_reasoning_details = list(raw_details) if isinstance(raw_details, list) else [raw_details]
264+
elif isinstance(pending_reasoning_details, list):
265+
if isinstance(raw_details, list):
266+
pending_reasoning_details.extend(raw_details)
267+
else:
268+
pending_reasoning_details.append(raw_details)
255269
# else: skip reasoning blocks for normal LLM messages
256270

257271
elif item_type == 'open_webui:code_interpreter':

0 commit comments

Comments
 (0)