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