@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 582340f273
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 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 (