Skip to content

Commit 55d22dc

Browse files
committed
fix: preserve reasoning_details for multi-turn continuity
1 parent 2aa7ef9 commit 55d22dc

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
@@ -218,6 +218,38 @@ def split_json_objects(raw: str) -> list[str]:
218218
return expanded
219219

220220

221+
def _merge_reasoning_details(details: list, chunk: object) -> None:
222+
"""Merge reasoning_details delta items into a list in-place, keyed by index.
223+
224+
Items sharing the same index are merged: text and summary fields are
225+
concatenated; all other fields are overwritten with the latest value.
226+
chunk may be a list of items or a single dict.
227+
"""
228+
items = chunk if isinstance(chunk, list) else ([chunk] if isinstance(chunk, dict) else [])
229+
for item in items:
230+
if not isinstance(item, dict):
231+
continue
232+
idx = item.get('index', 0)
233+
if not isinstance(idx, int) or idx < 0:
234+
continue
235+
while len(details) <= idx:
236+
details.append({})
237+
existing = details[idx]
238+
existing['type'] = item.get('type', existing.get('type', 'reasoning.text'))
239+
if 'format' in item:
240+
existing['format'] = item['format']
241+
if 'id' in item:
242+
existing['id'] = item['id']
243+
if item.get('text'):
244+
existing['text'] = existing.get('text', '') + item['text']
245+
if item.get('summary'):
246+
existing['summary'] = existing.get('summary', '') + item['summary']
247+
if 'data' in item:
248+
existing['data'] = item['data']
249+
if item.get('signature'):
250+
existing['signature'] = item['signature']
251+
252+
221253
def get_citation_source_from_tool_result(
222254
tool_name: str, tool_params: dict, tool_result: str, tool_id: str = ''
223255
) -> list[dict]:
@@ -524,6 +556,8 @@ def serialize_output(output: list) -> str:
524556
pass
525557

526558
reasoning_content = ''.join(reasoning_parts).strip()
559+
if not reasoning_content:
560+
continue # no displayable text; item stays in output for round-trip
527561

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

33733435
await event_emitter(
33743436
{
@@ -4074,9 +4136,12 @@ async def flush_pending_delta_data(threshold: int = 0):
40744136
or delta.get('reasoning')
40754137
or delta.get('thinking')
40764138
)
4077-
if reasoning_content:
4139+
reasoning_details_chunk = delta.get('reasoning_details')
4140+
4141+
# Ensure a reasoning item exists for both text and structured-block deltas.
4142+
if reasoning_content or reasoning_details_chunk:
40784143
if not output or output[-1].get('type') != 'reasoning':
4079-
reasoning_item = {
4144+
output.append({
40804145
'type': 'reasoning',
40814146
'id': output_id('r'),
40824147
'status': 'in_progress',
@@ -4086,23 +4151,24 @@ async def flush_pending_delta_data(threshold: int = 0):
40864151
'content': [],
40874152
'summary': None,
40884153
'started_at': time.time(),
4089-
}
4090-
output.append(reasoning_item)
4091-
else:
4092-
reasoning_item = output[-1]
4154+
})
40934155

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

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

41084174
if value:

backend/open_webui/utils/misc.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,19 +148,22 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
148148
messages = []
149149
pending_tool_calls = []
150150
pending_content = []
151+
pending_reasoning_details = None
151152

152153
def flush_pending():
153-
nonlocal pending_content, pending_tool_calls
154-
if pending_content or pending_tool_calls:
154+
nonlocal pending_content, pending_tool_calls, pending_reasoning_details
155+
if pending_content or pending_tool_calls or pending_reasoning_details:
155156
messages.append(
156157
{
157158
'role': 'assistant',
158159
'content': '\n'.join(pending_content) if pending_content else '',
159160
**({'tool_calls': pending_tool_calls} if pending_tool_calls else {}),
161+
**({'reasoning_details': pending_reasoning_details} if pending_reasoning_details else {}),
160162
}
161163
)
162164
pending_content = []
163165
pending_tool_calls = []
166+
pending_reasoning_details = None
164167

165168
for item in output:
166169
item_type = item.get('type', '')
@@ -241,16 +244,31 @@ def flush_pending():
241244
elif 'text' in part:
242245
reasoning_text += part.get('text', '')
243246

247+
# Read raw_details before reasoning_text to use it as a guard.
248+
raw_details = item.get('reasoning_details')
249+
244250
if reasoning_text:
245251
start_tag = item.get('start_tag', '<think>')
246252
end_tag = item.get('end_tag', '</think>')
247-
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
253+
# Skip <think> embed for structured-details models.
254+
if not raw_details:
255+
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
248256
# NOTE: Some providers (e.g. Moonshot/Kimi K2.5) require
249257
# reasoning_content as a top-level field on assistant
250258
# messages. This should be handled externally via a
251259
# pipeline filter or connection-level middleware, not
252260
# here — adding it universally breaks strict providers
253261
# (OpenAI, Vertex AI, Azure) that reject unknown fields.
262+
263+
# Preserve raw structured reasoning_details for provider round-trip
264+
if raw_details:
265+
if pending_reasoning_details is None:
266+
pending_reasoning_details = list(raw_details) if isinstance(raw_details, list) else [raw_details]
267+
elif isinstance(pending_reasoning_details, list):
268+
if isinstance(raw_details, list):
269+
pending_reasoning_details.extend(raw_details)
270+
else:
271+
pending_reasoning_details.append(raw_details)
254272
# else: skip reasoning blocks for normal LLM messages
255273

256274
elif item_type == 'open_webui:code_interpreter':

0 commit comments

Comments
 (0)