[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)