[Bugfix] DeepSeekV32/v4: respect string='true|false' attribute andunwrap arguments/input wrapper (#41801)
Signed-off-by: chaunceyjiang <chaunceyjiang@gmail.com> Co-authored-by: QwertyJack <7554089+QwertyJack@users.noreply.github.com>
This commit is contained in:
@@ -203,7 +203,14 @@ class TestExtractToolCalls:
|
||||
),
|
||||
)
|
||||
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 @@ class TestExtractToolCalls:
|
||||
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 @@ class TestExtractToolCallsStreaming:
|
||||
),
|
||||
)
|
||||
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"
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -69,7 +69,7 @@ class DeepSeekV32ToolParser(ToolParser):
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -101,10 +101,12 @@ class DeepSeekV32ToolParser(ToolParser):
|
||||
"""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 @@ class DeepSeekV32ToolParser(ToolParser):
|
||||
# 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 @@ class DeepSeekV32ToolParser(ToolParser):
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user