1use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20#[derive(
24 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display, strum::EnumString,
25)]
26#[strum(serialize_all = "lowercase")]
27#[serde(rename_all = "lowercase")]
28pub enum Role {
29 System,
31 User,
33 Assistant,
35 Developer,
37 Tool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ToolCall {
44 pub id: String,
46 pub name: String,
48 pub arguments: serde_json::Value,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ToolResult {
64 pub tool_call_id: String,
67 pub output: String,
70 pub success: bool,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(tag = "type")]
80pub enum ContentPart {
81 #[serde(rename = "text")]
82 Text { text: String },
83 #[serde(rename = "image")]
84 Image {
85 media_type: String,
86 data: String,
88 },
89 #[serde(rename = "image_url")]
90 ImageUrl { url: String },
91 #[serde(rename = "document")]
92 Document {
93 media_type: String,
94 filename: String,
95 data: String,
96 },
97 #[serde(rename = "video")]
98 Video {
99 media_type: String,
100 filename: String,
101 data: String,
102 },
103 #[serde(rename = "video_url")]
104 VideoUrl { url: String },
105 #[serde(rename = "audio")]
106 Audio {
107 media_type: String,
108 filename: String,
109 data: String,
110 },
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Message {
118 pub role: Role,
120 pub content: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub reasoning: Option<String>,
125 #[serde(default, skip_serializing_if = "crate::ReasoningDetails::is_empty")]
127 pub reasoning_details: crate::ReasoningDetails,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub tool_calls: Option<Vec<ToolCall>>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub tool_call_id: Option<String>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub parts: Option<Vec<ContentPart>>,
137 #[serde(default = "chrono::Utc::now")]
139 pub timestamp: DateTime<Utc>,
140}
141
142impl Message {
143 fn new(role: Role, content: String) -> Self {
144 Self {
145 role,
146 content,
147 reasoning: None,
148 reasoning_details: crate::ReasoningDetails::default(),
149 tool_calls: None,
150 tool_call_id: None,
151 parts: None,
152 timestamp: Utc::now(),
153 }
154 }
155
156 pub fn user(content: impl Into<String>) -> Self {
158 Self::new(Role::User, content.into())
159 }
160
161 pub fn assistant(content: impl Into<String>) -> Self {
163 Self::new(Role::Assistant, content.into())
164 }
165
166 pub fn assistant_with_tool_calls(
168 content: impl Into<String>,
169 tool_calls: Vec<ToolCall>,
170 ) -> Self {
171 Self {
172 tool_calls: Some(tool_calls),
173 ..Self::new(Role::Assistant, content.into())
174 }
175 }
176
177 pub fn system(content: impl Into<String>) -> Self {
179 Self::new(Role::System, content.into())
180 }
181
182 pub fn developer(content: impl Into<String>) -> Self {
184 Self::new(Role::Developer, content.into())
185 }
186
187 pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
189 Self {
190 tool_call_id: Some(tool_call_id.into()),
191 ..Self::new(Role::Tool, content.into())
192 }
193 }
194
195 pub fn user_with_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
197 Self {
198 parts: if parts.is_empty() { None } else { Some(parts) },
199 ..Self::new(Role::User, content.into())
200 }
201 }
202
203 pub fn strip_binary_data(&self) -> Self {
210 let parts = self.parts.as_ref().map(|parts| {
211 parts
212 .iter()
213 .map(|p| match p {
214 ContentPart::Image { media_type, .. } => ContentPart::Text {
215 text: format!("[Image: {}]", media_type),
216 },
217 ContentPart::ImageUrl { url } => ContentPart::Text {
218 text: format!("[Image: {}]", url),
219 },
220 ContentPart::Document {
221 media_type,
222 filename,
223 ..
224 } => ContentPart::Text {
225 text: format!("[Document: {} ({})]", filename, media_type),
226 },
227 ContentPart::Video {
228 media_type,
229 filename,
230 ..
231 } => ContentPart::Text {
232 text: format!("[Video: {} ({})]", filename, media_type),
233 },
234 ContentPart::VideoUrl { url } => ContentPart::Text {
235 text: format!("[Video: {}]", url),
236 },
237 ContentPart::Audio {
238 media_type,
239 filename,
240 ..
241 } => ContentPart::Text {
242 text: format!("[Audio: {} ({})]", filename, media_type),
243 },
244 other => other.clone(),
245 })
246 .collect()
247 });
248
249 Self {
250 parts,
251 ..self.clone()
252 }
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn test_message_serialization() {
262 let msg = Message::user("Hello");
263 let json = serde_json::to_string(&msg).unwrap();
264 assert!(json.contains("\"role\":\"user\""));
265 assert!(json.contains("Hello"));
266 }
267
268 #[test]
269 fn test_message_constructors() {
270 let user = Message::user("hi");
271 assert_eq!(user.role, Role::User);
272 assert_eq!(user.content, "hi");
273
274 let assistant = Message::assistant("hello");
275 assert_eq!(assistant.role, Role::Assistant);
276
277 let system = Message::system("you are helpful");
278 assert_eq!(system.role, Role::System);
279
280 let developer = Message::developer("injected context");
281 assert_eq!(developer.role, Role::Developer);
282 assert_eq!(developer.content, "injected context");
283
284 let tool = Message::tool_result("call-1", "result data");
285 assert_eq!(tool.role, Role::Tool);
286 assert_eq!(tool.tool_call_id.as_deref(), Some("call-1"));
287 }
288
289 #[test]
290 fn test_content_part_serde() {
291 let text = ContentPart::Text {
292 text: "hello".into(),
293 };
294 let json = serde_json::to_string(&text).unwrap();
295 assert!(json.contains("\"type\":\"text\""));
296 let roundtrip: ContentPart = serde_json::from_str(&json).unwrap();
297 match roundtrip {
298 ContentPart::Text { text } => assert_eq!(text, "hello"),
299 _ => panic!("expected text"),
300 }
301
302 let img = ContentPart::Image {
303 media_type: "image/jpeg".into(),
304 data: "abc123".into(),
305 };
306 let json = serde_json::to_string(&img).unwrap();
307 assert!(json.contains("\"type\":\"image\""));
308 }
309
310 #[test]
311 fn test_strip_binary_data() {
312 let msg = Message::user_with_parts(
313 "photo",
314 vec![
315 ContentPart::Text {
316 text: "a cat".into(),
317 },
318 ContentPart::Image {
319 media_type: "image/jpeg".into(),
320 data: "bigdata".into(),
321 },
322 ],
323 );
324 let stripped = msg.strip_binary_data();
325 let parts = stripped.parts.unwrap();
326 assert_eq!(parts.len(), 2);
327 match &parts[1] {
328 ContentPart::Text { text } => assert!(text.contains("image/jpeg")),
329 _ => panic!("expected text placeholder for image"),
330 }
331 }
332
333 #[test]
334 fn test_role_serde() {
335 let role = Role::Assistant;
336 let json = serde_json::to_string(&role).unwrap();
337 assert_eq!(json, "\"assistant\"");
338
339 let parsed: Role = serde_json::from_str("\"tool\"").unwrap();
340 assert_eq!(parsed, Role::Tool);
341 }
342}