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