Skip to main content

swiftide_core/chat_completion/
chat_completion_response.rs

1use std::collections::HashMap;
2
3use derive_builder::Builder;
4use itertools::Itertools as _;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use super::{ReasoningItem, ToolCallBuilder, tools::ToolCall};
9
10/// A generic response from chat completions
11///
12/// When streaming, the delta is available. Every response will have the accumulated message if
13/// present. The final message will also have the final tool calls.
14#[derive(Clone, Builder, Debug, Serialize, Deserialize, PartialEq)]
15#[builder(setter(strip_option, into), build_fn(error = anyhow::Error))]
16pub struct ChatCompletionResponse {
17    /// An identifier for the response
18    ///
19    /// Useful when streaming to make sure chunks can be mapped to the right response
20    #[builder(private, default = Uuid::new_v4())]
21    pub id: Uuid,
22
23    #[builder(default)]
24    pub message: Option<String>,
25
26    #[builder(default)]
27    pub tool_calls: Option<Vec<ToolCall>>,
28
29    #[builder(default)]
30    pub usage: Option<Usage>,
31
32    #[builder(default)]
33    pub reasoning: Option<Vec<ReasoningItem>>,
34
35    /// Streaming response
36    #[builder(default)]
37    pub delta: Option<ChatCompletionResponseDelta>,
38}
39
40impl Default for ChatCompletionResponse {
41    fn default() -> Self {
42        Self {
43            id: Uuid::new_v4(),
44            message: None,
45            tool_calls: None,
46            delta: None,
47            usage: None,
48            reasoning: None,
49        }
50    }
51}
52
53/// Usage statistics for a language model response.
54#[derive(Clone, Default, Builder, Debug, Serialize, Deserialize, PartialEq)]
55#[allow(clippy::struct_field_names)]
56pub struct Usage {
57    /// Tokens used in the prompt or input.
58    pub prompt_tokens: u32,
59    /// Tokens generated in the completion or output.
60    pub completion_tokens: u32,
61    /// Total tokens used for the request.
62    pub total_tokens: u32,
63    /// Provider-specific usage breakdowns, when available.
64    #[builder(default)]
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub details: Option<UsageDetails>,
67}
68
69impl Usage {
70    pub fn builder() -> UsageBuilder {
71        UsageBuilder::default()
72    }
73
74    /// Returns a normalized view of usage details when available.
75    ///
76    /// This keeps the public `Usage` fields intact and derives a consistent input/output breakdown
77    /// across providers (e.g. `OpenAI` chat vs. responses). Missing data is left as `None`.
78    pub fn normalized(&self) -> NormalizedUsage {
79        let details = self.details.as_ref().map(|details| {
80            let input = NormalizedInputUsageDetails {
81                cached_tokens: details
82                    .input_tokens_details
83                    .as_ref()
84                    .and_then(|input| input.cached_tokens)
85                    .or_else(|| {
86                        details
87                            .prompt_tokens_details
88                            .as_ref()
89                            .and_then(|prompt| prompt.cached_tokens)
90                    }),
91                audio_tokens: details
92                    .prompt_tokens_details
93                    .as_ref()
94                    .and_then(|prompt| prompt.audio_tokens),
95            };
96            let output = NormalizedOutputUsageDetails {
97                reasoning_tokens: details
98                    .output_tokens_details
99                    .as_ref()
100                    .and_then(|output| output.reasoning_tokens)
101                    .or_else(|| {
102                        details
103                            .completion_tokens_details
104                            .as_ref()
105                            .and_then(|completion| completion.reasoning_tokens)
106                    }),
107                audio_tokens: details
108                    .completion_tokens_details
109                    .as_ref()
110                    .and_then(|completion| completion.audio_tokens),
111                accepted_prediction_tokens: details
112                    .completion_tokens_details
113                    .as_ref()
114                    .and_then(|completion| completion.accepted_prediction_tokens),
115                rejected_prediction_tokens: details
116                    .completion_tokens_details
117                    .as_ref()
118                    .and_then(|completion| completion.rejected_prediction_tokens),
119            };
120
121            if input.is_empty() && output.is_empty() {
122                None
123            } else {
124                Some(NormalizedUsageDetails { input, output })
125            }
126        });
127
128        NormalizedUsage {
129            prompt_tokens: self.prompt_tokens,
130            completion_tokens: self.completion_tokens,
131            total_tokens: self.total_tokens,
132            details: details.flatten(),
133        }
134    }
135}
136
137/// Provider-specific usage breakdowns for a response.
138#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)]
139pub struct UsageDetails {
140    /// Chat-completions style prompt token details.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub prompt_tokens_details: Option<PromptTokensDetails>,
143    /// Chat-completions style completion token details.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub completion_tokens_details: Option<CompletionTokensDetails>,
146    /// Responses-style input token details.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub input_tokens_details: Option<InputTokenDetails>,
149    /// Responses-style output token details.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub output_tokens_details: Option<OutputTokenDetails>,
152}
153
154/// Normalized usage totals with optional normalized details.
155#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)]
156pub struct NormalizedUsage {
157    /// Tokens used in the prompt or input.
158    pub prompt_tokens: u32,
159    /// Tokens generated in the completion or output.
160    pub completion_tokens: u32,
161    /// Total tokens used for the request.
162    pub total_tokens: u32,
163    /// Normalized input/output breakdown, when available.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub details: Option<NormalizedUsageDetails>,
166}
167
168/// Normalized input/output usage breakdown.
169#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)]
170pub struct NormalizedUsageDetails {
171    /// Normalized input usage details.
172    pub input: NormalizedInputUsageDetails,
173    /// Normalized output usage details.
174    pub output: NormalizedOutputUsageDetails,
175}
176
177/// Normalized input usage details.
178#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)]
179pub struct NormalizedInputUsageDetails {
180    /// Tokens retrieved from cache, when provided.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub cached_tokens: Option<u32>,
183    /// Audio tokens in the input, when provided.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub audio_tokens: Option<u32>,
186}
187
188impl NormalizedInputUsageDetails {
189    fn is_empty(&self) -> bool {
190        self.cached_tokens.is_none() && self.audio_tokens.is_none()
191    }
192}
193
194/// Normalized output usage details.
195#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)]
196pub struct NormalizedOutputUsageDetails {
197    /// Tokens used for reasoning, when provided.
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub reasoning_tokens: Option<u32>,
200    /// Audio tokens in the output, when provided.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub audio_tokens: Option<u32>,
203    /// Accepted prediction tokens, when provided.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub accepted_prediction_tokens: Option<u32>,
206    /// Rejected prediction tokens, when provided.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub rejected_prediction_tokens: Option<u32>,
209}
210
211impl NormalizedOutputUsageDetails {
212    fn is_empty(&self) -> bool {
213        self.reasoning_tokens.is_none()
214            && self.audio_tokens.is_none()
215            && self.accepted_prediction_tokens.is_none()
216            && self.rejected_prediction_tokens.is_none()
217    }
218}
219
220/// OpenAI-style prompt token details (chat completions).
221#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)]
222pub struct PromptTokensDetails {
223    /// Audio input tokens present in the prompt.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub audio_tokens: Option<u32>,
226    /// Cached tokens present in the prompt.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub cached_tokens: Option<u32>,
229}
230
231impl PromptTokensDetails {
232    /// Returns true when no prompt token detail values are set.
233    pub fn is_empty(&self) -> bool {
234        self.audio_tokens.is_none() && self.cached_tokens.is_none()
235    }
236}
237
238/// OpenAI-style completion token details (chat completions).
239#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)]
240pub struct CompletionTokensDetails {
241    /// Tokens accepted from predicted output, when provided.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub accepted_prediction_tokens: Option<u32>,
244    /// Audio tokens generated by the model, when provided.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub audio_tokens: Option<u32>,
247    /// Tokens generated by the model for reasoning, when provided.
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub reasoning_tokens: Option<u32>,
250    /// Tokens rejected from predicted output, when provided.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub rejected_prediction_tokens: Option<u32>,
253}
254
255impl CompletionTokensDetails {
256    /// Returns true when no completion token detail values are set.
257    pub fn is_empty(&self) -> bool {
258        self.accepted_prediction_tokens.is_none()
259            && self.audio_tokens.is_none()
260            && self.reasoning_tokens.is_none()
261            && self.rejected_prediction_tokens.is_none()
262    }
263}
264
265/// OpenAI-style input token details (Responses API).
266#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)]
267pub struct InputTokenDetails {
268    /// Tokens retrieved from cache, when provided.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub cached_tokens: Option<u32>,
271}
272
273/// OpenAI-style output token details (Responses API).
274#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)]
275pub struct OutputTokenDetails {
276    /// Tokens used for reasoning, when provided.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub reasoning_tokens: Option<u32>,
279}
280
281#[cfg(feature = "openai")]
282mod openai_usage {
283    use super::{
284        CompletionTokensDetails, InputTokenDetails, OutputTokenDetails, PromptTokensDetails, Usage,
285        UsageDetails,
286    };
287    use async_openai::types::{
288        chat::CompletionUsage, embeddings::EmbeddingUsage, responses::ResponseUsage,
289    };
290
291    impl From<&CompletionUsage> for Usage {
292        fn from(usage: &CompletionUsage) -> Self {
293            let prompt_details = usage.prompt_tokens_details.as_ref().and_then(|details| {
294                let details = PromptTokensDetails {
295                    audio_tokens: details.audio_tokens,
296                    cached_tokens: details.cached_tokens,
297                };
298                if details.is_empty() {
299                    None
300                } else {
301                    Some(details)
302                }
303            });
304            let completion_details = usage
305                .completion_tokens_details
306                .as_ref()
307                .and_then(|details| {
308                    let details = CompletionTokensDetails {
309                        accepted_prediction_tokens: details.accepted_prediction_tokens,
310                        audio_tokens: details.audio_tokens,
311                        reasoning_tokens: details.reasoning_tokens,
312                        rejected_prediction_tokens: details.rejected_prediction_tokens,
313                    };
314                    if details.is_empty() {
315                        None
316                    } else {
317                        Some(details)
318                    }
319                });
320            let details = if prompt_details.is_some() || completion_details.is_some() {
321                Some(UsageDetails {
322                    prompt_tokens_details: prompt_details,
323                    completion_tokens_details: completion_details,
324                    input_tokens_details: None,
325                    output_tokens_details: None,
326                })
327            } else {
328                None
329            };
330
331            Usage {
332                prompt_tokens: usage.prompt_tokens,
333                completion_tokens: usage.completion_tokens,
334                total_tokens: usage.total_tokens,
335                details,
336            }
337        }
338    }
339
340    impl From<&ResponseUsage> for Usage {
341        fn from(usage: &ResponseUsage) -> Self {
342            Usage {
343                prompt_tokens: usage.input_tokens,
344                completion_tokens: usage.output_tokens,
345                total_tokens: usage.total_tokens,
346                details: Some(UsageDetails {
347                    prompt_tokens_details: None,
348                    completion_tokens_details: None,
349                    input_tokens_details: Some(InputTokenDetails {
350                        cached_tokens: Some(usage.input_tokens_details.cached_tokens),
351                    }),
352                    output_tokens_details: Some(OutputTokenDetails {
353                        reasoning_tokens: Some(usage.output_tokens_details.reasoning_tokens),
354                    }),
355                }),
356            }
357        }
358    }
359
360    impl From<&EmbeddingUsage> for Usage {
361        fn from(usage: &EmbeddingUsage) -> Self {
362            Usage {
363                prompt_tokens: usage.prompt_tokens,
364                completion_tokens: 0,
365                total_tokens: usage.total_tokens,
366                details: None,
367            }
368        }
369    }
370}
371
372#[derive(Clone, Builder, Debug, Serialize, Deserialize, PartialEq)]
373pub struct ChatCompletionResponseDelta {
374    #[builder(default)]
375    pub message_chunk: Option<String>,
376
377    #[builder(default)]
378    pub tool_calls_chunk: Option<HashMap<usize, ToolCallAccum>>,
379}
380
381// Accumulator for streamed tool calls
382#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
383pub struct ToolCallAccum {
384    pub id: Option<String>,
385    pub name: Option<String>,
386    pub arguments: Option<String>,
387}
388
389impl ChatCompletionResponse {
390    pub fn builder() -> ChatCompletionResponseBuilder {
391        ChatCompletionResponseBuilder::default()
392    }
393
394    pub fn message(&self) -> Option<&str> {
395        self.message.as_deref()
396    }
397
398    pub fn tool_calls(&self) -> Option<&[ToolCall]> {
399        self.tool_calls.as_deref()
400    }
401
402    /// Adds a streaming chunk to the message and also the delta.
403    ///
404    /// Passing `None` clears the current text delta without changing the accumulated message or
405    /// tool-call fragments.
406    pub fn append_message_delta(&mut self, message_delta: Option<&str>) -> &mut Self {
407        let Some(message_delta) = message_delta else {
408            if let Some(delta) = &mut self.delta {
409                delta.message_chunk = None;
410            }
411            return self;
412        };
413
414        if let Some(delta) = &mut self.delta {
415            delta.message_chunk = Some(message_delta.to_string());
416        } else {
417            self.delta = Some(ChatCompletionResponseDelta {
418                message_chunk: Some(message_delta.to_string()),
419                tool_calls_chunk: None,
420            });
421        }
422
423        self.message
424            .as_mut()
425            .map(|m| {
426                m.push_str(message_delta);
427            })
428            .unwrap_or_else(|| {
429                self.message = Some(message_delta.to_string());
430            });
431        self
432    }
433
434    /// Adds a streaming chunk to the tool calls, if it can be build, the tool call will be build,
435    /// otherwise it will remain in the delta and retried on the next call
436    ///
437    /// Reconstructed calls follow their stream index order.
438    pub fn append_tool_call_delta(
439        &mut self,
440        index: usize,
441        id: Option<&str>,
442        name: Option<&str>,
443        arguments: Option<&str>,
444    ) -> &mut Self {
445        if let Some(delta) = &mut self.delta {
446            let map = delta.tool_calls_chunk.get_or_insert_with(HashMap::new);
447            map.entry(index)
448                .and_modify(|v| {
449                    if v.id.is_none() {
450                        v.id = id.map(Into::into);
451                    }
452                    if v.name.is_none() {
453                        v.name = name.map(Into::into);
454                    }
455                    if let Some(v) = v.arguments.as_mut() {
456                        if let Some(arguments) = arguments {
457                            v.push_str(arguments);
458                        }
459                    } else {
460                        v.arguments = arguments.map(Into::into);
461                    }
462                })
463                .or_insert(ToolCallAccum {
464                    id: id.map(Into::into),
465                    name: name.map(Into::into),
466                    arguments: arguments.map(Into::into),
467                });
468        } else {
469            self.delta = Some(ChatCompletionResponseDelta {
470                message_chunk: None,
471                tool_calls_chunk: Some(HashMap::from([(
472                    index,
473                    ToolCallAccum {
474                        id: id.map(Into::into),
475                        name: name.map(Into::into),
476                        arguments: arguments.map(Into::into),
477                    },
478                )])),
479            });
480        }
481
482        // Now let's try to rebuild _every_ tool call and overwrite
483        // Performance wise very meh but it works, in reality it's only a couple of tool calls most
484        self.finalize_tools_from_stream();
485
486        self
487    }
488
489    pub fn append_usage_delta(
490        &mut self,
491        prompt_tokens: u32,
492        completion_tokens: u32,
493        total_tokens: u32,
494    ) -> &mut Self {
495        debug_assert_eq!(prompt_tokens + completion_tokens, total_tokens);
496
497        if let Some(usage) = &mut self.usage {
498            usage.prompt_tokens += prompt_tokens;
499            usage.completion_tokens += completion_tokens;
500            usage.total_tokens += total_tokens;
501        } else {
502            self.usage = Some(Usage {
503                prompt_tokens,
504                completion_tokens,
505                total_tokens,
506                details: None,
507            });
508        }
509        self
510    }
511
512    fn finalize_tools_from_stream(&mut self) {
513        if let Some(values) = self
514            .delta
515            .as_ref()
516            .and_then(|d| d.tool_calls_chunk.as_ref())
517        {
518            let maybe_tool_calls = values
519                .iter()
520                .sorted_by_key(|(index, _)| **index)
521                .filter_map(|(_, maybe_tool_call)| {
522                    ToolCallBuilder::default()
523                        .maybe_id(maybe_tool_call.id.clone())
524                        .maybe_name(maybe_tool_call.name.clone())
525                        .maybe_args(maybe_tool_call.arguments.clone())
526                        .build()
527                        .ok()
528                })
529                .collect::<Vec<_>>();
530
531            if !maybe_tool_calls.is_empty() {
532                self.tool_calls = Some(maybe_tool_calls);
533            }
534        }
535    }
536}
537
538impl ChatCompletionResponseBuilder {
539    pub fn maybe_message<T: Into<Option<String>>>(&mut self, message: T) -> &mut Self {
540        self.message = Some(message.into());
541        self
542    }
543
544    pub fn maybe_tool_calls<T: Into<Option<Vec<ToolCall>>>>(&mut self, tool_calls: T) -> &mut Self {
545        self.tool_calls = Some(tool_calls.into());
546        self
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn test_streamed_tool_calls_follow_their_indices() {
556        let mut response = ChatCompletionResponse::default();
557        for index in (0..16).rev() {
558            response.append_tool_call_delta(
559                index,
560                Some(&format!("call_{index}")),
561                Some("test_tool"),
562                Some("{}"),
563            );
564        }
565
566        let ids = response
567            .tool_calls()
568            .unwrap()
569            .iter()
570            .map(ToolCall::id)
571            .collect::<Vec<_>>();
572        let expected = (0..16)
573            .map(|index| format!("call_{index}"))
574            .collect::<Vec<_>>();
575        assert_eq!(ids, expected);
576    }
577
578    #[test]
579    fn test_append_message_delta_none_on_empty_response() {
580        let mut response = ChatCompletionResponse::default();
581        let original = response.clone();
582
583        response.append_message_delta(None);
584
585        assert_eq!(response, original);
586    }
587
588    #[test]
589    fn test_append_message_delta_clears_text_and_preserves_tool_fragments() {
590        let mut response = ChatCompletionResponse::default();
591        response.append_message_delta(Some("Hello"));
592        response.append_tool_call_delta(0, Some("call-1"), Some("weather"), Some("{\"city\":"));
593        let tool_fragments = response.delta.as_ref().unwrap().tool_calls_chunk.clone();
594
595        response.append_message_delta(None);
596
597        let delta = response.delta.as_ref().unwrap();
598        assert_eq!(delta.message_chunk, None);
599        assert_eq!(delta.tool_calls_chunk, tool_fragments);
600        assert_eq!(response.message(), Some("Hello"));
601
602        response.append_tool_call_delta(0, None, None, Some("\"Amsterdam\"}"));
603        let tool = &response.tool_calls().unwrap()[0];
604        assert_eq!(tool.id(), "call-1");
605        assert_eq!(tool.name(), "weather");
606        assert_eq!(tool.args(), Some("{\"city\":\"Amsterdam\"}"));
607
608        response.append_message_delta(Some(" again"));
609        assert_eq!(response.message(), Some("Hello again"));
610        assert_eq!(
611            response.delta.as_ref().unwrap().message_chunk.as_deref(),
612            Some(" again")
613        );
614    }
615}