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, ¤t_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}