Skip to main content

tool_parser/parsers/
deepseek_dsml.rs

1use std::collections::HashMap;
2
3use async_trait::async_trait;
4use openai_protocol::common::Tool;
5use regex::Regex;
6use serde_json::{json, Value};
7
8use crate::{
9    errors::{ParserError, ParserResult},
10    json_format,
11    parsers::helpers,
12    traits::ToolParser,
13    types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
14};
15
16/// The DSML tag dialect a [`DeepSeekDsmlParser`] speaks.
17///
18/// V3.2 and V4 share one grammar and differ only in the outer block name.
19/// V4.1 puts a leading space inside every tag (`<|DSML| calls>`,
20/// `<|DSML| invoke ...>`, `<|DSML| parameter ...>`) and follows the
21/// reference parser's rules rather than V4's (see `DsmlDialect::is_v41`).
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum DsmlDialect {
24    V32,
25    V4,
26    V41,
27}
28
29impl DsmlDialect {
30    /// The outer block tag name (rendered as `<|DSML|{block}>`).
31    pub const fn block(self) -> &'static str {
32        match self {
33            Self::V32 => "function_calls",
34            Self::V4 => "tool_calls",
35            Self::V41 => " calls",
36        }
37    }
38
39    /// The invoke tag name (rendered as `<|DSML|{invoke} name="...">`).
40    pub const fn invoke(self) -> &'static str {
41        match self {
42            Self::V41 => " invoke",
43            Self::V32 | Self::V4 => "invoke",
44        }
45    }
46
47    /// The parameter tag name (rendered as `<|DSML|{parameter} name="..." string="...">`).
48    pub const fn parameter(self) -> &'static str {
49        match self {
50            Self::V41 => " parameter",
51            Self::V32 | Self::V4 => "parameter",
52        }
53    }
54
55    /// V4.1 parsing rules, from the reference parser (`encoding.py`) and the
56    /// serving engines' parsers: exactly the `"\n\n"` separator before the block is
57    /// stripped from the content; `arguments` are serialised like Python's
58    /// `json.dumps`; an invoke outside a block is accepted; text after the
59    /// tool section is dropped; unknown tool names are forwarded unfiltered.
60    const fn is_v41(self) -> bool {
61        matches!(self, Self::V41)
62    }
63}
64
65/// DeepSeek DSML format parser for tool calls (V3.2, V4 and V4.1).
66///
67/// All dialects share the invoke/parameter grammar and the streaming state
68/// machine; the tag literals and regexes are built from the [`DsmlDialect`].
69///
70/// ```text
71/// <|DSML|{block}>
72/// <|DSML|{invoke} name="func">
73/// <|DSML|{parameter} name="key" string="true">value</|DSML|{parameter}>
74/// </|DSML|{invoke}>
75/// </|DSML|{block}>
76/// ```
77///
78/// Also supports direct JSON inside invoke blocks as a fallback format.
79///
80/// References:
81/// - <https://huggingface.co/deepseek-ai/DeepSeek-V3.2>
82/// - <https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash>
83/// - <https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash>
84pub struct DeepSeekDsmlParser {
85    dialect: DsmlDialect,
86    /// Cached `<|DSML|{block}>` for marker-scan hot paths.
87    block_open: String,
88    /// Cached `</|DSML|{block}>` for streaming cleanup.
89    block_close: String,
90    /// Cached `<|DSML|{invoke} name="`: an invoke opener, for V4.1's
91    /// block-less invokes and streaming holdback.
92    invoke_open_prefix: String,
93    /// Cached `</|DSML|{invoke}>` for suffix-based stripping during streaming.
94    invoke_end_tag: String,
95    /// Cached `</|DSML|{parameter}>` for suffix-based stripping during streaming.
96    parameter_end_tag: String,
97
98    /// Regex for extracting full outer-block content
99    tool_call_complete_regex: Regex,
100    /// Regex for extracting complete invoke blocks (name + body)
101    invoke_complete_regex: Regex,
102    /// Regex for extracting complete parameter tags (name, string attr, value)
103    parameter_complete_regex: Regex,
104    /// Regex for matching partial parameter tag during streaming (no closing tag)
105    partial_parameter_regex: Regex,
106    /// Regex for matching invoke blocks (complete or partial, for streaming)
107    invoke_regex: Regex,
108
109    /// Buffer for accumulating incomplete patterns across chunks
110    buffer: String,
111    /// Stores complete tool call info for each tool being parsed
112    prev_tool_call_arr: Vec<Value>,
113    /// Index of currently streaming tool call (-1 means no active tool)
114    current_tool_id: i32,
115    /// Flag for whether current tool's name has been sent to client
116    current_tool_name_sent: bool,
117    /// Tracks raw JSON string content streamed to client for each tool's arguments
118    streamed_args_for_tool: Vec<String>,
119    /// V4.1 streaming: a tool marker has been seen, so everything from it on
120    /// belongs to the tool section (text after it is never content again).
121    in_tool_section: bool,
122}
123
124/// The DSML sentinel every tag starts with (`<` + this + tag name).
125const DSML_TOKEN: &str = "|DSML|";
126
127/// An opening tag's first bytes, `<` + the sentinel: one BPE token in the
128/// model's tokenizer, so a live stream delivers it whole; the closing tags
129/// start with `</` + the sentinel instead and do not contain it.
130const DSML_OPEN: &str = "<|DSML|";
131
132/// DeepSeek end-of-sentence marker. Some engines emit this as raw text at the
133/// end of a truncated turn; it must never bleed into tool-call argument bytes.
134const EOS_TOKEN: &str = "<|end▁of▁sentence|>";
135
136/// The blank line the V4.1 renderer and model put between content and a tool
137/// block; the reference parser strips exactly this from the content.
138const V41_BLOCK_SEPARATOR: &str = "\n\n";
139
140/// Strip a trailing partial DSML closing tag from a string.
141///
142/// If the string ends with a prefix of `closing_tag` (e.g. `"Tokyo</|DSML|para"`
143/// ends with a prefix of `"</|DSML|parameter>"`), that trailing portion is removed.
144/// Unlike character-set stripping, this only removes text that actually starts
145/// the specified closing tag, so legitimate value bytes are preserved.
146fn strip_dsml_trailing(s: &str, closing_tag: &str) -> String {
147    for (idx, _) in s.char_indices() {
148        if closing_tag.starts_with(&s[idx..]) {
149            return s[..idx].to_string();
150        }
151    }
152    s.to_string()
153}
154
155/// Length in bytes of the longest suffix of `text` that is a proper prefix of
156/// one of `patterns` — the part a streaming parser must hold back because the
157/// next chunk could complete it.
158fn longest_partial_suffix(text: &str, patterns: &[&str]) -> usize {
159    let max_len = patterns.iter().map(|p| p.len()).max().unwrap_or(0);
160    let earliest_start = text.len().saturating_sub(max_len.saturating_sub(1));
161    text.char_indices()
162        .map(|(start, _)| start)
163        .filter(|start| *start >= earliest_start)
164        .find(|start| {
165            let suffix = &text[*start..];
166            patterns
167                .iter()
168                .any(|pattern| pattern.len() > suffix.len() && pattern.starts_with(suffix))
169        })
170        .map_or(0, |start| text.len() - start)
171}
172
173impl DeepSeekDsmlParser {
174    /// Create a DeepSeek V3.2 parser (outer block token `function_calls`).
175    pub fn v32() -> Self {
176        Self::new(DsmlDialect::V32)
177    }
178
179    /// Create a DeepSeek V4 parser (outer block token `tool_calls`).
180    pub fn v4() -> Self {
181        Self::new(DsmlDialect::V4)
182    }
183
184    /// Create a DeepSeek V4.1 parser (spaced tags, reference parsing rules).
185    pub fn v41() -> Self {
186        Self::new(DsmlDialect::V41)
187    }
188
189    /// Which dialect this instance parses.
190    pub fn dialect(&self) -> DsmlDialect {
191        self.dialect
192    }
193
194    /// Which block name this instance parses (`function_calls`, `tool_calls`
195    /// or V4.1's ` calls`).
196    pub fn block_name(&self) -> &'static str {
197        self.dialect.block()
198    }
199
200    #[expect(
201        clippy::expect_used,
202        reason = "regex patterns are built from compile-time tag literals"
203    )]
204    fn new(dialect: DsmlDialect) -> Self {
205        let dsml = DSML_TOKEN;
206        let block = regex::escape(dialect.block());
207        let inv = regex::escape(dialect.invoke());
208        let par = regex::escape(dialect.parameter());
209
210        let tool_call_complete_regex =
211            Regex::new(&format!(r"(?s)<{dsml}{block}>(.*?)</{dsml}{block}>"))
212                .expect("Valid regex pattern");
213
214        let invoke_complete_regex = Regex::new(&format!(
215            r#"(?s)<{dsml}{inv}\s+name="([^"]+)"\s*>(.*?)</{dsml}{inv}>"#
216        ))
217        .expect("Valid regex pattern");
218
219        let parameter_complete_regex = Regex::new(&format!(
220            r#"(?s)<{dsml}{par}\s+name="([^"]+)"\s+string="(true|false)"\s*>(.*?)</{dsml}{par}>"#
221        ))
222        .expect("Valid regex pattern");
223
224        let partial_parameter_regex = Regex::new(&format!(
225            r#"(?s)<{dsml}{par}\s+name="([^"]+)"\s+string="(true|false)"\s*>(.*)$"#
226        ))
227        .expect("Valid regex pattern");
228
229        // `[^"]*` (not `+`) so a malformed `name=""` still matches and can be
230        // advanced past by the empty/invalid-name handling in `stream_invokes`.
231        // Without this, a bad `name=""` invoke would stall the buffer forever
232        // and suppress every subsequent delta in the same stream.
233        let invoke_regex = Regex::new(&format!(
234            r#"(?s)<{dsml}{inv}\s+name="([^"]*)"\s*>(.*?)(</{dsml}{inv}>|$)"#
235        ))
236        .expect("Valid regex pattern");
237
238        Self {
239            dialect,
240            block_open: format!("<{dsml}{}>", dialect.block()),
241            block_close: format!("</{dsml}{}>", dialect.block()),
242            invoke_open_prefix: format!("<{dsml}{} name=\"", dialect.invoke()),
243            invoke_end_tag: format!("</{dsml}{}>", dialect.invoke()),
244            parameter_end_tag: format!("</{dsml}{}>", dialect.parameter()),
245            tool_call_complete_regex,
246            invoke_complete_regex,
247            parameter_complete_regex,
248            partial_parameter_regex,
249            invoke_regex,
250            buffer: String::new(),
251            prev_tool_call_arr: Vec::new(),
252            current_tool_id: -1,
253            current_tool_name_sent: false,
254            streamed_args_for_tool: Vec::new(),
255            in_tool_section: false,
256        }
257    }
258
259    /// The end tags of this dialect and the end-of-sentence marker: the
260    /// engine's own markup, never content.
261    fn end_tags(&self) -> [&str; 4] {
262        [
263            self.block_close.as_str(),
264            self.invoke_end_tag.as_str(),
265            self.parameter_end_tag.as_str(),
266            EOS_TOKEN,
267        ]
268    }
269
270    /// `text` without its complete end tags. The block close goes with the
271    /// whitespace around it (the newline the model puts between the last
272    /// invoke and the close, and any after the close), as the one-shot path,
273    /// which reads the content before the block only, never sees them.
274    fn without_end_tags(&self, text: &str) -> String {
275        let mut text = text.to_string();
276        while let Some(at) = text.find(self.block_close.as_str()) {
277            let start = text[..at].trim_end().len();
278            let after = at + self.block_close.len();
279            let end = text.len() - text[after..].trim_start().len();
280            text.replace_range(start..end, "");
281        }
282        for tag in [
283            self.invoke_end_tag.as_str(),
284            self.parameter_end_tag.as_str(),
285            EOS_TOKEN,
286        ] {
287            text = text.replace(tag, "");
288        }
289        text
290    }
291
292    /// Bytes at the end of `text` that are not content yet: a suffix that
293    /// could still become an opener's sentinel or an end tag once the next
294    /// piece arrives (`</|DSML|`, `tool`, `_c`, `all`, `s`, `>` come as separate
295    /// pieces), and the run of whitespace before it. Whitespace waits for the
296    /// next piece because the one-shot path trims it in front of a block and
297    /// the block close takes it after the last invoke; text that follows
298    /// instead is flushed with it, and a stream that ends on it without a
299    /// tool call gives it back through
300    /// [`ToolParser::take_unstreamed_normal_text`]. The visible text of a
301    /// stream is the one-shot path's content whatever the piece boundaries.
302    fn held_suffix_len(&self, text: &str) -> usize {
303        let [block_close, invoke_end, parameter_end, eos] = self.end_tags();
304        let partial = longest_partial_suffix(
305            text,
306            &[DSML_OPEN, block_close, invoke_end, parameter_end, eos],
307        );
308        let before = &text[..text.len() - partial];
309        partial + (before.len() - before.trim_end().len())
310    }
311
312    /// Serialise an arguments object the way this dialect's reference does:
313    /// Python `json.dumps` spacing for V4.1, compact JSON for V3.2/V4.
314    fn dump_arguments(&self, params: serde_json::Map<String, Value>) -> String {
315        let value = Value::Object(params);
316        if self.dialect.is_v41() {
317            json_format::to_string(&value)
318        } else {
319            serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
320        }
321    }
322
323    /// Byte offset of the first tool marker in `text`: the block opener, or
324    /// for V4.1 also a block-less invoke opener.
325    fn first_tool_marker(&self, text: &str) -> Option<usize> {
326        let block = text.find(self.block_open.as_str());
327        if !self.dialect.is_v41() {
328            return block;
329        }
330        let invoke = text.find(self.invoke_open_prefix.as_str());
331        match (block, invoke) {
332            (Some(b), Some(i)) => Some(b.min(i)),
333            (b, i) => b.or(i),
334        }
335    }
336
337    /// V4.1 content before a tool marker: the reference strips exactly the
338    /// blank-line separator the model emits before the block.
339    fn v41_content(text: &str) -> String {
340        text.strip_suffix(V41_BLOCK_SEPARATOR)
341            .unwrap_or(text)
342            .replace(EOS_TOKEN, "")
343    }
344
345    /// Parse DSML parameters from invoke content into a JSON string.
346    ///
347    /// Supports two formats:
348    /// 1. Direct JSON: content starts with `{` — returned as-is
349    /// 2. XML parameters: `<|DSML|{parameter} name="k" string="true|false">v</|DSML|{parameter}>`
350    ///
351    /// When `allow_partial` is true (streaming), also matches open parameter tags
352    /// and strips trailing DSML fragments.
353    fn parse_parameters_from_dsml(&self, invoke_content: &str, allow_partial: bool) -> String {
354        let trimmed = invoke_content.trim();
355
356        // Direct JSON path (also the shape one engine's grammar emits under a
357        // structural-tag constraint). The text is the arguments object and is
358        // passed through as written — a compact body stays compact rather
359        // than being re-spaced like DSML parameters — so streaming deltas stay
360        // consistent (that engine's parser does the same).
361        if trimmed.starts_with('{') {
362            if allow_partial {
363                // `strip_dsml_trailing` handles partial `</|DSML|invoke>` prefixes
364                // but can't match the EOS sentinel (different prefix). Strip it
365                // unconditionally so a truncated turn doesn't leak EOS into args.
366                return strip_dsml_trailing(trimmed, &self.invoke_end_tag).replace(EOS_TOKEN, "");
367            } else if trimmed.ends_with('}') {
368                return trimmed.to_string();
369            }
370        }
371
372        // XML parameter path
373        let mut params = serde_json::Map::new();
374
375        for cap in self.parameter_complete_regex.captures_iter(invoke_content) {
376            let name = cap.get(1).map_or("", |m| m.as_str());
377            let is_string = cap.get(2).map_or("true", |m| m.as_str());
378            // Strip any stray EOS marker — should never legitimately appear
379            // inside a closed parameter, but defend against malformed output.
380            let value = cap.get(3).map_or("", |m| m.as_str()).replace(EOS_TOKEN, "");
381
382            // `string="true"` is a JSON string as written; `string="false"` is
383            // parsed JSON, and JSON that does not parse stays the raw string.
384            // No schema-driven coercion (the reference parser has none).
385            let json_value = if is_string == "true" {
386                Value::String(value.to_string())
387            } else {
388                serde_json::from_str(value.trim())
389                    .unwrap_or_else(|_| Value::String(value.to_string()))
390            };
391
392            params.insert(name.to_string(), json_value);
393        }
394
395        // Partial parameter matching for streaming
396        // Following the engine's parser: strip DSML fragments from remaining content BEFORE
397        // running the partial regex, so the regex captures a clean value.
398        if allow_partial {
399            // Find where the last complete parameter match ended
400            let last_match_end = self
401                .parameter_complete_regex
402                .find_iter(invoke_content)
403                .last()
404                .map(|m| m.end())
405                .unwrap_or(0);
406
407            let remaining = &invoke_content[last_match_end..];
408            let cleaned = strip_dsml_trailing(remaining, &self.parameter_end_tag);
409
410            if let Some(cap) = self.partial_parameter_regex.captures(&cleaned) {
411                let name = cap.get(1).map_or("", |m| m.as_str());
412                let is_string = cap.get(2).map_or("true", |m| m.as_str());
413                // Strip EOS before trimming — `strip_dsml_trailing` above only
414                // handles `</|DSML|parameter>` prefixes, so a truncated turn
415                // with `value<EOS>` would otherwise stream EOS as arg bytes.
416                let value = cap.get(3).map_or("", |m| m.as_str()).replace(EOS_TOKEN, "");
417
418                // Only add if we have actual content and this param isn't already
419                // complete. A `string="true"` value is taken raw, exactly as the
420                // complete path takes it: trimming a partial value would make the
421                // streamed bytes diverge from the finished value and the delta
422                // offsets slice it mid-character. A `string="false"` value is
423                // held back until its text parses as JSON: emitting an unfinished
424                // `[1, true, nul` as a raw string would start the delta stream
425                // with a quote the finished array never has, corrupting every
426                // later delta.
427                if !value.is_empty() && !params.contains_key(name) {
428                    let json_value = if is_string == "true" {
429                        Some(Value::String(value.clone()))
430                    } else {
431                        serde_json::from_str(value.trim()).ok()
432                    };
433                    if let Some(json_value) = json_value {
434                        params.insert(name.to_string(), json_value);
435                    }
436                }
437            }
438        }
439
440        self.dump_arguments(params)
441    }
442
443    /// Parse a single complete invoke block into a ToolCall
444    fn parse_invoke(&self, name: &str, content: &str) -> ToolCall {
445        let arguments = self.parse_parameters_from_dsml(content, false);
446
447        ToolCall {
448            function: FunctionCall {
449                name: name.trim().to_string(),
450                arguments,
451            },
452        }
453    }
454
455    /// V4.1 one-shot parse: content is everything before the first tool
456    /// marker minus the blank-line separator; every complete invoke after it
457    /// is a call (inside a block or not); text after the section is dropped.
458    fn parse_complete_v41(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
459        let idx = self
460            .first_tool_marker(text)
461            .ok_or_else(|| ParserError::ParsingFailed("DSML marker not found".to_string()))?;
462        let normal_text = Self::v41_content(&text[..idx]);
463        let tools = self
464            .invoke_complete_regex
465            .captures_iter(&text[idx..])
466            .map(|cap| {
467                let name = cap.get(1).map_or("", |m| m.as_str());
468                let body = cap.get(2).map_or("", |m| m.as_str());
469                self.parse_invoke(name, body)
470            })
471            .collect();
472        Ok((normal_text, tools))
473    }
474
475    /// V4.1 streaming. Before the tool section, content is flushed as it
476    /// arrives, holding back only a suffix that could still become the
477    /// separator plus a tool opener (so `"\n\n"` right before a block is
478    /// stripped exactly as in the one-shot path; a blank line at the very end
479    /// of a stream with no tool call is held and never flushed). From the
480    /// first tool marker on, everything belongs to the tool section: invokes
481    /// stream as deltas and any other text is dropped.
482    fn parse_incremental_v41(&mut self) -> StreamingParseResult {
483        let mut normal_text = String::new();
484        if !self.in_tool_section {
485            match self.first_tool_marker(&self.buffer) {
486                Some(idx) => {
487                    normal_text = Self::v41_content(&self.buffer[..idx]);
488                    self.buffer.drain(..idx);
489                    self.in_tool_section = true;
490                }
491                None => {
492                    let with_separator_block = format!("{V41_BLOCK_SEPARATOR}{}", self.block_open);
493                    let with_separator_invoke =
494                        format!("{V41_BLOCK_SEPARATOR}{}", self.invoke_open_prefix);
495                    let keep = longest_partial_suffix(
496                        &self.buffer,
497                        &[
498                            with_separator_block.as_str(),
499                            with_separator_invoke.as_str(),
500                            self.block_open.as_str(),
501                            self.invoke_open_prefix.as_str(),
502                        ],
503                    );
504                    let emit_end = self.buffer.len() - keep;
505                    normal_text = self.buffer[..emit_end].replace(EOS_TOKEN, "");
506                    self.buffer.drain(..emit_end);
507                    return StreamingParseResult {
508                        normal_text,
509                        calls: vec![],
510                    };
511                }
512            }
513        }
514        let calls = self.stream_invokes(None);
515        StreamingParseResult { normal_text, calls }
516    }
517
518    /// Stream every invoke currently in the buffer as name/argument deltas.
519    /// `allowed_names` filters unknown tool names (V3.2/V4); `None` forwards
520    /// every named invoke (V4.1, like the reference engines).
521    fn stream_invokes(
522        &mut self,
523        allowed_names: Option<&HashMap<String, usize>>,
524    ) -> Vec<ToolCallItem> {
525        let mut all_calls: Vec<ToolCallItem> = Vec::new();
526
527        // Process invoke blocks in a loop (handles multiple complete invokes in buffer)
528        loop {
529            let buf_snapshot = self.buffer.clone();
530            let invoke_match = self.invoke_regex.captures(&buf_snapshot);
531
532            let captures = match invoke_match {
533                Some(c) => c,
534                None => break,
535            };
536
537            let func_name = captures
538                .get(1)
539                .map_or(String::new(), |m| m.as_str().trim().to_string());
540            let invoke_content = captures
541                .get(2)
542                .map_or(String::new(), |m| m.as_str().to_string());
543            let is_complete = captures
544                .get(3)
545                .is_some_and(|m| m.as_str().contains(self.invoke_end_tag.as_str()));
546            let match_end = captures.get(0).map(|m| m.end());
547            drop(captures);
548
549            // Skip if tool name is absent or (when filtering) not in the
550            // provided tools list. Empty names reach this branch because
551            // `invoke_regex` allows `name=""` (quantifier `*` not `+`); the
552            // loosened regex + this guard together ensure a malformed
553            // `name=""` block is advanced past instead of trapping the buffer.
554            let name_invalid = func_name.is_empty()
555                || allowed_names.is_some_and(|names| !names.contains_key(func_name.as_str()));
556            if name_invalid {
557                tracing::debug!("Invalid tool name '{}' - skipping", func_name);
558                if is_complete {
559                    // Complete invalid invoke — advance buffer past it and try next
560                    if let Some(end) = match_end {
561                        self.buffer = self.buffer[end..].to_string();
562                    }
563                    continue;
564                } else {
565                    // Incomplete invalid invoke — reset state and wait for more data
566                    // Return any calls already collected from previous complete invokes
567                    helpers::reset_current_tool_state(
568                        &mut self.buffer,
569                        &mut self.current_tool_name_sent,
570                        &mut self.streamed_args_for_tool,
571                        &self.prev_tool_call_arr,
572                    );
573                    return all_calls;
574                }
575            }
576
577            // Initialize state on first tool
578            if self.current_tool_id == -1 {
579                self.current_tool_id = 0;
580                self.prev_tool_call_arr = Vec::new();
581                self.streamed_args_for_tool = vec![String::new()];
582            }
583
584            helpers::ensure_capacity(
585                self.current_tool_id,
586                &mut self.prev_tool_call_arr,
587                &mut self.streamed_args_for_tool,
588            );
589
590            // Emit tool name if not sent
591            if !self.current_tool_name_sent && !func_name.is_empty() {
592                all_calls.push(ToolCallItem {
593                    tool_index: self.current_tool_id as usize,
594                    name: Some(func_name.to_string()),
595                    parameters: String::new(),
596                });
597                self.current_tool_name_sent = true;
598
599                let tool_id = self.current_tool_id as usize;
600                if self.prev_tool_call_arr.len() <= tool_id {
601                    self.prev_tool_call_arr
602                        .resize_with(tool_id + 1, || Value::Null);
603                }
604                self.prev_tool_call_arr[tool_id] = json!({
605                    "name": func_name,
606                    "arguments": {},
607                });
608            }
609
610            // Parse current arguments (partial or complete)
611            let current_args = self.parse_parameters_from_dsml(&invoke_content, !is_complete);
612            let tool_id = self.current_tool_id as usize;
613
614            // Compute diff against what we've already sent
615            let sent_len = self
616                .streamed_args_for_tool
617                .get(tool_id)
618                .map(|s| s.len())
619                .unwrap_or(0);
620
621            // The snapshot the previous pass streamed against; before any
622            // pass it is the empty object, so the first partial snapshot is
623            // diffed like every later one and only the prefix two consecutive
624            // snapshots agree on is streamed. Emitting a first snapshot whole
625            // would send its closing brace and a still-growing value (a
626            // `string="false"` number, a `string="true"` body) ahead of bytes
627            // that later snapshots change, and the completion delta would then
628            // append a tail that no longer lines up with what was sent.
629            let prev_args = self
630                .prev_tool_call_arr
631                .get(tool_id)
632                .and_then(|prev| prev.get("arguments"))
633                .and_then(Value::as_str)
634                .unwrap_or("{}")
635                .to_string();
636
637            let argument_diff = if is_complete {
638                if sent_len < current_args.len() {
639                    Some(current_args.get(sent_len..).unwrap_or_default().to_string())
640                } else {
641                    Some(String::new())
642                }
643            } else if current_args == prev_args {
644                None
645            } else {
646                let prefix = helpers::find_common_prefix(&prev_args, &current_args);
647                if prefix.len() > sent_len {
648                    Some(prefix.get(sent_len..).unwrap_or_default().to_string())
649                } else {
650                    None
651                }
652            };
653
654            if let Some(diff) = argument_diff {
655                if !diff.is_empty() {
656                    if tool_id < self.streamed_args_for_tool.len() {
657                        self.streamed_args_for_tool[tool_id].push_str(&diff);
658                    }
659                    all_calls.push(ToolCallItem {
660                        tool_index: tool_id,
661                        name: None,
662                        parameters: diff,
663                    });
664                }
665            }
666
667            // Update prev state
668            if tool_id < self.prev_tool_call_arr.len() {
669                self.prev_tool_call_arr[tool_id] = json!({
670                    "name": func_name,
671                    "arguments": current_args,
672                });
673            }
674
675            // If invoke is complete, advance to next tool
676            if is_complete {
677                if let Some(end) = match_end {
678                    self.buffer = self.buffer[end..].to_string();
679                } else {
680                    self.buffer.clear();
681                }
682                self.current_tool_id += 1;
683                self.current_tool_name_sent = false;
684                continue;
685            } else {
686                break;
687            }
688        }
689
690        all_calls
691    }
692
693    /// The V4.1 tool-call grammar as an xgrammar structural tag, mirroring
694    /// the serving engine's `deepseek_v41` builder for a forced tool choice
695    /// (`tool_choice: required` or a named function): a blank line, the
696    /// `<|DSML| calls>` block, at least one invoke of a listed tool, and the
697    /// block close. Each parameter constrains the DSML syntax only — a
698    /// `string="true"` body is any text without a closing tag, a
699    /// `string="false"` body is any JSON value; parameter names and schemas
700    /// are not lowered (the engine's builder leaves that as a TODO too). `at_least_one` is
701    /// wired to `tool_choice` by the registry; like the Kimi builders,
702    /// `stop_after_first` is left unset so a single listed tool may still be
703    /// called more than once.
704    pub fn build_v41_structural_tag(tools: &[Tool], at_least_one: bool) -> Value {
705        let dialect = DsmlDialect::V41;
706        let calls_start = format!("<{DSML_TOKEN}{}>", dialect.block());
707        let calls_end = format!("</{DSML_TOKEN}{}>", dialect.block());
708        let invoke_end = format!("</{DSML_TOKEN}{}>", dialect.invoke());
709        let parameter_end = format!("</{DSML_TOKEN}{}>", dialect.parameter());
710
711        let parameter = json!({
712            "type": "tag",
713            "begin": format!("<{DSML_TOKEN}{} name=\"", dialect.parameter()),
714            "content": {
715                "type": "sequence",
716                "elements": [
717                    { "type": "regex", "pattern": "[^\"]+" },
718                    { "type": "const_string", "value": "\" string=\"" },
719                    {
720                        "type": "or",
721                        "elements": [
722                            {
723                                "type": "sequence",
724                                "elements": [
725                                    { "type": "const_string", "value": "true\">" },
726                                    {
727                                        "type": "any_text",
728                                        "excludes": [parameter_end, invoke_end, calls_end],
729                                    },
730                                ],
731                            },
732                            {
733                                "type": "sequence",
734                                "elements": [
735                                    { "type": "const_string", "value": "false\">" },
736                                    { "type": "json_schema", "json_schema": true },
737                                ],
738                            },
739                        ],
740                    },
741                ],
742            },
743            "end": format!("{parameter_end}\n"),
744        });
745
746        let invokes: Vec<Value> = tools
747            .iter()
748            .filter(|tool| !tool.function.name.is_empty())
749            .map(|tool| {
750                json!({
751                    "type": "tag",
752                    "begin": format!(
753                        "<{DSML_TOKEN}{} name=\"{}\">\n",
754                        dialect.invoke(),
755                        tool.function.name
756                    ),
757                    "content": { "type": "star", "content": parameter },
758                    "end": format!("{invoke_end}\n"),
759                })
760            })
761            .collect();
762
763        json!({
764            "format": {
765                "type": "sequence",
766                "elements": [
767                    { "type": "const_string", "value": format!("{V41_BLOCK_SEPARATOR}{calls_start}\n") },
768                    {
769                        "type": "tags_with_separator",
770                        "tags": invokes,
771                        "separator": "",
772                        "at_least_one": at_least_one,
773                    },
774                    { "type": "const_string", "value": calls_end },
775                ],
776            }
777        })
778    }
779}
780
781// Intentionally no `Default` impl — callers must pick `v32()`, `v4()` or `v41()`.
782
783#[async_trait]
784impl ToolParser for DeepSeekDsmlParser {
785    async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
786        if !self.has_tool_markers(text) {
787            return Ok((text.to_string(), vec![]));
788        }
789
790        if self.dialect.is_v41() {
791            return self.parse_complete_v41(text);
792        }
793
794        let idx = text
795            .find(self.block_open.as_str())
796            .ok_or_else(|| ParserError::ParsingFailed("DSML marker not found".to_string()))?;
797        let normal_text = text[..idx].trim_end().to_string();
798
799        let mut tools = Vec::new();
800
801        for fc_cap in self.tool_call_complete_regex.captures_iter(text) {
802            let fc_content = fc_cap.get(1).map_or("", |m| m.as_str());
803
804            for inv_cap in self.invoke_complete_regex.captures_iter(fc_content) {
805                let func_name = inv_cap.get(1).map_or("", |m| m.as_str());
806                let invoke_content = inv_cap.get(2).map_or("", |m| m.as_str());
807
808                tools.push(self.parse_invoke(func_name, invoke_content));
809            }
810        }
811
812        if tools.is_empty() {
813            return Ok((normal_text, vec![]));
814        }
815
816        Ok((normal_text, tools))
817    }
818
819    async fn parse_incremental(
820        &mut self,
821        chunk: &str,
822        tools: &[Tool],
823    ) -> ParserResult<StreamingParseResult> {
824        self.buffer.push_str(chunk);
825
826        if self.dialect.is_v41() {
827            return Ok(self.parse_incremental_v41());
828        }
829
830        // The stream is in its tool section as soon as an opener's sentinel
831        // is anywhere in the buffer: live backends deliver the sentinel whole
832        // and the rest of the opener in per-token pieces (`<|DSML|` + `tool` +
833        // `_c` + `all` + `s` + `>`), so waiting for a complete opener would
834        // flush the sentinel as text and lose the tool call (regression test
835        // `test_deepseek_dsml_v4_streaming_bpe_chunked_opener`).
836        if !self.buffer.contains(DSML_OPEN) {
837            // Content, less the engine's own markup: complete end tags go
838            // (with the whitespace around a block close), and what could still
839            // become a tag, or pads one, waits for the next piece. The block
840            // close arrives in the same pieces as the opener; flushing them
841            // as they came put `</|DSML|tool_calls>` in the visible text.
842            let taken = std::mem::take(&mut self.buffer);
843            let text = self.without_end_tags(&taken);
844            let emit_end = text.len() - self.held_suffix_len(&text);
845            self.buffer = text[emit_end..].to_string();
846            return Ok(StreamingParseResult {
847                normal_text: text[..emit_end].to_string(),
848                calls: vec![],
849            });
850        }
851
852        // Text in front of the first opener, before any invoke has streamed,
853        // is the content before the block, read as the one-shot path reads it
854        // (trailing whitespace trimmed); between invokes it is the block's own
855        // newlines, which are nobody's.
856        let mut normal_text = String::new();
857        if self.current_tool_id == -1 {
858            if let Some(idx) = self.buffer.find(DSML_OPEN) {
859                normal_text = self.buffer[..idx].trim_end().replace(EOS_TOKEN, "");
860                self.buffer.drain(..idx);
861            }
862        }
863
864        let tool_indices = helpers::get_tool_indices(tools);
865        let calls = self.stream_invokes(Some(&tool_indices));
866
867        Ok(StreamingParseResult { normal_text, calls })
868    }
869
870    fn has_tool_markers(&self, text: &str) -> bool {
871        self.first_tool_marker(text).is_some()
872    }
873
874    fn get_unstreamed_tool_args(&self) -> Option<Vec<ToolCallItem>> {
875        // The tracked snapshot is the exact argument string the deltas were
876        // diffed against, so it is compared verbatim; the shared helper would
877        // re-serialise it as a quoted JSON string and never match.
878        let tool_index = self.prev_tool_call_arr.len().checked_sub(1)?;
879        let snapshot = self.prev_tool_call_arr[tool_index]
880            .get("arguments")?
881            .as_str()?;
882        let streamed = self.streamed_args_for_tool.get(tool_index)?;
883        let remaining = snapshot.strip_prefix(streamed.as_str())?;
884        (!remaining.is_empty()).then(|| {
885            vec![ToolCallItem {
886                tool_index,
887                name: None,
888                parameters: remaining.to_string(),
889            }]
890        })
891    }
892
893    fn take_unstreamed_normal_text(&mut self) -> String {
894        // V4.1 holds back a suffix that could still become the separator plus
895        // a tool opener; V3.2/V4 hold a suffix that could still become an
896        // opener's sentinel, an end tag or the EOS marker (with the whitespace
897        // before it, see `held_suffix_len`) and, once the DSML sentinel has
898        // arrived, everything from it on. At end of stream the held text is
899        // content when no tool syntax ever started (the trailing whitespace of
900        // a plain stream comes back here), and is dropped otherwise: a
901        // truncated opener or invoke is not content (the remaining arguments
902        // of a truncated invoke come from `get_unstreamed_tool_args`).
903        if self.in_tool_section || self.buffer.contains(DSML_OPEN) {
904            self.buffer.clear();
905            return String::new();
906        }
907        helpers::take_unstreamed_normal_text(&mut self.buffer, self.current_tool_id)
908            .replace(EOS_TOKEN, "")
909    }
910
911    fn reset(&mut self) {
912        self.buffer.clear();
913        self.prev_tool_call_arr.clear();
914        self.current_tool_id = -1;
915        self.current_tool_name_sent = false;
916        self.streamed_args_for_tool.clear();
917        self.in_tool_section = false;
918    }
919}