Files
vllm-project--vllm/tests/parser/engine/test_parser_engine.py
T
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

1675 lines
58 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for :class:`ParserEngine` — the glue layer between
:class:`StreamingParserEngine` events and the serving layer's
DeltaMessage / ExtractedToolCallInformation protocol.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import regex as re
from tests.parser.engine.conftest import make_mock_tokenizer
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
ChatCompletionToolsParam,
)
from vllm.entrypoints.openai.engine.protocol import (
DeltaFunctionCall,
DeltaToolCall,
FunctionDefinition,
)
from vllm.parser.abstract_parser import DelegatingParser
from vllm.parser.engine.adapters import make_adapters
from vllm.parser.engine.events import EventType, SemanticEvent
from vllm.parser.engine.parser_engine import ParserEngine
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
# ── Shared test configs ──────────────────────────────────────────────
_VOCAB: dict[str, int] = {
"<think>": 200,
"</think>": 201,
"<tool_call>": 202,
"</tool_call>": 203,
}
def _combined_config() -> ParserEngineConfig:
"""Config with reasoning tags and tool-call tags."""
return ParserEngineConfig(
name="combined_test",
terminals={
"THINK_START": "<think>",
"THINK_END": "</think>",
"TOOL_START": "<tool_call>",
"TOOL_END": "</tool_call>",
},
token_id_terminals={
"THINK_START": "<think>",
"THINK_END": "</think>",
"TOOL_START": "<tool_call>",
"TOOL_END": "</tool_call>",
},
transitions={
(ParserState.REASONING, "THINK_END"): Transition(
ParserState.CONTENT,
(EventType.REASONING_END,),
),
(ParserState.CONTENT, "THINK_START"): Transition(
ParserState.REASONING,
(EventType.REASONING_START,),
),
(ParserState.CONTENT, "TOOL_START"): Transition(
ParserState.TOOL_ARGS,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_ARGS, "TOOL_END"): Transition(
ParserState.CONTENT,
(EventType.TOOL_CALL_END,),
),
},
initial_state=ParserState.REASONING,
content_events={
ParserState.CONTENT: EventType.TEXT_CHUNK,
ParserState.REASONING: EventType.REASONING_CHUNK,
ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
},
)
def _hermes_config() -> ParserEngineConfig:
"""Tool-call-only config (no reasoning)."""
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 _make_engine(
config: ParserEngineConfig | None = None,
tools: list | None = None,
vocab: dict[str, int] | None = None,
special_tokens: list[str] | None = None,
) -> ParserEngine:
tokenizer = make_mock_tokenizer(vocab or _VOCAB, special_tokens=special_tokens)
cfg = config or _combined_config()
return ParserEngine(
tokenizer,
tools=tools,
parser_engine_config=cfg,
)
# ── TestEventsToDelta ────────────────────────────────────────────────
class TestEventsToDelta:
"""Unit tests for ParserEngine._events_to_delta()."""
def test_text_chunk_produces_content(self):
engine = _make_engine()
delta = engine._events_to_delta(
[
SemanticEvent(EventType.TEXT_CHUNK, "Hello world"),
]
)
assert delta is not None
assert delta.content == "Hello world"
assert not delta.tool_calls
def test_reasoning_chunk_produces_reasoning(self):
engine = _make_engine()
delta = engine._events_to_delta(
[
SemanticEvent(EventType.REASONING_CHUNK, "Let me think"),
]
)
assert delta is not None
assert delta.reasoning == "Let me think"
assert delta.content is None
def test_empty_events_returns_none(self):
engine = _make_engine()
delta = engine._events_to_delta([])
assert delta is None
def test_tool_call_produces_tool_call_delta(self):
engine = _make_engine()
events = [
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0),
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
'{"location": "NYC"}',
tool_index=0,
),
SemanticEvent(EventType.TOOL_CALL_END, tool_index=0),
]
delta = engine._events_to_delta(events)
assert delta is not None
assert len(delta.tool_calls) > 0
names = [
tc.function.name
for tc in delta.tool_calls
if tc.function and tc.function.name
]
assert "get_weather" in names
def test_reasoning_end_sets_flag(self):
engine = _make_engine()
assert engine._reasoning_ended is False
engine._events_to_delta([SemanticEvent(EventType.REASONING_END)])
assert engine._reasoning_ended is True
def test_mixed_content_and_reasoning(self):
engine = _make_engine()
events = [
SemanticEvent(EventType.REASONING_CHUNK, "thinking..."),
SemanticEvent(EventType.REASONING_END),
SemanticEvent(EventType.TEXT_CHUNK, "answer"),
]
delta = engine._events_to_delta(events)
assert delta is not None
assert delta.reasoning == "thinking..."
assert delta.content == "answer"
@pytest.mark.parametrize(
"events,expected,excluded",
[
(
[SemanticEvent(EventType.TEXT_CHUNK, "Hello world")],
"content",
["tool_calls", "reasoning"],
),
(
[SemanticEvent(EventType.REASONING_CHUNK, "Let me think")],
"reasoning",
["tool_calls", "content"],
),
(
[
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
SemanticEvent(EventType.TOOL_NAME, "fn", tool_index=0),
SemanticEvent(EventType.ARG_VALUE_CHUNK, '{"k":1}', tool_index=0),
SemanticEvent(EventType.TOOL_CALL_END, tool_index=0),
],
"tool_calls",
["content", "reasoning"],
),
],
ids=["content_only", "reasoning_only", "tool_call_only"],
)
def test_delta_excludes_unset_fields(self, events, expected, excluded):
engine = _make_engine()
delta = engine._events_to_delta(events)
assert delta is not None
dumped = delta.model_dump(exclude_unset=True)
assert expected in dumped
for field in excluded:
assert field not in dumped
def test_kimi_k2_tool_call_id_includes_func_name(self):
engine = _make_engine()
engine._stream_state.tool_call_id_type = "kimi_k2"
events = [
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0),
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
'{"city": "NYC"}',
tool_index=0,
),
SemanticEvent(EventType.TOOL_CALL_END, tool_index=0),
]
delta = engine._events_to_delta(events)
assert delta is not None
assert len(delta.tool_calls) == 1
assert delta.tool_calls[0].id == "functions.get_weather:0"
def test_multiple_arg_chunks_same_batch_coalesced(self):
"""Multiple events for the same tool in one batch must produce
at most one DeltaToolCall per index."""
engine = _make_engine()
events = [
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0),
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
'{"city": ',
tool_index=0,
),
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
'"Tokyo"}',
tool_index=0,
),
SemanticEvent(EventType.TOOL_CALL_END, tool_index=0),
]
delta = engine._events_to_delta(events)
assert delta is not None
indices = [tc.index for tc in delta.tool_calls]
assert len(indices) == len(set(indices)), (
f"Duplicate indices in tool_calls: {delta.tool_calls}"
)
assert delta.tool_calls[0].function.name == "get_weather"
assert delta.tool_calls[0].id is not None
# ── TestCoalesceToolCallDeltas ──────────────────────────────────────
class TestCoalesceToolCallDeltas:
"""Unit tests for ParserEngine._coalesce_tool_call_deltas()."""
def test_no_duplicates_unchanged(self):
deltas = [
DeltaToolCall(
index=0,
id="a",
type="function",
function=DeltaFunctionCall(name="f"),
),
DeltaToolCall(
index=1,
function=DeltaFunctionCall(arguments="{}"),
),
]
result = ParserEngine._coalesce_tool_call_deltas(deltas)
assert len(result) == 2
assert result[0].index == 0
assert result[1].index == 1
def test_name_and_args_same_index_merged(self):
deltas = [
DeltaToolCall(
index=0,
id="call_1",
type="function",
function=DeltaFunctionCall(name="get_weather"),
),
DeltaToolCall(
index=0,
function=DeltaFunctionCall(arguments='{"city":'),
),
DeltaToolCall(
index=0,
function=DeltaFunctionCall(arguments='"Tokyo"}'),
),
]
result = ParserEngine._coalesce_tool_call_deltas(deltas)
assert len(result) == 1
assert result[0].index == 0
assert result[0].id == "call_1"
assert result[0].type == "function"
assert result[0].function.name == "get_weather"
assert result[0].function.arguments == '{"city":"Tokyo"}'
def test_empty_list(self):
assert ParserEngine._coalesce_tool_call_deltas([]) == []
def test_single_element(self):
tc = DeltaToolCall(
index=0,
function=DeltaFunctionCall(name="f"),
)
result = ParserEngine._coalesce_tool_call_deltas([tc])
assert result == [tc]
def test_partial_duplicates(self):
deltas = [
DeltaToolCall(
index=0,
id="a",
type="function",
function=DeltaFunctionCall(name="f1"),
),
DeltaToolCall(
index=1,
id="b",
type="function",
function=DeltaFunctionCall(name="f2"),
),
DeltaToolCall(
index=0,
function=DeltaFunctionCall(arguments='{"x":1}'),
),
]
result = ParserEngine._coalesce_tool_call_deltas(deltas)
assert len(result) == 2
assert result[0].index == 0
assert result[0].function.name == "f1"
assert result[0].function.arguments == '{"x":1}'
assert result[1].index == 1
def test_id_type_from_later_entry(self):
deltas = [
DeltaToolCall(
index=0,
function=DeltaFunctionCall(arguments='{"a":1}'),
),
DeltaToolCall(
index=0,
id="call_1",
type="function",
function=DeltaFunctionCall(name="f"),
),
]
result = ParserEngine._coalesce_tool_call_deltas(deltas)
assert len(result) == 1
assert result[0].id == "call_1"
assert result[0].type == "function"
assert result[0].function.name == "f"
assert result[0].function.arguments == '{"a":1}'
# ── TestContentWhitespaceHandling ────────────────────────────────────
class TestContentWhitespaceHandling:
"""Unit tests for whitespace deferral / dropping in _events_to_delta."""
def test_whitespace_only_deferred_until_next_tick(self):
engine = _make_engine()
d1 = engine._events_to_delta(
[SemanticEvent(EventType.TEXT_CHUNK, " \n")],
)
assert d1 is None
d2 = engine._events_to_delta(
[SemanticEvent(EventType.TEXT_CHUNK, "hello")],
)
assert d2 is not None
assert d2.content == " \nhello"
def test_whitespace_only_emitted_on_finished(self):
engine = _make_engine()
d = engine._events_to_delta(
[SemanticEvent(EventType.TEXT_CHUNK, " \n")],
finished=True,
)
assert d is not None
assert d.content == " \n"
def test_whitespace_dropped_before_tool_call(self):
engine = _make_engine()
engine._events_to_delta(
[
SemanticEvent(EventType.TEXT_CHUNK, " \n"),
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
]
)
d = engine._events_to_delta(
[
SemanticEvent(EventType.TOOL_NAME, "f", tool_index=0),
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
'{"a":1}',
tool_index=0,
),
SemanticEvent(EventType.TOOL_CALL_END, tool_index=0),
]
)
assert d is not None
assert d.content is None
assert d.tool_calls
def test_real_content_before_tool_preserved(self):
engine = _make_engine()
d = engine._events_to_delta(
[
SemanticEvent(EventType.TEXT_CHUNK, "prefix"),
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
]
)
assert d is not None
assert d.content == "prefix"
def test_whitespace_after_nonws_content_preserved(self):
engine = _make_engine()
engine._events_to_delta(
[SemanticEvent(EventType.TEXT_CHUNK, "hello")],
)
d = engine._events_to_delta(
[SemanticEvent(EventType.TEXT_CHUNK, " \n")],
)
assert d is not None
assert d.content == " \n"
def test_whitespace_after_nonws_not_dropped_with_tools(self):
engine = _make_engine()
engine._events_to_delta(
[SemanticEvent(EventType.TEXT_CHUNK, "hello")],
)
d = engine._events_to_delta(
[
SemanticEvent(EventType.TEXT_CHUNK, " \n"),
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
]
)
assert d is not None
assert d.content == " \n"
# ── TestPostToolContentDeferral ──────────────────────────────────────
class TestPostToolContentDeferral:
"""Regression: content after TOOL_CALL_END in the same batch must not
produce a mixed DeltaMessage(content=..., tool_calls=...) — that causes
split_delta to reorder content before tool_calls, breaking the Responses
API state machine."""
def test_text_after_tool_end_deferred(self):
engine = _make_engine()
events = [
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0),
SemanticEvent(EventType.ARG_VALUE_CHUNK, '{"city":"NYC"}', tool_index=0),
SemanticEvent(EventType.TOOL_CALL_END, tool_index=0),
SemanticEvent(EventType.TEXT_CHUNK, "\nHere is the result"),
]
delta = engine._events_to_delta(events)
assert delta is not None
assert delta.tool_calls
assert delta.content is None
deferred = engine._events_to_delta([])
assert deferred is not None
assert deferred.content == "\nHere is the result"
assert not deferred.tool_calls
def test_text_after_tool_deferred_even_when_finished(self):
engine = _make_engine()
events = [
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
SemanticEvent(EventType.TOOL_NAME, "f", tool_index=0),
SemanticEvent(EventType.ARG_VALUE_CHUNK, "{}", tool_index=0),
SemanticEvent(EventType.TOOL_CALL_END, tool_index=0),
SemanticEvent(EventType.TEXT_CHUNK, "done"),
]
delta = engine._events_to_delta(events, finished=True)
assert delta is not None
assert delta.tool_calls
assert delta.content is None
def test_text_before_tool_not_deferred(self):
engine = _make_engine()
engine._content_has_nonws = True
events = [
SemanticEvent(EventType.TEXT_CHUNK, "hello"),
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
SemanticEvent(EventType.TOOL_NAME, "f", tool_index=0),
SemanticEvent(EventType.ARG_VALUE_CHUNK, "{}", tool_index=0),
SemanticEvent(EventType.TOOL_CALL_END, tool_index=0),
]
delta = engine._events_to_delta(events)
assert delta is not None
assert delta.content == "hello"
assert delta.tool_calls
def test_deferred_content_not_flushed_during_arg_continuation(self):
"""Deferred content from batch N must not mix with arg-continuation
tool events in batch N+1 — that creates a DeltaMessage with both
content and nameless tool_calls, which crashes the Responses API
state machine (name=None → Pydantic ValidationError)."""
engine = _make_engine()
engine._content_has_nonws = True
batch1 = [
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0),
SemanticEvent(EventType.ARG_VALUE_CHUNK, '{"city":', tool_index=0),
SemanticEvent(EventType.TEXT_CHUNK, "\n"),
]
delta1 = engine._events_to_delta(batch1)
assert delta1 is not None
assert delta1.tool_calls
assert delta1.content is None
batch2 = [
SemanticEvent(EventType.ARG_VALUE_CHUNK, '"NYC"}', tool_index=0),
]
delta2 = engine._events_to_delta(batch2)
assert delta2 is not None
assert delta2.tool_calls
assert delta2.content is None
flush = engine._events_to_delta([])
assert flush is not None
assert flush.content == "\n"
assert not flush.tool_calls
# ── TestFixArgTypes ──────────────────────────────────────────────────
def _make_tool(name: str, properties: dict) -> ChatCompletionToolsParam:
return ChatCompletionToolsParam(
type="function",
function=FunctionDefinition(
name=name,
parameters={"type": "object", "properties": properties},
),
)
class TestFixArgTypes:
"""Tests for ParserEngine._fix_arg_types()."""
def test_string_param_reverted_from_int(self):
tool = _make_tool("f", {"zipcode": {"type": "string"}})
engine = _make_engine(tools=[tool])
result = engine._fix_arg_types('{"zipcode": 12345}', "f")
assert '"zipcode": "12345"' in result
def test_string_param_reverted_from_bool(self):
tool = _make_tool("f", {"flag": {"type": "string"}})
engine = _make_engine(tools=[tool])
result = engine._fix_arg_types('{"flag": true}', "f")
assert '"flag": "true"' in result
def test_string_param_reverted_from_null(self):
tool = _make_tool("f", {"val": {"type": "string"}})
engine = _make_engine(tools=[tool])
result = engine._fix_arg_types('{"val": null}', "f")
assert '"val": "null"' in result
def test_int_param_not_changed(self):
tool = _make_tool("f", {"count": {"type": "integer"}})
engine = _make_engine(tools=[tool])
result = engine._fix_arg_types('{"count": 42}', "f")
assert '"count": 42' in result
def test_no_tools_returns_unchanged(self):
engine = _make_engine(tools=None)
original = '{"a": 1}'
assert engine._fix_arg_types(original, "f") == original
def test_unknown_function_returns_unchanged(self):
tool = _make_tool("known", {"x": {"type": "string"}})
engine = _make_engine(tools=[tool])
original = '{"x": 1}'
assert engine._fix_arg_types(original, "unknown") == original
def test_invalid_json_returns_unchanged(self):
tool = _make_tool("f", {"x": {"type": "string"}})
engine = _make_engine(tools=[tool])
original = "not json"
assert engine._fix_arg_types(original, "f") == original
def test_string_value_not_touched(self):
tool = _make_tool("f", {"name": {"type": "string"}})
engine = _make_engine(tools=[tool])
original = '{"name": "Alice"}'
assert engine._fix_arg_types(original, "f") == original
@pytest.mark.parametrize(
"properties, input_json, expected_substr",
[
({"count": {"type": "integer"}}, '{"count": "42"}', '"count": 42'),
({"score": {"type": "number"}}, '{"score": "3.14"}', '"score": 3.14'),
({"flag": {"type": "boolean"}}, '{"flag": "true"}', '"flag": true'),
({"flag": {"type": "boolean"}}, '{"flag": "false"}', '"flag": false'),
({"val": {"type": "null"}}, '{"val": "null"}', '"val": null'),
({"val": {"type": ["string", "null"]}}, '{"val": "null"}', '"val": null'),
({"score": {"type": "number"}}, '{"score": "108."}', '"score": 108'),
],
ids=[
"string_to_int",
"string_to_float",
"string_to_bool_true",
"string_to_bool_false",
"string_to_null",
"string_to_null_union",
"trailing_dot_float",
],
)
def test_string_coerced_to_schema_type(
self,
properties,
input_json,
expected_substr,
):
tool = _make_tool("f", properties)
engine = _make_engine(tools=[tool])
result = engine._fix_arg_types(input_json, "f")
assert expected_substr in result
def test_mixed_types_coerced(self):
tool = _make_tool(
"f",
{
"count": {"type": "integer"},
"active": {"type": "boolean"},
"score": {"type": "number"},
},
)
engine = _make_engine(tools=[tool])
result = engine._fix_arg_types(
'{"count": "42", "active": "true", "score": "3.14"}', "f"
)
parsed = json.loads(result)
assert parsed["count"] == 42
assert parsed["active"] is True
assert parsed["score"] == 3.14
def test_nested_object_coercion(self):
tool = _make_tool(
"f",
{
"inner": {
"type": "object",
"properties": {
"count": {"type": "integer"},
},
},
},
)
engine = _make_engine(tools=[tool])
result = engine._fix_arg_types('{"inner": {"count": "42"}}', "f")
parsed = json.loads(result)
assert parsed["inner"]["count"] == 42
def test_array_item_coercion(self):
tool = _make_tool(
"f",
{
"nums": {
"type": "array",
"items": {"type": "integer"},
},
},
)
engine = _make_engine(tools=[tool])
result = engine._fix_arg_types('{"nums": ["42", "5"]}', "f")
parsed = json.loads(result)
assert parsed["nums"] == [42, 5]
def test_array_mixed_item_types(self):
tool = _make_tool(
"f",
{
"vals": {
"type": "array",
"items": {"type": "number"},
},
},
)
engine = _make_engine(tools=[tool])
result = engine._fix_arg_types('{"vals": ["42", "3.14"]}', "f")
parsed = json.loads(result)
assert parsed["vals"] == [42, 3.14]
# ── TestBuildExtractedResult ─────────────────────────────────────────
class TestBuildExtractedResult:
"""Tests for ParserEngine._build_extracted_result()."""
def test_no_tool_calls(self):
engine = _make_engine()
result = engine._build_extracted_result()
assert result.tools_called is False
assert result.tool_calls == []
def test_single_tool_call(self):
engine = _make_engine(_hermes_config())
text = '<tool_call>{"name": "f", "arguments": {"a": 1}}</tool_call>'
events = engine._engine.feed(text, [])
events.extend(engine._engine.finish())
delta = engine._events_to_delta(events)
result = engine._build_extracted_result(delta)
assert result.tools_called is True
assert len(result.tool_calls) == 1
assert result.tool_calls[0].function.name == "f"
def test_content_passthrough(self):
engine = _make_engine(_hermes_config())
text = "Hello world"
events = engine._engine.feed(text, [])
events.extend(engine._engine.finish())
delta = engine._events_to_delta(events, finished=True)
result = engine._build_extracted_result(delta)
assert result.tools_called is False
assert result.content == "Hello world"
# ── TestEngineBasedPath ──────────────────────────────────────────────
class TestEngineBasedPath:
"""Tests for the _engine_based accumulation behavior in
DelegatingParser.parse_delta."""
def test_engine_based_true_when_both_parsers_engine(self):
r = SimpleNamespace(engine_based_streaming=True)
t = SimpleNamespace(engine_based_streaming=True)
engine_based = r.engine_based_streaming and t.engine_based_streaming
assert engine_based is True
def test_engine_based_false_when_reasoning_parser_not_engine(self):
r = SimpleNamespace(engine_based_streaming=False)
t = SimpleNamespace(engine_based_streaming=True)
engine_based = r.engine_based_streaming and t.engine_based_streaming
assert engine_based is False
def test_parse_delta_streaming(self, mock_request):
"""Engine's parse_delta returns content from streaming events."""
engine = _make_engine(_hermes_config())
engine._streaming_initialized = True
result = engine.parse_delta(
"Hello",
[],
mock_request,
finished=False,
)
assert result is not None
assert result.content == "Hello"
def test_parse_delta_tool_call(self, mock_request):
"""Engine's parse_delta handles tool calls in streaming."""
engine = _make_engine(_hermes_config())
engine._streaming_initialized = True
result = engine.parse_delta(
'<tool_call>{"name": "f", "arguments": {}}</tool_call>',
[],
mock_request,
finished=True,
)
assert result is not None
assert len(result.tool_calls) > 0
# ── TestParseTokenIdPassthrough ────────────────────────────────────
class TestParseTokenIdPassthrough:
"""parse() must forward model_output_token_ids to _single_pass_parse
so that token-ID-based strict terminal matching is active."""
def test_literal_tool_tag_in_content_preserved_with_token_ids(self, mock_request):
engine = _make_engine(_hermes_config())
text = (
"Use <tool_call> to call tools."
'<tool_call>{"name": "f", "arguments": {"a": 1}}</tool_call>'
)
token_ids = [
65,
66,
67,
68,
69,
70,
71, # "Use <tool_call> to call tools."
202, # real <tool_call>
72,
73,
74, # '{"name": "f", ...}'
203, # real </tool_call>
]
_, content, tool_calls = engine.parse(
text, mock_request, model_output_token_ids=token_ids
)
assert content is not None
assert "<tool_call>" in content
assert content == "Use <tool_call> to call tools."
assert tool_calls is not None
assert len(tool_calls) == 1
assert tool_calls[0].name == "f"
def test_parse_with_token_ids_basic(self, mock_request):
engine = _make_engine(_hermes_config())
text = '<tool_call>{"name": "h", "arguments": {"x": 1}}</tool_call>'
token_ids = [202, 65, 66, 67, 203]
_, content, tool_calls = engine.parse(
text, mock_request, model_output_token_ids=token_ids
)
assert tool_calls is not None
assert len(tool_calls) == 1
assert tool_calls[0].name == "h"
def test_parse_without_token_ids_backward_compat(self, mock_request):
engine = _make_engine(_hermes_config())
text = '<tool_call>{"name": "g", "arguments": {}}</tool_call>'
_, content, tool_calls = engine.parse(text, mock_request)
assert tool_calls is not None
assert len(tool_calls) == 1
assert tool_calls[0].name == "g"
# ── TestAdapterFinishOnStreamEnd ────────────────────────────────────
class _CombinedTestEngine(ParserEngine):
def __init__(self, tokenizer, tools=None, **kwargs):
super().__init__(
tokenizer, tools, parser_engine_config=_combined_config(), **kwargs
)
_CombinedReasoningAdapter, _CombinedToolAdapter = make_adapters(_CombinedTestEngine)
class _CombinedDelegating(DelegatingParser):
reasoning_parser_cls = _CombinedReasoningAdapter
tool_parser_cls = _CombinedToolAdapter
def _make_delegating_request():
req = MagicMock(spec=ChatCompletionRequest)
req.tools = []
req.tool_choice = "auto"
req.include_reasoning = True
return req
class TestAdapterFinishOnStreamEnd:
"""Engine adapters must flush buffered text when streaming ends.
When a DelegatingParser wraps engine adapters, the underlying
StreamingParserEngine.finish() must be called on the last
parse_delta(finished=True) so that lexer-buffered text (terminal
prefixes) and scanner-deferred terminals are not silently lost.
"""
def test_lexer_buffer_flushed_on_finished(self):
"""Text buffered as a potential terminal prefix must be emitted
as content when the stream ends."""
tokenizer = make_mock_tokenizer(_VOCAB)
parser = _CombinedDelegating(tokenizer)
request = _make_delegating_request()
# Feed reasoning then content with a trailing '<' that looks like
# the start of a terminal ('<think>' or '<tool_call>').
parser.parse_delta("</think>", [201], request, finished=False)
delta = parser.parse_delta("Hello world<", [], request, finished=True)
# The '<' must NOT be silently dropped.
assert delta is not None
assert delta.content is not None
assert "<" in delta.content, (
"Trailing '<' lost: lexer buffer was not flushed on finish"
)
def test_args_buffer_flushed_on_finished(self):
"""Pending arg buffer text must be emitted when stream ends
mid-tool-call (closing brace held back in buffer)."""
tokenizer = make_mock_tokenizer(_VOCAB)
parser = _CombinedDelegating(tokenizer)
request = _make_delegating_request()
parser.parse_delta("</think>", [201], request, finished=False)
parser.parse_delta("<tool_call>", [202], request, finished=False)
parser.parse_delta('{"name": "f"}', [], request, finished=False)
# The closing } is held back in args buffer, waiting for
# a TOOL_END terminal. Stream ends without one — finish()
# must flush the buffer.
delta = parser.parse_delta("", [], request, finished=True)
assert delta is not None, (
"Engine finish should produce a delta with flushed args/end"
)
# ── TestReasoningOnlyDelegatingParser ─────────────────────────────
class _ReasoningOnlyDelegating(DelegatingParser):
"""DelegatingParser with reasoning adapter but NO tool adapter."""
reasoning_parser_cls = _CombinedReasoningAdapter
tool_parser_cls = None
class TestReasoningOnlyEndTokenLeak:
"""When there is no tool parser, the content passthrough must not
re-emit the end-of-reasoning marker (e.g. ``</think>``) as content.
Regression test for the scenario where ``</think>`` arrives as a
single-token delta: the engine correctly consumes it (emitting
REASONING_END with no content), but the content passthrough
fired because ``delta_message is None`` and reasoning had just ended.
"""
def test_think_end_not_leaked_as_content(self):
tokenizer = make_mock_tokenizer(_VOCAB)
parser = _ReasoningOnlyDelegating(tokenizer)
request = _make_delegating_request()
# Feed reasoning text.
d1 = parser.parse_delta(
"I am thinking",
[],
request,
finished=False,
)
assert d1 is not None
assert d1.reasoning is not None
assert d1.content is None
# Feed </think> as a single-token delta.
d2 = parser.parse_delta(
"</think>",
[201],
request,
finished=False,
)
# The end-of-reasoning marker must NOT appear as content.
if d2 is not None:
assert d2.content is None, f"</think> leaked as content: {d2.content!r}"
# Feed content after reasoning.
d3 = parser.parse_delta(
"\n\nHello!",
[],
request,
finished=False,
)
assert d3 is not None
assert d3.content is not None
assert "</think>" not in d3.content
def test_streaming_content_matches_non_streaming(self):
"""Concatenated streaming content must match extract_reasoning."""
tokenizer = make_mock_tokenizer(_VOCAB)
# No <think> in input: the combined config starts in REASONING
# state, so all text before </think> is reasoning.
full_text = "reasoning</think>\n\nHello!"
# Non-streaming extraction.
parser_ns = _ReasoningOnlyDelegating(tokenizer)
request = _make_delegating_request()
reasoning, content = parser_ns.extract_reasoning(full_text, request)
assert reasoning == "reasoning"
assert content == "\n\nHello!"
# Streaming extraction — simulate per-token deltas.
parser_s = _ReasoningOnlyDelegating(tokenizer)
deltas = [
("reasoning", []),
("</think>", [201]),
("\n\n", []),
("Hello!", []),
]
content_parts: list[str] = []
for text, ids in deltas:
dm = parser_s.parse_delta(text, ids, request, finished=False)
if dm is not None and dm.content:
content_parts.append(dm.content)
dm = parser_s.parse_delta("", [], request, finished=True)
if dm is not None and dm.content:
content_parts.append(dm.content)
streaming_content = "".join(content_parts)
assert streaming_content == content, (
f"Streaming content {streaming_content!r} "
f"does not match non-streaming {content!r}"
)
def test_multi_token_delta_preserves_content_after_think_end(self):
"""Content after </think> in the same delta must not be lost."""
tokenizer = make_mock_tokenizer(_VOCAB)
parser = _ReasoningOnlyDelegating(tokenizer)
request = _make_delegating_request()
# Feed reasoning text.
d1 = parser.parse_delta(
"thinking",
[],
request,
finished=False,
)
assert d1 is not None
assert d1.reasoning is not None
# Feed </think> and content in the same delta (e.g. speculative
# decoding accepting multiple tokens at once). Token IDs must
# cover all text so the scanner can split correctly.
# chr(10)='\n', chr(72)='H', chr(105)='i', chr(33)='!'
d2 = parser.parse_delta(
"</think>\n\nHi!",
[201, 10, 10, 72, 105, 33],
request,
finished=False,
)
assert d2 is not None, "Content after </think> in multi-token delta was lost"
assert d2.content is not None, (
"Content after </think> in multi-token delta was nullified"
)
assert "</think>" not in d2.content
assert "Hi!" in d2.content
# ── TestToolAdapterForwardsKwargs ──────────────────────────────────
class TestToolAdapterForwardsKwargs:
"""ParserEngineToolAdapter.__init__ must forward **kwargs to the
parser engine class so chat_template_kwargs reach model parsers."""
@pytest.mark.parametrize(
"enable_thinking,expected_state",
[
(False, ParserState.CONTENT),
(True, ParserState.REASONING),
],
)
def test_kwargs_forwarded_to_parser_engine(self, enable_thinking, expected_state):
from vllm.parser.qwen3 import Qwen3Parser
vocab = {"<think>": 100, "</think>": 101}
tokenizer = make_mock_tokenizer(vocab)
_, ToolAdapter = make_adapters(Qwen3Parser)
adapter = ToolAdapter(
tokenizer,
tools=None,
chat_template_kwargs={"enable_thinking": enable_thinking},
)
engine = adapter._parser_engine
assert engine.parser_engine_config.initial_state == expected_state
# ── TestExtractContentIdsNoEmptyReturn ─────────────────────────────
class TestExtractContentIdsNoEmptyReturn:
"""extract_content_ids must return input_ids (not []) when there is
no THINK_END token ID and _reasoning_ended is True."""
_NO_THINK_CONFIG = ParserEngineConfig(name="no_think_end", token_id_terminals={})
@pytest.mark.parametrize("input_ids", [[1, 2, 3], []])
def test_returns_input_ids_without_think_end(self, input_ids):
engine = _make_engine(self._NO_THINK_CONFIG)
assert engine._reasoning_end_token_id is None
engine._reasoning_ended = True
assert engine.extract_content_ids(input_ids) == input_ids
# ── TestValuePostprocessorRemoved ──────────────────────────────────
class TestValuePostprocessorRemoved:
"""ParserEngineConfig no longer has a value_postprocessor field."""
def test_no_value_postprocessor_field(self):
config = ParserEngineConfig(name="test")
assert not hasattr(config, "value_postprocessor")
def test_constructor_rejects_value_postprocessor(self):
with pytest.raises(TypeError):
ParserEngineConfig(
name="test",
value_postprocessor=lambda x: x, # type: ignore[call-arg]
)
# ── TestArgDeltaWithConverter ─────────────────────────────────────
_KV_RE = re.compile(r"(\w+)=(\S+)")
def _kv_converter(raw_args: str, partial: bool) -> str:
params: dict[str, str] = {}
for m in _KV_RE.finditer(raw_args):
params[m.group(1)] = m.group(2)
return json.dumps(params, ensure_ascii=False)
def _converter_config(
converter=_kv_converter,
name: str = "converter_test",
) -> ParserEngineConfig:
return ParserEngineConfig(
name=name,
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,
},
arg_converter=converter,
stream_arg_deltas=True,
)
def _collect_arg_deltas(deltas: list) -> str:
parts: list[str] = []
for d in deltas:
if d is None:
continue
for tc in d.tool_calls or []:
if tc.function and tc.function.arguments:
parts.append(tc.function.arguments)
return "".join(parts)
def _run_streaming_tool(engine, name: str, chunks: list[str]) -> dict:
deltas = []
deltas.append(
engine._events_to_delta(
[SemanticEvent(EventType.TOOL_CALL_START, tool_index=0)]
)
)
deltas.append(
engine._events_to_delta(
[SemanticEvent(EventType.TOOL_NAME, name, tool_index=0)]
)
)
for chunk in chunks:
deltas.append(
engine._events_to_delta(
[SemanticEvent(EventType.ARG_VALUE_CHUNK, chunk, tool_index=0)]
)
)
deltas.append(
engine._events_to_delta([SemanticEvent(EventType.TOOL_CALL_END, tool_index=0)])
)
return json.loads(_collect_arg_deltas(deltas))
class TestArgDeltaWithConverter:
"""Exercise _compute_arg_delta with arg_converter + stream_arg_deltas.
The startswith guard on line 814 of parser_engine.py validates that
converted JSON grows prefix-monotonically across streaming ticks.
These tests exercise that path with a synthetic config.
"""
def test_streaming_arg_deltas_prefix_monotonic(self):
engine = _make_engine(_converter_config())
deltas = []
deltas.append(
engine._events_to_delta(
[SemanticEvent(EventType.TOOL_CALL_START, tool_index=0)],
)
)
deltas.append(
engine._events_to_delta(
[SemanticEvent(EventType.TOOL_NAME, "f", tool_index=0)],
)
)
deltas.append(
engine._events_to_delta(
[
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
"a=hello ",
tool_index=0,
)
],
)
)
deltas.append(
engine._events_to_delta(
[
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
"b=world ",
tool_index=0,
)
],
)
)
deltas.append(
engine._events_to_delta(
[
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
"c=ok",
tool_index=0,
)
],
)
)
deltas.append(
engine._events_to_delta(
[SemanticEvent(EventType.TOOL_CALL_END, tool_index=0)],
)
)
all_args = _collect_arg_deltas(deltas)
assert json.loads(all_args) == {
"a": "hello",
"b": "world",
"c": "ok",
}
def test_streaming_arg_deltas_with_type_coercion(self):
tool = _make_tool(
"f",
{
"count": {"type": "integer"},
"name": {"type": "string"},
},
)
engine = _make_engine(_converter_config(), tools=[tool])
deltas = []
deltas.append(
engine._events_to_delta(
[SemanticEvent(EventType.TOOL_CALL_START, tool_index=0)],
)
)
deltas.append(
engine._events_to_delta(
[SemanticEvent(EventType.TOOL_NAME, "f", tool_index=0)],
)
)
deltas.append(
engine._events_to_delta(
[
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
"count=5 ",
tool_index=0,
)
],
)
)
deltas.append(
engine._events_to_delta(
[
SemanticEvent(
EventType.ARG_VALUE_CHUNK,
"name=test",
tool_index=0,
)
],
)
)
deltas.append(
engine._events_to_delta(
[SemanticEvent(EventType.TOOL_CALL_END, tool_index=0)],
)
)
all_args = _collect_arg_deltas(deltas)
parsed = json.loads(all_args)
assert parsed == {"count": 5, "name": "test"}
assert isinstance(parsed["count"], int)
def test_streamable_string_keys_cached_after_name_delta(self, monkeypatch):
tool = _make_tool(
"f",
{
"name": {"type": "string"},
"count": {"type": "integer"},
},
)
engine = _make_engine(_converter_config(), tools=[tool])
original = ParserEngine._streamable_string_keys
calls: list[dict] = []
def wrapped(properties: dict) -> set[str] | None:
calls.append(properties)
return original(properties)
monkeypatch.setattr(
ParserEngine,
"_streamable_string_keys",
staticmethod(wrapped),
)
_run_streaming_tool(
engine,
"f",
["name=alice ", "count=4", "2"],
)
assert calls == [
{
"name": {"type": "string"},
"count": {"type": "integer"},
}
]
# ── TestSafeArgPrefix ────────────────────────────────────────────
class TestSafeArgPrefix:
"""Unit tests for ParserEngine._safe_arg_prefix."""
@pytest.mark.parametrize(
"json_str, expected",
[
('{"a": 1}', '{"a": '),
('{"a": 1, "b": 2}', '{"a": 1, "b": '),
('{"a": "hello", "b": "world"}', '{"a": "hello", "b": "world'),
('{"obj": {"x": 1}, "b": 2}', '{"obj": {"x": 1}, "b": '),
('{"url": "http://x:80", "b": 1}', '{"url": "http://x:80", "b": '),
('{"a": 1', '{"a": '),
("{}", ""),
("{", ""),
("", ""),
('{"k":1}', '{"k":'),
('{"k": 1, "v":2}', '{"k": 1, "v":'),
('{"k":"value"}', '{"k":"value'),
('{"k":"unterminated', '{"k":"unterminated'),
(r'{"k":"escaped \" quote"}', r'{"k":"escaped \" quote'),
],
)
def test_safe_arg_prefix(self, json_str, expected):
assert ParserEngine._safe_arg_prefix(json_str) == expected
# ── Coercion instability regression tests ────────────────────────
def _growing_kv_converter(raw_args: str, partial: bool) -> str:
"""Converter that produces growing bare values (no delimiter)."""
params: dict[str, str] = {}
for part in raw_args.split(" "):
if "=" in part:
k, v = part.split("=", 1)
params[k] = v
return json.dumps(params, ensure_ascii=False)
class TestCoercionInstabilityRegression:
"""Regression tests for _fix_arg_types coercion instability.
These tests exercise scenarios where a trailing value's coercion
status changes between ticks (e.g. "4" coerces to int but "4e"
does not). Before the _safe_arg_prefix fix, these would violate
the startswith prefix invariant and permanently drop deltas.
"""
def test_coercion_flip_does_not_corrupt_stream(self):
tool = _make_tool(
"f",
{
"count": {"type": "integer"},
"flag": {"type": "string"},
},
)
engine = _make_engine(_converter_config(), tools=[tool])
parsed = _run_streaming_tool(
engine,
"f",
["count=42 ", "flag=ok"],
)
assert parsed == {"count": 42, "flag": "ok"}
assert isinstance(parsed["count"], int)
def test_bool_partial_value_coercion_is_safe(self):
"""Boolean value building char by char must not break prefix."""
tool = _make_tool(
"f",
{
"name": {"type": "string"},
"flag": {"type": "boolean"},
},
)
cfg = _converter_config(_growing_kv_converter)
engine = _make_engine(cfg, tools=[tool])
parsed = _run_streaming_tool(
engine,
"f",
["name=hello ", "flag=t", "r", "u", "e"],
)
assert parsed == {"name": "hello", "flag": True}
assert isinstance(parsed["flag"], bool)
def test_int_partial_value_flip_is_safe(self):
"""Integer that becomes non-coercible must not break prefix.
A dummy first arg is needed so the name emission consumes the
first ARG_VALUE_CHUNK, ensuring _compute_arg_delta runs for the
chunk where val="4" coerces to int 4. On the next chunk val
grows to "4e" which is NOT a valid int, flipping the coercion.
"""
tool = _make_tool(
"f",
{
"dummy": {"type": "string"},
"val": {"type": "integer"},
"extra": {"type": "string"},
},
)
cfg = _converter_config(_growing_kv_converter)
engine = _make_engine(cfg, tools=[tool])
parsed = _run_streaming_tool(
engine,
"f",
["dummy=x ", "val=4", "e ", "extra=ok"],
)
assert parsed["dummy"] == "x"
assert parsed["val"] == "4e"
assert isinstance(parsed["val"], str)
assert parsed["extra"] == "ok"
_DROP_VOCAB: dict[str, int] = {
**_VOCAB,
"<bos>": 204,
"<eos>": 205,
}
class TestDropSpecialTokens:
"""Special token dropping via the __DROP__ terminal mechanism."""
def test_drops_special_token_by_id_from_content(self):
"""A special token (not a configured terminal) is dropped when
it arrives as its actual token ID."""
config = ParserEngineConfig(
name="drop_content_test",
terminals={},
token_id_terminals={},
transitions={},
initial_state=ParserState.CONTENT,
content_events={ParserState.CONTENT: EventType.TEXT_CHUNK},
)
engine = _make_engine(
config=config,
vocab=_DROP_VOCAB,
special_tokens=list(_DROP_VOCAB.keys()),
)
engine._engine.reset()
events = engine._engine.feed("hello<bos>world", [72, 204, 73])
delta = engine._events_to_delta(events)
assert delta is not None
assert "<bos>" not in delta.content
assert delta.content == "helloworld"
def test_drops_special_token_by_id_from_reasoning(self):
engine = _make_engine(
vocab=_DROP_VOCAB,
special_tokens=list(_DROP_VOCAB.keys()),
)
engine._engine.reset()
events = engine._engine.feed("thinking<eos>more", [72, 205, 73])
delta = engine._events_to_delta(events)
assert delta is not None
assert "<eos>" not in delta.reasoning
assert delta.reasoning == "thinkingmore"
def test_drops_via_text_fallback_when_no_token_ids(self):
"""When no token IDs are provided, text-based lexer catches
drop tokens as a fallback."""
engine = _make_engine(
vocab=_DROP_VOCAB,
special_tokens=list(_DROP_VOCAB.keys()),
)
engine._engine.reset()
events = engine._engine.feed("hello<bos>world", [])
delta = engine._events_to_delta(events)
assert delta is not None
assert "<bos>" not in delta.reasoning
def test_regular_tokens_spelling_special_survive(self):
"""Regular tokens that spell out a drop-token string survive
when token IDs prove they are not the special token."""
vocab = {**_DROP_VOCAB, "h": 72, "<": 73, "bos": 74, ">": 75, "w": 76}
engine = _make_engine(
vocab=vocab,
special_tokens=list(_DROP_VOCAB.keys()),
)
engine._engine.reset()
# Feed regular token IDs (73, 74, 75) that spell <bos>,
# not the special token ID 204.
events = engine._engine.feed("h<bos>w", [72, 73, 74, 75, 76])
delta = engine._events_to_delta(events)
assert delta is not None
assert "<bos>" in delta.reasoning
def test_configured_terminal_not_treated_as_drop(self):
"""Tokens already in config.terminals (like <think>) are handled
by the state machine, not the drop mechanism."""
engine = _make_engine(
vocab=_DROP_VOCAB,
special_tokens=list(_DROP_VOCAB.keys()),
)
# Start from CONTENT so <think> triggers REASONING_START
engine._engine.reset(initial_state=ParserState.CONTENT)
events = engine._engine.feed("<think>", [200])
has_reasoning_start = any(e.type == EventType.REASONING_START for e in events)
assert has_reasoning_start
def test_tool_name_not_affected_by_drops(self):
engine = _make_engine(
vocab=_DROP_VOCAB,
special_tokens=list(_DROP_VOCAB.keys()),
)
engine._engine.reset()
events = [
SemanticEvent(EventType.TOOL_CALL_START, tool_index=0),
SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0),
SemanticEvent(EventType.ARG_VALUE_CHUNK, '{"city": "NYC"}', tool_index=0),
SemanticEvent(EventType.TOOL_CALL_END, tool_index=0),
]
delta = engine._events_to_delta(events)
assert delta is not None
assert delta.tool_calls is not None
names = [
tc.function.name
for tc in delta.tool_calls
if tc.function and tc.function.name
]
assert "get_weather" in names
def test_adjacent_drop_tokens_by_id(self):
engine = _make_engine(
vocab=_DROP_VOCAB,
special_tokens=list(_DROP_VOCAB.keys()),
)
engine._engine.reset()
events = engine._engine.feed("<bos><eos>", [204, 205])
delta = engine._events_to_delta(events)
assert delta is None
def test_no_special_tokens_means_no_drops(self):
"""Engine with no special tokens passes all text through."""
engine = _make_engine(special_tokens=[])
engine._engine.reset()
events = engine._engine.feed("hello<bos>world", [])
delta = engine._events_to_delta(events)
assert delta is not None
assert "<bos>" in delta.reasoning
def test_drops_suppressed_with_skip_tool_parsing(self):
"""When skip_tool_parsing is active, drop tokens are preserved
as content so a later tool-call pass can see them."""
engine = _make_engine(
vocab=_DROP_VOCAB,
special_tokens=list(_DROP_VOCAB.keys()),
)
engine._engine.skip_tool_parsing = True
engine._engine.reset()
events = engine._engine.feed("hello<bos>world", [72, 204, 73])
delta = engine._events_to_delta(events)
assert delta is not None
assert "<bos>" in delta.reasoning
def test_drops_in_tool_args_state(self):
"""Drop tokens in TOOL_ARGS state are silently discarded."""
vocab = {**_DROP_VOCAB, '{"a":1}': 300}
engine = _make_engine(
vocab=vocab,
special_tokens=list(_DROP_VOCAB.keys()),
)
engine._engine.reset(initial_state=ParserState.CONTENT)
events = engine._engine.feed(
'<tool_call>{"a":1}<bos></tool_call>',
[202, 300, 204, 203],
)
arg_chunks = [e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK]
combined = "".join(arg_chunks)
assert "<bos>" not in combined
assert '{"a":1}' in combined
def test_mixed_configured_and_drop_terminals(self):
"""Configured terminals trigger transitions while drop terminals
are silently removed in the same stream."""
engine = _make_engine(
vocab=_DROP_VOCAB,
special_tokens=list(_DROP_VOCAB.keys()),
)
engine._engine.reset()
events = engine._engine.feed("thought<bos></think>answer", [72, 204, 201, 73])
types = [e.type for e in events]
assert EventType.REASONING_CHUNK in types
assert EventType.REASONING_END in types
assert EventType.TEXT_CHUNK in types
reasoning_text = "".join(
e.value for e in events if e.type == EventType.REASONING_CHUNK
)
assert "<bos>" not in reasoning_text