[Frontend] Support count_reasoning_tokens in the Streaming Parser Engine (#45802)

Signed-off-by: chaunceyjiang <chaunceyjiang@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Chauncey committed Aug 15, 2026 at 21:51 UTC ac2ae8798c7ef59c1924d5c0447a4279fa645770
16 files changed +669 -73
tests/entrypoints/openai/chat_completion/test_serving_chat.py
+65
@@ -28,9 +28,11 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
28 from vllm.entrypoints.openai.chat_completion.serving import (
29 OpenAIServingChat,
30 _get_mm_token_counts,
31 + _make_completion_tokens_details,
32 _make_prompt_tokens_details,
33 )
34 from vllm.entrypoints.openai.engine.protocol import (
35 + DeltaMessage,
36 ErrorResponse,
37 RequestResponseMetadata,
38 )
@@ -626,6 +628,7 @@ def _build_minimal_metrics_serving_chat(
628 serving.response_role = "assistant"
629 serving.parser_cls = None
630 serving.enable_auto_tools = False
631 + serving._include_reasoning_tokens_details = False
632 serving.enable_prompt_tokens_details = False
633 serving.enable_log_outputs = False
634 serving.enable_log_deltas = False
@@ -666,6 +669,13 @@ async def _single_request_output(
669 yield request_output
670
671
672 +async def _stream_request_outputs(
673 + *request_outputs: RequestOutput,
674 +) -> AsyncIterator[RequestOutput]:
675 + for request_output in request_outputs:
676 + yield request_output
677 +
678 +
679 async def _collect_metrics_stream_chunks(
680 serving: OpenAIServingChat,
681 request: ChatCompletionRequest,
@@ -724,6 +734,7 @@ async def test_chat_per_request_metrics_follow_server_flag():
734 request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"),
735 )
736 assert disabled_response.metrics is None
737 + assert disabled_response.usage.completion_tokens_details is None
738
739 enabled_serving = _build_minimal_metrics_serving_chat(
740 enable_per_request_metrics=True
@@ -781,6 +792,55 @@ async def test_chat_streaming_metrics_ride_on_usage_chunk():
792 assert usage_chunks[-1]["metrics"]["time_to_first_token_ms"] == pytest.approx(500.0)
793
794
795 +@pytest.mark.asyncio
796 +async def test_streaming_reasoning_usage_counts_across_deltas():
797 + serving = _build_minimal_metrics_serving_chat(enable_per_request_metrics=False)
798 + serving._include_reasoning_tokens_details = True
799 + serving.model_config = None
800 +
801 + parser = MagicMock()
802 + parser.parse_delta.side_effect = [
803 + DeltaMessage(reasoning="reasoning"),
804 + DeltaMessage(content="answer"),
805 + ]
806 + parser.count_reasoning_tokens.side_effect = lambda token_ids: sum(
807 + token_id == 20 for token_id in token_ids
808 + )
809 + serving.parser_cls = MagicMock(return_value=parser)
810 +
811 + first = _make_metrics_request_output(metrics=None, token_ids=(10, 20))
812 + first.outputs[0].text = "<think>reasoning"
813 + first.outputs[0].finish_reason = None
814 + second = _make_metrics_request_output(metrics=None, token_ids=(11, 30))
815 + second.outputs[0].text = "</think>answer"
816 +
817 + request = ChatCompletionRequest(
818 + model="test-model",
819 + messages=[{"role": "user", "content": "Test prompt"}],
820 + max_tokens=10,
821 + stream=True,
822 + stream_options={"include_usage": True},
823 + )
824 + chunks: list[dict[str, Any]] = []
825 + async for line in serving.chat_completion_stream_generator(
826 + request,
827 + _stream_request_outputs(first, second),
828 + "chatcmpl-test-id",
829 + "test-model",
830 + conversation=[{"role": "user", "content": "Test"}],
831 + tokenizer=MagicMock(),
832 + request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"),
833 + ):
834 + payload = line.removeprefix("data: ").strip()
835 + if payload != "[DONE]":
836 + chunks.append(json.loads(payload))
837 +
838 + usage_chunks = [chunk for chunk in chunks if chunk.get("usage")]
839 + assert usage_chunks[-1]["usage"]["completion_tokens_details"] == {
840 + "reasoning_tokens": 1
841 + }
842 +
843 +
844 @dataclass
845 class MockEngine:
846 model_config: MockModelConfig = field(default_factory=MockModelConfig)
@@ -810,6 +870,7 @@ async def _async_serving_chat_init():
870 def test_async_serving_chat_init():
871 serving_completion = asyncio.run(_async_serving_chat_init())
872 assert serving_completion.chat_template == CHAT_TEMPLATE
873 + assert serving_completion._include_reasoning_tokens_details is False
874
875
876 def test_mm_prompt_tokens_details():
@@ -850,6 +911,10 @@ def test_mm_prompt_tokens_details():
911 assert details.multimodal_tokens == {"image": 600, "video": 1200}
912
913
914 +def test_completion_tokens_details():
915 + assert _make_completion_tokens_details(7).reasoning_tokens == 7
916 +
917 +
918 @pytest.mark.asyncio
919 async def test_serving_chat_returns_correct_model_name():
920 mock_engine = MagicMock(spec=AsyncLLM)
tests/entrypoints/openai/responses/test_serving_responses.py
+4
@@ -360,6 +360,10 @@ async def test_reasoning_tokens_counted_for_text_reasoning_model(monkeypatch):
360 def get_vocab(self):
361 return self._vocab
362
363 + def decode(self, token_ids):
364 + id_to_token = {v: k for k, v in self._vocab.items()}
365 + return "".join(id_to_token.get(token_id, "x") for token_id in token_ids)
366 +
367 # Force non-harmony, SimpleContext path
368 monkeypatch.setattr(envs, "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", False)
369
tests/parser/engine/test_engine.py
+51
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for the streaming parser engine core pipeline."""
4
5 +from dataclasses import replace
6 from unittest.mock import MagicMock
7
8 import pytest
@@ -71,6 +72,16 @@ def _think_config() -> ParserEngineConfig:
72 )
73
74
75 +def _token_think_config() -> ParserEngineConfig:
76 + return replace(
77 + _think_config(),
78 + token_id_terminals={
79 + "THINK_START": "<think>",
80 + "THINK_END": "</think>",
81 + },
82 + )
83 +
84 +
85 class TestNonStreaming:
86 def test_plain_text(self):
87 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
@@ -441,6 +452,46 @@ class TestTokenIdFiltering:
452 assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_END) == 1
453
454
455 +class TestReasoningTokenCounts:
456 + def test_counts_reasoning_and_excludes_boundaries_and_content(self):
457 + engine = StreamingParserEngine(_token_think_config(), _make_think_tokenizer())
458 +
459 + events = engine.feed(
460 + "<think>tok1tok2</think>tok3",
461 + [_START_ID, 1, 2, _END_ID, 3],
462 + )
463 + events.extend(engine.finish())
464 +
465 + reasoning = [e for e in events if e.type == EventType.REASONING_CHUNK]
466 + assert "".join(e.value for e in reasoning) == "tok1tok2"
467 + assert sum(e.token_count for e in reasoning) == 2
468 + assert engine.reasoning_token_count == 2
469 +
470 + def test_counts_tokens_after_final_deferred_start_terminal(self):
471 + engine = StreamingParserEngine(_token_think_config(), _make_think_tokenizer())
472 +
473 + assert engine.feed("", [_START_ID, 1, 2]) == []
474 + events = engine.feed("<think>tok1tok2", [])
475 +
476 + reasoning = [e for e in events if e.type == EventType.REASONING_CHUNK]
477 + assert "".join(e.value for e in reasoning) == "tok1tok2"
478 + assert sum(e.token_count for e in reasoning) == 2
479 + assert engine.reasoning_token_count == 2
480 +
481 + def test_deferred_reasoning_tokens_stay_before_next_end_terminal(self):
482 + engine = StreamingParserEngine(_token_think_config(), _make_think_tokenizer())
483 +
484 + assert engine.feed("", [_START_ID, 1, 2]) == []
485 + events = engine.feed("<think>tok1tok2</think>tok3", [_END_ID, 3])
486 +
487 + assert (
488 + sum(e.token_count for e in events if e.type == EventType.REASONING_CHUNK)
489 + == 2
490 + )
491 + assert sum(e.token_count for e in events if e.type == EventType.TEXT_CHUNK) == 1
492 + assert engine.reasoning_token_count == 2
493 +
494 +
495 def _func_prefix_config() -> ParserEngineConfig:
496 """Config mixing token-ID terminals (TOOL_START/END) with
497 text-only terminals (FUNC_PREFIX) and fallback transitions."""
tests/parser/engine/test_parser_engine.py
+124
@@ -33,6 +33,7 @@ from vllm.parser.engine.parser_engine_config import (
33 ParserState,
34 Transition,
35 )
36 +from vllm.parser.parser_manager import ParserManager
37
38 # ── Shared test configs ──────────────────────────────────────────────
39
@@ -892,6 +893,129 @@ class _CombinedDelegating(DelegatingParser):
893 tool_parser_cls = _CombinedToolAdapter
894
895
896 +def test_parser_manager_uses_shared_engine_directly(monkeypatch):
897 + monkeypatch.setattr(
898 + ParserManager,
899 + "get_reasoning_parser",
900 + classmethod(lambda cls, name: _CombinedReasoningAdapter),
901 + )
902 + monkeypatch.setattr(
903 + ParserManager,
904 + "get_tool_parser",
905 + classmethod(lambda cls, name, enabled, model: _CombinedToolAdapter),
906 + )
907 +
908 + parser_cls = ParserManager.get_parser(
909 + tool_parser_name="combined",
910 + reasoning_parser_name="combined",
911 + enable_auto_tools=True,
912 + )
913 +
914 + assert parser_cls is not None
915 + assert parser_cls is _CombinedTestEngine
916 + parser = parser_cls(make_mock_tokenizer(_VOCAB))
917 + request = _make_delegating_request()
918 + reasoning, content, _ = parser.parse(
919 + "ab</think>c",
920 + request,
921 + model_output_token_ids=[ord("a"), ord("b"), 201, ord("c")],
922 + )
923 + assert reasoning == "ab"
924 + assert content == "c"
925 + assert parser.count_reasoning_tokens([]) == 2
926 +
927 +
928 +def test_parser_manager_preserves_reasoning_only_adapter(monkeypatch):
929 + monkeypatch.setattr(
930 + ParserManager,
931 + "get_reasoning_parser",
932 + classmethod(lambda cls, name: _CombinedReasoningAdapter),
933 + )
934 + monkeypatch.setattr(
935 + ParserManager,
936 + "get_tool_parser",
937 + classmethod(lambda cls, name, enabled, model: None),
938 + )
939 +
940 + parser_cls = ParserManager.get_parser(reasoning_parser_name="combined")
941 +
942 + assert parser_cls is not None
943 + parser = parser_cls(make_mock_tokenizer(_VOCAB))
944 + assert parser.reasoning_parser is not None
945 + assert parser.tool_parser is None
946 + reasoning, content, _ = parser.parse(
947 + 'ab</think><tool_call>{"name":"h","arguments":{}}</tool_call>',
948 + _make_delegating_request(),
949 + model_output_token_ids=[ord("a"), ord("b"), 201],
950 + )
951 + assert reasoning == "ab"
952 + assert content == '<tool_call>{"name":"h","arguments":{}}</tool_call>'
953 + assert parser.count_reasoning_tokens([ord("a"), ord("b"), 201]) == 2
954 +
955 +
956 +def test_parser_manager_preserves_tool_only_adapter(monkeypatch):
957 + monkeypatch.setattr(
958 + ParserManager,
959 + "get_reasoning_parser",
960 + classmethod(lambda cls, name: None),
961 + )
962 + monkeypatch.setattr(
963 + ParserManager,
964 + "get_tool_parser",
965 + classmethod(lambda cls, name, enabled, model: _CombinedToolAdapter),
966 + )
967 +
968 + parser_cls = ParserManager.get_parser(
969 + tool_parser_name="combined", enable_auto_tools=True
970 + )
971 +
972 + assert parser_cls is not None
973 + parser = parser_cls(make_mock_tokenizer(_VOCAB))
974 + assert parser.reasoning_parser is None
975 + assert parser.tool_parser is not None
976 + reasoning, content, tool_calls = parser.parse(
977 + "<think>ab</think>c",
978 + _make_delegating_request(),
979 + model_output_token_ids=[200, ord("a"), ord("b"), 201, ord("c")],
980 + )
981 + assert reasoning is None
982 + assert content == "<think>ab</think>c"
983 + assert tool_calls == []
984 +
985 +
986 +def test_parser_manager_rejects_non_engine_adapter_metadata():
987 + class TraditionalParser:
988 + pass
989 +
990 + class InvalidEngineAdapter:
991 + _parser_engine_cls = TraditionalParser
992 +
993 + assert ParserManager._get_parser_engine_cls(TraditionalParser) is None
994 + assert ParserManager._get_parser_engine_cls(InvalidEngineAdapter) is None
995 + assert (
996 + ParserManager._get_parser_engine_cls(_CombinedReasoningAdapter)
997 + is _CombinedTestEngine
998 + )
999 +
1000 +
1001 +def test_reasoning_adapter_counts_after_final_non_streaming_parse():
1002 + parser = _CombinedReasoningAdapter(make_mock_tokenizer(_VOCAB))
1003 + request = _make_delegating_request()
1004 + token_ids = [ord("a"), ord("b"), 201, ord("c")]
1005 +
1006 + parser.extract_reasoning_streaming(
1007 + "",
1008 + "ab</think>c",
1009 + "ab</think>c",
1010 + [],
1011 + token_ids,
1012 + token_ids,
1013 + )
1014 + parser.extract_reasoning("ab</think>c", request)
1015 +
1016 + assert parser.count_reasoning_tokens(token_ids) == 2
1017 +
1018 +
1019 def _make_delegating_request():
1020 req = MagicMock(spec=ChatCompletionRequest)
1021 req.tools = []
tests/parser/engine/test_token_id_scanner.py
+20 -1
@@ -163,9 +163,28 @@ class TestHoldbackTextRecovery:
163 assert len(result) == 0
164
165 flushed = scanner.flush_pending()
166 - assert len(flushed) == 1
166 + assert len(flushed) == 2
167 assert isinstance(flushed[0], PreLexedTerminal)
168 assert flushed[0].terminal == "TOOL_START"
169 + assert isinstance(flushed[1], TextChunk)
170 + assert flushed[1].text == ""
171 + assert flushed[1].token_count == 2
172 +
173 + def test_deferred_terminal_preserves_trailing_token_count(self, tokenizer):
174 + tokenizer.decode.side_effect = lambda ids: {
175 + CHANNEL_START_ID: CHANNEL_START,
176 + 201: "alpha",
177 + 202: "beta",
178 + }[ids[0]]
179 + scanner = TokenIDScanner({CHANNEL_START_ID: "THINK_START"}, tokenizer)
180 +
181 + assert scanner.scan("", [CHANNEL_START_ID, 201, 202]) == []
182 +
183 + result = scanner.scan(f"{CHANNEL_START}alphabeta", [])
184 + assert isinstance(result[0], PreLexedTerminal)
185 + assert isinstance(result[1], TextChunk)
186 + assert result[1].text == "alphabeta"
187 + assert result[1].token_count == 2
188
189 def test_holdback_before_start_tag(self, scanner):
190 result = scanner.scan(
vllm/entrypoints/openai/chat_completion/serving.py
+50
@@ -36,6 +36,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
36 ChatMessage,
37 )
38 from vllm.entrypoints.openai.engine.protocol import (
39 + CompletionTokenUsageInfo,
40 DeltaMessage,
41 ErrorResponse,
42 FunctionCall,
@@ -105,6 +106,12 @@ def _make_prompt_tokens_details(
106 )
107
108
109 +def _make_completion_tokens_details(
110 + reasoning_tokens: int,
111 +) -> CompletionTokenUsageInfo:
112 + return CompletionTokenUsageInfo(reasoning_tokens=reasoning_tokens)
113 +
114 +
115 class OpenAIServingChat(GenerateBaseServing):
116 def __init__(
117 self,
@@ -146,6 +153,7 @@ class OpenAIServingChat(GenerateBaseServing):
153 self.enable_log_deltas = enable_log_deltas
154
155 self.enable_auto_tools: bool = enable_auto_tools
156 + self._include_reasoning_tokens_details = bool(reasoning_parser)
157 self.parser_cls = ParserManager.get_parser(
158 tool_parser_name=tool_parser,
159 reasoning_parser_name=reasoning_parser,
@@ -439,6 +447,9 @@ class OpenAIServingChat(GenerateBaseServing):
447 # Send response for each token for each request.n (index)
448 num_choices = 1 if request.n is None else request.n
449 previous_num_tokens = [0] * num_choices
450 + # TODO: Remove once all reasoning parsers use the Parser Engine.
451 + generated_token_ids: list[list[int]] = [[] for _ in range(num_choices)]
452 + previous_reasoning_tokens = [0] * num_choices
453 finish_reason_sent = [False] * num_choices
454 num_prompt_tokens = 0
455 num_cached_tokens = None
@@ -537,6 +548,11 @@ class OpenAIServingChat(GenerateBaseServing):
548 prompt_tokens=num_prompt_tokens,
549 completion_tokens=0,
550 total_tokens=num_prompt_tokens,
551 + completion_tokens_details=(
552 + _make_completion_tokens_details(0)
553 + if self._include_reasoning_tokens_details
554 + else None
555 + ),
556 )
557
558 data = chunk.model_dump_json(exclude_unset=True)
@@ -573,6 +589,11 @@ class OpenAIServingChat(GenerateBaseServing):
589 prompt_tokens=num_prompt_tokens,
590 completion_tokens=0,
591 total_tokens=num_prompt_tokens,
592 + completion_tokens_details=(
593 + _make_completion_tokens_details(0)
594 + if self._include_reasoning_tokens_details
595 + else None
596 + ),
597 )
598
599 data = chunk.model_dump_json(exclude_unset=True)
@@ -631,6 +652,11 @@ class OpenAIServingChat(GenerateBaseServing):
652
653 # set the previous values for the next iteration
654 previous_num_tokens[i] += len(output.token_ids)
655 + if parser is not None:
656 + generated_token_ids[i].extend(output.token_ids)
657 + previous_reasoning_tokens[i] = parser.count_reasoning_tokens(
658 + tuple(generated_token_ids[i])
659 + )
660
661 # if the message delta is None (e.g. because it was a
662 # "control token" for tool calls or the parser otherwise
@@ -756,6 +782,13 @@ class OpenAIServingChat(GenerateBaseServing):
782 prompt_tokens=num_prompt_tokens,
783 completion_tokens=completion_tokens,
784 total_tokens=num_prompt_tokens + completion_tokens,
785 + completion_tokens_details=(
786 + _make_completion_tokens_details(
787 + previous_reasoning_tokens[i]
788 + )
789 + if self._include_reasoning_tokens_details
790 + else None
791 + ),
792 )
793
794 data = chunk.model_dump_json(exclude_unset=True)
@@ -769,6 +802,11 @@ class OpenAIServingChat(GenerateBaseServing):
802 prompt_tokens=num_prompt_tokens,
803 completion_tokens=completion_tokens,
804 total_tokens=num_prompt_tokens + completion_tokens,
805 + completion_tokens_details=_make_completion_tokens_details(
806 + sum(previous_reasoning_tokens)
807 + )
808 + if self._include_reasoning_tokens_details
809 + else None,
810 )
811 final_usage.prompt_tokens_details = _make_prompt_tokens_details(
812 self.enable_prompt_tokens_details,
@@ -813,6 +851,11 @@ class OpenAIServingChat(GenerateBaseServing):
851 prompt_tokens=num_prompt_tokens,
852 completion_tokens=num_completion_tokens,
853 total_tokens=num_prompt_tokens + num_completion_tokens,
854 + completion_tokens_details=_make_completion_tokens_details(
855 + sum(previous_reasoning_tokens)
856 + )
857 + if self._include_reasoning_tokens_details
858 + else None,
859 )
860
861 # Log complete streaming response if output logging is enabled
@@ -871,6 +914,7 @@ class OpenAIServingChat(GenerateBaseServing):
914 )
915
916 choices: list[ChatCompletionResponseChoice] = []
917 + total_reasoning_tokens = 0
918
919 role = self.get_chat_request_role(request)
920 tool_parser_cls = (
@@ -908,6 +952,7 @@ class OpenAIServingChat(GenerateBaseServing):
952 suppress_metadata = not request.include_reasoning and parser is not None
953 if not request.include_reasoning:
954 reasoning = None
955 + total_reasoning_tokens += parser.count_reasoning_tokens(token_ids)
956 if suppress_metadata:
957 logprobs = None
958 else:
@@ -1058,6 +1103,11 @@ class OpenAIServingChat(GenerateBaseServing):
1103 prompt_tokens=num_prompt_tokens,
1104 completion_tokens=num_generated_tokens,
1105 total_tokens=num_prompt_tokens + num_generated_tokens,
1106 + completion_tokens_details=_make_completion_tokens_details(
1107 + total_reasoning_tokens
1108 + )
1109 + if self._include_reasoning_tokens_details
1110 + else None,
1111 )
1112 usage.prompt_tokens_details = _make_prompt_tokens_details(
1113 self.enable_prompt_tokens_details,
vllm/entrypoints/openai/engine/protocol.py
+5
@@ -117,11 +117,16 @@ class PromptTokenUsageInfo(OpenAIBaseModel):
117 request has no multimodal input."""
118
119
120 +class CompletionTokenUsageInfo(OpenAIBaseModel):
121 + reasoning_tokens: int = 0
122 +
123 +
124 class UsageInfo(OpenAIBaseModel):
125 prompt_tokens: int = 0
126 total_tokens: int = 0
127 completion_tokens: int | None = 0
128 prompt_tokens_details: PromptTokenUsageInfo | None = None
129 + completion_tokens_details: CompletionTokenUsageInfo | None = None
130
131
132 class PerRequestTimingMetrics(OpenAIBaseModel):
vllm/entrypoints/openai/responses/serving.py
+6 -8
@@ -487,6 +487,7 @@ class OpenAIServingResponses(GenerateBaseServing):
487 response_parser=response_parser,
488 )
489
490 + reasoning_parser_kwargs = None
491 if (
492 context.response_parser is not None
493 and context.response_parser.reasoning_parser is not None
@@ -518,9 +519,7 @@ class OpenAIServingResponses(GenerateBaseServing):
519 priority=self._get_priority(request, raw_request),
520 trace_headers=trace_headers,
521 session_id=session_id,
521 - reasoning_parser_kwargs=reasoning_parser_kwargs
522 - if self.parser and self.parser.reasoning_parser_cls is not None
523 - else None,
522 + reasoning_parser_kwargs=reasoning_parser_kwargs,
523 )
524 generators.append(generator)
525
@@ -873,6 +872,8 @@ class OpenAIServingResponses(GenerateBaseServing):
872 if final_output.finish_reason == "length":
873 status = "incomplete"
874
875 + # TODO: Build final response items from the accumulated streaming
876 + # parser results instead of reparsing the complete output.
877 output = self._make_response_output_items(
878 request,
879 final_output,
@@ -901,13 +902,10 @@ class OpenAIServingResponses(GenerateBaseServing):
902 num_reasoning_tokens == 0
903 and isinstance(context, (SimpleContext, ParsableContext))
904 and context.response_parser is not None
904 - and context.response_parser.reasoning_parser is not None
905 ):
906 accumulated = getattr(context, "_accumulated_token_ids", []) or []
907 - num_reasoning_tokens = (
908 - context.response_parser.reasoning_parser.count_reasoning_tokens(
909 - accumulated
910 - )
907 + num_reasoning_tokens = context.response_parser.count_reasoning_tokens(
908 + accumulated
909 )
910
911 usage = ResponseUsage(
vllm/parser/abstract_parser.py
+10
@@ -367,6 +367,10 @@ class Parser:
367 tool call extraction via internal stream state.
368 """
369
370 + def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
371 + """Return the number of reasoning tokens in generated token IDs."""
372 + return 0
373 +
374
375 class DelegatingParser(Parser):
376 """
@@ -939,6 +943,12 @@ class DelegatingParser(Parser):
943
944 return delta_message
945
946 + def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
947 + """Count reasoning tokens through the configured reasoning parser."""
948 + if self._reasoning_parser is None:
949 + return 0
950 + return self._reasoning_parser.count_reasoning_tokens(token_ids)
951 +
952 def _flush_engine_parsers(
953 self, delta_message: DeltaMessage | None
954 ) -> DeltaMessage | None:
vllm/parser/engine/adapters.py
+19 -1
@@ -47,6 +47,11 @@ class ParserEngineReasoningAdapter(ReasoningParser):
47 def __init__(self, tokenizer: TokenizerLike, *args, **kwargs) -> None:
48 super().__init__(tokenizer, *args, **kwargs)
49 self._parser_engine = self._parser_engine_cls(tokenizer, **kwargs) # type: ignore[call-arg]
50 + self._parser_engine_kwargs = kwargs
51 + self._counting_parser_engine: ParserEngine | None = None
52 + # TODO: Remove once Responses finalization reuses accumulated streaming
53 + # parser results instead of reparsing the complete output.
54 + self._streaming_count_valid = False
55
56 @contextmanager
57 def _skip_tool_parsing(self) -> Iterator[None]:
@@ -71,6 +76,7 @@ class ParserEngineReasoningAdapter(ReasoningParser):
76 model_output: str,
77 request: ChatCompletionRequest | ResponsesRequest,
78 ) -> tuple[str | None, str | None]:
79 + self._streaming_count_valid = False
80 with self._skip_tool_parsing():
81 return self._parser_engine.extract_reasoning(model_output, request)
82
@@ -83,6 +89,7 @@ class ParserEngineReasoningAdapter(ReasoningParser):
89 current_token_ids: Sequence[int],
90 delta_token_ids: Sequence[int],
91 ) -> DeltaMessage | None:
92 + self._streaming_count_valid = True
93 with self._skip_tool_parsing():
94 return self._parser_engine.extract_reasoning_streaming(
95 previous_text,
@@ -122,7 +129,18 @@ class ParserEngineReasoningAdapter(ReasoningParser):
129 return self._parser_engine.get_streaming_fallback_content(text, request)
130
131 def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
125 - return self._parser_engine.count_reasoning_tokens(token_ids)
132 + if self._streaming_count_valid:
133 + return self._parser_engine.count_reasoning_tokens(token_ids)
134 + if not token_ids:
135 + return 0
136 + if self._counting_parser_engine is None:
137 + self._counting_parser_engine = self._parser_engine_cls(
138 + self.model_tokenizer, **self._parser_engine_kwargs
139 + ) # type: ignore[call-arg]
140 + self._counting_parser_engine._single_pass_parse(
141 + self.model_tokenizer.decode(token_ids), token_ids
142 + )
143 + return self._counting_parser_engine.count_reasoning_tokens(token_ids)
144
145
146 class ParserEngineToolAdapter(ToolParser):
vllm/parser/engine/events.py
+1
@@ -24,3 +24,4 @@ class SemanticEvent:
24 type: EventType
25 value: str = ""
26 tool_index: int = -1
27 + token_count: int = 0
vllm/parser/engine/incremental_lexer.py
+81 -8
@@ -24,6 +24,7 @@ class TerminalDef:
24 class LexToken:
25 terminal: str
26 value: str
27 + token_count: int = 0
28
29
30 class LexerShape:
@@ -104,6 +105,7 @@ class IncrementalLexer:
105 self.terminals = shape.terminals
106 self.content_terminal = content_terminal
107 self.buffer = ""
108 + self._token_counts: list[int] = []
109
110 self._literal_strings = shape.literal_strings
111 self._max_literal_len = shape.max_literal_len
@@ -114,15 +116,23 @@ class IncrementalLexer:
116
117 def reset(self) -> None:
118 self.buffer = ""
119 + self._token_counts.clear()
120
118 - def feed(self, text: str) -> list[LexToken]:
121 + def feed(
122 + self,
123 + text: str,
124 + token_texts: tuple[str, ...] = (),
125 + token_count: int = 0,
126 + ) -> list[LexToken]:
127 + char_token_counts = self._char_token_counts(text, token_texts, token_count)
128 if not self.buffer and self._has_only_literals and self._literal_first_chars:
129 for ch in text:
130 if ch in self._literal_first_chars:
131 break
132 else:
124 - return [LexToken(self.content_terminal, text)]
133 + return [LexToken(self.content_terminal, text, sum(char_token_counts))]
134 self.buffer += text
135 + self._token_counts.extend(char_token_counts)
136 return self._drain()
137
138 def flush(self) -> list[LexToken]:
@@ -130,10 +140,46 @@ class IncrementalLexer:
140 if self.buffer:
141 tokens.extend(self._drain(final=True))
142 if self.buffer:
133 - tokens.append(LexToken(self.content_terminal, self.buffer))
143 + tokens.append(
144 + LexToken(self.content_terminal, self.buffer, sum(self._token_counts))
145 + )
146 self.buffer = ""
147 + self._token_counts.clear()
148 return tokens
149
150 + @staticmethod
151 + def _char_token_counts(
152 + text: str,
153 + token_texts: tuple[str, ...],
154 + token_count: int,
155 + ) -> list[int]:
156 + counts = [0] * len(text)
157 + if not text:
158 + return counts
159 + if token_texts:
160 + pos = 0
161 + assigned = 0
162 + for token_text in token_texts:
163 + if not token_text:
164 + continue
165 + found = text.find(token_text, pos)
166 + if found < 0:
167 + continue
168 + counts[found] += 1
169 + assigned += 1
170 + pos = found + len(token_text)
171 + missing = token_count - assigned
172 + if missing > 0:
173 + counts[0] += missing
174 + elif token_count:
175 + counts[0] = token_count
176 + return counts
177 +
178 + def _pop_token_count(self, length: int) -> int:
179 + token_count = sum(self._token_counts[:length])
180 + del self._token_counts[:length]
181 + return token_count
182 +
183 def _drain(self, *, final: bool = False) -> list[LexToken]:
184 tokens: list[LexToken] = []
185 first_chars = self._literal_first_chars
@@ -150,8 +196,11 @@ class IncrementalLexer:
196 has_potential = True
197 break
198 if not has_potential:
153 - tokens.append(LexToken(content_terminal, self.buffer))
199 + tokens.append(
200 + LexToken(content_terminal, self.buffer, sum(self._token_counts))
201 + )
202 self.buffer = ""
203 + self._token_counts.clear()
204 break
205
206 best_match: tuple[str, str, int] | None = None
@@ -175,7 +224,13 @@ class IncrementalLexer:
224 longer_match = True
225 break
226 if not longer_match:
178 - tokens.append(LexToken(best_match[0], best_match[1]))
227 + tokens.append(
228 + LexToken(
229 + best_match[0],
230 + best_match[1],
231 + self._pop_token_count(best_match[2]),
232 + )
233 + )
234 self.buffer = self.buffer[best_match[2] :]
235 continue
236 break
@@ -183,15 +238,33 @@ class IncrementalLexer:
238 break
239
240 if best_match is not None:
186 - tokens.append(LexToken(best_match[0], best_match[1]))
241 + tokens.append(
242 + LexToken(
243 + best_match[0],
244 + best_match[1],
245 + self._pop_token_count(best_match[2]),
246 + )
247 + )
248 self.buffer = self.buffer[best_match[2] :]
249 else:
250 content_end = self._find_content_boundary()
251 if content_end > 0:
191 - tokens.append(LexToken(content_terminal, self.buffer[:content_end]))
252 + tokens.append(
253 + LexToken(
254 + content_terminal,
255 + self.buffer[:content_end],
256 + self._pop_token_count(content_end),
257 + )
258 + )
259 self.buffer = self.buffer[content_end:]
260 else:
194 - tokens.append(LexToken(content_terminal, self.buffer[0]))
261 + tokens.append(
262 + LexToken(
263 + content_terminal,
264 + self.buffer[0],
265 + self._pop_token_count(1),
266 + )
267 + )
268 self.buffer = self.buffer[1:]
269
270 return tokens
vllm/parser/engine/parser_engine.py
+2 -17
@@ -625,23 +625,8 @@ class ParserEngine(Parser):
625 return None
626
627 def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
628 - start_id = self._reasoning_start_token_id
629 - end_id = self._reasoning_end_token_id
630 - if start_id is None or end_id is None:
631 - return 0
632 - count = 0
633 - depth = 0
634 - for token_id in token_ids:
635 - if token_id == start_id:
636 - depth += 1
637 - continue
638 - if token_id == end_id:
639 - if depth > 0:
640 - depth -= 1
641 - continue
642 - if depth > 0:
643 - count += 1
644 - return count
628 + """Return reasoning tokens observed by the parser engine so far."""
629 + return self._engine.reasoning_token_count
630
631 # ── Single-pass parse helper ────────────────────────────────────────
632
vllm/parser/engine/streaming_parser_engine.py
+80 -17
@@ -167,6 +167,17 @@ class StreamingParserEngine:
167 self.skip_tool_parsing = False
168 self.reset(initial_state=initial_state)
169
170 + @property
171 + def reasoning_token_count(self) -> int:
172 + return self._reasoning_token_count
173 +
174 + def _record_reasoning_tokens(self, events: Sequence[SemanticEvent]) -> None:
175 + self._reasoning_token_count += sum(
176 + event.token_count
177 + for event in events
178 + if event.type == EventType.REASONING_CHUNK
179 + )
180 +
181 def _reset_args_state(self) -> None:
182 self._args_buffer: str = ""
183 self._args_safe_end: int = 0
@@ -186,6 +197,7 @@ class StreamingParserEngine:
197 )
198 self.tool_index = -1
199 self._ever_had_token_ids = False
200 + self._reasoning_token_count = 0
201 # DO NOT reset skip_tool_parsing here — callers set it before
202 # calling methods that trigger reset() (e.g. extract_reasoning),
203 # and clearing it silently breaks non-streaming tool-call-as-
@@ -193,6 +205,7 @@ class StreamingParserEngine:
205 self._scanner.reset()
206 self._lexer.reset()
207 self._message_header_buffer = ""
208 + self._message_header_token_count = 0
209 self._in_skipped_tool_span = False
210 self._reset_args_state()
211
@@ -218,18 +231,30 @@ class StreamingParserEngine:
231 has_special = True
232 break
233 if not has_special:
221 - return self._emit_for_state(delta_text)
234 + events = self._emit_for_state(
235 + delta_text, token_count=len(delta_token_ids)
236 + )
237 + self._record_reasoning_tokens(events)
238 + return events
239
240 scanner_items = self._scanner.scan(delta_text, delta_token_ids)
241
242 if len(scanner_items) == 1 and isinstance(scanner_items[0], TextChunk):
226 - lex_tokens = self._lexer.feed(scanner_items[0].text)
243 + item = scanner_items[0]
244 + lex_tokens = self._lexer.feed(item.text, item.token_texts, item.token_count)
245 if len(lex_tokens) == 1 and lex_tokens[0].terminal == CONTENT_TERMINAL:
228 - text = lex_tokens[0].value
229 - return self._emit_for_state(text)
230 - return self._process_lex_tokens(lex_tokens)
246 + events = self._emit_for_state(
247 + lex_tokens[0].value,
248 + token_count=lex_tokens[0].token_count,
249 + )
250 + else:
251 + events = self._process_lex_tokens(lex_tokens)
252 + self._record_reasoning_tokens(events)
253 + return events
254
232 - return self._process_scanner_items(scanner_items)
255 + events = self._process_scanner_items(scanner_items)
256 + self._record_reasoning_tokens(events)
257 + return events
258
259 def _process_scanner_items(
260 self, items: Sequence[LexerInput]
@@ -240,7 +265,18 @@ class StreamingParserEngine:
265 events.extend(self._process_lex_tokens(self._lexer.flush()))
266 events.extend(self._on_terminal(item.terminal, item.text))
267 elif isinstance(item, TextChunk):
243 - events.extend(self._process_lex_tokens(self._lexer.feed(item.text)))
268 + if not item.text and item.token_count:
269 + events.extend(
270 + self._emit_for_state("", token_count=item.token_count)
271 + )
272 + else:
273 + events.extend(
274 + self._process_lex_tokens(
275 + self._lexer.feed(
276 + item.text, item.token_texts, item.token_count
277 + )
278 + )
279 + )
280 return events
281
282 def finish(self) -> list[SemanticEvent]:
@@ -285,11 +321,14 @@ class StreamingParserEngine:
321 EventType.TEXT_CHUNK,
322 value=self._message_header_buffer,
323 tool_index=self.tool_index,
324 + token_count=self._message_header_token_count,
325 )
326 )
327 self._message_header_buffer = ""
328 + self._message_header_token_count = 0
329 self.state = ParserState.CONTENT
330
331 + self._record_reasoning_tokens(events)
332 return events
333
334 def parse_complete(self, text: str) -> list[SemanticEvent]:
@@ -303,9 +342,11 @@ class StreamingParserEngine:
342 strict = self._token_id_terminal_names if self._ever_had_token_ids else None
343 for tok in tokens:
344 if tok.terminal == CONTENT_TERMINAL or (strict and tok.terminal in strict):
306 - events.extend(self._on_content(tok.value))
345 + events.extend(self._on_content(tok.value, tok.token_count))
346 else:
308 - events.extend(self._on_terminal(tok.terminal, tok.value))
347 + events.extend(
348 + self._on_terminal(tok.terminal, tok.value, tok.token_count)
349 + )
350 return events
351
352 _TOOL_STATES = frozenset(
@@ -317,7 +358,9 @@ class StreamingParserEngine:
358 }
359 )
360
320 - def _on_terminal(self, terminal: str, value: str) -> list[SemanticEvent]:
361 + def _on_terminal(
362 + self, terminal: str, value: str, token_count: int = 0
363 + ) -> list[SemanticEvent]:
364 key = (self.state, terminal)
365 transition = self.config.transitions.get(key)
366
@@ -327,7 +370,7 @@ class StreamingParserEngine:
370 # The projected skip state may not define the wrapper closer.
371 if self.skip_tool_parsing and terminal in self._tool_exit_terminals:
372 self._in_skipped_tool_span = False
330 - return self._emit_for_state(value)
373 + return self._emit_for_state(value, token_count)
374
375 if self.skip_tool_parsing and terminal in self._tool_terminals:
376 # Inkling reuses one terminal for tool, text, and reasoning exits.
@@ -345,6 +388,7 @@ class StreamingParserEngine:
388 leaving_message_header = self.state == ParserState.MESSAGE_HEADER
389 if leaving_message_header:
390 self._message_header_buffer = ""
391 + self._message_header_token_count = 0
392 # A tool terminal that implicitly ends reasoning must report
393 # that even from the header state, or the reasoning pass never
394 # hands the block to the tool pass.
@@ -381,13 +425,14 @@ class StreamingParserEngine:
425 return []
426
427 if transition.skip_in_token_id_mode and self._ever_had_token_ids:
384 - return self._emit_for_state(value)
428 + return self._emit_for_state(value, token_count)
429
386 - return self._apply_transition(transition, value)
430 + return self._apply_transition(transition, value, token_count)
431
388 - def _emit_for_state(self, text: str) -> list[SemanticEvent]:
432 + def _emit_for_state(self, text: str, token_count: int = 0) -> list[SemanticEvent]:
433 if self.state == ParserState.MESSAGE_HEADER:
434 self._message_header_buffer += text
435 + self._message_header_token_count += token_count
436 return []
437 if self.state == ParserState.TOOL_ARGS:
438 if self.config.tool_args_json:
@@ -397,26 +442,36 @@ class StreamingParserEngine:
442 EventType.ARG_VALUE_CHUNK,
443 value=text,
444 tool_index=self.tool_index,
445 + token_count=token_count,
446 )
447 ]
448 content_type = self.config.content_events.get(self.state)
449 if content_type is not None:
404 - return [SemanticEvent(content_type, value=text, tool_index=self.tool_index)]
450 + return [
451 + SemanticEvent(
452 + content_type,
453 + value=text,
454 + tool_index=self.tool_index,
455 + token_count=token_count,
456 + )
457 + ]
458 return []
459
407 - def _on_content(self, text: str) -> list[SemanticEvent]:
460 + def _on_content(self, text: str, token_count: int = 0) -> list[SemanticEvent]:
461 if not text:
462 return []
410 - return self._emit_for_state(text)
463 + return self._emit_for_state(text, token_count)
464
465 def _apply_transition(
466 self,
467 transition: Transition,
468 value: str,
469 + token_count: int = 0,
470 ) -> list[SemanticEvent]:
471 events: list[SemanticEvent] = []
472 previous_state = self.state
473 message_header = ""
474 + message_header_token_count = 0
475
476 if (
477 self.state == ParserState.TOOL_ARGS
@@ -434,7 +489,9 @@ class StreamingParserEngine:
489
490 if previous_state == ParserState.MESSAGE_HEADER:
491 message_header = self._message_header_buffer
492 + message_header_token_count = self._message_header_token_count
493 self._message_header_buffer = ""
494 + self._message_header_token_count = 0
495
496 self.state = transition.next_state
497
@@ -454,6 +511,12 @@ class StreamingParserEngine:
511 event_type,
512 value=event_value,
513 tool_index=self.tool_index,
514 + token_count=(
515 + message_header_token_count
516 + if previous_state == ParserState.MESSAGE_HEADER
517 + and event_type == EventType.TEXT_CHUNK
518 + else token_count
519 + ),
520 )
521 )
522
vllm/parser/engine/token_id_scanner.py
+121 -17
@@ -14,6 +14,8 @@ DROP_TERMINAL = "__DROP__"
14 @dataclass(slots=True)
15 class TextChunk:
16 text: str
17 + token_texts: tuple[str, ...] = ()
18 + token_count: int = 0
19
20
21 @dataclass(slots=True)
@@ -49,18 +51,30 @@ class TokenIDScanner:
51 self.tokenizer = tokenizer
52 self._token_text_cache: dict[int, str] = {}
53 self._deferred_terminals: list[PreLexedTerminal] = []
54 + self._deferred_prefix_token_counts: list[int] = []
55 + self._deferred_trailing_token_count = 0
56 self._deferred_post_text: str = ""
57
58 def reset(self) -> None:
59 """Clear mutable state for reuse. Preserves the token text cache."""
60 self._deferred_terminals.clear()
61 + self._deferred_prefix_token_counts.clear()
62 + self._deferred_trailing_token_count = 0
63 self._deferred_post_text = ""
64
65 def _decode_token(self, token_id: int) -> str:
66 if token_id not in self._token_text_cache:
61 - self._token_text_cache[token_id] = self.tokenizer.decode([token_id])
67 + token_text = self.tokenizer.decode([token_id])
68 + self._token_text_cache[token_id] = (
69 + token_text if isinstance(token_text, str) else ""
70 + )
71 return self._token_text_cache[token_id]
72
73 + def _decode_tokens(self, token_ids: Sequence[int]) -> tuple[str, ...]:
74 + if self.tokenizer is None:
75 + return ()
76 + return tuple(self._decode_token(tid) for tid in token_ids)
77 +
78 _EMPTY: tuple[LexerInput, ...] = ()
79
80 def scan(
@@ -70,13 +84,23 @@ class TokenIDScanner:
84 ) -> Sequence[LexerInput]:
85 prefix_items: list[LexerInput] = []
86 effective_text = delta_text
87 + deferred_trailing_count = 0
88
89 if self._deferred_terminals:
75 - prefix_items, effective_text = self._resolve_deferred(delta_text)
90 + prefix_items, effective_text, deferred_trailing_count = (
91 + self._resolve_deferred(delta_text, len(delta_token_ids))
92 + )
93
94 if not self.token_id_to_terminal:
95 if effective_text:
79 - prefix_items.append(TextChunk(effective_text))
96 + token_texts = self._decode_tokens(delta_token_ids)
97 + prefix_items.append(
98 + TextChunk(
99 + effective_text,
100 + token_texts,
101 + deferred_trailing_count + len(token_texts),
102 + )
103 + )
104 return prefix_items
105
106 has_special = False
@@ -89,14 +113,23 @@ class TokenIDScanner:
113 if not has_special:
114 if effective_text:
115 if not prefix_items:
92 - return [TextChunk(effective_text)]
93 - prefix_items.append(TextChunk(effective_text))
116 + token_texts = self._decode_tokens(delta_token_ids)
117 + return [TextChunk(effective_text, token_texts, len(token_texts))]
118 + token_texts = self._decode_tokens(delta_token_ids)
119 + prefix_items.append(
120 + TextChunk(
121 + effective_text,
122 + token_texts,
123 + deferred_trailing_count + len(token_texts),
124 + )
125 + )
126 return prefix_items or self._EMPTY
127
96 - token_texts = [self._decode_token(tid) for tid in delta_token_ids]
128 + decoded_token_texts = [self._decode_token(tid) for tid in delta_token_ids]
129
130 results: list[LexerInput] = []
131 text_accum: list[str] = []
132 + token_text_accum: list[str] = []
133
134 for idx, tid in enumerate(delta_token_ids):
135 terminal = self.token_id_to_terminal.get(tid)
@@ -104,16 +137,31 @@ class TokenIDScanner:
137 if text_accum:
138 joined = "".join(text_accum)
139 if joined:
107 - results.append(TextChunk(joined))
140 + results.append(
141 + TextChunk(
142 + joined,
143 + tuple(token_text_accum),
144 + len(token_text_accum),
145 + )
146 + )
147 text_accum.clear()
109 - results.append(PreLexedTerminal(terminal, tid, token_texts[idx]))
148 + token_text_accum.clear()
149 + results.append(
150 + PreLexedTerminal(terminal, tid, decoded_token_texts[idx])
151 + )
152 else:
111 - text_accum.append(token_texts[idx])
153 + text_accum.append(decoded_token_texts[idx])
154 + token_text_accum.append(decoded_token_texts[idx])
155
156 if text_accum:
157 joined = "".join(text_accum)
158 if joined:
116 - results.append(TextChunk(joined))
159 + results.append(
160 + TextChunk(joined, tuple(token_text_accum), len(token_text_accum))
161 + )
162 +
163 + if deferred_trailing_count:
164 + results.insert(0, TextChunk("", token_count=deferred_trailing_count))
165
166 if effective_text:
167 results = self._recover_holdback_text(effective_text, results)
@@ -124,9 +172,15 @@ class TokenIDScanner:
172 # transition before the preceding text has arrived. The
173 # deferred terminals will be resolved against the actual
174 # delta_text in a subsequent scan() or flushed by finish().
175 + prefix_token_count = 0
176 for r in results:
177 + if isinstance(r, TextChunk):
178 + prefix_token_count += r.token_count
179 if isinstance(r, PreLexedTerminal):
180 self._deferred_terminals.append(r)
181 + self._deferred_prefix_token_counts.append(prefix_token_count)
182 + prefix_token_count = 0
183 + self._deferred_trailing_token_count += prefix_token_count
184 results = []
185
186 return prefix_items + results
@@ -136,16 +190,30 @@ class TokenIDScanner:
190 return []
191 results: list[LexerInput] = []
192 if self._deferred_post_text:
139 - results.append(TextChunk(self._deferred_post_text))
193 + prefix_count = (
194 + self._deferred_prefix_token_counts[0]
195 + if self._deferred_prefix_token_counts
196 + else 0
197 + )
198 + results.append(
199 + TextChunk(self._deferred_post_text, token_count=prefix_count)
200 + )
201 self._deferred_post_text = ""
202 results.extend(self._deferred_terminals)
203 + if self._deferred_trailing_token_count:
204 + results.append(
205 + TextChunk("", token_count=self._deferred_trailing_token_count)
206 + )
207 self._deferred_terminals.clear()
208 + self._deferred_prefix_token_counts.clear()
209 + self._deferred_trailing_token_count = 0
210 return results
211
212 def _resolve_deferred(
213 self,
214 delta_text: str,
148 - ) -> tuple[list[LexerInput], str]:
215 + current_token_count: int = 0,
216 + ) -> tuple[list[LexerInput], str, int]:
217 """Resolve deferred terminals against new delta_text.
218
219 When a previous ``scan()`` deferred a terminal (its text hadn't
@@ -160,7 +228,11 @@ class TokenIDScanner:
228 should be scanned with the current delta's token IDs.
229 """
230 deferred = self._deferred_terminals
231 + prefix_token_counts = self._deferred_prefix_token_counts
232 + trailing_token_count = self._deferred_trailing_token_count
233 self._deferred_terminals = []
234 + self._deferred_prefix_token_counts = []
235 + self._deferred_trailing_token_count = 0
236
237 results: list[LexerInput] = []
238 remaining = delta_text
@@ -171,10 +243,13 @@ class TokenIDScanner:
243
244 # Duplicate-text deferred terminals resolve left-to-right via
245 # find(); correct when each terminal text appears once in sequence.
174 - for terminal in deferred:
246 + for idx, terminal in enumerate(deferred):
247 + prefix_token_count = prefix_token_counts[idx]
248 pos = remaining.find(terminal.text)
249 if pos > 0:
177 - results.append(TextChunk(remaining[:pos]))
250 + results.append(
251 + TextChunk(remaining[:pos], token_count=prefix_token_count)
252 + )
253 results.append(terminal)
254 remaining = remaining[pos + len(terminal.text) :]
255 elif pos == 0:
@@ -185,10 +260,16 @@ class TokenIDScanner:
260 # only the terminal provides a reliable split point.
261 if remaining:
262 self._deferred_post_text += remaining
263 + prefix_token_count += current_token_count
264 remaining = ""
265 self._deferred_terminals.append(terminal)
266 + self._deferred_prefix_token_counts.append(prefix_token_count)
267 +
268 + if self._deferred_terminals:
269 + self._deferred_trailing_token_count = trailing_token_count
270 + trailing_token_count = 0
271
191 - return results, remaining
272 + return results, remaining, trailing_token_count
273
274 def _recover_holdback_text(
275 self,
@@ -257,6 +338,16 @@ class TokenIDScanner:
338 if not anchors:
339 return [TextChunk(delta_text)]
340
341 + token_groups: list[list[str]] = [[] for _ in range(len(anchors) + 1)]
342 + count_groups = [0] * (len(anchors) + 1)
343 + group_idx = 0
344 + for item in results:
345 + if isinstance(item, PreLexedTerminal):
346 + group_idx += 1
347 + else:
348 + token_groups[group_idx].extend(item.token_texts)
349 + count_groups[group_idx] += item.token_count
350 +
351 # Resolve positions right-to-left: each anchor gets the
352 # rightmost occurrence that is still before the next anchor.
353 positions: list[int] = [-1] * len(anchors)
@@ -274,7 +365,13 @@ class TokenIDScanner:
365 pos = positions[i]
366 if pos >= consumed:
367 if pos > consumed:
277 - new_results.append(TextChunk(delta_text[consumed:pos]))
368 + new_results.append(
369 + TextChunk(
370 + delta_text[consumed:pos],
371 + tuple(token_groups[i]),
372 + count_groups[i],
373 + )
374 + )
375 new_results.append(anchor)
376 consumed = pos + len(anchor.text)
377 else:
@@ -291,6 +388,13 @@ class TokenIDScanner:
388 self._deferred_post_text += delta_text[consumed:]
389 consumed = len(delta_text)
390 self._deferred_terminals.append(anchor)
391 + self._deferred_prefix_token_counts.append(count_groups[i])
392 if consumed < len(delta_text):
295 - new_results.append(TextChunk(delta_text[consumed:]))
393 + new_results.append(
394 + TextChunk(
395 + delta_text[consumed:],
396 + tuple(token_groups[-1]),
397 + count_groups[-1],
398 + )
399 + )
400 return new_results
vllm/parser/parser_manager.py
+30 -4
@@ -9,6 +9,7 @@ from vllm.logger import init_logger
9
10 if TYPE_CHECKING:
11 from vllm.parser.abstract_parser import Parser
12 + from vllm.parser.engine.parser_engine import ParserEngine
13 from vllm.reasoning import ReasoningParser
14 from vllm.tool_parsers import ToolParser
15
@@ -17,10 +18,30 @@ logger = init_logger(__name__)
18
19 class ParserManager:
20 """
20 - Provides a unified Parser by composing individual reasoning and tool
21 - parsers from their respective registries.
21 + Provides a unified Parser from the reasoning and tool parser registries.
22 +
23 + Parser engine adapters backed by the same engine are collapsed back into
24 + that engine. Other parser pairs are composed through ``DelegatingParser``.
25 """
26
27 + @staticmethod
28 + def _get_parser_engine_cls(
29 + parser_cls: type[object] | None,
30 + ) -> type[ParserEngine] | None:
31 + if parser_cls is None:
32 + return None
33 + parser_engine_cls = getattr(parser_cls, "_parser_engine_cls", None)
34 + if parser_engine_cls is None:
35 + return None
36 +
37 + from vllm.parser.engine.parser_engine import ParserEngine
38 +
39 + if not isinstance(parser_engine_cls, type) or not issubclass(
40 + parser_engine_cls, ParserEngine
41 + ):
42 + return None
43 + return parser_engine_cls
44 +
45 @classmethod
46 def get_tool_parser(
47 cls,
@@ -84,8 +105,8 @@ class ParserManager:
105 """
106 Get a Parser that handles both reasoning and tool parsing.
107
87 - Composes individual reasoning and tool parsers into a single
88 - DelegatingParser subclass.
108 + Reuses a shared parser engine when possible, otherwise composes the
109 + individual parsers into a ``DelegatingParser`` subclass.
110
111 Args:
112 tool_parser_name: The name of the tool parser.
@@ -116,6 +137,11 @@ class ParserManager:
137 HarmonyParser.tool_parser_cls = tool_parser_cls
138 return HarmonyParser
139
140 + reasoning_engine_cls = cls._get_parser_engine_cls(reasoning_parser_cls)
141 + tool_engine_cls = cls._get_parser_engine_cls(tool_parser_cls)
142 + if reasoning_engine_cls is not None and reasoning_engine_cls is tool_engine_cls:
143 + return reasoning_engine_cls
144 +
145 if reasoning_parser_name == "kimi_k3" or tool_parser_name == "kimi_k3":
146 from vllm.parser.kimi_k3 import KimiK3Parser
147