Tool and reasoning parsers: from model text to tool_calls and reasoning
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
Parsers split the model's raw text into reasoning, content and tool calls; tool_choice "required" or a named tool is also enforced with a grammar.
The idea#
A model writes everything as text. A reasoning model writes its thinking first, for example between <think> and </think>. A model with tools writes a tool call in its own format, for example <tool_call>{"name": ..., "arguments": ...}</tool_call>. The OpenAI API wants three separate fields: reasoning (reasoning output), content and tool_calls (The OpenAI-compatible API).
Two kinds of classes do the split in the API server. A reasoning parser finds where the thinking ends. A tool parser finds tool calls in the rest. Each family of models has its own format, so vLLM has many of each, chosen by name with --reasoning-parser and --tool-call-parser. One Parser object per choice runs both, first the reasoning parser and then the tool parser.
Parsing only reads what the model wrote. To force a tool call (tool_choice "required" or a named function), vLLM also constrains the generation with a grammar (Structured outputs: JSON schemas, grammars and token bitmasks in vLLM). The engine core needs to know when the thinking ends, because the grammar applies only after it. So the reasoning parser runs in the engine core process too.
An everyday comparison. Think of a secretary who sorts a dictated message into three trays: private notes, the letter, and order forms. The secretary knows each writer's habits, for example that one writer always starts notes with "NB". The comparison stops being true in two places. The secretary cannot fix an order form that the writer filled in wrongly; a malformed tool call stays text. And for "required", vLLM does not only sort: it stops the writer from writing anything other than an order form.
The picture#
Read panel 2 from top to bottom; it is the worked example of the first deep dive. Rows that say "None" send no SSE chunk at all. The text of the markers reaches the parsers because the Hermes parser turns off skip_special_tokens for requests with tools (vllm/tool_parsers/hermes_tool_parser.py:59-68).
In the code#
Step 1: the flags#
--enable-auto-tool-choiceplus--tool-call-parser NAMEturn on tool parsing. The first without the second stops the start-up withTypeError(vllm/entrypoints/common/cli_args.py:523-525). An unknown parser name with auto tool choice raisesKeyErrorat start-up (vllm/entrypoints/common/cli_args.py:586-594).--reasoning-parser NAMEturns on reasoning parsing;nonemeans unset (vllm/engine/arg_utils.py:188-190). The flag is a field ofStructuredOutputsConfig, because the engine core needs it too (vllm/config/structured_outputs.py:34-39,vllm/engine/arg_utils.py:2187-2204). For gpt-oss models, an empty value becomesopenai_gptoss(vllm/model_executor/models/config.py:543-546).--tool-parser-pluginand--reasoning-parser-pluginimport your own parser module (Plugins in the API server).--tool-strict-level auto|function|parametersets how strictly tool calls are constrained (Step 4).
Step 2: the registries#
ToolParserManager registers 58 built-in names lazily: module path and class name, imported on first use (vllm/tool_parsers/__init__.py:24-270). Examples are hermes, llama3_json, mistral, pythonic, qwen3_coder, deepseek_v3 and openai. ReasoningParserManager registers 42 names the same way, for example deepseek_r1, qwen3, granite, mistral and openai_gptoss (vllm/reasoning/__init__.py:22-198). (Counts of the dict entries at this commit.) The lookup, the lazy import and the plugin flags are on Plugins in the API server.
Step 3: one Parser class for both#
ParserManager.get_parser composes the two named classes into one Parser class. OpenAIServingChat calls it once at start-up (vllm/entrypoints/openai/chat_completion/serving.py:167-175), and so does OnlineRenderer (vllm/renderers/online_renderer.py:180-188). The tool half has a condition:
@classmethod
def get_tool_parser(
cls,
tool_parser_name: str | None = None,
enable_auto_tools: bool = False,
model_name: str | None = None,
) -> type[ToolParser] | None:
"""Get the tool parser based on the name."""
from vllm.tool_parsers import ToolParserManager
parser: type[ToolParser] | None = None
if not enable_auto_tools or tool_parser_name is None:
return parser
logger.info_once('"auto" tool choice has been enabled.')
try:
if (
tool_parser_name == "pythonic"
and model_name
and model_name.startswith("meta-llama/Llama-3.2")
):
logger.warning(
"Llama3.2 models may struggle to emit valid pythonic tool calls"
)
parser = ToolParserManager.get_tool_parser(tool_parser_name)
except Exception as e:
raise TypeError(
"Error: --enable-auto-tool-choice requires "
f"tool_parser:'{tool_parser_name}' which has not "
"been registered"
) from e
return parser_compose_parser then picks the class (vllm/parser/parser_manager.py:151-274):
- gpt-oss (Harmony format): always
HarmonyParser(lines 179-185). - Parsers that read a response template from the tokenizer: one template class. A reasoning parser and a tool parser of two different template kinds cannot be combined (lines 187-220).
- Three families with their own composite class:
kimi_k3,cohere_command3/4andminicpmv(lines 222-262). - Everything else: a subclass of
DelegatingParserthat holds the reasoning class and the tool class (lines 264-274).
At start-up, validate_parser_tokenizer builds one instance with the model's tokenizer. A parser that cannot find its marker tokens stops the server with TypeError (vllm/parser/parser_manager.py:74-95). For example, a <think>-based reasoning parser raises when the vocabulary has no such token (vllm/reasoning/basic_parsers.py:61-67).
Step 4: the tool_choice modes and how each is enforced#
The request validators set and check tool_choice before anything runs: tools without tool_choice means "auto", and a named function must be one of tools (vllm/entrypoints/openai/chat_completion/protocol.py:975-1042). After rendering, the parser's adjust_request may add a constraint (vllm/renderers/online_renderer.py:824-859, vllm/parser/abstract_parser.py:493-501):
| tool_choice | Prompt | Generation | Parsing of the output |
|---|---|---|---|
"none" | tools are in the prompt, unless --exclude-tools-when-tool-choice-none | free | no tool calls; the text stays content (vllm/parser/abstract_parser.py:401-405) |
"auto" | tools in the prompt | free, unless a tool has strict: true or the strict level is function/parameter (vllm/tool_parsers/structural_tag_registry.py:175-197) | the tool parser decides; no call → content |
"required" | tools in the prompt | constrained: a structural tag of the model's format, or a JSON array of calls | parsed from the constrained text |
| named function | tools in the prompt | constrained to that function's call or its argument schema | one call with that name; finish_reason stays "stop" (vllm/entrypoints/serve/utils/tool_calls_utils.py:59-65) |
There are two ways to build the constraint for "required" and named calls:
- Structural tag. Some tool parsers set
structural_tag_model, for examplehermes,llama3_json,qwen3_coder,kimi_k2anddeepseek_v3. They get a grammar of their own call syntax from xgrammar's templates (vllm/tool_parsers/abstract_tool_parser.py:180-199)._apply_structural_tagwrites it intorequest.structured_outputsand clearsresponse_format(vllm/parser/abstract_parser.py:503-608). This needsVLLM_ENFORCE_STRICT_TOOL_CALLING, which is on by default (vllm/envs.py:1845-1847). - JSON schema. Other parsers get a JSON schema: an array of
{name, parameters}objects for"required", or the function's parameter schema for a named call (vllm/tool_parsers/utils.py:449-490,vllm/tool_parsers/abstract_tool_parser.py:122-178). The output is then plain JSON, and the serving layer reads it without the model's own markers (vllm/parser/abstract_parser.py:423-451).
With "parallel_tool_calls": false, the grammar stops after the first call, and the serving layer drops any call with an index above 0 (vllm/parser/abstract_parser.py:531-535, vllm/entrypoints/serve/utils/tool_calls_utils.py:24-44).
Step 5: a full response#
Without a stream, the full generator calls parser.parse once per choice, on the whole output text (vllm/entrypoints/openai/chat_completion/serving.py:1007-1019). DelegatingParser.parse splits the reasoning first and looks for tool calls in the rest:
def parse(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
enable_auto_tools: bool = False,
model_output_token_ids: Sequence[int] = (),
) -> tuple[str | None, str | None, list[FunctionCall] | None]:
self._initialize_history_tool_call_cnt(request)
reasoning, content = self.extract_reasoning(model_output, request)
tool_calls, content = self._extract_tool_calls(
content=content,
request=request,
enable_auto_tools=enable_auto_tools,
)
return reasoning, content, tool_callsThe serving class then builds the message with reasoning, content and tool_calls. Each call gets an id, chatcmpl-tool-<uuid> by default (vllm/renderers/chat_utils/tool_call.py:27-32). finish_reason becomes "tool_calls" when the message has calls and the engine stopped normally (vllm/entrypoints/openai/chat_completion/serving.py:1110-1117). A call cut by max_tokens keeps finish_reason: "length", so the client can see that it is incomplete (vllm/entrypoints/serve/utils/tool_calls_utils.py:52-58).
Step 6: streaming deltas#
In a stream, each choice has a ChatStreamProcessor with its own parser (vllm/entrypoints/openai/chat_completion/serving.py:540-555). For each engine output it calls parse_delta (vllm/entrypoints/openai/chat_completion/stream_processor.py:63-72). DelegatingParser.parse_delta works in three parts:
if not state.prompt_reasoning_checked and prompt_token_ids is not None:
state.prompt_reasoning_checked = True
self.set_prompt_token_ids(prompt_token_ids)
if self._reasoning_parser is None or self.is_reasoning_end(
prompt_token_ids
):
state.reasoning_ended = True
else:
# Reasoning is still open at the end of the prompt; let the
# reasoning parser adjust its initial parsing state so the
# first generated tokens are classified correctly.
self._reasoning_parser.adjust_initial_state_from_prompt(
prompt_token_ids
)
current_text, current_token_ids = state.advance(delta_text, delta_token_ids)
delta_message: DeltaMessage | None = None
reasoning_transitioned = False
# Reasoning extraction
if self._in_reasoning_phase(state):
delta_message = self.extract_reasoning_streaming(
previous_text=state.previous_text,
current_text=current_text,
delta_text=delta_text,
previous_token_ids=state.previous_token_ids,
current_token_ids=current_token_ids,
delta_token_ids=delta_token_ids,
)
reasoning_parser = self._reasoning_parser
if reasoning_parser is not None and reasoning_parser.engine_based_streaming:
should_transition = (
reasoning_parser.has_engine_confirmed_reasoning_end()
)
else:
should_transition = self._is_reasoning_end_streaming(
current_token_ids, delta_token_ids
)
if should_transition:
state.reasoning_ended = True
reasoning_transitioned = True
current_token_ids = self._extract_content_ids(delta_token_ids)- Lines 869-882: on the first call, check the prompt. If the prompt already ended the thinking, skip the reasoning phase. For example, the Hunyuan example template writes an empty
<think>\n\n</think>whenenable_thinkingis false (examples/tool_chat_template_hunyuan_a13b.jinja:110-111). - Lines 889-897: in the reasoning phase, the reasoning parser turns the delta into
reasoningtext. - Lines 904-909: the phase ends when the end-of-thinking token id is in the delta; the text after it goes on to the tool phase.
In the tool phase, the tool parser gets the text since the end of the thinking (vllm/parser/abstract_parser.py:938-964). A tool parser's extract_tool_calls_streaming returns None when it has nothing new. Then the serving layer sends no chunk, unless logprobs or token ids must go out (vllm/entrypoints/openai/chat_completion/serving.py:731-741). Tool-call deltas follow the OpenAI stream form. The first delta of a call has index, id, type and the name. Later deltas have only index and more arguments text (vllm/tool_parsers/hermes_tool_parser.py:229-262).
With "include_reasoning": false, reasoning deltas are dropped, and a delta with only reasoning sends nothing (vllm/parser/abstract_parser.py:998-1005). Logprobs and token ids are hidden for the whole stream, because they would leak the hidden thinking (vllm/entrypoints/openai/chat_completion/stream_processor.py:35-37).
Step 7: how the end of the thinking is found#
Most reasoning parsers with a start and an end marker derive from BaseThinkingReasoningParser:
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
start_token_id = self.start_token_id
end_token_id = self.end_token_id
for i in range(len(input_ids) - 1, -1, -1):
if input_ids[i] == start_token_id:
return False
if input_ids[i] == end_token_id:
return True
return False
def is_reasoning_end_streaming(
self, input_ids: Sequence[int], delta_ids: Iterable[int]
) -> bool:
end_token_id = self.end_token_id
return end_token_id in delta_ids
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
"""Extract the content after the end tokens."""
if self.end_token_id not in islice(input_ids, 0, max(0, len(input_ids) - 1)):
return []
else:
return input_ids[input_ids.index(self.end_token_id) + 1 :]is_reasoning_end(ids)reads the ids backwards: an end token before a start token means "ended" (lines 71-80). It is used on the prompt and in the engine core.is_reasoning_end_streamingonly asks whether the end token is in the new ids (lines 82-86).extract_content_idsreturns the ids after the end token (lines 88-93).
The reasoning parser also counts the thinking tokens, between start and end markers (vllm/reasoning/basic_parsers.py:178-196). The serving layer reports the count as usage.completion_tokens_details.reasoning_tokens, when a reasoning parser is set (vllm/entrypoints/openai/chat_completion/serving.py:1154-1163). In the response, the thinking is in the vLLM field reasoning. An old client may send reasoning_content in earlier messages; the request validator renames it to reasoning (vllm/entrypoints/openai/chat_completion/protocol.py:590-592).
Step 8: the reasoning parser in the engine core#
A grammar must not constrain the thinking. So the engine core loads the same reasoning parser class for its structured output manager (vllm/v1/structured_output/__init__.py:100-112). The front end helps it twice:
- It computes
reasoning_endedfrom the rendered prompt and sends it in theEngineCoreRequest(vllm/entrypoints/openai/chat_completion/protocol.py:616-633,vllm/entrypoints/openai/chat_completion/serving.py:242-262,vllm/v1/engine/async_llm.py:516-519). - It sends the same
chat_template_kwargsasreasoning_parser_kwargs, so the engine core's parser instance reads the output like the front end's one (vllm/v1/structured_output/__init__.py:118-131).
With include_reasoning: false, reasoning_ended is True from the start, so the grammar applies from the first token (vllm/entrypoints/openai/chat_completion/protocol.py:625-626). The page Structured outputs: JSON schemas, grammars and token bitmasks in vLLM shows the engine side.
Step 9: the declarative parser engine (vllm/parser/)#
Newer parsers do not write their own streaming code. A ParserEngineConfig describes a format with terminals (marker strings or token ids), state transitions and the event that text produces in each state (vllm/parser/engine/parser_engine_config.py:50-79). ParserEngine runs one such config for both reasoning and tool calls, streaming and non-streaming (vllm/parser/engine/parser_engine.py:111-123). The folder's guide summarizes the layout: one file per model family, and adapters that expose an engine through the old reasoning-parser and tool-parser interfaces (vllm/parser/AGENTS.md:1-26).
The adapters are what the registries load. For example, the reasoning name qwen3 loads Qwen3ParserReasoningAdapter (vllm/reasoning/__init__.py:168-171, vllm/parser/engine/registered_adapters.py:1-33). Adapters set engine_based_streaming = True (vllm/parser/engine/adapters.py:35-45), and DelegatingParser then feeds them deltas, not the growing full text (vllm/parser/abstract_parser.py:83-105, vllm/parser/abstract_parser.py:152-155). For a user, nothing changes: the flags and names stay the same.
Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep diveWorked example: a streamed reply with reasoning, then a tool call
Server: vllm serve <DeepSeek-R1-style model> --reasoning-parser deepseek_r1 --enable-auto-tool-choice --tool-call-parser hermes. The request has one tool get_weather(city), tool_choice unset (so "auto") and "stream": true. The prompt has no <think> marker at its end. The model writes:
<think>Weather is needed.</think>\n\n<tool_call>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool_call><EOS>
Before the first token. adjust_request runs: no strict tool and the default strict level, so resolve_tool_strictness returns None, and no grammar is added (vllm/tool_parsers/structural_tag_registry.py:186-191). Hermes sets skip_special_tokens = False. With VLLM_ENFORCE_STRICT_TOOL_CALLING on, Hermes has supports_required_and_named = False, because it has a structural tag model (vllm/tool_parsers/abstract_tool_parser.py:68-74); for "auto" this changes nothing.
Output by output (the numbers match the figure):
<think>: the firstparse_deltachecks the prompt: no marker, so the reasoning phase is on. A single start token givesNone(vllm/reasoning/basic_parsers.py:108-112). No chunk.Weather: the start id is in the previous ids and no end id is anywhere, soDeltaMessage(reasoning="Weather")(vllm/reasoning/basic_parsers.py:116-133). Chunk{"reasoning": "Weather"}.is needed.: the same branch. Chunk{"reasoning": " is needed."}.</think>: a single end token givesNone, butis_reasoning_end_streamingis true. The state switches; the content ids after the marker are[], so the tool parser gets empty text and returnsNone(vllm/parser/abstract_parser.py:904-936). No chunk.\n\n: tool phase. Hermes finds no<tool_call>and no partial tag at the end, so it sends the new text as content (vllm/tool_parsers/hermes_tool_parser.py:122-139). Chunk{"content": "\n\n"}.<tool_call>: content stops at the tag; the call body is empty, so no name yet (vllm/tool_parsers/hermes_tool_parser.py:229-239). No chunk.{"name": "get_weather",: the name regex needs the closing quote of the name (vllm/tool_parsers/hermes_tool_parser.py:169-172). The outputs before that quote giveNone. The output with the quote gives a chunk withindex0, a new id,type: "function"andname(vllm/tool_parsers/hermes_tool_parser.py:234-250)."arguments": {"city": "Paris"}}: after the key"arguments":, each output that adds argument text gives anargumentsdelta with only the new part. When the call body is complete JSON, the parser strips the outer}. In total the client gets the argument text{"city": "Paris"}(vllm/tool_parsers/hermes_tool_parser.py:175-202).</tool_call>: the arguments did not grow. No chunk.- EOS with
finish_reason: "stop": the end token is not detokenized, so the text is empty and the parser returnsNone. Because a tool call was streamed, the processor changes the reason to"tool_calls"(vllm/entrypoints/openai/chat_completion/stream_processor.py:79-82). The serving layer sends an empty delta with that reason (vllm/entrypoints/openai/chat_completion/serving.py:731-741).
The same output without a stream gives one message. reasoning is "Weather is needed.", and there is one tool call with the arguments {"city": "Paris"} and finish_reason: "tool_calls" (vllm/reasoning/basic_parsers.py:153-176, vllm/tool_parsers/hermes_tool_parser.py:70-114). content is null: the text before the tag is only whitespace, and the non-streaming path turns whitespace-only content into None (vllm/parser/abstract_parser.py:468-470). The stream sent that whitespace as a content delta (output 5), so the two forms differ slightly.
Deep diveStrict levels for tool calls
--tool-strict-level is a server-side floor; when it is unset, the tool parser's default applies, which is auto for most parsers (vllm/entrypoints/common/cli_args.py:130-137, vllm/tool_parsers/abstract_tool_parser.py:62-65).
| Level | tool_choice auto | required / named |
|---|---|---|
auto (0) | no grammar, unless some tool has strict: true | call envelope constrained; arguments only for strict tools |
function (1) | call envelope constrained for every request with tools | same |
parameter (2) | envelope and argument schemas, as if every tool were strict | same |
Sources: vllm/tool_parsers/tool_strict_level.py:10-22, vllm/tool_parsers/structural_tag_registry.py:175-197. parallel_tool_calls: false raises the level to at least function, because the grammar must see the call envelope to stop after one call (vllm/parser/abstract_parser.py:531-535). With tool_choice: "auto" and a response_format, the grammar allows either a tool call or the response format (vllm/parser/abstract_parser.py:549-594).
Deep diveWhere the parsers run, and how often they are built
| Object | Process | Made | Lines |
|---|---|---|---|
| Parser class (composed) | API server | once in OpenAIServingChat.__init__, once in OnlineRenderer.__init__ | vllm/entrypoints/openai/chat_completion/serving.py:167-175, vllm/renderers/online_renderer.py:180-188 |
Parser for adjust_request | API server | per request, after rendering | vllm/renderers/online_renderer.py:848-859 |
| Parser for the output | API server | per request, and per choice for n > 1 | vllm/entrypoints/openai/chat_completion/serving.py:545-551, vllm/entrypoints/openai/chat_completion/serving.py:984-988 |
| reasoning parser class | engine core | once, in StructuredOutputManager.__init__ | vllm/v1/structured_output/__init__.py:106-112 |
| reasoning parser instance | engine core | per structured request, on first use | vllm/v1/structured_output/__init__.py:118-131 |
The engine core imports a reasoning parser plugin itself, so the plugin path must work in that process too (vllm/v1/structured_output/__init__.py:100-104). The parsers count their calls in the Prometheus counter vllm:tool_call_parser_invocations_total, which exists only when --tool-call-parser is set (vllm/parser/metrics.py:15, vllm/entrypoints/launchers/api_server/app_state.py:32-37).
Deep diveFlags and environment variables
| Flag or variable | Default | Effect |
|---|---|---|
--enable-auto-tool-choice | false (vllm/entrypoints/common/cli_args.py:115-117) | Needed for any tool parser (Step 3). |
--tool-call-parser | none (vllm/entrypoints/common/cli_args.py:121-125) | Name in ToolParserManager. |
--reasoning-parser | "" (vllm/config/structured_outputs.py:34-36) | Name in ReasoningParserManager; front end and engine core. |
--tool-strict-level | none → parser default (vllm/entrypoints/common/cli_args.py:130-137) | Floor for tool-call grammars. |
--exclude-tools-when-tool-choice-none | false | Leave tools out of the prompt for "none". |
--structured-outputs-config.enable_in_reasoning | false (vllm/config/structured_outputs.py:40-41) | Apply the grammar during the thinking too. |
VLLM_ENFORCE_STRICT_TOOL_CALLING | true (vllm/envs.py:1845-1847) | Allow structural tags for tool calls; off → JSON schema path or no grammar. |
What can go wrong#
- Tool calls arrive as plain text in
content.--enable-auto-tool-choiceis missing, or the parser does not match the model's format (vllm/parser/parser_manager.py:34-35). - "tool_choice=... requires --tool-call-parser to be set", but it is set. The tool parser needs
--enable-auto-tool-choiceas well (warning in Step 3). - The thinking text is in
content. No--reasoning-parser, or the template already opened the thinking in the prompt and the parser does not handle that case. - A client reads an empty
reasoning_content. The response field isreasoning(vllm/entrypoints/openai/chat_completion/protocol.py:79-80);docs/features/reasoning_outputs.mddescribes the rename (docs/features/reasoning_outputs.md:8-9). - The server does not start with a reasoning parser. The tokenizer has no matching marker token; check that the parser fits the model (
vllm/parser/parser_manager.py:84-95). - A tool call is cut off.
finish_reason: "length"meansmax_tokensended the call; the arguments may be invalid JSON.
Why it matters. Agents depend on tool_calls and on a clean content. Most "the model does not call tools" reports are a flag or a parser that does not match the model's format, not a model problem.
Check yourself#
- 1Which two flags turn on tool-call parsing, and what happens with only one of them?easy
Show answer
--enable-auto-tool-choiceand--tool-call-parser NAME. The first alone stops the start-up withTypeError(vllm/entrypoints/common/cli_args.py:523-525). The second alone starts, but no tool parser is used, becauseget_tool_parserreturnsNonewithout auto tool choice (vllm/parser/parser_manager.py:34-35). - 2With the Hermes parser and default settings, how ismedium
tool_choice: "required"enforced?Show answer
With a structural tag: Hermes has
structural_tag_model = "hermes"(vllm/tool_parsers/hermes_tool_parser.py:35), andVLLM_ENFORCE_STRICT_TOOL_CALLINGis on by default._apply_structural_tagputs the tag grammar intorequest.structured_outputs(vllm/parser/abstract_parser.py:601-607). The output is in Hermes' own format, so Hermes parses it like an"auto"reply, becausesupports_required_and_namedis false (vllm/tool_parsers/abstract_tool_parser.py:68-74,vllm/parser/abstract_parser.py:413-420). - 3How does amedium
<think>-based reasoning parser decide that a prompt has already ended the thinking?Show answer
is_reasoning_endreads the ids from the end: it returns true if it finds the end token before any start token, and false otherwise (vllm/reasoning/basic_parsers.py:71-80). - 4In the worked example, why does thehard
</think>output produce no chunk, although the parser changes state?Show answer
A single end token makes
extract_reasoning_streamingreturnNone(vllm/reasoning/basic_parsers.py:108-112). The transition sets the content ids to the ids after the marker, an empty list. So the tool parser gets empty text and returnsNone(vllm/parser/abstract_parser.py:904-936). The pass-through is skipped because the reasoning just ended (lines 984-990), and the serving layer skips aNonedelta (vllm/entrypoints/openai/chat_completion/serving.py:731-740). - 5A request setshard
"include_reasoning": falseand a JSON schemaresponse_formaton a reasoning model. When does the grammar start to constrain the output, and what does the client see of the thinking?Show answer
From the first generated token:
resolve_reasoning_endedreturnsTruewheninclude_reasoningis false (vllm/entrypoints/openai/chat_completion/protocol.py:625-626), and the engine core then applies the grammar at once (vllm/v1/structured_output/__init__.py:305-306). The client sees no thinking: reasoning deltas are dropped, and logprobs and token ids are hidden (vllm/parser/abstract_parser.py:998-1005,vllm/entrypoints/openai/chat_completion/stream_processor.py:35-37).