Skip to main content

swiftide_core/chat_completion/
chat_message.rs

1use std::borrow::Cow;
2
3use serde::{Deserialize, Serialize};
4
5use super::tools::{ToolCall, ToolOutput};
6
7/// Reasoning items returned by chat providers that expose chain-of-thought metadata.
8#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, Default)]
9pub struct ReasoningItem {
10    /// Provider-assigned identifier for this reasoning item, when available.
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub id: Option<String>,
13    /// Reasoning summary content
14    pub summary: Vec<String>,
15    /// Reasoning text content
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub content: Option<Vec<String>>,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub encrypted_content: Option<String>,
20    /// The status of the item. One of `in_progress`, `completed`, or `incomplete`.
21    /// Populated when items are returned via API.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub status: Option<ReasoningStatus>,
24}
25
26#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
27#[serde(rename_all = "snake_case")]
28pub enum ReasoningStatus {
29    InProgress,
30    Completed,
31    Incomplete,
32}
33
34#[derive(Clone, PartialEq, Serialize, Deserialize)]
35#[serde(tag = "type", rename_all = "snake_case")]
36pub enum ChatMessageContentSource {
37    Url {
38        url: String,
39    },
40    Bytes {
41        data: Vec<u8>,
42        #[serde(default, skip_serializing_if = "Option::is_none")]
43        media_type: Option<String>,
44    },
45    S3 {
46        uri: String,
47        #[serde(default, skip_serializing_if = "Option::is_none")]
48        bucket_owner: Option<String>,
49    },
50    FileId {
51        file_id: String,
52    },
53}
54
55impl ChatMessageContentSource {
56    pub fn url(url: impl Into<String>) -> Self {
57        Self::Url { url: url.into() }
58    }
59
60    pub fn bytes<M>(data: impl Into<Vec<u8>>, media_type: Option<M>) -> Self
61    where
62        M: Into<String>,
63    {
64        Self::Bytes {
65            data: data.into(),
66            media_type: media_type.map(Into::into),
67        }
68    }
69
70    pub fn s3<O>(uri: impl Into<String>, bucket_owner: Option<O>) -> Self
71    where
72        O: Into<String>,
73    {
74        Self::S3 {
75            uri: uri.into(),
76            bucket_owner: bucket_owner.map(Into::into),
77        }
78    }
79
80    pub fn file_id(file_id: impl Into<String>) -> Self {
81        Self::FileId {
82            file_id: file_id.into(),
83        }
84    }
85}
86
87impl From<String> for ChatMessageContentSource {
88    fn from(value: String) -> Self {
89        Self::Url { url: value }
90    }
91}
92
93impl From<&str> for ChatMessageContentSource {
94    fn from(value: &str) -> Self {
95        Self::Url {
96            url: value.to_owned(),
97        }
98    }
99}
100
101impl From<Vec<u8>> for ChatMessageContentSource {
102    fn from(value: Vec<u8>) -> Self {
103        Self::Bytes {
104            data: value,
105            media_type: None,
106        }
107    }
108}
109
110impl From<&[u8]> for ChatMessageContentSource {
111    fn from(value: &[u8]) -> Self {
112        Self::Bytes {
113            data: value.to_vec(),
114            media_type: None,
115        }
116    }
117}
118
119impl std::fmt::Debug for ChatMessageContentSource {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            ChatMessageContentSource::Url { url } => f
123                .debug_struct("Url")
124                .field("url", &truncate_data_url(url))
125                .finish(),
126            ChatMessageContentSource::Bytes { data, media_type } => f
127                .debug_struct("Bytes")
128                .field("len", &data.len())
129                .field("media_type", media_type)
130                .finish(),
131            ChatMessageContentSource::S3 { uri, bucket_owner } => f
132                .debug_struct("S3")
133                .field("uri", uri)
134                .field("bucket_owner", bucket_owner)
135                .finish(),
136            ChatMessageContentSource::FileId { file_id } => {
137                f.debug_struct("FileId").field("file_id", file_id).finish()
138            }
139        }
140    }
141}
142
143#[derive(Clone, PartialEq, Serialize, Deserialize)]
144#[serde(tag = "type", rename_all = "snake_case")]
145pub enum ChatMessageContentPart {
146    Text {
147        text: String,
148    },
149    Image {
150        source: ChatMessageContentSource,
151        #[serde(default, skip_serializing_if = "Option::is_none")]
152        format: Option<String>,
153    },
154    Document {
155        source: ChatMessageContentSource,
156        #[serde(default, skip_serializing_if = "Option::is_none")]
157        format: Option<String>,
158        #[serde(default, skip_serializing_if = "Option::is_none")]
159        name: Option<String>,
160    },
161    Audio {
162        source: ChatMessageContentSource,
163        #[serde(default, skip_serializing_if = "Option::is_none")]
164        format: Option<String>,
165    },
166    Video {
167        source: ChatMessageContentSource,
168        #[serde(default, skip_serializing_if = "Option::is_none")]
169        format: Option<String>,
170    },
171}
172
173impl ChatMessageContentPart {
174    pub fn text(text: impl Into<String>) -> Self {
175        Self::Text { text: text.into() }
176    }
177
178    pub fn image(source: impl Into<ChatMessageContentSource>) -> Self {
179        Self::Image {
180            source: source.into(),
181            format: None,
182        }
183    }
184
185    pub fn image_with_format(
186        source: impl Into<ChatMessageContentSource>,
187        format: impl Into<String>,
188    ) -> Self {
189        Self::Image {
190            source: source.into(),
191            format: Some(format.into()),
192        }
193    }
194
195    pub fn document(source: impl Into<ChatMessageContentSource>) -> Self {
196        Self::Document {
197            source: source.into(),
198            format: None,
199            name: None,
200        }
201    }
202
203    pub fn document_with_name(
204        source: impl Into<ChatMessageContentSource>,
205        name: impl Into<String>,
206    ) -> Self {
207        Self::Document {
208            source: source.into(),
209            format: None,
210            name: Some(name.into()),
211        }
212    }
213
214    pub fn audio(source: impl Into<ChatMessageContentSource>) -> Self {
215        Self::Audio {
216            source: source.into(),
217            format: None,
218        }
219    }
220
221    pub fn video(source: impl Into<ChatMessageContentSource>) -> Self {
222        Self::Video {
223            source: source.into(),
224            format: None,
225        }
226    }
227}
228
229impl std::fmt::Debug for ChatMessageContentPart {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        match self {
232            ChatMessageContentPart::Text { text } => {
233                f.debug_struct("Text").field("text", text).finish()
234            }
235            ChatMessageContentPart::Image { source, format } => f
236                .debug_struct("Image")
237                .field("source", source)
238                .field("format", format)
239                .finish(),
240            ChatMessageContentPart::Document {
241                source,
242                format,
243                name,
244            } => f
245                .debug_struct("Document")
246                .field("source", source)
247                .field("format", format)
248                .field("name", name)
249                .finish(),
250            ChatMessageContentPart::Audio { source, format } => f
251                .debug_struct("Audio")
252                .field("source", source)
253                .field("format", format)
254                .finish(),
255            ChatMessageContentPart::Video { source, format } => f
256                .debug_struct("Video")
257                .field("source", source)
258                .field("format", format)
259                .finish(),
260        }
261    }
262}
263
264#[derive(Clone, strum_macros::EnumIs, PartialEq, Debug, Serialize, Deserialize)]
265pub enum ChatMessage {
266    System(String),
267    User(String),
268    UserWithParts(Vec<ChatMessageContentPart>),
269    Assistant(Option<String>, Option<Vec<ToolCall>>),
270    ToolOutput(ToolCall, ToolOutput),
271    Reasoning(ReasoningItem),
272
273    // A summary of the chat. If encountered all previous messages are ignored, except the system
274    // prompt
275    Summary(String),
276}
277
278impl std::fmt::Display for ChatMessage {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        match self {
281            Self::System(s) => write!(f, "System: \"{s}\""),
282            Self::User(s) => write!(f, "User: \"{s}\""),
283            Self::UserWithParts(parts) => {
284                let (text, attachments) = summarize_user_parts(parts);
285                if attachments == 0 {
286                    write!(f, "User: \"{text}\"")
287                } else {
288                    write!(f, "User: \"{text}\", attachments: {attachments}")
289                }
290            }
291            Self::Assistant(content, tool_calls) => write!(
292                f,
293                "Assistant: \"{}\", tools: {}",
294                content.as_deref().unwrap_or("None"),
295                tool_calls.as_deref().map_or("None".to_string(), |tc| {
296                    tc.iter()
297                        .map(ToString::to_string)
298                        .collect::<Vec<_>>()
299                        .join(", ")
300                })
301            ),
302            Self::ToolOutput(tc, to) => write!(f, "ToolOutput: \"{tc}\": \"{to}\""),
303            Self::Reasoning(item) => write!(
304                f,
305                "Reasoning: \"{}\", encrypted: {}",
306                item.summary.join("\n"),
307                item.encrypted_content.is_some()
308            ),
309            Self::Summary(s) => write!(f, "Summary: \"{s}\""),
310        }
311    }
312}
313
314impl ChatMessage {
315    pub fn new_system(message: impl Into<String>) -> Self {
316        Self::System(message.into())
317    }
318
319    pub fn new_user(message: impl Into<String>) -> Self {
320        Self::User(message.into())
321    }
322
323    pub fn new_user_with_parts(parts: impl Into<Vec<ChatMessageContentPart>>) -> Self {
324        Self::UserWithParts(parts.into())
325    }
326
327    pub fn new_assistant(
328        message: Option<impl Into<String>>,
329        tool_calls: Option<Vec<ToolCall>>,
330    ) -> Self {
331        Self::Assistant(message.map(Into::into), tool_calls)
332    }
333
334    pub fn new_tool_output(tool_call: impl Into<ToolCall>, output: impl Into<ToolOutput>) -> Self {
335        Self::ToolOutput(tool_call.into(), output.into())
336    }
337
338    pub fn new_reasoning(message: ReasoningItem) -> Self {
339        Self::Reasoning(message)
340    }
341
342    pub fn new_summary(message: impl Into<String>) -> Self {
343        Self::Summary(message.into())
344    }
345
346    #[must_use]
347    pub fn to_owned(&self) -> Self {
348        self.clone()
349    }
350}
351
352/// Returns the content of the message as a string slice.
353///
354/// Note that this omits the tool calls from the assistant message.
355///
356/// If used for estimating tokens, consider this a very rought estimate
357impl AsRef<str> for ChatMessage {
358    fn as_ref(&self) -> &str {
359        match self {
360            Self::System(s) | Self::User(s) | Self::Summary(s) => s,
361            Self::UserWithParts(parts) => match parts.as_slice() {
362                [ChatMessageContentPart::Text { text }] => text.as_ref(),
363                _ => "",
364            },
365            Self::Assistant(message, _) => message.as_deref().unwrap_or(""),
366            Self::ToolOutput(_, output) => output.content().unwrap_or(""),
367            Self::Reasoning(_) => "",
368        }
369    }
370}
371
372fn summarize_user_parts(parts: &[ChatMessageContentPart]) -> (String, usize) {
373    let mut text_parts = Vec::new();
374    let mut attachments = 0;
375    for part in parts {
376        match part {
377            ChatMessageContentPart::Text { text } => text_parts.push(text.as_str()),
378            ChatMessageContentPart::Image { .. }
379            | ChatMessageContentPart::Document { .. }
380            | ChatMessageContentPart::Audio { .. }
381            | ChatMessageContentPart::Video { .. } => attachments += 1,
382        }
383    }
384    (text_parts.join(" "), attachments)
385}
386
387fn truncate_data_url(url: &str) -> Cow<'_, str> {
388    const MAX_DATA_PREVIEW: usize = 32;
389
390    if !url.starts_with("data:") {
391        return Cow::Borrowed(url);
392    }
393
394    let Some((prefix, data)) = url.split_once(',') else {
395        return Cow::Borrowed(url);
396    };
397
398    if data.len() <= MAX_DATA_PREVIEW {
399        return Cow::Borrowed(url);
400    }
401
402    let preview = &data[..MAX_DATA_PREVIEW];
403    let truncated = data.len() - MAX_DATA_PREVIEW;
404
405    Cow::Owned(format!(
406        "{prefix},{preview}...[truncated {truncated} chars]"
407    ))
408}