Files
sglang/test/registered/unit/function_call/test_spark25_detector.py
T

203 lines
6.8 KiB
Python

"""Unit tests for Spark25Detector - no server, no model loading."""
import json
import unittest
from sglang.srt.entrypoints.openai.protocol import Function, Tool
from sglang.srt.environ import envs
from sglang.srt.function_call.function_call_parser import FunctionCallParser
from sglang.srt.function_call.spark25_detector import Spark25Detector
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=11, suite="base-a-test-cpu")
def _xml(name: str, arguments: list[tuple[str, str]]) -> str:
pairs = "".join(
f"<arg_key>{key}</arg_key><arg_value>{value}</arg_value>"
for key, value in arguments
)
return f"<tool_call>{name}{pairs}</tool_call>"
def _tools():
return [
Tool(
type="function",
function=Function(
name="set_state",
parameters={
"type": "object",
"properties": {
"name": {"type": "string"},
"count": {"type": "integer"},
"ratio": {"type": "number"},
"active": {"type": "boolean"},
"items": {"type": "array"},
"metadata": {"type": "object"},
},
},
),
),
Tool(
type="function",
function=Function(
name="now",
parameters={"type": "object", "properties": {}},
),
),
]
class TestSpark25DetectorDetectAndParse(CustomTestCase):
def setUp(self):
self.tools = _tools()
self.detector = Spark25Detector()
def test_spark25_parser_is_registered(self):
self.assertIs(FunctionCallParser.ToolCallParserEnum["spark25"], Spark25Detector)
def test_nonstream_parses_multiple_calls_and_preserves_normal_text(self):
text = (
"before"
+ _xml(
"set_state",
[
("name", "上海"),
("count", "42"),
("ratio", "2.5"),
("active", "1"),
("items", '["a", "b"]'),
("metadata", '{"source":"spark"}'),
],
)
+ "middle"
+ _xml("now", [])
+ "after"
)
result = self.detector.detect_and_parse(text, self.tools)
self.assertEqual(result.normal_text, "beforemiddleafter")
self.assertEqual([call.tool_index for call in result.calls], [0, 1])
self.assertEqual([call.name for call in result.calls], ["set_state", "now"])
self.assertEqual(
json.loads(result.calls[0].parameters),
{
"name": "上海",
"count": 42,
"ratio": 2.5,
"active": True,
"items": ["a", "b"],
"metadata": {"source": "spark"},
},
)
self.assertEqual(json.loads(result.calls[1].parameters), {})
def test_null_and_conversion_fallbacks_match_spark2_5_protocol(self):
text = _xml(
"set_state",
[
("name", "null"),
("count", "not-an-int"),
("active", "false"),
("undeclared", "42"),
],
)
result = self.detector.detect_and_parse(text, self.tools)
self.assertEqual(
json.loads(result.calls[0].parameters),
{
"name": None,
"count": "not-an-int",
"active": False,
"undeclared": "42",
},
)
def test_malformed_block_is_text_and_unknown_tool_honors_policy(self):
malformed = "x<tool_call></tool_call>y"
result = self.detector.detect_and_parse(malformed, self.tools)
self.assertEqual(result.normal_text, malformed)
self.assertEqual(result.calls, [])
unknown = _xml("missing", [("value", "1")])
with envs.SGLANG_FORWARD_UNKNOWN_TOOLS.override(False):
result = self.detector.detect_and_parse(unknown, self.tools)
self.assertEqual(result.normal_text, "")
self.assertEqual(result.calls, [])
with envs.SGLANG_FORWARD_UNKNOWN_TOOLS.override(True):
result = self.detector.detect_and_parse(unknown, self.tools)
self.assertEqual(result.calls[0].name, "missing")
self.assertEqual(json.loads(result.calls[0].parameters), {"value": "1"})
def test_stream_end_flushes_partial_marker_and_required_stays_native(self):
result = self.detector.parse_streaming_increment("plain<tool_", self.tools)
self.assertEqual(result.normal_text, "plain")
self.assertEqual(self.detector.finish(self.tools).normal_text, "<tool_")
truncated = Spark25Detector()
result = truncated.parse_streaming_increment(
"plain<tool_call>set_state<arg_key>count</arg_key>", self.tools
)
self.assertEqual(result.normal_text, "plain")
self.assertEqual(truncated.finish(self.tools).normal_text, "")
self.assertFalse(self.detector.supports_structural_tag())
self.assertTrue(self.detector.parses_required_natively())
self.assertIs(
FunctionCallParser(self.tools, "spark25").get_structure_constraint(
"required"
),
None,
)
class TestSpark25DetectorStreaming(CustomTestCase):
def setUp(self):
self.tools = _tools()
def test_streaming_character_chunks_match_nonstream_result(self):
text = (
"answer:"
+ _xml("set_state", [("count", "42"), ("active", "0")])
+ _xml("now", [])
+ "done"
)
detector = Spark25Detector()
normal_parts = []
calls = []
for character in text:
result = detector.parse_streaming_increment(character, self.tools)
normal_parts.append(result.normal_text)
calls.extend(result.calls)
end = detector.finish(self.tools)
normal_parts.append(end.normal_text)
calls.extend(end.calls)
self.assertEqual("".join(normal_parts), "answer:done")
self.assertEqual([call.tool_index for call in calls], [0, 1])
self.assertEqual(
json.loads(calls[0].parameters), {"count": 42, "active": False}
)
self.assertEqual(json.loads(calls[1].parameters), {})
self.assertEqual(
detector.prev_tool_call_arr,
[
{"name": "set_state", "arguments": {"count": 42, "active": False}},
{"name": "now", "arguments": {}},
],
)
self.assertEqual(
detector.streamed_args_for_tool,
['{"count":42,"active":false}', "{}"],
)
if __name__ == "__main__":
unittest.main()