Skip to content

Commit 6a8afbc

Browse files
committed
fix: preserve reasoning_details for multi-turn continuity
1 parent e0b5529 commit 6a8afbc

2 files changed

Lines changed: 102 additions & 18 deletions

File tree

backend/open_webui/utils/middleware.py

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,38 @@ 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, keyed by index.
224+
225+
Items sharing the same index are merged: text and summary fields are
226+
concatenated; all other fields are overwritten with the latest value.
227+
chunk may be a list of items or a single dict.
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', 0)
234+
if not isinstance(idx, int) or idx < 0:
235+
continue
236+
while len(details) <= idx:
237+
details.append({})
238+
existing = details[idx]
239+
existing['type'] = item.get('type', existing.get('type', 'reasoning.text'))
240+
if 'format' in item:
241+
existing['format'] = item['format']
242+
if 'id' in item:
243+
existing['id'] = item['id']
244+
if item.get('text'):
245+
existing['text'] = existing.get('text', '') + item['text']
246+
if item.get('summary'):
247+
existing['summary'] = existing.get('summary', '') + item['summary']
248+
if 'data' in item:
249+
existing['data'] = item['data']
250+
if item.get('signature'):
251+
existing['signature'] = item['signature']
252+
253+
222254
def get_citation_source_from_tool_result(
223255
tool_name: str, tool_params: dict, tool_result: str, tool_id: str = ''
224256
) -> list[dict]:
@@ -525,6 +557,8 @@ def serialize_output(output: list) -> str:
525557
pass
526558

527559
reasoning_content = ''.join(reasoning_parts).strip()
560+
if not reasoning_content:
561+
continue # no displayable text; item stays in output for round-trip
528562

529563
duration = item.get('duration')
530564
status = item.get('status', 'in_progress')
@@ -3397,15 +3431,43 @@ async def non_streaming_chat_response_handler(response, ctx):
33973431
# otherwise generate from response content
33983432
response_output = response_data.get('output')
33993433
if not response_output:
3400-
response_output = [
3434+
message_obj = choices[0].get('message', {})
3435+
reasoning_text = (
3436+
message_obj.get('reasoning_content')
3437+
or message_obj.get('reasoning')
3438+
)
3439+
reasoning_details = message_obj.get('reasoning_details')
3440+
3441+
response_output = []
3442+
3443+
if reasoning_text or reasoning_details:
3444+
r_item = {
3445+
'type': 'reasoning',
3446+
'id': output_id('r'),
3447+
'status': 'completed',
3448+
'start_tag': '<think>',
3449+
'end_tag': '</think>',
3450+
'attributes': {'type': 'reasoning_content'},
3451+
'content': [{'type': 'output_text', 'text': reasoning_text}] if reasoning_text else [],
3452+
'summary': None,
3453+
}
3454+
if reasoning_details:
3455+
r_item['reasoning_details'] = (
3456+
reasoning_details
3457+
if isinstance(reasoning_details, list)
3458+
else [reasoning_details]
3459+
)
3460+
response_output.append(r_item)
3461+
3462+
response_output.append(
34013463
{
34023464
'type': 'message',
34033465
'id': output_id('msg'),
34043466
'status': 'completed',
34053467
'role': 'assistant',
34063468
'content': [{'type': 'output_text', 'text': content}],
34073469
}
3408-
]
3470+
)
34093471

34103472
await event_emitter(
34113473
{
@@ -4111,9 +4173,12 @@ async def flush_pending_delta_data(threshold: int = 0):
41114173
or delta.get('reasoning')
41124174
or delta.get('thinking')
41134175
)
4114-
if reasoning_content:
4176+
reasoning_details_chunk = delta.get('reasoning_details')
4177+
4178+
# Ensure a reasoning item exists for both text and structured-block deltas.
4179+
if reasoning_content or reasoning_details_chunk:
41154180
if not output or output[-1].get('type') != 'reasoning':
4116-
reasoning_item = {
4181+
output.append({
41174182
'type': 'reasoning',
41184183
'id': output_id('r'),
41194184
'status': 'in_progress',
@@ -4123,23 +4188,24 @@ async def flush_pending_delta_data(threshold: int = 0):
41234188
'content': [],
41244189
'summary': None,
41254190
'started_at': time.time(),
4126-
}
4127-
output.append(reasoning_item)
4128-
else:
4129-
reasoning_item = output[-1]
4191+
})
41304192

4131-
# Append to reasoning content
4193+
if reasoning_content:
4194+
reasoning_item = output[-1]
41324195
parts = reasoning_item.get('content', [])
41334196
if parts and parts[-1].get('type') == 'output_text':
41344197
parts[-1]['text'] += reasoning_content
41354198
else:
4136-
reasoning_item['content'] = [
4137-
{
4138-
'type': 'output_text',
4139-
'text': reasoning_content,
4140-
}
4141-
]
4199+
reasoning_item['content'] = [{'type': 'output_text', 'text': reasoning_content}]
4200+
4201+
# Accumulate raw structured reasoning_details for provider round-trip.
4202+
if reasoning_details_chunk:
4203+
_merge_reasoning_details(
4204+
output[-1].setdefault('reasoning_details', []),
4205+
reasoning_details_chunk,
4206+
)
41424207

4208+
if reasoning_content or reasoning_details_chunk:
41434209
data = {'content': serialize_output(full_output())}
41444210

41454211
if value:

backend/open_webui/utils/misc.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,19 +148,22 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
148148
messages = []
149149
pending_tool_calls = []
150150
pending_content = []
151+
pending_reasoning_details = None
151152

152153
def flush_pending():
153-
nonlocal pending_content, pending_tool_calls
154-
if pending_content or pending_tool_calls:
154+
nonlocal pending_content, pending_tool_calls, pending_reasoning_details
155+
if pending_content or pending_tool_calls or pending_reasoning_details:
155156
messages.append(
156157
{
157158
'role': 'assistant',
158159
'content': '\n'.join(pending_content) if pending_content else '',
159160
**({'tool_calls': pending_tool_calls} if pending_tool_calls else {}),
161+
**({'reasoning_details': pending_reasoning_details} if pending_reasoning_details else {}),
160162
}
161163
)
162164
pending_content = []
163165
pending_tool_calls = []
166+
pending_reasoning_details = None
164167

165168
for item in output:
166169
item_type = item.get('type', '')
@@ -241,16 +244,31 @@ def flush_pending():
241244
elif 'text' in part:
242245
reasoning_text += part.get('text', '')
243246

247+
# Read raw_details before reasoning_text to use it as a guard.
248+
raw_details = item.get('reasoning_details')
249+
244250
if reasoning_text:
245251
start_tag = item.get('start_tag', '<think>')
246252
end_tag = item.get('end_tag', '</think>')
247-
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
253+
# Skip <think> embed for structured-details models.
254+
if not raw_details:
255+
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
248256
# NOTE: Some providers (e.g. Moonshot/Kimi K2.5) require
249257
# reasoning_content as a top-level field on assistant
250258
# messages. This should be handled externally via a
251259
# pipeline filter or connection-level middleware, not
252260
# here — adding it universally breaks strict providers
253261
# (OpenAI, Vertex AI, Azure) that reject unknown fields.
262+
263+
# Preserve raw structured reasoning_details for provider round-trip
264+
if raw_details:
265+
if pending_reasoning_details is None:
266+
pending_reasoning_details = list(raw_details) if isinstance(raw_details, list) else [raw_details]
267+
elif isinstance(pending_reasoning_details, list):
268+
if isinstance(raw_details, list):
269+
pending_reasoning_details.extend(raw_details)
270+
else:
271+
pending_reasoning_details.append(raw_details)
254272
# else: skip reasoning blocks for normal LLM messages
255273

256274
elif item_type == 'open_webui:code_interpreter':

0 commit comments

Comments
 (0)