Skip to main content

a2a_llm/
content.rs

1//! What a message says, when not all of it is text.
2//!
3//! [`ChatMessage::content`] used to be `Option<String>`, which is the shape
4//! every chat-completion API takes for prose and none of them takes for bytes.
5//! A caller holding an image, a PDF or a recording had nowhere to put it, so it
6//! sent the file's *name* and withheld the file — which reads to the model as a
7//! question about nothing.
8//!
9//! [`MessageContent`] is that string or an ordered list of [`ContentPart`]s.
10//! The text-only case is still a string on the wire (`#[serde(untagged)]`), so
11//! a conversation persisted before this existed still deserializes, and the
12//! `String` constructors on [`ChatMessage`] are unchanged.
13//!
14//! Bytes are held decoded, as `Vec<u8>`. Both providers want base64 on the
15//! wire and each spells the envelope differently, so encoding is the
16//! provider's job and this type stays the thing a caller actually has.
17//!
18//! [`ChatMessage`]: crate::ChatMessage
19//! [`ChatMessage::content`]: crate::ChatMessage::content
20
21use serde::{Deserialize, Serialize};
22
23/// The content of one message: prose, or parts when some of it is not prose.
24#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(untagged)]
26pub enum MessageContent {
27    /// Prose, and nothing else. The wire form is a bare string.
28    Text(String),
29    /// An ordered list of parts, at least one of which is usually not text.
30    Parts(Vec<ContentPart>),
31}
32
33impl MessageContent {
34    /// The content as one string, when it is text and nothing else.
35    ///
36    /// `None` for a parts list — including a parts list that happens to hold
37    /// only text — because a caller reaching for this wants to know it is
38    /// seeing the whole message. Use [`to_text`](Self::to_text) to read what
39    /// text there is regardless.
40    pub fn as_text(&self) -> Option<&str> {
41        match self {
42            Self::Text(text) => Some(text),
43            Self::Parts(_) => None,
44        }
45    }
46
47    /// Every text part, joined with a newline. Parts carrying bytes or a URI
48    /// contribute nothing — this is the text, not a description of the
49    /// message.
50    pub fn to_text(&self) -> String {
51        match self {
52            Self::Text(text) => text.clone(),
53            Self::Parts(parts) => parts
54                .iter()
55                .filter_map(ContentPart::as_text)
56                .collect::<Vec<_>>()
57                .join("\n"),
58        }
59    }
60
61    /// The parts, with a text-only content read as a single text part.
62    pub fn parts(&self) -> impl Iterator<Item = &ContentPart> {
63        // `Text` has no `ContentPart` to borrow, so the two arms cannot share
64        // an iterator over `&ContentPart` without one existing somewhere.
65        // Boxing the pair is cheaper than making callers match.
66        let iter: Box<dyn Iterator<Item = &ContentPart>> = match self {
67            Self::Text(_) => Box::new(std::iter::empty()),
68            Self::Parts(parts) => Box::new(parts.iter()),
69        };
70        iter
71    }
72
73    /// Whether any part carries something a text channel cannot: bytes, or a
74    /// URI naming them.
75    ///
76    /// What a consumer checks before deciding a message can go somewhere only
77    /// prose fits — a summarizer, a token estimate, a provider that takes no
78    /// parts.
79    pub fn has_media(&self) -> bool {
80        self.parts()
81            .any(|part| !matches!(part, ContentPart::Text { .. }))
82    }
83}
84
85impl From<String> for MessageContent {
86    fn from(text: String) -> Self {
87        Self::Text(text)
88    }
89}
90
91impl From<&String> for MessageContent {
92    fn from(text: &String) -> Self {
93        Self::Text(text.clone())
94    }
95}
96
97impl From<&str> for MessageContent {
98    fn from(text: &str) -> Self {
99        Self::Text(text.to_string())
100    }
101}
102
103impl From<Vec<ContentPart>> for MessageContent {
104    fn from(parts: Vec<ContentPart>) -> Self {
105        Self::Parts(parts)
106    }
107}
108
109impl From<ContentPart> for MessageContent {
110    fn from(part: ContentPart) -> Self {
111        Self::Parts(vec![part])
112    }
113}
114
115/// `Vec<u8>` in a `Debug` line is one number per byte. A megabyte of PDF in a
116/// log is not a diagnostic, so the bytes are reported as a count.
117impl std::fmt::Debug for MessageContent {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            Self::Text(text) => f.debug_tuple("Text").field(text).finish(),
121            Self::Parts(parts) => f.debug_tuple("Parts").field(parts).finish(),
122        }
123    }
124}
125
126/// One part of a message.
127///
128/// `#[non_exhaustive]`: the set grows with what providers accept. Build one
129/// with [`text`](Self::text), [`blob`](Self::blob) or [`uri`](Self::uri).
130#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(tag = "type", rename_all = "snake_case")]
132#[non_exhaustive]
133pub enum ContentPart {
134    /// Prose.
135    Text { text: String },
136    /// Bytes carried in the request itself, with the MIME type that says how
137    /// to read them.
138    ///
139    /// Every provider caps how much it will take this way (a few megabytes);
140    /// past that the file belongs behind a [`Uri`](Self::Uri).
141    Blob {
142        /// The MIME type, e.g. `image/png`, `application/pdf`. Required: it is
143        /// what decides how each provider frames the bytes, and a provider
144        /// given no type has nothing to guess from.
145        mime_type: String,
146        #[serde(with = "base64_bytes")]
147        data: Vec<u8>,
148        /// The file name, where there is one. Sent where the provider has a
149        /// field for it; a model reads it as a label.
150        #[serde(default, skip_serializing_if = "Option::is_none")]
151        name: Option<String>,
152    },
153    /// Bytes the provider fetches, named by URI.
154    ///
155    /// Gemini takes any MIME type this way. OpenAI takes only images; anything
156    /// else reaches the model as a line of text naming the file, because there
157    /// is no field on that API to put it in.
158    Uri {
159        mime_type: String,
160        uri: String,
161        #[serde(default, skip_serializing_if = "Option::is_none")]
162        name: Option<String>,
163    },
164}
165
166impl ContentPart {
167    pub fn text(text: impl Into<String>) -> Self {
168        Self::Text { text: text.into() }
169    }
170
171    pub fn blob(mime_type: impl Into<String>, data: Vec<u8>) -> Self {
172        Self::Blob {
173            mime_type: mime_type.into(),
174            data,
175            name: None,
176        }
177    }
178
179    pub fn uri(mime_type: impl Into<String>, uri: impl Into<String>) -> Self {
180        Self::Uri {
181            mime_type: mime_type.into(),
182            uri: uri.into(),
183            name: None,
184        }
185    }
186
187    /// The same part with a file name attached. No-op on a text part, which
188    /// has nothing to name.
189    pub fn named(mut self, file_name: impl Into<String>) -> Self {
190        match &mut self {
191            Self::Text { .. } => {}
192            Self::Blob { name, .. } | Self::Uri { name, .. } => *name = Some(file_name.into()),
193        }
194        self
195    }
196
197    /// The text of a text part; `None` for anything else.
198    pub fn as_text(&self) -> Option<&str> {
199        match self {
200            Self::Text { text } => Some(text),
201            _ => None,
202        }
203    }
204
205    /// The MIME type, for a part that has one.
206    pub fn mime_type(&self) -> Option<&str> {
207        match self {
208            Self::Text { .. } => None,
209            Self::Blob { mime_type, .. } | Self::Uri { mime_type, .. } => Some(mime_type),
210        }
211    }
212
213    /// The file name, for a part that was given one.
214    pub fn name(&self) -> Option<&str> {
215        match self {
216            Self::Text { .. } => None,
217            Self::Blob { name, .. } | Self::Uri { name, .. } => name.as_deref(),
218        }
219    }
220
221    /// A file's name and type as one phrase, in whatever combination it
222    /// actually has — for the places a provider can only name what it could
223    /// not send.
224    pub(crate) fn describe(&self) -> String {
225        match (self.name(), self.mime_type()) {
226            (Some(name), Some(mime)) => format!("{name} ({mime})"),
227            (Some(name), None) => name.to_string(),
228            (None, Some(mime)) => format!("unnamed {mime} file"),
229            (None, None) => "unnamed file".to_string(),
230        }
231    }
232}
233
234/// See [`MessageContent`]'s `Debug`: the bytes are a count.
235impl std::fmt::Debug for ContentPart {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        match self {
238            Self::Text { text } => f.debug_struct("Text").field("text", text).finish(),
239            Self::Blob {
240                mime_type,
241                data,
242                name,
243            } => f
244                .debug_struct("Blob")
245                .field("mime_type", mime_type)
246                .field("name", name)
247                .field("data", &format_args!("{} bytes", data.len()))
248                .finish(),
249            Self::Uri {
250                mime_type,
251                uri,
252                name,
253            } => f
254                .debug_struct("Uri")
255                .field("mime_type", mime_type)
256                .field("uri", uri)
257                .field("name", name)
258                .finish(),
259        }
260    }
261}
262
263/// Bytes as base64 in JSON, rather than the array of numbers `Vec<u8>`
264/// derives. A stored conversation is read by things other than this crate, and
265/// a 300-element array where every other tool writes a string is a trap.
266mod base64_bytes {
267    use base64::Engine as _;
268    use serde::{Deserialize, Deserializer, Serializer, de::Error as _};
269
270    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
271        serializer.serialize_str(&base64::engine::general_purpose::STANDARD.encode(bytes))
272    }
273
274    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
275        let encoded = String::deserialize(deserializer)?;
276        base64::engine::general_purpose::STANDARD
277            .decode(encoded.as_bytes())
278            .map_err(D::Error::custom)
279    }
280}
281
282/// The base64 a provider puts on the wire.
283pub(crate) fn encode_base64(bytes: &[u8]) -> String {
284    use base64::Engine as _;
285    base64::engine::general_purpose::STANDARD.encode(bytes)
286}
287
288/// A `data:` URL, which is how OpenAI takes bytes for every part shape it has.
289pub(crate) fn data_url(mime_type: &str, bytes: &[u8]) -> String {
290    format!("data:{mime_type};base64,{}", encode_base64(bytes))
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    /// The point of `untagged`: a message written before parts existed is
298    /// still readable, and a text-only message written now is still readable
299    /// by whatever reads the old shape.
300    #[test]
301    fn text_content_is_a_bare_string_on_the_wire() {
302        let content = MessageContent::from("hei");
303        assert_eq!(
304            serde_json::to_value(&content).expect("serializes"),
305            serde_json::json!("hei")
306        );
307        let back: MessageContent =
308            serde_json::from_value(serde_json::json!("hei")).expect("deserializes");
309        assert_eq!(back, content);
310    }
311
312    /// Bytes round-trip as base64, not as an array of numbers.
313    #[test]
314    fn a_blob_is_base64_on_the_wire() {
315        let content = MessageContent::from(vec![
316            ContentPart::text("what is this"),
317            ContentPart::blob("image/png", vec![0, 1, 2]).named("shot.png"),
318        ]);
319        let json = serde_json::to_value(&content).expect("serializes");
320        assert_eq!(
321            json,
322            serde_json::json!([
323                { "type": "text", "text": "what is this" },
324                { "type": "blob", "mime_type": "image/png", "data": "AAEC", "name": "shot.png" },
325            ])
326        );
327        let back: MessageContent = serde_json::from_value(json).expect("deserializes");
328        assert_eq!(back, content);
329    }
330
331    /// A parts list holding only text is still a parts list: `as_text` is
332    /// "this is the whole message", which is what a caller replacing the
333    /// content needs to know.
334    #[test]
335    fn as_text_speaks_only_for_a_whole_text_message() {
336        assert_eq!(MessageContent::from("hei").as_text(), Some("hei"));
337        let parts = MessageContent::from(vec![ContentPart::text("hei")]);
338        assert_eq!(parts.as_text(), None);
339        assert_eq!(parts.to_text(), "hei");
340    }
341
342    /// What a consumer checks before sending a message somewhere only prose
343    /// fits.
344    #[test]
345    fn media_is_what_a_text_channel_cannot_carry() {
346        assert!(!MessageContent::from("hei").has_media());
347        assert!(!MessageContent::from(vec![ContentPart::text("hei")]).has_media());
348        assert!(MessageContent::from(ContentPart::blob("image/png", vec![1])).has_media());
349        assert!(MessageContent::from(ContentPart::uri("image/png", "https://x/y.png")).has_media());
350    }
351
352    /// The bytes are a count, so a logged message stays a diagnostic.
353    #[test]
354    fn debug_does_not_print_the_bytes() {
355        let content = MessageContent::from(ContentPart::blob("application/pdf", vec![7; 4096]));
356        let rendered = format!("{content:?}");
357        assert!(rendered.contains("4096 bytes"), "{rendered}");
358        assert!(!rendered.contains(", 7,"), "{rendered}");
359    }
360}