diff --git a/python/sglang/srt/function_call/qwen3_coder_detector.py b/python/sglang/srt/function_call/qwen3_coder_detector.py
index 025404572..8319dcd05 100644
--- a/python/sglang/srt/function_call/qwen3_coder_detector.py
+++ b/python/sglang/srt/function_call/qwen3_coder_detector.py
@@ -11,6 +11,7 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
+from sglang.srt.function_call.utils import infer_type_from_json_schema
logger = logging.getLogger(__name__)
@@ -86,6 +87,13 @@ class Qwen3CoderDetector(BaseFormatDetector):
logger.warning(f"Tool '{func_name}' is not defined in the tools list.")
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(
self, param_value: str, param_name: str, param_config: dict, func_name: str
) -> Any:
@@ -102,13 +110,7 @@ class Qwen3CoderDetector(BaseFormatDetector):
)
return param_value
- if (
- 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"
+ param_type = self._get_param_type(param_config[param_name])
if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
return param_value
elif (
diff --git a/python/sglang/srt/function_call/utils.py b/python/sglang/srt/function_call/utils.py
index 072bf50a2..d775313d8 100644
--- a/python/sglang/srt/function_call/utils.py
+++ b/python/sglang/srt/function_call/utils.py
@@ -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 len(set(types)) == 1:
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)
if "string" in types:
return "string"
diff --git a/test/registered/unit/function_call/test_function_call_parser.py b/test/registered/unit/function_call/test_function_call_parser.py
index 17f0a9816..ab86a43d8 100644
--- a/test/registered/unit/function_call/test_function_call_parser.py
+++ b/test/registered/unit/function_call/test_function_call_parser.py
@@ -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()
@@ -2413,6 +2434,75 @@ class TestQwen3CoderDetector(unittest.TestCase):
self.assertEqual(params["todos"][0]["content"], "Buy groceries")
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 = """
+
+
+["NYC"]
+
+
+"""
+ 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 = """
+
+
+null
+
+
+"""
+ 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 = [
+ "",
+ "",
+ "",
+ '["NYC"]',
+ "",
+ "",
+ "",
+ ]
+
+ 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 ====================
def test_empty_parameter_value(self):
@@ -2466,6 +2556,63 @@ class TestQwen3CoderDetector(unittest.TestCase):
result = self.detector.detect_and_parse(text, self.tools)
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 = """
+
+
+[true, null, {"enabled": false}]
+
+
+"""
+
+ 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) ====================
# Qwen3 Coder uses the new builtin structural tag path. supports_structural_tag()
# is True so required/named tool_choice routes through FunctionCallParser