diff --git a/tests/tool_parsers/test_qwen3coder_tool_parser.py b/tests/tool_parsers/test_qwen3coder_tool_parser.py
index 26bbf1a044b..defc6d23eff 100644
--- a/tests/tool_parsers/test_qwen3coder_tool_parser.py
+++ b/tests/tool_parsers/test_qwen3coder_tool_parser.py
@@ -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 = """
+
+
+5
+
+
+hello
+
+
+["a", "b", "c"]
+
+
+{"key": "value"}
+
+
+42
+
+
+some text
+
+
+"""
+
+ 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 = """
+
+
+vllm tool parser
+
+
+10
+
+
+true
+
+
+"""
+
+ 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",
diff --git a/vllm/tool_parsers/qwen3coder_tool_parser.py b/vllm/tool_parsers/qwen3coder_tool_parser.py
index 46aa8a4acc3..7457590c5ac 100644
--- a/vllm/tool_parsers/qwen3coder_tool_parser.py
+++ b/vllm/tool_parsers/qwen3coder_tool_parser.py
@@ -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