"...ssh:/git@developer.sourcefind.cn:2222/OpenDAS/dynamo.git" did not exist on "8dfed1731093b61770c47731a56aad561dcc43b9"
Unverified Commit 7cfac6b8 authored by GuanLuo's avatar GuanLuo Committed by GitHub
Browse files

fix: slipping <tool_calls_end> tool call parsing for DeepSeek v3.1 (#3995)


Signed-off-by: default avatarGuan Luo <41310872+GuanLuo@users.noreply.github.com>
parent 5528f3b4
...@@ -1792,6 +1792,123 @@ mod tests { ...@@ -1792,6 +1792,123 @@ mod tests {
); );
} }
#[tokio::test]
async fn test_deepseek_v3_1() {
// DeepSeek v3.1 format with two tool calls encoded in special tags
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|>"#;
let chunks = vec![create_mock_response_chunk(text.to_string(), 0)];
let input_stream = stream::iter(chunks);
let jail = JailedStream::builder()
.tool_call_parser("deepseek_v3_1")
.build();
let jailed_stream = jail.apply(input_stream);
let results: Vec<_> = jailed_stream.collect().await;
// Should have at least one output containing both analysis text and parsed tool call
assert!(!results.is_empty());
// Verify a tool call was parsed with expected name and args
let tool_call_idx = results
.iter()
.position(test_utils::has_tool_call)
.expect("Should have a tool call result");
test_utils::assert_tool_call(
&results[tool_call_idx],
"get_current_weather",
json!({"location": "Berlin", "units": "metric"}),
);
for result in results {
let Some(data) = result.data else {
continue;
};
for choice in data.choices {
if let Some(content) = choice.delta.content {
assert!(
!content.contains("<|tool▁calls▁end|>"),
"Should not contain deepseek special tokens in content"
);
}
}
}
}
#[tokio::test]
async fn test_deepseek_v3_1_chunk() {
// DeepSeek v3.1 format with two tool calls encoded in special tags
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|>"#;
// Split text into words, treating angle-bracketed tokens as one word
let mut words = Vec::new();
let mut i = 0;
let chars: Vec<char> = text.chars().collect();
while i < chars.len() {
if chars[i] == '<' {
// Find the next '>'
if let Some(end) = chars[i..].iter().position(|&c| c == '>') {
let word: String = chars[i..=i + end].iter().collect();
words.push(word);
i += end + 1;
} else {
// Malformed, just push the rest
words.push(chars[i..].iter().collect());
break;
}
} else if chars[i].is_whitespace() {
i += 1;
} else {
// Collect until next whitespace or '<'
let start = i;
while i < chars.len() && !chars[i].is_whitespace() && chars[i] != '<' {
i += 1;
}
words.push(chars[start..i].iter().collect());
}
}
let chunks = words
.into_iter()
.map(|word| create_mock_response_chunk(word, 0))
.collect::<Vec<_>>();
let input_stream = stream::iter(chunks);
let jail = JailedStream::builder()
.tool_call_parser("deepseek_v3_1")
.build();
let jailed_stream = jail.apply(input_stream);
let results: Vec<_> = jailed_stream.collect().await;
// Should have at least one output containing both analysis text and parsed tool call
assert!(!results.is_empty());
// Verify a tool call was parsed with expected name and args
let tool_call_idx = results
.iter()
.position(test_utils::has_tool_call)
.expect("Should have a tool call result");
test_utils::assert_tool_call(
&results[tool_call_idx],
"get_current_weather",
json!({"location": "Berlin", "units": "metric"}),
);
for result in results {
let Some(data) = result.data else {
continue;
};
for choice in data.choices {
if let Some(content) = choice.delta.content {
assert!(
!content.contains("<|tool▁calls▁end|>"),
"Should not contain deepseek special tokens in content"
);
}
}
}
}
#[tokio::test] #[tokio::test]
async fn test_jailed_stream_mistral_false_positive_curly() { async fn test_jailed_stream_mistral_false_positive_curly() {
// Curly brace in normal text should not trigger tool call detection for mistral // Curly brace in normal text should not trigger tool call detection for mistral
......
...@@ -153,16 +153,22 @@ impl ToolCallConfig { ...@@ -153,16 +153,22 @@ impl ToolCallConfig {
} }
pub fn deepseek_v3_1() -> Self { pub fn deepseek_v3_1() -> Self {
// The whole tool calls block is wrapped between
// <|tool▁calls▁begin|> ... <|tool▁calls▁end|>
// regardless of number of tool calls. For external use of this
// config, we want them to only be operating on the whole block,
// so the tool parser can properly consume all tool call tokens.
// https://huggingface.co/deepseek-ai/DeepSeek-V3.1#toolcall
Self { Self {
format: ToolCallParserType::Json, format: ToolCallParserType::Json,
json: JsonParserConfig { json: JsonParserConfig {
tool_call_start_tokens: vec![ tool_call_start_tokens: vec![
"<|tool▁calls▁begin|>".to_string(), "<|tool▁calls▁begin|>".to_string(),
"<|tool▁call▁begin|>".to_string(), // "<|tool▁call▁begin|>".to_string(),
], ],
tool_call_end_tokens: vec![ tool_call_end_tokens: vec![
"<|tool▁calls▁end|>".to_string(), "<|tool▁calls▁end|>".to_string(),
"<|tool▁call▁end|>".to_string(), // "<|tool▁call▁end|>".to_string(),
], ],
tool_call_separator_tokens: vec!["<|tool▁sep|>".to_string()], tool_call_separator_tokens: vec!["<|tool▁sep|>".to_string()],
parser_type: JsonParserType::DeepseekV31, parser_type: JsonParserType::DeepseekV31,
......
...@@ -126,8 +126,28 @@ pub fn parse_tool_calls_deepseek_v3_1( ...@@ -126,8 +126,28 @@ pub fn parse_tool_calls_deepseek_v3_1(
return Ok((vec![], Some(String::new()))); return Ok((vec![], Some(String::new())));
} }
let tool_call_start_tokens = &config.tool_call_start_tokens; // For DeepSeek_v3_1, we consider the tool call block to be
let tool_call_end_tokens = &config.tool_call_end_tokens; // <|tool▁calls▁begin|>...<|tool▁calls▁end|> and only start parsing
// if seeing <|tool▁calls▁begin|>, even though the individual calls are
// parsed by <|tool▁call▁begin|>...<|tool▁call▁end|>.
// This is because if we start parsing by considering all call(s) tokens,
// we are not properly grouping the tool calls and results in groups:
// 1. <|tool▁calls▁begin|><|tool▁call▁begin|>...<|tool▁call▁end|>
// 2. <|tool▁calls▁end|>
// where 2. will not be recognized as part of the tool call block due
// to missing start token and will not be consumed.
let has_end_token = config
.tool_call_end_tokens
.iter()
.any(|token| !token.is_empty() && trimmed.contains(token));
if !has_end_token {
return Ok((vec![], Some(trimmed.to_string())));
}
let mut tool_call_start_tokens = config.tool_call_start_tokens.clone();
tool_call_start_tokens.extend(vec!["<|tool▁call▁begin|>".to_string()]);
let mut tool_call_end_tokens = config.tool_call_end_tokens.clone();
tool_call_end_tokens.extend(vec!["<|tool▁call▁end|>".to_string()]);
let separator_tokens = &config.tool_call_separator_tokens; let separator_tokens = &config.tool_call_separator_tokens;
// Early exit if no tokens configured // Early exit if no tokens configured
...@@ -166,7 +186,7 @@ pub fn parse_tool_calls_deepseek_v3_1( ...@@ -166,7 +186,7 @@ pub fn parse_tool_calls_deepseek_v3_1(
}; };
// Extract individual tool call blocks // Extract individual tool call blocks
let blocks = extract_tool_call_blocks(trimmed, tool_call_start_tokens, tool_call_end_tokens); let blocks = extract_tool_call_blocks(trimmed, &tool_call_start_tokens, &tool_call_end_tokens);
if blocks.is_empty() { if blocks.is_empty() {
// Found start token but no valid blocks // Found start token but no valid blocks
...@@ -398,7 +418,7 @@ mod detect_parser_tests { ...@@ -398,7 +418,7 @@ mod detect_parser_tests {
let text = r#"<|tool▁call▁begin|>get_current_weather宽带}"#; let text = r#"<|tool▁call▁begin|>get_current_weather宽带}"#;
let config = ToolCallConfig::deepseek_v3_1().json; let config = ToolCallConfig::deepseek_v3_1().json;
let result = detect_tool_call_start_deepseek_v3_1(text, &config); let result = detect_tool_call_start_deepseek_v3_1(text, &config);
assert!(result); assert!(!result);
} }
#[test] #[test]
......
...@@ -2413,15 +2413,15 @@ mod detect_parser_tests { ...@@ -2413,15 +2413,15 @@ mod detect_parser_tests {
} }
#[test] #[test]
fn test_e2e_detect_tool_call_start_deepseek_v3_1() { fn test_e2e_detect_incomplete_tool_call_start_deepseek_v3_1() {
let text = let text =
r#"<|tool▁call▁begin|>get_current_weather{"location": "Tokyo"}<|tool▁call▁end|>"#; r#"<|tool▁call▁begin|>get_current_weather{"location": "Tokyo"}<|tool▁call▁end|>"#;
let result = detect_tool_call_start(text, Some("deepseek_v3_1")).unwrap(); let result = detect_tool_call_start(text, Some("deepseek_v3_1")).unwrap();
assert!(result); assert!(!result);
} }
#[test] #[test]
fn test_e2e_detect_tool_call_multiple_start_deepseek_v3_1() { fn test_e2e_detect_tool_call_start_deepseek_v3_1() {
let text = r#"<|tool▁calls▁begin|><|tool▁call▁begin|>get_current_weather{"location": "Tokyo"}<|tool▁call▁end|>"#; let text = r#"<|tool▁calls▁begin|><|tool▁call▁begin|>get_current_weather{"location": "Tokyo"}<|tool▁call▁end|>"#;
let result = detect_tool_call_start(text, Some("deepseek_v3_1")).unwrap(); let result = detect_tool_call_start(text, Some("deepseek_v3_1")).unwrap();
assert!(result); assert!(result);
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment