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 5, 16:00 UTC
[Bugfix] Fix Qwen3CoderToolParser anyOf/oneOf type resolution for nullable params (#37831)
Signed-off-by: AAISSJ <maze0717@g.skku.edu> Signed-off-by: <> Co-authored-by: 세덩 <saison@sedeong-ui-MacBookAir.local>
손세정 committed
Apr 1, 2026 at 21:22 UTC
582340f273666d768630aca65314dd104be41b14
2 files changed
+254
-14
tests/tool_parsers/test_qwen3coder_tool_parser.py
+202
@@ -429,6 +429,208 @@ hello world
429
assert args["obj_param"] == {"key": "value"}
430
431
432
+def test_extract_tool_calls_anyof_type_conversion(qwen3_tool_parser):
433
+ """Test type conversion for anyOf/oneOf nullable schemas (Pydantic v2).
434
+
435
+ Pydantic v2 emits anyOf for Optional[T] fields, e.g.:
436
+ Optional[int] -> {"anyOf": [{"type": "integer"}, {"type": "null"}]}
437
+ The parser must extract the non-null type and apply the correct
438
+ conversion (int(), float(), etc.) instead of returning a raw string.
439
+ """
440
+ tools = [
441
+ ChatCompletionToolsParam(
442
+ type="function",
443
+ function={
444
+ "name": "test_anyof",
445
+ "parameters": {
446
+ "type": "object",
447
+ "properties": {
448
+ "anyof_int": {
449
+ "anyOf": [
450
+ {"type": "integer"},
451
+ {"type": "null"},
452
+ ],
453
+ "default": 5,
454
+ },
455
+ "anyof_str": {
456
+ "anyOf": [
457
+ {"type": "string"},
458
+ {"type": "null"},
459
+ ],
460
+ },
461
+ "anyof_array": {
462
+ "anyOf": [
463
+ {"type": "array", "items": {"type": "string"}},
464
+ {"type": "null"},
465
+ ],
466
+ },
467
+ "anyof_obj": {
468
+ "anyOf": [
469
+ {"type": "object"},
470
+ {"type": "null"},
471
+ ],
472
+ },
473
+ "type_as_array": {
474
+ "type": ["integer", "null"],
475
+ },
476
+ "multi_non_null": {
477
+ "anyOf": [
478
+ {"type": "string"},
479
+ {"type": "integer"},
480
+ {"type": "null"},
481
+ ],
482
+ },
483
+ "ref_param": {
484
+ "$ref": "#/$defs/ToolInput",
485
+ },
486
+ },
487
+ },
488
+ },
489
+ )
490
+ ]
491
+
492
+ model_output = """<tool_call>
493
+<function=test_anyof>
494
+<parameter=anyof_int>
495
+5
496
+</parameter>
497
+<parameter=anyof_str>
498
+hello
499
+</parameter>
500
+<parameter=anyof_array>
501
+["a", "b", "c"]
502
+</parameter>
503
+<parameter=anyof_obj>
504
+{"key": "value"}
505
+</parameter>
506
+<parameter=type_as_array>
507
+42
508
+</parameter>
509
+<parameter=multi_non_null>
510
+some text
511
+</parameter>
512
+<parameter=ref_param>
513
+{"city": "Paris"}
514
+</parameter>
515
+</function>
516
+</tool_call>"""
517
+
518
+ request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
519
+ extracted = qwen3_tool_parser.extract_tool_calls(model_output, request=request)
520
+
521
+ args = json.loads(extracted.tool_calls[0].function.arguments)
522
+ assert args["anyof_int"] == 5
523
+ assert isinstance(args["anyof_int"], int)
524
+ assert args["anyof_str"] == "hello"
525
+ assert isinstance(args["anyof_str"], str)
526
+ assert args["anyof_array"] == ["a", "b", "c"]
527
+ assert isinstance(args["anyof_array"], list)
528
+ assert args["anyof_obj"] == {"key": "value"}
529
+ assert isinstance(args["anyof_obj"], dict)
530
+ assert args["type_as_array"] == 42
531
+ assert isinstance(args["type_as_array"], int)
532
+ # Multi non-null: anyOf[string, integer, null] → first non-null is string
533
+ assert args["multi_non_null"] == "some text"
534
+ assert isinstance(args["multi_non_null"], str)
535
+ # $ref: treated as object, parsed via json.loads
536
+ assert args["ref_param"] == {"city": "Paris"}
537
+ assert isinstance(args["ref_param"], dict)
538
+
539
+
540
+def test_extract_tool_calls_anyof_type_conversion_streaming(
541
+ qwen3_tool_parser, qwen3_tokenizer
542
+):
543
+ """Test streaming e2e for anyOf/oneOf nullable schemas (Pydantic v2).
544
+
545
+ Verifies that the full streaming pipeline — tokenize, incrementally
546
+ decode, extract_tool_calls_streaming — correctly resolves types from
547
+ anyOf schemas and produces valid JSON with properly typed values.
548
+ """
549
+ tools = [
550
+ ChatCompletionToolsParam(
551
+ type="function",
552
+ function={
553
+ "name": "search_web",
554
+ "parameters": {
555
+ "type": "object",
556
+ "properties": {
557
+ "query": {
558
+ "anyOf": [
559
+ {"type": "string"},
560
+ {"type": "null"},
561
+ ],
562
+ },
563
+ "count": {
564
+ "anyOf": [
565
+ {"type": "integer"},
566
+ {"type": "null"},
567
+ ],
568
+ "default": 5,
569
+ },
570
+ "verbose": {
571
+ "anyOf": [
572
+ {"type": "boolean"},
573
+ {"type": "null"},
574
+ ],
575
+ },
576
+ "filters": {
577
+ "$ref": "#/$defs/SearchFilters",
578
+ },
579
+ },
580
+ },
581
+ },
582
+ )
583
+ ]
584
+
585
+ model_output = """<tool_call>
586
+<function=search_web>
587
+<parameter=query>
588
+vllm tool parser
589
+</parameter>
590
+<parameter=count>
591
+10
592
+</parameter>
593
+<parameter=verbose>
594
+true
595
+</parameter>
596
+<parameter=filters>
597
+{"lang": "en", "year": 2025}
598
+</parameter>
599
+</function>
600
+</tool_call>"""
601
+
602
+ request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
603
+
604
+ tool_states = {}
605
+ for delta_message in stream_delta_message_generator(
606
+ qwen3_tool_parser, qwen3_tokenizer, model_output, request
607
+ ):
608
+ if delta_message.tool_calls:
609
+ for tool_call in delta_message.tool_calls:
610
+ idx = tool_call.index
611
+ if idx not in tool_states:
612
+ tool_states[idx] = {"name": None, "arguments": ""}
613
+ if tool_call.function:
614
+ if tool_call.function.name:
615
+ tool_states[idx]["name"] = tool_call.function.name
616
+ if tool_call.function.arguments is not None:
617
+ tool_states[idx]["arguments"] += tool_call.function.arguments
618
+
619
+ assert len(tool_states) == 1
620
+ assert tool_states[0]["name"] == "search_web"
621
+ assert tool_states[0]["arguments"] is not None
622
+ args = json.loads(tool_states[0]["arguments"])
623
+ assert args["query"] == "vllm tool parser"
624
+ assert isinstance(args["query"], str)
625
+ assert args["count"] == 10
626
+ assert isinstance(args["count"], int)
627
+ assert args["verbose"] is True
628
+ assert isinstance(args["verbose"], bool)
629
+ # $ref: treated as object, parsed via json.loads
630
+ assert args["filters"] == {"lang": "en", "year": 2025}
631
+ assert isinstance(args["filters"], dict)
632
+
633
+
634
@pytest.mark.parametrize(
635
ids=[
636
"no_tools",
vllm/tool_parsers/qwen3coder_tool_parser.py
+52
-14
@@ -131,11 +131,58 @@ class Qwen3CoderToolParser(ToolParser):
131
logger.debug("Tool '%s' is not defined in the tools list.", func_name)
132
return {}
133
134
+ @staticmethod
135
+ def _first_non_null_type(type_value: Any) -> str | None:
136
+ """Extract the first non-null type from a type value.
137
+
138
+ Handles both scalar types ("integer") and type-as-array
139
+ (["integer", "null"]) per JSON Schema spec.
140
+ """
141
+ if isinstance(type_value, list):
142
+ return next(
143
+ (
144
+ str(t).strip().lower()
145
+ for t in type_value
146
+ if t is not None and str(t).lower() != "null"
147
+ ),
148
+ None,
149
+ )
150
+ if type_value is not None and str(type_value).lower() != "null":
151
+ return str(type_value).strip().lower()
152
+ return None
153
+
154
+ def _resolve_param_type(self, param_def: dict) -> str:
155
+ """Resolve the effective type string from a parameter definition.
156
+
157
+ Handles direct "type" fields (including type-as-array),
158
+ anyOf/oneOf schemas emitted by Pydantic v2 for Optional[T],
159
+ and $ref schemas from Pydantic model inputs.
160
+ """
161
+ if "type" in param_def:
162
+ resolved = self._first_non_null_type(param_def["type"])
163
+ return resolved or "string"
164
+
165
+ if "anyOf" in param_def or "oneOf" in param_def:
166
+ variants = param_def.get("anyOf") or param_def.get("oneOf", [])
167
+ for v in variants:
168
+ if not isinstance(v, dict):
169
+ continue
170
+ resolved = self._first_non_null_type(v.get("type"))
171
+ if resolved:
172
+ return resolved
173
+
174
+ # $ref points to a schema definition (e.g. a Pydantic model).
175
+ # The referenced type is almost always an object, so treat it
176
+ # as such to route through json.loads.
177
+ if "$ref" in param_def:
178
+ return "object"
179
+
180
+ return "string"
181
+
182
def _convert_param_value(
183
self, param_value: str, param_name: str, param_config: dict, func_name: str
184
) -> Any:
185
"""Convert parameter value based on its type in the schema."""
138
- # Handle null value for any type
186
if param_value.lower() == "null":
187
return None
188
@@ -150,19 +197,10 @@ class Qwen3CoderToolParser(ToolParser):
197
)
198
return param_value
199
153
- if (
154
- isinstance(param_config[param_name], dict)
155
- and "type" in param_config[param_name]
156
- ):
157
- param_type = str(param_config[param_name]["type"]).strip().lower()
158
- elif (
159
- isinstance(param_config[param_name], dict)
160
- and "anyOf" in param_config[param_name]
161
- ):
162
- # anyOf has no top-level "type"; treat as object to trigger json.loads.
163
- param_type = "object"
164
- else:
165
- param_type = "string"
200
+ if not isinstance(param_config[param_name], dict):
201
+ return param_value
202
+
203
+ param_type = self._resolve_param_type(param_config[param_name])
204
if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
205
return param_value
206
elif (