Skip to content

Commit 8a7786a

Browse files
Algorithm5838github-actions[bot]
authored andcommitted
fix: preserve reasoning_details for multi-turn continuity
1 parent 1b355a8 commit 8a7786a

2 files changed

Lines changed: 144 additions & 22 deletions

File tree

backend/open_webui/utils/middleware.py

Lines changed: 118 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,35 @@ def split_json_objects(raw: str) -> list[str]:
205205
return expanded
206206

207207

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

511540
reasoning_content = ''.join(reasoning_parts).strip()
541+
if not reasoning_content:
542+
continue # no displayable text; item stays in output for round-trip
512543

513544
duration = item.get('duration')
514545
status = item.get('status', 'in_progress')
@@ -3581,15 +3612,43 @@ async def non_streaming_chat_response_handler(response, ctx):
35813612
# otherwise generate from response content
35823613
response_output = response_data.get('output')
35833614
if not response_output:
3584-
response_output = [
3615+
message_obj = choices[0].get('message', {})
3616+
reasoning_text = (
3617+
message_obj.get('reasoning_content')
3618+
or message_obj.get('reasoning')
3619+
)
3620+
reasoning_details = message_obj.get('reasoning_details')
3621+
3622+
response_output = []
3623+
3624+
if reasoning_text or reasoning_details:
3625+
r_item = {
3626+
'type': 'reasoning',
3627+
'id': output_id('r'),
3628+
'status': 'completed',
3629+
'start_tag': '<think>',
3630+
'end_tag': '</think>',
3631+
'attributes': {'type': 'reasoning_content'},
3632+
'content': [{'type': 'output_text', 'text': reasoning_text}] if reasoning_text else [],
3633+
'summary': None,
3634+
}
3635+
if reasoning_details:
3636+
r_item['reasoning_details'] = (
3637+
reasoning_details
3638+
if isinstance(reasoning_details, list)
3639+
else [reasoning_details]
3640+
)
3641+
response_output.append(r_item)
3642+
3643+
response_output.append(
35853644
{
35863645
'type': 'message',
35873646
'id': output_id('msg'),
35883647
'status': 'completed',
35893648
'role': 'assistant',
35903649
'content': [{'type': 'output_text', 'text': content}],
35913650
}
3592-
]
3651+
)
35933652

35943653
await event_emitter(
35953654
{
@@ -3939,6 +3998,8 @@ def set_last_text(out, text):
39393998

39403999
# Initialize output: use existing from message if continuing, else create new
39414000
existing_output = message.get('output') if message else None
4001+
_pending_reasoning_details = []
4002+
39424003
if existing_output:
39434004
output = existing_output
39444005
else:
@@ -4322,9 +4383,14 @@ async def flush_pending_delta_data(threshold: int = 0):
43224383
or delta.get('reasoning')
43234384
or delta.get('thinking')
43244385
)
4386+
reasoning_details_chunk = delta.get('reasoning_details')
4387+
4388+
# Only create a reasoning item for visible reasoning text.
4389+
# Details-only deltas (e.g. Gemini encrypted blobs) are
4390+
# buffered to avoid splitting the assistant message mid-stream.
43254391
if reasoning_content:
43264392
if not output or output[-1].get('type') != 'reasoning':
4327-
reasoning_item = {
4393+
output.append({
43284394
'type': 'reasoning',
43294395
'id': output_id('r'),
43304396
'status': 'in_progress',
@@ -4334,23 +4400,37 @@ async def flush_pending_delta_data(threshold: int = 0):
43344400
'content': [],
43354401
'summary': None,
43364402
'started_at': time.time(),
4337-
}
4338-
output.append(reasoning_item)
4339-
else:
4340-
reasoning_item = output[-1]
4403+
})
43414404

4342-
# Append to reasoning content
4405+
if reasoning_content:
4406+
reasoning_item = output[-1]
43434407
parts = reasoning_item.get('content', [])
43444408
if parts and parts[-1].get('type') == 'output_text':
43454409
parts[-1]['text'] += reasoning_content
43464410
else:
4347-
reasoning_item['content'] = [
4348-
{
4349-
'type': 'output_text',
4350-
'text': reasoning_content,
4351-
}
4352-
]
4411+
reasoning_item['content'] = [{'type': 'output_text', 'text': reasoning_content}]
43534412

4413+
# Flush any buffered details-only chunks into this reasoning item.
4414+
if _pending_reasoning_details:
4415+
_merge_reasoning_details(
4416+
reasoning_item.setdefault('reasoning_details', []),
4417+
_pending_reasoning_details,
4418+
)
4419+
_pending_reasoning_details.clear()
4420+
4421+
# Accumulate raw structured reasoning_details for provider round-trip.
4422+
if reasoning_details_chunk:
4423+
if output and output[-1].get('type') == 'reasoning':
4424+
_merge_reasoning_details(
4425+
output[-1].setdefault('reasoning_details', []),
4426+
reasoning_details_chunk,
4427+
)
4428+
else:
4429+
# Buffer until a safe boundary (end-of-stream or real reasoning text).
4430+
items = reasoning_details_chunk if isinstance(reasoning_details_chunk, list) else [reasoning_details_chunk]
4431+
_pending_reasoning_details.extend(items)
4432+
4433+
if reasoning_content or reasoning_details_chunk:
43544434
data = {'content': serialize_output(full_output())}
43554435

43564436
if value:
@@ -4566,6 +4646,30 @@ async def flush_pending_delta_data(threshold: int = 0):
45664646
)
45674647
reasoning_item['status'] = 'completed'
45684648

4649+
# Flush any buffered reasoning_details that never found a reasoning item.
4650+
if _pending_reasoning_details:
4651+
target = next((item for item in output if item.get('type') == 'reasoning'), None)
4652+
if target is None:
4653+
target = {
4654+
'type': 'reasoning',
4655+
'id': output_id('r'),
4656+
'status': 'completed',
4657+
'start_tag': '<think>',
4658+
'end_tag': '</think>',
4659+
'attributes': {'type': 'reasoning_content'},
4660+
'content': [],
4661+
'summary': None,
4662+
'started_at': time.time(),
4663+
'ended_at': time.time(),
4664+
'duration': 0,
4665+
}
4666+
output.insert(0, target)
4667+
_merge_reasoning_details(
4668+
target.setdefault('reasoning_details', []),
4669+
_pending_reasoning_details,
4670+
)
4671+
_pending_reasoning_details.clear()
4672+
45694673
if response_tool_calls:
45704674
tool_calls.append(_split_tool_calls(response_tool_calls))
45714675

backend/open_webui/utils/misc.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,11 @@ def convert_output_to_messages(
250250
pending_tool_calls = []
251251
pending_content = []
252252
pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content'
253+
pending_reasoning_details = None
253254

254255
def flush_pending():
255-
nonlocal pending_content, pending_tool_calls, pending_reasoning
256-
if not pending_content and not pending_tool_calls and not pending_reasoning:
256+
nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details
257+
if not pending_content and not pending_tool_calls and not pending_reasoning and not pending_reasoning_details:
257258
return
258259

259260
message = {
@@ -265,10 +266,14 @@ def flush_pending():
265266
if pending_reasoning:
266267
message['reasoning_content'] = '\n'.join(pending_reasoning)
267268

269+
if pending_reasoning_details:
270+
message['reasoning_details'] = pending_reasoning_details
271+
268272
messages.append(message)
269273
pending_content = []
270274
pending_tool_calls = []
271275
pending_reasoning = []
276+
pending_reasoning_details = None
272277

273278
for item in output:
274279
item_type = item.get('type', '')
@@ -339,7 +344,7 @@ def flush_pending():
339344
)
340345

341346
elif item_type == 'reasoning':
342-
if not reasoning_format:
347+
if not raw:
343348
continue
344349

345350
reasoning_text = ''
@@ -350,15 +355,28 @@ def flush_pending():
350355
elif 'text' in part:
351356
reasoning_text += part.get('text', '')
352357

358+
raw_details = item.get('reasoning_details')
359+
353360
if reasoning_text:
354-
if reasoning_format == 'think_tags':
355-
# Ollama: embed in content with the item's original tags
361+
if reasoning_format == 'reasoning_content':
362+
# llama.cpp: collect for reasoning_content field
363+
pending_reasoning.append(reasoning_text)
364+
elif not raw_details:
365+
# Wrap reasoning in <think> tags for Ollama and any
366+
# provider that lacks structured reasoning_details.
356367
start_tag = item.get('start_tag', '<think>')
357368
end_tag = item.get('end_tag', '</think>')
358369
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
359-
elif reasoning_format == 'reasoning_content':
360-
# llama.cpp: collect for reasoning_content field
361-
pending_reasoning.append(reasoning_text)
370+
371+
# Preserve raw structured reasoning_details for provider round-trip
372+
if raw_details:
373+
if pending_reasoning_details is None:
374+
pending_reasoning_details = list(raw_details) if isinstance(raw_details, list) else [raw_details]
375+
elif isinstance(pending_reasoning_details, list):
376+
if isinstance(raw_details, list):
377+
pending_reasoning_details.extend(raw_details)
378+
else:
379+
pending_reasoning_details.append(raw_details)
362380

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

0 commit comments

Comments
 (0)