use bytes::Bytes;
use futures::StreamExt;
use tokio::sync::mpsc;
use bamboo_llm::provider::LLMStream;
use bamboo_llm::types::LLMChunk;
use bamboo_metrics::{ForwardStatus, MetricsCollector};
use super::sse::{done_marker_bytes, error_chunk_bytes, openai_chunk_bytes};
use crate::handlers::llm_compat::usage::{build_estimated_usage, estimate_completion_tokens};
pub(super) struct StreamWorkerArgs {
pub(super) stream_result: LLMStream,
pub(super) tx: mpsc::Sender<Result<Bytes, anyhow::Error>>,
pub(super) model: String,
pub(super) metrics: MetricsCollector,
pub(super) forward_id: String,
pub(super) estimated_prompt_tokens: u64,
}
pub(super) fn spawn_stream_worker(args: StreamWorkerArgs) {
tokio::spawn(async move {
run_stream_worker(args).await;
});
}
async fn run_stream_worker(mut args: StreamWorkerArgs) {
let mut error_message: Option<String> = None;
let mut saw_done = false;
let mut streamed_text = String::new();
while let Some(chunk_result) = args.stream_result.next().await {
match chunk_result {
Ok(LLMChunk::ResponseId(_)) => {}
Ok(LLMChunk::Done) => {
saw_done = true;
if let Some(done_chunk) = openai_chunk_bytes(LLMChunk::Done, &args.model) {
if args.tx.send(Ok(done_chunk)).await.is_err() {
break;
}
}
break;
}
Ok(LLMChunk::Token(text)) => {
streamed_text.push_str(&text);
if let Some(chunk) = openai_chunk_bytes(LLMChunk::Token(text), &args.model) {
if args.tx.send(Ok(chunk)).await.is_err() {
break;
}
}
}
Ok(LLMChunk::ReasoningToken(_)) => {}
Ok(LLMChunk::ToolCalls(calls)) => {
if let Some(chunk) = openai_chunk_bytes(LLMChunk::ToolCalls(calls), &args.model) {
if args.tx.send(Ok(chunk)).await.is_err() {
break;
}
}
}
Ok(LLMChunk::ToolCallsIndexed(indexed)) => {
let calls = indexed.into_iter().map(|(_, call)| call).collect();
if let Some(chunk) = openai_chunk_bytes(LLMChunk::ToolCalls(calls), &args.model) {
if args.tx.send(Ok(chunk)).await.is_err() {
break;
}
}
}
Ok(LLMChunk::CacheUsage { .. }) | Ok(LLMChunk::UsageSummary { .. }) => {}
Err(error) => {
tracing::error!("Stream error: {}", error);
let message = error.to_string();
args.metrics.forward_completed(
args.forward_id.clone(),
chrono::Utc::now(),
None,
ForwardStatus::Error,
None,
Some(message.clone()),
);
error_message = Some(message);
break;
}
}
}
if let Some(message) = error_message {
let _ = args.tx.send(Ok(error_chunk_bytes(&message))).await;
let _ = args.tx.send(Ok(done_marker_bytes())).await;
} else {
if !saw_done {
if let Some(done_chunk) = openai_chunk_bytes(LLMChunk::Done, &args.model) {
let _ = args.tx.send(Ok(done_chunk)).await;
}
}
let _ = args.tx.send(Ok(done_marker_bytes())).await;
let completion_tokens = estimate_completion_tokens(&streamed_text);
args.metrics.forward_completed(
args.forward_id,
chrono::Utc::now(),
Some(200),
ForwardStatus::Success,
Some(build_estimated_usage(
args.estimated_prompt_tokens,
completion_tokens,
)),
None,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_llm::provider::LLMError;
use bamboo_metrics::storage::SqliteMetricsStorage;
use futures::stream;
use std::sync::Arc;
fn test_metrics() -> (MetricsCollector, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let storage = Arc::new(SqliteMetricsStorage::new(dir.path().join("metrics.db")));
(MetricsCollector::spawn(storage, 7), dir)
}
#[tokio::test]
async fn stream_error_emits_error_frame_before_done() {
let (metrics, _dir) = test_metrics();
let chunks: Vec<Result<LLMChunk, LLMError>> = vec![
Ok(LLMChunk::Token("partial".to_string())),
Err(LLMError::Stream("upstream boom".to_string())),
];
let stream_result: LLMStream = Box::pin(stream::iter(chunks));
let (tx, mut rx) = mpsc::channel(16);
run_stream_worker(StreamWorkerArgs {
stream_result,
tx,
model: "gpt-x".to_string(),
metrics,
forward_id: "fid".to_string(),
estimated_prompt_tokens: 0,
})
.await;
let mut frames = Vec::new();
while let Ok(item) = rx.try_recv() {
frames.push(String::from_utf8_lossy(&item.unwrap()).to_string());
}
assert!(
frames
.iter()
.any(|f| f.contains("\"error\"") && f.contains("upstream boom")),
"expected an error frame carrying the upstream message, got: {frames:?}"
);
assert_eq!(frames.last().map(String::as_str), Some("[DONE]"));
}
}