From fd19a762375843c6c63fe8a110a76989fbcd1619 Mon Sep 17 00:00:00 2001 From: BingjiaWang Date: Fri, 10 Jul 2026 17:05:26 +0800 Subject: [PATCH] [BUG] fix strip streaming empty-string suffix from DSV4 tool arguments (#29883) --- .../srt/entrypoints/openai/serving_chat.py | 9 ++- .../entrypoints/openai/test_serving_chat.py | 75 +++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/python/sglang/srt/entrypoints/openai/serving_chat.py b/python/sglang/srt/entrypoints/openai/serving_chat.py index b72dac910..bd0607682 100644 --- a/python/sglang/srt/entrypoints/openai/serving_chat.py +++ b/python/sglang/srt/entrypoints/openai/serving_chat.py @@ -2019,13 +2019,16 @@ class OpenAIServingChat(OpenAIServingBase): # Get expected vs actual arguments expected_args = detector.prev_tool_call_arr[tool_index].get("arguments", {}) - expected_call = json.dumps(expected_args, ensure_ascii=False) + if isinstance(expected_args, str): + expected_call = expected_args + else: + expected_call = json.dumps(expected_args, ensure_ascii=False) actual_call = detector.streamed_args_for_tool[tool_index] # Check if there are remaining arguments to send remaining_call = ( - expected_call.replace(actual_call, "", 1) - if actual_call in expected_call + expected_call[len(actual_call) :] + if expected_call.startswith(actual_call) else "" ) diff --git a/test/registered/unit/entrypoints/openai/test_serving_chat.py b/test/registered/unit/entrypoints/openai/test_serving_chat.py index 16b38857d..8e3ba3b66 100644 --- a/test/registered/unit/entrypoints/openai/test_serving_chat.py +++ b/test/registered/unit/entrypoints/openai/test_serving_chat.py @@ -816,6 +816,81 @@ class ServingChatTestCase(unittest.TestCase): # Should return None since no completion is needed self.assertIsNone(result, "Should return None when no completion is needed") + def test_unstreamed_tool_args_raw_string_no_completion_needed(self): + """Test raw JSON string arguments are not JSON-encoded again at finish.""" + + mock_parser = Mock() + mock_detector = Mock() + mock_detector.prev_tool_call_arr = [ + {"name": "report_template_recognition_commit", "arguments": "{}"} + ] + mock_detector.streamed_args_for_tool = ["{}"] + mock_parser.detector = mock_detector + + content = { + "meta_info": { + "id": "chatcmpl-test123", + } + } + + request = ChatCompletionRequest( + model="test", + messages=[{"role": "user", "content": "commit"}], + tools=[ + { + "type": "function", + "function": {"name": "report_template_recognition_commit"}, + } + ], + ) + + result = self.chat._check_for_unstreamed_tool_args( + parser=mock_parser, + content=content, + request=request, + index=0, + ) + + self.assertIsNone(result, "Should not append encoded quotes") + + def test_unstreamed_tool_args_raw_string_completion(self): + """Test remaining raw JSON string arguments are sent at finish.""" + + mock_parser = Mock() + mock_detector = Mock() + mock_detector.prev_tool_call_arr = [ + { + "name": "get_weather", + "arguments": '{"location": "San Francisco", "unit": "celsius"}', + } + ] + mock_detector.streamed_args_for_tool = ['{"location": "San Francisco"'] + mock_parser.detector = mock_detector + + content = { + "meta_info": { + "id": "chatcmpl-test123", + } + } + + request = ChatCompletionRequest( + model="test", + messages=[{"role": "user", "content": "What's the weather?"}], + tools=[{"type": "function", "function": {"name": "get_weather"}}], + ) + + result = self.chat._check_for_unstreamed_tool_args( + parser=mock_parser, + content=content, + request=request, + index=0, + ) + + self.assertIsNotNone(result, "Should return chunk with remaining arguments") + chunk = json.loads(result[6:]) + tool_calls = chunk["choices"][0]["delta"]["tool_calls"] + self.assertEqual(tool_calls[0]["function"]["arguments"], ', "unit": "celsius"}') + def test_unstreamed_tool_args_no_parser_data(self): """Test that no completion chunk is sent when parser has no tool call data."""