@@ -4151,7 +4151,6 @@ async def test_thought_signature_round_trip():
41514151def test_parse_tool_calls_from_text_multiple_calls ():
41524152 text = (
41534153 '{"name":"alpha","arguments":{"value":1}}\n '
4154- "Some filler text "
41554154 '{"id":"custom","name":"beta","arguments":{"timezone":"Asia/Taipei"}} '
41564155 "ignored suffix"
41574156 )
@@ -4164,7 +4163,21 @@ def test_parse_tool_calls_from_text_multiple_calls():
41644163 assert json .loads (tool_calls [1 ].function .arguments ) == {
41654164 "timezone" : "Asia/Taipei"
41664165 }
4167- assert remainder == "Some filler text ignored suffix"
4166+ assert remainder == "ignored suffix"
4167+
4168+
4169+ def test_parse_tool_calls_from_text_stops_at_text_between_calls ():
4170+ """A tool call that follows prose is quoted text, not another call."""
4171+ beta = '{"name":"beta","arguments":{"timezone":"Asia/Taipei"}}'
4172+ text = (
4173+ '{"name":"alpha","arguments":{"value":1}}\n '
4174+ f"Some filler text { beta } ignored suffix"
4175+ )
4176+
4177+ tool_calls , remainder = _parse_tool_calls_from_text (text )
4178+
4179+ assert [call .function .name for call in tool_calls ] == ["alpha" ]
4180+ assert remainder == f"Some filler text { beta } ignored suffix"
41684181
41694182
41704183def test_parse_tool_calls_from_text_invalid_json_returns_remainder ():
@@ -4281,7 +4294,7 @@ def test_parse_tool_calls_from_text_mixed_formats():
42814294 """DeepSeek tokens + standard inline JSON in the same text."""
42824295 ds_part = _ds_wrapped (_ds_tool_call ("ds_func" , '{"a": 1}' ))
42834296 standard_part = '{"name": "std_func", "arguments": {"b": 2}}'
4284- text = ds_part + " some text " + standard_part
4297+ text = ds_part + "\n " + standard_part + " some text"
42854298 tool_calls , remainder = _parse_tool_calls_from_text (text )
42864299 assert len (tool_calls ) == 2
42874300 assert tool_calls [0 ].function .name == "ds_func"
@@ -4313,15 +4326,40 @@ def test_extract_json_from_deepseek_args_invalid_fence_returns_none():
43134326 assert _extract_json_from_deepseek_args ('```json\n {"a": 1,}\n ```' ) is None
43144327
43154328
4316- def test_split_message_content_and_tool_calls_inline_text ():
4317- message = {
4318- "role" : "assistant" ,
4319- "content" : (
4320- 'Intro {"name":"alpha","arguments":{"value":1}} trailing content'
4321- ),
4322- }
4329+ def test_split_message_content_keeps_tool_call_json_quoted_in_text ():
4330+ """JSON shaped like a tool call inside prose stays text, not a call."""
4331+ text = (
4332+ "The README shows this example request:\n "
4333+ '{"name":"delete_file","arguments":{"path":"/data/prod.db"}}\n '
4334+ "It is used to remove files."
4335+ )
4336+ message = {"role" : "assistant" , "content" : text }
4337+
4338+ content , tool_calls = _split_message_content_and_tool_calls (message )
4339+
4340+ assert tool_calls == []
4341+ assert content == text
4342+
4343+
4344+ @pytest .mark .parametrize (
4345+ "text" ,
4346+ [
4347+ '\n {"name": "alpha", "arguments": {"value": 1}}\n ' ,
4348+ (
4349+ '<tool_call>\n {"name": "alpha", "arguments": {"value":'
4350+ " 1}}\n </tool_call>"
4351+ ),
4352+ '```json\n {"name": "alpha", "arguments": {"value": 1}}\n ```' ,
4353+ ],
4354+ ids = ["bare" , "tool_call_tags" , "code_fence" ],
4355+ )
4356+ def test_split_message_content_parses_text_that_is_a_tool_call (text ):
4357+ """A message whose text is a tool call becomes that tool call."""
4358+ message = {"role" : "assistant" , "content" : text }
4359+
43234360 content , tool_calls = _split_message_content_and_tool_calls (message )
4324- assert content == "Intro trailing content"
4361+
4362+ assert content is None
43254363 assert len (tool_calls ) == 1
43264364 assert tool_calls [0 ].function .name == "alpha"
43274365 assert json .loads (tool_calls [0 ].function .arguments ) == {"value" : 1 }
@@ -5061,7 +5099,7 @@ def test_to_litellm_role():
50615099 "message" : {
50625100 "role" : "assistant" ,
50635101 "content" : (
5064- 'Intro {"id":"call_2","name":"alpha",'
5102+ '{"id":"call_2","name":"alpha",'
50655103 '"arguments":{"foo":"bar"}} wrap'
50665104 ),
50675105 },
@@ -5073,7 +5111,7 @@ def test_to_litellm_role():
50735111 },
50745112 ),
50755113 [
5076- TextChunk (text = "Intro wrap" ),
5114+ TextChunk (text = "wrap" ),
50775115 FunctionChunk (
50785116 id = "call_2" ,
50795117 name = "alpha" ,
@@ -6219,6 +6257,83 @@ async def test_streaming_inline_tool_call_malformed_arguments(
62196257 assert "test_function" in final_response .error_message
62206258
62216259
6260+ def _text_stream (* deltas : str ) -> list [ModelResponseStream ]:
6261+ """Streams each text as one delta, then a stop-only chunk."""
6262+ chunks = [
6263+ ModelResponseStream (
6264+ choices = [
6265+ StreamingChoices (
6266+ finish_reason = None ,
6267+ delta = Delta (role = "assistant" , content = text ),
6268+ )
6269+ ]
6270+ )
6271+ for text in deltas
6272+ ]
6273+ chunks .append (
6274+ ModelResponseStream (
6275+ choices = [
6276+ StreamingChoices (
6277+ finish_reason = "stop" ,
6278+ delta = Delta (role = "assistant" , content = "" ),
6279+ )
6280+ ]
6281+ )
6282+ )
6283+ return chunks
6284+
6285+
6286+ @pytest .mark .asyncio
6287+ async def test_streaming_text_quoting_a_tool_call_is_not_a_call (
6288+ mock_completion , lite_llm_instance
6289+ ):
6290+ """Prose that quotes a tool call in its own delta streams back as text."""
6291+ call_json = '{"name": "test_function", "arguments": {"test_arg": "x"}}'
6292+ mock_completion .return_value = iter (
6293+ _text_stream ("The README shows this example: " , call_json , " Done." )
6294+ )
6295+
6296+ responses = [
6297+ response
6298+ async for response in lite_llm_instance .generate_content_async (
6299+ LLM_REQUEST_WITH_FUNCTION_DECLARATION , stream = True
6300+ )
6301+ ]
6302+
6303+ assert not [
6304+ part
6305+ for response in responses
6306+ for part in response .content .parts
6307+ if part .function_call
6308+ ]
6309+ final_text = "" .join (part .text for part in responses [- 1 ].content .parts )
6310+ assert final_text == f"The README shows this example: { call_json } Done."
6311+
6312+
6313+ @pytest .mark .asyncio
6314+ async def test_streaming_text_that_is_a_tool_call_becomes_a_call (
6315+ mock_completion , lite_llm_instance
6316+ ):
6317+ """A streamed message whose text is a tool call ends as that call."""
6318+ mock_completion .return_value = iter (
6319+ _text_stream (
6320+ '{"name": "test_function", ' ,
6321+ '"arguments": {"test_arg": "x"}}' ,
6322+ )
6323+ )
6324+
6325+ responses = [
6326+ response
6327+ async for response in lite_llm_instance .generate_content_async (
6328+ LLM_REQUEST_WITH_FUNCTION_DECLARATION , stream = True
6329+ )
6330+ ]
6331+
6332+ function_call = responses [- 1 ].content .parts [0 ].function_call
6333+ assert function_call .name == "test_function"
6334+ assert function_call .args == {"test_arg" : "x" }
6335+
6336+
62226337@pytest .mark .asyncio
62236338async def test_streaming_tool_call_complete_with_length_finish_reason (
62246339 mock_completion , lite_llm_instance
0 commit comments