Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
371 lines
13 KiB
Python
371 lines
13 KiB
Python
import unittest
|
|
|
|
from sglang.srt.entrypoints.openai.chat_encoding import encode_simple_chat
|
|
from sglang.srt.parser.inkling_renderer import render_inkling_messages
|
|
from sglang.srt.parser.inkling_tokenizer import (
|
|
CONTENT_IMAGE,
|
|
CONTENT_INVOKE_TOOL_JSON,
|
|
CONTENT_MODEL_END_SAMPLING,
|
|
CONTENT_TEXT,
|
|
CONTENT_THINKING,
|
|
CONTENT_XML,
|
|
END_MESSAGE,
|
|
IMAGE_TOKEN_ID,
|
|
INKLING_SPECIAL_TOKEN_IDS,
|
|
MESSAGE_MODEL,
|
|
MESSAGE_SYSTEM,
|
|
MESSAGE_TOOL,
|
|
MESSAGE_USER,
|
|
)
|
|
from sglang.test.ci.ci_register import register_cpu_ci
|
|
|
|
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
|
|
|
|
|
def _text(value: str) -> list[int]:
|
|
return list(value.encode())
|
|
|
|
|
|
class _InklingTokenizer:
|
|
def encode_special(self, token: str) -> int:
|
|
return INKLING_SPECIAL_TOKEN_IDS[token]
|
|
|
|
def encode_text(self, text: str) -> list[int]:
|
|
return _text(text)
|
|
|
|
|
|
class _BaseTokenizer:
|
|
chat_template = None
|
|
|
|
def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
|
|
return _text(text)
|
|
|
|
|
|
def _block(role: str, kind: str, payload: str, author: str = "") -> list[int]:
|
|
return (
|
|
[INKLING_SPECIAL_TOKEN_IDS[role]]
|
|
+ _text(author)
|
|
+ [
|
|
INKLING_SPECIAL_TOKEN_IDS[kind],
|
|
*_text(payload),
|
|
INKLING_SPECIAL_TOKEN_IDS[END_MESSAGE],
|
|
]
|
|
)
|
|
|
|
|
|
class TestInklingRenderer(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tokenizer = _InklingTokenizer()
|
|
|
|
def test_generation_prompt_is_not_prefilled(self):
|
|
actual = render_inkling_messages(
|
|
[{"role": "user", "content": "hello"}], self.tokenizer
|
|
)
|
|
self.assertEqual(
|
|
actual,
|
|
_block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.9")
|
|
+ _block(MESSAGE_USER, CONTENT_TEXT, "hello"),
|
|
)
|
|
self.assertNotEqual(actual[-1], INKLING_SPECIAL_TOKEN_IDS[MESSAGE_MODEL])
|
|
|
|
def test_tool_system_and_effort_have_canonical_prefix_order(self):
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "weather",
|
|
"description": "Lookup weather",
|
|
"parameters": {"type": "object"},
|
|
},
|
|
}
|
|
]
|
|
actual = render_inkling_messages(
|
|
[
|
|
{"role": "system", "content": "original"},
|
|
{"role": "user", "content": "question"},
|
|
],
|
|
self.tokenizer,
|
|
tools=tools,
|
|
reasoning_effort=0.8764,
|
|
)
|
|
tool_json = (
|
|
'[{"description":"Lookup weather","name":"weather",'
|
|
'"parameters":{"type":"object"},"type":"function"}]'
|
|
)
|
|
expected = (
|
|
_block(
|
|
MESSAGE_SYSTEM,
|
|
CONTENT_XML,
|
|
tool_json,
|
|
author="tool_declare",
|
|
)
|
|
+ _block(MESSAGE_SYSTEM, CONTENT_TEXT, "original")
|
|
+ _block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.88")
|
|
+ _block(MESSAGE_USER, CONTENT_TEXT, "question")
|
|
)
|
|
self.assertEqual(actual, expected)
|
|
|
|
def test_multiturn_conversation_has_one_fixed_effort_directive(self):
|
|
system = {"role": "system", "content": "system"}
|
|
user1 = {"role": "user", "content": "user1"}
|
|
assistant1 = {"role": "assistant", "content": "assistant1"}
|
|
user2 = {"role": "user", "content": "user2"}
|
|
prefix = (
|
|
_block(MESSAGE_SYSTEM, CONTENT_TEXT, "system")
|
|
+ _block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.2")
|
|
+ _block(MESSAGE_USER, CONTENT_TEXT, "user1")
|
|
)
|
|
|
|
turn1 = render_inkling_messages(
|
|
[system, user1], self.tokenizer, reasoning_effort=0.2
|
|
)
|
|
turn2 = render_inkling_messages(
|
|
[system, user1, assistant1, user2],
|
|
self.tokenizer,
|
|
reasoning_effort=0.2,
|
|
)
|
|
|
|
self.assertEqual(turn1, prefix)
|
|
self.assertEqual(
|
|
turn2,
|
|
prefix
|
|
+ _block(MESSAGE_MODEL, CONTENT_TEXT, "assistant1")
|
|
+ [INKLING_SPECIAL_TOKEN_IDS[CONTENT_MODEL_END_SAMPLING]]
|
|
+ _block(MESSAGE_USER, CONTENT_TEXT, "user2"),
|
|
)
|
|
|
|
def test_historical_assistant_preserves_parts_and_ends_sampling(self):
|
|
actual = render_inkling_messages(
|
|
[
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "thinking", "thinking": "first"},
|
|
{"type": "text", "text": "visible"},
|
|
{"type": "reasoning", "text": "second"},
|
|
],
|
|
"tool_calls": [
|
|
{
|
|
"id": "call-1",
|
|
"function": {
|
|
"name": "weather",
|
|
"arguments": '{"city":"SF"}',
|
|
},
|
|
}
|
|
],
|
|
}
|
|
],
|
|
self.tokenizer,
|
|
)
|
|
expected = (
|
|
_block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.9")
|
|
+ _block(MESSAGE_MODEL, CONTENT_THINKING, "first")
|
|
+ _block(MESSAGE_MODEL, CONTENT_TEXT, "visible")
|
|
+ _block(MESSAGE_MODEL, CONTENT_THINKING, "second")
|
|
+ _block(
|
|
MESSAGE_MODEL,
|
|
CONTENT_INVOKE_TOOL_JSON,
|
|
'{"name":"weather","args":{"city":"SF"}}',
|
|
author="weather",
|
|
)
|
|
+ [INKLING_SPECIAL_TOKEN_IDS[CONTENT_MODEL_END_SAMPLING]]
|
|
)
|
|
self.assertEqual(actual, expected)
|
|
|
|
def test_empty_assistant_message_does_not_emit_bare_terminator(self):
|
|
"""Bug regression: an assistant message that renders zero blocks
|
|
(content None, no reasoning, no tool calls) appended a bare
|
|
<|content_model_end_sampling|> with no preceding model block —
|
|
injecting a malformed turn terminator into the prompt."""
|
|
actual = render_inkling_messages(
|
|
[
|
|
{"role": "user", "content": "hi"},
|
|
{"role": "assistant", "content": None},
|
|
{"role": "user", "content": "again"},
|
|
],
|
|
self.tokenizer,
|
|
)
|
|
self.assertNotIn(INKLING_SPECIAL_TOKEN_IDS[CONTENT_MODEL_END_SAMPLING], actual)
|
|
|
|
def test_tool_result_renders_image_parts_alongside_text(self):
|
|
"""Bug regression: the tool branch coerced content to a string, so a
|
|
tool_result carrying an image (Claude Code screenshots / Read of a PNG)
|
|
raised TypeError and 500'd the request. Every part must render, and the
|
|
image must emit a placeholder for the MM processor to expand."""
|
|
actual = render_inkling_messages(
|
|
[
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call-1",
|
|
"name": "screenshot",
|
|
"content": [
|
|
{"type": "text", "text": "captured"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": "data:image/png;base64,AAAA"},
|
|
},
|
|
],
|
|
}
|
|
],
|
|
self.tokenizer,
|
|
)
|
|
self.assertEqual(
|
|
actual,
|
|
_block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.9")
|
|
+ _block(MESSAGE_TOOL, CONTENT_TEXT, "captured", author="screenshot")
|
|
+ [
|
|
INKLING_SPECIAL_TOKEN_IDS[MESSAGE_TOOL],
|
|
*_text("screenshot"),
|
|
INKLING_SPECIAL_TOKEN_IDS[CONTENT_IMAGE],
|
|
IMAGE_TOKEN_ID,
|
|
INKLING_SPECIAL_TOKEN_IDS[END_MESSAGE],
|
|
],
|
|
)
|
|
|
|
def test_tool_result_placeholder_count_matches_image_parts(self):
|
|
"""The MM processor harvests media from tool messages and expands one
|
|
placeholder per item, so the counts have to agree or the two passes
|
|
desync."""
|
|
image = {
|
|
"type": "image_url",
|
|
"image_url": {"url": "data:image/png;base64,AAAA"},
|
|
}
|
|
actual = render_inkling_messages(
|
|
[{"role": "tool", "name": "shot", "content": [image, image, image]}],
|
|
self.tokenizer,
|
|
)
|
|
self.assertEqual(actual.count(IMAGE_TOKEN_ID), 3)
|
|
|
|
def test_tool_result_with_multiple_text_blocks_renders_each(self):
|
|
"""A tool_result with 2+ text blocks also arrives as a list and used to
|
|
raise, even with no image involved."""
|
|
actual = render_inkling_messages(
|
|
[
|
|
{
|
|
"role": "tool",
|
|
"name": "bash",
|
|
"content": [
|
|
{"type": "text", "text": "stdout"},
|
|
{"type": "text", "text": "stderr"},
|
|
],
|
|
}
|
|
],
|
|
self.tokenizer,
|
|
)
|
|
self.assertEqual(
|
|
actual,
|
|
_block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.9")
|
|
+ _block(MESSAGE_TOOL, CONTENT_TEXT, "stdout", author="bash")
|
|
+ _block(MESSAGE_TOOL, CONTENT_TEXT, "stderr", author="bash"),
|
|
)
|
|
|
|
def test_empty_tool_result_still_emits_a_block(self):
|
|
"""An empty tool result must not vanish — the tool_call it answers
|
|
would be left dangling."""
|
|
for content in ("", None, []):
|
|
with self.subTest(content=content):
|
|
actual = render_inkling_messages(
|
|
[{"role": "tool", "name": "noop", "content": content}],
|
|
self.tokenizer,
|
|
)
|
|
self.assertEqual(
|
|
actual,
|
|
_block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.9")
|
|
+ _block(MESSAGE_TOOL, CONTENT_TEXT, "", author="noop"),
|
|
)
|
|
|
|
def test_tool_result_author_falls_back_to_tool_call_id(self):
|
|
"""String content still resolves the author from a prior tool_call."""
|
|
actual = render_inkling_messages(
|
|
[
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call-1",
|
|
"function": {"name": "weather", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call-1", "content": "sunny"},
|
|
],
|
|
self.tokenizer,
|
|
)
|
|
self.assertEqual(
|
|
actual[
|
|
-len(_block(MESSAGE_TOOL, CONTENT_TEXT, "sunny", author="weather")) :
|
|
],
|
|
_block(MESSAGE_TOOL, CONTENT_TEXT, "sunny", author="weather"),
|
|
)
|
|
|
|
def test_tool_result_rejects_thinking_parts(self):
|
|
with self.assertRaisesRegex(ValueError, "require role='assistant'"):
|
|
render_inkling_messages(
|
|
[
|
|
{
|
|
"role": "tool",
|
|
"name": "t",
|
|
"content": [{"type": "thinking", "thinking": "nope"}],
|
|
}
|
|
],
|
|
self.tokenizer,
|
|
)
|
|
|
|
def test_reasoning_content_cannot_reorder_thinking_parts(self):
|
|
with self.assertRaisesRegex(ValueError, "cannot mix"):
|
|
render_inkling_messages(
|
|
[
|
|
{
|
|
"role": "assistant",
|
|
"reasoning_content": "legacy",
|
|
"content": [{"type": "thinking", "thinking": "ordered"}],
|
|
}
|
|
],
|
|
self.tokenizer,
|
|
)
|
|
|
|
def test_reasoning_effort_is_two_decimal_quantized_and_validated(self):
|
|
for value, expected in (
|
|
(0.8766, "0.88"),
|
|
(0.0, "0"),
|
|
(0.99, "0.99"),
|
|
(0.125, "0.12"),
|
|
(0.875, "0.88"),
|
|
):
|
|
with self.subTest(value=value):
|
|
actual = render_inkling_messages(
|
|
[{"role": "user", "content": "q"}],
|
|
self.tokenizer,
|
|
reasoning_effort=value,
|
|
)
|
|
directive = _block(
|
|
MESSAGE_SYSTEM,
|
|
CONTENT_TEXT,
|
|
f"Thinking effort level: {expected}",
|
|
)
|
|
self.assertEqual(actual[: len(directive)], directive)
|
|
for value in (-0.1, 1.0, 1.1, float("nan")):
|
|
with self.subTest(value=value), self.assertRaises(ValueError):
|
|
render_inkling_messages(
|
|
[{"role": "user", "content": "q"}],
|
|
self.tokenizer,
|
|
reasoning_effort=value,
|
|
)
|
|
|
|
def test_offline_encoder_uses_the_same_inkling_format(self):
|
|
actual = encode_simple_chat(
|
|
tokenizer=_BaseTokenizer(),
|
|
spec="inkling",
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
)
|
|
self.assertEqual(
|
|
actual,
|
|
_block(MESSAGE_SYSTEM, CONTENT_TEXT, "Thinking effort level: 0.9")
|
|
+ _block(MESSAGE_USER, CONTENT_TEXT, "hello"),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|