Skip to content

Commit ee10cc4

Browse files
Algorithm5838github-actions[bot]
authored andcommitted
fix: preserve reasoning_details for multi-turn continuity
1 parent 9906caf commit ee10cc4

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
@@ -219,6 +219,35 @@ def split_json_objects(raw: str) -> list[str]:
219219
return expanded
220220

221221

222+
def _merge_reasoning_details(details: list, chunk: object) -> None:
223+
"""Merge reasoning_details delta items into a list in-place.
224+
225+
Items sharing an integer index are merged: text and summary deltas
226+
concatenate when string-valued, other fields overwrite with the latest
227+
delta. Items without a usable index are appended in arrival order.
228+
"""
229+
items = chunk if isinstance(chunk, list) else ([chunk] if isinstance(chunk, dict) else [])
230+
for item in items:
231+
if not isinstance(item, dict):
232+
continue
233+
idx = item.get('index')
234+
existing = None
235+
if isinstance(idx, int) and idx >= 0:
236+
existing = next((e for e in details if e.get('index') == idx), None)
237+
if existing is None:
238+
new_entry = dict(item)
239+
new_entry.setdefault('type', 'reasoning.text')
240+
details.append(new_entry)
241+
continue
242+
for k, v in item.items():
243+
if k in ('text', 'summary'):
244+
if isinstance(v, str):
245+
base = existing.get(k)
246+
existing[k] = (base + v) if isinstance(base, str) else v
247+
else:
248+
existing[k] = v
249+
250+
222251
def get_citation_source_from_tool_result(
223252
tool_name: str, tool_params: dict, tool_result: str, tool_id: str = ''
224253
) -> list[dict]:
@@ -525,6 +554,8 @@ def serialize_output(output: list) -> str:
525554
pass
526555

527556
reasoning_content = ''.join(reasoning_parts).strip()
557+
if not reasoning_content:
558+
continue # no displayable text; item stays in output for round-trip
528559

529560
duration = item.get('duration')
530561
status = item.get('status', 'in_progress')
@@ -3473,15 +3504,43 @@ async def non_streaming_chat_response_handler(response, ctx):
34733504
# otherwise generate from response content
34743505
response_output = response_data.get('output')
34753506
if not response_output:
3476-
response_output = [
3507+
message_obj = choices[0].get('message', {})
3508+
reasoning_text = (
3509+
message_obj.get('reasoning_content')
3510+
or message_obj.get('reasoning')
3511+
)
3512+
reasoning_details = message_obj.get('reasoning_details')
3513+
3514+
response_output = []
3515+
3516+
if reasoning_text or reasoning_details:
3517+
r_item = {
3518+
'type': 'reasoning',
3519+
'id': output_id('r'),
3520+
'status': 'completed',
3521+
'start_tag': '<think>',
3522+
'end_tag': '</think>',
3523+
'attributes': {'type': 'reasoning_content'},
3524+
'content': [{'type': 'output_text', 'text': reasoning_text}] if reasoning_text else [],
3525+
'summary': None,
3526+
}
3527+
if reasoning_details:
3528+
r_item['reasoning_details'] = (
3529+
reasoning_details
3530+
if isinstance(reasoning_details, list)
3531+
else [reasoning_details]
3532+
)
3533+
response_output.append(r_item)
3534+
3535+
response_output.append(
34773536
{
34783537
'type': 'message',
34793538
'id': output_id('msg'),
34803539
'status': 'completed',
34813540
'role': 'assistant',
34823541
'content': [{'type': 'output_text', 'text': content}],
34833542
}
3484-
]
3543+
)
34853544

34863545
await event_emitter(
34873546
{
@@ -3846,6 +3905,7 @@ def set_last_text(out, text):
38463905
]
38473906
else:
38483907
output = []
3908+
_pending_reasoning_details = []
38493909

38503910
usage = None
38513911
prior_output = []
@@ -4194,9 +4254,14 @@ async def flush_pending_delta_data(threshold: int = 0):
41944254
or delta.get('reasoning')
41954255
or delta.get('thinking')
41964256
)
4257+
reasoning_details_chunk = delta.get('reasoning_details')
4258+
4259+
# Only create a reasoning item for visible reasoning text.
4260+
# Details-only deltas (e.g. Gemini encrypted blobs) are
4261+
# buffered to avoid splitting the assistant message mid-stream.
41974262
if reasoning_content:
41984263
if not output or output[-1].get('type') != 'reasoning':
4199-
reasoning_item = {
4264+
output.append({
42004265
'type': 'reasoning',
42014266
'id': output_id('r'),
42024267
'status': 'in_progress',
@@ -4206,23 +4271,37 @@ async def flush_pending_delta_data(threshold: int = 0):
42064271
'content': [],
42074272
'summary': None,
42084273
'started_at': time.time(),
4209-
}
4210-
output.append(reasoning_item)
4211-
else:
4212-
reasoning_item = output[-1]
4274+
})
42134275

4214-
# Append to reasoning content
4276+
if reasoning_content:
4277+
reasoning_item = output[-1]
42154278
parts = reasoning_item.get('content', [])
42164279
if parts and parts[-1].get('type') == 'output_text':
42174280
parts[-1]['text'] += reasoning_content
42184281
else:
4219-
reasoning_item['content'] = [
4220-
{
4221-
'type': 'output_text',
4222-
'text': reasoning_content,
4223-
}
4224-
]
4282+
reasoning_item['content'] = [{'type': 'output_text', 'text': reasoning_content}]
42254283

4284+
# Flush any buffered details-only chunks into this reasoning item.
4285+
if _pending_reasoning_details:
4286+
_merge_reasoning_details(
4287+
reasoning_item.setdefault('reasoning_details', []),
4288+
_pending_reasoning_details,
4289+
)
4290+
_pending_reasoning_details.clear()
4291+
4292+
# Accumulate raw structured reasoning_details for provider round-trip.
4293+
if reasoning_details_chunk:
4294+
if output and output[-1].get('type') == 'reasoning':
4295+
_merge_reasoning_details(
4296+
output[-1].setdefault('reasoning_details', []),
4297+
reasoning_details_chunk,
4298+
)
4299+
else:
4300+
# Buffer until a safe boundary (end-of-stream or real reasoning text).
4301+
items = reasoning_details_chunk if isinstance(reasoning_details_chunk, list) else [reasoning_details_chunk]
4302+
_pending_reasoning_details.extend(items)
4303+
4304+
if reasoning_content or reasoning_details_chunk:
42264305
data = {'content': serialize_output(full_output())}
42274306

42284307
if value:
@@ -4436,6 +4515,30 @@ async def flush_pending_delta_data(threshold: int = 0):
44364515
)
44374516
reasoning_item['status'] = 'completed'
44384517

4518+
# Flush any buffered reasoning_details that never found a reasoning item.
4519+
if _pending_reasoning_details:
4520+
target = next((item for item in output if item.get('type') == 'reasoning'), None)
4521+
if target is None:
4522+
target = {
4523+
'type': 'reasoning',
4524+
'id': output_id('r'),
4525+
'status': 'completed',
4526+
'start_tag': '<think>',
4527+
'end_tag': '</think>',
4528+
'attributes': {'type': 'reasoning_content'},
4529+
'content': [],
4530+
'summary': None,
4531+
'started_at': time.time(),
4532+
'ended_at': time.time(),
4533+
'duration': 0,
4534+
}
4535+
output.insert(0, target)
4536+
_merge_reasoning_details(
4537+
target.setdefault('reasoning_details', []),
4538+
_pending_reasoning_details,
4539+
)
4540+
_pending_reasoning_details.clear()
4541+
44394542
if response_tool_calls:
44404543
tool_calls.append(_split_tool_calls(response_tool_calls))
44414544

backend/open_webui/utils/misc.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,11 @@ def convert_output_to_messages(
159159
pending_tool_calls = []
160160
pending_content = []
161161
pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content'
162+
pending_reasoning_details = None
162163

163164
def flush_pending():
164-
nonlocal pending_content, pending_tool_calls, pending_reasoning
165-
if not pending_content and not pending_tool_calls and not pending_reasoning:
165+
nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details
166+
if not pending_content and not pending_tool_calls and not pending_reasoning and not pending_reasoning_details:
166167
return
167168

168169
message = {
@@ -174,10 +175,14 @@ def flush_pending():
174175
if pending_reasoning:
175176
message['reasoning_content'] = '\n'.join(pending_reasoning)
176177

178+
if pending_reasoning_details:
179+
message['reasoning_details'] = pending_reasoning_details
180+
177181
messages.append(message)
178182
pending_content = []
179183
pending_tool_calls = []
180184
pending_reasoning = []
185+
pending_reasoning_details = None
181186

182187
for item in output:
183188
item_type = item.get('type', '')
@@ -248,7 +253,7 @@ def flush_pending():
248253
)
249254

250255
elif item_type == 'reasoning':
251-
if not reasoning_format:
256+
if not raw:
252257
continue
253258

254259
reasoning_text = ''
@@ -259,15 +264,28 @@ def flush_pending():
259264
elif 'text' in part:
260265
reasoning_text += part.get('text', '')
261266

267+
raw_details = item.get('reasoning_details')
268+
262269
if reasoning_text:
263-
if reasoning_format == 'think_tags':
264-
# Ollama: embed in content with the item's original tags
270+
if reasoning_format == 'reasoning_content':
271+
# llama.cpp: collect for reasoning_content field
272+
pending_reasoning.append(reasoning_text)
273+
elif not raw_details:
274+
# Wrap reasoning in <think> tags for Ollama and any
275+
# provider that lacks structured reasoning_details.
265276
start_tag = item.get('start_tag', '<think>')
266277
end_tag = item.get('end_tag', '</think>')
267278
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
268-
elif reasoning_format == 'reasoning_content':
269-
# llama.cpp: collect for reasoning_content field
270-
pending_reasoning.append(reasoning_text)
279+
280+
# Preserve raw structured reasoning_details for provider round-trip
281+
if raw_details:
282+
if pending_reasoning_details is None:
283+
pending_reasoning_details = list(raw_details) if isinstance(raw_details, list) else [raw_details]
284+
elif isinstance(pending_reasoning_details, list):
285+
if isinstance(raw_details, list):
286+
pending_reasoning_details.extend(raw_details)
287+
else:
288+
pending_reasoning_details.append(raw_details)
271289

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

0 commit comments

Comments
 (0)