main
py 484 lines 16.6 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Golden tests for the native Inkling renderer encoding.
4
5 The message fixtures mirror the Rust renderer's fixture tests
6 (``rust/src/chat/src/renderer/inkling/tests.rs``) so the two frontends stay
7 in token-level parity. Image blocks emit the bare ``<|content_image|>`` marker;
8 ``InklingMultiModalProcessor`` inserts the per-patch placeholder run.
9 """
10
11 import pytest
12
13 from vllm.renderers.inkling import (
14 InklingRenderer,
15 _HfBackedTmlTokenizer,
16 _resolve_reasoning_effort,
17 )
18 from vllm.renderers.inkling_encoding import (
19 SPECIAL_TOKEN_SPELLINGS,
20 render_inkling_messages,
21 )
22 from vllm.renderers.params import ChatParams
23
24
25 @pytest.fixture()
26 def should_do_global_cleanup_after_test() -> bool:
27 # These tests touch no distributed or device state; the global
28 # cleanup fixture is unnecessary (and trips a torch MPS allocator
29 # assert on macOS dev machines).
30 return False
31
32
33 # One id per special token, mirroring the real Inkling vocab layout; plain
34 # text encodes one token per character so decoded output is exact.
35 _SPECIAL_VOCAB = {
36 "<|message_user|>": 200000,
37 "<|message_model|>": 200001,
38 "<|message_system|>": 200002,
39 "<|message_tool|>": 200003,
40 "<|content_text|>": 200004,
41 "<|content_image|>": 200005,
42 "<|content_model_end_sampling|>": 200006,
43 "<|content_thinking|>": 200008,
44 "<|end_message|>": 200010,
45 "<|content_audio_input|>": 200020,
46 # The HF vocab spells CONTENT_XML as an unused slot.
47 "<|unused_200024|>": 200024,
48 "<|audio_end|>": 200043,
49 "<|content_invoke_tool_json|>": 200049,
50 }
51
52 _ID_TO_SPECIAL = {v: k for k, v in _SPECIAL_VOCAB.items()}
53
54
55 class FakeHfTokenizer:
56 def get_vocab(self):
57 return dict(_SPECIAL_VOCAB)
58
59 def encode(self, text, add_special_tokens=False):
60 assert not add_special_tokens
61 return [ord(ch) for ch in text]
62
63
64 def decode(token_ids):
65 return "".join(
66 _ID_TO_SPECIAL.get(tid, chr(tid) if tid < 200000 else f"<{tid}>")
67 for tid in token_ids
68 )
69
70
71 @pytest.fixture
72 def inkling_tokenizer():
73 return _HfBackedTmlTokenizer(FakeHfTokenizer())
74
75
76 def render_text(inkling_tokenizer, messages, **kwargs):
77 return decode(render_inkling_messages(messages, inkling_tokenizer, **kwargs))
78
79
80 class TestRustFixtureParity:
81 def test_tool_round_trip(self, inkling_tokenizer):
82 messages = [
83 {
84 "role": "assistant",
85 "reasoning_content": "think",
86 "content": "answer",
87 "tool_calls": [
88 {
89 "id": "call_1",
90 "function": {
91 "name": "get_weather",
92 "arguments": '{"city":"SF"}',
93 },
94 }
95 ],
96 },
97 {"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
98 ]
99 assert render_text(
100 inkling_tokenizer, messages, add_generation_prompt=False
101 ) == (
102 "<|message_model|><|content_thinking|>think<|end_message|>"
103 "<|message_model|><|content_text|>answer<|end_message|>"
104 "<|message_model|>get_weather<|content_invoke_tool_json|>"
105 '{"name":"get_weather","args":{"city":"SF"}}<|end_message|>'
106 "<|content_model_end_sampling|>"
107 "<|message_tool|>get_weather<|content_text|>sunny<|end_message|>"
108 )
109
110 def test_tool_declare(self, inkling_tokenizer):
111 tools = [
112 {
113 "type": "function",
114 "function": {
115 "name": "get_weather",
116 "description": "Get weather information",
117 "parameters": {
118 "type": "object",
119 "required": ["city"],
120 "properties": {"city": {"type": "string"}},
121 },
122 },
123 }
124 ]
125 messages = [
126 {
127 "role": "developer",
128 "content": "rules",
129 "tools": [
130 {
131 "type": "function",
132 "function": {
133 "name": "local_tool",
134 "parameters": {"z": 1, "a": {"b": 2}},
135 },
136 }
137 ],
138 },
139 {"role": "user", "content": "hi"},
140 ]
141 assert render_text(inkling_tokenizer, messages, tools=tools) == (
142 "<|message_system|>tool_declare<|unused_200024|>"
143 '[{"description":"Get weather information","name":"get_weather",'
144 '"parameters":{"properties":{"city":{"type":"string"}},'
145 '"required":["city"],"type":"object"},"type":"function"},'
146 '{"description":"","name":"local_tool",'
147 '"parameters":{"a":{"b":2},"z":1},"type":"function"}]'
148 "<|end_message|>"
149 "<|message_system|><|content_text|>rules<|end_message|>"
150 "<|message_user|><|content_text|>hi<|end_message|>"
151 "<|message_model|>"
152 )
153
154 def test_text_image(self, inkling_tokenizer):
155 messages = [
156 {
157 "role": "user",
158 "content": [
159 {"type": "text", "text": "look"},
160 {"type": "image_url", "image_url": "data:image/png;base64,"},
161 ],
162 }
163 ]
164 # Multimodal preprocessing expands this bare marker after rendering.
165 assert render_text(inkling_tokenizer, messages) == (
166 "<|message_user|><|content_text|>look<|end_message|>"
167 "<|message_user|><|content_image|><|end_message|>"
168 "<|message_model|>"
169 )
170
171
172 class TestRenderingSemantics:
173 @pytest.mark.parametrize(
174 ("value", "expected"),
175 [
176 ("none", 0.0),
177 ("minimal", 0.1),
178 ("low", 0.2),
179 ("medium", 0.7),
180 ("high", 0.9),
181 ("xhigh", 0.99),
182 ("max", 0.99),
183 (None, 0.9),
184 (0.8, 0.8),
185 (True, None),
186 ("invalid", None),
187 ],
188 )
189 def test_resolve_reasoning_effort(self, value, expected):
190 assert _resolve_reasoning_effort(value) == expected
191
192 def test_generation_prompt_default(self, inkling_tokenizer):
193 text = render_text(inkling_tokenizer, [{"role": "user", "content": "hi"}])
194 assert text.endswith("<|end_message|><|message_model|>")
195
196 def test_developer_folds_into_system(self, inkling_tokenizer):
197 assert (
198 render_text(
199 inkling_tokenizer,
200 [{"role": "developer", "content": "be nice"}],
201 add_generation_prompt=False,
202 )
203 == "<|message_system|><|content_text|>be nice<|end_message|>"
204 )
205
206 def test_empty_string_content_skipped(self, inkling_tokenizer):
207 assert (
208 render_text(
209 inkling_tokenizer,
210 [{"role": "user", "content": ""}],
211 add_generation_prompt=False,
212 )
213 == ""
214 )
215
216 def test_empty_reasoning_skipped(self, inkling_tokenizer):
217 assert (
218 render_text(
219 inkling_tokenizer,
220 [{"role": "assistant", "reasoning_content": "", "content": "hi"}],
221 add_generation_prompt=False,
222 )
223 == "<|message_model|><|content_text|>hi<|end_message|>"
224 "<|content_model_end_sampling|>"
225 )
226
227 def test_reasoning_field(self, inkling_tokenizer):
228 messages = [
229 {
230 "role": "assistant",
231 "reasoning": "think",
232 "content": "answer",
233 }
234 ]
235 assert render_text(
236 inkling_tokenizer, messages, add_generation_prompt=False
237 ) == (
238 "<|message_model|><|content_thinking|>think<|end_message|>"
239 "<|message_model|><|content_text|>answer<|end_message|>"
240 "<|content_model_end_sampling|>"
241 )
242
243 def test_audio_part(self, inkling_tokenizer):
244 messages = [
245 {
246 "role": "user",
247 "content": [{"type": "input_audio", "input_audio": {}}],
248 }
249 ]
250 assert render_text(
251 inkling_tokenizer, messages, add_generation_prompt=False
252 ) == ("<|message_user|><|content_audio_input|><|audio_end|><|end_message|>")
253
254 def test_tool_response_name_from_message(self, inkling_tokenizer):
255 assert (
256 render_text(
257 inkling_tokenizer,
258 [{"role": "tool", "name": "my_tool", "content": "ok"}],
259 add_generation_prompt=False,
260 )
261 == "<|message_tool|>my_tool<|content_text|>ok<|end_message|>"
262 )
263
264 def test_tool_call_args_object_form(self, inkling_tokenizer):
265 messages = [
266 {
267 "role": "assistant",
268 "tool_calls": [
269 {
270 "id": "x",
271 "function": {
272 "name": "f",
273 # dict-form arguments, unsorted keys
274 "arguments": {"z": 1, "a": 2},
275 },
276 }
277 ],
278 }
279 ]
280 assert render_text(
281 inkling_tokenizer, messages, add_generation_prompt=False
282 ) == (
283 "<|message_model|>f<|content_invoke_tool_json|>"
284 '{"name":"f","args":{"a":2,"z":1}}<|end_message|>'
285 "<|content_model_end_sampling|>"
286 )
287
288 def test_tool_call_empty_args(self, inkling_tokenizer):
289 messages = [
290 {
291 "role": "assistant",
292 "tool_calls": [{"id": "x", "function": {"name": "f", "arguments": ""}}],
293 }
294 ]
295 assert render_text(
296 inkling_tokenizer, messages, add_generation_prompt=False
297 ) == (
298 "<|message_model|>f<|content_invoke_tool_json|>"
299 '{"name":"f","args":{}}<|end_message|>'
300 "<|content_model_end_sampling|>"
301 )
302
303 def test_tool_call_non_object_args_rejected(self, inkling_tokenizer):
304 messages = [
305 {
306 "role": "assistant",
307 "tool_calls": [
308 {"id": "x", "function": {"name": "f", "arguments": "[1]"}}
309 ],
310 }
311 ]
312 with pytest.raises(TypeError, match="decode to an object"):
313 render_inkling_messages(
314 messages, inkling_tokenizer, add_generation_prompt=False
315 )
316
317 def test_unsupported_role_rejected(self, inkling_tokenizer):
318 with pytest.raises(ValueError, match="unsupported Inkling message role"):
319 render_inkling_messages(
320 [{"role": "narrator", "content": "hi"}], inkling_tokenizer
321 )
322
323
324 class TestReasoningEffort:
325 def test_frontend_defaults_to_high(self, inkling_tokenizer):
326 renderer = InklingRenderer.__new__(InklingRenderer)
327 renderer._inkling_tokenizer = inkling_tokenizer
328
329 text = decode(
330 renderer._render(
331 [
332 {"role": "system", "content": "rules"},
333 {"role": "user", "content": "hi"},
334 ],
335 ChatParams(),
336 )
337 )
338
339 assert text.startswith(
340 "<|message_system|><|content_text|>rules<|end_message|>"
341 "<|message_system|><|content_text|>Thinking effort level: 0.9"
342 "<|end_message|>"
343 )
344
345 @pytest.mark.parametrize("value", ["none", 0, 0.0, -0.0])
346 def test_zero_effort_has_one_canonical_spelling(self, inkling_tokenizer, value):
347 renderer = InklingRenderer.__new__(InklingRenderer)
348 renderer._inkling_tokenizer = inkling_tokenizer
349
350 text = decode(
351 renderer._render(
352 [{"role": "user", "content": "hi"}],
353 ChatParams(chat_template_kwargs={"reasoning_effort": value}),
354 )
355 )
356
357 assert text.startswith(
358 "<|message_system|><|content_text|>Thinking effort level: 0.0"
359 "<|end_message|>"
360 )
361
362 def test_emits_one_effort_after_initial_prefix(self, inkling_tokenizer):
363 effort_block = (
364 "<|message_system|><|content_text|>Thinking effort level: 0.7"
365 "<|end_message|>"
366 )
367 text = render_text(
368 inkling_tokenizer,
369 [
370 {"role": "system", "content": "rules"},
371 {"role": "developer", "content": "policy"},
372 {"role": "user", "content": "user1"},
373 {"role": "assistant", "content": "assistant1"},
374 {"role": "user", "content": "user2"},
375 ],
376 tools=[{"type": "function", "function": {"name": "f"}}],
377 reasoning_effort=0.7,
378 )
379
380 assert text.count(effort_block) == 1
381 assert (
382 text.index("tool_declare")
383 < text.index("rules")
384 < text.index("policy")
385 < text.index(effort_block)
386 < text.index("user1")
387 < text.index("assistant1")
388 < text.index("user2")
389 )
390
391 def test_renders_after_tool_declare(self, inkling_tokenizer):
392 tools = [{"type": "function", "function": {"name": "f"}}]
393 text = render_text(
394 inkling_tokenizer,
395 [{"role": "user", "content": "hi"}],
396 tools=tools,
397 reasoning_effort=0.8,
398 )
399 declare_end = text.index("<|end_message|>") + len("<|end_message|>")
400 assert text[declare_end:].startswith(
401 "<|message_system|><|content_text|>Thinking effort level: 0.8"
402 "<|end_message|>"
403 )
404
405 @pytest.mark.parametrize(
406 ("value", "expected"),
407 [(0.2, "0.2"), (0.7, "0.7"), (0.9, "0.9"), (0.99, "0.99")],
408 )
409 def test_formats_at_most_two_decimals(self, inkling_tokenizer, value, expected):
410 text = render_text(
411 inkling_tokenizer,
412 [{"role": "user", "content": "hi"}],
413 reasoning_effort=value,
414 )
415 assert f"Thinking effort level: {expected}<|end_message|>" in text
416
417 def test_absent_emits_no_block(self, inkling_tokenizer):
418 text = render_text(inkling_tokenizer, [{"role": "user", "content": "hi"}])
419 assert "Thinking effort" not in text
420
421 @pytest.mark.parametrize("value", [0.9900001, 1, 1.5, -0.1])
422 def test_out_of_range_rejected(self, inkling_tokenizer, value):
423 with pytest.raises(ValueError, match="must be in"):
424 render_inkling_messages(
425 [{"role": "user", "content": "hi"}],
426 inkling_tokenizer,
427 reasoning_effort=value,
428 )
429
430
431 class TestSpecialTokenResolution:
432 def test_missing_special_token_raises(self):
433 class IncompleteTokenizer(FakeHfTokenizer):
434 def get_vocab(self):
435 vocab = dict(_SPECIAL_VOCAB)
436 del vocab["<|content_invoke_tool_json|>"]
437 return vocab
438
439 with pytest.raises(ValueError, match="missing special tokens"):
440 _HfBackedTmlTokenizer(IncompleteTokenizer())
441
442 def test_semantic_spelling_preferred(self):
443 class SemanticSpellingTokenizer(FakeHfTokenizer):
444 def get_vocab(self):
445 vocab = dict(_SPECIAL_VOCAB)
446 del vocab["<|unused_200024|>"]
447 vocab["<|content_xml|>"] = 200024
448 return vocab
449
450 inkling_tokenizer = _HfBackedTmlTokenizer(SemanticSpellingTokenizer())
451 tools = [{"type": "function", "function": {"name": "f"}}]
452 ids = render_inkling_messages(
453 [{"role": "user", "content": "hi"}], inkling_tokenizer, tools=tools
454 )
455 assert 200024 in ids
456
457 def test_all_spellings_covered(self):
458 # Every semantic token must resolve from the reference vocab.
459 for token, spellings in SPECIAL_TOKEN_SPELLINGS.items():
460 assert any(s in _SPECIAL_VOCAB for s in spellings), token
461
462
463 def test_render_inkling_messages_direct_protocol():
464 """The encoding core only needs the structural tokenizer protocol."""
465
466 class ProtocolTokenizer:
467 def encode_text(self, text):
468 return [ord(c) for c in text]
469
470 def encode_special(self, token):
471 return {
472 "<|message_user|>": 200000,
473 "<|message_model|>": 200001,
474 "<|content_text|>": 200004,
475 "<|content_model_end_sampling|>": 200006,
476 "<|end_message|>": 200010,
477 }[token]
478
479 ids = render_inkling_messages(
480 [{"role": "user", "content": "hi"}],
481 ProtocolTokenizer(),
482 add_generation_prompt=True,
483 )
484 assert ids == [200000, 200004, ord("h"), ord("i"), 200010, 200001]