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