mirror of
https://github.com/vllm-project/vllm.git
synced 2026-08-23 14:10:14 +00:00
[ToolParser][Bugfix] Re-land: Fix anyOf/oneOf/$ref type resolution in Qwen3CoderToolParser (#37831) (#38973)
Signed-off-by: AAISSJ <[email protected]> Signed-off-by: <> Signed-off-by: sejung-son <[email protected]> Signed-off-by: sfeng33 <[email protected]> Co-authored-by: 세덩 <[email protected]> Co-authored-by: sejung-son <[email protected]> Co-authored-by: sfeng33 <[email protected]>
This commit is contained in:
co-authored by
세덩
sejung-son
sfeng33
parent
6441cf4a44
commit
d97ba29fdc
@@ -474,6 +474,190 @@ hello world
|
||||
assert args["obj_param"] == {"key": "value"}
|
||||
|
||||
|
||||
def test_extract_tool_calls_anyof_type_conversion(qwen3_tokenizer):
|
||||
"""Test type conversion for anyOf/oneOf nullable schemas (Pydantic v2).
|
||||
|
||||
Pydantic v2 emits anyOf for Optional[T] fields, e.g.:
|
||||
Optional[int] -> {"anyOf": [{"type": "integer"}, {"type": "null"}]}
|
||||
The parser must extract the non-null type and apply the correct
|
||||
conversion (int(), float(), etc.) instead of returning a raw string.
|
||||
"""
|
||||
tools = [
|
||||
ChatCompletionToolsParam(
|
||||
type="function",
|
||||
function={
|
||||
"name": "test_anyof",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"anyof_int": {
|
||||
"anyOf": [
|
||||
{"type": "integer"},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": 5,
|
||||
},
|
||||
"anyof_str": {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
"anyof_array": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
"anyof_obj": {
|
||||
"anyOf": [
|
||||
{"type": "object"},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
"type_as_array": {
|
||||
"type": ["integer", "null"],
|
||||
},
|
||||
"multi_non_null": {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "integer"},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
model_output = """<tool_call>
|
||||
<function=test_anyof>
|
||||
<parameter=anyof_int>
|
||||
5
|
||||
</parameter>
|
||||
<parameter=anyof_str>
|
||||
hello
|
||||
</parameter>
|
||||
<parameter=anyof_array>
|
||||
["a", "b", "c"]
|
||||
</parameter>
|
||||
<parameter=anyof_obj>
|
||||
{"key": "value"}
|
||||
</parameter>
|
||||
<parameter=type_as_array>
|
||||
42
|
||||
</parameter>
|
||||
<parameter=multi_non_null>
|
||||
some text
|
||||
</parameter>
|
||||
</function>
|
||||
</tool_call>"""
|
||||
|
||||
parser = Qwen3CoderToolParser(qwen3_tokenizer, tools=tools)
|
||||
request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
|
||||
extracted = parser.extract_tool_calls(model_output, request=request)
|
||||
|
||||
args = json.loads(extracted.tool_calls[0].function.arguments)
|
||||
assert args["anyof_int"] == 5
|
||||
assert isinstance(args["anyof_int"], int)
|
||||
assert args["anyof_str"] == "hello"
|
||||
assert isinstance(args["anyof_str"], str)
|
||||
assert args["anyof_array"] == ["a", "b", "c"]
|
||||
assert isinstance(args["anyof_array"], list)
|
||||
assert args["anyof_obj"] == {"key": "value"}
|
||||
assert isinstance(args["anyof_obj"], dict)
|
||||
assert args["type_as_array"] == 42
|
||||
assert isinstance(args["type_as_array"], int)
|
||||
# Multi non-null: anyOf[string, integer, null] → first non-null is string
|
||||
assert args["multi_non_null"] == "some text"
|
||||
assert isinstance(args["multi_non_null"], str)
|
||||
|
||||
|
||||
def test_extract_tool_calls_anyof_type_conversion_streaming(qwen3_tokenizer):
|
||||
"""Test streaming e2e for anyOf/oneOf nullable schemas (Pydantic v2).
|
||||
|
||||
Verifies that the full streaming pipeline — tokenize, incrementally
|
||||
decode, extract_tool_calls_streaming — correctly resolves types from
|
||||
anyOf schemas and produces valid JSON with properly typed values.
|
||||
"""
|
||||
tools = [
|
||||
ChatCompletionToolsParam(
|
||||
type="function",
|
||||
function={
|
||||
"name": "search_web",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
"count": {
|
||||
"anyOf": [
|
||||
{"type": "integer"},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": 5,
|
||||
},
|
||||
"verbose": {
|
||||
"anyOf": [
|
||||
{"type": "boolean"},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
model_output = """<tool_call>
|
||||
<function=search_web>
|
||||
<parameter=query>
|
||||
vllm tool parser
|
||||
</parameter>
|
||||
<parameter=count>
|
||||
10
|
||||
</parameter>
|
||||
<parameter=verbose>
|
||||
true
|
||||
</parameter>
|
||||
</function>
|
||||
</tool_call>"""
|
||||
|
||||
parser = Qwen3CoderToolParser(qwen3_tokenizer, tools=tools)
|
||||
request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
|
||||
|
||||
tool_states = {}
|
||||
for delta_message in stream_delta_message_generator(
|
||||
parser, qwen3_tokenizer, model_output, request
|
||||
):
|
||||
if delta_message.tool_calls:
|
||||
for tool_call in delta_message.tool_calls:
|
||||
idx = tool_call.index
|
||||
if idx not in tool_states:
|
||||
tool_states[idx] = {"name": None, "arguments": ""}
|
||||
if tool_call.function:
|
||||
if tool_call.function.name:
|
||||
tool_states[idx]["name"] = tool_call.function.name
|
||||
if tool_call.function.arguments is not None:
|
||||
tool_states[idx]["arguments"] += tool_call.function.arguments
|
||||
|
||||
assert len(tool_states) == 1
|
||||
assert tool_states[0]["name"] == "search_web"
|
||||
assert tool_states[0]["arguments"] is not None
|
||||
args = json.loads(tool_states[0]["arguments"])
|
||||
assert args["query"] == "vllm tool parser"
|
||||
assert isinstance(args["query"], str)
|
||||
assert args["count"] == 10
|
||||
assert isinstance(args["count"], int)
|
||||
assert args["verbose"] is True
|
||||
assert isinstance(args["verbose"], bool)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
ids=[
|
||||
"no_tools",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import ast
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
@@ -30,7 +29,11 @@ from vllm.tool_parsers.structural_tag_registry import (
|
||||
get_enable_structured_outputs_in_reasoning,
|
||||
get_model_structural_tag,
|
||||
)
|
||||
from vllm.tool_parsers.utils import find_tool_properties
|
||||
from vllm.tool_parsers.utils import (
|
||||
coerce_to_schema_type,
|
||||
extract_types_from_schema,
|
||||
find_tool_properties,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
@@ -121,113 +124,11 @@ class Qwen3CoderToolParser(ToolParser):
|
||||
self, param_value: str, param_name: str, param_config: dict, func_name: str
|
||||
) -> Any:
|
||||
"""Convert parameter value based on its type in the schema."""
|
||||
# Handle null value for any type
|
||||
if param_value.lower() == "null":
|
||||
return None
|
||||
|
||||
if param_name not in param_config:
|
||||
if param_config != {}:
|
||||
logger.debug(
|
||||
"Parsed parameter '%s' is not defined in the tool "
|
||||
"parameters for tool '%s', directly returning the "
|
||||
"string value.",
|
||||
param_name,
|
||||
func_name,
|
||||
)
|
||||
return param_value
|
||||
|
||||
if (
|
||||
isinstance(param_config[param_name], dict)
|
||||
and "type" in param_config[param_name]
|
||||
):
|
||||
param_type = str(param_config[param_name]["type"]).strip().lower()
|
||||
elif (
|
||||
isinstance(param_config[param_name], dict)
|
||||
and "anyOf" in param_config[param_name]
|
||||
):
|
||||
# anyOf has no top-level "type"; treat as object to trigger json.loads.
|
||||
param_type = "object"
|
||||
else:
|
||||
param_type = "string"
|
||||
if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
|
||||
return param_value
|
||||
elif (
|
||||
param_type.startswith("int")
|
||||
or param_type.startswith("uint")
|
||||
or param_type.startswith("long")
|
||||
or param_type.startswith("short")
|
||||
or param_type.startswith("unsigned")
|
||||
):
|
||||
try:
|
||||
return int(param_value)
|
||||
except (ValueError, TypeError):
|
||||
logger.debug(
|
||||
"Parsed value '%s' of parameter '%s' is not an "
|
||||
"integer in tool '%s', degenerating to string.",
|
||||
param_value,
|
||||
param_name,
|
||||
func_name,
|
||||
)
|
||||
return param_value
|
||||
elif param_type.startswith("num") or param_type.startswith("float"):
|
||||
try:
|
||||
float_param_value = float(param_value)
|
||||
return (
|
||||
float_param_value
|
||||
if float_param_value - int(float_param_value) != 0
|
||||
else int(float_param_value)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
logger.debug(
|
||||
"Parsed value '%s' of parameter '%s' is not a float "
|
||||
"in tool '%s', degenerating to string.",
|
||||
param_value,
|
||||
param_name,
|
||||
func_name,
|
||||
)
|
||||
return param_value
|
||||
elif param_type in ["boolean", "bool", "binary"]:
|
||||
param_value = param_value.lower()
|
||||
if param_value not in ["true", "false"]:
|
||||
logger.debug(
|
||||
"Parsed value '%s' of parameter '%s' is not a boolean "
|
||||
"(`true` or `false`) in tool '%s', degenerating to "
|
||||
"false.",
|
||||
param_value,
|
||||
param_name,
|
||||
func_name,
|
||||
)
|
||||
return param_value == "true"
|
||||
else:
|
||||
if (
|
||||
param_type in ["object", "array", "arr"]
|
||||
or param_type.startswith("dict")
|
||||
or param_type.startswith("list")
|
||||
):
|
||||
try:
|
||||
param_value = json.loads(param_value)
|
||||
return param_value
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
logger.debug(
|
||||
"Parsed value '%s' of parameter '%s' cannot be "
|
||||
"parsed with json.loads in tool '%s', will try "
|
||||
"other methods to parse it.",
|
||||
param_value,
|
||||
param_name,
|
||||
func_name,
|
||||
)
|
||||
try:
|
||||
param_value = ast.literal_eval(param_value) # safer
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
logger.debug(
|
||||
"Parsed value '%s' of parameter '%s' cannot be "
|
||||
"converted via Python `ast.literal_eval()` in tool "
|
||||
"'%s', degenerating to string.",
|
||||
param_value,
|
||||
param_name,
|
||||
func_name,
|
||||
)
|
||||
if not isinstance(param_value, str):
|
||||
return param_value
|
||||
param_schema = param_config.get(param_name, {})
|
||||
param_types = extract_types_from_schema(param_schema)
|
||||
return coerce_to_schema_type(param_value, param_types)
|
||||
|
||||
def _parse_xml_function_call(self, function_call_str: str) -> ToolCall | None:
|
||||
# Extract function name
|
||||
|
||||
Reference in New Issue
Block a user