Skip to content

Commit 6d56a66

Browse files
Algorithm5838github-actions[bot]
authored andcommitted
fix: preserve reasoning_details for multi-turn continuity
1 parent 155fbfc commit 6d56a66

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]:
@@ -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')
@@ -3443,15 +3474,43 @@ async def non_streaming_chat_response_handler(response, ctx):
34433474
# otherwise generate from response content
34443475
response_output = response_data.get('output')
34453476
if not response_output:
3446-
response_output = [
3477+
message_obj = choices[0].get('message', {})
3478+
reasoning_text = (
3479+
message_obj.get('reasoning_content')
3480+
or message_obj.get('reasoning')
3481+
)
3482+
reasoning_details = message_obj.get('reasoning_details')
3483+
3484+
response_output = []
3485+
3486+
if reasoning_text or reasoning_details:
3487+
r_item = {
3488+
'type': 'reasoning',
3489+
'id': output_id('r'),
3490+
'status': 'completed',
3491+
'start_tag': '<think>',
3492+
'end_tag': '</think>',
3493+
'attributes': {'type': 'reasoning_content'},
3494+
'content': [{'type': 'output_text', 'text': reasoning_text}] if reasoning_text else [],
3495+
'summary': None,
3496+
}
3497+
if reasoning_details:
3498+
r_item['reasoning_details'] = (
3499+
reasoning_details
3500+
if isinstance(reasoning_details, list)
3501+
else [reasoning_details]
3502+
)
3503+
response_output.append(r_item)
3504+
3505+
response_output.append(
34473506
{
34483507
'type': 'message',
34493508
'id': output_id('msg'),
34503509
'status': 'completed',
34513510
'role': 'assistant',
34523511
'content': [{'type': 'output_text', 'text': content}],
34533512
}
3454-
]
3513+
)
34553514

34563515
await event_emitter(
34573516
{
@@ -3816,6 +3875,7 @@ def set_last_text(out, text):
38163875
]
38173876
else:
38183877
output = []
3878+
_pending_reasoning_details = []
38193879

38203880
usage = None
38213881
prior_output = []
@@ -4164,9 +4224,14 @@ async def flush_pending_delta_data(threshold: int = 0):
41644224
or delta.get('reasoning')
41654225
or delta.get('thinking')
41664226
)
4227+
reasoning_details_chunk = delta.get('reasoning_details')
4228+
4229+
# Only create a reasoning item for visible reasoning text.
4230+
# Details-only deltas (e.g. Gemini encrypted blobs) are
4231+
# buffered to avoid splitting the assistant message mid-stream.
41674232
if reasoning_content:
41684233
if not output or output[-1].get('type') != 'reasoning':
4169-
reasoning_item = {
4234+
output.append({
41704235
'type': 'reasoning',
41714236
'id': output_id('r'),
41724237
'status': 'in_progress',
@@ -4176,23 +4241,37 @@ async def flush_pending_delta_data(threshold: int = 0):
41764241
'content': [],
41774242
'summary': None,
41784243
'started_at': time.time(),
4179-
}
4180-
output.append(reasoning_item)
4181-
else:
4182-
reasoning_item = output[-1]
4244+
})
41834245

4184-
# Append to reasoning content
4246+
if reasoning_content:
4247+
reasoning_item = output[-1]
41854248
parts = reasoning_item.get('content', [])
41864249
if parts and parts[-1].get('type') == 'output_text':
41874250
parts[-1]['text'] += reasoning_content
41884251
else:
4189-
reasoning_item['content'] = [
4190-
{
4191-
'type': 'output_text',
4192-
'text': reasoning_content,
4193-
}
4194-
]
4252+
reasoning_item['content'] = [{'type': 'output_text', 'text': reasoning_content}]
41954253

4254+
# Flush any buffered details-only chunks into this reasoning item.
4255+
if _pending_reasoning_details:
4256+
_merge_reasoning_details(
4257+
reasoning_item.setdefault('reasoning_details', []),
4258+
_pending_reasoning_details,
4259+
)
4260+
_pending_reasoning_details.clear()
4261+
4262+
# Accumulate raw structured reasoning_details for provider round-trip.
4263+
if reasoning_details_chunk:
4264+
if output and output[-1].get('type') == 'reasoning':
4265+
_merge_reasoning_details(
4266+
output[-1].setdefault('reasoning_details', []),
4267+
reasoning_details_chunk,
4268+
)
4269+
else:
4270+
# Buffer until a safe boundary (end-of-stream or real reasoning text).
4271+
items = reasoning_details_chunk if isinstance(reasoning_details_chunk, list) else [reasoning_details_chunk]
4272+
_pending_reasoning_details.extend(items)
4273+
4274+
if reasoning_content or reasoning_details_chunk:
41964275
data = {'content': serialize_output(full_output())}
41974276

41984277
if value:
@@ -4406,6 +4485,30 @@ async def flush_pending_delta_data(threshold: int = 0):
44064485
)
44074486
reasoning_item['status'] = 'completed'
44084487

4488+
# Flush any buffered reasoning_details that never found a reasoning item.
4489+
if _pending_reasoning_details:
4490+
target = next((item for item in output if item.get('type') == 'reasoning'), None)
4491+
if target is None:
4492+
target = {
4493+
'type': 'reasoning',
4494+
'id': output_id('r'),
4495+
'status': 'completed',
4496+
'start_tag': '<think>',
4497+
'end_tag': '</think>',
4498+
'attributes': {'type': 'reasoning_content'},
4499+
'content': [],
4500+
'summary': None,
4501+
'started_at': time.time(),
4502+
'ended_at': time.time(),
4503+
'duration': 0,
4504+
}
4505+
output.insert(0, target)
4506+
_merge_reasoning_details(
4507+
target.setdefault('reasoning_details', []),
4508+
_pending_reasoning_details,
4509+
)
4510+
_pending_reasoning_details.clear()
4511+
44094512
if response_tool_calls:
44104513
tool_calls.append(_split_tool_calls(response_tool_calls))
44114514

backend/open_webui/utils/misc.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,11 @@ def convert_output_to_messages(
218218
pending_tool_calls = []
219219
pending_content = []
220220
pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content'
221+
pending_reasoning_details = None
221222

222223
def flush_pending():
223-
nonlocal pending_content, pending_tool_calls, pending_reasoning
224-
if not pending_content and not pending_tool_calls and not pending_reasoning:
224+
nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details
225+
if not pending_content and not pending_tool_calls and not pending_reasoning and not pending_reasoning_details:
225226
return
226227

227228
message = {
@@ -233,10 +234,14 @@ def flush_pending():
233234
if pending_reasoning:
234235
message['reasoning_content'] = '\n'.join(pending_reasoning)
235236

237+
if pending_reasoning_details:
238+
message['reasoning_details'] = pending_reasoning_details
239+
236240
messages.append(message)
237241
pending_content = []
238242
pending_tool_calls = []
239243
pending_reasoning = []
244+
pending_reasoning_details = None
240245

241246
for item in output:
242247
item_type = item.get('type', '')
@@ -307,7 +312,7 @@ def flush_pending():
307312
)
308313

309314
elif item_type == 'reasoning':
310-
if not reasoning_format:
315+
if not raw:
311316
continue
312317

313318
reasoning_text = ''
@@ -318,15 +323,28 @@ def flush_pending():
318323
elif 'text' in part:
319324
reasoning_text += part.get('text', '')
320325

326+
raw_details = item.get('reasoning_details')
327+
321328
if reasoning_text:
322-
if reasoning_format == 'think_tags':
323-
# Ollama: embed in content with the item's original tags
329+
if reasoning_format == 'reasoning_content':
330+
# llama.cpp: collect for reasoning_content field
331+
pending_reasoning.append(reasoning_text)
332+
elif not raw_details:
333+
# Wrap reasoning in <think> tags for Ollama and any
334+
# provider that lacks structured reasoning_details.
324335
start_tag = item.get('start_tag', '<think>')
325336
end_tag = item.get('end_tag', '</think>')
326337
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
327-
elif reasoning_format == 'reasoning_content':
328-
# llama.cpp: collect for reasoning_content field
329-
pending_reasoning.append(reasoning_text)
338+
339+
# Preserve raw structured reasoning_details for provider round-trip
340+
if raw_details:
341+
if pending_reasoning_details is None:
342+
pending_reasoning_details = list(raw_details) if isinstance(raw_details, list) else [raw_details]
343+
elif isinstance(pending_reasoning_details, list):
344+
if isinstance(raw_details, list):
345+
pending_reasoning_details.extend(raw_details)
346+
else:
347+
pending_reasoning_details.append(raw_details)
330348

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

0 commit comments

Comments
 (0)