@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / d97ba29fdc
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 3, 04:15 UTC

[ToolParser][Bugfix] Re-land: Fix anyOf/oneOf/$ref type resolution in Qwen3CoderToolParser (#37831) (#38973)

Signed-off-by: AAISSJ <maze0717@g.skku.edu> Signed-off-by: <> Signed-off-by: sejung-son <sejung.son@nhn.com> Signed-off-by: sfeng33 <4florafeng@gmail.com> Co-authored-by: 세덩 <saison@sedeong-ui-MacBookAir.local> Co-authored-by: sejung-son <sejung.son@nhn.com> Co-authored-by: sfeng33 <4florafeng@gmail.com>

손세정 committed May 21, 2026 at 13:24 UTC d97ba29fdcf2538359fac5c644c0f07e59bc1988
2 files changed +193 -108
tests/tool_parsers/test_qwen3coder_tool_parser.py
+184
@@ -474,6 +474,190 @@ hello world
474 assert args["obj_param"] == {"key": "value"}
475
476
477 +def test_extract_tool_calls_anyof_type_conversion(qwen3_tokenizer):
478 + """Test type conversion for anyOf/oneOf nullable schemas (Pydantic v2).
479 +
480 + Pydantic v2 emits anyOf for Optional[T] fields, e.g.:
481 + Optional[int] -> {"anyOf": [{"type": "integer"}, {"type": "null"}]}
482 + The parser must extract the non-null type and apply the correct
483 + conversion (int(), float(), etc.) instead of returning a raw string.
484 + """
485 + tools = [
486 + ChatCompletionToolsParam(
487 + type="function",
488 + function={
489 + "name": "test_anyof",
490 + "parameters": {
491 + "type": "object",
492 + "properties": {
493 + "anyof_int": {
494 + "anyOf": [
495 + {"type": "integer"},
496 + {"type": "null"},
497 + ],
498 + "default": 5,
499 + },
500 + "anyof_str": {
501 + "anyOf": [
502 + {"type": "string"},
503 + {"type": "null"},
504 + ],
505 + },
506 + "anyof_array": {
507 + "anyOf": [
508 + {"type": "array", "items": {"type": "string"}},
509 + {"type": "null"},
510 + ],
511 + },
512 + "anyof_obj": {
513 + "anyOf": [
514 + {"type": "object"},
515 + {"type": "null"},
516 + ],
517 + },
518 + "type_as_array": {
519 + "type": ["integer", "null"],
520 + },
521 + "multi_non_null": {
522 + "anyOf": [
523 + {"type": "string"},
524 + {"type": "integer"},
525 + {"type": "null"},
526 + ],
527 + },
528 + },
529 + },
530 + },
531 + )
532 + ]
533 +
534 + model_output = """<tool_call>
535 +<function=test_anyof>
536 +<parameter=anyof_int>
537 +5
538 +</parameter>
539 +<parameter=anyof_str>
540 +hello
541 +</parameter>
542 +<parameter=anyof_array>
543 +["a", "b", "c"]
544 +</parameter>
545 +<parameter=anyof_obj>
546 +{"key": "value"}
547 +</parameter>
548 +<parameter=type_as_array>
549 +42
550 +</parameter>
551 +<parameter=multi_non_null>
552 +some text
553 +</parameter>
554 +</function>
555 +</tool_call>"""
556 +
557 + parser = Qwen3CoderToolParser(qwen3_tokenizer, tools=tools)
558 + request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
559 + extracted = parser.extract_tool_calls(model_output, request=request)
560 +
561 + args = json.loads(extracted.tool_calls[0].function.arguments)
562 + assert args["anyof_int"] == 5
563 + assert isinstance(args["anyof_int"], int)
564 + assert args["anyof_str"] == "hello"
565 + assert isinstance(args["anyof_str"], str)
566 + assert args["anyof_array"] == ["a", "b", "c"]
567 + assert isinstance(args["anyof_array"], list)
568 + assert args["anyof_obj"] == {"key": "value"}
569 + assert isinstance(args["anyof_obj"], dict)
570 + assert args["type_as_array"] == 42
571 + assert isinstance(args["type_as_array"], int)
572 + # Multi non-null: anyOf[string, integer, null] → first non-null is string
573 + assert args["multi_non_null"] == "some text"
574 + assert isinstance(args["multi_non_null"], str)
575 +
576 +
577 +def test_extract_tool_calls_anyof_type_conversion_streaming(qwen3_tokenizer):
578 + """Test streaming e2e for anyOf/oneOf nullable schemas (Pydantic v2).
579 +
580 + Verifies that the full streaming pipeline — tokenize, incrementally
581 + decode, extract_tool_calls_streaming — correctly resolves types from
582 + anyOf schemas and produces valid JSON with properly typed values.
583 + """
584 + tools = [
585 + ChatCompletionToolsParam(
586 + type="function",
587 + function={
588 + "name": "search_web",
589 + "parameters": {
590 + "type": "object",
591 + "properties": {
592 + "query": {
593 + "anyOf": [
594 + {"type": "string"},
595 + {"type": "null"},
596 + ],
597 + },
598 + "count": {
599 + "anyOf": [
600 + {"type": "integer"},
601 + {"type": "null"},
602 + ],
603 + "default": 5,
604 + },
605 + "verbose": {
606 + "anyOf": [
607 + {"type": "boolean"},
608 + {"type": "null"},
609 + ],
610 + },
611 + },
612 + },
613 + },
614 + )
615 + ]
616 +
617 + model_output = """<tool_call>
618 +<function=search_web>
619 +<parameter=query>
620 +vllm tool parser
621 +</parameter>
622 +<parameter=count>
623 +10
624 +</parameter>
625 +<parameter=verbose>
626 +true
627 +</parameter>
628 +</function>
629 +</tool_call>"""
630 +
631 + parser = Qwen3CoderToolParser(qwen3_tokenizer, tools=tools)
632 + request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
633 +
634 + tool_states = {}
635 + for delta_message in stream_delta_message_generator(
636 + parser, qwen3_tokenizer, model_output, request
637 + ):
638 + if delta_message.tool_calls:
639 + for tool_call in delta_message.tool_calls:
640 + idx = tool_call.index
641 + if idx not in tool_states:
642 + tool_states[idx] = {"name": None, "arguments": ""}
643 + if tool_call.function:
644 + if tool_call.function.name:
645 + tool_states[idx]["name"] = tool_call.function.name
646 + if tool_call.function.arguments is not None:
647 + tool_states[idx]["arguments"] += tool_call.function.arguments
648 +
649 + assert len(tool_states) == 1
650 + assert tool_states[0]["name"] == "search_web"
651 + assert tool_states[0]["arguments"] is not None
652 + args = json.loads(tool_states[0]["arguments"])
653 + assert args["query"] == "vllm tool parser"
654 + assert isinstance(args["query"], str)
655 + assert args["count"] == 10
656 + assert isinstance(args["count"], int)
657 + assert args["verbose"] is True
658 + assert isinstance(args["verbose"], bool)
659 +
660 +
661 @pytest.mark.parametrize(
662 ids=[
663 "no_tools",
vllm/tool_parsers/qwen3coder_tool_parser.py
+9 -108
@@ -1,6 +1,5 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -import ast
3 import json
4 import uuid
5 from collections.abc import Sequence
@@ -30,7 +29,11 @@ from vllm.tool_parsers.structural_tag_registry import (
29 get_enable_structured_outputs_in_reasoning,
30 get_model_structural_tag,
31 )
33 -from vllm.tool_parsers.utils import find_tool_properties
32 +from vllm.tool_parsers.utils import (
33 + coerce_to_schema_type,
34 + extract_types_from_schema,
35 + find_tool_properties,
36 +)
37
38 logger = init_logger(__name__)
39
@@ -121,113 +124,11 @@ class Qwen3CoderToolParser(ToolParser):
124 self, param_value: str, param_name: str, param_config: dict, func_name: str
125 ) -> Any:
126 """Convert parameter value based on its type in the schema."""
124 - # Handle null value for any type
125 - if param_value.lower() == "null":
126 - return None
127 -
128 - if param_name not in param_config:
129 - if param_config != {}:
130 - logger.debug(
131 - "Parsed parameter '%s' is not defined in the tool "
132 - "parameters for tool '%s', directly returning the "
133 - "string value.",
134 - param_name,
135 - func_name,
136 - )
137 - return param_value
138 -
139 - if (
140 - isinstance(param_config[param_name], dict)
141 - and "type" in param_config[param_name]
142 - ):
143 - param_type = str(param_config[param_name]["type"]).strip().lower()
144 - elif (
145 - isinstance(param_config[param_name], dict)
146 - and "anyOf" in param_config[param_name]
147 - ):
148 - # anyOf has no top-level "type"; treat as object to trigger json.loads.
149 - param_type = "object"
150 - else:
151 - param_type = "string"
152 - if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
153 - return param_value
154 - elif (
155 - param_type.startswith("int")
156 - or param_type.startswith("uint")
157 - or param_type.startswith("long")
158 - or param_type.startswith("short")
159 - or param_type.startswith("unsigned")
160 - ):
161 - try:
162 - return int(param_value)
163 - except (ValueError, TypeError):
164 - logger.debug(
165 - "Parsed value '%s' of parameter '%s' is not an "
166 - "integer in tool '%s', degenerating to string.",
167 - param_value,
168 - param_name,
169 - func_name,
170 - )
171 - return param_value
172 - elif param_type.startswith("num") or param_type.startswith("float"):
173 - try:
174 - float_param_value = float(param_value)
175 - return (
176 - float_param_value
177 - if float_param_value - int(float_param_value) != 0
178 - else int(float_param_value)
179 - )
180 - except (ValueError, TypeError):
181 - logger.debug(
182 - "Parsed value '%s' of parameter '%s' is not a float "
183 - "in tool '%s', degenerating to string.",
184 - param_value,
185 - param_name,
186 - func_name,
187 - )
188 - return param_value
189 - elif param_type in ["boolean", "bool", "binary"]:
190 - param_value = param_value.lower()
191 - if param_value not in ["true", "false"]:
192 - logger.debug(
193 - "Parsed value '%s' of parameter '%s' is not a boolean "
194 - "(`true` or `false`) in tool '%s', degenerating to "
195 - "false.",
196 - param_value,
197 - param_name,
198 - func_name,
199 - )
200 - return param_value == "true"
201 - else:
202 - if (
203 - param_type in ["object", "array", "arr"]
204 - or param_type.startswith("dict")
205 - or param_type.startswith("list")
206 - ):
207 - try:
208 - param_value = json.loads(param_value)
209 - return param_value
210 - except (json.JSONDecodeError, TypeError, ValueError):
211 - logger.debug(
212 - "Parsed value '%s' of parameter '%s' cannot be "
213 - "parsed with json.loads in tool '%s', will try "
214 - "other methods to parse it.",
215 - param_value,
216 - param_name,
217 - func_name,
218 - )
219 - try:
220 - param_value = ast.literal_eval(param_value) # safer
221 - except (ValueError, SyntaxError, TypeError):
222 - logger.debug(
223 - "Parsed value '%s' of parameter '%s' cannot be "
224 - "converted via Python `ast.literal_eval()` in tool "
225 - "'%s', degenerating to string.",
226 - param_value,
227 - param_name,
228 - func_name,
229 - )
127 + if not isinstance(param_value, str):
128 return param_value
129 + param_schema = param_config.get(param_name, {})
130 + param_types = extract_types_from_schema(param_schema)
131 + return coerce_to_schema_type(param_value, param_types)
132
133 def _parse_xml_function_call(self, function_call_str: str) -> ToolCall | None:
134 # Extract function name