Skip to main content

llm_stream_accumulator/
lib.rs

1/*!
2llm-stream-accumulator: accumulate LLM streaming chunks into a final response.
3
4```rust
5use llm_stream_accumulator::StreamAccumulator;
6
7let mut acc = StreamAccumulator::new();
8acc.push_text("Hello");
9acc.push_text(", world");
10acc.push_text("!");
11assert_eq!(acc.text(), "Hello, world!");
12assert!(!acc.is_complete());
13acc.finish();
14assert!(acc.is_complete());
15```
16*/
17
18/// A single streamed chunk.
19#[derive(Debug, Clone)]
20pub enum Chunk {
21    Text(String),
22    ToolUse { id: String, name: String, input_fragment: String },
23    InputTokens(u64),
24    OutputTokens(u64),
25    Stop(String),
26}
27
28/// Accumulated tool-use call.
29#[derive(Debug, Clone)]
30pub struct ToolCall {
31    pub id: String,
32    pub name: String,
33    pub input_json: String,
34}
35
36/// Accumulated result of a streaming LLM response.
37#[derive(Debug, Default)]
38pub struct StreamAccumulator {
39    text_parts: Vec<String>,
40    tool_calls: Vec<ToolCall>,
41    /// Current in-progress tool use id (last seen).
42    current_tool_id: Option<String>,
43    input_tokens: u64,
44    output_tokens: u64,
45    stop_reason: Option<String>,
46    complete: bool,
47}
48
49impl StreamAccumulator {
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Push a plain text delta.
55    pub fn push_text(&mut self, delta: &str) {
56        self.text_parts.push(delta.to_string());
57    }
58
59    /// Push a tool-use fragment.
60    pub fn push_tool_use(&mut self, id: &str, name: &str, input_fragment: &str) {
61        if self.current_tool_id.as_deref() != Some(id) {
62            self.tool_calls.push(ToolCall { id: id.to_string(), name: name.to_string(), input_json: String::new() });
63            self.current_tool_id = Some(id.to_string());
64        }
65        if let Some(tc) = self.tool_calls.iter_mut().find(|t| t.id == id) {
66            tc.input_json.push_str(input_fragment);
67        }
68    }
69
70    /// Push a generic chunk.
71    pub fn push(&mut self, chunk: Chunk) {
72        match chunk {
73            Chunk::Text(s) => self.push_text(&s),
74            Chunk::ToolUse { id, name, input_fragment } => self.push_tool_use(&id, &name, &input_fragment),
75            Chunk::InputTokens(n) => self.input_tokens += n,
76            Chunk::OutputTokens(n) => self.output_tokens += n,
77            Chunk::Stop(r) => { self.stop_reason = Some(r); self.complete = true; }
78        }
79    }
80
81    /// Mark stream as done.
82    pub fn finish(&mut self) {
83        self.complete = true;
84    }
85
86    /// Concatenated text.
87    pub fn text(&self) -> String {
88        self.text_parts.join("")
89    }
90
91    /// Completed tool calls.
92    pub fn tool_calls(&self) -> &[ToolCall] {
93        &self.tool_calls
94    }
95
96    pub fn input_tokens(&self) -> u64 { self.input_tokens }
97    pub fn output_tokens(&self) -> u64 { self.output_tokens }
98    pub fn stop_reason(&self) -> Option<&str> { self.stop_reason.as_deref() }
99    pub fn is_complete(&self) -> bool { self.complete }
100
101    /// True if any text was accumulated.
102    pub fn has_text(&self) -> bool { !self.text_parts.is_empty() }
103
104    /// True if any tool calls were accumulated.
105    pub fn has_tool_calls(&self) -> bool { !self.tool_calls.is_empty() }
106
107    /// Reset all accumulated state.
108    pub fn reset(&mut self) {
109        *self = Self::default();
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn accumulates_text() {
119        let mut acc = StreamAccumulator::new();
120        acc.push_text("Hello");
121        acc.push_text(", world");
122        assert_eq!(acc.text(), "Hello, world");
123    }
124
125    #[test]
126    fn finish_marks_complete() {
127        let mut acc = StreamAccumulator::new();
128        assert!(!acc.is_complete());
129        acc.finish();
130        assert!(acc.is_complete());
131    }
132
133    #[test]
134    fn stop_chunk_marks_complete() {
135        let mut acc = StreamAccumulator::new();
136        acc.push(Chunk::Stop("end_turn".to_string()));
137        assert!(acc.is_complete());
138        assert_eq!(acc.stop_reason(), Some("end_turn"));
139    }
140
141    #[test]
142    fn token_counts_accumulate() {
143        let mut acc = StreamAccumulator::new();
144        acc.push(Chunk::InputTokens(100));
145        acc.push(Chunk::InputTokens(50));
146        acc.push(Chunk::OutputTokens(200));
147        assert_eq!(acc.input_tokens(), 150);
148        assert_eq!(acc.output_tokens(), 200);
149    }
150
151    #[test]
152    fn tool_use_accumulates_fragments() {
153        let mut acc = StreamAccumulator::new();
154        acc.push_tool_use("t1", "search", r#"{"q": "#);
155        acc.push_tool_use("t1", "search", r#""rust"}"#);
156        assert_eq!(acc.tool_calls().len(), 1);
157        assert_eq!(acc.tool_calls()[0].input_json, r#"{"q": "rust"}"#);
158    }
159
160    #[test]
161    fn multiple_tool_calls() {
162        let mut acc = StreamAccumulator::new();
163        acc.push_tool_use("t1", "search", "{}");
164        acc.push_tool_use("t2", "fetch", "{}");
165        assert_eq!(acc.tool_calls().len(), 2);
166    }
167
168    #[test]
169    fn has_text_and_has_tool_calls() {
170        let mut acc = StreamAccumulator::new();
171        assert!(!acc.has_text());
172        assert!(!acc.has_tool_calls());
173        acc.push_text("hi");
174        assert!(acc.has_text());
175        acc.push_tool_use("t1", "fn", "{}");
176        assert!(acc.has_tool_calls());
177    }
178
179    #[test]
180    fn reset_clears_state() {
181        let mut acc = StreamAccumulator::new();
182        acc.push_text("hello");
183        acc.push(Chunk::InputTokens(100));
184        acc.reset();
185        assert!(acc.text().is_empty());
186        assert_eq!(acc.input_tokens(), 0);
187    }
188
189    #[test]
190    fn empty_text_by_default() {
191        let acc = StreamAccumulator::new();
192        assert_eq!(acc.text(), "");
193    }
194
195    #[test]
196    fn push_text_chunk() {
197        let mut acc = StreamAccumulator::new();
198        acc.push(Chunk::Text("chunk".to_string()));
199        assert_eq!(acc.text(), "chunk");
200    }
201
202    #[test]
203    fn stop_reason_none_before_stop() {
204        let acc = StreamAccumulator::new();
205        assert_eq!(acc.stop_reason(), None);
206    }
207
208    #[test]
209    fn tool_call_name_preserved() {
210        let mut acc = StreamAccumulator::new();
211        acc.push_tool_use("id1", "my_tool", "{}");
212        assert_eq!(acc.tool_calls()[0].name, "my_tool");
213    }
214
215    #[test]
216    fn interleaved_text_and_tool() {
217        let mut acc = StreamAccumulator::new();
218        acc.push_text("I'll search for ");
219        acc.push_tool_use("t1", "search", r#"{"q": "foo"}"#);
220        acc.push_text("Done.");
221        assert_eq!(acc.text(), "I'll search for Done.");
222        assert_eq!(acc.tool_calls().len(), 1);
223    }
224}