1use std::borrow::Cow;
2
3use serde::{Deserialize, Serialize};
4
5use super::tools::{ToolCall, ToolOutput};
6
7#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, Default)]
9pub struct ReasoningItem {
10 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub id: Option<String>,
13 pub summary: Vec<String>,
15 #[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 #[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 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
352impl 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}