[Model][Frontend] Support DeepSeek-V4.1-Flash in Rust and Python frontends (#56208)

Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Co-authored-by: Isotr0py <Isotr0py@outlook.com>
This commit is contained in:
Bugen Zhao
2026-09-10 01:15:06 -07:00
committed by GitHub
co-authored by Isotr0py
parent 9521c60bdc
commit b47b01cf33
52 changed files with 2986 additions and 113 deletions
+1 -1
View File
@@ -2290,7 +2290,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "llm-multimodal"
version = "1.7.1"
source = "git+https://github.com/smg-project/llm-multimodal?rev=74f64753e040ade87c9d7f4bb0ad2f4dd7764652#74f64753e040ade87c9d7f4bb0ad2f4dd7764652"
source = "git+https://github.com/smg-project/llm-multimodal?rev=24c676dd49d73f05cb4068aade2e60397a5c0afe#24c676dd49d73f05cb4068aade2e60397a5c0afe"
dependencies = [
"anyhow",
"base64 0.22.1",
+1 -1
View File
@@ -60,7 +60,7 @@ indexmap = "2.13.0"
indicatif = "0.18.4"
itertools = "0.14.0"
libc = "0.2.177"
llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "74f64753e040ade87c9d7f4bb0ad2f4dd7764652", default-features = false, features = ["native-tls"] }
llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "24c676dd49d73f05cb4068aade2e60397a5c0afe", default-features = false, features = ["native-tls"] }
mimalloc = "0.1.52"
minijinja = { version = "2.24", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
minijinja-contrib = { version = "2.24", features = ["pycompat"] }
+3 -2
View File
@@ -19,8 +19,8 @@ use crate::output::{
};
use crate::renderer::hf::{HfChatRenderer, MultimodalRenderInfo};
use crate::renderer::{
DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer, HarmonyChatRenderer,
InklingChatRenderer, KimiK3ChatRenderer,
DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DeepSeekV41ChatRenderer, DynChatRenderer,
HarmonyChatRenderer, InklingChatRenderer, KimiK3ChatRenderer,
};
use crate::request::ChatRequest;
use crate::{DynChatOutputProcessor, RendererSelection};
@@ -72,6 +72,7 @@ impl HfChatBackend {
)?),
RendererSelection::DeepSeekV32 => Arc::new(DeepSeekV32ChatRenderer::new()),
RendererSelection::DeepSeekV4 => Arc::new(DeepSeekV4ChatRenderer::new()),
RendererSelection::DeepSeekV41 => Arc::new(DeepSeekV41ChatRenderer::new()),
RendererSelection::Harmony => Arc::new(HarmonyChatRenderer::new()?),
RendererSelection::Inkling => Arc::new(InklingChatRenderer::new(tokenizer.clone())?),
RendererSelection::KimiK3 => Arc::new(KimiK3ChatRenderer::new(tokenizer.clone())),
+3
View File
@@ -17,6 +17,8 @@ pub enum Error {
MissingChatTemplate,
#[error("chat template error: {0}")]
ChatTemplate(String),
#[error("{0}")]
InvalidReasoningEffort(String),
#[error("multimodal input is not supported by this chat renderer")]
UnsupportedMultimodalRenderer,
#[error("unsupported multimodal content: {0}")]
@@ -96,6 +98,7 @@ impl Error {
pub fn is_request_validation_error(&self) -> bool {
match self {
Self::PromptTooLong { .. }
| Self::InvalidReasoningEffort(_)
| Self::DuplicateToolName { .. }
| Self::ToolChoiceRequiresTools
| Self::ToolChoiceFunctionNotFound { .. } => true,
+4 -4
View File
@@ -33,8 +33,8 @@ pub use parser::tool::{ToolParser, ToolParserError, ToolParserFactory};
pub use parser::{ParserSelection, validate_parser_overrides};
pub use renderer::hf::ChatTemplateContentFormatOption;
pub use renderer::{
ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer,
HarmonyChatRenderer, InklingChatRenderer, KimiK3ChatRenderer, RenderedPrompt,
ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DeepSeekV41ChatRenderer,
DynChatRenderer, HarmonyChatRenderer, InklingChatRenderer, KimiK3ChatRenderer, RenderedPrompt,
RendererSelection,
};
pub use request::{
@@ -363,7 +363,7 @@ mod tests {
)
.unwrap_err();
expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, granite4, hermes, hy_v3, hy_v4, inkling, internlm, kimi_k2, kimi_k3, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, granite4, hermes, hy_v3, hy_v4, inkling, internlm, kimi_k2, kimi_k3, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
}
#[test]
@@ -374,6 +374,6 @@ mod tests {
)
.unwrap_err();
expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, glm47, hy_v3, hy_v4, inkling, kimi, kimi_k2, kimi_k3, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, hy_v3, hy_v4, inkling, kimi, kimi_k2, kimi_k3, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
}
}
+39 -1
View File
@@ -153,6 +153,10 @@ struct ResolvedMultimodalSpec {
modality: Modality,
field_layouts: EncoderFieldLayouts,
keep_on_cpu_keys: HashSet<String>,
/// Spec-declared wire key for the primary encoder input tensor, when it
/// differs from the per-modality default (e.g. `"patches"` for
/// DeepSeek-V4.1 images).
encoder_input_key: Option<String>,
}
impl ResolvedMultimodalSpec {
@@ -162,10 +166,14 @@ impl ResolvedMultimodalSpec {
modality,
field_layouts: raw.encoder_field_layouts_for(modality),
keep_on_cpu_keys: raw.keep_on_cpu_keys_for(modality).into_iter().collect(),
encoder_input_key: raw.encoder_input_key_for(modality),
}
}
fn primary_key(&self) -> &'static str {
fn primary_key(&self) -> &str {
if let Some(key) = &self.encoder_input_key {
return key;
}
match self.modality {
Modality::Image => image::IMAGE_PRIMARY_KEY,
Modality::Video => video::VIDEO_PRIMARY_KEY,
@@ -841,6 +849,9 @@ mod tests {
pub(super) const QWEN3_IMAGE_PAD_ID: u32 = 151655;
pub(super) const QWEN3_VIDEO_PAD_ID: u32 = 151656;
pub(super) const DEEPSEEK_V41_IMAGE_ID: u32 = 129264;
pub(super) const DEEPSEEK_V41_IMAGE_PAD_ID: u32 = 129265;
fn llama4_tokenizer() -> TestTokenizer {
TestTokenizer::new()
.with_regular_token("<|image_start|>", LLAMA4_IMAGE_START_ID)
@@ -954,6 +965,33 @@ mod tests {
assert_eq!(info.placeholder_token(Modality::Video), None);
}
fn deepseek_v41_info() -> MultimodalModelInfo {
let config = serde_json::json!({
"model_type": "deepseek_v41",
"image_token_id": DEEPSEEK_V41_IMAGE_ID,
});
let tokenizer = TestTokenizer::new()
.with_regular_token("<|deepseek_image|>", DEEPSEEK_V41_IMAGE_ID)
.with_regular_token("<|place_holder_mm_span_0436|>", DEEPSEEK_V41_IMAGE_PAD_ID);
test_info("deepseek_v41", config, tokenizer)
}
#[test]
fn deepseek_v41_resolves_image_support_only() {
let info = deepseek_v41_info();
assert_eq!(
info.placeholder_token(Modality::Image),
Some("<|deepseek_image|>")
);
assert_eq!(info.placeholder_token(Modality::Video), None);
let image = info.image.as_ref().expect("image support");
assert_eq!(image.placeholder.marker_token_id, DEEPSEEK_V41_IMAGE_ID);
assert_eq!(image.placeholder.embed_token_id, DEEPSEEK_V41_IMAGE_ID);
// The engine's forward kwargs pop `patches` (not `pixel_values`).
assert_eq!(image.spec.primary_key(), "patches");
}
#[test]
fn input_audio_uses_connector_data_urls() {
assert_eq!(
+136 -8
View File
@@ -45,6 +45,14 @@ impl<'a> ExpansionLane<'a> {
/// matching the original media-part order within that modality; markers of
/// different modalities may interleave freely.
///
/// Replacements may carry an [`AlignmentPad`](llm_multimodal::AlignmentPad)
/// (e.g. DeepSeek-V4.1's compressor-alignment pad): `period - 1 - offset %
/// period` pad tokens are
/// prepended at splice time, where `offset` is the position the replacement
/// lands at in the expanded prompt. The pads are part of the reported range
/// and count as embed positions only if the pad token id equals the
/// modality's embed token id.
///
/// The returned ranges point into the already-expanded prompt, grouped per
/// modality in item order.
pub(super) fn expand_prompt_token_ids(
@@ -56,12 +64,14 @@ pub(super) fn expand_prompt_token_ids(
return Ok(HashMap::new());
}
let replacement_growth = lanes
.iter()
.flat_map(|lane| lane.replacements.iter())
.fold(0usize, |total, replacement| {
total.saturating_add(replacement.tokens.len().saturating_sub(1))
});
let replacement_growth = lanes.iter().flat_map(|lane| lane.replacements.iter()).fold(
0usize,
|total, replacement| {
// Alignment pads are position-dependent; reserve the worst case.
let max_pads = replacement.alignment_pad.map_or(0, |pad| pad.period - 1);
total.saturating_add(max_pads + replacement.tokens.len().saturating_sub(1))
},
);
let expanded_len = prompt_token_ids.len().saturating_add(replacement_growth);
let mut expanded = Vec::with_capacity(expanded_len);
@@ -95,10 +105,20 @@ pub(super) fn expand_prompt_token_ids(
expanded.len()
);
}
let range_len = replacement_len.saturating_add(structural_prefix);
// Position-dependent alignment padding, computed from the offset the
// replacement block lands at in the expanded prompt (mirroring
// vLLM's `_apply_token_matches_with_placeholders` splice-time pad).
let pad_count = replacement.alignment_pad.map_or(0, |pad| pad.count_at(expanded.len()));
let range_len = structural_prefix + pad_count + replacement_len;
let is_embed = {
let mut mask = Vec::with_capacity(range_len);
mask.extend(std::iter::repeat_n(false, structural_prefix));
if let Some(pad) = &replacement.alignment_pad {
mask.extend(std::iter::repeat_n(
pad.token_id as u32 == lane.embed_token_id,
pad_count,
));
}
mask.extend(
replacement.tokens.iter().map(|&token| token as u32 == lane.embed_token_id),
);
@@ -106,6 +126,9 @@ pub(super) fn expand_prompt_token_ids(
};
let expanded_offset = expanded.len() - structural_prefix;
if let Some(pad) = &replacement.alignment_pad {
expanded.extend(std::iter::repeat_n(pad.token_id as u32, pad_count));
}
expanded.extend(replacement.tokens.iter().map(|&token| token as u32));
ranges.entry(lane.modality).or_default().push(PlaceholderRange {
offset: expanded_offset,
@@ -454,7 +477,6 @@ mod tests {
}
const QWEN3_AUDIO_PAD_ID: u32 = 151_676;
fn qwen3_audio_prepared(replacements: Vec<PromptReplacement>) -> PreparedMedia {
prepared_media(
Modality::Audio,
@@ -542,4 +564,110 @@ mod tests {
));
assert_eq!(prompt_token_ids, original_prompt_token_ids);
}
// DeepSeek-V4.1 image spans: every span position carries the image token
// id; a compressor-alignment pad (id 129265, period 2) is prepended at
// splice time so spans always start at the same compressor phase.
const DS_IMAGE_ID: u32 = 129_264;
const DS_IMAGE_PAD_ID: u32 = 129_265;
fn deepseek_prepared(span_lens: Vec<usize>) -> PreparedMedia {
prepared_media(
Modality::Image,
"<|deepseek_image|>",
DS_IMAGE_ID,
DS_IMAGE_ID,
span_lens
.into_iter()
.map(|len| {
PromptReplacement::repeated(
Modality::Image,
"<|deepseek_image|>",
DS_IMAGE_ID as TokenId,
len,
)
.with_alignment_pad(DS_IMAGE_PAD_ID as TokenId, 2)
})
.collect(),
)
}
#[test]
fn expand_prompt_tokens_skips_alignment_pad_at_odd_offset() {
// Odd block start: already at the target compressor phase, no pad.
let mut prompt_token_ids = vec![1, DS_IMAGE_ID, 2];
let prepared = vec![deepseek_prepared(vec![3])];
let ranges = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap();
let ranges = &ranges[&Modality::Image];
assert_eq!(
prompt_token_ids,
vec![1, DS_IMAGE_ID, DS_IMAGE_ID, DS_IMAGE_ID, 2]
);
assert_eq!(ranges[0].offset, 1);
assert_eq!(ranges[0].length, 3);
assert_bool_mask(&ranges[0], &[true, true, true]);
}
#[test]
fn expand_prompt_tokens_prepends_alignment_pad_at_even_offset() {
// Even block start: one pad token aligns the span to phase 1.
let mut prompt_token_ids = vec![1, 3, DS_IMAGE_ID, 2];
let prepared = vec![deepseek_prepared(vec![3])];
let ranges = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap();
let ranges = &ranges[&Modality::Image];
assert_eq!(
prompt_token_ids,
vec![
1,
3,
DS_IMAGE_PAD_ID,
DS_IMAGE_ID,
DS_IMAGE_ID,
DS_IMAGE_ID,
2
]
);
// The range starts at the pad; pads are not embed positions.
assert_eq!(ranges[0].offset, 2);
assert_eq!(ranges[0].length, 4);
assert_bool_mask(&ranges[0], &[false, true, true, true]);
}
#[test]
fn expand_prompt_tokens_alignment_pad_realigns_each_span() {
// Every image span must start at phase 1 (odd offset) regardless of
// where the previous span landed.
let mut prompt_token_ids = vec![1, DS_IMAGE_ID, 2, DS_IMAGE_ID, 3];
let prepared = vec![deepseek_prepared(vec![2, 2])];
let ranges = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap();
let ranges = &ranges[&Modality::Image];
// First span at odd offset 1: no pad (block [1,3)).
// Second marker originally at index 3; after the first expansion the
// block starts at offset 4 (even): one pad, span at odd offset 5.
assert_eq!(
prompt_token_ids,
vec![
1,
DS_IMAGE_ID,
DS_IMAGE_ID,
2,
DS_IMAGE_PAD_ID,
DS_IMAGE_ID,
DS_IMAGE_ID,
3,
]
);
assert_eq!((ranges[0].offset, ranges[0].length), (1, 2));
assert_bool_mask(&ranges[0], &[true, true]);
assert_eq!((ranges[1].offset, ranges[1].length), (4, 3));
assert_bool_mask(&ranges[1], &[false, true, true]);
// Both spans (past the pad) start at odd offsets.
assert_eq!((ranges[1].offset + 1) % 2, 1);
}
}
+8 -4
View File
@@ -7,10 +7,11 @@ use std::sync::{Arc, LazyLock};
pub use vllm_parser::reasoning::{
CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DeepSeekV3ReasoningParser,
DeepSeekV4ReasoningParser, Glm45ReasoningParser, Glm47ReasoningParser, KimiK2ReasoningParser,
KimiReasoningParser, MiniMaxM2ReasoningParser, MiniMaxM3ReasoningParser,
NemotronV3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta, ReasoningError,
ReasoningParser, SeedOssReasoningParser, Step3ReasoningParser, Step3p5ReasoningParser,
DeepSeekV4ReasoningParser, DeepSeekV41ReasoningParser, Glm45ReasoningParser,
Glm47ReasoningParser, KimiK2ReasoningParser, KimiReasoningParser, MiniMaxM2ReasoningParser,
MiniMaxM3ReasoningParser, NemotronV3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta,
ReasoningError, ReasoningParser, SeedOssReasoningParser, Step3ReasoningParser,
Step3p5ReasoningParser,
};
use vllm_tokenizer::DynTokenizer;
@@ -22,6 +23,7 @@ pub mod names {
pub const DEEPSEEK_R1: &str = "deepseek_r1";
pub const DEEPSEEK_V3: &str = "deepseek_v3";
pub const DEEPSEEK_V4: &str = "deepseek_v4";
pub const DEEPSEEK_V41: &str = "deepseek_v41";
pub const GLM45: &str = "glm45";
pub const GLM47: &str = "glm47";
pub const KIMI: &str = "kimi";
@@ -62,6 +64,7 @@ impl ReasoningParserFactory {
.register_parser::<DeepSeekR1ReasoningParser>(names::DEEPSEEK_R1)
.register_parser::<DeepSeekV3ReasoningParser>(names::DEEPSEEK_V3)
.register_parser::<DeepSeekV4ReasoningParser>(names::DEEPSEEK_V4)
.register_parser::<DeepSeekV41ReasoningParser>(names::DEEPSEEK_V41)
.register_parser::<Glm45ReasoningParser>(names::GLM45)
.register_parser::<Glm47ReasoningParser>(names::GLM47)
.register_parser::<KimiReasoningParser>(names::KIMI)
@@ -76,6 +79,7 @@ impl ReasoningParserFactory {
factory
.register_pattern("deepseek-r1", names::DEEPSEEK_R1)
.register_pattern("deepseek-v4.1", names::DEEPSEEK_V41)
.register_pattern("deepseek-v4", names::DEEPSEEK_V4)
.register_pattern("deepseek_v4", names::DEEPSEEK_V4)
.register_pattern("deepseek-v3", names::DEEPSEEK_V3)
@@ -33,6 +33,10 @@ fn factory_resolves_deepseek_v4() {
factory.resolve_name_for_model("deepseek_v4"),
Some(names::DEEPSEEK_V4)
);
assert_eq!(
factory.resolve_name_for_model("deepseek-ai/DeepSeek-V4.1-Flash"),
Some(names::DEEPSEEK_V41)
);
}
#[test]
+7 -4
View File
@@ -7,10 +7,10 @@ use std::sync::{Arc, LazyLock};
pub use vllm_parser::tool::{
DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser,
Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser, HermesToolParser,
Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser,
MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser, Qwen3CoderToolParser,
Qwen3XmlToolParser, SeedOssToolParser, ToolParser, ToolParserError,
DeepSeekV41ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser,
HermesToolParser, Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser,
MinimaxM2ToolParser, MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser,
Qwen3CoderToolParser, Qwen3XmlToolParser, SeedOssToolParser, ToolParser, ToolParserError,
};
use crate::parser::ParserFactory;
@@ -22,6 +22,7 @@ pub mod names {
pub const DEEPSEEK_V31: &str = "deepseek_v31";
pub const DEEPSEEK_V32: &str = "deepseek_v32";
pub const DEEPSEEK_V4: &str = "deepseek_v4";
pub const DEEPSEEK_V41: &str = "deepseek_v41";
pub const GLM45: &str = "glm45";
pub const GLM47: &str = "glm47";
pub const GRANITE4: &str = "granite4";
@@ -66,6 +67,7 @@ impl ToolParserFactory {
.register_parser::<DeepSeekV31ToolParser>(names::DEEPSEEK_V31)
.register_parser::<DeepSeekV32ToolParser>(names::DEEPSEEK_V32)
.register_parser::<DeepSeekV4ToolParser>(names::DEEPSEEK_V4)
.register_parser::<DeepSeekV41ToolParser>(names::DEEPSEEK_V41)
.register_parser::<Glm45MoeToolParser>(names::GLM45)
.register_parser::<Glm47MoeToolParser>(names::GLM47)
.register_parser::<Granite4ToolParser>(names::GRANITE4)
@@ -101,6 +103,7 @@ impl ToolParserFactory {
.register_pattern("llama-3.2", names::LLAMA3_JSON)
.register_pattern("llama-3.1", names::LLAMA3_JSON)
.register_pattern("deepseek-r1", names::DEEPSEEK_V3)
.register_pattern("deepseek-v4.1", names::DEEPSEEK_V41)
.register_pattern("deepseek-v4", names::DEEPSEEK_V4)
.register_pattern("deepseek_v4", names::DEEPSEEK_V4)
.register_pattern("deepseek-v3.2", names::DEEPSEEK_V32)
+4
View File
@@ -147,6 +147,10 @@ fn factory_new_resolves_default_patterns() {
factory.resolve_name_for_model("deepseek_v4"),
Some(names::DEEPSEEK_V4)
);
assert_eq!(
factory.resolve_name_for_model("deepseek-ai/DeepSeek-V4.1-Flash"),
Some(names::DEEPSEEK_V41)
);
assert_eq!(
factory.resolve_name_for_model("deepseek-ai/DeepSeek-R1-0528"),
Some(names::DEEPSEEK_V3)
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//! DeepSeek V4 prompt renderer.
//! Shared DeepSeek V4 and V4.1 prompt rendering.
//!
//! Official Python reference:
//! <https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash/blob/main/encoding/encoding_dsv4.py>
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::Write as _;
@@ -13,8 +14,12 @@ use serde::Serialize;
use serde_json::Value;
use serde_json_fmt::JsonFormat;
use llm_multimodal::DEEPSEEK_V41_IMAGE_PLACEHOLDER;
use crate::error::{Error, Result};
use crate::request::{ChatContent, ChatMessage, ChatRequest, ChatTool, ReasoningEffort};
use crate::request::{
ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ReasoningEffort,
};
use crate::{AssistantContentBlock, AssistantMessageExt, AssistantToolCall};
const BOS_TOKEN: &str = "<|begin▁of▁sentence|>";
@@ -23,6 +28,7 @@ const THINKING_START_TOKEN: &str = "<think>";
const THINKING_END_TOKEN: &str = "</think>";
const DSML_TOKEN: &str = "|DSML|";
const USER_SP_TOKEN: &str = "<|User|>";
const SYSTEM_SP_TOKEN: &str = "<|System|>";
const ASSISTANT_SP_TOKEN: &str = "<|Assistant|>";
const REASONING_EFFORT_HIGH: &str = concat!(
"Reasoning Effort: Absolute maximum with no shortcuts permitted.\n",
@@ -41,6 +47,35 @@ enum ThinkingMode {
Thinking,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum DsDialect {
V4,
V41,
}
impl DsDialect {
fn tool_calls_tag(self) -> &'static str {
match self {
Self::V4 => "tool_calls",
Self::V41 => " calls",
}
}
fn invoke_tag(self) -> &'static str {
match self {
Self::V4 => "invoke",
Self::V41 => " invoke",
}
}
fn parameter_tag(self) -> &'static str {
match self {
Self::V4 => "parameter",
Self::V41 => " parameter",
}
}
}
#[serde_with::skip_serializing_none]
#[derive(Debug, Serialize)]
struct RenderedToolSchema<'a> {
@@ -51,25 +86,47 @@ struct RenderedToolSchema<'a> {
}
/// Render one chat request into the final prompt string.
pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
let (thinking_mode, reasoning_effort_prompt) = resolve_thinking_options(request)?;
pub(super) fn render_request(request: &ChatRequest, dialect: DsDialect) -> Result<String> {
let (thinking_mode, reasoning_effort_prompt) = match dialect {
DsDialect::V4 => {
resolve_thinking_options(request).map(|(mode, prompt)| (mode, Cow::Borrowed(prompt)))?
}
DsDialect::V41 => resolve_v41_thinking_options(request)?,
};
let request_tools = request_tools(request);
let synthetic_tool_system = needs_synthetic_tool_system(request, request_tools);
let drop_thinking = request.parse_template_bool("drop_thinking")?.unwrap_or(true)
&& !rendered_tools_present(request, request_tools);
let drop_historical_developers = thinking_mode == ThinkingMode::Thinking && drop_thinking;
let last_user_like_message_index = request.messages.iter().rposition(is_user_like_entry);
let last_user_render_index =
find_last_user_render_index(request.messages.as_slice(), synthetic_tool_system);
let last_user_like_message_index = request
.messages
.iter()
.enumerate()
.rfind(|(index, message)| is_user_like_entry(message, *index, dialect))
.map(|(index, _)| index);
let last_user_render_index = find_last_user_render_index(
request.messages.as_slice(),
synthetic_tool_system,
drop_historical_developers,
last_user_like_message_index,
dialect,
);
let mut out = String::from(BOS_TOKEN);
if dialect == DsDialect::V41
&& (thinking_mode == ThinkingMode::Thinking
|| synthetic_tool_system
|| matches!(request.messages.first(), Some(ChatMessage::System { .. })))
{
out.push_str(SYSTEM_SP_TOKEN);
}
if thinking_mode == ThinkingMode::Thinking {
out.push_str(reasoning_effort_prompt);
out.push_str(&reasoning_effort_prompt);
}
let mut request_tools_attached = false;
let mut render_index = 0isize;
if synthetic_tool_system {
render_system_message(&mut out, None, request_tools)?;
render_system_message(&mut out, None, request_tools, dialect)?;
request_tools_attached = true;
render_index += 1;
}
@@ -80,27 +137,35 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
continue;
}
let current_render_index = render_index;
render_index += 1;
if drop_historical_developers
&& is_historical_developer(message, message_index, last_user_like_message_index)
{
continue;
}
let current_render_index = render_index;
render_index += 1;
match message {
ChatMessage::System { content } => {
if dialect == DsDialect::V41 && current_render_index > 0 {
out.push_str(SYSTEM_SP_TOKEN);
}
let tools = if !request_tools_attached {
request_tools_attached = true;
request_tools
} else {
&[]
};
render_system_message(&mut out, Some(content), tools)?;
render_system_message(&mut out, Some(content), tools, dialect)?;
}
ChatMessage::Developer { content, tools } => {
render_developer_message(&mut out, content, tools.as_deref().unwrap_or(&[]))?;
render_developer_message(
&mut out,
content,
tools.as_deref().unwrap_or(&[]),
dialect,
)?;
}
ChatMessage::User { .. } | ChatMessage::ToolResponse { .. } => {
render_user_content_block(
@@ -108,6 +173,7 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
request.messages.as_slice(),
message_index,
&last_tool_call_order,
dialect,
)?;
}
ChatMessage::Assistant { content } => {
@@ -119,7 +185,13 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
&& (!drop_thinking || current_render_index > last_user_render_index);
let append_eos = !(message_index + 1 == request.messages.len()
&& request.chat_options.continue_final_message());
render_assistant_message(&mut out, emit_thinking_block, append_eos, content)?;
render_assistant_message(
&mut out,
emit_thinking_block,
append_eos,
content,
dialect,
)?;
if content.has_tool_calls() {
last_tool_call_order.clear();
@@ -133,7 +205,8 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
}
}
if (is_user_like_entry(message) || matches!(message, ChatMessage::System { .. }))
if (is_user_like_entry(message, current_render_index as usize, dialect)
|| (dialect == DsDialect::V4 && matches!(message, ChatMessage::System { .. })))
&& next_rendered_entry_is_assistant_or_end(
request.messages.as_slice(),
message_index,
@@ -181,6 +254,57 @@ fn resolve_thinking_options(request: &ChatRequest) -> Result<(ThinkingMode, &'st
Ok((thinking_mode, reasoning_effort_prompt))
}
/// Resolve V4.1's numeric reasoning effort using the top-level value before template kwargs.
fn resolve_v41_thinking_options(
request: &ChatRequest,
) -> Result<(ThinkingMode, Cow<'static, str>)> {
let mut thinking = request.enable_thinking()?.unwrap_or(true);
let budget = match request.chat_options.reasoning_effort {
Some(ReasoningEffort::None) => {
thinking = false;
50
}
Some(ReasoningEffort::Low) => 25,
Some(ReasoningEffort::High) => 50,
Some(ReasoningEffort::XHigh) => 75,
Some(ReasoningEffort::Max) => 100,
Some(ReasoningEffort::Minimal | ReasoningEffort::Medium) => {
return Err(invalid_v41_reasoning_effort());
}
None => match request.chat_options.template_kwargs.get("reasoning_effort") {
None | Some(Value::Null) => 50,
Some(Value::String(effort)) => match effort.as_str() {
"low" => 25,
"high" => 50,
"xhigh" => 75,
"max" => 100,
_ => return Err(invalid_v41_reasoning_effort()),
},
Some(Value::Number(budget)) => budget
.as_u64()
.filter(|budget| (1..=100).contains(budget))
.ok_or_else(invalid_v41_reasoning_effort)?,
Some(_) => return Err(invalid_v41_reasoning_effort()),
},
};
if thinking {
Ok((
ThinkingMode::Thinking,
Cow::Owned(format!(
"Reasoning Effort: {budget} (range 1-100, the higher the value, the more thorough the reasoning)\n\n"
)),
))
} else {
Ok((ThinkingMode::Chat, Cow::Borrowed("")))
}
}
fn invalid_v41_reasoning_effort() -> Error {
Error::InvalidReasoningEffort(
"DeepSeek V4.1 reasoning_effort must be low, high, xhigh, max, or an integer within [1, 100] in chat_template_kwargs".to_string(),
)
}
/// Return request-level tools only when native tool parsing is enabled.
fn request_tools(request: &ChatRequest) -> &[ChatTool] {
if request.tool_parsing_enabled() {
@@ -214,16 +338,25 @@ fn rendered_tools_present(request: &ChatRequest, request_tools: &[ChatTool]) ->
}
/// Find the last user-like turn after inline tool-response merging.
fn find_last_user_render_index(messages: &[ChatMessage], synthetic_tool_system: bool) -> isize {
fn find_last_user_render_index(
messages: &[ChatMessage],
synthetic_tool_system: bool,
drop_historical_developers: bool,
last_user_like_message_index: Option<usize>,
dialect: DsDialect,
) -> isize {
let mut render_index = isize::from(synthetic_tool_system);
let mut last_user_index = -1;
for (message_index, message) in messages.iter().enumerate() {
if is_following_user_content(messages, message_index) {
if is_following_user_content(messages, message_index)
|| (drop_historical_developers
&& is_historical_developer(message, message_index, last_user_like_message_index))
{
continue;
}
if is_user_like_entry(message) {
if is_user_like_entry(message, render_index as usize, dialect) {
last_user_index = render_index;
}
render_index += 1;
@@ -249,11 +382,13 @@ fn is_user_content_entry(message: &ChatMessage) -> bool {
}
/// Return whether one rendered entry should be treated as user-like.
fn is_user_like_entry(message: &ChatMessage) -> bool {
fn is_user_like_entry(message: &ChatMessage, message_index: usize, dialect: DsDialect) -> bool {
matches!(
message,
ChatMessage::Developer { .. } | ChatMessage::User { .. } | ChatMessage::ToolResponse { .. }
)
) || (dialect == DsDialect::V41
&& message_index > 0
&& matches!(message, ChatMessage::System { .. }))
}
/// Return whether a developer entry precedes another user-like turn.
@@ -293,22 +428,26 @@ fn next_rendered_entry_is_assistant_or_end(
.unwrap_or(true)
}
/// Render the tool preamble shown to the model, V4 flavor.
fn render_tools(out: &mut String, tools: &[ChatTool]) -> Result<()> {
out.push_str(
/// Render the tool preamble shown to the model for one DeepSeek dialect.
fn render_tools(out: &mut String, tools: &[ChatTool], dialect: DsDialect) -> Result<()> {
let tool_calls_tag = dialect.tool_calls_tag();
let invoke_tag = dialect.invoke_tag();
let parameter_tag = dialect.parameter_tag();
write!(
out,
r#"## Tools
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML|tool_calls>" block like the following:
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML|{tool_calls_tag}>" block like the following:
<|DSML|tool_calls>
<|DSML|invoke name="$TOOL_NAME">
<|DSML|parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|parameter>
<|DSML|{tool_calls_tag}>
<|DSML|{invoke_tag} name="$TOOL_NAME">
<|DSML|{parameter_tag} name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|{parameter_tag}>
...
</|DSML|invoke>
<|DSML|invoke name="$TOOL_NAME2">
</|DSML|{invoke_tag}>
<|DSML|{invoke_tag} name="$TOOL_NAME2">
...
</|DSML|invoke>
</|DSML|tool_calls>
</|DSML|{invoke_tag}>
</|DSML|{tool_calls_tag}>
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"`.
@@ -319,7 +458,8 @@ Otherwise, output directly after </think> with tool calls or final response.
### Available Tool Schemas
"#,
);
)
.expect("writing to String cannot fail");
for (index, tool) in tools.iter().enumerate() {
if index > 0 {
@@ -350,13 +490,14 @@ fn render_system_message(
out: &mut String,
content: Option<&ChatContent>,
tools: &[ChatTool],
dialect: DsDialect,
) -> Result<()> {
if let Some(content) = content {
write_chat_content(out, content)?;
write_chat_content(out, content, dialect)?;
}
if !tools.is_empty() {
out.push_str("\n\n");
render_tools(out, tools)?;
render_tools(out, tools, dialect)?;
}
Ok(())
}
@@ -366,18 +507,19 @@ fn render_developer_message(
out: &mut String,
content: &ChatContent,
tools: &[ChatTool],
dialect: DsDialect,
) -> Result<()> {
if content.is_empty() {
return Err(Error::ChatTemplate(
"invalid DeepSeek V4 developer message: empty content".to_string(),
"invalid DeepSeek developer message: empty content".to_string(),
));
}
out.push_str(USER_SP_TOKEN);
write_chat_content(out, content)?;
write_chat_content(out, content, dialect)?;
if !tools.is_empty() {
out.push_str("\n\n");
render_tools(out, tools)?;
render_tools(out, tools, dialect)?;
}
Ok(())
}
@@ -388,6 +530,7 @@ fn render_user_content_block(
messages: &[ChatMessage],
message_index: usize,
tool_call_order: &HashMap<String, usize>,
dialect: DsDialect,
) -> Result<()> {
let (block_start, block_end) = user_content_block_bounds(messages, message_index);
let mut sorted_tool_indices =
@@ -399,7 +542,7 @@ fn render_user_content_block(
out.push_str("\n\n");
}
match &messages[message_index] {
ChatMessage::User { content } => write_chat_content(out, content)?,
ChatMessage::User { content } => write_chat_content(out, content, dialect)?,
ChatMessage::ToolResponse { .. } => {
let sorted_index = sorted_tool_indices
.next()
@@ -407,7 +550,7 @@ fn render_user_content_block(
let ChatMessage::ToolResponse { content, .. } = &messages[sorted_index] else {
unreachable!("sorted tool response index should reference a tool message");
};
write_tool_result(out, content)?;
write_tool_result(out, content, dialect)?;
}
_ => unreachable!("user content block should only contain user content messages"),
}
@@ -454,9 +597,9 @@ fn sorted_tool_response_indices(
}
/// Render one tool response payload inside a V4 `<tool_result>` block.
fn write_tool_result(out: &mut String, content: &ChatContent) -> Result<()> {
fn write_tool_result(out: &mut String, content: &ChatContent, dialect: DsDialect) -> Result<()> {
out.push_str("<tool_result>");
write_chat_content(out, content)?;
write_chat_content(out, content, dialect)?;
out.push_str("</tool_result>");
Ok(())
}
@@ -483,6 +626,7 @@ fn render_assistant_message(
emit_thinking_block: bool,
append_eos: bool,
content: &[AssistantContentBlock],
dialect: DsDialect,
) -> Result<()> {
let has_tool_calls = content.has_tool_calls();
@@ -496,14 +640,15 @@ fn render_assistant_message(
write_assistant_text(out, content);
if has_tool_calls {
out.push_str("\n\n<|DSML|tool_calls>\n");
let tool_calls_tag = dialect.tool_calls_tag();
writeln!(out, "\n\n<{DSML_TOKEN}{tool_calls_tag}>").expect("writing to String cannot fail");
for (index, tool_call) in content.tool_calls().enumerate() {
if index > 0 {
out.push('\n');
}
render_tool_call(out, tool_call)?;
render_tool_call(out, tool_call, dialect)?;
}
out.push_str("\n</|DSML|tool_calls>");
write!(out, "\n</{DSML_TOKEN}{tool_calls_tag}>").expect("writing to String cannot fail");
}
if append_eos {
@@ -513,11 +658,20 @@ fn render_assistant_message(
}
/// Render one assistant tool call in DSML XML-like format.
fn render_tool_call(out: &mut String, tool_call: &AssistantToolCall) -> Result<()> {
writeln!(out, "<{DSML_TOKEN}invoke name=\"{}\">", tool_call.name)
.expect("writing to String cannot fail");
encode_arguments_to_dsml(out, tool_call)?;
write!(out, "\n</{DSML_TOKEN}invoke>").expect("writing to String cannot fail");
fn render_tool_call(
out: &mut String,
tool_call: &AssistantToolCall,
dialect: DsDialect,
) -> Result<()> {
let invoke_tag = dialect.invoke_tag();
writeln!(
out,
"<{DSML_TOKEN}{invoke_tag} name=\"{}\">",
tool_call.name
)
.expect("writing to String cannot fail");
encode_arguments_to_dsml(out, tool_call, dialect)?;
write!(out, "\n</{DSML_TOKEN}{invoke_tag}>").expect("writing to String cannot fail");
Ok(())
}
@@ -525,15 +679,20 @@ fn render_tool_call(out: &mut String, tool_call: &AssistantToolCall) -> Result<(
///
/// String values are emitted raw with `string="true"`, while all other JSON
/// values are rendered with JSON syntax and `string="false"`.
fn encode_arguments_to_dsml(out: &mut String, tool_call: &AssistantToolCall) -> Result<()> {
fn encode_arguments_to_dsml(
out: &mut String,
tool_call: &AssistantToolCall,
dialect: DsDialect,
) -> Result<()> {
let parameter_tag = dialect.parameter_tag();
let arguments: Value = serde_json::from_str(&tool_call.arguments).map_err(|error| {
Error::ChatTemplate(format!(
"assistant tool call has invalid JSON arguments for DeepSeek V4: {error}"
"assistant tool call has invalid JSON arguments for DeepSeek: {error}"
))
})?;
let Some(arguments) = arguments.as_object() else {
return Err(Error::ChatTemplate(
"assistant tool call arguments for DeepSeek V4 must be a JSON object".to_string(),
"assistant tool call arguments for DeepSeek must be a JSON object".to_string(),
));
};
@@ -546,7 +705,7 @@ fn encode_arguments_to_dsml(out: &mut String, tool_call: &AssistantToolCall) ->
let is_string = matches!(value, Value::String(_));
write!(
out,
"<{DSML_TOKEN}parameter name=\"{key}\" string=\"{}\">",
"<{DSML_TOKEN}{parameter_tag} name=\"{key}\" string=\"{}\">",
if is_string { "true" } else { "false" }
)
.expect("writing to String cannot fail");
@@ -556,7 +715,7 @@ fn encode_arguments_to_dsml(out: &mut String, tool_call: &AssistantToolCall) ->
value => out.push_str(&json_dumps(value)?),
}
write!(out, "</{DSML_TOKEN}parameter>").expect("writing to String cannot fail");
write!(out, "</{DSML_TOKEN}{parameter_tag}>").expect("writing to String cannot fail");
wrote_parameter = true;
}
@@ -565,12 +724,24 @@ fn encode_arguments_to_dsml(out: &mut String, tool_call: &AssistantToolCall) ->
/// Write chat content directly into the destination buffer without flattening
/// it into an intermediate `String`.
fn write_chat_content(out: &mut String, content: &ChatContent) -> Result<()> {
///
/// The V4.1 dialect inlines the image placeholder at each image part's
/// position unconditionally (matching the Python encoding's
/// `IMAGE_PLACEHOLDER`); other dialects reject multimodal parts.
fn write_chat_content(out: &mut String, content: &ChatContent, dialect: DsDialect) -> Result<()> {
match content {
ChatContent::Text(text) => out.push_str(text),
ChatContent::Parts(parts) => {
for part in parts {
out.push_str(part.as_text()?);
for (index, part) in parts.iter().enumerate() {
if index > 0 && dialect == DsDialect::V41 {
out.push_str("\n\n");
}
match part {
ChatContentPart::ImageUrl { .. } if dialect == DsDialect::V41 => {
out.push_str(DEEPSEEK_V41_IMAGE_PLACEHOLDER);
}
_ => out.push_str(part.as_text()?),
}
}
}
}
@@ -606,7 +777,7 @@ fn json_dumps<T: Serialize>(value: &T) -> Result<String> {
.format_to_string(value)
.map_err(|error| {
Error::ChatTemplate(format!(
"failed to serialize DeepSeek V4 JSON payload: {error}"
"failed to serialize DeepSeek JSON payload: {error}"
))
})
}
@@ -1,10 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
mod encoding;
use vllm_text::Prompt;
use super::deepseek::{self, DsDialect};
use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
use crate::Result;
use crate::request::ChatRequest;
@@ -24,7 +23,7 @@ impl ChatRenderer for DeepSeekV4ChatRenderer {
request.validate()?;
Ok(RenderedPrompt {
prompt: Prompt::Text(encoding::render_request(request)?),
prompt: Prompt::Text(deepseek::render_request(request, DsDialect::V4)?),
effective_template_kwargs: request_template_kwargs(request),
})
}
@@ -0,0 +1,38 @@
<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)
You are a helpful assistant.
## Tools
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML| calls>" block like the following:
<|DSML| calls>
<|DSML| invoke name="$TOOL_NAME">
<|DSML| parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML| parameter>
...
</|DSML| invoke>
<|DSML| invoke name="$TOOL_NAME2">
...
</|DSML| invoke>
</|DSML| calls>
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"`.
If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
Otherwise, output directly after </think> with tool calls or final response.
### Available Tool Schemas
{"name": "get_weather", "description": "Get the weather for a specific location", "parameters": {"type": "object", "properties": {"location": {"type": "string", "description": "The city name"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "Temperature unit"}}, "required": ["location"]}}
{"name": "search", "description": "Search the web for information", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "Search query"}, "num_results": {"type": "integer", "description": "Number of results to return"}}, "required": ["query"]}}
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
<|User|>What's the weather in Beijing?<|Assistant|><think>The user wants to know the weather in Beijing. I should use the get_weather tool.</think>
<|DSML| calls>
<|DSML| invoke name="get_weather">
<|DSML| parameter name="location" string="true">Beijing</|DSML| parameter>
<|DSML| parameter name="unit" string="true">celsius</|DSML| parameter>
</|DSML| invoke>
</|DSML| calls><|end▁of▁sentence|><|User|><tool_result>{"temperature": 22, "condition": "sunny", "humidity": 45}</tool_result><|Assistant|><think>Got the weather data. Let me format a nice response.</think>The weather in Beijing is currently sunny with a temperature of 22°C and 45% humidity.<|end▁of▁sentence|>
@@ -0,0 +1,3 @@
<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)
You are a helpful assistant.<|User|>Hello<|Assistant|></think>Hi there! How can I help you?<|end▁of▁sentence|><|User|>What is the capital of France?<|Assistant|><think>The user asks about the capital of France. It is Paris.</think>The capital of France is Paris.<|end▁of▁sentence|>
@@ -0,0 +1,30 @@
<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)
<|User|>question<|User|>policy
## Tools
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML| calls>" block like the following:
<|DSML| calls>
<|DSML| invoke name="$TOOL_NAME">
<|DSML| parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML| parameter>
...
</|DSML| invoke>
<|DSML| invoke name="$TOOL_NAME2">
...
</|DSML| invoke>
</|DSML| calls>
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"`.
If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
Otherwise, output directly after </think> with tool calls or final response.
### Available Tool Schemas
{"name": "lookup", "parameters": {"type": "object"}}
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
<|Assistant|><think></think>answer<|end▁of▁sentence|>
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use vllm_text::Prompt;
use super::deepseek::{self, DsDialect};
use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
use crate::Result;
use crate::request::ChatRequest;
/// Dedicated DeepSeek V4.1 renderer.
#[derive(Debug, Clone, Copy, Default)]
pub struct DeepSeekV41ChatRenderer;
impl DeepSeekV41ChatRenderer {
pub fn new() -> Self {
Self
}
}
impl ChatRenderer for DeepSeekV41ChatRenderer {
fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
request.validate()?;
Ok(RenderedPrompt {
prompt: Prompt::Text(deepseek::render_request(request, DsDialect::V41)?),
effective_template_kwargs: request_template_kwargs(request),
})
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,223 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use std::path::PathBuf;
use expect_test::{expect, expect_file};
use serde_json::{Value, json};
use super::DeepSeekV41ChatRenderer;
use crate::ChatRenderer;
use crate::event::AssistantContentBlock;
use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest, ReasoningEffort};
fn render(request: &ChatRequest) -> String {
DeepSeekV41ChatRenderer::new()
.render(request)
.unwrap()
.prompt
.into_text()
.unwrap()
}
fn request() -> ChatRequest {
ChatRequest {
messages: vec![ChatMessage::user("question")],
..ChatRequest::for_test()
}
}
#[test]
fn renders_shared_deepseek_fixtures_with_v41_reference_encoding() {
let fixture_dir =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/renderer/deepseek_v4/fixtures");
for (input, expected) in [
(
"test_input_1.json",
expect_file!["fixtures/test_output_1.txt"],
),
(
"test_input_2.json",
expect_file!["fixtures/test_output_2.txt"],
),
(
"test_input_developer_tools.json",
expect_file!["fixtures/test_output_developer_tools.txt"],
),
] {
let request = fixture_chat_request(
&fixture_dir.join(input),
FixtureRequestOptions {
enable_thinking: Some(true),
no_generation_prompt_when_last_assistant: true,
},
);
expected.assert_eq(&render(&request));
}
}
#[test]
fn inlines_image_placeholder_at_image_part_positions() {
// The placeholder is unconditional (matching the Python encoding's
// `IMAGE_PLACEHOLDER`); requests to a multimodal-less backend fail later,
// at media preparation.
let renderer = DeepSeekV41ChatRenderer::new();
let request = ChatRequest {
messages: vec![ChatMessage::User {
content: ChatContent::Parts(vec![
ChatContentPart::image_url("https://example.com/carrots.jpeg"),
ChatContentPart::text("what is in the image?"),
]),
}],
..ChatRequest::for_test()
};
let rendered = renderer.render(&request).unwrap().prompt.into_text().unwrap();
// Image parts inline the placeholder at their position; parts stay
// separated by "\n\n" (the V4.1 reference encoding's part separator).
assert!(
rendered.contains("<|User|><|deepseek_image|>\n\nwhat is in the image?"),
"unexpected prompt: {rendered:?}"
);
}
#[test]
fn maps_reasoning_effort_to_reference_numeric_budget() {
for (effort, budget) in [
(None, 50),
(Some(ReasoningEffort::Low), 25),
(Some(ReasoningEffort::High), 50),
(Some(ReasoningEffort::XHigh), 75),
(Some(ReasoningEffort::Max), 100),
] {
let mut request = request();
request.chat_options.reasoning_effort = effort;
assert_eq!(
render(&request),
format!(
"<|begin▁of▁sentence|><|System|>Reasoning Effort: {budget} (range 1-100, the higher the value, the more thorough the reasoning)\n\n<|User|>question<|Assistant|><think>"
)
);
}
}
#[test]
fn accepts_numeric_template_effort_with_top_level_precedence() {
for (effort, budget) in [
(json!(1), 1),
(json!(42), 42),
(json!(100), 100),
(json!("low"), 25),
] {
let mut request = request();
request.chat_options.template_kwargs.insert("reasoning_effort".into(), effort);
assert!(render(&request).contains(&format!("Reasoning Effort: {budget} (range 1-100")));
request.chat_options.reasoning_effort = Some(ReasoningEffort::XHigh);
assert!(render(&request).contains("Reasoning Effort: 75 (range 1-100"));
}
}
#[test]
fn rejects_undefined_effort_names_and_invalid_numeric_budgets() {
for effort in [
json!("medium"),
json!("minimal"),
json!(-1),
json!(0),
json!(101),
json!(true),
json!(1.5),
] {
let mut request = request();
request.chat_options.template_kwargs.insert("reasoning_effort".into(), effort);
let error = DeepSeekV41ChatRenderer::new().render(&request).unwrap_err();
assert!(error.is_request_validation_error());
}
for effort in [ReasoningEffort::Minimal, ReasoningEffort::Medium] {
let mut request = request();
request.chat_options.reasoning_effort = Some(effort);
let error = DeepSeekV41ChatRenderer::new().render(&request).unwrap_err();
assert!(error.is_request_validation_error());
}
}
#[test]
fn chat_mode_and_none_effort_use_closed_thinking_prefix() {
let mut request = request();
request.chat_options.template_kwargs.insert("thinking".into(), json!(false));
let expected = expect!["<|begin▁of▁sentence|><|User|>question<|Assistant|></think>"];
expected.assert_eq(&render(&request));
request.chat_options.template_kwargs.insert("thinking".into(), json!(true));
request.chat_options.reasoning_effort = Some(ReasoningEffort::None);
expected.assert_eq(&render(&request));
}
#[test]
fn leading_system_in_chat_mode_uses_system_token() {
let mut request = request();
request.messages.insert(0, ChatMessage::system("policy"));
request.chat_options.template_kwargs.insert("thinking".into(), json!(false));
expect!["<|begin▁of▁sentence|><|System|>policy<|User|>question<|Assistant|></think>"]
.assert_eq(&render(&request));
}
#[test]
fn system_after_dropped_developer_becomes_leading_system() {
let mut request = ChatRequest {
messages: vec![
ChatMessage::developer("old policy", None),
ChatMessage::system("sys"),
],
..ChatRequest::for_test()
};
expect!["<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)\n\nsys"]
.assert_eq(&render(&request));
request.messages.push(ChatMessage::user("question"));
expect!["<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)\n\nsys<|User|>question<|Assistant|><think>"]
.assert_eq(&render(&request));
}
#[test]
fn mid_system_advances_last_user_boundary_and_drops_old_reasoning() {
let mut request = ChatRequest {
messages: vec![
ChatMessage::system("sys"),
ChatMessage::user("q1"),
ChatMessage::assistant_blocks(vec![
AssistantContentBlock::Reasoning { text: "r1".into() },
AssistantContentBlock::Text { text: "a1".into() },
]),
ChatMessage::developer("old policy", None),
ChatMessage::system("mid sys"),
],
..ChatRequest::for_test()
};
request
.chat_options
.template_kwargs
.insert("reasoning_effort".into(), json!(88));
expect!["<|begin▁of▁sentence|><|System|>Reasoning Effort: 88 (range 1-100, the higher the value, the more thorough the reasoning)\n\nsys<|User|>q1<|Assistant|></think>a1<|end▁of▁sentence|><|System|>mid sys<|Assistant|><think>"]
.assert_eq(&render(&request));
}
#[test]
fn joins_text_parts_with_reference_separator_and_preserves_literal_tags() {
let mut request = ChatRequest {
messages: vec![ChatMessage::user(ChatContent::Parts(vec![
ChatContentPart::text("first"),
ChatContentPart::text("<|DSML|invoke name=\"literal\">"),
]))],
..ChatRequest::for_test()
};
request
.chat_options
.template_kwargs
.insert("thinking".into(), Value::Bool(false));
expect!["<|begin▁of▁sentence|><|User|>first\n\n<|DSML|invoke name=\"literal\"><|Assistant|></think>"]
.assert_eq(&render(&request));
}
+3
View File
@@ -10,8 +10,10 @@ use vllm_text::Prompt;
use crate::error::Result;
use crate::request::{ChatRequest, ReasoningEffort};
mod deepseek;
pub mod deepseek_v32;
pub mod deepseek_v4;
pub mod deepseek_v41;
pub mod harmony;
pub mod hf;
mod inkling;
@@ -22,6 +24,7 @@ mod test_utils;
pub use deepseek_v4::DeepSeekV4ChatRenderer;
pub use deepseek_v32::DeepSeekV32ChatRenderer;
pub use deepseek_v41::DeepSeekV41ChatRenderer;
pub use harmony::HarmonyChatRenderer;
pub use inkling::InklingChatRenderer;
pub use kimi_k3::KimiK3ChatRenderer;
+8 -1
View File
@@ -22,6 +22,8 @@ pub enum RendererSelection {
DeepSeekV32,
/// Force the DeepSeek V4 renderer.
DeepSeekV4,
/// Force the DeepSeek V4.1 renderer.
DeepSeekV41,
/// Force the GPT-OSS Harmony renderer.
Harmony,
/// Force the Inkling native token renderer.
@@ -34,6 +36,7 @@ impl RendererSelection {
pub const AUTO_LITERAL: &str = "auto";
pub const DEEPSEEK_V32_LITERAL: &str = "deepseek_v32";
pub const DEEPSEEK_V4_LITERAL: &str = "deepseek_v4";
pub const DEEPSEEK_V41_LITERAL: &str = "deepseek_v41";
pub const GPT_OSS_MODEL_TYPE: &str = "gpt_oss";
pub const HARMONY_LITERAL: &str = "harmony";
pub const HF_LITERAL: &str = "hf";
@@ -48,6 +51,7 @@ impl RendererSelection {
Self::Auto => match model_type {
Self::DEEPSEEK_V32_LITERAL => Self::DeepSeekV32,
Self::DEEPSEEK_V4_LITERAL => Self::DeepSeekV4,
Self::DEEPSEEK_V41_LITERAL => Self::DeepSeekV41,
Self::GPT_OSS_MODEL_TYPE => Self::Harmony,
Self::INKLING_MODEL_TYPE => Self::Inkling,
Self::KIMI_K3_LITERAL => Self::KimiK3,
@@ -70,6 +74,8 @@ impl FromStr for RendererSelection {
Ok(Self::DeepSeekV32)
} else if value.eq_ignore_ascii_case(Self::DEEPSEEK_V4_LITERAL) {
Ok(Self::DeepSeekV4)
} else if value.eq_ignore_ascii_case(Self::DEEPSEEK_V41_LITERAL) {
Ok(Self::DeepSeekV41)
} else if value.eq_ignore_ascii_case(Self::HARMONY_LITERAL) {
Ok(Self::Harmony)
} else if value.eq_ignore_ascii_case(Self::INKLING_LITERAL) {
@@ -92,6 +98,7 @@ impl fmt::Display for RendererSelection {
Self::Hf => f.write_str(Self::HF_LITERAL),
Self::DeepSeekV32 => f.write_str(Self::DEEPSEEK_V32_LITERAL),
Self::DeepSeekV4 => f.write_str(Self::DEEPSEEK_V4_LITERAL),
Self::DeepSeekV41 => f.write_str(Self::DEEPSEEK_V41_LITERAL),
Self::Harmony => f.write_str(Self::HARMONY_LITERAL),
Self::Inkling => f.write_str(Self::INKLING_LITERAL),
Self::KimiK3 => f.write_str(Self::KIMI_K3_LITERAL),
@@ -121,7 +128,7 @@ mod tests {
fn renderer_selection_expected_error_message() {
let err = RendererSelection::from_str("unknown").unwrap_err();
expect_test::expect![
"unknown renderer `unknown` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony, inkling, kimi_k3)"
"unknown renderer `unknown` (expected one of: auto, hf, deepseek_v32, deepseek_v4, deepseek_v41, harmony, inkling, kimi_k3)"
]
.assert_eq(&err);
}
+15 -1
View File
@@ -29,7 +29,7 @@ const TOKEN_COMPLETION_CHUNK_TOKENS: usize = 1;
/// One model/parser configuration used to run the fixed roundtrip fixtures.
#[derive(Clone)]
struct RoundtripCase {
/// Hugging Face model id resolved through the production backend loader.
/// Hugging Face model id or local checkpoint resolved through the production backend loader.
model_id: &'static str,
/// Final assistant-history suffix rendered by the chat template but not
/// generated by the model body consumed by the output processor.
@@ -180,6 +180,19 @@ impl RoundtripCase {
}
}
/// DeepSeek V4.1 Flash DSML tool-call format.
fn deepseek_v41() -> Self {
Self {
model_id: "deepseek-ai/DeepSeek-V4.1-Flash",
assistant_stop_suffix: "<|end▁of▁sentence|>",
tool_call_parser: ParserSelection::Auto,
reasoning_parser: ParserSelection::Auto,
thinking_behavior: ThinkingBehavior::Toggleable { default: true },
json_fmt: compact_json_fmt(),
sort_json_keys: false,
}
}
/// DeepSeek V3.2 DSML tool-call format.
fn deepseek_v32() -> Self {
Self {
@@ -385,6 +398,7 @@ roundtrip_tests! {
minimax_m25 => [reasoning_and_content, tool_call_mix],
minimax_m3 => [reasoning_and_content, tool_call_mix],
deepseek_v4 => [reasoning_and_content, tool_call_mix],
deepseek_v41 => [reasoning_and_content, tool_call_mix],
deepseek_v32 => [tool_call_mix],
glm45 => [reasoning_and_content, tool_call_mix],
glm47 => [reasoning_and_content, tool_call_mix],
+1 -1
View File
@@ -903,7 +903,7 @@ fn serve_args_reject_unknown_renderer_value() {
.unwrap_err();
expect![[r#"
error: invalid value 'definitely_missing' for '--tokenizer-mode <RENDERER>': unknown renderer `definitely_missing` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony, inkling, kimi_k3)
error: invalid value 'definitely_missing' for '--tokenizer-mode <RENDERER>': unknown renderer `definitely_missing` (expected one of: auto, hf, deepseek_v32, deepseek_v4, deepseek_v41, harmony, inkling, kimi_k3)
For more information, try '--help'.
"#]]
+2
View File
@@ -53,6 +53,8 @@ pub type Step3ReasoningParser = DeepSeekR1ReasoningParser;
/// DeepSeek V4 currently shares the DeepSeek V3 reasoning parser.
pub type DeepSeekV4ReasoningParser = DeepSeekV3ReasoningParser;
/// DeepSeek V4.1 uses bare `<think>...</think>` delimiters.
pub type DeepSeekV41ReasoningParser = DeepSeekV3ReasoningParser;
/// GLM-4.7 and GLM-5 use bare reasoning delimiters.
pub type Glm47ReasoningParser = DeepSeekV3ReasoningParser;
/// Kimi K2 currently shares the DeepSeek V3 reasoning parser.
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use super::{DeepSeekDsmlToolParser, DsmlTokens};
use crate::tool::{Result, StructuralTagBuilder, Tool, ToolParser, ToolParserOutput};
mod structural_tag;
/// Tool parser for DeepSeek V4.1's spaced DSML tags.
///
/// Arguments are emitted only after a full `invoke` block is parsed.
pub struct DeepSeekV41ToolParser(DeepSeekDsmlToolParser);
impl ToolParser for DeepSeekV41ToolParser {
fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>>
where
Self: Sized + 'static,
{
Ok(Box::new(Self(DeepSeekDsmlToolParser::new(
tools,
DsmlTokens::V41,
))))
}
fn preserve_special_tokens(&self) -> bool {
true
}
fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
Some(&structural_tag::DeepSeekV41StructuralTagBuilder)
}
fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
self.0.parse_into(chunk, output)
}
fn finish(&mut self) -> Result<ToolParserOutput> {
self.0.finish()
}
fn reset(&mut self) -> String {
self.0.reset()
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//! Visible-text grammar for DeepSeek V4.1 tool calls.
//!
//! Adapted from xgrammar-structural-tag 0.2.0's DeepSeek V4 builder, with
//! V4.1's spaced DSML tags and schema-unconstrained parameter values.
use serde_json::Value;
use xgrammar_structural_tag::Result;
use xgrammar_structural_tag::builders::{
StructuralTagBuilder, StructuralTagContext, StructuralTagOptions,
};
use xgrammar_structural_tag::format::{
Format, JsonSchemaFormat, StructuralTag, TagFormat, TriggeredTagsFormat,
};
use xgrammar_structural_tag::tool::{BuilderToolChoice, FunctionToolParam};
use super::DsmlTokens;
const TOKENS: DsmlTokens = DsmlTokens::V41;
pub(super) struct DeepSeekV41StructuralTagBuilder;
impl StructuralTagBuilder for DeepSeekV41StructuralTagBuilder {
fn build(&self, ctx: StructuralTagContext<'_>) -> Result<StructuralTag> {
let tags = ctx
.function_tools
.iter()
.map(|tool| call_tag(tool, ctx.options))
.collect::<Vec<_>>();
let excludes = if ctx.options.exclude_special_tokens {
&["<think>", "</think>"][..]
} else {
&[]
};
let format = match ctx.tool_choice {
BuilderToolChoice::Auto if tags.is_empty() => Format::any_text_excluding(excludes),
BuilderToolChoice::Auto => Format::TriggeredTags(
TriggeredTagsFormat::new(
&[TOKENS.tool_calls_start],
vec![TagFormat::new(
format!("{}\n", TOKENS.tool_calls_start),
Format::tags_with_separator(tags, "", true, false),
TOKENS.tool_calls_end,
)],
)
.with_excludes(excludes),
),
BuilderToolChoice::Forced | BuilderToolChoice::Required => Format::sequence(vec![
Format::const_string(format!("\n\n{}\n", TOKENS.tool_calls_start)),
Format::tags_with_separator(
tags,
"",
true,
ctx.tool_choice == BuilderToolChoice::Forced,
),
Format::const_string(TOKENS.tool_calls_end),
]),
};
// Reasoning boundaries remain owned by the engine reasoner; serving
// calls this visible-text builder with reasoning=false.
Ok(StructuralTag::new(format))
}
}
fn call_tag(tool: &FunctionToolParam, options: StructuralTagOptions) -> TagFormat {
// TODO: enforce tool.parameters and strict schema semantics once V4.1
// argument conversion is available. This builder intentionally ignores
// parameters and strict: it constrains tool names and DSML/value syntax
// only. Parameter names, presence, uniqueness and value schemas remain
// unconstrained, including for strict=true. Serving still uses strict to
// decide whether auto tool choice activates a grammar.
TagFormat::new(
format!("{} name=\"{}\">\n", TOKENS.invoke_start, tool.function.name),
Format::star(parameter(options)),
format!("{}\n", TOKENS.invoke_end),
)
}
fn parameter(options: StructuralTagOptions) -> Format {
Format::tag(
format!("{} name=\"", TOKENS.parameter_start),
Format::sequence(vec![
Format::regex(r#"[^"]+"#),
Format::const_string("\" string=\""),
Format::or(vec![
Format::sequence(vec![
Format::const_string("true\">"),
Format::any_text_excluding(&[
TOKENS.parameter_end,
TOKENS.invoke_end,
TOKENS.tool_calls_end,
]),
]),
Format::sequence(vec![
Format::const_string("false\">"),
Format::JsonSchema(
JsonSchemaFormat::new(Value::Bool(true))
.with_any_order(options.any_order)
.with_max_whitespace_cnt(options.max_whitespace_cnt),
),
]),
]),
]),
format!("{}\n", TOKENS.parameter_end),
)
}
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use expect_test::expect;
use serde_json::json;
use xgrammar_structural_tag::builders::StructuralTagOptions;
use xgrammar_structural_tag::{
FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
};
use super::DeepSeekV41ToolParser;
use crate::tool::test_utils::collect_stream;
use crate::tool::{Tool, ToolParser};
fn tools() -> Vec<Tool> {
vec![Tool {
name: "lookup".into(),
description: None,
parameters: json!({
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
"enabled": {"type": "boolean"},
"data": {"type": "object"},
"items": {"type": "array"},
"label": {"type": "string"}
}
}),
strict: None,
}]
}
#[test]
fn spaced_dsml_streaming_preserves_shared_schema_coercion() {
let wire = concat!(
"summary\n\n<|DSML| calls>\n",
"<|DSML| invoke name=\"lookup\">\n",
"<|DSML| parameter name=\"query\" string=\"true\"> value\n</|DSML| parameter>\n",
"<|DSML| parameter name=\"limit\" string=\"false\">2</|DSML| parameter>\n",
"<|DSML| parameter name=\"enabled\" string=\"false\">true</|DSML| parameter>\n",
"<|DSML| parameter name=\"data\" string=\"false\">{\"x\":1}</|DSML| parameter>\n",
"<|DSML| parameter name=\"items\" string=\"false\">[1,2]</|DSML| parameter>\n",
"<|DSML| parameter name=\"label\" string=\"false\">42</|DSML| parameter>\n",
"<|DSML| parameter name=\"unknown\" string=\"false\">42</|DSML| parameter>\n",
"<|DSML| parameter name=\"nullish\" string=\"false\">null</|DSML| parameter>\n",
"</|DSML| invoke>\n",
"<|DSML| invoke name=\"lookup\">\n",
"<|DSML| parameter name=\"query\" string=\"true\">second</|DSML| parameter>\n",
"</|DSML| invoke>\n</|DSML| calls>",
);
let expected = [
json!({"query":" value\n","limit":2,"enabled":true,"data":{"x":1},"items":[1,2],"label":"42","unknown":"42","nullish":null}),
json!({"query":"second"}),
];
for split in wire.char_indices().map(|(i, _)| i).chain(std::iter::once(wire.len())) {
let mut parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
let output = collect_stream(parser.as_mut(), &[&wire[..split], &wire[split..]]);
// Protocol framing follows the current shared DSML behavior.
assert_eq!(output.normal_text(), "summary", "split {split}");
assert_eq!(output.calls().len(), 2, "split {split}");
for (index, call) in output.calls().iter().enumerate() {
assert_eq!(call.tool_index, index, "split {split}");
assert_eq!(call.name.as_deref(), Some("lookup"), "split {split}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&call.arguments).unwrap(),
expected[index],
"split {split}"
);
}
}
}
#[test]
fn v4_tags_remain_text_in_v41_dialect() {
let mut parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
let output = collect_stream(parser.as_mut(), &["before<|DSML|tool_calls>after"]);
expect!["before<|DSML|tool_calls>after"].assert_eq(&output.normal_text());
assert!(output.calls().is_empty());
}
#[test]
fn structural_tag_ignores_parameter_schemas_and_strict() {
let parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
let builder = parser.structural_tag_builder().unwrap();
let options = StructuralTagOptions::default().with_reasoning(false);
for choice in [
ToolChoice::auto(),
ToolChoice::required(),
ToolChoice::function("lookup"),
] {
let build = |function| {
build_structural_tag(
builder,
&[ToolParam::Function(FunctionToolParam::new(function))],
choice.clone(),
options,
)
.unwrap()
};
let expected = build(FunctionDefinition::new("lookup"));
for strict in [None, Some(false), Some(true)] {
for parameters in [
json!(false),
json!({"type":"object", "properties":{"query":{"const":"fixed"}}, "required":["query"]}),
] {
let mut function = FunctionDefinition::new("lookup").with_parameters(parameters);
function.strict = strict;
assert_eq!(build(function), expected);
}
}
}
}
+40 -16
View File
@@ -14,14 +14,11 @@ use crate::tool::Tool;
mod deepseek_v32;
mod deepseek_v4;
mod deepseek_v41;
pub use deepseek_v4::DeepSeekV4ToolParser;
pub use deepseek_v32::DeepSeekV32ToolParser;
const INVOKE_START: &str = "<|DSML|invoke";
const INVOKE_END: &str = "</|DSML|invoke>";
const PARAMETER_START: &str = "<|DSML|parameter";
const PARAMETER_END: &str = "</|DSML|parameter>";
pub use deepseek_v41::DeepSeekV41ToolParser;
type DsmlInput<'i> = Partial<&'i str>;
@@ -30,6 +27,10 @@ struct DsmlTokens {
tool_calls_start: &'static str,
framed_tool_calls_start: &'static str,
tool_calls_end: &'static str,
invoke_start: &'static str,
invoke_end: &'static str,
parameter_start: &'static str,
parameter_end: &'static str,
}
impl DsmlTokens {
@@ -37,11 +38,25 @@ impl DsmlTokens {
tool_calls_start: "<|DSML|function_calls>",
framed_tool_calls_start: "\n\n<|DSML|function_calls>",
tool_calls_end: "</|DSML|function_calls>",
invoke_start: "<|DSML|invoke",
invoke_end: "</|DSML|invoke>",
parameter_start: "<|DSML|parameter",
parameter_end: "</|DSML|parameter>",
};
const V4: Self = Self {
tool_calls_start: "<|DSML|tool_calls>",
framed_tool_calls_start: "\n\n<|DSML|tool_calls>",
tool_calls_end: "</|DSML|tool_calls>",
..Self::V32
};
const V41: Self = Self {
tool_calls_start: "<|DSML| calls>",
framed_tool_calls_start: "\n\n<|DSML| calls>",
tool_calls_end: "</|DSML| calls>",
invoke_start: "<|DSML| invoke",
invoke_end: "</|DSML| invoke>",
parameter_start: "<|DSML| parameter",
parameter_end: "</|DSML| parameter>",
};
}
@@ -205,7 +220,7 @@ fn parse_tool_block_event(
) -> ModalResult<DsmlEvent> {
ws0.void().parse_next(input)?;
alt((
|input: &mut DsmlInput<'_>| invoke_event(input, invoke_end_scan),
|input: &mut DsmlInput<'_>| invoke_event(input, tokens, invoke_end_scan),
|input: &mut DsmlInput<'_>| tool_calls_end_event(input, tokens),
))
.parse_next(input)
@@ -243,19 +258,20 @@ fn safe_text_event(input: &mut DsmlInput<'_>, tokens: DsmlTokens) -> ModalResult
/// Parse a DSML invoke block.
fn invoke_event(
input: &mut DsmlInput<'_>,
tokens: DsmlTokens,
invoke_end_scan: &mut MarkerScanState,
) -> ModalResult<DsmlEvent> {
let (name, body) = seq!(
_: literal(INVOKE_START),
_: literal(tokens.invoke_start),
_: ws1,
dsml_name_attr,
_: ws0,
_: ">",
take_until_marker(INVOKE_END, invoke_end_scan),
_: literal(INVOKE_END),
take_until_marker(tokens.invoke_end, invoke_end_scan),
_: literal(tokens.invoke_end),
)
.parse_next(input)?;
let raw_params = parse_invoke_params(body)?;
let raw_params = parse_invoke_params(body, tokens)?;
Ok(DsmlEvent::Invoke {
name: name.to_string(),
raw_params,
@@ -263,23 +279,31 @@ fn invoke_event(
}
/// Parse a DSML invoke body.
fn parse_invoke_params(invoke_body: &str) -> ModalResult<Vec<DsmlParameter>> {
fn parse_invoke_params(invoke_body: &str, tokens: DsmlTokens) -> ModalResult<Vec<DsmlParameter>> {
let mut input = invoke_body;
delimited(ws0, repeat(0.., terminated(parse_parameter, ws0)), eof).parse_next(&mut input)
delimited(
ws0,
repeat(
0..,
terminated(|input: &mut &str| parse_parameter(input, tokens), ws0),
),
eof,
)
.parse_next(&mut input)
}
/// Parse a DSML parameter block.
fn parse_parameter(input: &mut &str) -> ModalResult<DsmlParameter> {
fn parse_parameter(input: &mut &str, tokens: DsmlTokens) -> ModalResult<DsmlParameter> {
seq! {DsmlParameter {
_: literal(PARAMETER_START),
_: literal(tokens.parameter_start),
_: ws1,
name: name_attr.map(|name: &str| name.to_string()),
_: ws1,
is_string: string_attr.map(|value| value == "true"),
_: ws0,
_: ">",
value: take_until(0.., PARAMETER_END).map(str::to_string),
_: literal(PARAMETER_END),
value: take_until(0.., tokens.parameter_end).map(str::to_string),
_: literal(tokens.parameter_end),
}}
.parse_next(input)
}
+1 -1
View File
@@ -20,7 +20,7 @@ mod seed_oss;
pub mod test_utils;
use std::collections::{BTreeMap, btree_map};
pub use deepseek_dsml::{DeepSeekV4ToolParser, DeepSeekV32ToolParser};
pub use deepseek_dsml::{DeepSeekV4ToolParser, DeepSeekV32ToolParser, DeepSeekV41ToolParser};
pub use deepseek_json::{DeepSeekV3ToolParser, DeepSeekV31ToolParser};
pub use error::{Result, ToolParserError};
pub use glm_xml::{Glm45MoeToolParser, Glm47MoeToolParser};
+13
View File
@@ -167,6 +167,19 @@ mod tests {
assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
}
#[test]
fn invalid_reasoning_effort_maps_to_invalid_request() {
let error = vllm_chat::Error::InvalidReasoningEffort(
"DeepSeek V4.1 reasoning_effort must be within [1, 100]".to_string(),
);
let api_error = chat_submit_error("failed to submit chat request", error);
assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
api_error.to_error_response().error.error_type,
"invalid_request_error"
);
}
#[test]
fn llm_wrapped_empty_prompt_maps_to_invalid_request() {
let error = vllm_text::Error::Llm(vllm_llm::Error::EmptyPromptTokenIds {
+146
View File
@@ -0,0 +1,146 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
import pytest
from tests.parser.engine.replay_harness import (
DUMMY_TOOLS,
MockTokenizer,
_test_request,
collect_output,
replay_streaming,
)
from vllm.parser.deepseek_v41 import deepseek_v41_config
from vllm.parser.parser_manager import ParserManager
CALLS = (
'\n\n<|DSML| calls>\n<|DSML| invoke name="get_weather">\n'
'<|DSML| parameter name="city" string="true">杭州</|DSML| parameter>\n'
'<|DSML| parameter name="count" string="false">42</|DSML| parameter>\n'
'</|DSML| invoke>\n<|DSML| invoke name="add">\n'
'<|DSML| parameter name="x" string="false">1.5</|DSML| parameter>\n'
'<|DSML| parameter name="y" string="false">2.25</|DSML| parameter>\n'
"</|DSML| invoke>\n</|DSML| calls>"
)
def tokenizer_for(text, special_calls):
vocab = {"<think>": 50, "</think>": 51}
if special_calls:
vocab |= {"<|DSML| calls>": 52, "</|DSML| calls>": 53}
tokens: list[tuple[int, str]] = []
while text:
special = next((marker for marker in vocab if text.startswith(marker)), None)
piece = special or text[0]
tokens.append((vocab[piece] if special else 100 + len(tokens), piece))
text = text[len(piece) :]
return MockTokenizer(vocab, tokens), tokens
def parser_for(tokenizer, controls):
cls = ParserManager.get_parser(
tool_parser_name="deepseek_v41",
reasoning_parser_name="deepseek_v41",
enable_auto_tools=True,
)
return cls(tokenizer, chat_template_kwargs=controls)
@pytest.mark.parametrize("thinking", [False, True])
@pytest.mark.parametrize("special_calls", [False, True])
@pytest.mark.parametrize("chunk_size", [1, 7, 10000])
def test_registered_adapters_parse_parallel_calls_across_chunks(
thinking,
special_calls,
chunk_size,
):
text = ("Plan.</think>" if thinking else "") + "Checking." + CALLS
tokenizer, tokens = tokenizer_for(text, special_calls)
parser = parser_for(tokenizer, {"thinking": thinking})
output = collect_output(
replay_streaming(
parser,
tokens,
chunk_size=chunk_size,
finished_on_last=True,
tools=DUMMY_TOOLS,
prompt_token_ids=[50 if thinking else 51],
)
)
assert output.reasoning == ("Plan." if thinking else "")
assert output.content.strip() == "Checking."
assert [call["name"] for call in output.tool_calls] == ["get_weather", "add"]
assert [json.loads(call["arguments"]) for call in output.tool_calls] == [
{"city": "杭州", "count": 42},
{"x": 1.5, "y": 2.25},
]
@pytest.mark.parametrize("thinking", [False, True])
def test_registered_adapters_parse_complete_output(thinking):
text = ("Plan.</think>" if thinking else "") + CALLS
tokenizer, tokens = tokenizer_for(text, False)
parser = parser_for(tokenizer, {"thinking": thinking})
reasoning, content, calls = parser.parse(
text,
_test_request(tools=DUMMY_TOOLS),
enable_auto_tools=True,
model_output_token_ids=[tid for tid, _ in tokens],
)
assert (reasoning or "") == ("Plan." if thinking else "")
assert not (content or "").strip()
assert [call.name for call in calls] == ["get_weather", "add"]
assert json.loads(calls[0].arguments) == {"city": "杭州", "count": 42}
@pytest.mark.parametrize(
("controls", "text", "expected_reasoning", "reasoning_tokens"),
[
({"thinking": False}, "12", "", 0),
({"enable_thinking": False}, "12", "", 0),
({"thinking": True, "reasoning_effort": "none"}, "12", "", 0),
({}, "</think>12", "", 0),
({}, "Plan.</think>12", "Plan.", 5),
],
)
def test_reasoning_adapter_controls_and_usage(
controls,
text,
expected_reasoning,
reasoning_tokens,
):
tokenizer, tokens = tokenizer_for(text, False)
parser = parser_for(tokenizer, controls)
output = collect_output(
replay_streaming(
parser,
tokens,
chunk_size=1,
finished_on_last=True,
)
)
assert output.reasoning == expected_reasoning
assert output.content == "12"
assert (
parser.reasoning_parser.count_reasoning_tokens([tid for tid, _ in tokens])
== reasoning_tokens
)
assert parser.is_reasoning_end([50, 100, 51])
assert not parser.is_reasoning_end([51, 100, 50])
def test_python_argument_conversion_and_partial_values():
converter = deepseek_v41_config().arg_converter
raw = (
'<|DSML| parameter name="object" string="false">'
'{"a": [true, null]}</|DSML| parameter>'
'<|DSML| parameter name="bad" string="false">[broken</|DSML| parameter>'
'<|DSML| parameter name="text" string="true"> a<b'
)
assert json.loads(converter(raw, True)) == {
"object": {"a": [True, None]},
"bad": "[broken",
"text": " a<b",
}
@@ -0,0 +1,38 @@
<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)
You are a helpful assistant.
## Tools
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML| calls>" block like the following:
<|DSML| calls>
<|DSML| invoke name="$TOOL_NAME">
<|DSML| parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML| parameter>
...
</|DSML| invoke>
<|DSML| invoke name="$TOOL_NAME2">
...
</|DSML| invoke>
</|DSML| calls>
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"`.
If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
Otherwise, output directly after </think> with tool calls or final response.
### Available Tool Schemas
{"name": "get_weather", "description": "Get the weather for a specific location", "parameters": {"type": "object", "properties": {"location": {"type": "string", "description": "The city name"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "Temperature unit"}}, "required": ["location"]}}
{"name": "search", "description": "Search the web for information", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "Search query"}, "num_results": {"type": "integer", "description": "Number of results to return"}}, "required": ["query"]}}
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
<|User|>What's the weather in Beijing?<|Assistant|><think>The user wants to know the weather in Beijing. I should use the get_weather tool.</think>
<|DSML| calls>
<|DSML| invoke name="get_weather">
<|DSML| parameter name="location" string="true">Beijing</|DSML| parameter>
<|DSML| parameter name="unit" string="true">celsius</|DSML| parameter>
</|DSML| invoke>
</|DSML| calls><|end▁of▁sentence|><|User|><tool_result>{"temperature": 22, "condition": "sunny", "humidity": 45}</tool_result><|Assistant|><think>Got the weather data. Let me format a nice response.</think>The weather in Beijing is currently sunny with a temperature of 22°C and 45% humidity.<|end▁of▁sentence|>
@@ -0,0 +1,3 @@
<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)
You are a helpful assistant.<|User|>Hello<|Assistant|></think>Hi there! How can I help you?<|end▁of▁sentence|><|User|>What is the capital of France?<|Assistant|><think>The user asks about the capital of France. It is Paris.</think>The capital of France is Paris.<|end▁of▁sentence|>
+169
View File
@@ -0,0 +1,169 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import copy
import json
from pathlib import Path
import pytest
from tests.tokenizers_.test_deepseek_v4 import FakeHfTokenizer
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.tokenizers.deepseek_v41 import get_deepseek_v41_tokenizer
FIXTURES = Path(__file__).parent / "fixtures"
def render(messages, **kwargs):
return get_deepseek_v41_tokenizer(FakeHfTokenizer()).apply_chat_template(
messages, tokenize=False, **kwargs
)
@pytest.mark.parametrize("case_id", [1, 2])
def test_reference_encoder_fixtures(case_id):
# Expected prompts come from the DeepSeek V4.1 reference encoder.
data = json.loads(
(FIXTURES / "deepseek_v4" / f"test_input_{case_id}.json").read_text()
)
messages = data["messages"] if isinstance(data, dict) else data
tools = data.get("tools") if isinstance(data, dict) else None
expected = (FIXTURES / "deepseek_v41" / f"test_output_{case_id}.txt").read_text()
assert render(messages, tools=tools) == expected
@pytest.mark.parametrize(
("effort", "budget"),
[
(None, 50),
("low", 25),
("high", 50),
("xhigh", 75),
("max", 100),
(1, 1),
(42, 42),
(100, 100),
],
)
def test_numeric_reasoning_effort(effort, budget):
assert render(
[{"role": "user", "content": "question"}], reasoning_effort=effort
) == (
"<|begin▁of▁sentence|><|System|>"
f"Reasoning Effort: {budget} (range 1-100, the higher the value, "
"the more thorough the reasoning)\n\n"
"<|User|>question<|Assistant|><think>"
)
@pytest.mark.parametrize("effort", ["medium", "minimal", -1, 0, 101, True, 1.5, []])
def test_invalid_effort_is_a_request_error(effort):
with pytest.raises(ValueError, match="reasoning_effort"):
render([{"role": "user", "content": "question"}], reasoning_effort=effort)
@pytest.mark.parametrize(
"controls",
[
{"thinking": False},
{"enable_thinking": False},
{"thinking": True, "reasoning_effort": "none"},
{"enable_thinking": True, "reasoning_effort": "none"},
],
)
def test_chat_mode_has_closed_thinking_prefix(controls):
assert render([{"role": "user", "content": "question"}], **controls) == (
"<|begin▁of▁sentence|><|User|>question<|Assistant|></think>"
)
def test_raw_text_parts_preserve_reference_separator():
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "first"},
{"type": "text", "text": "second"},
],
}
]
original = copy.deepcopy(messages)
assert (
render(
messages,
conversation=[{"role": "user", "content": "first\nsecond"}],
thinking=False,
)
== "<|begin▁of▁sentence|><|User|>first\n\nsecond<|Assistant|></think>"
)
assert messages == original
def test_mid_system_gets_its_own_marker_and_generation_header():
assert render(
[
{"role": "user", "content": "question"},
{"role": "system", "content": "answer briefly"},
],
thinking=False,
) == (
"<|begin▁of▁sentence|><|User|>question"
"<|System|>answer briefly<|Assistant|></think>"
)
def test_top_level_effort_overrides_template_effort():
request = ChatCompletionRequest(
model="deepseek-ai/DeepSeek-V4.1-Flash",
messages=[{"role": "user", "content": "question"}],
reasoning_effort="low",
chat_template_kwargs={"reasoning_effort": 100},
)
kwargs = request.build_chat_params(None, "auto").get_apply_chat_template_kwargs()
assert "Reasoning Effort: 25 " in render(request.messages, **kwargs)
def test_encode_uses_one_bos_and_forwards_truncation():
tokenizer = get_deepseek_v41_tokenizer(FakeHfTokenizer())
tokenizer.apply_chat_template(
[{"role": "user", "content": "question"}],
thinking=False,
truncation=True,
max_length=16,
)
text, add_special_tokens, kwargs = tokenizer.last_encode
assert text.startswith("<|begin▁of▁sentence|>")
assert add_special_tokens is False
assert kwargs == {"truncation": True, "max_length": 16}
@pytest.mark.parametrize("image_type", ["image_url", "input_image", "image_pil"])
def test_images_preserve_content_order_and_reference_separator(image_type):
from PIL import Image
def image_part(color):
if image_type == "image_pil":
return {"type": image_type, "image_pil": Image.new("RGB", (2, 2), color)}
url = f"https://example.com/{color}.png"
return {
"type": image_type,
"image_url": {"url": url} if image_type == "image_url" else url,
}
parts = [
{"type": "text", "text": "first"},
image_part("red"),
{"type": "text", "text": "second"},
image_part("blue"),
{"type": "text", "text": "compare"},
]
assert render([{"role": "user", "content": parts}], thinking=False) == (
"<|begin▁of▁sentence|><|User|>first\n\n<|deepseek_image|>\n\n"
"second\n\n<|deepseek_image|>\n\ncompare<|Assistant|></think>"
)
assert parts[1]["type"] == parts[3]["type"] == image_type
def test_unsupported_media_is_a_request_error():
with pytest.raises(ValueError, match="text and image content only"):
render([{"role": "user", "content": [{"type": "input_audio"}]}])
@@ -80,6 +80,67 @@ def test_supported_structural_tag_models_include_vllm_builtins():
assert "hermes" in VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
@pytest.mark.parametrize("choice", ["auto", "required", "get_weather"])
def test_deepseek_v41_format_constraints_ignore_parameter_schema(
choice,
sample_tools_strict,
):
tool_choice = (
ChatCompletionNamedToolChoiceParam(
function=ChatCompletionNamedFunction(name=choice)
)
if choice == "get_weather"
else choice
)
tag = get_model_structural_tag(
"deepseek_v41", sample_tools_strict, tool_choice, reasoning=False
)
grammar = Grammar.from_structural_tag(tag)
begin = '\n\n<|DSML| calls>\n<|DSML| invoke name="get_weather">\n'
end = "</|DSML| invoke>\n</|DSML| calls>"
parameter = (
'<|DSML| parameter name="undeclared" string="false">'
'{"nested": [true, null, 1.5]}</|DSML| parameter>\n'
)
# Required city is omitted; extra and repeated fields remain unconstrained.
assert _is_grammar_accept_string(grammar, begin + end)
assert _is_grammar_accept_string(grammar, begin + parameter * 2 + end)
assert not _is_grammar_accept_string(
grammar,
begin + parameter.replace('{"nested": [true, null, 1.5]}', "invalid") + end,
)
assert not _is_grammar_accept_string(
grammar, (begin + end).replace('name="get_weather"', 'name="unknown"')
)
assert not _is_grammar_accept_string(
grammar, begin + parameter.replace("| parameter", "|parameter") + end
)
assert _is_grammar_accept_string(grammar, "Hello") == (choice == "auto")
def test_deepseek_v41_named_choice_emits_one_call(sample_tools):
tag = get_model_structural_tag(
"deepseek_v41",
sample_tools,
ChatCompletionNamedToolChoiceParam(
function=ChatCompletionNamedFunction(name="get_weather")
),
reasoning=False,
)
grammar = Grammar.from_structural_tag(tag)
call = '<|DSML| invoke name="get_weather">\n</|DSML| invoke>\n'
assert _is_grammar_accept_string(
grammar, "\n\n<|DSML| calls>\n" + call + "</|DSML| calls>"
)
assert not _is_grammar_accept_string(
grammar, "\n\n<|DSML| calls>\n" + call * 2 + "</|DSML| calls>"
)
assert (
get_model_structural_tag("deepseek_v41", sample_tools, "auto", reasoning=False)
is None
)
@pytest.mark.parametrize("model", sorted(XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS))
def test_get_model_structural_tag_supports_all_xgrammar_builtins(
model: str,
+3
View File
@@ -152,6 +152,7 @@ class ModelConfig:
- "mistral" will always use the tokenizer from `mistral_common`.
- "deepseek_v32" will always use the tokenizer from `deepseek_v32`.
- "deepseek_v4" will always use the tokenizer from `deepseek_v4`.
- "deepseek_v41" will use the DeepSeek V4.1 prompt encoder.
- "kimi_k3" will always use the "hf" tokenizer but render chat prompts
with Kimi K3's Python XTML encoding instead of a Jinja template.
- "cohere" uses the standard HF tokenizer but renders the chat template
@@ -705,6 +706,8 @@ class ModelConfig:
"DeepseekV4ForConditionalGeneration",
):
self.tokenizer_mode = "deepseek_v4"
elif arch == "DeepseekV41ForCausalLM":
self.tokenizer_mode = "deepseek_v41"
elif arch in ("InklingForCausalLM", "InklingForConditionalGeneration"):
self.tokenizer_mode = "inkling"
@@ -0,0 +1,463 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Multimodal preprocessing for the DeepSeek-V4.1 vision variant.
The image transform and image-span construction are ported from the
official repository's ``image_processor.py`` so that token counts bit-match
the reference. Each ``<|deepseek_image|>`` placeholder in the prompt expands
to ``[IMAGE_START] + ([IMAGE] * n_llm_w + [IMAGE_NEW_LINE]) * n_llm_h +
[IMAGE_END]``; every span position carries ``image_token_id`` (129264) in
``input_ids`` and the roles ride along in a per-image ``types`` tensor
(the reference's out-of-band ``token_types``). IMAGE slots receive aligner
rows in reading order; the delimiters take the learned ``image_start`` /
``image_newline`` / ``image_end`` vectors.
One vLLM-side deviation from the reference token stream: a leading
compressor-alignment pad (``COMPRESS_PAD_TO - 1 - start % COMPRESS_PAD_TO``
positions, so the span always starts at the same compressor phase) is
prepended when the block is spliced into the final prompt. Pad positions
borrow the reserved in-vocab token ``<|place_holder_mm_span_0436|>`` so
they stay distinguishable from real span positions; they embed as the
plain image token (v4.1 has no ``image_pad`` vector) and are routed and
engram-deadened like image tokens.
"""
import math
from collections.abc import Mapping, Sequence
from typing import Any, cast
import numpy as np
import torch
from PIL import Image, ImageOps
from transformers import BatchFeature
from typing_extensions import assert_never
from vllm.config.multimodal import BaseDummyOptions, ImageDummyOptions
from vllm.inputs import MultiModalDataDict
from vllm.multimodal.inputs import MultiModalFieldConfig, MultiModalKwargsItems
from vllm.multimodal.parse import ImageSize, MultiModalDataItems
from vllm.multimodal.processing import (
BaseDummyInputsBuilder,
BaseMultiModalProcessor,
BaseProcessingInfo,
PromptReplacement,
PromptUpdate,
PromptUpdateDetails,
)
from vllm.multimodal.processing.processor import (
MultiModalPromptUpdates,
MultiModalPromptUpdatesApplyResult,
PlaceholderFeaturesInfo,
UpdateMode,
_plan_prompt_updates,
)
from vllm.transformers_utils.configs.deepseek_v41 import DeepseekV41Config
IMAGE_START, IMAGE, IMAGE_NEW_LINE, IMAGE_END = range(4)
# Image spans are padded so they always start at the same compressor group
# phase; v4.1 uses ratio-2 compressors (v4.0 used 4).
COMPRESS_PAD_TO = 2
IMAGE_PLACEHOLDER = "<|deepseek_image|>"
# The checkpoint tokenizer's id for IMAGE_PLACEHOLDER (the config's
# ``image_token_id``). Every image-span position carries this id, exactly
# like the reference; the MoE router detects image tokens as the range
# [IMAGE_SENTINEL_BASE_ID, IMAGE_SENTINEL_BASE_ID + 5), which also covers
# IMAGE_PAD_ID below.
IMAGE_SENTINEL_BASE_ID = 129264
# Leading compressor-alignment pads borrow this reserved in-vocab token
# (``<|place_holder_mm_span_0436|>``) so they can be told apart from real
# span positions when the per-position role is decided from token ids.
IMAGE_PAD_ID = 129265
IMAGE_PAD_TOKEN_NAME = "<|place_holder_mm_span_0436|>"
def image_sentinel_mask(token_ids: torch.Tensor) -> torch.Tensor:
"""Boolean mask for image-span positions (span tokens and align pads)."""
return (token_ids == IMAGE_SENTINEL_BASE_ID) | (token_ids == IMAGE_PAD_ID)
def validate_image_sentinel_ids(tokenizer) -> None:
"""Check the image/pad token ids against the tokenizer."""
image_id = tokenizer.convert_tokens_to_ids(IMAGE_PLACEHOLDER)
if image_id != IMAGE_SENTINEL_BASE_ID:
raise ValueError(
f"Image placeholder {IMAGE_PLACEHOLDER!r} has id {image_id}, "
f"expected {IMAGE_SENTINEL_BASE_ID} (the config's "
"image_token_id); the DeepSeek-V4.1 vision path keys image "
"routing and engram masking off this id."
)
pad_id = tokenizer.convert_tokens_to_ids(IMAGE_PAD_TOKEN_NAME)
if pad_id != IMAGE_PAD_ID:
raise ValueError(
f"Image pad token {IMAGE_PAD_TOKEN_NAME!r} has id {pad_id}, "
f"expected {IMAGE_PAD_ID}; the DeepSeek-V4.1 vision path "
"borrows this reserved id for compressor-alignment pads."
)
def llm_grid(best_height, best_width, patch_size, downsample_ratio):
"""Token grid the aligner produces from a patch grid of this pixel size."""
return (
math.ceil((best_height // patch_size) / downsample_ratio),
math.ceil((best_width // patch_size) / downsample_ratio),
)
def num_image_tokens(n_llm_h: int, n_llm_w: int) -> int:
return n_llm_h * (n_llm_w + 1) + 2
def solve_resize_ratio(height, width, patch_size, downsample_ratio, max_n_token):
"""Largest aspect-preserving pixel size whose token grid still fits in
max_n_token. Returns (best_height, best_width)."""
r = height / width
max_w_float = math.sqrt((max_n_token - 2) / r + 0.25) - 0.5
max_h_float = max_w_float * r
cell = patch_size * downsample_ratio
if max_w_float < 1.0: # very tall: collapse to a single column
return (max_n_token - 2) // 2 * cell, cell
if max_h_float < 1.0: # very wide: collapse to a single row
return cell, (max_n_token - 3) * cell
beta = min(
math.floor(max_w_float) * cell / width,
math.floor(max_h_float) * cell / height,
)
return (
math.floor(height * beta / patch_size) * patch_size,
math.floor(width * beta / patch_size) * patch_size,
)
def safe_resize(
height, width, best_height, best_width, patch_size, downsample_ratio, max_n_token
):
"""Shrink the pixel size until the image costs at most max_n_token LLM
tokens (minus the reservation for the compressor-alignment pad)."""
max_n_token -= COMPRESS_PAD_TO - 1
n_llm_h, n_llm_w = llm_grid(best_height, best_width, patch_size, downsample_ratio)
if num_image_tokens(n_llm_h, n_llm_w) > max_n_token:
best_height, best_width = solve_resize_ratio(
height, width, patch_size, downsample_ratio, max_n_token
)
n_llm_h, n_llm_w = llm_grid(
best_height, best_width, patch_size, downsample_ratio
)
assert num_image_tokens(n_llm_h, n_llm_w) <= max_n_token
return n_llm_h, n_llm_w, best_height, best_width
def load_image(
image: Image.Image,
*,
patch_size: int,
downsample_ratio: int,
max_n_token: int,
min_pixels: int,
max_wh_ratio: float | None,
):
"""Transform one PIL image into ViT patches.
Same math as the reference ``load_image``, except the image is already
decoded (vLLM supplies PIL images instead of a record dict).
"""
p = patch_size
image = image.convert("RGB")
width, height = image.size
if max_wh_ratio is not None and width > height * max_wh_ratio:
width = height * max_wh_ratio
if 0 < width * height < min_pixels:
ratio = (min_pixels / (width * height)) ** 0.5
width = int(width * ratio)
height = int(height * ratio)
best_width = math.ceil(width / p) * p
best_height = math.ceil(height / p) * p
n_llm_h, n_llm_w, best_height, best_width = safe_resize(
height, width, best_height, best_width, p, downsample_ratio, max_n_token
)
n_vit_h, n_vit_w = best_height // p, best_width // p
if max_wh_ratio is not None and image.width >= max_wh_ratio * image.height:
image = image.resize((best_width, best_height))
else:
image = ImageOps.pad(image, (best_width, best_height), color=(127, 127, 127))
x = torch.from_numpy(np.asarray(image, dtype=np.float32)).permute(2, 0, 1) / 255
x = ((x - 0.5) / 0.5).to(torch.bfloat16)
patches = (
x.reshape(3, n_vit_h, p, n_vit_w, p)
.permute(1, 3, 0, 2, 4)
.reshape(n_vit_h * n_vit_w, 3, p, p)
)
return patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w
def image_token_types(n_llm_h: int, n_llm_w: int) -> torch.Tensor:
"""Reading-order span layout: one IMAGE_NEW_LINE per row."""
types = [IMAGE_START]
types += ([IMAGE] * n_llm_w + [IMAGE_NEW_LINE]) * n_llm_h
types.append(IMAGE_END)
return torch.tensor(types, dtype=torch.int64)
class DeepseekV4VLImageProcessor:
"""Per-image transform (the PIL-input equivalent of the reference
``load_image``)."""
def __init__(self, config: DeepseekV41Config) -> None:
super().__init__()
self.patch_size = config.vision_patch_size
self.downsample_ratio = config.vision_downsample_ratio
self.max_n_token = config.vision_max_n_token
self.min_pixels = config.vision_min_pixels
self.max_wh_ratio = config.vision_max_wh_ratio
def __call__(self, image: Image.Image):
return load_image(
image,
patch_size=self.patch_size,
downsample_ratio=self.downsample_ratio,
max_n_token=self.max_n_token,
min_pixels=self.min_pixels,
max_wh_ratio=self.max_wh_ratio,
)
class DeepseekV4VLProcessor:
"""Minimal stand-in for the HF processor of DeepSeek-V4.1 vision models.
The official repository ships image preprocessing as plain functions in
``image_processor.py`` (no ``auto_map`` processor), so this class wraps
their ports directly and the model loads without ``--trust-remote-code``.
``__call__`` returns a ``BatchFeature`` with one entry per image
(flattened across images):
- ``patches``: ``(sum(n_vit_h * n_vit_w), 3, p, p)`` bf16 ViT patches.
- ``vit_grid``: ``(num_images, 2)`` int64 ``[n_vit_h, n_vit_w]``.
- ``llm_grid``: ``(num_images, 2)`` int64 ``[n_llm_h, n_llm_w]``.
- ``types``: concatenated per-image pad-free span roles
(IMAGE_START/IMAGE/IMAGE_NEW_LINE/IMAGE_END); every span position
carries ``image_token_id`` in the prompt's token ids.
"""
def __init__(self, config: DeepseekV41Config) -> None:
super().__init__()
self.config = config
self.image_processor = DeepseekV4VLImageProcessor(config)
def __call__(
self,
text: str | None = None,
images: Sequence[Image.Image] | None = None,
return_tensors: str | None = None,
**kwargs: Any,
) -> BatchFeature:
patches_list = []
vit_grid = []
llm_grid_list = []
types_list = []
for image in images or []:
patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w = self.image_processor(image)
patches_list.append(patches)
vit_grid.append((n_vit_h, n_vit_w))
llm_grid_list.append((n_llm_h, n_llm_w))
types_list.append(image_token_types(n_llm_h, n_llm_w))
if not patches_list:
return BatchFeature({})
return BatchFeature(
{
"patches": torch.cat(patches_list),
"vit_grid": torch.tensor(vit_grid, dtype=torch.int64),
"llm_grid": torch.tensor(llm_grid_list, dtype=torch.int64),
"types": torch.cat(types_list),
}
)
class DeepseekV4VLProcessingInfo(BaseProcessingInfo):
def get_hf_config(self) -> DeepseekV41Config:
return self.ctx.get_hf_config(DeepseekV41Config)
def get_hf_processor(self, **kwargs: object) -> DeepseekV4VLProcessor:
if kwargs:
raise ValueError(f"Unexpected processor kwargs: {sorted(kwargs)}")
return DeepseekV4VLProcessor(self.get_hf_config())
def get_supported_mm_limits(self) -> Mapping[str, int | None]:
return {"image": None}
def get_mm_max_tokens_per_item(
self,
seq_len: int,
mm_counts: Mapping[str, int],
) -> Mapping[str, int]:
# ``safe_resize`` reserves COMPRESS_PAD_TO - 1 tokens of
# vision_max_n_token for the compressor-alignment pad, so the full
# image span is bounded by vision_max_n_token; the margin is kept
# in case that reservation changes.
return {
"image": self.get_hf_config().vision_max_n_token + COMPRESS_PAD_TO - 1,
}
def get_image_placeholder_token_id(self) -> int:
token_id = self.get_tokenizer().convert_tokens_to_ids(IMAGE_PLACEHOLDER)
if token_id is None:
raise ValueError(f"Token not found in tokenizer: {IMAGE_PLACEHOLDER}")
return token_id
def get_image_size_with_most_features(self) -> ImageSize:
hf_config = self.get_hf_config()
patch_size = hf_config.vision_patch_size
downsample_ratio = hf_config.vision_downsample_ratio
# A square maximizes the ViT patch count (area) within the token
# budget; solve the budget-derived size directly to keep the dummy
# image small.
budget = hf_config.vision_max_n_token - (COMPRESS_PAD_TO - 1)
side = budget * patch_size * downsample_ratio
best_h, best_w = solve_resize_ratio(
side, side, patch_size, downsample_ratio, budget
)
return ImageSize(width=best_w, height=best_h)
class DeepseekV4VLDummyInputsBuilder(
BaseDummyInputsBuilder[DeepseekV4VLProcessingInfo]
):
def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
return IMAGE_PLACEHOLDER * mm_counts.get("image", 0)
def get_dummy_mm_data(
self,
seq_len: int,
mm_counts: Mapping[str, int],
mm_options: Mapping[str, BaseDummyOptions],
) -> MultiModalDataDict:
size = self.info.get_image_size_with_most_features()
return {
"image": self._get_dummy_images(
width=size.width,
height=size.height,
num_images=mm_counts.get("image", 0),
overrides=cast(ImageDummyOptions | None, mm_options.get("image")),
),
}
class DeepseekV4VLMultiModalProcessor(
BaseMultiModalProcessor[DeepseekV4VLProcessingInfo]
):
def _get_mm_fields_config(
self,
hf_inputs: BatchFeature,
hf_processor_mm_kwargs: Mapping[str, object],
) -> Mapping[str, MultiModalFieldConfig]:
vit_grid = hf_inputs.get("vit_grid")
llm_grid = hf_inputs.get("llm_grid")
if vit_grid is None or llm_grid is None:
empty = torch.empty(0, dtype=torch.long)
patch_sizes = types_sizes = empty
else:
patch_sizes = vit_grid.prod(-1)
n_llm_h, n_llm_w = llm_grid[:, 0], llm_grid[:, 1]
types_sizes = n_llm_h * (n_llm_w + 1) + 2
return {
"patches": MultiModalFieldConfig.flat_from_sizes("image", patch_sizes),
"vit_grid": MultiModalFieldConfig.batched("image", keep_on_cpu=True),
"llm_grid": MultiModalFieldConfig.batched("image", keep_on_cpu=True),
"types": MultiModalFieldConfig.flat_from_sizes(
"image", types_sizes, keep_on_cpu=True
),
}
def _get_prompt_updates(
self,
mm_items: MultiModalDataItems,
hf_processor_mm_kwargs: Mapping[str, object],
out_mm_kwargs: MultiModalKwargsItems,
) -> Sequence[PromptUpdate]:
image_token_id = self.info.get_image_placeholder_token_id()
validate_image_sentinel_ids(self.info.get_tokenizer())
def get_image_replacement(item_idx: int) -> PromptUpdateDetails:
types: torch.Tensor = out_mm_kwargs["image"][item_idx]["types"].data
# Every span position carries image_token_id; the roles live in
# ``types``. All of them are embed positions (delimiters get the
# learned vectors from embed_multimodal, not the embed table).
full = [image_token_id] * types.numel()
return PromptUpdateDetails.select_token_id(full, image_token_id)
return [
PromptReplacement(
modality="image",
target=[image_token_id],
replacement=get_image_replacement,
),
]
def _apply_token_matches_with_placeholders(
self,
token_ids: list[int],
mm_prompt_updates: MultiModalPromptUpdates,
) -> tuple[
list[int],
MultiModalPromptUpdatesApplyResult,
Mapping[str, list[PlaceholderFeaturesInfo]],
]:
# Same as the base implementation, except that each image block gets
# its compressor-alignment pad (``COMPRESS_PAD_TO - 1 - start %
# COMPRESS_PAD_TO`` IMAGE_PAD_ID tokens) prepended while splicing:
# the pad depends on the block's final position in the prompt, which
# is unknown when the (cacheable) prompt updates are built. Pad
# positions are not embed positions (their id differs from
# image_token_id), so ``is_embed`` stays aligned with the pad-free
# replacement content.
matched_updates, result = _plan_prompt_updates(token_ids, mm_prompt_updates)
placeholders: dict[str, list[PlaceholderFeaturesInfo]] = {
modality: [] for modality in mm_prompt_updates
}
new_token_ids = list[int]()
prev_end_idx = 0
for matched_update in matched_updates:
update = matched_update.update
match = matched_update.match
if update.mode == UpdateMode.INSERT:
end_idx_to_insert = match.end_idx
elif update.mode == UpdateMode.REPLACE:
end_idx_to_insert = match.start_idx
else:
assert_never(update.mode)
new_token_ids.extend(token_ids[prev_end_idx:end_idx_to_insert])
start_idx = len(new_token_ids)
tokens = list(update.content.full)
if tokens and update.modality == "image":
compress_pad = COMPRESS_PAD_TO - 1 - start_idx % COMPRESS_PAD_TO
tokens = [IMAGE_PAD_ID] * compress_pad + tokens
if tokens:
content_is_embed = update.content.is_embed
is_embed = (
content_is_embed(tokens) if content_is_embed is not None else None
)
placeholders[update.modality].append(
PlaceholderFeaturesInfo(
modality=update.modality,
item_idx=update.item_idx,
start_idx=start_idx,
tokens=tokens,
is_embed=is_embed,
)
)
new_token_ids.extend(tokens)
prev_end_idx = match.end_idx
new_token_ids.extend(token_ids[prev_end_idx:])
return new_token_ids, result, placeholders
+15 -5
View File
@@ -70,11 +70,17 @@ _PARTIAL_PARAM_RE = re.compile(
)
def _dsml_arg_converter(raw_args: str, partial: bool) -> str:
def _dsml_arg_converter(
raw_args: str,
partial: bool,
*,
param_re: re.Pattern = _PARAM_RE,
partial_param_re: re.Pattern = _PARTIAL_PARAM_RE,
) -> str:
params: dict[str, object] = {}
last_end = 0
for m in _PARAM_RE.finditer(raw_args):
for m in param_re.finditer(raw_args):
name, is_str, value = m.group(1), m.group(2), m.group(3)
if is_str == "true":
params[name] = value
@@ -86,7 +92,7 @@ def _dsml_arg_converter(raw_args: str, partial: bool) -> str:
last_end = m.end()
if partial:
pm = _PARTIAL_PARAM_RE.search(raw_args, last_end)
pm = partial_param_re.search(raw_args, last_end)
if pm:
name, is_str, value = pm.group(1), pm.group(2), pm.group(3)
if is_str == "true":
@@ -229,6 +235,8 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
class DeepSeekV4Parser(ParserEngine):
parser_config = staticmethod(deepseek_v4_config)
def __init__(
self,
tokenizer: TokenizerLike,
@@ -245,13 +253,15 @@ class DeepSeekV4Parser(ParserEngine):
super().__init__(
tokenizer,
tools,
parser_engine_config=deepseek_v4_config(thinking=thinking),
parser_engine_config=self.parser_config(thinking=thinking),
**kwargs,
)
self._arg_converter = self._convert_args
def _convert_args(self, raw_args: str, partial: bool) -> str:
result = _dsml_arg_converter(raw_args, partial)
converter = self.parser_engine_config.arg_converter
assert converter is not None
result = converter(raw_args, partial)
if not self._tools:
return result
func_name = next((s.name for s in self._tool_slots if s.args == raw_args), None)
+65
View File
@@ -0,0 +1,65 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""DeepSeek V4.1 reasoning and spaced DSML tool calls."""
import functools
from dataclasses import replace
import regex as re
from vllm.parser.deepseek_v4 import (
DeepSeekV4Parser,
_dsml_arg_converter,
deepseek_v4_config,
)
from vllm.parser.engine.parser_engine_config import ParserEngineConfig
DSML_TOOL_START = "<|DSML| calls>"
DSML_TOOL_END = "</|DSML| calls>"
DSML_INVOKE_PREFIX = '<|DSML| invoke name="'
DSML_INVOKE_END = "</|DSML| invoke>"
DSML_PARAM_START = "<|DSML| parameter"
DSML_PARAM_CLOSE = "</|DSML| parameter>"
_PARAM_RE = re.compile(
r'<|DSML| parameter\s+name="([^"]+)"\s+string="(true|false)">'
r"(.*?)"
r"(?:</|DSML| parameter>|(?=<|DSML| parameter\s+name=))",
re.DOTALL,
)
_PARTIAL_PARAM_RE = re.compile(
r'<|DSML| parameter\s+name="([^"]+)"\s+string="(true|false)">'
r"(.*)$",
re.DOTALL,
)
@functools.cache
def deepseek_v41_config(thinking: bool = False) -> ParserEngineConfig:
config = deepseek_v4_config(thinking=thinking)
terminal_overrides = {
"TOOL_START": DSML_TOOL_START,
"TOOL_END": DSML_TOOL_END,
"INVOKE_PREFIX": DSML_INVOKE_PREFIX,
"INVOKE_END": DSML_INVOKE_END,
"PARAM_START": DSML_PARAM_START,
"PARAM_CLOSE": DSML_PARAM_CLOSE,
}
return replace(
config,
name="deepseek_v41",
terminals={**config.terminals, **terminal_overrides},
token_id_terminals={
key: terminal_overrides.get(key, value)
for key, value in config.token_id_terminals.items()
},
arg_converter=functools.partial(
_dsml_arg_converter,
param_re=_PARAM_RE,
partial_param_re=_PARTIAL_PARAM_RE,
),
)
class DeepSeekV41Parser(DeepSeekV4Parser):
parser_config = staticmethod(deepseek_v41_config)
@@ -9,6 +9,7 @@ names so that :class:`ReasoningParserManager` and
from vllm.parser.deepseek_v4 import DeepSeekV4Parser
from vllm.parser.deepseek_v32 import DeepSeekV32Parser
from vllm.parser.deepseek_v41 import DeepSeekV41Parser
from vllm.parser.engine.adapters import make_adapters
from vllm.parser.gemma4 import Gemma4Parser
from vllm.parser.glm47_moe import Glm47MoeParser
@@ -30,6 +31,11 @@ from vllm.parser.seed_oss import SeedOssParser
DeepSeekV4ParserToolAdapter,
) = make_adapters(DeepSeekV4Parser)
(
DeepSeekV41ParserReasoningAdapter,
DeepSeekV41ParserToolAdapter,
) = make_adapters(DeepSeekV41Parser)
(
MinimaxM2ParserReasoningAdapter,
MinimaxM2ParserToolAdapter,
+4
View File
@@ -32,6 +32,10 @@ _REASONING_PARSERS_TO_REGISTER = {
"deepseek_v4_engine_reasoning_parser",
"DeepSeekV4ParserReasoningAdapter",
),
"deepseek_v41": (
"deepseek_v41_engine_reasoning_parser",
"DeepSeekV41ParserReasoningAdapter",
),
"poolside_v1": (
"poolside_v1_reasoning_parser",
"PoolsideV1ReasoningParser",
@@ -0,0 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from vllm.parser.engine.registered_adapters import DeepSeekV41ParserReasoningAdapter
__all__ = ["DeepSeekV41ParserReasoningAdapter"]
+1
View File
@@ -23,6 +23,7 @@ _VLLM_RENDERERS = {
"cohere": ("cohere", "CohereRenderer"),
"deepseek_v32": ("deepseek_v32", "DeepseekV32Renderer"),
"deepseek_v4": ("deepseek_v4", "DeepseekV4Renderer"),
"deepseek_v41": ("deepseek_v4", "DeepseekV4Renderer"),
"hf": ("hf", "HfRenderer"),
"kimi_audio": ("hf", "HfRenderer"),
"kimi_k3": ("kimi_k3", "KimiK3Renderer"),
+121
View File
@@ -0,0 +1,121 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import copy
from typing import Any
from transformers import TokenizersBackend
from vllm.entrypoints.chat_utils import ChatCompletionMessageParam
from .deepseek_v41_encoding import (
IMAGE_PLACEHOLDER,
REASONING_EFFORT_MAPPINGS,
encode_messages,
)
from .hf import HfTokenizer, get_cached_tokenizer
from .protocol import TokenizerLike
def _normalize_messages(
messages: list[ChatCompletionMessageParam],
) -> list[dict[str, Any]]:
result = [dict(message) for message in copy.deepcopy(messages)]
for message in result:
role = message.get("role")
if role not in ("system", "developer", "user", "assistant", "tool"):
raise ValueError(f"Invalid role: {role}")
if "reasoning" in message:
message["reasoning_content"] = message["reasoning"]
content = message.get("content")
if isinstance(content, list):
parts = []
for block in content:
part_type = block.get("type")
if part_type == "text":
parts.append(block.get("text", ""))
elif part_type in ("image_url", "input_image", "image_pil"):
parts.append(IMAGE_PLACEHOLDER)
else:
raise ValueError(
"DeepSeek V4.1 supports text and image content only; "
f"got {part_type!r}"
)
message["content"] = "\n\n".join(parts)
return result
def get_deepseek_v41_tokenizer(tokenizer: HfTokenizer) -> HfTokenizer:
"""Wrap an HF tokenizer with the V4.1 prompt encoder."""
wrapped = copy.copy(tokenizer)
added_vocab = tokenizer.get_added_vocab()
class _DeepseekV41Tokenizer(tokenizer.__class__): # type: ignore
def apply_chat_template(
self,
messages: list[ChatCompletionMessageParam],
tools: list[dict[str, Any]] | None = None,
**kwargs,
) -> str | list[int]:
# The generic renderer's conversation has already flattened text
# parts with '\n'. V4.1 encodes the original messages with '\n\n'.
conversation = _normalize_messages(messages)
if tools:
system = next((m for m in conversation if m["role"] == "system"), None)
if system is None:
system = {"role": "system", "content": ""}
conversation.insert(0, system)
system["tools"] = tools
thinking = bool(kwargs.get("thinking") or kwargs.get("enable_thinking"))
if "thinking" not in kwargs and "enable_thinking" not in kwargs:
thinking = True
effort = kwargs.get("reasoning_effort")
if effort == "none":
thinking = False
effort = None
if effort is None:
effort = "high"
if not (
(type(effort) is int and 1 <= effort <= 100)
or (isinstance(effort, str) and effort in REASONING_EFFORT_MAPPINGS)
):
raise ValueError(
"DeepSeek V4.1 reasoning_effort must be low, high, xhigh, max, "
"or an integer within [1, 100] in chat_template_kwargs"
)
prompt = encode_messages(
conversation,
thinking_mode="thinking" if thinking else "chat",
drop_thinking=kwargs.get("drop_thinking", True),
reasoning_effort=effort,
)
if kwargs.get("tokenize", True):
tokenizer_kwargs = {
key: kwargs[key]
for key in ("truncation", "max_length")
if key in kwargs
}
return self.encode(prompt, add_special_tokens=False, **tokenizer_kwargs)
return prompt
def num_special_tokens_to_add(self) -> int:
return len(self.encode(""))
def get_added_vocab(self) -> dict[str, int]:
return added_vocab.copy()
def __reduce__(self):
return get_deepseek_v41_tokenizer, (tokenizer,)
_DeepseekV41Tokenizer.__name__ = f"DSV41{tokenizer.__class__.__name__}"
wrapped.__class__ = _DeepseekV41Tokenizer
return wrapped
class DeepseekV41Tokenizer(TokenizerLike):
@classmethod
def from_pretrained(cls, *args, **kwargs) -> HfTokenizer:
tokenizer = TokenizersBackend.from_pretrained(*args, **kwargs)
return get_cached_tokenizer(get_deepseek_v41_tokenizer(tokenizer))
+577
View File
@@ -0,0 +1,577 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# ruff: noqa
"""Text encoding adapted from the DeepSeek V4.1 reference encoder.
Message normalization and serving controls live in deepseek_v41.py.
"""
import copy
import json
from typing import Any, Dict, List, Optional, Union
bos_token: str = "<|begin▁of▁sentence|>"
eos_token: str = "<|end▁of▁sentence|>"
thinking_start_token: str = "<think>"
thinking_end_token: str = "</think>"
dsml_token: str = "|DSML|"
USER_SP_TOKEN = "<|User|>"
ASSISTANT_SP_TOKEN = "<|Assistant|>"
LATEST_REMINDER_SP_TOKEN = "<|latest_reminder|>"
IMAGE_PLACEHOLDER = "<|deepseek_image|>"
DS_TASK_SP_TOKENS = {
"action": "<|action|>",
"query": "<|query|>",
"authority": "<|authority|>",
"domain": "<|domain|>",
"title": "<|title|>",
"read_url": "<|read_url|>",
}
VALID_TASKS = set(DS_TASK_SP_TOKENS.keys())
system_msg_template: str = "{content}"
user_msg_template: str = "{content}"
latest_reminder_msg_template: str = "{content}"
assistant_msg_template: str = "{reasoning}{content}{tool_calls}" + eos_token
assistant_msg_wo_eos_template: str = "{reasoning}{content}{tool_calls}"
thinking_template: str = "{reasoning_content}"
response_format_template: str = (
"## Response Format:\n\n"
"You MUST strictly adhere to the following schema to reply:\n{schema}"
)
tool_output_template: str = "<tool_result>{content}</tool_result>"
def to_json(value: Any) -> str:
"""Serialize a value to JSON string."""
try:
return json.dumps(value, ensure_ascii=False)
except:
return json.dumps(value, ensure_ascii=True)
def tools_from_openai_format(tools):
"""Extract function definitions from OpenAI-format tool list."""
return [tool["function"] for tool in tools]
def tool_calls_from_openai_format(tool_calls):
"""Convert OpenAI-format tool calls to internal format."""
return [
{
"name": tool_call["function"]["name"],
"arguments": tool_call["function"]["arguments"],
}
for tool_call in tool_calls
]
def merge_tool_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Merge tool messages into the preceding user message using content_blocks format.
DeepSeek-V4.1 does not have a standalone "tool" role; instead, tool results
are encoded as <tool_result> blocks within user messages.
"""
merged: List[Dict[str, Any]] = []
for msg in messages:
msg = copy.deepcopy(msg)
role = msg.get("role")
if role == "tool":
# Convert tool message to a user message with tool_result block
tool_block = {
"type": "tool_result",
"tool_use_id": msg.get("tool_call_id", ""),
"content": msg.get("content", ""),
}
# Merge into previous message if it's already a user (merged tool)
if (
merged
and merged[-1].get("role") == "user"
and "content_blocks" in merged[-1]
):
merged[-1]["content_blocks"].append(tool_block)
else:
merged.append(
{
"role": "user",
"content_blocks": [tool_block],
}
)
elif role == "user":
content_blocks = msg.get("content_blocks")
if content_blocks is None:
content_blocks = [{"type": "text", "text": msg.get("content", "")}]
if (
merged
and merged[-1].get("role") == "user"
and "content_blocks" in merged[-1]
and merged[-1].get("task") is None
):
merged[-1]["content_blocks"].extend(content_blocks)
else:
# Preserve structured content and all message-level metadata.
new_msg = msg
new_msg["content_blocks"] = content_blocks
merged.append(new_msg)
else:
merged.append(msg)
return merged
def sort_tool_results_by_call_order(
messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""
Sort tool_result blocks within user messages by the order of tool_calls
in the preceding assistant message.
"""
last_tool_call_order: Dict[str, int] = {}
for msg in messages:
role = msg.get("role")
if role == "assistant" and msg.get("tool_calls"):
last_tool_call_order = {}
for idx, tc in enumerate(msg["tool_calls"]):
tc_id = tc.get("id") or tc.get("function", {}).get("id", "")
if tc_id:
last_tool_call_order[tc_id] = idx
elif role == "user" and msg.get("content_blocks"):
tool_blocks = [
b for b in msg["content_blocks"] if b.get("type") == "tool_result"
]
if len(tool_blocks) > 1 and last_tool_call_order:
sorted_blocks = sorted(
tool_blocks,
key=lambda b: last_tool_call_order.get(b.get("tool_use_id", ""), 0),
)
sorted_idx = 0
new_blocks = []
for block in msg["content_blocks"]:
if block.get("type") == "tool_result":
new_blocks.append(sorted_blocks[sorted_idx])
sorted_idx += 1
else:
new_blocks.append(block)
msg["content_blocks"] = new_blocks
return messages
SYSTEM_SP_TOKEN = "<|System|>"
tool_calls_block_name: str = " calls"
tool_call_tag_name: str = " invoke"
tool_parameter_tag_name: str = " parameter"
tool_call_template: str = '<{dsml_token}{tool_call_tag_name} name="{name}">\n{arguments}\n</{dsml_token}{tool_call_tag_name}>'
tool_calls_template = (
"<{dsml_token}{tc_block_name}>\n{tool_calls}\n</{dsml_token}{tc_block_name}>"
)
REASONING_EFFORT_TEMPLATE = (
"Reasoning Effort: {budget} "
"(range 1-100, the higher the value, the more thorough the reasoning)\n\n"
)
REASONING_EFFORT_MAPPINGS: Dict[str, int] = {
"low": 25,
"high": 50,
"xhigh": 75,
"max": 100,
}
DEFAULT_REASONING_EFFORT = "high"
def render_reasoning_effort(
index: int,
thinking_mode: str,
effort: Union[str, int, None],
) -> str:
"""Render the V4.1 numeric reasoning effort prefix (thinking mode, index 0 only)."""
if effort is None:
effort = DEFAULT_REASONING_EFFORT
assert (
type(effort) is int and 1 <= effort <= 100
) or effort in REASONING_EFFORT_MAPPINGS, (
"Invalid reasoning effort for deepseek_v41: "
f"{effort}, should be int within [1,100] or {list(REASONING_EFFORT_MAPPINGS)}"
)
if type(effort) is str:
effort = REASONING_EFFORT_MAPPINGS[effort]
if index == 0 and thinking_mode == "thinking":
return REASONING_EFFORT_TEMPLATE.format(budget=effort)
return ""
TOOLS_TEMPLATE = """## Tools
You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}{tc_block_name}>" block like the following:
<{dsml_token}{tc_block_name}>
<{dsml_token}{tool_call_tag_name} name="$TOOL_NAME">
<{dsml_token}{tool_parameter_tag_name} name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}{tool_parameter_tag_name}>
...
</{dsml_token}{tool_call_tag_name}>
<{dsml_token}{tool_call_tag_name} name="$TOOL_NAME2">
...
</{dsml_token}{tool_call_tag_name}>
</{dsml_token}{tc_block_name}>
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"`.
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.
Otherwise, output directly after {thinking_end_token} with tool calls or final response.
### Available Tool Schemas
{tool_schemas}
You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
"""
def render_tools(tools: List[Dict[str, Union[str, Dict[str, Any]]]]) -> str:
"""Render tool schemas into the V4.1 system prompt format."""
tools_json = [to_json(t) for t in tools]
return TOOLS_TEMPLATE.format(
tool_schemas="\n".join(tools_json),
dsml_token=dsml_token,
tc_block_name=tool_calls_block_name,
tool_call_tag_name=tool_call_tag_name,
tool_parameter_tag_name=tool_parameter_tag_name,
thinking_start_token=thinking_start_token,
thinking_end_token=thinking_end_token,
)
def encode_arguments_to_dsml(tool_call: Dict[str, Any]) -> str:
"""Encode tool call arguments into V4.1 DSML parameter format."""
p_dsml_template = (
'<{dsml_token}{tool_parameter_tag_name} name="{key}" string="{is_str}">'
"{value}</{dsml_token}{tool_parameter_tag_name}>"
)
P_dsml_strs = []
arguments = tool_call["arguments"]
if not isinstance(arguments, dict):
# Tolerate JSON strings, including double-encoded ones.
for _ in range(2):
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except Exception:
break
else:
break
if not isinstance(arguments, dict):
arguments = {"arguments": tool_call["arguments"]}
for k, v in arguments.items():
P_dsml_strs.append(
p_dsml_template.format(
dsml_token=dsml_token,
tool_parameter_tag_name=tool_parameter_tag_name,
key=k,
is_str="true" if isinstance(v, str) else "false",
value=v if isinstance(v, str) else to_json(v),
)
)
return "\n".join(P_dsml_strs)
def find_last_user_index(messages: List[Dict[str, Any]]) -> int:
"""
Find the index of the last user/developer message.
V4.1 supports mid-conversation system messages, which count as user
messages for the purposes of the assistant generation header.
"""
last_user_index = -1
for idx in range(len(messages) - 1, -1, -1):
role = messages[idx].get("role")
if role in ["user", "developer"] or (role == "system" and idx > 0):
last_user_index = idx
break
return last_user_index
def render_message(
index: int,
messages: List[Dict[str, Any]],
thinking_mode: str,
drop_thinking: bool = True,
reasoning_effort: Union[str, int, None] = None,
) -> str:
"""
Render a single message at the given index into its V4.1 encoded string form.
"""
assert 0 <= index < len(messages)
assert thinking_mode in ["chat", "thinking"], (
f"Invalid thinking_mode `{thinking_mode}`"
)
msg = messages[index]
last_user_idx = find_last_user_index(messages)
role = msg.get("role")
content = msg.get("content")
tools = msg.get("tools")
response_format = msg.get("response_format")
tool_calls = msg.get("tool_calls")
reasoning_content = msg.get("reasoning_content")
wo_eos = msg.get("wo_eos", False)
if tools:
tools = tools_from_openai_format(tools)
if tool_calls:
tool_calls = tool_calls_from_openai_format(tool_calls)
# Reasoning effort prefix (thinking mode, index 0 only)
reasoning_effort_prompt = render_reasoning_effort(
index, thinking_mode, reasoning_effort
)
# System token leads the conversation when there is a reasoning effort prompt
# or the first message is a system message.
prompt = (
SYSTEM_SP_TOKEN
if index == 0 and (reasoning_effort_prompt or role == "system")
else ""
)
prompt += reasoning_effort_prompt
if role == "system":
if index > 0:
# Mid-conversation system message
prompt += SYSTEM_SP_TOKEN
prompt += system_msg_template.format(content=content or "")
if tools:
prompt += "\n\n" + render_tools(tools)
if response_format:
prompt += "\n\n" + response_format_template.format(
schema=to_json(response_format)
)
elif role == "developer":
assert content, f"Invalid message for role `{role}`: {msg}"
content_developer = USER_SP_TOKEN
content_developer += content
if tools:
content_developer += "\n\n" + render_tools(tools)
if response_format:
content_developer += "\n\n" + response_format_template.format(
schema=to_json(response_format)
)
prompt += user_msg_template.format(content=content_developer)
elif role == "user":
prompt += USER_SP_TOKEN
# Handle content blocks (tool results mixed with text)
content_blocks = msg.get("content_blocks")
if content_blocks:
parts = []
for block in content_blocks:
block_type = block.get("type")
if block_type == "text":
parts.append(block.get("text", ""))
elif block_type == "tool_result":
tool_content = block.get("content", "")
if isinstance(tool_content, list):
text_parts = []
for b in tool_content:
if b.get("type") == "text":
text_parts.append(b.get("text", ""))
else:
text_parts.append(f"[Unsupported {b.get('type')}]")
tool_content = "\n\n".join(text_parts)
parts.append(tool_output_template.format(content=tool_content))
else:
parts.append(f"[Unsupported {block_type}]")
prompt += "\n\n".join(parts)
else:
prompt += content or ""
elif role == "latest_reminder":
prompt += LATEST_REMINDER_SP_TOKEN + latest_reminder_msg_template.format(
content=content
)
elif role == "tool":
raise NotImplementedError(
"deepseek_v41 merges tool messages into user; please preprocess with merge_tool_messages()"
)
elif role == "assistant":
thinking_part = ""
tc_content = ""
if tool_calls:
tc_list = [
tool_call_template.format(
dsml_token=dsml_token,
tool_call_tag_name=tool_call_tag_name,
name=tc.get("name"),
arguments=encode_arguments_to_dsml(tc),
)
for tc in tool_calls
]
tc_content += "\n\n" + tool_calls_template.format(
dsml_token=dsml_token,
tool_calls="\n".join(tc_list),
tc_block_name=tool_calls_block_name,
)
summary_content = content or ""
rc = reasoning_content or ""
# Check if previous message has a task - if so, this is a task output (no thinking)
prev_has_task = index - 1 >= 0 and messages[index - 1].get("task") is not None
if thinking_mode == "thinking" and not prev_has_task:
if not drop_thinking or index > last_user_idx:
thinking_part = (
thinking_template.format(reasoning_content=rc) + thinking_end_token
)
else:
thinking_part = ""
if wo_eos:
prompt += assistant_msg_wo_eos_template.format(
reasoning=thinking_part,
content=summary_content,
tool_calls=tc_content,
)
else:
prompt += assistant_msg_template.format(
reasoning=thinking_part,
content=summary_content,
tool_calls=tc_content,
)
else:
raise NotImplementedError(f"Unknown role: {role}")
# Append transition tokens based on what follows
if index + 1 < len(messages) and messages[index + 1].get("role") not in [
"assistant",
"latest_reminder",
]:
return prompt
task = messages[index].get("task")
if task is not None:
# Task special token for internal classification tasks
assert task in VALID_TASKS, (
f"Invalid task: '{task}'. Valid tasks are: {list(VALID_TASKS)}"
)
task_sp_token = DS_TASK_SP_TOKENS[task]
if task != "action":
# Non-action tasks: append task sp token directly after the message
prompt += task_sp_token
else:
# Action task: append Assistant + thinking token + action sp token
prompt += ASSISTANT_SP_TOKEN
prompt += (
thinking_end_token
if thinking_mode != "thinking"
else thinking_start_token
)
prompt += task_sp_token
elif messages[index].get("role") in ["user", "developer"] or (
messages[index].get("role") == "system" and index > 0
):
# Normal generation: append Assistant + thinking token
# (mid-conversation system messages also trigger the assistant header)
prompt += ASSISTANT_SP_TOKEN
if not drop_thinking and thinking_mode == "thinking":
prompt += thinking_start_token
elif drop_thinking and thinking_mode == "thinking" and index >= last_user_idx:
prompt += thinking_start_token
else:
prompt += thinking_end_token
return prompt
def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Drop reasoning_content and non-essential messages before the last user message.
Same as V4, but uses the V4.1 last-user definition (mid systems count).
"""
last_user_idx = find_last_user_index(messages)
result = []
keep_roles = {"user", "system", "tool", "latest_reminder", "direct_search_results"}
for idx, msg in enumerate(messages):
role = msg.get("role")
if role in keep_roles or idx >= last_user_idx:
result.append(msg)
elif role == "assistant":
msg = copy.copy(msg)
msg.pop("reasoning_content", None)
result.append(msg)
# developer and other roles before last_user_idx are dropped
return result
def encode_messages(
messages: List[Dict[str, Any]],
thinking_mode: str,
context: Optional[List[Dict[str, Any]]] = None,
drop_thinking: bool = True,
add_default_bos_token: bool = True,
reasoning_effort: Union[str, int, None] = None,
) -> str:
"""Encode preprocessed (text-only) messages into the V4.1 prompt format."""
context = context if context else []
# Preprocess: merge tool messages and sort tool results
messages = merge_tool_messages(messages)
messages = sort_tool_results_by_call_order(context + messages)[len(context) :]
if context:
context = merge_tool_messages(context)
context = sort_tool_results_by_call_order(context)
full_messages = context + messages
prompt = bos_token if add_default_bos_token and len(context) == 0 else ""
# Resolve drop_thinking: if any message has tools defined, don't drop thinking
effective_drop_thinking = drop_thinking
if any(m.get("tools") for m in full_messages):
effective_drop_thinking = False
if thinking_mode == "thinking" and effective_drop_thinking:
full_messages = _drop_thinking_messages(full_messages)
num_to_render = len(full_messages) - len(_drop_thinking_messages(context))
context_len = len(full_messages) - num_to_render
else:
num_to_render = len(messages)
context_len = len(context)
for idx in range(num_to_render):
prompt += render_message(
idx + context_len,
full_messages,
thinking_mode=thinking_mode,
drop_thinking=effective_drop_thinking,
reasoning_effort=reasoning_effort,
)
return prompt
+1
View File
@@ -45,6 +45,7 @@ _VLLM_TOKENIZERS = {
"cohere": ("hf", "CachedHfTokenizer"),
"deepseek_v32": ("deepseek_v32", "DeepseekV32Tokenizer"),
"deepseek_v4": ("deepseek_v4", "DeepseekV4Tokenizer"),
"deepseek_v41": ("deepseek_v41", "DeepseekV41Tokenizer"),
"hf": ("hf", "CachedHfTokenizer"),
"kimi_audio": ("kimi_audio", "KimiAudioTokenizer"),
"kimi_k3": ("hf", "CachedHfTokenizer"),
+4
View File
@@ -42,6 +42,10 @@ _TOOL_PARSERS_TO_REGISTER = {
"deepseekv4_engine_tool_parser",
"DeepSeekV4EngineToolParser",
),
"deepseek_v41": (
"deepseekv41_engine_tool_parser",
"DeepSeekV41EngineToolParser",
),
"cohere_command3": (
"cohere_command_tool_parser",
"CohereCommand3ToolParser",
@@ -0,0 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from vllm.parser.engine.registered_adapters import DeepSeekV41ParserToolAdapter
class DeepSeekV41EngineToolParser(DeepSeekV41ParserToolAdapter): # type: ignore[valid-type, misc]
structural_tag_model = "deepseek_v41"
+96 -1
View File
@@ -71,7 +71,9 @@ XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
"deepseek_v4",
}
)
VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset({"hermes", "hy_v4", "kimi_k3"})
VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
{"deepseek_v41", "hermes", "hy_v4", "kimi_k3"}
)
SUPPORTED_STRUCTURAL_TAG_MODELS = (
XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS | VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
)
@@ -225,6 +227,99 @@ def get_function_parameters(function) -> dict[str, Any] | bool:
return function.parameters if function.parameters is not None else True
_V41_CALLS_START = "<|DSML| calls>"
_V41_CALLS_END = "</|DSML| calls>"
_V41_INVOKE_END = "</|DSML| invoke>"
_V41_PARAMETER_END = "</|DSML| parameter>"
@register_vllm_structural_tag("deepseek_v41")
def get_deepseek_v41_structural_tag(
tools: list[FunctionToolParam],
builtin_tools: list[BuiltinToolParam],
tool_choice: SimplifiedToolChoice,
reasoning: bool,
token_suffix: str = "",
) -> StructuralTag:
# Serving enables this visible-text grammar after the reasoning boundary.
del builtin_tools, reasoning, token_suffix
# TODO: lower parameter schemas into DSML constraints. This builder constrains
# tool names and DSML/value syntax only. Parameter names, presence, uniqueness
# and value schemas remain unconstrained, including for strict=true. The request
# layer still uses strict to decide whether auto tool choice activates a grammar.
parameter = TagFormat(
begin='<|DSML| parameter name="',
content=SequenceFormat(
elements=[
RegexFormat(pattern=r'[^"]+'),
ConstStringFormat(value='" string="'),
OrFormat(
elements=[
SequenceFormat(
elements=[
ConstStringFormat(value='true">'),
AnyTextFormat(
excludes=[
_V41_PARAMETER_END,
_V41_INVOKE_END,
_V41_CALLS_END,
]
),
]
),
SequenceFormat(
elements=[
ConstStringFormat(value='false">'),
JSONSchemaFormat(json_schema=True),
]
),
]
),
]
),
end=f"{_V41_PARAMETER_END}\n",
)
calls = TagsWithSeparatorFormat(
tags=[
TagFormat(
begin=f'<|DSML| invoke name="{tool.function.name}">\n',
content=StarFormat(content=parameter),
end=f"{_V41_INVOKE_END}\n",
)
for tool in tools
],
separator="",
at_least_one=True,
stop_after_first=tool_choice == "forced",
)
if tool_choice == "auto":
return StructuralTag(
format=TriggeredTagsFormat(
triggers=[_V41_CALLS_START],
tags=[
TagFormat(
begin=f"{_V41_CALLS_START}\n",
content=calls,
end=_V41_CALLS_END,
)
],
excludes=["<think>", "</think>"],
)
if tools
else AnyTextFormat(excludes=["<think>", "</think>"])
)
return StructuralTag(
format=SequenceFormat(
elements=[
ConstStringFormat(value=f"\n\n{_V41_CALLS_START}\n"),
calls,
ConstStringFormat(value=_V41_CALLS_END),
]
)
)
def _hermes_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]:
arguments_field_prefix = '", "arguments": '
formats = [
+1
View File
@@ -152,6 +152,7 @@ _CONFIG_REGISTRY: dict[str, type[PretrainedConfig]] = LazyConfigDict(
laguna="LagunaConfig",
lfm2_moe="Lfm2MoeConfig",
**{"unlimited-ocr": "UnlimitedOCRConfig"},
**{"deepseek_v41": "DeepseekV41Config"},
inkling_mm_model="InklingMMConfig",
inkling_model="InklingModelConfig",
)
@@ -33,6 +33,7 @@ _CLASS_TO_MODULE: dict[str, str] = {
"DiffusionGemmaTextConfig": "vllm.transformers_utils.configs.diffusion_gemma",
"DeepseekVLV2Config": "vllm.transformers_utils.configs.deepseek_vl2",
"DeepseekV4Config": "vllm.transformers_utils.configs.deepseek_v4",
"DeepseekV41Config": "vllm.transformers_utils.configs.deepseek_v41",
"Dots3NoteConfig": "vllm.transformers_utils.configs.dots3_note",
"K3DSparkConfig": "vllm.transformers_utils.configs.k3_dspark",
"DotsOCRConfig": "vllm.transformers_utils.configs.dotsocr",
@@ -0,0 +1,76 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from typing import Any
from transformers import PretrainedConfig
class DeepseekV41Config(PretrainedConfig):
"""DeepSeek V4.1 config.
The HF config nests the text model under ``text_config`` and the vision
tower under ``vision_config``. The vLLM model code (ported from
``deepseek_v4``) reads flat attributes, so both sub-configs are flattened
onto the top level here: text fields are exposed as-is, vision fields
with the ``vision_*`` naming used by ``deepseek_v4_1.common.vision``.
"""
model_type = "deepseek_v41"
def __init__(
self,
text_config: dict[str, Any] | None = None,
vision_config: dict[str, Any] | None = None,
**kwargs,
):
text_config = dict(text_config or {})
vision_config = dict(vision_config or {})
# ``rope_scaling`` is a property in Transformers v5 (backed by
# ``rope_parameters``); capture it here and restore after
# super().__init__, which re-standardizes rope params.
rope_scaling = text_config.pop("rope_scaling", None)
for key, value in text_config.items():
if key == "model_type":
continue
# Don't clobber PretrainedConfig properties (e.g. is_encoder_decoder).
if isinstance(getattr(type(self), key, None), property):
continue
setattr(self, key, value)
super().__init__(**kwargs)
if rope_scaling is not None:
self.rope_parameters = rope_scaling
# The v4.1 quantization_config carries the expert dtype; the model
# code reads it from the top-level hf_config.
quant_cfg = getattr(self, "quantization_config", None) or {}
if not hasattr(self, "expert_dtype") and "expert_dtype" in quant_cfg:
self.expert_dtype = quant_cfg["expert_dtype"]
vision_n_layers = vision_config.get("num_hidden_layers", 0)
self.vision_n_layers = vision_n_layers
self.vision_dim = vision_config.get("hidden_size", 1024)
self.vision_n_heads = vision_config.get("num_attention_heads", 16)
self.vision_inter_dim = vision_config.get("intermediate_size", 2816)
self.vision_patch_size = vision_config.get("patch_size", 14)
self.vision_rope_theta = vision_config.get("rope_theta", 10000.0)
self.vision_downsample_ratio = vision_config.get("downsample_ratio", 3)
self.vision_max_n_token = vision_config.get("max_image_tokens", 1024)
self.vision_min_pixels = vision_config.get("min_pixels", 295936)
self.vision_max_wh_ratio = vision_config.get("max_wh_ratio")
# The vision variant needs the mm-prefix plumbing (atomic image-span
# prefill + sparse-SWA window widening); the base arch-config
# convertor reads this config-level attribute.
self.is_mm_prefix_lm = vision_n_layers > 0
# The sparse-SWA index kernels widen the window within image spans
# in-kernel, so mm-prefix ranges longer than sliding_window must be
# kept (they are only consumed by those kernels).
self.mm_prefix_clamp_sliding_window = vision_n_layers > 0
# The visibility span covers the image block [IMAGE_START,
# IMAGE_END]; the mm placeholder additionally carries a leading
# compressor-alignment pad of ``COMPRESS_PAD_TO - 1 - offset %
# COMPRESS_PAD_TO`` tokens (see common/mm_preprocess.py), with
# COMPRESS_PAD_TO = 2 for v4.1's ratio-2 compressors.
self.mm_prefix_span_leading_pad_modulus = 2 if vision_n_layers > 0 else 0
@@ -317,6 +317,7 @@ class ModelArchConfigConvertorBase:
"deepseek_v3",
"deepseek_v32",
"deepseek_v4",
"deepseek_v41",
"dots3_note",
"deepseek_mtp",
"k3_dspark",