Skip to main content

vtcode_llm/provider/
response.rs

1use std::pin::Pin;
2
3pub use vtcode_commons::llm::{FinishReason, LLMError, LLMResponse, Usage};
4
5/// Provider-side classification for streamed reasoning text.
6///
7/// Only an explicit provider summary is safe to expose in the user interface.
8/// Raw and continuation-only reasoning remains available on the response for
9/// providers that require it on the next request.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ReasoningSource {
12    /// The provider explicitly marked this text as a public reasoning summary.
13    ProviderSummary,
14    /// The provider returned reasoning text without a public-summary marker.
15    Raw,
16    /// The provider returned reasoning metadata needed to continue a request.
17    Continuation,
18    /// The source was not classified by the provider adapter.
19    Unknown,
20}
21
22impl ReasoningSource {
23    #[must_use]
24    pub const fn is_public_summary(self) -> bool {
25        matches!(self, Self::ProviderSummary)
26    }
27}
28
29#[derive(Debug, Clone)]
30pub enum LLMStreamEvent {
31    Token { delta: String },
32    Reasoning { delta: String },
33    ReasoningSignature { signature: String },
34    ReasoningStage { stage: String },
35    Completed { response: Box<LLMResponse> },
36}
37
38#[derive(Debug, Clone)]
39pub enum NormalizedStreamEvent {
40    TextDelta {
41        delta: String,
42    },
43    ReasoningDelta {
44        delta: String,
45        source: ReasoningSource,
46    },
47    /// A provider-native reasoning stage transition.
48    ReasoningStage {
49        stage: String,
50    },
51    ToolCallStart {
52        call_id: String,
53        name: Option<String>,
54    },
55    ToolCallDelta {
56        call_id: String,
57        delta: String,
58    },
59    Usage {
60        usage: Usage,
61    },
62    Done {
63        response: Box<LLMResponse>,
64    },
65}
66
67pub type LLMStream = Pin<Box<dyn futures::Stream<Item = Result<LLMStreamEvent, LLMError>> + Send>>;
68pub type BorrowedLLMStream<'a> = Pin<Box<dyn futures::Stream<Item = Result<LLMStreamEvent, LLMError>> + Send + 'a>>;
69pub type LLMNormalizedStream = Pin<Box<dyn futures::Stream<Item = Result<NormalizedStreamEvent, LLMError>> + Send>>;
70
71impl LLMStreamEvent {
72    pub(crate) fn into_normalized(self) -> Vec<NormalizedStreamEvent> {
73        match self {
74            Self::Token { delta } => vec![NormalizedStreamEvent::TextDelta { delta }],
75            Self::Reasoning { delta } => {
76                vec![NormalizedStreamEvent::ReasoningDelta { delta, source: ReasoningSource::Unknown }]
77            }
78            Self::ReasoningSignature { .. } => Vec::new(),
79            Self::ReasoningStage { stage } => vec![NormalizedStreamEvent::ReasoningStage { stage }],
80            Self::Completed { response } => {
81                let mut events = Vec::new();
82                if let Some(usage) = response.usage.clone() {
83                    events.push(NormalizedStreamEvent::Usage { usage });
84                }
85                events.push(NormalizedStreamEvent::Done { response });
86                events
87            }
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::{FinishReason, LLMResponse, LLMStreamEvent, NormalizedStreamEvent, ReasoningSource, Usage};
95
96    #[test]
97    fn completed_event_emits_usage_before_done() {
98        let events = LLMStreamEvent::Completed {
99            response: Box::new(LLMResponse {
100                content: Some("done".to_string()),
101                model: "gpt-5.6-sol".to_string(),
102                tool_calls: None,
103                usage: Some(Usage {
104                    prompt_tokens: 10,
105                    completion_tokens: 5,
106                    total_tokens: 15,
107                    cached_prompt_tokens: None,
108                    cache_creation_tokens: None,
109                    cache_read_tokens: None,
110                    iterations: None,
111                }),
112                finish_reason: FinishReason::Stop,
113                reasoning: None,
114                reasoning_details: None,
115                organization_id: None,
116                request_id: None,
117                tool_references: Vec::new(),
118                compaction: None,
119            }),
120        }
121        .into_normalized();
122
123        assert!(matches!(events.first(), Some(NormalizedStreamEvent::Usage { .. })));
124        assert!(matches!(events.last(), Some(NormalizedStreamEvent::Done { .. })));
125    }
126
127    #[test]
128    fn token_event_maps_to_text_delta() {
129        let events = LLMStreamEvent::Token { delta: "hello".to_string() }.into_normalized();
130
131        assert!(matches!(
132            events.as_slice(),
133            [NormalizedStreamEvent::TextDelta { delta }] if delta == "hello"
134        ));
135    }
136
137    #[test]
138    fn reasoning_stage_is_preserved_by_normalization() {
139        let events = LLMStreamEvent::ReasoningStage { stage: "analysis".to_string() }.into_normalized();
140
141        assert!(matches!(
142            events.as_slice(),
143            [NormalizedStreamEvent::ReasoningStage { stage }] if stage == "analysis"
144        ));
145    }
146
147    #[test]
148    fn legacy_reasoning_events_are_unknown_and_not_public_summaries() {
149        let events = LLMStreamEvent::Reasoning { delta: "private".to_string() }.into_normalized();
150
151        assert!(matches!(
152            events.as_slice(),
153            [NormalizedStreamEvent::ReasoningDelta { delta, source }]
154                if delta == "private" && *source == ReasoningSource::Unknown && !source.is_public_summary()
155        ));
156    }
157}