[Bugfix][Frontend] Constrain forced named tool choice with empty parameters to a JSON object (#45290)
Signed-off-by: Ting Sun <suntcrick@gmail.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>
Ting SUN committed
Sep 30, 2026 at 13:44 UTC
fcf4750466b07c2ba921a7da3831f585cbc2b8d7
2 files changed
+33
-5
tests/tool_use/test_tool_choice_required.py
+25
-1
@@ -5,7 +5,7 @@ from copy import deepcopy
5
6
import pytest
7
import regex as re
8
-from openai.types.responses import FunctionTool, WebSearchTool
8
+from openai.types.responses import FunctionTool, ToolChoiceFunction, WebSearchTool
9
from pydantic import TypeAdapter
10
11
from vllm.entrypoints.openai.chat_completion.protocol import (
@@ -456,3 +456,27 @@ class TestParallelToolCallsConstraint:
456
)
457
assert isinstance(schema, dict)
458
assert "maxItems" not in schema
459
+
460
+
461
+class TestForcedNamedToolChoiceEmptyParams:
462
+ """A forced named tool_choice with missing/empty parameters must still
463
+ constrain the generated arguments to a JSON object, like the
464
+ `tool_choice="required"` path, instead of leaving them unconstrained."""
465
+
466
+ @pytest.mark.parametrize("params", [None, {}])
467
+ def test_chat_empty_params_constrains_object(self, params):
468
+ tool = ChatCompletionToolsParam.model_validate(
469
+ {"type": "function", "function": {"name": "ping", "parameters": params}}
470
+ )
471
+ choice = ChatCompletionNamedToolChoiceParam.model_validate(
472
+ {"type": "function", "function": {"name": "ping"}}
473
+ )
474
+ schema = get_json_schema_from_tools(choice, [tool])
475
+ assert schema == {"type": "object", "properties": {}}
476
+
477
+ @pytest.mark.parametrize("params", [None, {}])
478
+ def test_responses_empty_params_constrains_object(self, params):
479
+ tool = FunctionTool(type="function", name="ping", parameters=params)
480
+ choice = ToolChoiceFunction(type="function", name="ping")
481
+ schema = get_json_schema_from_tools(choice, [tool])
482
+ assert schema == {"type": "object", "properties": {}}
vllm/tool_parsers/utils.py
+8
-4
@@ -308,14 +308,18 @@ def find_tool_name(
308
return False
309
310
311
+def _params_or_empty_object(params: dict[str, Any] | None) -> dict[str, Any]:
312
+ # Empty/missing parameters still constrain arguments to a JSON object.
313
+ return params if params else {"type": "object", "properties": {}}
314
+
315
+
316
def _get_tool_schema_from_name_and_params(
317
name: str, params: dict[str, Any] | None
318
) -> dict:
314
- params = params if params else {"type": "object", "properties": {}}
319
return {
320
"properties": {
321
"name": {"type": "string", "enum": [name]},
318
- "parameters": params,
322
+ "parameters": _params_or_empty_object(params),
323
},
324
"required": ["name", "parameters"],
325
}
@@ -403,7 +407,7 @@ def get_json_schema_from_tools(
407
responses_tool_map.setdefault(name.rsplit("__", 1)[1], params)
408
if tool_name not in responses_tool_map:
409
raise ValueError(f"Tool '{tool_name}' has not been passed in `tools`.")
406
- return responses_tool_map[tool_name]
410
+ return _params_or_empty_object(responses_tool_map[tool_name])
411
# tool_choice: Forced Function (ChatCompletion)
412
if (not isinstance(tool_choice, str)) and isinstance(
413
tool_choice, ChatCompletionNamedToolChoiceParam
@@ -416,7 +420,7 @@ def get_json_schema_from_tools(
420
}
421
if tool_name not in chat_tool_map:
422
raise ValueError(f"Tool '{tool_name}' has not been passed in `tools`.")
419
- return chat_tool_map[tool_name].function.parameters
423
+ return _params_or_empty_object(chat_tool_map[tool_name].function.parameters)
424
# tool_choice: "required"
425
if tool_choice == "required":
426
return _get_json_schema_from_tools(tools, parallel_tool_calls)