Skip to content

Commit 7cfac6b

Browse files
authored
fix: slipping <tool_calls_end> tool call parsing for DeepSeek v3.1 (#3995)
Signed-off-by: Guan Luo <[email protected]>
1 parent 5528f3b commit 7cfac6b

File tree

4 files changed

+152
-9
lines changed

4 files changed

+152
-9
lines changed

lib/llm/tests/test_jail.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1792,6 +1792,123 @@ mod tests {
17921792
);
17931793
}
17941794

1795+
#[tokio::test]
1796+
async fn test_deepseek_v3_1() {
1797+
// DeepSeek v3.1 format with two tool calls encoded in special tags
1798+
let text = r#"<|tool▁calls▁begin|><|tool▁call▁begin|>get_current_weather<|tool▁sep|>{"location": "Berlin", "units": "metric"}<|tool▁call▁end|><|tool▁call▁begin|>get_weather_forecast<|tool▁sep|>{"location": "Berlin", "days": 7, "units": "imperial"}<|tool▁call▁end|><|tool▁call▁begin|>get_air_quality<|tool▁sep|>{"location": "Berlin", "radius": 50}<|tool▁call▁end|><|tool▁calls▁end|><|end▁of▁sentence|>"#;
1799+
1800+
let chunks = vec![create_mock_response_chunk(text.to_string(), 0)];
1801+
1802+
let input_stream = stream::iter(chunks);
1803+
1804+
let jail = JailedStream::builder()
1805+
.tool_call_parser("deepseek_v3_1")
1806+
.build();
1807+
let jailed_stream = jail.apply(input_stream);
1808+
let results: Vec<_> = jailed_stream.collect().await;
1809+
1810+
// Should have at least one output containing both analysis text and parsed tool call
1811+
assert!(!results.is_empty());
1812+
1813+
// Verify a tool call was parsed with expected name and args
1814+
let tool_call_idx = results
1815+
.iter()
1816+
.position(test_utils::has_tool_call)
1817+
.expect("Should have a tool call result");
1818+
test_utils::assert_tool_call(
1819+
&results[tool_call_idx],
1820+
"get_current_weather",
1821+
json!({"location": "Berlin", "units": "metric"}),
1822+
);
1823+
for result in results {
1824+
let Some(data) = result.data else {
1825+
continue;
1826+
};
1827+
for choice in data.choices {
1828+
if let Some(content) = choice.delta.content {
1829+
assert!(
1830+
!content.contains("<|tool▁calls▁end|>"),
1831+
"Should not contain deepseek special tokens in content"
1832+
);
1833+
}
1834+
}
1835+
}
1836+
}
1837+
1838+
#[tokio::test]
1839+
async fn test_deepseek_v3_1_chunk() {
1840+
// DeepSeek v3.1 format with two tool calls encoded in special tags
1841+
let text = r#"<|tool▁calls▁begin|><|tool▁call▁begin|>get_current_weather<|tool▁sep|>{"location": "Berlin", "units": "metric"}<|tool▁call▁end|><|tool▁call▁begin|>get_weather_forecast<|tool▁sep|>{"location": "Berlin", "days": 7, "units": "imperial"}<|tool▁call▁end|><|tool▁call▁begin|>get_air_quality<|tool▁sep|>{"location": "Berlin", "radius": 50}<|tool▁call▁end|><|tool▁calls▁end|><|end▁of▁sentence|>"#;
1842+
1843+
// Split text into words, treating angle-bracketed tokens as one word
1844+
let mut words = Vec::new();
1845+
let mut i = 0;
1846+
let chars: Vec<char> = text.chars().collect();
1847+
while i < chars.len() {
1848+
if chars[i] == '<' {
1849+
// Find the next '>'
1850+
if let Some(end) = chars[i..].iter().position(|&c| c == '>') {
1851+
let word: String = chars[i..=i + end].iter().collect();
1852+
words.push(word);
1853+
i += end + 1;
1854+
} else {
1855+
// Malformed, just push the rest
1856+
words.push(chars[i..].iter().collect());
1857+
break;
1858+
}
1859+
} else if chars[i].is_whitespace() {
1860+
i += 1;
1861+
} else {
1862+
// Collect until next whitespace or '<'
1863+
let start = i;
1864+
while i < chars.len() && !chars[i].is_whitespace() && chars[i] != '<' {
1865+
i += 1;
1866+
}
1867+
words.push(chars[start..i].iter().collect());
1868+
}
1869+
}
1870+
1871+
let chunks = words
1872+
.into_iter()
1873+
.map(|word| create_mock_response_chunk(word, 0))
1874+
.collect::<Vec<_>>();
1875+
1876+
let input_stream = stream::iter(chunks);
1877+
1878+
let jail = JailedStream::builder()
1879+
.tool_call_parser("deepseek_v3_1")
1880+
.build();
1881+
let jailed_stream = jail.apply(input_stream);
1882+
let results: Vec<_> = jailed_stream.collect().await;
1883+
1884+
// Should have at least one output containing both analysis text and parsed tool call
1885+
assert!(!results.is_empty());
1886+
1887+
// Verify a tool call was parsed with expected name and args
1888+
let tool_call_idx = results
1889+
.iter()
1890+
.position(test_utils::has_tool_call)
1891+
.expect("Should have a tool call result");
1892+
test_utils::assert_tool_call(
1893+
&results[tool_call_idx],
1894+
"get_current_weather",
1895+
json!({"location": "Berlin", "units": "metric"}),
1896+
);
1897+
for result in results {
1898+
let Some(data) = result.data else {
1899+
continue;
1900+
};
1901+
for choice in data.choices {
1902+
if let Some(content) = choice.delta.content {
1903+
assert!(
1904+
!content.contains("<|tool▁calls▁end|>"),
1905+
"Should not contain deepseek special tokens in content"
1906+
);
1907+
}
1908+
}
1909+
}
1910+
}
1911+
17951912
#[tokio::test]
17961913
async fn test_jailed_stream_mistral_false_positive_curly() {
17971914
// Curly brace in normal text should not trigger tool call detection for mistral

lib/parsers/src/tool_calling/config.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,16 +153,22 @@ impl ToolCallConfig {
153153
}
154154

155155
pub fn deepseek_v3_1() -> Self {
156+
// The whole tool calls block is wrapped between
157+
// <|tool▁calls▁begin|> ... <|tool▁calls▁end|>
158+
// regardless of number of tool calls. For external use of this
159+
// config, we want them to only be operating on the whole block,
160+
// so the tool parser can properly consume all tool call tokens.
161+
// https://huggingface.co/deepseek-ai/DeepSeek-V3.1#toolcall
156162
Self {
157163
format: ToolCallParserType::Json,
158164
json: JsonParserConfig {
159165
tool_call_start_tokens: vec![
160166
"<|tool▁calls▁begin|>".to_string(),
161-
"<|tool▁call▁begin|>".to_string(),
167+
// "<|tool▁call▁begin|>".to_string(),
162168
],
163169
tool_call_end_tokens: vec![
164170
"<|tool▁calls▁end|>".to_string(),
165-
"<|tool▁call▁end|>".to_string(),
171+
// "<|tool▁call▁end|>".to_string(),
166172
],
167173
tool_call_separator_tokens: vec!["<|tool▁sep|>".to_string()],
168174
parser_type: JsonParserType::DeepseekV31,

lib/parsers/src/tool_calling/json/deepseek_parser.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,28 @@ pub fn parse_tool_calls_deepseek_v3_1(
126126
return Ok((vec![], Some(String::new())));
127127
}
128128

129-
let tool_call_start_tokens = &config.tool_call_start_tokens;
130-
let tool_call_end_tokens = &config.tool_call_end_tokens;
129+
// For DeepSeek_v3_1, we consider the tool call block to be
130+
// <|tool▁calls▁begin|>...<|tool▁calls▁end|> and only start parsing
131+
// if seeing <|tool▁calls▁begin|>, even though the individual calls are
132+
// parsed by <|tool▁call▁begin|>...<|tool▁call▁end|>.
133+
// This is because if we start parsing by considering all call(s) tokens,
134+
// we are not properly grouping the tool calls and results in groups:
135+
// 1. <|tool▁calls▁begin|><|tool▁call▁begin|>...<|tool▁call▁end|>
136+
// 2. <|tool▁calls▁end|>
137+
// where 2. will not be recognized as part of the tool call block due
138+
// to missing start token and will not be consumed.
139+
let has_end_token = config
140+
.tool_call_end_tokens
141+
.iter()
142+
.any(|token| !token.is_empty() && trimmed.contains(token));
143+
if !has_end_token {
144+
return Ok((vec![], Some(trimmed.to_string())));
145+
}
146+
147+
let mut tool_call_start_tokens = config.tool_call_start_tokens.clone();
148+
tool_call_start_tokens.extend(vec!["<|tool▁call▁begin|>".to_string()]);
149+
let mut tool_call_end_tokens = config.tool_call_end_tokens.clone();
150+
tool_call_end_tokens.extend(vec!["<|tool▁call▁end|>".to_string()]);
131151
let separator_tokens = &config.tool_call_separator_tokens;
132152

133153
// Early exit if no tokens configured
@@ -166,7 +186,7 @@ pub fn parse_tool_calls_deepseek_v3_1(
166186
};
167187

168188
// Extract individual tool call blocks
169-
let blocks = extract_tool_call_blocks(trimmed, tool_call_start_tokens, tool_call_end_tokens);
189+
let blocks = extract_tool_call_blocks(trimmed, &tool_call_start_tokens, &tool_call_end_tokens);
170190

171191
if blocks.is_empty() {
172192
// Found start token but no valid blocks
@@ -398,7 +418,7 @@ mod detect_parser_tests {
398418
let text = r#"<|tool▁call▁begin|>get_current_weather宽带}"#;
399419
let config = ToolCallConfig::deepseek_v3_1().json;
400420
let result = detect_tool_call_start_deepseek_v3_1(text, &config);
401-
assert!(result);
421+
assert!(!result);
402422
}
403423

404424
#[test]

lib/parsers/src/tool_calling/parsers.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2413,15 +2413,15 @@ mod detect_parser_tests {
24132413
}
24142414

24152415
#[test]
2416-
fn test_e2e_detect_tool_call_start_deepseek_v3_1() {
2416+
fn test_e2e_detect_incomplete_tool_call_start_deepseek_v3_1() {
24172417
let text =
24182418
r#"<|tool▁call▁begin|>get_current_weather{"location": "Tokyo"}<|tool▁call▁end|>"#;
24192419
let result = detect_tool_call_start(text, Some("deepseek_v3_1")).unwrap();
2420-
assert!(result);
2420+
assert!(!result);
24212421
}
24222422

24232423
#[test]
2424-
fn test_e2e_detect_tool_call_multiple_start_deepseek_v3_1() {
2424+
fn test_e2e_detect_tool_call_start_deepseek_v3_1() {
24252425
let text = r#"<|tool▁calls▁begin|><|tool▁call▁begin|>get_current_weather{"location": "Tokyo"}<|tool▁call▁end|>"#;
24262426
let result = detect_tool_call_start(text, Some("deepseek_v3_1")).unwrap();
24272427
assert!(result);

0 commit comments

Comments
 (0)