Skip to main content

tool_parser/parsers/
deepseek_dsml.rs

1use async_trait::async_trait;
2use openai_protocol::common::Tool;
3use regex::Regex;
4use serde_json::Value;
5
6use crate::{
7    errors::{ParserError, ParserResult},
8    parsers::helpers,
9    traits::ToolParser,
10    types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
11};
12
13/// DeepSeek DSML format parser for tool calls (V3.2 and V4).
14///
15/// Both variants share an identical invoke/parameter grammar and streaming
16/// state machine. They differ only in the outer block-name token:
17/// - V3.2: `function_calls`
18/// - V4:   `tool_calls`
19///
20/// ```text
21/// <|DSML|{block_name}>
22/// <|DSML|invoke name="func">
23/// <|DSML|parameter name="key" string="true">value</|DSML|parameter>
24/// </|DSML|invoke>
25/// </|DSML|{block_name}>
26/// ```
27///
28/// Also supports direct JSON inside invoke blocks as a fallback format.
29///
30/// References:
31/// - <https://huggingface.co/deepseek-ai/DeepSeek-V3.2>
32/// - <https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash>
33pub struct DeepSeekDsmlParser {
34    /// Outer block-name token: `function_calls` (V3.2) or `tool_calls` (V4).
35    block_name: &'static str,
36    /// Cached `<|DSML|{block_name}>` for marker-scan hot paths.
37    block_open: String,
38    /// Cached `</|DSML|{block_name}>` for streaming cleanup.
39    block_close: String,
40
41    /// Regex for extracting full outer-block content
42    tool_call_complete_regex: Regex,
43    /// Regex for extracting complete invoke blocks (name + body)
44    invoke_complete_regex: Regex,
45    /// Regex for extracting complete parameter tags (name, string attr, value)
46    parameter_complete_regex: Regex,
47    /// Regex for matching partial parameter tag during streaming (no closing tag)
48    partial_parameter_regex: Regex,
49    /// Regex for matching invoke blocks (complete or partial, for streaming)
50    invoke_regex: Regex,
51
52    /// Buffer for accumulating incomplete patterns across chunks
53    buffer: String,
54    /// Stores complete tool call info for each tool being parsed
55    prev_tool_call_arr: Vec<Value>,
56    /// Index of currently streaming tool call (-1 means no active tool)
57    current_tool_id: i32,
58    /// Flag for whether current tool's name has been sent to client
59    current_tool_name_sent: bool,
60    /// Tracks raw JSON string content streamed to client for each tool's arguments
61    streamed_args_for_tool: Vec<String>,
62}
63
64/// Full DSML closing tags for suffix-based stripping during streaming.
65const DSML_PARAMETER_END_TAG: &str = "</|DSML|parameter>";
66const DSML_INVOKE_END_TAG: &str = "</|DSML|invoke>";
67
68/// DeepSeek end-of-sentence marker. Some engines emit this as raw text at the
69/// end of a truncated turn; it must never bleed into tool-call argument bytes.
70const EOS_TOKEN: &str = "<|end▁of▁sentence|>";
71
72/// Strip a trailing partial DSML closing tag from a string.
73///
74/// If the string ends with a prefix of `closing_tag` (e.g. `"Tokyo</|DSML|para"`
75/// ends with a prefix of `"</|DSML|parameter>"`), that trailing portion is removed.
76/// Unlike character-set stripping, this only removes text that actually starts
77/// the specified closing tag, so legitimate value bytes are preserved.
78fn strip_dsml_trailing(s: &str, closing_tag: &str) -> String {
79    for (idx, _) in s.char_indices() {
80        if closing_tag.starts_with(&s[idx..]) {
81            return s[..idx].to_string();
82        }
83    }
84    s.to_string()
85}
86
87impl DeepSeekDsmlParser {
88    /// Create a DeepSeek V3.2 parser (outer block token `function_calls`).
89    pub fn v32() -> Self {
90        Self::new("function_calls")
91    }
92
93    /// Create a DeepSeek V4 parser (outer block token `tool_calls`).
94    pub fn v4() -> Self {
95        Self::new("tool_calls")
96    }
97
98    /// Which block name this instance parses (`function_calls` or `tool_calls`).
99    pub fn block_name(&self) -> &'static str {
100        self.block_name
101    }
102
103    #[expect(
104        clippy::expect_used,
105        reason = "regex patterns are compile-time string literals"
106    )]
107    fn new(block_name: &'static str) -> Self {
108        // `regex::escape` is not needed — both known block names are
109        // `[a-z_]`-only. If a future variant introduces regex metacharacters,
110        // wrap with `regex::escape(block_name)` before interpolation.
111        let tool_call_complete_regex = Regex::new(&format!(
112            r"(?s)<|DSML|{block_name}>(.*?)</|DSML|{block_name}>"
113        ))
114        .expect("Valid regex pattern");
115
116        let invoke_complete_regex =
117            Regex::new(r#"(?s)<|DSML|invoke\s+name="([^"]+)"\s*>(.*?)</|DSML|invoke>"#)
118                .expect("Valid regex pattern");
119
120        let parameter_complete_regex = Regex::new(
121            r#"(?s)<|DSML|parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>(.*?)</|DSML|parameter>"#,
122        )
123        .expect("Valid regex pattern");
124
125        let partial_parameter_regex = Regex::new(
126            r#"(?s)<|DSML|parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>(.*)$"#,
127        )
128        .expect("Valid regex pattern");
129
130        // `[^"]*` (not `+`) so a malformed `name=""` still matches and can be
131        // advanced past by the empty/invalid-name handling in `parse_incremental`.
132        // Without this, a bad `name=""` invoke would stall the buffer forever
133        // and suppress every subsequent delta in the same stream.
134        let invoke_regex =
135            Regex::new(r#"(?s)<|DSML|invoke\s+name="([^"]*)"\s*>(.*?)(</|DSML|invoke>|$)"#)
136                .expect("Valid regex pattern");
137
138        Self {
139            block_name,
140            block_open: format!("<|DSML|{block_name}>"),
141            block_close: format!("</|DSML|{block_name}>"),
142            tool_call_complete_regex,
143            invoke_complete_regex,
144            parameter_complete_regex,
145            partial_parameter_regex,
146            invoke_regex,
147            buffer: String::new(),
148            prev_tool_call_arr: Vec::new(),
149            current_tool_id: -1,
150            current_tool_name_sent: false,
151            streamed_args_for_tool: Vec::new(),
152        }
153    }
154
155    /// Parse DSML parameters from invoke content into a JSON string.
156    ///
157    /// Supports two formats:
158    /// 1. Direct JSON: content starts with `{` — returned as-is
159    /// 2. XML parameters: `<|DSML|parameter name="k" string="true|false">v</|DSML|parameter>`
160    ///
161    /// When `allow_partial` is true (streaming), also matches open parameter tags
162    /// and strips trailing DSML fragments.
163    fn parse_parameters_from_dsml(&self, invoke_content: &str, allow_partial: bool) -> String {
164        let trimmed = invoke_content.trim();
165
166        // Direct JSON path
167        if trimmed.starts_with('{') {
168            if allow_partial {
169                // `strip_dsml_trailing` handles partial `</|DSML|invoke>` prefixes
170                // but can't match the EOS sentinel (different prefix). Strip it
171                // unconditionally so a truncated turn doesn't leak EOS into args.
172                return strip_dsml_trailing(trimmed, DSML_INVOKE_END_TAG).replace(EOS_TOKEN, "");
173            } else if trimmed.ends_with('}') {
174                return trimmed.to_string();
175            }
176        }
177
178        // XML parameter path
179        let mut params = serde_json::Map::new();
180
181        for cap in self.parameter_complete_regex.captures_iter(invoke_content) {
182            let name = cap.get(1).map_or("", |m| m.as_str());
183            let is_string = cap.get(2).map_or("true", |m| m.as_str());
184            // Strip any stray EOS marker — should never legitimately appear
185            // inside a closed parameter, but defend against malformed output.
186            let value = cap.get(3).map_or("", |m| m.as_str()).replace(EOS_TOKEN, "");
187
188            let json_value = if is_string == "true" {
189                Value::String(value.to_string())
190            } else {
191                serde_json::from_str(value.trim())
192                    .unwrap_or_else(|_| Value::String(value.to_string()))
193            };
194
195            params.insert(name.to_string(), json_value);
196        }
197
198        // Partial parameter matching for streaming
199        // Following SGLang: strip DSML fragments from remaining content BEFORE
200        // running the partial regex, so the regex captures a clean value.
201        if allow_partial {
202            // Find where the last complete parameter match ended
203            let last_match_end = self
204                .parameter_complete_regex
205                .find_iter(invoke_content)
206                .last()
207                .map(|m| m.end())
208                .unwrap_or(0);
209
210            let remaining = &invoke_content[last_match_end..];
211            let cleaned = strip_dsml_trailing(remaining, DSML_PARAMETER_END_TAG);
212
213            if let Some(cap) = self.partial_parameter_regex.captures(&cleaned) {
214                let name = cap.get(1).map_or("", |m| m.as_str());
215                let is_string = cap.get(2).map_or("true", |m| m.as_str());
216                // Strip EOS before trimming — `strip_dsml_trailing` above only
217                // handles `</|DSML|parameter>` prefixes, so a truncated turn
218                // with `value<EOS>` would otherwise stream EOS as arg bytes.
219                let value = cap.get(3).map_or("", |m| m.as_str()).replace(EOS_TOKEN, "");
220                let value = value.trim();
221
222                // Only add if we have actual content and this param isn't already complete
223                if !value.is_empty() && !params.contains_key(name) {
224                    let json_value = if is_string == "true" {
225                        Value::String(value.to_string())
226                    } else {
227                        serde_json::from_str(value)
228                            .unwrap_or_else(|_| Value::String(value.to_string()))
229                    };
230                    params.insert(name.to_string(), json_value);
231                }
232            }
233        }
234
235        serde_json::to_string(&Value::Object(params)).unwrap_or_else(|_| "{}".to_string())
236    }
237
238    /// Parse a single complete invoke block into a ToolCall
239    fn parse_invoke(&self, name: &str, content: &str) -> ToolCall {
240        let arguments = self.parse_parameters_from_dsml(content, false);
241
242        ToolCall {
243            function: FunctionCall {
244                name: name.trim().to_string(),
245                arguments,
246            },
247        }
248    }
249}
250
251// Intentionally no `Default` impl — callers must pick `v32()` or `v4()`.
252
253#[async_trait]
254impl ToolParser for DeepSeekDsmlParser {
255    async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
256        if !self.has_tool_markers(text) {
257            return Ok((text.to_string(), vec![]));
258        }
259
260        let idx = text
261            .find(self.block_open.as_str())
262            .ok_or_else(|| ParserError::ParsingFailed("DSML marker not found".to_string()))?;
263        let normal_text = text[..idx].trim_end().to_string();
264
265        let mut tools = Vec::new();
266
267        for fc_cap in self.tool_call_complete_regex.captures_iter(text) {
268            let fc_content = fc_cap.get(1).map_or("", |m| m.as_str());
269
270            for inv_cap in self.invoke_complete_regex.captures_iter(fc_content) {
271                let func_name = inv_cap.get(1).map_or("", |m| m.as_str());
272                let invoke_content = inv_cap.get(2).map_or("", |m| m.as_str());
273
274                tools.push(self.parse_invoke(func_name, invoke_content));
275            }
276        }
277
278        if tools.is_empty() {
279            return Ok((normal_text, vec![]));
280        }
281
282        Ok((normal_text, tools))
283    }
284
285    async fn parse_incremental(
286        &mut self,
287        chunk: &str,
288        tools: &[Tool],
289    ) -> ParserResult<StreamingParseResult> {
290        self.buffer.push_str(chunk);
291        let current_text = self.buffer.clone();
292
293        // Check for DSML markers or partial DSML prefixes.
294        //
295        // `<|DSML|` is a single BPE token in DeepSeek's tokenizer (id 128793),
296        // so real streams deliver it atomically. We flag the stream as DSML as
297        // soon as the sentinel appears anywhere in the buffer — we don't wait
298        // for a complete outer `<|DSML|{function,tool}_calls>` opener, because
299        // live backends chunk the opener into per-token pieces after the
300        // sentinel (e.g. `<|DSML|` + `tool` + `_c` + `all` + `s` + `>`).
301        // Without this, chunk 2 of a live stream would flush the buffer on the
302        // passthrough path and lose the sentinel, turning every subsequent
303        // chunk into plain text. (See regression test
304        // `test_deepseek_dsml_v4_streaming_bpe_chunked_opener`.)
305        let has_dsml = current_text.contains("<|DSML|");
306        let has_partial_prefix = current_text.ends_with('<')
307            || current_text.ends_with("<|")
308            || current_text.ends_with("</")
309            || current_text.ends_with("</|");
310
311        if !has_dsml && !has_partial_prefix {
312            let mut normal_text = std::mem::take(&mut self.buffer);
313            for end_token in [
314                self.block_close.as_str(),
315                DSML_INVOKE_END_TAG,
316                DSML_PARAMETER_END_TAG,
317                EOS_TOKEN,
318            ] {
319                normal_text = normal_text.replace(end_token, "");
320            }
321            return Ok(StreamingParseResult {
322                normal_text,
323                calls: vec![],
324            });
325        }
326
327        // If we have partial prefix but no actual DSML content, buffer and wait
328        if !has_dsml && has_partial_prefix {
329            return Ok(StreamingParseResult::default());
330        }
331
332        let tool_indices = helpers::get_tool_indices(tools);
333        let mut all_calls: Vec<ToolCallItem> = Vec::new();
334
335        // Process invoke blocks in a loop (handles multiple complete invokes in buffer)
336        loop {
337            let buf_snapshot = self.buffer.clone();
338            let invoke_match = self.invoke_regex.captures(&buf_snapshot);
339
340            let captures = match invoke_match {
341                Some(c) => c,
342                None => break,
343            };
344
345            let func_name = captures
346                .get(1)
347                .map_or(String::new(), |m| m.as_str().trim().to_string());
348            let invoke_content = captures
349                .get(2)
350                .map_or(String::new(), |m| m.as_str().to_string());
351            let is_complete = captures
352                .get(3)
353                .is_some_and(|m| m.as_str().contains("</|DSML|invoke>"));
354            let match_end = captures.get(0).map(|m| m.end());
355            drop(captures);
356
357            // Skip if tool name is absent or not in the provided tools list.
358            // Empty names reach this branch because `invoke_regex` allows
359            // `name=""` (quantifier `*` not `+`); the loosened regex + this
360            // guard together ensure a malformed `name=""` block is advanced
361            // past instead of trapping the buffer forever.
362            let name_invalid =
363                func_name.is_empty() || !tool_indices.contains_key(func_name.as_str());
364            if name_invalid {
365                tracing::debug!("Invalid tool name '{}' - skipping", func_name);
366                if is_complete {
367                    // Complete invalid invoke — advance buffer past it and try next
368                    if let Some(end) = match_end {
369                        self.buffer = self.buffer[end..].to_string();
370                    }
371                    continue;
372                } else {
373                    // Incomplete invalid invoke — reset state and wait for more data
374                    // Return any calls already collected from previous complete invokes
375                    helpers::reset_current_tool_state(
376                        &mut self.buffer,
377                        &mut self.current_tool_name_sent,
378                        &mut self.streamed_args_for_tool,
379                        &self.prev_tool_call_arr,
380                    );
381                    return Ok(StreamingParseResult {
382                        normal_text: String::new(),
383                        calls: all_calls,
384                    });
385                }
386            }
387
388            // Initialize state on first tool
389            if self.current_tool_id == -1 {
390                self.current_tool_id = 0;
391                self.prev_tool_call_arr = Vec::new();
392                self.streamed_args_for_tool = vec![String::new()];
393            }
394
395            helpers::ensure_capacity(
396                self.current_tool_id,
397                &mut self.prev_tool_call_arr,
398                &mut self.streamed_args_for_tool,
399            );
400
401            // Emit tool name if not sent
402            if !self.current_tool_name_sent && !func_name.is_empty() {
403                all_calls.push(ToolCallItem {
404                    tool_index: self.current_tool_id as usize,
405                    name: Some(func_name.to_string()),
406                    parameters: String::new(),
407                });
408                self.current_tool_name_sent = true;
409
410                let tool_id = self.current_tool_id as usize;
411                if self.prev_tool_call_arr.len() <= tool_id {
412                    self.prev_tool_call_arr
413                        .resize_with(tool_id + 1, || Value::Null);
414                }
415                self.prev_tool_call_arr[tool_id] = serde_json::json!({
416                    "name": func_name,
417                    "arguments": {},
418                });
419            }
420
421            // Parse current arguments (partial or complete)
422            let current_args = self.parse_parameters_from_dsml(&invoke_content, !is_complete);
423            let tool_id = self.current_tool_id as usize;
424
425            // Compute diff against what we've already sent
426            let sent_len = self
427                .streamed_args_for_tool
428                .get(tool_id)
429                .map(|s| s.len())
430                .unwrap_or(0);
431
432            let prev_args = if tool_id < self.prev_tool_call_arr.len() {
433                self.prev_tool_call_arr[tool_id]
434                    .get("arguments")
435                    .and_then(|v| v.as_str())
436                    .map(|s| s.to_string())
437            } else {
438                None
439            };
440
441            let argument_diff = if is_complete {
442                if sent_len < current_args.len() {
443                    Some(current_args[sent_len..].to_string())
444                } else {
445                    Some(String::new())
446                }
447            } else if let Some(prev) = &prev_args {
448                if current_args == *prev {
449                    None
450                } else {
451                    let prefix = helpers::find_common_prefix(prev, &current_args);
452                    if prefix.len() > sent_len {
453                        Some(prefix[sent_len..].to_string())
454                    } else {
455                        None
456                    }
457                }
458            } else if sent_len < current_args.len() && current_args != "{}" {
459                // First partial chunk — no prev_args yet, emit from sent_len.
460                // Skip empty "{}" to avoid corrupting the delta stream when the
461                // buffer ends right after <invoke> with no parameter content yet.
462                Some(current_args[sent_len..].to_string())
463            } else {
464                None
465            };
466
467            if let Some(diff) = argument_diff {
468                if !diff.is_empty() {
469                    if tool_id < self.streamed_args_for_tool.len() {
470                        self.streamed_args_for_tool[tool_id].push_str(&diff);
471                    }
472                    all_calls.push(ToolCallItem {
473                        tool_index: tool_id,
474                        name: None,
475                        parameters: diff,
476                    });
477                }
478            }
479
480            // Update prev state
481            if tool_id < self.prev_tool_call_arr.len() {
482                self.prev_tool_call_arr[tool_id] = serde_json::json!({
483                    "name": func_name,
484                    "arguments": current_args,
485                });
486            }
487
488            // If invoke is complete, advance to next tool
489            if is_complete {
490                if let Some(end) = match_end {
491                    self.buffer = self.buffer[end..].to_string();
492                } else {
493                    self.buffer.clear();
494                }
495                self.current_tool_id += 1;
496                self.current_tool_name_sent = false;
497                continue;
498            } else {
499                break;
500            }
501        }
502
503        Ok(StreamingParseResult {
504            normal_text: String::new(),
505            calls: all_calls,
506        })
507    }
508
509    fn has_tool_markers(&self, text: &str) -> bool {
510        text.contains(self.block_open.as_str())
511    }
512
513    fn get_unstreamed_tool_args(&self) -> Option<Vec<ToolCallItem>> {
514        helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool)
515    }
516
517    fn reset(&mut self) {
518        self.buffer.clear();
519        self.prev_tool_call_arr.clear();
520        self.current_tool_id = -1;
521        self.current_tool_name_sent = false;
522        self.streamed_args_for_tool.clear();
523    }
524}