Add 'anyOf' schema support for qwen3_coder tool call parser (#30832)
Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com> Co-authored-by: Liangsheng Yin <hnyls2002@gmail.com> Co-authored-by: Alex Nails <alex.nails@radixark.ai>
This commit is contained in:
co-authored by
Xinyuan Tong
Liangsheng Yin
Alex Nails
parent
e7511141ea
commit
c20c48b8fd
@@ -11,6 +11,7 @@ from sglang.srt.function_call.core_types import (
|
|||||||
ToolCallItem,
|
ToolCallItem,
|
||||||
_GetInfoFunc,
|
_GetInfoFunc,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.function_call.utils import infer_type_from_json_schema
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -86,6 +87,13 @@ class Qwen3CoderDetector(BaseFormatDetector):
|
|||||||
logger.warning(f"Tool '{func_name}' is not defined in the tools list.")
|
logger.warning(f"Tool '{func_name}' is not defined in the tools list.")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
def _get_param_type(self, param_schema: Any) -> str:
|
||||||
|
"""Infer the parser conversion type from a JSON schema parameter."""
|
||||||
|
inferred_type = infer_type_from_json_schema(param_schema)
|
||||||
|
if inferred_type is None:
|
||||||
|
return "string"
|
||||||
|
return str(inferred_type).strip().lower()
|
||||||
|
|
||||||
def _convert_param_value(
|
def _convert_param_value(
|
||||||
self, param_value: str, param_name: str, param_config: dict, func_name: str
|
self, param_value: str, param_name: str, param_config: dict, func_name: str
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -102,13 +110,7 @@ class Qwen3CoderDetector(BaseFormatDetector):
|
|||||||
)
|
)
|
||||||
return param_value
|
return param_value
|
||||||
|
|
||||||
if (
|
param_type = self._get_param_type(param_config[param_name])
|
||||||
isinstance(param_config[param_name], dict)
|
|
||||||
and "type" in param_config[param_name]
|
|
||||||
):
|
|
||||||
param_type = str(param_config[param_name]["type"]).strip().lower()
|
|
||||||
else:
|
|
||||||
param_type = "string"
|
|
||||||
if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
|
if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
|
||||||
return param_value
|
return param_value
|
||||||
elif (
|
elif (
|
||||||
|
|||||||
@@ -320,6 +320,9 @@ def infer_type_from_json_schema(schema: Dict[str, Any]) -> Optional[str]:
|
|||||||
# If all types are the same, return unified type
|
# If all types are the same, return unified type
|
||||||
if len(set(types)) == 1:
|
if len(set(types)) == 1:
|
||||||
return types[0]
|
return types[0]
|
||||||
|
# If it's an optional type, return original type.
|
||||||
|
if len(set(types)) == 2 and "null" in types:
|
||||||
|
return [t for t in types if t != "null"][0]
|
||||||
# When types differ, prioritize string (safest)
|
# When types differ, prioritize string (safest)
|
||||||
if "string" in types:
|
if "string" in types:
|
||||||
return "string"
|
return "string"
|
||||||
|
|||||||
@@ -2209,6 +2209,27 @@ class TestQwen3CoderDetector(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Tool(
|
||||||
|
type="function",
|
||||||
|
function=Function(
|
||||||
|
name="get_current_time",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cities": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
},
|
||||||
|
{"type": "null"},
|
||||||
|
],
|
||||||
|
"default": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
self.detector = Qwen3CoderDetector()
|
self.detector = Qwen3CoderDetector()
|
||||||
|
|
||||||
@@ -2413,6 +2434,75 @@ class TestQwen3CoderDetector(unittest.TestCase):
|
|||||||
self.assertEqual(params["todos"][0]["content"], "Buy groceries")
|
self.assertEqual(params["todos"][0]["content"], "Buy groceries")
|
||||||
self.assertEqual(params["todos"][1]["status"], "completed")
|
self.assertEqual(params["todos"][1]["status"], "completed")
|
||||||
|
|
||||||
|
def test_anyof_array_parameter_conversion(self):
|
||||||
|
"""
|
||||||
|
Test array parameter conversion for nullable anyOf schemas.
|
||||||
|
|
||||||
|
Scenario: A Pydantic-style nullable list schema is represented by anyOf.
|
||||||
|
Purpose: Verify array values are parsed as arrays, not JSON-looking strings.
|
||||||
|
"""
|
||||||
|
text = """<tool_call>
|
||||||
|
<function=get_current_time>
|
||||||
|
<parameter=cities>
|
||||||
|
["NYC"]
|
||||||
|
</parameter>
|
||||||
|
</function>
|
||||||
|
</tool_call>"""
|
||||||
|
result = self.detector.detect_and_parse(text, self.tools)
|
||||||
|
|
||||||
|
params = json.loads(result.calls[0].parameters)
|
||||||
|
self.assertIsInstance(params["cities"], list)
|
||||||
|
self.assertEqual(params["cities"], ["NYC"])
|
||||||
|
|
||||||
|
def test_anyof_array_parameter_conversion_null(self):
|
||||||
|
"""
|
||||||
|
Test 'null' is converted correctly for nullable anyOf schemas.
|
||||||
|
|
||||||
|
Scenario: A Pydantic-style nullable list schema is represented by anyOf.
|
||||||
|
Purpose: Verify null values are parsed as 'None', not as strings.
|
||||||
|
"""
|
||||||
|
text = """<tool_call>
|
||||||
|
<function=get_current_time>
|
||||||
|
<parameter=cities>
|
||||||
|
null
|
||||||
|
</parameter>
|
||||||
|
</function>
|
||||||
|
</tool_call>"""
|
||||||
|
result = self.detector.detect_and_parse(text, self.tools)
|
||||||
|
|
||||||
|
params = json.loads(result.calls[0].parameters)
|
||||||
|
self.assertEqual(params["cities"], None)
|
||||||
|
|
||||||
|
def test_streaming_anyof_array_parameter_conversion(self):
|
||||||
|
"""
|
||||||
|
Test streaming array parameter conversion for nullable anyOf schemas.
|
||||||
|
|
||||||
|
Scenario: A Pydantic-style nullable list schema is streamed in Qwen3 Coder format.
|
||||||
|
Purpose: Verify the streamed JSON fragments encode an array value, not a string value.
|
||||||
|
"""
|
||||||
|
chunks = [
|
||||||
|
"<tool_call>",
|
||||||
|
"<function=get_current_time>",
|
||||||
|
"<parameter=cities>",
|
||||||
|
'["NYC"]',
|
||||||
|
"</parameter>",
|
||||||
|
"</function>",
|
||||||
|
"</tool_call>",
|
||||||
|
]
|
||||||
|
|
||||||
|
detector = Qwen3CoderDetector()
|
||||||
|
collected_params = ""
|
||||||
|
|
||||||
|
for chunk in chunks:
|
||||||
|
result = detector.parse_streaming_increment(chunk, self.tools)
|
||||||
|
for call in result.calls:
|
||||||
|
if call.parameters:
|
||||||
|
collected_params += call.parameters
|
||||||
|
|
||||||
|
params = json.loads(collected_params)
|
||||||
|
self.assertIsInstance(params["cities"], list)
|
||||||
|
self.assertEqual(params["cities"], ["NYC"])
|
||||||
|
|
||||||
# ==================== Edge Cases ====================
|
# ==================== Edge Cases ====================
|
||||||
|
|
||||||
def test_empty_parameter_value(self):
|
def test_empty_parameter_value(self):
|
||||||
@@ -2466,6 +2556,63 @@ class TestQwen3CoderDetector(unittest.TestCase):
|
|||||||
result = self.detector.detect_and_parse(text, self.tools)
|
result = self.detector.detect_and_parse(text, self.tools)
|
||||||
self.assertIsInstance(result, StreamingParseResult)
|
self.assertIsInstance(result, StreamingParseResult)
|
||||||
|
|
||||||
|
def test_nested_anyof_array_with_multiple_types_parameter_conversion(self):
|
||||||
|
"""
|
||||||
|
Test several edge cases of parameter conversion for nullable anyOf schemas.
|
||||||
|
1) Test nested anyOf 'T | None' extracts 'T' correctly.
|
||||||
|
2) Test that order of null and non-null type doesn't affect schema parsing.
|
||||||
|
3) Test that list of multiple types (including dict) is parsed correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tool = Tool(
|
||||||
|
type="function",
|
||||||
|
function=Function(
|
||||||
|
name="process_optional_list",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"optional_items_to_process": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
# Note: here "null" is listed before the non-null type.
|
||||||
|
{"type": "null"},
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {},
|
||||||
|
},
|
||||||
|
{"type": "null"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"type": "null"},
|
||||||
|
],
|
||||||
|
"default": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
text = """<tool_call>
|
||||||
|
<function=process_optional_list>
|
||||||
|
<parameter=optional_items_to_process>
|
||||||
|
[true, null, {"enabled": false}]
|
||||||
|
</parameter>
|
||||||
|
</function>
|
||||||
|
</tool_call>"""
|
||||||
|
|
||||||
|
result = self.detector.detect_and_parse(text, [tool])
|
||||||
|
|
||||||
|
params = json.loads(result.calls[0].parameters)
|
||||||
|
self.assertIsInstance(params["optional_items_to_process"], list)
|
||||||
|
self.assertEqual(
|
||||||
|
params["optional_items_to_process"], [True, None, {"enabled": False}]
|
||||||
|
)
|
||||||
|
|
||||||
# ==================== Structural tag (xgrammar builtin) ====================
|
# ==================== Structural tag (xgrammar builtin) ====================
|
||||||
# Qwen3 Coder uses the new builtin structural tag path. supports_structural_tag()
|
# Qwen3 Coder uses the new builtin structural tag path. supports_structural_tag()
|
||||||
# is True so required/named tool_choice routes through FunctionCallParser
|
# is True so required/named tool_choice routes through FunctionCallParser
|
||||||
|
|||||||
Reference in New Issue
Block a user