[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:
Chauncey
2026-05-06 21:48:01 +00:00
committed by GitHub
co-authored by QwertyJack
parent 50acdc5b5c
commit 95582868ef
3 changed files with 224 additions and 10 deletions
@@ -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"}
+36 -8
View File
@@ -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,