Skip to content

Commit 90bbe4f

Browse files
LuYanFCPmgoin
authored andcommitted
[Model] Support SeedOss Reason Parser (vllm-project#24263)
Signed-off-by: Yan Lu <luyan@nvidia.com> Co-authored-by: Michael Goin <mgoin64@gmail.com>
1 parent b7f85d8 commit 90bbe4f

9 files changed

+887
-246
lines changed
Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
4+
import pytest
5+
from transformers import AutoTokenizer
6+
7+
from tests.reasoning.utils import run_reasoning_extraction
8+
from vllm.entrypoints.openai.protocol import ChatCompletionRequest
9+
from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
10+
11+
12+
# Create a concrete test implementation of BaseThinkingReasoningParser
13+
class TestThinkingReasoningParser(BaseThinkingReasoningParser):
14+
"""Test implementation of BaseThinkingReasoningParser."""
15+
16+
@property
17+
def start_token(self) -> str:
18+
return "<test:think>"
19+
20+
@property
21+
def end_token(self) -> str:
22+
return "</test:think>"
23+
24+
25+
class TestThinkingReasoningParserAlt(BaseThinkingReasoningParser):
26+
"""Alternative test implementation with different tokens."""
27+
28+
@property
29+
def start_token(self) -> str:
30+
return "<alt:start>"
31+
32+
@property
33+
def end_token(self) -> str:
34+
return "<alt:end>"
35+
36+
37+
# Use a test model
38+
REASONING_MODEL_NAME = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B"
39+
40+
41+
@pytest.fixture(scope="module")
42+
def test_tokenizer():
43+
tokenizer = AutoTokenizer.from_pretrained(REASONING_MODEL_NAME)
44+
# Add custom test tokens
45+
test_tokens = ["<test:think>", "</test:think>", "<alt:start>", "<alt:end>"]
46+
existing_tokens = set(tokenizer.get_vocab().keys())
47+
new_tokens = [
48+
token for token in test_tokens if token not in existing_tokens
49+
]
50+
if new_tokens:
51+
tokenizer.add_tokens(new_tokens)
52+
return tokenizer
53+
54+
55+
class TestBaseThinkingReasoningParserInit:
56+
"""
57+
Test initialization and basic properties of
58+
BaseThinkingReasoningParser.
59+
"""
60+
61+
def test_successful_initialization(self, test_tokenizer):
62+
"""Test successful initialization with valid tokens."""
63+
parser = TestThinkingReasoningParser(test_tokenizer)
64+
assert parser.start_token == "<test:think>"
65+
assert parser.end_token == "</test:think>"
66+
assert parser.start_token_id is not None
67+
assert parser.end_token_id is not None
68+
69+
def test_initialization_with_missing_tokenizer(self):
70+
"""Test that initialization fails without tokenizer."""
71+
with pytest.raises(ValueError, match="model tokenizer must be passed"):
72+
TestThinkingReasoningParser(None)
73+
74+
def test_initialization_with_missing_tokens(self, test_tokenizer):
75+
"""Test that initialization fails when tokens are not in vocabulary."""
76+
77+
# Create a parser with tokens not in vocabulary
78+
class MissingTokenParser(BaseThinkingReasoningParser):
79+
80+
@property
81+
def start_token(self) -> str:
82+
return "<missing:start>"
83+
84+
@property
85+
def end_token(self) -> str:
86+
return "<missing:end>"
87+
88+
with pytest.raises(RuntimeError,
89+
match="could not locate think start/end tokens"):
90+
MissingTokenParser(test_tokenizer)
91+
92+
def test_initialization_with_empty_tokens(self, test_tokenizer):
93+
"""Test that initialization fails with empty token strings."""
94+
95+
class EmptyTokenParser(BaseThinkingReasoningParser):
96+
97+
@property
98+
def start_token(self) -> str:
99+
return ""
100+
101+
@property
102+
def end_token(self) -> str:
103+
return ""
104+
105+
with pytest.raises(ValueError,
106+
match="start_token and end_token must be defined"):
107+
EmptyTokenParser(test_tokenizer)
108+
109+
110+
class TestBaseThinkingReasoningParserMethods:
111+
"""Test the methods of BaseThinkingReasoningParser."""
112+
113+
def test_is_reasoning_end(self, test_tokenizer):
114+
"""Test the is_reasoning_end method."""
115+
parser = TestThinkingReasoningParser(test_tokenizer)
116+
end_token_id = parser.end_token_id
117+
118+
# Test with end token present
119+
assert parser.is_reasoning_end([1, 2, end_token_id, 4]) is True
120+
121+
# Test without end token
122+
assert parser.is_reasoning_end([1, 2, 3, 4]) is False
123+
124+
# Test with empty list
125+
assert parser.is_reasoning_end([]) is False
126+
127+
def test_extract_content_ids(self, test_tokenizer):
128+
"""Test the extract_content_ids method."""
129+
parser = TestThinkingReasoningParser(test_tokenizer)
130+
end_token_id = parser.end_token_id
131+
132+
# Test with end token in the middle
133+
input_ids = [1, 2, end_token_id, 4, 5]
134+
content_ids = parser.extract_content_ids(input_ids)
135+
assert content_ids == [4, 5]
136+
137+
# Test with end token at the end
138+
input_ids = [1, 2, 3, end_token_id]
139+
content_ids = parser.extract_content_ids(input_ids)
140+
assert content_ids == []
141+
142+
# Test without end token
143+
input_ids = [1, 2, 3, 4]
144+
content_ids = parser.extract_content_ids(input_ids)
145+
assert content_ids == []
146+
147+
# Test with end token as last element (should not extract)
148+
input_ids = [1, 2, 3, end_token_id]
149+
content_ids = parser.extract_content_ids(input_ids)
150+
assert content_ids == []
151+
152+
153+
class TestBaseThinkingReasoningParserExtraction:
154+
"""Test reasoning content extraction methods."""
155+
156+
def test_extract_reasoning_content_with_both_tokens(self, test_tokenizer):
157+
"""Test extraction when both start and end tokens are present."""
158+
parser = TestThinkingReasoningParser(test_tokenizer)
159+
request = ChatCompletionRequest(messages=[], model="test-model")
160+
161+
model_output = ("<test:think>This is reasoning"
162+
"</test:think>This is content")
163+
reasoning, content = parser.extract_reasoning_content(
164+
model_output, request)
165+
166+
assert reasoning == "This is reasoning"
167+
assert content == "This is content"
168+
169+
def test_extract_reasoning_content_only_end_token(self, test_tokenizer):
170+
"""Test extraction when only end token is present."""
171+
parser = TestThinkingReasoningParser(test_tokenizer)
172+
request = ChatCompletionRequest(messages=[], model="test-model")
173+
174+
model_output = ("This is reasoning</test:think>This is content")
175+
reasoning, content = parser.extract_reasoning_content(
176+
model_output, request)
177+
178+
assert reasoning == "This is reasoning"
179+
assert content == "This is content"
180+
181+
def test_extract_reasoning_content_no_end_token(self, test_tokenizer):
182+
"""Test extraction when no end token is present."""
183+
parser = TestThinkingReasoningParser(test_tokenizer)
184+
request = ChatCompletionRequest(messages=[], model="test-model")
185+
186+
model_output = "This is just content"
187+
reasoning, content = parser.extract_reasoning_content(
188+
model_output, request)
189+
190+
assert reasoning == "This is just content"
191+
assert content is None
192+
193+
def test_extract_reasoning_content_empty_output(self, test_tokenizer):
194+
"""Test extraction with empty output."""
195+
parser = TestThinkingReasoningParser(test_tokenizer)
196+
request = ChatCompletionRequest(messages=[], model="test-model")
197+
198+
model_output = ""
199+
reasoning, content = parser.extract_reasoning_content(
200+
model_output, request)
201+
202+
assert reasoning == ""
203+
assert content is None
204+
205+
def test_extract_reasoning_content_only_tokens(self, test_tokenizer):
206+
"""Test extraction with only tokens and no content."""
207+
parser = TestThinkingReasoningParser(test_tokenizer)
208+
request = ChatCompletionRequest(messages=[], model="test-model")
209+
210+
model_output = ("<test:think></test:think>")
211+
reasoning, content = parser.extract_reasoning_content(
212+
model_output, request)
213+
214+
assert reasoning == ""
215+
assert content is None
216+
217+
218+
class TestBaseThinkingReasoningParserStreaming:
219+
"""Test streaming functionality of BaseThinkingReasoningParser."""
220+
221+
@pytest.mark.parametrize("streaming", [True, False])
222+
def test_simple_reasoning_extraction(self, test_tokenizer, streaming):
223+
"""
224+
Test basic reasoning extraction in both
225+
streaming and non-streaming modes.
226+
"""
227+
parser = TestThinkingReasoningParser(test_tokenizer)
228+
229+
model_output = [
230+
"<test:think>", "Some ", "reasoning ", "content", "</test:think>",
231+
"Final ", "answer"
232+
]
233+
234+
reasoning, content = run_reasoning_extraction(parser,
235+
model_output,
236+
streaming=streaming)
237+
238+
assert reasoning == "Some reasoning content"
239+
assert content == "Final answer"
240+
241+
def test_streaming_with_incremental_deltas(self, test_tokenizer):
242+
"""Test streaming processing with small incremental deltas."""
243+
parser = TestThinkingReasoningParser(test_tokenizer)
244+
245+
deltas = [
246+
"<test:think>",
247+
"Some ",
248+
"reasoning ",
249+
"content",
250+
"</test:think>",
251+
"Final ",
252+
"answer",
253+
]
254+
255+
reasoning, content = run_reasoning_extraction(parser,
256+
deltas,
257+
streaming=True)
258+
259+
assert reasoning == "Some reasoning content"
260+
assert content == "Final answer"
261+
262+
def test_streaming_with_start_token(self, test_tokenizer):
263+
"""Test streaming with start token included."""
264+
parser = TestThinkingReasoningParser(test_tokenizer)
265+
266+
deltas = [
267+
"<test:think>",
268+
"Some ",
269+
"reasoning",
270+
"</test:think>",
271+
"Answer",
272+
]
273+
274+
reasoning, content = run_reasoning_extraction(parser,
275+
deltas,
276+
streaming=True)
277+
278+
assert reasoning == "Some reasoning"
279+
assert content == "Answer"
280+
281+
def test_streaming_no_end_token(self, test_tokenizer):
282+
"""Test streaming when no end token is encountered."""
283+
parser = TestThinkingReasoningParser(test_tokenizer)
284+
285+
deltas = [
286+
"<test:think>",
287+
"Some ",
288+
"reasoning ",
289+
"without ",
290+
"end",
291+
]
292+
293+
reasoning, content = run_reasoning_extraction(parser,
294+
deltas,
295+
streaming=True)
296+
297+
assert reasoning == "Some reasoning without end"
298+
assert content is None
299+
300+
def test_streaming_only_end_token(self, test_tokenizer):
301+
"""Test streaming when only end token appears."""
302+
parser = TestThinkingReasoningParser(test_tokenizer)
303+
304+
deltas = [
305+
"<test:think>",
306+
"Reasoning ",
307+
"content",
308+
"</test:think>",
309+
"Final",
310+
]
311+
312+
reasoning, content = run_reasoning_extraction(parser,
313+
deltas,
314+
streaming=True)
315+
316+
assert reasoning == "Reasoning content"
317+
assert content == "Final"
318+
319+
320+
class TestBaseThinkingReasoningParserMultipleImplementations:
321+
"""
322+
Test that multiple implementations of
323+
BaseThinkingReasoningParser work correctly.
324+
"""
325+
326+
def test_different_token_implementations(self, test_tokenizer):
327+
"""
328+
Test that different implementations
329+
with different tokens work independently.
330+
"""
331+
parser1 = TestThinkingReasoningParser(test_tokenizer)
332+
parser2 = TestThinkingReasoningParserAlt(test_tokenizer)
333+
334+
# Test parser1
335+
model_output1 = ("Reasoning1</test:think>Content1")
336+
reasoning1, content1 = run_reasoning_extraction(
337+
parser1, [model_output1])
338+
assert reasoning1 == "Reasoning1"
339+
assert content1 == "Content1"
340+
341+
# Test parser2
342+
model_output2 = "Reasoning2<alt:end>Content2"
343+
reasoning2, content2 = run_reasoning_extraction(
344+
parser2, [model_output2])
345+
assert reasoning2 == "Reasoning2"
346+
assert content2 == "Content2"
347+
348+
# Verify tokens are different
349+
assert parser1.start_token != parser2.start_token
350+
assert parser1.end_token != parser2.end_token
351+
assert parser1.start_token_id != parser2.start_token_id
352+
assert parser1.end_token_id != parser2.end_token_id
353+
354+
355+
class TestBaseThinkingReasoningParserEdgeCases:
356+
"""Test edge cases and error conditions."""
357+
358+
def test_multiple_end_tokens(self, test_tokenizer):
359+
"""Test behavior with multiple end tokens."""
360+
parser = TestThinkingReasoningParser(test_tokenizer)
361+
362+
model_output = ("First</test:think>Middle</test:think>Last")
363+
reasoning, content = run_reasoning_extraction(parser, [model_output])
364+
365+
# Should stop at first end token
366+
assert reasoning == "First"
367+
assert content == "Middle</test:think>Last"
368+
369+
def test_nested_tokens(self, test_tokenizer):
370+
"""Test behavior with nested-like token patterns."""
371+
parser = TestThinkingReasoningParser(test_tokenizer)
372+
373+
model_output = ("<test:think>Outer"
374+
"<test:think>Inner</test:think>Content")
375+
reasoning, content = run_reasoning_extraction(parser, [model_output])
376+
377+
# Should process normally, start from first start token
378+
assert reasoning == "Outer<test:think>Inner"
379+
assert content == "Content"
380+
381+
def test_malformed_tokens(self, test_tokenizer):
382+
"""Test behavior with malformed token-like strings."""
383+
parser = TestThinkingReasoningParser(test_tokenizer)
384+
385+
model_output = ("<test:thinking>Not a real token"
386+
"</test:thinking>Content")
387+
reasoning, content = run_reasoning_extraction(parser, [model_output])
388+
389+
# Should treat as regular content since tokens don't match exactly
390+
assert reasoning == ("<test:thinking>Not a real token"
391+
"</test:thinking>Content")
392+
assert content is None

0 commit comments

Comments
 (0)