Skip to content

Commit 478c5fb

Browse files
committed
fix: preserve reasoning_details for multi-turn continuity
1 parent 93e8e30 commit 478c5fb

2 files changed

Lines changed: 143 additions & 22 deletions

File tree

backend/open_webui/utils/middleware.py

Lines changed: 117 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,35 @@ def split_json_objects(raw: str) -> list[str]:
203203
return expanded
204204

205205

206+
def _merge_reasoning_details(details: list, chunk: object) -> None:
207+
"""Merge reasoning_details delta items into a list in-place.
208+
209+
Items sharing an integer index are merged: text and summary deltas
210+
concatenate when string-valued, other fields overwrite with the latest
211+
delta. Items without a usable index are appended in arrival order.
212+
"""
213+
items = chunk if isinstance(chunk, list) else ([chunk] if isinstance(chunk, dict) else [])
214+
for item in items:
215+
if not isinstance(item, dict):
216+
continue
217+
idx = item.get('index')
218+
existing = None
219+
if isinstance(idx, int) and idx >= 0:
220+
existing = next((e for e in details if e.get('index') == idx), None)
221+
if existing is None:
222+
new_entry = dict(item)
223+
new_entry.setdefault('type', 'reasoning.text')
224+
details.append(new_entry)
225+
continue
226+
for k, v in item.items():
227+
if k in ('text', 'summary'):
228+
if isinstance(v, str):
229+
base = existing.get(k)
230+
existing[k] = (base + v) if isinstance(base, str) else v
231+
else:
232+
existing[k] = v
233+
234+
206235
def get_citation_source_from_tool_result(
207236
tool_name: str, tool_params: dict, tool_result: str, tool_id: str = ''
208237
) -> list[dict]:
@@ -507,6 +536,8 @@ def serialize_output(output: list) -> str:
507536
pass
508537

509538
reasoning_content = ''.join(reasoning_parts).strip()
539+
if not reasoning_content:
540+
continue # no displayable text; item stays in output for round-trip
510541

511542
duration = item.get('duration')
512543
status = item.get('status', 'in_progress')
@@ -3493,15 +3524,43 @@ async def non_streaming_chat_response_handler(response, ctx):
34933524
# otherwise generate from response content
34943525
response_output = response_data.get('output')
34953526
if not response_output:
3496-
response_output = [
3527+
message_obj = choices[0].get('message', {})
3528+
reasoning_text = (
3529+
message_obj.get('reasoning_content')
3530+
or message_obj.get('reasoning')
3531+
)
3532+
reasoning_details = message_obj.get('reasoning_details')
3533+
3534+
response_output = []
3535+
3536+
if reasoning_text or reasoning_details:
3537+
r_item = {
3538+
'type': 'reasoning',
3539+
'id': output_id('r'),
3540+
'status': 'completed',
3541+
'start_tag': '<think>',
3542+
'end_tag': '</think>',
3543+
'attributes': {'type': 'reasoning_content'},
3544+
'content': [{'type': 'output_text', 'text': reasoning_text}] if reasoning_text else [],
3545+
'summary': None,
3546+
}
3547+
if reasoning_details:
3548+
r_item['reasoning_details'] = (
3549+
reasoning_details
3550+
if isinstance(reasoning_details, list)
3551+
else [reasoning_details]
3552+
)
3553+
response_output.append(r_item)
3554+
3555+
response_output.append(
34973556
{
34983557
'type': 'message',
34993558
'id': output_id('msg'),
35003559
'status': 'completed',
35013560
'role': 'assistant',
35023561
'content': [{'type': 'output_text', 'text': content}],
35033562
}
3504-
]
3563+
)
35053564

35063565
await event_emitter(
35073566
{
@@ -3866,6 +3925,7 @@ def set_last_text(out, text):
38663925
]
38673926
else:
38683927
output = []
3928+
_pending_reasoning_details = []
38693929

38703930
usage = None
38713931
prior_output = []
@@ -4233,9 +4293,14 @@ async def flush_pending_delta_data(threshold: int = 0):
42334293
or delta.get('reasoning')
42344294
or delta.get('thinking')
42354295
)
4296+
reasoning_details_chunk = delta.get('reasoning_details')
4297+
4298+
# Only create a reasoning item for visible reasoning text.
4299+
# Details-only deltas (e.g. Gemini encrypted blobs) are
4300+
# buffered to avoid splitting the assistant message mid-stream.
42364301
if reasoning_content:
42374302
if not output or output[-1].get('type') != 'reasoning':
4238-
reasoning_item = {
4303+
output.append({
42394304
'type': 'reasoning',
42404305
'id': output_id('r'),
42414306
'status': 'in_progress',
@@ -4245,23 +4310,37 @@ async def flush_pending_delta_data(threshold: int = 0):
42454310
'content': [],
42464311
'summary': None,
42474312
'started_at': time.time(),
4248-
}
4249-
output.append(reasoning_item)
4250-
else:
4251-
reasoning_item = output[-1]
4313+
})
42524314

4253-
# Append to reasoning content
4315+
if reasoning_content:
4316+
reasoning_item = output[-1]
42544317
parts = reasoning_item.get('content', [])
42554318
if parts and parts[-1].get('type') == 'output_text':
42564319
parts[-1]['text'] += reasoning_content
42574320
else:
4258-
reasoning_item['content'] = [
4259-
{
4260-
'type': 'output_text',
4261-
'text': reasoning_content,
4262-
}
4263-
]
4321+
reasoning_item['content'] = [{'type': 'output_text', 'text': reasoning_content}]
42644322

4323+
# Flush any buffered details-only chunks into this reasoning item.
4324+
if _pending_reasoning_details:
4325+
_merge_reasoning_details(
4326+
reasoning_item.setdefault('reasoning_details', []),
4327+
_pending_reasoning_details,
4328+
)
4329+
_pending_reasoning_details.clear()
4330+
4331+
# Accumulate raw structured reasoning_details for provider round-trip.
4332+
if reasoning_details_chunk:
4333+
if output and output[-1].get('type') == 'reasoning':
4334+
_merge_reasoning_details(
4335+
output[-1].setdefault('reasoning_details', []),
4336+
reasoning_details_chunk,
4337+
)
4338+
else:
4339+
# Buffer until a safe boundary (end-of-stream or real reasoning text).
4340+
items = reasoning_details_chunk if isinstance(reasoning_details_chunk, list) else [reasoning_details_chunk]
4341+
_pending_reasoning_details.extend(items)
4342+
4343+
if reasoning_content or reasoning_details_chunk:
42654344
data = {'content': serialize_output(full_output())}
42664345

42674346
if value:
@@ -4477,6 +4556,30 @@ async def flush_pending_delta_data(threshold: int = 0):
44774556
)
44784557
reasoning_item['status'] = 'completed'
44794558

4559+
# Flush any buffered reasoning_details that never found a reasoning item.
4560+
if _pending_reasoning_details:
4561+
target = next((item for item in output if item.get('type') == 'reasoning'), None)
4562+
if target is None:
4563+
target = {
4564+
'type': 'reasoning',
4565+
'id': output_id('r'),
4566+
'status': 'completed',
4567+
'start_tag': '<think>',
4568+
'end_tag': '</think>',
4569+
'attributes': {'type': 'reasoning_content'},
4570+
'content': [],
4571+
'summary': None,
4572+
'started_at': time.time(),
4573+
'ended_at': time.time(),
4574+
'duration': 0,
4575+
}
4576+
output.insert(0, target)
4577+
_merge_reasoning_details(
4578+
target.setdefault('reasoning_details', []),
4579+
_pending_reasoning_details,
4580+
)
4581+
_pending_reasoning_details.clear()
4582+
44804583
if response_tool_calls:
44814584
tool_calls.append(_split_tool_calls(response_tool_calls))
44824585

backend/open_webui/utils/misc.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -212,10 +212,11 @@ def convert_output_to_messages(
212212
pending_tool_calls = []
213213
pending_content = []
214214
pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content'
215+
pending_reasoning_details = None
215216

216217
def flush_pending():
217-
nonlocal pending_content, pending_tool_calls, pending_reasoning
218-
if not pending_content and not pending_tool_calls and not pending_reasoning:
218+
nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details
219+
if not pending_content and not pending_tool_calls and not pending_reasoning and not pending_reasoning_details:
219220
return
220221

221222
message = {
@@ -227,10 +228,14 @@ def flush_pending():
227228
if pending_reasoning:
228229
message['reasoning_content'] = '\n'.join(pending_reasoning)
229230

231+
if pending_reasoning_details:
232+
message['reasoning_details'] = pending_reasoning_details
233+
230234
messages.append(message)
231235
pending_content = []
232236
pending_tool_calls = []
233237
pending_reasoning = []
238+
pending_reasoning_details = None
234239

235240
for item in output:
236241
item_type = item.get('type', '')
@@ -301,7 +306,7 @@ def flush_pending():
301306
)
302307

303308
elif item_type == 'reasoning':
304-
if not reasoning_format:
309+
if not raw:
305310
continue
306311

307312
reasoning_text = ''
@@ -312,15 +317,28 @@ def flush_pending():
312317
elif 'text' in part:
313318
reasoning_text += part.get('text', '')
314319

320+
raw_details = item.get('reasoning_details')
321+
315322
if reasoning_text:
316-
if reasoning_format == 'think_tags':
317-
# Ollama: embed in content with the item's original tags
323+
if reasoning_format == 'reasoning_content':
324+
# llama.cpp: collect for reasoning_content field
325+
pending_reasoning.append(reasoning_text)
326+
elif not raw_details:
327+
# Wrap reasoning in <think> tags for Ollama and any
328+
# provider that lacks structured reasoning_details.
318329
start_tag = item.get('start_tag', '<think>')
319330
end_tag = item.get('end_tag', '</think>')
320331
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
321-
elif reasoning_format == 'reasoning_content':
322-
# llama.cpp: collect for reasoning_content field
323-
pending_reasoning.append(reasoning_text)
332+
333+
# Preserve raw structured reasoning_details for provider round-trip
334+
if raw_details:
335+
if pending_reasoning_details is None:
336+
pending_reasoning_details = list(raw_details) if isinstance(raw_details, list) else [raw_details]
337+
elif isinstance(pending_reasoning_details, list):
338+
if isinstance(raw_details, list):
339+
pending_reasoning_details.extend(raw_details)
340+
else:
341+
pending_reasoning_details.append(raw_details)
324342

325343
elif item_type == 'open_webui:code_interpreter':
326344
# Always include code interpreter content so the LLM knows

0 commit comments

Comments
 (0)