Skip to content

Commit 971ea03

Browse files
committed
fix: preserve reasoning_details for multi-turn continuity
1 parent d7c787f commit 971ea03

2 files changed

Lines changed: 141 additions & 17 deletions

File tree

backend/open_webui/utils/middleware.py

Lines changed: 120 additions & 14 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')
@@ -3421,15 +3455,43 @@ async def non_streaming_chat_response_handler(response, ctx):
34213455
# otherwise generate from response content
34223456
response_output = response_data.get('output')
34233457
if not response_output:
3424-
response_output = [
3458+
message_obj = choices[0].get('message', {})
3459+
reasoning_text = (
3460+
message_obj.get('reasoning_content')
3461+
or message_obj.get('reasoning')
3462+
)
3463+
reasoning_details = message_obj.get('reasoning_details')
3464+
3465+
response_output = []
3466+
3467+
if reasoning_text or reasoning_details:
3468+
r_item = {
3469+
'type': 'reasoning',
3470+
'id': output_id('r'),
3471+
'status': 'completed',
3472+
'start_tag': '<think>',
3473+
'end_tag': '</think>',
3474+
'attributes': {'type': 'reasoning_content'},
3475+
'content': [{'type': 'output_text', 'text': reasoning_text}] if reasoning_text else [],
3476+
'summary': None,
3477+
}
3478+
if reasoning_details:
3479+
r_item['reasoning_details'] = (
3480+
reasoning_details
3481+
if isinstance(reasoning_details, list)
3482+
else [reasoning_details]
3483+
)
3484+
response_output.append(r_item)
3485+
3486+
response_output.append(
34253487
{
34263488
'type': 'message',
34273489
'id': output_id('msg'),
34283490
'status': 'completed',
34293491
'role': 'assistant',
34303492
'content': [{'type': 'output_text', 'text': content}],
34313493
}
3432-
]
3494+
)
34333495

34343496
await event_emitter(
34353497
{
@@ -3793,6 +3855,7 @@ def set_last_text(out, text):
37933855
]
37943856
else:
37953857
output = []
3858+
_pending_reasoning_details = []
37963859

37973860
usage = None
37983861
prior_output = []
@@ -4141,9 +4204,14 @@ async def flush_pending_delta_data(threshold: int = 0):
41414204
or delta.get('reasoning')
41424205
or delta.get('thinking')
41434206
)
4207+
reasoning_details_chunk = delta.get('reasoning_details')
4208+
4209+
# Only create a reasoning item for visible reasoning text.
4210+
# Details-only deltas (e.g. Gemini encrypted blobs) are
4211+
# buffered to avoid splitting the assistant message mid-stream.
41444212
if reasoning_content:
41454213
if not output or output[-1].get('type') != 'reasoning':
4146-
reasoning_item = {
4214+
output.append({
41474215
'type': 'reasoning',
41484216
'id': output_id('r'),
41494217
'status': 'in_progress',
@@ -4153,23 +4221,37 @@ async def flush_pending_delta_data(threshold: int = 0):
41534221
'content': [],
41544222
'summary': None,
41554223
'started_at': time.time(),
4156-
}
4157-
output.append(reasoning_item)
4158-
else:
4159-
reasoning_item = output[-1]
4224+
})
41604225

4161-
# Append to reasoning content
4226+
if reasoning_content:
4227+
reasoning_item = output[-1]
41624228
parts = reasoning_item.get('content', [])
41634229
if parts and parts[-1].get('type') == 'output_text':
41644230
parts[-1]['text'] += reasoning_content
41654231
else:
4166-
reasoning_item['content'] = [
4167-
{
4168-
'type': 'output_text',
4169-
'text': reasoning_content,
4170-
}
4171-
]
4232+
reasoning_item['content'] = [{'type': 'output_text', 'text': reasoning_content}]
41724233

4234+
# Flush any buffered details-only chunks into this reasoning item.
4235+
if _pending_reasoning_details:
4236+
_merge_reasoning_details(
4237+
reasoning_item.setdefault('reasoning_details', []),
4238+
_pending_reasoning_details,
4239+
)
4240+
_pending_reasoning_details.clear()
4241+
4242+
# Accumulate raw structured reasoning_details for provider round-trip.
4243+
if reasoning_details_chunk:
4244+
if output and output[-1].get('type') == 'reasoning':
4245+
_merge_reasoning_details(
4246+
output[-1].setdefault('reasoning_details', []),
4247+
reasoning_details_chunk,
4248+
)
4249+
else:
4250+
# Buffer until a safe boundary (end-of-stream or real reasoning text).
4251+
items = reasoning_details_chunk if isinstance(reasoning_details_chunk, list) else [reasoning_details_chunk]
4252+
_pending_reasoning_details.extend(items)
4253+
4254+
if reasoning_content or reasoning_details_chunk:
41734255
data = {'content': serialize_output(full_output())}
41744256

41754257
if value:
@@ -4383,6 +4465,30 @@ async def flush_pending_delta_data(threshold: int = 0):
43834465
)
43844466
reasoning_item['status'] = 'completed'
43854467

4468+
# Flush any buffered reasoning_details that never found a reasoning item.
4469+
if _pending_reasoning_details:
4470+
target = next((item for item in output if item.get('type') == 'reasoning'), None)
4471+
if target is None:
4472+
target = {
4473+
'type': 'reasoning',
4474+
'id': output_id('r'),
4475+
'status': 'completed',
4476+
'start_tag': '<think>',
4477+
'end_tag': '</think>',
4478+
'attributes': {'type': 'reasoning_content'},
4479+
'content': [],
4480+
'summary': None,
4481+
'started_at': time.time(),
4482+
'ended_at': time.time(),
4483+
'duration': 0,
4484+
}
4485+
output.insert(0, target)
4486+
_merge_reasoning_details(
4487+
target.setdefault('reasoning_details', []),
4488+
_pending_reasoning_details,
4489+
)
4490+
_pending_reasoning_details.clear()
4491+
43864492
if response_tool_calls:
43874493
tool_calls.append(_split_tool_calls(response_tool_calls))
43884494

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)