use std::fmt;
use std::ops::Deref;
use std::sync::Arc;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::ProviderData;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum MediaSource {
Url {
url: String,
},
#[non_exhaustive]
Base64 {
media_type: String,
data: Base64Data,
},
#[non_exhaustive]
ProviderFile {
origin: String,
id: String,
},
}
impl MediaSource {
#[must_use]
pub fn url(url: impl Into<String>) -> Self {
Self::Url { url: url.into() }
}
#[must_use]
pub fn base64(media_type: impl Into<String>, data: impl Into<Base64Data>) -> Self {
Self::Base64 {
media_type: media_type.into(),
data: data.into(),
}
}
#[must_use]
pub fn bytes(media_type: impl Into<String>, data: impl AsRef<[u8]>) -> Self {
Self::base64(media_type, encode_base64(data.as_ref()))
}
#[must_use]
pub fn provider_file(origin: impl Into<String>, id: impl Into<String>) -> Self {
Self::ProviderFile {
origin: origin.into(),
id: id.into(),
}
}
#[must_use]
pub fn media_type(&self) -> Option<&str> {
match self {
Self::Base64 { media_type, .. } => Some(media_type),
Self::Url { .. } | Self::ProviderFile { .. } => None,
}
}
#[must_use]
pub fn data(&self) -> Option<&str> {
match self {
Self::Base64 { data, .. } => Some(data.as_str()),
Self::Url { .. } | Self::ProviderFile { .. } => None,
}
}
#[must_use]
pub fn provider_file_id(&self) -> Option<(&str, &str)> {
match self {
Self::ProviderFile { origin, id } => Some((origin, id)),
Self::Url { .. } | Self::Base64 { .. } => None,
}
}
}
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
pub struct Base64Data(Arc<str>);
impl Base64Data {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Deref for Base64Data {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for Base64Data {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Base64Data {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&*self.0, f)
}
}
impl fmt::Display for Base64Data {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for Base64Data {
fn from(value: String) -> Self {
Self(value.into())
}
}
impl From<&str> for Base64Data {
fn from(value: &str) -> Self {
Self(value.into())
}
}
impl From<Arc<str>> for Base64Data {
fn from(value: Arc<str>) -> Self {
Self(value)
}
}
impl From<Base64Data> for String {
fn from(value: Base64Data) -> Self {
value.0.as_ref().to_owned()
}
}
impl PartialEq<str> for Base64Data {
fn eq(&self, other: &str) -> bool {
&*self.0 == other
}
}
impl PartialEq<&str> for Base64Data {
fn eq(&self, other: &&str) -> bool {
&*self.0 == *other
}
}
impl Serialize for Base64Data {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for Base64Data {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer).map(Self::from)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ImageDetail {
Auto,
Low,
High,
}
impl ImageDetail {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Low => "low",
Self::High => "high",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Image {
pub source: MediaSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<ImageDetail>,
#[serde(default, skip_serializing_if = "ProviderData::is_empty")]
pub provider_data: ProviderData,
}
impl Image {
#[must_use]
pub fn new(source: MediaSource) -> Self {
Self {
source,
media_type: None,
detail: None,
provider_data: ProviderData::new(),
}
}
#[must_use]
pub fn url(url: impl Into<String>) -> Self {
Self::new(MediaSource::url(url))
}
#[must_use]
pub fn base64(media_type: impl Into<String>, data: impl Into<Base64Data>) -> Self {
Self::new(MediaSource::base64(media_type, data))
}
#[must_use]
pub fn bytes(media_type: impl Into<String>, data: impl AsRef<[u8]>) -> Self {
Self::new(MediaSource::bytes(media_type, data))
}
#[must_use]
pub fn provider_file(origin: impl Into<String>, id: impl Into<String>) -> Self {
Self::new(MediaSource::provider_file(origin, id))
}
#[must_use]
pub fn with_media_type(mut self, media_type: impl Into<String>) -> Self {
self.media_type = Some(media_type.into());
self
}
pub fn resolved_media_type(&self) -> crate::Result<Option<&str>> {
match (&self.source, self.media_type.as_deref()) {
(MediaSource::Base64 { media_type, .. }, Some(declared))
if !media_type.eq_ignore_ascii_case(declared) =>
{
Err(crate::Error::invalid_request(format!(
"image media type `{declared}` disagrees with its inline data (`{media_type}`)"
))
.authored())
}
(MediaSource::Base64 { media_type, .. }, _) => Ok(Some(media_type)),
(_, declared) => Ok(declared),
}
}
#[must_use]
pub fn with_detail(mut self, detail: ImageDetail) -> Self {
self.detail = Some(detail);
self
}
#[must_use]
pub fn with_provider_data(mut self, provider_data: ProviderData) -> Self {
self.provider_data = provider_data;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub struct File {
pub media_type: String,
pub source: MediaSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(default, skip_serializing_if = "ProviderData::is_empty")]
pub provider_data: ProviderData,
}
impl File {
#[must_use]
pub fn base64(media_type: impl Into<String>, data: impl Into<Base64Data>) -> Self {
let media_type = media_type.into();
Self::new(media_type.clone(), MediaSource::base64(media_type, data))
}
#[must_use]
pub fn bytes(media_type: impl Into<String>, data: impl AsRef<[u8]>) -> Self {
Self::base64(media_type, encode_base64(data.as_ref()))
}
#[must_use]
pub fn url(media_type: impl Into<String>, url: impl Into<String>) -> Self {
Self::new(media_type, MediaSource::url(url))
}
#[must_use]
pub fn provider_file(
media_type: impl Into<String>,
origin: impl Into<String>,
id: impl Into<String>,
) -> Self {
Self::new(media_type, MediaSource::provider_file(origin, id))
}
#[must_use]
pub fn new(media_type: impl Into<String>, source: MediaSource) -> Self {
Self {
media_type: media_type.into(),
source,
filename: None,
provider_data: ProviderData::new(),
}
}
#[must_use]
pub fn kind(&self) -> FileKind {
FileKind::of(&self.media_type)
}
#[must_use]
pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
self.filename = Some(filename.into());
self
}
#[must_use]
pub fn with_provider_data(mut self, provider_data: ProviderData) -> Self {
self.provider_data = provider_data;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum FileKind {
Document,
Audio,
Video,
}
impl FileKind {
#[must_use]
pub fn of(media_type: &str) -> Self {
let top = media_type.split('/').next().unwrap_or_default().trim();
if top.eq_ignore_ascii_case("audio") {
Self::Audio
} else if top.eq_ignore_ascii_case("video") {
Self::Video
} else {
Self::Document
}
}
}
fn encode_base64(data: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut encoded = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let bytes = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let group = u32::from_be_bytes([0, bytes[0], bytes[1], bytes[2]]);
for i in 0..4 {
if i <= chunk.len() {
let index = (group >> (18 - 6 * i)) & 0x3f;
encoded.push(char::from(ALPHABET[index as usize]));
} else {
encoded.push('=');
}
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_encoding_follows_rfc_4648() {
for (input, expected) in [
("", ""),
("f", "Zg=="),
("fo", "Zm8="),
("foo", "Zm9v"),
("foob", "Zm9vYg=="),
("fooba", "Zm9vYmE="),
("foobar", "Zm9vYmFy"),
] {
assert_eq!(encode_base64(input.as_bytes()), expected, "{input:?}");
}
assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
assert_eq!(encode_base64(&[0, 0x10, 0x83]), "ABCD");
}
#[test]
fn bytes_constructors_send_the_bytes_inline() {
let png = [0x89, b'P', b'N', b'G'];
assert_eq!(
Image::bytes("image/png", png),
Image::base64("image/png", "iVBORw==")
);
assert_eq!(
File::bytes("application/pdf", b"%PDF-").with_filename("a.pdf"),
File::base64("application/pdf", "JVBERi0=").with_filename("a.pdf")
);
}
#[test]
fn resolved_media_type_prefers_inline_data_and_rejects_conflicts() {
assert_eq!(
Image::base64("image/png", "AA")
.resolved_media_type()
.unwrap(),
Some("image/png")
);
assert_eq!(
Image::base64("image/png", "AA")
.with_media_type("IMAGE/PNG")
.resolved_media_type()
.unwrap(),
Some("image/png")
);
assert!(
Image::base64("image/png", "AA")
.with_media_type("image/jpeg")
.resolved_media_type()
.is_err()
);
assert_eq!(
Image::url("s3://bucket/key")
.with_media_type("image/webp")
.resolved_media_type()
.unwrap(),
Some("image/webp")
);
assert_eq!(
Image::url("https://e.x/a.png")
.resolved_media_type()
.unwrap(),
None
);
}
#[test]
fn cloned_inline_data_is_shared() {
let request = crate::ChatRequest::new("m").message(crate::Message::user_parts(vec![
crate::ContentPart::image_base64("image/png", "A".repeat(1 << 16)),
]));
let data = |request: &crate::ChatRequest| {
let crate::Message::User(user) = &request.messages[0] else {
unreachable!()
};
let crate::UserContent::Parts(parts) = &user.content else {
unreachable!()
};
let crate::ContentPart::Image(image) = &parts[0] else {
unreachable!()
};
image.source.data().unwrap().as_ptr()
};
assert_eq!(data(&request), data(&request.clone()));
}
#[test]
fn sources_serialize_with_their_type_tag() {
let base64 = MediaSource::base64("image/png", "AA");
assert_eq!(
serde_json::to_value(&base64).unwrap(),
serde_json::json!({"type": "base64", "media_type": "image/png", "data": "AA"})
);
let file = MediaSource::provider_file("openai", "file-1");
let value = serde_json::to_value(&file).unwrap();
assert_eq!(
value,
serde_json::json!({"type": "provider_file", "origin": "openai", "id": "file-1"})
);
assert_eq!(serde_json::from_value::<MediaSource>(value).unwrap(), file);
assert_eq!(file.data(), None);
assert_eq!(base64.data(), Some("AA"));
}
#[test]
fn file_kind_follows_the_media_type() {
assert_eq!(File::base64("audio/mpeg", "AA").kind(), FileKind::Audio);
assert_eq!(
File::url("video/webm", "https://e.x/v").kind(),
FileKind::Video
);
assert_eq!(
File::base64("application/pdf", "AA").kind(),
FileKind::Document
);
assert_eq!(
FileKind::of("text/plain; charset=utf-8"),
FileKind::Document
);
}
#[test]
fn media_type_is_omitted_when_unset() {
let value = serde_json::to_value(Image::url("https://e.x/a.png")).unwrap();
assert_eq!(
value,
serde_json::json!({"source": {"type": "url", "url": "https://e.x/a.png"}})
);
}
}