From b7b7524e9560c8efa5dfb832af05596b8162f7d3 Mon Sep 17 00:00:00 2001 From: wentx <3843588+momaek@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:32:10 +0800 Subject: [PATCH] [Tool Call] Fix DeepSeekV32Detector skipping functions with no params in streaming mode (#14573) --- .../srt/function_call/deepseekv32_detector.py | 9 +- .../test_function_call_parser.py | 142 ++++++++++++++++++ 2 files changed, 144 insertions(+), 7 deletions(-) diff --git a/python/sglang/srt/function_call/deepseekv32_detector.py b/python/sglang/srt/function_call/deepseekv32_detector.py index 8b28494a5..1368afc52 100644 --- a/python/sglang/srt/function_call/deepseekv32_detector.py +++ b/python/sglang/srt/function_call/deepseekv32_detector.py @@ -241,13 +241,8 @@ class DeepSeekV32Detector(BaseFormatDetector): # This ensures each function returns at most once calls_for_this_invoke: list[ToolCallItem] = [] - # Check if invoke_content is empty or whitespace only - # If so, skip this tool call entirely (it's likely incomplete or malformed) - if not invoke_content.strip(): - # Remove the incomplete tool call from buffer - self._buffer = current_text[invoke_match.end() :] - current_text = self._buffer - continue + # Note: invoke_content can be empty for functions with no parameters + # This is valid and should NOT be skipped # Send tool name calls_for_this_invoke.append( diff --git a/test/registered/function_call/test_function_call_parser.py b/test/registered/function_call/test_function_call_parser.py index 9810a9544..629710718 100644 --- a/test/registered/function_call/test_function_call_parser.py +++ b/test/registered/function_call/test_function_call_parser.py @@ -1277,6 +1277,148 @@ class TestDeepSeekV32Detector(unittest.TestCase): params = json.loads(params_str) self.assertEqual(params["city"], "San Francisco") + def test_detect_and_parse_no_parameters(self): + """Test parsing function calls with no parameters (non-streaming)""" + # Add a no-parameter tool + tools_with_no_param = self.tools + [ + Tool( + type="function", + function=Function( + name="get_date", + description="Get the current date.", + parameters={"type": "object", "properties": {}}, + ), + ), + ] + + text = """Let me get the current date for you. + +<|DSML|function_calls> +<|DSML|invoke name="get_date"> + +""" + + result = self.detector.detect_and_parse(text, tools_with_no_param) + + self.assertIn("Let me get the current date", result.normal_text) + self.assertEqual(len(result.calls), 1) + + call = result.calls[0] + self.assertEqual(call.name, "get_date") + params = json.loads(call.parameters) + self.assertEqual(params, {}) + + def test_streaming_no_parameters(self): + """Test streaming parsing of function calls with no parameters. + + This test verifies the fix for the bug where functions with no parameters + were being silently skipped in streaming mode. + """ + # Add a no-parameter tool + tools_with_no_param = self.tools + [ + Tool( + type="function", + function=Function( + name="get_date", + description="Get the current date.", + parameters={"type": "object", "properties": {}}, + ), + ), + ] + + text = """<|DSML|function_calls> +<|DSML|invoke name="get_date"> + +""" + + # Reset detector state + self.detector = DeepSeekV32Detector() + + # Simulate streaming by splitting into small chunks + chunks = [text[i : i + 5] for i in range(0, len(text), 5)] + + tool_calls_by_index = {} + + for chunk in chunks: + result = self.detector.parse_streaming_increment(chunk, tools_with_no_param) + for call in result.calls: + if call.tool_index is not None: + if call.tool_index not in tool_calls_by_index: + tool_calls_by_index[call.tool_index] = { + "name": "", + "parameters": "", + } + + if call.name: + tool_calls_by_index[call.tool_index]["name"] = call.name + if call.parameters: + tool_calls_by_index[call.tool_index][ + "parameters" + ] += call.parameters + + # Verify that the no-parameter function was correctly parsed + self.assertEqual( + len(tool_calls_by_index), 1, "Should have exactly one tool call" + ) + self.assertEqual(tool_calls_by_index[0]["name"], "get_date") + + # Parameters should be empty JSON object + params_str = tool_calls_by_index[0]["parameters"].strip() + params = json.loads(params_str) + self.assertEqual(params, {}) + + def test_streaming_no_parameters_with_whitespace(self): + """Test streaming parsing when invoke content has only whitespace (newlines).""" + tools_with_no_param = self.tools + [ + Tool( + type="function", + function=Function( + name="get_date", + description="Get the current date.", + parameters={"type": "object", "properties": {}}, + ), + ), + ] + + # This format has newlines inside the invoke tag (common model output) + text = """<|DSML|function_calls> +<|DSML|invoke name="get_date"> + + +""" + + # Reset detector state + self.detector = DeepSeekV32Detector() + + chunks = [text[i : i + 5] for i in range(0, len(text), 5)] + + tool_calls_by_index = {} + + for chunk in chunks: + result = self.detector.parse_streaming_increment(chunk, tools_with_no_param) + for call in result.calls: + if call.tool_index is not None: + if call.tool_index not in tool_calls_by_index: + tool_calls_by_index[call.tool_index] = { + "name": "", + "parameters": "", + } + + if call.name: + tool_calls_by_index[call.tool_index]["name"] = call.name + if call.parameters: + tool_calls_by_index[call.tool_index][ + "parameters" + ] += call.parameters + + # Should still parse correctly even with whitespace-only content + self.assertEqual( + len(tool_calls_by_index), 1, "Should have exactly one tool call" + ) + self.assertEqual(tool_calls_by_index[0]["name"], "get_date") + params = json.loads(tool_calls_by_index[0]["parameters"]) + self.assertEqual(params, {}) + class TestQwen3CoderDetector(unittest.TestCase): def setUp(self):