vLLM Explained

Tool and reasoning parsers: from model text to tool_calls and reasoning

IntermediatevLLM conceptsabout 14 min read + 5 min deep dives

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#

Figure: the order in which the parsers see each output (panel 1), and what the client gets for each output of a reply that thinks and then calls a tool (panel 2).
Open full size ↗

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#

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

  1. gpt-oss (Harmony format): always HarmonyParser (lines 179-185).
  2. 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).
  3. Three families with their own composite class: kimi_k3, cohere_command3/4 and minicpmv (lines 222-262).
  4. Everything else: a subclass of DelegatingParser that 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_choicePromptGenerationParsing of the output
"none"tools are in the prompt, unless --exclude-tools-when-tool-choice-nonefreeno tool calls; the text stays content (vllm/parser/abstract_parser.py:401-405)
"auto"tools in the promptfree, 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 promptconstrained: a structural tag of the model's format, or a JSON array of callsparsed from the constrained text
named functiontools in the promptconstrained to that function's call or its argument schemaone 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:

  1. Structural tag. Some tool parsers set structural_tag_model, for example hermes, llama3_json, qwen3_coder, kimi_k2 and deepseek_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_tag writes it into request.structured_outputs and clears response_format (vllm/parser/abstract_parser.py:503-608). This needs VLLM_ENFORCE_STRICT_TOOL_CALLING, which is on by default (vllm/envs.py:1845-1847).
  2. 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_calls

The 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)
  1. 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> when enable_thinking is false (examples/tool_chat_template_hunyuan_a13b.jinja:110-111).
  2. Lines 889-897: in the reasoning phase, the reasoning parser turns the delta into reasoning text.
  3. 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_streaming only asks whether the end token is in the new ids (lines 82-86).
  • extract_content_ids returns 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:

  1. It computes reasoning_ended from the rendered prompt and sends it in the EngineCoreRequest (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).
  2. It sends the same chat_template_kwargs as reasoning_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 dive

Worked 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):

  1. <think>: the first parse_delta checks the prompt: no marker, so the reasoning phase is on. A single start token gives None (vllm/reasoning/basic_parsers.py:108-112). No chunk.
  2. Weather: the start id is in the previous ids and no end id is anywhere, so DeltaMessage(reasoning="Weather") (vllm/reasoning/basic_parsers.py:116-133). Chunk {"reasoning": "Weather"}.
  3. is needed.: the same branch. Chunk {"reasoning": " is needed."}.
  4. </think>: a single end token gives None, but is_reasoning_end_streaming is true. The state switches; the content ids after the marker are [], so the tool parser gets empty text and returns None (vllm/parser/abstract_parser.py:904-936). No chunk.
  5. \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"}.
  6. <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.
  7. {"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 give None. The output with the quote gives a chunk with index 0, a new id, type: "function" and name (vllm/tool_parsers/hermes_tool_parser.py:234-250).
  8. "arguments": {"city": "Paris"}}: after the key "arguments":, each output that adds argument text gives an arguments delta 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).
  9. </tool_call>: the arguments did not grow. No chunk.
  10. EOS with finish_reason: "stop": the end token is not detokenized, so the text is empty and the parser returns None. 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 dive

Strict 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).

Leveltool_choice autorequired / named
auto (0)no grammar, unless some tool has strict: truecall envelope constrained; arguments only for strict tools
function (1)call envelope constrained for every request with toolssame
parameter (2)envelope and argument schemas, as if every tool were strictsame

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 dive

Where the parsers run, and how often they are built

ObjectProcessMadeLines
Parser class (composed)API serveronce 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_requestAPI serverper request, after renderingvllm/renderers/online_renderer.py:848-859
Parser for the outputAPI serverper request, and per choice for n > 1vllm/entrypoints/openai/chat_completion/serving.py:545-551, vllm/entrypoints/openai/chat_completion/serving.py:984-988
reasoning parser classengine coreonce, in StructuredOutputManager.__init__vllm/v1/structured_output/__init__.py:106-112
reasoning parser instanceengine coreper structured request, on first usevllm/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 dive

Flags and environment variables

Flag or variableDefaultEffect
--enable-auto-tool-choicefalse (vllm/entrypoints/common/cli_args.py:115-117)Needed for any tool parser (Step 3).
--tool-call-parsernone (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-levelnone → parser default (vllm/entrypoints/common/cli_args.py:130-137)Floor for tool-call grammars.
--exclude-tools-when-tool-choice-nonefalseLeave tools out of the prompt for "none".
--structured-outputs-config.enable_in_reasoningfalse (vllm/config/structured_outputs.py:40-41)Apply the grammar during the thinking too.
VLLM_ENFORCE_STRICT_TOOL_CALLINGtrue (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-choice is 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-choice as 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 is reasoning (vllm/entrypoints/openai/chat_completion/protocol.py:79-80); docs/features/reasoning_outputs.md describes 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" means max_tokens ended 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#

  1. 1
    Which two flags turn on tool-call parsing, and what happens with only one of them?
    easy
    Show answer

    --enable-auto-tool-choice and --tool-call-parser NAME. The first alone stops the start-up with TypeError (vllm/entrypoints/common/cli_args.py:523-525). The second alone starts, but no tool parser is used, because get_tool_parser returns None without auto tool choice (vllm/parser/parser_manager.py:34-35).

  2. 2
    With the Hermes parser and default settings, how is tool_choice: "required" enforced?
    medium
    Show answer

    With a structural tag: Hermes has structural_tag_model = "hermes" (vllm/tool_parsers/hermes_tool_parser.py:35), and VLLM_ENFORCE_STRICT_TOOL_CALLING is on by default. _apply_structural_tag puts the tag grammar into request.structured_outputs (vllm/parser/abstract_parser.py:601-607). The output is in Hermes' own format, so Hermes parses it like an "auto" reply, because supports_required_and_named is false (vllm/tool_parsers/abstract_tool_parser.py:68-74, vllm/parser/abstract_parser.py:413-420).

  3. 3
    How does a <think>-based reasoning parser decide that a prompt has already ended the thinking?
    medium
    Show answer

    is_reasoning_end reads 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).

  4. 4
    In the worked example, why does the </think> output produce no chunk, although the parser changes state?
    hard
    Show answer

    A single end token makes extract_reasoning_streaming return None (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 returns None (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 a None delta (vllm/entrypoints/openai/chat_completion/serving.py:731-740).

  5. 5
    A request sets "include_reasoning": false and a JSON schema response_format on a reasoning model. When does the grammar start to constrain the output, and what does the client see of the thinking?
    hard
    Show answer

    From the first generated token: resolve_reasoning_ended returns True when include_reasoning is 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).