Skip to main content

xai_rust/models/
content.rs

1//! Content part types for multimodal messages.
2
3use serde::{Deserialize, Serialize};
4
5/// A part of message content.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(tag = "type", rename_all = "snake_case")]
8pub enum ContentPart {
9    /// Text content.
10    Text {
11        /// The text content.
12        text: String,
13    },
14    /// Image URL content.
15    ImageUrl {
16        /// Image URL details.
17        image_url: ImageUrlContent,
18    },
19    /// File content reference.
20    File {
21        /// File details.
22        file: FileContent,
23    },
24}
25
26impl ContentPart {
27    /// Create a text content part.
28    pub fn text(text: impl Into<String>) -> Self {
29        Self::Text { text: text.into() }
30    }
31
32    /// Create an image URL content part.
33    pub fn image_url(url: impl Into<String>) -> Self {
34        Self::ImageUrl {
35            image_url: ImageUrlContent::new(url),
36        }
37    }
38
39    /// Create an image URL content part with detail level.
40    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    /// Create a file content part.
47    pub fn file(file_id: impl Into<String>) -> Self {
48        Self::File {
49            file: FileContent::new(file_id),
50        }
51    }
52
53    /// Get the text if this is a text content part.
54    pub fn as_text(&self) -> Option<&str> {
55        match self {
56            ContentPart::Text { text } => Some(text),
57            _ => None,
58        }
59    }
60}
61
62/// Image URL content details.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ImageUrlContent {
65    /// The URL of the image (can be a web URL or base64 data URL).
66    pub url: String,
67    /// Detail level for image processing.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub detail: Option<ImageDetail>,
70}
71
72impl ImageUrlContent {
73    /// Create a new image URL content.
74    pub fn new(url: impl Into<String>) -> Self {
75        Self {
76            url: url.into(),
77            detail: None,
78        }
79    }
80
81    /// Create from base64-encoded image data.
82    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    /// Set the detail level.
90    pub fn with_detail(mut self, detail: ImageDetail) -> Self {
91        self.detail = Some(detail);
92        self
93    }
94}
95
96/// Image detail level for processing.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum ImageDetail {
100    /// Automatically determine the detail level.
101    #[default]
102    Auto,
103    /// Low detail - faster, fewer tokens.
104    Low,
105    /// High detail - slower, more tokens, better for fine details.
106    High,
107}
108
109/// File content reference.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct FileContent {
112    /// The ID of an uploaded file.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub file_id: Option<String>,
115    /// The filename (when using inline content).
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub filename: Option<String>,
118    /// The file data (when using inline content).
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub file_data: Option<String>,
121}
122
123impl FileContent {
124    /// Create a file content reference by ID.
125    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    /// Create inline file content.
134    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    // ── ContentPart serde roundtrips ───────────────────────────────────
148
149    #[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        // detail should not be present when None
167        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        // file_id should be absent
212        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    // ── ImageDetail serde roundtrip ───────────────────────────────────
224
225    #[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    // ── ImageUrlContent ──────────────────────────────────────────────
246
247    #[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    // ── FileContent ──────────────────────────────────────────────────
262
263    #[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}