741 lines
29 KiB
Python
741 lines
29 KiB
Python
"""Unit tests for HunyuanDetector - no server, no model loading."""
|
|
|
|
import json
|
|
import unittest
|
|
|
|
from sglang.srt.entrypoints.openai.protocol import Function, Tool
|
|
from sglang.srt.function_call.hunyuan_detector import HunyuanDetector
|
|
from sglang.test.ci.ci_register import register_cpu_ci
|
|
from sglang.test.test_utils import CustomTestCase
|
|
|
|
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
|
|
|
|
|
|
def _make_tools():
|
|
return [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="get_current_date",
|
|
description="Get the current date",
|
|
parameters={},
|
|
),
|
|
),
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="get_weather",
|
|
description="Get weather information",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"city": {"type": "string", "description": "City name"},
|
|
"date": {"type": "string", "description": "Date"},
|
|
},
|
|
"required": ["city"],
|
|
},
|
|
),
|
|
),
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="search",
|
|
description="Search the web",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"type": "string",
|
|
"description": "Search query",
|
|
},
|
|
"count": {
|
|
"type": "integer",
|
|
"description": "Number of results",
|
|
},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
),
|
|
),
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name="calculate",
|
|
description="Calculate expression",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"expression": {"type": "string"},
|
|
"precision": {"type": "number"},
|
|
"verbose": {"type": "boolean"},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
class _Hy4Tokenizer:
|
|
def get_vocab(self):
|
|
return {
|
|
"<tool_calls:opensource>": 1,
|
|
"<tool_call:opensource>": 2,
|
|
"<arg_key:opensource>": 3,
|
|
"<arg_value:opensource>": 4,
|
|
"<think:opensource>": 5,
|
|
}
|
|
|
|
|
|
class TestHunyuanDetectorHasToolCall(CustomTestCase):
|
|
def setUp(self):
|
|
self.detector = HunyuanDetector()
|
|
|
|
def test_has_tool_call_true(self):
|
|
text = (
|
|
"<tool_calls><tool_call>get_current_date<tool_sep></tool_call></tool_calls>"
|
|
)
|
|
self.assertTrue(self.detector.has_tool_call(text))
|
|
|
|
def test_has_tool_call_false(self):
|
|
self.assertFalse(
|
|
self.detector.has_tool_call("The weather in Beijing is sunny today.")
|
|
)
|
|
|
|
def test_has_tool_call_partial_tag(self):
|
|
self.assertFalse(self.detector.has_tool_call("<tool_call>"))
|
|
self.assertFalse(self.detector.has_tool_call("<tool_call"))
|
|
|
|
def test_has_tool_call_with_surrounding_text(self):
|
|
self.assertTrue(
|
|
self.detector.has_tool_call("text before <tool_calls> text after")
|
|
)
|
|
|
|
|
|
class TestHunyuanDetectorDetectAndParse(CustomTestCase):
|
|
def setUp(self):
|
|
self.tools = _make_tools()
|
|
self.detector = HunyuanDetector()
|
|
|
|
def test_no_tool_call(self):
|
|
text = "This is a plain response."
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(len(result.calls), 0)
|
|
self.assertEqual(result.normal_text, text)
|
|
|
|
def test_hy4_format_without_tool_separator(self):
|
|
detector = HunyuanDetector(_Hy4Tokenizer())
|
|
text = (
|
|
"<tool_calls:opensource>"
|
|
"<tool_call:opensource>get_weather"
|
|
"<arg_key:opensource>city</arg_key:opensource>"
|
|
"<arg_value:opensource>Beijing</arg_value:opensource>"
|
|
"</tool_call:opensource></tool_calls:opensource>"
|
|
)
|
|
|
|
result = detector.detect_and_parse(text, self.tools)
|
|
|
|
self.assertEqual(result.calls[0].name, "get_weather")
|
|
self.assertEqual(json.loads(result.calls[0].parameters), {"city": "Beijing"})
|
|
|
|
def test_zero_arg_inline(self):
|
|
text = (
|
|
"<tool_calls><tool_call>get_current_date<tool_sep></tool_call></tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.calls[0].name, "get_current_date")
|
|
self.assertEqual(json.loads(result.calls[0].parameters), {})
|
|
|
|
def test_zero_arg_newline(self):
|
|
text = (
|
|
"<tool_calls>\n"
|
|
"<tool_call>get_current_date<tool_sep>\n"
|
|
"</tool_call>\n"
|
|
"</tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.calls[0].name, "get_current_date")
|
|
|
|
def test_single_string_arg(self):
|
|
text = (
|
|
"<tool_calls><tool_call>get_weather<tool_sep>"
|
|
"<arg_key>city</arg_key><arg_value>Beijing</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
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": "Beijing"})
|
|
|
|
def test_multiple_args_same_line(self):
|
|
text = (
|
|
"<tool_calls><tool_call>get_weather<tool_sep>"
|
|
"<arg_key>city</arg_key><arg_value>Beijing</arg_value>"
|
|
"<arg_key>date</arg_key><arg_value>2026-03-30</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
args = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(args["city"], "Beijing")
|
|
self.assertEqual(args["date"], "2026-03-30")
|
|
|
|
def test_args_with_newlines(self):
|
|
text = (
|
|
"<tool_calls>\n"
|
|
"<tool_call>get_weather<tool_sep>\n"
|
|
"<arg_key>city</arg_key>\n"
|
|
"<arg_value>Beijing</arg_value>\n"
|
|
"<arg_key>date</arg_key>\n"
|
|
"<arg_value>2026-03-30</arg_value>\n"
|
|
"</tool_call>\n"
|
|
"</tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
args = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(args["city"], "Beijing")
|
|
self.assertEqual(args["date"], "2026-03-30")
|
|
|
|
def test_content_before_tool_call(self):
|
|
text = (
|
|
"Checking."
|
|
"<tool_calls>\n"
|
|
"<tool_call>get_current_date<tool_sep>\n"
|
|
"</tool_call>\n"
|
|
"</tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(len(result.calls), 1)
|
|
self.assertEqual(result.normal_text, "Checking.")
|
|
|
|
def test_multiple_tool_calls(self):
|
|
text = (
|
|
"<tool_calls>\n"
|
|
"<tool_call>get_weather<tool_sep>\n"
|
|
"<arg_key>city</arg_key>\n<arg_value>Beijing</arg_value>\n"
|
|
"</tool_call>\n"
|
|
"<tool_call>get_weather<tool_sep>\n"
|
|
"<arg_key>city</arg_key>\n<arg_value>Hangzhou</arg_value>\n"
|
|
"</tool_call>\n"
|
|
"</tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(len(result.calls), 2)
|
|
self.assertEqual(json.loads(result.calls[0].parameters)["city"], "Beijing")
|
|
self.assertEqual(json.loads(result.calls[1].parameters)["city"], "Hangzhou")
|
|
|
|
def test_empty_content_returns_empty_normal_text(self):
|
|
text = "<tool_calls>\n<tool_call>get_current_date<tool_sep>\n</tool_call>\n</tool_calls>"
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(result.normal_text, "")
|
|
|
|
def test_unknown_tool_skipped(self):
|
|
text = (
|
|
"<tool_calls><tool_call>nonexistent_func<tool_sep>"
|
|
"<arg_key>x</arg_key><arg_value>1</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(len(result.calls), 0)
|
|
|
|
def test_mixed_known_and_unknown_tools(self):
|
|
"""Known tools should be parsed, unknown ones skipped."""
|
|
text = (
|
|
"<tool_calls>"
|
|
"<tool_call>get_current_date<tool_sep></tool_call>"
|
|
"<tool_call>nonexistent<tool_sep></tool_call>"
|
|
"<tool_call>search<tool_sep>"
|
|
"<arg_key>query</arg_key><arg_value>test</arg_value>"
|
|
"</tool_call>"
|
|
"</tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(len(result.calls), 2)
|
|
self.assertEqual(result.calls[0].name, "get_current_date")
|
|
self.assertEqual(result.calls[1].name, "search")
|
|
|
|
def test_three_parallel_tool_calls(self):
|
|
text = (
|
|
"<tool_calls>"
|
|
"<tool_call>get_weather<tool_sep>"
|
|
"<arg_key>city</arg_key><arg_value>Beijing</arg_value>"
|
|
"</tool_call>"
|
|
"<tool_call>get_weather<tool_sep>"
|
|
"<arg_key>city</arg_key><arg_value>Tokyo</arg_value>"
|
|
"</tool_call>"
|
|
"<tool_call>get_current_date<tool_sep></tool_call>"
|
|
"</tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
self.assertEqual(len(result.calls), 3)
|
|
self.assertEqual(result.calls[0].name, "get_weather")
|
|
self.assertEqual(result.calls[1].name, "get_weather")
|
|
self.assertEqual(result.calls[2].name, "get_current_date")
|
|
# tool_index maps to position in tools list
|
|
self.assertEqual(result.calls[0].tool_index, 1) # get_weather is index 1
|
|
self.assertEqual(result.calls[2].tool_index, 0) # get_current_date is index 0
|
|
|
|
|
|
class TestHunyuanDetectorArgDeserialization(CustomTestCase):
|
|
"""Test type-aware argument deserialization."""
|
|
|
|
def setUp(self):
|
|
self.tools = _make_tools()
|
|
self.detector = HunyuanDetector()
|
|
|
|
def test_integer_arg(self):
|
|
text = (
|
|
"<tool_calls><tool_call>search<tool_sep>"
|
|
"<arg_key>query</arg_key><arg_value>restaurants</arg_value>"
|
|
"<arg_key>count</arg_key><arg_value>5</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
args = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(args["query"], "restaurants")
|
|
self.assertEqual(args["count"], 5)
|
|
self.assertIsInstance(args["count"], int)
|
|
|
|
def test_float_arg(self):
|
|
text = (
|
|
"<tool_calls><tool_call>calculate<tool_sep>"
|
|
"<arg_key>expression</arg_key><arg_value>1+1</arg_value>"
|
|
"<arg_key>precision</arg_key><arg_value>0.01</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
args = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(args["expression"], "1+1")
|
|
self.assertAlmostEqual(args["precision"], 0.01)
|
|
|
|
def test_boolean_arg(self):
|
|
text = (
|
|
"<tool_calls><tool_call>calculate<tool_sep>"
|
|
"<arg_key>expression</arg_key><arg_value>2+2</arg_value>"
|
|
"<arg_key>verbose</arg_key><arg_value>true</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
args = json.loads(result.calls[0].parameters)
|
|
self.assertIs(args["verbose"], True)
|
|
|
|
def test_top_level_composed_schema_args(self):
|
|
cases = (
|
|
("anyOf", {"type": "integer"}, "7", 7),
|
|
("oneOf", {"type": "boolean"}, "true", True),
|
|
("allOf", {"type": "array", "items": {"type": "integer"}}, "[1,2]", [1, 2]),
|
|
)
|
|
for keyword, arg_schema, raw_value, expected in cases:
|
|
with self.subTest(keyword=keyword):
|
|
function_name = f"composed_{keyword}"
|
|
tools = [
|
|
Tool(
|
|
type="function",
|
|
function=Function(
|
|
name=function_name,
|
|
description="Composed schema",
|
|
parameters={
|
|
keyword: [
|
|
{
|
|
"type": "object",
|
|
"properties": {"value": arg_schema},
|
|
}
|
|
]
|
|
},
|
|
),
|
|
)
|
|
]
|
|
text = (
|
|
f"<tool_calls><tool_call>{function_name}<tool_sep>"
|
|
f"<arg_key>value</arg_key><arg_value>{raw_value}</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
|
|
result = self.detector.detect_and_parse(text, tools)
|
|
args = json.loads(result.calls[0].parameters)
|
|
|
|
self.assertEqual(args, {"value": expected})
|
|
|
|
def test_string_arg_not_deserialized(self):
|
|
"""String-typed args should stay as strings even if they look like JSON."""
|
|
text = (
|
|
"<tool_calls><tool_call>search<tool_sep>"
|
|
'<arg_key>query</arg_key><arg_value>{"key": "value"}</arg_value>'
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
args = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(args["query"], '{"key": "value"}')
|
|
self.assertIsInstance(args["query"], str)
|
|
|
|
def test_non_json_value_stays_string(self):
|
|
"""Non-JSON-parseable values for non-string types should fall back to string."""
|
|
text = (
|
|
"<tool_calls><tool_call>search<tool_sep>"
|
|
"<arg_key>query</arg_key><arg_value>hello world</arg_value>"
|
|
"<arg_key>count</arg_key><arg_value>not a number</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
result = self.detector.detect_and_parse(text, self.tools)
|
|
args = json.loads(result.calls[0].parameters)
|
|
self.assertEqual(args["count"], "not a number")
|
|
|
|
|
|
def _collect_streamed_tool_calls(all_calls):
|
|
"""Accumulate streaming ToolCallItems (name + arg-JSON fragments) by tool_index."""
|
|
tools = {}
|
|
for c in all_calls:
|
|
idx = c.tool_index
|
|
if idx not in tools:
|
|
tools[idx] = {"name": c.name or "", "parameters": c.parameters or ""}
|
|
else:
|
|
if c.name:
|
|
tools[idx]["name"] += c.name
|
|
if c.parameters:
|
|
tools[idx]["parameters"] += c.parameters
|
|
return [tools[i] for i in sorted(tools.keys())]
|
|
|
|
|
|
class TestHunyuanDetectorStreaming(CustomTestCase):
|
|
def setUp(self):
|
|
self.tools = _make_tools()
|
|
|
|
def _new_detector(self):
|
|
return HunyuanDetector()
|
|
|
|
def test_normal_text_only(self):
|
|
detector = self._new_detector()
|
|
result = detector.parse_streaming_increment(
|
|
"Hello, I can help you with that.", self.tools
|
|
)
|
|
self.assertEqual(result.normal_text, "Hello, I can help you with that.")
|
|
self.assertEqual(len(result.calls), 0)
|
|
|
|
def test_complete_tool_call_single_chunk(self):
|
|
detector = self._new_detector()
|
|
text = (
|
|
"<tool_calls><tool_call>get_current_date<tool_sep></tool_call></tool_calls>"
|
|
)
|
|
result = detector.parse_streaming_increment(text, self.tools)
|
|
collected = _collect_streamed_tool_calls(result.calls)
|
|
self.assertEqual(len(collected), 1)
|
|
self.assertEqual(collected[0]["name"], "get_current_date")
|
|
self.assertEqual(json.loads(collected[0]["parameters"]), {})
|
|
|
|
def test_hy4_format_without_tool_separator_char_by_char(self):
|
|
detector = HunyuanDetector(_Hy4Tokenizer())
|
|
text = (
|
|
"<tool_calls:opensource>"
|
|
"<tool_call:opensource>get_weather"
|
|
"<arg_key:opensource>city</arg_key:opensource>"
|
|
"<arg_value:opensource>Tokyo</arg_value:opensource>"
|
|
"</tool_call:opensource></tool_calls:opensource>"
|
|
)
|
|
all_calls = []
|
|
for char in text:
|
|
all_calls.extend(detector.parse_streaming_increment(char, self.tools).calls)
|
|
|
|
collected = _collect_streamed_tool_calls(all_calls)
|
|
self.assertEqual(collected[0]["name"], "get_weather")
|
|
self.assertEqual(json.loads(collected[0]["parameters"]), {"city": "Tokyo"})
|
|
|
|
def test_chunked_tool_call(self):
|
|
detector = self._new_detector()
|
|
chunks = [
|
|
"<tool_calls>",
|
|
"<tool_call>get_weather<tool_sep>",
|
|
"<arg_key>city</arg_key>",
|
|
"<arg_value>Tokyo</arg_value>",
|
|
"</tool_call>",
|
|
"</tool_calls>",
|
|
]
|
|
all_calls = []
|
|
for chunk in chunks:
|
|
result = detector.parse_streaming_increment(chunk, self.tools)
|
|
all_calls.extend(result.calls)
|
|
|
|
collected = _collect_streamed_tool_calls(all_calls)
|
|
self.assertEqual(len(collected), 1)
|
|
self.assertEqual(collected[0]["name"], "get_weather")
|
|
args = json.loads(collected[0]["parameters"])
|
|
self.assertEqual(args["city"], "Tokyo")
|
|
|
|
def test_normal_text_before_tool(self):
|
|
detector = self._new_detector()
|
|
r1 = detector.parse_streaming_increment("Let me check. ", self.tools)
|
|
self.assertIn("Let me check.", r1.normal_text)
|
|
|
|
r2 = detector.parse_streaming_increment(
|
|
"<tool_calls><tool_call>get_current_date<tool_sep></tool_call></tool_calls>",
|
|
self.tools,
|
|
)
|
|
collected = _collect_streamed_tool_calls(r2.calls)
|
|
self.assertEqual([c["name"] for c in collected], ["get_current_date"])
|
|
|
|
def test_multiple_tool_calls_chunked(self):
|
|
detector = self._new_detector()
|
|
chunks = [
|
|
"<tool_calls>\n",
|
|
"<tool_call>get_weather<tool_sep>\n",
|
|
"<arg_key>city</arg_key><arg_value>Beijing</arg_value>\n",
|
|
"</tool_call>\n",
|
|
"<tool_call>get_weather<tool_sep>\n",
|
|
"<arg_key>city</arg_key><arg_value>Tokyo</arg_value>\n",
|
|
"</tool_call>\n",
|
|
"</tool_calls>",
|
|
]
|
|
all_calls = []
|
|
for chunk in chunks:
|
|
result = detector.parse_streaming_increment(chunk, self.tools)
|
|
all_calls.extend(result.calls)
|
|
|
|
collected = _collect_streamed_tool_calls(all_calls)
|
|
self.assertEqual(len(collected), 2)
|
|
self.assertEqual(json.loads(collected[0]["parameters"])["city"], "Beijing")
|
|
self.assertEqual(json.loads(collected[1]["parameters"])["city"], "Tokyo")
|
|
|
|
def test_partial_bot_token_buffered(self):
|
|
"""Partial <tool_calls> at end of chunk should be buffered, not emitted."""
|
|
detector = self._new_detector()
|
|
r1 = detector.parse_streaming_increment("Hello <tool_", self.tools)
|
|
# "Hello " should be emitted, "<tool_" buffered
|
|
self.assertIn("Hello", r1.normal_text)
|
|
self.assertNotIn("<tool_", r1.normal_text)
|
|
|
|
def test_char_by_char_streaming(self):
|
|
"""Simulate extreme character-by-character streaming."""
|
|
detector = self._new_detector()
|
|
full = (
|
|
"<tool_calls><tool_call>get_current_date<tool_sep></tool_call></tool_calls>"
|
|
)
|
|
all_calls = []
|
|
for ch in full:
|
|
result = detector.parse_streaming_increment(ch, self.tools)
|
|
all_calls.extend(result.calls)
|
|
|
|
collected = _collect_streamed_tool_calls(all_calls)
|
|
self.assertEqual(len(collected), 1)
|
|
self.assertEqual(collected[0]["name"], "get_current_date")
|
|
self.assertEqual(json.loads(collected[0]["parameters"]), {})
|
|
|
|
def test_streaming_with_args_char_by_char(self):
|
|
detector = self._new_detector()
|
|
full = (
|
|
"<tool_calls><tool_call>get_weather<tool_sep>"
|
|
"<arg_key>city</arg_key><arg_value>NYC</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
all_calls = []
|
|
for ch in full:
|
|
result = detector.parse_streaming_increment(ch, self.tools)
|
|
all_calls.extend(result.calls)
|
|
|
|
collected = _collect_streamed_tool_calls(all_calls)
|
|
self.assertEqual(len(collected), 1)
|
|
args = json.loads(collected[0]["parameters"])
|
|
self.assertEqual(args["city"], "NYC")
|
|
|
|
def test_streaming_three_tools_sequential(self):
|
|
"""Three different tool calls arriving sequentially."""
|
|
detector = self._new_detector()
|
|
chunks = [
|
|
"<tool_calls>",
|
|
"<tool_call>get_current_date<tool_sep></tool_call>",
|
|
"<tool_call>get_weather<tool_sep><arg_key>city</arg_key><arg_value>SF</arg_value></tool_call>",
|
|
"<tool_call>search<tool_sep><arg_key>query</arg_key><arg_value>test</arg_value></tool_call>",
|
|
"</tool_calls>",
|
|
]
|
|
all_calls = []
|
|
for chunk in chunks:
|
|
result = detector.parse_streaming_increment(chunk, self.tools)
|
|
all_calls.extend(result.calls)
|
|
|
|
collected = _collect_streamed_tool_calls(all_calls)
|
|
self.assertEqual(len(collected), 3)
|
|
self.assertEqual(collected[0]["name"], "get_current_date")
|
|
self.assertEqual(collected[1]["name"], "get_weather")
|
|
self.assertEqual(collected[2]["name"], "search")
|
|
# Streaming uses sequential tool_index (0, 1, 2)
|
|
self.assertEqual(sorted({c.tool_index for c in all_calls}), [0, 1, 2])
|
|
|
|
def test_streaming_normal_text_not_lost(self):
|
|
"""All normal text before tool_calls should be fully emitted."""
|
|
detector = self._new_detector()
|
|
all_normal = ""
|
|
for chunk in ["I will ", "check the ", "date now. "]:
|
|
result = detector.parse_streaming_increment(chunk, self.tools)
|
|
all_normal += result.normal_text
|
|
|
|
result = detector.parse_streaming_increment(
|
|
"<tool_calls><tool_call>get_current_date<tool_sep></tool_call></tool_calls>",
|
|
self.tools,
|
|
)
|
|
all_normal += result.normal_text
|
|
self.assertIn("I will check the date now.", all_normal)
|
|
|
|
def test_streaming_name_comes_before_args(self):
|
|
"""The name delta must arrive before any arg deltas (two-phase contract)."""
|
|
detector = self._new_detector()
|
|
text = (
|
|
"<tool_calls><tool_call>get_weather<tool_sep>"
|
|
"<arg_key>city</arg_key><arg_value>Paris</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
all_calls = []
|
|
for ch in text:
|
|
all_calls.extend(detector.parse_streaming_increment(ch, self.tools).calls)
|
|
|
|
name_indices = [i for i, c in enumerate(all_calls) if c.name]
|
|
param_indices = [i for i, c in enumerate(all_calls) if c.parameters]
|
|
self.assertTrue(name_indices, "expected at least one name delta")
|
|
self.assertTrue(param_indices, "expected at least one arg delta")
|
|
self.assertLess(min(name_indices), min(param_indices))
|
|
|
|
def test_streaming_typed_args_coerced(self):
|
|
"""Streaming must apply schema-aware type coercion (int/float/bool)."""
|
|
detector = self._new_detector()
|
|
chunks = [
|
|
"<tool_calls>",
|
|
"<tool_call>search<tool_sep>",
|
|
"<arg_key>query</arg_key><arg_value>pizza</arg_value>",
|
|
"<arg_key>count</arg_key><arg_value>7</arg_value>",
|
|
"</tool_call></tool_calls>",
|
|
]
|
|
all_calls = []
|
|
for chunk in chunks:
|
|
all_calls.extend(
|
|
detector.parse_streaming_increment(chunk, self.tools).calls
|
|
)
|
|
collected = _collect_streamed_tool_calls(all_calls)
|
|
args = json.loads(collected[0]["parameters"])
|
|
self.assertEqual(args["query"], "pizza")
|
|
self.assertEqual(args["count"], 7)
|
|
self.assertIsInstance(args["count"], int)
|
|
|
|
def test_streaming_string_arg_holds_back_partial_end_tag(self):
|
|
"""Char-by-char string streaming must not leak `</arg_value>` into the value."""
|
|
detector = self._new_detector()
|
|
full = (
|
|
"<tool_calls><tool_call>get_weather<tool_sep>"
|
|
"<arg_key>city</arg_key><arg_value>San Francisco</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
all_calls = []
|
|
for ch in full:
|
|
all_calls.extend(detector.parse_streaming_increment(ch, self.tools).calls)
|
|
|
|
collected = _collect_streamed_tool_calls(all_calls)
|
|
args = json.loads(collected[0]["parameters"])
|
|
self.assertEqual(args["city"], "San Francisco")
|
|
|
|
def test_streaming_all_in_one_delta(self):
|
|
"""Entire tool call arriving in a single delta."""
|
|
detector = self._new_detector()
|
|
text = (
|
|
"<tool_calls>\n<tool_call>get_current_date<tool_sep>\n"
|
|
"</tool_call>\n</tool_calls>"
|
|
)
|
|
result = detector.parse_streaming_increment(text, self.tools)
|
|
collected = _collect_streamed_tool_calls(result.calls)
|
|
self.assertEqual(len(collected), 1)
|
|
self.assertEqual(collected[0]["name"], "get_current_date")
|
|
self.assertEqual(json.loads(collected[0]["parameters"]), {})
|
|
|
|
def test_streaming_content_before(self):
|
|
"""Normal text preceding a tool call must be surfaced."""
|
|
detector = self._new_detector()
|
|
deltas = [
|
|
"Checking.",
|
|
"<tool_calls>",
|
|
"\n<tool_call>",
|
|
"get_current_date",
|
|
"<tool_sep>",
|
|
"\n</tool_call>",
|
|
"\n</tool_calls>",
|
|
]
|
|
all_calls = []
|
|
all_normal = ""
|
|
for d in deltas:
|
|
r = detector.parse_streaming_increment(d, self.tools)
|
|
all_calls.extend(r.calls)
|
|
all_normal += r.normal_text
|
|
self.assertIn("Checking.", all_normal)
|
|
collected = _collect_streamed_tool_calls(all_calls)
|
|
self.assertEqual(len(collected), 1)
|
|
self.assertEqual(collected[0]["name"], "get_current_date")
|
|
|
|
|
|
class TestHunyuanDetectorStructureInfo(CustomTestCase):
|
|
def setUp(self):
|
|
self.detector = HunyuanDetector()
|
|
|
|
def test_structure_info_content(self):
|
|
info_fn = self.detector.structure_info()
|
|
info = info_fn("get_weather")
|
|
self.assertIn("get_weather", info.begin)
|
|
self.assertIn("<tool_call>", info.begin)
|
|
self.assertIn("<tool_sep>", info.begin)
|
|
self.assertIn("</tool_call>", info.end)
|
|
self.assertEqual(info.trigger, "<tool_calls>")
|
|
|
|
def test_supports_structural_tag(self):
|
|
self.assertFalse(self.detector.supports_structural_tag())
|
|
|
|
|
|
class TestHunyuanDetectorFunctionCallParser(CustomTestCase):
|
|
"""Test through the FunctionCallParser interface."""
|
|
|
|
def setUp(self):
|
|
self.tools = _make_tools()
|
|
|
|
def test_parser_registry(self):
|
|
from sglang.srt.function_call.function_call_parser import FunctionCallParser
|
|
|
|
parser = FunctionCallParser(self.tools, "hunyuan")
|
|
self.assertIsInstance(parser.detector, HunyuanDetector)
|
|
|
|
def test_parse_non_stream(self):
|
|
from sglang.srt.function_call.function_call_parser import FunctionCallParser
|
|
|
|
parser = FunctionCallParser(self.tools, "hunyuan")
|
|
text = (
|
|
"Checking.<tool_calls><tool_call>get_weather<tool_sep>"
|
|
"<arg_key>city</arg_key><arg_value>Tokyo</arg_value>"
|
|
"</tool_call></tool_calls>"
|
|
)
|
|
normal, calls = parser.parse_non_stream(text)
|
|
self.assertEqual(normal, "Checking.")
|
|
self.assertEqual(len(calls), 1)
|
|
self.assertEqual(calls[0].name, "get_weather")
|
|
self.assertEqual(json.loads(calls[0].parameters)["city"], "Tokyo")
|
|
|
|
def test_parse_stream_chunks(self):
|
|
from sglang.srt.function_call.function_call_parser import FunctionCallParser
|
|
|
|
parser = FunctionCallParser(self.tools, "hunyuan")
|
|
chunks = [
|
|
"<tool_calls>",
|
|
"<tool_call>get_current_date<tool_sep></tool_call>",
|
|
"</tool_calls>",
|
|
]
|
|
all_calls = []
|
|
for chunk in chunks:
|
|
normal, calls = parser.parse_stream_chunk(chunk)
|
|
all_calls.extend(calls)
|
|
|
|
collected = _collect_streamed_tool_calls(all_calls)
|
|
self.assertEqual(len(collected), 1)
|
|
self.assertEqual(collected[0]["name"], "get_current_date")
|
|
self.assertEqual(json.loads(collected[0]["parameters"]), {})
|
|
|
|
def test_has_tool_call_through_parser(self):
|
|
from sglang.srt.function_call.function_call_parser import FunctionCallParser
|
|
|
|
parser = FunctionCallParser(self.tools, "hunyuan")
|
|
self.assertTrue(parser.has_tool_call("<tool_calls>foo</tool_calls>"))
|
|
self.assertFalse(parser.has_tool_call("no tools here"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|