Skip to content

Commit e1234b4

Browse files
Algorithm5838github-actions[bot]
authored andcommitted
fix: preserve reasoning_details for multi-turn continuity
1 parent 7bc8490 commit e1234b4

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')
@@ -3411,15 +3445,43 @@ async def non_streaming_chat_response_handler(response, ctx):
34113445
# otherwise generate from response content
34123446
response_output = response_data.get('output')
34133447
if not response_output:
3414-
response_output = [
3448+
message_obj = choices[0].get('message', {})
3449+
reasoning_text = (
3450+
message_obj.get('reasoning_content')
3451+
or message_obj.get('reasoning')
3452+
)
3453+
reasoning_details = message_obj.get('reasoning_details')
3454+
3455+
response_output = []
3456+
3457+
if reasoning_text or reasoning_details:
3458+
r_item = {
3459+
'type': 'reasoning',
3460+
'id': output_id('r'),
3461+
'status': 'completed',
3462+
'start_tag': '<think>',
3463+
'end_tag': '</think>',
3464+
'attributes': {'type': 'reasoning_content'},
3465+
'content': [{'type': 'output_text', 'text': reasoning_text}] if reasoning_text else [],
3466+
'summary': None,
3467+
}
3468+
if reasoning_details:
3469+
r_item['reasoning_details'] = (
3470+
reasoning_details
3471+
if isinstance(reasoning_details, list)
3472+
else [reasoning_details]
3473+
)
3474+
response_output.append(r_item)
3475+
3476+
response_output.append(
34153477
{
34163478
'type': 'message',
34173479
'id': output_id('msg'),
34183480
'status': 'completed',
34193481
'role': 'assistant',
34203482
'content': [{'type': 'output_text', 'text': content}],
34213483
}
3422-
]
3484+
)
34233485

34243486
await event_emitter(
34253487
{
@@ -3783,6 +3845,7 @@ def set_last_text(out, text):
37833845
]
37843846
else:
37853847
output = []
3848+
_pending_reasoning_details = []
37863849

37873850
usage = None
37883851
prior_output = []
@@ -4125,9 +4188,14 @@ async def flush_pending_delta_data(threshold: int = 0):
41254188
or delta.get('reasoning')
41264189
or delta.get('thinking')
41274190
)
4191+
reasoning_details_chunk = delta.get('reasoning_details')
4192+
4193+
# Only create a reasoning item for visible reasoning text.
4194+
# Details-only deltas (e.g. Gemini encrypted blobs) are
4195+
# buffered to avoid splitting the assistant message mid-stream.
41284196
if reasoning_content:
41294197
if not output or output[-1].get('type') != 'reasoning':
4130-
reasoning_item = {
4198+
output.append({
41314199
'type': 'reasoning',
41324200
'id': output_id('r'),
41334201
'status': 'in_progress',
@@ -4137,23 +4205,37 @@ async def flush_pending_delta_data(threshold: int = 0):
41374205
'content': [],
41384206
'summary': None,
41394207
'started_at': time.time(),
4140-
}
4141-
output.append(reasoning_item)
4142-
else:
4143-
reasoning_item = output[-1]
4208+
})
41444209

4145-
# Append to reasoning content
4210+
if reasoning_content:
4211+
reasoning_item = output[-1]
41464212
parts = reasoning_item.get('content', [])
41474213
if parts and parts[-1].get('type') == 'output_text':
41484214
parts[-1]['text'] += reasoning_content
41494215
else:
4150-
reasoning_item['content'] = [
4151-
{
4152-
'type': 'output_text',
4153-
'text': reasoning_content,
4154-
}
4155-
]
4216+
reasoning_item['content'] = [{'type': 'output_text', 'text': reasoning_content}]
41564217

4218+
# Flush any buffered details-only chunks into this reasoning item.
4219+
if _pending_reasoning_details:
4220+
_merge_reasoning_details(
4221+
reasoning_item.setdefault('reasoning_details', []),
4222+
_pending_reasoning_details,
4223+
)
4224+
_pending_reasoning_details.clear()
4225+
4226+
# Accumulate raw structured reasoning_details for provider round-trip.
4227+
if reasoning_details_chunk:
4228+
if output and output[-1].get('type') == 'reasoning':
4229+
_merge_reasoning_details(
4230+
output[-1].setdefault('reasoning_details', []),
4231+
reasoning_details_chunk,
4232+
)
4233+
else:
4234+
# Buffer until a safe boundary (end-of-stream or real reasoning text).
4235+
items = reasoning_details_chunk if isinstance(reasoning_details_chunk, list) else [reasoning_details_chunk]
4236+
_pending_reasoning_details.extend(items)
4237+
4238+
if reasoning_content or reasoning_details_chunk:
41574239
data = {'content': serialize_output(full_output())}
41584240

41594241
if value:
@@ -4367,6 +4449,30 @@ async def flush_pending_delta_data(threshold: int = 0):
43674449
)
43684450
reasoning_item['status'] = 'completed'
43694451

4452+
# Flush any buffered reasoning_details that never found a reasoning item.
4453+
if _pending_reasoning_details:
4454+
target = next((item for item in output if item.get('type') == 'reasoning'), None)
4455+
if target is None:
4456+
target = {
4457+
'type': 'reasoning',
4458+
'id': output_id('r'),
4459+
'status': 'completed',
4460+
'start_tag': '<think>',
4461+
'end_tag': '</think>',
4462+
'attributes': {'type': 'reasoning_content'},
4463+
'content': [],
4464+
'summary': None,
4465+
'started_at': time.time(),
4466+
'ended_at': time.time(),
4467+
'duration': 0,
4468+
}
4469+
output.insert(0, target)
4470+
_merge_reasoning_details(
4471+
target.setdefault('reasoning_details', []),
4472+
_pending_reasoning_details,
4473+
)
4474+
_pending_reasoning_details.clear()
4475+
43704476
if response_tool_calls:
43714477
tool_calls.append(_split_tool_calls(response_tool_calls))
43724478

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)