[Test] Add unit tests for srt/parser (#20947)

This commit is contained in:
Zijun Gao
2026-03-24 00:26:46 +08:00
committed by GitHub
parent 777edb6ef7
commit 4dbe42527e
5 changed files with 2123 additions and 12 deletions
@@ -0,0 +1,188 @@
"""Unit tests for srt/parser/code_completion_parser.py"""
import unittest
from unittest.mock import patch
from sglang.srt.entrypoints.openai.protocol import CompletionRequest
from sglang.srt.parser.code_completion_parser import (
CompletionTemplate,
FimPosition,
completion_template_exists,
completion_templates,
generate_completion_prompt,
generate_completion_prompt_from_request,
is_completion_template_defined,
register_completion_template,
set_completion_template,
)
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=5, suite="stage-a-cpu-only")
class TestFimPosition(CustomTestCase):
def test_middle_and_end_are_distinct(self):
"""Test that MIDDLE and END are different enum values."""
self.assertNotEqual(FimPosition.MIDDLE, FimPosition.END)
class TestCompletionTemplate(CustomTestCase):
def test_dataclass_fields(self):
"""Test creating a CompletionTemplate with all fields."""
t = CompletionTemplate(
name="test",
fim_begin_token="<begin>",
fim_middle_token="<middle>",
fim_end_token="<end>",
fim_position=FimPosition.MIDDLE,
)
self.assertEqual(t.name, "test")
self.assertEqual(t.fim_begin_token, "<begin>")
self.assertEqual(t.fim_position, FimPosition.MIDDLE)
class TestRegisterCompletionTemplate(CustomTestCase):
def test_builtin_templates_registered(self):
"""Test that deepseek_coder, star_coder, qwen_coder are pre-registered."""
self.assertTrue(completion_template_exists("deepseek_coder"))
self.assertTrue(completion_template_exists("star_coder"))
self.assertTrue(completion_template_exists("qwen_coder"))
def test_unregistered_template_not_found(self):
"""Test that a non-existent template returns False."""
self.assertFalse(completion_template_exists("nonexistent_template"))
def test_register_new_template(self):
"""Test registering a new template."""
t = CompletionTemplate(
name="_test_new_template",
fim_begin_token="<b>",
fim_middle_token="<m>",
fim_end_token="<e>",
fim_position=FimPosition.END,
)
register_completion_template(t)
self.assertTrue(completion_template_exists("_test_new_template"))
# Cleanup
del completion_templates["_test_new_template"]
def test_register_duplicate_raises(self):
"""Test that registering a duplicate name without override raises."""
with self.assertRaises(AssertionError):
register_completion_template(
CompletionTemplate(
name="deepseek_coder",
fim_begin_token="x",
fim_middle_token="y",
fim_end_token="z",
fim_position=FimPosition.MIDDLE,
)
)
def test_register_duplicate_with_override(self):
"""Test that override=True allows re-registration."""
original = completion_templates["deepseek_coder"]
try:
register_completion_template(
CompletionTemplate(
name="deepseek_coder",
fim_begin_token="<new>",
fim_middle_token="<new_m>",
fim_end_token="<new_e>",
fim_position=FimPosition.END,
),
override=True,
)
self.assertEqual(
completion_templates["deepseek_coder"].fim_begin_token, "<new>"
)
finally:
# Restore original
completion_templates["deepseek_coder"] = original
class TestGenerateCompletionPrompt(CustomTestCase):
def test_deepseek_coder_middle_position(self):
"""Test FIM prompt with MIDDLE position (deepseek_coder style)."""
result = generate_completion_prompt(
"prefix_code", "suffix_code", "deepseek_coder"
)
t = completion_templates["deepseek_coder"]
expected = f"{t.fim_begin_token}prefix_code{t.fim_middle_token}suffix_code{t.fim_end_token}"
self.assertEqual(result, expected)
def test_star_coder_end_position(self):
"""Test FIM prompt with END position (star_coder style)."""
result = generate_completion_prompt("prefix_code", "suffix_code", "star_coder")
t = completion_templates["star_coder"]
expected = f"{t.fim_begin_token}prefix_code{t.fim_end_token}suffix_code{t.fim_middle_token}"
self.assertEqual(result, expected)
def test_qwen_coder_end_position(self):
"""Test FIM prompt with END position (qwen_coder style)."""
result = generate_completion_prompt("prefix", "suffix", "qwen_coder")
t = completion_templates["qwen_coder"]
expected = (
f"{t.fim_begin_token}prefix{t.fim_end_token}suffix{t.fim_middle_token}"
)
self.assertEqual(result, expected)
def test_empty_prompt_and_suffix(self):
"""Test FIM prompt generation with empty strings."""
result = generate_completion_prompt("", "", "deepseek_coder")
t = completion_templates["deepseek_coder"]
expected = f"{t.fim_begin_token}{t.fim_middle_token}{t.fim_end_token}"
self.assertEqual(result, expected)
class TestGenerateCompletionPromptFromRequest(CustomTestCase):
def test_empty_suffix_returns_prompt_directly(self):
"""Test that empty suffix bypasses FIM formatting."""
request = CompletionRequest(prompt="just code", suffix="")
result = generate_completion_prompt_from_request(request)
self.assertEqual(result, "just code")
def test_nonempty_suffix_uses_fim_template(self):
"""Test that non-empty suffix triggers FIM formatting."""
with patch(
"sglang.srt.parser.code_completion_parser.completion_template_name",
"deepseek_coder",
):
request = CompletionRequest(prompt="prefix", suffix="suffix")
result = generate_completion_prompt_from_request(request)
t = completion_templates["deepseek_coder"]
expected = (
f"{t.fim_begin_token}prefix{t.fim_middle_token}suffix{t.fim_end_token}"
)
self.assertEqual(result, expected)
class TestSetCompletionTemplate(CustomTestCase):
def test_set_only_once(self):
"""Test that set_completion_template only sets the name once."""
import sglang.srt.parser.code_completion_parser as module
with patch.object(module, "completion_template_name", None):
set_completion_template("star_coder")
self.assertEqual(module.completion_template_name, "star_coder")
# Second call should be ignored
set_completion_template("qwen_coder")
self.assertEqual(module.completion_template_name, "star_coder")
def test_is_completion_template_defined(self):
"""Test the defined check before and after setting."""
import sglang.srt.parser.code_completion_parser as module
old_name = module.completion_template_name
try:
module.completion_template_name = None
self.assertFalse(is_completion_template_defined())
set_completion_template("deepseek_coder")
self.assertTrue(is_completion_template_defined())
finally:
module.completion_template_name = old_name
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
"""Unit tests for srt/parser/harmony_parser.py"""
import unittest
from sglang.srt.parser.harmony_parser import (
@@ -483,15 +485,12 @@ class TestHarmonyParser(CustomTestCase):
events = self.parser.parse(chunk)
all_events.extend(events)
self.assertEqual(len(all_events), 5)
# Verify we get reasoning events
# Verify we get both reasoning and normal events
reasoning_events = [e for e in all_events if e.event_type == "reasoning"]
self.assertTrue(len(reasoning_events) > 0)
self.assertGreater(len(reasoning_events), 0)
# Verify we get normal events
normal_events = [e for e in all_events if e.event_type == "normal"]
self.assertTrue(len(normal_events) > 0)
self.assertGreater(len(normal_events), 0)
# Verify content is eventually parsed correctly
combined_reasoning = "".join(e.content for e in reasoning_events)
@@ -875,5 +874,150 @@ class TestEdgeCases(CustomTestCase):
self.assertEqual(events[1].content, "second reasoning")
class TestAdditionalEdgeCases(CustomTestCase):
"""Additional tests to cover remaining edge cases."""
def test_prefix_hold_with_empty_token_in_list(self):
"""Test that empty string token in the list is skipped."""
from sglang.srt.parser.harmony_parser import prefix_hold
emit, hold = prefix_hold("hello", ["", "world"])
self.assertEqual(emit, "hello")
self.assertEqual(hold, "")
def test_iter_tokens_unknown_token_no_closing(self):
"""Test iter_tokens with <| that has no closing |>."""
from sglang.srt.parser.harmony_parser import iter_tokens
tokens = list(iter_tokens("<|broken text without close", 0))
# Should emit TEXT tokens for the content after <|
self.assertTrue(any(t.type == "TEXT" for t in tokens))
def test_canonical_commentary_filler_after_call(self):
"""Test that MESSAGE token after CALL is filtered as commentary filler."""
from sglang.srt.parser.harmony_parser import CanonicalStrategy
strategy = CanonicalStrategy()
text = "<|start|><|channel|>analysis<|message|>thinking<|end|><|call|><|message|>noise<|return|><|channel|>final<|message|>answer<|end|>"
events, remainder = strategy.parse(text)
# The MESSAGE after CALL should be filtered, final answer should appear
answers = [e.content for e in events if e.event_type == "normal"]
self.assertTrue(any("answer" in a for a in answers))
def test_canonical_standalone_structural_token_filtered(self):
"""Test that standalone structural tokens like <|end|> in TEXT position are filtered."""
from sglang.srt.parser.harmony_parser import CanonicalStrategy
strategy = CanonicalStrategy()
# A malformed sequence where an END token appears in an unexpected position
text = "<|start|><|channel|>analysis<|message|>content<|end|>"
events, remainder = strategy.parse(text)
# Should parse without error
self.assertTrue(len(events) >= 0)
def test_canonical_incomplete_block_returns_partial(self):
"""Test parsing an incomplete channel block (no END token)."""
from sglang.srt.parser.harmony_parser import CanonicalStrategy
strategy = CanonicalStrategy()
text = "<|start|><|channel|>analysis<|message|>partial content"
events, remainder = strategy.parse(text)
# Incomplete block: should hold content as remainder or emit partial
reasoning_events = [e for e in events if e.event_type == "reasoning"]
# The partial content may be in events or remainder
total = "".join(e.content for e in reasoning_events) + remainder
self.assertIn("partial", total)
def test_text_strategy_commentary_channel(self):
"""Test TextStrategy parsing commentary channel."""
from sglang.srt.parser.harmony_parser import TextStrategy
strategy = TextStrategy()
text = "commentary: some discussion\nassistantfinal: the answer"
events, remainder = strategy.parse(text)
normal = [e for e in events if e.event_type == "normal"]
self.assertTrue(any("the answer" in e.content for e in normal))
def test_canonical_call_with_text_commentary_after(self):
"""Test filtering of 'commentary' text after CALL token."""
from sglang.srt.parser.harmony_parser import CanonicalStrategy
strategy = CanonicalStrategy()
text = "<|start|><|channel|>analysis<|message|>think<|end|><|call|>commentary<|return|><|channel|>final<|message|>result<|end|>"
events, remainder = strategy.parse(text)
normal = [e for e in events if e.event_type == "normal"]
self.assertTrue(any("result" in e.content for e in normal))
def test_canonical_return_without_final(self):
"""Test that _parse_block returns None for block without proper end."""
from sglang.srt.parser.harmony_parser import CanonicalStrategy
strategy = CanonicalStrategy()
# Channel block that has no message content before end
text = "<|start|><|channel|>final<|end|>"
events, remainder = strategy.parse(text)
# Should handle gracefully
self.assertIsInstance(events, list)
def test_iter_tokens_unknown_at_end_no_next_marker(self):
"""Test unknown token with |> close but no next <| marker after it."""
from sglang.srt.parser.harmony_parser import iter_tokens
# <|weird|> is unknown, has closing |>, but nothing after it
tokens = list(iter_tokens("<|weird|>trailing", 0))
# Should emit TEXT tokens covering the content
all_text = "".join(
"<|weird|>trailing"[t.start : t.end] for t in tokens if t.type == "TEXT"
)
self.assertIn("weird|>trailing", all_text)
def test_canonical_standalone_end_token_filtered(self):
"""Test that standalone <|end|> in TEXT position is filtered out."""
from sglang.srt.parser.harmony_parser import CanonicalStrategy
strategy = CanonicalStrategy()
# Malformed: <|end|> appears before any channel/message structure
text = "<|end|><|start|><|channel|>final<|message|>answer<|end|>"
events, remainder = strategy.parse(text)
# The standalone <|end|> should be filtered, answer should appear
normal = [e.content for e in events if e.event_type == "normal"]
self.assertTrue(any("answer" in c for c in normal))
def test_canonical_incomplete_parse_block_no_end(self):
"""Test that a channel block without END/CALL/RETURN returns None (incomplete)."""
from sglang.srt.parser.harmony_parser import CanonicalStrategy
strategy = CanonicalStrategy()
# Channel with message but no end token
text = "<|start|><|channel|>final<|message|>partial"
events, remainder = strategy.parse(text)
# Should be treated as incomplete
total = "".join(e.content for e in events) + remainder
self.assertIn("partial", total)
def test_text_strategy_commentary_only(self):
"""Test TextStrategy with commentary-only pattern (no 'assistantfinal')."""
from sglang.srt.parser.harmony_parser import TextStrategy
strategy = TextStrategy()
text = "commentary: just a comment here"
events, remainder = strategy.parse(text)
normal = [e for e in events if e.event_type == "normal"]
# Commentary content should appear as normal text
combined = "".join(e.content for e in normal) + remainder
self.assertIn("comment", combined)
def test_text_strategy_commentary_with_hold(self):
"""Test TextStrategy commentary channel with prefix that could be 'assistantfinal'."""
from sglang.srt.parser.harmony_parser import TextStrategy
strategy = TextStrategy()
# Content ends with "assistant" which is a prefix of "assistantfinal"
text = "commentary: discussion assistant"
events, remainder = strategy.parse(text)
# "assistant" at end should be held back
self.assertIn("assistant", remainder)
if __name__ == "__main__":
unittest.main()
@@ -1,6 +1,4 @@
"""
Unit tests for Jinja chat template utils.
"""
"""Unit tests for srt/parser/jinja_template_utils.py"""
import unittest
@@ -308,6 +306,186 @@ class TestTemplateContentFormatDetection(CustomTestCase):
expected_keys = {"role", "content"}
self.assertEqual(set(result.keys()), expected_keys)
def test_process_content_with_video(self):
"""Test content processing with video_url content."""
msg_dict = {
"role": "user",
"content": [
{"type": "text", "text": "Watch this:"},
{"type": "video_url", "video_url": {"url": "http://example.com/v.mp4"}},
],
}
image_data = []
video_data = []
audio_data = []
modalities = []
result = process_content_for_template_format(
msg_dict, "openai", image_data, video_data, audio_data, modalities
)
self.assertEqual(len(video_data), 1)
self.assertEqual(video_data[0], "http://example.com/v.mp4")
self.assertEqual(result["content"][1], {"type": "video"})
def test_process_content_video_with_max_dynamic_patch(self):
"""Test video_url with max_dynamic_patch stores structured dict."""
msg_dict = {
"role": "user",
"content": [
{
"type": "video_url",
"video_url": {
"url": "http://example.com/v.mp4",
"max_dynamic_patch": 4,
},
},
],
}
image_data = []
video_data = []
audio_data = []
modalities = []
result = process_content_for_template_format(
msg_dict, "openai", image_data, video_data, audio_data, modalities
)
self.assertEqual(len(video_data), 1)
self.assertIsInstance(video_data[0], dict)
self.assertEqual(video_data[0]["max_dynamic_patch"], 4)
def test_process_content_v32_encoding(self):
"""Test v32 encoding mode flattens text and ignores structured content parts."""
msg_dict = {
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{
"type": "image_url",
"image_url": {"url": "http://example.com/img.jpg"},
},
{"type": "text", "text": "World"},
],
}
image_data = []
video_data = []
audio_data = []
modalities = []
result = process_content_for_template_format(
msg_dict,
"openai",
image_data,
video_data,
audio_data,
modalities,
use_dpsk_v32_encoding=True,
)
# v32 encoding: content is joined text, not list
self.assertEqual(result["content"], "Hello World")
# Image data is still extracted
self.assertEqual(len(image_data), 1)
def test_process_content_invalid_format_raises(self):
"""Test that invalid content_format raises ValueError."""
msg_dict = {
"role": "user",
"content": [{"type": "text", "text": "Hi"}],
}
with self.assertRaises(ValueError):
process_content_for_template_format(
msg_dict, "invalid_format", [], [], [], []
)
def test_process_content_video_with_modalities(self):
"""Test that video content with modalities field is extracted."""
msg_dict = {
"role": "user",
"content": [
{
"type": "video_url",
"video_url": {"url": "http://example.com/v.mp4"},
"modalities": ["video"],
},
],
}
image_data = []
video_data = []
audio_data = []
modalities = []
result = process_content_for_template_format(
msg_dict, "openai", image_data, video_data, audio_data, modalities
)
self.assertEqual(len(modalities), 1)
self.assertEqual(modalities[0], ["video"])
def test_detect_template_with_filter(self):
"""Test that content access through a Jinja filter is detected as openai."""
# Template with | trim filter on content iteration
template = """
{%- for message in messages %}
{%- for content in message['content'] | trim %}
{{- content }}
{%- endfor %}
{%- endfor %}
"""
result = detect_jinja_template_content_format(template)
self.assertEqual(result, "openai")
def test_detect_template_with_is_test(self):
"""Test that 'is string' test on content triggers openai detection."""
# Template with 'is string' test that also iterates content
template = """
{%- for message in messages %}
{%- if message['content'] is string %}
{{- message['content'] }}
{%- else %}
{%- for item in message['content'] %}
{{- item }}
{%- endfor %}
{%- endif %}
{%- endfor %}
"""
result = detect_jinja_template_content_format(template)
self.assertEqual(result, "openai")
def test_detect_template_with_slice(self):
"""Test that content access through slice is detected as openai."""
template = """
{%- for message in messages %}
{%- for item in message['content'][:5] %}
{{- item }}
{%- endfor %}
{%- endfor %}
"""
result = detect_jinja_template_content_format(template)
self.assertEqual(result, "openai")
def test_detect_template_no_content_loop_is_string(self):
"""Test that template without content iteration returns string format."""
template = """
{%- for message in messages %}
{{- message['role'] }}: {{ message['content'] }}
{%- endfor %}
"""
# No "image"/"audio"/"video" keyword, no content loop → string
result = detect_jinja_template_content_format(template)
self.assertEqual(result, "string")
def test_detect_msg_content_without_multimodal_keywords(self):
"""Test AST detection of 'for item in msg.content' without keyword shortcut.
Templates that contain 'image'/'video'/'audio'/'vision' take a shortcut.
This template deliberately avoids those keywords to test the AST path."""
template = """
{%- for msg in messages %}
{%- if msg.content is string %}
{{- msg.content }}
{%- else %}
{%- for item in msg.content %}
{{- item.text }}
{%- endfor %}
{%- endif %}
{%- endfor %}
"""
result = detect_jinja_template_content_format(template)
self.assertEqual(result, "openai")
if __name__ == "__main__":
unittest.main()
@@ -1,3 +1,5 @@
"""Unit tests for srt/parser/reasoning_parser.py"""
import unittest
from sglang.srt.parser.reasoning_parser import (
@@ -483,8 +485,11 @@ class TestGlm45Detector(CustomTestCase):
self.assertEqual(result.reasoning_text, "")
self.assertEqual(result.normal_text, "")
# Tool interruption should still work - flushes buffered reasoning
# Note: buffer preserves original text including <think> tag
# Tool interruption should still work - flushes buffered reasoning.
# Note: when stream_reasoning=False, the <think> tag is stripped from the
# local `current_text` variable but NOT from `self._buffer` (which is never
# cleared in the non-streaming path). So the flushed reasoning content
# includes the raw <think> tag.
result = detector.parse_streaming_increment("<tool_call>tool call")
self.assertEqual(result.reasoning_text, "<think>thinking")
self.assertEqual(result.normal_text, "<tool_call>tool call")
@@ -836,7 +841,7 @@ class TestBufferLossBugFix(CustomTestCase):
5. Buffer is cleared and "answer" is returned directly
6. The "</" from previous step is lost
This test verifies the fix where line 108 was changed from:
This test verifies the fix where the return was changed from:
return StreamingParseResult(normal_text=new_text)
to:
return StreamingParseResult(normal_text=current_text)
@@ -933,5 +938,301 @@ class TestBufferLossBugFix(CustomTestCase):
self.assertTrue(detector.stripped_think_start)
class TestGptOssDetector(CustomTestCase):
"""Test cases for GptOssDetector which delegates to HarmonyParser."""
def setUp(self):
from sglang.srt.parser.reasoning_parser import GptOssDetector
self.detector = GptOssDetector()
def test_detect_and_parse_with_analysis_and_final(self):
"""Test one-shot parsing with analysis (reasoning) and final (normal) blocks."""
text = "<|start|><|channel|>analysis<|message|>thinking hard<|end|><|channel|>final<|message|>the answer<|end|>"
result = self.detector.detect_and_parse(text)
self.assertIn("thinking hard", result.reasoning_text)
self.assertIn("the answer", result.normal_text)
def test_detect_and_parse_normal_only(self):
"""Test one-shot parsing with only final block."""
text = "<|start|><|channel|>final<|message|>just the answer<|end|>"
result = self.detector.detect_and_parse(text)
self.assertIn("just the answer", result.normal_text)
def test_streaming_analysis_then_final(self):
"""Test streaming parse across multiple chunks."""
chunks = [
"<|start|><|channel|>analysis<|message|>",
"reasoning part",
"<|end|>",
"<|channel|>final<|message|>answer",
"<|end|>",
]
all_reasoning = ""
all_normal = ""
for chunk in chunks:
result = self.detector.parse_streaming_increment(chunk)
all_reasoning += result.reasoning_text
all_normal += result.normal_text
self.assertIn("reasoning part", all_reasoning)
self.assertIn("answer", all_normal)
def test_streaming_with_tool_call(self):
"""Test streaming parse with tool call events."""
text = "<|start|><|channel|>analysis<|message|>think<|end|><|call|>tool_data<|return|><|channel|>final<|message|>result<|end|>"
result = self.detector.detect_and_parse(text)
self.assertIn("think", result.reasoning_text)
self.assertIn("result", result.normal_text)
class TestMiniMaxAppendThinkDetector(CustomTestCase):
"""Test cases for MiniMaxAppendThinkDetector."""
def setUp(self):
from sglang.srt.parser.reasoning_parser import MiniMaxAppendThinkDetector
self.detector = MiniMaxAppendThinkDetector()
def test_detect_and_parse_prepends_think(self):
"""Test that detect_and_parse prepends <think> to the text."""
result = self.detector.detect_and_parse("Hello world")
self.assertEqual(result.normal_text, "<think>Hello world")
def test_streaming_first_chunk_prepends_think(self):
"""Test that first streaming chunk gets <think> prepended."""
result = self.detector.parse_streaming_increment("First chunk")
self.assertEqual(result.normal_text, "<think>First chunk")
def test_streaming_second_chunk_no_prepend(self):
"""Test that subsequent streaming chunks are passed through."""
self.detector.parse_streaming_increment("First")
result = self.detector.parse_streaming_increment("Second")
self.assertEqual(result.normal_text, "Second")
class TestReasoningParserAdvanced(CustomTestCase):
"""Additional tests for ReasoningParser init edge cases."""
def test_gpt_oss_model_type(self):
"""Test that gpt-oss model type creates GptOssDetector."""
from sglang.srt.parser.reasoning_parser import GptOssDetector
parser = ReasoningParser("gpt-oss")
self.assertIsInstance(parser.detector, GptOssDetector)
def test_minimax_append_think_model_type(self):
"""Test that minimax-append-think creates MiniMaxAppendThinkDetector."""
from sglang.srt.parser.reasoning_parser import MiniMaxAppendThinkDetector
parser = ReasoningParser("minimax-append-think")
self.assertIsInstance(parser.detector, MiniMaxAppendThinkDetector)
def test_qwen3_thinking_forces_reasoning(self):
"""Test that qwen3-thinking model type forces reasoning mode."""
parser = ReasoningParser("qwen3-thinking")
self.assertTrue(parser.detector._in_reasoning)
def test_minimax_forces_reasoning(self):
"""Test that minimax model type forces reasoning mode.
minimax maps to Qwen3Detector but ReasoningParser overrides
force_reasoning=True, unlike the default Qwen3Detector behavior.
"""
parser = ReasoningParser("minimax")
self.assertIsInstance(parser.detector, Qwen3Detector)
self.assertTrue(parser.detector._in_reasoning)
def test_detector_map_aliases(self):
"""Test that all DetectorMap alias keys create the correct detector type."""
# These are aliases that map to existing detector classes
alias_tests = {
"deepseek-v3": Qwen3Detector,
"step3": DeepSeekR1Detector,
"step3p5": DeepSeekR1Detector,
"interns1": Qwen3Detector,
}
for model_type, expected_class in alias_tests.items():
parser = ReasoningParser(model_type)
self.assertIsInstance(
parser.detector,
expected_class,
f"{model_type} should create {expected_class.__name__}",
)
def test_continue_final_message_with_request(self):
"""Test continue_final_message passes previous content to detector."""
from sglang.srt.entrypoints.openai.protocol import (
ChatCompletionMessageGenericParam,
ChatCompletionMessageUserParam,
ChatCompletionRequest,
)
request = ChatCompletionRequest(
model="test",
messages=[
ChatCompletionMessageUserParam(role="user", content="Hi"),
ChatCompletionMessageGenericParam(
role="assistant", content="Let me think..."
),
],
continue_final_message=True,
)
parser = ReasoningParser("qwen3", request=request)
self.assertTrue(parser.detector.continue_final_message)
def test_force_nonempty_content_via_chat_template_kwargs(self):
"""Test that force_nonempty_content is passed via chat_template_kwargs."""
from sglang.srt.entrypoints.openai.protocol import (
ChatCompletionMessageUserParam,
ChatCompletionRequest,
)
request = ChatCompletionRequest(
model="test",
messages=[
ChatCompletionMessageUserParam(role="user", content="Hi"),
],
chat_template_kwargs={"force_nonempty_content": True},
)
parser = ReasoningParser("nemotron_3", request=request)
self.assertTrue(parser.detector._force_nonempty_content)
class TestContinueFinalMessage(CustomTestCase):
"""Test continue_final_message mode for BaseReasoningFormatDetector."""
def test_continue_with_think_start_in_previous(self):
"""Test that previous_content with <think> sets _in_reasoning=True."""
detector = BaseReasoningFormatDetector(
"<think>",
"</think>",
force_reasoning=False,
continue_final_message=True,
previous_content="<think>some reasoning",
)
self.assertTrue(detector._in_reasoning)
self.assertEqual(detector.previous_count, len("<think>some reasoning"))
def test_continue_with_think_end_in_previous(self):
"""Test that previous_content with </think> sets _in_reasoning=False."""
detector = BaseReasoningFormatDetector(
"<think>",
"</think>",
force_reasoning=True,
continue_final_message=True,
previous_content="<think>done</think>normal",
)
# think_end_token in previous → _in_reasoning = False
self.assertFalse(detector._in_reasoning)
def test_continue_detect_parse_with_end_in_previous(self):
"""Test detect_and_parse when think_end_token is in previous_content only.
This covers the branch where think_end is NOT in current text
but IS in previous_content, so output is treated as normal_text."""
detector = BaseReasoningFormatDetector(
"<think>",
"</think>",
force_reasoning=True,
continue_final_message=True,
previous_content="<think>reasoning</think>",
)
# _in_reasoning is False (think_end in previous)
# But force_reasoning was True → detect_and_parse still enters the
# reasoning path because think_start is in previous_content.
# However, since _in_reasoning=False and no think_start in new text,
# it returns normal_text directly.
result = detector.detect_and_parse("new content here")
self.assertEqual(result.normal_text, "new content here")
def test_continue_end_in_previous_new_text_has_start_but_no_end(self):
"""Test: think_end in previous, new text has think_start but no think_end.
This produces: in_reasoning=True (from think_start in text),
think_end NOT in processed_text, think_end IN previous_content,
so it falls through to the else branch that returns normal_text."""
detector = BaseReasoningFormatDetector(
"<think>",
"</think>",
force_reasoning=False,
continue_final_message=True,
previous_content="earlier <think>old</think>old answer",
)
# _in_reasoning = False (think_end in previous overrides)
self.assertFalse(detector._in_reasoning)
# New text has <think> (triggers in_reasoning) but no </think>
# think_end IS in previous_content → skips the truncated-reasoning branch
# think_end NOT in processed_text → falls to else that returns normal_text
result = detector.detect_and_parse("<think>continuing reasoning")
self.assertEqual(result.normal_text, "continuing reasoning")
self.assertEqual(result.reasoning_text, "")
def test_continue_detect_parse_think_start_in_prev_but_end_also_in_prev(self):
"""Test detect_and_parse where both think tokens are in previous,
and new text contains <think> to re-enter reasoning."""
detector = BaseReasoningFormatDetector(
"<think>",
"</think>",
force_reasoning=False,
continue_final_message=True,
previous_content="<think>old reasoning</think>old answer",
)
# _in_reasoning = False (end token in previous overrides start)
self.assertFalse(detector._in_reasoning)
# New text starts a fresh reasoning block
result = detector.detect_and_parse("<think>new reasoning</think>new answer")
self.assertEqual(result.reasoning_text, "new reasoning")
self.assertEqual(result.normal_text, "new answer")
def test_streaming_returns_empty_when_in_reasoning_and_end_buffered(self):
"""Test that streaming returns empty when buffer could be partial end token."""
detector = BaseReasoningFormatDetector(
"<think>", "</think>", force_reasoning=True, stream_reasoning=True
)
# In reasoning mode, send partial end token
result = detector.parse_streaming_increment("</")
self.assertEqual(result.reasoning_text, "")
self.assertEqual(result.normal_text, "")
# This goes through the path where _in_reasoning is True but buffer
# is a prefix of think_end_token → returns empty
class TestGptOssDetectorToolCall(CustomTestCase):
"""Test GptOssDetector tool_call raw_text handling."""
def test_detect_and_parse_tool_call_raw_text(self):
"""Test that tool_call events use raw_text when available."""
from sglang.srt.parser.reasoning_parser import GptOssDetector
detector = GptOssDetector()
# Sequence with CALL...RETURN that produces tool_call events with raw_text
text = (
"<|start|><|channel|>analysis<|message|>think<|end|>"
"<|call|>function_data<|return|>"
"<|channel|>final<|message|>result<|end|>"
)
result = detector.detect_and_parse(text)
self.assertIn("think", result.reasoning_text)
# Tool call raw_text and/or final result should be in normal_text
self.assertIn("result", result.normal_text)
def test_streaming_tool_call_raw_text(self):
"""Test streaming parse with tool_call events preserving raw_text."""
from sglang.srt.parser.reasoning_parser import GptOssDetector
detector = GptOssDetector()
chunks = [
"<|start|><|channel|>analysis<|message|>reason<|end|>",
"<|call|>tool_payload<|return|>",
"<|channel|>final<|message|>done<|end|>",
]
all_reasoning = ""
all_normal = ""
for chunk in chunks:
result = detector.parse_streaming_increment(chunk)
all_reasoning += result.reasoning_text
all_normal += result.normal_text
self.assertIn("reason", all_reasoning)
self.assertIn("done", all_normal)
if __name__ == "__main__":
unittest.main()