[Frontend] Add Streaming Parser Engine and new Qwen3 Parser (#45413)

Signed-off-by: Ben Browning <bbrownin@redhat.com>
Co-authored-by: Flora Feng <4florafeng@gmail.com>
This commit is contained in:
Ben Browning
2026-06-15 11:59:05 +08:00
committed by GitHub
co-authored by Flora Feng
parent e3e3cd5458
commit c4a3f9d137
33 changed files with 8494 additions and 904 deletions
View File
+40
View File
@@ -0,0 +1,40 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from unittest.mock import MagicMock
import pytest
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
)
@pytest.fixture()
def should_do_global_cleanup_after_test() -> bool:
return False
def make_mock_tokenizer(vocab: dict[str, int]) -> MagicMock:
"""Create a mock tokenizer with the given special-token vocabulary.
The returned mock supports get_vocab(), encode(), and decode().
decode() maps known token IDs back to their text and falls back to
chr(id) for ASCII IDs or ``<id>`` for others.
"""
id_to_text = {v: k for k, v in vocab.items()}
tokenizer = MagicMock()
tokenizer.encode.return_value = [1, 2, 3]
tokenizer.get_vocab.return_value = dict(vocab)
tokenizer.decode.side_effect = lambda ids: "".join(
id_to_text.get(i, chr(i) if i < 128 else f"<{i}>") for i in ids
)
return tokenizer
@pytest.fixture
def mock_request():
req = MagicMock(spec=ChatCompletionRequest)
req.tools = []
req.tool_choice = "auto"
return req
+377
View File
@@ -0,0 +1,377 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Data-driven replay harness for parser engine testing.
Replays token sequences through parsers at different chunk sizes to
verify chunk-size invariance: the same token sequence must produce
identical output regardless of how tokens are batched.
"""
from __future__ import annotations
import json
from collections.abc import Sequence
from dataclasses import dataclass, field
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
)
from vllm.entrypoints.openai.engine.protocol import DeltaMessage
@dataclass
class Sample:
"""One test sample loaded from a JSONL file."""
id: str
description: str
source: str
vocab: dict[str, int]
tokens: list[tuple[int, str]]
expected_reasoning: str | None
expected_content: str | None
expected_tool_calls: list[dict] | None
tools: list[dict] | None = None
chat_template_kwargs: dict | None = None
@dataclass
class ParseOutput:
"""Accumulated parse output from replaying a token stream."""
reasoning: str = ""
content: str = ""
tool_calls: list[dict] = field(default_factory=list)
class MockTokenizer:
"""Lightweight tokenizer mock that avoids unittest.mock overhead.
Used by ``benchmarks/benchmark_parsers.py`` in tight timing loops,
so hot-path methods (``decode``, ``get_vocab``) must be cheap.
MagicMock's call-recording machinery added ~40% overhead to small-
sample benchmarks, inflating the per-token cost of the parser engine.
"""
__slots__ = (
"_vocab",
"_token_ids",
"_token_decode_map",
"_special_ids",
"eos_token_id",
"bos_token_id",
"pad_token_id",
)
def __init__(
self,
vocab: dict[str, int],
tokens: list[tuple[int, str]],
) -> None:
self._vocab = vocab
self._token_ids = [tid for tid, _ in tokens]
self._token_decode_map = {tid: text for tid, text in tokens}
self._special_ids = set(vocab.values())
self.eos_token_id = None
self.bos_token_id = None
self.pad_token_id = None
def set_vocab(self, vocab: dict[str, int]) -> None:
self._vocab = vocab
def get_vocab(self) -> dict[str, int]:
return self._vocab
def encode(self, text: str, **kwargs) -> list[int]:
return self._token_ids
def decode(self, ids: list[int], skip_special_tokens: bool = False) -> str:
parts: list[str] = []
for tid in ids:
if skip_special_tokens and tid in self._special_ids:
continue
text = self._token_decode_map.get(tid, f"?{tid}?")
parts.append(text)
return "".join(parts)
def make_mock_tokenizer(sample: Sample) -> MockTokenizer:
"""Build a mock tokenizer from a sample's vocab and token data."""
return MockTokenizer(
vocab=dict(sample.vocab),
tokens=sample.tokens,
)
def _test_request(
tools: list[dict] | None = None,
) -> ChatCompletionRequest:
return ChatCompletionRequest(
model="test-model",
messages=[{"role": "user", "content": "test"}],
tools=tools,
)
def replay_streaming(
parser,
tokens: list[tuple[int, str]],
chunk_size: int | None = None,
holdback_chars: int = 0,
finished_on_last: bool = False,
tools: list[dict] | None = None,
) -> list[DeltaMessage | None]:
"""Feed tokens through ``parser.parse_delta()`` at a given chunk size.
Args:
parser: A :class:`Parser` instance with ``parse_delta()`` method.
tokens: List of ``(token_id, decoded_text)`` pairs.
chunk_size: Number of tokens per batch. ``None`` means all at once.
holdback_chars: Simulate detokenizer holdback by holding back
this many characters of decoded text between batches.
finished_on_last: When True, pass ``finished=True`` on the last
``parse_delta()`` call, matching real server behavior.
tools: Optional tool definitions to include on the request,
matching the serving layer where tools set
``tool_choice`` to ``"auto"``.
Returns:
List of ``DeltaMessage`` results from each ``parse_delta()`` call.
"""
if chunk_size is None:
chunk_size = len(tokens)
results: list[DeltaMessage | None] = []
all_ids = [tid for tid, _ in tokens]
all_texts = [text for _, text in tokens]
request = _test_request(tools=tools)
if holdback_chars <= 0:
chunks = list(range(0, len(tokens), chunk_size))
for i, start in enumerate(chunks):
batch_end = min(start + chunk_size, len(tokens))
batch_ids = all_ids[start:batch_end]
delta_text = "".join(all_texts[start:batch_end])
is_last = i == len(chunks) - 1
result = parser.parse_delta(
delta_text,
batch_ids,
request,
prompt_token_ids=[] if start == 0 else None,
finished=finished_on_last and is_last,
)
results.append(result)
return results
emitted_up_to = 0
is_first = True
for start in range(0, len(tokens), chunk_size):
batch_end = min(start + chunk_size, len(tokens))
if batch_end < len(tokens):
held_chars = 0
safe_end = batch_end
while safe_end > emitted_up_to and held_chars < holdback_chars:
safe_end -= 1
held_chars += len(all_texts[safe_end])
else:
safe_end = batch_end
if safe_end <= emitted_up_to:
continue
batch_ids = all_ids[emitted_up_to:safe_end]
delta_text = "".join(all_texts[emitted_up_to:safe_end])
emitted_up_to = safe_end
is_last_chunk = batch_end >= len(tokens)
result = parser.parse_delta(
delta_text,
batch_ids,
request,
prompt_token_ids=[] if is_first else None,
finished=finished_on_last and is_last_chunk,
)
results.append(result)
is_first = False
if emitted_up_to < len(tokens):
batch_ids = all_ids[emitted_up_to:]
delta_text = "".join(all_texts[emitted_up_to:])
result = parser.parse_delta(
delta_text,
batch_ids,
request,
prompt_token_ids=[] if is_first else None,
finished=finished_on_last,
)
results.append(result)
return results
def replay_with_text_holdback(
parser,
tokens: list[tuple[int, str]],
text_delay: int = 1,
tools: list[dict] | None = None,
) -> list[DeltaMessage | None]:
"""Replay token-by-token with text arriving *text_delay* steps late.
Simulates the production detokenizer holdback where token IDs arrive
immediately but decoded text is delayed. On the last token all
remaining held-back text is flushed, matching real server behavior::
step 0: ids=[tok0], text="" (held back)
step 1: ids=[tok1], text=tok0_text (tok0 released)
...
step N-1: ids=[tokN-1], text=remaining_texts (flush all)
This exercises the TokenIDScanner deferred-terminal path that
``replay_streaming`` (which keeps text and IDs aligned) does not.
"""
results: list[DeltaMessage | None] = []
request = _test_request(tools=tools)
n = len(tokens)
held_texts: list[str] = []
for i in range(n):
token_id = tokens[i][0]
held_texts.append(tokens[i][1])
is_last = i == n - 1
if is_last:
delta_text = "".join(held_texts)
held_texts.clear()
elif len(held_texts) > text_delay:
delta_text = held_texts.pop(0)
else:
delta_text = ""
result = parser.parse_delta(
delta_text,
[token_id],
request,
prompt_token_ids=[] if i == 0 else None,
finished=is_last,
)
results.append(result)
return results
def accumulate_deltas(
deltas: Sequence[DeltaMessage | None],
) -> dict:
reasoning_parts: list[str] = []
content_parts: list[str] = []
tool_calls_by_idx: dict[int, dict] = {}
for delta in deltas:
if delta is None:
continue
if delta.reasoning:
reasoning_parts.append(delta.reasoning)
if delta.content:
content_parts.append(delta.content)
if delta.tool_calls:
for tc in delta.tool_calls:
if tc.function and tc.function.name:
existing = tool_calls_by_idx.get(tc.index)
if existing is None:
tool_calls_by_idx[tc.index] = {
"name": tc.function.name,
"_args_parts": [tc.function.arguments or ""],
}
else:
existing["_args_parts"].append(tc.function.arguments or "")
elif tc.function and tc.function.arguments:
existing = tool_calls_by_idx.get(tc.index)
if existing is not None:
existing["_args_parts"].append(tc.function.arguments)
return {
"reasoning": "".join(reasoning_parts),
"content": "".join(content_parts),
"tool_calls": [
{"name": tc["name"], "arguments": "".join(tc["_args_parts"])}
for tc in tool_calls_by_idx.values()
],
}
def collect_output(results: list[DeltaMessage | None]) -> ParseOutput:
"""Accumulate ``DeltaMessage`` results into a :class:`ParseOutput`."""
result = accumulate_deltas(results)
return ParseOutput(
reasoning=result["reasoning"],
content=result["content"],
tool_calls=result["tool_calls"],
)
def assert_parse_output(actual: ParseOutput, sample: Sample) -> None:
"""Compare actual parse output against expected values from a sample."""
if sample.expected_reasoning is not None:
assert actual.reasoning == sample.expected_reasoning, (
f"Reasoning mismatch:\n"
f" expected: {sample.expected_reasoning!r}\n"
f" actual: {actual.reasoning!r}"
)
if sample.expected_content is not None:
assert actual.content == sample.expected_content, (
f"Content mismatch:\n"
f" expected: {sample.expected_content!r}\n"
f" actual: {actual.content!r}"
)
if sample.expected_tool_calls is not None:
assert len(actual.tool_calls) == len(sample.expected_tool_calls), (
f"Tool call count mismatch: "
f"expected {len(sample.expected_tool_calls)}, "
f"got {len(actual.tool_calls)}"
)
for i, (expected_tc, actual_tc) in enumerate(
zip(sample.expected_tool_calls, actual.tool_calls)
):
assert actual_tc["name"] == expected_tc["name"], (
f"Tool call {i} name mismatch: "
f"expected {expected_tc['name']!r}, "
f"got {actual_tc['name']!r}"
)
if "arguments" in expected_tc:
expected_args = expected_tc["arguments"]
actual_args_str = actual_tc.get("arguments", "{}")
if isinstance(expected_args, dict):
try:
actual_args = json.loads(actual_args_str)
except json.JSONDecodeError as e:
raise AssertionError(
f"Tool call {i} arguments not valid JSON: "
f"{actual_args_str!r}"
) from e
assert actual_args == expected_args, (
f"Tool call {i} arguments mismatch:\n"
f" expected: {expected_args}\n"
f" actual: {actual_args}"
)
def assert_no_terminal_leakage(
actual: ParseOutput,
terminals: list[str],
context: str = "",
) -> None:
"""Assert that none of *terminals* appear in reasoning or content."""
suffix = f" ({context})" if context else ""
for terminal in terminals:
assert terminal not in actual.reasoning, (
f"{terminal!r} leaked into reasoning{suffix}"
)
assert terminal not in actual.content, (
f"{terminal!r} leaked into content{suffix}"
)
+137
View File
@@ -0,0 +1,137 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Shared streaming simulation helpers for parser engine tests."""
from __future__ import annotations
from typing import Any
from vllm.entrypoints.openai.engine.protocol import DeltaMessage
def _build_token_id_map(parser) -> dict[str, int]:
"""Map special token text to token IDs from the parser's config."""
token_id_map: dict[str, int] = {}
cfg = getattr(parser, "parser_engine_config", None)
vocab = getattr(parser, "vocab", None)
if cfg is not None and vocab is not None:
for text in (cfg.token_id_terminals or {}).values():
tid = vocab.get(text)
if tid is not None:
token_id_map[text] = tid
return token_id_map
def simulate_tool_streaming(
parser,
request,
chunks: list[str],
) -> list[tuple[DeltaMessage | None, str]]:
"""Feed text chunks through ``extract_tool_calls_streaming()``."""
token_id_map = _build_token_id_map(parser)
results: list[tuple[Any, str]] = []
previous_text = ""
previous_token_ids: list[int] = []
for chunk in chunks:
current_text = previous_text + chunk
delta_token_ids: list[int] = [
tid for text, tid in token_id_map.items() if text in chunk
]
current_token_ids = previous_token_ids + delta_token_ids
delta = parser.extract_tool_calls_streaming(
previous_text=previous_text,
current_text=current_text,
delta_text=chunk,
previous_token_ids=tuple(previous_token_ids),
current_token_ids=tuple(current_token_ids),
delta_token_ids=tuple(delta_token_ids),
request=request,
)
results.append((delta, current_text))
previous_text = current_text
previous_token_ids = list(current_token_ids)
return results
def collect_tool_arguments(
results: list[tuple[DeltaMessage | None, str]],
) -> str:
"""Concatenate all streamed argument fragments."""
args_text = ""
for delta, _ in results:
if delta and delta.tool_calls:
for tc in delta.tool_calls:
if tc.function and tc.function.arguments:
args_text += tc.function.arguments
return args_text
def collect_content(
results: list[tuple[DeltaMessage | None, str]],
) -> str:
"""Concatenate all streamed content parts."""
parts: list[str] = []
for delta, _ in results:
if delta and delta.content:
parts.append(delta.content)
return "".join(parts)
def collect_function_name(
results: list[tuple[DeltaMessage | None, str]],
) -> str | None:
"""Return first function name from deltas."""
for delta, _ in results:
if delta and delta.tool_calls:
for tc in delta.tool_calls:
if tc.function and tc.function.name:
return tc.function.name
return None
def simulate_reasoning_streaming(
parser,
chunks: list[str],
delta_token_ids_per_chunk: list[tuple[int, ...]] | None = None,
) -> tuple[str, str]:
"""Feed chunks through ``extract_reasoning_streaming()``.
Returns ``(reasoning_text, content_text)`` tuple.
"""
token_id_map = (
_build_token_id_map(parser) if delta_token_ids_per_chunk is None else {}
)
reasoning_parts: list[str] = []
content_parts: list[str] = []
prev_text = ""
prev_ids: list[int] = []
for i, chunk in enumerate(chunks):
cur_text = prev_text + chunk
if delta_token_ids_per_chunk is not None:
d_ids = delta_token_ids_per_chunk[i]
else:
d_ids = tuple(tid for text, tid in token_id_map.items() if text in chunk)
cur_ids = prev_ids + list(d_ids)
delta = parser.extract_reasoning_streaming(
previous_text=prev_text,
current_text=cur_text,
delta_text=chunk,
previous_token_ids=tuple(prev_ids),
current_token_ids=tuple(cur_ids),
delta_token_ids=d_ids,
)
if delta:
if delta.reasoning:
reasoning_parts.append(delta.reasoning)
if delta.content:
content_parts.append(delta.content)
prev_text = cur_text
prev_ids = list(cur_ids)
return "".join(reasoning_parts), "".join(content_parts)
@@ -0,0 +1,82 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Replay tests for DelegatingParser with engine adapters.
Exercises DelegatingParser in engine-adapter mode to verify that delegated
routing produces correct output across chunk sizes.
See test_replay.py for tests that target engine parsers directly.
"""
from __future__ import annotations
from functools import lru_cache
import pytest
from pydantic import TypeAdapter
from tests.parser.engine.replay_harness import (
assert_parse_output,
collect_output,
make_mock_tokenizer,
replay_streaming,
)
from tests.parser.engine.trace_builder import build_samples
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionToolsParam,
)
from vllm.parser.abstract_parser import Parser
from vllm.parser.parser_manager import ParserManager
_TOOLS_VALIDATOR = TypeAdapter(list[ChatCompletionToolsParam])
_PAIRINGS: dict[str, tuple[str, str]] = {
"engine": ("qwen3_coder", "qwen3"),
}
CHUNK_SIZES = [1, 2, 3, 5, 11, 23, None]
@lru_cache
def _get_delegating_parser_cls(pairings: str) -> type[Parser]:
tool_name, reasoning_name = _PAIRINGS[pairings]
parser_cls = ParserManager.get_parser(
tool_parser_name=tool_name,
reasoning_parser_name=reasoning_name,
enable_auto_tools=True,
)
assert parser_cls is not None
return parser_cls
_all_samples = build_samples("qwen3")
@pytest.mark.parametrize(
"pairings",
list(_PAIRINGS),
ids=lambda p: f"mode={p}",
)
@pytest.mark.parametrize("chunk_size", CHUNK_SIZES, ids=lambda c: f"chunk={c}")
@pytest.mark.parametrize("sample", _all_samples, ids=lambda s: s.id)
def test_delegating_replay(sample, chunk_size, pairings):
parser_cls = _get_delegating_parser_cls(pairings=pairings)
tokenizer = make_mock_tokenizer(sample)
validated_tools = (
_TOOLS_VALIDATOR.validate_python(sample.tools) if sample.tools else None
)
parser = parser_cls(
tokenizer,
validated_tools,
chat_template_kwargs=sample.chat_template_kwargs,
)
deltas = replay_streaming(
parser,
sample.tokens,
chunk_size=chunk_size,
finished_on_last=True,
tools=sample.tools,
)
output = collect_output(deltas)
assert_parse_output(output, sample)
+846
View File
@@ -0,0 +1,846 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for the streaming parser engine core pipeline."""
from unittest.mock import MagicMock
import pytest
from tests.parser.engine.conftest import make_mock_tokenizer
from vllm.parser.engine.events import EventType, SemanticEvent
from vllm.parser.engine.incremental_lexer import (
LexerShape,
TerminalDef,
terminals_from_literals,
)
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
def _hermes_config() -> ParserEngineConfig:
"""Simple Hermes-style config: <tool_call>JSON</tool_call>."""
return ParserEngineConfig(
name="hermes_test",
terminals={
"TOOL_START": "<tool_call>",
"TOOL_END": "</tool_call>",
},
token_id_terminals={
"TOOL_START": "<tool_call>",
"TOOL_END": "</tool_call>",
},
transitions={
(ParserState.CONTENT, "TOOL_START"): Transition(
ParserState.TOOL_ARGS,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_ARGS, "TOOL_END"): Transition(
ParserState.CONTENT,
(EventType.TOOL_CALL_END,),
),
},
content_events={
ParserState.CONTENT: EventType.TEXT_CHUNK,
ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
},
)
def _think_config() -> ParserEngineConfig:
"""Simple think-tag reasoning config: <think>...</think>."""
return ParserEngineConfig(
name="think_test",
terminals={
"THINK_START": "<think>",
"THINK_END": "</think>",
},
transitions={
(ParserState.CONTENT, "THINK_START"): Transition(
ParserState.REASONING,
(EventType.REASONING_START,),
),
(ParserState.REASONING, "THINK_END"): Transition(
ParserState.CONTENT,
(EventType.REASONING_END,),
),
},
)
class TestNonStreaming:
def test_plain_text(self):
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
events = engine.parse_complete("Hello, world!")
assert len(events) == 1
assert events[0].type == EventType.TEXT_CHUNK
assert events[0].value == "Hello, world!"
def test_single_tool_call(self):
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
text = (
'<tool_call>{"name": "get_weather",'
' "arguments": {"city": "SF"}}'
"</tool_call>"
)
events = engine.parse_complete(text)
types = [e.type for e in events]
assert EventType.TOOL_CALL_START in types
assert EventType.TOOL_CALL_END in types
assert EventType.ARG_VALUE_CHUNK in types
arg_text = "".join(
e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK
)
assert '"name": "get_weather"' in arg_text
assert '"city": "SF"' in arg_text
def test_text_then_tool_call(self):
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
text = 'Sure!<tool_call>{"name": "add"}</tool_call>'
events = engine.parse_complete(text)
types = [e.type for e in events]
assert types[0] == EventType.TEXT_CHUNK
assert events[0].value == "Sure!"
assert EventType.TOOL_CALL_START in types
assert EventType.TOOL_CALL_END in types
def test_multiple_tool_calls(self):
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
text = (
'<tool_call>{"name": "a"}</tool_call><tool_call>{"name": "b"}</tool_call>'
)
events = engine.parse_complete(text)
starts = [e for e in events if e.type == EventType.TOOL_CALL_START]
ends = [e for e in events if e.type == EventType.TOOL_CALL_END]
assert len(starts) == 2
assert len(ends) == 2
assert starts[0].tool_index == 0
assert starts[1].tool_index == 1
def test_reasoning(self):
engine = StreamingParserEngine(_think_config(), tokenizer=None)
text = "<think>Let me think...</think>The answer is 42."
events = engine.parse_complete(text)
types = [e.type for e in events]
assert types[0] == EventType.REASONING_START
assert EventType.REASONING_CHUNK in types
assert EventType.REASONING_END in types
assert EventType.TEXT_CHUNK in types
reasoning = "".join(
e.value for e in events if e.type == EventType.REASONING_CHUNK
)
assert "Let me think..." in reasoning
content = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
assert "The answer is 42." in content
class TestStreaming:
@staticmethod
def _feed_chars(
engine: StreamingParserEngine,
text: str,
) -> list[SemanticEvent]:
"""Feed text one character at a time."""
all_events = []
for ch in text:
all_events.extend(engine.feed(ch, []))
all_events.extend(engine.finish())
return all_events
@staticmethod
def _feed_chunks(
engine: StreamingParserEngine,
text: str,
chunk_size: int,
) -> list[SemanticEvent]:
"""Feed text in fixed-size chunks."""
all_events = []
for i in range(0, len(text), chunk_size):
chunk = text[i : i + chunk_size]
all_events.extend(engine.feed(chunk, []))
all_events.extend(engine.finish())
return all_events
def test_char_by_char_tool_call(self):
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
text = '<tool_call>{"name": "add", "arguments": {"a": 1}}</tool_call>'
events = self._feed_chars(engine, text)
types = [e.type for e in events]
assert EventType.TOOL_CALL_START in types
assert EventType.TOOL_CALL_END in types
assert EventType.ARG_VALUE_CHUNK in types
arg_text = "".join(
e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK
)
assert '"name": "add"' in arg_text
@pytest.mark.parametrize(
"text",
[
'<tool_call>{"name": "get", "arguments": {"x": "hello"}}</tool_call>',
'<tool_call>{"name": "f", "arguments": '
'{"items": [1, [2, 3]], "obj": {"k": "v"}}}'
"</tool_call>",
],
ids=["flat_args", "nested_arrays"],
)
def test_chunk_sizes_produce_same_content(self, text):
"""Different chunk sizes must produce identical concatenated content."""
results = {}
for chunk_size in [1, 2, 3, 5, 7, len(text)]:
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
events = self._feed_chunks(engine, text, chunk_size)
arg_text = "".join(
e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK
)
results[chunk_size] = arg_text
values = list(results.values())
for v in values[1:]:
assert v == values[0], f"Mismatch: {results}"
def test_prefix_buffering_prevents_premature_emit(self):
"""Text like '<tool_' should be buffered, not emitted as content."""
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
events1 = engine.feed("<tool_", [])
content_events = [e for e in events1 if e.type == EventType.TEXT_CHUNK]
assert len(content_events) == 0, "Should buffer partial tag"
events2 = engine.feed("call>", [])
starts = [e for e in events2 if e.type == EventType.TOOL_CALL_START]
assert len(starts) == 1
def test_prefix_buffering_flush_on_mismatch(self):
"""Text like '<tool_box' should eventually flush as content."""
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
events1 = engine.feed("<tool_", [])
assert len([e for e in events1 if e.type == EventType.TEXT_CHUNK]) == 0
events2 = engine.feed("box>rest", [])
events2.extend(engine.finish())
content = "".join(e.value for e in events2 if e.type == EventType.TEXT_CHUNK)
assert content == "<tool_box>rest"
def test_reasoning_streaming(self):
engine = StreamingParserEngine(_think_config(), tokenizer=None)
events = self._feed_chars(engine, "<think>hmm</think>answer")
reasoning = "".join(
e.value for e in events if e.type == EventType.REASONING_CHUNK
)
content = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
assert "hmm" in reasoning
assert "answer" in content
def test_text_between_tool_calls(self):
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
text = (
'Hi<tool_call>{"name":"a"}</tool_call>'
'mid<tool_call>{"name":"b"}</tool_call>end'
)
events = self._feed_chunks(engine, text, 3)
texts = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
assert "Hi" in texts
assert "mid" in texts
assert "end" in texts
starts = [e for e in events if e.type == EventType.TOOL_CALL_START]
assert len(starts) == 2
def test_unmatched_close_brace_does_not_poison_depth(self):
"""A stray } in malformed JSON must not kill streaming for
all subsequent content."""
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
engine.feed("<tool_call>", [])
malformed = '}{{"a": 1}}'
events = self._feed_chars(engine, malformed + "</tool_call>")
arg_chunks = [e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK]
assert len(arg_chunks) > 1, (
"Content after stray } should still stream incrementally"
)
arg_text = "".join(arg_chunks)
assert '"a": 1' in arg_text
def test_json_args_no_premature_close_brace(self):
"""Closing braces of the top-level JSON shouldn't be streamed
until confirmed by the end tag."""
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
engine.feed("<tool_call>", [])
events = engine.feed('{"name": "f"}', [])
arg_text = "".join(
e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK
)
assert "}" not in arg_text, "Top-level } should be held back"
events2 = engine.feed("</tool_call>", [])
arg_text2 = "".join(
e.value for e in events2 if e.type == EventType.ARG_VALUE_CHUNK
)
assert "}" in arg_text2, "} should flush on end tag"
_START_ID = 50
_END_ID = 51
_TOOL_START_ID = 60
_TOOL_END_ID = 61
def _make_think_tokenizer():
tok = MagicMock()
tok.encode.return_value = [1, 2, 3]
tok.get_vocab.return_value = {"<think>": _START_ID, "</think>": _END_ID}
tok.decode.side_effect = lambda ids: {
_START_ID: "<think>",
_END_ID: "</think>",
}.get(ids[0], f"tok{ids[0]}")
return tok
def _make_hermes_tokenizer():
"""Tokenizer that resolves tool_call tags to special IDs."""
_special = {_TOOL_START_ID: "<tool_call>", _TOOL_END_ID: "</tool_call>"}
tok = MagicMock()
tok.encode.return_value = [1, 2, 3]
tok.get_vocab.return_value = {
"<tool_call>": _TOOL_START_ID,
"</tool_call>": _TOOL_END_ID,
}
tok.decode.side_effect = lambda ids: "".join(
_special.get(i, chr(i) if i < 128 else f"<{i}>") for i in ids
)
return tok
class TestLexerBufferFlush:
"""Lexer buffer must be flushed before PreLexedTerminal transitions."""
def test_buffered_prefix_emitted_in_current_state(self):
"""Text buffered by the lexer (e.g. '<') must be emitted as
REASONING_CHUNK before THINK_END transitions to CONTENT."""
engine = StreamingParserEngine(_think_config(), _make_think_tokenizer())
events = engine.feed("<think>", [_START_ID])
assert any(e.type == EventType.REASONING_START for e in events)
events = engine.feed("reasoning text<", [])
reasoning_text = "".join(
e.value for e in events if e.type == EventType.REASONING_CHUNK
)
assert "reasoning text" in reasoning_text
events = engine.feed("</think>", [_END_ID])
event_types = [e.type for e in events]
if EventType.REASONING_CHUNK in event_types:
rc_idx = event_types.index(EventType.REASONING_CHUNK)
re_idx = event_types.index(EventType.REASONING_END)
assert rc_idx < re_idx, (
"'<' must be emitted as REASONING_CHUNK before REASONING_END"
)
flushed = events[rc_idx].value
assert "<" in flushed
def test_empty_buffer_no_extra_events(self):
"""When the lexer buffer is empty, flushing is a no-op."""
engine = StreamingParserEngine(_think_config(), _make_think_tokenizer())
engine.feed("<think>", [_START_ID])
engine.feed("clean text", [])
events = engine.feed("</think>", [_END_ID])
assert any(e.type == EventType.REASONING_END for e in events)
chunk_events = [e for e in events if e.type == EventType.REASONING_CHUNK]
assert all(e.value for e in chunk_events)
class TestTokenIdFiltering:
"""When token IDs are available, lex-matched terminals that also
have token_id_terminal entries should be demoted to content."""
def test_lex_matched_terminal_demoted_after_token_ids_seen(self):
"""After receiving token IDs, text that matches a token-ID
terminal should be treated as content, not trigger a transition."""
engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer())
# First feed with a non-special token ID to set _ever_had_token_ids
engine.feed("prefix ", [1])
# Now feed text containing <tool_call> as literal text
events = engine.feed(
"Use <tool_call> to invoke tools.</tool_call>", [2, 3, 4, 5]
)
events.extend(engine.finish())
types = [e.type for e in events]
assert EventType.TOOL_CALL_START not in types
assert EventType.TEXT_CHUNK in types
text = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
assert "<tool_call>" in text
def test_scanner_matched_terminal_bypasses_filter(self):
"""PreLexedTerminals from the scanner bypass the filter and
still trigger state transitions."""
engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer())
events = engine.feed("<tool_call>", [_TOOL_START_ID])
assert any(e.type == EventType.TOOL_CALL_START for e in events)
events = engine.feed('{"name": "f"}', [2, 3])
events.extend(engine.feed("</tool_call>", [_TOOL_END_ID]))
events.extend(engine.finish())
assert any(e.type == EventType.TOOL_CALL_END for e in events)
def test_no_filtering_without_token_ids(self):
"""When no token IDs are ever provided (non-streaming),
text matching still triggers transitions."""
engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer())
events = engine.feed('<tool_call>{"name": "f"}</tool_call>', [])
events.extend(engine.finish())
types = [e.type for e in events]
assert EventType.TOOL_CALL_START in types
assert EventType.TOOL_CALL_END in types
def test_mixed_text_then_real_tool_call(self):
"""Text mentioning tool syntax followed by a real special-token
tool call."""
engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer())
events1 = engine.feed("Mention <tool_call> in text. ", [1, 2, 3, 4])
events2 = engine.feed("<tool_call>", [_TOOL_START_ID])
events3 = engine.feed('{"name": "a"}', [5, 6])
events4 = engine.feed("</tool_call>", [_TOOL_END_ID])
events4.extend(engine.finish())
all_events = events1 + events2 + events3 + events4
content = "".join(e.value for e in all_events if e.type == EventType.TEXT_CHUNK)
assert "<tool_call>" in content
assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_START) == 1
assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_END) == 1
def _func_prefix_config() -> ParserEngineConfig:
"""Config mixing token-ID terminals (TOOL_START/END) with
text-only terminals (FUNC_PREFIX) and fallback transitions."""
return ParserEngineConfig(
name="func_prefix_test",
terminals={
"TOOL_START": "<tool_call>",
"TOOL_END": "</tool_call>",
"FUNC_PREFIX": "<function=",
"FUNC_END": "</function>",
"CLOSE_ANGLE": ">",
},
token_id_terminals={
"TOOL_START": "<tool_call>",
"TOOL_END": "</tool_call>",
},
transitions={
(ParserState.CONTENT, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(EventType.TOOL_CALL_START,),
),
(ParserState.CONTENT, "FUNC_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
skip_in_token_id_mode=True,
),
(ParserState.TOOL_PREAMBLE, "FUNC_PREFIX"): Transition(
ParserState.TOOL_NAME,
(),
),
(ParserState.TOOL_NAME, "CLOSE_ANGLE"): Transition(
ParserState.TOOL_ARGS,
(),
),
(ParserState.TOOL_ARGS, "FUNC_END"): Transition(
ParserState.TOOL_BETWEEN,
(EventType.TOOL_CALL_END,),
),
(ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
ParserState.CONTENT,
(),
),
(ParserState.TOOL_BETWEEN, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_BETWEEN, "FUNC_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
skip_in_token_id_mode=True,
),
},
content_events={
ParserState.CONTENT: EventType.TEXT_CHUNK,
ParserState.TOOL_NAME: EventType.TOOL_NAME,
ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
},
)
def _make_func_prefix_tokenizer():
return make_mock_tokenizer(
{
"<tool_call>": _TOOL_START_ID,
"</tool_call>": _TOOL_END_ID,
}
)
class TestTextOnlyFallbackFiltering:
"""When token IDs are available, transitions marked
skip_in_token_id_mode should be skipped."""
def test_func_prefix_in_prose_demoted_in_strict_mode(self):
"""<function=get_time> in prose should NOT trigger a tool call
when strict mode is active."""
engine = StreamingParserEngine(
_func_prefix_config(), _make_func_prefix_tokenizer()
)
engine.feed("prefix ", [1])
events = engine.feed("Use <function=get_time> to check.", [2, 3, 4, 5])
events.extend(engine.finish())
types = [e.type for e in events]
assert EventType.TOOL_CALL_START not in types
assert EventType.TEXT_CHUNK in types
text = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
assert "<function=" in text
def test_normal_flow_after_tool_start_still_works(self):
"""TOOL_START (special token) -> FUNC_PREFIX (text) should
still parse a tool call normally in strict mode."""
engine = StreamingParserEngine(
_func_prefix_config(), _make_func_prefix_tokenizer()
)
events1 = engine.feed("<tool_call>", [_TOOL_START_ID])
assert any(e.type == EventType.TOOL_CALL_START for e in events1)
events2 = engine.feed("<function=get_weather>", [2, 3])
events3 = engine.feed("args", [4])
events4 = engine.feed("</function>", [5, 6])
events4.extend(engine.feed("</tool_call>", [_TOOL_END_ID]))
events4.extend(engine.finish())
all_events = events1 + events2 + events3 + events4
assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_START) == 1
assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_END) == 1
def test_fallback_fires_without_token_ids(self):
"""When no token IDs are provided, fallback transitions should
still fire normally."""
engine = StreamingParserEngine(
_func_prefix_config(), _make_func_prefix_tokenizer()
)
events = engine.feed("<function=get_time>args</function>", [])
events.extend(engine.finish())
types = [e.type for e in events]
assert EventType.TOOL_CALL_START in types
assert EventType.TOOL_CALL_END in types
def test_tool_between_fallback_blocked_in_strict_mode(self):
"""The (TOOL_BETWEEN, FUNC_PREFIX) fallback should also be
blocked in strict mode."""
engine = StreamingParserEngine(
_func_prefix_config(), _make_func_prefix_tokenizer()
)
engine.feed("<tool_call>", [_TOOL_START_ID])
engine.feed("<function=a>", [2, 3])
engine.feed("args", [4])
engine.feed("</function>", [5, 6])
engine.feed("</tool_call>", [_TOOL_END_ID])
events = engine.feed("<function=b>more</function>", [7, 8, 9])
events.extend(engine.finish())
types = [e.type for e in events]
assert EventType.TOOL_CALL_START not in types
class TestNoUnusedTokenizerAttr:
"""StreamingParserEngine no longer stores a redundant _tokenizer."""
def test_no_tokenizer_attribute(self):
config = ParserEngineConfig(name="test")
engine = StreamingParserEngine(config, tokenizer=None)
assert not hasattr(engine, "_tokenizer")
class TestArgsResetOnReentry:
"""When leaving TOOL_ARGS and later re-entering (e.g. two tool
calls), the entering-TOOL_ARGS block resets args tracking. The
redundant reset on exit was removed."""
@staticmethod
def _multi_tool_config() -> ParserEngineConfig:
return ParserEngineConfig(
name="multi_tool",
terminals={
"TOOL_START": "<tool_call>",
"TOOL_END": "</tool_call>",
"TOOL_SEP": "<tool_sep>",
},
transitions={
(ParserState.CONTENT, "TOOL_START"): Transition(
ParserState.TOOL_ARGS,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_ARGS, "TOOL_END"): Transition(
ParserState.TOOL_BETWEEN,
(EventType.TOOL_CALL_END,),
),
(ParserState.TOOL_BETWEEN, "TOOL_SEP"): Transition(
ParserState.TOOL_ARGS,
(EventType.TOOL_CALL_START,),
),
},
content_events={
ParserState.CONTENT: EventType.TEXT_CHUNK,
ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
},
)
def test_args_tracking_across_reentry(self):
engine = StreamingParserEngine(self._multi_tool_config(), tokenizer=None)
events = engine.feed(
'<tool_call>{"city": "SF"}</tool_call>'
"<tool_sep>"
'{"name": "bar"}</tool_call>',
[],
)
tool_starts = [e for e in events if e.type == EventType.TOOL_CALL_START]
tool_ends = [e for e in events if e.type == EventType.TOOL_CALL_END]
arg_chunks = [e for e in events if e.type == EventType.ARG_VALUE_CHUNK]
assert len(tool_starts) == 2
assert len(tool_ends) == 2
assert tool_starts[0].tool_index == 0
assert tool_starts[1].tool_index == 1
first_args = "".join(e.value for e in arg_chunks if e.tool_index == 0)
second_args = "".join(e.value for e in arg_chunks if e.tool_index == 1)
assert '"city"' in first_args
assert '"name"' in second_args
def test_brace_depth_resets_on_reentry(self):
"""Verify _args_brace_depth resets when re-entering TOOL_ARGS."""
engine = StreamingParserEngine(self._multi_tool_config(), tokenizer=None)
engine.feed("<tool_call>", [])
assert engine.state == ParserState.TOOL_ARGS
assert engine._args_brace_depth == 0
engine.feed('{"a": 1}', [])
engine.feed("</tool_call>", [])
assert engine.state == ParserState.TOOL_BETWEEN
engine.feed("<tool_sep>", [])
assert engine.state == ParserState.TOOL_ARGS
assert engine._args_brace_depth == 0
assert engine._args_in_string is False
assert engine._args_escape_next is False
class TestToolPreambleFinish:
"""finish() in TOOL_PREAMBLE state emits TOOL_CALL_END when a tool
call was started (tool_index >= 0), but not when tool_index is -1."""
@staticmethod
def _preamble_with_tool_call_start_config() -> ParserEngineConfig:
return ParserEngineConfig(
name="preamble_tcs",
terminals={"TOOL_START": "<tool_call>"},
transitions={
(ParserState.CONTENT, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(EventType.TOOL_CALL_START,),
),
},
content_events={ParserState.CONTENT: EventType.TEXT_CHUNK},
)
@staticmethod
def _preamble_without_tool_call_start_config() -> ParserEngineConfig:
return ParserEngineConfig(
name="preamble_no_tcs",
terminals={"TOOL_CALLS_START": "<tool_calls>"},
transitions={
(ParserState.CONTENT, "TOOL_CALLS_START"): Transition(
ParserState.TOOL_PREAMBLE,
(),
),
},
content_events={ParserState.CONTENT: EventType.TEXT_CHUNK},
)
def test_finish_emits_tool_call_end_with_tool_index(self):
config = self._preamble_with_tool_call_start_config()
engine = StreamingParserEngine(config, tokenizer=None)
engine.feed("<tool_call>", [])
assert engine.state == ParserState.TOOL_PREAMBLE
assert engine.tool_index == 0
finish_events = engine.finish()
end_events = [e for e in finish_events if e.type == EventType.TOOL_CALL_END]
assert len(end_events) == 1
assert end_events[0].tool_index == 0
def test_finish_no_tool_call_end_without_tool_index(self):
config = self._preamble_without_tool_call_start_config()
engine = StreamingParserEngine(config, tokenizer=None)
engine.feed("<tool_calls>", [])
assert engine.state == ParserState.TOOL_PREAMBLE
assert engine.tool_index == -1
finish_events = engine.finish()
end_events = [e for e in finish_events if e.type == EventType.TOOL_CALL_END]
assert len(end_events) == 0
assert engine.state == ParserState.CONTENT
class TestRegexTerminalInfraRemoved:
"""TerminalDef.priority, LexerShape.regex_terminals, and the regex
matching loop were removed."""
def test_terminal_def_no_priority(self):
import regex as re
td = TerminalDef(name="X", pattern=re.compile("x"))
assert not hasattr(td, "priority")
def test_lexer_shape_no_regex_terminals(self):
shape = LexerShape([])
assert not hasattr(shape, "regex_terminals")
def test_terminals_from_literals_still_works(self):
literals = {"TOOL_START": "<tool_call>", "TOOL_END": "</tool_call>"}
defs = terminals_from_literals(literals)
assert len(defs) == 2
names = {d.name for d in defs}
assert names == {"TOOL_START", "TOOL_END"}
for d in defs:
assert d.is_literal
assert d.literal in ("<tool_call>", "</tool_call>")
class TestMultiCharTerminalInArgs:
"""Regression: multi-char terminals falling through in TOOL_ARGS
must be fed char-by-char via _feed_args_text, not _feed_args_char."""
@staticmethod
def _newline_config() -> ParserEngineConfig:
return ParserEngineConfig(
name="newline_test",
terminals={
"TOOL_START": "<tool_call>",
"TOOL_END": "</tool_call>",
"NEWLINE": "\n",
},
transitions={
(ParserState.CONTENT, "TOOL_START"): Transition(
ParserState.TOOL_ARGS,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_ARGS, "TOOL_END"): Transition(
ParserState.CONTENT,
(EventType.TOOL_CALL_END,),
),
},
content_events={
ParserState.CONTENT: EventType.TEXT_CHUNK,
ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
},
)
def test_newline_in_args_parsed_correctly(self):
engine = StreamingParserEngine(self._newline_config(), tokenizer=None)
text = '<tool_call>{"name": "f",\n"arguments": {"a": 1}}</tool_call>'
events = engine.parse_complete(text)
arg_text = "".join(
e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK
)
assert '"name": "f"' in arg_text
assert '"arguments"' in arg_text
def test_newline_in_args_streaming(self):
engine = StreamingParserEngine(self._newline_config(), tokenizer=None)
all_events = TestStreaming._feed_chars(
engine, '<tool_call>{"name": "f",\n"a": 1}</tool_call>'
)
arg_text = "".join(
e.value for e in all_events if e.type == EventType.ARG_VALUE_CHUNK
)
assert '"name": "f"' in arg_text
assert '"a": 1' in arg_text
class TestSkipToolParsing:
"""When skip_tool_parsing is set, tool tags become content."""
def test_tool_tags_emitted_as_content(self):
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
engine.skip_tool_parsing = True
text = '<tool_call>{"name": "f"}</tool_call>'
events = engine.parse_complete(text)
types = [e.type for e in events]
assert EventType.TOOL_CALL_START not in types
assert EventType.TOOL_CALL_END not in types
content = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
assert "<tool_call>" in content
assert "</tool_call>" in content
def test_skip_tool_streaming(self):
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
engine.skip_tool_parsing = True
all_events = TestStreaming._feed_chars(
engine, '<tool_call>{"name": "f"}</tool_call>'
)
types = [e.type for e in all_events]
assert EventType.TOOL_CALL_START not in types
content = "".join(e.value for e in all_events if e.type == EventType.TEXT_CHUNK)
assert "<tool_call>" in content
def test_reset_preserves_skip_tool_parsing(self):
engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
engine.skip_tool_parsing = True
engine.reset()
assert engine.skip_tool_parsing is True
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+549
View File
@@ -0,0 +1,549 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for the engine-based Qwen3 reasoning parser.
Validates that ``Qwen3Parser`` correctly handles
``<think>``/``</think>`` reasoning with Qwen3-specific extensions:
- ``<tool_call>`` as implicit reasoning end (terminal + token ID)
- Stripping ``<think>`` from generated output (old template compat)
- No terminal text (``</think>``, ``<tool_call>``) leaks into output
"""
import dataclasses
import pytest
from tests.parser.engine.conftest import make_mock_tokenizer
from tests.parser.engine.streaming_helpers import simulate_reasoning_streaming
from vllm.parser.engine.parser_engine_config import ParserState
from vllm.parser.qwen3 import Qwen3Parser, qwen3_config
_THINK_START_ID = 50
_THINK_END_ID = 51
_TOOL_CALL_ID = 60
_TOOL_CALL_END_ID = 61
_TEXT_ID = 100
_QWEN3_VOCAB = {
"<think>": _THINK_START_ID,
"</think>": _THINK_END_ID,
"<tool_call>": _TOOL_CALL_ID,
"</tool_call>": _TOOL_CALL_END_ID,
}
@pytest.fixture
def mock_tokenizer():
return make_mock_tokenizer(_QWEN3_VOCAB)
@pytest.fixture
def parser(mock_tokenizer):
return Qwen3Parser(mock_tokenizer)
class TestNonStreaming:
def test_reasoning_then_content(self, parser):
text = "<think>Let me analyze.</think>The answer is 42."
reasoning, content = parser.extract_reasoning(text, None)
assert reasoning == "Let me analyze."
assert content == "The answer is 42."
def test_no_start_token_in_output(self, parser):
"""Qwen3.5+ style: <think> in prompt, only </think> in output."""
text = "Let me think about this.</think>The answer is 42."
reasoning, content = parser.extract_reasoning(text, None)
assert reasoning == "Let me think about this."
assert content == "The answer is 42."
def test_reasoning_only(self, parser):
text = "<think>Still thinking...</think>"
reasoning, content = parser.extract_reasoning(text, None)
assert reasoning == "Still thinking..."
assert content is None
def test_no_end_tag_all_reasoning(self, parser):
"""No </think> means truncated output — everything is reasoning."""
text = "Hello, no reasoning here."
reasoning, content = parser.extract_reasoning(text, None)
assert reasoning == "Hello, no reasoning here."
assert content is None
def test_multiline_reasoning(self, parser):
text = (
"<think>Step 1: parse.\nStep 2: compute.\nStep 3: output.</think>Result: 7."
)
reasoning, content = parser.extract_reasoning(text, None)
assert "Step 1" in reasoning
assert "Step 3" in reasoning
assert content == "Result: 7."
def test_tool_call_implicit_end(self, parser):
"""<tool_call> without </think> acts as implicit reasoning end."""
text = (
"<think>I need to read the file.\n\n"
"<tool_call>\n<function=bash>\n"
"<parameter=cmd>ls</parameter>\n"
"</function>\n</tool_call>"
)
reasoning, content = parser.extract_reasoning(text, None)
assert reasoning == "I need to read the file.\n\n"
assert "</think>" not in reasoning
assert "<tool_call>" not in reasoning
def test_tool_call_implicit_end_no_think(self, parser):
"""<tool_call> as implicit end, no <think> in output."""
text = (
"I need to read the file.\n\n"
"<tool_call>\n<function=bash>\n"
"<parameter=cmd>ls</parameter>\n"
"</function>\n</tool_call>"
)
reasoning, content = parser.extract_reasoning(text, None)
assert reasoning == "I need to read the file.\n\n"
assert "<tool_call>" not in reasoning
def test_live_scenario_think_end_before_tool_call(self, parser):
"""Real model output: </think> immediately before <tool_call>.
Regression test for the bug where </think> and <parameter=...>
leaked into reasoning content.
"""
text = (
"The user wants to see what files are in the current directory"
" and their contents. Let me start by listing the directory."
"</think><tool_call><function=read>"
"<parameter=filePath>/Users/test/demo</parameter>"
"</function></tool_call>"
)
reasoning, content = parser.extract_reasoning(text, None)
expected_reasoning = (
"The user wants to see what files are in the current directory"
" and their contents. Let me start by listing the directory."
)
assert reasoning == expected_reasoning
assert "</think>" not in reasoning
assert "<tool_call>" not in reasoning
assert "<parameter=" not in reasoning
def test_no_terminal_text_in_reasoning(self, parser):
"""Terminal text must never appear in reasoning output."""
text = "Reasoning here.</think>Content here."
reasoning, content = parser.extract_reasoning(text, None)
assert "</think>" not in (reasoning or "")
assert "<think>" not in (reasoning or "")
def test_no_terminal_text_in_content(self, parser):
"""Terminal text must never appear in content output."""
text = "Reasoning here.</think>Content here."
reasoning, content = parser.extract_reasoning(text, None)
assert "</think>" not in (content or "")
assert "<think>" not in (content or "")
def test_duplicate_think_end_absorbed(self, parser):
"""Duplicate </think> in CONTENT state must not leak."""
text = "Reasoning here.</think>Content here.</think>More content."
reasoning, content = parser.extract_reasoning(text, None)
assert reasoning == "Reasoning here."
assert content == "Content here.More content."
class TestIsReasoningEnd:
def test_think_end_token(self, parser):
assert parser.is_reasoning_end([_THINK_START_ID, 1, _THINK_END_ID])
def test_no_end_token(self, parser):
assert not parser.is_reasoning_end([_THINK_START_ID, 1, 2])
def test_start_after_end_means_not_ended(self, parser):
assert not parser.is_reasoning_end([_THINK_END_ID, _THINK_START_ID, 1])
def test_tool_call_as_implicit_end(self, parser):
"""Unpaired <tool_call> is implicit reasoning end."""
assert parser.is_reasoning_end([_THINK_START_ID, 1, _TOOL_CALL_ID])
def test_paired_tool_call_not_end(self, parser):
"""Paired <tool_call>...</tool_call> (from template) is NOT end."""
assert not parser.is_reasoning_end(
[_THINK_START_ID, 1, _TOOL_CALL_ID, 2, _TOOL_CALL_END_ID]
)
def test_tool_call_after_think_end(self, parser):
"""<tool_call> after </think> — already ended."""
assert parser.is_reasoning_end(
[_THINK_START_ID, 1, _THINK_END_ID, _TOOL_CALL_ID]
)
def test_empty_ids(self, parser):
assert not parser.is_reasoning_end([])
class TestStreaming:
def test_basic_streaming(self, parser):
reasoning, content = simulate_reasoning_streaming(
parser,
["<think>", "thinking", " hard", "</think>", "done"],
[
(_THINK_START_ID,),
(1,),
(2,),
(_THINK_END_ID,),
(3,),
],
)
assert reasoning == "thinking hard"
assert content == "done"
def test_streaming_no_start_token(self, parser):
"""Qwen3.5 style: no <think> in output, just reasoning then </think>."""
reasoning, content = simulate_reasoning_streaming(
parser,
["reasoning ", "text", "</think>", "content"],
[
(1,),
(2,),
(_THINK_END_ID,),
(3,),
],
)
assert reasoning == "reasoning text"
assert content == "content"
def test_streaming_start_token_stripped(self, parser):
"""<think> in output (old template) should be stripped."""
reasoning, content = simulate_reasoning_streaming(
parser,
["<think>reasoning", "</think>", "content"],
[
(_THINK_START_ID, 1),
(_THINK_END_ID,),
(2,),
],
)
assert reasoning == "reasoning"
assert content == "content"
def test_streaming_tool_call_implicit_end(self, parser):
"""<tool_call> ends reasoning implicitly during streaming."""
reasoning, content = simulate_reasoning_streaming(
parser,
["I need to check.", "<tool_call>", "\n<function=test>"],
[
(1,),
(_TOOL_CALL_ID,),
(2,),
],
)
assert reasoning == "I need to check."
assert "<tool_call>" not in reasoning
assert "</think>" not in reasoning
assert content is not None
def test_streaming_content_after_think_end(self, parser):
"""Content deltas after </think> are routed as content."""
reasoning, content = simulate_reasoning_streaming(
parser,
["reasoning", "</think>", "content1", " content2"],
[
(1,),
(_THINK_END_ID,),
(2,),
(3,),
],
)
assert reasoning == "reasoning"
assert content == "content1 content2"
def test_streaming_content_after_tool_call(self, parser):
"""Content deltas after <tool_call> are routed as content."""
reasoning, content = simulate_reasoning_streaming(
parser,
["thinking", "<tool_call>", "<function=f>"],
[
(1,),
(_TOOL_CALL_ID,),
(2,),
],
)
assert reasoning == "thinking"
assert "<tool_call>" not in reasoning
assert content is not None
def test_streaming_end_grouped_with_content(self, parser):
"""</think> grouped with following content in one delta."""
reasoning, content = simulate_reasoning_streaming(
parser,
["reasoning", "</think>the answer"],
[
(1,),
(_THINK_END_ID, 2),
],
)
assert reasoning == "reasoning"
assert content == "the answer"
def test_streaming_think_and_end_in_one_delta(self, parser):
"""<think> and </think> in the same delta."""
reasoning, content = simulate_reasoning_streaming(
parser,
["<think>reasoning</think>"],
[
(_THINK_START_ID, 1, _THINK_END_ID),
],
)
assert reasoning == "reasoning"
assert content == ""
def test_streaming_pure_content_no_think(self, parser):
"""No think tokens at all — everything is reasoning (truncated)."""
reasoning, content = simulate_reasoning_streaming(
parser,
["hello ", "world"],
[
(1,),
(2,),
],
)
assert reasoning == "hello world"
assert content == ""
def test_streaming_think_end_and_tool_call_same_delta(self, parser):
"""</think> and <tool_call> in the same delta — no leakage.
Regression test: the old override split at <tool_call> without
stripping </think>, causing </think> to leak into reasoning.
"""
reasoning, content = simulate_reasoning_streaming(
parser,
[
"Let me list the directory.",
"</think><tool_call>",
"<function=read>",
"<parameter=filePath>/tmp</parameter>",
],
[
(1,),
(_THINK_END_ID, _TOOL_CALL_ID),
(2,),
(3,),
],
)
assert reasoning == "Let me list the directory."
assert "</think>" not in reasoning
assert "<tool_call>" not in reasoning
assert "<parameter=" not in reasoning
assert content is not None
def test_streaming_no_terminal_text_leaks(self, parser):
"""Terminal text must never appear in reasoning or content."""
reasoning, content = simulate_reasoning_streaming(
parser,
["reasoning", "</think>", "content"],
[
(1,),
(_THINK_END_ID,),
(2,),
],
)
assert "</think>" not in reasoning
assert "</think>" not in content
assert "<think>" not in reasoning
def test_streaming_duplicate_think_end_absorbed(self, parser):
"""Duplicate </think> token in CONTENT state must not leak."""
reasoning, content = simulate_reasoning_streaming(
parser,
["reasoning", "</think>", "content", "</think>", "more"],
[
(1,),
(_THINK_END_ID,),
(2,),
(_THINK_END_ID,),
(3,),
],
)
assert reasoning == "reasoning"
assert content == "contentmore"
class TestTrailingWhitespaceStripping:
"""When strip_trailing_reasoning_whitespace is True,
trailing whitespace before </think> must be stripped.
Models often generate trailing newlines before </think>, and these
accumulate across multi-turn conversations via a feedback loop.
"""
@pytest.fixture
def parser_with_strip(self):
cfg = dataclasses.replace(
qwen3_config(),
strip_trailing_reasoning_whitespace=True,
)
return Qwen3Parser(make_mock_tokenizer(_QWEN3_VOCAB), parser_engine_config=cfg)
def test_non_streaming_trailing_newline(self, parser_with_strip):
text = "Reasoning here.\n</think>Content."
reasoning, content = parser_with_strip.extract_reasoning(text, None)
assert reasoning == "Reasoning here."
assert content == "Content."
def test_non_streaming_multiple_trailing_newlines(self, parser_with_strip):
text = "Reasoning here.\n\n\n</think>Content."
reasoning, content = parser_with_strip.extract_reasoning(text, None)
assert reasoning == "Reasoning here."
assert content == "Content."
def test_non_streaming_internal_newlines_preserved(self, parser_with_strip):
text = "Step 1.\n\nStep 2.\n\nStep 3.</think>Answer."
reasoning, content = parser_with_strip.extract_reasoning(text, None)
assert reasoning == "Step 1.\n\nStep 2.\n\nStep 3."
assert content == "Answer."
def test_non_streaming_only_newlines_becomes_none(self, parser_with_strip):
text = "\n\n\n</think>Content."
reasoning, content = parser_with_strip.extract_reasoning(text, None)
assert reasoning is None
assert content == "Content."
def test_streaming_trailing_newline_stripped(self, parser_with_strip):
reasoning, content = simulate_reasoning_streaming(
parser_with_strip,
["thinking.\n", "</think>", "done"],
[
(1,),
(_THINK_END_ID,),
(2,),
],
)
assert reasoning == "thinking."
assert content == "done"
def test_streaming_multiple_trailing_newlines_stripped(self, parser_with_strip):
reasoning, content = simulate_reasoning_streaming(
parser_with_strip,
["thinking.\n", "\n", "\n", "</think>", "done"],
[
(1,),
(2,),
(3,),
(_THINK_END_ID,),
(4,),
],
)
assert reasoning == "thinking."
assert content == "done"
def test_streaming_internal_newlines_preserved(self, parser_with_strip):
reasoning, content = simulate_reasoning_streaming(
parser_with_strip,
["Step 1.\n", "\nStep 2.\n", "</think>", "Answer"],
[
(1,),
(2,),
(_THINK_END_ID,),
(3,),
],
)
assert reasoning == "Step 1.\n\nStep 2."
assert content == "Answer"
def test_streaming_trailing_newlines_before_tool_call(self, parser_with_strip):
"""Trailing newlines before implicit <tool_call> end are stripped."""
reasoning, content = simulate_reasoning_streaming(
parser_with_strip,
["I'll check.\n\n", "<tool_call>", "<function=test>"],
[
(1,),
(_TOOL_CALL_ID,),
(2,),
],
)
assert reasoning == "I'll check."
assert "<tool_call>" not in reasoning
class TestWhitespaceStrippingDisabled:
"""When strip_trailing_reasoning_whitespace is False,
trailing whitespace in reasoning must be preserved."""
@pytest.fixture
def parser_no_strip(self):
cfg = dataclasses.replace(
qwen3_config(),
strip_trailing_reasoning_whitespace=False,
)
return Qwen3Parser(make_mock_tokenizer(_QWEN3_VOCAB), parser_engine_config=cfg)
def test_non_streaming_preserves_trailing_newline(self, parser_no_strip):
text = "Reasoning here.\n</think>Content."
reasoning, content = parser_no_strip.extract_reasoning(text, None)
assert reasoning == "Reasoning here.\n"
assert content == "Content."
def test_streaming_preserves_trailing_newlines(self, parser_no_strip):
reasoning, content = simulate_reasoning_streaming(
parser_no_strip,
["thinking.\n", "\n", "</think>", "done"],
[
(1,),
(2,),
(_THINK_END_ID,),
(3,),
],
)
assert reasoning == "thinking.\n\n"
assert content == "done"
class TestThinkingDisabled:
"""When ``enable_thinking=False``, the chat template pre-fills a closed
``<think>\\n\\n</think>\\n\\n`` block. The model output starts in content
state, so the parser's initial state must be CONTENT — not REASONING.
"""
def test_thinking_disabled_initial_state_is_content(self, mock_tokenizer):
p = Qwen3Parser(
mock_tokenizer,
chat_template_kwargs={"enable_thinking": False},
)
assert p.parser_engine_config.initial_state == ParserState.CONTENT
def test_thinking_enabled_initial_state_is_reasoning(self, mock_tokenizer):
p = Qwen3Parser(
mock_tokenizer,
chat_template_kwargs={"enable_thinking": True},
)
assert p.parser_engine_config.initial_state == ParserState.REASONING
def test_default_initial_state_is_reasoning(self, mock_tokenizer):
p = Qwen3Parser(mock_tokenizer)
assert p.parser_engine_config.initial_state == ParserState.REASONING
def test_thinking_disabled_streaming_content_only(self, mock_tokenizer):
"""Plain text with thinking disabled must stream as content, not
reasoning. Before the fix, the REASONING initial state caused all
output to be emitted as reasoning chunks."""
p = Qwen3Parser(
mock_tokenizer,
chat_template_kwargs={"enable_thinking": False},
)
reasoning, content = simulate_reasoning_streaming(
p,
["The answer", " is 42."],
[
(_TEXT_ID,),
(_TEXT_ID,),
],
)
assert content == "The answer is 42."
assert reasoning == ""
def test_thinking_disabled_non_streaming(self, mock_tokenizer):
p = Qwen3Parser(
mock_tokenizer,
chat_template_kwargs={"enable_thinking": False},
)
reasoning, content = p.extract_reasoning("The answer is 42.", None)
assert reasoning is None
assert content == "The answer is 42."
+189
View File
@@ -0,0 +1,189 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Replay tests for engine parsers (holdback, skip-tool-parsing, adapters).
Replays dynamically built token sequences at different chunk sizes and
holdback depths to verify chunk-size invariance and terminal-token hygiene.
"""
from __future__ import annotations
import pytest
from tests.parser.engine.replay_harness import (
_test_request,
assert_no_terminal_leakage,
assert_parse_output,
collect_output,
make_mock_tokenizer,
replay_streaming,
)
from tests.parser.engine.trace_builder import build_samples
from vllm.parser.abstract_parser import Parser
from vllm.parser.engine.registered_adapters import (
Qwen3Parser,
)
_ENGINE_PARSERS: dict[str, type[Parser]] = {
"qwen3_engine": Qwen3Parser,
}
_qwen3_samples = build_samples("qwen3")
_QWEN3_TERMINALS = [
"<think>",
"</think>",
"<tool_call>",
"</tool_call>",
"<function=",
"</function>",
]
HOLDBACK_CONFIGS = [6, 12, 24]
@pytest.mark.parametrize("holdback", HOLDBACK_CONFIGS, ids=lambda h: f"holdback{h}")
@pytest.mark.parametrize("chunk_size", [5, 10], ids=lambda c: f"chunk{c}")
@pytest.mark.parametrize("sample", _qwen3_samples, ids=lambda s: s.id)
class TestQwen3ReplayWithHoldback:
"""Replay Qwen3 with simulated detokenizer holdback."""
def test_replay(self, sample, chunk_size, holdback):
tokenizer = make_mock_tokenizer(sample)
parser = Qwen3Parser(tokenizer, sample.tools)
deltas = replay_streaming(
parser,
sample.tokens,
chunk_size=chunk_size,
holdback_chars=holdback,
)
output = collect_output(deltas)
assert_parse_output(output, sample)
assert_no_terminal_leakage(
output,
_QWEN3_TERMINALS,
context=f"chunk_size={chunk_size}, holdback={holdback}",
)
_TOOL_CALL_SAMPLES = [
(Qwen3Parser, s)
for s in _qwen3_samples
if s.expected_tool_calls and s.expected_reasoning
]
def _suppressed_expectations(sample) -> tuple[str, str]:
"""Compute expected (reasoning, content) when tools are suppressed.
When an explicit reasoning-end delimiter (``</think>``, ``<channel|>``)
is present, reasoning ends there and the tool call block becomes content.
When reasoning ends implicitly (the tool-start token triggers both
REASONING_END and TOOL_CALL_START), reasoning still ends at the tool
start and the raw tool call block becomes content text — only the
structured tool parsing is suppressed, not the reasoning boundary.
"""
full_text = "".join(text for _, text in sample.tokens)
reasoning = sample.expected_reasoning
idx = full_text.find(reasoning)
if idx < 0:
return (full_text, "")
after_reasoning = full_text[idx + len(reasoning) :]
for delim in ("</think>", "<channel|>"):
pos = after_reasoning.find(delim)
if pos >= 0:
return (reasoning, after_reasoning[pos + len(delim) :])
for delim in ("<tool_call>",):
pos = after_reasoning.find(delim)
if pos >= 0:
return (reasoning, after_reasoning[pos:])
return (full_text, "")
_DUMMY_TOOLS = [
{
"type": "function",
"function": {"name": "stub", "parameters": {"type": "object"}},
}
]
@pytest.mark.parametrize("chunk_size", [1, 5, None], ids=lambda c: f"chunk{c}")
@pytest.mark.parametrize(
"parser_cls,sample",
_TOOL_CALL_SAMPLES,
ids=lambda v: v.id if hasattr(v, "id") else v.__name__,
)
class TestSkipToolParsingReplay:
"""Replay with skip_tool_parsing=True (tool_choice='none').
Verifies that reasoning is extracted normally and the raw tool call
block appears as content text with no tool calls parsed.
"""
def test_replay(self, parser_cls, sample, chunk_size):
tokenizer = make_mock_tokenizer(sample)
kwargs = {}
if sample.chat_template_kwargs:
kwargs["chat_template_kwargs"] = sample.chat_template_kwargs
parser = parser_cls(tokenizer, **kwargs)
request = _test_request()
request.tool_choice = "none"
request.tools = _DUMMY_TOOLS
all_ids = [tid for tid, _ in sample.tokens]
all_texts = [text for _, text in sample.tokens]
if chunk_size is None:
chunk_size = len(all_ids)
results = []
chunks = list(range(0, len(all_ids), chunk_size))
for i, start in enumerate(chunks):
end = min(start + chunk_size, len(all_ids))
is_last = i == len(chunks) - 1
result = parser.parse_delta(
"".join(all_texts[start:end]),
all_ids[start:end],
request,
prompt_token_ids=[] if start == 0 else None,
finished=is_last,
)
results.append(result)
output = collect_output(results)
expected_reasoning, expected_content = _suppressed_expectations(sample)
assert output.reasoning == expected_reasoning, (
f"Reasoning mismatch:\n"
f" expected: {expected_reasoning!r}\n"
f" actual: {output.reasoning!r}"
)
assert output.tool_calls == [], (
f"Expected no tool calls but got {output.tool_calls}"
)
assert output.content == expected_content, (
f"Content mismatch:\n"
f" expected: {expected_content!r}\n"
f" actual: {output.content!r}"
)
class TestAdapterReferences:
"""Verify make_adapters sets reasoning/tool parser class refs on parser engine
parser classes so the serving layer finds them and calls adjust_request."""
@pytest.mark.parametrize(
"parser_name",
list(_ENGINE_PARSERS.keys()),
)
def test_adapter_cls_refs_set(self, parser_name):
parser_cls = _ENGINE_PARSERS[parser_name]
assert parser_cls.reasoning_parser_cls is not None, (
f"{parser_name}: reasoning_parser_cls is None"
)
assert parser_cls.tool_parser_cls is not None, (
f"{parser_name}: tool_parser_cls is None"
)
@@ -0,0 +1,631 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for TokenIDScanner, focusing on hold-back text recovery.
Uses gemma4_config for all end-to-end engine tests, covering
reasoning channels, tool calls, and combined flows."""
from unittest.mock import MagicMock
import pytest
from vllm.parser.engine.events import EventType
from vllm.parser.engine.token_id_scanner import (
PreLexedTerminal,
TextChunk,
TokenIDScanner,
)
CHANNEL_START = "<|channel>"
CHANNEL_END = "<channel|>"
CHANNEL_START_ID = 100
CHANNEL_END_ID = 101
REGULAR_TOKEN_ID = 200
TOOL_START = "<tool_call>"
TOOL_END = "</tool_call>"
TOOL_START_ID = 110
TOOL_END_ID = 111
@pytest.fixture
def tokenizer():
tok = MagicMock()
tok.get_vocab.return_value = {
CHANNEL_START: CHANNEL_START_ID,
CHANNEL_END: CHANNEL_END_ID,
}
tok.decode.side_effect = lambda ids: {
CHANNEL_START_ID: CHANNEL_START,
CHANNEL_END_ID: CHANNEL_END,
REGULAR_TOKEN_ID: "regular",
}.get(ids[0], f"<unk:{ids[0]}>")
return tok
@pytest.fixture
def scanner(tokenizer):
return TokenIDScanner(
token_id_to_terminal={
CHANNEL_START_ID: "THINK_START",
CHANNEL_END_ID: "THINK_END",
},
tokenizer=tokenizer,
)
class TestJoinDecodedTextReturnsStr:
"""_join_decoded_text now returns str unconditionally (was
str | None when an isinstance guard made a branch unreachable)."""
@pytest.fixture
def bare_scanner(self):
return TokenIDScanner({}, tokenizer=None, drop_token_ids=set())
def test_mixed_items(self, bare_scanner):
items = [
TextChunk("hello "),
PreLexedTerminal("TOOL_START", 42, "<tool_call>"),
TextChunk(" world"),
]
result = bare_scanner._join_decoded_text(items)
assert isinstance(result, str)
assert result == "hello <tool_call> world"
def test_empty_list(self, bare_scanner):
result = bare_scanner._join_decoded_text([])
assert isinstance(result, str)
assert result == ""
def test_only_text_chunks(self, bare_scanner):
result = bare_scanner._join_decoded_text([TextChunk("abc"), TextChunk("def")])
assert result == "abcdef"
class TestHoldbackTextRecovery:
def test_holdback_text_with_special_token_text_absent(self, scanner):
"""delta_text has hold-back text but the special token's text is
NOT in delta_text (held back by the detokenizer). Terminal is
deferred until the text arrives in a subsequent delta."""
result = scanner.scan(
delta_text="processed is appropriate.",
delta_token_ids=[CHANNEL_END_ID],
)
assert len(result) == 0
# Second scan: terminal text arrives (detokenizer flushes).
# Deferred terminal resolves with holdback text before it.
result2 = scanner.scan(
delta_text="<channel|>Understood.",
delta_token_ids=[20, 21],
)
pre_lexed = [r for r in result2 if isinstance(r, PreLexedTerminal)]
assert len(pre_lexed) == 1
assert pre_lexed[0].terminal == "THINK_END"
texts = [r.text for r in result2 if isinstance(r, TextChunk)]
combined = "".join(texts)
assert "processed is appropriate." in combined
assert "Understood." in combined
def test_holdback_text_with_special_token_text_present(self, scanner):
"""delta_text includes hold-back text AND the special token text."""
result = scanner.scan(
delta_text="holdback text<channel|>",
delta_token_ids=[CHANNEL_END_ID],
)
assert len(result) == 2
assert isinstance(result[0], TextChunk)
assert result[0].text == "holdback text"
assert isinstance(result[1], PreLexedTerminal)
assert result[1].terminal == "THINK_END"
def test_no_holdback_text(self, scanner):
"""delta_text is exactly the special token text — no hold-back."""
result = scanner.scan(
delta_text="<channel|>",
delta_token_ids=[CHANNEL_END_ID],
)
assert len(result) == 1
assert isinstance(result[0], PreLexedTerminal)
assert result[0].terminal == "THINK_END"
def test_empty_delta_text(self, scanner):
"""delta_text is empty — terminal deferred until text arrives."""
result = scanner.scan(
delta_text="",
delta_token_ids=[CHANNEL_END_ID],
)
assert len(result) == 0
flushed = scanner.flush_pending()
assert len(flushed) == 1
assert isinstance(flushed[0], PreLexedTerminal)
assert flushed[0].terminal == "THINK_END"
def test_empty_delta_text_drops_individual_decode_text(self, tokenizer):
"""delta_text="" with multiple tokens including special: all
results deferred — individually-decoded TextChunks are unreliable
and PreLexedTerminals wait for text confirmation."""
tool_start_id = 400
tok_a = 201
tok_b = 202
tokenizer.decode.side_effect = lambda ids: {
tool_start_id: "<|tool_call>",
tok_a: "call:",
tok_b: "get_weather",
}.get(ids[0], "?")
scanner = TokenIDScanner(
token_id_to_terminal={tool_start_id: "TOOL_START"},
tokenizer=tokenizer,
)
result = scanner.scan(
delta_text="",
delta_token_ids=[tool_start_id, tok_a, tok_b],
)
assert len(result) == 0
flushed = scanner.flush_pending()
assert len(flushed) == 1
assert isinstance(flushed[0], PreLexedTerminal)
assert flushed[0].terminal == "TOOL_START"
def test_holdback_before_start_tag(self, scanner):
"""Hold-back text before a reasoning start tag."""
result = scanner.scan(
delta_text="prefix text<|channel>",
delta_token_ids=[CHANNEL_START_ID],
)
assert len(result) == 2
assert isinstance(result[0], TextChunk)
assert result[0].text == "prefix text"
assert isinstance(result[1], PreLexedTerminal)
assert result[1].terminal == "THINK_START"
def test_multi_token_batch_special_in_middle(self, scanner, tokenizer):
"""Stream-interval > 1: batch has regular tokens + special token.
delta_text differs from individual decodes (context-dependent)."""
tok_a = 201
tok_b = 202
tokenizer.decode.side_effect = lambda ids: {
tok_a: "wordA",
tok_b: "wordB",
CHANNEL_END_ID: CHANNEL_END,
}.get(ids[0], "?")
scanner_multi = TokenIDScanner(
token_id_to_terminal={CHANNEL_END_ID: "THINK_END"},
tokenizer=tokenizer,
)
result = scanner_multi.scan(
delta_text="holdback wordA<channel|> wordB",
delta_token_ids=[tok_a, CHANNEL_END_ID, tok_b],
)
texts = [r.text for r in result if isinstance(r, TextChunk)]
terminals = [r.terminal for r in result if isinstance(r, PreLexedTerminal)]
assert "THINK_END" in terminals
assert "holdback wordA" in "".join(texts)
def test_multi_token_batch_special_token_text_absent(self, scanner, tokenizer):
"""Stream-interval > 1: batch has regular + special token, but
delta_text doesn't contain the special token text at all
(held back by detokenizer along with trailing regular tokens).
Terminal is deferred until text arrives."""
tok_a = 201
tok_b = 202
tokenizer.decode.side_effect = lambda ids: {
tok_a: "alpha",
tok_b: "beta",
CHANNEL_END_ID: CHANNEL_END,
}.get(ids[0], "?")
scanner_multi = TokenIDScanner(
token_id_to_terminal={CHANNEL_END_ID: "THINK_END"},
tokenizer=tokenizer,
)
result = scanner_multi.scan(
delta_text="holdback alpha",
delta_token_ids=[tok_a, CHANNEL_END_ID, tok_b],
)
assert len(result) == 0
# Next delta: terminal text arrives (detokenizer flushes).
# Deferred terminal resolves with holdback text before it.
result2 = scanner_multi.scan(
delta_text="<channel|> more text",
delta_token_ids=[300],
)
pre_lexed = [r for r in result2 if isinstance(r, PreLexedTerminal)]
assert len(pre_lexed) == 1
assert pre_lexed[0].terminal == "THINK_END"
text_chunks = [r for r in result2 if isinstance(r, TextChunk)]
combined = "".join(t.text for t in text_chunks)
assert "holdback alpha" in combined
assert "more text" in combined
def test_holdback_with_content_after_special_token(self, tokenizer):
"""delta_text has hold-back + special token + content after,
with corresponding token IDs for all parts."""
tok_content = 210
tokenizer.decode.side_effect = lambda ids: {
CHANNEL_END_ID: CHANNEL_END,
tok_content: "content start",
}.get(ids[0], "?")
scanner = TokenIDScanner(
token_id_to_terminal={CHANNEL_END_ID: "THINK_END"},
tokenizer=tokenizer,
)
result = scanner.scan(
delta_text="reasoning end.<channel|>content start",
delta_token_ids=[CHANNEL_END_ID, tok_content],
)
pre_lexed = [r for r in result if isinstance(r, PreLexedTerminal)]
assert len(pre_lexed) == 1
assert pre_lexed[0].terminal == "THINK_END"
text_chunks = [r for r in result if isinstance(r, TextChunk)]
combined = "".join(t.text for t in text_chunks)
assert "reasoning end." in combined
class TestDropTokens:
def test_drop_token_with_holdback(self, tokenizer):
"""Drop tokens stripped from delta_text, hold-back text preserved.
Terminal is deferred when its text is absent from delta_text."""
drop_id = 300
tokenizer.decode.side_effect = lambda ids: {
CHANNEL_END_ID: CHANNEL_END,
drop_id: "<eos>",
}.get(ids[0], "?")
scanner = TokenIDScanner(
token_id_to_terminal={CHANNEL_END_ID: "THINK_END"},
tokenizer=tokenizer,
drop_token_ids={drop_id},
)
result = scanner.scan(
delta_text="holdback<eos>",
delta_token_ids=[drop_id, CHANNEL_END_ID],
)
assert len(result) == 0
# Terminal text arrives in next delta; deferred terminal resolves.
result2 = scanner.scan(
delta_text="<channel|>content",
delta_token_ids=[20],
)
pre_lexed = [r for r in result2 if isinstance(r, PreLexedTerminal)]
assert len(pre_lexed) == 1
assert pre_lexed[0].terminal == "THINK_END"
texts = [r.text for r in result2 if isinstance(r, TextChunk)]
combined = "".join(texts)
assert "holdback" in combined
assert "<eos>" not in combined
assert len(scanner.flush_pending()) == 0
class TestEndToEndReasoningHoldback:
"""End-to-end tests through the full parser engine simulating
stream-interval > 1 and detokenizer hold-back, using
gemma4_config."""
def test_reasoning_content_not_truncated(self):
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
config = ParserEngineConfig(
name="test_channel",
initial_state=ParserState.CONTENT,
terminals={
"THINK_START": CHANNEL_START,
"THINK_END": CHANNEL_END,
},
token_id_terminals={
"THINK_START": CHANNEL_START,
"THINK_END": CHANNEL_END,
},
transitions={
(ParserState.CONTENT, "THINK_START"): Transition(
ParserState.REASONING,
(EventType.REASONING_START,),
),
(ParserState.REASONING, "THINK_END"): Transition(
ParserState.CONTENT,
(EventType.REASONING_END,),
),
},
)
tok = MagicMock()
vocab = {
CHANNEL_START: CHANNEL_START_ID,
CHANNEL_END: CHANNEL_END_ID,
}
tok.get_vocab.return_value = vocab
tok.decode.side_effect = lambda ids: {
CHANNEL_START_ID: CHANNEL_START,
CHANNEL_END_ID: CHANNEL_END,
}.get(ids[0], f"tok{ids[0]}")
engine = StreamingParserEngine(config, tok)
all_events = []
# Delta 1: channel start token (text includes start tag)
all_events.extend(engine.feed(CHANNEL_START, [CHANNEL_START_ID]))
# Delta 2: reasoning text (normal content, no special tokens)
all_events.extend(
engine.feed(
"thought\nThe request was received and ",
[10, 11, 12, 13, 14],
)
)
# Delta 3: MORE reasoning text, the detokenizer held some back.
# Then channel end token arrives in token_ids, but its text
# is NOT in delta_text (held back by detokenizer).
# delta_text = previously held-back reasoning text only.
all_events.extend(
engine.feed(
"processed is appropriate.",
[CHANNEL_END_ID],
)
)
# Delta 4: detokenizer flushes held-back channel end text
# plus new content tokens.
all_events.extend(
engine.feed(
"<channel|>Understood.",
[20, 21],
)
)
all_events.extend(engine.finish())
reasoning_text = "".join(
e.value for e in all_events if e.type == EventType.REASONING_CHUNK
)
content_text = "".join(
e.value for e in all_events if e.type == EventType.TEXT_CHUNK
)
assert "processed is appropriate." in reasoning_text
assert "Understood." in content_text
def test_backtick_content_not_truncated(self):
"""Reproduces the hostname backtick truncation case."""
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
config = ParserEngineConfig(
name="test_channel",
initial_state=ParserState.CONTENT,
terminals={
"THINK_START": CHANNEL_START,
"THINK_END": CHANNEL_END,
},
token_id_terminals={
"THINK_START": CHANNEL_START,
"THINK_END": CHANNEL_END,
},
transitions={
(ParserState.CONTENT, "THINK_START"): Transition(
ParserState.REASONING,
(EventType.REASONING_START,),
),
(ParserState.REASONING, "THINK_END"): Transition(
ParserState.CONTENT,
(EventType.REASONING_END,),
),
},
)
tok = MagicMock()
vocab = {
CHANNEL_START: CHANNEL_START_ID,
CHANNEL_END: CHANNEL_END_ID,
}
tok.get_vocab.return_value = vocab
tok.decode.side_effect = lambda ids: {
CHANNEL_START_ID: CHANNEL_START,
CHANNEL_END_ID: CHANNEL_END,
}.get(ids[0], f"tok{ids[0]}")
engine = StreamingParserEngine(config, tok)
all_events = []
all_events.extend(engine.feed(CHANNEL_START, [CHANNEL_START_ID]))
all_events.extend(
engine.feed(
"thought\n1/10 completed. Next: ",
[10, 11, 12, 13],
)
)
# Hold-back text includes backtick content; channel end text
# absent from delta_text.
all_events.extend(
engine.feed(
"`hostname`.\n",
[CHANNEL_END_ID],
)
)
# Next delta flushes channel end + tool call start
all_events.extend(
engine.feed(
"<channel|>tool output",
[20, 21],
)
)
all_events.extend(engine.finish())
reasoning_text = "".join(
e.value for e in all_events if e.type == EventType.REASONING_CHUNK
)
assert "`hostname`." in reasoning_text
class TestRebuildFromAnchorsLiteralLookalike:
"""When delta_text contains a literal mention of a special token's
text before the real special token, _rebuild_from_anchors must
anchor at the real occurrence, not the literal one."""
@pytest.fixture
def tool_scanner(self):
tok = MagicMock()
tok.get_vocab.return_value = {
TOOL_START: TOOL_START_ID,
TOOL_END: TOOL_END_ID,
}
tok.decode.side_effect = lambda ids: {
TOOL_START_ID: TOOL_START,
TOOL_END_ID: TOOL_END,
}.get(ids[0], f"t{ids[0]}")
return TokenIDScanner(
{TOOL_START_ID: "TOOL_START", TOOL_END_ID: "TOOL_END"},
tok,
)
def test_literal_before_real_anchor(self, tool_scanner):
"""Literal <tool_call> in prose followed by a real <tool_call>
special token — the scanner must split at the real one."""
delta_text = 'Use <tool_call> like this: <tool_call>{"name":"f"}</tool_call>'
delta_token_ids = [1, 2, 3, 4, 5, TOOL_START_ID, 6, 7, TOOL_END_ID]
items = tool_scanner.scan(delta_text, delta_token_ids)
text_parts = [it.text for it in items if isinstance(it, TextChunk)]
terminals = [it for it in items if isinstance(it, PreLexedTerminal)]
assert len(terminals) == 2
assert terminals[0].terminal == "TOOL_START"
assert terminals[1].terminal == "TOOL_END"
# The literal mention must appear in a text chunk, not be
# consumed by the TOOL_START anchor.
joined_text = "".join(text_parts)
assert "<tool_call>" in joined_text
assert '{"name":"f"}' in joined_text
def test_multiple_tool_calls_with_literal_between(self, tool_scanner):
"""Two real tool calls with a literal mention between them."""
delta_text = (
'<tool_call>{"name":"a"}</tool_call>'
" see <tool_call> syntax "
'<tool_call>{"name":"b"}</tool_call>'
)
delta_token_ids = [
TOOL_START_ID,
1,
TOOL_END_ID,
2,
3,
4,
TOOL_START_ID,
5,
TOOL_END_ID,
]
items = tool_scanner.scan(delta_text, delta_token_ids)
terminals = [it for it in items if isinstance(it, PreLexedTerminal)]
assert len(terminals) == 4
text_parts = [it.text for it in items if isinstance(it, TextChunk)]
joined_text = "".join(text_parts)
# The literal mention between the two real calls must be in text
assert "<tool_call> syntax" in joined_text
class TestRebuildFromAnchorsCascadingDeferral:
"""When a middle anchor's text is absent from delta_text,
only that anchor should be deferred — not subsequent ones
with valid positions."""
@pytest.fixture
def bare_scanner(self):
tok = MagicMock()
tok.decode.side_effect = lambda ids: f"t{ids[0]}"
return TokenIDScanner({}, tok)
def test_middle_anchor_missing_does_not_cascade(self, bare_scanner):
a = PreLexedTerminal("TOOL_START", TOOL_START_ID, TOOL_START)
b = PreLexedTerminal("THINK_END", CHANNEL_END_ID, CHANNEL_END)
c = PreLexedTerminal("TOOL_END", TOOL_END_ID, TOOL_END)
delta_text = f"prefix{TOOL_START}middle{TOOL_END}suffix"
results = [a, b, c]
rebuilt = bare_scanner._rebuild_from_anchors(delta_text, results)
terminals = [r for r in rebuilt if isinstance(r, PreLexedTerminal)]
texts = [r for r in rebuilt if isinstance(r, TextChunk)]
joined = "".join(t.text for t in texts)
assert len(terminals) == 2
assert terminals[0].terminal == "TOOL_START"
assert terminals[1].terminal == "TOOL_END"
assert "prefix" in joined
assert "middle" in joined
assert "suffix" in joined
assert len(bare_scanner._deferred_terminals) == 1
assert bare_scanner._deferred_terminals[0].terminal == "THINK_END"
assert bare_scanner._deferred_post_text == ""
def test_first_anchor_missing_rest_still_emitted(self, bare_scanner):
a = PreLexedTerminal("THINK_END", CHANNEL_END_ID, CHANNEL_END)
b = PreLexedTerminal("TOOL_START", TOOL_START_ID, TOOL_START)
delta_text = f"text{TOOL_START}more"
results = [a, b]
rebuilt = bare_scanner._rebuild_from_anchors(delta_text, results)
terminals = [r for r in rebuilt if isinstance(r, PreLexedTerminal)]
assert len(terminals) == 1
assert terminals[0].terminal == "TOOL_START"
assert len(bare_scanner._deferred_terminals) == 1
assert bare_scanner._deferred_terminals[0].terminal == "THINK_END"
def test_last_anchor_missing_preceding_still_emitted(self, bare_scanner):
a = PreLexedTerminal("TOOL_START", TOOL_START_ID, TOOL_START)
b = PreLexedTerminal("THINK_END", CHANNEL_END_ID, CHANNEL_END)
delta_text = f"text{TOOL_START}more"
results = [a, b]
rebuilt = bare_scanner._rebuild_from_anchors(delta_text, results)
terminals = [r for r in rebuilt if isinstance(r, PreLexedTerminal)]
assert len(terminals) == 1
assert terminals[0].terminal == "TOOL_START"
texts = [r for r in rebuilt if isinstance(r, TextChunk)]
joined = "".join(t.text for t in texts)
assert "text" in joined
# "more" is deferred along with the missing terminal —
# it will be resolved in the next scan when the terminal
# text arrives.
assert bare_scanner._deferred_post_text == "more"
assert len(bare_scanner._deferred_terminals) == 1
assert bare_scanner._deferred_terminals[0].terminal == "THINK_END"
+410
View File
@@ -0,0 +1,410 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""On-demand trace builder for parser engine testing and benchmarks.
Generates token sequences programmatically from model-agnostic scenario
definitions. Each model format handler knows how to render scenarios
into the model's output format, tokenize them with correct special token
IDs, and compute expected parse outputs.
Every generated sample is self-validated by replaying it through the
real parser before being returned.
"""
from __future__ import annotations
import functools
import json
from dataclasses import dataclass
from typing import Any
from tests.parser.engine.replay_harness import (
MockTokenizer,
Sample,
assert_parse_output,
collect_output,
replay_streaming,
)
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionToolsParam,
)
from vllm.parser.engine.registered_adapters import (
Qwen3Parser,
)
# ── Data structures ──────────────────────────────────────────────────
@dataclass
class ToolCallSpec:
name: str
arguments: dict[str, Any]
@dataclass
class Scenario:
id: str
description: str
reasoning: str | None = None
content: str | None = None
tool_calls: list[ToolCallSpec] | None = None
# ── Scenarios ────────────────────────────────────────────────────────
_READ_TOOL = ToolCallSpec("read_file", {"path": "/tmp/test.txt"})
_BASH_TOOL = ToolCallSpec(
"bash", {"command": "hostname", "description": "Get hostname"}
)
_WEATHER_TOOL = ToolCallSpec(
"get_weather",
{"city": "Dallas", "state": "TX", "unit": "fahrenheit"},
)
_COMPLEX_TOOL = ToolCallSpec(
"search",
{
"query": "vllm parser",
"filters": {"language": "python", "min_stars": 100},
"tags": ["ml", "inference"],
"limit": 10,
"verbose": True,
},
)
SCENARIOS: list[Scenario] = [
Scenario(
id="think-then-tool",
description="Reasoning then single tool call",
reasoning="Let me check the file.",
tool_calls=[_READ_TOOL],
),
Scenario(
id="think-then-parallel-tools",
description="Reasoning then two parallel tool calls",
reasoning="I need to run both commands.",
tool_calls=[_BASH_TOOL, _WEATHER_TOOL],
),
Scenario(
id="think-then-content",
description="Reasoning then content response",
reasoning="Let me think about this carefully.",
content="The answer is 42.",
),
Scenario(
id="content-only",
description="Plain content response without reasoning",
content="Hello! How can I help you today?",
),
Scenario(
id="tool-only",
description="Tool call without reasoning",
tool_calls=[_READ_TOOL],
),
Scenario(
id="complex-json-args",
description="Tool call with nested objects, arrays, numbers, booleans",
reasoning="This needs a complex query.",
tool_calls=[_COMPLEX_TOOL],
),
Scenario(
id="whitespace-before-tool",
description="Whitespace-only content before tool call",
content="\n\n",
tool_calls=[_WEATHER_TOOL],
),
Scenario(
id="think-content-tool",
description="Reasoning, content, then tool call",
reasoning="Let me analyze and then fetch data.",
content="Checking the weather now.",
tool_calls=[_WEATHER_TOOL],
),
Scenario(
id="think-whitespace-tool",
description="Reasoning, whitespace-only gap, then tool call",
reasoning="Let me check the file contents.",
content="\n\n",
tool_calls=[_READ_TOOL],
),
Scenario(
id="empty-reasoning-content",
description="Empty reasoning section followed by content",
reasoning="",
content="The epoch timestamp is 1779111346.",
),
]
# ── Tokenization ─────────────────────────────────────────────────────
def _word_split(text: str) -> list[str]:
"""Split text into word-like tokens, preserving all characters."""
if not text:
return []
parts: list[str] = []
current = ""
for ch in text:
if ch in " \t\n\r" and current and current[-1] not in " \t\n\r":
parts.append(current)
current = ch
else:
current += ch
if current:
parts.append(current)
return parts
def _tokenize(
segments: list[tuple[str, bool]],
vocab: dict[str, int],
start_id: int = 100,
) -> list[tuple[int, str]]:
"""Build token list from segments.
Each segment is ``(text, is_special)``. Special segments use vocab
IDs; content segments are word-split with sequential IDs.
"""
tokens: list[tuple[int, str]] = []
next_id = start_id
for text, is_special in segments:
if not text:
continue
if is_special:
tid = vocab.get(text)
if tid is None:
raise ValueError(f"Special token {text!r} not in vocab")
tokens.append((tid, text))
else:
for word in _word_split(text):
tokens.append((next_id, word))
next_id += 1
return tokens
# ── Tool definitions ─────────────────────────────────────────────────
def _infer_schema(value: object) -> dict:
"""Infer a JSON Schema from a Python value, recursing into dicts/lists."""
if isinstance(value, bool):
return {"type": "boolean"}
if isinstance(value, int):
return {"type": "integer"}
if isinstance(value, float):
return {"type": "number"}
if isinstance(value, str):
return {"type": "string"}
if isinstance(value, dict):
return {
"type": "object",
"properties": {k: _infer_schema(v) for k, v in value.items()},
}
if isinstance(value, list) and value:
return {"type": "array", "items": _infer_schema(value[0])}
if isinstance(value, list):
return {"type": "array"}
return {}
def _tool_defs(tool_calls: list[ToolCallSpec]) -> list[dict]:
"""Generate OpenAI-style tool definitions from tool call specs."""
seen: set[str] = set()
tools: list[dict] = []
for tc in tool_calls:
if tc.name in seen:
continue
seen.add(tc.name)
properties = {k: _infer_schema(v) for k, v in tc.arguments.items()}
tools.append(
{
"type": "function",
"function": {
"name": tc.name,
"parameters": {
"type": "object",
"properties": properties,
},
},
}
)
return tools
# ── Format handlers ──────────────────────────────────────────────────
def _expected_tc(scenario: Scenario) -> list[dict] | None:
if not scenario.tool_calls:
return None
return [{"name": tc.name, "arguments": tc.arguments} for tc in scenario.tool_calls]
def _expected_tools(scenario: Scenario) -> list[dict] | None:
return _tool_defs(scenario.tool_calls) if scenario.tool_calls else None
def _validate_sample(sample: Sample, parser_cls: type, **kwargs) -> None:
"""Replay sample through the real parser and assert correctness."""
tokenizer = MockTokenizer(vocab=dict(sample.vocab), tokens=sample.tokens)
parser = parser_cls(tokenizer, sample.tools, **kwargs)
deltas = replay_streaming(parser, sample.tokens, chunk_size=1, tools=sample.tools)
output = collect_output(deltas)
assert_parse_output(output, sample)
def _validate_tools(
tools: list[dict] | None,
) -> list[ChatCompletionToolsParam] | None:
if not tools:
return None
return [ChatCompletionToolsParam.model_validate(t) for t in tools]
def _make_sample(
sample_id: str,
description: str,
vocab: dict[str, int],
segments: list[tuple[str, bool]],
expected_reasoning: str | None,
expected_content: str | None,
expected_tool_calls: list[dict] | None,
tools: list[dict] | None,
chat_template_kwargs: dict | None = None,
) -> Sample:
tokens = _tokenize(segments, vocab)
return Sample(
id=sample_id,
description=description,
source="trace-builder",
vocab=dict(vocab),
tokens=tokens,
expected_reasoning=expected_reasoning,
expected_content=expected_content,
expected_tool_calls=expected_tool_calls,
tools=_validate_tools(tools),
chat_template_kwargs=chat_template_kwargs,
)
# ── Qwen3 / NemotronV3 (XML tool format, starts in REASONING) ───────
_QWEN3_VOCAB: dict[str, int] = {
"<think>": 50,
"</think>": 51,
"<tool_call>": 60,
"</tool_call>": 61,
}
def _qwen3_arg_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False)
def _qwen3_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
parts = [f"\n<function={tc.name}>"]
for key, value in tc.arguments.items():
parts.append(f"\n<parameter={key}>{_qwen3_arg_value(value)}</parameter>")
parts.append("\n</function>\n")
return [
("<tool_call>", True),
("".join(parts), False),
("</tool_call>", True),
]
def _qwen3_segments(scenario: Scenario) -> list[tuple[str, bool]]:
segs: list[tuple[str, bool]] = []
if scenario.reasoning is not None:
segs.append((scenario.reasoning, False))
if scenario.content is not None or scenario.tool_calls:
segs.append(("</think>", True))
if scenario.content is not None:
segs.append((scenario.content, False))
if scenario.tool_calls:
for tc in scenario.tool_calls:
segs.extend(_qwen3_tool_segments(tc))
return segs
def _qwen3_expected_content(scenario: Scenario) -> str | None:
if (
scenario.content is not None
and scenario.tool_calls
and not scenario.content.strip()
):
return ""
return scenario.content
def _build_qwen3(
scenario: Scenario,
name: str = "qwen3",
parser_cls: type = Qwen3Parser,
strip_trailing_ws: bool = False,
validate: bool = True,
) -> Sample:
expected_reasoning: str | None
if scenario.reasoning is not None:
r = scenario.reasoning
if strip_trailing_ws:
r = r.rstrip()
expected_reasoning = r
else:
expected_reasoning = ""
sample = _make_sample(
sample_id=f"{name}-{scenario.id}",
description=scenario.description,
vocab=_QWEN3_VOCAB,
segments=_qwen3_segments(scenario),
expected_reasoning=expected_reasoning,
expected_content=_qwen3_expected_content(scenario),
expected_tool_calls=_expected_tc(scenario),
tools=_expected_tools(scenario),
)
if validate:
_validate_sample(sample, parser_cls)
return sample
# ── Registry and public API ──────────────────────────────────────────
_BUILDERS: dict[str, Any] = {
"qwen3": _build_qwen3,
}
@functools.cache
def build_samples(model: str) -> tuple[Sample, ...]:
"""Build all scenario samples for a model, self-validated."""
builder = _BUILDERS[model]
return tuple(builder(s) for s in SCENARIOS)
def build_sample(model: str, scenario: Scenario) -> Sample:
"""Build a single sample for one model + scenario."""
return _BUILDERS[model](scenario)
def build_scaling_sample(
model: str, token_count: int, validate: bool = False
) -> Sample:
"""Build a sample with approximately *token_count* tokens."""
sentence = "The quick brown fox jumps over the lazy dog. "
text = sentence * (token_count // 10 + 1)
scenario = Scenario(
id=f"scaling-{token_count}",
description=f"Scaling test with ~{token_count} tokens",
reasoning=text,
tool_calls=[_READ_TOOL],
)
return _BUILDERS[model](scenario, validate=validate)
+115 -43
View File
@@ -23,8 +23,8 @@ from vllm.entrypoints.openai.engine.protocol import (
from vllm.parser.abstract_parser import DelegatingParser
from vllm.tokenizers import TokenizerLike, get_tokenizer
from vllm.tokenizers.detokenizer_utils import detokenize_incrementally
from vllm.tool_parsers.qwen3coder_tool_parser import (
Qwen3CoderToolParser,
from vllm.tool_parsers.qwen3_engine_tool_parser import (
Qwen3EngineToolParser,
)
MODEL = "Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8"
@@ -37,12 +37,7 @@ def qwen3_tokenizer():
@pytest.fixture
def qwen3_tool_parser(qwen3_tokenizer, sample_tools):
return Qwen3CoderToolParser(qwen3_tokenizer, tools=sample_tools)
@pytest.fixture
def qwen3_tool_parser_parametrized(qwen3_tool_parser):
return qwen3_tool_parser
return Qwen3EngineToolParser(qwen3_tokenizer, tools=sample_tools)
WEATHER_PARAMS = {
@@ -208,9 +203,9 @@ def stream_delta_message_generator(
read_offset = new_read_offset
def test_extract_tool_calls_no_tools(qwen3_tool_parser_parametrized):
def test_extract_tool_calls_no_tools(qwen3_tool_parser):
model_output = "This is a test response without any tool calls"
extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls(
extracted_tool_calls = qwen3_tool_parser.extract_tool_calls(
model_output, request=None
) # type: ignore[arg-type]
assert not extracted_tool_calls.tools_called
@@ -391,13 +386,13 @@ circle
],
)
def test_extract_tool_calls(
qwen3_tool_parser_parametrized,
qwen3_tool_parser,
model_output,
expected_tool_calls,
expected_content,
):
request = ChatCompletionRequest(model=MODEL, messages=[])
extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls(
extracted_tool_calls = qwen3_tool_parser.extract_tool_calls(
model_output, request=request
)
assert extracted_tool_calls.tools_called
@@ -408,7 +403,7 @@ def test_extract_tool_calls(
def test_extract_tool_calls_fallback_no_tags(
qwen3_tool_parser_parametrized,
qwen3_tool_parser,
):
"""Test fallback parsing when XML tags are missing"""
model_output = """<function=get_current_weather>
@@ -421,7 +416,7 @@ TX
</function>"""
request = ChatCompletionRequest(model=MODEL, messages=[])
extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls(
extracted_tool_calls = qwen3_tool_parser.extract_tool_calls(
model_output, request=request
)
@@ -471,7 +466,7 @@ hello world
</function>
</tool_call>"""
parser = Qwen3CoderToolParser(qwen3_tokenizer, tools=tools)
parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools)
request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
extracted_tool_calls = parser.extract_tool_calls(model_output, request=request)
@@ -563,7 +558,7 @@ some text
</function>
</tool_call>"""
parser = Qwen3CoderToolParser(qwen3_tokenizer, tools=tools)
parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools)
request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
extracted = parser.extract_tool_calls(model_output, request=request)
@@ -637,7 +632,7 @@ true
</function>
</tool_call>"""
parser = Qwen3CoderToolParser(qwen3_tokenizer, tools=tools)
parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools)
request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools)
tool_states = {}
@@ -843,7 +838,7 @@ circle
],
)
def test_extract_tool_calls_streaming(
qwen3_tool_parser_parametrized,
qwen3_tool_parser,
qwen3_tokenizer,
model_output,
expected_tool_calls,
@@ -856,7 +851,7 @@ def test_extract_tool_calls_streaming(
tool_states = {} # Track state per tool index
for delta_message in stream_delta_message_generator(
qwen3_tool_parser_parametrized, qwen3_tokenizer, model_output, request
qwen3_tool_parser, qwen3_tokenizer, model_output, request
):
# role should never be streamed from tool parser
assert not delta_message.role
@@ -900,9 +895,6 @@ def test_extract_tool_calls_streaming(
# Verify we got all expected tool calls
assert len(tool_states) == len(expected_tool_calls)
assert len(qwen3_tool_parser_parametrized.prev_tool_call_arr) == len(
expected_tool_calls
)
# Verify each tool call
for idx, expected_tool in enumerate(expected_tool_calls):
@@ -920,7 +912,7 @@ def test_extract_tool_calls_streaming(
def test_extract_tool_calls_missing_closing_parameter_tag(
qwen3_tool_parser_parametrized,
qwen3_tool_parser,
):
"""Test handling of missing closing </parameter> tag"""
# Using get_current_weather from sample_tools but with malformed XML
@@ -939,7 +931,7 @@ fahrenheit
</tool_call>"""
request = ChatCompletionRequest(model=MODEL, messages=[])
extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls(
extracted_tool_calls = qwen3_tool_parser.extract_tool_calls(
model_output, request=request
)
@@ -962,7 +954,7 @@ fahrenheit
def test_extract_tool_calls_streaming_missing_closing_tag(
qwen3_tool_parser_parametrized, qwen3_tokenizer
qwen3_tool_parser, qwen3_tokenizer
):
"""Test streaming with missing closing </parameter> tag"""
# Using get_current_weather from sample_tools but with malformed XML
@@ -986,7 +978,7 @@ fahrenheit
tool_states = {}
for delta_message in stream_delta_message_generator(
qwen3_tool_parser_parametrized, qwen3_tokenizer, model_output, request
qwen3_tool_parser, qwen3_tokenizer, model_output, request
):
if delta_message.content:
other_content += delta_message.content
@@ -1021,7 +1013,6 @@ fahrenheit
assert "Let me check the weather for you:" in other_content
# Verify we got the tool call
assert len(tool_states) == 1
assert len(qwen3_tool_parser_parametrized.prev_tool_call_arr) == 1
state = tool_states[0]
assert state["id"] is not None
@@ -1036,9 +1027,7 @@ fahrenheit
assert args["unit"] == "fahrenheit"
def test_extract_tool_calls_streaming_incremental(
qwen3_tool_parser_parametrized, qwen3_tokenizer
):
def test_extract_tool_calls_streaming_incremental(qwen3_tool_parser, qwen3_tokenizer):
"""Test that streaming is truly incremental"""
model_output = """I'll check the weather.<tool_call>
<function=get_current_weather>
@@ -1055,7 +1044,7 @@ TX
chunks = []
for delta_message in stream_delta_message_generator(
qwen3_tool_parser_parametrized, qwen3_tokenizer, model_output, request
qwen3_tool_parser, qwen3_tokenizer, model_output, request
):
chunks.append(delta_message)
@@ -1073,19 +1062,21 @@ TX
header_found = True
assert chunk.tool_calls[0].function.name == "get_current_weather"
assert chunk.tool_calls[0].type == "function"
# Empty initially
assert chunk.tool_calls[0].function.arguments == ""
break
assert header_found
# Should have chunks with incremental arguments
arg_chunks = []
for chunk in chunks:
if chunk.tool_calls and chunk.tool_calls[0].function.arguments:
if (
chunk.tool_calls
and chunk.tool_calls[0].function
and chunk.tool_calls[0].function.arguments
):
arg_chunks.append(chunk.tool_calls[0].function.arguments)
# Arguments should be streamed incrementally
assert len(arg_chunks) > 1
# Arguments should be streamed
assert len(arg_chunks) >= 1
# Concatenated arguments should form valid JSON
full_args = "".join(arg_chunks)
@@ -1094,6 +1085,85 @@ TX
assert parsed_args["state"] == "TX"
def test_extract_tool_calls_streaming_missing_opening_tag(
qwen3_tool_parser, qwen3_tokenizer
):
"""Test streaming with missing opening <tool_call> tag
This tests that the streaming parser correctly handles
tool calls that start directly with <function=...>
"""
model_output = """I'll check the weather for you.
<function=get_current_weather>
<parameter=city>
Dallas
</parameter>
<parameter=state>
TX
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
</tool_call>"""
request = ChatCompletionRequest(model=MODEL, messages=[])
other_content = ""
tool_states = {}
for delta_message in stream_delta_message_generator(
qwen3_tool_parser, qwen3_tokenizer, model_output, request
):
if delta_message.content:
other_content += delta_message.content
if delta_message.tool_calls:
for tool_call in delta_message.tool_calls:
idx = tool_call.index
if idx not in tool_states:
tool_states[idx] = {
"id": None,
"name": None,
"arguments": "",
"type": None,
}
if tool_call.id:
tool_states[idx]["id"] = tool_call.id
if tool_call.type:
assert tool_call.type == "function"
tool_states[idx]["type"] = tool_call.type
if tool_call.function:
if tool_call.function.name:
tool_states[idx]["name"] = tool_call.function.name
if tool_call.function.arguments is not None:
tool_states[idx]["arguments"] += tool_call.function.arguments
# Verify content was streamed
assert "I'll check the weather for you." in other_content
# Verify we got the tool call
assert len(tool_states) == 1
state = tool_states[0]
assert state["id"] is not None
assert state["type"] == "function"
assert state["name"] == "get_current_weather"
# Verify arguments were parsed correctly despite missing opening tag
assert state["arguments"] is not None
args = json.loads(state["arguments"])
assert args["city"] == "Dallas"
assert args["state"] == "TX"
assert args["unit"] == "fahrenheit"
def test_malformed_xml_no_gt_delimiter(qwen3_tool_parser):
"""Regression: malformed XML without '>' must not crash (PR #36774)."""
model_output = (
@@ -1130,9 +1200,11 @@ def test_none_tool_calls_filtered(qwen3_tool_parser):
result = qwen3_tool_parser.extract_tool_calls(model_output, request=request)
assert all(tc is not None for tc in result.tool_calls)
assert result.tools_called
assert len(result.tool_calls) == 1
assert result.tool_calls[0].function.name == "get_current_weather"
args = json.loads(result.tool_calls[0].function.arguments)
valid = [
tc for tc in result.tool_calls if tc.function.name == "get_current_weather"
]
assert len(valid) == 1
args = json.loads(valid[0].function.arguments)
assert args["city"] == "Dallas"
assert args["state"] == "TX"
@@ -1156,7 +1228,7 @@ def test_anyof_parameter_not_double_encoded(qwen3_tokenizer):
)
]
parser = Qwen3CoderToolParser(qwen3_tokenizer, tools=tools)
parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools)
model_output = (
"<tool_call>\n"
@@ -1247,7 +1319,7 @@ def test_no_double_serialization_string_args(qwen3_tool_parser):
def test_get_vllm_registry_structural_tag_returns_structural_tag(
qwen3_tool_parser: Qwen3CoderToolParser,
qwen3_tool_parser: Qwen3EngineToolParser,
sample_tools: list[ChatCompletionToolsParam],
) -> None:
request_tools = _as_chat_completion_tools(sample_tools)
@@ -1289,7 +1361,7 @@ def test_adjust_request_auto_uses_vllm_registry_structural_tag(
include_reasoning: bool,
) -> None:
class TestParser(DelegatingParser):
tool_parser_cls = Qwen3CoderToolParser
tool_parser_cls = Qwen3EngineToolParser
request_tools = _as_chat_completion_tools(sample_tools)
req = ChatCompletionRequest(
@@ -1311,7 +1383,7 @@ def test_adjust_request_required_prefers_structural_tag(
sample_tools: list[ChatCompletionToolsParam],
) -> None:
class TestParser(DelegatingParser):
tool_parser_cls = Qwen3CoderToolParser
tool_parser_cls = Qwen3EngineToolParser
request_tools = _as_chat_completion_tools(sample_tools)
req = ChatCompletionRequest(
@@ -24,7 +24,7 @@ from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser
from vllm.tool_parsers.kimi_k2_tool_parser import KimiK2ToolParser
from vllm.tool_parsers.llama_tool_parser import Llama3JsonToolParser
from vllm.tool_parsers.minimax_m2_tool_parser import MinimaxM2ToolParser
from vllm.tool_parsers.qwen3coder_tool_parser import Qwen3CoderToolParser
from vllm.tool_parsers.qwen3_engine_tool_parser import Qwen3EngineToolParser
from vllm.tool_parsers.structural_tag_registry import (
SUPPORTED_STRUCTURAL_TAG_MODELS,
VLLM_BUILTIN_STRUCTURAL_TAG_MODELS,
@@ -183,7 +183,7 @@ def test_get_model_structural_tag_supports_named_tool_choice(
(KimiK2ToolParser, "kimi"),
(Llama3JsonToolParser, "llama"),
(MinimaxM2ToolParser, "minimax"),
(Qwen3CoderToolParser, "qwen_3_coder"),
(Qwen3EngineToolParser, "qwen_3_coder"),
],
)
def test_tool_parsers_declare_matching_xgrammar_builtin_model(parser_cls, model):
@@ -238,7 +238,7 @@ def test_get_structural_tag_disables_reasoning(
tools=sample_tools,
tool_choice="auto",
)
parser = Qwen3CoderToolParser(MagicMock(), tools=sample_tools)
parser = Qwen3EngineToolParser(MagicMock(), tools=sample_tools)
parser.get_structural_tag(request)
@@ -261,7 +261,7 @@ def test_unified_parser_get_structural_tag_disables_reasoning(
)
class TestParser(DelegatingParser):
tool_parser_cls = Qwen3CoderToolParser
tool_parser_cls = Qwen3EngineToolParser
request = ChatCompletionRequest(
messages=[],
+135 -26
View File
@@ -50,6 +50,31 @@ class StreamState:
# only used for "required" and "named tool" choices,
# tracks whether function name has been fully returned in the stream yet
function_name_returned: bool = False
engine_based: bool = False
def advance(
self,
delta_text: str,
delta_token_ids: list[int],
) -> tuple[str, list[int]]:
if self.engine_based:
return delta_text, delta_token_ids
return (
self.previous_text + delta_text,
self.previous_token_ids + delta_token_ids,
)
def commit(
self,
current_text: str,
current_token_ids: list[int],
) -> None:
if self.engine_based:
self.previous_text = ""
self.previous_token_ids = []
else:
self.previous_text = current_text
self.previous_token_ids = current_token_ids
class Parser:
@@ -88,8 +113,6 @@ class Parser:
self.model_tokenizer = tokenizer
self._reasoning_parser: ReasoningParser | None = None
self._tool_parser: ToolParser | None = None
self._stream_state = StreamState()
if self.__class__.reasoning_parser_cls is not None:
self._reasoning_parser = self.__class__.reasoning_parser_cls(
tokenizer, *args, **kwargs
@@ -97,6 +120,12 @@ class Parser:
if self.__class__.tool_parser_cls is not None:
self._tool_parser = self.__class__.tool_parser_cls(tokenizer, tools)
self._engine_based = (
self._reasoning_parser is None
or self._reasoning_parser.engine_based_streaming
) and (self._tool_parser is None or self._tool_parser.engine_based_streaming)
self._stream_state = StreamState(engine_based=self._engine_based)
@cached_property
def vocab(self) -> dict[str, int]:
"""Get the vocabulary mapping from tokens to IDs."""
@@ -571,11 +600,28 @@ class DelegatingParser(Parser):
tool_call_id_type: str = "random",
function_name_returned: bool = False,
) -> tuple[DeltaMessage | None, bool]:
if request.tool_choice == "none":
return (DeltaMessage(content=delta_text) if delta_text else None), False
assert self._tool_parser is not None
supports_required_and_named = self._tool_parser.supports_required_and_named
if request.tool_choice == "none":
if self._engine_based:
# Engine-backed parsers route content extraction through
# extract_tool_calls_streaming, so run the full pipeline
# and strip tool_calls after.
delta_message = self.extract_tool_calls_streaming(
previous_text,
current_text,
delta_text,
previous_token_ids,
current_token_ids,
delta_token_ids,
request, # type: ignore[arg-type]
)
if delta_message:
delta_message.tool_calls = []
return delta_message, False
return (DeltaMessage(content=delta_text) if delta_text else None), False
if (
supports_required_and_named
and request.tool_choice
@@ -713,9 +759,9 @@ class DelegatingParser(Parser):
):
state.reasoning_ended = True
current_text = state.previous_text + delta_text
current_token_ids = state.previous_token_ids + delta_token_ids
current_text, current_token_ids = state.advance(delta_text, delta_token_ids)
delta_message: DeltaMessage | None = None
reasoning_transitioned = False
# Reasoning extraction
if self._in_reasoning_phase(state):
@@ -727,16 +773,34 @@ class DelegatingParser(Parser):
current_token_ids=current_token_ids,
delta_token_ids=delta_token_ids,
)
if self.is_reasoning_end_streaming(current_token_ids, delta_token_ids):
state.reasoning_ended = True
current_token_ids = self.extract_content_ids(delta_token_ids)
current_text = (
delta_message.content
if delta_message and delta_message.content
else ""
reasoning_parser = self._reasoning_parser
if reasoning_parser is not None and reasoning_parser.engine_based_streaming:
should_transition = (
reasoning_parser.has_engine_confirmed_reasoning_end()
)
delta_text = current_text
delta_token_ids = current_token_ids
else:
should_transition = self.is_reasoning_end_streaming(
current_token_ids, delta_token_ids
)
if should_transition:
state.reasoning_ended = True
reasoning_transitioned = True
current_token_ids = self.extract_content_ids(delta_token_ids)
if self._engine_based:
current_text = (
self.model_tokenizer.decode(current_token_ids)
if current_token_ids
else ""
)
if delta_message and self._tool_parser is not None:
delta_message.content = None
else:
current_text = (
delta_message.content
if delta_message and delta_message.content
else ""
)
delta_text = current_text
# Tool call extraction
if self._in_tool_call_phase(state):
@@ -747,9 +811,10 @@ class DelegatingParser(Parser):
delta_text = current_text
delta_token_ids = current_token_ids
# A boundary delta may carry both reasoning and tool call,
# save it before the tool parser overwrites delta_message.
reasoning = delta_message.reasoning if delta_message else None
reasoning_from_this_batch = (
delta_message.reasoning if delta_message else None
)
delta_message, state.function_name_returned = (
self._extract_tool_calls_streaming(
previous_text=state.previous_text,
@@ -764,10 +829,12 @@ class DelegatingParser(Parser):
function_name_returned=state.function_name_returned,
)
)
if reasoning:
if not delta_message:
delta_message = DeltaMessage()
delta_message.reasoning = reasoning
if reasoning_from_this_batch:
if delta_message is None:
delta_message = DeltaMessage(reasoning=reasoning_from_this_batch)
elif not delta_message.reasoning:
delta_message.reasoning = reasoning_from_this_batch
if (
delta_message
@@ -776,18 +843,60 @@ class DelegatingParser(Parser):
):
state.history_tool_call_cnt += 1
# No phase active: pass through as content
# No phase active: pass through as content.
# Skip when reasoning just ended in this delta — the engine already
# consumed the end-of-reasoning marker (e.g. </think>) and
# delta_text still contains the raw marker text.
if (
delta_message is None
and not reasoning_transitioned
and not self._in_reasoning_phase(state)
and not self._in_tool_call_phase(state)
):
delta_message = DeltaMessage(content=delta_text)
state.previous_text = current_text
state.previous_token_ids = current_token_ids
state.commit(current_text, current_token_ids)
if finished:
delta_message = self.finalize_generation(delta_message, request, state)
delta_message = self._flush_engine_parsers(delta_message)
return delta_message
def _flush_engine_parsers(
self, delta_message: DeltaMessage | None
) -> DeltaMessage | None:
"""Flush buffered state from engine-based parsers at stream end."""
reasoning_ended = self._stream_state.reasoning_ended
for parser in (self._reasoning_parser, self._tool_parser):
if not getattr(parser, "engine_based_streaming", False):
continue
# When reasoning has ended and we transitioned to the tool
# phase, the reasoning parser's engine may still have buffered
# characters from tool-call markup it saw with
# skip_tool_parsing=True. Flushing that would leak spurious
# content (e.g. a stray '"'), so skip it.
if parser is self._reasoning_parser and reasoning_ended:
continue
finish = getattr(parser, "finish_streaming", None)
if finish is None:
continue
flush_delta = finish()
if flush_delta is None:
continue
if delta_message is None:
delta_message = flush_delta
else:
if flush_delta.content:
delta_message.content = (
delta_message.content or ""
) + flush_delta.content
if flush_delta.reasoning:
delta_message.reasoning = (
delta_message.reasoning or ""
) + flush_delta.reasoning
if flush_delta.tool_calls:
delta_message.tool_calls = (
delta_message.tool_calls or []
) + flush_delta.tool_calls
return delta_message
+17
View File
@@ -0,0 +1,17 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Streaming parser engine framework for tool call and reasoning extraction.
Instead of hand-rolling a parser for every model's tool-call / reasoning
format, each format is declared as a ParserEngineConfig (terminals,
states, and transitions) and a shared incremental engine handles
streaming, ambiguity buffering, token-ID mapping, and delta computation.
"""
from vllm.parser.engine.events import EventType, SemanticEvent
__all__ = [
"EventType",
"SemanticEvent",
]
+199
View File
@@ -0,0 +1,199 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Adapters that expose :class:`ParserEngine` through the legacy
:class:`ReasoningParser` and :class:`ToolParser` interfaces.
This lets parser engines flow through the existing serving-layer code
paths that expect separate reasoning and tool parser instances, without
any changes to the serving layer itself.
"""
from __future__ import annotations
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from typing import TYPE_CHECKING
from vllm.parser.engine.parser_engine_config import ParserState
from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
from vllm.tool_parsers.abstract_tool_parser import ToolParser
if TYPE_CHECKING:
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
)
from vllm.entrypoints.openai.engine.protocol import (
DeltaMessage,
ExtractedToolCallInformation,
)
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.parser.engine.parser_engine import ParserEngine
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.utils import Tool
class ParserEngineReasoningAdapter(ReasoningParser):
"""Adapts a :class:`ParserEngine` to the :class:`ReasoningParser`
interface so parser engines can be used as reasoning parsers in the
existing serving code.
Subclasses set :attr:`_parser_engine_cls` to the concrete
:class:`ParserEngine` class.
"""
_parser_engine_cls: type[ParserEngine]
engine_based_streaming: bool = True
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]
@contextmanager
def _skip_tool_parsing(self) -> Iterator[None]:
saved = self._parser_engine.skip_tool_parsing
self._parser_engine.skip_tool_parsing = True
try:
yield
finally:
self._parser_engine.skip_tool_parsing = saved
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
return self._parser_engine.is_reasoning_end(list(input_ids))
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
return self._parser_engine.extract_content_ids(input_ids)
def extract_reasoning(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> tuple[str | None, str | None]:
with self._skip_tool_parsing():
return self._parser_engine.extract_reasoning(model_output, request)
def extract_reasoning_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
) -> DeltaMessage | None:
with self._skip_tool_parsing():
return self._parser_engine.extract_reasoning_streaming(
previous_text,
current_text,
delta_text,
previous_token_ids,
current_token_ids,
delta_token_ids,
)
@property
def reasoning_start_str(self) -> str | None:
return self._parser_engine.reasoning_start_str
@property
def reasoning_end_str(self) -> str | None:
return self._parser_engine.reasoning_end_str
def adjust_request(
self,
request: ChatCompletionRequest | ResponsesRequest,
) -> ChatCompletionRequest | ResponsesRequest:
return self._parser_engine.adjust_request(request)
def has_engine_confirmed_reasoning_end(self) -> bool:
return self._parser_engine.reasoning_ended
def finish_streaming(self) -> DeltaMessage | None:
return self._parser_engine.finish_streaming()
def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
return self._parser_engine.count_reasoning_tokens(token_ids)
class ParserEngineToolAdapter(ToolParser):
"""Adapts a :class:`ParserEngine` to the :class:`ToolParser` interface.
:meth:`extract_tool_calls` starts the parser engine in ``CONTENT``
state so it can parse reasoning-stripped content (i.e. the output of
:meth:`ReasoningParser.extract_reasoning`).
Subclasses set :attr:`_parser_engine_cls` to the concrete
:class:`ParserEngine` class.
"""
_parser_engine_cls: type[ParserEngine]
engine_based_streaming: bool = True
def __init__(
self,
tokenizer: TokenizerLike,
tools: list[Tool] | None = None,
**kwargs,
) -> None:
super().__init__(tokenizer, tools)
self._parser_engine = self._parser_engine_cls(tokenizer, tools, **kwargs) # type: ignore[call-arg]
def adjust_request(
self,
request: ChatCompletionRequest | ResponsesRequest,
) -> ChatCompletionRequest | ResponsesRequest:
request = super().adjust_request(request)
return self._parser_engine.adjust_request(request)
def extract_tool_calls(
self,
model_output: str,
request: ChatCompletionRequest,
) -> ExtractedToolCallInformation:
return self._parser_engine.extract_tool_calls_from_content(
model_output, request
)
def extract_tool_calls_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
request: ChatCompletionRequest,
) -> DeltaMessage | None:
engine = self._parser_engine
engine.initialize_streaming(initial_state=ParserState.CONTENT)
return engine.extract_tool_calls_streaming(
previous_text,
current_text,
delta_text,
previous_token_ids,
current_token_ids,
delta_token_ids,
request,
)
def finish_streaming(self) -> DeltaMessage | None:
return self._parser_engine.finish_streaming()
def make_adapters(
parser_engine_cls: type[ParserEngine],
) -> tuple[type[ParserEngineReasoningAdapter], type[ParserEngineToolAdapter]]:
reasoning_adapter = type(
f"{parser_engine_cls.__name__}ReasoningAdapter",
(ParserEngineReasoningAdapter,),
{"_parser_engine_cls": parser_engine_cls},
)
tool_adapter = type(
f"{parser_engine_cls.__name__}ToolAdapter",
(ParserEngineToolAdapter,),
{"_parser_engine_cls": parser_engine_cls},
)
# Let the serving layer find the adapters and call adjust_request(),
# which sets skip_special_tokens=False for the detokenizer.
parser_engine_cls.reasoning_parser_cls = reasoning_adapter # type: ignore[attr-defined]
parser_engine_cls.tool_parser_cls = tool_adapter # type: ignore[attr-defined]
return reasoning_adapter, tool_adapter
+26
View File
@@ -0,0 +1,26 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Semantic event types emitted by the streaming parser engine."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum, auto
class EventType(Enum):
TEXT_CHUNK = auto()
REASONING_START = auto()
REASONING_CHUNK = auto()
REASONING_END = auto()
TOOL_CALL_START = auto()
TOOL_NAME = auto()
ARG_VALUE_CHUNK = auto()
TOOL_CALL_END = auto()
@dataclass(slots=True)
class SemanticEvent:
type: EventType
value: str = ""
tool_index: int = -1
+210
View File
@@ -0,0 +1,210 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Incremental text lexer that converts text chunks into terminal
tokens, with prefix-match buffering for ambiguous boundaries."""
from __future__ import annotations
from dataclasses import dataclass
import regex as re
CONTENT_TERMINAL = "__CONTENT__"
@dataclass(slots=True)
class TerminalDef:
name: str
pattern: re.Pattern[str]
is_literal: bool = False
literal: str = ""
@dataclass(slots=True)
class LexToken:
terminal: str
value: str
class LexerShape:
"""Immutable pre-computed data derived from terminal definitions.
Created once per :class:`ParserEngineConfig` and shared across all
:class:`IncrementalLexer` instances that use the same config.
"""
__slots__ = (
"terminals",
"literal_strings",
"max_literal_len",
"literal_first_chars",
"has_only_literals",
"prefix_set",
"literals_by_first",
)
def __init__(self, terminals: list[TerminalDef]) -> None:
self.terminals = sorted(
terminals,
key=lambda t: (not t.is_literal, -len(t.pattern.pattern)),
)
literal_strings: list[tuple[str, str]] = []
for t in self.terminals:
if t.is_literal:
literal_strings.append((t.literal, t.name))
self.literal_strings = literal_strings
max_len = 0
for lit, _ in literal_strings:
if len(lit) > max_len:
max_len = len(lit)
self.max_literal_len = max_len
self.literal_first_chars = frozenset(
lit[0] for lit, _ in literal_strings if lit
)
self.has_only_literals = all(t.is_literal for t in terminals)
prefix_set: set[str] = set()
for lit, _ in literal_strings:
for i in range(1, len(lit)):
prefix_set.add(lit[:i])
self.prefix_set = frozenset(prefix_set)
by_first: dict[str, list[tuple[str, str]]] = {}
for lit, name in literal_strings:
if lit:
by_first.setdefault(lit[0], []).append((lit, name))
self.literals_by_first = by_first
class IncrementalLexer:
"""Converts streaming text into terminal tokens.
The key feature is **prefix-match buffering**: when the text in the
buffer could be the start of a multi-character terminal (e.g.
``"<tool_"`` that could become ``"<tool_call>"``), the lexer holds
the text rather than emitting it. When the next chunk arrives, it
either completes the terminal or flushes the buffered text as
content.
Terminals are tried in priority order (literals first, then by
descending priority, then by pattern length).
"""
def __init__(
self,
terminals: list[TerminalDef] | LexerShape,
content_terminal: str = CONTENT_TERMINAL,
) -> None:
if isinstance(terminals, LexerShape):
shape = terminals
else:
shape = LexerShape(terminals)
self._shape = shape
self.terminals = shape.terminals
self.content_terminal = content_terminal
self.buffer = ""
self._literal_strings = shape.literal_strings
self._max_literal_len = shape.max_literal_len
self._literal_first_chars = shape.literal_first_chars
self._has_only_literals = shape.has_only_literals
self._prefix_set = shape.prefix_set
self._literals_by_first = shape.literals_by_first
def reset(self) -> None:
self.buffer = ""
def feed(self, text: str) -> list[LexToken]:
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)]
self.buffer += text
return self._drain()
def flush(self) -> list[LexToken]:
tokens: list[LexToken] = []
if self.buffer:
tokens.append(LexToken(self.content_terminal, self.buffer))
self.buffer = ""
return tokens
def _drain(self) -> list[LexToken]:
tokens: list[LexToken] = []
first_chars = self._literal_first_chars
content_terminal = self.content_terminal
has_only_literals = self._has_only_literals
literals_by_first = self._literals_by_first
prefix_set = self._prefix_set
while self.buffer:
if has_only_literals and first_chars:
has_potential = False
for ch in self.buffer:
if ch in first_chars:
has_potential = True
break
if not has_potential:
tokens.append(LexToken(content_terminal, self.buffer))
self.buffer = ""
break
best_match: tuple[str, str, int] | None = None
first = self.buffer[0]
for lit, name in literals_by_first.get(first, ()):
if self.buffer.startswith(lit) and (
best_match is None or len(lit) > best_match[2]
):
best_match = (name, lit, len(lit))
if self.buffer in prefix_set:
if best_match is not None:
tokens.append(LexToken(best_match[0], best_match[1]))
self.buffer = self.buffer[best_match[2] :]
continue
else:
break
if best_match is not None:
tokens.append(LexToken(best_match[0], best_match[1]))
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]))
self.buffer = self.buffer[content_end:]
else:
tokens.append(LexToken(content_terminal, self.buffer[0]))
self.buffer = self.buffer[1:]
return tokens
def _find_content_boundary(self) -> int:
buf = self.buffer
n = len(buf)
first_chars = self._literal_first_chars
for i in range(1, n):
if buf[i] not in first_chars:
continue
remaining = n - i
for lit, _ in self._literal_strings:
check_len = min(remaining, len(lit))
if buf[i : i + check_len] == lit[:check_len]:
return i
return n
def terminals_from_literals(literals: dict[str, str]) -> list[TerminalDef]:
return [
TerminalDef(
name=name,
pattern=re.compile(re.escape(lit)),
is_literal=True,
literal=lit,
)
for name, lit in literals.items()
]
+969
View File
@@ -0,0 +1,969 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Parser engine base that handles both reasoning and tool call
extraction with a single :class:`StreamingParserEngine`.
"""
from __future__ import annotations
import json
from collections.abc import Sequence
from functools import cached_property
from typing import TYPE_CHECKING
import regex as re
from vllm.entrypoints.chat_utils import make_tool_call_id
from vllm.entrypoints.openai.engine.protocol import (
DeltaFunctionCall,
DeltaMessage,
DeltaToolCall,
ExtractedToolCallInformation,
FunctionCall,
ToolCall,
)
from vllm.logger import init_logger
from vllm.parser.abstract_parser import Parser, StreamState
from vllm.parser.engine.events import EventType, SemanticEvent
from vllm.parser.engine.parser_engine_config import ParserEngineConfig, ParserState
from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
from vllm.tool_parsers.utils import (
coerce_to_schema_type,
extract_types_from_schema,
find_tool_properties,
)
if TYPE_CHECKING:
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
)
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.abstract_tool_parser import Tool
logger = init_logger(__name__)
class ToolCallSlot:
__slots__ = (
"id",
"name",
"_args_parts",
"_args_joined",
"name_sent",
"streamed_json",
)
def __init__(self) -> None:
self.id: str = ""
self.name: str = ""
self._args_parts: list[str] = []
self._args_joined: str | None = ""
self.name_sent: bool = False
self.streamed_json: str = ""
@property
def args(self) -> str:
if self._args_joined is None:
self._args_joined = "".join(self._args_parts)
return self._args_joined
def append_args(self, value: str) -> None:
self._args_parts.append(value)
self._args_joined = None
class ParserEngine(Parser):
"""A :class:`Parser` backed by a single declarative engine config.
Subclasses set the ``ParserEngineConfig`` in ``__init__`` to define the
complete output format for a model (reasoning + tool calls).
"""
def __init__(
self,
tokenizer: TokenizerLike,
tools: list[Tool] | None = None,
*,
parser_engine_config: ParserEngineConfig,
**kwargs,
) -> None:
self.model_tokenizer = tokenizer
self._tools = tools
self._stream_state = StreamState()
self._reasoning_parser = None
self._tool_parser = None
self.parser_engine_config = parser_engine_config
self._engine = StreamingParserEngine(
parser_engine_config, tokenizer, vocab=self.vocab
)
self._reasoning_ended: bool = False
self._streaming_initialized: bool = False
self._tool_slots: list[ToolCallSlot] = []
self._deferred_content: str = ""
self._deferred_reasoning: str = ""
self._content_has_nonws: bool = False
self._arg_converter = parser_engine_config.arg_converter
self._arg_structural_chars = parser_engine_config.arg_structural_chars
self._stream_arg_deltas = parser_engine_config.stream_arg_deltas
self._strip_trailing_reasoning_ws = (
parser_engine_config.strip_trailing_reasoning_whitespace
)
self._drop_ws_only_content_before_tools = (
parser_engine_config.drop_whitespace_only_content_before_tools
)
self._strip_content_ws_with_tools = (
parser_engine_config.strip_content_whitespace_with_tools
)
vocab = self.vocab
self._reasoning_start_token_id: int | None = None
self._reasoning_end_token_id: int | None = None
start_text = parser_engine_config.token_id_terminals.get("THINK_START")
end_text = parser_engine_config.token_id_terminals.get("THINK_END")
if start_text:
self._reasoning_start_token_id = vocab.get(start_text)
if end_text:
self._reasoning_end_token_id = vocab.get(end_text)
@property
def reasoning_start_str(self) -> str | None:
return self.parser_engine_config.terminals.get("THINK_START")
@property
def reasoning_end_str(self) -> str | None:
return self.parser_engine_config.terminals.get("THINK_END")
@cached_property
def vocab(self) -> dict[str, int]:
return self.model_tokenizer.get_vocab()
# ── Engine lifecycle ──────────────────────────────────────────────
@property
def skip_tool_parsing(self) -> bool:
return self._engine.skip_tool_parsing
@skip_tool_parsing.setter
def skip_tool_parsing(self, value: bool) -> None:
self._engine.skip_tool_parsing = value
@property
def reasoning_ended(self) -> bool:
return self._reasoning_ended
def initialize_streaming(
self,
initial_state: ParserState | None = None,
) -> None:
if not self._streaming_initialized:
self._streaming_initialized = True
self._reset(initial_state=initial_state)
def finish_streaming(self) -> DeltaMessage | None:
events = self._engine.finish()
return self._events_to_delta(events) if events else None
def _reset(self, initial_state: ParserState | None = None) -> None:
self._engine.reset(initial_state=initial_state)
self._reasoning_ended = False
self._tool_slots.clear()
self._deferred_content = ""
self._deferred_reasoning = ""
self._content_has_nonws = False
def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
request.skip_special_tokens = False
return request
# ── Schema-aware type correction ─────────────────────────────────
@staticmethod
def _coerce_value(value: object, schema: dict) -> tuple[object, bool]:
"""Coerce a single value according to its schema.
Returns ``(coerced_value, changed)``.
"""
if isinstance(value, str):
types = extract_types_from_schema(schema)
coerced = coerce_to_schema_type(value, types)
if coerced is not value:
return coerced, True
return value, False
if isinstance(value, dict):
nested_props = schema.get("properties")
if isinstance(nested_props, dict):
_, changed = ParserEngine._coerce_dict(value, nested_props)
return value, changed
return value, False
if isinstance(value, list):
items_schema = schema.get("items")
if isinstance(items_schema, dict):
changed = False
for i, item in enumerate(value):
coerced, item_changed = ParserEngine._coerce_value(
item, items_schema
)
if item_changed:
value[i] = coerced
changed = True
return value, changed
return value, False
types = extract_types_from_schema(schema)
as_str = json.dumps(value, ensure_ascii=False)
coerced = coerce_to_schema_type(as_str, types)
if coerced != value:
return coerced, True
return value, False
@staticmethod
def _coerce_dict(args: dict, properties: dict) -> tuple[dict, bool]:
"""Coerce all values in *args* using *properties* schemas."""
changed = False
for key, value in args.items():
prop = properties.get(key)
if not isinstance(prop, dict):
continue
coerced, val_changed = ParserEngine._coerce_value(value, prop)
if val_changed:
args[key] = coerced
changed = True
return args, changed
@staticmethod
def _safe_arg_prefix(json_str: str) -> str:
"""Return the prefix of *json_str* up to the last top-level value.
Middle values (followed by a comma) are stable across streaming
ticks and included. The trailing value is excluded because type
coercion may change its serialised form between ticks, which
would violate the ``startswith(prev)`` prefix invariant.
"""
last_colon = -1
in_string = False
escape = False
depth = 0
for i, c in enumerate(json_str):
if escape:
escape = False
continue
if in_string:
if c == "\\":
escape = True
elif c == '"':
in_string = False
continue
if c == '"':
in_string = True
elif c in ("{", "["):
depth += 1
elif c in ("}", "]"):
depth -= 1
elif c == ":" and depth == 1:
last_colon = i
if last_colon < 0:
return ""
end = last_colon + 1
while end < len(json_str) and json_str[end] in (" ", "\t", "\n", "\r"):
end += 1
return json_str[:end]
def _fix_arg_types(self, args_json: str, func_name: str) -> str:
"""Correct parameter types using the tool schema.
String values are coerced via :func:`coerce_to_schema_type`.
Nested objects and arrays are recursed into when the schema
defines ``properties`` or ``items``. Without a schema, values
stay as strings.
"""
if not self._tools or not func_name:
return args_json
try:
args = json.loads(args_json)
except (json.JSONDecodeError, ValueError):
return args_json
if not isinstance(args, dict):
return args_json
properties = find_tool_properties(self._tools, func_name)
if not properties:
return args_json
_, changed = self._coerce_dict(args, properties)
if changed:
return json.dumps(args, ensure_ascii=False)
return args_json
# ── Private helpers ─────────────────────────────────────────────
def _check_skip_tool_parsing(
self,
request: ChatCompletionRequest | ResponsesRequest,
) -> None:
if not self.skip_tool_parsing:
tool_choice = getattr(request, "tool_choice", None)
tools = getattr(request, "tools", None)
if tool_choice == "none" and tools:
self.skip_tool_parsing = True
def _strip_content_whitespace(
self,
content: str,
tools_called: bool,
) -> str | None:
if tools_called:
if self._strip_content_ws_with_tools:
content = content.strip()
elif self._drop_ws_only_content_before_tools and not content.strip():
content = ""
return content or None
# ── Streaming: parse_delta ────────────────────────────────────────
def parse_delta(
self,
delta_text: str,
delta_token_ids: list[int],
request: ChatCompletionRequest | ResponsesRequest,
prompt_token_ids: list[int] | None = None,
*,
finished: bool,
) -> DeltaMessage | None:
self._check_skip_tool_parsing(request)
events = self._engine.feed(delta_text, delta_token_ids)
if finished:
events.extend(self._engine.finish())
result = self._events_to_delta(events, finished=finished)
return self._strip_trailing_reasoning(result)
def _strip_trailing_reasoning(
self,
delta: DeltaMessage | None,
) -> DeltaMessage | None:
"""Strip trailing whitespace from reasoning, deferring it until we
know whether more reasoning follows or reasoning has ended.
Runs in ``parse_delta`` *after* ``_events_to_delta`` (and any
subclass overrides) so that overrides see the raw reasoning text.
Gated by ``strip_trailing_reasoning_whitespace``; when disabled,
passes through unchanged.
"""
if not self._strip_trailing_reasoning_ws:
return delta
if delta is not None and delta.reasoning is not None:
combined = self._deferred_reasoning + delta.reasoning
trimmed = combined.rstrip()
self._deferred_reasoning = combined[len(trimmed) :]
delta.reasoning = trimmed or None
if (
delta.reasoning is None
and delta.content is None
and not delta.tool_calls
):
return None
elif self._deferred_reasoning and self._reasoning_ended:
self._deferred_reasoning = ""
return delta
# ── Non-streaming: extract_reasoning ──────────────────────────────
def extract_reasoning(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> tuple[str | None, str | None]:
self._reset()
events = self._engine.feed(model_output, [])
events.extend(self._engine.finish())
reasoning_parts: list[str] = []
content_parts: list[str] = []
for event in events:
if event.type == EventType.REASONING_CHUNK:
reasoning_parts.append(event.value)
elif event.type == EventType.TEXT_CHUNK:
content_parts.append(event.value)
elif event.type == EventType.REASONING_END:
self._reasoning_ended = True
raw_reasoning = "".join(reasoning_parts)
if self._strip_trailing_reasoning_ws:
raw_reasoning = raw_reasoning.rstrip()
reasoning = raw_reasoning or None
content = "".join(content_parts) or None
return reasoning, content
# ── Non-streaming: extract_reasoning_streaming ────────────────────
def extract_reasoning_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
) -> DeltaMessage | None:
self.initialize_streaming()
events = self._engine.feed(delta_text, delta_token_ids)
return self._strip_trailing_reasoning(self._events_to_delta(events))
# ── Non-streaming: extract_tool_calls ─────────────────────────────
def extract_tool_calls(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> ExtractedToolCallInformation:
self._reset()
self._streaming_initialized = True
result = self.extract_tool_calls_streaming(
previous_text="",
current_text=model_output,
delta_text=model_output,
previous_token_ids=[],
current_token_ids=[],
delta_token_ids=[],
request=request,
)
finish_delta = self.finish_streaming()
return self._build_extracted_result(result, finish_delta)
def extract_tool_calls_from_content(
self,
content: str,
request: ChatCompletionRequest,
) -> ExtractedToolCallInformation:
"""Extract tool calls from reasoning-stripped content.
Unlike :meth:`extract_tool_calls` which re-parses the full model
output, this method starts the parser engine in ``CONTENT`` state
so it can parse content that has already had reasoning stripped.
"""
_, parsed_content, tool_call_info = self._single_pass_parse(
content,
[],
initial_state=ParserState.CONTENT,
)
if parsed_content is not None and tool_call_info.content is None:
tool_call_info = ExtractedToolCallInformation(
tools_called=tool_call_info.tools_called,
tool_calls=tool_call_info.tool_calls,
content=parsed_content,
)
return tool_call_info
def extract_tool_calls_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
request: ChatCompletionRequest | ResponsesRequest,
) -> DeltaMessage | None:
self.initialize_streaming()
self._check_skip_tool_parsing(request)
events = self._engine.feed(delta_text, delta_token_ids)
return self._strip_trailing_reasoning(self._events_to_delta(events))
# ── Reasoning state queries ───────────────────────────────────────
def is_reasoning_end(self, input_ids: list[int]) -> bool:
end_id = self._reasoning_end_token_id
start_id = self._reasoning_start_token_id
if end_id is not None:
if not input_ids:
return self.parser_engine_config.initial_state != ParserState.REASONING
for i in range(len(input_ids) - 1, -1, -1):
if input_ids[i] == end_id:
return True
if start_id is not None and input_ids[i] == start_id:
return False
return False
return self._reasoning_ended
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
end_id = self._reasoning_end_token_id
if end_id is not None:
for i in range(len(input_ids) - 1, -1, -1):
if input_ids[i] == end_id:
return input_ids[i + 1 :]
return input_ids
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
# ── Single-pass parse helper ────────────────────────────────────────
def _single_pass_parse(
self,
text: str,
token_ids: Sequence[int],
initial_state: ParserState | None = None,
) -> tuple[str | None, str | None, ExtractedToolCallInformation]:
"""Reset, feed, finish, and extract results in one pass.
Must be called as a unit — ``_events_to_delta`` populates tool
state that ``_build_extracted_result`` reads.
"""
self._reset(initial_state=initial_state)
events = self._engine.feed(text, token_ids)
events.extend(self._engine.finish())
delta = self._events_to_delta(events)
tool_call_info = self._build_extracted_result()
reasoning = delta.reasoning if delta else None
if reasoning and self._strip_trailing_reasoning_ws:
reasoning = reasoning.rstrip() or None
content = delta.content if delta else None
if content:
content = self._strip_content_whitespace(
content, tool_call_info.tools_called
)
return reasoning, content, tool_call_info
# ── Non-streaming: parse ───────────────────────────────────────────
def parse(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
enable_auto_tools: bool = False,
model_output_token_ids: Sequence[int] = (),
) -> tuple[str | None, str | None, list[FunctionCall] | None]:
reasoning, content, tool_call_info = self._single_pass_parse(
model_output,
model_output_token_ids,
)
tool_calls: list[FunctionCall] | None = None
if tool_call_info.tools_called:
tool_calls = [
FunctionCall(
id=tc.id,
name=tc.function.name,
arguments=tc.function.arguments,
)
for tc in tool_call_info.tool_calls
]
return reasoning, content, tool_calls
# ── Event-to-delta conversion ─────────────────────────────────────
def _events_to_delta(
self,
events: list[SemanticEvent],
finished: bool = False,
) -> DeltaMessage | None:
if not events and not self._deferred_content:
return None
tool_call_deltas: list[DeltaToolCall] = []
content_parts: list[str] = []
reasoning_parts: list[str] = []
seen_tool_event = False
for event in events:
match event.type:
case EventType.TEXT_CHUNK:
if seen_tool_event:
self._deferred_content += event.value
else:
content_parts.append(event.value)
case EventType.REASONING_CHUNK:
reasoning_parts.append(event.value)
case EventType.REASONING_END:
self._reasoning_ended = True
case EventType.TOOL_CALL_START:
seen_tool_event = True
self._ensure_slot(event.tool_index)
case EventType.TOOL_NAME:
seen_tool_event = True
self._handle_tool_name(event)
case EventType.ARG_VALUE_CHUNK:
seen_tool_event = True
self._handle_arg_chunk(event, tool_call_deltas)
case EventType.TOOL_CALL_END:
seen_tool_event = True
self._handle_tool_end(event, tool_call_deltas)
case EventType.REASONING_START:
pass # no delta-level effect
if len(tool_call_deltas) > 1:
tool_call_deltas = self._coalesce_tool_call_deltas(tool_call_deltas)
if self._deferred_content and not seen_tool_event:
content_parts.insert(0, self._deferred_content)
self._deferred_content = ""
content_str = "".join(content_parts)
if self._content_has_nonws:
pass
elif content_str:
stripped = content_str.strip()
if stripped:
self._content_has_nonws = True
elif self._tool_slots:
if self._drop_ws_only_content_before_tools:
content_str = ""
elif not finished:
self._deferred_content = content_str
content_str = ""
content = content_str or None
reasoning = "".join(reasoning_parts) or None
if content or tool_call_deltas or reasoning:
kwargs: dict[str, object] = {}
if content is not None:
kwargs["content"] = content
if reasoning is not None:
kwargs["reasoning"] = reasoning
if tool_call_deltas:
kwargs["tool_calls"] = tool_call_deltas
return DeltaMessage(**kwargs)
return None
def _ensure_slot(self, idx: int) -> None:
while len(self._tool_slots) <= idx:
self._tool_slots.append(ToolCallSlot())
def _ensure_tool_id(self, slot: ToolCallSlot, name: str) -> None:
if not slot.id:
state = self._stream_state
slot.id = make_tool_call_id(
id_type=state.tool_call_id_type,
func_name=name,
idx=state.history_tool_call_cnt,
)
state.history_tool_call_cnt += 1
def _handle_tool_name(self, event: SemanticEvent) -> None:
idx = event.tool_index
self._tool_slots[idx].name += event.value
def _emit_name_delta(
self,
idx: int,
deltas: list[DeltaToolCall],
name: str | None,
) -> None:
if not name:
return
slot = self._tool_slots[idx]
slot.name = name
slot.name_sent = True
self._ensure_tool_id(slot, name)
deltas.append(
DeltaToolCall(
index=idx,
id=slot.id,
type="function",
function=DeltaFunctionCall(name=name),
)
)
def _handle_arg_chunk(
self,
event: SemanticEvent,
deltas: list[DeltaToolCall],
) -> None:
idx = event.tool_index
slot = self._tool_slots[idx]
if event.value:
slot.append_args(event.value)
if not slot.name_sent:
if slot.name:
self._emit_name_delta(idx, deltas, slot.name)
elif event.value:
# Name not yet known — try to extract from accumulated args
name = self._try_extract_name(idx)
self._emit_name_delta(idx, deltas, name)
elif event.value:
# Name already sent — emit arg delta
arg_delta = self._compute_arg_delta(idx, event.value)
if arg_delta:
deltas.append(
DeltaToolCall(
index=idx,
function=DeltaFunctionCall(arguments=arg_delta),
)
)
def _handle_tool_end(
self,
event: SemanticEvent,
deltas: list[DeltaToolCall],
) -> None:
idx = event.tool_index
if idx >= len(self._tool_slots):
return
remaining = self._flush_arg_converter(idx)
slot = self._tool_slots[idx]
if not slot.name_sent:
name = slot.name or self._try_extract_name(idx)
if name:
slot.name = name
slot.name_sent = True
self._ensure_tool_id(slot, name)
deltas.append(
DeltaToolCall(
index=idx,
id=slot.id,
type="function",
function=DeltaFunctionCall(
name=name,
arguments=remaining or "",
),
)
)
remaining = None
if remaining and slot.name_sent:
deltas.append(
DeltaToolCall(
index=idx,
function=DeltaFunctionCall(arguments=remaining),
)
)
# ── Tool-call delta coalescing ──────────────────────────────────────
@staticmethod
def _coalesce_tool_call_deltas(
deltas: list[DeltaToolCall],
) -> list[DeltaToolCall]:
"""Merge entries that share the same index into one per index."""
merged: dict[int, DeltaToolCall] = {}
for tc in deltas:
existing = merged.get(tc.index)
if existing is None:
merged[tc.index] = tc
continue
if tc.id is not None and existing.id is None:
existing.id = tc.id
if tc.type is not None and existing.type is None:
existing.type = tc.type
if tc.function is not None:
if existing.function is None:
existing.function = tc.function
else:
if tc.function.name is not None and existing.function.name is None:
existing.function.name = tc.function.name
if tc.function.arguments is not None:
if existing.function.arguments is None:
existing.function.arguments = tc.function.arguments
else:
existing.function.arguments += tc.function.arguments
if len(merged) == len(deltas):
return deltas
return list(merged.values())
# ── Arg conversion helpers ─────────────────────────────────────────
def _compute_arg_delta(self, idx: int, raw_delta: str) -> str | None:
converter = self._arg_converter
if converter is None:
return raw_delta
if not self._stream_arg_deltas:
return None
structural = self._arg_structural_chars
if structural is not None and structural.isdisjoint(raw_delta):
return None
slot = self._tool_slots[idx]
try:
current_json = converter(slot.args, True)
except (json.JSONDecodeError, ValueError, TypeError):
logger.debug("arg converter failed (streaming): %s", slot.args[:80])
return None
if not current_json:
return None
if slot.name:
current_json = self._fix_arg_types(current_json, slot.name)
prev = slot.streamed_json
safe_json = self._safe_arg_prefix(current_json)
if not safe_json or safe_json == prev:
return None
if prev:
if not safe_json.startswith(prev):
return None
diff = safe_json[len(prev) :]
else:
diff = safe_json
if diff:
slot.streamed_json = safe_json
return diff
return None
def _flush_arg_converter(self, idx: int) -> str | None:
converter = self._arg_converter
if converter is None:
return None
slot = self._tool_slots[idx]
try:
final_json = converter(slot.args, False)
except (json.JSONDecodeError, ValueError, TypeError):
logger.debug("arg converter failed (flush): %s", slot.args[:80])
return None
if final_json:
final_json = self._fix_arg_types(final_json, slot.name)
prev = slot.streamed_json
if final_json and len(final_json) > len(prev):
if prev and not final_json.startswith(prev):
return None
diff = final_json[len(prev) :]
slot.streamed_json = final_json
return diff
return None
_NAME_RE = re.compile(r'"name"\s*:\s*"([^"]*)"')
def _try_extract_name(self, idx: int) -> str | None:
m = self._NAME_RE.search(self._tool_slots[idx].args)
if m:
name = m.group(1)
if name:
return name
return None
# ── Build ExtractedToolCallInformation ─────────────────────────────
def _build_extracted_result(
self,
*deltas: DeltaMessage | None,
) -> ExtractedToolCallInformation:
content_parts: list[str] = []
for delta in deltas:
if delta is not None and delta.content:
content_parts.append(delta.content)
tool_calls: list[ToolCall] = []
for idx, slot in enumerate(self._tool_slots):
if not slot.name and not slot.args:
continue
name = slot.name.strip()
raw_body = slot.args
if not name and raw_body.strip():
name, args_json = self._extract_name_and_args(raw_body)
elif raw_body.strip():
converter = self._arg_converter
if converter is not None:
try:
args_json = converter(raw_body, False)
except (json.JSONDecodeError, ValueError, TypeError):
logger.debug(
"arg converter failed (extract): %s", raw_body[:80]
)
args_json = self._extract_args_json(raw_body, name)
else:
args_json = self._extract_args_json(raw_body, name)
else:
args_json = "{}"
if name:
self._ensure_tool_id(slot, name)
args_json = self._fix_arg_types(args_json, name)
tool_calls.append(
ToolCall(
id=slot.id,
function=FunctionCall(name=name, arguments=args_json),
)
)
content_str = "".join(content_parts)
content = self._strip_content_whitespace(content_str, len(tool_calls) > 0)
return ExtractedToolCallInformation(
tools_called=len(tool_calls) > 0,
tool_calls=tool_calls,
content=content,
)
@staticmethod
def _extract_args_value(parsed: dict) -> str | None:
for key in ("arguments", "parameters"):
if key in parsed:
val = parsed[key]
if isinstance(val, str):
return val
return json.dumps(val, ensure_ascii=False)
return None
def _extract_name_and_args(
self,
raw_body: str,
) -> tuple[str, str]:
raw_body = raw_body.strip()
try:
parsed = json.loads(raw_body)
except json.JSONDecodeError:
return "", raw_body
if not isinstance(parsed, dict):
return "", raw_body
name = parsed.get("name", "")
args = self._extract_args_value(parsed)
if args is not None:
return name, args
without_name = {k: v for k, v in parsed.items() if k != "name"}
return name, json.dumps(without_name, ensure_ascii=False)
def _extract_args_json(self, raw_args: str, func_name: str) -> str:
if not raw_args.strip():
return "{}"
_, args = self._extract_name_and_args(raw_args)
return args
+114
View File
@@ -0,0 +1,114 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Declarative configuration for model tool-call and reasoning formats.
Each model format is described by a :class:`ParserEngineConfig` that specifies:
* **terminals** – literal strings or regex patterns that delimit the format
(e.g. ``<tool_call>``, ``</think>``).
* **token_id_terminals** – terminals that should be matched by token ID
rather than (or in addition to) text.
* **transitions** – a state machine mapping
``(state, terminal) → (new_state, events_to_emit)`` that drives semantic
event generation during streaming.
* **content_events** – what :class:`EventType` to emit for plain content
(non-terminal text) in each state.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum, auto
from functools import cached_property
from vllm.parser.engine.events import EventType
STRUCTURAL_DROP_TOKENS: frozenset[str] = frozenset(
{
"<eos>",
"<bos>",
"<pad>",
"<unk>",
"<mask>",
}
)
class ParserState(Enum):
CONTENT = auto()
REASONING = auto()
TOOL_PREAMBLE = auto()
TOOL_NAME = auto()
TOOL_ARGS = auto()
TOOL_BETWEEN = auto()
@dataclass(frozen=True, slots=True)
class Transition:
next_state: ParserState
events: tuple[EventType, ...] = field(default_factory=tuple)
skip_in_token_id_mode: bool = False
@dataclass(frozen=True)
class ParserEngineConfig:
"""Declarative description of a model's tool-call / reasoning format.
The engine feeds terminals from the incremental lexer into the
transition table and emits the corresponding semantic events.
Content tokens (text between terminals) are classified by the
current state via ``content_events``.
"""
name: str
terminals: dict[str, str] = field(default_factory=dict)
token_id_terminals: dict[str, str] = field(default_factory=dict)
transitions: dict[tuple[ParserState, str], Transition] = field(
default_factory=dict,
)
content_events: dict[ParserState, EventType] = field(
default_factory=lambda: {
ParserState.CONTENT: EventType.TEXT_CHUNK,
ParserState.REASONING: EventType.REASONING_CHUNK,
ParserState.TOOL_NAME: EventType.TOOL_NAME,
ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
},
)
initial_state: ParserState = ParserState.CONTENT
arg_converter: Callable[[str, bool], str] | None = None
stream_arg_deltas: bool = True
tool_args_json: bool = True
arg_structural_chars: frozenset[str] | None = None
# Prevents trailing-whitespace accumulation across multi-turn conversations.
strip_trailing_reasoning_whitespace: bool = True
# Drop content that is entirely whitespace when tool calls follow.
drop_whitespace_only_content_before_tools: bool = True
# .strip() content text when tool calls are present.
strip_content_whitespace_with_tools: bool = True
drop_tokens: frozenset[str] = field(default_factory=frozenset)
@cached_property
def terminal_defs(self):
from vllm.parser.engine.incremental_lexer import terminals_from_literals
return terminals_from_literals(self.terminals)
@cached_property
def lexer_shape(self):
from vllm.parser.engine.incremental_lexer import LexerShape
return LexerShape(self.terminal_defs)
+16
View File
@@ -0,0 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Concrete adapter classes for each registered parser engine.
These are created via :func:`make_adapters` and exposed as module-level
names so that :class:`ReasoningParserManager` and
:class:`ToolParserManager` can load them lazily.
"""
from vllm.parser.engine.adapters import make_adapters
from vllm.parser.qwen3 import Qwen3Parser
(
Qwen3ParserReasoningAdapter,
Qwen3ParserToolAdapter,
) = make_adapters(Qwen3Parser)
@@ -0,0 +1,408 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Streaming parser engine that orchestrates token ID scanning,
incremental lexing, and state-machine-driven semantic event emission."""
from __future__ import annotations
from collections.abc import Sequence
from vllm.parser.engine.events import EventType, SemanticEvent
from vllm.parser.engine.incremental_lexer import (
CONTENT_TERMINAL,
IncrementalLexer,
LexToken,
)
from vllm.parser.engine.parser_engine_config import (
STRUCTURAL_DROP_TOKENS,
ParserEngineConfig,
ParserState,
Transition,
)
from vllm.parser.engine.token_id_scanner import (
LexerInput,
PreLexedTerminal,
TextChunk,
TokenIDScanner,
)
class StreamingParserEngine:
"""Consumes ``(delta_text, delta_token_ids)`` pairs and produces a
stream of :class:`SemanticEvent` instances.
This is the main entry point for streaming parsing.
Create one per request (it is stateful).
The pipeline is::
delta_text + delta_token_ids
→ TokenIDScanner (special token pre-lexing)
→ IncrementalLexer (text → terminal tokens with prefix buffering)
→ State Machine (terminal → semantic events)
→ list[SemanticEvent]
Usage::
engine = StreamingParserEngine(config, tokenizer)
for each streaming delta:
events = engine.feed(delta_text, delta_token_ids)
# convert events to DeltaMessage
"""
def __init__(
self,
config: ParserEngineConfig,
tokenizer,
initial_state: ParserState | None = None,
vocab: dict[str, int] | None = None,
) -> None:
self.config = config
resolved_token_ids: dict[int, str] = {}
drop_token_ids: set[int] = set()
if tokenizer is not None:
if vocab is None:
vocab = tokenizer.get_vocab()
if config.token_id_terminals:
for terminal_name, token_text in config.token_id_terminals.items():
tid = vocab.get(token_text)
if tid is not None:
resolved_token_ids[tid] = terminal_name
all_drop = config.drop_tokens | STRUCTURAL_DROP_TOKENS
for token_text in all_drop:
tid = vocab.get(token_text)
if tid is not None:
drop_token_ids.add(tid)
for attr in ("eos_token_id", "bos_token_id", "pad_token_id"):
tid = getattr(tokenizer, attr, None)
if tid is not None:
drop_token_ids.add(tid)
self._resolved_token_ids = resolved_token_ids
self._drop_token_ids = drop_token_ids
self._scanner = TokenIDScanner(
resolved_token_ids,
tokenizer,
drop_token_ids,
)
self._token_id_terminal_names: frozenset[str] = frozenset(
resolved_token_ids.values()
)
self._lexer = IncrementalLexer(
config.lexer_shape, content_terminal=CONTENT_TERMINAL
)
self._tool_terminals: frozenset[str] = frozenset(
terminal
for (state, terminal), tr in config.transitions.items()
if tr.next_state in self._TOOL_STATES or state in self._TOOL_STATES
)
self.skip_tool_parsing = False
self.reset(initial_state=initial_state)
def _reset_args_state(self) -> None:
self._args_buffer: str = ""
self._args_safe_end: int = 0
self._args_brace_depth: int = 0
self._args_in_string: bool = False
self._args_escape_next: bool = False
def reset(self, initial_state: ParserState | None = None) -> None:
"""Reset mutable state for reuse across requests.
Preserves cached immutable structures (compiled terminals,
resolved token IDs, lexer shape, token text cache) to avoid
redundant initialization work.
"""
self.state = (
initial_state if initial_state is not None else self.config.initial_state
)
self.tool_index = -1
self._ever_had_token_ids = False
# 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-
# implicit-reasoning-end (content returns None).
self._scanner.reset()
self._lexer.reset()
self._reset_args_state()
def feed(
self,
delta_text: str,
delta_token_ids: Sequence[int],
) -> list[SemanticEvent]:
if delta_token_ids:
self._ever_had_token_ids = True
# Fast path: skip scanner and lexer when the delta is plain
# content with no special tokens and no terminal-starting chars.
if (
delta_text
and not self._lexer.buffer
and not self._scanner._deferred_terminals
and self._lexer._literal_first_chars.isdisjoint(delta_text)
):
has_special = False
for tid in delta_token_ids:
if tid in self._resolved_token_ids or tid in self._drop_token_ids:
has_special = True
break
if not has_special:
return self._emit_for_state(delta_text)
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)
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)
return self._process_scanner_items(scanner_items)
def _process_scanner_items(
self, items: Sequence[LexerInput]
) -> list[SemanticEvent]:
events: list[SemanticEvent] = []
for item in items:
if isinstance(item, PreLexedTerminal):
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)))
return events
def finish(self) -> list[SemanticEvent]:
events = self._process_scanner_items(self._scanner.flush_pending())
events.extend(self._process_lex_tokens(self._lexer.flush()))
if self._args_buffer:
events.append(
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
value=self._args_buffer,
tool_index=self.tool_index,
)
)
self._args_buffer = ""
self._args_safe_end = 0
if self.state in (
ParserState.TOOL_PREAMBLE,
ParserState.TOOL_ARGS,
ParserState.TOOL_NAME,
ParserState.TOOL_BETWEEN,
):
if self.tool_index >= 0:
events.append(
SemanticEvent(
EventType.TOOL_CALL_END,
tool_index=self.tool_index,
)
)
self.state = ParserState.CONTENT
elif self.state == ParserState.REASONING:
events.append(
SemanticEvent(EventType.REASONING_END, tool_index=self.tool_index)
)
self.state = ParserState.CONTENT
return events
def parse_complete(self, text: str) -> list[SemanticEvent]:
token_ids: list[int] = []
events = self.feed(text, token_ids)
events.extend(self.finish())
return events
def _process_lex_tokens(self, tokens: list[LexToken]) -> list[SemanticEvent]:
events: list[SemanticEvent] = []
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))
else:
events.extend(self._on_terminal(tok.terminal, tok.value))
return events
_TOOL_STATES = frozenset(
{
ParserState.TOOL_PREAMBLE,
ParserState.TOOL_NAME,
ParserState.TOOL_ARGS,
ParserState.TOOL_BETWEEN,
}
)
def _on_terminal(self, terminal: str, value: str) -> list[SemanticEvent]:
key = (self.state, terminal)
transition = self.config.transitions.get(key)
if transition is None:
return self._emit_for_state(value)
if self.skip_tool_parsing and terminal in self._tool_terminals:
if EventType.REASONING_END in transition.events:
self.state = ParserState.CONTENT
return [
SemanticEvent(
EventType.REASONING_END,
value=value,
tool_index=self.tool_index,
),
SemanticEvent(
EventType.TEXT_CHUNK,
value=value,
tool_index=self.tool_index,
),
]
content_type = self.config.content_events.get(self.state)
if content_type is not None:
return [
SemanticEvent(content_type, value=value, tool_index=self.tool_index)
]
return []
if transition.skip_in_token_id_mode and self._ever_had_token_ids:
return self._emit_for_state(value)
return self._apply_transition(transition, value)
def _emit_for_state(self, text: str) -> list[SemanticEvent]:
if self.state == ParserState.TOOL_ARGS:
if self.config.tool_args_json:
return self._feed_args_text(text)
return [
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
value=text,
tool_index=self.tool_index,
)
]
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 []
def _on_content(self, text: str) -> list[SemanticEvent]:
if not text:
return []
return self._emit_for_state(text)
def _apply_transition(
self,
transition: Transition,
value: str,
) -> list[SemanticEvent]:
events: list[SemanticEvent] = []
if (
self.state == ParserState.TOOL_ARGS
and transition.next_state != ParserState.TOOL_ARGS
and self._args_buffer
):
events.append(
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
value=self._args_buffer,
tool_index=self.tool_index,
)
)
self._args_buffer = ""
self.state = transition.next_state
for event_type in transition.events:
if event_type == EventType.TOOL_CALL_START:
self.tool_index += 1
events.append(
SemanticEvent(
event_type,
value=value,
tool_index=self.tool_index,
)
)
if self.state == ParserState.TOOL_ARGS:
self._args_brace_depth = 0
self._args_in_string = False
self._args_escape_next = False
self._args_safe_end = 0
return events
def _feed_args_text(self, text: str) -> list[SemanticEvent]:
"""Feed text into the JSON argument streaming buffer.
Streams argument characters incrementally while holding back
closing braces/brackets that might change as more input arrives.
"""
events: list[SemanticEvent] = []
for ch in text:
result = self._feed_args_char(ch)
events.extend(result)
return events
def _feed_args_char(self, ch: str) -> list[SemanticEvent]:
self._args_buffer += ch
if self._args_escape_next:
self._args_escape_next = False
self._args_safe_end = len(self._args_buffer)
return self._flush_safe_args()
if self._args_in_string:
if ch == "\\":
self._args_escape_next = True
elif ch == '"':
self._args_in_string = False
self._args_safe_end = len(self._args_buffer)
return self._flush_safe_args()
if ch == '"':
self._args_in_string = True
self._args_safe_end = len(self._args_buffer)
return self._flush_safe_args()
if ch in ("{", "["):
self._args_brace_depth += 1
self._args_safe_end = len(self._args_buffer)
return self._flush_safe_args()
if ch in ("}", "]"):
if self._args_brace_depth > 0:
self._args_brace_depth -= 1
if self._args_brace_depth == 0:
return []
self._args_safe_end = len(self._args_buffer)
return self._flush_safe_args()
self._args_safe_end = len(self._args_buffer)
return self._flush_safe_args()
def _flush_safe_args(self) -> list[SemanticEvent]:
"""Emit buffered argument characters up to the safe-end watermark.
Top-level closing braces are held back (safe_end not advanced)
until confirmed safe by a subsequent character or finish().
"""
if self._args_safe_end == 0:
return []
to_emit = self._args_buffer[: self._args_safe_end]
self._args_buffer = self._args_buffer[self._args_safe_end :]
self._args_safe_end = 0
return [
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
value=to_emit,
tool_index=self.tool_index,
)
]
+309
View File
@@ -0,0 +1,309 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Scan delta token IDs for special tokens and split the stream into
pre-lexed terminals and plain text chunks."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
@dataclass(slots=True)
class TextChunk:
text: str
@dataclass(slots=True)
class PreLexedTerminal:
terminal: str
token_id: int
text: str
LexerInput = TextChunk | PreLexedTerminal
class TokenIDScanner:
"""Maps special token IDs in the delta to terminals.
Before text-based lexing happens, the scanner checks each token ID
in the delta against a mapping of ``{token_id: terminal_name}``.
Matched tokens are emitted as :class:`PreLexedTerminal` items;
everything else is grouped into :class:`TextChunk` items for the
incremental lexer to process.
When a terminal's text is not yet in ``delta_text`` (held back by
the detokenizer), the terminal is deferred until the text arrives
in a subsequent delta.
"""
def __init__(
self,
token_id_to_terminal: dict[int, str],
tokenizer,
drop_token_ids: set[int] | None = None,
) -> None:
self.token_id_to_terminal = token_id_to_terminal
self.tokenizer = tokenizer
self._token_text_cache: dict[int, str] = {}
self._drop_token_ids = drop_token_ids or set()
self._deferred_terminals: list[PreLexedTerminal] = []
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_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])
return self._token_text_cache[token_id]
_EMPTY: tuple[LexerInput, ...] = ()
def scan(
self,
delta_text: str,
delta_token_ids: Sequence[int],
) -> Sequence[LexerInput]:
prefix_items: list[LexerInput] = []
effective_text = delta_text
if self._deferred_terminals:
prefix_items, effective_text = self._resolve_deferred(delta_text)
if not self.token_id_to_terminal and not self._drop_token_ids:
if effective_text:
prefix_items.append(TextChunk(effective_text))
return prefix_items
has_special = False
has_drop = False
token_id_to_terminal = self.token_id_to_terminal
drop_token_ids = self._drop_token_ids
for tid in delta_token_ids:
if tid in token_id_to_terminal:
has_special = True
if tid in drop_token_ids:
has_drop = True
if not has_special and not has_drop:
if effective_text:
if not prefix_items:
return [TextChunk(effective_text)]
prefix_items.append(TextChunk(effective_text))
return prefix_items or self._EMPTY
token_texts = [self._decode_token(tid) for tid in delta_token_ids]
results: list[LexerInput] = []
text_accum: list[str] = []
for idx, tid in enumerate(delta_token_ids):
if tid in self._drop_token_ids:
continue
terminal = self.token_id_to_terminal.get(tid)
if terminal is not None:
if text_accum:
joined = "".join(text_accum)
if joined:
results.append(TextChunk(joined))
text_accum.clear()
results.append(PreLexedTerminal(terminal, tid, token_texts[idx]))
else:
text_accum.append(token_texts[idx])
if text_accum:
joined = "".join(text_accum)
if joined:
results.append(TextChunk(joined))
if effective_text:
if has_drop:
clean_delta = effective_text
for idx, tid in enumerate(delta_token_ids):
if tid in self._drop_token_ids:
dropped = token_texts[idx]
pos = clean_delta.find(dropped)
if pos >= 0:
clean_delta = (
clean_delta[:pos] + clean_delta[pos + len(dropped) :]
)
if clean_delta:
if results:
results = self._recover_holdback_text(clean_delta, results)
else:
results = [TextChunk(clean_delta)]
else:
results = self._recover_holdback_text(effective_text, results)
else:
# No detokenizer text to validate against — individually-decoded
# TextChunks are unreliable (context-dependent decoding).
# Defer PreLexedTerminals so the state machine doesn't
# 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().
for r in results:
if isinstance(r, PreLexedTerminal):
self._deferred_terminals.append(r)
results = []
return prefix_items + results
def flush_pending(self) -> list[LexerInput]:
if not self._deferred_terminals and not self._deferred_post_text:
return []
results: list[LexerInput] = []
if self._deferred_post_text:
results.append(TextChunk(self._deferred_post_text))
self._deferred_post_text = ""
results.extend(self._deferred_terminals)
self._deferred_terminals.clear()
return results
def _resolve_deferred(
self,
delta_text: str,
) -> tuple[list[LexerInput], str]:
"""Resolve deferred terminals against new delta_text.
When a previous ``scan()`` deferred a terminal (its text hadn't
arrived yet), the next delta's text should contain that terminal's
text. Split delta_text at the terminal boundary: text before
belongs to the previous parser state, the terminal triggers the
state transition, and text after belongs to the new state.
Returns ``(prefix_items, remaining_text)`` where prefix_items
are the resolved deferred terminals (with any preceding text)
and remaining_text is the unconsumed portion of delta_text that
should be scanned with the current delta's token IDs.
"""
deferred = self._deferred_terminals
self._deferred_terminals = []
results: list[LexerInput] = []
remaining = delta_text
if self._deferred_post_text:
remaining = self._deferred_post_text + remaining
self._deferred_post_text = ""
# Duplicate-text deferred terminals resolve left-to-right via
# find(); correct when each terminal text appears once in sequence.
for terminal in deferred:
pos = remaining.find(terminal.text)
if pos > 0:
results.append(TextChunk(remaining[:pos]))
results.append(terminal)
remaining = remaining[pos + len(terminal.text) :]
elif pos == 0:
results.append(terminal)
remaining = remaining[len(terminal.text) :]
else:
# Accumulate text until terminal text arrives —
# only the terminal provides a reliable split point.
if remaining:
self._deferred_post_text += remaining
remaining = ""
self._deferred_terminals.append(terminal)
return results, remaining
def _recover_holdback_text(
self,
delta_text: str,
results: list[LexerInput],
) -> list[LexerInput]:
"""Recover detokenizer hold-back text not in delta_token_ids.
The detokenizer may flush previously held-back text in
``delta_text`` that has no corresponding token ID in
``delta_token_ids``. This hold-back text always appears as a
prefix of ``delta_text``.
"""
if not results:
return [TextChunk(delta_text)]
reconstructed = self._join_decoded_text(results)
if not reconstructed:
return [TextChunk(delta_text)] + results
pos = delta_text.find(reconstructed)
if pos > 0:
return [TextChunk(delta_text[:pos])] + results
if pos == 0:
return results
# Fallback: SentencePiece context-dependent decoding mismatch.
# Rebuild from delta_text using PreLexedTerminals as split anchors.
return self._rebuild_from_anchors(delta_text, results)
def _join_decoded_text(self, results: list[LexerInput]) -> str:
"""Join TextChunk and PreLexedTerminal text into one string."""
parts: list[str] = []
for item in results:
if isinstance(item, (TextChunk, PreLexedTerminal)):
parts.append(item.text)
return "".join(parts)
def _rebuild_from_anchors(
self,
delta_text: str,
results: list[LexerInput],
) -> list[LexerInput]:
"""Rebuild results from delta_text using terminals as anchors.
When context-dependent decoding creates a mismatch between
individually-decoded tokens and delta_text, use
PreLexedTerminals as split points and reallocate text from
delta_text. If a terminal's text is not found in delta_text,
it is deferred to the next scan() call.
Anchors are resolved right-to-left with ``rfind`` so that each
anchor binds to the *rightmost* available occurrence of its
text. This prevents earlier literal lookalikes (e.g. a user
mentioning ``<tool_call>`` in prose) from stealing the position
of a real special-token anchor that appears later.
If the same anchor text appears multiple times as real special
tokens (not prose), the rightmost-first binding could misalign.
In practice this doesn't happen: each special token ID maps to
a distinct PreLexedTerminal, and duplicates in prose are resolved
by the token-ID filtering layer above.
"""
anchors = [item for item in results if isinstance(item, PreLexedTerminal)]
if not anchors:
return [TextChunk(delta_text)]
# Resolve positions right-to-left: each anchor gets the
# rightmost occurrence that is still before the next anchor.
positions: list[int] = [-1] * len(anchors)
search_end = len(delta_text)
for i in range(len(anchors) - 1, -1, -1):
pos = delta_text.rfind(anchors[i].text, 0, search_end)
if pos >= 0:
positions[i] = pos
search_end = pos
# Build results left-to-right using the resolved positions.
new_results: list[LexerInput] = []
consumed = 0
for i, anchor in enumerate(anchors):
pos = positions[i]
if pos >= consumed:
if pos > consumed:
new_results.append(TextChunk(delta_text[consumed:pos]))
new_results.append(anchor)
consumed = pos + len(anchor.text)
else:
has_later_valid = any(p >= 0 for p in positions[i + 1 :])
if not has_later_valid and consumed < len(delta_text):
self._deferred_post_text += delta_text[consumed:]
consumed = len(delta_text)
self._deferred_terminals.append(anchor)
if consumed < len(delta_text):
new_results.append(TextChunk(delta_text[consumed:]))
return new_results
+218
View File
@@ -0,0 +1,218 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Qwen3 parser for tool calls and reasoning.
Qwen3 XML tool call format::
<tool_call>
<function=func_name>
<parameter=key>value</parameter>
</function>
</tool_call>
The argument body consists of ``<parameter=NAME>VALUE</parameter>`` tags.
The ``_qwen3_arg_converter`` parses these into a JSON object.
"""
from __future__ import annotations
import functools
import json
from typing import TYPE_CHECKING
import regex as re
from vllm.parser.engine.events import EventType
from vllm.parser.engine.parser_engine import ParserEngine
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
if TYPE_CHECKING:
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
)
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.abstract_tool_parser import Tool
TOOL_CALL_START = "<tool_call>"
TOOL_CALL_END = "</tool_call>"
FUNC_PREFIX = "<function="
FUNC_END = "</function>"
_PARAM_RE = re.compile(
r"<\s*parameter\s*=\s*([^>]*)>"
r"(.*?)"
r"(?:<\s*/\s*parameter\s*>|(?=<\s*parameter\s*=))",
re.DOTALL,
)
_PARTIAL_PARAM_RE = re.compile(r"<\s*parameter\s*=\s*([^>]+)>([^<]*)$", re.DOTALL)
def _qwen3_arg_converter(raw_args: str, partial: bool) -> str:
params: dict[str, object] = {}
for match in _PARAM_RE.finditer(raw_args):
name = match.group(1)
value = match.group(2)
params[name] = value.strip()
if partial:
remaining = _PARAM_RE.sub("", raw_args)
m = _PARTIAL_PARAM_RE.search(remaining)
if m:
name = m.group(1)
value = m.group(2)
if name:
params[name] = value
return json.dumps(params, ensure_ascii=False)
@functools.cache
def qwen3_config(thinking: bool = True) -> ParserEngineConfig:
return ParserEngineConfig(
name="qwen3",
initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
terminals={
# Reasoning terminals
"THINK_START": "<think>",
"THINK_END": "</think>",
# Tool call terminals
"TOOL_START": TOOL_CALL_START,
"TOOL_END": TOOL_CALL_END,
"FUNC_PREFIX": FUNC_PREFIX,
"FUNC_END": FUNC_END,
"CLOSE_ANGLE": ">",
},
token_id_terminals={
"THINK_START": "<think>",
"THINK_END": "</think>",
"TOOL_START": TOOL_CALL_START,
"TOOL_END": TOOL_CALL_END,
},
transitions={
# -- Reasoning transitions --
(ParserState.REASONING, "THINK_START"): Transition(
ParserState.REASONING,
(),
),
(ParserState.REASONING, "THINK_END"): Transition(
ParserState.CONTENT,
(EventType.REASONING_END,),
),
# Absorb duplicate </think> — model may emit it after
# already transitioning to CONTENT; drop it silently.
(ParserState.CONTENT, "THINK_END"): Transition(
ParserState.CONTENT,
(),
),
# Tool call directly from reasoning (implicit end)
(ParserState.REASONING, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(EventType.REASONING_END, EventType.TOOL_CALL_START),
),
# -- Tool call transitions --
(ParserState.CONTENT, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(EventType.TOOL_CALL_START,),
),
# Fallback: <function= without a preceding <tool_call>
(ParserState.CONTENT, "FUNC_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_PREAMBLE, "FUNC_PREFIX"): Transition(
ParserState.TOOL_NAME,
(),
),
(ParserState.TOOL_NAME, "CLOSE_ANGLE"): Transition(
ParserState.TOOL_ARGS,
(),
),
# Malformed: </function> while still in TOOL_NAME (no closing >)
(ParserState.TOOL_NAME, "FUNC_END"): Transition(
ParserState.TOOL_BETWEEN,
(EventType.TOOL_CALL_END,),
),
(ParserState.TOOL_ARGS, "FUNC_END"): Transition(
ParserState.TOOL_BETWEEN,
(EventType.TOOL_CALL_END,),
),
(ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
ParserState.CONTENT,
(),
),
# Consecutive tool call without closing </tool_call>
(ParserState.TOOL_BETWEEN, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_BETWEEN, "FUNC_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
),
},
arg_converter=_qwen3_arg_converter,
stream_arg_deltas=True,
strip_trailing_reasoning_whitespace=False,
tool_args_json=False,
)
class Qwen3Parser(ParserEngine):
"""Qwen3 parser: ``<think>``/``</think>`` reasoning +
``<tool_call>`` XML tool calls in a single engine.
- ``<tool_call>`` as implicit reasoning end
- Unpaired ``<tool_call>`` token ID detection for ``is_reasoning_end``
"""
def __init__(
self,
tokenizer: TokenizerLike,
tools: list[Tool] | None = None,
**kwargs,
) -> None:
chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
self.thinking_enabled = chat_kwargs.get("enable_thinking", True)
kwargs.setdefault(
"parser_engine_config",
qwen3_config(thinking=self.thinking_enabled),
)
super().__init__(
tokenizer,
tools,
**kwargs,
)
vocab = self.vocab
self._tool_call_token_id: int | None = vocab.get("<tool_call>")
self._tool_call_end_token_id: int | None = vocab.get("</tool_call>")
def extract_reasoning(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> tuple[str | None, str | None]:
if not self.thinking_enabled:
return None, model_output
return super().extract_reasoning(model_output, request)
def is_reasoning_end(self, input_ids: list[int]) -> bool:
if super().is_reasoning_end(input_ids):
return True
tool_call_id = self._tool_call_token_id
tool_call_end_id = self._tool_call_end_token_id
if tool_call_id is not None:
for i in range(len(input_ids) - 1, -1, -1):
if input_ids[i] == tool_call_id:
if tool_call_end_id is not None and any(
input_ids[j] == tool_call_end_id
for j in range(i + 1, len(input_ids))
):
continue
return True
return False
+6 -6
View File
@@ -13,8 +13,8 @@ Register a lazy module mapping.
Example:
ReasoningParserManager.register_lazy_module(
name="qwen3",
module_path="vllm.reasoning.qwen3_reasoning_parser",
class_name="Qwen3ReasoningParser",
module_path="vllm.reasoning.qwen3_engine_reasoning_parser",
class_name="Qwen3ParserReasoningAdapter",
)
"""
@@ -81,8 +81,8 @@ _REASONING_PARSERS_TO_REGISTER = {
"KimiK2ReasoningParser",
),
"mimo": (
"qwen3_reasoning_parser",
"Qwen3ReasoningParser",
"qwen3_engine_reasoning_parser",
"Qwen3ParserReasoningAdapter",
),
"minimax_m2": (
"minimax_m2_reasoning_parser",
@@ -105,8 +105,8 @@ _REASONING_PARSERS_TO_REGISTER = {
"Olmo3ReasoningParser",
),
"qwen3": (
"qwen3_reasoning_parser",
"Qwen3ReasoningParser",
"qwen3_engine_reasoning_parser",
"Qwen3ParserReasoningAdapter",
),
"seed_oss": (
"seedoss_reasoning_parser",
+15 -2
View File
@@ -31,6 +31,8 @@ class ReasoningParser:
It is used to extract reasoning content from the model output.
"""
engine_based_streaming: bool = False
def __init__(self, tokenizer: "TokenizerLike", *args, **kwargs):
self.model_tokenizer = tokenizer
# Optional vLLM ModelConfig from the server. Use get (not pop) so composite
@@ -57,6 +59,17 @@ class ReasoningParser:
"""
return None
def has_engine_confirmed_reasoning_end(self) -> bool:
"""Whether the engine has confirmed the reasoning end transition.
Engine-based parsers may defer terminal processing when the
detokenizer holds back text. This method returns the engine's
*processed* state, not a raw token-ID check.
Only called for parsers with ``engine_based_streaming = True``.
"""
return False
@abstractmethod
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
"""
@@ -285,8 +298,8 @@ class ReasoningParserManager:
Example:
ReasoningParserManager.register_lazy_module(
name="qwen3",
module_path="vllm.reasoning.parsers.qwen3_reasoning_parser",
class_name="Qwen3ReasoningParser",
module_path="vllm.reasoning.qwen3_engine_reasoning_parser",
class_name="Qwen3ParserReasoningAdapter",
)
"""
cls.lazy_parsers[name] = (module_path, class_name)
@@ -0,0 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from vllm.parser.engine.registered_adapters import Qwen3ParserReasoningAdapter
__all__ = ["Qwen3ParserReasoningAdapter"]
-231
View File
@@ -1,231 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Iterable, Sequence
from typing import TYPE_CHECKING
from vllm.entrypoints.openai.engine.protocol import DeltaMessage
from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
if TYPE_CHECKING:
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.tokenizers import TokenizerLike
class Qwen3ReasoningParser(BaseThinkingReasoningParser):
"""
Reasoning parser for the Qwen3/Qwen3.5 model family.
The Qwen3 model family uses <think>...</think> tokens to denote reasoning
text. Starting with Qwen3.5, the chat template places <think> in the
prompt so only </think> appears in the generated output. The model
provides a strict switch to disable reasoning output via the
'enable_thinking=False' parameter.
When thinking is disabled, the template places <think>\\n\\n</think>\\n\\n
in the prompt. The serving layer detects this via prompt_is_reasoning_end
and routes deltas as content without calling the streaming parser.
NOTE: Models up to the 2507 release (e.g., Qwen/Qwen3-235B-A22B-Instruct-2507)
use an older chat template where the model generates <think> itself.
This parser handles both styles: if <think> appears in the generated output
it is stripped before extraction (non-streaming) or skipped (streaming).
NOTE: Qwen3.5 models may emit <tool_call> inside the thinking block
without closing </think> first. <tool_call> is treated as an implicit
end of reasoning, matching the approach in KimiK2ReasoningParser.
"""
def __init__(self, tokenizer: "TokenizerLike", *args, **kwargs):
super().__init__(tokenizer, *args, **kwargs)
chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
# Qwen3 defaults to thinking enabled; only treat output as
# pure content when the user explicitly disables it.
self.thinking_enabled = chat_kwargs.get("enable_thinking", True)
self._tool_call_tag = "<tool_call>"
self._tool_call_token_id = self.vocab.get(self._tool_call_tag)
self._tool_call_end_tag = "</tool_call>"
self._tool_call_end_token_id = self.vocab.get(self._tool_call_end_tag)
@property
def start_token(self) -> str:
"""The token that starts reasoning content."""
return "<think>"
@property
def end_token(self) -> str:
"""The token that ends reasoning content."""
return "</think>"
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
start_token_id = self.start_token_id
end_token_id = self.end_token_id
tool_call_token_id = self._tool_call_token_id
tool_call_end_token_id = self._tool_call_end_token_id
for i in range(len(input_ids) - 1, -1, -1):
token_id = input_ids[i]
if token_id == start_token_id:
# Found <think> before </think> or <tool_call>
return False
if token_id == end_token_id:
return True
if tool_call_token_id is not None and token_id == tool_call_token_id:
# Only treat as implicit reasoning end if this <tool_call>
# is NOT followed by </tool_call>. Paired occurrences are
# template examples in the prompt, not model output.
if tool_call_end_token_id is not None and any(
input_ids[j] == tool_call_end_token_id
for j in range(i + 1, len(input_ids))
):
continue
return True
return False
def is_reasoning_end_streaming(
self, input_ids: Sequence[int], delta_ids: Iterable[int]
) -> bool:
if super().is_reasoning_end_streaming(input_ids, delta_ids):
return True
if self._tool_call_token_id is not None:
return self._tool_call_token_id in delta_ids
return False
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
"""
Extract content token ids from the input_ids.
"""
result = super().extract_content_ids(input_ids)
if result:
return result
# Fall back: content starts at <tool_call> (implicit reasoning end).
if (
self._tool_call_token_id is not None
and self._tool_call_token_id in input_ids
):
tool_call_index = (
len(input_ids) - 1 - input_ids[::-1].index(self._tool_call_token_id)
)
return input_ids[tool_call_index:]
return []
def extract_reasoning(
self, model_output: str, request: "ChatCompletionRequest | ResponsesRequest"
) -> tuple[str | None, str | None]:
"""
Extract reasoning content from the model output.
The <think> token is placed in the prompt by the chat template,
so typically only </think> appears in the generated output.
If <think> is present (e.g. from a different template), it is
stripped before extraction.
When thinking is explicitly disabled and no </think> appears,
returns (None, model_output) — all output is content.
Otherwise (thinking enabled, default), a missing </think> means
the output was truncated and everything is reasoning:
returns (model_output, None).
Returns:
tuple[Optional[str], Optional[str]]: reasoning content and content
"""
# Strip <think> if present in the generated output.
model_output_parts = model_output.partition(self.start_token)
model_output = (
model_output_parts[2] if model_output_parts[1] else model_output_parts[0]
)
if self.end_token in model_output:
reasoning, _, content = model_output.partition(self.end_token)
return reasoning, content or None
if not self.thinking_enabled:
# Thinking explicitly disabled — treat everything as content.
return None, model_output
# No </think> — check for implicit reasoning end via <tool_call>.
tool_call_index = model_output.find(self._tool_call_tag)
if tool_call_index != -1:
reasoning = model_output[:tool_call_index]
content = model_output[tool_call_index:]
return reasoning or None, content or None
# Thinking enabled but no </think>: output was truncated.
# Everything generated so far is reasoning.
return model_output, None
def extract_reasoning_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
) -> DeltaMessage | None:
"""
Extract reasoning content from a streaming delta.
Since <think> is placed in the prompt by the chat template, all
generated tokens before </think> are reasoning and tokens after
are content.
NOTE: When thinking is disabled, no think tokens appear in the
generated output. The serving layer detects this via
prompt_is_reasoning_end and routes deltas as content without
calling this method.
"""
# Strip <think> from delta if present (old template / edge case
# where the model generates <think> itself).
if self.start_token_id in delta_token_ids:
start_idx = delta_text.find(self.start_token)
if start_idx >= 0:
delta_text = delta_text[start_idx + len(self.start_token) :]
if self.end_token_id in delta_token_ids:
# End token in this delta: split reasoning from content.
end_index = delta_text.find(self.end_token)
if end_index >= 0:
reasoning = delta_text[:end_index]
content = delta_text[end_index + len(self.end_token) :]
if not reasoning and not content:
return None
return DeltaMessage(
reasoning=reasoning if reasoning else None,
content=content if content else None,
)
# end_token_id in IDs but not in text (already stripped)
return None
# Implicit reasoning end via <tool_call>.
if (
self._tool_call_token_id is not None
and self._tool_call_token_id in delta_token_ids
):
tool_index = delta_text.find(self._tool_call_tag)
if tool_index >= 0:
reasoning = delta_text[:tool_index]
content = delta_text[tool_index:]
return DeltaMessage(
reasoning=reasoning if reasoning else None,
content=content if content else None,
)
# No end token in this delta.
if not delta_text:
# Nothing left after stripping start token.
return None
elif self.end_token_id in previous_token_ids:
# End token already passed: everything is content now.
return DeltaMessage(content=delta_text)
elif (
self._tool_call_token_id is not None
and self._tool_call_token_id in previous_token_ids
):
return DeltaMessage(content=delta_text)
else:
# No end token yet: still in reasoning phase.
return DeltaMessage(reasoning=delta_text)
+6 -6
View File
@@ -119,8 +119,8 @@ _TOOL_PARSERS_TO_REGISTER = {
"LongcatFlashToolParser",
),
"mimo": (
"qwen3coder_tool_parser",
"Qwen3CoderToolParser",
"qwen3_engine_tool_parser",
"Qwen3EngineToolParser",
),
"minimax_m2": (
"minimax_m2_tool_parser",
@@ -155,12 +155,12 @@ _TOOL_PARSERS_TO_REGISTER = {
"PythonicToolParser",
),
"qwen3_coder": (
"qwen3coder_tool_parser",
"Qwen3CoderToolParser",
"qwen3_engine_tool_parser",
"Qwen3EngineToolParser",
),
"qwen3_xml": (
"qwen3coder_tool_parser",
"Qwen3CoderToolParser",
"qwen3_engine_tool_parser",
"Qwen3EngineToolParser",
),
"seed_oss": (
"seed_oss_tool_parser",
@@ -60,6 +60,7 @@ class ToolParser:
# xgrammar builtin structural tag model key. Subclasses set this when
# their parsed tool-call syntax matches a builtin xgrammar format.
structural_tag_model: str | None = None
engine_based_streaming: bool = False
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
@@ -0,0 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from vllm.parser.engine.registered_adapters import Qwen3ParserToolAdapter
class Qwen3EngineToolParser(Qwen3ParserToolAdapter): # type: ignore[valid-type, misc]
structural_tag_model = "qwen_3_coder"
-586
View File
@@ -1,586 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
import uuid
from collections.abc import Sequence
from typing import Any
import regex as re
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
)
from vllm.entrypoints.openai.engine.protocol import (
DeltaFunctionCall,
DeltaMessage,
DeltaToolCall,
ExtractedToolCallInformation,
FunctionCall,
ToolCall,
)
from vllm.logger import init_logger
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.abstract_tool_parser import (
Tool,
ToolParser,
)
from vllm.tool_parsers.utils import (
coerce_to_schema_type,
extract_types_from_schema,
find_tool_properties,
)
logger = init_logger(__name__)
class Qwen3CoderToolParser(ToolParser):
structural_tag_model = "qwen_3_coder"
def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
super().__init__(tokenizer, tools)
self.current_tool_name_sent: bool = False
self.prev_tool_call_arr: list[dict] = []
# Override base class type - we use string IDs for tool calls
self.current_tool_id: str | None = None # type: ignore
self.streamed_args_for_tool: list[str] = []
# Sentinel tokens for streaming mode
self.tool_call_start_token: str = "<tool_call>"
self.tool_call_end_token: str = "</tool_call>"
self.tool_call_prefix: str = "<function="
self.function_end_token: str = "</function>"
self.parameter_prefix: str = "<parameter="
self.parameter_end_token: str = "</parameter>"
self.is_tool_call_started: bool = False
self.failed_count: int = 0
# Enhanced streaming state - reset for each new message
self._reset_streaming_state()
# Regex patterns
self.tool_call_complete_regex = re.compile(
r"<tool_call>(.*?)</tool_call>", re.DOTALL
)
self.tool_call_regex = re.compile(
r"<tool_call>(.*?)</tool_call>|<tool_call>(.*?)$", re.DOTALL
)
self.tool_call_function_regex = re.compile(
r"<function=(.*?)</function>|<function=(.*)$", re.DOTALL
)
self.tool_call_parameter_regex = re.compile(
r"<parameter=(.*?)(?:</parameter>|(?=<parameter=)|(?=</function>)|$)",
re.DOTALL,
)
if not self.model_tokenizer:
raise ValueError(
"The model tokenizer must be passed to the ToolParser "
"constructor during construction."
)
self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token)
self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
if self.tool_call_start_token_id is None or self.tool_call_end_token_id is None:
raise RuntimeError(
"Qwen3 XML Tool parser could not locate tool call start/end "
"tokens in the tokenizer!"
)
logger.debug(
"vLLM Successfully import tool parser %s !", self.__class__.__name__
)
def _generate_tool_call_id(self) -> str:
"""Generate a unique tool call ID."""
return f"call_{uuid.uuid4().hex[:24]}"
def _reset_streaming_state(self):
"""Reset all streaming state."""
self.current_tool_index = 0
self.is_tool_call_started = False
self.header_sent = False
self.current_tool_id = None
self.current_function_name = None
self.current_param_name = None
self.current_param_value = ""
self.param_count = 0
self.in_param = False
self.in_function = False
self.accumulated_text = ""
self.json_started = False
self.json_closed = False
# Store accumulated parameters for type conversion
self.accumulated_params = {}
self.streaming_request = None
def _convert_param_value(
self, param_value: str, param_name: str, param_config: dict, func_name: str
) -> Any:
"""Convert parameter value based on its type in the schema."""
if not isinstance(param_value, str):
return param_value
param_schema = param_config.get(param_name, {})
param_types = extract_types_from_schema(param_schema)
return coerce_to_schema_type(param_value, param_types)
def _parse_xml_function_call(self, function_call_str: str) -> ToolCall | None:
# Extract function name
end_index = function_call_str.find(">")
# If there's no ">" character, this is not a valid xml function call
if end_index == -1:
return None
function_name = function_call_str[:end_index]
param_config = find_tool_properties(self.tools, function_name)
parameters = function_call_str[end_index + 1 :]
param_dict = {}
for match_text in self.tool_call_parameter_regex.findall(parameters):
idx = match_text.index(">")
param_name = match_text[:idx]
param_value = str(match_text[idx + 1 :])
# Remove prefix and trailing \n
if param_value.startswith("\n"):
param_value = param_value[1:]
if param_value.endswith("\n"):
param_value = param_value[:-1]
param_dict[param_name] = self._convert_param_value(
param_value, param_name, param_config, function_name
)
return ToolCall(
type="function",
function=FunctionCall(
name=function_name, arguments=json.dumps(param_dict, ensure_ascii=False)
),
)
def _get_function_calls(self, model_output: str) -> list[str]:
# Find all tool calls
matched_ranges = self.tool_call_regex.findall(model_output)
raw_tool_calls = [
match[0] if match[0] else match[1] for match in matched_ranges
]
# Back-off strategy if no tool_call tags found
if len(raw_tool_calls) == 0:
raw_tool_calls = [model_output]
raw_function_calls = []
for tool_call in raw_tool_calls:
raw_function_calls.extend(self.tool_call_function_regex.findall(tool_call))
function_calls = [
match[0] if match[0] else match[1] for match in raw_function_calls
]
return function_calls
def extract_tool_calls(
self,
model_output: str,
request: ChatCompletionRequest,
) -> ExtractedToolCallInformation:
# Quick check to avoid unnecessary processing
if self.tool_call_prefix not in model_output:
return ExtractedToolCallInformation(
tools_called=False, tool_calls=[], content=model_output
)
try:
function_calls = self._get_function_calls(model_output)
if len(function_calls) == 0:
return ExtractedToolCallInformation(
tools_called=False, tool_calls=[], content=model_output
)
tool_calls = [
self._parse_xml_function_call(function_call_str)
for function_call_str in function_calls
]
# Populate prev_tool_call_arr for serving layer to set finish_reason
self.prev_tool_call_arr.clear() # Clear previous calls
for tool_call in tool_calls:
if tool_call:
self.prev_tool_call_arr.append(
{
"name": tool_call.function.name,
"arguments": tool_call.function.arguments,
}
)
# Extract content before tool calls
content_index = model_output.find(self.tool_call_start_token)
idx = model_output.find(self.tool_call_prefix)
content_index = content_index if content_index >= 0 else idx
content = model_output[:content_index] # .rstrip()
valid_tool_calls = [tc for tc in tool_calls if tc is not None]
return ExtractedToolCallInformation(
tools_called=(len(valid_tool_calls) > 0),
tool_calls=valid_tool_calls,
content=content if content else None,
)
except Exception:
logger.exception("Error in extracting tool call from response.")
return ExtractedToolCallInformation(
tools_called=False, tool_calls=[], content=model_output
)
def extract_tool_calls_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
request: ChatCompletionRequest,
) -> DeltaMessage | None:
# Store request for type conversion
if not previous_text:
self._reset_streaming_state()
self.streaming_request = request
# If no delta text, return None unless it's an EOS token after tools
if not delta_text:
# Check if this is an EOS token after all tool calls are complete
# Check for tool calls in text even if is_tool_call_started
# is False (might have been reset after processing all tools)
if delta_token_ids and self.tool_call_end_token_id not in delta_token_ids:
# Count complete tool calls
complete_calls = len(
self.tool_call_complete_regex.findall(current_text)
)
# If we have completed tool calls and populated
# prev_tool_call_arr
if complete_calls > 0 and len(self.prev_tool_call_arr) > 0:
# Check if all tool calls are closed
open_calls = current_text.count(
self.tool_call_start_token
) - current_text.count(self.tool_call_end_token)
if open_calls == 0:
# Return empty delta for finish_reason processing
return DeltaMessage(content="")
elif not self.is_tool_call_started and current_text:
# This is a regular content response that's now complete
return DeltaMessage(content="")
return None
# Update accumulated text
self.accumulated_text = current_text
# Check if we need to advance to next tool
if self.json_closed and not self.in_function:
# Check if this tool call has ended
tool_ends = current_text.count(self.tool_call_end_token)
if tool_ends > self.current_tool_index:
# This tool has ended, advance to next
self.current_tool_index += 1
self.header_sent = False
self.param_count = 0
self.json_started = False
self.json_closed = False
self.accumulated_params = {}
# Check if there are more tool calls
tool_starts = current_text.count(self.tool_call_start_token)
if self.current_tool_index >= tool_starts:
# No more tool calls
self.is_tool_call_started = False
# Continue processing next tool
return None
# Handle normal content before tool calls
if not self.is_tool_call_started:
# Check if tool call is starting
if (
self.tool_call_start_token_id in delta_token_ids
or self.tool_call_start_token in delta_text
):
self.is_tool_call_started = True
# Return any content before the tool call
if self.tool_call_start_token in delta_text:
content_before = delta_text[
: delta_text.index(self.tool_call_start_token)
]
if content_before:
return DeltaMessage(content=content_before)
return None
else:
# Check if we're between tool calls - skip whitespace
if (
current_text.rstrip().endswith(self.tool_call_end_token)
and delta_text.strip() == ""
):
# We just ended a tool call, skip whitespace
return None
# Normal content, no tool call
return DeltaMessage(content=delta_text)
# Check if we're between tool calls (waiting for next one)
# Count tool calls we've seen vs processed
tool_starts_count = current_text.count(self.tool_call_start_token)
if self.current_tool_index >= tool_starts_count:
# We're past all tool calls, shouldn't be here
return None
# We're in a tool call, find the current tool call portion
# Need to find the correct tool call based on current_tool_index
tool_start_positions: list[int] = []
idx = 0
while True:
idx = current_text.find(self.tool_call_start_token, idx)
if idx == -1:
break
tool_start_positions.append(idx)
idx += len(self.tool_call_start_token)
if self.current_tool_index >= len(tool_start_positions):
# No more tool calls to process yet
return None
tool_start_idx = tool_start_positions[self.current_tool_index]
# Find where this tool call ends (or current position if not ended yet)
tool_end_idx = current_text.find(self.tool_call_end_token, tool_start_idx)
if tool_end_idx == -1:
tool_text = current_text[tool_start_idx:]
else:
tool_text = current_text[
tool_start_idx : tool_end_idx + len(self.tool_call_end_token)
]
# Looking for function header
if not self.header_sent:
if self.tool_call_prefix in tool_text:
func_start = tool_text.find(self.tool_call_prefix) + len(
self.tool_call_prefix
)
func_end = tool_text.find(">", func_start)
if func_end != -1:
# Found complete function name
self.current_function_name = tool_text[func_start:func_end]
self.current_tool_id = self._generate_tool_call_id()
self.header_sent = True
self.in_function = True
# Always append — each tool call is a separate
# invocation even if the function name is the same
# (e.g. two consecutive "read" calls).
self.prev_tool_call_arr.append(
{
"name": self.current_function_name,
"arguments": "{}",
}
)
# Initialize streamed args tracking for this tool.
# The serving layer reads streamed_args_for_tool to
# compute remaining arguments at stream end. Without
# this, IndexError occurs when the serving layer
# accesses streamed_args_for_tool[index].
self.streamed_args_for_tool.append("")
# Send header with function info
return DeltaMessage(
tool_calls=[
DeltaToolCall(
index=self.current_tool_index,
id=self.current_tool_id,
function=DeltaFunctionCall(
name=self.current_function_name, arguments=""
),
type="function",
)
]
)
return None
# We've sent header, now handle function body
if self.in_function:
# Always send opening brace first, regardless of whether
# parameter_prefix is in the current delta. With speculative
# decoding, a single delta may contain both the opening brace
# and parameter data; skipping "{" here would desync
# json_started from what was actually streamed.
if not self.json_started:
self.json_started = True
self.streamed_args_for_tool[self.current_tool_index] += "{"
return DeltaMessage(
tool_calls=[
DeltaToolCall(
index=self.current_tool_index,
function=DeltaFunctionCall(arguments="{"),
)
]
)
# Find all parameter start positions in current tool_text
param_starts = []
search_idx = 0
while True:
search_idx = tool_text.find(self.parameter_prefix, search_idx)
if search_idx == -1:
break
param_starts.append(search_idx)
search_idx += len(self.parameter_prefix)
# Process ALL complete params in a loop (spec decode fix).
# With speculative decoding a single delta can deliver
# multiple complete parameters at once. The old single-pass
# code would process one and ``return None`` if the next was
# incomplete — skipping any already-complete params that
# preceded it. Using a loop with ``break`` instead ensures
# we emit every complete parameter before yielding control.
json_fragments = []
while not self.in_param and self.param_count < len(param_starts):
param_idx = param_starts[self.param_count]
param_start = param_idx + len(self.parameter_prefix)
remaining = tool_text[param_start:]
if ">" not in remaining:
break
name_end = remaining.find(">")
current_param_name = remaining[:name_end]
value_start = param_start + name_end + 1
value_text = tool_text[value_start:]
if value_text.startswith("\n"):
value_text = value_text[1:]
param_end_idx = value_text.find(self.parameter_end_token)
if param_end_idx == -1:
next_param_idx = value_text.find(self.parameter_prefix)
func_end_idx = value_text.find(self.function_end_token)
if next_param_idx != -1 and (
func_end_idx == -1 or next_param_idx < func_end_idx
):
param_end_idx = next_param_idx
elif func_end_idx != -1:
param_end_idx = func_end_idx
else:
# Fallback for malformed XML where </function>
# is missing. Use </tool_call> as a delimiter
# if present in the value so we don't include
# the closing tag as part of the param value.
tool_end_in_value = value_text.find(self.tool_call_end_token)
if tool_end_in_value != -1:
param_end_idx = tool_end_in_value
else:
# Parameter incomplete — break so we still
# emit any fragments accumulated by earlier
# loop iterations.
break
if param_end_idx == -1:
break
param_value = value_text[:param_end_idx]
if param_value.endswith("\n"):
param_value = param_value[:-1]
self.current_param_name = current_param_name
self.accumulated_params[current_param_name] = param_value
param_config = find_tool_properties(
self.tools, self.current_function_name or ""
)
converted_value = self._convert_param_value(
param_value,
current_param_name,
param_config,
self.current_function_name or "",
)
serialized_value = json.dumps(converted_value, ensure_ascii=False)
if self.param_count == 0:
json_fragment = f'"{current_param_name}": {serialized_value}'
else:
json_fragment = f', "{current_param_name}": {serialized_value}'
self.param_count += 1
json_fragments.append(json_fragment)
if json_fragments:
combined = "".join(json_fragments)
if self.current_tool_index < len(self.streamed_args_for_tool):
self.streamed_args_for_tool[self.current_tool_index] += combined
else:
logger.warning(
"streamed_args_for_tool out of sync: index=%d len=%d",
self.current_tool_index,
len(self.streamed_args_for_tool),
)
return DeltaMessage(
tool_calls=[
DeltaToolCall(
index=self.current_tool_index,
function=DeltaFunctionCall(arguments=combined),
)
]
)
# Check for function end AFTER processing parameters.
# This ordering is critical: with speculative decoding a
# burst can deliver the final parameter value together with
# </function>. If the close check ran first it would emit
# "}" and set in_function=False before the parameter loop
# ever ran, causing the parameter to be silently dropped.
if not self.json_closed and self.function_end_token in tool_text:
self.json_closed = True
func_start = tool_text.find(self.tool_call_prefix) + len(
self.tool_call_prefix
)
func_content_end = tool_text.find(self.function_end_token, func_start)
if func_content_end != -1:
func_content = tool_text[func_start:func_content_end]
try:
parsed_tool = self._parse_xml_function_call(
func_content,
)
if parsed_tool and self.current_tool_index < len(
self.prev_tool_call_arr
):
self.prev_tool_call_arr[self.current_tool_index][
"arguments"
] = parsed_tool.function.arguments
except Exception:
logger.debug(
"Failed to parse tool call during streaming: %s",
tool_text,
exc_info=True,
)
if self.current_tool_index < len(self.streamed_args_for_tool):
self.streamed_args_for_tool[self.current_tool_index] += "}"
else:
logger.warning(
"streamed_args_for_tool out of sync: index=%d len=%d",
self.current_tool_index,
len(self.streamed_args_for_tool),
)
result = DeltaMessage(
tool_calls=[
DeltaToolCall(
index=self.current_tool_index,
function=DeltaFunctionCall(arguments="}"),
)
]
)
self.in_function = False
self.json_closed = True
self.accumulated_params = {}
return result
return None