Skip to content

Commit b4fd234

Browse files
committed
fix: preserve reasoning_details for multi-turn continuity
1 parent 6557e54 commit b4fd234

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
@@ -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]:
@@ -511,6 +540,8 @@ def serialize_output(output: list) -> str:
511540
pass
512541

513542
reasoning_content = ''.join(reasoning_parts).strip()
543+
if not reasoning_content:
544+
continue # no displayable text; item stays in output for round-trip
514545

515546
duration = item.get('duration')
516547
status = item.get('status', 'in_progress')
@@ -3459,15 +3490,43 @@ async def non_streaming_chat_response_handler(response, ctx):
34593490
# otherwise generate from response content
34603491
response_output = response_data.get('output')
34613492
if not response_output:
3462-
response_output = [
3493+
message_obj = choices[0].get('message', {})
3494+
reasoning_text = (
3495+
message_obj.get('reasoning_content')
3496+
or message_obj.get('reasoning')
3497+
)
3498+
reasoning_details = message_obj.get('reasoning_details')
3499+
3500+
response_output = []
3501+
3502+
if reasoning_text or reasoning_details:
3503+
r_item = {
3504+
'type': 'reasoning',
3505+
'id': output_id('r'),
3506+
'status': 'completed',
3507+
'start_tag': '<think>',
3508+
'end_tag': '</think>',
3509+
'attributes': {'type': 'reasoning_content'},
3510+
'content': [{'type': 'output_text', 'text': reasoning_text}] if reasoning_text else [],
3511+
'summary': None,
3512+
}
3513+
if reasoning_details:
3514+
r_item['reasoning_details'] = (
3515+
reasoning_details
3516+
if isinstance(reasoning_details, list)
3517+
else [reasoning_details]
3518+
)
3519+
response_output.append(r_item)
3520+
3521+
response_output.append(
34633522
{
34643523
'type': 'message',
34653524
'id': output_id('msg'),
34663525
'status': 'completed',
34673526
'role': 'assistant',
34683527
'content': [{'type': 'output_text', 'text': content}],
34693528
}
3470-
]
3529+
)
34713530

34723531
await event_emitter(
34733532
{
@@ -3832,6 +3891,7 @@ def set_last_text(out, text):
38323891
]
38333892
else:
38343893
output = []
3894+
_pending_reasoning_details = []
38353895

38363896
usage = None
38373897
prior_output = []
@@ -4180,9 +4240,14 @@ async def flush_pending_delta_data(threshold: int = 0):
41804240
or delta.get('reasoning')
41814241
or delta.get('thinking')
41824242
)
4243+
reasoning_details_chunk = delta.get('reasoning_details')
4244+
4245+
# Only create a reasoning item for visible reasoning text.
4246+
# Details-only deltas (e.g. Gemini encrypted blobs) are
4247+
# buffered to avoid splitting the assistant message mid-stream.
41834248
if reasoning_content:
41844249
if not output or output[-1].get('type') != 'reasoning':
4185-
reasoning_item = {
4250+
output.append({
41864251
'type': 'reasoning',
41874252
'id': output_id('r'),
41884253
'status': 'in_progress',
@@ -4192,23 +4257,37 @@ async def flush_pending_delta_data(threshold: int = 0):
41924257
'content': [],
41934258
'summary': None,
41944259
'started_at': time.time(),
4195-
}
4196-
output.append(reasoning_item)
4197-
else:
4198-
reasoning_item = output[-1]
4260+
})
41994261

4200-
# Append to reasoning content
4262+
if reasoning_content:
4263+
reasoning_item = output[-1]
42014264
parts = reasoning_item.get('content', [])
42024265
if parts and parts[-1].get('type') == 'output_text':
42034266
parts[-1]['text'] += reasoning_content
42044267
else:
4205-
reasoning_item['content'] = [
4206-
{
4207-
'type': 'output_text',
4208-
'text': reasoning_content,
4209-
}
4210-
]
4268+
reasoning_item['content'] = [{'type': 'output_text', 'text': reasoning_content}]
42114269

4270+
# Flush any buffered details-only chunks into this reasoning item.
4271+
if _pending_reasoning_details:
4272+
_merge_reasoning_details(
4273+
reasoning_item.setdefault('reasoning_details', []),
4274+
_pending_reasoning_details,
4275+
)
4276+
_pending_reasoning_details.clear()
4277+
4278+
# Accumulate raw structured reasoning_details for provider round-trip.
4279+
if reasoning_details_chunk:
4280+
if output and output[-1].get('type') == 'reasoning':
4281+
_merge_reasoning_details(
4282+
output[-1].setdefault('reasoning_details', []),
4283+
reasoning_details_chunk,
4284+
)
4285+
else:
4286+
# Buffer until a safe boundary (end-of-stream or real reasoning text).
4287+
items = reasoning_details_chunk if isinstance(reasoning_details_chunk, list) else [reasoning_details_chunk]
4288+
_pending_reasoning_details.extend(items)
4289+
4290+
if reasoning_content or reasoning_details_chunk:
42124291
data = {'content': serialize_output(full_output())}
42134292

42144293
if value:
@@ -4422,6 +4501,30 @@ async def flush_pending_delta_data(threshold: int = 0):
44224501
)
44234502
reasoning_item['status'] = 'completed'
44244503

4504+
# Flush any buffered reasoning_details that never found a reasoning item.
4505+
if _pending_reasoning_details:
4506+
target = next((item for item in output if item.get('type') == 'reasoning'), None)
4507+
if target is None:
4508+
target = {
4509+
'type': 'reasoning',
4510+
'id': output_id('r'),
4511+
'status': 'completed',
4512+
'start_tag': '<think>',
4513+
'end_tag': '</think>',
4514+
'attributes': {'type': 'reasoning_content'},
4515+
'content': [],
4516+
'summary': None,
4517+
'started_at': time.time(),
4518+
'ended_at': time.time(),
4519+
'duration': 0,
4520+
}
4521+
output.insert(0, target)
4522+
_merge_reasoning_details(
4523+
target.setdefault('reasoning_details', []),
4524+
_pending_reasoning_details,
4525+
)
4526+
_pending_reasoning_details.clear()
4527+
44254528
if response_tool_calls:
44264529
tool_calls.append(_split_tool_calls(response_tool_calls))
44274530

backend/open_webui/utils/misc.py

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

164165
def flush_pending():
165-
nonlocal pending_content, pending_tool_calls, pending_reasoning
166-
if not pending_content and not pending_tool_calls and not pending_reasoning:
166+
nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details
167+
if not pending_content and not pending_tool_calls and not pending_reasoning and not pending_reasoning_details:
167168
return
168169

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

179+
if pending_reasoning_details:
180+
message['reasoning_details'] = pending_reasoning_details
181+
178182
messages.append(message)
179183
pending_content = []
180184
pending_tool_calls = []
181185
pending_reasoning = []
186+
pending_reasoning_details = None
182187

183188
for item in output:
184189
item_type = item.get('type', '')
@@ -249,7 +254,7 @@ def flush_pending():
249254
)
250255

251256
elif item_type == 'reasoning':
252-
if not reasoning_format:
257+
if not raw:
253258
continue
254259

255260
reasoning_text = ''
@@ -260,15 +265,28 @@ def flush_pending():
260265
elif 'text' in part:
261266
reasoning_text += part.get('text', '')
262267

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

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

0 commit comments

Comments
 (0)