[Tool Call][DSV32] Streamline function call parameters (#14750)
Signed-off-by: Muqi Li <muqi1029@gmail.com>
This commit is contained in:
@@ -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,
|
|
||||||
# but for XML format it constructs JSON at the end.
|
|
||||||
# Let's check if the final parameters parse correctly.
|
|
||||||
try:
|
|
||||||
params = json.loads(tool_calls_by_index[0]["parameters"])
|
params = json.loads(tool_calls_by_index[0]["parameters"])
|
||||||
self.assertEqual(params["city"], "San Francisco")
|
self.assertEqual(params["city"], "San Francisco")
|
||||||
except json.JSONDecodeError:
|
self.assertEqual(params["another_city"], "London")
|
||||||
# In streaming XML, parameters might be constructed differently or incrementally
|
self.assertEqual(params["topn"], 10)
|
||||||
pass
|
self.assertEqual(params["obj"]["name"], "John")
|
||||||
|
self.assertEqual(params["obj"]["age"], 30)
|
||||||
|
|
||||||
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 = {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user