Skip to main content

litellm_rs/core/streaming/
handler.rs

1//! Streaming response handler implementation
2
3use 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
17/// Streaming response handler
18pub struct StreamingHandler {
19    /// Request ID for tracking
20    request_id: String,
21    /// Model being used
22    pub(crate) model: String,
23    /// Whether this is the first chunk
24    pub(crate) is_first_chunk: bool,
25    /// Accumulated content for final usage calculation
26    pub(crate) accumulated_content: String,
27}
28
29impl StreamingHandler {
30    /// Create a new streaming handler
31    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    /// Create a streaming response from a provider stream for Actix-web.
41    ///
42    /// The optional `cancel` token allows the caller to abort the provider
43    /// stream when the HTTP client disconnects, preventing wasted upstream
44    /// work and network I/O.
45    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, // Skip empty chunks
86                            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            // Send final chunk with usage information
108            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            // Send done event
121            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    /// Process a single chunk from the provider
129    async fn process_chunk(&mut self, chunk_data: &str) -> Result<Option<Event>> {
130        // Parse provider-specific chunk format
131        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    /// Extract content from provider-specific chunk format
171    pub(crate) fn extract_content_from_chunk(&self, chunk_data: &str) -> Result<String> {
172        // Handle different provider formats
173        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            // Parse JSON chunk
181            if let Ok(json_chunk) = serde_json::from_str::<serde_json::Value>(data) {
182                // OpenAI format
183                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                // Anthropic format
192                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                // Generic text field
199                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        // Fallback: treat as plain text
206        Ok(chunk_data.to_string())
207    }
208
209    /// Create the final chunk with usage information
210    async fn create_final_chunk(&self, tokenizer_identity: &TokenizerIdentity) -> Result<Event> {
211        // Calculate actual token counts using the token counter
212        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        // For prompt tokens, we'd need the original request context
218        // For now, use a reasonable estimate based on typical chat requests
219        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    /// Estimate prompt tokens based on typical chat requests
257    fn estimate_prompt_tokens(&self) -> u32 {
258        // This is a rough estimate since we don't have the original request
259        // In a real implementation, we'd store the original prompt tokens
260        // For now, use a reasonable default based on typical usage
261        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    // ==================== StreamingHandler Creation Tests ====================
276
277    #[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    // ==================== Content Extraction Tests ====================
337
338    #[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        // Empty delta returns the original chunk when no content is found
400        assert!(!result.is_empty()); // Falls through to raw JSON string
401    }
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        // No choices means fallback
409        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        // Should extract first choice only
419        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    // ==================== Accumulated Content Tests ====================
447
448    #[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    // ==================== ChatCompletionChunk Format Tests ====================
488
489    #[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            // Should contain valid JSON data
499            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        // First chunk should have role
512        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            // First chunk includes role
519            assert!(event_str.contains("assistant") || event_str.contains("role"));
520        }
521
522        // After first chunk, is_first_chunk should be false
523        assert!(!handler.is_first_chunk);
524    }
525
526    // ==================== Final Chunk Tests ====================
527
528    #[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        // Should include token usage fields
560        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    // ==================== Edge Cases ====================
604
605    #[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        // Should fall back to raw text
611        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        // null content should not be extracted as string
620        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); // Falls to default
628    }
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}