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` and `build_drain_user_message` on the
486/// agent side.
487#[cfg(feature = "mcp")]
488impl From<Vec<crate::mcp::tool::ContentBlock>> for RichContent {
489    fn from(blocks: Vec<crate::mcp::tool::ContentBlock>) -> Self {
490        let parts: Vec<RichContentPart> =
491            blocks.into_iter().map(Into::into).collect();
492        RichContent::from(parts)
493    }
494}
495
496/// Expression variant of [`RichContent`] for dynamic content.
497#[derive(
498    Debug,
499    Clone,
500    PartialEq,
501    Serialize,
502    Deserialize,
503    JsonSchema,
504    arbitrary::Arbitrary,
505)]
506#[serde(untagged)]
507#[schemars(rename = "agent.completions.message.RichContentExpression")]
508pub enum RichContentExpression {
509    /// Plain text content.
510    #[schemars(title = "Text")]
511    Text(String),
512    /// Multi-part content expressions.
513    #[schemars(title = "Parts")]
514    Parts(
515        Vec<functions::expression::WithExpression<RichContentPartExpression>>,
516    ),
517}
518
519impl RichContentExpression {
520    /// Compiles the expression into a concrete [`RichContent`].
521    pub fn compile(
522        self,
523        params: &functions::expression::Params,
524    ) -> Result<RichContent, functions::expression::ExpressionError> {
525        match self {
526            RichContentExpression::Text(text) => Ok(RichContent::Text(text)),
527            RichContentExpression::Parts(parts) => {
528                let mut compiled_parts = Vec::with_capacity(parts.len());
529                for part in parts {
530                    match part.compile_one_or_many(params)? {
531                        functions::expression::OneOrMany::One(one_part) => {
532                            compiled_parts.push(one_part.compile(params)?);
533                        }
534                        functions::expression::OneOrMany::Many(many_parts) => {
535                            for part in many_parts {
536                                compiled_parts.push(part.compile(params)?);
537                            }
538                        }
539                    }
540                }
541                Ok(RichContent::Parts(compiled_parts))
542            }
543        }
544    }
545}
546
547impl From<RichContent> for RichContentExpression {
548    fn from(content: RichContent) -> Self {
549        match content {
550            RichContent::Text(text) => RichContentExpression::Text(text),
551            RichContent::Parts(parts) => RichContentExpression::Parts(
552                parts
553                    .into_iter()
554                    .map(RichContentPartExpression::from)
555                    .map(WithExpression::Value)
556                    .collect(),
557            ),
558        }
559    }
560}
561
562impl FromStarlarkValue for RichContentExpression {
563    fn from_starlark_value(
564        value: &StarlarkValue,
565    ) -> Result<Self, ExpressionError> {
566        if let Ok(Some(s)) = <&str as UnpackValue>::unpack_value(*value) {
567            return Ok(RichContentExpression::Text(s.to_owned()));
568        }
569        let parts = Vec::<WithExpression<RichContentPartExpression>>::from_starlark_value(value)?;
570        Ok(RichContentExpression::Parts(parts))
571    }
572}
573
574/// A part of rich content.
575#[derive(
576    Debug,
577    Clone,
578    Hash,
579    PartialEq,
580    Eq,
581    Serialize,
582    Deserialize,
583    JsonSchema,
584    arbitrary::Arbitrary,
585)]
586#[serde(tag = "type", rename_all = "snake_case")]
587#[schemars(rename = "agent.completions.message.RichContentPart")]
588pub enum RichContentPart {
589    /// Text content.
590    #[schemars(title = "Text")]
591    Text { text: String },
592    /// An image URL.
593    #[schemars(title = "ImageUrl")]
594    ImageUrl { image_url: ImageUrl },
595    /// Audio input.
596    #[schemars(title = "InputAudio")]
597    InputAudio { input_audio: InputAudio },
598    /// Video input.
599    #[schemars(title = "InputVideo")]
600    InputVideo { video_url: VideoUrl },
601    /// A video URL.
602    #[schemars(title = "VideoUrl")]
603    VideoUrl { video_url: VideoUrl },
604    /// A file.
605    #[schemars(title = "File")]
606    File { file: File },
607}
608
609impl RichContentPart {
610    /// Prepares the content part by normalizing optional fields.
611    pub fn prepare(&mut self) {
612        match self {
613            RichContentPart::Text { .. } => {}
614            RichContentPart::ImageUrl { image_url } => {
615                image_url.prepare();
616            }
617            RichContentPart::InputAudio { .. } => {}
618            RichContentPart::InputVideo { .. } => {}
619            RichContentPart::VideoUrl { .. } => {}
620            RichContentPart::File { file } => {
621                file.prepare();
622            }
623        }
624    }
625
626    /// Returns `true` if the content part is empty.
627    pub fn is_empty(&self) -> bool {
628        match self {
629            RichContentPart::Text { text } => text.is_empty(),
630            RichContentPart::ImageUrl { image_url } => image_url.is_empty(),
631            RichContentPart::InputAudio { input_audio } => {
632                input_audio.is_empty()
633            }
634            RichContentPart::InputVideo { video_url } => video_url.is_empty(),
635            RichContentPart::VideoUrl { video_url } => video_url.is_empty(),
636            RichContentPart::File { file } => file.is_empty(),
637        }
638    }
639}
640
641impl ToStarlarkValue for RichContentPart {
642    fn to_starlark_value<'v>(
643        &self,
644        heap: &'v StarlarkHeap,
645    ) -> StarlarkValue<'v> {
646        match self {
647            RichContentPart::Text { text } => heap.alloc(StarlarkAllocDict([
648                ("type", "text".to_starlark_value(heap)),
649                ("text", text.to_starlark_value(heap)),
650            ])),
651            RichContentPart::ImageUrl { image_url } => {
652                heap.alloc(StarlarkAllocDict([
653                    ("type", "image_url".to_starlark_value(heap)),
654                    ("image_url", image_url.to_starlark_value(heap)),
655                ]))
656            }
657            RichContentPart::InputAudio { input_audio } => {
658                heap.alloc(StarlarkAllocDict([
659                    ("type", "input_audio".to_starlark_value(heap)),
660                    ("input_audio", input_audio.to_starlark_value(heap)),
661                ]))
662            }
663            RichContentPart::InputVideo { video_url } => {
664                heap.alloc(StarlarkAllocDict([
665                    ("type", "input_video".to_starlark_value(heap)),
666                    ("video_url", video_url.to_starlark_value(heap)),
667                ]))
668            }
669            RichContentPart::VideoUrl { video_url } => {
670                heap.alloc(StarlarkAllocDict([
671                    ("type", "video_url".to_starlark_value(heap)),
672                    ("video_url", video_url.to_starlark_value(heap)),
673                ]))
674            }
675            RichContentPart::File { file } => heap.alloc(StarlarkAllocDict([
676                ("type", "file".to_starlark_value(heap)),
677                ("file", file.to_starlark_value(heap)),
678            ])),
679        }
680    }
681}
682
683impl FromStarlarkValue for RichContentPart {
684    fn from_starlark_value(
685        value: &StarlarkValue,
686    ) -> Result<Self, ExpressionError> {
687        let dict = StarlarkDictRef::from_value(*value).ok_or_else(|| {
688            ExpressionError::StarlarkConversionError(
689                "RichContentPart: expected dict".into(),
690            )
691        })?;
692        // First pass: find the type
693        let mut typ = None;
694        for (k, v) in dict.iter() {
695            if let Ok(Some("type")) = <&str as UnpackValue>::unpack_value(k) {
696                typ = Some(
697                    <&str as UnpackValue>::unpack_value(v)
698                        .map_err(|e| {
699                            ExpressionError::StarlarkConversionError(
700                                e.to_string(),
701                            )
702                        })?
703                        .ok_or_else(|| {
704                            ExpressionError::StarlarkConversionError(
705                                "RichContentPart: expected string type".into(),
706                            )
707                        })?,
708                );
709                break;
710            }
711        }
712        let typ = typ.ok_or_else(|| {
713            ExpressionError::StarlarkConversionError(
714                "RichContentPart: missing type".into(),
715            )
716        })?;
717        // Second pass: find the payload by expected key
718        let payload_key = match typ {
719            "text" => "text",
720            "image_url" => "image_url",
721            "input_audio" => "input_audio",
722            "input_video" | "video_url" => "video_url",
723            "file" => "file",
724            _ => {
725                return Err(ExpressionError::StarlarkConversionError(format!(
726                    "RichContentPart: unknown type: {}",
727                    typ
728                )));
729            }
730        };
731        let mut payload = None;
732        for (k, v) in dict.iter() {
733            if let Ok(Some(key)) = <&str as UnpackValue>::unpack_value(k) {
734                if key == payload_key {
735                    payload = Some(v);
736                    break;
737                }
738            }
739        }
740        let v = payload.ok_or_else(|| {
741            ExpressionError::StarlarkConversionError(format!(
742                "RichContentPart: missing {}",
743                payload_key
744            ))
745        })?;
746        match typ {
747            "text" => Ok(RichContentPart::Text {
748                text: String::from_starlark_value(&v)?,
749            }),
750            "image_url" => Ok(RichContentPart::ImageUrl {
751                image_url: ImageUrl::from_starlark_value(&v)?,
752            }),
753            "input_audio" => Ok(RichContentPart::InputAudio {
754                input_audio: InputAudio::from_starlark_value(&v)?,
755            }),
756            "input_video" => Ok(RichContentPart::InputVideo {
757                video_url: VideoUrl::from_starlark_value(&v)?,
758            }),
759            "video_url" => Ok(RichContentPart::VideoUrl {
760                video_url: VideoUrl::from_starlark_value(&v)?,
761            }),
762            "file" => Ok(RichContentPart::File {
763                file: File::from_starlark_value(&v)?,
764            }),
765            _ => unreachable!(),
766        }
767    }
768}
769
770/// Expression variant of [`RichContentPart`] for dynamic content.
771#[derive(
772    Debug,
773    Clone,
774    PartialEq,
775    Serialize,
776    Deserialize,
777    JsonSchema,
778    arbitrary::Arbitrary,
779)]
780#[serde(tag = "type", rename_all = "snake_case")]
781#[schemars(rename = "agent.completions.message.RichContentPartExpression")]
782pub enum RichContentPartExpression {
783    #[schemars(title = "Text")]
784    Text {
785        text: functions::expression::WithExpression<String>,
786    },
787    #[schemars(title = "ImageUrl")]
788    ImageUrl {
789        image_url: functions::expression::WithExpression<ImageUrl>,
790    },
791    #[schemars(title = "InputAudio")]
792    InputAudio {
793        input_audio: functions::expression::WithExpression<InputAudio>,
794    },
795    #[schemars(title = "InputVideo")]
796    InputVideo {
797        video_url: functions::expression::WithExpression<VideoUrl>,
798    },
799    #[schemars(title = "VideoUrl")]
800    VideoUrl {
801        video_url: functions::expression::WithExpression<VideoUrl>,
802    },
803    #[schemars(title = "File")]
804    File {
805        file: functions::expression::WithExpression<File>,
806    },
807}
808
809impl RichContentPartExpression {
810    /// Compiles the expression into a concrete [`RichContentPart`].
811    pub fn compile(
812        self,
813        params: &functions::expression::Params,
814    ) -> Result<RichContentPart, functions::expression::ExpressionError> {
815        match self {
816            RichContentPartExpression::Text { text } => {
817                let text = text.compile_one(params)?;
818                Ok(RichContentPart::Text { text })
819            }
820            RichContentPartExpression::ImageUrl { image_url } => {
821                let image_url = image_url.compile_one(params)?;
822                Ok(RichContentPart::ImageUrl { image_url })
823            }
824            RichContentPartExpression::InputAudio { input_audio } => {
825                let input_audio = input_audio.compile_one(params)?;
826                Ok(RichContentPart::InputAudio { input_audio })
827            }
828            RichContentPartExpression::InputVideo { video_url } => {
829                let video_url = video_url.compile_one(params)?;
830                Ok(RichContentPart::InputVideo { video_url })
831            }
832            RichContentPartExpression::VideoUrl { video_url } => {
833                let video_url = video_url.compile_one(params)?;
834                Ok(RichContentPart::VideoUrl { video_url })
835            }
836            RichContentPartExpression::File { file } => {
837                let file = file.compile_one(params)?;
838                Ok(RichContentPart::File { file })
839            }
840        }
841    }
842}
843
844impl From<RichContentPart> for RichContentPartExpression {
845    fn from(part: RichContentPart) -> Self {
846        match part {
847            RichContentPart::Text { text } => RichContentPartExpression::Text {
848                text: WithExpression::Value(text),
849            },
850            RichContentPart::ImageUrl { image_url } => {
851                RichContentPartExpression::ImageUrl {
852                    image_url: WithExpression::Value(image_url),
853                }
854            }
855            RichContentPart::InputAudio { input_audio } => {
856                RichContentPartExpression::InputAudio {
857                    input_audio: WithExpression::Value(input_audio),
858                }
859            }
860            RichContentPart::InputVideo { video_url } => {
861                RichContentPartExpression::InputVideo {
862                    video_url: WithExpression::Value(video_url),
863                }
864            }
865            RichContentPart::VideoUrl { video_url } => {
866                RichContentPartExpression::VideoUrl {
867                    video_url: WithExpression::Value(video_url),
868                }
869            }
870            RichContentPart::File { file } => RichContentPartExpression::File {
871                file: WithExpression::Value(file),
872            },
873        }
874    }
875}
876
877impl FromStarlarkValue for RichContentPartExpression {
878    fn from_starlark_value(
879        value: &StarlarkValue,
880    ) -> Result<Self, ExpressionError> {
881        let part = RichContentPart::from_starlark_value(value)?;
882        match part {
883            RichContentPart::Text { text } => {
884                Ok(RichContentPartExpression::Text {
885                    text: WithExpression::Value(text),
886                })
887            }
888            RichContentPart::ImageUrl { image_url } => {
889                Ok(RichContentPartExpression::ImageUrl {
890                    image_url: WithExpression::Value(image_url),
891                })
892            }
893            RichContentPart::InputAudio { input_audio } => {
894                Ok(RichContentPartExpression::InputAudio {
895                    input_audio: WithExpression::Value(input_audio),
896                })
897            }
898            RichContentPart::InputVideo { video_url } => {
899                Ok(RichContentPartExpression::InputVideo {
900                    video_url: WithExpression::Value(video_url),
901                })
902            }
903            RichContentPart::VideoUrl { video_url } => {
904                Ok(RichContentPartExpression::VideoUrl {
905                    video_url: WithExpression::Value(video_url),
906                })
907            }
908            RichContentPart::File { file } => {
909                Ok(RichContentPartExpression::File {
910                    file: WithExpression::Value(file),
911                })
912            }
913        }
914    }
915}
916
917/// An image URL for multimodal input.
918#[derive(
919    Debug,
920    Clone,
921    Hash,
922    PartialEq,
923    Eq,
924    Serialize,
925    Deserialize,
926    JsonSchema,
927    arbitrary::Arbitrary,
928)]
929#[schemars(rename = "agent.completions.message.ImageUrl")]
930pub struct ImageUrl {
931    /// The URL of the image (can be a data URL or HTTP URL).
932    pub url: String,
933    /// The detail level for image processing.
934    #[serde(skip_serializing_if = "Option::is_none")]
935    #[schemars(extend("omitempty" = true))]
936    pub detail: Option<ImageUrlDetail>,
937}
938
939impl ImageUrl {
940    /// Prepares the image URL by normalizing the detail field.
941    pub fn prepare(&mut self) {
942        if matches!(self.detail, Some(ImageUrlDetail::Auto)) {
943            self.detail = None;
944        }
945    }
946
947    /// Returns `true` if the URL is empty and no detail is set.
948    pub fn is_empty(&self) -> bool {
949        self.url.is_empty() && self.detail.is_none()
950    }
951
952    /// Returns extractable file content if this is a base64 data URL.
953    ///
954    /// HTTP/HTTPS URLs return `None` (kept inline).
955    pub fn file_content(&self) -> Option<super::FileContent<'_>> {
956        let (mime, payload) = crate::data_url::parse_data_url(&self.url)?;
957        Some(super::FileContent {
958            content: payload,
959            extension: super::file_content::mime_to_ext(mime),
960        })
961    }
962}
963
964/// Compose a base64 data URL from an MCP `ImageContent`'s mime + data.
965/// `detail` defaults to `None`.
966#[cfg(feature = "mcp")]
967impl From<crate::mcp::tool::ImageContent> for ImageUrl {
968    fn from(image: crate::mcp::tool::ImageContent) -> Self {
969        Self {
970            url: format!("data:{};base64,{}", image.mime_type, image.data),
971            detail: None,
972        }
973    }
974}
975
976impl ToStarlarkValue for ImageUrl {
977    fn to_starlark_value<'v>(
978        &self,
979        heap: &'v StarlarkHeap,
980    ) -> StarlarkValue<'v> {
981        heap.alloc(StarlarkAllocDict([
982            ("url", self.url.to_starlark_value(heap)),
983            ("detail", self.detail.to_starlark_value(heap)),
984        ]))
985    }
986}
987
988impl FromStarlarkValue for ImageUrl {
989    fn from_starlark_value(
990        value: &StarlarkValue,
991    ) -> Result<Self, ExpressionError> {
992        let dict = StarlarkDictRef::from_value(*value).ok_or_else(|| {
993            ExpressionError::StarlarkConversionError(
994                "ImageUrl: expected dict".into(),
995            )
996        })?;
997        let mut url = None;
998        let mut detail = None;
999        for (k, v) in dict.iter() {
1000            let key = <&str as UnpackValue>::unpack_value(k)
1001                .map_err(|e| {
1002                    ExpressionError::StarlarkConversionError(e.to_string())
1003                })?
1004                .ok_or_else(|| {
1005                    ExpressionError::StarlarkConversionError(
1006                        "ImageUrl: expected string key".into(),
1007                    )
1008                })?;
1009            match key {
1010                "url" => url = Some(String::from_starlark_value(&v)?),
1011                "detail" => {
1012                    detail = Option::<ImageUrlDetail>::from_starlark_value(&v)?
1013                }
1014                _ => {}
1015            }
1016            if url.is_some() && detail.is_some() {
1017                break;
1018            }
1019        }
1020        Ok(ImageUrl {
1021            url: url.ok_or_else(|| {
1022                ExpressionError::StarlarkConversionError(
1023                    "ImageUrl: missing url".into(),
1024                )
1025            })?,
1026            detail,
1027        })
1028    }
1029}
1030
1031/// Detail level for image processing.
1032#[derive(
1033    Debug,
1034    Clone,
1035    Copy,
1036    Hash,
1037    PartialEq,
1038    Eq,
1039    Serialize,
1040    Deserialize,
1041    JsonSchema,
1042    arbitrary::Arbitrary,
1043)]
1044#[schemars(rename = "agent.completions.message.ImageUrlDetail")]
1045pub enum ImageUrlDetail {
1046    /// Let the model decide the detail level.
1047    #[schemars(title = "Auto")]
1048    #[serde(rename = "auto")]
1049    Auto,
1050    /// Low detail mode (faster, less tokens).
1051    #[schemars(title = "Low")]
1052    #[serde(rename = "low")]
1053    Low,
1054    /// High detail mode (more accurate, more tokens).
1055    #[schemars(title = "High")]
1056    #[serde(rename = "high")]
1057    High,
1058}
1059
1060impl ToStarlarkValue for ImageUrlDetail {
1061    fn to_starlark_value<'v>(
1062        &self,
1063        heap: &'v StarlarkHeap,
1064    ) -> StarlarkValue<'v> {
1065        match self {
1066            ImageUrlDetail::Auto => "auto".to_starlark_value(heap),
1067            ImageUrlDetail::Low => "low".to_starlark_value(heap),
1068            ImageUrlDetail::High => "high".to_starlark_value(heap),
1069        }
1070    }
1071}
1072
1073impl FromStarlarkValue for ImageUrlDetail {
1074    fn from_starlark_value(
1075        value: &StarlarkValue,
1076    ) -> Result<Self, ExpressionError> {
1077        let s = <&str as UnpackValue>::unpack_value(*value)
1078            .map_err(|e| {
1079                ExpressionError::StarlarkConversionError(e.to_string())
1080            })?
1081            .ok_or_else(|| {
1082                ExpressionError::StarlarkConversionError(
1083                    "ImageUrlDetail: expected string".into(),
1084                )
1085            })?;
1086        match s {
1087            "auto" => Ok(ImageUrlDetail::Auto),
1088            "low" => Ok(ImageUrlDetail::Low),
1089            "high" => Ok(ImageUrlDetail::High),
1090            _ => Err(ExpressionError::StarlarkConversionError(format!(
1091                "ImageUrlDetail: unknown value: {}",
1092                s
1093            ))),
1094        }
1095    }
1096}
1097
1098/// Audio input for multimodal messages.
1099#[derive(
1100    Debug,
1101    Clone,
1102    Hash,
1103    PartialEq,
1104    Eq,
1105    Serialize,
1106    Deserialize,
1107    JsonSchema,
1108    arbitrary::Arbitrary,
1109)]
1110#[schemars(rename = "agent.completions.message.InputAudio")]
1111pub struct InputAudio {
1112    /// Base64-encoded audio data.
1113    pub data: String,
1114    /// The audio format (e.g., "wav", "mp3").
1115    pub format: String,
1116}
1117
1118impl InputAudio {
1119    /// Returns `true` if both data and format are empty.
1120    pub fn is_empty(&self) -> bool {
1121        self.data.is_empty() && self.format.is_empty()
1122    }
1123
1124    /// Returns extractable file content if audio data is present.
1125    ///
1126    /// Audio is always base64-encoded inline, so this returns `Some`
1127    /// whenever `data` is non-empty.
1128    pub fn file_content(&self) -> Option<super::FileContent<'_>> {
1129        if self.data.is_empty() {
1130            return None;
1131        }
1132        Some(super::FileContent {
1133            content: &self.data,
1134            extension: if self.format.is_empty() {
1135                "bin"
1136            } else {
1137                &self.format
1138            },
1139        })
1140    }
1141}
1142
1143/// Adopt an MCP `AudioContent`'s `mime_type` as `format` verbatim.
1144#[cfg(feature = "mcp")]
1145impl From<crate::mcp::tool::AudioContent> for InputAudio {
1146    fn from(audio: crate::mcp::tool::AudioContent) -> Self {
1147        Self {
1148            data: audio.data,
1149            format: audio.mime_type,
1150        }
1151    }
1152}
1153
1154impl ToStarlarkValue for InputAudio {
1155    fn to_starlark_value<'v>(
1156        &self,
1157        heap: &'v StarlarkHeap,
1158    ) -> StarlarkValue<'v> {
1159        heap.alloc(StarlarkAllocDict([
1160            ("data", self.data.to_starlark_value(heap)),
1161            ("format", self.format.to_starlark_value(heap)),
1162        ]))
1163    }
1164}
1165
1166impl FromStarlarkValue for InputAudio {
1167    fn from_starlark_value(
1168        value: &StarlarkValue,
1169    ) -> Result<Self, ExpressionError> {
1170        let dict = StarlarkDictRef::from_value(*value).ok_or_else(|| {
1171            ExpressionError::StarlarkConversionError(
1172                "InputAudio: expected dict".into(),
1173            )
1174        })?;
1175        let mut data = None;
1176        let mut format = None;
1177        for (k, v) in dict.iter() {
1178            let key = <&str as UnpackValue>::unpack_value(k)
1179                .map_err(|e| {
1180                    ExpressionError::StarlarkConversionError(e.to_string())
1181                })?
1182                .ok_or_else(|| {
1183                    ExpressionError::StarlarkConversionError(
1184                        "InputAudio: expected string key".into(),
1185                    )
1186                })?;
1187            match key {
1188                "data" => data = Some(String::from_starlark_value(&v)?),
1189                "format" => format = Some(String::from_starlark_value(&v)?),
1190                _ => {}
1191            }
1192            if data.is_some() && format.is_some() {
1193                break;
1194            }
1195        }
1196        Ok(InputAudio {
1197            data: data.unwrap_or_default(),
1198            format: format.unwrap_or_default(),
1199        })
1200    }
1201}
1202
1203/// A video URL for multimodal input.
1204#[derive(
1205    Debug,
1206    Clone,
1207    Hash,
1208    PartialEq,
1209    Eq,
1210    Serialize,
1211    Deserialize,
1212    JsonSchema,
1213    arbitrary::Arbitrary,
1214)]
1215#[schemars(rename = "agent.completions.message.VideoUrl")]
1216pub struct VideoUrl {
1217    /// The URL of the video.
1218    pub url: String,
1219}
1220
1221impl VideoUrl {
1222    /// Returns `true` if the URL is empty.
1223    pub fn is_empty(&self) -> bool {
1224        self.url.is_empty()
1225    }
1226
1227    /// Returns extractable file content if this is a base64 data URL.
1228    ///
1229    /// HTTP/HTTPS URLs return `None` (kept inline).
1230    pub fn file_content(&self) -> Option<super::FileContent<'_>> {
1231        let (mime, payload) = crate::data_url::parse_data_url(&self.url)?;
1232        Some(super::FileContent {
1233            content: payload,
1234            extension: super::file_content::mime_to_ext(mime),
1235        })
1236    }
1237}
1238
1239impl ToStarlarkValue for VideoUrl {
1240    fn to_starlark_value<'v>(
1241        &self,
1242        heap: &'v StarlarkHeap,
1243    ) -> StarlarkValue<'v> {
1244        heap.alloc(StarlarkAllocDict([(
1245            "url",
1246            self.url.to_starlark_value(heap),
1247        )]))
1248    }
1249}
1250
1251impl FromStarlarkValue for VideoUrl {
1252    fn from_starlark_value(
1253        value: &StarlarkValue,
1254    ) -> Result<Self, ExpressionError> {
1255        let dict = StarlarkDictRef::from_value(*value).ok_or_else(|| {
1256            ExpressionError::StarlarkConversionError(
1257                "VideoUrl: expected dict".into(),
1258            )
1259        })?;
1260        let mut url = None;
1261        for (k, v) in dict.iter() {
1262            let key = <&str as UnpackValue>::unpack_value(k)
1263                .map_err(|e| {
1264                    ExpressionError::StarlarkConversionError(e.to_string())
1265                })?
1266                .ok_or_else(|| {
1267                    ExpressionError::StarlarkConversionError(
1268                        "VideoUrl: expected string key".into(),
1269                    )
1270                })?;
1271            if key == "url" {
1272                url = Some(String::from_starlark_value(&v)?);
1273            }
1274        }
1275        Ok(VideoUrl {
1276            url: url.ok_or_else(|| {
1277                ExpressionError::StarlarkConversionError(
1278                    "VideoUrl: missing url".into(),
1279                )
1280            })?,
1281        })
1282    }
1283}
1284
1285/// A file attachment for multimodal input.
1286#[derive(
1287    Debug,
1288    Clone,
1289    Hash,
1290    PartialEq,
1291    Eq,
1292    Serialize,
1293    Deserialize,
1294    JsonSchema,
1295    arbitrary::Arbitrary,
1296)]
1297#[schemars(rename = "agent.completions.message.File")]
1298pub struct File {
1299    /// Base64-encoded file data.
1300    #[serde(skip_serializing_if = "Option::is_none")]
1301    #[schemars(extend("omitempty" = true))]
1302    pub file_data: Option<String>,
1303    /// The ID of a previously uploaded file.
1304    #[serde(skip_serializing_if = "Option::is_none")]
1305    #[schemars(extend("omitempty" = true))]
1306    pub file_id: Option<String>,
1307    /// The filename for display purposes.
1308    #[serde(skip_serializing_if = "Option::is_none")]
1309    #[schemars(extend("omitempty" = true))]
1310    pub filename: Option<String>,
1311    /// A URL to fetch the file from.
1312    #[serde(skip_serializing_if = "Option::is_none")]
1313    #[schemars(extend("omitempty" = true))]
1314    pub file_url: Option<String>,
1315}
1316
1317impl File {
1318    /// Prepares the file by normalizing empty strings to `None`.
1319    pub fn prepare(&mut self) {
1320        if self.file_data.as_ref().is_some_and(String::is_empty) {
1321            self.file_data = None;
1322        }
1323        if self.file_id.as_ref().is_some_and(String::is_empty) {
1324            self.file_id = None;
1325        }
1326        if self.filename.as_ref().is_some_and(String::is_empty) {
1327            self.filename = None;
1328        }
1329        if self.file_url.as_ref().is_some_and(String::is_empty) {
1330            self.file_url = None;
1331        }
1332    }
1333
1334    /// Returns `true` if all file fields are `None`.
1335    pub fn is_empty(&self) -> bool {
1336        self.file_data.is_none()
1337            && self.file_id.is_none()
1338            && self.filename.is_none()
1339            && self.file_url.is_none()
1340    }
1341
1342    /// Returns extractable file content if inline file data is present.
1343    ///
1344    /// Files referenced only by URL or ID return `None` (kept inline).
1345    pub fn file_content(&self) -> Option<super::FileContent<'_>> {
1346        let data = self.file_data.as_deref()?;
1347        if data.is_empty() {
1348            return None;
1349        }
1350        let ext = self
1351            .filename
1352            .as_deref()
1353            .and_then(|name| name.rsplit_once('.'))
1354            .map(|(_, ext)| ext)
1355            .unwrap_or("bin");
1356        Some(super::FileContent {
1357            content: data,
1358            extension: ext,
1359        })
1360    }
1361}
1362
1363impl ToStarlarkValue for File {
1364    fn to_starlark_value<'v>(
1365        &self,
1366        heap: &'v StarlarkHeap,
1367    ) -> StarlarkValue<'v> {
1368        heap.alloc(StarlarkAllocDict([
1369            ("file_data", self.file_data.to_starlark_value(heap)),
1370            ("file_id", self.file_id.to_starlark_value(heap)),
1371            ("filename", self.filename.to_starlark_value(heap)),
1372            ("file_url", self.file_url.to_starlark_value(heap)),
1373        ]))
1374    }
1375}
1376
1377impl FromStarlarkValue for File {
1378    fn from_starlark_value(
1379        value: &StarlarkValue,
1380    ) -> Result<Self, ExpressionError> {
1381        let dict = StarlarkDictRef::from_value(*value).ok_or_else(|| {
1382            ExpressionError::StarlarkConversionError(
1383                "File: expected dict".into(),
1384            )
1385        })?;
1386        let mut file_data = None;
1387        let mut file_id = None;
1388        let mut filename = None;
1389        let mut file_url = None;
1390        for (k, v) in dict.iter() {
1391            let key = <&str as UnpackValue>::unpack_value(k)
1392                .map_err(|e| {
1393                    ExpressionError::StarlarkConversionError(e.to_string())
1394                })?
1395                .ok_or_else(|| {
1396                    ExpressionError::StarlarkConversionError(
1397                        "File: expected string key".into(),
1398                    )
1399                })?;
1400            match key {
1401                "file_data" => {
1402                    file_data = Option::<String>::from_starlark_value(&v)?
1403                }
1404                "file_id" => {
1405                    file_id = Option::<String>::from_starlark_value(&v)?
1406                }
1407                "filename" => {
1408                    filename = Option::<String>::from_starlark_value(&v)?
1409                }
1410                "file_url" => {
1411                    file_url = Option::<String>::from_starlark_value(&v)?
1412                }
1413                _ => {}
1414            }
1415        }
1416        Ok(File {
1417            file_data,
1418            file_id,
1419            filename,
1420            file_url,
1421        })
1422    }
1423}
1424
1425crate::functions::expression::impl_from_special_unsupported!(
1426    RichContentExpression,
1427    RichContentPartExpression,
1428    ImageUrl,
1429    InputAudio,
1430    VideoUrl,
1431    File,
1432);
1433
1434impl crate::functions::expression::FromSpecial
1435    for Vec<crate::functions::expression::WithExpression<RichContentExpression>>
1436{
1437    fn from_special(
1438        _special: &crate::functions::expression::Special,
1439        _params: &crate::functions::expression::Params,
1440    ) -> Result<Self, crate::functions::expression::ExpressionError> {
1441        Err(crate::functions::expression::ExpressionError::UnsupportedSpecial)
1442    }
1443}