[BugFix] Omit empty tool_calls from OpenAI chat responses (#44105)
Signed-off-by: QwertyJack <7554089+QwertyJack@users.noreply.github.com> Signed-off-by: Chauncey <chaunceyjiang@gmail.com> Co-authored-by: QwertyJack <7554089+QwertyJack@users.noreply.github.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>
jack committed
Jun 23, 2026 at 13:43 UTC
6c427dd40141870b9076c9a9f128eec3a7ce86bc
5 files changed
+134
-5
tests/entrypoints/openai/chat_completion/test_completion_with_function_calling.py
+2
-2
@@ -540,8 +540,8 @@ async def test_max_tokens_with_tool_choice_required(
540
tool_choice=tool_choice,
541
)
542
# When `tool_choice="required"` and the tokens of `tools` exceed `max_tokens`,
543
- # both `tool_calls` and `content` should be empty.
543
+ # `tool_calls` should be absent and `content` should be empty.
544
# This behavior should be consistent with OpenAI.
545
choice = chat_completion.choices[0]
546
assert choice.finish_reason == "length"
547
- assert len(choice.message.tool_calls) == 0
547
+ assert choice.message.tool_calls is None
tests/entrypoints/openai/chat_completion/test_serving_chat.py
+1
-1
@@ -465,7 +465,7 @@ class TestGPTOSSChat:
465
)
466
467
msg = tool_choice_none.choices[0].message
468
- assert len(msg.tool_calls) == 0
468
+ assert msg.tool_calls is None
469
470
471
class TestGPTOSSSpeculativeChat:
tests/entrypoints/openai/test_tool_choice_content_none.py
+117
-2
@@ -2,8 +2,25 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
import pytest
5
-
6
-from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
5
+from openai.types.chat.chat_completion import ChatCompletion as OpenAIChatCompletion
6
+from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
7
+
8
+from vllm.entrypoints.openai.chat_completion.protocol import (
9
+ ChatCompletionRequest,
10
+ ChatCompletionResponse,
11
+ ChatCompletionResponseChoice,
12
+ ChatCompletionResponseStreamChoice,
13
+ ChatCompletionStreamResponse,
14
+ ChatMessage,
15
+)
16
+from vllm.entrypoints.openai.engine.protocol import (
17
+ DeltaFunctionCall,
18
+ DeltaMessage,
19
+ DeltaToolCall,
20
+ FunctionCall,
21
+ ToolCall,
22
+ UsageInfo,
23
+)
24
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
25
from vllm.parser.abstract_parser import DelegatingParser
26
@@ -86,3 +103,101 @@ def test_responses_parser_allows_named_tool_choice_with_none_content():
103
104
assert content is None
105
assert tool_calls == []
106
+
107
+
108
+def _chat_response(message: ChatMessage) -> ChatCompletionResponse:
109
+ return ChatCompletionResponse(
110
+ model="test-model",
111
+ choices=[
112
+ ChatCompletionResponseChoice(
113
+ index=0,
114
+ message=message,
115
+ finish_reason="stop",
116
+ )
117
+ ],
118
+ usage=UsageInfo(prompt_tokens=1, completion_tokens=1, total_tokens=2),
119
+ )
120
+
121
+
122
+def test_chat_completion_response_omits_empty_tool_calls_payload():
123
+ response = _chat_response(ChatMessage(role="assistant", content="done"))
124
+
125
+ payload = response.model_dump()
126
+ payload_exclude_unset = response.model_dump(exclude_unset=True)
127
+
128
+ assert "tool_calls" not in payload["choices"][0]["message"]
129
+ assert "tool_calls" not in payload_exclude_unset["choices"][0]["message"]
130
+ parsed = OpenAIChatCompletion.model_validate(payload)
131
+ assert parsed.choices[0].message.tool_calls is None
132
+
133
+
134
+def test_chat_completion_response_keeps_non_empty_tool_calls_payload():
135
+ response = _chat_response(
136
+ ChatMessage(
137
+ role="assistant",
138
+ content="",
139
+ tool_calls=[
140
+ ToolCall(
141
+ function=FunctionCall(
142
+ name="get_weather",
143
+ arguments='{"city": "Beijing"}',
144
+ )
145
+ )
146
+ ],
147
+ )
148
+ )
149
+
150
+ message = response.model_dump()["choices"][0]["message"]
151
+
152
+ assert len(message["tool_calls"]) == 1
153
+ assert message["tool_calls"][0]["function"]["name"] == "get_weather"
154
+
155
+
156
+def _stream_response(delta: DeltaMessage) -> ChatCompletionStreamResponse:
157
+ return ChatCompletionStreamResponse(
158
+ id="chatcmpl-test",
159
+ object="chat.completion.chunk",
160
+ created=1,
161
+ model="test-model",
162
+ choices=[
163
+ ChatCompletionResponseStreamChoice(
164
+ index=0,
165
+ delta=delta,
166
+ finish_reason=None,
167
+ )
168
+ ],
169
+ )
170
+
171
+
172
+def test_chat_completion_stream_response_omits_empty_tool_calls_payload():
173
+ response = _stream_response(DeltaMessage(content="done"))
174
+
175
+ payload = response.model_dump(exclude_unset=True)
176
+ payload_json = response.model_dump_json(exclude_unset=True)
177
+
178
+ assert "tool_calls" not in payload["choices"][0]["delta"]
179
+ parsed = ChatCompletionChunk.model_validate_json(payload_json)
180
+ assert parsed.choices[0].delta.tool_calls is None
181
+
182
+
183
+def test_chat_completion_stream_response_keeps_non_empty_tool_calls_payload():
184
+ response = _stream_response(
185
+ DeltaMessage(
186
+ tool_calls=[
187
+ DeltaToolCall(
188
+ index=0,
189
+ id="call-test",
190
+ type="function",
191
+ function=DeltaFunctionCall(
192
+ name="get_weather",
193
+ arguments='{"city": "Beijing"}',
194
+ ),
195
+ )
196
+ ]
197
+ )
198
+ )
199
+
200
+ delta = response.model_dump(exclude_unset=True)["choices"][0]["delta"]
201
+
202
+ assert len(delta["tool_calls"]) == 1
203
+ assert delta["tool_calls"][0]["function"]["name"] == "get_weather"
vllm/entrypoints/openai/chat_completion/protocol.py
+7
@@ -66,6 +66,13 @@ class ChatMessage(OpenAIBaseModel):
66
# vLLM-specific fields that are not in OpenAI spec
67
reasoning: str | None = None
68
69
+ @model_serializer(mode="wrap")
70
+ def _serialize(self, handler):
71
+ data = handler(self)
72
+ if len(data.get("tool_calls", [])) == 0:
73
+ data.pop("tool_calls", None)
74
+ return data
75
+
76
77
class ChatCompletionLogProb(OpenAIBaseModel):
78
token: str
vllm/entrypoints/openai/engine/protocol.py
+7
@@ -353,6 +353,13 @@ class DeltaMessage(OpenAIBaseModel):
353
reasoning: str | None = None
354
tool_calls: list[DeltaToolCall] = Field(default_factory=list)
355
356
+ @model_serializer(mode="wrap")
357
+ def _serialize(self, handler):
358
+ data = handler(self)
359
+ if len(data.get("tool_calls", [])) == 0:
360
+ data.pop("tool_calls", None)
361
+ return data
362
+
363
364
class GenerationError(Exception):
365
"""raised when finish_reason indicates internal server error (500)"""