From 30f5b8760851bb3aafdb7babe70bc726e58b9994 Mon Sep 17 00:00:00 2001 From: Lidang Jiang <119769478+Lidang-Jiang@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:29:55 +0800 Subject: [PATCH] [CI] Add unit tests for function_call detectors (hermes, llama32, mistral) (#21399) --- .../function_call/test_hermes_detector.py | 179 ++++++++++++++ .../function_call/test_llama32_detector.py | 192 +++++++++++++++ .../function_call/test_mistral_detector.py | 224 ++++++++++++++++++ 3 files changed, 595 insertions(+) create mode 100644 test/registered/unit/function_call/test_hermes_detector.py create mode 100644 test/registered/unit/function_call/test_llama32_detector.py create mode 100644 test/registered/unit/function_call/test_mistral_detector.py diff --git a/test/registered/unit/function_call/test_hermes_detector.py b/test/registered/unit/function_call/test_hermes_detector.py new file mode 100644 index 000000000..d1a67c3ae --- /dev/null +++ b/test/registered/unit/function_call/test_hermes_detector.py @@ -0,0 +1,179 @@ +"""Unit tests for HermesDetector — no server, no model loading.""" + +import json + +from sglang.srt.entrypoints.openai.protocol import Function, Tool +from sglang.srt.function_call.hermes_detector import HermesDetector +from sglang.test.ci.ci_register import register_cpu_ci +from sglang.test.test_utils import CustomTestCase + +register_cpu_ci(1.0, "stage-a-test-cpu") + + +class TestHermesDetector(CustomTestCase): + def setUp(self): + self.tools = [ + Tool( + type="function", + function=Function( + name="get_weather", + description="Get weather information", + parameters={ + "type": "object", + "properties": { + "city": {"type": "string", "description": "City name"}, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + }, + }, + "required": ["city"], + }, + ), + ), + Tool( + type="function", + function=Function( + name="search", + description="Search the web", + parameters={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query", + }, + }, + "required": ["query"], + }, + ), + ), + ] + self.detector = HermesDetector() + + # ==================== has_tool_call Tests ==================== + + def test_has_tool_call_true(self): + text = '{"name": "get_weather", "arguments": {"city": "Beijing"}}' + self.assertTrue(self.detector.has_tool_call(text)) + + def test_has_tool_call_false(self): + text = "The weather in Beijing is sunny today." + self.assertFalse(self.detector.has_tool_call(text)) + + # ==================== detect_and_parse Tests ==================== + + def test_single_tool_call(self): + text = '{"name": "get_weather", "arguments": {"city": "Beijing"}}' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(result.calls[0].name, "get_weather") + args = json.loads(result.calls[0].parameters) + self.assertEqual(args["city"], "Beijing") + self.assertEqual(result.normal_text, "") + + def test_multiple_tool_calls(self): + text = ( + '{"name": "get_weather", "arguments": {"city": "Beijing"}}' + '{"name": "search", "arguments": {"query": "restaurants"}}' + ) + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 2) + self.assertEqual(result.calls[0].name, "get_weather") + self.assertEqual(result.calls[1].name, "search") + + def test_tool_call_with_leading_text(self): + text = 'I will check the weather for you. {"name": "get_weather", "arguments": {"city": "Tokyo"}}' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(result.calls[0].name, "get_weather") + self.assertEqual(result.normal_text, "I will check the weather for you.") + + def test_no_tool_call(self): + text = "The weather is nice today." + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 0) + self.assertEqual(result.normal_text, "The weather is nice today.") + + def test_tool_call_with_multiple_arguments(self): + text = '{"name": "get_weather", "arguments": {"city": "London", "unit": "celsius"}}' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + args = json.loads(result.calls[0].parameters) + self.assertEqual(args["city"], "London") + self.assertEqual(args["unit"], "celsius") + + def test_malformed_json_returns_original_text(self): + text = "not valid json" + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 0) + self.assertEqual(result.normal_text, text) + + # ==================== structure_info Tests ==================== + + def test_structure_info(self): + info_func = self.detector.structure_info() + info = info_func("get_weather") + self.assertIn("get_weather", info.begin) + self.assertEqual(info.trigger, "") + self.assertEqual(info.end, "}") + + # ==================== Streaming Tests ==================== + + def test_streaming_single_tool_call(self): + detector = HermesDetector() + chunks = [ + "{"name": "get_weather",', + ' "arguments": {"city": "Beijing"', + "}}", + ] + all_calls = [] + for chunk in chunks: + result = detector.parse_streaming_increment(chunk, self.tools) + all_calls.extend(result.calls) + + # Verify tool name + func_calls = [c for c in all_calls if c.name] + self.assertEqual(len(func_calls), 1) + self.assertEqual(func_calls[0].name, "get_weather") + + # Verify parameters + full_params = "".join(c.parameters for c in all_calls if c.parameters) + params = json.loads(full_params) + self.assertEqual(params["city"], "Beijing") + + def test_streaming_normal_text_before_tool(self): + detector = HermesDetector() + result = detector.parse_streaming_increment("Hello! Let me help. ", self.tools) + self.assertEqual(result.normal_text, "Hello! Let me help. ") + self.assertEqual(len(result.calls), 0) + + def test_streaming_text_then_tool_call(self): + detector = HermesDetector() + chunks = [ + "Sure, let me check. ", + '{"name": "get_weather",', + ' "arguments": {"city": "Tokyo"', + "}}", + ] + all_calls = [] + all_normal_text = "" + for chunk in chunks: + result = detector.parse_streaming_increment(chunk, self.tools) + all_calls.extend(result.calls) + all_normal_text += result.normal_text + + self.assertEqual(all_normal_text, "Sure, let me check. ") + func_calls = [c for c in all_calls if c.name] + self.assertEqual(len(func_calls), 1) + self.assertEqual(func_calls[0].name, "get_weather") + full_params = "".join(c.parameters for c in all_calls if c.parameters) + params = json.loads(full_params) + self.assertEqual(params["city"], "Tokyo") + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/test/registered/unit/function_call/test_llama32_detector.py b/test/registered/unit/function_call/test_llama32_detector.py new file mode 100644 index 000000000..db828ef04 --- /dev/null +++ b/test/registered/unit/function_call/test_llama32_detector.py @@ -0,0 +1,192 @@ +"""Unit tests for Llama32Detector — no server, no model loading.""" + +import json + +from sglang.srt.entrypoints.openai.protocol import Function, Tool +from sglang.srt.function_call.llama32_detector import Llama32Detector +from sglang.test.ci.ci_register import register_cpu_ci +from sglang.test.test_utils import CustomTestCase + +register_cpu_ci(1.0, "stage-a-test-cpu") + + +class TestLlama32Detector(CustomTestCase): + def setUp(self): + self.tools = [ + Tool( + type="function", + function=Function( + name="get_weather", + description="Get weather information", + parameters={ + "type": "object", + "properties": { + "city": {"type": "string", "description": "City name"}, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + }, + }, + "required": ["city"], + }, + ), + ), + Tool( + type="function", + function=Function( + name="search", + description="Search the web", + parameters={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query", + }, + }, + "required": ["query"], + }, + ), + ), + ] + self.detector = Llama32Detector() + + # ==================== has_tool_call Tests ==================== + + def test_has_tool_call_with_python_tag(self): + text = '<|python_tag|>{"name": "get_weather", "arguments": {"city": "Beijing"}}' + self.assertTrue(self.detector.has_tool_call(text)) + + def test_has_tool_call_with_json_start(self): + text = '{"name": "get_weather", "arguments": {"city": "Beijing"}}' + self.assertTrue(self.detector.has_tool_call(text)) + + def test_has_tool_call_false(self): + text = "The weather in Beijing is sunny today." + self.assertFalse(self.detector.has_tool_call(text)) + + # ==================== detect_and_parse Tests ==================== + + def test_single_tool_call_with_python_tag(self): + text = '<|python_tag|>{"name": "get_weather", "arguments": {"city": "Beijing"}}' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(result.calls[0].name, "get_weather") + args = json.loads(result.calls[0].parameters) + self.assertEqual(args["city"], "Beijing") + + def test_single_tool_call_without_python_tag(self): + text = '{"name": "get_weather", "arguments": {"city": "Beijing"}}' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(result.calls[0].name, "get_weather") + + def test_normal_text_before_python_tag(self): + text = 'Let me check. <|python_tag|>{"name": "get_weather", "arguments": {"city": "Tokyo"}}' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(result.normal_text, "Let me check. ") + + def test_no_tool_call(self): + text = "The weather is nice today." + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 0) + self.assertEqual(result.normal_text, "The weather is nice today.") + + def test_multiple_json_objects(self): + text = '<|python_tag|>{"name": "get_weather", "arguments": {"city": "Beijing"}};{"name": "search", "arguments": {"query": "restaurants"}}' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 2) + self.assertEqual(result.calls[0].name, "get_weather") + self.assertEqual(result.calls[1].name, "search") + + def test_tool_call_with_multiple_arguments(self): + text = '<|python_tag|>{"name": "get_weather", "arguments": {"city": "London", "unit": "celsius"}}' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + args = json.loads(result.calls[0].parameters) + self.assertEqual(args["city"], "London") + self.assertEqual(args["unit"], "celsius") + + # ==================== Python dict conversion Tests ==================== + + def test_convert_python_dict_to_json(self): + python_dict = "{'name': 'get_weather', 'arguments': {'city': 'Beijing'}}" + result = self.detector._convert_python_dict_to_json(python_dict) + parsed = json.loads(result) + self.assertEqual(parsed["name"], "get_weather") + + def test_convert_invalid_string_returns_original(self): + invalid = "not a dict at all" + result = self.detector._convert_python_dict_to_json(invalid) + self.assertEqual(result, invalid) + + # ==================== structure_info Tests ==================== + + def test_structure_info(self): + info_func = self.detector.structure_info() + info = info_func("get_weather") + self.assertIn("get_weather", info.begin) + self.assertIn("<|python_tag|>", info.begin) + self.assertEqual(info.trigger, "<|python_tag|>") + + # ==================== Streaming Tests ==================== + + def test_streaming_single_tool_call(self): + detector = Llama32Detector() + chunks = [ + "<|python_", + 'tag|>{"name": "get_weather",', + ' "arguments": {"city": "Beijing"', + "}}", + ] + all_calls = [] + for chunk in chunks: + result = detector.parse_streaming_increment(chunk, self.tools) + all_calls.extend(result.calls) + + func_calls = [c for c in all_calls if c.name] + self.assertEqual(len(func_calls), 1) + self.assertEqual(func_calls[0].name, "get_weather") + + full_params = "".join(c.parameters for c in all_calls if c.parameters) + params = json.loads(full_params) + self.assertEqual(params["city"], "Beijing") + + def test_streaming_normal_text_before_tool(self): + detector = Llama32Detector() + result = detector.parse_streaming_increment( + "Let me check the weather. ", self.tools + ) + self.assertEqual(result.normal_text, "Let me check the weather. ") + self.assertEqual(len(result.calls), 0) + + def test_streaming_text_then_tool_call(self): + detector = Llama32Detector() + chunks = [ + "I'll look that up. ", + '<|python_tag|>{"name": "get_weather",', + ' "arguments": {"city": "Tokyo", "unit": "celsius"', + "}}", + ] + all_calls = [] + all_normal_text = "" + for chunk in chunks: + result = detector.parse_streaming_increment(chunk, self.tools) + all_calls.extend(result.calls) + all_normal_text += result.normal_text + + self.assertEqual(all_normal_text, "I'll look that up. ") + func_calls = [c for c in all_calls if c.name] + self.assertEqual(len(func_calls), 1) + self.assertEqual(func_calls[0].name, "get_weather") + full_params = "".join(c.parameters for c in all_calls if c.parameters) + params = json.loads(full_params) + self.assertEqual(params["city"], "Tokyo") + self.assertEqual(params["unit"], "celsius") + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/test/registered/unit/function_call/test_mistral_detector.py b/test/registered/unit/function_call/test_mistral_detector.py new file mode 100644 index 000000000..ec49efedb --- /dev/null +++ b/test/registered/unit/function_call/test_mistral_detector.py @@ -0,0 +1,224 @@ +"""Unit tests for MistralDetector — no server, no model loading.""" + +import json + +from sglang.srt.entrypoints.openai.protocol import Function, Tool +from sglang.srt.function_call.mistral_detector import MistralDetector +from sglang.test.ci.ci_register import register_cpu_ci +from sglang.test.test_utils import CustomTestCase + +register_cpu_ci(1.0, "stage-a-test-cpu") + + +class TestMistralDetector(CustomTestCase): + def setUp(self): + self.tools = [ + Tool( + type="function", + function=Function( + name="get_weather", + description="Get weather information", + parameters={ + "type": "object", + "properties": { + "city": {"type": "string", "description": "City name"}, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + }, + }, + "required": ["city"], + }, + ), + ), + Tool( + type="function", + function=Function( + name="search", + description="Search the web", + parameters={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query", + }, + }, + "required": ["query"], + }, + ), + ), + ] + self.detector = MistralDetector() + + # ==================== has_tool_call Tests ==================== + + def test_has_tool_call_json_array_format(self): + text = ( + '[TOOL_CALLS] [{"name": "get_weather", "arguments": {"city": "Beijing"}}]' + ) + self.assertTrue(self.detector.has_tool_call(text)) + + def test_has_tool_call_compact_format(self): + text = '[TOOL_CALLS]get_weather[ARGS]{"city": "Beijing"}' + self.assertTrue(self.detector.has_tool_call(text)) + + def test_has_tool_call_false(self): + text = "The weather in Beijing is sunny today." + self.assertFalse(self.detector.has_tool_call(text)) + + # ==================== JSON Array Format Tests ==================== + + def test_json_array_single_tool_call(self): + text = ( + '[TOOL_CALLS] [{"name": "get_weather", "arguments": {"city": "Beijing"}}]' + ) + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(result.calls[0].name, "get_weather") + args = json.loads(result.calls[0].parameters) + self.assertEqual(args["city"], "Beijing") + self.assertEqual(result.normal_text, "") + + def test_json_array_multiple_tool_calls(self): + text = '[TOOL_CALLS] [{"name": "get_weather", "arguments": {"city": "Beijing"}}, {"name": "search", "arguments": {"query": "restaurants"}}]' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 2) + self.assertEqual(result.calls[0].name, "get_weather") + self.assertEqual(result.calls[1].name, "search") + + def test_json_array_with_leading_text(self): + text = 'I will check. [TOOL_CALLS] [{"name": "get_weather", "arguments": {"city": "Tokyo"}}]' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(result.normal_text, "I will check.") + + # ==================== Compact Format Tests ==================== + + def test_compact_format_single_tool_call(self): + text = '[TOOL_CALLS]get_weather[ARGS]{"city": "Beijing"}' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(result.calls[0].name, "get_weather") + args = json.loads(result.calls[0].parameters) + self.assertEqual(args["city"], "Beijing") + + def test_compact_format_with_leading_text(self): + text = 'Let me help. [TOOL_CALLS]get_weather[ARGS]{"city": "Tokyo"}' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(result.normal_text, "Let me help.") + + # ==================== No Tool Call Tests ==================== + + def test_no_tool_call(self): + text = "The weather is nice today." + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 0) + self.assertEqual(result.normal_text, "The weather is nice today.") + + # ==================== Edge Cases ==================== + + def test_tool_call_with_nested_json(self): + text = '[TOOL_CALLS] [{"name": "get_weather", "arguments": {"city": "Beijing", "options": {"detailed": true}}}]' + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 1) + args = json.loads(result.calls[0].parameters) + self.assertEqual(args["options"]["detailed"], True) + + def test_json_array_with_invalid_json(self): + text = "[TOOL_CALLS] [not valid json]" + result = self.detector.detect_and_parse(text, self.tools) + self.assertEqual(len(result.calls), 0) + self.assertEqual(result.normal_text, "") + + # ==================== Internal Methods Tests ==================== + + def test_extract_json_array(self): + text = ( + '[TOOL_CALLS] [{"name": "get_weather", "arguments": {"city": "Beijing"}}]' + ) + result = self.detector._extract_json_array(text) + self.assertIsNotNone(result) + parsed = json.loads(result) + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0]["name"], "get_weather") + + def test_extract_json_array_nested_brackets(self): + text = ( + '[TOOL_CALLS] [{"name": "get_weather", "arguments": {"tags": ["a", "b"]}}]' + ) + result = self.detector._extract_json_array(text) + self.assertIsNotNone(result) + parsed = json.loads(result) + self.assertEqual(parsed[0]["arguments"]["tags"], ["a", "b"]) + + def test_extract_json_array_no_marker(self): + text = "no tool calls here" + result = self.detector._extract_json_array(text) + self.assertIsNone(result) + + # ==================== structure_info Tests ==================== + + def test_structure_info(self): + info_func = self.detector.structure_info() + info = info_func("get_weather") + self.assertIn("get_weather", info.begin) + self.assertIn("[TOOL_CALLS]", info.trigger) + self.assertEqual(info.end, "}]") + + # ==================== Streaming Tests ==================== + + def test_streaming_compact_format(self): + detector = MistralDetector() + chunks = [ + "[TOOL_", + "CALLS]get_weather", + '[ARGS]{"city": "Beijing"}', + ] + all_calls = [] + for chunk in chunks: + result = detector.parse_streaming_increment(chunk, self.tools) + all_calls.extend(result.calls) + + func_calls = [c for c in all_calls if c.name] + self.assertEqual(len(func_calls), 1) + self.assertEqual(func_calls[0].name, "get_weather") + + full_params = "".join(c.parameters for c in all_calls if c.parameters) + params = json.loads(full_params) + self.assertEqual(params["city"], "Beijing") + + def test_streaming_normal_text_before_tool(self): + detector = MistralDetector() + result = detector.parse_streaming_increment("Let me check. ", self.tools) + self.assertEqual(result.normal_text, "Let me check. ") + self.assertEqual(len(result.calls), 0) + + def test_streaming_text_then_tool_call(self): + detector = MistralDetector() + chunks = [ + "Sure! ", + "[TOOL_CALLS]get_weather", + '[ARGS]{"city": "Tokyo"}', + ] + all_calls = [] + all_normal_text = "" + for chunk in chunks: + result = detector.parse_streaming_increment(chunk, self.tools) + all_calls.extend(result.calls) + all_normal_text += result.normal_text + + self.assertEqual(all_normal_text, "Sure! ") + func_calls = [c for c in all_calls if c.name] + self.assertEqual(len(func_calls), 1) + self.assertEqual(func_calls[0].name, "get_weather") + full_params = "".join(c.parameters for c in all_calls if c.parameters) + params = json.loads(full_params) + self.assertEqual(params["city"], "Tokyo") + + +if __name__ == "__main__": + import unittest + + unittest.main()