Skip to main content

objectiveai_sdk/agent/completions/message/
rich_content.rs

1//! Rich content types for user/assistant messages (supports multimodal input).
2
3use crate::functions;
4use functions::expression::{
5    ExpressionError, FromStarlarkValue, ToStarlarkValue, WithExpression,
6};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use starlark::values::dict::{
10    AllocDict as StarlarkAllocDict, DictRef as StarlarkDictRef,
11};
12use starlark::values::{
13    Heap as StarlarkHeap, UnpackValue, Value as StarlarkValue,
14};
15
16/// Rich content for user/assistant messages (supports multimodal input).
17#[derive(
18    Debug,
19    Clone,
20    PartialEq,
21    Serialize,
22    Deserialize,
23    JsonSchema,
24    arbitrary::Arbitrary,
25)]
26#[serde(untagged)]
27#[schemars(rename = "agent.completions.message.RichContent")]
28pub enum RichContent {
29    /// Plain text content.
30    #[schemars(title = "Text")]
31    Text(String),
32    /// Multi-part content (text, images, audio, video, files).
33    #[schemars(title = "Parts")]
34    Parts(Vec<RichContentPart>),
35}
36
37impl RichContent {
38    pub fn push(&mut self, other: &RichContent) {
39        match (&mut *self, other) {
40            (RichContent::Text(self_text), RichContent::Text(other_text)) => {
41                self_text.push_str(&other_text);
42            }
43            (RichContent::Text(self_text), RichContent::Parts(other_parts)) => {
44                let mut parts = Vec::with_capacity(1 + other_parts.len());
45                parts.push(RichContentPart::Text {
46                    text: std::mem::take(self_text),
47                });
48                parts.extend(other_parts.iter().cloned());
49                *self = RichContent::Parts(parts);
50            }
51            (RichContent::Parts(self_parts), RichContent::Text(other_text)) => {
52                self_parts.push(RichContentPart::Text {
53                    text: other_text.clone(),
54                });
55            }
56            (
57                RichContent::Parts(self_parts),
58                RichContent::Parts(other_parts),
59            ) => {
60                self_parts.extend(other_parts.iter().cloned());
61            }
62        }
63    }
64
65    /// Prepares the content by normalizing parts.
66    ///
67    /// This consolidates consecutive text parts, removes empty parts,
68    /// and converts single-part content to plain text.
69    pub fn prepare(&mut self) {
70        // nothing to prepare for plain text
71        let parts = match self {
72            RichContent::Text(_) => return,
73            RichContent::Parts(parts) => parts,
74        };
75
76        // prepare all parts
77        parts.iter_mut().for_each(RichContentPart::prepare);
78
79        // join consecutive text parts + remove empty parts
80        let mut final_parts = Vec::with_capacity(parts.len());
81        let mut buffer: Option<String> = None;
82        for part in parts.drain(..) {
83            match part {
84                part if part.is_empty() => continue,
85                RichContentPart::Text { text } => {
86                    if let Some(buffer) = &mut buffer {
87                        buffer.push_str(&text);
88                    } else {
89                        buffer = Some(text);
90                    }
91                }
92                part => {
93                    if let Some(buffer) = buffer.take() {
94                        final_parts
95                            .push(RichContentPart::Text { text: buffer });
96                    }
97                    final_parts.push(part);
98                }
99            }
100        }
101        if let Some(buffer) = buffer.take() {
102            final_parts.push(RichContentPart::Text { text: buffer });
103        }
104
105        // replace self with final parts
106        if final_parts.len() == 1
107            && matches!(&final_parts[0], RichContentPart::Text { .. })
108        {
109            match final_parts.into_iter().next() {
110                Some(RichContentPart::Text { text }) => {
111                    *self = RichContent::Text(text);
112                }
113                _ => unreachable!(),
114            }
115        } else {
116            *self = RichContent::Parts(final_parts);
117        }
118    }
119
120    /// Returns `true` if the content is empty.
121    pub fn is_empty(&self) -> bool {
122        match self {
123            RichContent::Text(text) => text.is_empty(),
124            RichContent::Parts(parts) => parts.is_empty(),
125        }
126    }
127
128    /// Computes a content-addressed ID for this content.
129    pub fn id(&self) -> String {
130        let mut hasher = twox_hash::XxHash3_128::with_seed(0);
131        hasher.write(serde_json::to_string(self).unwrap().as_bytes());
132        format!("{:0>22}", base62::encode(hasher.finish_128()))
133    }
134
135    /// Validates that this content contains only text or image parts.
136    ///
137    /// Used by upstream agent definitions whose prefix/suffix content
138    /// rendering can only express text and image media (audio, video, and
139    /// file parts have no representation in those upstreams' prompts).
140    /// Returns `Err` naming the offending part variant if any non-text /
141    /// non-image part is present.
142    pub fn validate_text_or_image_only(&self) -> Result<(), String> {
143        match self {
144            RichContent::Text(_) => Ok(()),
145            RichContent::Parts(parts) => {
146                for (idx, part) in parts.iter().enumerate() {
147                    match part {
148                        RichContentPart::Text { .. }
149                        | RichContentPart::ImageUrl { .. } => {}
150                        RichContentPart::InputAudio { .. } => {
151                            return Err(format!(
152                                "part[{idx}] has unsupported media type `input_audio`; only text and image parts are allowed"
153                            ));
154                        }
155                        RichContentPart::InputVideo { .. } => {
156                            return Err(format!(
157                                "part[{idx}] has unsupported media type `input_video`; only text and image parts are allowed"
158                            ));
159                        }
160                        RichContentPart::VideoUrl { .. } => {
161                            return Err(format!(
162                                "part[{idx}] has unsupported media type `video_url`; only text and image parts are allowed"
163                            ));
164                        }
165                        RichContentPart::File { .. } => {
166                            return Err(format!(
167                                "part[{idx}] has unsupported media type `file`; only text and image parts are allowed"
168                            ));
169                        }
170                    }
171                }
172                Ok(())
173            }
174        }
175    }
176}
177
178impl FromStarlarkValue for RichContent {
179    fn from_starlark_value(
180        value: &StarlarkValue,
181    ) -> Result<Self, ExpressionError> {
182        if let Ok(Some(s)) = <&str as UnpackValue>::unpack_value(*value) {
183            return Ok(RichContent::Text(s.to_owned()));
184        }
185        let parts = Vec::<RichContentPart>::from_starlark_value(value)?;
186        Ok(RichContent::Parts(parts))
187    }
188}
189
190/// Collapse a `Vec<RichContentPart>` into `RichContent`, joining
191/// consecutive text-only parts into one `RichContent::Text` (separated
192/// by `\n\n`) and leaving mixed-media inputs as `RichContent::Parts`.
193/// Empty input yields `RichContent::Text(String::new())`.
194impl From<Vec<RichContentPart>> for RichContent {
195    fn from(parts: Vec<RichContentPart>) -> Self {
196        if parts.is_empty() {
197            return RichContent::Text(String::new());
198        }
199        let all_text = parts
200            .iter()
201            .all(|p| matches!(p, RichContentPart::Text { .. }));
202        if all_text {
203            let joined = parts
204                .into_iter()
205                .filter_map(|p| match p {
206                    RichContentPart::Text { text } => Some(text),
207                    _ => None,
208                })
209                .collect::<Vec<_>>()
210                .join("\n\n");
211            RichContent::Text(joined)
212        } else {
213            RichContent::Parts(parts)
214        }
215    }
216}
217
218impl RichContentPart {
219    /// Build a part from a raw string. If `text` parses as a
220    /// `data:<mime>;base64,<payload>` URL, route it through the same
221    /// mime-prefix dispatch the inlined-resource path uses
222    /// (`image/*` → `ImageUrl`, `audio/*` → `InputAudio`, `video/*` →
223    /// `InputVideo`, else `File`). Otherwise return a plain
224    /// `RichContentPart::Text { text }`.
225    pub fn from_text_or_data_url(text: String) -> Self {
226        match crate::data_url::parse_data_url(&text) {
227            Some((mime, payload)) => {
228                Self::from_blob(mime, payload.to_string(), None)
229            }
230            None => RichContentPart::Text { text },
231        }
232    }
233
234    /// Build a `RichContentPart` from a raw base64 blob and explicit
235    /// mime. Routing rules:
236    ///
237    /// - `image/*` → `ImageUrl` (base64 data URL).
238    /// - `audio/*` → `InputAudio` (raw base64 + format string, matching
239    ///   the `From<AudioContent>` convention).
240    /// - `video/*` → `InputVideo` (base64 data URL in the `VideoUrl`
241    ///   shape).
242    /// - Anything else — including ambiguous container types like
243    ///   `application/ogg` or `application/mp4` where the bytes would
244    ///   be needed to disambiguate audio vs video — becomes a `File`
245    ///   part with the raw base64 data and the caller-supplied filename.
246    pub fn from_blob(
247        mime: &str,
248        blob: String,
249        filename: Option<String>,
250    ) -> Self {
251        if mime.starts_with("image/") {
252            RichContentPart::ImageUrl {
253                image_url: ImageUrl {
254                    url: format!("data:{};base64,{}", mime, blob),
255                    detail: None,
256                },
257            }
258        } else if mime.starts_with("audio/") {
259            RichContentPart::InputAudio {
260                input_audio: InputAudio {
261                    data: blob,
262                    format: mime.to_string(),
263                },
264            }
265        } else if mime.starts_with("video/") {
266            RichContentPart::InputVideo {
267                video_url: VideoUrl {
268                    url: format!("data:{};base64,{}", mime, blob),
269                },
270            }
271        } else {
272            RichContentPart::File {
273                file: File {
274                    file_data: Some(blob),
275                    filename,
276                    file_id: None,
277                    file_url: None,
278                },
279            }
280        }
281    }
282}
283
284impl RichContent {
285    /// Build a `RichContent` from a raw string. Wraps
286    /// [`RichContentPart::from_text_or_data_url`] and lets the
287    /// existing `From<Vec<RichContentPart>>` collapse demote a
288    /// single Text part back to `RichContent::Text` automatically.
289    pub fn from_text_or_data_url(text: String) -> Self {
290        Self::from(vec![RichContentPart::from_text_or_data_url(text)])
291    }
292}
293
294/// Convert an inlined MCP resource (`ResourceContentsUnion`) into a
295/// `RichContentPart`. Mapping rules:
296///
297/// - `Text` → text part.
298/// - `Blob` with `image/*` mime → `image_url` part (base64 data URL).
299/// - `Blob` with `audio/*` mime → `input_audio` part (raw base64 +
300///   format string, matching the `From<AudioContent>` convention).
301/// - `Blob` with `video/*` mime → `input_video` part (base64 data URL
302///   in the `VideoUrl` shape).
303/// - Any other blob — including ambiguous container types like
304///   `application/ogg` or `application/mp4` where the bytes would be
305///   needed to disambiguate audio vs video — becomes a file part
306///   with the raw base64 data and a filename lifted from the
307///   resource URI's trailing path segment.
308///
309/// Used by [`From<ContentBlock>`]'s `EmbeddedResource` arm and by
310/// [`crate::mcp::Connection::call_tool_as_message`]'s `ResourceLink`
311/// fetch path — both produce a `ResourceContentsUnion`, both want
312/// the same mapping.
313#[cfg(feature = "mcp")]
314impl From<crate::mcp::shared::ResourceContentsUnion> for RichContentPart {
315    fn from(contents: crate::mcp::shared::ResourceContentsUnion) -> Self {
316        use crate::mcp::shared::ResourceContentsUnion;
317        match contents {
318            ResourceContentsUnion::Text(text) => {
319                RichContentPart::Text { text: text.text }
320            }
321            ResourceContentsUnion::Blob(blob) => {
322                let mime = blob
323                    .base
324                    .mime_type
325                    .as_deref()
326                    .unwrap_or("application/octet-stream");
327                let filename = blob
328                    .base
329                    .uri
330                    .rsplit('/')
331                    .next()
332                    .filter(|s| !s.is_empty())
333                    .map(String::from);
334                RichContentPart::from_blob(mime, blob.blob, filename)
335            }
336        }
337    }
338}
339
340/// Convert an MCP `ContentBlock` into a `RichContentPart`. Lossless
341/// for image / audio / embedded_resource. The `Text` arm also peeks
342/// at the text body: if it parses as a `data:<mime>;base64,<payload>`
343/// URL it's routed through the same mime-prefix dispatch the blob
344/// path uses (so servers that pack media into a text block — common
345/// for sloppy upstreams — still land in the right `RichContentPart`
346/// variant). Plain text passes through unchanged. `ResourceLink` is
347/// the only stateful variant — resolving its URI requires a live
348/// `Connection` to call `read_resource`, which this `From` impl
349/// cannot do — so it falls back to a JSON-serialized text part. The
350/// connection-bound path in
351/// [`crate::mcp::Connection::call_tool_as_message`] handles
352/// `ResourceLink` properly by fetching its contents and routing
353/// them through [`From<ResourceContentsUnion>`].
354/// `_meta` string-value lookup. Returns `None` when the key is
355/// absent or the value isn't a JSON string.
356#[cfg(feature = "mcp")]
357fn meta_string(
358    meta: &Option<indexmap::IndexMap<String, serde_json::Value>>,
359    key: &str,
360) -> Option<String> {
361    meta.as_ref()?
362        .get(key)
363        .and_then(|v| v.as_str().map(String::from))
364}
365
366/// Deserialize a `_meta` entry into [`ImageUrlDetail`]. Accepts a
367/// string value (`"auto"` / `"low"` / `"high"`) that serde_json
368/// reads back via the type's `Deserialize` impl.
369#[cfg(feature = "mcp")]
370fn meta_image_detail(
371    meta: &Option<indexmap::IndexMap<String, serde_json::Value>>,
372    key: &str,
373) -> Option<ImageUrlDetail> {
374    let v = meta.as_ref()?.get(key)?.clone();
375    serde_json::from_value::<ImageUrlDetail>(v).ok()
376}
377
378/// Decode a Text carrier in the absence of an `objectiveai/kind`
379/// marker. Runs the existing `parse_data_url` heuristic and applies
380/// the `objectiveai/filename` marker for `File` outcomes.
381#[cfg(feature = "mcp")]
382fn decode_text_no_marker(
383    text: String,
384    meta: &Option<indexmap::IndexMap<String, serde_json::Value>>,
385) -> RichContentPart {
386    if let Some((mime, payload)) = crate::data_url::parse_data_url(&text) {
387        let filename = meta_string(meta, "objectiveai/filename");
388        RichContentPart::from_blob(mime, payload.to_string(), filename)
389    } else {
390        RichContentPart::Text { text }
391    }
392}
393
394#[cfg(feature = "mcp")]
395impl From<crate::mcp::tool::ContentBlock> for RichContentPart {
396    fn from(block: crate::mcp::tool::ContentBlock) -> Self {
397        use crate::mcp::tool::ContentBlock;
398        // `_meta` marker keys produced by `From<RichContentPart>` —
399        // every Text/Image carrier here may consult them. See
400        // `mcp/tool/content_block.rs` for the catalogue.
401        const META_KIND: &str = "objectiveai/kind";
402        const META_IMAGE_DETAIL: &str = "objectiveai/image_detail";
403        const META_FILENAME: &str = "objectiveai/filename";
404        const KIND_IMAGE_URL_REMOTE: &str = "image_url_remote";
405        const KIND_INPUT_VIDEO_REMOTE: &str = "input_video_remote";
406        const KIND_VIDEO_URL: &str = "video_url";
407        const KIND_FILE_URL: &str = "file_url";
408        const KIND_FILE_ID: &str = "file_id";
409
410        match block {
411            ContentBlock::Text(t) => {
412                // 1) Marker-driven reconstruction. Read `kind` and
413                //    rebuild the corresponding RichContentPart
414                //    variant, pulling companion markers as needed.
415                let kind = meta_string(&t._meta, META_KIND);
416                if let Some(kind) = kind {
417                    return match kind.as_str() {
418                        KIND_IMAGE_URL_REMOTE => {
419                            let detail =
420                                meta_image_detail(&t._meta, META_IMAGE_DETAIL);
421                            RichContentPart::ImageUrl {
422                                image_url: ImageUrl {
423                                    url: t.text,
424                                    detail,
425                                },
426                            }
427                        }
428                        KIND_INPUT_VIDEO_REMOTE => {
429                            RichContentPart::InputVideo {
430                                video_url: VideoUrl { url: t.text },
431                            }
432                        }
433                        KIND_VIDEO_URL => RichContentPart::VideoUrl {
434                            video_url: VideoUrl { url: t.text },
435                        },
436                        KIND_FILE_URL => RichContentPart::File {
437                            file: File {
438                                file_data: None,
439                                filename: meta_string(&t._meta, META_FILENAME),
440                                file_id: None,
441                                file_url: Some(t.text),
442                            },
443                        },
444                        KIND_FILE_ID => RichContentPart::File {
445                            file: File {
446                                file_data: None,
447                                filename: meta_string(&t._meta, META_FILENAME),
448                                file_id: Some(t.text),
449                                file_url: None,
450                            },
451                        },
452                        // Unknown kind: fall through to the
453                        // marker-free decode below. Be lenient.
454                        _ => decode_text_no_marker(t.text, &t._meta),
455                    };
456                }
457                // 2) No `kind` marker: parse_data_url-driven
458                //    heuristic (image/audio/video/file by mime,
459                //    plain text otherwise). Filename meta may still
460                //    apply to the file_data case.
461                decode_text_no_marker(t.text, &t._meta)
462            }
463            ContentBlock::Image(i) => {
464                let detail = meta_image_detail(&i._meta, META_IMAGE_DETAIL);
465                let mut image_url: ImageUrl = i.into();
466                image_url.detail = detail;
467                RichContentPart::ImageUrl { image_url }
468            }
469            ContentBlock::Audio(a) => RichContentPart::InputAudio {
470                input_audio: a.into(),
471            },
472            ContentBlock::EmbeddedResource(embedded) => {
473                embedded.resource.into()
474            }
475            block @ ContentBlock::ResourceLink(_) => RichContentPart::Text {
476                text: serde_json::to_string(&block).unwrap_or_default(),
477            },
478        }
479    }
480}
481
482/// Build a `RichContent` from an MCP `Vec<ContentBlock>` via the
483/// element-wise [`From<ContentBlock>`] impl, then collapse to plain
484/// text when every part is text. Matches the shape produced by
485/// `call_tool_as_message` on the agent side.
486#[cfg(feature = "mcp")]
487impl From<Vec<crate::mcp::tool::ContentBlock>> for RichContent {
488    fn from(blocks: Vec<crate::mcp::tool::ContentBlock>) -> Self {
489        let parts: Vec<RichContentPart> =
490            blocks.into_iter().map(Into::into).collect();
491        RichContent::from(parts)
492    }
493}
494
495/// Expression variant of [`RichContent`] for dynamic content.
496#[derive(
497    Debug,
498    Clone,
499    PartialEq,
500    Serialize,
501    Deserialize,
502    JsonSchema,
503    arbitrary::Arbitrary,
504)]
505#[serde(untagged)]
506#[schemars(rename = "agent.completions.message.RichContentExpression")]
507pub enum RichContentExpression {
508    /// Plain text content.
509    #[schemars(title = "Text")]
510    Text(String),
511    /// Multi-part content expressions.
512    #[schemars(title = "Parts")]
513    Parts(
514        Vec<functions::expression::WithExpression<RichContentPartExpression>>,
515    ),
516}
517
518impl RichContentExpression {
519    /// Compiles the expression into a concrete [`RichContent`].
520    pub fn compile(
521        self,
522        params: &functions::expression::Params,
523    ) -> Result<RichContent, functions::expression::ExpressionError> {
524        match self {
525            RichContentExpression::Text(text) => Ok(RichContent::Text(text)),
526            RichContentExpression::Parts(parts) => {
527                let mut compiled_parts = Vec::with_capacity(parts.len());
528                for part in parts {
529                    match part.compile_one_or_many(params)? {
530                        functions::expression::OneOrMany::One(one_part) => {
531                            compiled_parts.push(one_part.compile(params)?);
532                        }
533                        functions::expression::OneOrMany::Many(many_parts) => {
534                            for part in many_parts {
535                                compiled_parts.push(part.compile(params)?);
536                            }
537                        }
538                    }
539                }
540                Ok(RichContent::Parts(compiled_parts))
541            }
542        }
543    }
544}
545
546impl From<RichContent> for RichContentExpression {
547    fn from(content: RichContent) -> Self {
548        match content {
549            RichContent::Text(text) => RichContentExpression::Text(text),
550            RichContent::Parts(parts) => RichContentExpression::Parts(
551                parts
552                    .into_iter()
553                    .map(RichContentPartExpression::from)
554                    .map(WithExpression::Value)
555                    .collect(),
556            ),
557        }
558    }
559}
560
561impl FromStarlarkValue for RichContentExpression {
562    fn from_starlark_value(
563        value: &StarlarkValue,
564    ) -> Result<Self, ExpressionError> {
565        if let Ok(Some(s)) = <&str as UnpackValue>::unpack_value(*value) {
566            return Ok(RichContentExpression::Text(s.to_owned()));
567        }
568        let parts = Vec::<WithExpression<RichContentPartExpression>>::from_starlark_value(value)?;
569        Ok(RichContentExpression::Parts(parts))
570    }
571}
572
573/// A part of rich content.
574#[derive(
575    Debug,
576    Clone,
577    Hash,
578    PartialEq,
579    Eq,
580    Serialize,
581    Deserialize,
582    JsonSchema,
583    arbitrary::Arbitrary,
584)]
585#[serde(tag = "type", rename_all = "snake_case")]
586#[schemars(rename = "agent.completions.message.RichContentPart")]
587pub enum RichContentPart {
588    /// Text content.
589    #[schemars(title = "Text")]
590    Text { text: String },
591    /// An image URL.
592    #[schemars(title = "ImageUrl")]
593    ImageUrl { image_url: ImageUrl },
594    /// Audio input.
595    #[schemars(title = "InputAudio")]
596    InputAudio { input_audio: InputAudio },
597    /// Video input.
598    #[schemars(title = "InputVideo")]
599    InputVideo { video_url: VideoUrl },
600    /// A video URL.
601    #[schemars(title = "VideoUrl")]
602    VideoUrl { video_url: VideoUrl },
603    /// A file.
604    #[schemars(title = "File")]
605    File { file: File },
606}
607
608impl RichContentPart {
609    /// Prepares the content part by normalizing optional fields.
610    pub fn prepare(&mut self) {
611        match self {
612            RichContentPart::Text { .. } => {}
613            RichContentPart::ImageUrl { image_url } => {
614                image_url.prepare();
615            }
616            RichContentPart::InputAudio { .. } => {}
617            RichContentPart::InputVideo { .. } => {}
618            RichContentPart::VideoUrl { .. } => {}
619            RichContentPart::File { file } => {
620                file.prepare();
621            }
622        }
623    }
624
625    /// Returns `true` if the content part is empty.
626    pub fn is_empty(&self) -> bool {
627        match self {
628            RichContentPart::Text { text } => text.is_empty(),
629            RichContentPart::ImageUrl { image_url } => image_url.is_empty(),
630            RichContentPart::InputAudio { input_audio } => {
631                input_audio.is_empty()
632            }
633            RichContentPart::InputVideo { video_url } => video_url.is_empty(),
634            RichContentPart::VideoUrl { video_url } => video_url.is_empty(),
635            RichContentPart::File { file } => file.is_empty(),
636        }
637    }
638}
639
640impl ToStarlarkValue for RichContentPart {
641    fn to_starlark_value<'v>(
642        &self,
643        heap: &'v StarlarkHeap,
644    ) -> StarlarkValue<'v> {
645        match self {
646            RichContentPart::Text { text } => heap.alloc(StarlarkAllocDict([
647                ("type", "text".to_starlark_value(heap)),
648                ("text", text.to_starlark_value(heap)),
649            ])),
650            RichContentPart::ImageUrl { image_url } => {
651                heap.alloc(StarlarkAllocDict([
652                    ("type", "image_url".to_starlark_value(heap)),
653                    ("image_url", image_url.to_starlark_value(heap)),
654                ]))
655            }
656            RichContentPart::InputAudio { input_audio } => {
657                heap.alloc(StarlarkAllocDict([
658                    ("type", "input_audio".to_starlark_value(heap)),
659                    ("input_audio", input_audio.to_starlark_value(heap)),
660                ]))
661            }
662            RichContentPart::InputVideo { video_url } => {
663                heap.alloc(StarlarkAllocDict([
664                    ("type", "input_video".to_starlark_value(heap)),
665                    ("video_url", video_url.to_starlark_value(heap)),
666                ]))
667            }
668            RichContentPart::VideoUrl { video_url } => {
669                heap.alloc(StarlarkAllocDict([
670                    ("type", "video_url".to_starlark_value(heap)),
671                    ("video_url", video_url.to_starlark_value(heap)),
672                ]))
673            }
674            RichContentPart::File { file } => heap.alloc(StarlarkAllocDict([
675                ("type", "file".to_starlark_value(heap)),
676                ("file", file.to_starlark_value(heap)),
677            ])),
678        }
679    }
680}
681
682impl FromStarlarkValue for RichContentPart {
683    fn from_starlark_value(
684        value: &StarlarkValue,
685    ) -> Result<Self, ExpressionError> {
686        let dict = StarlarkDictRef::from_value(*value).ok_or_else(|| {
687            ExpressionError::StarlarkConversionError(
688                "RichContentPart: expected dict".into(),
689            )
690        })?;
691        // First pass: find the type
692        let mut typ = None;
693        for (k, v) in dict.iter() {
694            if let Ok(Some("type")) = <&str as UnpackValue>::unpack_value(k) {
695                typ = Some(
696                    <&str as UnpackValue>::unpack_value(v)
697                        .map_err(|e| {
698                            ExpressionError::StarlarkConversionError(
699                                e.to_string(),
700                            )
701                        })?
702                        .ok_or_else(|| {
703                            ExpressionError::StarlarkConversionError(
704                                "RichContentPart: expected string type".into(),
705                            )
706                        })?,
707                );
708                break;
709            }
710        }
711        let typ = typ.ok_or_else(|| {
712            ExpressionError::StarlarkConversionError(
713                "RichContentPart: missing type".into(),
714            )
715        })?;
716        // Second pass: find the payload by expected key
717        let payload_key = match typ {
718            "text" => "text",
719            "image_url" => "image_url",
720            "input_audio" => "input_audio",
721            "input_video" | "video_url" => "video_url",
722            "file" => "file",
723            _ => {
724                return Err(ExpressionError::StarlarkConversionError(format!(
725                    "RichContentPart: unknown type: {}",
726                    typ
727                )));
728            }
729        };
730        let mut payload = None;
731        for (k, v) in dict.iter() {
732            if let Ok(Some(key)) = <&str as UnpackValue>::unpack_value(k) {
733                if key == payload_key {
734                    payload = Some(v);
735                    break;
736                }
737            }
738        }
739        let v = payload.ok_or_else(|| {
740            ExpressionError::StarlarkConversionError(format!(
741                "RichContentPart: missing {}",
742                payload_key
743            ))
744        })?;
745        match typ {
746            "text" => Ok(RichContentPart::Text {
747                text: String::from_starlark_value(&v)?,
748            }),
749            "image_url" => Ok(RichContentPart::ImageUrl {
750                image_url: ImageUrl::from_starlark_value(&v)?,
751            }),
752            "input_audio" => Ok(RichContentPart::InputAudio {
753                input_audio: InputAudio::from_starlark_value(&v)?,
754            }),
755            "input_video" => Ok(RichContentPart::InputVideo {
756                video_url: VideoUrl::from_starlark_value(&v)?,
757            }),
758            "video_url" => Ok(RichContentPart::VideoUrl {
759                video_url: VideoUrl::from_starlark_value(&v)?,
760            }),
761            "file" => Ok(RichContentPart::File {
762                file: File::from_starlark_value(&v)?,
763            }),
764            _ => unreachable!(),
765        }
766    }
767}
768
769/// Expression variant of [`RichContentPart`] for dynamic content.
770#[derive(
771    Debug,
772    Clone,
773    PartialEq,
774    Serialize,
775    Deserialize,
776    JsonSchema,
777    arbitrary::Arbitrary,
778)]
779#[serde(tag = "type", rename_all = "snake_case")]
780#[schemars(rename = "agent.completions.message.RichContentPartExpression")]
781pub enum RichContentPartExpression {
782    #[schemars(title = "Text")]
783    Text {
784        text: functions::expression::WithExpression<String>,
785    },
786    #[schemars(title = "ImageUrl")]
787    ImageUrl {
788        image_url: functions::expression::WithExpression<ImageUrl>,
789    },
790    #[schemars(title = "InputAudio")]
791    InputAudio {
792        input_audio: functions::expression::WithExpression<InputAudio>,
793    },
794    #[schemars(title = "InputVideo")]
795    InputVideo {
796        video_url: functions::expression::WithExpression<VideoUrl>,
797    },
798    #[schemars(title = "VideoUrl")]
799    VideoUrl {
800        video_url: functions::expression::WithExpression<VideoUrl>,
801    },
802    #[schemars(title = "File")]
803    File {
804        file: functions::expression::WithExpression<File>,
805    },
806}
807
808impl RichContentPartExpression {
809    /// Compiles the expression into a concrete [`RichContentPart`].
810    pub fn compile(
811        self,
812        params: &functions::expression::Params,
813    ) -> Result<RichContentPart, functions::expression::ExpressionError> {
814        match self {
815            RichContentPartExpression::Text { text } => {
816                let text = text.compile_one(params)?;
817                Ok(RichContentPart::Text { text })
818            }
819            RichContentPartExpression::ImageUrl { image_url } => {
820                let image_url = image_url.compile_one(params)?;
821                Ok(RichContentPart::ImageUrl { image_url })
822            }
823            RichContentPartExpression::InputAudio { input_audio } => {
824                let input_audio = input_audio.compile_one(params)?;
825                Ok(RichContentPart::InputAudio { input_audio })
826            }
827            RichContentPartExpression::InputVideo { video_url } => {
828                let video_url = video_url.compile_one(params)?;
829                Ok(RichContentPart::InputVideo { video_url })
830            }
831            RichContentPartExpression::VideoUrl { video_url } => {
832                let video_url = video_url.compile_one(params)?;
833                Ok(RichContentPart::VideoUrl { video_url })
834            }
835            RichContentPartExpression::File { file } => {
836                let file = file.compile_one(params)?;
837                Ok(RichContentPart::File { file })
838            }
839        }
840    }
841}
842
843impl From<RichContentPart> for RichContentPartExpression {
844    fn from(part: RichContentPart) -> Self {
845        match part {
846            RichContentPart::Text { text } => RichContentPartExpression::Text {
847                text: WithExpression::Value(text),
848            },
849            RichContentPart::ImageUrl { image_url } => {
850                RichContentPartExpression::ImageUrl {
851                    image_url: WithExpression::Value(image_url),
852                }
853            }
854            RichContentPart::InputAudio { input_audio } => {
855                RichContentPartExpression::InputAudio {
856                    input_audio: WithExpression::Value(input_audio),
857                }
858            }
859            RichContentPart::InputVideo { video_url } => {
860                RichContentPartExpression::InputVideo {
861                    video_url: WithExpression::Value(video_url),
862                }
863            }
864            RichContentPart::VideoUrl { video_url } => {
865                RichContentPartExpression::VideoUrl {
866                    video_url: WithExpression::Value(video_url),
867                }
868            }
869            RichContentPart::File { file } => RichContentPartExpression::File {
870                file: WithExpression::Value(file),
871            },
872        }
873    }
874}
875
876impl FromStarlarkValue for RichContentPartExpression {
877    fn from_starlark_value(
878        value: &StarlarkValue,
879    ) -> Result<Self, ExpressionError> {
880        let part = RichContentPart::from_starlark_value(value)?;
881        match part {
882            RichContentPart::Text { text } => {
883                Ok(RichContentPartExpression::Text {
884                    text: WithExpression::Value(text),
885                })
886            }
887            RichContentPart::ImageUrl { image_url } => {
888                Ok(RichContentPartExpression::ImageUrl {
889                    image_url: WithExpression::Value(image_url),
890                })
891            }
892            RichContentPart::InputAudio { input_audio } => {
893                Ok(RichContentPartExpression::InputAudio {
894                    input_audio: WithExpression::Value(input_audio),
895                })
896            }
897            RichContentPart::InputVideo { video_url } => {
898                Ok(RichContentPartExpression::InputVideo {
899                    video_url: WithExpression::Value(video_url),
900                })
901            }
902            RichContentPart::VideoUrl { video_url } => {
903                Ok(RichContentPartExpression::VideoUrl {
904                    video_url: WithExpression::Value(video_url),
905                })
906            }
907            RichContentPart::File { file } => {
908                Ok(RichContentPartExpression::File {
909                    file: WithExpression::Value(file),
910                })
911            }
912        }
913    }
914}
915
916/// An image URL for multimodal input.
917#[derive(
918    Debug,
919    Clone,
920    Hash,
921    PartialEq,
922    Eq,
923    Serialize,
924    Deserialize,
925    JsonSchema,
926    arbitrary::Arbitrary,
927)]
928#[schemars(rename = "agent.completions.message.ImageUrl")]
929pub struct ImageUrl {
930    /// The URL of the image (can be a data URL or HTTP URL).
931    pub url: String,
932    /// The detail level for image processing.
933    #[serde(skip_serializing_if = "Option::is_none")]
934    #[schemars(extend("omitempty" = true))]
935    pub detail: Option<ImageUrlDetail>,
936}
937
938impl ImageUrl {
939    /// Prepares the image URL by normalizing the detail field.
940    pub fn prepare(&mut self) {
941        if matches!(self.detail, Some(ImageUrlDetail::Auto)) {
942            self.detail = None;
943        }
944    }
945
946    /// Returns `true` if the URL is empty and no detail is set.
947    pub fn is_empty(&self) -> bool {
948        self.url.is_empty() && self.detail.is_none()
949    }
950
951    /// Returns extractable file content if this is a base64 data URL.
952    ///
953    /// HTTP/HTTPS URLs return `None` (kept inline).
954    pub fn file_content(&self) -> Option<super::FileContent<'_>> {
955        let (mime, payload) = crate::data_url::parse_data_url(&self.url)?;
956        Some(super::FileContent {
957            content: payload,
958            extension: super::file_content::mime_to_ext(mime),
959        })
960    }
961}
962
963/// Compose a base64 data URL from an MCP `ImageContent`'s mime + data.
964/// `detail` defaults to `None`.
965#[cfg(feature = "mcp")]
966impl From<crate::mcp::tool::ImageContent> for ImageUrl {
967    fn from(image: crate::mcp::tool::ImageContent) -> Self {
968        Self {
969            url: format!("data:{};base64,{}", image.mime_type, image.data),
970            detail: None,
971        }
972    }
973}
974
975impl ToStarlarkValue for ImageUrl {
976    fn to_starlark_value<'v>(
977        &self,
978        heap: &'v StarlarkHeap,
979    ) -> StarlarkValue<'v> {
980        heap.alloc(StarlarkAllocDict([
981            ("url", self.url.to_starlark_value(heap)),
982            ("detail", self.detail.to_starlark_value(heap)),
983        ]))
984    }
985}
986
987impl FromStarlarkValue for ImageUrl {
988    fn from_starlark_value(
989        value: &StarlarkValue,
990    ) -> Result<Self, ExpressionError> {
991        let dict = StarlarkDictRef::from_value(*value).ok_or_else(|| {
992            ExpressionError::StarlarkConversionError(
993                "ImageUrl: expected dict".into(),
994            )
995        })?;
996        let mut url = None;
997        let mut detail = None;
998        for (k, v) in dict.iter() {
999            let key = <&str as UnpackValue>::unpack_value(k)
1000                .map_err(|e| {
1001                    ExpressionError::StarlarkConversionError(e.to_string())
1002                })?
1003                .ok_or_else(|| {
1004                    ExpressionError::StarlarkConversionError(
1005                        "ImageUrl: expected string key".into(),
1006                    )
1007                })?;
1008            match key {
1009                "url" => url = Some(String::from_starlark_value(&v)?),
1010                "detail" => {
1011                    detail = Option::<ImageUrlDetail>::from_starlark_value(&v)?
1012                }
1013                _ => {}
1014            }
1015            if url.is_some() && detail.is_some() {
1016                break;
1017            }
1018        }
1019        Ok(ImageUrl {
1020            url: url.ok_or_else(|| {
1021                ExpressionError::StarlarkConversionError(
1022                    "ImageUrl: missing url".into(),
1023                )
1024            })?,
1025            detail,
1026        })
1027    }
1028}
1029
1030/// Detail level for image processing.
1031#[derive(
1032    Debug,
1033    Clone,
1034    Copy,
1035    Hash,
1036    PartialEq,
1037    Eq,
1038    Serialize,
1039    Deserialize,
1040    JsonSchema,
1041    arbitrary::Arbitrary,
1042)]
1043#[schemars(rename = "agent.completions.message.ImageUrlDetail")]
1044pub enum ImageUrlDetail {
1045    /// Let the model decide the detail level.
1046    #[schemars(title = "Auto")]
1047    #[serde(rename = "auto")]
1048    Auto,
1049    /// Low detail mode (faster, less tokens).
1050    #[schemars(title = "Low")]
1051    #[serde(rename = "low")]
1052    Low,
1053    /// High detail mode (more accurate, more tokens).
1054    #[schemars(title = "High")]
1055    #[serde(rename = "high")]
1056    High,
1057}
1058
1059impl ToStarlarkValue for ImageUrlDetail {
1060    fn to_starlark_value<'v>(
1061        &self,
1062        heap: &'v StarlarkHeap,
1063    ) -> StarlarkValue<'v> {
1064        match self {
1065            ImageUrlDetail::Auto => "auto".to_starlark_value(heap),
1066            ImageUrlDetail::Low => "low".to_starlark_value(heap),
1067            ImageUrlDetail::High => "high".to_starlark_value(heap),
1068        }
1069    }
1070}
1071
1072impl FromStarlarkValue for ImageUrlDetail {
1073    fn from_starlark_value(
1074        value: &StarlarkValue,
1075    ) -> Result<Self, ExpressionError> {
1076        let s = <&str as UnpackValue>::unpack_value(*value)
1077            .map_err(|e| {
1078                ExpressionError::StarlarkConversionError(e.to_string())
1079            })?
1080            .ok_or_else(|| {
1081                ExpressionError::StarlarkConversionError(
1082                    "ImageUrlDetail: expected string".into(),
1083                )
1084            })?;
1085        match s {
1086            "auto" => Ok(ImageUrlDetail::Auto),
1087            "low" => Ok(ImageUrlDetail::Low),
1088            "high" => Ok(ImageUrlDetail::High),
1089            _ => Err(ExpressionError::StarlarkConversionError(format!(
1090                "ImageUrlDetail: unknown value: {}",
1091                s
1092            ))),
1093        }
1094    }
1095}
1096
1097/// Audio input for multimodal messages.
1098#[derive(
1099    Debug,
1100    Clone,
1101    Hash,
1102    PartialEq,
1103    Eq,
1104    Serialize,
1105    Deserialize,
1106    JsonSchema,
1107    arbitrary::Arbitrary,
1108)]
1109#[schemars(rename = "agent.completions.message.InputAudio")]
1110pub struct InputAudio {
1111    /// Base64-encoded audio data.
1112    pub data: String,
1113    /// The audio format (e.g., "wav", "mp3").
1114    pub format: String,
1115}
1116
1117impl InputAudio {
1118    /// Returns `true` if both data and format are empty.
1119    pub fn is_empty(&self) -> bool {
1120        self.data.is_empty() && self.format.is_empty()
1121    }
1122
1123    /// Returns extractable file content if audio data is present.
1124    ///
1125    /// Audio is always base64-encoded inline, so this returns `Some`
1126    /// whenever `data` is non-empty.
1127    pub fn file_content(&self) -> Option<super::FileContent<'_>> {
1128        if self.data.is_empty() {
1129            return None;
1130        }
1131        Some(super::FileContent {
1132            content: &self.data,
1133            extension: if self.format.is_empty() {
1134                "bin"
1135            } else {
1136                &self.format
1137            },
1138        })
1139    }
1140}
1141
1142/// Adopt an MCP `AudioContent`'s `mime_type` as `format` verbatim.
1143#[cfg(feature = "mcp")]
1144impl From<crate::mcp::tool::AudioContent> for InputAudio {
1145    fn from(audio: crate::mcp::tool::AudioContent) -> Self {
1146        Self {
1147            data: audio.data,
1148            format: audio.mime_type,
1149        }
1150    }
1151}
1152
1153impl ToStarlarkValue for InputAudio {
1154    fn to_starlark_value<'v>(
1155        &self,
1156        heap: &'v StarlarkHeap,
1157    ) -> StarlarkValue<'v> {
1158        heap.alloc(StarlarkAllocDict([
1159            ("data", self.data.to_starlark_value(heap)),
1160            ("format", self.format.to_starlark_value(heap)),
1161        ]))
1162    }
1163}
1164
1165impl FromStarlarkValue for InputAudio {
1166    fn from_starlark_value(
1167        value: &StarlarkValue,
1168    ) -> Result<Self, ExpressionError> {
1169        let dict = StarlarkDictRef::from_value(*value).ok_or_else(|| {
1170            ExpressionError::StarlarkConversionError(
1171                "InputAudio: expected dict".into(),
1172            )
1173        })?;
1174        let mut data = None;
1175        let mut format = None;
1176        for (k, v) in dict.iter() {
1177            let key = <&str as UnpackValue>::unpack_value(k)
1178                .map_err(|e| {
1179                    ExpressionError::StarlarkConversionError(e.to_string())
1180                })?
1181                .ok_or_else(|| {
1182                    ExpressionError::StarlarkConversionError(
1183                        "InputAudio: expected string key".into(),
1184                    )
1185                })?;
1186            match key {
1187                "data" => data = Some(String::from_starlark_value(&v)?),
1188                "format" => format = Some(String::from_starlark_value(&v)?),
1189                _ => {}
1190            }
1191            if data.is_some() && format.is_some() {
1192                break;
1193            }
1194        }
1195        Ok(InputAudio {
1196            data: data.unwrap_or_default(),
1197            format: format.unwrap_or_default(),
1198        })
1199    }
1200}
1201
1202/// A video URL for multimodal input.
1203#[derive(
1204    Debug,
1205    Clone,
1206    Hash,
1207    PartialEq,
1208    Eq,
1209    Serialize,
1210    Deserialize,
1211    JsonSchema,
1212    arbitrary::Arbitrary,
1213)]
1214#[schemars(rename = "agent.completions.message.VideoUrl")]
1215pub struct VideoUrl {
1216    /// The URL of the video.
1217    pub url: String,
1218}
1219
1220impl VideoUrl {
1221    /// Returns `true` if the URL is empty.
1222    pub fn is_empty(&self) -> bool {
1223        self.url.is_empty()
1224    }
1225
1226    /// Returns extractable file content if this is a base64 data URL.
1227    ///
1228    /// HTTP/HTTPS URLs return `None` (kept inline).
1229    pub fn file_content(&self) -> Option<super::FileContent<'_>> {
1230        let (mime, payload) = crate::data_url::parse_data_url(&self.url)?;
1231        Some(super::FileContent {
1232            content: payload,
1233            extension: super::file_content::mime_to_ext(mime),
1234        })
1235    }
1236}
1237
1238impl ToStarlarkValue for VideoUrl {
1239    fn to_starlark_value<'v>(
1240        &self,
1241        heap: &'v StarlarkHeap,
1242    ) -> StarlarkValue<'v> {
1243        heap.alloc(StarlarkAllocDict([(
1244            "url",
1245            self.url.to_starlark_value(heap),
1246        )]))
1247    }
1248}
1249
1250impl FromStarlarkValue for VideoUrl {
1251    fn from_starlark_value(
1252        value: &StarlarkValue,
1253    ) -> Result<Self, ExpressionError> {
1254        let dict = StarlarkDictRef::from_value(*value).ok_or_else(|| {
1255            ExpressionError::StarlarkConversionError(
1256                "VideoUrl: expected dict".into(),
1257            )
1258        })?;
1259        let mut url = None;
1260        for (k, v) in dict.iter() {
1261            let key = <&str as UnpackValue>::unpack_value(k)
1262                .map_err(|e| {
1263                    ExpressionError::StarlarkConversionError(e.to_string())
1264                })?
1265                .ok_or_else(|| {
1266                    ExpressionError::StarlarkConversionError(
1267                        "VideoUrl: expected string key".into(),
1268                    )
1269                })?;
1270            if key == "url" {
1271                url = Some(String::from_starlark_value(&v)?);
1272            }
1273        }
1274        Ok(VideoUrl {
1275            url: url.ok_or_else(|| {
1276                ExpressionError::StarlarkConversionError(
1277                    "VideoUrl: missing url".into(),
1278                )
1279            })?,
1280        })
1281    }
1282}
1283
1284/// A file attachment for multimodal input.
1285#[derive(
1286    Debug,
1287    Clone,
1288    Hash,
1289    PartialEq,
1290    Eq,
1291    Serialize,
1292    Deserialize,
1293    JsonSchema,
1294    arbitrary::Arbitrary,
1295)]
1296#[schemars(rename = "agent.completions.message.File")]
1297pub struct File {
1298    /// Base64-encoded file data.
1299    #[serde(skip_serializing_if = "Option::is_none")]
1300    #[schemars(extend("omitempty" = true))]
1301    pub file_data: Option<String>,
1302    /// The ID of a previously uploaded file.
1303    #[serde(skip_serializing_if = "Option::is_none")]
1304    #[schemars(extend("omitempty" = true))]
1305    pub file_id: Option<String>,
1306    /// The filename for display purposes.
1307    #[serde(skip_serializing_if = "Option::is_none")]
1308    #[schemars(extend("omitempty" = true))]
1309    pub filename: Option<String>,
1310    /// A URL to fetch the file from.
1311    #[serde(skip_serializing_if = "Option::is_none")]
1312    #[schemars(extend("omitempty" = true))]
1313    pub file_url: Option<String>,
1314}
1315
1316impl File {
1317    /// Prepares the file by normalizing empty strings to `None`.
1318    pub fn prepare(&mut self) {
1319        if self.file_data.as_ref().is_some_and(String::is_empty) {
1320            self.file_data = None;
1321        }
1322        if self.file_id.as_ref().is_some_and(String::is_empty) {
1323            self.file_id = None;
1324        }
1325        if self.filename.as_ref().is_some_and(String::is_empty) {
1326            self.filename = None;
1327        }
1328        if self.file_url.as_ref().is_some_and(String::is_empty) {
1329            self.file_url = None;
1330        }
1331    }
1332
1333    /// Returns `true` if all file fields are `None`.
1334    pub fn is_empty(&self) -> bool {
1335        self.file_data.is_none()
1336            && self.file_id.is_none()
1337            && self.filename.is_none()
1338            && self.file_url.is_none()
1339    }
1340
1341    /// Returns extractable file content if inline file data is present.
1342    ///
1343    /// Files referenced only by URL or ID return `None` (kept inline).
1344    pub fn file_content(&self) -> Option<super::FileContent<'_>> {
1345        let data = self.file_data.as_deref()?;
1346        if data.is_empty() {
1347            return None;
1348        }
1349        let ext = self
1350            .filename
1351            .as_deref()
1352            .and_then(|name| name.rsplit_once('.'))
1353            .map(|(_, ext)| ext)
1354            .unwrap_or("bin");
1355        Some(super::FileContent {
1356            content: data,
1357            extension: ext,
1358        })
1359    }
1360}
1361
1362impl ToStarlarkValue for File {
1363    fn to_starlark_value<'v>(
1364        &self,
1365        heap: &'v StarlarkHeap,
1366    ) -> StarlarkValue<'v> {
1367        heap.alloc(StarlarkAllocDict([
1368            ("file_data", self.file_data.to_starlark_value(heap)),
1369            ("file_id", self.file_id.to_starlark_value(heap)),
1370            ("filename", self.filename.to_starlark_value(heap)),
1371            ("file_url", self.file_url.to_starlark_value(heap)),
1372        ]))
1373    }
1374}
1375
1376impl FromStarlarkValue for File {
1377    fn from_starlark_value(
1378        value: &StarlarkValue,
1379    ) -> Result<Self, ExpressionError> {
1380        let dict = StarlarkDictRef::from_value(*value).ok_or_else(|| {
1381            ExpressionError::StarlarkConversionError(
1382                "File: expected dict".into(),
1383            )
1384        })?;
1385        let mut file_data = None;
1386        let mut file_id = None;
1387        let mut filename = None;
1388        let mut file_url = None;
1389        for (k, v) in dict.iter() {
1390            let key = <&str as UnpackValue>::unpack_value(k)
1391                .map_err(|e| {
1392                    ExpressionError::StarlarkConversionError(e.to_string())
1393                })?
1394                .ok_or_else(|| {
1395                    ExpressionError::StarlarkConversionError(
1396                        "File: expected string key".into(),
1397                    )
1398                })?;
1399            match key {
1400                "file_data" => {
1401                    file_data = Option::<String>::from_starlark_value(&v)?
1402                }
1403                "file_id" => {
1404                    file_id = Option::<String>::from_starlark_value(&v)?
1405                }
1406                "filename" => {
1407                    filename = Option::<String>::from_starlark_value(&v)?
1408                }
1409                "file_url" => {
1410                    file_url = Option::<String>::from_starlark_value(&v)?
1411                }
1412                _ => {}
1413            }
1414        }
1415        Ok(File {
1416            file_data,
1417            file_id,
1418            filename,
1419            file_url,
1420        })
1421    }
1422}
1423
1424crate::functions::expression::impl_from_special_unsupported!(
1425    RichContentExpression,
1426    RichContentPartExpression,
1427    ImageUrl,
1428    InputAudio,
1429    VideoUrl,
1430    File,
1431);
1432
1433impl crate::functions::expression::FromSpecial
1434    for Vec<crate::functions::expression::WithExpression<RichContentExpression>>
1435{
1436    fn from_special(
1437        _special: &crate::functions::expression::Special,
1438        _params: &crate::functions::expression::Params,
1439    ) -> Result<Self, crate::functions::expression::ExpressionError> {
1440        Err(crate::functions::expression::ExpressionError::UnsupportedSpecial)
1441    }
1442}