Skip to content

Commit 1785dc5

Browse files
authored
Revert "[Bugfix] Fix Qwen3CoderToolParser anyOf/oneOf type resolution for nullable params (vllm-project#37831)" (vllm-project#38751)
1 parent 5450054 commit 1785dc5

File tree

2 files changed

+14
-254
lines changed

2 files changed

+14
-254
lines changed

tests/tool_parsers/test_qwen3coder_tool_parser.py

Lines changed: 0 additions & 202 deletions
Original file line numberDiff line numberDiff line change
@@ -429,208 +429,6 @@ def test_extract_tool_calls_type_conversion(qwen3_tokenizer):
429429
assert args["obj_param"] == {"key": "value"}
430430

431431

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-
634432
@pytest.mark.parametrize(
635433
ids=[
636434
"no_tools",

vllm/tool_parsers/qwen3coder_tool_parser.py

Lines changed: 14 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -131,58 +131,11 @@ def _get_arguments_config(self, func_name: str, tools: list[Tool] | None) -> dic
131131
logger.debug("Tool '%s' is not defined in the tools list.", func_name)
132132
return {}
133133

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-
182134
def _convert_param_value(
183135
self, param_value: str, param_name: str, param_config: dict, func_name: str
184136
) -> Any:
185137
"""Convert parameter value based on its type in the schema."""
138+
# Handle null value for any type
186139
if param_value.lower() == "null":
187140
return None
188141

@@ -197,10 +150,19 @@ def _convert_param_value(
197150
)
198151
return param_value
199152

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])
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"
204166
if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
205167
return param_value
206168
elif (

0 commit comments

Comments
 (0)