diff --git a/tests/tool_parsers/test_qwen3coder_tool_parser.py b/tests/tool_parsers/test_qwen3coder_tool_parser.py index 8f92d5b3745b..7db1b6857152 100644 --- a/tests/tool_parsers/test_qwen3coder_tool_parser.py +++ b/tests/tool_parsers/test_qwen3coder_tool_parser.py @@ -429,208 +429,6 @@ def test_extract_tool_calls_type_conversion(qwen3_tokenizer): assert args["obj_param"] == {"key": "value"} -def test_extract_tool_calls_anyof_type_conversion(qwen3_tool_parser): - """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"}, - ], - }, - "ref_param": { - "$ref": "#/$defs/ToolInput", - }, - }, - }, - }, - ) - ] - - model_output = """ - - -5 - - -hello - - -["a", "b", "c"] - - -{"key": "value"} - - -42 - - -some text - - -{"city": "Paris"} - - -""" - - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) - extracted = qwen3_tool_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) - # $ref: treated as object, parsed via json.loads - assert args["ref_param"] == {"city": "Paris"} - assert isinstance(args["ref_param"], dict) - - -def test_extract_tool_calls_anyof_type_conversion_streaming( - qwen3_tool_parser, 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"}, - ], - }, - "filters": { - "$ref": "#/$defs/SearchFilters", - }, - }, - }, - }, - ) - ] - - model_output = """ - - -vllm tool parser - - -10 - - -true - - -{"lang": "en", "year": 2025} - - -""" - - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) - - tool_states = {} - for delta_message in stream_delta_message_generator( - qwen3_tool_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) - # $ref: treated as object, parsed via json.loads - assert args["filters"] == {"lang": "en", "year": 2025} - assert isinstance(args["filters"], dict) - - @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 8a5b4f5a2726..ea25ea2be923 100644 --- a/vllm/tool_parsers/qwen3coder_tool_parser.py +++ b/vllm/tool_parsers/qwen3coder_tool_parser.py @@ -131,58 +131,11 @@ def _get_arguments_config(self, func_name: str, tools: list[Tool] | None) -> dic logger.debug("Tool '%s' is not defined in the tools list.", func_name) return {} - @staticmethod - def _first_non_null_type(type_value: Any) -> str | None: - """Extract the first non-null type from a type value. - - Handles both scalar types ("integer") and type-as-array - (["integer", "null"]) per JSON Schema spec. - """ - if isinstance(type_value, list): - return next( - ( - str(t).strip().lower() - for t in type_value - if t is not None and str(t).lower() != "null" - ), - None, - ) - if type_value is not None and str(type_value).lower() != "null": - return str(type_value).strip().lower() - return None - - def _resolve_param_type(self, param_def: dict) -> str: - """Resolve the effective type string from a parameter definition. - - Handles direct "type" fields (including type-as-array), - anyOf/oneOf schemas emitted by Pydantic v2 for Optional[T], - and $ref schemas from Pydantic model inputs. - """ - if "type" in param_def: - resolved = self._first_non_null_type(param_def["type"]) - return resolved or "string" - - if "anyOf" in param_def or "oneOf" in param_def: - variants = param_def.get("anyOf") or param_def.get("oneOf", []) - for v in variants: - if not isinstance(v, dict): - continue - resolved = self._first_non_null_type(v.get("type")) - if resolved: - return resolved - - # $ref points to a schema definition (e.g. a Pydantic model). - # The referenced type is almost always an object, so treat it - # as such to route through json.loads. - if "$ref" in param_def: - return "object" - - return "string" - def _convert_param_value( 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 @@ -197,10 +150,19 @@ def _convert_param_value( ) return param_value - if not isinstance(param_config[param_name], dict): - return param_value - - param_type = self._resolve_param_type(param_config[param_name]) + 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 (