1use serde::Deserialize;
4
5use crate::error::{LlmError, Result};
6use crate::types::{AssistantBlock, FinishReason, FunctionCall, ToolCall, Usage};
7use af_context::ToolCallId;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct StreamDelta {
12 pub content: String,
14 pub has_tool_calls: bool,
16}
17
18#[derive(Debug, Default)]
19pub(crate) struct StreamAssembler {
20 id: String,
21 content: String,
22 tool_slots: Vec<Option<ToolCallBuilder>>,
23 usage: Option<Usage>,
24 finish_reason: Option<FinishReason>,
25 output_blocks: Vec<AssistantBlock>,
26 saw_choice: bool,
27}
28
29#[derive(Debug, Default, Clone)]
30struct ToolCallBuilder {
31 id: String,
32 kind: String,
33 name: String,
34 arguments: String,
35}
36
37impl StreamAssembler {
38 pub fn apply_json(&mut self, data: &str) -> Result<Option<StreamDelta>> {
39 let chunk: StreamChunk = serde_json::from_str(data)?;
40 if !chunk.id.is_empty() {
41 self.id = chunk.id;
42 }
43 if let Some(usage) = chunk.usage {
44 self.usage = Some(usage);
45 }
46
47 let mut content_changed = false;
48 for choice in chunk.choices {
49 if choice.index != 0 {
50 continue;
51 }
52 self.saw_choice = true;
53 if let Some(reason) = choice.finish_reason {
54 self.finish_reason = Some(reason);
55 }
56 let Some(delta) = choice.delta else {
57 continue;
58 };
59 self.output_blocks.extend(delta.output_blocks);
60 if let Some(piece) = delta.content.filter(|piece| !piece.is_empty()) {
61 self.content.push_str(&piece);
62 content_changed = true;
63 }
64 for tool_delta in delta.tool_calls.unwrap_or_default() {
65 self.merge_tool_delta(tool_delta);
66 }
67 }
68
69 Ok(content_changed.then(|| StreamDelta {
70 content: self.content.clone(),
71 has_tool_calls: self.has_tool_calls(),
72 }))
73 }
74
75 fn has_tool_calls(&self) -> bool {
76 self.tool_slots
77 .iter()
78 .flatten()
79 .any(|tool| !tool.id.is_empty() || !tool.name.is_empty() || !tool.arguments.is_empty())
80 }
81
82 fn merge_tool_delta(&mut self, delta: ToolCallDelta) {
83 let index = delta.index as usize;
84 if self.tool_slots.len() <= index {
85 self.tool_slots.resize_with(index + 1, || None);
86 }
87 let tool = self.tool_slots[index].get_or_insert_with(ToolCallBuilder::default);
88 if let Some(id) = delta.id.filter(|value| !value.is_empty()) {
89 tool.id = id;
90 }
91 if let Some(kind) = delta.kind.filter(|value| !value.is_empty()) {
92 tool.kind = kind;
93 }
94 if let Some(function) = delta.function {
95 if let Some(name) = function.name.filter(|value| !value.is_empty()) {
96 merge_tool_name(&mut tool.name, &name);
97 }
98 if let Some(arguments) = function.arguments {
99 tool.arguments.push_str(&arguments);
100 }
101 }
102 }
103
104 pub fn finish(self) -> Result<crate::types::CompletionResponse> {
105 use crate::types::{ChatMessage, Choice, CompletionResponse, Role};
106
107 if !self.saw_choice {
108 return Err(LlmError::StreamProtocol(
109 "stream completed without choice 0".into(),
110 ));
111 }
112 if self.finish_reason.is_none() {
113 return Err(LlmError::StreamProtocol(
114 "stream completed without finish_reason".into(),
115 ));
116 }
117
118 let tool_calls = self
119 .tool_slots
120 .into_iter()
121 .flatten()
122 .filter(|tool| !tool.name.is_empty() || !tool.arguments.is_empty())
123 .map(|tool| {
124 let id = ToolCallId::parse(tool.id)
125 .or_else(|_| {
126 ToolCallId::parse(format!(
127 "call_{:08x}",
128 stable_hash(&tool.name, &tool.arguments)
129 ))
130 })
131 .map_err(|error| LlmError::StreamProtocol(error.to_string()))?;
132 Ok(ToolCall {
133 id,
134 kind: if tool.kind.is_empty() {
135 "function".into()
136 } else {
137 tool.kind
138 },
139 function: FunctionCall {
140 name: tool.name,
141 arguments: tool.arguments,
142 },
143 })
144 })
145 .collect::<Result<Vec<_>>>()?;
146
147 Ok(CompletionResponse {
148 id: self.id,
149 choices: vec![Choice {
150 index: 0,
151 message: ChatMessage {
152 images: Vec::new(),
153 role: Role::Assistant,
154 content: (!self.content.is_empty()).then_some(self.content),
155 tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
156 tool_call_id: None,
157 name: None,
158 },
159 finish_reason: self.finish_reason,
160 output_blocks: self.output_blocks,
161 }],
162 usage: self.usage,
163 })
164 }
165}
166
167fn stable_hash(left: &str, right: &str) -> u32 {
168 let mut hash = 0x811c_9dc5u32;
169 for byte in left.bytes().chain(right.bytes()) {
170 hash ^= u32::from(byte);
171 hash = hash.wrapping_mul(0x0100_0193);
172 }
173 hash
174}
175
176fn merge_tool_name(current: &mut String, incoming: &str) {
177 if current.is_empty() {
178 current.push_str(incoming);
179 } else if incoming.starts_with(current.as_str()) {
180 *current = incoming.to_string();
181 } else if !current.starts_with(incoming) {
182 current.push_str(incoming);
183 }
184}
185
186#[derive(Debug, Deserialize)]
187struct StreamChunk {
188 #[serde(default)]
189 id: String,
190 #[serde(default)]
191 choices: Vec<StreamChoice>,
192 #[serde(default)]
193 usage: Option<Usage>,
194}
195
196#[derive(Debug, Deserialize)]
197struct StreamChoice {
198 #[serde(default)]
199 index: u32,
200 #[serde(default)]
201 delta: Option<DeltaBody>,
202 #[serde(default)]
203 finish_reason: Option<FinishReason>,
204}
205
206#[derive(Debug, Deserialize)]
207struct DeltaBody {
208 #[serde(default)]
209 content: Option<String>,
210 #[serde(default)]
211 tool_calls: Option<Vec<ToolCallDelta>>,
212 #[serde(default, alias = "content_blocks")]
213 output_blocks: Vec<AssistantBlock>,
214}
215
216#[derive(Debug, Deserialize)]
217struct ToolCallDelta {
218 #[serde(default)]
219 index: u32,
220 #[serde(default)]
221 id: Option<String>,
222 #[serde(default, rename = "type")]
223 kind: Option<String>,
224 #[serde(default)]
225 function: Option<ToolFunctionDelta>,
226}
227
228#[derive(Debug, Deserialize)]
229struct ToolFunctionDelta {
230 #[serde(default)]
231 name: Option<String>,
232 #[serde(default)]
233 arguments: Option<String>,
234}
235
236#[derive(Debug, Default)]
240pub(crate) struct SseDecoder {
241 buffer: Vec<u8>,
242 data_lines: Vec<String>,
243}
244
245impl SseDecoder {
246 pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<String>> {
247 self.buffer.extend_from_slice(chunk);
248 let mut events = Vec::new();
249 while let Some(newline) = self.buffer.iter().position(|byte| *byte == b'\n') {
250 let mut line = self.buffer.drain(..=newline).collect::<Vec<_>>();
251 line.pop();
252 if line.last() == Some(&b'\r') {
253 line.pop();
254 }
255 let line = String::from_utf8(line)?;
256 if line.is_empty() {
257 if !self.data_lines.is_empty() {
258 events.push(self.data_lines.join("\n"));
259 self.data_lines.clear();
260 }
261 } else if let Some(data) = line.strip_prefix("data:") {
262 self.data_lines
263 .push(data.strip_prefix(' ').unwrap_or(data).to_string());
264 }
265 }
266 Ok(events)
267 }
268
269 pub fn finish(self) -> Result<()> {
270 if self.buffer.is_empty() && self.data_lines.is_empty() {
271 return Ok(());
272 }
273 String::from_utf8(self.buffer)?;
274 Err(LlmError::StreamProtocol(
275 "stream ended with an incomplete SSE frame".into(),
276 ))
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn assembles_content_and_tool_calls() {
286 let mut assembler = StreamAssembler::default();
287 assert!(assembler
288 .apply_json(r#"{"id":"chatcmpl-1","choices":[{"delta":{"content":"Hi"}}]}"#,)
289 .unwrap()
290 .is_some());
291 assembler
292 .apply_json(r#"{"choices":[{"delta":{"content":" there"}}]}"#)
293 .unwrap();
294 assembler.apply_json(
295 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup_weather","arguments":""}}]}}]}"#,
296 ).unwrap();
297 assembler.apply_json(
298 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"Paris\"}"}}]}}]}"#,
299 ).unwrap();
300 assembler
301 .apply_json(r#"{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}"#)
302 .unwrap();
303
304 let response = assembler.finish().unwrap();
305 assert_eq!(response.first_content(), Some("Hi there"));
306 let calls = response.first_tool_calls().unwrap();
307 assert_eq!(calls[0].function.name, "lookup_weather");
308 assert_eq!(calls[0].function.arguments, r#"{"city":"Paris"}"#);
309 }
310
311 #[test]
312 fn drains_lf_and_crlf_frames() {
313 let mut decoder = SseDecoder::default();
314 assert_eq!(
315 decoder
316 .push(b"data: {\"a\":1}\r\n\r\ndata: [DONE]\n\npartial")
317 .unwrap(),
318 [r#"{"a":1}"#, "[DONE]"]
319 );
320 assert!(matches!(decoder.finish(), Err(LlmError::StreamProtocol(_))));
321 }
322
323 #[test]
324 fn joins_multiline_data_and_preserves_usage() {
325 let mut decoder = SseDecoder::default();
326 let frames = decoder
327 .push(b"data: {\"id\":\"x\",\"choices\":[\r\ndata: {\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}],\r\ndata: \"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3}}\r\n\r\n")
328 .unwrap();
329 let mut assembler = StreamAssembler::default();
330 assembler.apply_json(&frames[0]).unwrap();
331 let response = assembler.finish().unwrap();
332 assert_eq!(response.first_content(), Some("hi"));
333 assert_eq!(response.usage.unwrap().total_tokens, 3);
334 }
335
336 #[test]
337 fn streams_typed_output_blocks_and_rejects_unknown_kinds() {
338 let mut assembler = StreamAssembler::default();
339 assembler.apply_json(r#"{"choices":[{"delta":{"output_blocks":[{"type":"citation","resource_id":"doc-1","label":"Doc","uri":"docs://doc-1"}]},"finish_reason":"stop"}]}"#).unwrap();
340 let response = assembler.finish().unwrap();
341 assert!(matches!(
342 &response.choices[0].output_blocks[0],
343 AssistantBlock::Citation { resource_id, .. } if resource_id == "doc-1"
344 ));
345 assert!(StreamAssembler::default()
346 .apply_json(
347 r#"{"choices":[{"delta":{"output_blocks":[{"type":"html","html":"bad"}]}}]}"#
348 )
349 .is_err());
350 }
351
352 #[test]
353 fn malformed_json_and_utf8_fail_closed() {
354 assert!(StreamAssembler::default().apply_json("{").is_err());
355 let mut decoder = SseDecoder::default();
356 assert!(matches!(
357 decoder.push(b"data: \xff\n\n"),
358 Err(LlmError::InvalidUtf8(_))
359 ));
360 }
361
362 #[test]
363 fn empty_and_unfinished_assemblies_fail_closed() {
364 assert!(matches!(
365 StreamAssembler::default().finish(),
366 Err(LlmError::StreamProtocol(_))
367 ));
368 let mut assembler = StreamAssembler::default();
369 assembler
370 .apply_json(r#"{"choices":[{"delta":{"content":"partial"}}]}"#)
371 .unwrap();
372 assert!(matches!(
373 assembler.finish(),
374 Err(LlmError::StreamProtocol(_))
375 ));
376 }
377
378 #[test]
379 fn repeated_tool_names_do_not_duplicate() {
380 let mut name = String::new();
381 merge_tool_name(&mut name, "fetch_report");
382 merge_tool_name(&mut name, "fetch_report");
383 assert_eq!(name, "fetch_report");
384 }
385
386 #[test]
387 fn incremental_tool_name_fragments_append() {
388 let mut name = String::new();
389 merge_tool_name(&mut name, "fetch_");
390 merge_tool_name(&mut name, "report");
391 assert_eq!(name, "fetch_report");
392 }
393}