[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>
Bugen Zhao committed
Sep 10, 2026 at 18:15 UTC
b47b01cf3383d50a5fae8569cadcbd0736076234
52 files changed
+2986
-113
rust/Cargo.lock
+1
-1
@@ -2290,7 +2290,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
2290
[[package]]
2291
name = "llm-multimodal"
2292
version = "1.7.1"
2293
-source = "git+https://github.com/smg-project/llm-multimodal?rev=74f64753e040ade87c9d7f4bb0ad2f4dd7764652#74f64753e040ade87c9d7f4bb0ad2f4dd7764652"
2293
+source = "git+https://github.com/smg-project/llm-multimodal?rev=24c676dd49d73f05cb4068aade2e60397a5c0afe#24c676dd49d73f05cb4068aade2e60397a5c0afe"
2294
dependencies = [
2295
"anyhow",
2296
"base64 0.22.1",
rust/Cargo.toml
+1
-1
@@ -60,7 +60,7 @@ indexmap = "2.13.0"
60
indicatif = "0.18.4"
61
itertools = "0.14.0"
62
libc = "0.2.177"
63
-llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "74f64753e040ade87c9d7f4bb0ad2f4dd7764652", default-features = false, features = ["native-tls"] }
63
+llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "24c676dd49d73f05cb4068aade2e60397a5c0afe", default-features = false, features = ["native-tls"] }
64
mimalloc = "0.1.52"
65
minijinja = { version = "2.24", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
66
minijinja-contrib = { version = "2.24", features = ["pycompat"] }
rust/src/chat/src/backend/hf.rs
+3
-2
@@ -19,8 +19,8 @@ use crate::output::{
19
};
20
use crate::renderer::hf::{HfChatRenderer, MultimodalRenderInfo};
21
use crate::renderer::{
22
- DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer, HarmonyChatRenderer,
23
- InklingChatRenderer, KimiK3ChatRenderer,
22
+ DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DeepSeekV41ChatRenderer, DynChatRenderer,
23
+ HarmonyChatRenderer, InklingChatRenderer, KimiK3ChatRenderer,
24
};
25
use crate::request::ChatRequest;
26
use crate::{DynChatOutputProcessor, RendererSelection};
@@ -72,6 +72,7 @@ impl HfChatBackend {
72
)?),
73
RendererSelection::DeepSeekV32 => Arc::new(DeepSeekV32ChatRenderer::new()),
74
RendererSelection::DeepSeekV4 => Arc::new(DeepSeekV4ChatRenderer::new()),
75
+ RendererSelection::DeepSeekV41 => Arc::new(DeepSeekV41ChatRenderer::new()),
76
RendererSelection::Harmony => Arc::new(HarmonyChatRenderer::new()?),
77
RendererSelection::Inkling => Arc::new(InklingChatRenderer::new(tokenizer.clone())?),
78
RendererSelection::KimiK3 => Arc::new(KimiK3ChatRenderer::new(tokenizer.clone())),
rust/src/chat/src/error.rs
+3
@@ -17,6 +17,8 @@ pub enum Error {
17
MissingChatTemplate,
18
#[error("chat template error: {0}")]
19
ChatTemplate(String),
20
+ #[error("{0}")]
21
+ InvalidReasoningEffort(String),
22
#[error("multimodal input is not supported by this chat renderer")]
23
UnsupportedMultimodalRenderer,
24
#[error("unsupported multimodal content: {0}")]
@@ -96,6 +98,7 @@ impl Error {
98
pub fn is_request_validation_error(&self) -> bool {
99
match self {
100
Self::PromptTooLong { .. }
101
+ | Self::InvalidReasoningEffort(_)
102
| Self::DuplicateToolName { .. }
103
| Self::ToolChoiceRequiresTools
104
| Self::ToolChoiceFunctionNotFound { .. } => true,
rust/src/chat/src/lib.rs
+4
-4
@@ -33,8 +33,8 @@ pub use parser::tool::{ToolParser, ToolParserError, ToolParserFactory};
33
pub use parser::{ParserSelection, validate_parser_overrides};
34
pub use renderer::hf::ChatTemplateContentFormatOption;
35
pub use renderer::{
36
- ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer,
37
- HarmonyChatRenderer, InklingChatRenderer, KimiK3ChatRenderer, RenderedPrompt,
36
+ ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DeepSeekV41ChatRenderer,
37
+ DynChatRenderer, HarmonyChatRenderer, InklingChatRenderer, KimiK3ChatRenderer, RenderedPrompt,
38
RendererSelection,
39
};
40
pub use request::{
@@ -363,7 +363,7 @@ mod tests {
363
)
364
.unwrap_err();
365
366
- 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());
366
+ 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());
367
}
368
369
#[test]
@@ -374,6 +374,6 @@ mod tests {
374
)
375
.unwrap_err();
376
377
- 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());
377
+ 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());
378
}
379
}
rust/src/chat/src/multimodal.rs
+39
-1
@@ -153,6 +153,10 @@ struct ResolvedMultimodalSpec {
153
modality: Modality,
154
field_layouts: EncoderFieldLayouts,
155
keep_on_cpu_keys: HashSet<String>,
156
+ /// Spec-declared wire key for the primary encoder input tensor, when it
157
+ /// differs from the per-modality default (e.g. `"patches"` for
158
+ /// DeepSeek-V4.1 images).
159
+ encoder_input_key: Option<String>,
160
}
161
162
impl ResolvedMultimodalSpec {
@@ -162,10 +166,14 @@ impl ResolvedMultimodalSpec {
166
modality,
167
field_layouts: raw.encoder_field_layouts_for(modality),
168
keep_on_cpu_keys: raw.keep_on_cpu_keys_for(modality).into_iter().collect(),
169
+ encoder_input_key: raw.encoder_input_key_for(modality),
170
}
171
}
172
168
- fn primary_key(&self) -> &'static str {
173
+ fn primary_key(&self) -> &str {
174
+ if let Some(key) = &self.encoder_input_key {
175
+ return key;
176
+ }
177
match self.modality {
178
Modality::Image => image::IMAGE_PRIMARY_KEY,
179
Modality::Video => video::VIDEO_PRIMARY_KEY,
@@ -841,6 +849,9 @@ mod tests {
849
pub(super) const QWEN3_IMAGE_PAD_ID: u32 = 151655;
850
pub(super) const QWEN3_VIDEO_PAD_ID: u32 = 151656;
851
852
+ pub(super) const DEEPSEEK_V41_IMAGE_ID: u32 = 129264;
853
+ pub(super) const DEEPSEEK_V41_IMAGE_PAD_ID: u32 = 129265;
854
+
855
fn llama4_tokenizer() -> TestTokenizer {
856
TestTokenizer::new()
857
.with_regular_token("<|image_start|>", LLAMA4_IMAGE_START_ID)
@@ -954,6 +965,33 @@ mod tests {
965
assert_eq!(info.placeholder_token(Modality::Video), None);
966
}
967
968
+ fn deepseek_v41_info() -> MultimodalModelInfo {
969
+ let config = serde_json::json!({
970
+ "model_type": "deepseek_v41",
971
+ "image_token_id": DEEPSEEK_V41_IMAGE_ID,
972
+ });
973
+ let tokenizer = TestTokenizer::new()
974
+ .with_regular_token("<|deepseek_image|>", DEEPSEEK_V41_IMAGE_ID)
975
+ .with_regular_token("<|place_holder_mm_span_0436|>", DEEPSEEK_V41_IMAGE_PAD_ID);
976
+ test_info("deepseek_v41", config, tokenizer)
977
+ }
978
+
979
+ #[test]
980
+ fn deepseek_v41_resolves_image_support_only() {
981
+ let info = deepseek_v41_info();
982
+
983
+ assert_eq!(
984
+ info.placeholder_token(Modality::Image),
985
+ Some("<|deepseek_image|>")
986
+ );
987
+ assert_eq!(info.placeholder_token(Modality::Video), None);
988
+ let image = info.image.as_ref().expect("image support");
989
+ assert_eq!(image.placeholder.marker_token_id, DEEPSEEK_V41_IMAGE_ID);
990
+ assert_eq!(image.placeholder.embed_token_id, DEEPSEEK_V41_IMAGE_ID);
991
+ // The engine's forward kwargs pop `patches` (not `pixel_values`).
992
+ assert_eq!(image.spec.primary_key(), "patches");
993
+ }
994
+
995
#[test]
996
fn input_audio_uses_connector_data_urls() {
997
assert_eq!(
rust/src/chat/src/multimodal/expand.rs
+136
-8
@@ -45,6 +45,14 @@ impl<'a> ExpansionLane<'a> {
45
/// matching the original media-part order within that modality; markers of
46
/// different modalities may interleave freely.
47
///
48
+/// Replacements may carry an [`AlignmentPad`](llm_multimodal::AlignmentPad)
49
+/// (e.g. DeepSeek-V4.1's compressor-alignment pad): `period - 1 - offset %
50
+/// period` pad tokens are
51
+/// prepended at splice time, where `offset` is the position the replacement
52
+/// lands at in the expanded prompt. The pads are part of the reported range
53
+/// and count as embed positions only if the pad token id equals the
54
+/// modality's embed token id.
55
+///
56
/// The returned ranges point into the already-expanded prompt, grouped per
57
/// modality in item order.
58
pub(super) fn expand_prompt_token_ids(
@@ -56,12 +64,14 @@ pub(super) fn expand_prompt_token_ids(
64
return Ok(HashMap::new());
65
}
66
59
- let replacement_growth = lanes
60
- .iter()
61
- .flat_map(|lane| lane.replacements.iter())
62
- .fold(0usize, |total, replacement| {
63
- total.saturating_add(replacement.tokens.len().saturating_sub(1))
64
- });
67
+ let replacement_growth = lanes.iter().flat_map(|lane| lane.replacements.iter()).fold(
68
+ 0usize,
69
+ |total, replacement| {
70
+ // Alignment pads are position-dependent; reserve the worst case.
71
+ let max_pads = replacement.alignment_pad.map_or(0, |pad| pad.period - 1);
72
+ total.saturating_add(max_pads + replacement.tokens.len().saturating_sub(1))
73
+ },
74
+ );
75
let expanded_len = prompt_token_ids.len().saturating_add(replacement_growth);
76
77
let mut expanded = Vec::with_capacity(expanded_len);
@@ -95,10 +105,20 @@ pub(super) fn expand_prompt_token_ids(
105
expanded.len()
106
);
107
}
98
- let range_len = replacement_len.saturating_add(structural_prefix);
108
+ // Position-dependent alignment padding, computed from the offset the
109
+ // replacement block lands at in the expanded prompt (mirroring
110
+ // vLLM's `_apply_token_matches_with_placeholders` splice-time pad).
111
+ let pad_count = replacement.alignment_pad.map_or(0, |pad| pad.count_at(expanded.len()));
112
+ let range_len = structural_prefix + pad_count + replacement_len;
113
let is_embed = {
114
let mut mask = Vec::with_capacity(range_len);
115
mask.extend(std::iter::repeat_n(false, structural_prefix));
116
+ if let Some(pad) = &replacement.alignment_pad {
117
+ mask.extend(std::iter::repeat_n(
118
+ pad.token_id as u32 == lane.embed_token_id,
119
+ pad_count,
120
+ ));
121
+ }
122
mask.extend(
123
replacement.tokens.iter().map(|&token| token as u32 == lane.embed_token_id),
124
);
@@ -106,6 +126,9 @@ pub(super) fn expand_prompt_token_ids(
126
};
127
128
let expanded_offset = expanded.len() - structural_prefix;
129
+ if let Some(pad) = &replacement.alignment_pad {
130
+ expanded.extend(std::iter::repeat_n(pad.token_id as u32, pad_count));
131
+ }
132
expanded.extend(replacement.tokens.iter().map(|&token| token as u32));
133
ranges.entry(lane.modality).or_default().push(PlaceholderRange {
134
offset: expanded_offset,
@@ -454,7 +477,6 @@ mod tests {
477
}
478
479
const QWEN3_AUDIO_PAD_ID: u32 = 151_676;
457
-
480
fn qwen3_audio_prepared(replacements: Vec<PromptReplacement>) -> PreparedMedia {
481
prepared_media(
482
Modality::Audio,
@@ -542,4 +564,110 @@ mod tests {
564
));
565
assert_eq!(prompt_token_ids, original_prompt_token_ids);
566
}
567
+
568
+ // DeepSeek-V4.1 image spans: every span position carries the image token
569
+ // id; a compressor-alignment pad (id 129265, period 2) is prepended at
570
+ // splice time so spans always start at the same compressor phase.
571
+ const DS_IMAGE_ID: u32 = 129_264;
572
+ const DS_IMAGE_PAD_ID: u32 = 129_265;
573
+
574
+ fn deepseek_prepared(span_lens: Vec<usize>) -> PreparedMedia {
575
+ prepared_media(
576
+ Modality::Image,
577
+ "<|deepseek_image|>",
578
+ DS_IMAGE_ID,
579
+ DS_IMAGE_ID,
580
+ span_lens
581
+ .into_iter()
582
+ .map(|len| {
583
+ PromptReplacement::repeated(
584
+ Modality::Image,
585
+ "<|deepseek_image|>",
586
+ DS_IMAGE_ID as TokenId,
587
+ len,
588
+ )
589
+ .with_alignment_pad(DS_IMAGE_PAD_ID as TokenId, 2)
590
+ })
591
+ .collect(),
592
+ )
593
+ }
594
+
595
+ #[test]
596
+ fn expand_prompt_tokens_skips_alignment_pad_at_odd_offset() {
597
+ // Odd block start: already at the target compressor phase, no pad.
598
+ let mut prompt_token_ids = vec![1, DS_IMAGE_ID, 2];
599
+ let prepared = vec![deepseek_prepared(vec![3])];
600
+
601
+ let ranges = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap();
602
+ let ranges = &ranges[&Modality::Image];
603
+
604
+ assert_eq!(
605
+ prompt_token_ids,
606
+ vec![1, DS_IMAGE_ID, DS_IMAGE_ID, DS_IMAGE_ID, 2]
607
+ );
608
+ assert_eq!(ranges[0].offset, 1);
609
+ assert_eq!(ranges[0].length, 3);
610
+ assert_bool_mask(&ranges[0], &[true, true, true]);
611
+ }
612
+
613
+ #[test]
614
+ fn expand_prompt_tokens_prepends_alignment_pad_at_even_offset() {
615
+ // Even block start: one pad token aligns the span to phase 1.
616
+ let mut prompt_token_ids = vec![1, 3, DS_IMAGE_ID, 2];
617
+ let prepared = vec![deepseek_prepared(vec![3])];
618
+
619
+ let ranges = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap();
620
+ let ranges = &ranges[&Modality::Image];
621
+
622
+ assert_eq!(
623
+ prompt_token_ids,
624
+ vec![
625
+ 1,
626
+ 3,
627
+ DS_IMAGE_PAD_ID,
628
+ DS_IMAGE_ID,
629
+ DS_IMAGE_ID,
630
+ DS_IMAGE_ID,
631
+ 2
632
+ ]
633
+ );
634
+ // The range starts at the pad; pads are not embed positions.
635
+ assert_eq!(ranges[0].offset, 2);
636
+ assert_eq!(ranges[0].length, 4);
637
+ assert_bool_mask(&ranges[0], &[false, true, true, true]);
638
+ }
639
+
640
+ #[test]
641
+ fn expand_prompt_tokens_alignment_pad_realigns_each_span() {
642
+ // Every image span must start at phase 1 (odd offset) regardless of
643
+ // where the previous span landed.
644
+ let mut prompt_token_ids = vec![1, DS_IMAGE_ID, 2, DS_IMAGE_ID, 3];
645
+ let prepared = vec![deepseek_prepared(vec![2, 2])];
646
+
647
+ let ranges = expand_prompt_token_ids(&mut prompt_token_ids, &prepared).unwrap();
648
+ let ranges = &ranges[&Modality::Image];
649
+
650
+ // First span at odd offset 1: no pad (block [1,3)).
651
+ // Second marker originally at index 3; after the first expansion the
652
+ // block starts at offset 4 (even): one pad, span at odd offset 5.
653
+ assert_eq!(
654
+ prompt_token_ids,
655
+ vec![
656
+ 1,
657
+ DS_IMAGE_ID,
658
+ DS_IMAGE_ID,
659
+ 2,
660
+ DS_IMAGE_PAD_ID,
661
+ DS_IMAGE_ID,
662
+ DS_IMAGE_ID,
663
+ 3,
664
+ ]
665
+ );
666
+ assert_eq!((ranges[0].offset, ranges[0].length), (1, 2));
667
+ assert_bool_mask(&ranges[0], &[true, true]);
668
+ assert_eq!((ranges[1].offset, ranges[1].length), (4, 3));
669
+ assert_bool_mask(&ranges[1], &[false, true, true]);
670
+ // Both spans (past the pad) start at odd offsets.
671
+ assert_eq!((ranges[1].offset + 1) % 2, 1);
672
+ }
673
}
rust/src/chat/src/parser/reasoning/mod.rs
+8
-4
@@ -7,10 +7,11 @@ use std::sync::{Arc, LazyLock};
7
8
pub use vllm_parser::reasoning::{
9
CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DeepSeekV3ReasoningParser,
10
- DeepSeekV4ReasoningParser, Glm45ReasoningParser, Glm47ReasoningParser, KimiK2ReasoningParser,
11
- KimiReasoningParser, MiniMaxM2ReasoningParser, MiniMaxM3ReasoningParser,
12
- NemotronV3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta, ReasoningError,
13
- ReasoningParser, SeedOssReasoningParser, Step3ReasoningParser, Step3p5ReasoningParser,
10
+ DeepSeekV4ReasoningParser, DeepSeekV41ReasoningParser, Glm45ReasoningParser,
11
+ Glm47ReasoningParser, KimiK2ReasoningParser, KimiReasoningParser, MiniMaxM2ReasoningParser,
12
+ MiniMaxM3ReasoningParser, NemotronV3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta,
13
+ ReasoningError, ReasoningParser, SeedOssReasoningParser, Step3ReasoningParser,
14
+ Step3p5ReasoningParser,
15
};
16
use vllm_tokenizer::DynTokenizer;
17
@@ -22,6 +23,7 @@ pub mod names {
23
pub const DEEPSEEK_R1: &str = "deepseek_r1";
24
pub const DEEPSEEK_V3: &str = "deepseek_v3";
25
pub const DEEPSEEK_V4: &str = "deepseek_v4";
26
+ pub const DEEPSEEK_V41: &str = "deepseek_v41";
27
pub const GLM45: &str = "glm45";
28
pub const GLM47: &str = "glm47";
29
pub const KIMI: &str = "kimi";
@@ -62,6 +64,7 @@ impl ReasoningParserFactory {
64
.register_parser::<DeepSeekR1ReasoningParser>(names::DEEPSEEK_R1)
65
.register_parser::<DeepSeekV3ReasoningParser>(names::DEEPSEEK_V3)
66
.register_parser::<DeepSeekV4ReasoningParser>(names::DEEPSEEK_V4)
67
+ .register_parser::<DeepSeekV41ReasoningParser>(names::DEEPSEEK_V41)
68
.register_parser::<Glm45ReasoningParser>(names::GLM45)
69
.register_parser::<Glm47ReasoningParser>(names::GLM47)
70
.register_parser::<KimiReasoningParser>(names::KIMI)
@@ -76,6 +79,7 @@ impl ReasoningParserFactory {
79
80
factory
81
.register_pattern("deepseek-r1", names::DEEPSEEK_R1)
82
+ .register_pattern("deepseek-v4.1", names::DEEPSEEK_V41)
83
.register_pattern("deepseek-v4", names::DEEPSEEK_V4)
84
.register_pattern("deepseek_v4", names::DEEPSEEK_V4)
85
.register_pattern("deepseek-v3", names::DEEPSEEK_V3)
rust/src/chat/src/parser/reasoning/tests.rs
+4
@@ -33,6 +33,10 @@ fn factory_resolves_deepseek_v4() {
33
factory.resolve_name_for_model("deepseek_v4"),
34
Some(names::DEEPSEEK_V4)
35
);
36
+ assert_eq!(
37
+ factory.resolve_name_for_model("deepseek-ai/DeepSeek-V4.1-Flash"),
38
+ Some(names::DEEPSEEK_V41)
39
+ );
40
}
41
42
#[test]
rust/src/chat/src/parser/tool/mod.rs
+7
-4
@@ -7,10 +7,10 @@ use std::sync::{Arc, LazyLock};
7
8
pub use vllm_parser::tool::{
9
DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser,
10
- Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser, HermesToolParser,
11
- Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser,
12
- MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser, Qwen3CoderToolParser,
13
- Qwen3XmlToolParser, SeedOssToolParser, ToolParser, ToolParserError,
10
+ DeepSeekV41ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser,
11
+ HermesToolParser, Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser,
12
+ MinimaxM2ToolParser, MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser,
13
+ Qwen3CoderToolParser, Qwen3XmlToolParser, SeedOssToolParser, ToolParser, ToolParserError,
14
};
15
16
use crate::parser::ParserFactory;
@@ -22,6 +22,7 @@ pub mod names {
22
pub const DEEPSEEK_V31: &str = "deepseek_v31";
23
pub const DEEPSEEK_V32: &str = "deepseek_v32";
24
pub const DEEPSEEK_V4: &str = "deepseek_v4";
25
+ pub const DEEPSEEK_V41: &str = "deepseek_v41";
26
pub const GLM45: &str = "glm45";
27
pub const GLM47: &str = "glm47";
28
pub const GRANITE4: &str = "granite4";
@@ -66,6 +67,7 @@ impl ToolParserFactory {
67
.register_parser::<DeepSeekV31ToolParser>(names::DEEPSEEK_V31)
68
.register_parser::<DeepSeekV32ToolParser>(names::DEEPSEEK_V32)
69
.register_parser::<DeepSeekV4ToolParser>(names::DEEPSEEK_V4)
70
+ .register_parser::<DeepSeekV41ToolParser>(names::DEEPSEEK_V41)
71
.register_parser::<Glm45MoeToolParser>(names::GLM45)
72
.register_parser::<Glm47MoeToolParser>(names::GLM47)
73
.register_parser::<Granite4ToolParser>(names::GRANITE4)
@@ -101,6 +103,7 @@ impl ToolParserFactory {
103
.register_pattern("llama-3.2", names::LLAMA3_JSON)
104
.register_pattern("llama-3.1", names::LLAMA3_JSON)
105
.register_pattern("deepseek-r1", names::DEEPSEEK_V3)
106
+ .register_pattern("deepseek-v4.1", names::DEEPSEEK_V41)
107
.register_pattern("deepseek-v4", names::DEEPSEEK_V4)
108
.register_pattern("deepseek_v4", names::DEEPSEEK_V4)
109
.register_pattern("deepseek-v3.2", names::DEEPSEEK_V32)
rust/src/chat/src/parser/tool/tests.rs
+4
@@ -147,6 +147,10 @@ fn factory_new_resolves_default_patterns() {
147
factory.resolve_name_for_model("deepseek_v4"),
148
Some(names::DEEPSEEK_V4)
149
);
150
+ assert_eq!(
151
+ factory.resolve_name_for_model("deepseek-ai/DeepSeek-V4.1-Flash"),
152
+ Some(names::DEEPSEEK_V41)
153
+ );
154
assert_eq!(
155
factory.resolve_name_for_model("deepseek-ai/DeepSeek-R1-0528"),
156
Some(names::DEEPSEEK_V3)
rust/src/chat/src/renderer/deepseek.rs
renamed
+230
-59
@@ -1,11 +1,12 @@
1
// SPDX-License-Identifier: Apache-2.0
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-//! DeepSeek V4 prompt renderer.
4
+//! Shared DeepSeek V4 and V4.1 prompt rendering.
5
//!
6
//! Official Python reference:
7
//! <https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash/blob/main/encoding/encoding_dsv4.py>
8
9
+use std::borrow::Cow;
10
use std::collections::HashMap;
11
use std::fmt::Write as _;
12
@@ -13,8 +14,12 @@ use serde::Serialize;
14
use serde_json::Value;
15
use serde_json_fmt::JsonFormat;
16
17
+use llm_multimodal::DEEPSEEK_V41_IMAGE_PLACEHOLDER;
18
+
19
use crate::error::{Error, Result};
17
-use crate::request::{ChatContent, ChatMessage, ChatRequest, ChatTool, ReasoningEffort};
20
+use crate::request::{
21
+ ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ReasoningEffort,
22
+};
23
use crate::{AssistantContentBlock, AssistantMessageExt, AssistantToolCall};
24
25
const BOS_TOKEN: &str = "<|begin▁of▁sentence|>";
@@ -23,6 +28,7 @@ const THINKING_START_TOKEN: &str = "<think>";
28
const THINKING_END_TOKEN: &str = "</think>";
29
const DSML_TOKEN: &str = "|DSML|";
30
const USER_SP_TOKEN: &str = "<|User|>";
31
+const SYSTEM_SP_TOKEN: &str = "<|System|>";
32
const ASSISTANT_SP_TOKEN: &str = "<|Assistant|>";
33
const REASONING_EFFORT_HIGH: &str = concat!(
34
"Reasoning Effort: Absolute maximum with no shortcuts permitted.\n",
@@ -41,6 +47,35 @@ enum ThinkingMode {
47
Thinking,
48
}
49
50
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51
+pub(super) enum DsDialect {
52
+ V4,
53
+ V41,
54
+}
55
+
56
+impl DsDialect {
57
+ fn tool_calls_tag(self) -> &'static str {
58
+ match self {
59
+ Self::V4 => "tool_calls",
60
+ Self::V41 => " calls",
61
+ }
62
+ }
63
+
64
+ fn invoke_tag(self) -> &'static str {
65
+ match self {
66
+ Self::V4 => "invoke",
67
+ Self::V41 => " invoke",
68
+ }
69
+ }
70
+
71
+ fn parameter_tag(self) -> &'static str {
72
+ match self {
73
+ Self::V4 => "parameter",
74
+ Self::V41 => " parameter",
75
+ }
76
+ }
77
+}
78
+
79
#[serde_with::skip_serializing_none]
80
#[derive(Debug, Serialize)]
81
struct RenderedToolSchema<'a> {
@@ -51,25 +86,47 @@ struct RenderedToolSchema<'a> {
86
}
87
88
/// Render one chat request into the final prompt string.
54
-pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
55
- let (thinking_mode, reasoning_effort_prompt) = resolve_thinking_options(request)?;
89
+pub(super) fn render_request(request: &ChatRequest, dialect: DsDialect) -> Result<String> {
90
+ let (thinking_mode, reasoning_effort_prompt) = match dialect {
91
+ DsDialect::V4 => {
92
+ resolve_thinking_options(request).map(|(mode, prompt)| (mode, Cow::Borrowed(prompt)))?
93
+ }
94
+ DsDialect::V41 => resolve_v41_thinking_options(request)?,
95
+ };
96
let request_tools = request_tools(request);
97
let synthetic_tool_system = needs_synthetic_tool_system(request, request_tools);
98
let drop_thinking = request.parse_template_bool("drop_thinking")?.unwrap_or(true)
99
&& !rendered_tools_present(request, request_tools);
100
let drop_historical_developers = thinking_mode == ThinkingMode::Thinking && drop_thinking;
61
- let last_user_like_message_index = request.messages.iter().rposition(is_user_like_entry);
62
- let last_user_render_index =
63
- find_last_user_render_index(request.messages.as_slice(), synthetic_tool_system);
101
+ let last_user_like_message_index = request
102
+ .messages
103
+ .iter()
104
+ .enumerate()
105
+ .rfind(|(index, message)| is_user_like_entry(message, *index, dialect))
106
+ .map(|(index, _)| index);
107
+ let last_user_render_index = find_last_user_render_index(
108
+ request.messages.as_slice(),
109
+ synthetic_tool_system,
110
+ drop_historical_developers,
111
+ last_user_like_message_index,
112
+ dialect,
113
+ );
114
let mut out = String::from(BOS_TOKEN);
115
+ if dialect == DsDialect::V41
116
+ && (thinking_mode == ThinkingMode::Thinking
117
+ || synthetic_tool_system
118
+ || matches!(request.messages.first(), Some(ChatMessage::System { .. })))
119
+ {
120
+ out.push_str(SYSTEM_SP_TOKEN);
121
+ }
122
if thinking_mode == ThinkingMode::Thinking {
66
- out.push_str(reasoning_effort_prompt);
123
+ out.push_str(&reasoning_effort_prompt);
124
}
125
126
let mut request_tools_attached = false;
127
let mut render_index = 0isize;
128
if synthetic_tool_system {
72
- render_system_message(&mut out, None, request_tools)?;
129
+ render_system_message(&mut out, None, request_tools, dialect)?;
130
request_tools_attached = true;
131
render_index += 1;
132
}
@@ -80,27 +137,35 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
137
continue;
138
}
139
83
- let current_render_index = render_index;
84
- render_index += 1;
85
-
140
if drop_historical_developers
141
&& is_historical_developer(message, message_index, last_user_like_message_index)
142
{
143
continue;
144
}
145
146
+ let current_render_index = render_index;
147
+ render_index += 1;
148
+
149
match message {
150
ChatMessage::System { content } => {
151
+ if dialect == DsDialect::V41 && current_render_index > 0 {
152
+ out.push_str(SYSTEM_SP_TOKEN);
153
+ }
154
let tools = if !request_tools_attached {
155
request_tools_attached = true;
156
request_tools
157
} else {
158
&[]
159
};
100
- render_system_message(&mut out, Some(content), tools)?;
160
+ render_system_message(&mut out, Some(content), tools, dialect)?;
161
}
162
ChatMessage::Developer { content, tools } => {
103
- render_developer_message(&mut out, content, tools.as_deref().unwrap_or(&[]))?;
163
+ render_developer_message(
164
+ &mut out,
165
+ content,
166
+ tools.as_deref().unwrap_or(&[]),
167
+ dialect,
168
+ )?;
169
}
170
ChatMessage::User { .. } | ChatMessage::ToolResponse { .. } => {
171
render_user_content_block(
@@ -108,6 +173,7 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
173
request.messages.as_slice(),
174
message_index,
175
&last_tool_call_order,
176
+ dialect,
177
)?;
178
}
179
ChatMessage::Assistant { content } => {
@@ -119,7 +185,13 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
185
&& (!drop_thinking || current_render_index > last_user_render_index);
186
let append_eos = !(message_index + 1 == request.messages.len()
187
&& request.chat_options.continue_final_message());
122
- render_assistant_message(&mut out, emit_thinking_block, append_eos, content)?;
188
+ render_assistant_message(
189
+ &mut out,
190
+ emit_thinking_block,
191
+ append_eos,
192
+ content,
193
+ dialect,
194
+ )?;
195
196
if content.has_tool_calls() {
197
last_tool_call_order.clear();
@@ -133,7 +205,8 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
205
}
206
}
207
136
- if (is_user_like_entry(message) || matches!(message, ChatMessage::System { .. }))
208
+ if (is_user_like_entry(message, current_render_index as usize, dialect)
209
+ || (dialect == DsDialect::V4 && matches!(message, ChatMessage::System { .. })))
210
&& next_rendered_entry_is_assistant_or_end(
211
request.messages.as_slice(),
212
message_index,
@@ -181,6 +254,57 @@ fn resolve_thinking_options(request: &ChatRequest) -> Result<(ThinkingMode, &'st
254
Ok((thinking_mode, reasoning_effort_prompt))
255
}
256
257
+/// Resolve V4.1's numeric reasoning effort using the top-level value before template kwargs.
258
+fn resolve_v41_thinking_options(
259
+ request: &ChatRequest,
260
+) -> Result<(ThinkingMode, Cow<'static, str>)> {
261
+ let mut thinking = request.enable_thinking()?.unwrap_or(true);
262
+ let budget = match request.chat_options.reasoning_effort {
263
+ Some(ReasoningEffort::None) => {
264
+ thinking = false;
265
+ 50
266
+ }
267
+ Some(ReasoningEffort::Low) => 25,
268
+ Some(ReasoningEffort::High) => 50,
269
+ Some(ReasoningEffort::XHigh) => 75,
270
+ Some(ReasoningEffort::Max) => 100,
271
+ Some(ReasoningEffort::Minimal | ReasoningEffort::Medium) => {
272
+ return Err(invalid_v41_reasoning_effort());
273
+ }
274
+ None => match request.chat_options.template_kwargs.get("reasoning_effort") {
275
+ None | Some(Value::Null) => 50,
276
+ Some(Value::String(effort)) => match effort.as_str() {
277
+ "low" => 25,
278
+ "high" => 50,
279
+ "xhigh" => 75,
280
+ "max" => 100,
281
+ _ => return Err(invalid_v41_reasoning_effort()),
282
+ },
283
+ Some(Value::Number(budget)) => budget
284
+ .as_u64()
285
+ .filter(|budget| (1..=100).contains(budget))
286
+ .ok_or_else(invalid_v41_reasoning_effort)?,
287
+ Some(_) => return Err(invalid_v41_reasoning_effort()),
288
+ },
289
+ };
290
+ if thinking {
291
+ Ok((
292
+ ThinkingMode::Thinking,
293
+ Cow::Owned(format!(
294
+ "Reasoning Effort: {budget} (range 1-100, the higher the value, the more thorough the reasoning)\n\n"
295
+ )),
296
+ ))
297
+ } else {
298
+ Ok((ThinkingMode::Chat, Cow::Borrowed("")))
299
+ }
300
+}
301
+
302
+fn invalid_v41_reasoning_effort() -> Error {
303
+ Error::InvalidReasoningEffort(
304
+ "DeepSeek V4.1 reasoning_effort must be low, high, xhigh, max, or an integer within [1, 100] in chat_template_kwargs".to_string(),
305
+ )
306
+}
307
+
308
/// Return request-level tools only when native tool parsing is enabled.
309
fn request_tools(request: &ChatRequest) -> &[ChatTool] {
310
if request.tool_parsing_enabled() {
@@ -214,16 +338,25 @@ fn rendered_tools_present(request: &ChatRequest, request_tools: &[ChatTool]) ->
338
}
339
340
/// Find the last user-like turn after inline tool-response merging.
217
-fn find_last_user_render_index(messages: &[ChatMessage], synthetic_tool_system: bool) -> isize {
341
+fn find_last_user_render_index(
342
+ messages: &[ChatMessage],
343
+ synthetic_tool_system: bool,
344
+ drop_historical_developers: bool,
345
+ last_user_like_message_index: Option<usize>,
346
+ dialect: DsDialect,
347
+) -> isize {
348
let mut render_index = isize::from(synthetic_tool_system);
349
let mut last_user_index = -1;
350
351
for (message_index, message) in messages.iter().enumerate() {
222
- if is_following_user_content(messages, message_index) {
352
+ if is_following_user_content(messages, message_index)
353
+ || (drop_historical_developers
354
+ && is_historical_developer(message, message_index, last_user_like_message_index))
355
+ {
356
continue;
357
}
358
226
- if is_user_like_entry(message) {
359
+ if is_user_like_entry(message, render_index as usize, dialect) {
360
last_user_index = render_index;
361
}
362
render_index += 1;
@@ -249,11 +382,13 @@ fn is_user_content_entry(message: &ChatMessage) -> bool {
382
}
383
384
/// Return whether one rendered entry should be treated as user-like.
252
-fn is_user_like_entry(message: &ChatMessage) -> bool {
385
+fn is_user_like_entry(message: &ChatMessage, message_index: usize, dialect: DsDialect) -> bool {
386
matches!(
387
message,
388
ChatMessage::Developer { .. } | ChatMessage::User { .. } | ChatMessage::ToolResponse { .. }
256
- )
389
+ ) || (dialect == DsDialect::V41
390
+ && message_index > 0
391
+ && matches!(message, ChatMessage::System { .. }))
392
}
393
394
/// Return whether a developer entry precedes another user-like turn.
@@ -293,22 +428,26 @@ fn next_rendered_entry_is_assistant_or_end(
428
.unwrap_or(true)
429
}
430
296
-/// Render the tool preamble shown to the model, V4 flavor.
297
-fn render_tools(out: &mut String, tools: &[ChatTool]) -> Result<()> {
298
- out.push_str(
431
+/// Render the tool preamble shown to the model for one DeepSeek dialect.
432
+fn render_tools(out: &mut String, tools: &[ChatTool], dialect: DsDialect) -> Result<()> {
433
+ let tool_calls_tag = dialect.tool_calls_tag();
434
+ let invoke_tag = dialect.invoke_tag();
435
+ let parameter_tag = dialect.parameter_tag();
436
+ write!(
437
+ out,
438
r#"## Tools
439
301
-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:
440
+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:
441
303
-<|DSML|tool_calls>
304
-<|DSML|invoke name="$TOOL_NAME">
305
-<|DSML|parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|parameter>
442
+<|DSML|{tool_calls_tag}>
443
+<|DSML|{invoke_tag} name="$TOOL_NAME">
444
+<|DSML|{parameter_tag} name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|{parameter_tag}>
445
...
307
-</|DSML|invoke>
308
-<|DSML|invoke name="$TOOL_NAME2">
446
+</|DSML|{invoke_tag}>
447
+<|DSML|{invoke_tag} name="$TOOL_NAME2">
448
...
310
-</|DSML|invoke>
311
-</|DSML|tool_calls>
449
+</|DSML|{invoke_tag}>
450
+</|DSML|{tool_calls_tag}>
451
452
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"`.
453
@@ -319,7 +458,8 @@ Otherwise, output directly after </think> with tool calls or final response.
458
### Available Tool Schemas
459
460
"#,
322
- );
461
+ )
462
+ .expect("writing to String cannot fail");
463
464
for (index, tool) in tools.iter().enumerate() {
465
if index > 0 {
@@ -350,13 +490,14 @@ fn render_system_message(
490
out: &mut String,
491
content: Option<&ChatContent>,
492
tools: &[ChatTool],
493
+ dialect: DsDialect,
494
) -> Result<()> {
495
if let Some(content) = content {
355
- write_chat_content(out, content)?;
496
+ write_chat_content(out, content, dialect)?;
497
}
498
if !tools.is_empty() {
499
out.push_str("\n\n");
359
- render_tools(out, tools)?;
500
+ render_tools(out, tools, dialect)?;
501
}
502
Ok(())
503
}
@@ -366,18 +507,19 @@ fn render_developer_message(
507
out: &mut String,
508
content: &ChatContent,
509
tools: &[ChatTool],
510
+ dialect: DsDialect,
511
) -> Result<()> {
512
if content.is_empty() {
513
return Err(Error::ChatTemplate(
372
- "invalid DeepSeek V4 developer message: empty content".to_string(),
514
+ "invalid DeepSeek developer message: empty content".to_string(),
515
));
516
}
517
518
out.push_str(USER_SP_TOKEN);
377
- write_chat_content(out, content)?;
519
+ write_chat_content(out, content, dialect)?;
520
if !tools.is_empty() {
521
out.push_str("\n\n");
380
- render_tools(out, tools)?;
522
+ render_tools(out, tools, dialect)?;
523
}
524
Ok(())
525
}
@@ -388,6 +530,7 @@ fn render_user_content_block(
530
messages: &[ChatMessage],
531
message_index: usize,
532
tool_call_order: &HashMap<String, usize>,
533
+ dialect: DsDialect,
534
) -> Result<()> {
535
let (block_start, block_end) = user_content_block_bounds(messages, message_index);
536
let mut sorted_tool_indices =
@@ -399,7 +542,7 @@ fn render_user_content_block(
542
out.push_str("\n\n");
543
}
544
match &messages[message_index] {
402
- ChatMessage::User { content } => write_chat_content(out, content)?,
545
+ ChatMessage::User { content } => write_chat_content(out, content, dialect)?,
546
ChatMessage::ToolResponse { .. } => {
547
let sorted_index = sorted_tool_indices
548
.next()
@@ -407,7 +550,7 @@ fn render_user_content_block(
550
let ChatMessage::ToolResponse { content, .. } = &messages[sorted_index] else {
551
unreachable!("sorted tool response index should reference a tool message");
552
};
410
- write_tool_result(out, content)?;
553
+ write_tool_result(out, content, dialect)?;
554
}
555
_ => unreachable!("user content block should only contain user content messages"),
556
}
@@ -454,9 +597,9 @@ fn sorted_tool_response_indices(
597
}
598
599
/// Render one tool response payload inside a V4 `<tool_result>` block.
457
-fn write_tool_result(out: &mut String, content: &ChatContent) -> Result<()> {
600
+fn write_tool_result(out: &mut String, content: &ChatContent, dialect: DsDialect) -> Result<()> {
601
out.push_str("<tool_result>");
459
- write_chat_content(out, content)?;
602
+ write_chat_content(out, content, dialect)?;
603
out.push_str("</tool_result>");
604
Ok(())
605
}
@@ -483,6 +626,7 @@ fn render_assistant_message(
626
emit_thinking_block: bool,
627
append_eos: bool,
628
content: &[AssistantContentBlock],
629
+ dialect: DsDialect,
630
) -> Result<()> {
631
let has_tool_calls = content.has_tool_calls();
632
@@ -496,14 +640,15 @@ fn render_assistant_message(
640
write_assistant_text(out, content);
641
642
if has_tool_calls {
499
- out.push_str("\n\n<|DSML|tool_calls>\n");
643
+ let tool_calls_tag = dialect.tool_calls_tag();
644
+ writeln!(out, "\n\n<{DSML_TOKEN}{tool_calls_tag}>").expect("writing to String cannot fail");
645
for (index, tool_call) in content.tool_calls().enumerate() {
646
if index > 0 {
647
out.push('\n');
648
}
504
- render_tool_call(out, tool_call)?;
649
+ render_tool_call(out, tool_call, dialect)?;
650
}
506
- out.push_str("\n</|DSML|tool_calls>");
651
+ write!(out, "\n</{DSML_TOKEN}{tool_calls_tag}>").expect("writing to String cannot fail");
652
}
653
654
if append_eos {
@@ -513,11 +658,20 @@ fn render_assistant_message(
658
}
659
660
/// Render one assistant tool call in DSML XML-like format.
516
-fn render_tool_call(out: &mut String, tool_call: &AssistantToolCall) -> Result<()> {
517
- writeln!(out, "<{DSML_TOKEN}invoke name=\"{}\">", tool_call.name)
518
- .expect("writing to String cannot fail");
519
- encode_arguments_to_dsml(out, tool_call)?;
520
- write!(out, "\n</{DSML_TOKEN}invoke>").expect("writing to String cannot fail");
661
+fn render_tool_call(
662
+ out: &mut String,
663
+ tool_call: &AssistantToolCall,
664
+ dialect: DsDialect,
665
+) -> Result<()> {
666
+ let invoke_tag = dialect.invoke_tag();
667
+ writeln!(
668
+ out,
669
+ "<{DSML_TOKEN}{invoke_tag} name=\"{}\">",
670
+ tool_call.name
671
+ )
672
+ .expect("writing to String cannot fail");
673
+ encode_arguments_to_dsml(out, tool_call, dialect)?;
674
+ write!(out, "\n</{DSML_TOKEN}{invoke_tag}>").expect("writing to String cannot fail");
675
Ok(())
676
}
677
@@ -525,15 +679,20 @@ fn render_tool_call(out: &mut String, tool_call: &AssistantToolCall) -> Result<(
679
///
680
/// String values are emitted raw with `string="true"`, while all other JSON
681
/// values are rendered with JSON syntax and `string="false"`.
528
-fn encode_arguments_to_dsml(out: &mut String, tool_call: &AssistantToolCall) -> Result<()> {
682
+fn encode_arguments_to_dsml(
683
+ out: &mut String,
684
+ tool_call: &AssistantToolCall,
685
+ dialect: DsDialect,
686
+) -> Result<()> {
687
+ let parameter_tag = dialect.parameter_tag();
688
let arguments: Value = serde_json::from_str(&tool_call.arguments).map_err(|error| {
689
Error::ChatTemplate(format!(
531
- "assistant tool call has invalid JSON arguments for DeepSeek V4: {error}"
690
+ "assistant tool call has invalid JSON arguments for DeepSeek: {error}"
691
))
692
})?;
693
let Some(arguments) = arguments.as_object() else {
694
return Err(Error::ChatTemplate(
536
- "assistant tool call arguments for DeepSeek V4 must be a JSON object".to_string(),
695
+ "assistant tool call arguments for DeepSeek must be a JSON object".to_string(),
696
));
697
};
698
@@ -546,7 +705,7 @@ fn encode_arguments_to_dsml(out: &mut String, tool_call: &AssistantToolCall) ->
705
let is_string = matches!(value, Value::String(_));
706
write!(
707
out,
549
- "<{DSML_TOKEN}parameter name=\"{key}\" string=\"{}\">",
708
+ "<{DSML_TOKEN}{parameter_tag} name=\"{key}\" string=\"{}\">",
709
if is_string { "true" } else { "false" }
710
)
711
.expect("writing to String cannot fail");
@@ -556,7 +715,7 @@ fn encode_arguments_to_dsml(out: &mut String, tool_call: &AssistantToolCall) ->
715
value => out.push_str(&json_dumps(value)?),
716
}
717
559
- write!(out, "</{DSML_TOKEN}parameter>").expect("writing to String cannot fail");
718
+ write!(out, "</{DSML_TOKEN}{parameter_tag}>").expect("writing to String cannot fail");
719
wrote_parameter = true;
720
}
721
@@ -565,12 +724,24 @@ fn encode_arguments_to_dsml(out: &mut String, tool_call: &AssistantToolCall) ->
724
725
/// Write chat content directly into the destination buffer without flattening
726
/// it into an intermediate `String`.
568
-fn write_chat_content(out: &mut String, content: &ChatContent) -> Result<()> {
727
+///
728
+/// The V4.1 dialect inlines the image placeholder at each image part's
729
+/// position unconditionally (matching the Python encoding's
730
+/// `IMAGE_PLACEHOLDER`); other dialects reject multimodal parts.
731
+fn write_chat_content(out: &mut String, content: &ChatContent, dialect: DsDialect) -> Result<()> {
732
match content {
733
ChatContent::Text(text) => out.push_str(text),
734
ChatContent::Parts(parts) => {
572
- for part in parts {
573
- out.push_str(part.as_text()?);
735
+ for (index, part) in parts.iter().enumerate() {
736
+ if index > 0 && dialect == DsDialect::V41 {
737
+ out.push_str("\n\n");
738
+ }
739
+ match part {
740
+ ChatContentPart::ImageUrl { .. } if dialect == DsDialect::V41 => {
741
+ out.push_str(DEEPSEEK_V41_IMAGE_PLACEHOLDER);
742
+ }
743
+ _ => out.push_str(part.as_text()?),
744
+ }
745
}
746
}
747
}
@@ -606,7 +777,7 @@ fn json_dumps<T: Serialize>(value: &T) -> Result<String> {
777
.format_to_string(value)
778
.map_err(|error| {
779
Error::ChatTemplate(format!(
609
- "failed to serialize DeepSeek V4 JSON payload: {error}"
780
+ "failed to serialize DeepSeek JSON payload: {error}"
781
))
782
})
783
}
rust/src/chat/src/renderer/deepseek_v4/mod.rs
+2
-3
@@ -1,10 +1,9 @@
1
// SPDX-License-Identifier: Apache-2.0
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-mod encoding;
5
-
4
use vllm_text::Prompt;
5
6
+use super::deepseek::{self, DsDialect};
7
use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
8
use crate::Result;
9
use crate::request::ChatRequest;
@@ -24,7 +23,7 @@ impl ChatRenderer for DeepSeekV4ChatRenderer {
23
request.validate()?;
24
25
Ok(RenderedPrompt {
27
- prompt: Prompt::Text(encoding::render_request(request)?),
26
+ prompt: Prompt::Text(deepseek::render_request(request, DsDialect::V4)?),
27
effective_template_kwargs: request_template_kwargs(request),
28
})
29
}
rust/src/chat/src/renderer/deepseek_v41/fixtures/test_output_1.txt
new
+38
@@ -0,0 +1,38 @@
1
+<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)
2
+
3
+You are a helpful assistant.
4
+
5
+## Tools
6
+
7
+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:
8
+
9
+<|DSML| calls>
10
+<|DSML| invoke name="$TOOL_NAME">
11
+<|DSML| parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML| parameter>
12
+...
13
+</|DSML| invoke>
14
+<|DSML| invoke name="$TOOL_NAME2">
15
+...
16
+</|DSML| invoke>
17
+</|DSML| calls>
18
+
19
+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"`.
20
+
21
+If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
22
+
23
+Otherwise, output directly after </think> with tool calls or final response.
24
+
25
+### Available Tool Schemas
26
+
27
+{"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"]}}
28
+{"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"]}}
29
+
30
+You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
31
+<|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>
32
+
33
+<|DSML| calls>
34
+<|DSML| invoke name="get_weather">
35
+<|DSML| parameter name="location" string="true">Beijing</|DSML| parameter>
36
+<|DSML| parameter name="unit" string="true">celsius</|DSML| parameter>
37
+</|DSML| invoke>
38
+</|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|>
\ No newline at end of file
rust/src/chat/src/renderer/deepseek_v41/fixtures/test_output_2.txt
new
+3
@@ -0,0 +1,3 @@
1
+<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)
2
+
3
+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|>
\ No newline at end of file
rust/src/chat/src/renderer/deepseek_v41/fixtures/test_output_developer_tools.txt
new
+30
@@ -0,0 +1,30 @@
1
+<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)
2
+
3
+<|User|>question<|User|>policy
4
+
5
+## Tools
6
+
7
+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:
8
+
9
+<|DSML| calls>
10
+<|DSML| invoke name="$TOOL_NAME">
11
+<|DSML| parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML| parameter>
12
+...
13
+</|DSML| invoke>
14
+<|DSML| invoke name="$TOOL_NAME2">
15
+...
16
+</|DSML| invoke>
17
+</|DSML| calls>
18
+
19
+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"`.
20
+
21
+If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
22
+
23
+Otherwise, output directly after </think> with tool calls or final response.
24
+
25
+### Available Tool Schemas
26
+
27
+{"name": "lookup", "parameters": {"type": "object"}}
28
+
29
+You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
30
+<|Assistant|><think></think>answer<|end▁of▁sentence|>
\ No newline at end of file
rust/src/chat/src/renderer/deepseek_v41/mod.rs
new
+33
@@ -0,0 +1,33 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+use vllm_text::Prompt;
5
+
6
+use super::deepseek::{self, DsDialect};
7
+use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
8
+use crate::Result;
9
+use crate::request::ChatRequest;
10
+
11
+/// Dedicated DeepSeek V4.1 renderer.
12
+#[derive(Debug, Clone, Copy, Default)]
13
+pub struct DeepSeekV41ChatRenderer;
14
+
15
+impl DeepSeekV41ChatRenderer {
16
+ pub fn new() -> Self {
17
+ Self
18
+ }
19
+}
20
+
21
+impl ChatRenderer for DeepSeekV41ChatRenderer {
22
+ fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
23
+ request.validate()?;
24
+
25
+ Ok(RenderedPrompt {
26
+ prompt: Prompt::Text(deepseek::render_request(request, DsDialect::V41)?),
27
+ effective_template_kwargs: request_template_kwargs(request),
28
+ })
29
+ }
30
+}
31
+
32
+#[cfg(test)]
33
+mod tests;
rust/src/chat/src/renderer/deepseek_v41/tests.rs
new
+223
@@ -0,0 +1,223 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+use std::path::PathBuf;
5
+
6
+use expect_test::{expect, expect_file};
7
+use serde_json::{Value, json};
8
+
9
+use super::DeepSeekV41ChatRenderer;
10
+use crate::ChatRenderer;
11
+use crate::event::AssistantContentBlock;
12
+use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
13
+use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest, ReasoningEffort};
14
+
15
+fn render(request: &ChatRequest) -> String {
16
+ DeepSeekV41ChatRenderer::new()
17
+ .render(request)
18
+ .unwrap()
19
+ .prompt
20
+ .into_text()
21
+ .unwrap()
22
+}
23
+
24
+fn request() -> ChatRequest {
25
+ ChatRequest {
26
+ messages: vec![ChatMessage::user("question")],
27
+ ..ChatRequest::for_test()
28
+ }
29
+}
30
+
31
+#[test]
32
+fn renders_shared_deepseek_fixtures_with_v41_reference_encoding() {
33
+ let fixture_dir =
34
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/renderer/deepseek_v4/fixtures");
35
+ for (input, expected) in [
36
+ (
37
+ "test_input_1.json",
38
+ expect_file!["fixtures/test_output_1.txt"],
39
+ ),
40
+ (
41
+ "test_input_2.json",
42
+ expect_file!["fixtures/test_output_2.txt"],
43
+ ),
44
+ (
45
+ "test_input_developer_tools.json",
46
+ expect_file!["fixtures/test_output_developer_tools.txt"],
47
+ ),
48
+ ] {
49
+ let request = fixture_chat_request(
50
+ &fixture_dir.join(input),
51
+ FixtureRequestOptions {
52
+ enable_thinking: Some(true),
53
+ no_generation_prompt_when_last_assistant: true,
54
+ },
55
+ );
56
+ expected.assert_eq(&render(&request));
57
+ }
58
+}
59
+
60
+#[test]
61
+fn inlines_image_placeholder_at_image_part_positions() {
62
+ // The placeholder is unconditional (matching the Python encoding's
63
+ // `IMAGE_PLACEHOLDER`); requests to a multimodal-less backend fail later,
64
+ // at media preparation.
65
+ let renderer = DeepSeekV41ChatRenderer::new();
66
+ let request = ChatRequest {
67
+ messages: vec![ChatMessage::User {
68
+ content: ChatContent::Parts(vec![
69
+ ChatContentPart::image_url("https://example.com/carrots.jpeg"),
70
+ ChatContentPart::text("what is in the image?"),
71
+ ]),
72
+ }],
73
+ ..ChatRequest::for_test()
74
+ };
75
+
76
+ let rendered = renderer.render(&request).unwrap().prompt.into_text().unwrap();
77
+
78
+ // Image parts inline the placeholder at their position; parts stay
79
+ // separated by "\n\n" (the V4.1 reference encoding's part separator).
80
+ assert!(
81
+ rendered.contains("<|User|><|deepseek_image|>\n\nwhat is in the image?"),
82
+ "unexpected prompt: {rendered:?}"
83
+ );
84
+}
85
+
86
+#[test]
87
+fn maps_reasoning_effort_to_reference_numeric_budget() {
88
+ for (effort, budget) in [
89
+ (None, 50),
90
+ (Some(ReasoningEffort::Low), 25),
91
+ (Some(ReasoningEffort::High), 50),
92
+ (Some(ReasoningEffort::XHigh), 75),
93
+ (Some(ReasoningEffort::Max), 100),
94
+ ] {
95
+ let mut request = request();
96
+ request.chat_options.reasoning_effort = effort;
97
+ assert_eq!(
98
+ render(&request),
99
+ format!(
100
+ "<|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>"
101
+ )
102
+ );
103
+ }
104
+}
105
+
106
+#[test]
107
+fn accepts_numeric_template_effort_with_top_level_precedence() {
108
+ for (effort, budget) in [
109
+ (json!(1), 1),
110
+ (json!(42), 42),
111
+ (json!(100), 100),
112
+ (json!("low"), 25),
113
+ ] {
114
+ let mut request = request();
115
+ request.chat_options.template_kwargs.insert("reasoning_effort".into(), effort);
116
+ assert!(render(&request).contains(&format!("Reasoning Effort: {budget} (range 1-100")));
117
+
118
+ request.chat_options.reasoning_effort = Some(ReasoningEffort::XHigh);
119
+ assert!(render(&request).contains("Reasoning Effort: 75 (range 1-100"));
120
+ }
121
+}
122
+
123
+#[test]
124
+fn rejects_undefined_effort_names_and_invalid_numeric_budgets() {
125
+ for effort in [
126
+ json!("medium"),
127
+ json!("minimal"),
128
+ json!(-1),
129
+ json!(0),
130
+ json!(101),
131
+ json!(true),
132
+ json!(1.5),
133
+ ] {
134
+ let mut request = request();
135
+ request.chat_options.template_kwargs.insert("reasoning_effort".into(), effort);
136
+ let error = DeepSeekV41ChatRenderer::new().render(&request).unwrap_err();
137
+ assert!(error.is_request_validation_error());
138
+ }
139
+ for effort in [ReasoningEffort::Minimal, ReasoningEffort::Medium] {
140
+ let mut request = request();
141
+ request.chat_options.reasoning_effort = Some(effort);
142
+ let error = DeepSeekV41ChatRenderer::new().render(&request).unwrap_err();
143
+ assert!(error.is_request_validation_error());
144
+ }
145
+}
146
+
147
+#[test]
148
+fn chat_mode_and_none_effort_use_closed_thinking_prefix() {
149
+ let mut request = request();
150
+ request.chat_options.template_kwargs.insert("thinking".into(), json!(false));
151
+ let expected = expect!["<|begin▁of▁sentence|><|User|>question<|Assistant|></think>"];
152
+ expected.assert_eq(&render(&request));
153
+
154
+ request.chat_options.template_kwargs.insert("thinking".into(), json!(true));
155
+ request.chat_options.reasoning_effort = Some(ReasoningEffort::None);
156
+ expected.assert_eq(&render(&request));
157
+}
158
+
159
+#[test]
160
+fn leading_system_in_chat_mode_uses_system_token() {
161
+ let mut request = request();
162
+ request.messages.insert(0, ChatMessage::system("policy"));
163
+ request.chat_options.template_kwargs.insert("thinking".into(), json!(false));
164
+ expect!["<|begin▁of▁sentence|><|System|>policy<|User|>question<|Assistant|></think>"]
165
+ .assert_eq(&render(&request));
166
+}
167
+
168
+#[test]
169
+fn system_after_dropped_developer_becomes_leading_system() {
170
+ let mut request = ChatRequest {
171
+ messages: vec![
172
+ ChatMessage::developer("old policy", None),
173
+ ChatMessage::system("sys"),
174
+ ],
175
+ ..ChatRequest::for_test()
176
+ };
177
+ expect!["<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)\n\nsys"]
178
+ .assert_eq(&render(&request));
179
+
180
+ request.messages.push(ChatMessage::user("question"));
181
+ 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>"]
182
+ .assert_eq(&render(&request));
183
+}
184
+
185
+#[test]
186
+fn mid_system_advances_last_user_boundary_and_drops_old_reasoning() {
187
+ let mut request = ChatRequest {
188
+ messages: vec![
189
+ ChatMessage::system("sys"),
190
+ ChatMessage::user("q1"),
191
+ ChatMessage::assistant_blocks(vec![
192
+ AssistantContentBlock::Reasoning { text: "r1".into() },
193
+ AssistantContentBlock::Text { text: "a1".into() },
194
+ ]),
195
+ ChatMessage::developer("old policy", None),
196
+ ChatMessage::system("mid sys"),
197
+ ],
198
+ ..ChatRequest::for_test()
199
+ };
200
+ request
201
+ .chat_options
202
+ .template_kwargs
203
+ .insert("reasoning_effort".into(), json!(88));
204
+ 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>"]
205
+ .assert_eq(&render(&request));
206
+}
207
+
208
+#[test]
209
+fn joins_text_parts_with_reference_separator_and_preserves_literal_tags() {
210
+ let mut request = ChatRequest {
211
+ messages: vec![ChatMessage::user(ChatContent::Parts(vec![
212
+ ChatContentPart::text("first"),
213
+ ChatContentPart::text("<|DSML|invoke name=\"literal\">"),
214
+ ]))],
215
+ ..ChatRequest::for_test()
216
+ };
217
+ request
218
+ .chat_options
219
+ .template_kwargs
220
+ .insert("thinking".into(), Value::Bool(false));
221
+ expect!["<|begin▁of▁sentence|><|User|>first\n\n<|DSML|invoke name=\"literal\"><|Assistant|></think>"]
222
+ .assert_eq(&render(&request));
223
+}
rust/src/chat/src/renderer/mod.rs
+3
@@ -10,8 +10,10 @@ use vllm_text::Prompt;
10
use crate::error::Result;
11
use crate::request::{ChatRequest, ReasoningEffort};
12
13
+mod deepseek;
14
pub mod deepseek_v32;
15
pub mod deepseek_v4;
16
+pub mod deepseek_v41;
17
pub mod harmony;
18
pub mod hf;
19
mod inkling;
@@ -22,6 +24,7 @@ mod test_utils;
24
25
pub use deepseek_v4::DeepSeekV4ChatRenderer;
26
pub use deepseek_v32::DeepSeekV32ChatRenderer;
27
+pub use deepseek_v41::DeepSeekV41ChatRenderer;
28
pub use harmony::HarmonyChatRenderer;
29
pub use inkling::InklingChatRenderer;
30
pub use kimi_k3::KimiK3ChatRenderer;
rust/src/chat/src/renderer/selection.rs
+8
-1
@@ -22,6 +22,8 @@ pub enum RendererSelection {
22
DeepSeekV32,
23
/// Force the DeepSeek V4 renderer.
24
DeepSeekV4,
25
+ /// Force the DeepSeek V4.1 renderer.
26
+ DeepSeekV41,
27
/// Force the GPT-OSS Harmony renderer.
28
Harmony,
29
/// Force the Inkling native token renderer.
@@ -34,6 +36,7 @@ impl RendererSelection {
36
pub const AUTO_LITERAL: &str = "auto";
37
pub const DEEPSEEK_V32_LITERAL: &str = "deepseek_v32";
38
pub const DEEPSEEK_V4_LITERAL: &str = "deepseek_v4";
39
+ pub const DEEPSEEK_V41_LITERAL: &str = "deepseek_v41";
40
pub const GPT_OSS_MODEL_TYPE: &str = "gpt_oss";
41
pub const HARMONY_LITERAL: &str = "harmony";
42
pub const HF_LITERAL: &str = "hf";
@@ -48,6 +51,7 @@ impl RendererSelection {
51
Self::Auto => match model_type {
52
Self::DEEPSEEK_V32_LITERAL => Self::DeepSeekV32,
53
Self::DEEPSEEK_V4_LITERAL => Self::DeepSeekV4,
54
+ Self::DEEPSEEK_V41_LITERAL => Self::DeepSeekV41,
55
Self::GPT_OSS_MODEL_TYPE => Self::Harmony,
56
Self::INKLING_MODEL_TYPE => Self::Inkling,
57
Self::KIMI_K3_LITERAL => Self::KimiK3,
@@ -70,6 +74,8 @@ impl FromStr for RendererSelection {
74
Ok(Self::DeepSeekV32)
75
} else if value.eq_ignore_ascii_case(Self::DEEPSEEK_V4_LITERAL) {
76
Ok(Self::DeepSeekV4)
77
+ } else if value.eq_ignore_ascii_case(Self::DEEPSEEK_V41_LITERAL) {
78
+ Ok(Self::DeepSeekV41)
79
} else if value.eq_ignore_ascii_case(Self::HARMONY_LITERAL) {
80
Ok(Self::Harmony)
81
} else if value.eq_ignore_ascii_case(Self::INKLING_LITERAL) {
@@ -92,6 +98,7 @@ impl fmt::Display for RendererSelection {
98
Self::Hf => f.write_str(Self::HF_LITERAL),
99
Self::DeepSeekV32 => f.write_str(Self::DEEPSEEK_V32_LITERAL),
100
Self::DeepSeekV4 => f.write_str(Self::DEEPSEEK_V4_LITERAL),
101
+ Self::DeepSeekV41 => f.write_str(Self::DEEPSEEK_V41_LITERAL),
102
Self::Harmony => f.write_str(Self::HARMONY_LITERAL),
103
Self::Inkling => f.write_str(Self::INKLING_LITERAL),
104
Self::KimiK3 => f.write_str(Self::KIMI_K3_LITERAL),
@@ -121,7 +128,7 @@ mod tests {
128
fn renderer_selection_expected_error_message() {
129
let err = RendererSelection::from_str("unknown").unwrap_err();
130
expect_test::expect![
124
- "unknown renderer `unknown` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony, inkling, kimi_k3)"
131
+ "unknown renderer `unknown` (expected one of: auto, hf, deepseek_v32, deepseek_v4, deepseek_v41, harmony, inkling, kimi_k3)"
132
]
133
.assert_eq(&err);
134
}
rust/src/chat/tests/roundtrip.rs
+15
-1
@@ -29,7 +29,7 @@ const TOKEN_COMPLETION_CHUNK_TOKENS: usize = 1;
29
/// One model/parser configuration used to run the fixed roundtrip fixtures.
30
#[derive(Clone)]
31
struct RoundtripCase {
32
- /// Hugging Face model id resolved through the production backend loader.
32
+ /// Hugging Face model id or local checkpoint resolved through the production backend loader.
33
model_id: &'static str,
34
/// Final assistant-history suffix rendered by the chat template but not
35
/// generated by the model body consumed by the output processor.
@@ -180,6 +180,19 @@ impl RoundtripCase {
180
}
181
}
182
183
+ /// DeepSeek V4.1 Flash DSML tool-call format.
184
+ fn deepseek_v41() -> Self {
185
+ Self {
186
+ model_id: "deepseek-ai/DeepSeek-V4.1-Flash",
187
+ assistant_stop_suffix: "<|end▁of▁sentence|>",
188
+ tool_call_parser: ParserSelection::Auto,
189
+ reasoning_parser: ParserSelection::Auto,
190
+ thinking_behavior: ThinkingBehavior::Toggleable { default: true },
191
+ json_fmt: compact_json_fmt(),
192
+ sort_json_keys: false,
193
+ }
194
+ }
195
+
196
/// DeepSeek V3.2 DSML tool-call format.
197
fn deepseek_v32() -> Self {
198
Self {
@@ -385,6 +398,7 @@ roundtrip_tests! {
398
minimax_m25 => [reasoning_and_content, tool_call_mix],
399
minimax_m3 => [reasoning_and_content, tool_call_mix],
400
deepseek_v4 => [reasoning_and_content, tool_call_mix],
401
+ deepseek_v41 => [reasoning_and_content, tool_call_mix],
402
deepseek_v32 => [tool_call_mix],
403
glm45 => [reasoning_and_content, tool_call_mix],
404
glm47 => [reasoning_and_content, tool_call_mix],
rust/src/cmd/src/cli/tests.rs
+1
-1
@@ -903,7 +903,7 @@ fn serve_args_reject_unknown_renderer_value() {
903
.unwrap_err();
904
905
expect![[r#"
906
- 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)
906
+ 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)
907
908
For more information, try '--help'.
909
"#]]
rust/src/parser/src/reasoning/mod.rs
+2
@@ -53,6 +53,8 @@ pub type Step3ReasoningParser = DeepSeekR1ReasoningParser;
53
54
/// DeepSeek V4 currently shares the DeepSeek V3 reasoning parser.
55
pub type DeepSeekV4ReasoningParser = DeepSeekV3ReasoningParser;
56
+/// DeepSeek V4.1 uses bare `<think>...</think>` delimiters.
57
+pub type DeepSeekV41ReasoningParser = DeepSeekV3ReasoningParser;
58
/// GLM-4.7 and GLM-5 use bare reasoning delimiters.
59
pub type Glm47ReasoningParser = DeepSeekV3ReasoningParser;
60
/// Kimi K2 currently shares the DeepSeek V3 reasoning parser.
rust/src/parser/src/tool/deepseek_dsml/deepseek_v41.rs
new
+47
@@ -0,0 +1,47 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+use super::{DeepSeekDsmlToolParser, DsmlTokens};
5
+use crate::tool::{Result, StructuralTagBuilder, Tool, ToolParser, ToolParserOutput};
6
+
7
+mod structural_tag;
8
+
9
+/// Tool parser for DeepSeek V4.1's spaced DSML tags.
10
+///
11
+/// Arguments are emitted only after a full `invoke` block is parsed.
12
+pub struct DeepSeekV41ToolParser(DeepSeekDsmlToolParser);
13
+
14
+impl ToolParser for DeepSeekV41ToolParser {
15
+ fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>>
16
+ where
17
+ Self: Sized + 'static,
18
+ {
19
+ Ok(Box::new(Self(DeepSeekDsmlToolParser::new(
20
+ tools,
21
+ DsmlTokens::V41,
22
+ ))))
23
+ }
24
+
25
+ fn preserve_special_tokens(&self) -> bool {
26
+ true
27
+ }
28
+
29
+ fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
30
+ Some(&structural_tag::DeepSeekV41StructuralTagBuilder)
31
+ }
32
+
33
+ fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
34
+ self.0.parse_into(chunk, output)
35
+ }
36
+
37
+ fn finish(&mut self) -> Result<ToolParserOutput> {
38
+ self.0.finish()
39
+ }
40
+
41
+ fn reset(&mut self) -> String {
42
+ self.0.reset()
43
+ }
44
+}
45
+
46
+#[cfg(test)]
47
+mod tests;
rust/src/parser/src/tool/deepseek_dsml/deepseek_v41/structural_tag.rs
new
+108
@@ -0,0 +1,108 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Visible-text grammar for DeepSeek V4.1 tool calls.
5
+//!
6
+//! Adapted from xgrammar-structural-tag 0.2.0's DeepSeek V4 builder, with
7
+//! V4.1's spaced DSML tags and schema-unconstrained parameter values.
8
+
9
+use serde_json::Value;
10
+use xgrammar_structural_tag::Result;
11
+use xgrammar_structural_tag::builders::{
12
+ StructuralTagBuilder, StructuralTagContext, StructuralTagOptions,
13
+};
14
+use xgrammar_structural_tag::format::{
15
+ Format, JsonSchemaFormat, StructuralTag, TagFormat, TriggeredTagsFormat,
16
+};
17
+use xgrammar_structural_tag::tool::{BuilderToolChoice, FunctionToolParam};
18
+
19
+use super::DsmlTokens;
20
+
21
+const TOKENS: DsmlTokens = DsmlTokens::V41;
22
+
23
+pub(super) struct DeepSeekV41StructuralTagBuilder;
24
+
25
+impl StructuralTagBuilder for DeepSeekV41StructuralTagBuilder {
26
+ fn build(&self, ctx: StructuralTagContext<'_>) -> Result<StructuralTag> {
27
+ let tags = ctx
28
+ .function_tools
29
+ .iter()
30
+ .map(|tool| call_tag(tool, ctx.options))
31
+ .collect::<Vec<_>>();
32
+ let excludes = if ctx.options.exclude_special_tokens {
33
+ &["<think>", "</think>"][..]
34
+ } else {
35
+ &[]
36
+ };
37
+ let format = match ctx.tool_choice {
38
+ BuilderToolChoice::Auto if tags.is_empty() => Format::any_text_excluding(excludes),
39
+ BuilderToolChoice::Auto => Format::TriggeredTags(
40
+ TriggeredTagsFormat::new(
41
+ &[TOKENS.tool_calls_start],
42
+ vec![TagFormat::new(
43
+ format!("{}\n", TOKENS.tool_calls_start),
44
+ Format::tags_with_separator(tags, "", true, false),
45
+ TOKENS.tool_calls_end,
46
+ )],
47
+ )
48
+ .with_excludes(excludes),
49
+ ),
50
+ BuilderToolChoice::Forced | BuilderToolChoice::Required => Format::sequence(vec![
51
+ Format::const_string(format!("\n\n{}\n", TOKENS.tool_calls_start)),
52
+ Format::tags_with_separator(
53
+ tags,
54
+ "",
55
+ true,
56
+ ctx.tool_choice == BuilderToolChoice::Forced,
57
+ ),
58
+ Format::const_string(TOKENS.tool_calls_end),
59
+ ]),
60
+ };
61
+ // Reasoning boundaries remain owned by the engine reasoner; serving
62
+ // calls this visible-text builder with reasoning=false.
63
+ Ok(StructuralTag::new(format))
64
+ }
65
+}
66
+
67
+fn call_tag(tool: &FunctionToolParam, options: StructuralTagOptions) -> TagFormat {
68
+ // TODO: enforce tool.parameters and strict schema semantics once V4.1
69
+ // argument conversion is available. This builder intentionally ignores
70
+ // parameters and strict: it constrains tool names and DSML/value syntax
71
+ // only. Parameter names, presence, uniqueness and value schemas remain
72
+ // unconstrained, including for strict=true. Serving still uses strict to
73
+ // decide whether auto tool choice activates a grammar.
74
+ TagFormat::new(
75
+ format!("{} name=\"{}\">\n", TOKENS.invoke_start, tool.function.name),
76
+ Format::star(parameter(options)),
77
+ format!("{}\n", TOKENS.invoke_end),
78
+ )
79
+}
80
+
81
+fn parameter(options: StructuralTagOptions) -> Format {
82
+ Format::tag(
83
+ format!("{} name=\"", TOKENS.parameter_start),
84
+ Format::sequence(vec![
85
+ Format::regex(r#"[^"]+"#),
86
+ Format::const_string("\" string=\""),
87
+ Format::or(vec![
88
+ Format::sequence(vec![
89
+ Format::const_string("true\">"),
90
+ Format::any_text_excluding(&[
91
+ TOKENS.parameter_end,
92
+ TOKENS.invoke_end,
93
+ TOKENS.tool_calls_end,
94
+ ]),
95
+ ]),
96
+ Format::sequence(vec![
97
+ Format::const_string("false\">"),
98
+ Format::JsonSchema(
99
+ JsonSchemaFormat::new(Value::Bool(true))
100
+ .with_any_order(options.any_order)
101
+ .with_max_whitespace_cnt(options.max_whitespace_cnt),
102
+ ),
103
+ ]),
104
+ ]),
105
+ ]),
106
+ format!("{}\n", TOKENS.parameter_end),
107
+ )
108
+}
rust/src/parser/src/tool/deepseek_dsml/deepseek_v41/tests.rs
new
+113
@@ -0,0 +1,113 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+use expect_test::expect;
5
+use serde_json::json;
6
+use xgrammar_structural_tag::builders::StructuralTagOptions;
7
+use xgrammar_structural_tag::{
8
+ FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
9
+};
10
+
11
+use super::DeepSeekV41ToolParser;
12
+use crate::tool::test_utils::collect_stream;
13
+use crate::tool::{Tool, ToolParser};
14
+
15
+fn tools() -> Vec<Tool> {
16
+ vec![Tool {
17
+ name: "lookup".into(),
18
+ description: None,
19
+ parameters: json!({
20
+ "type": "object",
21
+ "properties": {
22
+ "query": {"type": "string"},
23
+ "limit": {"type": "integer"},
24
+ "enabled": {"type": "boolean"},
25
+ "data": {"type": "object"},
26
+ "items": {"type": "array"},
27
+ "label": {"type": "string"}
28
+ }
29
+ }),
30
+ strict: None,
31
+ }]
32
+}
33
+
34
+#[test]
35
+fn spaced_dsml_streaming_preserves_shared_schema_coercion() {
36
+ let wire = concat!(
37
+ "summary\n\n<|DSML| calls>\n",
38
+ "<|DSML| invoke name=\"lookup\">\n",
39
+ "<|DSML| parameter name=\"query\" string=\"true\"> value\n</|DSML| parameter>\n",
40
+ "<|DSML| parameter name=\"limit\" string=\"false\">2</|DSML| parameter>\n",
41
+ "<|DSML| parameter name=\"enabled\" string=\"false\">true</|DSML| parameter>\n",
42
+ "<|DSML| parameter name=\"data\" string=\"false\">{\"x\":1}</|DSML| parameter>\n",
43
+ "<|DSML| parameter name=\"items\" string=\"false\">[1,2]</|DSML| parameter>\n",
44
+ "<|DSML| parameter name=\"label\" string=\"false\">42</|DSML| parameter>\n",
45
+ "<|DSML| parameter name=\"unknown\" string=\"false\">42</|DSML| parameter>\n",
46
+ "<|DSML| parameter name=\"nullish\" string=\"false\">null</|DSML| parameter>\n",
47
+ "</|DSML| invoke>\n",
48
+ "<|DSML| invoke name=\"lookup\">\n",
49
+ "<|DSML| parameter name=\"query\" string=\"true\">second</|DSML| parameter>\n",
50
+ "</|DSML| invoke>\n</|DSML| calls>",
51
+ );
52
+ let expected = [
53
+ json!({"query":" value\n","limit":2,"enabled":true,"data":{"x":1},"items":[1,2],"label":"42","unknown":"42","nullish":null}),
54
+ json!({"query":"second"}),
55
+ ];
56
+ for split in wire.char_indices().map(|(i, _)| i).chain(std::iter::once(wire.len())) {
57
+ let mut parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
58
+ let output = collect_stream(parser.as_mut(), &[&wire[..split], &wire[split..]]);
59
+ // Protocol framing follows the current shared DSML behavior.
60
+ assert_eq!(output.normal_text(), "summary", "split {split}");
61
+ assert_eq!(output.calls().len(), 2, "split {split}");
62
+ for (index, call) in output.calls().iter().enumerate() {
63
+ assert_eq!(call.tool_index, index, "split {split}");
64
+ assert_eq!(call.name.as_deref(), Some("lookup"), "split {split}");
65
+ assert_eq!(
66
+ serde_json::from_str::<serde_json::Value>(&call.arguments).unwrap(),
67
+ expected[index],
68
+ "split {split}"
69
+ );
70
+ }
71
+ }
72
+}
73
+
74
+#[test]
75
+fn v4_tags_remain_text_in_v41_dialect() {
76
+ let mut parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
77
+ let output = collect_stream(parser.as_mut(), &["before<|DSML|tool_calls>after"]);
78
+ expect!["before<|DSML|tool_calls>after"].assert_eq(&output.normal_text());
79
+ assert!(output.calls().is_empty());
80
+}
81
+
82
+#[test]
83
+fn structural_tag_ignores_parameter_schemas_and_strict() {
84
+ let parser = DeepSeekV41ToolParser::create(&tools()).unwrap();
85
+ let builder = parser.structural_tag_builder().unwrap();
86
+ let options = StructuralTagOptions::default().with_reasoning(false);
87
+ for choice in [
88
+ ToolChoice::auto(),
89
+ ToolChoice::required(),
90
+ ToolChoice::function("lookup"),
91
+ ] {
92
+ let build = |function| {
93
+ build_structural_tag(
94
+ builder,
95
+ &[ToolParam::Function(FunctionToolParam::new(function))],
96
+ choice.clone(),
97
+ options,
98
+ )
99
+ .unwrap()
100
+ };
101
+ let expected = build(FunctionDefinition::new("lookup"));
102
+ for strict in [None, Some(false), Some(true)] {
103
+ for parameters in [
104
+ json!(false),
105
+ json!({"type":"object", "properties":{"query":{"const":"fixed"}}, "required":["query"]}),
106
+ ] {
107
+ let mut function = FunctionDefinition::new("lookup").with_parameters(parameters);
108
+ function.strict = strict;
109
+ assert_eq!(build(function), expected);
110
+ }
111
+ }
112
+ }
113
+}
rust/src/parser/src/tool/deepseek_dsml/mod.rs
+40
-16
@@ -14,14 +14,11 @@ use crate::tool::Tool;
14
15
mod deepseek_v32;
16
mod deepseek_v4;
17
+mod deepseek_v41;
18
19
pub use deepseek_v4::DeepSeekV4ToolParser;
20
pub use deepseek_v32::DeepSeekV32ToolParser;
20
-
21
-const INVOKE_START: &str = "<|DSML|invoke";
22
-const INVOKE_END: &str = "</|DSML|invoke>";
23
-const PARAMETER_START: &str = "<|DSML|parameter";
24
-const PARAMETER_END: &str = "</|DSML|parameter>";
21
+pub use deepseek_v41::DeepSeekV41ToolParser;
22
23
type DsmlInput<'i> = Partial<&'i str>;
24
@@ -30,6 +27,10 @@ struct DsmlTokens {
27
tool_calls_start: &'static str,
28
framed_tool_calls_start: &'static str,
29
tool_calls_end: &'static str,
30
+ invoke_start: &'static str,
31
+ invoke_end: &'static str,
32
+ parameter_start: &'static str,
33
+ parameter_end: &'static str,
34
}
35
36
impl DsmlTokens {
@@ -37,11 +38,25 @@ impl DsmlTokens {
38
tool_calls_start: "<|DSML|function_calls>",
39
framed_tool_calls_start: "\n\n<|DSML|function_calls>",
40
tool_calls_end: "</|DSML|function_calls>",
41
+ invoke_start: "<|DSML|invoke",
42
+ invoke_end: "</|DSML|invoke>",
43
+ parameter_start: "<|DSML|parameter",
44
+ parameter_end: "</|DSML|parameter>",
45
};
46
const V4: Self = Self {
47
tool_calls_start: "<|DSML|tool_calls>",
48
framed_tool_calls_start: "\n\n<|DSML|tool_calls>",
49
tool_calls_end: "</|DSML|tool_calls>",
50
+ ..Self::V32
51
+ };
52
+ const V41: Self = Self {
53
+ tool_calls_start: "<|DSML| calls>",
54
+ framed_tool_calls_start: "\n\n<|DSML| calls>",
55
+ tool_calls_end: "</|DSML| calls>",
56
+ invoke_start: "<|DSML| invoke",
57
+ invoke_end: "</|DSML| invoke>",
58
+ parameter_start: "<|DSML| parameter",
59
+ parameter_end: "</|DSML| parameter>",
60
};
61
}
62
@@ -205,7 +220,7 @@ fn parse_tool_block_event(
220
) -> ModalResult<DsmlEvent> {
221
ws0.void().parse_next(input)?;
222
alt((
208
- |input: &mut DsmlInput<'_>| invoke_event(input, invoke_end_scan),
223
+ |input: &mut DsmlInput<'_>| invoke_event(input, tokens, invoke_end_scan),
224
|input: &mut DsmlInput<'_>| tool_calls_end_event(input, tokens),
225
))
226
.parse_next(input)
@@ -243,19 +258,20 @@ fn safe_text_event(input: &mut DsmlInput<'_>, tokens: DsmlTokens) -> ModalResult
258
/// Parse a DSML invoke block.
259
fn invoke_event(
260
input: &mut DsmlInput<'_>,
261
+ tokens: DsmlTokens,
262
invoke_end_scan: &mut MarkerScanState,
263
) -> ModalResult<DsmlEvent> {
264
let (name, body) = seq!(
249
- _: literal(INVOKE_START),
265
+ _: literal(tokens.invoke_start),
266
_: ws1,
267
dsml_name_attr,
268
_: ws0,
269
_: ">",
254
- take_until_marker(INVOKE_END, invoke_end_scan),
255
- _: literal(INVOKE_END),
270
+ take_until_marker(tokens.invoke_end, invoke_end_scan),
271
+ _: literal(tokens.invoke_end),
272
)
273
.parse_next(input)?;
258
- let raw_params = parse_invoke_params(body)?;
274
+ let raw_params = parse_invoke_params(body, tokens)?;
275
Ok(DsmlEvent::Invoke {
276
name: name.to_string(),
277
raw_params,
@@ -263,23 +279,31 @@ fn invoke_event(
279
}
280
281
/// Parse a DSML invoke body.
266
-fn parse_invoke_params(invoke_body: &str) -> ModalResult<Vec<DsmlParameter>> {
282
+fn parse_invoke_params(invoke_body: &str, tokens: DsmlTokens) -> ModalResult<Vec<DsmlParameter>> {
283
let mut input = invoke_body;
268
- delimited(ws0, repeat(0.., terminated(parse_parameter, ws0)), eof).parse_next(&mut input)
284
+ delimited(
285
+ ws0,
286
+ repeat(
287
+ 0..,
288
+ terminated(|input: &mut &str| parse_parameter(input, tokens), ws0),
289
+ ),
290
+ eof,
291
+ )
292
+ .parse_next(&mut input)
293
}
294
295
/// Parse a DSML parameter block.
272
-fn parse_parameter(input: &mut &str) -> ModalResult<DsmlParameter> {
296
+fn parse_parameter(input: &mut &str, tokens: DsmlTokens) -> ModalResult<DsmlParameter> {
297
seq! {DsmlParameter {
274
- _: literal(PARAMETER_START),
298
+ _: literal(tokens.parameter_start),
299
_: ws1,
300
name: name_attr.map(|name: &str| name.to_string()),
301
_: ws1,
302
is_string: string_attr.map(|value| value == "true"),
303
_: ws0,
304
_: ">",
281
- value: take_until(0.., PARAMETER_END).map(str::to_string),
282
- _: literal(PARAMETER_END),
305
+ value: take_until(0.., tokens.parameter_end).map(str::to_string),
306
+ _: literal(tokens.parameter_end),
307
}}
308
.parse_next(input)
309
}
rust/src/parser/src/tool/mod.rs
+1
-1
@@ -20,7 +20,7 @@ mod seed_oss;
20
pub mod test_utils;
21
use std::collections::{BTreeMap, btree_map};
22
23
-pub use deepseek_dsml::{DeepSeekV4ToolParser, DeepSeekV32ToolParser};
23
+pub use deepseek_dsml::{DeepSeekV4ToolParser, DeepSeekV32ToolParser, DeepSeekV41ToolParser};
24
pub use deepseek_json::{DeepSeekV3ToolParser, DeepSeekV31ToolParser};
25
pub use error::{Result, ToolParserError};
26
pub use glm_xml::{Glm45MoeToolParser, Glm47MoeToolParser};
rust/src/server/src/error.rs
+13
@@ -167,6 +167,19 @@ mod tests {
167
assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
168
}
169
170
+ #[test]
171
+ fn invalid_reasoning_effort_maps_to_invalid_request() {
172
+ let error = vllm_chat::Error::InvalidReasoningEffort(
173
+ "DeepSeek V4.1 reasoning_effort must be within [1, 100]".to_string(),
174
+ );
175
+ let api_error = chat_submit_error("failed to submit chat request", error);
176
+ assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
177
+ assert_eq!(
178
+ api_error.to_error_response().error.error_type,
179
+ "invalid_request_error"
180
+ );
181
+ }
182
+
183
#[test]
184
fn llm_wrapped_empty_prompt_maps_to_invalid_request() {
185
let error = vllm_text::Error::Llm(vllm_llm::Error::EmptyPromptTokenIds {
tests/parser/engine/test_deepseek_v41.py
new
+146
@@ -0,0 +1,146 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import json
5
+
6
+import pytest
7
+
8
+from tests.parser.engine.replay_harness import (
9
+ DUMMY_TOOLS,
10
+ MockTokenizer,
11
+ _test_request,
12
+ collect_output,
13
+ replay_streaming,
14
+)
15
+from vllm.parser.deepseek_v41 import deepseek_v41_config
16
+from vllm.parser.parser_manager import ParserManager
17
+
18
+CALLS = (
19
+ '\n\n<|DSML| calls>\n<|DSML| invoke name="get_weather">\n'
20
+ '<|DSML| parameter name="city" string="true">杭州</|DSML| parameter>\n'
21
+ '<|DSML| parameter name="count" string="false">42</|DSML| parameter>\n'
22
+ '</|DSML| invoke>\n<|DSML| invoke name="add">\n'
23
+ '<|DSML| parameter name="x" string="false">1.5</|DSML| parameter>\n'
24
+ '<|DSML| parameter name="y" string="false">2.25</|DSML| parameter>\n'
25
+ "</|DSML| invoke>\n</|DSML| calls>"
26
+)
27
+
28
+
29
+def tokenizer_for(text, special_calls):
30
+ vocab = {"<think>": 50, "</think>": 51}
31
+ if special_calls:
32
+ vocab |= {"<|DSML| calls>": 52, "</|DSML| calls>": 53}
33
+ tokens: list[tuple[int, str]] = []
34
+ while text:
35
+ special = next((marker for marker in vocab if text.startswith(marker)), None)
36
+ piece = special or text[0]
37
+ tokens.append((vocab[piece] if special else 100 + len(tokens), piece))
38
+ text = text[len(piece) :]
39
+ return MockTokenizer(vocab, tokens), tokens
40
+
41
+
42
+def parser_for(tokenizer, controls):
43
+ cls = ParserManager.get_parser(
44
+ tool_parser_name="deepseek_v41",
45
+ reasoning_parser_name="deepseek_v41",
46
+ enable_auto_tools=True,
47
+ )
48
+ return cls(tokenizer, chat_template_kwargs=controls)
49
+
50
+
51
+@pytest.mark.parametrize("thinking", [False, True])
52
+@pytest.mark.parametrize("special_calls", [False, True])
53
+@pytest.mark.parametrize("chunk_size", [1, 7, 10000])
54
+def test_registered_adapters_parse_parallel_calls_across_chunks(
55
+ thinking,
56
+ special_calls,
57
+ chunk_size,
58
+):
59
+ text = ("Plan.</think>" if thinking else "") + "Checking." + CALLS
60
+ tokenizer, tokens = tokenizer_for(text, special_calls)
61
+ parser = parser_for(tokenizer, {"thinking": thinking})
62
+ output = collect_output(
63
+ replay_streaming(
64
+ parser,
65
+ tokens,
66
+ chunk_size=chunk_size,
67
+ finished_on_last=True,
68
+ tools=DUMMY_TOOLS,
69
+ prompt_token_ids=[50 if thinking else 51],
70
+ )
71
+ )
72
+ assert output.reasoning == ("Plan." if thinking else "")
73
+ assert output.content.strip() == "Checking."
74
+ assert [call["name"] for call in output.tool_calls] == ["get_weather", "add"]
75
+ assert [json.loads(call["arguments"]) for call in output.tool_calls] == [
76
+ {"city": "杭州", "count": 42},
77
+ {"x": 1.5, "y": 2.25},
78
+ ]
79
+
80
+
81
+@pytest.mark.parametrize("thinking", [False, True])
82
+def test_registered_adapters_parse_complete_output(thinking):
83
+ text = ("Plan.</think>" if thinking else "") + CALLS
84
+ tokenizer, tokens = tokenizer_for(text, False)
85
+ parser = parser_for(tokenizer, {"thinking": thinking})
86
+ reasoning, content, calls = parser.parse(
87
+ text,
88
+ _test_request(tools=DUMMY_TOOLS),
89
+ enable_auto_tools=True,
90
+ model_output_token_ids=[tid for tid, _ in tokens],
91
+ )
92
+ assert (reasoning or "") == ("Plan." if thinking else "")
93
+ assert not (content or "").strip()
94
+ assert [call.name for call in calls] == ["get_weather", "add"]
95
+ assert json.loads(calls[0].arguments) == {"city": "杭州", "count": 42}
96
+
97
+
98
+@pytest.mark.parametrize(
99
+ ("controls", "text", "expected_reasoning", "reasoning_tokens"),
100
+ [
101
+ ({"thinking": False}, "12", "", 0),
102
+ ({"enable_thinking": False}, "12", "", 0),
103
+ ({"thinking": True, "reasoning_effort": "none"}, "12", "", 0),
104
+ ({}, "</think>12", "", 0),
105
+ ({}, "Plan.</think>12", "Plan.", 5),
106
+ ],
107
+)
108
+def test_reasoning_adapter_controls_and_usage(
109
+ controls,
110
+ text,
111
+ expected_reasoning,
112
+ reasoning_tokens,
113
+):
114
+ tokenizer, tokens = tokenizer_for(text, False)
115
+ parser = parser_for(tokenizer, controls)
116
+ output = collect_output(
117
+ replay_streaming(
118
+ parser,
119
+ tokens,
120
+ chunk_size=1,
121
+ finished_on_last=True,
122
+ )
123
+ )
124
+ assert output.reasoning == expected_reasoning
125
+ assert output.content == "12"
126
+ assert (
127
+ parser.reasoning_parser.count_reasoning_tokens([tid for tid, _ in tokens])
128
+ == reasoning_tokens
129
+ )
130
+ assert parser.is_reasoning_end([50, 100, 51])
131
+ assert not parser.is_reasoning_end([51, 100, 50])
132
+
133
+
134
+def test_python_argument_conversion_and_partial_values():
135
+ converter = deepseek_v41_config().arg_converter
136
+ raw = (
137
+ '<|DSML| parameter name="object" string="false">'
138
+ '{"a": [true, null]}</|DSML| parameter>'
139
+ '<|DSML| parameter name="bad" string="false">[broken</|DSML| parameter>'
140
+ '<|DSML| parameter name="text" string="true"> a<b'
141
+ )
142
+ assert json.loads(converter(raw, True)) == {
143
+ "object": {"a": [True, None]},
144
+ "bad": "[broken",
145
+ "text": " a<b",
146
+ }
tests/tokenizers_/fixtures/deepseek_v41/test_output_1.txt
new
+38
@@ -0,0 +1,38 @@
1
+<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)
2
+
3
+You are a helpful assistant.
4
+
5
+## Tools
6
+
7
+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:
8
+
9
+<|DSML| calls>
10
+<|DSML| invoke name="$TOOL_NAME">
11
+<|DSML| parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML| parameter>
12
+...
13
+</|DSML| invoke>
14
+<|DSML| invoke name="$TOOL_NAME2">
15
+...
16
+</|DSML| invoke>
17
+</|DSML| calls>
18
+
19
+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"`.
20
+
21
+If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
22
+
23
+Otherwise, output directly after </think> with tool calls or final response.
24
+
25
+### Available Tool Schemas
26
+
27
+{"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"]}}
28
+{"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"]}}
29
+
30
+You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
31
+<|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>
32
+
33
+<|DSML| calls>
34
+<|DSML| invoke name="get_weather">
35
+<|DSML| parameter name="location" string="true">Beijing</|DSML| parameter>
36
+<|DSML| parameter name="unit" string="true">celsius</|DSML| parameter>
37
+</|DSML| invoke>
38
+</|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|>
\ No newline at end of file
tests/tokenizers_/fixtures/deepseek_v41/test_output_2.txt
new
+3
@@ -0,0 +1,3 @@
1
+<|begin▁of▁sentence|><|System|>Reasoning Effort: 50 (range 1-100, the higher the value, the more thorough the reasoning)
2
+
3
+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|>
\ No newline at end of file
tests/tokenizers_/test_deepseek_v41.py
new
+169
@@ -0,0 +1,169 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import copy
5
+import json
6
+from pathlib import Path
7
+
8
+import pytest
9
+
10
+from tests.tokenizers_.test_deepseek_v4 import FakeHfTokenizer
11
+from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
12
+from vllm.tokenizers.deepseek_v41 import get_deepseek_v41_tokenizer
13
+
14
+FIXTURES = Path(__file__).parent / "fixtures"
15
+
16
+
17
+def render(messages, **kwargs):
18
+ return get_deepseek_v41_tokenizer(FakeHfTokenizer()).apply_chat_template(
19
+ messages, tokenize=False, **kwargs
20
+ )
21
+
22
+
23
+@pytest.mark.parametrize("case_id", [1, 2])
24
+def test_reference_encoder_fixtures(case_id):
25
+ # Expected prompts come from the DeepSeek V4.1 reference encoder.
26
+ data = json.loads(
27
+ (FIXTURES / "deepseek_v4" / f"test_input_{case_id}.json").read_text()
28
+ )
29
+ messages = data["messages"] if isinstance(data, dict) else data
30
+ tools = data.get("tools") if isinstance(data, dict) else None
31
+ expected = (FIXTURES / "deepseek_v41" / f"test_output_{case_id}.txt").read_text()
32
+ assert render(messages, tools=tools) == expected
33
+
34
+
35
+@pytest.mark.parametrize(
36
+ ("effort", "budget"),
37
+ [
38
+ (None, 50),
39
+ ("low", 25),
40
+ ("high", 50),
41
+ ("xhigh", 75),
42
+ ("max", 100),
43
+ (1, 1),
44
+ (42, 42),
45
+ (100, 100),
46
+ ],
47
+)
48
+def test_numeric_reasoning_effort(effort, budget):
49
+ assert render(
50
+ [{"role": "user", "content": "question"}], reasoning_effort=effort
51
+ ) == (
52
+ "<|begin▁of▁sentence|><|System|>"
53
+ f"Reasoning Effort: {budget} (range 1-100, the higher the value, "
54
+ "the more thorough the reasoning)\n\n"
55
+ "<|User|>question<|Assistant|><think>"
56
+ )
57
+
58
+
59
+@pytest.mark.parametrize("effort", ["medium", "minimal", -1, 0, 101, True, 1.5, []])
60
+def test_invalid_effort_is_a_request_error(effort):
61
+ with pytest.raises(ValueError, match="reasoning_effort"):
62
+ render([{"role": "user", "content": "question"}], reasoning_effort=effort)
63
+
64
+
65
+@pytest.mark.parametrize(
66
+ "controls",
67
+ [
68
+ {"thinking": False},
69
+ {"enable_thinking": False},
70
+ {"thinking": True, "reasoning_effort": "none"},
71
+ {"enable_thinking": True, "reasoning_effort": "none"},
72
+ ],
73
+)
74
+def test_chat_mode_has_closed_thinking_prefix(controls):
75
+ assert render([{"role": "user", "content": "question"}], **controls) == (
76
+ "<|begin▁of▁sentence|><|User|>question<|Assistant|></think>"
77
+ )
78
+
79
+
80
+def test_raw_text_parts_preserve_reference_separator():
81
+ messages = [
82
+ {
83
+ "role": "user",
84
+ "content": [
85
+ {"type": "text", "text": "first"},
86
+ {"type": "text", "text": "second"},
87
+ ],
88
+ }
89
+ ]
90
+ original = copy.deepcopy(messages)
91
+ assert (
92
+ render(
93
+ messages,
94
+ conversation=[{"role": "user", "content": "first\nsecond"}],
95
+ thinking=False,
96
+ )
97
+ == "<|begin▁of▁sentence|><|User|>first\n\nsecond<|Assistant|></think>"
98
+ )
99
+ assert messages == original
100
+
101
+
102
+def test_mid_system_gets_its_own_marker_and_generation_header():
103
+ assert render(
104
+ [
105
+ {"role": "user", "content": "question"},
106
+ {"role": "system", "content": "answer briefly"},
107
+ ],
108
+ thinking=False,
109
+ ) == (
110
+ "<|begin▁of▁sentence|><|User|>question"
111
+ "<|System|>answer briefly<|Assistant|></think>"
112
+ )
113
+
114
+
115
+def test_top_level_effort_overrides_template_effort():
116
+ request = ChatCompletionRequest(
117
+ model="deepseek-ai/DeepSeek-V4.1-Flash",
118
+ messages=[{"role": "user", "content": "question"}],
119
+ reasoning_effort="low",
120
+ chat_template_kwargs={"reasoning_effort": 100},
121
+ )
122
+ kwargs = request.build_chat_params(None, "auto").get_apply_chat_template_kwargs()
123
+ assert "Reasoning Effort: 25 " in render(request.messages, **kwargs)
124
+
125
+
126
+def test_encode_uses_one_bos_and_forwards_truncation():
127
+ tokenizer = get_deepseek_v41_tokenizer(FakeHfTokenizer())
128
+ tokenizer.apply_chat_template(
129
+ [{"role": "user", "content": "question"}],
130
+ thinking=False,
131
+ truncation=True,
132
+ max_length=16,
133
+ )
134
+ text, add_special_tokens, kwargs = tokenizer.last_encode
135
+ assert text.startswith("<|begin▁of▁sentence|>")
136
+ assert add_special_tokens is False
137
+ assert kwargs == {"truncation": True, "max_length": 16}
138
+
139
+
140
+@pytest.mark.parametrize("image_type", ["image_url", "input_image", "image_pil"])
141
+def test_images_preserve_content_order_and_reference_separator(image_type):
142
+ from PIL import Image
143
+
144
+ def image_part(color):
145
+ if image_type == "image_pil":
146
+ return {"type": image_type, "image_pil": Image.new("RGB", (2, 2), color)}
147
+ url = f"https://example.com/{color}.png"
148
+ return {
149
+ "type": image_type,
150
+ "image_url": {"url": url} if image_type == "image_url" else url,
151
+ }
152
+
153
+ parts = [
154
+ {"type": "text", "text": "first"},
155
+ image_part("red"),
156
+ {"type": "text", "text": "second"},
157
+ image_part("blue"),
158
+ {"type": "text", "text": "compare"},
159
+ ]
160
+ assert render([{"role": "user", "content": parts}], thinking=False) == (
161
+ "<|begin▁of▁sentence|><|User|>first\n\n<|deepseek_image|>\n\n"
162
+ "second\n\n<|deepseek_image|>\n\ncompare<|Assistant|></think>"
163
+ )
164
+ assert parts[1]["type"] == parts[3]["type"] == image_type
165
+
166
+
167
+def test_unsupported_media_is_a_request_error():
168
+ with pytest.raises(ValueError, match="text and image content only"):
169
+ render([{"role": "user", "content": [{"type": "input_audio"}]}])
tests/tool_parsers/test_structural_tag_registry.py
+61
@@ -80,6 +80,67 @@ def test_supported_structural_tag_models_include_vllm_builtins():
80
assert "hermes" in VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
81
82
83
+@pytest.mark.parametrize("choice", ["auto", "required", "get_weather"])
84
+def test_deepseek_v41_format_constraints_ignore_parameter_schema(
85
+ choice,
86
+ sample_tools_strict,
87
+):
88
+ tool_choice = (
89
+ ChatCompletionNamedToolChoiceParam(
90
+ function=ChatCompletionNamedFunction(name=choice)
91
+ )
92
+ if choice == "get_weather"
93
+ else choice
94
+ )
95
+ tag = get_model_structural_tag(
96
+ "deepseek_v41", sample_tools_strict, tool_choice, reasoning=False
97
+ )
98
+ grammar = Grammar.from_structural_tag(tag)
99
+ begin = '\n\n<|DSML| calls>\n<|DSML| invoke name="get_weather">\n'
100
+ end = "</|DSML| invoke>\n</|DSML| calls>"
101
+ parameter = (
102
+ '<|DSML| parameter name="undeclared" string="false">'
103
+ '{"nested": [true, null, 1.5]}</|DSML| parameter>\n'
104
+ )
105
+ # Required city is omitted; extra and repeated fields remain unconstrained.
106
+ assert _is_grammar_accept_string(grammar, begin + end)
107
+ assert _is_grammar_accept_string(grammar, begin + parameter * 2 + end)
108
+ assert not _is_grammar_accept_string(
109
+ grammar,
110
+ begin + parameter.replace('{"nested": [true, null, 1.5]}', "invalid") + end,
111
+ )
112
+ assert not _is_grammar_accept_string(
113
+ grammar, (begin + end).replace('name="get_weather"', 'name="unknown"')
114
+ )
115
+ assert not _is_grammar_accept_string(
116
+ grammar, begin + parameter.replace("| parameter", "|parameter") + end
117
+ )
118
+ assert _is_grammar_accept_string(grammar, "Hello") == (choice == "auto")
119
+
120
+
121
+def test_deepseek_v41_named_choice_emits_one_call(sample_tools):
122
+ tag = get_model_structural_tag(
123
+ "deepseek_v41",
124
+ sample_tools,
125
+ ChatCompletionNamedToolChoiceParam(
126
+ function=ChatCompletionNamedFunction(name="get_weather")
127
+ ),
128
+ reasoning=False,
129
+ )
130
+ grammar = Grammar.from_structural_tag(tag)
131
+ call = '<|DSML| invoke name="get_weather">\n</|DSML| invoke>\n'
132
+ assert _is_grammar_accept_string(
133
+ grammar, "\n\n<|DSML| calls>\n" + call + "</|DSML| calls>"
134
+ )
135
+ assert not _is_grammar_accept_string(
136
+ grammar, "\n\n<|DSML| calls>\n" + call * 2 + "</|DSML| calls>"
137
+ )
138
+ assert (
139
+ get_model_structural_tag("deepseek_v41", sample_tools, "auto", reasoning=False)
140
+ is None
141
+ )
142
+
143
+
144
@pytest.mark.parametrize("model", sorted(XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS))
145
def test_get_model_structural_tag_supports_all_xgrammar_builtins(
146
model: str,
vllm/config/model.py
+3
@@ -152,6 +152,7 @@ class ModelConfig:
152
- "mistral" will always use the tokenizer from `mistral_common`.
153
- "deepseek_v32" will always use the tokenizer from `deepseek_v32`.
154
- "deepseek_v4" will always use the tokenizer from `deepseek_v4`.
155
+ - "deepseek_v41" will use the DeepSeek V4.1 prompt encoder.
156
- "kimi_k3" will always use the "hf" tokenizer but render chat prompts
157
with Kimi K3's Python XTML encoding instead of a Jinja template.
158
- "cohere" uses the standard HF tokenizer but renders the chat template
@@ -705,6 +706,8 @@ class ModelConfig:
706
"DeepseekV4ForConditionalGeneration",
707
):
708
self.tokenizer_mode = "deepseek_v4"
709
+ elif arch == "DeepseekV41ForCausalLM":
710
+ self.tokenizer_mode = "deepseek_v41"
711
elif arch in ("InklingForCausalLM", "InklingForConditionalGeneration"):
712
self.tokenizer_mode = "inkling"
713
vllm/models/deepseek_v4_1/common/mm_preprocess.py
new
+463
@@ -0,0 +1,463 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Multimodal preprocessing for the DeepSeek-V4.1 vision variant.
4
+
5
+The image transform and image-span construction are ported from the
6
+official repository's ``image_processor.py`` so that token counts bit-match
7
+the reference. Each ``<|deepseek_image|>`` placeholder in the prompt expands
8
+to ``[IMAGE_START] + ([IMAGE] * n_llm_w + [IMAGE_NEW_LINE]) * n_llm_h +
9
+[IMAGE_END]``; every span position carries ``image_token_id`` (129264) in
10
+``input_ids`` and the roles ride along in a per-image ``types`` tensor
11
+(the reference's out-of-band ``token_types``). IMAGE slots receive aligner
12
+rows in reading order; the delimiters take the learned ``image_start`` /
13
+``image_newline`` / ``image_end`` vectors.
14
+
15
+One vLLM-side deviation from the reference token stream: a leading
16
+compressor-alignment pad (``COMPRESS_PAD_TO - 1 - start % COMPRESS_PAD_TO``
17
+positions, so the span always starts at the same compressor phase) is
18
+prepended when the block is spliced into the final prompt. Pad positions
19
+borrow the reserved in-vocab token ``<|place_holder_mm_span_0436|>`` so
20
+they stay distinguishable from real span positions; they embed as the
21
+plain image token (v4.1 has no ``image_pad`` vector) and are routed and
22
+engram-deadened like image tokens.
23
+"""
24
+
25
+import math
26
+from collections.abc import Mapping, Sequence
27
+from typing import Any, cast
28
+
29
+import numpy as np
30
+import torch
31
+from PIL import Image, ImageOps
32
+from transformers import BatchFeature
33
+from typing_extensions import assert_never
34
+
35
+from vllm.config.multimodal import BaseDummyOptions, ImageDummyOptions
36
+from vllm.inputs import MultiModalDataDict
37
+from vllm.multimodal.inputs import MultiModalFieldConfig, MultiModalKwargsItems
38
+from vllm.multimodal.parse import ImageSize, MultiModalDataItems
39
+from vllm.multimodal.processing import (
40
+ BaseDummyInputsBuilder,
41
+ BaseMultiModalProcessor,
42
+ BaseProcessingInfo,
43
+ PromptReplacement,
44
+ PromptUpdate,
45
+ PromptUpdateDetails,
46
+)
47
+from vllm.multimodal.processing.processor import (
48
+ MultiModalPromptUpdates,
49
+ MultiModalPromptUpdatesApplyResult,
50
+ PlaceholderFeaturesInfo,
51
+ UpdateMode,
52
+ _plan_prompt_updates,
53
+)
54
+from vllm.transformers_utils.configs.deepseek_v41 import DeepseekV41Config
55
+
56
+IMAGE_START, IMAGE, IMAGE_NEW_LINE, IMAGE_END = range(4)
57
+# Image spans are padded so they always start at the same compressor group
58
+# phase; v4.1 uses ratio-2 compressors (v4.0 used 4).
59
+COMPRESS_PAD_TO = 2
60
+
61
+IMAGE_PLACEHOLDER = "<|deepseek_image|>"
62
+
63
+# The checkpoint tokenizer's id for IMAGE_PLACEHOLDER (the config's
64
+# ``image_token_id``). Every image-span position carries this id, exactly
65
+# like the reference; the MoE router detects image tokens as the range
66
+# [IMAGE_SENTINEL_BASE_ID, IMAGE_SENTINEL_BASE_ID + 5), which also covers
67
+# IMAGE_PAD_ID below.
68
+IMAGE_SENTINEL_BASE_ID = 129264
69
+# Leading compressor-alignment pads borrow this reserved in-vocab token
70
+# (``<|place_holder_mm_span_0436|>``) so they can be told apart from real
71
+# span positions when the per-position role is decided from token ids.
72
+IMAGE_PAD_ID = 129265
73
+IMAGE_PAD_TOKEN_NAME = "<|place_holder_mm_span_0436|>"
74
+
75
+
76
+def image_sentinel_mask(token_ids: torch.Tensor) -> torch.Tensor:
77
+ """Boolean mask for image-span positions (span tokens and align pads)."""
78
+ return (token_ids == IMAGE_SENTINEL_BASE_ID) | (token_ids == IMAGE_PAD_ID)
79
+
80
+
81
+def validate_image_sentinel_ids(tokenizer) -> None:
82
+ """Check the image/pad token ids against the tokenizer."""
83
+ image_id = tokenizer.convert_tokens_to_ids(IMAGE_PLACEHOLDER)
84
+ if image_id != IMAGE_SENTINEL_BASE_ID:
85
+ raise ValueError(
86
+ f"Image placeholder {IMAGE_PLACEHOLDER!r} has id {image_id}, "
87
+ f"expected {IMAGE_SENTINEL_BASE_ID} (the config's "
88
+ "image_token_id); the DeepSeek-V4.1 vision path keys image "
89
+ "routing and engram masking off this id."
90
+ )
91
+ pad_id = tokenizer.convert_tokens_to_ids(IMAGE_PAD_TOKEN_NAME)
92
+ if pad_id != IMAGE_PAD_ID:
93
+ raise ValueError(
94
+ f"Image pad token {IMAGE_PAD_TOKEN_NAME!r} has id {pad_id}, "
95
+ f"expected {IMAGE_PAD_ID}; the DeepSeek-V4.1 vision path "
96
+ "borrows this reserved id for compressor-alignment pads."
97
+ )
98
+
99
+
100
+def llm_grid(best_height, best_width, patch_size, downsample_ratio):
101
+ """Token grid the aligner produces from a patch grid of this pixel size."""
102
+ return (
103
+ math.ceil((best_height // patch_size) / downsample_ratio),
104
+ math.ceil((best_width // patch_size) / downsample_ratio),
105
+ )
106
+
107
+
108
+def num_image_tokens(n_llm_h: int, n_llm_w: int) -> int:
109
+ return n_llm_h * (n_llm_w + 1) + 2
110
+
111
+
112
+def solve_resize_ratio(height, width, patch_size, downsample_ratio, max_n_token):
113
+ """Largest aspect-preserving pixel size whose token grid still fits in
114
+ max_n_token. Returns (best_height, best_width)."""
115
+ r = height / width
116
+ max_w_float = math.sqrt((max_n_token - 2) / r + 0.25) - 0.5
117
+ max_h_float = max_w_float * r
118
+ cell = patch_size * downsample_ratio
119
+ if max_w_float < 1.0: # very tall: collapse to a single column
120
+ return (max_n_token - 2) // 2 * cell, cell
121
+ if max_h_float < 1.0: # very wide: collapse to a single row
122
+ return cell, (max_n_token - 3) * cell
123
+ beta = min(
124
+ math.floor(max_w_float) * cell / width,
125
+ math.floor(max_h_float) * cell / height,
126
+ )
127
+ return (
128
+ math.floor(height * beta / patch_size) * patch_size,
129
+ math.floor(width * beta / patch_size) * patch_size,
130
+ )
131
+
132
+
133
+def safe_resize(
134
+ height, width, best_height, best_width, patch_size, downsample_ratio, max_n_token
135
+):
136
+ """Shrink the pixel size until the image costs at most max_n_token LLM
137
+ tokens (minus the reservation for the compressor-alignment pad)."""
138
+ max_n_token -= COMPRESS_PAD_TO - 1
139
+ n_llm_h, n_llm_w = llm_grid(best_height, best_width, patch_size, downsample_ratio)
140
+ if num_image_tokens(n_llm_h, n_llm_w) > max_n_token:
141
+ best_height, best_width = solve_resize_ratio(
142
+ height, width, patch_size, downsample_ratio, max_n_token
143
+ )
144
+ n_llm_h, n_llm_w = llm_grid(
145
+ best_height, best_width, patch_size, downsample_ratio
146
+ )
147
+ assert num_image_tokens(n_llm_h, n_llm_w) <= max_n_token
148
+ return n_llm_h, n_llm_w, best_height, best_width
149
+
150
+
151
+def load_image(
152
+ image: Image.Image,
153
+ *,
154
+ patch_size: int,
155
+ downsample_ratio: int,
156
+ max_n_token: int,
157
+ min_pixels: int,
158
+ max_wh_ratio: float | None,
159
+):
160
+ """Transform one PIL image into ViT patches.
161
+
162
+ Same math as the reference ``load_image``, except the image is already
163
+ decoded (vLLM supplies PIL images instead of a record dict).
164
+ """
165
+ p = patch_size
166
+ image = image.convert("RGB")
167
+ width, height = image.size
168
+ if max_wh_ratio is not None and width > height * max_wh_ratio:
169
+ width = height * max_wh_ratio
170
+ if 0 < width * height < min_pixels:
171
+ ratio = (min_pixels / (width * height)) ** 0.5
172
+ width = int(width * ratio)
173
+ height = int(height * ratio)
174
+ best_width = math.ceil(width / p) * p
175
+ best_height = math.ceil(height / p) * p
176
+ n_llm_h, n_llm_w, best_height, best_width = safe_resize(
177
+ height, width, best_height, best_width, p, downsample_ratio, max_n_token
178
+ )
179
+ n_vit_h, n_vit_w = best_height // p, best_width // p
180
+ if max_wh_ratio is not None and image.width >= max_wh_ratio * image.height:
181
+ image = image.resize((best_width, best_height))
182
+ else:
183
+ image = ImageOps.pad(image, (best_width, best_height), color=(127, 127, 127))
184
+ x = torch.from_numpy(np.asarray(image, dtype=np.float32)).permute(2, 0, 1) / 255
185
+ x = ((x - 0.5) / 0.5).to(torch.bfloat16)
186
+ patches = (
187
+ x.reshape(3, n_vit_h, p, n_vit_w, p)
188
+ .permute(1, 3, 0, 2, 4)
189
+ .reshape(n_vit_h * n_vit_w, 3, p, p)
190
+ )
191
+ return patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w
192
+
193
+
194
+def image_token_types(n_llm_h: int, n_llm_w: int) -> torch.Tensor:
195
+ """Reading-order span layout: one IMAGE_NEW_LINE per row."""
196
+ types = [IMAGE_START]
197
+ types += ([IMAGE] * n_llm_w + [IMAGE_NEW_LINE]) * n_llm_h
198
+ types.append(IMAGE_END)
199
+ return torch.tensor(types, dtype=torch.int64)
200
+
201
+
202
+class DeepseekV4VLImageProcessor:
203
+ """Per-image transform (the PIL-input equivalent of the reference
204
+ ``load_image``)."""
205
+
206
+ def __init__(self, config: DeepseekV41Config) -> None:
207
+ super().__init__()
208
+ self.patch_size = config.vision_patch_size
209
+ self.downsample_ratio = config.vision_downsample_ratio
210
+ self.max_n_token = config.vision_max_n_token
211
+ self.min_pixels = config.vision_min_pixels
212
+ self.max_wh_ratio = config.vision_max_wh_ratio
213
+
214
+ def __call__(self, image: Image.Image):
215
+ return load_image(
216
+ image,
217
+ patch_size=self.patch_size,
218
+ downsample_ratio=self.downsample_ratio,
219
+ max_n_token=self.max_n_token,
220
+ min_pixels=self.min_pixels,
221
+ max_wh_ratio=self.max_wh_ratio,
222
+ )
223
+
224
+
225
+class DeepseekV4VLProcessor:
226
+ """Minimal stand-in for the HF processor of DeepSeek-V4.1 vision models.
227
+
228
+ The official repository ships image preprocessing as plain functions in
229
+ ``image_processor.py`` (no ``auto_map`` processor), so this class wraps
230
+ their ports directly and the model loads without ``--trust-remote-code``.
231
+
232
+ ``__call__`` returns a ``BatchFeature`` with one entry per image
233
+ (flattened across images):
234
+
235
+ - ``patches``: ``(sum(n_vit_h * n_vit_w), 3, p, p)`` bf16 ViT patches.
236
+ - ``vit_grid``: ``(num_images, 2)`` int64 ``[n_vit_h, n_vit_w]``.
237
+ - ``llm_grid``: ``(num_images, 2)`` int64 ``[n_llm_h, n_llm_w]``.
238
+ - ``types``: concatenated per-image pad-free span roles
239
+ (IMAGE_START/IMAGE/IMAGE_NEW_LINE/IMAGE_END); every span position
240
+ carries ``image_token_id`` in the prompt's token ids.
241
+ """
242
+
243
+ def __init__(self, config: DeepseekV41Config) -> None:
244
+ super().__init__()
245
+ self.config = config
246
+ self.image_processor = DeepseekV4VLImageProcessor(config)
247
+
248
+ def __call__(
249
+ self,
250
+ text: str | None = None,
251
+ images: Sequence[Image.Image] | None = None,
252
+ return_tensors: str | None = None,
253
+ **kwargs: Any,
254
+ ) -> BatchFeature:
255
+ patches_list = []
256
+ vit_grid = []
257
+ llm_grid_list = []
258
+ types_list = []
259
+ for image in images or []:
260
+ patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w = self.image_processor(image)
261
+ patches_list.append(patches)
262
+ vit_grid.append((n_vit_h, n_vit_w))
263
+ llm_grid_list.append((n_llm_h, n_llm_w))
264
+ types_list.append(image_token_types(n_llm_h, n_llm_w))
265
+
266
+ if not patches_list:
267
+ return BatchFeature({})
268
+
269
+ return BatchFeature(
270
+ {
271
+ "patches": torch.cat(patches_list),
272
+ "vit_grid": torch.tensor(vit_grid, dtype=torch.int64),
273
+ "llm_grid": torch.tensor(llm_grid_list, dtype=torch.int64),
274
+ "types": torch.cat(types_list),
275
+ }
276
+ )
277
+
278
+
279
+class DeepseekV4VLProcessingInfo(BaseProcessingInfo):
280
+ def get_hf_config(self) -> DeepseekV41Config:
281
+ return self.ctx.get_hf_config(DeepseekV41Config)
282
+
283
+ def get_hf_processor(self, **kwargs: object) -> DeepseekV4VLProcessor:
284
+ if kwargs:
285
+ raise ValueError(f"Unexpected processor kwargs: {sorted(kwargs)}")
286
+ return DeepseekV4VLProcessor(self.get_hf_config())
287
+
288
+ def get_supported_mm_limits(self) -> Mapping[str, int | None]:
289
+ return {"image": None}
290
+
291
+ def get_mm_max_tokens_per_item(
292
+ self,
293
+ seq_len: int,
294
+ mm_counts: Mapping[str, int],
295
+ ) -> Mapping[str, int]:
296
+ # ``safe_resize`` reserves COMPRESS_PAD_TO - 1 tokens of
297
+ # vision_max_n_token for the compressor-alignment pad, so the full
298
+ # image span is bounded by vision_max_n_token; the margin is kept
299
+ # in case that reservation changes.
300
+ return {
301
+ "image": self.get_hf_config().vision_max_n_token + COMPRESS_PAD_TO - 1,
302
+ }
303
+
304
+ def get_image_placeholder_token_id(self) -> int:
305
+ token_id = self.get_tokenizer().convert_tokens_to_ids(IMAGE_PLACEHOLDER)
306
+ if token_id is None:
307
+ raise ValueError(f"Token not found in tokenizer: {IMAGE_PLACEHOLDER}")
308
+ return token_id
309
+
310
+ def get_image_size_with_most_features(self) -> ImageSize:
311
+ hf_config = self.get_hf_config()
312
+ patch_size = hf_config.vision_patch_size
313
+ downsample_ratio = hf_config.vision_downsample_ratio
314
+ # A square maximizes the ViT patch count (area) within the token
315
+ # budget; solve the budget-derived size directly to keep the dummy
316
+ # image small.
317
+ budget = hf_config.vision_max_n_token - (COMPRESS_PAD_TO - 1)
318
+ side = budget * patch_size * downsample_ratio
319
+ best_h, best_w = solve_resize_ratio(
320
+ side, side, patch_size, downsample_ratio, budget
321
+ )
322
+ return ImageSize(width=best_w, height=best_h)
323
+
324
+
325
+class DeepseekV4VLDummyInputsBuilder(
326
+ BaseDummyInputsBuilder[DeepseekV4VLProcessingInfo]
327
+):
328
+ def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
329
+ return IMAGE_PLACEHOLDER * mm_counts.get("image", 0)
330
+
331
+ def get_dummy_mm_data(
332
+ self,
333
+ seq_len: int,
334
+ mm_counts: Mapping[str, int],
335
+ mm_options: Mapping[str, BaseDummyOptions],
336
+ ) -> MultiModalDataDict:
337
+ size = self.info.get_image_size_with_most_features()
338
+ return {
339
+ "image": self._get_dummy_images(
340
+ width=size.width,
341
+ height=size.height,
342
+ num_images=mm_counts.get("image", 0),
343
+ overrides=cast(ImageDummyOptions | None, mm_options.get("image")),
344
+ ),
345
+ }
346
+
347
+
348
+class DeepseekV4VLMultiModalProcessor(
349
+ BaseMultiModalProcessor[DeepseekV4VLProcessingInfo]
350
+):
351
+ def _get_mm_fields_config(
352
+ self,
353
+ hf_inputs: BatchFeature,
354
+ hf_processor_mm_kwargs: Mapping[str, object],
355
+ ) -> Mapping[str, MultiModalFieldConfig]:
356
+ vit_grid = hf_inputs.get("vit_grid")
357
+ llm_grid = hf_inputs.get("llm_grid")
358
+
359
+ if vit_grid is None or llm_grid is None:
360
+ empty = torch.empty(0, dtype=torch.long)
361
+ patch_sizes = types_sizes = empty
362
+ else:
363
+ patch_sizes = vit_grid.prod(-1)
364
+ n_llm_h, n_llm_w = llm_grid[:, 0], llm_grid[:, 1]
365
+ types_sizes = n_llm_h * (n_llm_w + 1) + 2
366
+
367
+ return {
368
+ "patches": MultiModalFieldConfig.flat_from_sizes("image", patch_sizes),
369
+ "vit_grid": MultiModalFieldConfig.batched("image", keep_on_cpu=True),
370
+ "llm_grid": MultiModalFieldConfig.batched("image", keep_on_cpu=True),
371
+ "types": MultiModalFieldConfig.flat_from_sizes(
372
+ "image", types_sizes, keep_on_cpu=True
373
+ ),
374
+ }
375
+
376
+ def _get_prompt_updates(
377
+ self,
378
+ mm_items: MultiModalDataItems,
379
+ hf_processor_mm_kwargs: Mapping[str, object],
380
+ out_mm_kwargs: MultiModalKwargsItems,
381
+ ) -> Sequence[PromptUpdate]:
382
+ image_token_id = self.info.get_image_placeholder_token_id()
383
+ validate_image_sentinel_ids(self.info.get_tokenizer())
384
+
385
+ def get_image_replacement(item_idx: int) -> PromptUpdateDetails:
386
+ types: torch.Tensor = out_mm_kwargs["image"][item_idx]["types"].data
387
+ # Every span position carries image_token_id; the roles live in
388
+ # ``types``. All of them are embed positions (delimiters get the
389
+ # learned vectors from embed_multimodal, not the embed table).
390
+ full = [image_token_id] * types.numel()
391
+ return PromptUpdateDetails.select_token_id(full, image_token_id)
392
+
393
+ return [
394
+ PromptReplacement(
395
+ modality="image",
396
+ target=[image_token_id],
397
+ replacement=get_image_replacement,
398
+ ),
399
+ ]
400
+
401
+ def _apply_token_matches_with_placeholders(
402
+ self,
403
+ token_ids: list[int],
404
+ mm_prompt_updates: MultiModalPromptUpdates,
405
+ ) -> tuple[
406
+ list[int],
407
+ MultiModalPromptUpdatesApplyResult,
408
+ Mapping[str, list[PlaceholderFeaturesInfo]],
409
+ ]:
410
+ # Same as the base implementation, except that each image block gets
411
+ # its compressor-alignment pad (``COMPRESS_PAD_TO - 1 - start %
412
+ # COMPRESS_PAD_TO`` IMAGE_PAD_ID tokens) prepended while splicing:
413
+ # the pad depends on the block's final position in the prompt, which
414
+ # is unknown when the (cacheable) prompt updates are built. Pad
415
+ # positions are not embed positions (their id differs from
416
+ # image_token_id), so ``is_embed`` stays aligned with the pad-free
417
+ # replacement content.
418
+ matched_updates, result = _plan_prompt_updates(token_ids, mm_prompt_updates)
419
+ placeholders: dict[str, list[PlaceholderFeaturesInfo]] = {
420
+ modality: [] for modality in mm_prompt_updates
421
+ }
422
+
423
+ new_token_ids = list[int]()
424
+ prev_end_idx = 0
425
+ for matched_update in matched_updates:
426
+ update = matched_update.update
427
+ match = matched_update.match
428
+
429
+ if update.mode == UpdateMode.INSERT:
430
+ end_idx_to_insert = match.end_idx
431
+ elif update.mode == UpdateMode.REPLACE:
432
+ end_idx_to_insert = match.start_idx
433
+ else:
434
+ assert_never(update.mode)
435
+
436
+ new_token_ids.extend(token_ids[prev_end_idx:end_idx_to_insert])
437
+ start_idx = len(new_token_ids)
438
+
439
+ tokens = list(update.content.full)
440
+ if tokens and update.modality == "image":
441
+ compress_pad = COMPRESS_PAD_TO - 1 - start_idx % COMPRESS_PAD_TO
442
+ tokens = [IMAGE_PAD_ID] * compress_pad + tokens
443
+
444
+ if tokens:
445
+ content_is_embed = update.content.is_embed
446
+ is_embed = (
447
+ content_is_embed(tokens) if content_is_embed is not None else None
448
+ )
449
+ placeholders[update.modality].append(
450
+ PlaceholderFeaturesInfo(
451
+ modality=update.modality,
452
+ item_idx=update.item_idx,
453
+ start_idx=start_idx,
454
+ tokens=tokens,
455
+ is_embed=is_embed,
456
+ )
457
+ )
458
+ new_token_ids.extend(tokens)
459
+
460
+ prev_end_idx = match.end_idx
461
+
462
+ new_token_ids.extend(token_ids[prev_end_idx:])
463
+ return new_token_ids, result, placeholders
vllm/parser/deepseek_v4.py
+15
-5
@@ -70,11 +70,17 @@ _PARTIAL_PARAM_RE = re.compile(
70
)
71
72
73
-def _dsml_arg_converter(raw_args: str, partial: bool) -> str:
73
+def _dsml_arg_converter(
74
+ raw_args: str,
75
+ partial: bool,
76
+ *,
77
+ param_re: re.Pattern = _PARAM_RE,
78
+ partial_param_re: re.Pattern = _PARTIAL_PARAM_RE,
79
+) -> str:
80
params: dict[str, object] = {}
81
82
last_end = 0
77
- for m in _PARAM_RE.finditer(raw_args):
83
+ for m in param_re.finditer(raw_args):
84
name, is_str, value = m.group(1), m.group(2), m.group(3)
85
if is_str == "true":
86
params[name] = value
@@ -86,7 +92,7 @@ def _dsml_arg_converter(raw_args: str, partial: bool) -> str:
92
last_end = m.end()
93
94
if partial:
89
- pm = _PARTIAL_PARAM_RE.search(raw_args, last_end)
95
+ pm = partial_param_re.search(raw_args, last_end)
96
if pm:
97
name, is_str, value = pm.group(1), pm.group(2), pm.group(3)
98
if is_str == "true":
@@ -229,6 +235,8 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
235
236
237
class DeepSeekV4Parser(ParserEngine):
238
+ parser_config = staticmethod(deepseek_v4_config)
239
+
240
def __init__(
241
self,
242
tokenizer: TokenizerLike,
@@ -245,13 +253,15 @@ class DeepSeekV4Parser(ParserEngine):
253
super().__init__(
254
tokenizer,
255
tools,
248
- parser_engine_config=deepseek_v4_config(thinking=thinking),
256
+ parser_engine_config=self.parser_config(thinking=thinking),
257
**kwargs,
258
)
259
self._arg_converter = self._convert_args
260
261
def _convert_args(self, raw_args: str, partial: bool) -> str:
254
- result = _dsml_arg_converter(raw_args, partial)
262
+ converter = self.parser_engine_config.arg_converter
263
+ assert converter is not None
264
+ result = converter(raw_args, partial)
265
if not self._tools:
266
return result
267
func_name = next((s.name for s in self._tool_slots if s.args == raw_args), None)
vllm/parser/deepseek_v41.py
new
+65
@@ -0,0 +1,65 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""DeepSeek V4.1 reasoning and spaced DSML tool calls."""
4
+
5
+import functools
6
+from dataclasses import replace
7
+
8
+import regex as re
9
+
10
+from vllm.parser.deepseek_v4 import (
11
+ DeepSeekV4Parser,
12
+ _dsml_arg_converter,
13
+ deepseek_v4_config,
14
+)
15
+from vllm.parser.engine.parser_engine_config import ParserEngineConfig
16
+
17
+DSML_TOOL_START = "<|DSML| calls>"
18
+DSML_TOOL_END = "</|DSML| calls>"
19
+DSML_INVOKE_PREFIX = '<|DSML| invoke name="'
20
+DSML_INVOKE_END = "</|DSML| invoke>"
21
+DSML_PARAM_START = "<|DSML| parameter"
22
+DSML_PARAM_CLOSE = "</|DSML| parameter>"
23
+
24
+_PARAM_RE = re.compile(
25
+ r'<|DSML| parameter\s+name="([^"]+)"\s+string="(true|false)">'
26
+ r"(.*?)"
27
+ r"(?:</|DSML| parameter>|(?=<|DSML| parameter\s+name=))",
28
+ re.DOTALL,
29
+)
30
+_PARTIAL_PARAM_RE = re.compile(
31
+ r'<|DSML| parameter\s+name="([^"]+)"\s+string="(true|false)">'
32
+ r"(.*)$",
33
+ re.DOTALL,
34
+)
35
+
36
+
37
+@functools.cache
38
+def deepseek_v41_config(thinking: bool = False) -> ParserEngineConfig:
39
+ config = deepseek_v4_config(thinking=thinking)
40
+ terminal_overrides = {
41
+ "TOOL_START": DSML_TOOL_START,
42
+ "TOOL_END": DSML_TOOL_END,
43
+ "INVOKE_PREFIX": DSML_INVOKE_PREFIX,
44
+ "INVOKE_END": DSML_INVOKE_END,
45
+ "PARAM_START": DSML_PARAM_START,
46
+ "PARAM_CLOSE": DSML_PARAM_CLOSE,
47
+ }
48
+ return replace(
49
+ config,
50
+ name="deepseek_v41",
51
+ terminals={**config.terminals, **terminal_overrides},
52
+ token_id_terminals={
53
+ key: terminal_overrides.get(key, value)
54
+ for key, value in config.token_id_terminals.items()
55
+ },
56
+ arg_converter=functools.partial(
57
+ _dsml_arg_converter,
58
+ param_re=_PARAM_RE,
59
+ partial_param_re=_PARTIAL_PARAM_RE,
60
+ ),
61
+ )
62
+
63
+
64
+class DeepSeekV41Parser(DeepSeekV4Parser):
65
+ parser_config = staticmethod(deepseek_v41_config)
vllm/parser/engine/registered_adapters.py
+6
@@ -9,6 +9,7 @@ names so that :class:`ReasoningParserManager` and
9
10
from vllm.parser.deepseek_v4 import DeepSeekV4Parser
11
from vllm.parser.deepseek_v32 import DeepSeekV32Parser
12
+from vllm.parser.deepseek_v41 import DeepSeekV41Parser
13
from vllm.parser.engine.adapters import make_adapters
14
from vllm.parser.gemma4 import Gemma4Parser
15
from vllm.parser.glm47_moe import Glm47MoeParser
@@ -30,6 +31,11 @@ from vllm.parser.seed_oss import SeedOssParser
31
DeepSeekV4ParserToolAdapter,
32
) = make_adapters(DeepSeekV4Parser)
33
34
+(
35
+ DeepSeekV41ParserReasoningAdapter,
36
+ DeepSeekV41ParserToolAdapter,
37
+) = make_adapters(DeepSeekV41Parser)
38
+
39
(
40
MinimaxM2ParserReasoningAdapter,
41
MinimaxM2ParserToolAdapter,
vllm/reasoning/__init__.py
+4
@@ -32,6 +32,10 @@ _REASONING_PARSERS_TO_REGISTER = {
32
"deepseek_v4_engine_reasoning_parser",
33
"DeepSeekV4ParserReasoningAdapter",
34
),
35
+ "deepseek_v41": (
36
+ "deepseek_v41_engine_reasoning_parser",
37
+ "DeepSeekV41ParserReasoningAdapter",
38
+ ),
39
"poolside_v1": (
40
"poolside_v1_reasoning_parser",
41
"PoolsideV1ReasoningParser",
vllm/reasoning/deepseek_v41_engine_reasoning_parser.py
new
+6
@@ -0,0 +1,6 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.parser.engine.registered_adapters import DeepSeekV41ParserReasoningAdapter
5
+
6
+__all__ = ["DeepSeekV41ParserReasoningAdapter"]
vllm/renderers/registry.py
+1
@@ -23,6 +23,7 @@ _VLLM_RENDERERS = {
23
"cohere": ("cohere", "CohereRenderer"),
24
"deepseek_v32": ("deepseek_v32", "DeepseekV32Renderer"),
25
"deepseek_v4": ("deepseek_v4", "DeepseekV4Renderer"),
26
+ "deepseek_v41": ("deepseek_v4", "DeepseekV4Renderer"),
27
"hf": ("hf", "HfRenderer"),
28
"kimi_audio": ("hf", "HfRenderer"),
29
"kimi_k3": ("kimi_k3", "KimiK3Renderer"),
vllm/tokenizers/deepseek_v41.py
new
+121
@@ -0,0 +1,121 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import copy
5
+from typing import Any
6
+
7
+from transformers import TokenizersBackend
8
+
9
+from vllm.entrypoints.chat_utils import ChatCompletionMessageParam
10
+
11
+from .deepseek_v41_encoding import (
12
+ IMAGE_PLACEHOLDER,
13
+ REASONING_EFFORT_MAPPINGS,
14
+ encode_messages,
15
+)
16
+from .hf import HfTokenizer, get_cached_tokenizer
17
+from .protocol import TokenizerLike
18
+
19
+
20
+def _normalize_messages(
21
+ messages: list[ChatCompletionMessageParam],
22
+) -> list[dict[str, Any]]:
23
+ result = [dict(message) for message in copy.deepcopy(messages)]
24
+ for message in result:
25
+ role = message.get("role")
26
+ if role not in ("system", "developer", "user", "assistant", "tool"):
27
+ raise ValueError(f"Invalid role: {role}")
28
+ if "reasoning" in message:
29
+ message["reasoning_content"] = message["reasoning"]
30
+ content = message.get("content")
31
+ if isinstance(content, list):
32
+ parts = []
33
+ for block in content:
34
+ part_type = block.get("type")
35
+ if part_type == "text":
36
+ parts.append(block.get("text", ""))
37
+ elif part_type in ("image_url", "input_image", "image_pil"):
38
+ parts.append(IMAGE_PLACEHOLDER)
39
+ else:
40
+ raise ValueError(
41
+ "DeepSeek V4.1 supports text and image content only; "
42
+ f"got {part_type!r}"
43
+ )
44
+ message["content"] = "\n\n".join(parts)
45
+ return result
46
+
47
+
48
+def get_deepseek_v41_tokenizer(tokenizer: HfTokenizer) -> HfTokenizer:
49
+ """Wrap an HF tokenizer with the V4.1 prompt encoder."""
50
+ wrapped = copy.copy(tokenizer)
51
+ added_vocab = tokenizer.get_added_vocab()
52
+
53
+ class _DeepseekV41Tokenizer(tokenizer.__class__): # type: ignore
54
+ def apply_chat_template(
55
+ self,
56
+ messages: list[ChatCompletionMessageParam],
57
+ tools: list[dict[str, Any]] | None = None,
58
+ **kwargs,
59
+ ) -> str | list[int]:
60
+ # The generic renderer's conversation has already flattened text
61
+ # parts with '\n'. V4.1 encodes the original messages with '\n\n'.
62
+ conversation = _normalize_messages(messages)
63
+ if tools:
64
+ system = next((m for m in conversation if m["role"] == "system"), None)
65
+ if system is None:
66
+ system = {"role": "system", "content": ""}
67
+ conversation.insert(0, system)
68
+ system["tools"] = tools
69
+
70
+ thinking = bool(kwargs.get("thinking") or kwargs.get("enable_thinking"))
71
+ if "thinking" not in kwargs and "enable_thinking" not in kwargs:
72
+ thinking = True
73
+ effort = kwargs.get("reasoning_effort")
74
+ if effort == "none":
75
+ thinking = False
76
+ effort = None
77
+ if effort is None:
78
+ effort = "high"
79
+ if not (
80
+ (type(effort) is int and 1 <= effort <= 100)
81
+ or (isinstance(effort, str) and effort in REASONING_EFFORT_MAPPINGS)
82
+ ):
83
+ raise ValueError(
84
+ "DeepSeek V4.1 reasoning_effort must be low, high, xhigh, max, "
85
+ "or an integer within [1, 100] in chat_template_kwargs"
86
+ )
87
+
88
+ prompt = encode_messages(
89
+ conversation,
90
+ thinking_mode="thinking" if thinking else "chat",
91
+ drop_thinking=kwargs.get("drop_thinking", True),
92
+ reasoning_effort=effort,
93
+ )
94
+ if kwargs.get("tokenize", True):
95
+ tokenizer_kwargs = {
96
+ key: kwargs[key]
97
+ for key in ("truncation", "max_length")
98
+ if key in kwargs
99
+ }
100
+ return self.encode(prompt, add_special_tokens=False, **tokenizer_kwargs)
101
+ return prompt
102
+
103
+ def num_special_tokens_to_add(self) -> int:
104
+ return len(self.encode(""))
105
+
106
+ def get_added_vocab(self) -> dict[str, int]:
107
+ return added_vocab.copy()
108
+
109
+ def __reduce__(self):
110
+ return get_deepseek_v41_tokenizer, (tokenizer,)
111
+
112
+ _DeepseekV41Tokenizer.__name__ = f"DSV41{tokenizer.__class__.__name__}"
113
+ wrapped.__class__ = _DeepseekV41Tokenizer
114
+ return wrapped
115
+
116
+
117
+class DeepseekV41Tokenizer(TokenizerLike):
118
+ @classmethod
119
+ def from_pretrained(cls, *args, **kwargs) -> HfTokenizer:
120
+ tokenizer = TokenizersBackend.from_pretrained(*args, **kwargs)
121
+ return get_cached_tokenizer(get_deepseek_v41_tokenizer(tokenizer))
vllm/tokenizers/deepseek_v41_encoding.py
new
+577
@@ -0,0 +1,577 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+# ruff: noqa
4
+
5
+"""Text encoding adapted from the DeepSeek V4.1 reference encoder.
6
+
7
+Message normalization and serving controls live in deepseek_v41.py.
8
+"""
9
+
10
+import copy
11
+import json
12
+from typing import Any, Dict, List, Optional, Union
13
+
14
+bos_token: str = "<|begin▁of▁sentence|>"
15
+eos_token: str = "<|end▁of▁sentence|>"
16
+thinking_start_token: str = "<think>"
17
+thinking_end_token: str = "</think>"
18
+dsml_token: str = "|DSML|"
19
+
20
+USER_SP_TOKEN = "<|User|>"
21
+ASSISTANT_SP_TOKEN = "<|Assistant|>"
22
+LATEST_REMINDER_SP_TOKEN = "<|latest_reminder|>"
23
+IMAGE_PLACEHOLDER = "<|deepseek_image|>"
24
+
25
+DS_TASK_SP_TOKENS = {
26
+ "action": "<|action|>",
27
+ "query": "<|query|>",
28
+ "authority": "<|authority|>",
29
+ "domain": "<|domain|>",
30
+ "title": "<|title|>",
31
+ "read_url": "<|read_url|>",
32
+}
33
+VALID_TASKS = set(DS_TASK_SP_TOKENS.keys())
34
+
35
+system_msg_template: str = "{content}"
36
+user_msg_template: str = "{content}"
37
+latest_reminder_msg_template: str = "{content}"
38
+assistant_msg_template: str = "{reasoning}{content}{tool_calls}" + eos_token
39
+assistant_msg_wo_eos_template: str = "{reasoning}{content}{tool_calls}"
40
+thinking_template: str = "{reasoning_content}"
41
+
42
+response_format_template: str = (
43
+ "## Response Format:\n\n"
44
+ "You MUST strictly adhere to the following schema to reply:\n{schema}"
45
+)
46
+tool_output_template: str = "<tool_result>{content}</tool_result>"
47
+
48
+
49
+def to_json(value: Any) -> str:
50
+ """Serialize a value to JSON string."""
51
+ try:
52
+ return json.dumps(value, ensure_ascii=False)
53
+ except:
54
+ return json.dumps(value, ensure_ascii=True)
55
+
56
+
57
+def tools_from_openai_format(tools):
58
+ """Extract function definitions from OpenAI-format tool list."""
59
+ return [tool["function"] for tool in tools]
60
+
61
+
62
+def tool_calls_from_openai_format(tool_calls):
63
+ """Convert OpenAI-format tool calls to internal format."""
64
+ return [
65
+ {
66
+ "name": tool_call["function"]["name"],
67
+ "arguments": tool_call["function"]["arguments"],
68
+ }
69
+ for tool_call in tool_calls
70
+ ]
71
+
72
+
73
+def merge_tool_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
74
+ """
75
+ Merge tool messages into the preceding user message using content_blocks format.
76
+
77
+ DeepSeek-V4.1 does not have a standalone "tool" role; instead, tool results
78
+ are encoded as <tool_result> blocks within user messages.
79
+ """
80
+ merged: List[Dict[str, Any]] = []
81
+
82
+ for msg in messages:
83
+ msg = copy.deepcopy(msg)
84
+ role = msg.get("role")
85
+
86
+ if role == "tool":
87
+ # Convert tool message to a user message with tool_result block
88
+ tool_block = {
89
+ "type": "tool_result",
90
+ "tool_use_id": msg.get("tool_call_id", ""),
91
+ "content": msg.get("content", ""),
92
+ }
93
+ # Merge into previous message if it's already a user (merged tool)
94
+ if (
95
+ merged
96
+ and merged[-1].get("role") == "user"
97
+ and "content_blocks" in merged[-1]
98
+ ):
99
+ merged[-1]["content_blocks"].append(tool_block)
100
+ else:
101
+ merged.append(
102
+ {
103
+ "role": "user",
104
+ "content_blocks": [tool_block],
105
+ }
106
+ )
107
+ elif role == "user":
108
+ content_blocks = msg.get("content_blocks")
109
+ if content_blocks is None:
110
+ content_blocks = [{"type": "text", "text": msg.get("content", "")}]
111
+ if (
112
+ merged
113
+ and merged[-1].get("role") == "user"
114
+ and "content_blocks" in merged[-1]
115
+ and merged[-1].get("task") is None
116
+ ):
117
+ merged[-1]["content_blocks"].extend(content_blocks)
118
+ else:
119
+ # Preserve structured content and all message-level metadata.
120
+ new_msg = msg
121
+ new_msg["content_blocks"] = content_blocks
122
+ merged.append(new_msg)
123
+ else:
124
+ merged.append(msg)
125
+
126
+ return merged
127
+
128
+
129
+def sort_tool_results_by_call_order(
130
+ messages: List[Dict[str, Any]],
131
+) -> List[Dict[str, Any]]:
132
+ """
133
+ Sort tool_result blocks within user messages by the order of tool_calls
134
+ in the preceding assistant message.
135
+ """
136
+ last_tool_call_order: Dict[str, int] = {}
137
+
138
+ for msg in messages:
139
+ role = msg.get("role")
140
+ if role == "assistant" and msg.get("tool_calls"):
141
+ last_tool_call_order = {}
142
+ for idx, tc in enumerate(msg["tool_calls"]):
143
+ tc_id = tc.get("id") or tc.get("function", {}).get("id", "")
144
+ if tc_id:
145
+ last_tool_call_order[tc_id] = idx
146
+
147
+ elif role == "user" and msg.get("content_blocks"):
148
+ tool_blocks = [
149
+ b for b in msg["content_blocks"] if b.get("type") == "tool_result"
150
+ ]
151
+ if len(tool_blocks) > 1 and last_tool_call_order:
152
+ sorted_blocks = sorted(
153
+ tool_blocks,
154
+ key=lambda b: last_tool_call_order.get(b.get("tool_use_id", ""), 0),
155
+ )
156
+ sorted_idx = 0
157
+ new_blocks = []
158
+ for block in msg["content_blocks"]:
159
+ if block.get("type") == "tool_result":
160
+ new_blocks.append(sorted_blocks[sorted_idx])
161
+ sorted_idx += 1
162
+ else:
163
+ new_blocks.append(block)
164
+ msg["content_blocks"] = new_blocks
165
+
166
+ return messages
167
+
168
+
169
+SYSTEM_SP_TOKEN = "<|System|>"
170
+
171
+tool_calls_block_name: str = " calls"
172
+tool_call_tag_name: str = " invoke"
173
+tool_parameter_tag_name: str = " parameter"
174
+tool_call_template: str = '<{dsml_token}{tool_call_tag_name} name="{name}">\n{arguments}\n</{dsml_token}{tool_call_tag_name}>'
175
+tool_calls_template = (
176
+ "<{dsml_token}{tc_block_name}>\n{tool_calls}\n</{dsml_token}{tc_block_name}>"
177
+)
178
+
179
+REASONING_EFFORT_TEMPLATE = (
180
+ "Reasoning Effort: {budget} "
181
+ "(range 1-100, the higher the value, the more thorough the reasoning)\n\n"
182
+)
183
+REASONING_EFFORT_MAPPINGS: Dict[str, int] = {
184
+ "low": 25,
185
+ "high": 50,
186
+ "xhigh": 75,
187
+ "max": 100,
188
+}
189
+
190
+DEFAULT_REASONING_EFFORT = "high"
191
+
192
+
193
+def render_reasoning_effort(
194
+ index: int,
195
+ thinking_mode: str,
196
+ effort: Union[str, int, None],
197
+) -> str:
198
+ """Render the V4.1 numeric reasoning effort prefix (thinking mode, index 0 only)."""
199
+ if effort is None:
200
+ effort = DEFAULT_REASONING_EFFORT
201
+ assert (
202
+ type(effort) is int and 1 <= effort <= 100
203
+ ) or effort in REASONING_EFFORT_MAPPINGS, (
204
+ "Invalid reasoning effort for deepseek_v41: "
205
+ f"{effort}, should be int within [1,100] or {list(REASONING_EFFORT_MAPPINGS)}"
206
+ )
207
+ if type(effort) is str:
208
+ effort = REASONING_EFFORT_MAPPINGS[effort]
209
+ if index == 0 and thinking_mode == "thinking":
210
+ return REASONING_EFFORT_TEMPLATE.format(budget=effort)
211
+ return ""
212
+
213
+
214
+TOOLS_TEMPLATE = """## Tools
215
+
216
+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:
217
+
218
+<{dsml_token}{tc_block_name}>
219
+<{dsml_token}{tool_call_tag_name} name="$TOOL_NAME">
220
+<{dsml_token}{tool_parameter_tag_name} name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}{tool_parameter_tag_name}>
221
+...
222
+</{dsml_token}{tool_call_tag_name}>
223
+<{dsml_token}{tool_call_tag_name} name="$TOOL_NAME2">
224
+...
225
+</{dsml_token}{tool_call_tag_name}>
226
+</{dsml_token}{tc_block_name}>
227
+
228
+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"`.
229
+
230
+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.
231
+
232
+Otherwise, output directly after {thinking_end_token} with tool calls or final response.
233
+
234
+### Available Tool Schemas
235
+
236
+{tool_schemas}
237
+
238
+You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
239
+"""
240
+
241
+
242
+def render_tools(tools: List[Dict[str, Union[str, Dict[str, Any]]]]) -> str:
243
+ """Render tool schemas into the V4.1 system prompt format."""
244
+ tools_json = [to_json(t) for t in tools]
245
+
246
+ return TOOLS_TEMPLATE.format(
247
+ tool_schemas="\n".join(tools_json),
248
+ dsml_token=dsml_token,
249
+ tc_block_name=tool_calls_block_name,
250
+ tool_call_tag_name=tool_call_tag_name,
251
+ tool_parameter_tag_name=tool_parameter_tag_name,
252
+ thinking_start_token=thinking_start_token,
253
+ thinking_end_token=thinking_end_token,
254
+ )
255
+
256
+
257
+def encode_arguments_to_dsml(tool_call: Dict[str, Any]) -> str:
258
+ """Encode tool call arguments into V4.1 DSML parameter format."""
259
+ p_dsml_template = (
260
+ '<{dsml_token}{tool_parameter_tag_name} name="{key}" string="{is_str}">'
261
+ "{value}</{dsml_token}{tool_parameter_tag_name}>"
262
+ )
263
+ P_dsml_strs = []
264
+
265
+ arguments = tool_call["arguments"]
266
+ if not isinstance(arguments, dict):
267
+ # Tolerate JSON strings, including double-encoded ones.
268
+ for _ in range(2):
269
+ if isinstance(arguments, str):
270
+ try:
271
+ arguments = json.loads(arguments)
272
+ except Exception:
273
+ break
274
+ else:
275
+ break
276
+ if not isinstance(arguments, dict):
277
+ arguments = {"arguments": tool_call["arguments"]}
278
+
279
+ for k, v in arguments.items():
280
+ P_dsml_strs.append(
281
+ p_dsml_template.format(
282
+ dsml_token=dsml_token,
283
+ tool_parameter_tag_name=tool_parameter_tag_name,
284
+ key=k,
285
+ is_str="true" if isinstance(v, str) else "false",
286
+ value=v if isinstance(v, str) else to_json(v),
287
+ )
288
+ )
289
+
290
+ return "\n".join(P_dsml_strs)
291
+
292
+
293
+def find_last_user_index(messages: List[Dict[str, Any]]) -> int:
294
+ """
295
+ Find the index of the last user/developer message.
296
+
297
+ V4.1 supports mid-conversation system messages, which count as user
298
+ messages for the purposes of the assistant generation header.
299
+ """
300
+ last_user_index = -1
301
+ for idx in range(len(messages) - 1, -1, -1):
302
+ role = messages[idx].get("role")
303
+ if role in ["user", "developer"] or (role == "system" and idx > 0):
304
+ last_user_index = idx
305
+ break
306
+ return last_user_index
307
+
308
+
309
+def render_message(
310
+ index: int,
311
+ messages: List[Dict[str, Any]],
312
+ thinking_mode: str,
313
+ drop_thinking: bool = True,
314
+ reasoning_effort: Union[str, int, None] = None,
315
+) -> str:
316
+ """
317
+ Render a single message at the given index into its V4.1 encoded string form.
318
+ """
319
+ assert 0 <= index < len(messages)
320
+ assert thinking_mode in ["chat", "thinking"], (
321
+ f"Invalid thinking_mode `{thinking_mode}`"
322
+ )
323
+
324
+ msg = messages[index]
325
+ last_user_idx = find_last_user_index(messages)
326
+
327
+ role = msg.get("role")
328
+ content = msg.get("content")
329
+ tools = msg.get("tools")
330
+ response_format = msg.get("response_format")
331
+ tool_calls = msg.get("tool_calls")
332
+ reasoning_content = msg.get("reasoning_content")
333
+ wo_eos = msg.get("wo_eos", False)
334
+
335
+ if tools:
336
+ tools = tools_from_openai_format(tools)
337
+ if tool_calls:
338
+ tool_calls = tool_calls_from_openai_format(tool_calls)
339
+
340
+ # Reasoning effort prefix (thinking mode, index 0 only)
341
+ reasoning_effort_prompt = render_reasoning_effort(
342
+ index, thinking_mode, reasoning_effort
343
+ )
344
+ # System token leads the conversation when there is a reasoning effort prompt
345
+ # or the first message is a system message.
346
+ prompt = (
347
+ SYSTEM_SP_TOKEN
348
+ if index == 0 and (reasoning_effort_prompt or role == "system")
349
+ else ""
350
+ )
351
+ prompt += reasoning_effort_prompt
352
+
353
+ if role == "system":
354
+ if index > 0:
355
+ # Mid-conversation system message
356
+ prompt += SYSTEM_SP_TOKEN
357
+ prompt += system_msg_template.format(content=content or "")
358
+ if tools:
359
+ prompt += "\n\n" + render_tools(tools)
360
+ if response_format:
361
+ prompt += "\n\n" + response_format_template.format(
362
+ schema=to_json(response_format)
363
+ )
364
+
365
+ elif role == "developer":
366
+ assert content, f"Invalid message for role `{role}`: {msg}"
367
+
368
+ content_developer = USER_SP_TOKEN
369
+ content_developer += content
370
+
371
+ if tools:
372
+ content_developer += "\n\n" + render_tools(tools)
373
+ if response_format:
374
+ content_developer += "\n\n" + response_format_template.format(
375
+ schema=to_json(response_format)
376
+ )
377
+
378
+ prompt += user_msg_template.format(content=content_developer)
379
+
380
+ elif role == "user":
381
+ prompt += USER_SP_TOKEN
382
+
383
+ # Handle content blocks (tool results mixed with text)
384
+ content_blocks = msg.get("content_blocks")
385
+ if content_blocks:
386
+ parts = []
387
+ for block in content_blocks:
388
+ block_type = block.get("type")
389
+ if block_type == "text":
390
+ parts.append(block.get("text", ""))
391
+ elif block_type == "tool_result":
392
+ tool_content = block.get("content", "")
393
+ if isinstance(tool_content, list):
394
+ text_parts = []
395
+ for b in tool_content:
396
+ if b.get("type") == "text":
397
+ text_parts.append(b.get("text", ""))
398
+ else:
399
+ text_parts.append(f"[Unsupported {b.get('type')}]")
400
+ tool_content = "\n\n".join(text_parts)
401
+ parts.append(tool_output_template.format(content=tool_content))
402
+ else:
403
+ parts.append(f"[Unsupported {block_type}]")
404
+ prompt += "\n\n".join(parts)
405
+ else:
406
+ prompt += content or ""
407
+
408
+ elif role == "latest_reminder":
409
+ prompt += LATEST_REMINDER_SP_TOKEN + latest_reminder_msg_template.format(
410
+ content=content
411
+ )
412
+
413
+ elif role == "tool":
414
+ raise NotImplementedError(
415
+ "deepseek_v41 merges tool messages into user; please preprocess with merge_tool_messages()"
416
+ )
417
+
418
+ elif role == "assistant":
419
+ thinking_part = ""
420
+ tc_content = ""
421
+
422
+ if tool_calls:
423
+ tc_list = [
424
+ tool_call_template.format(
425
+ dsml_token=dsml_token,
426
+ tool_call_tag_name=tool_call_tag_name,
427
+ name=tc.get("name"),
428
+ arguments=encode_arguments_to_dsml(tc),
429
+ )
430
+ for tc in tool_calls
431
+ ]
432
+ tc_content += "\n\n" + tool_calls_template.format(
433
+ dsml_token=dsml_token,
434
+ tool_calls="\n".join(tc_list),
435
+ tc_block_name=tool_calls_block_name,
436
+ )
437
+
438
+ summary_content = content or ""
439
+ rc = reasoning_content or ""
440
+
441
+ # Check if previous message has a task - if so, this is a task output (no thinking)
442
+ prev_has_task = index - 1 >= 0 and messages[index - 1].get("task") is not None
443
+
444
+ if thinking_mode == "thinking" and not prev_has_task:
445
+ if not drop_thinking or index > last_user_idx:
446
+ thinking_part = (
447
+ thinking_template.format(reasoning_content=rc) + thinking_end_token
448
+ )
449
+ else:
450
+ thinking_part = ""
451
+
452
+ if wo_eos:
453
+ prompt += assistant_msg_wo_eos_template.format(
454
+ reasoning=thinking_part,
455
+ content=summary_content,
456
+ tool_calls=tc_content,
457
+ )
458
+ else:
459
+ prompt += assistant_msg_template.format(
460
+ reasoning=thinking_part,
461
+ content=summary_content,
462
+ tool_calls=tc_content,
463
+ )
464
+ else:
465
+ raise NotImplementedError(f"Unknown role: {role}")
466
+
467
+ # Append transition tokens based on what follows
468
+ if index + 1 < len(messages) and messages[index + 1].get("role") not in [
469
+ "assistant",
470
+ "latest_reminder",
471
+ ]:
472
+ return prompt
473
+
474
+ task = messages[index].get("task")
475
+ if task is not None:
476
+ # Task special token for internal classification tasks
477
+ assert task in VALID_TASKS, (
478
+ f"Invalid task: '{task}'. Valid tasks are: {list(VALID_TASKS)}"
479
+ )
480
+ task_sp_token = DS_TASK_SP_TOKENS[task]
481
+
482
+ if task != "action":
483
+ # Non-action tasks: append task sp token directly after the message
484
+ prompt += task_sp_token
485
+ else:
486
+ # Action task: append Assistant + thinking token + action sp token
487
+ prompt += ASSISTANT_SP_TOKEN
488
+ prompt += (
489
+ thinking_end_token
490
+ if thinking_mode != "thinking"
491
+ else thinking_start_token
492
+ )
493
+ prompt += task_sp_token
494
+
495
+ elif messages[index].get("role") in ["user", "developer"] or (
496
+ messages[index].get("role") == "system" and index > 0
497
+ ):
498
+ # Normal generation: append Assistant + thinking token
499
+ # (mid-conversation system messages also trigger the assistant header)
500
+ prompt += ASSISTANT_SP_TOKEN
501
+ if not drop_thinking and thinking_mode == "thinking":
502
+ prompt += thinking_start_token
503
+ elif drop_thinking and thinking_mode == "thinking" and index >= last_user_idx:
504
+ prompt += thinking_start_token
505
+ else:
506
+ prompt += thinking_end_token
507
+
508
+ return prompt
509
+
510
+
511
+def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
512
+ """
513
+ Drop reasoning_content and non-essential messages before the last user message.
514
+ Same as V4, but uses the V4.1 last-user definition (mid systems count).
515
+ """
516
+ last_user_idx = find_last_user_index(messages)
517
+ result = []
518
+ keep_roles = {"user", "system", "tool", "latest_reminder", "direct_search_results"}
519
+
520
+ for idx, msg in enumerate(messages):
521
+ role = msg.get("role")
522
+ if role in keep_roles or idx >= last_user_idx:
523
+ result.append(msg)
524
+ elif role == "assistant":
525
+ msg = copy.copy(msg)
526
+ msg.pop("reasoning_content", None)
527
+ result.append(msg)
528
+ # developer and other roles before last_user_idx are dropped
529
+
530
+ return result
531
+
532
+
533
+def encode_messages(
534
+ messages: List[Dict[str, Any]],
535
+ thinking_mode: str,
536
+ context: Optional[List[Dict[str, Any]]] = None,
537
+ drop_thinking: bool = True,
538
+ add_default_bos_token: bool = True,
539
+ reasoning_effort: Union[str, int, None] = None,
540
+) -> str:
541
+ """Encode preprocessed (text-only) messages into the V4.1 prompt format."""
542
+ context = context if context else []
543
+
544
+ # Preprocess: merge tool messages and sort tool results
545
+ messages = merge_tool_messages(messages)
546
+ messages = sort_tool_results_by_call_order(context + messages)[len(context) :]
547
+ if context:
548
+ context = merge_tool_messages(context)
549
+ context = sort_tool_results_by_call_order(context)
550
+
551
+ full_messages = context + messages
552
+
553
+ prompt = bos_token if add_default_bos_token and len(context) == 0 else ""
554
+
555
+ # Resolve drop_thinking: if any message has tools defined, don't drop thinking
556
+ effective_drop_thinking = drop_thinking
557
+ if any(m.get("tools") for m in full_messages):
558
+ effective_drop_thinking = False
559
+
560
+ if thinking_mode == "thinking" and effective_drop_thinking:
561
+ full_messages = _drop_thinking_messages(full_messages)
562
+ num_to_render = len(full_messages) - len(_drop_thinking_messages(context))
563
+ context_len = len(full_messages) - num_to_render
564
+ else:
565
+ num_to_render = len(messages)
566
+ context_len = len(context)
567
+
568
+ for idx in range(num_to_render):
569
+ prompt += render_message(
570
+ idx + context_len,
571
+ full_messages,
572
+ thinking_mode=thinking_mode,
573
+ drop_thinking=effective_drop_thinking,
574
+ reasoning_effort=reasoning_effort,
575
+ )
576
+
577
+ return prompt
vllm/tokenizers/registry.py
+1
@@ -45,6 +45,7 @@ _VLLM_TOKENIZERS = {
45
"cohere": ("hf", "CachedHfTokenizer"),
46
"deepseek_v32": ("deepseek_v32", "DeepseekV32Tokenizer"),
47
"deepseek_v4": ("deepseek_v4", "DeepseekV4Tokenizer"),
48
+ "deepseek_v41": ("deepseek_v41", "DeepseekV41Tokenizer"),
49
"hf": ("hf", "CachedHfTokenizer"),
50
"kimi_audio": ("kimi_audio", "KimiAudioTokenizer"),
51
"kimi_k3": ("hf", "CachedHfTokenizer"),
vllm/tool_parsers/__init__.py
+4
@@ -42,6 +42,10 @@ _TOOL_PARSERS_TO_REGISTER = {
42
"deepseekv4_engine_tool_parser",
43
"DeepSeekV4EngineToolParser",
44
),
45
+ "deepseek_v41": (
46
+ "deepseekv41_engine_tool_parser",
47
+ "DeepSeekV41EngineToolParser",
48
+ ),
49
"cohere_command3": (
50
"cohere_command_tool_parser",
51
"CohereCommand3ToolParser",
vllm/tool_parsers/deepseekv41_engine_tool_parser.py
new
+8
@@ -0,0 +1,8 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.parser.engine.registered_adapters import DeepSeekV41ParserToolAdapter
5
+
6
+
7
+class DeepSeekV41EngineToolParser(DeepSeekV41ParserToolAdapter): # type: ignore[valid-type, misc]
8
+ structural_tag_model = "deepseek_v41"
vllm/tool_parsers/structural_tag_registry.py
+96
-1
@@ -71,7 +71,9 @@ XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
71
"deepseek_v4",
72
}
73
)
74
-VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset({"hermes", "hy_v4", "kimi_k3"})
74
+VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
75
+ {"deepseek_v41", "hermes", "hy_v4", "kimi_k3"}
76
+)
77
SUPPORTED_STRUCTURAL_TAG_MODELS = (
78
XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS | VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
79
)
@@ -225,6 +227,99 @@ def get_function_parameters(function) -> dict[str, Any] | bool:
227
return function.parameters if function.parameters is not None else True
228
229
230
+_V41_CALLS_START = "<|DSML| calls>"
231
+_V41_CALLS_END = "</|DSML| calls>"
232
+_V41_INVOKE_END = "</|DSML| invoke>"
233
+_V41_PARAMETER_END = "</|DSML| parameter>"
234
+
235
+
236
+@register_vllm_structural_tag("deepseek_v41")
237
+def get_deepseek_v41_structural_tag(
238
+ tools: list[FunctionToolParam],
239
+ builtin_tools: list[BuiltinToolParam],
240
+ tool_choice: SimplifiedToolChoice,
241
+ reasoning: bool,
242
+ token_suffix: str = "",
243
+) -> StructuralTag:
244
+ # Serving enables this visible-text grammar after the reasoning boundary.
245
+ del builtin_tools, reasoning, token_suffix
246
+
247
+ # TODO: lower parameter schemas into DSML constraints. This builder constrains
248
+ # tool names and DSML/value syntax only. Parameter names, presence, uniqueness
249
+ # and value schemas remain unconstrained, including for strict=true. The request
250
+ # layer still uses strict to decide whether auto tool choice activates a grammar.
251
+ parameter = TagFormat(
252
+ begin='<|DSML| parameter name="',
253
+ content=SequenceFormat(
254
+ elements=[
255
+ RegexFormat(pattern=r'[^"]+'),
256
+ ConstStringFormat(value='" string="'),
257
+ OrFormat(
258
+ elements=[
259
+ SequenceFormat(
260
+ elements=[
261
+ ConstStringFormat(value='true">'),
262
+ AnyTextFormat(
263
+ excludes=[
264
+ _V41_PARAMETER_END,
265
+ _V41_INVOKE_END,
266
+ _V41_CALLS_END,
267
+ ]
268
+ ),
269
+ ]
270
+ ),
271
+ SequenceFormat(
272
+ elements=[
273
+ ConstStringFormat(value='false">'),
274
+ JSONSchemaFormat(json_schema=True),
275
+ ]
276
+ ),
277
+ ]
278
+ ),
279
+ ]
280
+ ),
281
+ end=f"{_V41_PARAMETER_END}\n",
282
+ )
283
+ calls = TagsWithSeparatorFormat(
284
+ tags=[
285
+ TagFormat(
286
+ begin=f'<|DSML| invoke name="{tool.function.name}">\n',
287
+ content=StarFormat(content=parameter),
288
+ end=f"{_V41_INVOKE_END}\n",
289
+ )
290
+ for tool in tools
291
+ ],
292
+ separator="",
293
+ at_least_one=True,
294
+ stop_after_first=tool_choice == "forced",
295
+ )
296
+ if tool_choice == "auto":
297
+ return StructuralTag(
298
+ format=TriggeredTagsFormat(
299
+ triggers=[_V41_CALLS_START],
300
+ tags=[
301
+ TagFormat(
302
+ begin=f"{_V41_CALLS_START}\n",
303
+ content=calls,
304
+ end=_V41_CALLS_END,
305
+ )
306
+ ],
307
+ excludes=["<think>", "</think>"],
308
+ )
309
+ if tools
310
+ else AnyTextFormat(excludes=["<think>", "</think>"])
311
+ )
312
+ return StructuralTag(
313
+ format=SequenceFormat(
314
+ elements=[
315
+ ConstStringFormat(value=f"\n\n{_V41_CALLS_START}\n"),
316
+ calls,
317
+ ConstStringFormat(value=_V41_CALLS_END),
318
+ ]
319
+ )
320
+ )
321
+
322
+
323
def _hermes_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]:
324
arguments_field_prefix = '", "arguments": '
325
formats = [
vllm/transformers_utils/config.py
+1
@@ -152,6 +152,7 @@ _CONFIG_REGISTRY: dict[str, type[PretrainedConfig]] = LazyConfigDict(
152
laguna="LagunaConfig",
153
lfm2_moe="Lfm2MoeConfig",
154
**{"unlimited-ocr": "UnlimitedOCRConfig"},
155
+ **{"deepseek_v41": "DeepseekV41Config"},
156
inkling_mm_model="InklingMMConfig",
157
inkling_model="InklingModelConfig",
158
)
vllm/transformers_utils/configs/__init__.py
+1
@@ -33,6 +33,7 @@ _CLASS_TO_MODULE: dict[str, str] = {
33
"DiffusionGemmaTextConfig": "vllm.transformers_utils.configs.diffusion_gemma",
34
"DeepseekVLV2Config": "vllm.transformers_utils.configs.deepseek_vl2",
35
"DeepseekV4Config": "vllm.transformers_utils.configs.deepseek_v4",
36
+ "DeepseekV41Config": "vllm.transformers_utils.configs.deepseek_v41",
37
"Dots3NoteConfig": "vllm.transformers_utils.configs.dots3_note",
38
"K3DSparkConfig": "vllm.transformers_utils.configs.k3_dspark",
39
"DotsOCRConfig": "vllm.transformers_utils.configs.dotsocr",
vllm/transformers_utils/configs/deepseek_v41.py
new
+76
@@ -0,0 +1,76 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from typing import Any
4
+
5
+from transformers import PretrainedConfig
6
+
7
+
8
+class DeepseekV41Config(PretrainedConfig):
9
+ """DeepSeek V4.1 config.
10
+
11
+ The HF config nests the text model under ``text_config`` and the vision
12
+ tower under ``vision_config``. The vLLM model code (ported from
13
+ ``deepseek_v4``) reads flat attributes, so both sub-configs are flattened
14
+ onto the top level here: text fields are exposed as-is, vision fields
15
+ with the ``vision_*`` naming used by ``deepseek_v4_1.common.vision``.
16
+ """
17
+
18
+ model_type = "deepseek_v41"
19
+
20
+ def __init__(
21
+ self,
22
+ text_config: dict[str, Any] | None = None,
23
+ vision_config: dict[str, Any] | None = None,
24
+ **kwargs,
25
+ ):
26
+ text_config = dict(text_config or {})
27
+ vision_config = dict(vision_config or {})
28
+
29
+ # ``rope_scaling`` is a property in Transformers v5 (backed by
30
+ # ``rope_parameters``); capture it here and restore after
31
+ # super().__init__, which re-standardizes rope params.
32
+ rope_scaling = text_config.pop("rope_scaling", None)
33
+
34
+ for key, value in text_config.items():
35
+ if key == "model_type":
36
+ continue
37
+ # Don't clobber PretrainedConfig properties (e.g. is_encoder_decoder).
38
+ if isinstance(getattr(type(self), key, None), property):
39
+ continue
40
+ setattr(self, key, value)
41
+
42
+ super().__init__(**kwargs)
43
+ if rope_scaling is not None:
44
+ self.rope_parameters = rope_scaling
45
+
46
+ # The v4.1 quantization_config carries the expert dtype; the model
47
+ # code reads it from the top-level hf_config.
48
+ quant_cfg = getattr(self, "quantization_config", None) or {}
49
+ if not hasattr(self, "expert_dtype") and "expert_dtype" in quant_cfg:
50
+ self.expert_dtype = quant_cfg["expert_dtype"]
51
+
52
+ vision_n_layers = vision_config.get("num_hidden_layers", 0)
53
+ self.vision_n_layers = vision_n_layers
54
+ self.vision_dim = vision_config.get("hidden_size", 1024)
55
+ self.vision_n_heads = vision_config.get("num_attention_heads", 16)
56
+ self.vision_inter_dim = vision_config.get("intermediate_size", 2816)
57
+ self.vision_patch_size = vision_config.get("patch_size", 14)
58
+ self.vision_rope_theta = vision_config.get("rope_theta", 10000.0)
59
+ self.vision_downsample_ratio = vision_config.get("downsample_ratio", 3)
60
+ self.vision_max_n_token = vision_config.get("max_image_tokens", 1024)
61
+ self.vision_min_pixels = vision_config.get("min_pixels", 295936)
62
+ self.vision_max_wh_ratio = vision_config.get("max_wh_ratio")
63
+ # The vision variant needs the mm-prefix plumbing (atomic image-span
64
+ # prefill + sparse-SWA window widening); the base arch-config
65
+ # convertor reads this config-level attribute.
66
+ self.is_mm_prefix_lm = vision_n_layers > 0
67
+ # The sparse-SWA index kernels widen the window within image spans
68
+ # in-kernel, so mm-prefix ranges longer than sliding_window must be
69
+ # kept (they are only consumed by those kernels).
70
+ self.mm_prefix_clamp_sliding_window = vision_n_layers > 0
71
+ # The visibility span covers the image block [IMAGE_START,
72
+ # IMAGE_END]; the mm placeholder additionally carries a leading
73
+ # compressor-alignment pad of ``COMPRESS_PAD_TO - 1 - offset %
74
+ # COMPRESS_PAD_TO`` tokens (see common/mm_preprocess.py), with
75
+ # COMPRESS_PAD_TO = 2 for v4.1's ratio-2 compressors.
76
+ self.mm_prefix_span_leading_pad_modulus = 2 if vision_n_layers > 0 else 0
vllm/transformers_utils/model_arch_config_convertor.py
+1
@@ -317,6 +317,7 @@ class ModelArchConfigConvertorBase:
317
"deepseek_v3",
318
"deepseek_v32",
319
"deepseek_v4",
320
+ "deepseek_v41",
321
"dots3_note",
322
"deepseek_mtp",
323
"k3_dspark",