From c20c48b8fd94c5553452988edda6b3832166e5dc Mon Sep 17 00:00:00 2001 From: ilyasher-harmonic Date: Wed, 22 Jul 2026 14:14:47 -0700 Subject: [PATCH] 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 Co-authored-by: Alex Nails --- .../srt/function_call/qwen3_coder_detector.py | 16 +- python/sglang/srt/function_call/utils.py | 3 + .../test_function_call_parser.py | 147 ++++++++++++++++++ 3 files changed, 159 insertions(+), 7 deletions(-) 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