@@ -3440,11 +3440,17 @@ async def add_assistant_reply_to_cur_messages(self):
34403440 to be `None` when `tool_calls` are present.
34413441 """
34423442 msg = dict (role = "assistant" )
3443- response = (
3444- self .partial_response_chunks [0 ]
3445- if not self .stream
3446- else litellm .stream_chunk_builder (self .partial_response_chunks )
3447- )
3443+
3444+ # Prefer the response already produced by consolidate_chunks(): it carries
3445+ # the provider-specific fields (e.g. reasoning_items) that we preserved
3446+ # across all chunks, which a fresh litellm.stream_chunk_builder() pass
3447+ # alone would drop or truncate.
3448+ if self .partial_response_consolidated :
3449+ response = self .partial_response_consolidated [0 ]
3450+ elif not self .stream :
3451+ response = self .partial_response_chunks [0 ]
3452+ else :
3453+ response = litellm .stream_chunk_builder (self .partial_response_chunks )
34483454
34493455 try :
34503456 # Use response_dict as a regular dictionary
@@ -3963,63 +3969,51 @@ def consolidate_chunks(self):
39633969 if getattr (last_chunk , "usage" , None ):
39643970 response .usage = last_chunk .usage
39653971
3966- # Collect provider-specific fields from chunks to preserve them
3967- # We need to track both by ID (primary) and index (fallback) since
3968- # early chunks might not have IDs established yet
3969- provider_specific_fields_by_id = {}
3970- provider_specific_fields_by_index = {}
3971-
3972+ # Collect message-level provider-specific fields (e.g. `reasoning_items`
3973+ # for reasoning models) from ALL chunks. litellm's stream_chunk_builder()
3974+ # merges these with last-wins semantics for list fields, silently dropping
3975+ # every reasoning item except the final one. Reasoning models depend on the
3976+ # full ordered item list being present in the assistant message so that
3977+ # exact-prefix prompt caching keeps working across turns, so we collect the
3978+ # fields ourselves and concatenate list-valued entries.
3979+ message_provider_specific_fields = {}
39723980 for chunk in self .partial_response_chunks :
39733981 try :
3974- if chunk .choices and chunk .choices [0 ].delta and chunk .choices [0 ].delta .tool_calls :
3975- for tool_call in chunk .choices [0 ].delta .tool_calls :
3976- if (
3977- hasattr (tool_call , "provider_specific_fields" )
3978- and tool_call .provider_specific_fields
3979- ):
3980- # Ensure provider_specific_fields is a dictionary
3981- psf = tool_call .provider_specific_fields
3982- if not isinstance (psf , dict ):
3983- continue
3984-
3985- # Try to use ID first
3986- if hasattr (tool_call , "id" ) and tool_call .id :
3987- tool_id = tool_call .id
3988- if tool_id not in provider_specific_fields_by_id :
3989- provider_specific_fields_by_id [tool_id ] = {}
3990- # Merge provider-specific fields for this tool ID
3991- provider_specific_fields_by_id [tool_id ].update (psf )
3992- # Also track by index as fallback
3993- elif hasattr (tool_call , "index" ):
3994- tool_index = tool_call .index
3995- if tool_index not in provider_specific_fields_by_index :
3996- provider_specific_fields_by_index [tool_index ] = {}
3997- provider_specific_fields_by_index [tool_index ].update (psf )
3982+ if chunk .choices and chunk .choices [0 ].delta :
3983+ psf = getattr (chunk .choices [0 ].delta , "provider_specific_fields" , None )
3984+ if psf and isinstance (psf , dict ):
3985+ for key , value in psf .items ():
3986+ if isinstance (value , list ):
3987+ message_provider_specific_fields .setdefault (key , []).extend (value )
3988+ elif value is not None :
3989+ message_provider_specific_fields [key ] = value
39983990 except (AttributeError , IndexError ):
39993991 continue
40003992
3993+ if message_provider_specific_fields :
3994+ message_psf = getattr (response .choices [0 ].message , "provider_specific_fields" , None )
3995+ if not isinstance (message_psf , dict ):
3996+ message_psf = {}
3997+ message_psf .update (message_provider_specific_fields )
3998+ response .choices [0 ].message .provider_specific_fields = message_psf
3999+
40014000 try :
4002- if response .choices [0 ].message .tool_calls :
4003- for i , tool_call in enumerate (response .choices [0 ].message .tool_calls ):
4004- # Add provider-specific fields if we collected any for this tool
4005- tool_id = tool_call .id
4006-
4007- # Try ID first
4008- if tool_id in provider_specific_fields_by_id :
4009- # Add provider-specific fields directly to the tool call object
4010- tool_call .provider_specific_fields = provider_specific_fields_by_id [tool_id ]
4011- # Fall back to index
4012- elif i in provider_specific_fields_by_index :
4013- # Add provider-specific fields directly to the tool call object
4014- tool_call .provider_specific_fields = provider_specific_fields_by_index [i ]
4015-
4016- # Only append to partial_response_tool_calls if it's empty
4017- if len (self .partial_response_tool_calls ) == 0 :
4018- self .partial_response_tool_calls .append (tool_call )
4019-
4020- self .partial_response_function_call = (
4021- response .choices [0 ].message .tool_calls [0 ].function
4022- )
4001+ message_tool_calls = response .choices [0 ].message .tool_calls
4002+ if message_tool_calls and len (message_tool_calls ):
4003+ if self .stream :
4004+ built_tool_calls = self ._build_tool_calls_from_chunks ()
4005+ if built_tool_calls :
4006+ response .choices [0 ].message .tool_calls = built_tool_calls
4007+ self .partial_response_tool_calls = built_tool_calls
4008+ else :
4009+ # Fall back to litellm's merged list, keeping every call
4010+ self .partial_response_tool_calls = list (message_tool_calls )
4011+ else :
4012+ # Non-streaming: the single response chunk already carries the
4013+ # full tool_calls list
4014+ self .partial_response_tool_calls = list (message_tool_calls )
4015+
4016+ self .partial_response_function_call = self .partial_response_tool_calls [0 ].function
40234017 except AttributeError as e :
40244018 func_err = e
40254019
@@ -4075,6 +4069,85 @@ def consolidate_chunks(self):
40754069 self .partial_response_consolidated = (response , func_err , content_err )
40764070 return response , func_err , content_err
40774071
4072+ def _build_tool_calls_from_chunks (self ):
4073+ """Rebuild tool calls from the raw streaming chunks, keyed by delta index.
4074+
4075+ Streaming deltas for parallel tool calls arrive interleaved and may start
4076+ at any index (not necessarily 0). Indexing into a dict by the delta's
4077+ tool-call ``index`` before converting it back to a list ensures every
4078+ parallel call is preserved, correctly ordered, and keeps its
4079+ provider-specific fields (e.g. thought signatures) attached.
4080+ """
4081+ from litellm .types .utils import ChatCompletionMessageToolCall , Function
4082+
4083+ tool_calls_dict = {}
4084+
4085+ for chunk in self .partial_response_chunks :
4086+ try :
4087+ if not (chunk .choices and chunk .choices [0 ].delta ):
4088+ continue
4089+
4090+ delta = chunk .choices [0 ].delta
4091+ for tool_call in delta .tool_calls or []:
4092+ if tool_call is None :
4093+ continue
4094+
4095+ if nested .getter (tool_call , "function" ) is None :
4096+ continue
4097+
4098+ index = nested .getter (tool_call , "index" )
4099+ if index is None :
4100+ index = len (tool_calls_dict )
4101+
4102+ entry = tool_calls_dict .setdefault (
4103+ index ,
4104+ {
4105+ "id" : None ,
4106+ "name" : None ,
4107+ "type" : "function" ,
4108+ "arguments" : [],
4109+ "provider_specific_fields" : {},
4110+ },
4111+ )
4112+
4113+ entry ["id" ] = nested .getter (tool_call , "id" ) or entry ["id" ]
4114+ entry ["type" ] = nested .getter (tool_call , "type" ) or entry ["type" ]
4115+ entry ["name" ] = nested .getter (tool_call , "function.name" ) or entry ["name" ]
4116+
4117+ arguments = nested .getter (tool_call , "function.arguments" )
4118+ if arguments :
4119+ entry ["arguments" ].append (arguments )
4120+
4121+ psf = nested .getter (tool_call , "provider_specific_fields" )
4122+ if not psf :
4123+ psf = nested .getter (tool_call , "function.provider_specific_fields" )
4124+ if psf and isinstance (psf , dict ):
4125+ entry ["provider_specific_fields" ].update (psf )
4126+ except (AttributeError , IndexError ):
4127+ continue
4128+
4129+ tool_calls = []
4130+ for index in sorted (tool_calls_dict .keys ()):
4131+ data = tool_calls_dict [index ]
4132+ if not (data ["id" ] and data ["name" ]):
4133+ continue
4134+
4135+ function = Function (
4136+ arguments = "" .join (data ["arguments" ]) or "{}" ,
4137+ name = data ["name" ],
4138+ )
4139+ params = {
4140+ "id" : data ["id" ],
4141+ "function" : function ,
4142+ "type" : data ["type" ] or "function" ,
4143+ }
4144+ if data ["provider_specific_fields" ]:
4145+ params ["provider_specific_fields" ] = data ["provider_specific_fields" ]
4146+
4147+ tool_calls .append (ChatCompletionMessageToolCall (** params ))
4148+
4149+ return tool_calls
4150+
40784151 def stream_wrapper (self , content , final ):
40794152 if not hasattr (self , "_streaming_buffer_length" ):
40804153 self ._streaming_buffer_length = 0
0 commit comments