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