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