From 2b5aed94f5d4b706622a90f039ea866d8fefe420 Mon Sep 17 00:00:00 2001 From: Khoa Pham Date: Thu, 2 Apr 2026 19:35:24 -0700 Subject: [PATCH] Remove maxItems=1 restriction when tool_choice is specified (#20208) --- .../sglang/srt/entrypoints/openai/protocol.py | 1 + .../srt/entrypoints/openai/serving_chat.py | 7 ++- .../srt/function_call/function_call_parser.py | 8 ++- python/sglang/srt/function_call/utils.py | 13 +++- .../function_call/test_tool_choice.py | 8 --- .../test_json_schema_constraint.py | 63 ++++++++++++++++++- 6 files changed, 83 insertions(+), 17 deletions(-) diff --git a/python/sglang/srt/entrypoints/openai/protocol.py b/python/sglang/srt/entrypoints/openai/protocol.py index 837c2f86b..c40bd37d9 100644 --- a/python/sglang/srt/entrypoints/openai/protocol.py +++ b/python/sglang/srt/entrypoints/openai/protocol.py @@ -585,6 +585,7 @@ class ChatCompletionRequest(BaseModel): tool_choice: Union[ToolChoice, Literal["auto", "required", "none"]] = Field( default="auto", examples=["none"] ) # noqa + parallel_tool_calls: bool = True return_hidden_states: bool = False return_routed_experts: bool = False return_cached_tokens_details: bool = False diff --git a/python/sglang/srt/entrypoints/openai/serving_chat.py b/python/sglang/srt/entrypoints/openai/serving_chat.py index 81610fe59..b4cee0bd0 100644 --- a/python/sglang/srt/entrypoints/openai/serving_chat.py +++ b/python/sglang/srt/entrypoints/openai/serving_chat.py @@ -352,14 +352,17 @@ class OpenAIServingChat(OpenAIServingBase): if self.tool_call_parser: parser = FunctionCallParser(request.tools, self.tool_call_parser) tool_call_constraint = parser.get_structure_constraint( - request.tool_choice + request.tool_choice, + parallel_tool_calls=request.parallel_tool_calls, ) # Handle JSON schema constraint directly for required or named tool choice if request.tool_choice == "required" or isinstance( request.tool_choice, ToolChoice ): json_schema = get_json_schema_constraint( - request.tools, request.tool_choice + request.tools, + request.tool_choice, + parallel_tool_calls=request.parallel_tool_calls, ) tool_call_constraint = ("json_schema", json_schema) diff --git a/python/sglang/srt/function_call/function_call_parser.py b/python/sglang/srt/function_call/function_call_parser.py index 2f562192e..ca066e196 100644 --- a/python/sglang/srt/function_call/function_call_parser.py +++ b/python/sglang/srt/function_call/function_call_parser.py @@ -184,7 +184,9 @@ class FunctionCallParser: ) def get_structure_constraint( - self, tool_choice: Union[ToolChoice, Literal["auto", "required"]] + self, + tool_choice: Union[ToolChoice, Literal["auto", "required"]], + parallel_tool_calls: bool = True, ) -> Optional[ToolCallConstraint]: """ Returns the appropriate structure constraint for tool calls based on the tool_choice. @@ -210,5 +212,7 @@ class FunctionCallParser: tag = self.get_structure_tag() return ("structural_tag", tag) elif tool_choice == "required" or isinstance(tool_choice, ToolChoice): - json_schema = get_json_schema_constraint(self.tools, tool_choice) + json_schema = get_json_schema_constraint( + self.tools, tool_choice, parallel_tool_calls=parallel_tool_calls + ) return ("json_schema", json_schema) diff --git a/python/sglang/srt/function_call/utils.py b/python/sglang/srt/function_call/utils.py index 567ca583b..1ef93e051 100644 --- a/python/sglang/srt/function_call/utils.py +++ b/python/sglang/srt/function_call/utils.py @@ -205,13 +205,16 @@ def infer_type_from_json_schema(schema: Dict[str, Any]) -> Optional[str]: def get_json_schema_constraint( - tools: List[Tool], tool_choice: Union[ToolChoice, Literal["required"]] + tools: List[Tool], + tool_choice: Union[ToolChoice, Literal["required"]], + parallel_tool_calls: bool = True, ) -> Optional[dict]: """ Get the JSON schema constraint for the specified tool choice. Args: tool_choice: The tool choice specification + parallel_tool_calls: If False, constrain to exactly one tool call (maxItems=1) Returns: JSON schema dict, or None if no valid tools found @@ -222,12 +225,14 @@ def get_json_schema_constraint( fn_name = tool_choice.function.name for tool in tools: if tool.function.name == fn_name: - return { + schema = { "type": "array", "minItems": 1, - "maxItems": 1, "items": _get_tool_schema(tool), } + if not parallel_tool_calls: + schema["maxItems"] = 1 + return schema return None elif tool_choice == "required": json_schema = { @@ -238,6 +243,8 @@ def get_json_schema_constraint( "anyOf": [_get_tool_schema(tool) for tool in tools], }, } + if not parallel_tool_calls: + json_schema["maxItems"] = 1 json_schema_defs = _get_tool_schema_defs(tools) if json_schema_defs: json_schema["$defs"] = json_schema_defs diff --git a/test/registered/openai_server/function_call/test_tool_choice.py b/test/registered/openai_server/function_call/test_tool_choice.py index 9f2d2dfc6..80d4cd4a1 100644 --- a/test/registered/openai_server/function_call/test_tool_choice.py +++ b/test/registered/openai_server/function_call/test_tool_choice.py @@ -915,14 +915,6 @@ class TestToolChoiceLfm2Moe(TestToolChoiceLlama32): cls.base_url += "/v1" cls.tokenizer = get_tokenizer(cls.model) - @unittest.skip("maxItems:1 bug causes whitespace stall") - def test_tool_choice_required_non_streaming(self): - pass - - @unittest.skip("maxItems:1 bug causes whitespace stall") - def test_tool_choice_specific_function_non_streaming(self): - pass - if __name__ == "__main__": unittest.main() diff --git a/test/registered/unit/function_call/test_json_schema_constraint.py b/test/registered/unit/function_call/test_json_schema_constraint.py index 65f149538..f66943718 100644 --- a/test/registered/unit/function_call/test_json_schema_constraint.py +++ b/test/registered/unit/function_call/test_json_schema_constraint.py @@ -102,7 +102,7 @@ class TestJsonSchemaConstraint(unittest.TestCase): self.assertEqual(schema["type"], "array") self.assertEqual(schema["minItems"], 1) - self.assertEqual(schema["maxItems"], 1) + self.assertNotIn("maxItems", schema) # Should only have schema for the specific tool item_schema = schema["items"] @@ -121,13 +121,72 @@ class TestJsonSchemaConstraint(unittest.TestCase): self.assertEqual(schema["type"], "array") self.assertEqual(schema["minItems"], 1) - self.assertEqual(schema["maxItems"], 1) + self.assertNotIn("maxItems", schema) # Should only have schema for the specific tool item_schema = schema["items"] self.assertEqual(item_schema["properties"]["name"]["enum"], ["search"]) self.assertIn("parameters", item_schema["properties"]) + def test_specific_tool_choice_allows_multiple_calls(self): + """Test that specific tool choice schema allows multiple calls. + + Regression test for https://github.com/sgl-project/sglang/issues/17998: + maxItems: 1 caused the model to stall on whitespace when the prompt + implied multiple calls to the same function. + """ + tool_choice = ToolChoice( + type="function", function=ToolChoiceFuncName(name="get_weather") + ) + schema = get_json_schema_constraint(self.tools, tool_choice) + + single_call = [ + {"name": "get_weather", "parameters": {"location": "NYC"}}, + ] + multi_call = [ + {"name": "get_weather", "parameters": {"location": "NYC"}}, + {"name": "get_weather", "parameters": {"location": "LA"}}, + {"name": "get_weather", "parameters": {"location": "Chicago"}}, + ] + + validator = jsonschema.Draft202012Validator(schema) + validator.validate(single_call) + validator.validate(multi_call) + + def test_specific_tool_choice_no_parallel(self): + """Test that parallel_tool_calls=False sets maxItems=1""" + tool_choice = ToolChoice( + type="function", function=ToolChoiceFuncName(name="get_weather") + ) + schema = get_json_schema_constraint( + self.tools, tool_choice, parallel_tool_calls=False + ) + + self.assertIsNotNone(schema) + self.assertEqual(schema["maxItems"], 1) + + single_call = [ + {"name": "get_weather", "parameters": {"location": "NYC"}}, + ] + multi_call = [ + {"name": "get_weather", "parameters": {"location": "NYC"}}, + {"name": "get_weather", "parameters": {"location": "LA"}}, + ] + + validator = jsonschema.Draft202012Validator(schema) + validator.validate(single_call) + with self.assertRaises(jsonschema.ValidationError): + validator.validate(multi_call) + + def test_required_tool_choice_no_parallel(self): + """Test that required + parallel_tool_calls=False sets maxItems=1""" + schema = get_json_schema_constraint( + self.tools, "required", parallel_tool_calls=False + ) + + self.assertIsNotNone(schema) + self.assertEqual(schema["maxItems"], 1) + def test_nonexistent_tool_choice(self): """Test schema generation for nonexistent tool""" tool_choice = ToolChoice(