main
py 630 lines 18.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from pathlib import Path
6 from types import SimpleNamespace
7
8 import pytest
9 import torch
10
11 from vllm.entrypoints.chat_utils import parse_chat_messages
12 from vllm.renderers.registry import RENDERER_REGISTRY
13 from vllm.tokenizers.deepseek_v4 import get_deepseek_v4_tokenizer
14 from vllm.tokenizers.registry import TokenizerRegistry
15
16 FIXTURES_DIR = Path(__file__).parent / "fixtures" / "deepseek_v4"
17
18
19 class FakeHfTokenizer:
20 vocab_size = 100
21
22 def get_added_vocab(self) -> dict[str, int]:
23 return {"</think>": 100}
24
25 def encode(
26 self,
27 text: str,
28 add_special_tokens: bool = False,
29 **kwargs,
30 ) -> list[int]:
31 self.last_encode = (text, add_special_tokens, kwargs)
32 return [len(text)]
33
34
35 def _tokenizer():
36 return get_deepseek_v4_tokenizer(FakeHfTokenizer())
37
38
39 def _model_config():
40 return SimpleNamespace(
41 multimodal_config=None,
42 allowed_local_media_path="",
43 allowed_media_domains=None,
44 enable_prompt_embeds=False,
45 )
46
47
48 def _load_reference_case(case_id: int):
49 data = json.loads((FIXTURES_DIR / f"test_input_{case_id}.json").read_text())
50 if isinstance(data, dict):
51 return data["messages"], data.get("tools")
52 return data, None
53
54
55 def _render_reference_case(case_id: int, **kwargs):
56 messages, tools = _load_reference_case(case_id)
57 # Preserve content blocks for encoding without fetching fixture media.
58 return _tokenizer().apply_chat_template(
59 conversation=messages,
60 messages=messages,
61 tools=tools,
62 tokenize=False,
63 **kwargs,
64 )
65
66
67 def test_deepseek_v4_tokenizer_registered():
68 assert TokenizerRegistry.load_tokenizer_cls("deepseek_v4").__name__ == (
69 "DeepseekV4Tokenizer"
70 )
71 assert RENDERER_REGISTRY.load_renderer_cls("deepseek_v4").__name__ == (
72 "DeepseekV4Renderer"
73 )
74
75
76 def test_deepseek_v4_defaults_to_thinking_with_high_effort():
77 prompt = _tokenizer().apply_chat_template(
78 [{"role": "user", "content": "Hello"}],
79 tokenize=False,
80 )
81
82 assert prompt.startswith(
83 "<|begin▁of▁sentence|>Reasoning Effort: Absolute maximum"
84 )
85 assert prompt.endswith("<|Assistant|><think>")
86
87
88 @pytest.mark.parametrize("kwargs", [{"thinking": True}, {"enable_thinking": True}])
89 def test_deepseek_v4_enables_thinking_with_compatible_kwargs(kwargs):
90 prompt = _tokenizer().apply_chat_template(
91 [{"role": "user", "content": "Hello"}],
92 tokenize=False,
93 **kwargs,
94 )
95
96 assert prompt.startswith(
97 "<|begin▁of▁sentence|>Reasoning Effort: Absolute maximum"
98 )
99 assert prompt.endswith("<|Assistant|><think>")
100
101
102 @pytest.mark.parametrize("kwargs", [{"thinking": False}, {"enable_thinking": False}])
103 def test_deepseek_v4_explicitly_disables_thinking(kwargs):
104 prompt = _tokenizer().apply_chat_template(
105 [{"role": "user", "content": "Hello"}],
106 tokenize=False,
107 **kwargs,
108 )
109
110 assert prompt == ("<|begin▁of▁sentence|><|User|>Hello<|Assistant|></think>")
111
112
113 @pytest.mark.parametrize(
114 ("enable_thinking", "thinking_token"),
115 [(False, "</think>"), (True, "<think>")],
116 )
117 def test_deepseek_v4_appends_assistant_transition_after_trailing_system(
118 enable_thinking, thinking_token
119 ):
120 prompt = _tokenizer().apply_chat_template(
121 [
122 {"role": "system", "content": "You are helpful."},
123 {"role": "user", "content": "What is 2+2?"},
124 {"role": "system", "content": "Be concise."},
125 ],
126 tokenize=False,
127 enable_thinking=enable_thinking,
128 reasoning_effort="low",
129 )
130
131 assert prompt == (
132 "<|begin▁of▁sentence|>You are helpful."
133 f"<|User|>What is 2+2?Be concise.<|Assistant|>{thinking_token}"
134 )
135
136
137 def test_deepseek_v4_does_not_transition_after_mid_conversation_system():
138 prompt = _tokenizer().apply_chat_template(
139 [
140 {"role": "user", "content": "What is 3+3?"},
141 {"role": "assistant", "content": "6"},
142 {"role": "system", "content": "Answer in French."},
143 {"role": "user", "content": "What is 5+5?"},
144 ],
145 tokenize=False,
146 enable_thinking=False,
147 )
148
149 assert prompt == (
150 "<|begin▁of▁sentence|><|User|>What is 3+3?"
151 "<|Assistant|></think>6<|end▁of▁sentence|>"
152 "Answer in French.<|User|>What is 5+5?"
153 "<|Assistant|></think>"
154 )
155
156
157 @pytest.mark.parametrize(
158 ("enable_thinking", "expected"),
159 [
160 (
161 False,
162 "<|begin▁of▁sentence|>rules<|Assistant|></think>answer"
163 "<|end▁of▁sentence|>",
164 ),
165 (
166 True,
167 "<|begin▁of▁sentence|>rules<|Assistant|><think>reason</think>answer"
168 "<|end▁of▁sentence|>",
169 ),
170 ],
171 )
172 def test_deepseek_v4_transitions_from_system_to_assistant(enable_thinking, expected):
173 prompt = _tokenizer().apply_chat_template(
174 [
175 {"role": "system", "content": "rules"},
176 {
177 "role": "assistant",
178 "reasoning": "reason",
179 "content": "answer",
180 },
181 ],
182 tokenize=False,
183 enable_thinking=enable_thinking,
184 reasoning_effort="low",
185 )
186
187 assert prompt == expected
188
189
190 def test_deepseek_v4_unknown_role_raises_value_error():
191 # Invalid roles are client errors: they must surface as ValueError
192 # (mapped to HTTP 400 by the OpenAI serving layer), not
193 # NotImplementedError (mapped to HTTP 501).
194 with pytest.raises(ValueError, match="Invalid role: SYSTEM"):
195 _tokenizer().apply_chat_template(
196 [{"role": "SYSTEM", "content": "Hello"}],
197 tokenize=False,
198 )
199
200
201 def test_deepseek_v4_uses_v4_tool_prompt_from_request_tools():
202 tools = [
203 {
204 "type": "function",
205 "function": {
206 "name": "get_weather",
207 "description": "Get weather for a city",
208 "parameters": {
209 "type": "object",
210 "properties": {"city": {"type": "string"}},
211 "required": ["city"],
212 },
213 },
214 }
215 ]
216
217 prompt = _tokenizer().apply_chat_template(
218 [{"role": "user", "content": "Weather?"}],
219 tools=tools,
220 tokenize=False,
221 )
222
223 assert "## Tools" in prompt
224 assert "<|DSML|tool_calls>" in prompt
225 assert "</|DSML|tool_calls>" in prompt
226 assert "function_calls" not in prompt
227 assert '"name": "get_weather"' in prompt
228 assert prompt.startswith(
229 "<|begin▁of▁sentence|>Reasoning Effort: Absolute maximum"
230 )
231 assert prompt.endswith("<|User|>Weather?<|Assistant|><think>")
232
233
234 def test_deepseek_v4_renders_parsed_history_tool_arguments():
235 messages = [
236 {"role": "user", "content": "List the repo"},
237 {
238 "role": "assistant",
239 "tool_calls": [
240 {
241 "id": "call_1",
242 "type": "function",
243 "function": {
244 "name": "str_replace_editor",
245 "arguments": '{"command": "view", "path": "/testbed"}',
246 },
247 }
248 ],
249 },
250 {
251 "role": "tool",
252 "tool_call_id": "call_1",
253 "content": "file list",
254 },
255 ]
256 tools = [
257 {
258 "type": "function",
259 "function": {
260 "name": "str_replace_editor",
261 "description": "Edit files",
262 "parameters": {
263 "type": "object",
264 "properties": {
265 "command": {"type": "string"},
266 "path": {"type": "string"},
267 },
268 "required": ["command", "path"],
269 },
270 },
271 }
272 ]
273 conversation, _, _ = parse_chat_messages(
274 messages,
275 _model_config(),
276 content_format="string",
277 )
278
279 prompt = _tokenizer().apply_chat_template(
280 conversation=conversation,
281 messages=messages,
282 tools=tools,
283 tokenize=False,
284 )
285
286 assert '<|DSML|parameter name="command" string="true">view' in prompt
287 assert '<|DSML|parameter name="path" string="true">/testbed' in prompt
288 assert 'parameter name="arguments"' not in prompt
289
290
291 @pytest.mark.parametrize(
292 ("reasoning_effort", "expected_prefix"),
293 [
294 ("low", "<|begin▁of▁sentence|><|User|>Hello"),
295 ("high", "<|begin▁of▁sentence|>Reasoning Effort: Absolute maximum"),
296 ("max", "<|begin▁of▁sentence|>Reasoning Effort: Beyond maximum"),
297 ],
298 )
299 def test_deepseek_v4_renders_0731_reasoning_effort_prompts(
300 reasoning_effort, expected_prefix
301 ):
302 prompt = _tokenizer().apply_chat_template(
303 [{"role": "user", "content": "Hello"}],
304 tokenize=False,
305 enable_thinking=True,
306 reasoning_effort=reasoning_effort,
307 )
308
309 assert prompt.endswith("<|Assistant|><think>")
310 assert prompt.startswith(expected_prefix)
311
312
313 def test_deepseek_v4_none_reasoning_effort_disables_thinking():
314 prompt = _tokenizer().apply_chat_template(
315 [{"role": "user", "content": "Hello"}],
316 tokenize=False,
317 enable_thinking=True,
318 reasoning_effort="none",
319 )
320
321 assert prompt == ("<|begin▁of▁sentence|><|User|>Hello<|Assistant|></think>")
322
323
324 @pytest.mark.parametrize(
325 ("reasoning_effort", "expected_mode", "expected_effort"),
326 [
327 ("none", "chat", None),
328 ("minimal", "thinking", "low"),
329 ("low", "thinking", "low"),
330 ("medium", "thinking", "low"),
331 ("high", "thinking", "high"),
332 ("xhigh", "thinking", "high"),
333 ("max", "thinking", "max"),
334 ("unexpected", "thinking", "high"),
335 ],
336 )
337 def test_deepseek_v4_maps_compatible_thinking_reasoning_effort_values(
338 monkeypatch: pytest.MonkeyPatch,
339 reasoning_effort,
340 expected_mode,
341 expected_effort,
342 ):
343 captured_kwargs = []
344
345 def fake_encode_messages(messages, **kwargs):
346 captured_kwargs.append(kwargs)
347 return "prompt"
348
349 monkeypatch.setattr(
350 "vllm.tokenizers.deepseek_v4.encode_messages",
351 fake_encode_messages,
352 )
353
354 _tokenizer().apply_chat_template(
355 [{"role": "user", "content": "Hello"}],
356 tokenize=False,
357 enable_thinking=True,
358 reasoning_effort=reasoning_effort,
359 )
360
361 assert captured_kwargs[-1]["thinking_mode"] == expected_mode
362 assert captured_kwargs[-1]["reasoning_effort"] == expected_effort
363
364
365 def test_deepseek_v4_renders_0731_max_reasoning_effort():
366 prompt = _tokenizer().apply_chat_template(
367 [{"role": "user", "content": "Hello"}],
368 tokenize=False,
369 enable_thinking=True,
370 reasoning_effort="max",
371 )
372
373 assert prompt.startswith("<|begin▁of▁sentence|>Reasoning Effort: Beyond maximum")
374
375
376 def test_deepseek_v4_maps_xhigh_to_high_reasoning_effort():
377 prompt = _tokenizer().apply_chat_template(
378 [{"role": "user", "content": "Hello"}],
379 tokenize=False,
380 enable_thinking=True,
381 reasoning_effort="xhigh",
382 )
383
384 assert prompt.startswith(
385 "<|begin▁of▁sentence|>Reasoning Effort: Absolute maximum"
386 )
387
388
389 @pytest.mark.parametrize(
390 ("case_id", "kwargs"),
391 [
392 (1, {"thinking": True, "reasoning_effort": "low"}),
393 (2, {"thinking": True, "reasoning_effort": "low"}),
394 (3, {"thinking": True, "reasoning_effort": "low"}),
395 (4, {"thinking": False}),
396 (5, {"thinking": False}),
397 ],
398 )
399 def test_deepseek_v4_matches_reference_golden_fixtures(case_id, kwargs):
400 prompt = _render_reference_case(case_id, **kwargs)
401
402 expected = (FIXTURES_DIR / f"test_output_{case_id}.txt").read_text()
403 assert prompt == expected
404
405
406 def test_deepseek_v4_rejects_empty_developer_content():
407 with pytest.raises(ValueError):
408 _tokenizer().apply_chat_template(
409 [{"role": "developer", "content": ""}],
410 tokenize=False,
411 enable_thinking=True,
412 reasoning_effort="low",
413 )
414
415
416 @pytest.mark.parametrize(
417 "kwargs",
418 [
419 {"thinking_mode": "bogus"},
420 {"thinking_mode": "thinking", "reasoning_effort": "turbo"},
421 ],
422 )
423 def test_deepseek_v4_encode_messages_rejects_invalid_arguments(kwargs):
424 from vllm.tokenizers.deepseek_v4_encoding import encode_messages
425
426 with pytest.raises(ValueError):
427 encode_messages([{"role": "user", "content": "Hello"}], **kwargs)
428
429
430 def test_deepseek_v4_image_blocks_become_placeholders():
431 prompt = _tokenizer().apply_chat_template(
432 [
433 {
434 "role": "user",
435 "content": [
436 {"type": "text", "text": "first:"},
437 {"type": "image_url", "image_url": {"url": "file:///a.png"}},
438 {"type": "text", "text": "second:"},
439 {"type": "image", "source": {"data": "AAAA"}},
440 ],
441 }
442 ],
443 tokenize=False,
444 thinking=False,
445 )
446
447 assert (
448 "<|User|>first:\n\n<|deepseek_image|>\n\n"
449 "second:\n\n<|deepseek_image|>" in prompt
450 )
451
452
453 def test_deepseek_v4_image_sentinel_ids_match_tokenizer():
454 """The borrowed sentinel ids must line up with the reserved
455 ``<|place_holder_mm_span_XXXX|>`` tokens in the tokenizer."""
456 from vllm.models.deepseek_v4.common.mm_preprocess import (
457 IMAGE_SENTINEL_BASE_ID,
458 IMAGE_SENTINEL_TOKEN_NAMES,
459 image_sentinel_mask,
460 validate_image_sentinel_ids,
461 )
462
463 class FakeTokenizer:
464 def __init__(self, offset: int = 0) -> None:
465 self.offset = offset
466
467 def convert_tokens_to_ids(self, token: str) -> int:
468 return (
469 IMAGE_SENTINEL_BASE_ID
470 + self.offset
471 + (IMAGE_SENTINEL_TOKEN_NAMES.index(token))
472 )
473
474 validate_image_sentinel_ids(FakeTokenizer()) # no raise
475 with pytest.raises(ValueError, match="sentinel"):
476 validate_image_sentinel_ids(FakeTokenizer(offset=1))
477
478 ids = torch.tensor(
479 [
480 1,
481 IMAGE_SENTINEL_BASE_ID,
482 IMAGE_SENTINEL_BASE_ID + 4,
483 IMAGE_SENTINEL_BASE_ID + 5,
484 129264,
485 ]
486 )
487 assert image_sentinel_mask(ids).tolist() == [False, True, True, False, False]
488
489
490 def _request_tools():
491 return [
492 {
493 "type": "function",
494 "function": {
495 "name": "get_weather",
496 "description": "Get weather for a city",
497 "parameters": {
498 "type": "object",
499 "properties": {"city": {"type": "string"}},
500 "required": ["city"],
501 },
502 },
503 }
504 ]
505
506
507 def _encode_reference(messages):
508 from vllm.tokenizers.deepseek_v4_encoding import encode_messages
509
510 return encode_messages(messages, thinking_mode="thinking", reasoning_effort="low")
511
512
513 def test_deepseek_v4_attaches_request_tools_to_existing_system_message():
514 messages = [
515 {"role": "system", "content": "You are helpful."},
516 {"role": "user", "content": "Weather in Paris?"},
517 ]
518
519 prompt = _tokenizer().apply_chat_template(
520 messages,
521 tools=_request_tools(),
522 tokenize=False,
523 thinking=True,
524 reasoning_effort="low",
525 )
526
527 expected = _encode_reference(
528 [{**messages[0], "tools": _request_tools()}, messages[1]]
529 )
530 assert prompt == expected
531 assert prompt.startswith("<|begin▁of▁sentence|>You are helpful.\n\n## Tools")
532 assert prompt.count("## Tools") == 1
533
534
535 def test_deepseek_v4_synthetic_system_only_when_no_system_message():
536 """Without a system message, request tools still get a synthetic leading
537 system entry."""
538 messages = [{"role": "user", "content": "Weather in Paris?"}]
539
540 prompt = _tokenizer().apply_chat_template(
541 messages,
542 tools=_request_tools(),
543 tokenize=False,
544 thinking=True,
545 reasoning_effort="low",
546 )
547
548 expected = _encode_reference(
549 [{"role": "system", "tools": _request_tools()}, messages[0]]
550 )
551 assert prompt == expected
552 assert prompt.startswith("<|begin▁of▁sentence|>\n\n## Tools")
553
554
555 def test_deepseek_v4_request_tools_override_system_message_tools():
556 message_tools = [
557 {
558 "type": "function",
559 "function": {
560 "name": "message_level_tool",
561 "description": "Should not be rendered",
562 "parameters": {"type": "object", "properties": {}},
563 },
564 }
565 ]
566 system_message = {
567 "role": "system",
568 "content": "You are helpful.",
569 "tools": message_tools,
570 }
571 user_message = {"role": "user", "content": "Weather in Paris?"}
572
573 prompt = _tokenizer().apply_chat_template(
574 [system_message, user_message],
575 tools=_request_tools(),
576 tokenize=False,
577 thinking=True,
578 reasoning_effort="low",
579 )
580
581 expected = _encode_reference(
582 [{**system_message, "tools": _request_tools()}, user_message]
583 )
584 assert prompt == expected
585 assert '"name": "get_weather"' in prompt
586 assert "message_level_tool" not in prompt
587
588
589 def test_deepseek_v4_attaches_request_tools_to_first_system_message_only():
590 """Only the first system message, at any index, gets the request tools."""
591 messages = [
592 {"role": "user", "content": "Hi"},
593 {"role": "system", "content": "First system."},
594 {"role": "system", "content": "Second system."},
595 {"role": "user", "content": "Weather in Paris?"},
596 ]
597
598 prompt = _tokenizer().apply_chat_template(
599 messages,
600 tools=_request_tools(),
601 tokenize=False,
602 thinking=True,
603 reasoning_effort="low",
604 )
605
606 expected = _encode_reference(
607 [
608 messages[0],
609 {**messages[1], "tools": _request_tools()},
610 messages[2],
611 messages[3],
612 ]
613 )
614 assert prompt == expected
615 assert "First system.\n\n## Tools" in prompt
616 assert prompt.count("## Tools") == 1
617
618
619 def test_deepseek_v4_request_tools_do_not_mutate_caller_messages():
620 import copy
621
622 messages = [
623 {"role": "system", "content": "You are helpful."},
624 {"role": "user", "content": "Weather in Paris?"},
625 ]
626 snapshot = copy.deepcopy(messages)
627
628 _tokenizer().apply_chat_template(messages, tools=_request_tools(), tokenize=False)
629
630 assert messages == snapshot