[releases/v0.18.0][Platform][BugFix] Guard forced tool choice with empty content (#8400)

### What this PR does / why we need it?

This backports the forced-tool-choice `content=None` guard to the
`releases/v0.18.0` compatibility layer.

Upstream vLLM still has forced named tool-choice branches that assert
`content is not None` after reasoning extraction. Some reasoning parsers
can legally consume the full output and return `(reasoning, None)`,
which makes the assert reachable and can surface as a server-side
failure.

This PR follows the same compatibility-patch pattern used by:
- `7314bbe2` fix(platform): reimplement MiniMax usage accounting patch
(#7835)
- `f83cb0e6` [Bugfix][Platform] Fix GLM47 tool-call finish backfill
(#7710)

The patch is intentionally narrow:
- normalize `content=None` to `""` only for forced named tool choice
- patch both chat-completions and responses parser entry points
- keep the rest of upstream behavior unchanged

Upstream tracking:
- issue: vllm-project/vllm#40147
- PR: vllm-project/vllm#40148

### Does this PR introduce _any_ user-facing change?

Yes.

Forced named tool choice becomes robust when the reasoning parser
returns no post-reasoning content, avoiding an internal assertion
failure and emitting an empty-argument function call instead.

### How was this patch tested?

Unit tests:
```bash
pytest -sv tests/ut/patch/platform/test_patch_tool_choice_none_content.py \
  tests/ut/patch/platform/test_patch_glm_tool_call_parser.py \
  tests/ut/patch/platform/test_patch_minimax_usage_accounting.py
```

Result: 22 passed.

---------

Signed-off-by: QwertyJack <7554089+QwertyJack@users.noreply.github.com>
Co-authored-by: QwertyJack <7554089+QwertyJack@users.noreply.github.com>
This commit is contained in:
jack
2026-04-23 16:46:10 +08:00
committed by GitHub
parent ff76c6780e
commit d81101acdd
4 changed files with 202 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
# SPDX-License-Identifier: Apache-2.0
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.engine.serving import OpenAIServing
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.parser.abstract_parser import DelegatingParser
from vllm_ascend.patch.platform import patch_tool_choice_none_content # noqa: F401
class _DummyDelegatingParser(DelegatingParser):
def is_reasoning_end(self, input_ids: list[int]) -> bool:
return False
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
return input_ids
def extract_reasoning(self, model_output: str, request):
return None, model_output
def extract_reasoning_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: list[int],
current_token_ids: list[int],
delta_token_ids: list[int],
):
return None
def extract_tool_calls(self, model_output: str, request):
return None
def test_parse_tool_calls_from_content_allows_named_tool_choice_with_none_content():
request = ChatCompletionRequest.model_validate(
{
"model": "test-model",
"messages": [{"role": "user", "content": "test"}],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {"type": "object", "properties": {}},
},
}
],
"tool_choice": {"type": "function", "function": {"name": "get_weather"}},
}
)
tool_calls, content = OpenAIServing._parse_tool_calls_from_content(
request=request,
tokenizer=None,
enable_auto_tools=True,
tool_parser_cls=None,
content=None,
)
assert content is None
assert tool_calls is not None
assert len(tool_calls) == 1
assert tool_calls[0].name == "get_weather"
assert tool_calls[0].arguments == ""
def test_responses_parser_allows_named_tool_choice_with_none_content():
request = ResponsesRequest.model_validate(
{
"model": "test-model",
"input": "test",
"tools": [
{
"type": "function",
"name": "get_weather",
"parameters": {"type": "object", "properties": {}},
}
],
"tool_choice": {"type": "function", "name": "get_weather"},
}
)
parser = _DummyDelegatingParser(tokenizer=None)
tool_calls, content = parser._parse_tool_calls(
request=request,
content=None,
enable_auto_tools=False,
)
assert content is None
assert len(tool_calls) == 1
assert tool_calls[0].name == "get_weather"
assert tool_calls[0].arguments == ""