[Feature][Parser] Support include_reasoning param for non-Harmony models (#44301)

Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>

alberto committed Jul 10, 2026 at 08:34 UTC 5715fde12c1e28eccd08a2394a114339b12a96c1
13 files changed +765 -26
docs/features/reasoning_outputs.md
+63
@@ -351,6 +351,69 @@ print(response.choices[0].message.reasoning)
351 print(response.choices[0].message.content)
352 ```
353
354 +## Suppressing Reasoning Output
355 +
356 +You can suppress reasoning content from API responses using the `include_reasoning` parameter. When set to `false`, reasoning tokens are still generated (so model quality is unaffected) but excluded from the response. This reduces network traffic without changing inference behavior.
357 +
358 +The parameter is supported in both the Chat Completions API and the Responses API, for streaming and non-streaming requests.
359 +
360 +When `include_reasoning=false`, vLLM also suppresses per-token metadata (logprobs and token IDs) to prevent leaking reasoning content through decoded token text in logprob entries or raw token IDs.
361 +
362 +### Chat Completions API
363 +
364 +```python
365 +from openai import OpenAI
366 +
367 +client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
368 +model = client.models.list().data[0].id
369 +
370 +# Reasoning is included by default (include_reasoning=True)
371 +response = client.chat.completions.create(
372 + model=model,
373 + messages=[{"role": "user", "content": "What is 15 * 37?"}],
374 + extra_body={"include_reasoning": False},
375 +)
376 +
377 +msg = response.choices[0].message
378 +assert msg.content # Content is still present
379 +assert not getattr(msg, "reasoning", None) # Reasoning is suppressed
380 +```
381 +
382 +Streaming works the same way, reasoning deltas are omitted from chunks:
383 +
384 +```python
385 +stream = client.chat.completions.create(
386 + model=model,
387 + messages=[{"role": "user", "content": "What is 15 * 37?"}],
388 + stream=True,
389 + extra_body={"include_reasoning": False},
390 +)
391 +
392 +for chunk in stream:
393 + delta = chunk.choices[0].delta
394 + # delta.reasoning will always be None
395 + if delta.content:
396 + print(delta.content, end="", flush=True)
397 +```
398 +
399 +### Responses API
400 +
401 +```python
402 +from openai import OpenAI
403 +
404 +client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
405 +
406 +response = client.responses.create(
407 + model=client.models.list().data[0].id,
408 + input="What is 15 * 37?",
409 + include_reasoning=False,
410 +)
411 +
412 +# No "reasoning" items in output
413 +types = [item.type for item in response.output]
414 +assert "reasoning" not in types
415 +```
416 +
417 ## Limitations
418
419 - The reasoning content is only available for online serving's chat completion endpoint (`/v1/chat/completions`), Anthropic Messages API (`/v1/messages`) and the Responses API (`/v1/responses`).
tests/entrypoints/openai/chat_completion/test_include_reasoning.py new
+158
@@ -0,0 +1,158 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +"""E2E tests for ``include_reasoning`` with non-Harmony reasoning models.
5 +
6 +Verifies that reasoning content is included by default and suppressed
7 +when ``include_reasoning=False``, for both streaming and non-streaming
8 +Chat Completions.
9 +"""
10 +
11 +import openai
12 +import pytest
13 +import pytest_asyncio
14 +
15 +from tests.utils import RemoteOpenAIServer
16 +
17 +MODEL_NAME = "Qwen/Qwen3-0.6B"
18 +MESSAGES = [{"role": "user", "content": "What is 1+1? Be concise."}]
19 +
20 +
21 +@pytest.fixture(scope="module")
22 +def server():
23 + args = [
24 + "--reasoning-parser",
25 + "qwen3",
26 + "--max-model-len",
27 + "2048",
28 + "--enforce-eager",
29 + "--gpu-memory-utilization",
30 + "0.4",
31 + ]
32 + with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
33 + yield remote_server
34 +
35 +
36 +@pytest_asyncio.fixture
37 +async def client(server):
38 + async with server.get_async_client() as async_client:
39 + yield async_client
40 +
41 +
42 +@pytest.mark.asyncio
43 +async def test_include_reasoning_true_non_streaming(client: openai.AsyncOpenAI):
44 + """Default: reasoning content appears in non-streaming response."""
45 + response = await client.chat.completions.create(
46 + model=MODEL_NAME,
47 + messages=MESSAGES,
48 + max_tokens=200,
49 + extra_body={"include_reasoning": True},
50 + )
51 +
52 + msg = response.choices[0].message
53 + reasoning = getattr(msg, "reasoning", None) or getattr(
54 + msg, "reasoning_content", None
55 + )
56 + assert reasoning, "Expected reasoning content when include_reasoning=True"
57 + assert msg.content, "Expected content in response"
58 +
59 +
60 +@pytest.mark.asyncio
61 +async def test_include_reasoning_false_non_streaming(client: openai.AsyncOpenAI):
62 + """Reasoning content is suppressed when include_reasoning=False."""
63 + response = await client.chat.completions.create(
64 + model=MODEL_NAME,
65 + messages=MESSAGES,
66 + max_tokens=200,
67 + extra_body={"include_reasoning": False},
68 + )
69 +
70 + msg = response.choices[0].message
71 + reasoning = getattr(msg, "reasoning", None) or getattr(
72 + msg, "reasoning_content", None
73 + )
74 + assert not reasoning, (
75 + f"Expected no reasoning when include_reasoning=False, got: {reasoning}"
76 + )
77 + assert msg.content, "Expected content in response even without reasoning"
78 +
79 +
80 +@pytest.mark.asyncio
81 +async def test_include_reasoning_true_streaming(client: openai.AsyncOpenAI):
82 + """Default: reasoning deltas appear in streaming response."""
83 + stream = await client.chat.completions.create(
84 + model=MODEL_NAME,
85 + messages=MESSAGES,
86 + max_tokens=200,
87 + stream=True,
88 + extra_body={"include_reasoning": True},
89 + )
90 +
91 + reasoning_parts = []
92 + content_parts = []
93 + async for chunk in stream:
94 + delta = chunk.choices[0].delta if chunk.choices else None
95 + if delta:
96 + r = getattr(delta, "reasoning", None) or getattr(
97 + delta, "reasoning_content", None
98 + )
99 + if r:
100 + reasoning_parts.append(r)
101 + if delta.content:
102 + content_parts.append(delta.content)
103 +
104 + reasoning_text = "".join(reasoning_parts)
105 + content_text = "".join(content_parts)
106 +
107 + assert reasoning_text, "Expected reasoning deltas when include_reasoning=True"
108 + assert content_text, "Expected content deltas in streaming response"
109 +
110 +
111 +@pytest.mark.asyncio
112 +async def test_include_reasoning_false_streaming(client: openai.AsyncOpenAI):
113 + """Reasoning deltas are suppressed in streaming when include_reasoning=False."""
114 + stream = await client.chat.completions.create(
115 + model=MODEL_NAME,
116 + messages=MESSAGES,
117 + max_tokens=200,
118 + stream=True,
119 + extra_body={"include_reasoning": False},
120 + )
121 +
122 + reasoning_parts = []
123 + content_parts = []
124 + async for chunk in stream:
125 + delta = chunk.choices[0].delta if chunk.choices else None
126 + if delta:
127 + r = getattr(delta, "reasoning", None) or getattr(
128 + delta, "reasoning_content", None
129 + )
130 + if r:
131 + reasoning_parts.append(r)
132 + if delta.content:
133 + content_parts.append(delta.content)
134 +
135 + reasoning_text = "".join(reasoning_parts)
136 + content_text = "".join(content_parts)
137 +
138 + assert not reasoning_text, (
139 + f"Expected no reasoning deltas when include_reasoning=False, "
140 + f"got: {reasoning_text[:100]}"
141 + )
142 + assert content_text, "Expected content deltas even without reasoning"
143 +
144 +
145 +@pytest.mark.asyncio
146 +async def test_default_includes_reasoning(client: openai.AsyncOpenAI):
147 + """Without specifying include_reasoning, reasoning appears (default=True)."""
148 + response = await client.chat.completions.create(
149 + model=MODEL_NAME,
150 + messages=MESSAGES,
151 + max_tokens=200,
152 + )
153 +
154 + msg = response.choices[0].message
155 + reasoning = getattr(msg, "reasoning", None) or getattr(
156 + msg, "reasoning_content", None
157 + )
158 + assert reasoning, "Expected reasoning content by default"
tests/parser/engine/conftest.py
+1
@@ -49,4 +49,5 @@ def mock_request():
49 req = MagicMock(spec=ChatCompletionRequest)
50 req.tools = []
51 req.tool_choice = "auto"
52 + req.include_reasoning = True
53 return req
tests/parser/engine/test_nemotron_v3.py
+1
@@ -43,6 +43,7 @@ def _make_request(**chat_template_kwargs):
43 request = MagicMock(spec=ChatCompletionRequest)
44 request.tools = []
45 request.tool_choice = "auto"
46 + request.include_reasoning = True
47 request.chat_template_kwargs = chat_template_kwargs or None
48 return request
49
tests/parser/engine/test_parser_engine.py
+1
@@ -896,6 +896,7 @@ def _make_delegating_request():
896 req = MagicMock(spec=ChatCompletionRequest)
897 req.tools = []
898 req.tool_choice = "auto"
899 + req.include_reasoning = True
900 return req
901
902
tests/parser/test_include_reasoning.py new
+456
@@ -0,0 +1,456 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for include_reasoning suppression in the unified Parser interface.
4 +
5 +Covers non-streaming (parser.parse() + build_response_output_items),
6 +streaming (parse_delta), and ParsableContext.append_output() paths.
7 +"""
8 +
9 +import json
10 +import os
11 +
12 +import pytest
13 +
14 +_STRICT_TOOL_CALLING_ENV = "VLLM_ENFORCE_STRICT_TOOL_CALLING"
15 +_STRICT_TOOL_CALLING_ENV_VALUE = os.environ.get(_STRICT_TOOL_CALLING_ENV)
16 +os.environ[_STRICT_TOOL_CALLING_ENV] = "0"
17 +
18 +from vllm.entrypoints.openai.chat_completion.protocol import ( # noqa: E402
19 + ChatCompletionRequest,
20 +)
21 +from vllm.entrypoints.openai.engine.protocol import DeltaMessage # noqa: E402
22 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest # noqa: E402
23 +from vllm.parser.abstract_parser import DelegatingParser # noqa: E402
24 +from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser # noqa: E402
25 +from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser # noqa: E402
26 +
27 +
28 +@pytest.fixture(scope="module", autouse=True)
29 +def restore_strict_tool_calling_env():
30 + yield
31 + if _STRICT_TOOL_CALLING_ENV_VALUE is None:
32 + os.environ.pop(_STRICT_TOOL_CALLING_ENV, None)
33 + else:
34 + os.environ[_STRICT_TOOL_CALLING_ENV] = _STRICT_TOOL_CALLING_ENV_VALUE
35 +
36 +
37 +class ThinkReasoningParser(BaseThinkingReasoningParser):
38 + @property
39 + def start_token(self) -> str:
40 + return "<think>"
41 +
42 + @property
43 + def end_token(self) -> str:
44 + return "</think>"
45 +
46 +
47 +MODEL_OUTPUT_REASONING_AND_CONTENT = (
48 + "<think>let me think about this</think>The answer is 42."
49 +)
50 +
51 +MODEL_OUTPUT_REASONING_AND_TOOL = (
52 + "<think>I need to call a tool</think>"
53 + '<tool_call>\n{"name": "get_weather", '
54 + '"arguments": {"city": "Dallas"}}\n</tool_call>'
55 +)
56 +
57 +MODEL_OUTPUT_CONTENT_ONLY = "The answer is 42."
58 +
59 +
60 +@pytest.fixture(scope="module")
61 +def tokenizer():
62 + from vllm.tokenizers import get_tokenizer
63 +
64 + return get_tokenizer("Qwen/Qwen3-32B")
65 +
66 +
67 +def make_responses_request(**kwargs) -> ResponsesRequest:
68 + defaults = dict(model="test-model", input="test input")
69 + defaults.update(kwargs)
70 + return ResponsesRequest(**defaults)
71 +
72 +
73 +def make_chat_request(**kwargs) -> ChatCompletionRequest:
74 + defaults = dict(
75 + model="test-model",
76 + messages=[{"role": "user", "content": "hi"}],
77 + )
78 + defaults.update(kwargs)
79 + return ChatCompletionRequest(**defaults)
80 +
81 +
82 +def make_parser(tokenizer, reasoning=False, tool=False):
83 + class TestParser(DelegatingParser):
84 + reasoning_parser_cls = ThinkReasoningParser if reasoning else None
85 + tool_parser_cls = Hermes2ProToolParser if tool else None
86 +
87 + return TestParser(tokenizer)
88 +
89 +
90 +# ── Non-streaming: parser.parse() + build_response_output_items ──────
91 +
92 +
93 +def parse_and_build(parser, request, model_output, enable_auto_tools=False):
94 + """Mirrors the non-streaming path in _make_response_output_items /
95 + ParsableContext.append_output(): parse → suppress reasoning → build items.
96 + """
97 + from vllm.entrypoints.openai.responses.utils import (
98 + build_response_output_items,
99 + )
100 +
101 + reasoning, content, tool_calls = parser.parse(
102 + model_output, request, enable_auto_tools=enable_auto_tools
103 + )
104 + if not request.include_reasoning:
105 + reasoning = None
106 + return build_response_output_items(
107 + reasoning=reasoning,
108 + content=content,
109 + tool_calls=tool_calls,
110 + )
111 +
112 +
113 +class TestNonStreamingIncludeReasoning:
114 + def test_include_reasoning_true_has_reasoning_item(self, tokenizer):
115 + """Default: reasoning items appear in output."""
116 + parser = make_parser(tokenizer, reasoning=True)
117 + request = make_responses_request(include_reasoning=True)
118 +
119 + outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
120 +
121 + types = [o.type for o in outputs]
122 + assert "reasoning" in types
123 + assert "message" in types
124 +
125 + def test_include_reasoning_false_no_reasoning_item(self, tokenizer):
126 + """Reasoning item is suppressed when include_reasoning=False."""
127 + parser = make_parser(tokenizer, reasoning=True)
128 + request = make_responses_request(include_reasoning=False)
129 +
130 + outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
131 +
132 + types = [o.type for o in outputs]
133 + assert "reasoning" not in types
134 + assert "message" in types
135 + assert outputs[0].content[0].text == "The answer is 42."
136 +
137 + def test_include_reasoning_false_content_preserved(self, tokenizer):
138 + """Content is extracted correctly even when reasoning is suppressed."""
139 + parser = make_parser(tokenizer, reasoning=True)
140 + request = make_responses_request(include_reasoning=False)
141 +
142 + outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
143 +
144 + message = next(o for o in outputs if o.type == "message")
145 + assert message.content[0].text == "The answer is 42."
146 +
147 + def test_include_reasoning_false_tool_calls_preserved(self, tokenizer):
148 + """Tool calls still work when reasoning is suppressed."""
149 + parser = make_parser(tokenizer, reasoning=True, tool=True)
150 + request = make_responses_request(
151 + include_reasoning=False,
152 + tools=[
153 + {
154 + "type": "function",
155 + "name": "get_weather",
156 + "parameters": {
157 + "type": "object",
158 + "properties": {"city": {"type": "string"}},
159 + },
160 + }
161 + ],
162 + )
163 +
164 + outputs = parse_and_build(
165 + parser,
166 + request,
167 + MODEL_OUTPUT_REASONING_AND_TOOL,
168 + enable_auto_tools=True,
169 + )
170 +
171 + types = [o.type for o in outputs]
172 + assert "reasoning" not in types
173 + assert "function_call" in types
174 + fc = next(o for o in outputs if o.type == "function_call")
175 + assert fc.name == "get_weather"
176 + assert json.loads(fc.arguments) == {"city": "Dallas"}
177 +
178 + def test_no_reasoning_parser_include_false_is_noop(self, tokenizer):
179 + """include_reasoning=False is harmless when no reasoning parser."""
180 + parser = make_parser(tokenizer, reasoning=False)
181 + request = make_responses_request(include_reasoning=False)
182 +
183 + outputs = parse_and_build(parser, request, MODEL_OUTPUT_CONTENT_ONLY)
184 +
185 + assert len(outputs) == 1
186 + assert outputs[0].type == "message"
187 + assert outputs[0].content[0].text == MODEL_OUTPUT_CONTENT_ONLY
188 +
189 + def test_default_include_reasoning_is_true(self, tokenizer):
190 + """ResponsesRequest defaults to include_reasoning=True."""
191 + request = make_responses_request()
192 + assert request.include_reasoning is True
193 +
194 + def test_include_reasoning_false_suppresses_all_reasoning(self, tokenizer):
195 + """Reasoning is suppressed regardless of request type."""
196 + parser = make_parser(tokenizer, reasoning=True)
197 + request = make_responses_request(include_reasoning=False)
198 +
199 + outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
200 +
201 + assert all(o.type != "reasoning" for o in outputs)
202 +
203 +
204 +# ── Streaming: parse_delta ───────────────────────────────────────────
205 +
206 +
207 +def stream_text(parser, tokenizer, text, request, prompt_token_ids=None):
208 + token_ids = tokenizer.encode(text, add_special_tokens=False)
209 + results: list[DeltaMessage | None] = []
210 + for i, tid in enumerate(token_ids):
211 + delta_text = tokenizer.decode([tid])
212 + is_last = i == len(token_ids) - 1
213 + result = parser.parse_delta(
214 + delta_text,
215 + [tid],
216 + request,
217 + prompt_token_ids=prompt_token_ids,
218 + finished=is_last,
219 + )
220 + prompt_token_ids = None
221 + results.append(result)
222 + return results
223 +
224 +
225 +def collect_fields(results):
226 + all_reasoning = "".join(r.reasoning for r in results if r and r.reasoning)
227 + all_content = "".join(r.content for r in results if r and r.content)
228 + all_tool_calls = [tc for r in results if r and r.tool_calls for tc in r.tool_calls]
229 + return all_reasoning, all_content, all_tool_calls
230 +
231 +
232 +class TestParseDeltaIncludeReasoning:
233 + def test_streaming_include_true_emits_reasoning(self, tokenizer):
234 + """With include_reasoning=True, reasoning deltas are emitted."""
235 + parser = make_parser(tokenizer, reasoning=True)
236 + request = make_responses_request(include_reasoning=True)
237 +
238 + results = stream_text(
239 + parser,
240 + tokenizer,
241 + MODEL_OUTPUT_REASONING_AND_CONTENT,
242 + request,
243 + prompt_token_ids=[],
244 + )
245 + reasoning, content, _ = collect_fields(results)
246 +
247 + assert "let me think about this" in reasoning
248 + assert "42" in content
249 +
250 + def test_streaming_include_false_suppresses_reasoning(self, tokenizer):
251 + """With include_reasoning=False, no reasoning deltas are emitted."""
252 + parser = make_parser(tokenizer, reasoning=True)
253 + request = make_responses_request(include_reasoning=False)
254 +
255 + results = stream_text(
256 + parser,
257 + tokenizer,
258 + MODEL_OUTPUT_REASONING_AND_CONTENT,
259 + request,
260 + prompt_token_ids=[],
261 + )
262 + reasoning, content, _ = collect_fields(results)
263 +
264 + assert reasoning == ""
265 + assert "42" in content
266 +
267 + def test_streaming_include_false_content_still_works(self, tokenizer):
268 + """Content is correctly extracted in streaming even with suppression."""
269 + parser = make_parser(tokenizer, reasoning=True)
270 + request = make_responses_request(include_reasoning=False)
271 +
272 + results = stream_text(
273 + parser,
274 + tokenizer,
275 + MODEL_OUTPUT_REASONING_AND_CONTENT,
276 + request,
277 + prompt_token_ids=[],
278 + )
279 + _, content, _ = collect_fields(results)
280 +
281 + assert "The answer is 42" in content
282 +
283 + def test_streaming_include_false_tool_calls_preserved(self, tokenizer):
284 + """Tool calls stream correctly when reasoning is suppressed."""
285 + parser = make_parser(tokenizer, reasoning=True, tool=True)
286 + request = make_responses_request(
287 + include_reasoning=False,
288 + tools=[
289 + {
290 + "type": "function",
291 + "name": "get_weather",
292 + "parameters": {
293 + "type": "object",
294 + "properties": {"city": {"type": "string"}},
295 + },
296 + }
297 + ],
298 + )
299 +
300 + results = stream_text(
301 + parser,
302 + tokenizer,
303 + MODEL_OUTPUT_REASONING_AND_TOOL,
304 + request,
305 + prompt_token_ids=[],
306 + )
307 + reasoning, content, tool_calls = collect_fields(results)
308 +
309 + assert reasoning == ""
310 + assert len(tool_calls) > 0
311 + assert tool_calls[0].function.name == "get_weather"
312 + tool_args = "".join(
313 + tc.function.arguments for tc in tool_calls if tc.function.arguments
314 + )
315 + assert json.loads(tool_args) == {"city": "Dallas"}
316 +
317 + def test_streaming_no_reasoning_parser_include_false(self, tokenizer):
318 + """No crash when reasoning parser absent and include_reasoning=False."""
319 + parser = make_parser(tokenizer, reasoning=False)
320 + request = make_responses_request(include_reasoning=False)
321 +
322 + results = stream_text(
323 + parser,
324 + tokenizer,
325 + MODEL_OUTPUT_CONTENT_ONLY,
326 + request,
327 + prompt_token_ids=[],
328 + )
329 + reasoning, content, _ = collect_fields(results)
330 +
331 + assert reasoning == ""
332 + assert "42" in content
333 +
334 + def test_streaming_chat_completion_include_false(self, tokenizer):
335 + """parse_delta also respects ChatCompletionRequest.include_reasoning."""
336 + parser = make_parser(tokenizer, reasoning=True)
337 + request = make_chat_request(include_reasoning=False)
338 +
339 + results = stream_text(
340 + parser,
341 + tokenizer,
342 + MODEL_OUTPUT_REASONING_AND_CONTENT,
343 + request,
344 + prompt_token_ids=[],
345 + )
346 + reasoning, content, _ = collect_fields(results)
347 +
348 + assert reasoning == ""
349 + assert "42" in content
350 +
351 + def test_streaming_reasoning_only_deltas_become_none(self, tokenizer):
352 + """Deltas that carry only reasoning become None (not empty)."""
353 + parser = make_parser(tokenizer, reasoning=True)
354 + request = make_responses_request(include_reasoning=False)
355 +
356 + results = stream_text(
357 + parser,
358 + tokenizer,
359 + MODEL_OUTPUT_REASONING_AND_CONTENT,
360 + request,
361 + prompt_token_ids=[],
362 + )
363 +
364 + for r in results:
365 + if r is not None:
366 + assert r.reasoning is None
367 +
368 +
369 +# ── ParsableContext.append_output() ───────────────────────────────────
370 +
371 +
372 +class TestParsableContextIncludeReasoning:
373 + def _make_context(self, tokenizer, request):
374 + from vllm.entrypoints.openai.responses.context import ParsableContext
375 +
376 + class TestParser(DelegatingParser):
377 + reasoning_parser_cls = ThinkReasoningParser
378 + tool_parser_cls = None
379 +
380 + return ParsableContext(
381 + tokenizer=tokenizer,
382 + parser_cls=TestParser,
383 + response_messages=[],
384 + request=request,
385 + available_tools=None,
386 + chat_template=None,
387 + chat_template_content_format="auto",
388 + )
389 +
390 + def test_process_include_false_suppresses_reasoning(self, tokenizer):
391 + """ParsableContext.process() suppresses reasoning items."""
392 + from vllm.outputs import CompletionOutput, RequestOutput
393 +
394 + request = make_responses_request(include_reasoning=False)
395 + ctx = self._make_context(tokenizer, request)
396 +
397 + output = RequestOutput(
398 + request_id="test",
399 + prompt=None,
400 + prompt_token_ids=[],
401 + prompt_logprobs=None,
402 + outputs=[
403 + CompletionOutput(
404 + index=0,
405 + text=MODEL_OUTPUT_REASONING_AND_CONTENT,
406 + token_ids=tokenizer.encode(
407 + MODEL_OUTPUT_REASONING_AND_CONTENT,
408 + add_special_tokens=False,
409 + ),
410 + cumulative_logprob=None,
411 + logprobs=None,
412 + finish_reason="stop",
413 + )
414 + ],
415 + finished=True,
416 + )
417 +
418 + ctx.append_output(output)
419 +
420 + types = [getattr(m, "type", None) for m in ctx.response_messages]
421 + assert "reasoning" not in types
422 + assert "message" in types
423 +
424 + def test_process_include_true_has_reasoning(self, tokenizer):
425 + """ParsableContext.process() includes reasoning by default."""
426 + from vllm.outputs import CompletionOutput, RequestOutput
427 +
428 + request = make_responses_request(include_reasoning=True)
429 + ctx = self._make_context(tokenizer, request)
430 +
431 + output = RequestOutput(
432 + request_id="test",
433 + prompt=None,
434 + prompt_token_ids=[],
435 + prompt_logprobs=None,
436 + outputs=[
437 + CompletionOutput(
438 + index=0,
439 + text=MODEL_OUTPUT_REASONING_AND_CONTENT,
440 + token_ids=tokenizer.encode(
441 + MODEL_OUTPUT_REASONING_AND_CONTENT,
442 + add_special_tokens=False,
443 + ),
444 + cumulative_logprob=None,
445 + logprobs=None,
446 + finish_reason="stop",
447 + )
448 + ],
449 + finished=True,
450 + )
451 +
452 + ctx.append_output(output)
453 +
454 + types = [getattr(m, "type", None) for m in ctx.response_messages]
455 + assert "reasoning" in types
456 + assert "message" in types
vllm/entrypoints/openai/chat_completion/serving.py
+27 -23
@@ -600,19 +600,8 @@ class OpenAIServingChat(GenerateBaseServing):
600 prompt_token_ids=res.prompt_token_ids,
601 finished=output.finish_reason is not None,
602 )
603 - if delta_message is not None:
604 - if delta_message.tool_calls:
605 - tools_streamed[i] = True
606 -
607 - if (
608 - delta_message.reasoning
609 - and not request.include_reasoning
610 - ):
611 - delta_message.reasoning = None
612 - if not (
613 - delta_message.content or delta_message.tool_calls
614 - ):
615 - delta_message = None
603 + if delta_message is not None and delta_message.tool_calls:
604 + tools_streamed[i] = True
605
606 # handle streaming just a content delta (no parsers)
607 else:
@@ -627,13 +616,22 @@ class OpenAIServingChat(GenerateBaseServing):
616 # "control token" for tool calls or the parser otherwise
617 # wasn't ready to send a token, then
618 # get the next token without streaming a chunk
619 + # When reasoning is hidden, suppress per-token
620 + # metadata (logprobs, token_ids) on every chunk to
621 + # prevent leaking reasoning tokens through decoded
622 + # token text in logprob entries or raw token IDs.
623 + hide_stream_metadata = (
624 + not request.include_reasoning and parser is not None
625 + )
626 + if hide_stream_metadata:
627 + logprobs = None
628 +
629 if delta_message is None:
630 # NOTE: If return_token_ids is enabled, we still need to
631 # send a chunk with token_ids even if delta_message is None
632 # to ensure all tokens are included in the response
634 - if (
635 - output.finish_reason is None
636 - and not request.return_token_ids
633 + if output.finish_reason is None and (
634 + not request.return_token_ids or hide_stream_metadata
635 ):
636 continue
637 delta_message = DeltaMessage()
@@ -666,6 +664,10 @@ class OpenAIServingChat(GenerateBaseServing):
664 delta=True,
665 )
666
667 + include_token_ids = (
668 + request.return_token_ids and not hide_stream_metadata
669 + )
670 +
671 if output.finish_reason is None:
672 # Send token-by-token response for each request.n
673 choice_data = ChatCompletionResponseStreamChoice(
@@ -674,9 +676,7 @@ class OpenAIServingChat(GenerateBaseServing):
676 logprobs=logprobs,
677 finish_reason=None,
678 token_ids=(
677 - as_list(output.token_ids)
678 - if request.return_token_ids
679 - else None
679 + as_list(output.token_ids) if include_token_ids else None
680 ),
681 )
682
@@ -704,9 +704,7 @@ class OpenAIServingChat(GenerateBaseServing):
704 finish_reason=finish_reason_,
705 stop_reason=output.stop_reason,
706 token_ids=(
707 - as_list(output.token_ids)
708 - if request.return_token_ids
709 - else None
707 + as_list(output.token_ids) if include_token_ids else None
708 ),
709 )
710
@@ -883,12 +881,16 @@ class OpenAIServingChat(GenerateBaseServing):
881 enable_auto_tools=self.enable_auto_tools,
882 model_output_token_ids=token_ids,
883 )
884 + suppress_metadata = not request.include_reasoning and parser is not None
885 if not request.include_reasoning:
886 reasoning = None
887 + if suppress_metadata:
888 + logprobs = None
889 else:
890 reasoning = None
891 content = output.text
892 tool_calls = []
893 + suppress_metadata = False
894
895 auto_tools_called = False
896 is_named_tool_choice = (
@@ -982,7 +984,9 @@ class OpenAIServingChat(GenerateBaseServing):
984 else "stop",
985 stop_reason=output.stop_reason,
986 token_ids=(
985 - as_list(output.token_ids) if request.return_token_ids else None
987 + as_list(output.token_ids)
988 + if request.return_token_ids and not suppress_metadata
989 + else None
990 ),
991 routed_experts=routed_experts_b64,
992 )
vllm/entrypoints/openai/responses/context.py
+5 -1
@@ -310,7 +310,9 @@ class ParsableContext(ConversationContext):
310 self.finish_reason: str | None = None
311 self.enable_auto_tools = enable_auto_tools
312
313 - self.response_parser = response_parser
313 + self.response_parser = response_parser or (
314 + parser_cls(tokenizer, request.tools) if parser_cls is not None else None
315 + )
316 self.parser_cls = parser_cls
317 self.request = request
318
@@ -344,6 +346,8 @@ class ParsableContext(ConversationContext):
346 enable_auto_tools=self.enable_auto_tools,
347 model_output_token_ids=completion.token_ids,
348 )
349 + if not self.request.include_reasoning:
350 + reasoning = None
351 self.response_messages.extend(
352 build_response_output_items(
353 reasoning=reasoning,
vllm/entrypoints/openai/responses/protocol.py
+9
@@ -161,6 +161,15 @@ class ResponsesRequest(OpenAIBaseModel):
161 previous_response_id: str | None = None
162 prompt: ResponsePrompt | None = None
163 reasoning: Reasoning | None = None
164 + include_reasoning: bool = Field(
165 + default=True,
166 + description=(
167 + "Whether to include reasoning content in the response. "
168 + "When false, reasoning tokens are still generated but "
169 + "excluded from the output. This reduces network traffic "
170 + "without affecting model inference."
171 + ),
172 + )
173 service_tier: Literal["auto", "default", "flex", "scale", "priority"] = "auto"
174 store: bool | None = True
175 stream: bool | None = False
vllm/entrypoints/openai/responses/serving.py
+7
@@ -1068,6 +1068,9 @@ class OpenAIServingResponses(GenerateBaseServing):
1068 enable_auto_tools=self.enable_auto_tools,
1069 model_output_token_ids=final_output.token_ids,
1070 )
1071 + if not request.include_reasoning:
1072 + reasoning = None
1073 + logprobs = None
1074 return build_response_output_items(
1075 reasoning=reasoning,
1076 content=content,
@@ -1342,11 +1345,15 @@ class OpenAIServingResponses(GenerateBaseServing):
1345 ) -> AsyncGenerator[StreamingResponsesResponse, None]:
1346 processor = SimpleStreamingEventProcessor(tools=request.tools)
1347
1348 + hide_stream_metadata = not request.include_reasoning and self.parser is not None
1349 +
1350 def _get_logprobs(
1351 output: CompletionOutput,
1352 ) -> list[response_text_delta_event.Logprob]:
1353 if not request.is_include_output_logprobs():
1354 return []
1355 + if hide_stream_metadata:
1356 + return []
1357 return self._create_stream_response_logprobs(
1358 token_ids=output.token_ids,
1359 logprobs=output.logprobs,
vllm/parser/abstract_parser.py
+18 -1
@@ -447,7 +447,7 @@ class DelegatingParser(Parser):
447
448 tool_calls = list[FunctionCall]()
449 if is_named_tool_choice and supports_required_and_named:
450 - if content is None:
450 + if content is None or (isinstance(content, str) and not content.strip()):
451 return [], None
452 function_name = self._get_function_name(request)
453 tool_calls.append(
@@ -496,6 +496,14 @@ class DelegatingParser(Parser):
496 content = None
497 else:
498 # No tool calls.
499 + # For required/named tool choice (when falling back to auto
500 + # parsing), if content is empty or whitespace-only, return
501 + # empty list with None content.
502 + if (is_required_tool_choice or is_named_tool_choice) and (
503 + content is None
504 + or (isinstance(content, str) and not content.strip())
505 + ):
506 + return [], None
507 return None, content
508
509 return tool_calls, content
@@ -917,6 +925,15 @@ class DelegatingParser(Parser):
925 delta_message = self.finalize_generation(delta_message, request, state)
926 delta_message = self._flush_engine_parsers(delta_message)
927
928 + # Suppress reasoning deltas if not requested
929 + if delta_message and not request.include_reasoning:
930 + delta_message.reasoning = None
931 +
932 + # If only reasoning was in the message (no content, no tool_calls)
933 + # skip emitting entirely
934 + if not delta_message.content and not delta_message.tool_calls:
935 + delta_message = None
936 +
937 return delta_message
938
939 def _flush_engine_parsers(
vllm/parser/engine/parser_engine.py
+9 -1
@@ -443,7 +443,15 @@ class ParserEngine(Parser):
443 if finished:
444 events.extend(self._engine.finish())
445 result = self._events_to_delta(events, finished=finished)
446 - return self._strip_trailing_reasoning(result)
446 + result = self._strip_trailing_reasoning(result)
447 +
448 + # Suppress reasoning deltas if not requested
449 + if result and not request.include_reasoning:
450 + result.reasoning = None
451 + if not result.content and not result.tool_calls:
452 + result = None
453 +
454 + return result
455
456 def _strip_trailing_reasoning(
457 self,
vllm/parser/harmony.py
+10
@@ -293,6 +293,16 @@ class HarmonyParser(DelegatingParser):
293 delta_message.reasoning = combined_reasoning
294 if tool_messages:
295 delta_message.tool_calls = tool_messages
296 +
297 + # Suppress reasoning deltas if not requested
298 + if delta_message and not request.include_reasoning:
299 + delta_message.reasoning = None
300 +
301 + # If only reasoning was in the message (no content, no tool_calls)
302 + # skip emitting entirely
303 + if not delta_message.content and not delta_message.tool_calls:
304 + return None
305 +
306 return delta_message
307
308 def process_chunk(self, token_ids: Sequence[int]) -> ChunkResult: