1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(tag = "type", rename_all = "snake_case")]
8pub enum ContentPart {
9 Text {
11 text: String,
13 },
14 ImageUrl {
16 image_url: ImageUrlContent,
18 },
19 File {
21 file: FileContent,
23 },
24}
25
26impl ContentPart {
27 pub fn text(text: impl Into<String>) -> Self {
29 Self::Text { text: text.into() }
30 }
31
32 pub fn image_url(url: impl Into<String>) -> Self {
34 Self::ImageUrl {
35 image_url: ImageUrlContent::new(url),
36 }
37 }
38
39 pub fn image_url_with_detail(url: impl Into<String>, detail: ImageDetail) -> Self {
41 Self::ImageUrl {
42 image_url: ImageUrlContent::new(url).with_detail(detail),
43 }
44 }
45
46 pub fn file(file_id: impl Into<String>) -> Self {
48 Self::File {
49 file: FileContent::new(file_id),
50 }
51 }
52
53 pub fn as_text(&self) -> Option<&str> {
55 match self {
56 ContentPart::Text { text } => Some(text),
57 _ => None,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ImageUrlContent {
65 pub url: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub detail: Option<ImageDetail>,
70}
71
72impl ImageUrlContent {
73 pub fn new(url: impl Into<String>) -> Self {
75 Self {
76 url: url.into(),
77 detail: None,
78 }
79 }
80
81 pub fn from_base64(data: &str, mime_type: &str) -> Self {
83 Self {
84 url: format!("data:{};base64,{}", mime_type, data),
85 detail: None,
86 }
87 }
88
89 pub fn with_detail(mut self, detail: ImageDetail) -> Self {
91 self.detail = Some(detail);
92 self
93 }
94}
95
96#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum ImageDetail {
100 #[default]
102 Auto,
103 Low,
105 High,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct FileContent {
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub file_id: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub filename: Option<String>,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub file_data: Option<String>,
121}
122
123impl FileContent {
124 pub fn new(file_id: impl Into<String>) -> Self {
126 Self {
127 file_id: Some(file_id.into()),
128 filename: None,
129 file_data: None,
130 }
131 }
132
133 pub fn inline(filename: impl Into<String>, data: impl Into<String>) -> Self {
135 Self {
136 file_id: None,
137 filename: Some(filename.into()),
138 file_data: Some(data.into()),
139 }
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
150 fn content_part_text_roundtrip() {
151 let part = ContentPart::text("hello");
152 let json = serde_json::to_value(&part).unwrap();
153 assert_eq!(json["type"], "text");
154 assert_eq!(json["text"], "hello");
155
156 let back: ContentPart = serde_json::from_value(json).unwrap();
157 assert_eq!(back.as_text().unwrap(), "hello");
158 }
159
160 #[test]
161 fn content_part_image_url_roundtrip() {
162 let part = ContentPart::image_url("https://example.com/img.jpg");
163 let json = serde_json::to_value(&part).unwrap();
164 assert_eq!(json["type"], "image_url");
165 assert_eq!(json["image_url"]["url"], "https://example.com/img.jpg");
166 assert!(json["image_url"].get("detail").is_none());
168
169 let back: ContentPart = serde_json::from_value(json).unwrap();
170 assert!(back.as_text().is_none());
171 }
172
173 #[test]
174 fn content_part_image_url_with_detail_roundtrip() {
175 let part =
176 ContentPart::image_url_with_detail("https://example.com/img.jpg", ImageDetail::High);
177 let json = serde_json::to_value(&part).unwrap();
178 assert_eq!(json["image_url"]["detail"], "high");
179
180 let back: ContentPart = serde_json::from_value(json).unwrap();
181 if let ContentPart::ImageUrl { image_url } = back {
182 assert_eq!(image_url.detail, Some(ImageDetail::High));
183 } else {
184 panic!("Expected ImageUrl variant");
185 }
186 }
187
188 #[test]
189 fn content_part_file_roundtrip() {
190 let part = ContentPart::file("file-abc123");
191 let json = serde_json::to_value(&part).unwrap();
192 assert_eq!(json["type"], "file");
193 assert_eq!(json["file"]["file_id"], "file-abc123");
194
195 let back: ContentPart = serde_json::from_value(json).unwrap();
196 if let ContentPart::File { file } = back {
197 assert_eq!(file.file_id.as_deref(), Some("file-abc123"));
198 } else {
199 panic!("Expected File variant");
200 }
201 }
202
203 #[test]
204 fn content_part_file_inline_roundtrip() {
205 let part = ContentPart::File {
206 file: FileContent::inline("data.json", r#"{"key":"value"}"#),
207 };
208 let json = serde_json::to_value(&part).unwrap();
209 assert_eq!(json["file"]["filename"], "data.json");
210 assert_eq!(json["file"]["file_data"], r#"{"key":"value"}"#);
211 assert!(json["file"].get("file_id").is_none());
213
214 let back: ContentPart = serde_json::from_value(json).unwrap();
215 if let ContentPart::File { file } = back {
216 assert!(file.file_id.is_none());
217 assert_eq!(file.filename.as_deref(), Some("data.json"));
218 } else {
219 panic!("Expected File variant");
220 }
221 }
222
223 #[test]
226 fn image_detail_roundtrip_all_variants() {
227 for (detail, expected) in [
228 (ImageDetail::Auto, "auto"),
229 (ImageDetail::Low, "low"),
230 (ImageDetail::High, "high"),
231 ] {
232 let json = serde_json::to_string(&detail).unwrap();
233 assert_eq!(json, format!("\"{expected}\""));
234
235 let back: ImageDetail = serde_json::from_str(&json).unwrap();
236 assert_eq!(back, detail);
237 }
238 }
239
240 #[test]
241 fn image_detail_default_is_auto() {
242 assert_eq!(ImageDetail::default(), ImageDetail::Auto);
243 }
244
245 #[test]
248 fn image_url_content_from_base64() {
249 let content = ImageUrlContent::from_base64("abc123", "image/png");
250 assert_eq!(content.url, "data:image/png;base64,abc123");
251 assert!(content.detail.is_none());
252 }
253
254 #[test]
255 fn image_url_content_with_detail() {
256 let content =
257 ImageUrlContent::new("https://example.com/img.jpg").with_detail(ImageDetail::Low);
258 assert_eq!(content.detail, Some(ImageDetail::Low));
259 }
260
261 #[test]
264 fn file_content_new_has_id_only() {
265 let fc = FileContent::new("file-xyz");
266 assert_eq!(fc.file_id.as_deref(), Some("file-xyz"));
267 assert!(fc.filename.is_none());
268 assert!(fc.file_data.is_none());
269 }
270
271 #[test]
272 fn file_content_inline_has_name_and_data() {
273 let fc = FileContent::inline("test.txt", "contents");
274 assert!(fc.file_id.is_none());
275 assert_eq!(fc.filename.as_deref(), Some("test.txt"));
276 assert_eq!(fc.file_data.as_deref(), Some("contents"));
277 }
278}