main
py 235 lines 8.09 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for the engine-based seed_oss parser.
4
5 seed_oss is Qwen3 with four overridden wrapper tokens, so the shared grammar
6 (arg types, multiline values, parallel calls, streaming mechanics, …) is
7 already covered by ``test_qwen3.py``/``test_qwen3_reasoning.py``. These tests
8 cover only what is seed_oss-specific: that the ``seed:`` token overrides are
9 wired through, the reasoning→tool boundary holds with them, the malformed
10 header from #46314 no longer drops sibling calls, and the registered adapters
11 resolve. Seed-specific budget-reflect tags inside reasoning are also covered
12 here because the old dedicated parser tests exercised them.
13 """
14
15 import json
16
17 import pytest
18
19 from tests.parser.engine.conftest import make_mock_tokenizer
20 from tests.parser.engine.streaming_helpers import (
21 collect_function_name,
22 collect_tool_arguments,
23 simulate_reasoning_streaming,
24 simulate_tool_streaming,
25 )
26 from vllm.parser.engine.registered_adapters import (
27 SeedOssParserReasoningAdapter,
28 SeedOssParserToolAdapter,
29 )
30 from vllm.parser.seed_oss import SeedOssParser
31
32 TOOL_CALL_START = "<seed:tool_call>"
33 TOOL_CALL_END = "</seed:tool_call>"
34 THINK_START = "<seed:think>"
35 THINK_END = "</seed:think>"
36 TURN_START = "<seed:bos>"
37 TURN_END = "<seed:eos>"
38
39 _THINK_START_ID = 50
40 _THINK_END_ID = 51
41 _TOOL_CALL_START_ID = 60
42 _TOOL_CALL_END_ID = 61
43 _SEED_BOS_ID = 70
44 _SEED_EOS_ID = 71
45 _TEXT_ID = 100
46
47 _SEED_OSS_VOCAB = {
48 THINK_START: _THINK_START_ID,
49 THINK_END: _THINK_END_ID,
50 TOOL_CALL_START: _TOOL_CALL_START_ID,
51 TOOL_CALL_END: _TOOL_CALL_END_ID,
52 TURN_START: _SEED_BOS_ID,
53 TURN_END: _SEED_EOS_ID,
54 }
55
56
57 @pytest.fixture
58 def mock_tokenizer():
59 return make_mock_tokenizer(_SEED_OSS_VOCAB)
60
61
62 @pytest.fixture
63 def tool_parser(mock_tokenizer):
64 return SeedOssParser(
65 mock_tokenizer, chat_template_kwargs={"enable_thinking": False}
66 )
67
68
69 @pytest.fixture
70 def parser(mock_tokenizer):
71 return SeedOssParser(mock_tokenizer)
72
73
74 def test_token_overrides_wired(parser):
75 assert parser.parser_engine_config.name == "seed_oss"
76 assert parser.reasoning_start_str == THINK_START
77 assert parser.reasoning_end_str == THINK_END
78 assert parser.parser_engine_config.turn_boundary_tokens == frozenset(
79 (TURN_START, TURN_END)
80 )
81
82
83 class TestSeedTurnBoundaries:
84 """The Seed-OSS chat template wraps every message in ``<seed:bos>role`` /
85 ``<seed:eos>`` and replays prior-turn ``reasoning_content`` inside
86 ``<seed:think>...</seed:think>``, so a stale ``</seed:think>`` in history
87 is the normal multi-turn case. The backward walk must stop at the Seed
88 boundary tokens; the inherited ChatML tokens are absent from the Seed
89 vocab and would disable the scoping entirely.
90 """
91
92 def test_replayed_reasoning_in_history_not_end(self, parser):
93 assert not parser.is_reasoning_end(
94 [
95 _SEED_BOS_ID,
96 _THINK_START_ID,
97 _TEXT_ID,
98 _THINK_END_ID,
99 _TEXT_ID,
100 _SEED_EOS_ID,
101 _SEED_BOS_ID,
102 ]
103 )
104
105 def test_think_end_in_current_turn_is_end(self, parser):
106 assert parser.is_reasoning_end(
107 [_SEED_BOS_ID, _TEXT_ID, _SEED_EOS_ID, _SEED_BOS_ID, _THINK_END_ID]
108 )
109
110 def test_boundary_with_thinking_disabled_is_end(self, mock_tokenizer):
111 parser = SeedOssParser(
112 mock_tokenizer, chat_template_kwargs={"enable_thinking": False}
113 )
114 assert parser.is_reasoning_end([_SEED_BOS_ID, _TEXT_ID])
115
116
117 def test_single_tool_call(tool_parser, mock_request):
118 text = (
119 f"{TOOL_CALL_START}\n<function=get_weather>\n"
120 "<parameter=city>Tokyo</parameter>\n"
121 f"</function>\n{TOOL_CALL_END}"
122 )
123 result = tool_parser.extract_tool_calls(text, mock_request)
124
125 assert result.tools_called is True
126 assert result.tool_calls[0].function.name == "get_weather"
127 assert json.loads(result.tool_calls[0].function.arguments) == {"city": "Tokyo"}
128
129
130 def test_malformed_function_end_does_not_drop_siblings(tool_parser, mock_request):
131 """Regression for #46314: a malformed ``</function>`` with no closing ``>``
132 on the header must not discard the other, well-formed calls."""
133 text = (
134 f"{TOOL_CALL_START}\n<function=broken</function>\n{TOOL_CALL_END}"
135 f"{TOOL_CALL_START}\n<function=get_weather>\n"
136 "<parameter=city>Tokyo</parameter>\n"
137 f"</function>\n{TOOL_CALL_END}"
138 )
139 result = tool_parser.extract_tool_calls(text, mock_request)
140
141 weather = next(tc for tc in result.tool_calls if tc.function.name == "get_weather")
142 assert json.loads(weather.function.arguments) == {"city": "Tokyo"}
143
144
145 def test_basic_streaming(tool_parser, mock_request):
146 chunks = [
147 f"{TOOL_CALL_START}\n",
148 "<function=get_weather>\n",
149 "<parameter=city>Tokyo",
150 "</parameter>\n",
151 "</function>\n",
152 f"{TOOL_CALL_END}",
153 ]
154 results = simulate_tool_streaming(tool_parser, mock_request, chunks)
155
156 assert collect_function_name(results) == "get_weather"
157 assert json.loads(collect_tool_arguments(results)) == {"city": "Tokyo"}
158
159
160 def test_reasoning_then_tool_call(parser):
161 text = (
162 f"{THINK_START}I need to read the file.{THINK_END}"
163 f"{TOOL_CALL_START}\n<function=read>\n"
164 "<parameter=path>/tmp/x</parameter>\n"
165 f"</function>\n{TOOL_CALL_END}"
166 )
167 reasoning, _ = parser.extract_reasoning(text, None)
168 assert reasoning == "I need to read the file."
169 assert TOOL_CALL_START not in reasoning
170
171
172 def test_streaming_think_end_and_tool_call_same_delta(parser):
173 """``</seed:think>`` and ``<seed:tool_call>`` arriving in one delta must
174 not leak the terminal tokens into the reasoning text."""
175 reasoning, content = simulate_reasoning_streaming(
176 parser,
177 [
178 "Let me list the directory.",
179 f"{THINK_END}{TOOL_CALL_START}",
180 "<function=read>",
181 ],
182 [(1,), (_THINK_END_ID, _TOOL_CALL_START_ID), (2,)],
183 )
184 assert reasoning == "Let me list the directory."
185 assert THINK_END not in reasoning
186 assert TOOL_CALL_START not in reasoning
187 assert content is not None
188
189
190 def test_end_to_end_through_registered_adapters(mock_tokenizer, mock_request):
191 reasoning_parser = SeedOssParserReasoningAdapter(mock_tokenizer)
192 tool_parser = SeedOssParserToolAdapter(mock_tokenizer)
193 text = (
194 f"{THINK_START}Plan the call.{THINK_END}"
195 f"{TOOL_CALL_START}\n<function=get_weather>\n"
196 "<parameter=city>Tokyo</parameter>\n"
197 f"</function>\n{TOOL_CALL_END}"
198 )
199 reasoning, remaining = reasoning_parser.extract_reasoning(text, mock_request)
200 assert reasoning == "Plan the call."
201
202 tool_result = tool_parser.extract_tool_calls(remaining, mock_request)
203 assert tool_result.tool_calls[0].function.name == "get_weather"
204 assert json.loads(tool_result.tool_calls[0].function.arguments) == {"city": "Tokyo"}
205
206
207 def test_budget_reflect_tags_do_not_break_adapter_pipeline(
208 mock_tokenizer,
209 mock_request,
210 ):
211 reasoning_parser = SeedOssParserReasoningAdapter(mock_tokenizer)
212 tool_parser = SeedOssParserToolAdapter(mock_tokenizer)
213 text = (
214 f"{THINK_START}"
215 "The user's current thinking budget is 512.</seed:cot_budget_reflect>\n"
216 "I need the weather.\n"
217 "<seed:cot_budget_reflect>I have used 131 tokens."
218 "</seed:cot_budget_reflect>\n"
219 f"{THINK_END}"
220 f"{TOOL_CALL_START}\n<function=get_weather>\n"
221 "<parameter=city>Barcelona</parameter>\n"
222 f"</function>\n{TOOL_CALL_END}"
223 )
224
225 reasoning, remaining = reasoning_parser.extract_reasoning(text, mock_request)
226 assert reasoning is not None
227 assert "current thinking budget is 512" in reasoning
228 assert "<seed:cot_budget_reflect>" in reasoning
229 assert "</seed:cot_budget_reflect>" in reasoning
230
231 tool_result = tool_parser.extract_tool_calls(remaining, mock_request)
232 assert tool_result.tool_calls[0].function.name == "get_weather"
233 assert json.loads(tool_result.tool_calls[0].function.arguments) == {
234 "city": "Barcelona"
235 }