Skip to main content

vtcode_core/llm/provider/
response.rs

1use std::pin::Pin;
2
3pub use vtcode_commons::llm::{FinishReason, LLMError, LLMResponse, Usage};
4
5#[derive(Debug, Clone)]
6pub enum LLMStreamEvent {
7    Token { delta: String },
8    Reasoning { delta: String },
9    ReasoningSignature { signature: String },
10    ReasoningStage { stage: String },
11    Completed { response: Box<LLMResponse> },
12}
13
14#[derive(Debug, Clone)]
15pub enum NormalizedStreamEvent {
16    TextDelta {
17        delta: String,
18    },
19    ReasoningDelta {
20        delta: String,
21    },
22    ToolCallStart {
23        call_id: String,
24        name: Option<String>,
25    },
26    ToolCallDelta {
27        call_id: String,
28        delta: String,
29    },
30    Usage {
31        usage: Usage,
32    },
33    Done {
34        response: Box<LLMResponse>,
35    },
36}
37
38pub type LLMStream = Pin<Box<dyn futures::Stream<Item = Result<LLMStreamEvent, LLMError>> + Send>>;
39pub type BorrowedLLMStream<'a> =
40    Pin<Box<dyn futures::Stream<Item = Result<LLMStreamEvent, LLMError>> + Send + 'a>>;
41pub type LLMNormalizedStream =
42    Pin<Box<dyn futures::Stream<Item = Result<NormalizedStreamEvent, LLMError>> + Send>>;
43
44impl LLMStreamEvent {
45    pub fn into_normalized(self) -> Vec<NormalizedStreamEvent> {
46        match self {
47            Self::Token { delta } => vec![NormalizedStreamEvent::TextDelta { delta }],
48            Self::Reasoning { delta } => vec![NormalizedStreamEvent::ReasoningDelta { delta }],
49            Self::ReasoningSignature { .. } => Vec::new(),
50            Self::ReasoningStage { .. } => Vec::new(),
51            Self::Completed { response } => {
52                let mut events = Vec::new();
53                if let Some(usage) = response.usage.clone() {
54                    events.push(NormalizedStreamEvent::Usage { usage });
55                }
56                events.push(NormalizedStreamEvent::Done { response });
57                events
58            }
59        }
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::{FinishReason, LLMResponse, LLMStreamEvent, NormalizedStreamEvent, Usage};
66
67    #[test]
68    fn completed_event_emits_usage_before_done() {
69        let events = LLMStreamEvent::Completed {
70            response: Box::new(LLMResponse {
71                content: Some("done".to_string()),
72                model: "gpt-5.4".to_string(),
73                tool_calls: None,
74                usage: Some(Usage {
75                    prompt_tokens: 10,
76                    completion_tokens: 5,
77                    total_tokens: 15,
78                    cached_prompt_tokens: None,
79                    cache_creation_tokens: None,
80                    cache_read_tokens: None,
81                }),
82                finish_reason: FinishReason::Stop,
83                reasoning: None,
84                reasoning_details: None,
85                organization_id: None,
86                request_id: None,
87                tool_references: Vec::new(),
88                compaction: None,
89            }),
90        }
91        .into_normalized();
92
93        assert!(matches!(
94            events.first(),
95            Some(NormalizedStreamEvent::Usage { .. })
96        ));
97        assert!(matches!(
98            events.last(),
99            Some(NormalizedStreamEvent::Done { .. })
100        ));
101    }
102
103    #[test]
104    fn token_event_maps_to_text_delta() {
105        let events = LLMStreamEvent::Token {
106            delta: "hello".to_string(),
107        }
108        .into_normalized();
109
110        assert!(matches!(
111            events.as_slice(),
112            [NormalizedStreamEvent::TextDelta { delta }] if delta == "hello"
113        ));
114    }
115}