Fix tool call constrained decoding and parsing for models with native formats (#21593)
This commit is contained in:
@@ -822,5 +822,77 @@ class ServingChatTestCase(unittest.TestCase):
|
||||
self.assertIn("must be an integer", context.exception.detail)
|
||||
|
||||
|
||||
class TestProcessToolCallsWithRequiredToolChoice(unittest.TestCase):
|
||||
"""Test _process_tool_calls with tool_choice='required' uses model-specific parser."""
|
||||
|
||||
def setUp(self):
|
||||
tm = _MockTokenizerManager()
|
||||
tm.server_args.tool_call_parser = "kimi_k2"
|
||||
self.chat = OpenAIServingChat(tm, _MockTemplateManager())
|
||||
|
||||
def test_required_with_parser_uses_function_call_parser(self):
|
||||
"""tool_choice='required' should use FunctionCallParser when tool_call_parser is set."""
|
||||
with patch(
|
||||
"sglang.srt.entrypoints.openai.serving_chat.FunctionCallParser"
|
||||
) as ParserMock:
|
||||
call_info = Mock()
|
||||
call_info.name = "get_weather"
|
||||
call_info.parameters = '{"location":"Tokyo"}'
|
||||
call_info.tool_index = 0
|
||||
|
||||
parser_instance = ParserMock.return_value
|
||||
parser_instance.has_tool_call.return_value = True
|
||||
parser_instance.parse_non_stream.return_value = ("", [call_info])
|
||||
|
||||
finish_reason = {"type": "stop", "matched": None}
|
||||
tools = [{"type": "function", "function": {"name": "get_weather"}}]
|
||||
|
||||
tool_calls, text, fr = self.chat._process_tool_calls(
|
||||
text="<|tool_calls_section_begin|>...<|tool_calls_section_end|>",
|
||||
tools=tools,
|
||||
finish_reason=finish_reason,
|
||||
tool_choice="required",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(tool_calls)
|
||||
self.assertEqual(len(tool_calls), 1)
|
||||
self.assertEqual(tool_calls[0].function.name, "get_weather")
|
||||
self.assertEqual(fr["type"], "tool_calls")
|
||||
|
||||
def test_required_without_parser_falls_back_to_json(self):
|
||||
"""tool_choice='required' without parser should parse as JSON array."""
|
||||
self.chat.tool_call_parser = None
|
||||
|
||||
finish_reason = {"type": "stop", "matched": None}
|
||||
tools = [{"type": "function", "function": {"name": "get_weather"}}]
|
||||
|
||||
tool_calls, text, fr = self.chat._process_tool_calls(
|
||||
text='[{"name":"get_weather","parameters":{"location":"Tokyo"}}]',
|
||||
tools=tools,
|
||||
finish_reason=finish_reason,
|
||||
tool_choice="required",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(tool_calls)
|
||||
self.assertEqual(len(tool_calls), 1)
|
||||
self.assertEqual(tool_calls[0].function.name, "get_weather")
|
||||
|
||||
def test_required_without_parser_invalid_json_returns_none(self):
|
||||
"""tool_choice='required' without parser and invalid JSON returns tool_calls=None."""
|
||||
self.chat.tool_call_parser = None
|
||||
|
||||
finish_reason = {"type": "stop", "matched": None}
|
||||
tools = [{"type": "function", "function": {"name": "get_weather"}}]
|
||||
|
||||
tool_calls, text, fr = self.chat._process_tool_calls(
|
||||
text="<|tool_calls_section_begin|>not json",
|
||||
tools=tools,
|
||||
finish_reason=finish_reason,
|
||||
tool_choice="required",
|
||||
)
|
||||
|
||||
self.assertIsNone(tool_calls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -348,8 +348,12 @@ class TestToolChoiceLlama32(CustomTestCase):
|
||||
self.assertEqual(found_name, "get_weather")
|
||||
|
||||
def test_required_streaming_arguments_chunks_json(self):
|
||||
"""In streaming required mode, complete tool call arguments should be valid JSON when all chunks are combined"""
|
||||
"""In streaming required mode, complete tool call arguments should be valid JSON when all chunks are combined.
|
||||
Uses strict=True so the grammar enforces the parameter schema."""
|
||||
tools = self.get_test_tools()
|
||||
# Add strict=True so arguments are schema-constrained
|
||||
for tool in tools:
|
||||
tool["function"]["strict"] = True
|
||||
messages = self.get_test_messages()
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
@@ -406,13 +410,15 @@ class TestToolChoiceLlama32(CustomTestCase):
|
||||
)
|
||||
|
||||
def test_complex_parameters_required_non_streaming(self):
|
||||
"""Validate complex nested parameter schemas in non-streaming required mode"""
|
||||
"""Validate complex nested parameter schemas in non-streaming required mode.
|
||||
Uses strict=True so the grammar enforces the parameter schema."""
|
||||
complex_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "analyze_data",
|
||||
"description": "Analyze complex data structures",
|
||||
"strict": True,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -3925,6 +3925,119 @@ function call<|role_sep|>
|
||||
self.assertEqual(params["city"], "Rome")
|
||||
|
||||
|
||||
class TestGetStructureConstraint(unittest.TestCase):
|
||||
"""Tests for FunctionCallParser.get_structure_constraint() logic.
|
||||
|
||||
Verifies that detectors supporting structural_tag use it for required/named
|
||||
tool_choice, and that the generic json_schema fallback is used otherwise.
|
||||
"""
|
||||
|
||||
def _make_tools(self, strict=False):
|
||||
return [
|
||||
Tool(
|
||||
type="function",
|
||||
function=Function(
|
||||
name="get_weather",
|
||||
description="Get weather",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
strict=strict,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def _make_parser(self, parser_name, strict=False):
|
||||
from sglang.srt.function_call.function_call_parser import FunctionCallParser
|
||||
|
||||
return FunctionCallParser(self._make_tools(strict=strict), parser_name)
|
||||
|
||||
# --- structural_tag detectors (kimi_k2, deepseekv3, qwen25, etc.) ---
|
||||
|
||||
def test_kimi_required_strict_returns_structural_tag(self):
|
||||
parser = self._make_parser("kimi_k2", strict=True)
|
||||
result = parser.get_structure_constraint("required")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result[0], "structural_tag")
|
||||
self.assertTrue(result[1].at_least_one)
|
||||
|
||||
def test_kimi_required_no_strict_returns_structural_tag(self):
|
||||
"""required should use structural_tag even without strict, to preserve native format."""
|
||||
parser = self._make_parser("kimi_k2", strict=False)
|
||||
result = parser.get_structure_constraint("required")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result[0], "structural_tag")
|
||||
self.assertTrue(result[1].at_least_one)
|
||||
|
||||
def test_kimi_auto_strict_returns_structural_tag(self):
|
||||
parser = self._make_parser("kimi_k2", strict=True)
|
||||
result = parser.get_structure_constraint("auto")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result[0], "structural_tag")
|
||||
self.assertFalse(result[1].at_least_one)
|
||||
|
||||
def test_kimi_auto_no_strict_returns_none(self):
|
||||
"""auto without strict should not constrain."""
|
||||
parser = self._make_parser("kimi_k2", strict=False)
|
||||
result = parser.get_structure_constraint("auto")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_kimi_named_tool_choice_returns_structural_tag(self):
|
||||
from sglang.srt.entrypoints.openai.protocol import (
|
||||
ToolChoice,
|
||||
ToolChoiceFuncName,
|
||||
)
|
||||
|
||||
parser = self._make_parser("kimi_k2", strict=False)
|
||||
tool_choice = ToolChoice(function=ToolChoiceFuncName(name="get_weather"))
|
||||
result = parser.get_structure_constraint(tool_choice)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result[0], "structural_tag")
|
||||
|
||||
def test_deepseekv3_required_no_strict_returns_structural_tag(self):
|
||||
parser = self._make_parser("deepseekv3", strict=False)
|
||||
result = parser.get_structure_constraint("required")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result[0], "structural_tag")
|
||||
|
||||
def test_qwen25_required_no_strict_returns_structural_tag(self):
|
||||
parser = self._make_parser("qwen25", strict=False)
|
||||
result = parser.get_structure_constraint("required")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result[0], "structural_tag")
|
||||
|
||||
# --- structural_tag content verification ---
|
||||
|
||||
def test_kimi_structural_tag_has_kimi_tokens(self):
|
||||
"""Verify structural_tag contains kimi-specific special tokens."""
|
||||
parser = self._make_parser("kimi_k2", strict=True)
|
||||
result = parser.get_structure_constraint("required")
|
||||
tag = result[1]
|
||||
structures = tag.structures
|
||||
self.assertTrue(len(structures) > 0)
|
||||
self.assertIn("<|tool_calls_section_begin|>", structures[0].begin)
|
||||
self.assertIn("<|tool_call_end|>", structures[0].end)
|
||||
|
||||
def test_kimi_required_no_strict_uses_empty_schema(self):
|
||||
"""Without strict, structural_tag should use empty schema per OpenAI
|
||||
protocol: strict=False means no parameter schema enforcement."""
|
||||
parser = self._make_parser("kimi_k2", strict=False)
|
||||
result = parser.get_structure_constraint("required")
|
||||
tag = result[1]
|
||||
self.assertEqual(tag.structures[0].schema_, {})
|
||||
|
||||
def test_kimi_required_strict_uses_tool_schema(self):
|
||||
"""With strict, structural_tag should include the tool's parameter schema."""
|
||||
parser = self._make_parser("kimi_k2", strict=True)
|
||||
result = parser.get_structure_constraint("required")
|
||||
tag = result[1]
|
||||
schema = tag.structures[0].schema_
|
||||
self.assertIn("properties", schema)
|
||||
self.assertIn("city", schema["properties"])
|
||||
|
||||
|
||||
class TestQwen25Detector(unittest.TestCase):
|
||||
"""Test Qwen25Detector streaming and non-streaming multi-tool-call parsing."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user