Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 155 additions & 2 deletions tests/tool_parsers/test_deepseekv32_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,14 @@ def test_type_conversion_in_non_streaming(self):
),
)
parser = make_parser(tools=[tool])
model_output = build_tool_call("toggle", {"enabled": "true", "count": "42"})
model_output = (
f"{FC_START}\n"
f'{INV_START}toggle">\n'
f'{PARAM_START}enabled" string="false">true{PARAM_END}\n'
f'{PARAM_START}count" string="false">42{PARAM_END}\n'
f"{INV_END}\n"
f"{FC_END}"
)
result = parser.extract_tool_calls(model_output, None)
assert result.tools_called
assert len(result.tool_calls) == 1
Expand All @@ -212,6 +219,118 @@ def test_type_conversion_in_non_streaming(self):
assert isinstance(args["enabled"], bool)
assert isinstance(args["count"], int)

def test_string_attr_true_preserves_literal_despite_schema(self):
"""string="true" must keep the value as a string even
if the schema says integer."""
tool = ChatCompletionToolsParam(
function=FunctionDefinition(
name="score",
parameters={
"type": "object",
"properties": {
"value": {"type": "integer"},
},
},
),
)
parser = make_parser(tools=[tool])
model_output = (
f"{FC_START}\n"
f'{INV_START}score">\n'
f'{PARAM_START}value" string="true">42{PARAM_END}\n'
f"{INV_END}\n"
f"{FC_END}"
)
result = parser.extract_tool_calls(model_output, None)
assert result.tools_called
args = json.loads(result.tool_calls[0].function.arguments)
assert args == {"value": "42"}
assert isinstance(args["value"], str)

def test_string_attr_false_allows_schema_conversion(self):
"""string="false" allows the parser to convert via the tool schema."""
tool = ChatCompletionToolsParam(
function=FunctionDefinition(
name="score",
parameters={
"type": "object",
"properties": {
"value": {"type": "integer"},
},
},
),
)
parser = make_parser(tools=[tool])
model_output = (
f"{FC_START}\n"
f'{INV_START}score">\n'
f'{PARAM_START}value" string="false">42{PARAM_END}\n'
f"{INV_END}\n"
f"{FC_END}"
)
result = parser.extract_tool_calls(model_output, None)
assert result.tools_called
args = json.loads(result.tool_calls[0].function.arguments)
assert args == {"value": 42}
assert isinstance(args["value"], int)

def test_arguments_wrapper_repaired(self):
"""A single 'arguments' wrapper parameter must be unwrapped when it
is not part of the tool schema and the inner object matches schema fields."""
tool = ChatCompletionToolsParam(
function=FunctionDefinition(
name="get_weather",
parameters={
"type": "object",
"properties": {
"location": {"type": "string"},
},
},
),
)
parser = make_parser(tools=[tool])
model_output = (
f"{FC_START}\n"
f'{INV_START}get_weather">\n'
f'{PARAM_START}arguments" string="false">'
f'{{"location":"Beijing"}}'
f"{PARAM_END}\n"
f"{INV_END}\n"
f"{FC_END}"
)
result = parser.extract_tool_calls(model_output, None)
assert result.tools_called
args = json.loads(result.tool_calls[0].function.arguments)
assert args == {"location": "Beijing"}

def test_input_wrapper_repaired(self):
"""A single 'input' wrapper parameter must be unwrapped similarly."""
tool = ChatCompletionToolsParam(
function=FunctionDefinition(
name="get_weather",
parameters={
"type": "object",
"properties": {
"location": {"type": "string"},
},
},
),
)
parser = make_parser(tools=[tool])
model_output = (
f"{FC_START}\n"
f'{INV_START}get_weather">\n'
f'{PARAM_START}input" string="true">'
f'{{"location":"Beijing"}}'
f"{PARAM_END}\n"
f"{INV_END}\n"
f"{FC_END}"
)
result = parser.extract_tool_calls(model_output, None)
assert result.tools_called
args = json.loads(result.tool_calls[0].function.arguments)
assert args == {"location": "Beijing"}


# ---------------------------------------------------------------------------
# Tests: extract_tool_calls_streaming
Expand Down Expand Up @@ -319,11 +438,45 @@ def test_type_conversion_in_streaming(self):
),
)
parser = make_parser(tools=[tool])
full_text = build_tool_call("add", {"x": "3", "y": "4"})
full_text = (
f"{FC_START}\n"
f'{INV_START}add">\n'
f'{PARAM_START}x" string="false">3{PARAM_END}\n'
f'{PARAM_START}y" string="false">4{PARAM_END}\n'
f"{INV_END}\n"
f"{FC_END}"
)
deltas = self._stream(parser, full_text)
args_str = self._reconstruct_args(deltas)
assert json.loads(args_str) == {"x": 3, "y": 4}

def test_string_attr_true_preserves_literal_in_streaming(self):
"""Streaming: string='true' must keep the value literal despite schema."""
tool = ChatCompletionToolsParam(
function=FunctionDefinition(
name="score",
parameters={
"type": "object",
"properties": {
"value": {"type": "integer"},
},
},
),
)
parser = make_parser(tools=[tool])
full_text = (
f"{FC_START}\n"
f'{INV_START}score">\n'
f'{PARAM_START}value" string="true">42{PARAM_END}\n'
f"{INV_END}\n"
f"{FC_END}"
)
deltas = self._stream(parser, full_text)
args_str = self._reconstruct_args(deltas)
args = json.loads(args_str)
assert args == {"value": "42"}
assert isinstance(args["value"], str)

def test_multiple_tools_streaming(self, parser):
full_text = (
f"{FC_START}\n"
Expand Down
33 changes: 33 additions & 0 deletions tests/tool_parsers/test_deepseekv4_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,36 @@ def test_get_vllm_registry_structural_tag_returns_structural_tag(
)
tag = parser.get_structural_tag(req)
assert isinstance(tag, StructuralTag)


def test_extract_tool_calls_arguments_wrapper():
mock_tokenizer = MagicMock()
mock_tokenizer.get_vocab.return_value = {}

tool = ChatCompletionToolsParam(
type="function",
function={
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
},
},
)

parser = DeepSeekV4ToolParser(mock_tokenizer, tools=[tool])
request = MagicMock()
request.tools = [tool]

model_output = (
f"{TC_START}"
f'{INV_START}get_weather">'
f'{PARAM_START}arguments" string="false">{{"location":"Beijing"}}{PARAM_END}'
f"{INV_END}"
f"{TC_END}"
)

result = parser.extract_tool_calls(model_output, request)
assert result.tools_called
args = json.loads(result.tool_calls[0].function.arguments)
assert args == {"location": "Beijing"}
44 changes: 36 additions & 8 deletions vllm/tool_parsers/deepseekv32_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
r'<|DSML|invoke\s+name="([^"]+)"\s*>(.*?)</|DSML|invoke>', re.DOTALL
)
self.parameter_complete_regex = re.compile(
r'<|DSML|parameter\s+name="([^"]+)"\s+string="(?:true|false)"\s*>(.*?)</|DSML|parameter>',
r'<|DSML|parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>(.*?)</|DSML|parameter>',
re.DOTALL,
)

Expand Down Expand Up @@ -101,10 +101,12 @@ def _generate_tool_call_id(self) -> str:
"""Generate a unique tool call ID."""
return f"call_{uuid.uuid4().hex[:24]}"

def _parse_invoke_params(self, invoke_str: str) -> dict:
param_dict = dict()
for param_name, param_val in self.parameter_complete_regex.findall(invoke_str):
param_dict[param_name] = param_val
def _parse_invoke_params(self, invoke_str: str) -> dict[str, tuple[str, str]]:
param_dict: dict[str, tuple[str, str]] = {}
for param_name, string_attr, param_val in self.parameter_complete_regex.findall(
invoke_str
):
param_dict[param_name] = (param_val, string_attr)
return param_dict

def _convert_param_value_checked(self, value: str, param_type: str) -> Any:
Expand Down Expand Up @@ -142,10 +144,32 @@ def _convert_param_value(self, value: str, param_type: str | list[str]) -> Any:
# return value as fallback
return value

@staticmethod
def _repair_param_dict(
param_dict: dict[str, Any],
param_config: dict[str, dict],
) -> dict[str, Any]:
"""Unwrap single 'arguments' / 'input' wrappers when the wrapper
is not part of the requested tool schema and the wrapped object
matches the schema fields."""
allowed = set(param_config.keys())
for wrapper in ("arguments", "input"):
if set(param_dict.keys()) != {wrapper} or wrapper in allowed:
continue
inner = param_dict[wrapper]
if isinstance(inner, str):
try:
inner = json.loads(inner)
except json.JSONDecodeError:
return param_dict
if isinstance(inner, dict) and set(inner.keys()).issubset(allowed):
return inner
return param_dict

def _convert_params_with_schema(
self,
function_name: str,
param_dict: dict[str, str],
param_dict: dict[str, tuple[str, str]],
) -> dict[str, Any]:
"""Convert raw string param values using the tool schema types."""
param_config: dict = {}
Expand All @@ -162,12 +186,16 @@ def _convert_params_with_schema(
break

converted: dict[str, Any] = {}
for name, value in param_dict.items():
for name, (value, string_attr) in param_dict.items():
if string_attr == "true":
converted[name] = value
continue

param_type = "string"
if name in param_config and isinstance(param_config[name], dict):
param_type = param_config[name].get("type", "string")
converted[name] = self._convert_param_value(value, param_type)
return converted
return self._repair_param_dict(converted, param_config)

def extract_tool_calls(
self,
Expand Down
Loading