@@ -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+
206235def get_citation_source_from_tool_result (
207236 tool_name : str , tool_params : dict , tool_result : str , tool_id : str = ''
208237) -> list [dict ]:
@@ -507,6 +536,8 @@ def serialize_output(output: list) -> str:
507536 pass
508537
509538 reasoning_content = '' .join (reasoning_parts ).strip ()
539+ if not reasoning_content :
540+ continue # no displayable text; item stays in output for round-trip
510541
511542 duration = item .get ('duration' )
512543 status = item .get ('status' , 'in_progress' )
@@ -3493,15 +3524,43 @@ async def non_streaming_chat_response_handler(response, ctx):
34933524 # otherwise generate from response content
34943525 response_output = response_data .get ('output' )
34953526 if not response_output :
3496- response_output = [
3527+ message_obj = choices [0 ].get ('message' , {})
3528+ reasoning_text = (
3529+ message_obj .get ('reasoning_content' )
3530+ or message_obj .get ('reasoning' )
3531+ )
3532+ reasoning_details = message_obj .get ('reasoning_details' )
3533+
3534+ response_output = []
3535+
3536+ if reasoning_text or reasoning_details :
3537+ r_item = {
3538+ 'type' : 'reasoning' ,
3539+ 'id' : output_id ('r' ),
3540+ 'status' : 'completed' ,
3541+ 'start_tag' : '<think>' ,
3542+ 'end_tag' : '</think>' ,
3543+ 'attributes' : {'type' : 'reasoning_content' },
3544+ 'content' : [{'type' : 'output_text' , 'text' : reasoning_text }] if reasoning_text else [],
3545+ 'summary' : None ,
3546+ }
3547+ if reasoning_details :
3548+ r_item ['reasoning_details' ] = (
3549+ reasoning_details
3550+ if isinstance (reasoning_details , list )
3551+ else [reasoning_details ]
3552+ )
3553+ response_output .append (r_item )
3554+
3555+ response_output .append (
34973556 {
34983557 'type' : 'message' ,
34993558 'id' : output_id ('msg' ),
35003559 'status' : 'completed' ,
35013560 'role' : 'assistant' ,
35023561 'content' : [{'type' : 'output_text' , 'text' : content }],
35033562 }
3504- ]
3563+ )
35053564
35063565 await event_emitter (
35073566 {
@@ -3866,6 +3925,7 @@ def set_last_text(out, text):
38663925 ]
38673926 else :
38683927 output = []
3928+ _pending_reasoning_details = []
38693929
38703930 usage = None
38713931 prior_output = []
@@ -4233,9 +4293,14 @@ async def flush_pending_delta_data(threshold: int = 0):
42334293 or delta .get ('reasoning' )
42344294 or delta .get ('thinking' )
42354295 )
4296+ reasoning_details_chunk = delta .get ('reasoning_details' )
4297+
4298+ # Only create a reasoning item for visible reasoning text.
4299+ # Details-only deltas (e.g. Gemini encrypted blobs) are
4300+ # buffered to avoid splitting the assistant message mid-stream.
42364301 if reasoning_content :
42374302 if not output or output [- 1 ].get ('type' ) != 'reasoning' :
4238- reasoning_item = {
4303+ output . append ( {
42394304 'type' : 'reasoning' ,
42404305 'id' : output_id ('r' ),
42414306 'status' : 'in_progress' ,
@@ -4245,23 +4310,37 @@ async def flush_pending_delta_data(threshold: int = 0):
42454310 'content' : [],
42464311 'summary' : None ,
42474312 'started_at' : time .time (),
4248- }
4249- output .append (reasoning_item )
4250- else :
4251- reasoning_item = output [- 1 ]
4313+ })
42524314
4253- # Append to reasoning content
4315+ if reasoning_content :
4316+ reasoning_item = output [- 1 ]
42544317 parts = reasoning_item .get ('content' , [])
42554318 if parts and parts [- 1 ].get ('type' ) == 'output_text' :
42564319 parts [- 1 ]['text' ] += reasoning_content
42574320 else :
4258- reasoning_item ['content' ] = [
4259- {
4260- 'type' : 'output_text' ,
4261- 'text' : reasoning_content ,
4262- }
4263- ]
4321+ reasoning_item ['content' ] = [{'type' : 'output_text' , 'text' : reasoning_content }]
42644322
4323+ # Flush any buffered details-only chunks into this reasoning item.
4324+ if _pending_reasoning_details :
4325+ _merge_reasoning_details (
4326+ reasoning_item .setdefault ('reasoning_details' , []),
4327+ _pending_reasoning_details ,
4328+ )
4329+ _pending_reasoning_details .clear ()
4330+
4331+ # Accumulate raw structured reasoning_details for provider round-trip.
4332+ if reasoning_details_chunk :
4333+ if output and output [- 1 ].get ('type' ) == 'reasoning' :
4334+ _merge_reasoning_details (
4335+ output [- 1 ].setdefault ('reasoning_details' , []),
4336+ reasoning_details_chunk ,
4337+ )
4338+ else :
4339+ # Buffer until a safe boundary (end-of-stream or real reasoning text).
4340+ items = reasoning_details_chunk if isinstance (reasoning_details_chunk , list ) else [reasoning_details_chunk ]
4341+ _pending_reasoning_details .extend (items )
4342+
4343+ if reasoning_content or reasoning_details_chunk :
42654344 data = {'content' : serialize_output (full_output ())}
42664345
42674346 if value :
@@ -4477,6 +4556,30 @@ async def flush_pending_delta_data(threshold: int = 0):
44774556 )
44784557 reasoning_item ['status' ] = 'completed'
44794558
4559+ # Flush any buffered reasoning_details that never found a reasoning item.
4560+ if _pending_reasoning_details :
4561+ target = next ((item for item in output if item .get ('type' ) == 'reasoning' ), None )
4562+ if target is None :
4563+ target = {
4564+ 'type' : 'reasoning' ,
4565+ 'id' : output_id ('r' ),
4566+ 'status' : 'completed' ,
4567+ 'start_tag' : '<think>' ,
4568+ 'end_tag' : '</think>' ,
4569+ 'attributes' : {'type' : 'reasoning_content' },
4570+ 'content' : [],
4571+ 'summary' : None ,
4572+ 'started_at' : time .time (),
4573+ 'ended_at' : time .time (),
4574+ 'duration' : 0 ,
4575+ }
4576+ output .insert (0 , target )
4577+ _merge_reasoning_details (
4578+ target .setdefault ('reasoning_details' , []),
4579+ _pending_reasoning_details ,
4580+ )
4581+ _pending_reasoning_details .clear ()
4582+
44804583 if response_tool_calls :
44814584 tool_calls .append (_split_tool_calls (response_tool_calls ))
44824585
0 commit comments