use std::collections::BTreeMap;
use super::{ChatStreamEvent, StreamCompletion, ToolCallDelta};
use crate::inference::types::{
AssistantMessage, ChatChoice, ChatResponse, FunctionCall, StopReason, ToolCall, UsageBlock,
};
#[derive(Debug, Default)]
struct PartialToolCall {
id: Option<String>,
name: Option<String>,
arguments: String,
}
#[derive(Debug, Default)]
pub struct StreamAssembly {
text: String,
calls: BTreeMap<usize, PartialToolCall>,
completion: Option<StreamCompletion>,
}
impl StreamAssembly {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, event: ChatStreamEvent) {
match event {
ChatStreamEvent::Delta(chunk) => self.text.push_str(&chunk),
ChatStreamEvent::ToolCall(delta) => self.merge_tool_call(delta),
ChatStreamEvent::Done(completion) => self.completion = Some(completion),
}
}
fn merge_tool_call(&mut self, delta: ToolCallDelta) {
let slot = self.calls.entry(delta.index).or_default();
if slot.id.is_none() && delta.id.is_some() {
slot.id = delta.id;
}
if slot.name.is_none() && delta.name.is_some() {
slot.name = delta.name;
}
slot.arguments.push_str(&delta.arguments);
}
pub fn text(&self) -> &str {
&self.text
}
pub fn into_response(self, id: impl Into<String>, model: impl Into<String>) -> ChatResponse {
let tool_calls: Vec<ToolCall> = self
.calls
.into_values()
.map(|c| ToolCall {
id: c.id.unwrap_or_default(),
kind: "function".to_string(),
function: FunctionCall {
name: c.name.unwrap_or_default(),
arguments: c.arguments,
},
})
.collect();
let content = if self.text.is_empty() {
None
} else {
Some(self.text)
};
let (finish_reason, usage) = match self.completion {
Some(done) => {
let u = done.usage;
let block = UsageBlock {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens(),
cache_read_input_tokens: u.cache_read_tokens,
cache_creation_input_tokens: u.cache_creation_tokens,
prompt_tokens_details: None,
cost: u.cost_usd,
};
(done.finish_reason.map(stop_reason_to_wire), block)
}
None => (None, UsageBlock::default()),
};
ChatResponse {
id: id.into(),
model: model.into(),
choices: vec![ChatChoice {
message: AssistantMessage {
content,
tool_calls,
},
finish_reason,
}],
usage,
}
}
}
fn stop_reason_to_wire(reason: StopReason) -> String {
match reason {
StopReason::Stop => "stop".to_string(),
StopReason::ToolCalls => "tool_calls".to_string(),
StopReason::Length => "length".to_string(),
StopReason::ContentFilter => "content_filter".to_string(),
StopReason::Other(other) => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inference::streaming::buffered_stream;
use crate::inference::types::Usage;
use futures_util::StreamExt;
#[test]
fn concatenates_text_deltas() {
let mut a = StreamAssembly::new();
a.push(ChatStreamEvent::Delta("Hel".into()));
a.push(ChatStreamEvent::Delta("lo, ".into()));
a.push(ChatStreamEvent::Delta("world".into()));
assert_eq!(a.text(), "Hello, world");
let resp = a.into_response("gen-1", "openai/gpt-4o-mini");
assert_eq!(resp.first_text().as_deref(), Some("Hello, world"));
assert_eq!(resp.id, "gen-1");
assert_eq!(resp.model, "openai/gpt-4o-mini");
}
#[test]
fn accumulates_fragmented_tool_call() {
let mut a = StreamAssembly::new();
a.push(ChatStreamEvent::ToolCall(ToolCallDelta {
index: 0,
id: Some("call_1".into()),
name: Some("read_file".into()),
arguments: "{\"path\":".into(),
}));
a.push(ChatStreamEvent::ToolCall(ToolCallDelta {
index: 0,
id: None,
name: None,
arguments: "\"src/".into(),
}));
a.push(ChatStreamEvent::ToolCall(ToolCallDelta {
index: 0,
id: None,
name: None,
arguments: "lib.rs\"}".into(),
}));
let resp = a.into_response("gen-2", "m");
let calls = resp.first_tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].kind, "function");
assert_eq!(calls[0].function.name, "read_file");
assert_eq!(calls[0].function.arguments, "{\"path\":\"src/lib.rs\"}");
assert!(resp.first_text().is_none());
}
#[test]
fn keeps_tool_call_slots_separate_and_ordered() {
let mut a = StreamAssembly::new();
a.push(ChatStreamEvent::ToolCall(ToolCallDelta {
index: 1,
id: Some("b".into()),
name: Some("second".into()),
arguments: "{}".into(),
}));
a.push(ChatStreamEvent::ToolCall(ToolCallDelta {
index: 0,
id: Some("a".into()),
name: Some("first".into()),
arguments: "{}".into(),
}));
let resp = a.into_response("id", "m");
let calls = resp.first_tool_calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].id, "a");
assert_eq!(calls[1].id, "b");
}
#[test]
fn carries_terminal_usage_and_finish_reason() {
let mut a = StreamAssembly::new();
a.push(ChatStreamEvent::Delta("ok".into()));
let mut usage = Usage::new(120, 30, 90, 10);
usage.cost_usd = Some(0.0042);
a.push(ChatStreamEvent::Done(StreamCompletion {
finish_reason: Some(StopReason::ToolCalls),
usage,
}));
let resp = a.into_response("gen-3", "m");
assert_eq!(resp.stop_reason(), Some(StopReason::ToolCalls));
assert_eq!(resp.usage.prompt_tokens, 120);
assert_eq!(resp.usage.completion_tokens, 30);
assert_eq!(resp.usage.cache_read_input_tokens, 90);
assert_eq!(resp.usage.cache_creation_input_tokens, 10);
assert_eq!(resp.usage.cost, Some(0.0042));
let normalized = resp.usage();
assert_eq!(normalized.cache_read_tokens, 90);
assert_eq!(normalized.cache_creation_tokens, 10);
}
#[test]
fn finish_reason_round_trips_through_wire() {
for reason in [
StopReason::Stop,
StopReason::ToolCalls,
StopReason::Length,
StopReason::ContentFilter,
StopReason::Other("provider_specific".into()),
] {
let mut a = StreamAssembly::new();
a.push(ChatStreamEvent::Done(StreamCompletion {
finish_reason: Some(reason.clone()),
usage: Usage::default(),
}));
let resp = a.into_response("id", "m");
assert_eq!(resp.stop_reason(), Some(reason));
}
}
#[test]
fn no_done_event_yields_defaults() {
let mut a = StreamAssembly::new();
a.push(ChatStreamEvent::Delta("partial".into()));
let resp = a.into_response("id", "m");
assert_eq!(resp.first_text().as_deref(), Some("partial"));
assert_eq!(resp.stop_reason(), None);
assert_eq!(resp.usage.prompt_tokens, 0);
}
#[tokio::test]
async fn buffered_stream_round_trips_through_assembly() {
let original = ChatResponse {
id: "gen-rt".into(),
model: "anthropic/claude-sonnet-4-5".into(),
choices: vec![ChatChoice {
message: AssistantMessage {
content: Some("hello".into()),
tool_calls: vec![ToolCall {
id: "call_x".into(),
kind: "function".into(),
function: FunctionCall {
name: "search".into(),
arguments: "{\"q\":\"rust\"}".into(),
},
}],
},
finish_reason: Some("tool_calls".into()),
}],
usage: UsageBlock {
prompt_tokens: 10,
completion_tokens: 4,
total_tokens: 14,
cache_read_input_tokens: 2,
cache_creation_input_tokens: 1,
prompt_tokens_details: None,
cost: Some(0.01),
},
};
let mut stream = buffered_stream(original.clone());
let mut assembly = StreamAssembly::new();
while let Some(event) = stream.next().await {
assembly.push(event.expect("buffered stream never errors"));
}
let rebuilt = assembly.into_response(original.id.clone(), original.model.clone());
assert_eq!(rebuilt.first_text(), original.first_text());
assert_eq!(rebuilt.first_tool_calls(), original.first_tool_calls());
assert_eq!(rebuilt.stop_reason(), original.stop_reason());
assert_eq!(rebuilt.usage(), original.usage());
}
}