[Tool Call][DSV32] Streamline function call parameters (#14750)

Signed-off-by: Muqi Li <muqi1029@gmail.com>
This commit is contained in:
Muqi Li
2025-12-26 11:35:30 -08:00
committed by GitHub
parent 43e1bbc0d5
commit 01bd0d3e8b
2 changed files with 60 additions and 29 deletions
@@ -2,6 +2,8 @@ import json
import logging import logging
import re import re
from partial_json_parser.core.options import Allow
from sglang.srt.entrypoints.openai.protocol import Tool from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import ( from sglang.srt.function_call.core_types import (
@@ -10,7 +12,7 @@ from sglang.srt.function_call.core_types import (
ToolCallItem, ToolCallItem,
_GetInfoFunc, _GetInfoFunc,
) )
from sglang.srt.function_call.utils import _find_common_prefix from sglang.srt.function_call.utils import _find_common_prefix, _partial_json_loads
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -82,11 +84,12 @@ class DeepSeekV32Detector(BaseFormatDetector):
self.invoke_regex = ( self.invoke_regex = (
r'<|DSML|invoke\s+name="([^"]+)"\s*>(.*?)(</|DSML|invoke>|$)' r'<|DSML|invoke\s+name="([^"]+)"\s*>(.*?)(</|DSML|invoke>|$)'
) )
self.prefix_parameter_end_call = ["</", "|DSML|", "parameter"]
self.current_tool_id = -1 self.current_tool_id = -1
def has_tool_call(self, text: str) -> bool: def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a deepseek v32 format tool call.""" """Check if the text contains a deepseek v32 format tool call."""
return self.bot_token in text return self.bot_token in text or "<|DSML|invoke" in text
def _parse_parameters_from_xml( def _parse_parameters_from_xml(
self, invoke_content: str, allow_partial: bool = False self, invoke_content: str, allow_partial: bool = False
@@ -139,16 +142,25 @@ class DeepSeekV32Detector(BaseFormatDetector):
# If allowed, try to parse a partial parameter at the end # If allowed, try to parse a partial parameter at the end
if allow_partial: if allow_partial:
remaining_content = invoke_content[last_match_end:] remaining_content = invoke_content[last_match_end:]
# Remove incomplete parameter_end_call prefix in case they are captured by param
for token in reversed(self.prefix_parameter_end_call):
remaining_content = remaining_content.rstrip(token)
# Match start of a parameter tag + value (potentially incomplete) # Match start of a parameter tag + value (potentially incomplete)
# Regex: <tag name="..." string="...">VALUE... (no end tag) # Regex: <tag name="..." string="...">VALUE... (no end tag)
partial_match = re.search( partial_match = re.search(
self.partial_parameter_regex, remaining_content, re.DOTALL self.partial_parameter_regex, remaining_content, re.DOTALL
) )
if partial_match: if partial_match and (param_value := partial_match.group(3)):
param_name = partial_match.group(1) param_name = partial_match.group(1)
param_value = partial_match.group(3) if partial_match.group(2) == "true":
parameters[param_name] = param_value parameters[param_name] = param_value.strip()
else:
parameters[param_name] = _partial_json_loads(
param_value, Allow.ALL
)[0]
return parameters return parameters
@@ -206,13 +218,6 @@ class DeepSeekV32Detector(BaseFormatDetector):
self._buffer += new_text self._buffer += new_text
current_text = self._buffer current_text = self._buffer
# Check if we have a tool call or any DSML-related content
# Key insight: DSML tags contain distinctive markers like "|DSML|"
# If we see these markers anywhere, we should keep buffering
has_tool_call = (
self.bot_token in current_text or "<|DSML|invoke" in current_text
)
# Check if buffer contains any DSML markers or ends with potential tag prefix # Check if buffer contains any DSML markers or ends with potential tag prefix
# This handles partial/streaming DSML content # This handles partial/streaming DSML content
dsml_markers = ["|DSML|", "<|", "</|"] dsml_markers = ["|DSML|", "<|", "</|"]
@@ -224,7 +229,11 @@ class DeepSeekV32Detector(BaseFormatDetector):
current_text.rstrip().endswith(prefix) for prefix in dsml_prefixes current_text.rstrip().endswith(prefix) for prefix in dsml_prefixes
) )
if not has_tool_call and not potentially_dsml and not ends_with_prefix: if (
not self.has_tool_call(current_text)
and not potentially_dsml
and not ends_with_prefix
):
self._buffer = "" self._buffer = ""
for e_token in [self.eot_token, self.invoke_end_token]: for e_token in [self.eot_token, self.invoke_end_token]:
if e_token in current_text: if e_token in current_text:
@@ -241,7 +250,6 @@ class DeepSeekV32Detector(BaseFormatDetector):
string=current_text, string=current_text,
flags=re.DOTALL, flags=re.DOTALL,
) )
if not invoke_match: if not invoke_match:
break break
@@ -342,5 +350,5 @@ class DeepSeekV32Detector(BaseFormatDetector):
return lambda name: StructureInfo( return lambda name: StructureInfo(
begin=f'<|DSML|invoke name="{name}">', begin=f'<|DSML|invoke name="{name}">',
end="</|DSML|invoke>", end="</|DSML|invoke>",
trigger=f'<|DSML|invoke name="{name}">', trigger=f"<|DSML|invoke",
) )
@@ -1159,6 +1159,10 @@ class TestDeepSeekV32Detector(unittest.TestCase):
), ),
] ]
self.detector = DeepSeekV32Detector() self.detector = DeepSeekV32Detector()
from transformers import AutoTokenizer
self.tokenizer = AutoTokenizer.from_pretrained("deepseek-ai/DeepSeek-V3.2")
self.interval = 1
def test_detect_and_parse_xml_format(self): def test_detect_and_parse_xml_format(self):
"""Test parsing standard XML format (DSML)""" """Test parsing standard XML format (DSML)"""
@@ -1236,12 +1240,19 @@ class TestDeepSeekV32Detector(unittest.TestCase):
text = """<|DSML|function_calls> text = """<|DSML|function_calls>
<|DSML|invoke name="get_favorite_tourist_spot"> <|DSML|invoke name="get_favorite_tourist_spot">
<|DSML|parameter name="city" string="true">San Francisco</|DSML|parameter> <|DSML|parameter name="city" string="true">San Francisco</|DSML|parameter>
<|DSML|parameter name="another_city" string="true">London</|DSML|parameter>
<|DSML|parameter name="topn" string="false">10</|DSML|parameter>
<|DSML|parameter name="obj" string="false">{"name": "John", "age": 30}</|DSML|parameter>
</|DSML|invoke> </|DSML|invoke>
</|DSML|function_calls>""" </|DSML|function_calls>"""
chunks = [text[i : i + 5] for i in range(0, len(text), 5)] input_ids = self.tokenizer.encode(text, add_special_tokens=False)
chunk_ids = [
input_ids[i : i + self.interval]
for i in range(0, len(input_ids), self.interval)
]
chunks = [self.tokenizer.decode(chunk_id) for chunk_id in chunk_ids]
accumulated_calls = []
tool_calls_by_index = {} tool_calls_by_index = {}
for chunk in chunks: for chunk in chunks:
@@ -1263,15 +1274,12 @@ class TestDeepSeekV32Detector(unittest.TestCase):
self.assertEqual(len(tool_calls_by_index), 1) self.assertEqual(len(tool_calls_by_index), 1)
self.assertEqual(tool_calls_by_index[0]["name"], "get_favorite_tourist_spot") self.assertEqual(tool_calls_by_index[0]["name"], "get_favorite_tourist_spot")
# Note: The detector might accumulate partial JSON string which is valid, params = json.loads(tool_calls_by_index[0]["parameters"])
# but for XML format it constructs JSON at the end. self.assertEqual(params["city"], "San Francisco")
# Let's check if the final parameters parse correctly. self.assertEqual(params["another_city"], "London")
try: self.assertEqual(params["topn"], 10)
params = json.loads(tool_calls_by_index[0]["parameters"]) self.assertEqual(params["obj"]["name"], "John")
self.assertEqual(params["city"], "San Francisco") self.assertEqual(params["obj"]["age"], 30)
except json.JSONDecodeError:
# In streaming XML, parameters might be constructed differently or incrementally
pass
def test_streaming_json_format(self): def test_streaming_json_format(self):
"""Test streaming parsing of JSON format""" """Test streaming parsing of JSON format"""
@@ -1283,7 +1291,12 @@ class TestDeepSeekV32Detector(unittest.TestCase):
</|DSML|invoke> </|DSML|invoke>
</|DSML|function_calls>""" </|DSML|function_calls>"""
chunks = [text[i : i + 5] for i in range(0, len(text), 5)] input_ids = self.tokenizer.encode(text, add_special_tokens=False)
chunk_ids = [
input_ids[i : i + self.interval]
for i in range(0, len(input_ids), self.interval)
]
chunks = [self.tokenizer.decode(chunk_id) for chunk_id in chunk_ids]
tool_calls_by_index = {} tool_calls_by_index = {}
@@ -1370,7 +1383,12 @@ class TestDeepSeekV32Detector(unittest.TestCase):
self.detector = DeepSeekV32Detector() self.detector = DeepSeekV32Detector()
# Simulate streaming by splitting into small chunks # Simulate streaming by splitting into small chunks
chunks = [text[i : i + 5] for i in range(0, len(text), 5)] input_ids = self.tokenizer.encode(text, add_special_tokens=False)
chunk_ids = [
input_ids[i : i + self.interval]
for i in range(0, len(input_ids), self.interval)
]
chunks = [self.tokenizer.decode(chunk_id) for chunk_id in chunk_ids]
tool_calls_by_index = {} tool_calls_by_index = {}
@@ -1425,7 +1443,12 @@ class TestDeepSeekV32Detector(unittest.TestCase):
# Reset detector state # Reset detector state
self.detector = DeepSeekV32Detector() self.detector = DeepSeekV32Detector()
chunks = [text[i : i + 5] for i in range(0, len(text), 5)] input_ids = self.tokenizer.encode(text, add_special_tokens=False)
chunk_ids = [
input_ids[i : i + self.interval]
for i in range(0, len(input_ids), self.interval)
]
chunks = [self.tokenizer.decode(chunk_id) for chunk_id in chunk_ids]
tool_calls_by_index = {} tool_calls_by_index = {}