use super::{AudioData, ImageData, Media, VideoData};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image_url")]
Image { image_url: ImageUrl },
#[serde(rename = "input_audio")]
Audio { input_audio: AudioInput },
#[serde(rename = "video_url")]
Video { video_url: VideoUrl },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageUrl {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioInput {
pub data: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_secs: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoUrl {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_secs: Option<f64>,
}
impl ContentPart {
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
pub fn image(image: &ImageData) -> Self {
Self::Image {
image_url: ImageUrl {
url: image.to_data_url(),
detail: image.detail.clone(),
},
}
}
pub fn audio(audio: &AudioData) -> Self {
let format = if audio.is_url() {
None
} else {
Some(audio.format.clone())
};
Self::Audio {
input_audio: AudioInput {
data: audio.base64_data.clone(),
format,
duration_secs: audio.duration_secs,
},
}
}
pub fn video(video: &VideoData) -> Self {
Self::Video {
video_url: VideoUrl {
url: video.to_data_url(),
duration_secs: video.duration_secs,
},
}
}
pub fn from_media(media: &Media) -> Self {
match media {
Media::Image(img) => Self::image(img),
Media::Audio(audio) => Self::audio(audio),
Media::Video(video) => Self::video(video),
}
}
pub fn is_text(&self) -> bool {
matches!(self, Self::Text { .. })
}
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text { text } => Some(text),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Parts(Vec<ContentPart>),
}
impl MessageContent {
pub fn text(text: impl Into<String>) -> Self {
Self::Text(text.into())
}
pub fn parts(parts: Vec<ContentPart>) -> Self {
Self::Parts(parts)
}
pub fn with_images(text: impl Into<String>, images: &[ImageData]) -> Self {
let mut parts = vec![ContentPart::text(text)];
parts.extend(images.iter().map(ContentPart::image));
Self::Parts(parts)
}
pub fn with_audio(text: impl Into<String>, audio: &[AudioData]) -> Self {
let mut parts = vec![ContentPart::text(text)];
parts.extend(audio.iter().map(ContentPart::audio));
Self::Parts(parts)
}
pub fn with_video(text: impl Into<String>, video: &[VideoData]) -> Self {
let mut parts = vec![ContentPart::text(text)];
parts.extend(video.iter().map(ContentPart::video));
Self::Parts(parts)
}
pub fn with_media(text: impl Into<String>, media: &[Media]) -> Self {
let mut parts = vec![ContentPart::text(text)];
parts.extend(media.iter().map(ContentPart::from_media));
Self::Parts(parts)
}
pub fn has_multimodal(&self) -> bool {
match self {
Self::Text(_) => false,
Self::Parts(parts) => parts.iter().any(|p| !p.is_text()),
}
}
pub fn get_text(&self) -> Option<&str> {
match self {
Self::Text(text) => Some(text),
Self::Parts(parts) => parts.iter().find_map(|p| p.as_text()),
}
}
pub fn all_text(&self) -> String {
match self {
Self::Text(text) => text.clone(),
Self::Parts(parts) => parts
.iter()
.filter_map(|p| p.as_text())
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn to_api_format(&self) -> serde_json::Value {
match self {
Self::Text(text) => serde_json::json!(text),
Self::Parts(parts) => {
let mut value = serde_json::json!(parts);
for part in value.as_array_mut().expect("parts serialize to an array") {
for media_key in ["input_audio", "video_url"] {
if let Some(media) = part.get_mut(media_key).and_then(|v| v.as_object_mut()) {
media.remove("duration_secs");
}
}
}
value
}
}
}
pub fn merge(&self, other: &MessageContent) -> MessageContent {
match (self, other) {
(Self::Text(a), Self::Text(b)) => Self::Text(format!("{}\n{}", a, b)),
(Self::Text(a), Self::Parts(b)) => {
let mut parts = vec![ContentPart::text(a)];
parts.extend(b.clone());
Self::Parts(parts)
}
(Self::Parts(a), Self::Text(b)) => {
let mut parts = a.clone();
parts.push(ContentPart::text(b));
Self::Parts(parts)
}
(Self::Parts(a), Self::Parts(b)) => {
let mut parts = a.clone();
parts.extend(b.clone());
Self::Parts(parts)
}
}
}
}
impl From<String> for MessageContent {
fn from(text: String) -> Self {
Self::Text(text)
}
}
impl From<&str> for MessageContent {
fn from(text: &str) -> Self {
Self::Text(text.to_string())
}
}
impl Default for MessageContent {
fn default() -> Self {
Self::Text(String::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::AudioData;
#[test]
fn audio_content_part_emits_base64_with_format() {
let audio = AudioData::from_bytes(&[0u8; 4], "mp3");
let part = ContentPart::audio(&audio);
let json = serde_json::to_value(&part).unwrap();
assert_eq!(json["type"], "input_audio");
assert_eq!(json["input_audio"]["format"], "mp3");
assert!(json["input_audio"]["data"].as_str().is_some());
}
#[test]
fn a_clips_duration_survives_a_round_trip_and_is_omitted_when_unknown() {
let timed = ContentPart::audio(&AudioData::from_bytes(&[0u8; 4], "mp3").with_duration(3.5));
let json = serde_json::to_value(&timed).unwrap();
assert_eq!(json["input_audio"]["duration_secs"], 3.5);
let ContentPart::Audio { input_audio } = serde_json::from_value(json).unwrap() else {
panic!("an audio part must deserialize as one");
};
assert_eq!(input_audio.duration_secs, Some(3.5));
let untimed = ContentPart::audio(&AudioData::from_bytes(&[0u8; 4], "mp3"));
let json = serde_json::to_value(&untimed).unwrap();
assert!(json["input_audio"].get("duration_secs").is_none(), "{json}");
}
#[test]
fn a_videos_duration_survives_a_round_trip_and_is_omitted_when_unknown() {
use crate::message::VideoData;
let timed = ContentPart::video(&VideoData::from_url("https://x/y.mp4").with_duration(12.0));
let json = serde_json::to_value(&timed).unwrap();
assert_eq!(json["video_url"]["duration_secs"], 12.0);
let ContentPart::Video { video_url } = serde_json::from_value(json).unwrap() else {
panic!("a video part must deserialize as one");
};
assert_eq!(video_url.duration_secs, Some(12.0));
let untimed = ContentPart::video(&VideoData::from_url("https://x/y.mp4"));
let json = serde_json::to_value(&untimed).unwrap();
assert!(json["video_url"].get("duration_secs").is_none(), "{json}");
}
#[test]
fn a_duration_never_reaches_the_request_payload() {
use crate::message::{MessageContent, VideoData};
let content = MessageContent::parts(vec![
ContentPart::text("what is in this?"),
ContentPart::audio(&AudioData::from_bytes(&[0u8; 4], "mp3").with_duration(3.5)),
ContentPart::video(&VideoData::from_url("https://x/y.mp4").with_duration(12.0)),
]);
let wire = content.to_api_format();
let parts = wire.as_array().expect("parts stay an array");
assert_eq!(parts[0]["text"], "what is in this?");
assert!(parts[1]["input_audio"].get("duration_secs").is_none(), "{wire}");
assert_eq!(parts[1]["input_audio"]["format"], "mp3", "only the duration is shed");
assert!(parts[2]["video_url"].get("duration_secs").is_none(), "{wire}");
assert_eq!(parts[2]["video_url"]["url"], "https://x/y.mp4");
}
#[test]
fn audio_content_part_url_does_not_leak_sentinel() {
let audio = AudioData::from_url("https://example.com/clip.mp3");
let part = ContentPart::audio(&audio);
let json = serde_json::to_value(&part).unwrap();
assert_eq!(json["input_audio"]["data"], "https://example.com/clip.mp3");
assert!(
json["input_audio"].get("format").is_none(),
"format must be omitted for URL audio, got {:?}",
json["input_audio"].get("format")
);
}
}