use std::collections::BTreeMap;
use crate::types::{Delta, FunctionCall, ToolCall};
#[derive(Debug, Default)]
pub struct ToolCallAccumulator {
by_index: BTreeMap<u32, PartialCall>,
}
#[derive(Debug, Default)]
struct PartialCall {
id: Option<String>,
kind: Option<String>,
name: Option<String>,
arguments: String,
}
impl ToolCallAccumulator {
pub fn new() -> Self {
Self::default()
}
pub fn push_delta(&mut self, delta: &Delta) {
let Some(calls) = delta.tool_calls.as_ref() else {
return;
};
for call in calls {
let idx = call.index.unwrap_or(0);
let entry = self.by_index.entry(idx).or_default();
if entry.id.is_none() && !call.id.is_empty() {
entry.id = Some(call.id.clone());
}
if entry.kind.is_none() && !call.kind.is_empty() {
entry.kind = Some(call.kind.clone());
}
if let Some(name) = call.function.name.as_deref() {
if entry.name.is_none() && !name.is_empty() {
entry.name = Some(name.to_string());
}
}
if let Some(args) = call.function.arguments.as_deref() {
entry.arguments.push_str(args);
}
}
}
pub fn finish(self) -> Vec<ToolCall> {
self.by_index
.into_iter()
.map(|(idx, p)| ToolCall {
id: p.id.unwrap_or_default(),
kind: p.kind.unwrap_or_else(|| "function".to_string()),
function: FunctionCall {
name: p.name,
arguments: Some(p.arguments),
},
index: Some(idx),
})
.collect()
}
pub fn len(&self) -> usize {
self.by_index.len()
}
pub fn is_empty(&self) -> bool {
self.by_index.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Delta, FunctionCall, ToolCall};
use pretty_assertions::assert_eq;
fn chunk(index: u32, id: &str, name: Option<&str>, args: &str) -> Delta {
Delta {
role: None,
content: None,
tool_calls: Some(vec![ToolCall {
id: id.to_string(),
kind: if id.is_empty() {
String::new()
} else {
"function".to_string()
},
function: FunctionCall {
name: name.map(str::to_string),
arguments: Some(args.to_string()),
},
index: Some(index),
}]),
reasoning: None,
}
}
#[test]
fn accumulates_split_arguments() {
let mut acc = ToolCallAccumulator::new();
acc.push_delta(&chunk(0, "call_1", Some("get_weather"), "{\"loc"));
acc.push_delta(&chunk(0, "", None, "ation\":\"SF\"}"));
let calls = acc.finish();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].function.name.as_deref(), Some("get_weather"));
assert_eq!(
calls[0].function.arguments.as_deref(),
Some("{\"location\":\"SF\"}")
);
}
#[test]
fn separates_calls_by_index() {
let mut acc = ToolCallAccumulator::new();
acc.push_delta(&chunk(0, "a", Some("f1"), "{}"));
acc.push_delta(&chunk(1, "b", Some("f2"), "{\"x\":1}"));
let calls = acc.finish();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].id, "a");
assert_eq!(calls[1].id, "b");
assert_eq!(calls[1].function.name.as_deref(), Some("f2"));
}
#[test]
fn ignores_deltas_without_tool_calls() {
let mut acc = ToolCallAccumulator::new();
let d = Delta {
content: Some("hi".to_string()),
..Delta::default()
};
acc.push_delta(&d);
assert!(acc.is_empty());
assert!(acc.finish().is_empty());
}
}