Skip to content

Commit 782d2f2

Browse files
committed
fix: preserve reasoning_details for multi-turn continuity
1 parent 4ff2cfb commit 782d2f2

2 files changed

Lines changed: 102 additions & 18 deletions

File tree

backend/open_webui/utils/middleware.py

Lines changed: 81 additions & 15 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]:
@@ -517,6 +549,8 @@ def serialize_output(output: list) -> str:
517549
pass
518550

519551
reasoning_content = ''.join(reasoning_parts).strip()
552+
if not reasoning_content:
553+
continue # no displayable text; item stays in output for round-trip
520554

521555
duration = item.get('duration')
522556
status = item.get('status', 'in_progress')
@@ -3361,15 +3395,43 @@ async def non_streaming_chat_response_handler(response, ctx):
33613395
# otherwise generate from response content
33623396
response_output = response_data.get('output')
33633397
if not response_output:
3364-
response_output = [
3398+
message_obj = choices[0].get('message', {})
3399+
reasoning_text = (
3400+
message_obj.get('reasoning_content')
3401+
or message_obj.get('reasoning')
3402+
)
3403+
reasoning_details = message_obj.get('reasoning_details')
3404+
3405+
response_output = []
3406+
3407+
if reasoning_text or reasoning_details:
3408+
r_item = {
3409+
'type': 'reasoning',
3410+
'id': output_id('r'),
3411+
'status': 'completed',
3412+
'start_tag': '<think>',
3413+
'end_tag': '</think>',
3414+
'attributes': {'type': 'reasoning_content'},
3415+
'content': [{'type': 'output_text', 'text': reasoning_text}] if reasoning_text else [],
3416+
'summary': None,
3417+
}
3418+
if reasoning_details:
3419+
r_item['reasoning_details'] = (
3420+
reasoning_details
3421+
if isinstance(reasoning_details, list)
3422+
else [reasoning_details]
3423+
)
3424+
response_output.append(r_item)
3425+
3426+
response_output.append(
33653427
{
33663428
'type': 'message',
33673429
'id': output_id('msg'),
33683430
'status': 'completed',
33693431
'role': 'assistant',
33703432
'content': [{'type': 'output_text', 'text': content}],
33713433
}
3372-
]
3434+
)
33733435

33743436
await event_emitter(
33753437
{
@@ -4075,9 +4137,12 @@ async def flush_pending_delta_data(threshold: int = 0):
40754137
or delta.get('reasoning')
40764138
or delta.get('thinking')
40774139
)
4078-
if reasoning_content:
4140+
reasoning_details_chunk = delta.get('reasoning_details')
4141+
4142+
# Ensure a reasoning item exists for both text and structured-block deltas.
4143+
if reasoning_content or reasoning_details_chunk:
40794144
if not output or output[-1].get('type') != 'reasoning':
4080-
reasoning_item = {
4145+
output.append({
40814146
'type': 'reasoning',
40824147
'id': output_id('r'),
40834148
'status': 'in_progress',
@@ -4087,23 +4152,24 @@ async def flush_pending_delta_data(threshold: int = 0):
40874152
'content': [],
40884153
'summary': None,
40894154
'started_at': time.time(),
4090-
}
4091-
output.append(reasoning_item)
4092-
else:
4093-
reasoning_item = output[-1]
4155+
})
40944156

4095-
# Append to reasoning content
4157+
if reasoning_content:
4158+
reasoning_item = output[-1]
40964159
parts = reasoning_item.get('content', [])
40974160
if parts and parts[-1].get('type') == 'output_text':
40984161
parts[-1]['text'] += reasoning_content
40994162
else:
4100-
reasoning_item['content'] = [
4101-
{
4102-
'type': 'output_text',
4103-
'text': reasoning_content,
4104-
}
4105-
]
4163+
reasoning_item['content'] = [{'type': 'output_text', 'text': reasoning_content}]
4164+
4165+
# Accumulate raw structured reasoning_details for provider round-trip.
4166+
if reasoning_details_chunk:
4167+
_merge_reasoning_details(
4168+
output[-1].setdefault('reasoning_details', []),
4169+
reasoning_details_chunk,
4170+
)
41064171

4172+
if reasoning_content or reasoning_details_chunk:
41074173
data = {'content': serialize_output(full_output())}
41084174

41094175
if value:

backend/open_webui/utils/misc.py

Lines changed: 21 additions & 3 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', '')
@@ -244,14 +247,29 @@ def flush_pending():
244247
elif 'text' in part:
245248
reasoning_text += part.get('text', '')
246249

250+
# Read raw_details before reasoning_text to use it as a guard.
251+
raw_details = item.get('reasoning_details')
252+
247253
if reasoning_text:
248254
start_tag = item.get('start_tag', '<think>')
249255
end_tag = item.get('end_tag', '</think>')
250-
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
256+
# Skip <think> embed for structured-details models.
257+
if not raw_details:
258+
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
251259
# Preserve raw reasoning text as reasoning_content for
252260
# providers that require it on assistant tool-call messages
253261
# (e.g. Moonshot/Kimi K2.5).
254262
pending_reasoning += reasoning_text
263+
264+
# Preserve raw structured reasoning_details for provider round-trip
265+
if raw_details:
266+
if pending_reasoning_details is None:
267+
pending_reasoning_details = list(raw_details) if isinstance(raw_details, list) else [raw_details]
268+
elif isinstance(pending_reasoning_details, list):
269+
if isinstance(raw_details, list):
270+
pending_reasoning_details.extend(raw_details)
271+
else:
272+
pending_reasoning_details.append(raw_details)
255273
# else: skip reasoning blocks for normal LLM messages
256274

257275
elif item_type == 'open_webui:code_interpreter':

0 commit comments

Comments
 (0)