vtcode_llm/provider/
response.rs1use std::pin::Pin;
2
3pub use vtcode_commons::llm::{FinishReason, LLMError, LLMResponse, Usage};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ReasoningSource {
12 ProviderSummary,
14 Raw,
16 Continuation,
18 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 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}