main
py 648 lines 23.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 # ruff: noqa
4 # fmt: off
5
6 """
7 DeepSeek-V4 Encoding
8
9 A self-contained implementation for encoding DeepSeek-V4 chat messages with tool
10 calling, thinking mode, and quick instruction task support.
11 """
12
13 from typing import Any, Dict, List, Union, Optional
14 import copy
15 import json
16
17 # ============================================================
18 # Special Tokens
19 # ============================================================
20
21 bos_token: str = "<|begin▁of▁sentence|>"
22 eos_token: str = "<|end▁of▁sentence|>"
23 thinking_start_token: str = "<think>"
24 thinking_end_token: str = "</think>"
25 dsml_token: str = "|DSML|"
26
27 USER_SP_TOKEN = "<|User|>"
28 ASSISTANT_SP_TOKEN = "<|Assistant|>"
29 LATEST_REMINDER_SP_TOKEN = "<|latest_reminder|>"
30
31 # Placeholder text inlined at each image's position; the multimodal processor
32 # later expands it into the sentinel token block.
33 IMAGE_PLACEHOLDER = "<|deepseek_image|>"
34
35 # Task special tokens for internal classification tasks
36 DS_TASK_SP_TOKENS = {
37 "action": "<|action|>",
38 "query": "<|query|>",
39 "authority": "<|authority|>",
40 "domain": "<|domain|>",
41 "title": "<|title|>",
42 "read_url": "<|read_url|>",
43 }
44 VALID_TASKS = set(DS_TASK_SP_TOKENS.keys())
45
46 # ============================================================
47 # Templates
48 # ============================================================
49
50 system_msg_template: str = "{content}"
51 user_msg_template: str = "{content}"
52 latest_reminder_msg_template: str = "{content}"
53 assistant_msg_template: str = "{reasoning}{content}{tool_calls}" + eos_token
54 assistant_msg_wo_eos_template: str = "{reasoning}{content}{tool_calls}"
55 thinking_template: str = "{reasoning}"
56
57 response_format_template: str = (
58 "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}"
59 )
60 tool_call_template: str = (
61 "<{dsml_token}invoke name=\"{name}\">\n{arguments}\n</{dsml_token}invoke>"
62 )
63 tool_calls_template = (
64 "<{dsml_token}{tc_block_name}>\n{tool_calls}\n</{dsml_token}{tc_block_name}>"
65 )
66 tool_calls_block_name: str = "tool_calls"
67
68 tool_output_template: str = (
69 "<tool_result>{content}</tool_result>"
70 )
71
72 REASONING_EFFORT_PROMPTS: Dict[str, str] = {
73 "low": "",
74 "high": (
75 "Reasoning Effort: Absolute maximum with no shortcuts permitted.\n"
76 "You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\n"
77 "Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n"
78 ),
79 "max": (
80 "Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\n"
81 "You MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\n"
82 "Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n"
83 ),
84 }
85 DEFAULT_REASONING_EFFORT = "low"
86
87 TOOLS_TEMPLATE = """## Tools
88
89 You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following:
90
91 <{dsml_token}tool_calls>
92 <{dsml_token}invoke name="$TOOL_NAME">
93 <{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
94 ...
95 </{dsml_token}invoke>
96 <{dsml_token}invoke name="$TOOL_NAME2">
97 ...
98 </{dsml_token}invoke>
99 </{dsml_token}tool_calls>
100
101 String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
102
103 If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
104
105 Otherwise, output directly after {thinking_end_token} with tool calls or final response.
106
107 ### Available Tool Schemas
108
109 {tool_schemas}
110
111 You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
112 """
113
114 # ============================================================
115 # Utility Functions
116 # ============================================================
117
118 def to_json(value: Any) -> str:
119 """Serialize a value to JSON string."""
120 try:
121 return json.dumps(value, ensure_ascii=False)
122 except Exception:
123 return json.dumps(value, ensure_ascii=True)
124
125
126 def tools_from_openai_format(tools):
127 """Extract function definitions from OpenAI-format tool list."""
128 return [tool["function"] for tool in tools]
129
130
131 def tool_calls_from_openai_format(tool_calls):
132 """Convert OpenAI-format tool calls to internal format."""
133 return [
134 {
135 "name": tool_call["function"]["name"],
136 "arguments": tool_call["function"]["arguments"],
137 }
138 for tool_call in tool_calls
139 ]
140
141
142 def encode_arguments_to_dsml(tool_call: Dict[str, Any]) -> str:
143 """
144 Encode tool call arguments into DSML parameter format.
145
146 Args:
147 tool_call: Dict with "name" and "arguments" keys.
148
149 Returns:
150 DSML-formatted parameter string.
151 """
152 p_dsml_template = '<{dsml_token}parameter name="{key}" string="{is_str}">{value}</{dsml_token}parameter>'
153 P_dsml_strs = []
154
155 if isinstance(tool_call["arguments"], str):
156 arguments = json.loads(tool_call["arguments"])
157 else:
158 arguments = tool_call["arguments"]
159
160 for k, v in arguments.items():
161 p_dsml_str = p_dsml_template.format(
162 dsml_token=dsml_token,
163 key=k,
164 is_str="true" if isinstance(v, str) else "false",
165 value=v if isinstance(v, str) else to_json(v),
166 )
167 P_dsml_strs.append(p_dsml_str)
168
169 return "\n".join(P_dsml_strs)
170
171
172 def render_tools(tools: List[Dict[str, Union[str, Dict[str, Any]]]]) -> str:
173 """
174 Render tool schemas into the system prompt format.
175
176 Args:
177 tools: List of tool schema dicts (each with name, description, parameters).
178
179 Returns:
180 Formatted tools section string.
181 """
182 tools_json = [to_json(t) for t in tools]
183
184 return TOOLS_TEMPLATE.format(
185 tool_schemas="\n".join(tools_json),
186 dsml_token=dsml_token,
187 thinking_start_token=thinking_start_token,
188 thinking_end_token=thinking_end_token,
189 )
190
191
192 def find_last_user_index(messages: List[Dict[str, Any]]) -> int:
193 """Find the index of the last user/developer message."""
194 last_user_index = -1
195 for idx in range(len(messages) - 1, -1, -1):
196 if messages[idx].get("role") in ["user", "developer"]:
197 last_user_index = idx
198 break
199 return last_user_index
200
201
202 # ============================================================
203 # Message Rendering
204 # ============================================================
205
206 def flatten_content_blocks(content: Any) -> Any:
207 """Flatten OpenAI-style content blocks to plain text.
208
209 Image blocks are inlined as IMAGE_PLACEHOLDER at their position. The image
210 data itself travels out of band (multi_modal_data) and is matched to
211 placeholders by order. Plain-string content passes through unchanged.
212 """
213 if not isinstance(content, list):
214 return content
215 parts: List[str] = []
216 for block in content:
217 if not isinstance(block, dict):
218 parts.append(str(block))
219 elif block.get("type") in ("image", "image_url"):
220 parts.append(IMAGE_PLACEHOLDER)
221 elif block.get("type") == "text":
222 parts.append(block.get("text", ""))
223 else:
224 raise ValueError(
225 f"Unsupported content block type: {block.get('type')!r}"
226 )
227 return "\n\n".join(parts)
228
229
230 def render_message(
231 index: int,
232 messages: List[Dict[str, Any]],
233 thinking_mode: str,
234 drop_thinking: bool = True,
235 reasoning_effort: Optional[str] = None,
236 last_user_idx: Optional[int] = None,
237 ) -> str:
238 """
239 Render a single message at the given index into its encoded string form.
240
241 This is the core function that converts each message in the conversation
242 into the DeepSeek-V4 format.
243
244 Args:
245 index: Index of the message to render.
246 messages: Full list of messages in the conversation.
247 thinking_mode: Either "chat" or "thinking".
248 drop_thinking: Whether to drop reasoning content from earlier turns.
249 reasoning_effort: Reasoning effort level, one of "low", "high", "max".
250 None is treated as "low".
251 last_user_idx: Cached index of the last user/developer message.
252
253 Returns:
254 Encoded string for this message.
255 """
256 if not (0 <= index < len(messages)):
257 raise ValueError(
258 f"Index {index} out of range for messages list of length {len(messages)}"
259 )
260 if thinking_mode not in ["chat", "thinking"]:
261 raise ValueError(f"Invalid thinking_mode `{thinking_mode}`")
262
263 prompt = ""
264 msg = messages[index]
265 last_user_idx = (
266 find_last_user_index(messages) if last_user_idx is None else last_user_idx
267 )
268
269 role = msg.get("role")
270 content = flatten_content_blocks(msg.get("content"))
271 tools = msg.get("tools")
272 response_format = msg.get("response_format")
273 tool_calls = msg.get("tool_calls")
274 reasoning = msg.get("reasoning")
275 wo_eos = msg.get("wo_eos", False)
276
277 if tools:
278 tools = tools_from_openai_format(tools)
279 if tool_calls:
280 tool_calls = tool_calls_from_openai_format(tool_calls)
281
282 reasoning_effort = reasoning_effort or DEFAULT_REASONING_EFFORT
283 if reasoning_effort not in REASONING_EFFORT_PROMPTS:
284 raise ValueError(
285 f"Invalid reasoning effort: {reasoning_effort}, expected one of "
286 f"{list(REASONING_EFFORT_PROMPTS)}"
287 )
288 if index == 0 and thinking_mode == "thinking":
289 prompt += REASONING_EFFORT_PROMPTS[reasoning_effort]
290
291 if role == "system":
292 prompt += system_msg_template.format(content=content or "")
293 if tools:
294 prompt += "\n\n" + render_tools(tools)
295 if response_format:
296 prompt += "\n\n" + response_format_template.format(schema=to_json(response_format))
297
298 elif role == "developer":
299 if not content:
300 raise ValueError(f"Invalid message for role `{role}`: {msg}")
301
302 content_developer = USER_SP_TOKEN
303 content_developer += content
304
305 if tools:
306 content_developer += "\n\n" + render_tools(tools)
307 if response_format:
308 content_developer += "\n\n" + response_format_template.format(schema=to_json(response_format))
309
310 prompt += user_msg_template.format(content=content_developer)
311
312 elif role == "user":
313 prompt += USER_SP_TOKEN
314
315 # Handle content blocks (tool results mixed with text)
316 content_blocks = msg.get("content_blocks")
317 if content_blocks:
318 parts = []
319 for block in content_blocks:
320 block_type = block.get("type")
321 if block_type == "text":
322 parts.append(block.get("text", ""))
323 elif block_type in ("image", "image_url"):
324 parts.append(IMAGE_PLACEHOLDER)
325 elif block_type == "tool_result":
326 tool_content = block.get("content", "")
327 if isinstance(tool_content, list):
328 text_parts = []
329 for b in tool_content:
330 if b.get("type") == "text":
331 text_parts.append(b.get("text", ""))
332 else:
333 text_parts.append(f"[Unsupported {b.get('type')}]")
334 tool_content = "\n\n".join(text_parts)
335 parts.append(tool_output_template.format(content=tool_content))
336 else:
337 parts.append(f"[Unsupported {block_type}]")
338 prompt += "\n\n".join(parts)
339 else:
340 prompt += content or ""
341
342 elif role == "latest_reminder":
343 prompt += LATEST_REMINDER_SP_TOKEN + latest_reminder_msg_template.format(content=content)
344
345 elif role == "tool":
346 raise NotImplementedError("deepseek_v4 merges tool messages into user; please preprocess with merge_tool_messages()")
347
348 elif role == "assistant":
349 thinking_part = ""
350 tc_content = ""
351
352 if tool_calls:
353 tc_list = [
354 tool_call_template.format(
355 dsml_token=dsml_token,
356 name=tc.get("name"),
357 arguments=encode_arguments_to_dsml(tc)
358 )
359 for tc in tool_calls
360 ]
361 tc_content += '\n\n' + tool_calls_template.format(
362 dsml_token=dsml_token,
363 tool_calls="\n".join(tc_list),
364 tc_block_name=tool_calls_block_name,
365 )
366
367 summary_content = content or ""
368 reasoning = reasoning or ""
369
370 # Check if previous message has a task - if so, this is a task output (no thinking)
371 prev_has_task = index - 1 >= 0 and messages[index - 1].get("task") is not None
372
373 if thinking_mode == "thinking" and not prev_has_task:
374 if not drop_thinking or index > last_user_idx:
375 thinking_part = thinking_template.format(reasoning=reasoning) + thinking_end_token
376 else:
377 thinking_part = ""
378
379 if wo_eos:
380 prompt += assistant_msg_wo_eos_template.format(
381 reasoning=thinking_part,
382 content=summary_content,
383 tool_calls=tc_content,
384 )
385 else:
386 prompt += assistant_msg_template.format(
387 reasoning=thinking_part,
388 content=summary_content,
389 tool_calls=tc_content,
390 )
391 else:
392 raise ValueError(f"Invalid role: {role}")
393
394 # Append transition tokens based on what follows
395 if index + 1 < len(messages) and messages[index + 1].get("role") not in ["assistant", "latest_reminder"]:
396 return prompt
397
398 task = messages[index].get("task")
399 if task is not None:
400 # Task special token for internal classification tasks
401 if task not in VALID_TASKS:
402 raise ValueError(
403 f"Invalid task: '{task}'. Valid tasks are: {list(VALID_TASKS)}"
404 )
405 task_sp_token = DS_TASK_SP_TOKENS[task]
406
407 if task != "action":
408 # Non-action tasks: append task sp token directly after the message
409 prompt += task_sp_token
410 else:
411 # Action task: append Assistant + thinking token + action sp token
412 prompt += ASSISTANT_SP_TOKEN
413 prompt += thinking_end_token if thinking_mode != "thinking" else thinking_start_token
414 prompt += task_sp_token
415
416 # A trailing system message opens generation, while a system message
417 # followed by assistant opens that assistant history turn.
418 elif role in ["user", "developer"] or (
419 role == "system"
420 and (
421 index == len(messages) - 1
422 or messages[index + 1].get("role") == "assistant"
423 )
424 ):
425 # Normal generation: append Assistant + thinking token
426 prompt += ASSISTANT_SP_TOKEN
427 if not drop_thinking and thinking_mode == "thinking":
428 prompt += thinking_start_token
429 elif drop_thinking and thinking_mode == "thinking" and index >= last_user_idx:
430 prompt += thinking_start_token
431 else:
432 prompt += thinking_end_token
433
434 return prompt
435
436
437 # ============================================================
438 # Preprocessing
439 # ============================================================
440
441 def merge_tool_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
442 """
443 Merge tool messages into the preceding user message using content_blocks format.
444
445 DeepSeek-V4 does not have a standalone "tool" role; instead, tool results
446 are encoded as <tool_result> blocks within user messages.
447
448 This function converts a standard OpenAI-format conversation (with separate
449 "tool" role messages) into V4 format where tool results are merged into
450 user messages.
451
452 Args:
453 messages: List of message dicts in OpenAI format.
454
455 Returns:
456 Processed message list with tool messages merged into user messages.
457 """
458 merged: List[Dict[str, Any]] = []
459
460 for msg in messages:
461 msg = copy.deepcopy(msg)
462 role = msg.get("role")
463
464 if role == "tool":
465 # Convert tool message to a user message with tool_result block
466 tool_block = {
467 "type": "tool_result",
468 "tool_use_id": msg.get("tool_call_id", ""),
469 "content": msg.get("content", ""),
470 }
471 # Merge into previous message if it's already a user (merged tool)
472 if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1]:
473 merged[-1]["content_blocks"].append(tool_block)
474 else:
475 merged.append({
476 "role": "user",
477 "content_blocks": [tool_block],
478 })
479 elif role == "user":
480 # Flatten OpenAI-style content blocks (images become placeholders)
481 text_content = flatten_content_blocks(msg.get("content", ""))
482 text_block = {"type": "text", "text": text_content}
483 if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1] and merged[-1].get("task") is None:
484 merged[-1]["content_blocks"].append(text_block)
485 else:
486 new_msg = {
487 "role": "user",
488 "content": text_content,
489 "content_blocks": [text_block],
490 }
491 # Preserve extra fields (task, wo_eos, mask, etc.)
492 for key in ("task", "wo_eos", "mask"):
493 if key in msg:
494 new_msg[key] = msg[key]
495 merged.append(new_msg)
496 else:
497 merged.append(msg)
498
499 return merged
500
501
502 def sort_tool_results_by_call_order(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
503 """
504 Sort tool_result blocks within user messages by the order of tool_calls
505 in the preceding assistant message.
506
507 Args:
508 messages: Preprocessed message list (after merge_tool_messages).
509
510 Returns:
511 Message list with sorted tool result blocks.
512 """
513 last_tool_call_order: Dict[str, int] = {}
514
515 for msg in messages:
516 role = msg.get("role")
517 if role == "assistant" and msg.get("tool_calls"):
518 last_tool_call_order = {}
519 for idx, tc in enumerate(msg["tool_calls"]):
520 tc_id = tc.get("id") or tc.get("function", {}).get("id", "")
521 if tc_id:
522 last_tool_call_order[tc_id] = idx
523
524 elif role == "user" and msg.get("content_blocks"):
525 tool_blocks = [b for b in msg["content_blocks"] if b.get("type") == "tool_result"]
526 if len(tool_blocks) > 1 and last_tool_call_order:
527 sorted_blocks = sorted(
528 tool_blocks,
529 key=lambda b: last_tool_call_order.get(b.get("tool_use_id", ""), 0)
530 )
531 sorted_idx = 0
532 new_blocks = []
533 for block in msg["content_blocks"]:
534 if block.get("type") == "tool_result":
535 new_blocks.append(sorted_blocks[sorted_idx])
536 sorted_idx += 1
537 else:
538 new_blocks.append(block)
539 msg["content_blocks"] = new_blocks
540
541 return messages
542
543
544 # ============================================================
545 # Main Encoding Function
546 # ============================================================
547
548 def encode_messages(
549 messages: List[Dict[str, Any]],
550 thinking_mode: str,
551 context: Optional[List[Dict[str, Any]]] = None,
552 drop_thinking: bool = True,
553 add_default_bos_token: bool = True,
554 reasoning_effort: Optional[str] = None,
555 ) -> str:
556 """
557 Encode a list of messages into the DeepSeek-V4 prompt format.
558
559 This is the main entry point for encoding conversations. It handles:
560 - BOS token insertion
561 - Thinking mode with optional reasoning content dropping
562 - Tool message merging into user messages
563 - Multi-turn conversation context
564
565 Args:
566 messages: List of message dicts to encode.
567 thinking_mode: Either "chat" or "thinking".
568 context: Optional preceding context messages (already encoded prefix).
569 drop_thinking: If True, drop reasoning from earlier assistant turns
570 (only keep reasoning for messages after the last user message).
571 add_default_bos_token: Whether to prepend BOS token at conversation start.
572 reasoning_effort: Reasoning effort level, one of "low", "high", "max".
573 Only takes effect in thinking mode. None is treated as "low".
574
575 Returns:
576 The encoded prompt string.
577 """
578 context = context if context else []
579
580 # Preprocess: merge tool messages and sort tool results
581 messages = merge_tool_messages(messages)
582 messages = sort_tool_results_by_call_order(context + messages)[len(context):]
583 if context:
584 context = merge_tool_messages(context)
585 context = sort_tool_results_by_call_order(context)
586
587 full_messages = context + messages
588
589 prompt = bos_token if add_default_bos_token and len(context) == 0 else ""
590
591 # Resolve drop_thinking: if any message has tools defined, don't drop thinking
592 effective_drop_thinking = drop_thinking
593 if any(m.get("tools") for m in full_messages):
594 effective_drop_thinking = False
595
596 if thinking_mode == "thinking" and effective_drop_thinking:
597 full_messages = _drop_thinking_messages(full_messages)
598 # After dropping, recalculate how many messages to render
599 # (context may have shrunk too)
600 num_to_render = len(full_messages) - len(_drop_thinking_messages(context))
601 context_len = len(full_messages) - num_to_render
602 else:
603 num_to_render = len(messages)
604 context_len = len(context)
605
606 last_user_idx = find_last_user_index(full_messages)
607
608 for idx in range(num_to_render):
609 prompt += render_message(
610 idx + context_len,
611 full_messages,
612 thinking_mode=thinking_mode,
613 drop_thinking=effective_drop_thinking,
614 reasoning_effort=reasoning_effort,
615 last_user_idx=last_user_idx,
616 )
617
618 return prompt
619
620
621 def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
622 """
623 Drop reasoning and non-essential messages before the last user message.
624
625 Behavior:
626 - Messages with role in ["user", "system", "tool", "latest_reminder"] are always kept.
627 - Messages at or after the last user index are always kept.
628 - Assistant messages before the last user get reasoning removed.
629 - Developer messages before the last user are dropped entirely.
630 """
631 last_user_idx = find_last_user_index(messages)
632 result = []
633 keep_roles = {"user", "system", "tool", "latest_reminder", "direct_search_results"}
634
635 for idx, msg in enumerate(messages):
636 role = msg.get("role")
637 if role in keep_roles or idx >= last_user_idx:
638 result.append(msg)
639 elif role == "assistant":
640 msg = copy.copy(msg)
641 msg.pop("reasoning", None)
642 result.append(msg)
643 # developer and other roles before last_user_idx are dropped
644
645 return result
646
647
648 # fmt: on