diff --git a/tests/tool_parsers/test_deepseekv32_tool_parser.py b/tests/tool_parsers/test_deepseekv32_tool_parser.py index c547795e7bf2..693cf5caddd5 100644 --- a/tests/tool_parsers/test_deepseekv32_tool_parser.py +++ b/tests/tool_parsers/test_deepseekv32_tool_parser.py @@ -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 @@ -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 @@ -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" diff --git a/tests/tool_parsers/test_deepseekv4_tool_parser.py b/tests/tool_parsers/test_deepseekv4_tool_parser.py index cc77a1f77756..afcd0573958b 100644 --- a/tests/tool_parsers/test_deepseekv4_tool_parser.py +++ b/tests/tool_parsers/test_deepseekv4_tool_parser.py @@ -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"} diff --git a/vllm/tool_parsers/deepseekv32_tool_parser.py b/vllm/tool_parsers/deepseekv32_tool_parser.py index 02182e22935a..f01f7f929426 100644 --- a/vllm/tool_parsers/deepseekv32_tool_parser.py +++ b/vllm/tool_parsers/deepseekv32_tool_parser.py @@ -69,7 +69,7 @@ def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): r'<|DSML|invoke\s+name="([^"]+)"\s*>(.*?)', re.DOTALL ) self.parameter_complete_regex = re.compile( - r'<|DSML|parameter\s+name="([^"]+)"\s+string="(?:true|false)"\s*>(.*?)', + r'<|DSML|parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>(.*?)', re.DOTALL, ) @@ -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: @@ -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 = {} @@ -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,