litellm_rs/core/streaming/
handler.rs1use super::types::{ChatCompletionChunk, ChatCompletionChunkChoice, ChatCompletionDelta, Event};
4use crate::core::models::openai::Usage;
5use crate::core::types::message::MessageRole;
6use crate::utils::ai::counter::token_counter::{TokenCounter, TokenizerIdentity};
7use crate::utils::error::gateway_error::Result;
8use bytes::Bytes;
9use futures::stream::{Stream, StreamExt};
10use serde_json::json;
11use tokio::sync::mpsc;
12use tokio_stream::wrappers::ReceiverStream;
13use tokio_util::sync::CancellationToken;
14use tracing::{error, info};
15use uuid::Uuid;
16
17pub struct StreamingHandler {
19 request_id: String,
21 pub(crate) model: String,
23 pub(crate) is_first_chunk: bool,
25 pub(crate) accumulated_content: String,
27}
28
29impl StreamingHandler {
30 pub fn new(model: String) -> Self {
32 Self {
33 request_id: format!("chatcmpl-{}", Uuid::new_v4()),
34 model,
35 is_first_chunk: true,
36 accumulated_content: String::new(),
37 }
38 }
39
40 pub fn create_sse_stream<S>(
46 mut self,
47 provider_stream: S,
48 cancel: Option<CancellationToken>,
49 tokenizer_identity: TokenizerIdentity,
50 ) -> impl Stream<Item = Result<Bytes>>
51 where
52 S: Stream<Item = Result<String>> + Send + 'static,
53 {
54 let (tx, rx) = mpsc::channel(100);
55
56 tokio::spawn(async move {
57 tokio::pin!(provider_stream);
58
59 loop {
60 let next_chunk = if let Some(ref token) = cancel {
61 tokio::select! {
62 biased;
63 _ = token.cancelled() => {
64 info!("streaming cancelled by client disconnect");
65 break;
66 }
67 chunk = provider_stream.next() => chunk,
68 }
69 } else {
70 provider_stream.next().await
71 };
72
73 let Some(chunk_result) = next_chunk else {
74 break;
75 };
76
77 match chunk_result {
78 Ok(chunk_data) => {
79 match self.process_chunk(&chunk_data).await {
80 Ok(Some(event)) => {
81 if tx.send(Ok(event.to_bytes())).await.is_err() {
82 break;
83 }
84 }
85 Ok(None) => continue, Err(e) => {
87 error!("Error processing chunk: {}", e);
88 let error_event = Event::default()
89 .event("error")
90 .data(&json!({"error": e.to_string()}).to_string());
91 let _ = tx.send(Ok(error_event.to_bytes())).await;
92 break;
93 }
94 }
95 }
96 Err(e) => {
97 error!("Provider stream error: {}", e);
98 let error_event = Event::default()
99 .event("error")
100 .data(&json!({"error": e.to_string()}).to_string());
101 let _ = tx.send(Ok(error_event.to_bytes())).await;
102 break;
103 }
104 }
105 }
106
107 match self.create_final_chunk(&tokenizer_identity).await {
109 Ok(final_event) => {
110 if tx.send(Ok(final_event.to_bytes())).await.is_err() {
111 return;
112 }
113 }
114 Err(error) => {
115 let _ = tx.send(Err(error)).await;
116 return;
117 }
118 }
119
120 let done_event = Event::default().data("[DONE]");
122 let _ = tx.send(Ok(done_event.to_bytes())).await;
123 });
124
125 ReceiverStream::new(rx)
126 }
127
128 async fn process_chunk(&mut self, chunk_data: &str) -> Result<Option<Event>> {
130 let content = self.extract_content_from_chunk(chunk_data)?;
132
133 if content.is_empty() {
134 return Ok(None);
135 }
136
137 self.accumulated_content.push_str(&content);
138
139 let chunk = ChatCompletionChunk {
140 id: self.request_id.clone(),
141 object: "chat.completion.chunk".to_string(),
142 created: chrono::Utc::now().timestamp() as u64,
143 model: self.model.clone(),
144 system_fingerprint: None,
145 choices: vec![ChatCompletionChunkChoice {
146 index: 0,
147 delta: ChatCompletionDelta {
148 role: if self.is_first_chunk {
149 Some(MessageRole::Assistant)
150 } else {
151 None
152 },
153 content: Some(content),
154 tool_calls: None,
155 ..Default::default()
156 },
157 finish_reason: None,
158 logprobs: None,
159 }],
160 usage: None,
161 };
162
163 self.is_first_chunk = false;
164
165 let event = Event::default().data(&serde_json::to_string(&chunk)?);
166
167 Ok(Some(event))
168 }
169
170 pub(crate) fn extract_content_from_chunk(&self, chunk_data: &str) -> Result<String> {
172 if chunk_data.starts_with("data: ") {
174 let data = chunk_data.strip_prefix("data: ").unwrap_or(chunk_data);
175
176 if data.trim() == "[DONE]" {
177 return Ok(String::new());
178 }
179
180 if let Ok(json_chunk) = serde_json::from_str::<serde_json::Value>(data) {
182 if let Some(choices) = json_chunk.get("choices").and_then(|c| c.as_array())
184 && let Some(choice) = choices.first()
185 && let Some(delta) = choice.get("delta")
186 && let Some(content) = delta.get("content").and_then(|c| c.as_str())
187 {
188 return Ok(content.to_string());
189 }
190
191 if let Some(delta) = json_chunk.get("delta")
193 && let Some(text) = delta.get("text").and_then(|t| t.as_str())
194 {
195 return Ok(text.to_string());
196 }
197
198 if let Some(text) = json_chunk.get("text").and_then(|t| t.as_str()) {
200 return Ok(text.to_string());
201 }
202 }
203 }
204
205 Ok(chunk_data.to_string())
207 }
208
209 async fn create_final_chunk(&self, tokenizer_identity: &TokenizerIdentity) -> Result<Event> {
211 let token_counter = TokenCounter::new();
213 let completion_tokens = token_counter
214 .count_completion_tokens(tokenizer_identity, &self.accumulated_content)?
215 .input_tokens;
216
217 let prompt_tokens = self.estimate_prompt_tokens();
220 let total_tokens = prompt_tokens + completion_tokens;
221
222 let usage = Usage {
223 prompt_tokens,
224 completion_tokens,
225 total_tokens,
226 prompt_tokens_details: None,
227 completion_tokens_details: None,
228 thinking_usage: None,
229 };
230
231 let final_chunk = ChatCompletionChunk {
232 id: self.request_id.clone(),
233 object: "chat.completion.chunk".to_string(),
234 created: chrono::Utc::now().timestamp() as u64,
235 model: self.model.clone(),
236 system_fingerprint: None,
237 choices: vec![ChatCompletionChunkChoice {
238 index: 0,
239 delta: ChatCompletionDelta {
240 role: None,
241 content: None,
242 tool_calls: None,
243 ..Default::default()
244 },
245 finish_reason: Some("stop".to_string()),
246 logprobs: None,
247 }],
248 usage: Some(usage),
249 };
250
251 let event = Event::default().data(&serde_json::to_string(&final_chunk)?);
252
253 Ok(event)
254 }
255
256 fn estimate_prompt_tokens(&self) -> u32 {
258 match self.model.as_str() {
262 m if m.contains("gpt-4") => 150,
263 m if m.contains("gpt-3.5") => 100,
264 m if m.contains("claude") => 200,
265 m if m.contains("gemini") => 120,
266 _ => 100,
267 }
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
278 fn test_streaming_handler_new() {
279 let handler = StreamingHandler::new("gpt-4".to_string());
280 assert_eq!(handler.model, "gpt-4");
281 assert!(handler.is_first_chunk);
282 assert!(handler.accumulated_content.is_empty());
283 assert!(handler.request_id.starts_with("chatcmpl-"));
284 }
285
286 #[test]
287 fn test_streaming_handler_new_different_models() {
288 let handler1 = StreamingHandler::new("gpt-3.5-turbo".to_string());
289 assert_eq!(handler1.model, "gpt-3.5-turbo");
290
291 let handler2 = StreamingHandler::new("claude-3-opus".to_string());
292 assert_eq!(handler2.model, "claude-3-opus");
293
294 let handler3 = StreamingHandler::new("gemini-pro".to_string());
295 assert_eq!(handler3.model, "gemini-pro");
296 }
297
298 #[test]
299 fn test_streaming_handler_unique_request_ids() {
300 let handler1 = StreamingHandler::new("gpt-4".to_string());
301 let handler2 = StreamingHandler::new("gpt-4".to_string());
302
303 assert_ne!(handler1.request_id, handler2.request_id);
304 }
305
306 #[test]
307 fn test_estimate_prompt_tokens_gpt4() {
308 let handler = StreamingHandler::new("gpt-4-turbo".to_string());
309 assert_eq!(handler.estimate_prompt_tokens(), 150);
310 }
311
312 #[test]
313 fn test_estimate_prompt_tokens_gpt35() {
314 let handler = StreamingHandler::new("gpt-3.5-turbo".to_string());
315 assert_eq!(handler.estimate_prompt_tokens(), 100);
316 }
317
318 #[test]
319 fn test_estimate_prompt_tokens_claude() {
320 let handler = StreamingHandler::new("claude-3-sonnet".to_string());
321 assert_eq!(handler.estimate_prompt_tokens(), 200);
322 }
323
324 #[test]
325 fn test_estimate_prompt_tokens_gemini() {
326 let handler = StreamingHandler::new("gemini-pro".to_string());
327 assert_eq!(handler.estimate_prompt_tokens(), 120);
328 }
329
330 #[test]
331 fn test_estimate_prompt_tokens_unknown() {
332 let handler = StreamingHandler::new("unknown-model".to_string());
333 assert_eq!(handler.estimate_prompt_tokens(), 100);
334 }
335
336 #[test]
339 fn test_extract_content_openai_format() {
340 let handler = StreamingHandler::new("gpt-4".to_string());
341 let chunk = r#"data: {"choices":[{"delta":{"content":"Hello"}}]}"#;
342 let result = handler.extract_content_from_chunk(chunk).unwrap();
343 assert_eq!(result, "Hello");
344 }
345
346 #[test]
347 fn test_extract_content_openai_format_with_role() {
348 let handler = StreamingHandler::new("gpt-4".to_string());
349 let chunk = r#"data: {"choices":[{"delta":{"role":"assistant","content":"World"}}]}"#;
350 let result = handler.extract_content_from_chunk(chunk).unwrap();
351 assert_eq!(result, "World");
352 }
353
354 #[test]
355 fn test_extract_content_anthropic_format() {
356 let handler = StreamingHandler::new("claude-3".to_string());
357 let chunk = r#"data: {"delta":{"text":"Bonjour"}}"#;
358 let result = handler.extract_content_from_chunk(chunk).unwrap();
359 assert_eq!(result, "Bonjour");
360 }
361
362 #[test]
363 fn test_extract_content_generic_text_field() {
364 let handler = StreamingHandler::new("custom-model".to_string());
365 let chunk = r#"data: {"text":"Generic text"}"#;
366 let result = handler.extract_content_from_chunk(chunk).unwrap();
367 assert_eq!(result, "Generic text");
368 }
369
370 #[test]
371 fn test_extract_content_done_signal() {
372 let handler = StreamingHandler::new("gpt-4".to_string());
373 let chunk = "data: [DONE]";
374 let result = handler.extract_content_from_chunk(chunk).unwrap();
375 assert!(result.is_empty());
376 }
377
378 #[test]
379 fn test_extract_content_done_signal_with_whitespace() {
380 let handler = StreamingHandler::new("gpt-4".to_string());
381 let chunk = "data: [DONE] ";
382 let result = handler.extract_content_from_chunk(chunk).unwrap();
383 assert!(result.is_empty());
384 }
385
386 #[test]
387 fn test_extract_content_plain_text_fallback() {
388 let handler = StreamingHandler::new("gpt-4".to_string());
389 let chunk = "Just plain text";
390 let result = handler.extract_content_from_chunk(chunk).unwrap();
391 assert_eq!(result, "Just plain text");
392 }
393
394 #[test]
395 fn test_extract_content_empty_delta() {
396 let handler = StreamingHandler::new("gpt-4".to_string());
397 let chunk = r#"data: {"choices":[{"delta":{}}]}"#;
398 let result = handler.extract_content_from_chunk(chunk).unwrap();
399 assert!(!result.is_empty()); }
402
403 #[test]
404 fn test_extract_content_empty_choices() {
405 let handler = StreamingHandler::new("gpt-4".to_string());
406 let chunk = r#"data: {"choices":[]}"#;
407 let result = handler.extract_content_from_chunk(chunk).unwrap();
408 assert!(!result.is_empty());
410 }
411
412 #[test]
413 fn test_extract_content_multiple_choices() {
414 let handler = StreamingHandler::new("gpt-4".to_string());
415 let chunk =
416 r#"data: {"choices":[{"delta":{"content":"First"}},{"delta":{"content":"Second"}}]}"#;
417 let result = handler.extract_content_from_chunk(chunk).unwrap();
418 assert_eq!(result, "First");
420 }
421
422 #[test]
423 fn test_extract_content_special_characters() {
424 let handler = StreamingHandler::new("gpt-4".to_string());
425 let chunk = r#"data: {"choices":[{"delta":{"content":"Hello\nWorld\t!"}}]}"#;
426 let result = handler.extract_content_from_chunk(chunk).unwrap();
427 assert_eq!(result, "Hello\nWorld\t!");
428 }
429
430 #[test]
431 fn test_extract_content_unicode_content() {
432 let handler = StreamingHandler::new("gpt-4".to_string());
433 let chunk = r#"data: {"choices":[{"delta":{"content":"こんにちは世界"}}]}"#;
434 let result = handler.extract_content_from_chunk(chunk).unwrap();
435 assert_eq!(result, "こんにちは世界");
436 }
437
438 #[test]
439 fn test_extract_content_empty_string() {
440 let handler = StreamingHandler::new("gpt-4".to_string());
441 let chunk = "";
442 let result = handler.extract_content_from_chunk(chunk).unwrap();
443 assert!(result.is_empty());
444 }
445
446 #[tokio::test]
449 async fn test_process_chunk_accumulates_content() {
450 let mut handler = StreamingHandler::new("gpt-4".to_string());
451
452 let chunk1 = r#"data: {"choices":[{"delta":{"content":"Hello "}}]}"#;
453 let _ = handler.process_chunk(chunk1).await;
454 assert_eq!(handler.accumulated_content, "Hello ");
455
456 let chunk2 = r#"data: {"choices":[{"delta":{"content":"World"}}]}"#;
457 let _ = handler.process_chunk(chunk2).await;
458 assert_eq!(handler.accumulated_content, "Hello World");
459 }
460
461 #[tokio::test]
462 async fn test_process_chunk_sets_first_chunk_flag() {
463 let mut handler = StreamingHandler::new("gpt-4".to_string());
464 assert!(handler.is_first_chunk);
465
466 let chunk = r#"data: {"choices":[{"delta":{"content":"Test"}}]}"#;
467 let _ = handler.process_chunk(chunk).await;
468 assert!(!handler.is_first_chunk);
469 }
470
471 #[tokio::test]
472 async fn test_process_chunk_empty_returns_none() {
473 let mut handler = StreamingHandler::new("gpt-4".to_string());
474 let chunk = "data: [DONE]";
475 let result = handler.process_chunk(chunk).await.unwrap();
476 assert!(result.is_none());
477 }
478
479 #[tokio::test]
480 async fn test_process_chunk_returns_event() {
481 let mut handler = StreamingHandler::new("gpt-4".to_string());
482 let chunk = r#"data: {"choices":[{"delta":{"content":"Hello"}}]}"#;
483 let result = handler.process_chunk(chunk).await.unwrap();
484 assert!(result.is_some());
485 }
486
487 #[tokio::test]
490 async fn test_process_chunk_returns_valid_json() {
491 let mut handler = StreamingHandler::new("gpt-4".to_string());
492 let chunk = r#"data: {"choices":[{"delta":{"content":"Test"}}]}"#;
493 let result = handler.process_chunk(chunk).await.unwrap();
494
495 if let Some(event) = result {
496 let bytes = event.to_bytes();
497 let event_str = String::from_utf8_lossy(&bytes);
498 assert!(event_str.contains("data:"));
500 assert!(event_str.contains("chat.completion.chunk"));
501 } else {
502 panic!("Expected Some event");
503 }
504 }
505
506 #[tokio::test]
507 async fn test_first_chunk_includes_role() {
508 let mut handler = StreamingHandler::new("gpt-4".to_string());
509 let chunk = r#"data: {"choices":[{"delta":{"content":"Hi"}}]}"#;
510
511 assert!(handler.is_first_chunk);
513 let result = handler.process_chunk(chunk).await.unwrap();
514
515 if let Some(event) = result {
516 let bytes = event.to_bytes();
517 let event_str = String::from_utf8_lossy(&bytes);
518 assert!(event_str.contains("assistant") || event_str.contains("role"));
520 }
521
522 assert!(!handler.is_first_chunk);
524 }
525
526 #[tokio::test]
529 async fn test_create_final_chunk() {
530 let mut handler = StreamingHandler::new("gpt-4".to_string());
531 handler.accumulated_content = "Hello world".to_string();
532
533 let result = handler
534 .create_final_chunk(&TokenizerIdentity::exact_openai("gpt-4"))
535 .await;
536 assert!(result.is_ok());
537
538 let event = result.unwrap();
539 let bytes = event.to_bytes();
540 let event_str = String::from_utf8_lossy(&bytes);
541
542 assert!(event_str.contains("finish_reason"));
543 assert!(event_str.contains("stop"));
544 assert!(event_str.contains("usage"));
545 }
546
547 #[tokio::test]
548 async fn test_create_final_chunk_includes_token_counts() {
549 let mut handler = StreamingHandler::new("gpt-4".to_string());
550 handler.accumulated_content = "This is a test response with some content.".to_string();
551
552 let result = handler
553 .create_final_chunk(&TokenizerIdentity::exact_openai("gpt-4"))
554 .await
555 .unwrap();
556 let bytes = result.to_bytes();
557 let event_str = String::from_utf8_lossy(&bytes);
558
559 assert!(event_str.contains("prompt_tokens"));
561 assert!(event_str.contains("completion_tokens"));
562 assert!(event_str.contains("total_tokens"));
563 }
564
565 #[tokio::test]
566 async fn final_chunk_propagates_exact_tokenizer_failure() {
567 let mut handler = StreamingHandler::new("gpt-audio-1.5".to_string());
568 handler.accumulated_content = "This must not be silently estimated.".to_string();
569
570 let error = handler
571 .create_final_chunk(&TokenizerIdentity::exact_openai("gpt-audio-1.5"))
572 .await
573 .expect_err("missing exact tokenizer must fail the final chunk");
574
575 assert!(error.to_string().contains("tokenizer"));
576 assert!(error.to_string().contains("gpt-audio-1.5"));
577 }
578
579 #[tokio::test]
580 async fn sse_stream_propagates_final_tokenizer_failure() {
581 let handler = StreamingHandler::new("gpt-audio-1.5".to_string());
582 let provider_stream = futures::stream::empty::<Result<String>>();
583 let stream = handler.create_sse_stream(
584 provider_stream,
585 None,
586 TokenizerIdentity::exact_openai("gpt-audio-1.5"),
587 );
588 tokio::pin!(stream);
589
590 let error = stream
591 .next()
592 .await
593 .expect("the stream must report its finalization result")
594 .expect_err("missing exact tokenizer must fail the stream");
595
596 assert!(error.to_string().contains("tokenizer"));
597 assert!(
598 stream.next().await.is_none(),
599 "[DONE] must not hide the error"
600 );
601 }
602
603 #[test]
606 fn test_extract_content_malformed_json() {
607 let handler = StreamingHandler::new("gpt-4".to_string());
608 let chunk = "data: {invalid json}";
609 let result = handler.extract_content_from_chunk(chunk);
610 assert!(result.is_ok());
612 }
613
614 #[test]
615 fn test_extract_content_null_content() {
616 let handler = StreamingHandler::new("gpt-4".to_string());
617 let chunk = r#"data: {"choices":[{"delta":{"content":null}}]}"#;
618 let result = handler.extract_content_from_chunk(chunk).unwrap();
619 assert!(!result.contains("null") || result.contains("{"));
621 }
622
623 #[test]
624 fn test_handler_with_empty_model() {
625 let handler = StreamingHandler::new(String::new());
626 assert!(handler.model.is_empty());
627 assert_eq!(handler.estimate_prompt_tokens(), 100); }
629
630 #[tokio::test]
631 async fn test_process_multiple_chunks_in_sequence() {
632 let mut handler = StreamingHandler::new("claude-3".to_string());
633
634 let chunks = vec![
635 r#"data: {"delta":{"text":"Hello"}}"#,
636 r#"data: {"delta":{"text":" "}}"#,
637 r#"data: {"delta":{"text":"World"}}"#,
638 r#"data: {"delta":{"text":"!"}}"#,
639 ];
640
641 for chunk in chunks {
642 let _ = handler.process_chunk(chunk).await;
643 }
644
645 assert_eq!(handler.accumulated_content, "Hello World!");
646 }
647}