diff --git a/tests/entrypoints/openai/chat_completion/test_serving_chat.py b/tests/entrypoints/openai/chat_completion/test_serving_chat.py
index 1d6919e9c8..debf547593 100644
--- a/tests/entrypoints/openai/chat_completion/test_serving_chat.py
+++ b/tests/entrypoints/openai/chat_completion/test_serving_chat.py
@@ -28,9 +28,11 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
from vllm.entrypoints.openai.chat_completion.serving import (
OpenAIServingChat,
_get_mm_token_counts,
+ _make_completion_tokens_details,
_make_prompt_tokens_details,
)
from vllm.entrypoints.openai.engine.protocol import (
+ DeltaMessage,
ErrorResponse,
RequestResponseMetadata,
)
@@ -626,6 +628,7 @@ def _build_minimal_metrics_serving_chat(
serving.response_role = "assistant"
serving.parser_cls = None
serving.enable_auto_tools = False
+ serving._include_reasoning_tokens_details = False
serving.enable_prompt_tokens_details = False
serving.enable_log_outputs = False
serving.enable_log_deltas = False
@@ -666,6 +669,13 @@ async def _single_request_output(
yield request_output
+async def _stream_request_outputs(
+ *request_outputs: RequestOutput,
+) -> AsyncIterator[RequestOutput]:
+ for request_output in request_outputs:
+ yield request_output
+
+
async def _collect_metrics_stream_chunks(
serving: OpenAIServingChat,
request: ChatCompletionRequest,
@@ -724,6 +734,7 @@ async def test_chat_per_request_metrics_follow_server_flag():
request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"),
)
assert disabled_response.metrics is None
+ assert disabled_response.usage.completion_tokens_details is None
enabled_serving = _build_minimal_metrics_serving_chat(
enable_per_request_metrics=True
@@ -781,6 +792,55 @@ async def test_chat_streaming_metrics_ride_on_usage_chunk():
assert usage_chunks[-1]["metrics"]["time_to_first_token_ms"] == pytest.approx(500.0)
+@pytest.mark.asyncio
+async def test_streaming_reasoning_usage_counts_across_deltas():
+ serving = _build_minimal_metrics_serving_chat(enable_per_request_metrics=False)
+ serving._include_reasoning_tokens_details = True
+ serving.model_config = None
+
+ parser = MagicMock()
+ parser.parse_delta.side_effect = [
+ DeltaMessage(reasoning="reasoning"),
+ DeltaMessage(content="answer"),
+ ]
+ parser.count_reasoning_tokens.side_effect = lambda token_ids: sum(
+ token_id == 20 for token_id in token_ids
+ )
+ serving.parser_cls = MagicMock(return_value=parser)
+
+ first = _make_metrics_request_output(metrics=None, token_ids=(10, 20))
+ first.outputs[0].text = "reasoning"
+ first.outputs[0].finish_reason = None
+ second = _make_metrics_request_output(metrics=None, token_ids=(11, 30))
+ second.outputs[0].text = "answer"
+
+ request = ChatCompletionRequest(
+ model="test-model",
+ messages=[{"role": "user", "content": "Test prompt"}],
+ max_tokens=10,
+ stream=True,
+ stream_options={"include_usage": True},
+ )
+ chunks: list[dict[str, Any]] = []
+ async for line in serving.chat_completion_stream_generator(
+ request,
+ _stream_request_outputs(first, second),
+ "chatcmpl-test-id",
+ "test-model",
+ conversation=[{"role": "user", "content": "Test"}],
+ tokenizer=MagicMock(),
+ request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"),
+ ):
+ payload = line.removeprefix("data: ").strip()
+ if payload != "[DONE]":
+ chunks.append(json.loads(payload))
+
+ usage_chunks = [chunk for chunk in chunks if chunk.get("usage")]
+ assert usage_chunks[-1]["usage"]["completion_tokens_details"] == {
+ "reasoning_tokens": 1
+ }
+
+
@dataclass
class MockEngine:
model_config: MockModelConfig = field(default_factory=MockModelConfig)
@@ -810,6 +870,7 @@ async def _async_serving_chat_init():
def test_async_serving_chat_init():
serving_completion = asyncio.run(_async_serving_chat_init())
assert serving_completion.chat_template == CHAT_TEMPLATE
+ assert serving_completion._include_reasoning_tokens_details is False
def test_mm_prompt_tokens_details():
@@ -850,6 +911,10 @@ def test_mm_prompt_tokens_details():
assert details.multimodal_tokens == {"image": 600, "video": 1200}
+def test_completion_tokens_details():
+ assert _make_completion_tokens_details(7).reasoning_tokens == 7
+
+
@pytest.mark.asyncio
async def test_serving_chat_returns_correct_model_name():
mock_engine = MagicMock(spec=AsyncLLM)
diff --git a/tests/entrypoints/openai/responses/test_serving_responses.py b/tests/entrypoints/openai/responses/test_serving_responses.py
index 48b68e96d8..51fea46623 100644
--- a/tests/entrypoints/openai/responses/test_serving_responses.py
+++ b/tests/entrypoints/openai/responses/test_serving_responses.py
@@ -360,6 +360,10 @@ async def test_reasoning_tokens_counted_for_text_reasoning_model(monkeypatch):
def get_vocab(self):
return self._vocab
+ def decode(self, token_ids):
+ id_to_token = {v: k for k, v in self._vocab.items()}
+ return "".join(id_to_token.get(token_id, "x") for token_id in token_ids)
+
# Force non-harmony, SimpleContext path
monkeypatch.setattr(envs, "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", False)
diff --git a/tests/parser/engine/test_engine.py b/tests/parser/engine/test_engine.py
index 6da4c428e0..07f5455718 100644
--- a/tests/parser/engine/test_engine.py
+++ b/tests/parser/engine/test_engine.py
@@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for the streaming parser engine core pipeline."""
+from dataclasses import replace
from unittest.mock import MagicMock
import pytest
@@ -71,6 +72,16 @@ def _think_config() -> ParserEngineConfig:
)
+def _token_think_config() -> ParserEngineConfig:
+ return replace(
+ _think_config(),
+ token_id_terminals={
+ "THINK_START": "",
+ "THINK_END": "",
+ },
+ )
+
+
class TestNonStreaming:
def test_plain_text(self):
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
@@ -441,6 +452,46 @@ class TestTokenIdFiltering:
assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_END) == 1
+class TestReasoningTokenCounts:
+ def test_counts_reasoning_and_excludes_boundaries_and_content(self):
+ engine = StreamingParserEngine(_token_think_config(), _make_think_tokenizer())
+
+ events = engine.feed(
+ "tok1tok2tok3",
+ [_START_ID, 1, 2, _END_ID, 3],
+ )
+ events.extend(engine.finish())
+
+ reasoning = [e for e in events if e.type == EventType.REASONING_CHUNK]
+ assert "".join(e.value for e in reasoning) == "tok1tok2"
+ assert sum(e.token_count for e in reasoning) == 2
+ assert engine.reasoning_token_count == 2
+
+ def test_counts_tokens_after_final_deferred_start_terminal(self):
+ engine = StreamingParserEngine(_token_think_config(), _make_think_tokenizer())
+
+ assert engine.feed("", [_START_ID, 1, 2]) == []
+ events = engine.feed("tok1tok2", [])
+
+ reasoning = [e for e in events if e.type == EventType.REASONING_CHUNK]
+ assert "".join(e.value for e in reasoning) == "tok1tok2"
+ assert sum(e.token_count for e in reasoning) == 2
+ assert engine.reasoning_token_count == 2
+
+ def test_deferred_reasoning_tokens_stay_before_next_end_terminal(self):
+ engine = StreamingParserEngine(_token_think_config(), _make_think_tokenizer())
+
+ assert engine.feed("", [_START_ID, 1, 2]) == []
+ events = engine.feed("tok1tok2tok3", [_END_ID, 3])
+
+ assert (
+ sum(e.token_count for e in events if e.type == EventType.REASONING_CHUNK)
+ == 2
+ )
+ assert sum(e.token_count for e in events if e.type == EventType.TEXT_CHUNK) == 1
+ assert engine.reasoning_token_count == 2
+
+
def _func_prefix_config() -> ParserEngineConfig:
"""Config mixing token-ID terminals (TOOL_START/END) with
text-only terminals (FUNC_PREFIX) and fallback transitions."""
diff --git a/tests/parser/engine/test_parser_engine.py b/tests/parser/engine/test_parser_engine.py
index 8616aac381..37c9e9734f 100644
--- a/tests/parser/engine/test_parser_engine.py
+++ b/tests/parser/engine/test_parser_engine.py
@@ -33,6 +33,7 @@ from vllm.parser.engine.parser_engine_config import (
ParserState,
Transition,
)
+from vllm.parser.parser_manager import ParserManager
# ── Shared test configs ──────────────────────────────────────────────
@@ -892,6 +893,129 @@ class _CombinedDelegating(DelegatingParser):
tool_parser_cls = _CombinedToolAdapter
+def test_parser_manager_uses_shared_engine_directly(monkeypatch):
+ monkeypatch.setattr(
+ ParserManager,
+ "get_reasoning_parser",
+ classmethod(lambda cls, name: _CombinedReasoningAdapter),
+ )
+ monkeypatch.setattr(
+ ParserManager,
+ "get_tool_parser",
+ classmethod(lambda cls, name, enabled, model: _CombinedToolAdapter),
+ )
+
+ parser_cls = ParserManager.get_parser(
+ tool_parser_name="combined",
+ reasoning_parser_name="combined",
+ enable_auto_tools=True,
+ )
+
+ assert parser_cls is not None
+ assert parser_cls is _CombinedTestEngine
+ parser = parser_cls(make_mock_tokenizer(_VOCAB))
+ request = _make_delegating_request()
+ reasoning, content, _ = parser.parse(
+ "abc",
+ request,
+ model_output_token_ids=[ord("a"), ord("b"), 201, ord("c")],
+ )
+ assert reasoning == "ab"
+ assert content == "c"
+ assert parser.count_reasoning_tokens([]) == 2
+
+
+def test_parser_manager_preserves_reasoning_only_adapter(monkeypatch):
+ monkeypatch.setattr(
+ ParserManager,
+ "get_reasoning_parser",
+ classmethod(lambda cls, name: _CombinedReasoningAdapter),
+ )
+ monkeypatch.setattr(
+ ParserManager,
+ "get_tool_parser",
+ classmethod(lambda cls, name, enabled, model: None),
+ )
+
+ parser_cls = ParserManager.get_parser(reasoning_parser_name="combined")
+
+ assert parser_cls is not None
+ parser = parser_cls(make_mock_tokenizer(_VOCAB))
+ assert parser.reasoning_parser is not None
+ assert parser.tool_parser is None
+ reasoning, content, _ = parser.parse(
+ 'ab{"name":"h","arguments":{}}',
+ _make_delegating_request(),
+ model_output_token_ids=[ord("a"), ord("b"), 201],
+ )
+ assert reasoning == "ab"
+ assert content == '{"name":"h","arguments":{}}'
+ assert parser.count_reasoning_tokens([ord("a"), ord("b"), 201]) == 2
+
+
+def test_parser_manager_preserves_tool_only_adapter(monkeypatch):
+ monkeypatch.setattr(
+ ParserManager,
+ "get_reasoning_parser",
+ classmethod(lambda cls, name: None),
+ )
+ monkeypatch.setattr(
+ ParserManager,
+ "get_tool_parser",
+ classmethod(lambda cls, name, enabled, model: _CombinedToolAdapter),
+ )
+
+ parser_cls = ParserManager.get_parser(
+ tool_parser_name="combined", enable_auto_tools=True
+ )
+
+ assert parser_cls is not None
+ parser = parser_cls(make_mock_tokenizer(_VOCAB))
+ assert parser.reasoning_parser is None
+ assert parser.tool_parser is not None
+ reasoning, content, tool_calls = parser.parse(
+ "abc",
+ _make_delegating_request(),
+ model_output_token_ids=[200, ord("a"), ord("b"), 201, ord("c")],
+ )
+ assert reasoning is None
+ assert content == "abc"
+ assert tool_calls == []
+
+
+def test_parser_manager_rejects_non_engine_adapter_metadata():
+ class TraditionalParser:
+ pass
+
+ class InvalidEngineAdapter:
+ _parser_engine_cls = TraditionalParser
+
+ assert ParserManager._get_parser_engine_cls(TraditionalParser) is None
+ assert ParserManager._get_parser_engine_cls(InvalidEngineAdapter) is None
+ assert (
+ ParserManager._get_parser_engine_cls(_CombinedReasoningAdapter)
+ is _CombinedTestEngine
+ )
+
+
+def test_reasoning_adapter_counts_after_final_non_streaming_parse():
+ parser = _CombinedReasoningAdapter(make_mock_tokenizer(_VOCAB))
+ request = _make_delegating_request()
+ token_ids = [ord("a"), ord("b"), 201, ord("c")]
+
+ parser.extract_reasoning_streaming(
+ "",
+ "abc",
+ "abc",
+ [],
+ token_ids,
+ token_ids,
+ )
+ parser.extract_reasoning("abc", request)
+
+ assert parser.count_reasoning_tokens(token_ids) == 2
+
+
def _make_delegating_request():
req = MagicMock(spec=ChatCompletionRequest)
req.tools = []
diff --git a/tests/parser/engine/test_token_id_scanner.py b/tests/parser/engine/test_token_id_scanner.py
index 2153778430..c37a46793a 100644
--- a/tests/parser/engine/test_token_id_scanner.py
+++ b/tests/parser/engine/test_token_id_scanner.py
@@ -163,9 +163,28 @@ class TestHoldbackTextRecovery:
assert len(result) == 0
flushed = scanner.flush_pending()
- assert len(flushed) == 1
+ assert len(flushed) == 2
assert isinstance(flushed[0], PreLexedTerminal)
assert flushed[0].terminal == "TOOL_START"
+ assert isinstance(flushed[1], TextChunk)
+ assert flushed[1].text == ""
+ assert flushed[1].token_count == 2
+
+ def test_deferred_terminal_preserves_trailing_token_count(self, tokenizer):
+ tokenizer.decode.side_effect = lambda ids: {
+ CHANNEL_START_ID: CHANNEL_START,
+ 201: "alpha",
+ 202: "beta",
+ }[ids[0]]
+ scanner = TokenIDScanner({CHANNEL_START_ID: "THINK_START"}, tokenizer)
+
+ assert scanner.scan("", [CHANNEL_START_ID, 201, 202]) == []
+
+ result = scanner.scan(f"{CHANNEL_START}alphabeta", [])
+ assert isinstance(result[0], PreLexedTerminal)
+ assert isinstance(result[1], TextChunk)
+ assert result[1].text == "alphabeta"
+ assert result[1].token_count == 2
def test_holdback_before_start_tag(self, scanner):
result = scanner.scan(
diff --git a/vllm/entrypoints/openai/chat_completion/serving.py b/vllm/entrypoints/openai/chat_completion/serving.py
index 12c028276a..28ec1e8a1a 100644
--- a/vllm/entrypoints/openai/chat_completion/serving.py
+++ b/vllm/entrypoints/openai/chat_completion/serving.py
@@ -36,6 +36,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
ChatMessage,
)
from vllm.entrypoints.openai.engine.protocol import (
+ CompletionTokenUsageInfo,
DeltaMessage,
ErrorResponse,
FunctionCall,
@@ -105,6 +106,12 @@ def _make_prompt_tokens_details(
)
+def _make_completion_tokens_details(
+ reasoning_tokens: int,
+) -> CompletionTokenUsageInfo:
+ return CompletionTokenUsageInfo(reasoning_tokens=reasoning_tokens)
+
+
class OpenAIServingChat(GenerateBaseServing):
def __init__(
self,
@@ -146,6 +153,7 @@ class OpenAIServingChat(GenerateBaseServing):
self.enable_log_deltas = enable_log_deltas
self.enable_auto_tools: bool = enable_auto_tools
+ self._include_reasoning_tokens_details = bool(reasoning_parser)
self.parser_cls = ParserManager.get_parser(
tool_parser_name=tool_parser,
reasoning_parser_name=reasoning_parser,
@@ -439,6 +447,9 @@ class OpenAIServingChat(GenerateBaseServing):
# Send response for each token for each request.n (index)
num_choices = 1 if request.n is None else request.n
previous_num_tokens = [0] * num_choices
+ # TODO: Remove once all reasoning parsers use the Parser Engine.
+ generated_token_ids: list[list[int]] = [[] for _ in range(num_choices)]
+ previous_reasoning_tokens = [0] * num_choices
finish_reason_sent = [False] * num_choices
num_prompt_tokens = 0
num_cached_tokens = None
@@ -537,6 +548,11 @@ class OpenAIServingChat(GenerateBaseServing):
prompt_tokens=num_prompt_tokens,
completion_tokens=0,
total_tokens=num_prompt_tokens,
+ completion_tokens_details=(
+ _make_completion_tokens_details(0)
+ if self._include_reasoning_tokens_details
+ else None
+ ),
)
data = chunk.model_dump_json(exclude_unset=True)
@@ -573,6 +589,11 @@ class OpenAIServingChat(GenerateBaseServing):
prompt_tokens=num_prompt_tokens,
completion_tokens=0,
total_tokens=num_prompt_tokens,
+ completion_tokens_details=(
+ _make_completion_tokens_details(0)
+ if self._include_reasoning_tokens_details
+ else None
+ ),
)
data = chunk.model_dump_json(exclude_unset=True)
@@ -631,6 +652,11 @@ class OpenAIServingChat(GenerateBaseServing):
# set the previous values for the next iteration
previous_num_tokens[i] += len(output.token_ids)
+ if parser is not None:
+ generated_token_ids[i].extend(output.token_ids)
+ previous_reasoning_tokens[i] = parser.count_reasoning_tokens(
+ tuple(generated_token_ids[i])
+ )
# if the message delta is None (e.g. because it was a
# "control token" for tool calls or the parser otherwise
@@ -756,6 +782,13 @@ class OpenAIServingChat(GenerateBaseServing):
prompt_tokens=num_prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=num_prompt_tokens + completion_tokens,
+ completion_tokens_details=(
+ _make_completion_tokens_details(
+ previous_reasoning_tokens[i]
+ )
+ if self._include_reasoning_tokens_details
+ else None
+ ),
)
data = chunk.model_dump_json(exclude_unset=True)
@@ -769,6 +802,11 @@ class OpenAIServingChat(GenerateBaseServing):
prompt_tokens=num_prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=num_prompt_tokens + completion_tokens,
+ completion_tokens_details=_make_completion_tokens_details(
+ sum(previous_reasoning_tokens)
+ )
+ if self._include_reasoning_tokens_details
+ else None,
)
final_usage.prompt_tokens_details = _make_prompt_tokens_details(
self.enable_prompt_tokens_details,
@@ -813,6 +851,11 @@ class OpenAIServingChat(GenerateBaseServing):
prompt_tokens=num_prompt_tokens,
completion_tokens=num_completion_tokens,
total_tokens=num_prompt_tokens + num_completion_tokens,
+ completion_tokens_details=_make_completion_tokens_details(
+ sum(previous_reasoning_tokens)
+ )
+ if self._include_reasoning_tokens_details
+ else None,
)
# Log complete streaming response if output logging is enabled
@@ -871,6 +914,7 @@ class OpenAIServingChat(GenerateBaseServing):
)
choices: list[ChatCompletionResponseChoice] = []
+ total_reasoning_tokens = 0
role = self.get_chat_request_role(request)
tool_parser_cls = (
@@ -908,6 +952,7 @@ class OpenAIServingChat(GenerateBaseServing):
suppress_metadata = not request.include_reasoning and parser is not None
if not request.include_reasoning:
reasoning = None
+ total_reasoning_tokens += parser.count_reasoning_tokens(token_ids)
if suppress_metadata:
logprobs = None
else:
@@ -1058,6 +1103,11 @@ class OpenAIServingChat(GenerateBaseServing):
prompt_tokens=num_prompt_tokens,
completion_tokens=num_generated_tokens,
total_tokens=num_prompt_tokens + num_generated_tokens,
+ completion_tokens_details=_make_completion_tokens_details(
+ total_reasoning_tokens
+ )
+ if self._include_reasoning_tokens_details
+ else None,
)
usage.prompt_tokens_details = _make_prompt_tokens_details(
self.enable_prompt_tokens_details,
diff --git a/vllm/entrypoints/openai/engine/protocol.py b/vllm/entrypoints/openai/engine/protocol.py
index 4d07c29408..dded1261dc 100644
--- a/vllm/entrypoints/openai/engine/protocol.py
+++ b/vllm/entrypoints/openai/engine/protocol.py
@@ -117,11 +117,16 @@ class PromptTokenUsageInfo(OpenAIBaseModel):
request has no multimodal input."""
+class CompletionTokenUsageInfo(OpenAIBaseModel):
+ reasoning_tokens: int = 0
+
+
class UsageInfo(OpenAIBaseModel):
prompt_tokens: int = 0
total_tokens: int = 0
completion_tokens: int | None = 0
prompt_tokens_details: PromptTokenUsageInfo | None = None
+ completion_tokens_details: CompletionTokenUsageInfo | None = None
class PerRequestTimingMetrics(OpenAIBaseModel):
diff --git a/vllm/entrypoints/openai/responses/serving.py b/vllm/entrypoints/openai/responses/serving.py
index 9d8c5dafb9..20a331f42a 100644
--- a/vllm/entrypoints/openai/responses/serving.py
+++ b/vllm/entrypoints/openai/responses/serving.py
@@ -487,6 +487,7 @@ class OpenAIServingResponses(GenerateBaseServing):
response_parser=response_parser,
)
+ reasoning_parser_kwargs = None
if (
context.response_parser is not None
and context.response_parser.reasoning_parser is not None
@@ -518,9 +519,7 @@ class OpenAIServingResponses(GenerateBaseServing):
priority=self._get_priority(request, raw_request),
trace_headers=trace_headers,
session_id=session_id,
- reasoning_parser_kwargs=reasoning_parser_kwargs
- if self.parser and self.parser.reasoning_parser_cls is not None
- else None,
+ reasoning_parser_kwargs=reasoning_parser_kwargs,
)
generators.append(generator)
@@ -873,6 +872,8 @@ class OpenAIServingResponses(GenerateBaseServing):
if final_output.finish_reason == "length":
status = "incomplete"
+ # TODO: Build final response items from the accumulated streaming
+ # parser results instead of reparsing the complete output.
output = self._make_response_output_items(
request,
final_output,
@@ -901,13 +902,10 @@ class OpenAIServingResponses(GenerateBaseServing):
num_reasoning_tokens == 0
and isinstance(context, (SimpleContext, ParsableContext))
and context.response_parser is not None
- and context.response_parser.reasoning_parser is not None
):
accumulated = getattr(context, "_accumulated_token_ids", []) or []
- num_reasoning_tokens = (
- context.response_parser.reasoning_parser.count_reasoning_tokens(
- accumulated
- )
+ num_reasoning_tokens = context.response_parser.count_reasoning_tokens(
+ accumulated
)
usage = ResponseUsage(
diff --git a/vllm/parser/abstract_parser.py b/vllm/parser/abstract_parser.py
index f7119d84ef..2eda37b432 100644
--- a/vllm/parser/abstract_parser.py
+++ b/vllm/parser/abstract_parser.py
@@ -367,6 +367,10 @@ class Parser:
tool call extraction via internal stream state.
"""
+ def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
+ """Return the number of reasoning tokens in generated token IDs."""
+ return 0
+
class DelegatingParser(Parser):
"""
@@ -939,6 +943,12 @@ class DelegatingParser(Parser):
return delta_message
+ def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
+ """Count reasoning tokens through the configured reasoning parser."""
+ if self._reasoning_parser is None:
+ return 0
+ return self._reasoning_parser.count_reasoning_tokens(token_ids)
+
def _flush_engine_parsers(
self, delta_message: DeltaMessage | None
) -> DeltaMessage | None:
diff --git a/vllm/parser/engine/adapters.py b/vllm/parser/engine/adapters.py
index b9c4e42b17..946367e137 100644
--- a/vllm/parser/engine/adapters.py
+++ b/vllm/parser/engine/adapters.py
@@ -47,6 +47,11 @@ class ParserEngineReasoningAdapter(ReasoningParser):
def __init__(self, tokenizer: TokenizerLike, *args, **kwargs) -> None:
super().__init__(tokenizer, *args, **kwargs)
self._parser_engine = self._parser_engine_cls(tokenizer, **kwargs) # type: ignore[call-arg]
+ self._parser_engine_kwargs = kwargs
+ self._counting_parser_engine: ParserEngine | None = None
+ # TODO: Remove once Responses finalization reuses accumulated streaming
+ # parser results instead of reparsing the complete output.
+ self._streaming_count_valid = False
@contextmanager
def _skip_tool_parsing(self) -> Iterator[None]:
@@ -71,6 +76,7 @@ class ParserEngineReasoningAdapter(ReasoningParser):
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> tuple[str | None, str | None]:
+ self._streaming_count_valid = False
with self._skip_tool_parsing():
return self._parser_engine.extract_reasoning(model_output, request)
@@ -83,6 +89,7 @@ class ParserEngineReasoningAdapter(ReasoningParser):
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
) -> DeltaMessage | None:
+ self._streaming_count_valid = True
with self._skip_tool_parsing():
return self._parser_engine.extract_reasoning_streaming(
previous_text,
@@ -122,7 +129,18 @@ class ParserEngineReasoningAdapter(ReasoningParser):
return self._parser_engine.get_streaming_fallback_content(text, request)
def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
- return self._parser_engine.count_reasoning_tokens(token_ids)
+ if self._streaming_count_valid:
+ return self._parser_engine.count_reasoning_tokens(token_ids)
+ if not token_ids:
+ return 0
+ if self._counting_parser_engine is None:
+ self._counting_parser_engine = self._parser_engine_cls(
+ self.model_tokenizer, **self._parser_engine_kwargs
+ ) # type: ignore[call-arg]
+ self._counting_parser_engine._single_pass_parse(
+ self.model_tokenizer.decode(token_ids), token_ids
+ )
+ return self._counting_parser_engine.count_reasoning_tokens(token_ids)
class ParserEngineToolAdapter(ToolParser):
diff --git a/vllm/parser/engine/events.py b/vllm/parser/engine/events.py
index f138fb248f..08eaf2d01f 100644
--- a/vllm/parser/engine/events.py
+++ b/vllm/parser/engine/events.py
@@ -24,3 +24,4 @@ class SemanticEvent:
type: EventType
value: str = ""
tool_index: int = -1
+ token_count: int = 0
diff --git a/vllm/parser/engine/incremental_lexer.py b/vllm/parser/engine/incremental_lexer.py
index 31e9bd4a3b..34345fe1d8 100644
--- a/vllm/parser/engine/incremental_lexer.py
+++ b/vllm/parser/engine/incremental_lexer.py
@@ -24,6 +24,7 @@ class TerminalDef:
class LexToken:
terminal: str
value: str
+ token_count: int = 0
class LexerShape:
@@ -104,6 +105,7 @@ class IncrementalLexer:
self.terminals = shape.terminals
self.content_terminal = content_terminal
self.buffer = ""
+ self._token_counts: list[int] = []
self._literal_strings = shape.literal_strings
self._max_literal_len = shape.max_literal_len
@@ -114,15 +116,23 @@ class IncrementalLexer:
def reset(self) -> None:
self.buffer = ""
+ self._token_counts.clear()
- def feed(self, text: str) -> list[LexToken]:
+ def feed(
+ self,
+ text: str,
+ token_texts: tuple[str, ...] = (),
+ token_count: int = 0,
+ ) -> list[LexToken]:
+ char_token_counts = self._char_token_counts(text, token_texts, token_count)
if not self.buffer and self._has_only_literals and self._literal_first_chars:
for ch in text:
if ch in self._literal_first_chars:
break
else:
- return [LexToken(self.content_terminal, text)]
+ return [LexToken(self.content_terminal, text, sum(char_token_counts))]
self.buffer += text
+ self._token_counts.extend(char_token_counts)
return self._drain()
def flush(self) -> list[LexToken]:
@@ -130,10 +140,46 @@ class IncrementalLexer:
if self.buffer:
tokens.extend(self._drain(final=True))
if self.buffer:
- tokens.append(LexToken(self.content_terminal, self.buffer))
+ tokens.append(
+ LexToken(self.content_terminal, self.buffer, sum(self._token_counts))
+ )
self.buffer = ""
+ self._token_counts.clear()
return tokens
+ @staticmethod
+ def _char_token_counts(
+ text: str,
+ token_texts: tuple[str, ...],
+ token_count: int,
+ ) -> list[int]:
+ counts = [0] * len(text)
+ if not text:
+ return counts
+ if token_texts:
+ pos = 0
+ assigned = 0
+ for token_text in token_texts:
+ if not token_text:
+ continue
+ found = text.find(token_text, pos)
+ if found < 0:
+ continue
+ counts[found] += 1
+ assigned += 1
+ pos = found + len(token_text)
+ missing = token_count - assigned
+ if missing > 0:
+ counts[0] += missing
+ elif token_count:
+ counts[0] = token_count
+ return counts
+
+ def _pop_token_count(self, length: int) -> int:
+ token_count = sum(self._token_counts[:length])
+ del self._token_counts[:length]
+ return token_count
+
def _drain(self, *, final: bool = False) -> list[LexToken]:
tokens: list[LexToken] = []
first_chars = self._literal_first_chars
@@ -150,8 +196,11 @@ class IncrementalLexer:
has_potential = True
break
if not has_potential:
- tokens.append(LexToken(content_terminal, self.buffer))
+ tokens.append(
+ LexToken(content_terminal, self.buffer, sum(self._token_counts))
+ )
self.buffer = ""
+ self._token_counts.clear()
break
best_match: tuple[str, str, int] | None = None
@@ -175,7 +224,13 @@ class IncrementalLexer:
longer_match = True
break
if not longer_match:
- tokens.append(LexToken(best_match[0], best_match[1]))
+ tokens.append(
+ LexToken(
+ best_match[0],
+ best_match[1],
+ self._pop_token_count(best_match[2]),
+ )
+ )
self.buffer = self.buffer[best_match[2] :]
continue
break
@@ -183,15 +238,33 @@ class IncrementalLexer:
break
if best_match is not None:
- tokens.append(LexToken(best_match[0], best_match[1]))
+ tokens.append(
+ LexToken(
+ best_match[0],
+ best_match[1],
+ self._pop_token_count(best_match[2]),
+ )
+ )
self.buffer = self.buffer[best_match[2] :]
else:
content_end = self._find_content_boundary()
if content_end > 0:
- tokens.append(LexToken(content_terminal, self.buffer[:content_end]))
+ tokens.append(
+ LexToken(
+ content_terminal,
+ self.buffer[:content_end],
+ self._pop_token_count(content_end),
+ )
+ )
self.buffer = self.buffer[content_end:]
else:
- tokens.append(LexToken(content_terminal, self.buffer[0]))
+ tokens.append(
+ LexToken(
+ content_terminal,
+ self.buffer[0],
+ self._pop_token_count(1),
+ )
+ )
self.buffer = self.buffer[1:]
return tokens
diff --git a/vllm/parser/engine/parser_engine.py b/vllm/parser/engine/parser_engine.py
index 048e714cb4..5ed837d22d 100644
--- a/vllm/parser/engine/parser_engine.py
+++ b/vllm/parser/engine/parser_engine.py
@@ -625,23 +625,8 @@ class ParserEngine(Parser):
return None
def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
- start_id = self._reasoning_start_token_id
- end_id = self._reasoning_end_token_id
- if start_id is None or end_id is None:
- return 0
- count = 0
- depth = 0
- for token_id in token_ids:
- if token_id == start_id:
- depth += 1
- continue
- if token_id == end_id:
- if depth > 0:
- depth -= 1
- continue
- if depth > 0:
- count += 1
- return count
+ """Return reasoning tokens observed by the parser engine so far."""
+ return self._engine.reasoning_token_count
# ── Single-pass parse helper ────────────────────────────────────────
diff --git a/vllm/parser/engine/streaming_parser_engine.py b/vllm/parser/engine/streaming_parser_engine.py
index 157a1796e8..ec21b05f4d 100644
--- a/vllm/parser/engine/streaming_parser_engine.py
+++ b/vllm/parser/engine/streaming_parser_engine.py
@@ -167,6 +167,17 @@ class StreamingParserEngine:
self.skip_tool_parsing = False
self.reset(initial_state=initial_state)
+ @property
+ def reasoning_token_count(self) -> int:
+ return self._reasoning_token_count
+
+ def _record_reasoning_tokens(self, events: Sequence[SemanticEvent]) -> None:
+ self._reasoning_token_count += sum(
+ event.token_count
+ for event in events
+ if event.type == EventType.REASONING_CHUNK
+ )
+
def _reset_args_state(self) -> None:
self._args_buffer: str = ""
self._args_safe_end: int = 0
@@ -186,6 +197,7 @@ class StreamingParserEngine:
)
self.tool_index = -1
self._ever_had_token_ids = False
+ self._reasoning_token_count = 0
# DO NOT reset skip_tool_parsing here — callers set it before
# calling methods that trigger reset() (e.g. extract_reasoning),
# and clearing it silently breaks non-streaming tool-call-as-
@@ -193,6 +205,7 @@ class StreamingParserEngine:
self._scanner.reset()
self._lexer.reset()
self._message_header_buffer = ""
+ self._message_header_token_count = 0
self._in_skipped_tool_span = False
self._reset_args_state()
@@ -218,18 +231,30 @@ class StreamingParserEngine:
has_special = True
break
if not has_special:
- return self._emit_for_state(delta_text)
+ events = self._emit_for_state(
+ delta_text, token_count=len(delta_token_ids)
+ )
+ self._record_reasoning_tokens(events)
+ return events
scanner_items = self._scanner.scan(delta_text, delta_token_ids)
if len(scanner_items) == 1 and isinstance(scanner_items[0], TextChunk):
- lex_tokens = self._lexer.feed(scanner_items[0].text)
+ item = scanner_items[0]
+ lex_tokens = self._lexer.feed(item.text, item.token_texts, item.token_count)
if len(lex_tokens) == 1 and lex_tokens[0].terminal == CONTENT_TERMINAL:
- text = lex_tokens[0].value
- return self._emit_for_state(text)
- return self._process_lex_tokens(lex_tokens)
+ events = self._emit_for_state(
+ lex_tokens[0].value,
+ token_count=lex_tokens[0].token_count,
+ )
+ else:
+ events = self._process_lex_tokens(lex_tokens)
+ self._record_reasoning_tokens(events)
+ return events
- return self._process_scanner_items(scanner_items)
+ events = self._process_scanner_items(scanner_items)
+ self._record_reasoning_tokens(events)
+ return events
def _process_scanner_items(
self, items: Sequence[LexerInput]
@@ -240,7 +265,18 @@ class StreamingParserEngine:
events.extend(self._process_lex_tokens(self._lexer.flush()))
events.extend(self._on_terminal(item.terminal, item.text))
elif isinstance(item, TextChunk):
- events.extend(self._process_lex_tokens(self._lexer.feed(item.text)))
+ if not item.text and item.token_count:
+ events.extend(
+ self._emit_for_state("", token_count=item.token_count)
+ )
+ else:
+ events.extend(
+ self._process_lex_tokens(
+ self._lexer.feed(
+ item.text, item.token_texts, item.token_count
+ )
+ )
+ )
return events
def finish(self) -> list[SemanticEvent]:
@@ -285,11 +321,14 @@ class StreamingParserEngine:
EventType.TEXT_CHUNK,
value=self._message_header_buffer,
tool_index=self.tool_index,
+ token_count=self._message_header_token_count,
)
)
self._message_header_buffer = ""
+ self._message_header_token_count = 0
self.state = ParserState.CONTENT
+ self._record_reasoning_tokens(events)
return events
def parse_complete(self, text: str) -> list[SemanticEvent]:
@@ -303,9 +342,11 @@ class StreamingParserEngine:
strict = self._token_id_terminal_names if self._ever_had_token_ids else None
for tok in tokens:
if tok.terminal == CONTENT_TERMINAL or (strict and tok.terminal in strict):
- events.extend(self._on_content(tok.value))
+ events.extend(self._on_content(tok.value, tok.token_count))
else:
- events.extend(self._on_terminal(tok.terminal, tok.value))
+ events.extend(
+ self._on_terminal(tok.terminal, tok.value, tok.token_count)
+ )
return events
_TOOL_STATES = frozenset(
@@ -317,7 +358,9 @@ class StreamingParserEngine:
}
)
- def _on_terminal(self, terminal: str, value: str) -> list[SemanticEvent]:
+ def _on_terminal(
+ self, terminal: str, value: str, token_count: int = 0
+ ) -> list[SemanticEvent]:
key = (self.state, terminal)
transition = self.config.transitions.get(key)
@@ -327,7 +370,7 @@ class StreamingParserEngine:
# The projected skip state may not define the wrapper closer.
if self.skip_tool_parsing and terminal in self._tool_exit_terminals:
self._in_skipped_tool_span = False
- return self._emit_for_state(value)
+ return self._emit_for_state(value, token_count)
if self.skip_tool_parsing and terminal in self._tool_terminals:
# Inkling reuses one terminal for tool, text, and reasoning exits.
@@ -345,6 +388,7 @@ class StreamingParserEngine:
leaving_message_header = self.state == ParserState.MESSAGE_HEADER
if leaving_message_header:
self._message_header_buffer = ""
+ self._message_header_token_count = 0
# A tool terminal that implicitly ends reasoning must report
# that even from the header state, or the reasoning pass never
# hands the block to the tool pass.
@@ -381,13 +425,14 @@ class StreamingParserEngine:
return []
if transition.skip_in_token_id_mode and self._ever_had_token_ids:
- return self._emit_for_state(value)
+ return self._emit_for_state(value, token_count)
- return self._apply_transition(transition, value)
+ return self._apply_transition(transition, value, token_count)
- def _emit_for_state(self, text: str) -> list[SemanticEvent]:
+ def _emit_for_state(self, text: str, token_count: int = 0) -> list[SemanticEvent]:
if self.state == ParserState.MESSAGE_HEADER:
self._message_header_buffer += text
+ self._message_header_token_count += token_count
return []
if self.state == ParserState.TOOL_ARGS:
if self.config.tool_args_json:
@@ -397,26 +442,36 @@ class StreamingParserEngine:
EventType.ARG_VALUE_CHUNK,
value=text,
tool_index=self.tool_index,
+ token_count=token_count,
)
]
content_type = self.config.content_events.get(self.state)
if content_type is not None:
- return [SemanticEvent(content_type, value=text, tool_index=self.tool_index)]
+ return [
+ SemanticEvent(
+ content_type,
+ value=text,
+ tool_index=self.tool_index,
+ token_count=token_count,
+ )
+ ]
return []
- def _on_content(self, text: str) -> list[SemanticEvent]:
+ def _on_content(self, text: str, token_count: int = 0) -> list[SemanticEvent]:
if not text:
return []
- return self._emit_for_state(text)
+ return self._emit_for_state(text, token_count)
def _apply_transition(
self,
transition: Transition,
value: str,
+ token_count: int = 0,
) -> list[SemanticEvent]:
events: list[SemanticEvent] = []
previous_state = self.state
message_header = ""
+ message_header_token_count = 0
if (
self.state == ParserState.TOOL_ARGS
@@ -434,7 +489,9 @@ class StreamingParserEngine:
if previous_state == ParserState.MESSAGE_HEADER:
message_header = self._message_header_buffer
+ message_header_token_count = self._message_header_token_count
self._message_header_buffer = ""
+ self._message_header_token_count = 0
self.state = transition.next_state
@@ -454,6 +511,12 @@ class StreamingParserEngine:
event_type,
value=event_value,
tool_index=self.tool_index,
+ token_count=(
+ message_header_token_count
+ if previous_state == ParserState.MESSAGE_HEADER
+ and event_type == EventType.TEXT_CHUNK
+ else token_count
+ ),
)
)
diff --git a/vllm/parser/engine/token_id_scanner.py b/vllm/parser/engine/token_id_scanner.py
index abcc2e2bae..60a64dcae2 100644
--- a/vllm/parser/engine/token_id_scanner.py
+++ b/vllm/parser/engine/token_id_scanner.py
@@ -14,6 +14,8 @@ DROP_TERMINAL = "__DROP__"
@dataclass(slots=True)
class TextChunk:
text: str
+ token_texts: tuple[str, ...] = ()
+ token_count: int = 0
@dataclass(slots=True)
@@ -49,18 +51,30 @@ class TokenIDScanner:
self.tokenizer = tokenizer
self._token_text_cache: dict[int, str] = {}
self._deferred_terminals: list[PreLexedTerminal] = []
+ self._deferred_prefix_token_counts: list[int] = []
+ self._deferred_trailing_token_count = 0
self._deferred_post_text: str = ""
def reset(self) -> None:
"""Clear mutable state for reuse. Preserves the token text cache."""
self._deferred_terminals.clear()
+ self._deferred_prefix_token_counts.clear()
+ self._deferred_trailing_token_count = 0
self._deferred_post_text = ""
def _decode_token(self, token_id: int) -> str:
if token_id not in self._token_text_cache:
- self._token_text_cache[token_id] = self.tokenizer.decode([token_id])
+ token_text = self.tokenizer.decode([token_id])
+ self._token_text_cache[token_id] = (
+ token_text if isinstance(token_text, str) else ""
+ )
return self._token_text_cache[token_id]
+ def _decode_tokens(self, token_ids: Sequence[int]) -> tuple[str, ...]:
+ if self.tokenizer is None:
+ return ()
+ return tuple(self._decode_token(tid) for tid in token_ids)
+
_EMPTY: tuple[LexerInput, ...] = ()
def scan(
@@ -70,13 +84,23 @@ class TokenIDScanner:
) -> Sequence[LexerInput]:
prefix_items: list[LexerInput] = []
effective_text = delta_text
+ deferred_trailing_count = 0
if self._deferred_terminals:
- prefix_items, effective_text = self._resolve_deferred(delta_text)
+ prefix_items, effective_text, deferred_trailing_count = (
+ self._resolve_deferred(delta_text, len(delta_token_ids))
+ )
if not self.token_id_to_terminal:
if effective_text:
- prefix_items.append(TextChunk(effective_text))
+ token_texts = self._decode_tokens(delta_token_ids)
+ prefix_items.append(
+ TextChunk(
+ effective_text,
+ token_texts,
+ deferred_trailing_count + len(token_texts),
+ )
+ )
return prefix_items
has_special = False
@@ -89,14 +113,23 @@ class TokenIDScanner:
if not has_special:
if effective_text:
if not prefix_items:
- return [TextChunk(effective_text)]
- prefix_items.append(TextChunk(effective_text))
+ token_texts = self._decode_tokens(delta_token_ids)
+ return [TextChunk(effective_text, token_texts, len(token_texts))]
+ token_texts = self._decode_tokens(delta_token_ids)
+ prefix_items.append(
+ TextChunk(
+ effective_text,
+ token_texts,
+ deferred_trailing_count + len(token_texts),
+ )
+ )
return prefix_items or self._EMPTY
- token_texts = [self._decode_token(tid) for tid in delta_token_ids]
+ decoded_token_texts = [self._decode_token(tid) for tid in delta_token_ids]
results: list[LexerInput] = []
text_accum: list[str] = []
+ token_text_accum: list[str] = []
for idx, tid in enumerate(delta_token_ids):
terminal = self.token_id_to_terminal.get(tid)
@@ -104,16 +137,31 @@ class TokenIDScanner:
if text_accum:
joined = "".join(text_accum)
if joined:
- results.append(TextChunk(joined))
+ results.append(
+ TextChunk(
+ joined,
+ tuple(token_text_accum),
+ len(token_text_accum),
+ )
+ )
text_accum.clear()
- results.append(PreLexedTerminal(terminal, tid, token_texts[idx]))
+ token_text_accum.clear()
+ results.append(
+ PreLexedTerminal(terminal, tid, decoded_token_texts[idx])
+ )
else:
- text_accum.append(token_texts[idx])
+ text_accum.append(decoded_token_texts[idx])
+ token_text_accum.append(decoded_token_texts[idx])
if text_accum:
joined = "".join(text_accum)
if joined:
- results.append(TextChunk(joined))
+ results.append(
+ TextChunk(joined, tuple(token_text_accum), len(token_text_accum))
+ )
+
+ if deferred_trailing_count:
+ results.insert(0, TextChunk("", token_count=deferred_trailing_count))
if effective_text:
results = self._recover_holdback_text(effective_text, results)
@@ -124,9 +172,15 @@ class TokenIDScanner:
# transition before the preceding text has arrived. The
# deferred terminals will be resolved against the actual
# delta_text in a subsequent scan() or flushed by finish().
+ prefix_token_count = 0
for r in results:
+ if isinstance(r, TextChunk):
+ prefix_token_count += r.token_count
if isinstance(r, PreLexedTerminal):
self._deferred_terminals.append(r)
+ self._deferred_prefix_token_counts.append(prefix_token_count)
+ prefix_token_count = 0
+ self._deferred_trailing_token_count += prefix_token_count
results = []
return prefix_items + results
@@ -136,16 +190,30 @@ class TokenIDScanner:
return []
results: list[LexerInput] = []
if self._deferred_post_text:
- results.append(TextChunk(self._deferred_post_text))
+ prefix_count = (
+ self._deferred_prefix_token_counts[0]
+ if self._deferred_prefix_token_counts
+ else 0
+ )
+ results.append(
+ TextChunk(self._deferred_post_text, token_count=prefix_count)
+ )
self._deferred_post_text = ""
results.extend(self._deferred_terminals)
+ if self._deferred_trailing_token_count:
+ results.append(
+ TextChunk("", token_count=self._deferred_trailing_token_count)
+ )
self._deferred_terminals.clear()
+ self._deferred_prefix_token_counts.clear()
+ self._deferred_trailing_token_count = 0
return results
def _resolve_deferred(
self,
delta_text: str,
- ) -> tuple[list[LexerInput], str]:
+ current_token_count: int = 0,
+ ) -> tuple[list[LexerInput], str, int]:
"""Resolve deferred terminals against new delta_text.
When a previous ``scan()`` deferred a terminal (its text hadn't
@@ -160,7 +228,11 @@ class TokenIDScanner:
should be scanned with the current delta's token IDs.
"""
deferred = self._deferred_terminals
+ prefix_token_counts = self._deferred_prefix_token_counts
+ trailing_token_count = self._deferred_trailing_token_count
self._deferred_terminals = []
+ self._deferred_prefix_token_counts = []
+ self._deferred_trailing_token_count = 0
results: list[LexerInput] = []
remaining = delta_text
@@ -171,10 +243,13 @@ class TokenIDScanner:
# Duplicate-text deferred terminals resolve left-to-right via
# find(); correct when each terminal text appears once in sequence.
- for terminal in deferred:
+ for idx, terminal in enumerate(deferred):
+ prefix_token_count = prefix_token_counts[idx]
pos = remaining.find(terminal.text)
if pos > 0:
- results.append(TextChunk(remaining[:pos]))
+ results.append(
+ TextChunk(remaining[:pos], token_count=prefix_token_count)
+ )
results.append(terminal)
remaining = remaining[pos + len(terminal.text) :]
elif pos == 0:
@@ -185,10 +260,16 @@ class TokenIDScanner:
# only the terminal provides a reliable split point.
if remaining:
self._deferred_post_text += remaining
+ prefix_token_count += current_token_count
remaining = ""
self._deferred_terminals.append(terminal)
+ self._deferred_prefix_token_counts.append(prefix_token_count)
- return results, remaining
+ if self._deferred_terminals:
+ self._deferred_trailing_token_count = trailing_token_count
+ trailing_token_count = 0
+
+ return results, remaining, trailing_token_count
def _recover_holdback_text(
self,
@@ -257,6 +338,16 @@ class TokenIDScanner:
if not anchors:
return [TextChunk(delta_text)]
+ token_groups: list[list[str]] = [[] for _ in range(len(anchors) + 1)]
+ count_groups = [0] * (len(anchors) + 1)
+ group_idx = 0
+ for item in results:
+ if isinstance(item, PreLexedTerminal):
+ group_idx += 1
+ else:
+ token_groups[group_idx].extend(item.token_texts)
+ count_groups[group_idx] += item.token_count
+
# Resolve positions right-to-left: each anchor gets the
# rightmost occurrence that is still before the next anchor.
positions: list[int] = [-1] * len(anchors)
@@ -274,7 +365,13 @@ class TokenIDScanner:
pos = positions[i]
if pos >= consumed:
if pos > consumed:
- new_results.append(TextChunk(delta_text[consumed:pos]))
+ new_results.append(
+ TextChunk(
+ delta_text[consumed:pos],
+ tuple(token_groups[i]),
+ count_groups[i],
+ )
+ )
new_results.append(anchor)
consumed = pos + len(anchor.text)
else:
@@ -291,6 +388,13 @@ class TokenIDScanner:
self._deferred_post_text += delta_text[consumed:]
consumed = len(delta_text)
self._deferred_terminals.append(anchor)
+ self._deferred_prefix_token_counts.append(count_groups[i])
if consumed < len(delta_text):
- new_results.append(TextChunk(delta_text[consumed:]))
+ new_results.append(
+ TextChunk(
+ delta_text[consumed:],
+ tuple(token_groups[-1]),
+ count_groups[-1],
+ )
+ )
return new_results
diff --git a/vllm/parser/parser_manager.py b/vllm/parser/parser_manager.py
index 865f749a79..40af5b801f 100644
--- a/vllm/parser/parser_manager.py
+++ b/vllm/parser/parser_manager.py
@@ -9,6 +9,7 @@ from vllm.logger import init_logger
if TYPE_CHECKING:
from vllm.parser.abstract_parser import Parser
+ from vllm.parser.engine.parser_engine import ParserEngine
from vllm.reasoning import ReasoningParser
from vllm.tool_parsers import ToolParser
@@ -17,10 +18,30 @@ logger = init_logger(__name__)
class ParserManager:
"""
- Provides a unified Parser by composing individual reasoning and tool
- parsers from their respective registries.
+ Provides a unified Parser from the reasoning and tool parser registries.
+
+ Parser engine adapters backed by the same engine are collapsed back into
+ that engine. Other parser pairs are composed through ``DelegatingParser``.
"""
+ @staticmethod
+ def _get_parser_engine_cls(
+ parser_cls: type[object] | None,
+ ) -> type[ParserEngine] | None:
+ if parser_cls is None:
+ return None
+ parser_engine_cls = getattr(parser_cls, "_parser_engine_cls", None)
+ if parser_engine_cls is None:
+ return None
+
+ from vllm.parser.engine.parser_engine import ParserEngine
+
+ if not isinstance(parser_engine_cls, type) or not issubclass(
+ parser_engine_cls, ParserEngine
+ ):
+ return None
+ return parser_engine_cls
+
@classmethod
def get_tool_parser(
cls,
@@ -84,8 +105,8 @@ class ParserManager:
"""
Get a Parser that handles both reasoning and tool parsing.
- Composes individual reasoning and tool parsers into a single
- DelegatingParser subclass.
+ Reuses a shared parser engine when possible, otherwise composes the
+ individual parsers into a ``DelegatingParser`` subclass.
Args:
tool_parser_name: The name of the tool parser.
@@ -116,6 +137,11 @@ class ParserManager:
HarmonyParser.tool_parser_cls = tool_parser_cls
return HarmonyParser
+ reasoning_engine_cls = cls._get_parser_engine_cls(reasoning_parser_cls)
+ tool_engine_cls = cls._get_parser_engine_cls(tool_parser_cls)
+ if reasoning_engine_cls is not None and reasoning_engine_cls is tool_engine_cls:
+ return reasoning_engine_cls
+
if reasoning_parser_name == "kimi_k3" or tool_parser_name == "kimi_k3":
from vllm.parser.kimi_k3 import KimiK3Parser