[Test] Add unit tests for srt/parser (#20947)
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user