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