Remove maxItems=1 restriction when tool_choice is specified (#20208)
This commit is contained in:
@@ -585,6 +585,7 @@ class ChatCompletionRequest(BaseModel):
|
|||||||
tool_choice: Union[ToolChoice, Literal["auto", "required", "none"]] = Field(
|
tool_choice: Union[ToolChoice, Literal["auto", "required", "none"]] = Field(
|
||||||
default="auto", examples=["none"]
|
default="auto", examples=["none"]
|
||||||
) # noqa
|
) # noqa
|
||||||
|
parallel_tool_calls: bool = True
|
||||||
return_hidden_states: bool = False
|
return_hidden_states: bool = False
|
||||||
return_routed_experts: bool = False
|
return_routed_experts: bool = False
|
||||||
return_cached_tokens_details: bool = False
|
return_cached_tokens_details: bool = False
|
||||||
|
|||||||
@@ -352,14 +352,17 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
if self.tool_call_parser:
|
if self.tool_call_parser:
|
||||||
parser = FunctionCallParser(request.tools, self.tool_call_parser)
|
parser = FunctionCallParser(request.tools, self.tool_call_parser)
|
||||||
tool_call_constraint = parser.get_structure_constraint(
|
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
|
# Handle JSON schema constraint directly for required or named tool choice
|
||||||
if request.tool_choice == "required" or isinstance(
|
if request.tool_choice == "required" or isinstance(
|
||||||
request.tool_choice, ToolChoice
|
request.tool_choice, ToolChoice
|
||||||
):
|
):
|
||||||
json_schema = get_json_schema_constraint(
|
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)
|
tool_call_constraint = ("json_schema", json_schema)
|
||||||
|
|
||||||
|
|||||||
@@ -184,7 +184,9 @@ class FunctionCallParser:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_structure_constraint(
|
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]:
|
) -> Optional[ToolCallConstraint]:
|
||||||
"""
|
"""
|
||||||
Returns the appropriate structure constraint for tool calls based on the tool_choice.
|
Returns the appropriate structure constraint for tool calls based on the tool_choice.
|
||||||
@@ -210,5 +212,7 @@ class FunctionCallParser:
|
|||||||
tag = self.get_structure_tag()
|
tag = self.get_structure_tag()
|
||||||
return ("structural_tag", tag)
|
return ("structural_tag", tag)
|
||||||
elif tool_choice == "required" or isinstance(tool_choice, ToolChoice):
|
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)
|
return ("json_schema", json_schema)
|
||||||
|
|||||||
@@ -205,13 +205,16 @@ def infer_type_from_json_schema(schema: Dict[str, Any]) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def get_json_schema_constraint(
|
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]:
|
) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Get the JSON schema constraint for the specified tool choice.
|
Get the JSON schema constraint for the specified tool choice.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
tool_choice: The tool choice specification
|
tool_choice: The tool choice specification
|
||||||
|
parallel_tool_calls: If False, constrain to exactly one tool call (maxItems=1)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON schema dict, or None if no valid tools found
|
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
|
fn_name = tool_choice.function.name
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
if tool.function.name == fn_name:
|
if tool.function.name == fn_name:
|
||||||
return {
|
schema = {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"maxItems": 1,
|
|
||||||
"items": _get_tool_schema(tool),
|
"items": _get_tool_schema(tool),
|
||||||
}
|
}
|
||||||
|
if not parallel_tool_calls:
|
||||||
|
schema["maxItems"] = 1
|
||||||
|
return schema
|
||||||
return None
|
return None
|
||||||
elif tool_choice == "required":
|
elif tool_choice == "required":
|
||||||
json_schema = {
|
json_schema = {
|
||||||
@@ -238,6 +243,8 @@ def get_json_schema_constraint(
|
|||||||
"anyOf": [_get_tool_schema(tool) for tool in tools],
|
"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)
|
json_schema_defs = _get_tool_schema_defs(tools)
|
||||||
if json_schema_defs:
|
if json_schema_defs:
|
||||||
json_schema["$defs"] = json_schema_defs
|
json_schema["$defs"] = json_schema_defs
|
||||||
|
|||||||
@@ -915,14 +915,6 @@ class TestToolChoiceLfm2Moe(TestToolChoiceLlama32):
|
|||||||
cls.base_url += "/v1"
|
cls.base_url += "/v1"
|
||||||
cls.tokenizer = get_tokenizer(cls.model)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class TestJsonSchemaConstraint(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(schema["type"], "array")
|
self.assertEqual(schema["type"], "array")
|
||||||
self.assertEqual(schema["minItems"], 1)
|
self.assertEqual(schema["minItems"], 1)
|
||||||
self.assertEqual(schema["maxItems"], 1)
|
self.assertNotIn("maxItems", schema)
|
||||||
|
|
||||||
# Should only have schema for the specific tool
|
# Should only have schema for the specific tool
|
||||||
item_schema = schema["items"]
|
item_schema = schema["items"]
|
||||||
@@ -121,13 +121,72 @@ class TestJsonSchemaConstraint(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(schema["type"], "array")
|
self.assertEqual(schema["type"], "array")
|
||||||
self.assertEqual(schema["minItems"], 1)
|
self.assertEqual(schema["minItems"], 1)
|
||||||
self.assertEqual(schema["maxItems"], 1)
|
self.assertNotIn("maxItems", schema)
|
||||||
|
|
||||||
# Should only have schema for the specific tool
|
# Should only have schema for the specific tool
|
||||||
item_schema = schema["items"]
|
item_schema = schema["items"]
|
||||||
self.assertEqual(item_schema["properties"]["name"]["enum"], ["search"])
|
self.assertEqual(item_schema["properties"]["name"]["enum"], ["search"])
|
||||||
self.assertIn("parameters", item_schema["properties"])
|
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):
|
def test_nonexistent_tool_choice(self):
|
||||||
"""Test schema generation for nonexistent tool"""
|
"""Test schema generation for nonexistent tool"""
|
||||||
tool_choice = ToolChoice(
|
tool_choice = ToolChoice(
|
||||||
|
|||||||
Reference in New Issue
Block a user