[New Model] Gemma 4 (#21952)

Signed-off-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
Co-authored-by: Pengyu Chen <pychen96@gmail.com>
Co-authored-by: kpham-sgl <khoa.pham@radixark.ai>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Luo <andy.luo@amd.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: adarshxs <adarsh.shirawalmath@gmail.com>
This commit is contained in:
Xinyuan Tong
2026-04-06 20:24:44 -07:00
committed by GitHub
co-authored by Pengyu Chen kpham-sgl Claude Opus 4.6 Andy Luo gemini-code-assist[bot] adarshxs
parent be0277f9a0
commit 2813cb6d9a
35 changed files with 6007 additions and 70 deletions
@@ -6,6 +6,12 @@ from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import StreamingParseResult
from sglang.srt.function_call.deepseekv3_detector import DeepSeekV3Detector
from sglang.srt.function_call.deepseekv32_detector import DeepSeekV32Detector
from sglang.srt.function_call.gemma4_detector import (
Gemma4Detector,
_parse_gemma4_args,
_parse_gemma4_array,
_parse_gemma4_value,
)
from sglang.srt.function_call.gigachat3_detector import GigaChat3Detector
from sglang.srt.function_call.glm4_moe_detector import Glm4MoeDetector
from sglang.srt.function_call.glm47_moe_detector import Glm47MoeDetector
@@ -4008,5 +4014,340 @@ class TestQwen25Detector(unittest.TestCase):
self.assertEqual(cities, ["NYC", "LA"])
class TestGemma4Detector(unittest.TestCase):
def setUp(self):
self.tools = [
Tool(
type="function",
function=Function(
name="get_weather",
description="Get weather information",
parameters={
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
},
},
"required": ["location"],
},
),
)
]
self.detector = Gemma4Detector()
def test_detect_and_parse(self):
text = 'Some text before <|tool_call>call:get_weather{location:<|"|>Tokyo<|"|>}<tool_call|>'
result = self.detector.detect_and_parse(text, self.tools)
self.assertEqual(result.normal_text, "Some text before ")
self.assertEqual(len(result.calls), 1)
self.assertEqual(result.calls[0].name, "get_weather")
params = json.loads(result.calls[0].parameters)
self.assertEqual(params["location"], "Tokyo")
def test_parse_streaming_increment(self):
chunks = [
"Some text ",
"before <|tool",
"_call>call:get_we",
"ather{location:<|", # codespell:ignore
'"|>Tokyo<|"|>}<tool_',
"call|> after",
]
all_results = []
for chunk in chunks:
res = self.detector.parse_streaming_increment(chunk, self.tools)
all_results.append(res)
combined_normal_text = "".join(r.normal_text for r in all_results)
self.assertEqual(combined_normal_text, "Some text before after")
found_name = False
found_params = False
for res in all_results:
for call in res.calls:
if call.name == "get_weather":
found_name = True
if call.parameters:
params = json.loads(call.parameters)
if params == {"location": "Tokyo"}:
found_params = True
self.assertTrue(found_name)
self.assertTrue(found_params)
def test_nested_array_streaming(self):
# Additional coverage for complex structure
chunks = [
'<|tool_call>call:get_weather{location:<|"',
'|>New York<|"|>,nested:[1, 2, {inner:<|"|>',
'val<|"|>}]}<tool_call|>',
]
all_results = []
for chunk in chunks:
res = self.detector.parse_streaming_increment(chunk, self.tools)
all_results.append(res)
found_params = False
for res in all_results:
for call in res.calls:
if call.parameters:
params = json.loads(call.parameters)
if "location" in params and params["location"] == "New York":
if "nested" in params and params["nested"] == [
1,
2,
{"inner": "val"},
]:
found_params = True
self.assertTrue(found_params)
def test_has_tool_call(self):
self.assertTrue(
self.detector.has_tool_call(
'<|tool_call>call:get_weather{location:<|"|>Tokyo<|"|>}<tool_call|>'
)
)
self.assertFalse(self.detector.has_tool_call("no tool call here"))
def test_detect_and_parse_no_tool_call(self):
text = "This is plain text without any tool calls."
result = self.detector.detect_and_parse(text, self.tools)
self.assertEqual(result.normal_text, text)
self.assertEqual(len(result.calls), 0)
def test_detect_and_parse_tool_index(self):
text = '<|tool_call>call:get_weather{location:<|"|>Tokyo<|"|>}<tool_call|>'
result = self.detector.detect_and_parse(text, self.tools)
self.assertEqual(len(result.calls), 1)
self.assertEqual(result.calls[0].tool_index, 0)
self.assertEqual(result.calls[0].name, "get_weather")
def test_detect_and_parse_unknown_tool_index(self):
text = '<|tool_call>call:unknown_func{arg:<|"|>val<|"|>}<tool_call|>'
result = self.detector.detect_and_parse(text, self.tools)
self.assertEqual(len(result.calls), 1)
self.assertEqual(result.calls[0].tool_index, -1)
def test_detect_and_parse_nested_object(self):
text = '<|tool_call>call:get_weather{location:<|"|>Tokyo<|"|>,details:{temp:25,unit:<|"|>celsius<|"|>}}<tool_call|>'
result = self.detector.detect_and_parse(text, self.tools)
self.assertEqual(len(result.calls), 1)
params = json.loads(result.calls[0].parameters)
self.assertEqual(params["location"], "Tokyo")
self.assertIsInstance(params["details"], dict)
self.assertEqual(params["details"]["temp"], 25)
self.assertEqual(params["details"]["unit"], "celsius")
def test_detect_and_parse_multiple_calls(self):
extra_tools = self.tools + [
Tool(
type="function",
function=Function(
name="get_time",
description="Get current time",
parameters={
"type": "object",
"properties": {"timezone": {"type": "string"}},
},
),
)
]
text = (
'Some text <|tool_call>call:get_weather{location:<|"|>Tokyo<|"|>}<tool_call|>'
' more text <|tool_call>call:get_time{timezone:<|"|>UTC<|"|>}<tool_call|>'
)
result = self.detector.detect_and_parse(text, extra_tools)
self.assertEqual(len(result.calls), 2)
self.assertEqual(result.calls[0].name, "get_weather")
self.assertEqual(result.calls[1].name, "get_time")
self.assertEqual(result.normal_text, "Some text ")
def test_parse_gemma4_args_empty(self):
self.assertEqual(_parse_gemma4_args(""), {})
self.assertEqual(_parse_gemma4_args(" "), {})
def test_parse_gemma4_args_booleans(self):
result = _parse_gemma4_args("flag:true,other:false")
self.assertIs(result["flag"], True)
self.assertIs(result["other"], False)
def test_parse_gemma4_args_numbers(self):
result = _parse_gemma4_args("count:42,ratio:3.14")
self.assertEqual(result["count"], 42)
self.assertAlmostEqual(result["ratio"], 3.14)
def test_parse_gemma4_args_string_with_colon(self):
result = _parse_gemma4_args('url:<|"|>http://example.com<|"|>')
self.assertEqual(result["url"], "http://example.com")
def test_parse_gemma4_args_nested_object(self):
result = _parse_gemma4_args('outer:{inner:<|"|>val<|"|>,num:5}')
self.assertIsInstance(result["outer"], dict)
self.assertEqual(result["outer"]["inner"], "val")
self.assertEqual(result["outer"]["num"], 5)
def test_parse_gemma4_array_mixed_types(self):
result = _parse_gemma4_array('<|"|>hello<|"|>, 42, true, {key:<|"|>val<|"|>}')
self.assertEqual(result[0], "hello")
self.assertEqual(result[1], 42)
self.assertIs(result[2], True)
self.assertIsInstance(result[3], dict)
self.assertEqual(result[3]["key"], "val")
def test_parse_gemma4_value_types(self):
self.assertIs(_parse_gemma4_value("true"), True)
self.assertIs(_parse_gemma4_value("false"), False)
self.assertEqual(_parse_gemma4_value("42"), 42)
self.assertAlmostEqual(_parse_gemma4_value("3.14"), 3.14)
self.assertEqual(_parse_gemma4_value("hello"), "hello")
self.assertEqual(_parse_gemma4_value(""), "")
def _collect_streaming(self, chunks):
"""Helper: feed chunks and collect normal text + tool calls by index."""
normal_text = ""
tool_calls_by_index = {}
for chunk in chunks:
result = self.detector.parse_streaming_increment(chunk, self.tools)
normal_text += result.normal_text
for call in result.calls:
if call.tool_index is not None:
if call.tool_index not in tool_calls_by_index:
tool_calls_by_index[call.tool_index] = {
"name": "",
"parameters": "",
}
if call.name:
tool_calls_by_index[call.tool_index]["name"] = call.name
if call.parameters:
tool_calls_by_index[call.tool_index][
"parameters"
] += call.parameters
return normal_text, tool_calls_by_index
def test_streaming_multiple_tool_calls(self):
"""Test streaming with two consecutive tool calls."""
extra_tools = self.tools + [
Tool(
type="function",
function=Function(
name="get_time",
description="Get current time",
parameters={
"type": "object",
"properties": {"timezone": {"type": "string"}},
},
),
)
]
chunks = [
'<|tool_call>call:get_weather{location:<|"|>',
'Tokyo<|"|>}<tool_call|>',
' <|tool_call>call:get_time{timezone:<|"|>',
'UTC<|"|>}<tool_call|>',
]
normal_text = ""
tool_calls_by_index = {}
for chunk in chunks:
result = self.detector.parse_streaming_increment(chunk, extra_tools)
normal_text += result.normal_text
for call in result.calls:
if call.tool_index is not None:
if call.tool_index not in tool_calls_by_index:
tool_calls_by_index[call.tool_index] = {
"name": "",
"parameters": "",
}
if call.name:
tool_calls_by_index[call.tool_index]["name"] = call.name
if call.parameters:
tool_calls_by_index[call.tool_index][
"parameters"
] += call.parameters
self.assertEqual(len(tool_calls_by_index), 2)
self.assertEqual(tool_calls_by_index[0]["name"], "get_weather")
self.assertEqual(tool_calls_by_index[1]["name"], "get_time")
params0 = json.loads(tool_calls_by_index[0]["parameters"])
params1 = json.loads(tool_calls_by_index[1]["parameters"])
self.assertEqual(params0["location"], "Tokyo")
self.assertEqual(params1["timezone"], "UTC")
def test_streaming_very_small_chunks(self):
"""Test streaming with character-by-character chunks."""
full_text = '<|tool_call>call:get_weather{location:<|"|>Rome<|"|>}<tool_call|>'
chunks = list(full_text)
normal_text, tool_calls = self._collect_streaming(chunks)
self.assertEqual(len(tool_calls), 1)
self.assertEqual(tool_calls[0]["name"], "get_weather")
params = json.loads(tool_calls[0]["parameters"])
self.assertEqual(params["location"], "Rome")
def test_streaming_empty_args(self):
"""Test streaming a tool call with no arguments."""
chunks = ["<|tool_call>call:get_weather{}", "<tool_call|>"]
normal_text, tool_calls = self._collect_streaming(chunks)
self.assertEqual(len(tool_calls), 1)
self.assertEqual(tool_calls[0]["name"], "get_weather")
def test_streaming_text_between_tool_calls(self):
"""Test streaming with normal text interleaved between two different tool calls."""
extra_tools = self.tools + [
Tool(
type="function",
function=Function(
name="get_time",
description="Get current time",
parameters={
"type": "object",
"properties": {"timezone": {"type": "string"}},
},
),
)
]
chunks = [
"Hello! ",
'<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|>',
" Let me also check ",
'<|tool_call>call:get_time{timezone:<|"|>UTC<|"|>}<tool_call|>',
]
normal_text = ""
tool_calls_by_index = {}
for chunk in chunks:
result = self.detector.parse_streaming_increment(chunk, extra_tools)
normal_text += result.normal_text
for call in result.calls:
if call.tool_index is not None:
if call.tool_index not in tool_calls_by_index:
tool_calls_by_index[call.tool_index] = {
"name": "",
"parameters": "",
}
if call.name:
tool_calls_by_index[call.tool_index]["name"] = call.name
if call.parameters:
tool_calls_by_index[call.tool_index][
"parameters"
] += call.parameters
self.assertIn("Hello!", normal_text)
self.assertIn("Let me also check", normal_text)
self.assertEqual(len(tool_calls_by_index), 2)
self.assertEqual(tool_calls_by_index[0]["name"], "get_weather")
self.assertEqual(tool_calls_by_index[1]["name"], "get_time")
params0 = json.loads(tool_calls_by_index[0]["parameters"])
params1 = json.loads(tool_calls_by_index[1]["parameters"])
self.assertEqual(params0["location"], "Paris")
self.assertEqual(params1["timezone"], "UTC")
if __name__ == "__main__":
unittest.main()
@@ -5,6 +5,7 @@ import unittest
from sglang.srt.parser.reasoning_parser import (
BaseReasoningFormatDetector,
DeepSeekR1Detector,
Gemma4Detector,
Glm45Detector,
KimiDetector,
KimiK2Detector,
@@ -586,6 +587,141 @@ class TestNemotron3Detector(CustomTestCase):
self.assertEqual(result.reasoning_text, "")
class TestGemma4Detector(CustomTestCase):
def setUp(self):
self.detector = Gemma4Detector()
def test_init(self):
"""Test Gemma4Detector initialization."""
self.assertEqual(self.detector.think_start_token, "<|channel>")
self.assertEqual(self.detector.think_end_token, "<channel|>")
self.assertEqual(self.detector.think_start_self_label, "thought\n")
self.assertFalse(self.detector._in_reasoning)
self.assertTrue(self.detector.stream_reasoning)
def test_detect_and_parse_complete_reasoning(self):
"""Test parsing complete Gemma4 reasoning block (think_start_self_label is stripped)."""
text = "<|channel>thought\nLet me think about this<channel|>The answer is 42."
result = self.detector.detect_and_parse(text)
self.assertEqual(result.reasoning_text, "Let me think about this")
self.assertEqual(result.normal_text, "The answer is 42.")
def test_detect_and_parse_without_thinking(self):
"""Test parsing without thinking (enable_thinking=False case)."""
text = "Direct answer without thinking."
result = self.detector.detect_and_parse(text)
self.assertEqual(result.normal_text, text)
self.assertEqual(result.reasoning_text, "")
def test_detect_and_parse_reasoning_only(self):
"""Test parsing when output is all reasoning (no end token yet)."""
text = "<|channel>thought\nStill thinking..."
result = self.detector.detect_and_parse(text)
self.assertEqual(result.reasoning_text, "Still thinking...")
self.assertEqual(result.normal_text, "")
def test_streaming_complete_flow(self):
"""Test streaming parse of Gemma4 reasoning flow."""
chunks = [
"<|channel>",
"thought\nreasoning content",
"<channel|>",
"final answer",
]
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 content", all_reasoning)
self.assertIn("final answer", all_normal)
def test_streaming_full_start_sequence(self):
"""Test streaming with the full start sequence (token + self_label)."""
# Gemma4 start sequence is "<|channel>thought\n", not just "<|channel>"
result = self.detector.parse_streaming_increment("<|channel>thought\n")
self.assertEqual(result.normal_text, "")
self.assertEqual(result.reasoning_text, "")
self.assertTrue(self.detector._in_reasoning)
result = self.detector.parse_streaming_increment("reasoning content")
self.assertEqual(result.reasoning_text, "reasoning content")
self.assertEqual(result.normal_text, "")
def test_streaming_partial_start_buffered(self):
"""Test that partial start sequence is buffered."""
# "<|channel>" alone is a prefix of "<|channel>thought\n", so it's buffered
result = self.detector.parse_streaming_increment("<|channel>")
self.assertEqual(result.normal_text, "")
self.assertEqual(result.reasoning_text, "")
def test_streaming_end_token_mid_chunk(self):
"""Test end token arriving in the same chunk as reasoning content."""
self.detector.parse_streaming_increment("<|channel>thought\n")
result = self.detector.parse_streaming_increment(
"some reasoning<channel|>the answer"
)
self.assertEqual(result.reasoning_text, "some reasoning")
self.assertEqual(result.normal_text, "the answer")
self.assertFalse(self.detector._in_reasoning)
def test_streaming_split_end_token(self):
"""Test end token split across two chunks."""
self.detector.parse_streaming_increment("<|channel>thought\n")
self.detector.parse_streaming_increment("reasoning content")
result1 = self.detector.parse_streaming_increment("<chan")
self.assertEqual(result1.normal_text, "")
result2 = self.detector.parse_streaming_increment("nel|>final answer")
self.assertFalse(self.detector._in_reasoning)
self.assertIn("final answer", result2.normal_text)
def test_streaming_self_label_split_across_chunks(self):
"""Test self_label ('thought\\n') arriving separately from start token."""
result1 = self.detector.parse_streaming_increment("<|channel>")
self.assertEqual(result1.reasoning_text, "")
self.assertEqual(result1.normal_text, "")
result2 = self.detector.parse_streaming_increment("thought\n")
self.assertTrue(self.detector._in_reasoning)
result3 = self.detector.parse_streaming_increment("reasoning here")
self.assertEqual(result3.reasoning_text, "reasoning here")
def test_streaming_force_reasoning(self):
"""Test streaming with force_reasoning=True (no start token needed)."""
detector = Gemma4Detector(force_reasoning=True)
result1 = detector.parse_streaming_increment("reasoning content")
self.assertEqual(result1.reasoning_text, "reasoning content")
self.assertEqual(result1.normal_text, "")
result2 = detector.parse_streaming_increment("<channel|>the answer")
self.assertFalse(detector._in_reasoning)
self.assertIn("the answer", result2.normal_text)
def test_streaming_multiple_reasoning_chunks(self):
"""Test reasoning content arriving in many small chunks."""
self.detector.parse_streaming_increment("<|channel>thought\n")
all_reasoning = ""
for chunk in ["Think", "ing ", "step ", "by ", "step."]:
result = self.detector.parse_streaming_increment(chunk)
all_reasoning += result.reasoning_text
self.assertEqual(result.normal_text, "")
self.assertEqual(all_reasoning, "Thinking step by step.")
def test_force_reasoning(self):
"""Test Gemma4Detector with force_reasoning=True."""
detector = Gemma4Detector(force_reasoning=True)
text = "This should be reasoning<channel|>The answer."
result = detector.detect_and_parse(text)
self.assertEqual(result.reasoning_text, "This should be reasoning")
self.assertEqual(result.normal_text, "The answer.")
class TestReasoningParser(CustomTestCase):
def test_init_valid_model(self):
"""Test initialization with valid model types."""
@@ -604,6 +740,9 @@ class TestReasoningParser(CustomTestCase):
parser = ReasoningParser("glm45")
self.assertIsInstance(parser.detector, Glm45Detector)
parser = ReasoningParser("gemma4")
self.assertIsInstance(parser.detector, Gemma4Detector)
def test_init_invalid_model(self):
"""Test initialization with invalid model type."""
with self.assertRaises(ValueError) as context:
@@ -782,6 +921,35 @@ class TestIntegrationScenarios(CustomTestCase):
self.assertIn("multiple factors", all_reasoning)
self.assertIn("42", all_normal)
def test_gemma4_complete_response(self):
"""Test complete Gemma4 response parsing (think_start_self_label stripped)."""
parser = ReasoningParser("gemma4")
text = "<|channel>thought\nI need to solve x + 2 = 5. Subtracting 2: x = 3.<channel|>The answer is x = 3."
reasoning, normal = parser.parse_non_stream(text)
self.assertIn("x = 3", reasoning)
self.assertNotIn("thought\n", reasoning)
self.assertEqual(normal, "The answer is x = 3.")
def test_gemma4_streaming_scenario(self):
"""Test Gemma4 streaming scenario."""
parser = ReasoningParser("gemma4")
chunks = [
"<|channel>",
"thought\nLet me analyze.",
" Multiple factors.",
"<channel|>",
"The solution is 42.",
]
all_reasoning = ""
all_normal = ""
for chunk in chunks:
reasoning, normal = parser.parse_stream_chunk(chunk)
all_reasoning += reasoning
all_normal += normal
self.assertIn("analyze", all_reasoning)
self.assertIn("Multiple factors", all_reasoning)
self.assertIn("42", all_normal)
def test_empty_reasoning_blocks(self):
"""Test handling of empty reasoning blocks."""
parser = ReasoningParser("qwen3")