@@ -71,6 +71,87 @@ def test_reasoning_content_streamed_and_captured(self):
7171 )
7272 c .close ()
7373
74+ def test_tool_call_null_index_does_not_merge_or_crash (self ):
75+ """A chunk with ``"index": null`` must neither crash nor merge.
76+
77+ ``dict.get("index", 0)`` only defaults when the key is ABSENT, so
78+ an explicit null used to land None in the accumulator: the final
79+ ``sorted(tc_index)`` then raised TypeError on the mixed int/None
80+ keys. The differing id marks a new call, so the two stay apart.
81+ """
82+ fake_openai_server .reset_state ()
83+ fake_openai_server .STREAM_CHUNKS = [
84+ {
85+ "choices" : [
86+ {
87+ "delta" : {
88+ "tool_calls" : [
89+ {
90+ "index" : 0 ,
91+ "id" : "call_1" ,
92+ "function" : {"name" : "Read" , "arguments" : "{}" },
93+ }
94+ ]
95+ }
96+ }
97+ ]
98+ },
99+ {
100+ "choices" : [
101+ {
102+ "delta" : {
103+ "tool_calls" : [
104+ {
105+ "index" : None ,
106+ "id" : "call_2" ,
107+ "function" : {"name" : "Grep" , "arguments" : "{}" },
108+ }
109+ ]
110+ }
111+ }
112+ ]
113+ },
114+ ]
115+ c = make_client ()
116+ try :
117+ msg , _ = c .chat ([Message (role = "user" , content = "hi" )])
118+ finally :
119+ c .close ()
120+ fake_openai_server .reset_state ()
121+ self .assertEqual (
122+ [(t .id , t .name ) for t in msg .tool_calls ],
123+ [("call_1" , "Read" ), ("call_2" , "Grep" )],
124+ )
125+
126+ def test_tool_call_fragments_without_index_still_merge (self ):
127+ """Fragments of ONE call from a backend that omits ``index``
128+ keep accumulating into the same slot: only a differing id (not a
129+ missing index) starts a new call."""
130+ fake_openai_server .reset_state ()
131+ fake_openai_server .STREAM_CHUNKS = [
132+ {
133+ "choices" : [
134+ {
135+ "delta" : {
136+ "tool_calls" : [
137+ {"id" : "call_1" , "function" : {"name" : "Read" , "arguments" : '{"a"' }}
138+ ]
139+ }
140+ }
141+ ]
142+ },
143+ {"choices" : [{"delta" : {"tool_calls" : [{"function" : {"arguments" : ": 1}" }}]}}]},
144+ ]
145+ c = make_client ()
146+ try :
147+ msg , _ = c .chat ([Message (role = "user" , content = "hi" )])
148+ finally :
149+ c .close ()
150+ fake_openai_server .reset_state ()
151+ self .assertEqual (len (msg .tool_calls ), 1 )
152+ self .assertEqual (msg .tool_calls [0 ].id , "call_1" )
153+ self .assertEqual (msg .tool_calls [0 ].arguments , '{"a": 1}' )
154+
74155 def test_streaming_default_sends_stream_true (self ):
75156 """The default mode is streaming: the request body must carry
76157 stream=True plus stream_options requesting usage chunks."""
@@ -191,6 +272,43 @@ def test_sync_chat_tool_calls_and_reasoning(self):
191272 self .assertEqual (usage .input_tokens , 7 )
192273 self .assertEqual (usage .output_tokens , 9 )
193274
275+ def test_sync_chat_null_indices_stay_separate_calls (self ):
276+ """Non-streaming calls carrying ``"index": null`` must not collapse
277+ into one slot: ``tc.get("index", i)`` returned None for every call,
278+ splicing their ids, names and arguments into a single garbage
279+ call. The positional fallback keeps them apart."""
280+ fake_openai_server .NON_STREAM_RESPONSE = {
281+ "choices" : [
282+ {
283+ "message" : {
284+ "role" : "assistant" ,
285+ "content" : "" ,
286+ "tool_calls" : [
287+ {
288+ "index" : None ,
289+ "id" : "call_1" ,
290+ "function" : {"name" : "Read" , "arguments" : '{"a": 1}' },
291+ },
292+ {
293+ "index" : None ,
294+ "id" : "call_2" ,
295+ "function" : {"name" : "Grep" , "arguments" : '{"b": 2}' },
296+ },
297+ ],
298+ }
299+ }
300+ ],
301+ }
302+ c = make_client ()
303+ try :
304+ msg , _ = c .chat ([Message (role = "user" , content = "hi" )], stream = False )
305+ finally :
306+ c .close ()
307+ self .assertEqual (
308+ [(t .id , t .name , t .arguments ) for t in msg .tool_calls ],
309+ [("call_1" , "Read" , '{"a": 1}' ), ("call_2" , "Grep" , '{"b": 2}' )],
310+ )
311+
194312 def test_sync_chat_dict_arguments_normalized (self ):
195313 """Some backends return tool-call arguments as an object rather
196314 than a JSON string; they must be normalized to a string."""
0 commit comments