#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ImageDetail {
Low,
High,
#[default]
Auto,
}
impl std::fmt::Display for ImageDetail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ImageDetail::Low => write!(f, "low"),
ImageDetail::High => write!(f, "high"),
ImageDetail::Auto => write!(f, "auto"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageBlock {
url: String,
#[serde(default)]
detail: ImageDetail,
}
impl ImageBlock {
pub fn from_url(url: impl Into<String>) -> crate::Result<Self> {
let url = url.into();
if url.is_empty() {
return Err(crate::Error::invalid_input("Image URL cannot be empty"));
}
if url.contains(char::is_control) {
return Err(crate::Error::invalid_input(
"Image URL contains invalid control characters",
));
}
if url.len() > 2000 {
eprintln!(
"WARNING: Very long image URL ({} chars). \
Some APIs may have URL length limits.",
url.len()
);
}
if url.starts_with("http://") || url.starts_with("https://") {
Ok(Self {
url,
detail: ImageDetail::default(),
})
} else if let Some(mime_part) = url.strip_prefix("data:") {
if !url.contains(";base64,") {
return Err(crate::Error::invalid_input(
"Data URI must be in format: data:image/TYPE;base64,DATA",
));
}
let mime_type = if let Some(semicolon_pos) = mime_part.find(';') {
&mime_part[..semicolon_pos]
} else {
return Err(crate::Error::invalid_input(
"Malformed data URI: missing MIME type",
));
};
if mime_type.is_empty() || !mime_type.starts_with("image/") {
return Err(crate::Error::invalid_input(
"Data URI MIME type must start with 'image/'",
));
}
if let Some(base64_start_pos) = url.find(";base64,") {
let base64_data = &url[base64_start_pos + 8..];
if base64_data.is_empty() {
return Err(crate::Error::invalid_input(
"Data URI base64 data cannot be empty",
));
}
if !base64_data
.chars()
.all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
{
return Err(crate::Error::invalid_input(
"Data URI base64 data contains invalid characters. Valid characters: A-Z, a-z, 0-9, +, /, =",
));
}
if base64_data.len() % 4 != 0 {
return Err(crate::Error::invalid_input(
"Data URI base64 data has invalid length (must be multiple of 4)",
));
}
let equals_count = base64_data.chars().filter(|c| *c == '=').count();
if equals_count > 2 {
return Err(crate::Error::invalid_input(
"Data URI base64 data has invalid padding (max 2 '=' characters allowed)",
));
}
if equals_count > 0 {
let trimmed = base64_data.trim_end_matches('=');
if trimmed.len() + equals_count != base64_data.len() {
return Err(crate::Error::invalid_input(
"Data URI base64 padding characters must be at the end",
));
}
}
}
Ok(Self {
url,
detail: ImageDetail::default(),
})
} else {
Err(crate::Error::invalid_input(
"Image URL must start with http://, https://, or data:",
))
}
}
pub fn from_base64(
base64_data: impl AsRef<str>,
mime_type: impl AsRef<str>,
) -> crate::Result<Self> {
let data = base64_data.as_ref();
let mime = mime_type.as_ref();
if data.is_empty() {
return Err(crate::Error::invalid_input(
"Base64 image data cannot be empty",
));
}
if !data
.chars()
.all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
{
return Err(crate::Error::invalid_input(
"Base64 data contains invalid characters. Valid characters: A-Z, a-z, 0-9, +, /, =",
));
}
if data.len() % 4 != 0 {
return Err(crate::Error::invalid_input(
"Base64 data has invalid length (must be multiple of 4)",
));
}
let equals_count = data.chars().filter(|c| *c == '=').count();
if equals_count > 2 {
return Err(crate::Error::invalid_input(
"Base64 data has invalid padding (max 2 '=' characters allowed)",
));
}
if equals_count > 0 {
let trimmed = data.trim_end_matches('=');
if trimmed.len() + equals_count != data.len() {
return Err(crate::Error::invalid_input(
"Base64 padding characters must be at the end",
));
}
}
if mime.is_empty() {
return Err(crate::Error::invalid_input("MIME type cannot be empty"));
}
if !mime.starts_with("image/") {
return Err(crate::Error::invalid_input(
"MIME type must start with 'image/' (e.g., 'image/png', 'image/jpeg')",
));
}
if mime.contains([';', ',', '\n', '\r']) {
return Err(crate::Error::invalid_input(
"MIME type contains invalid characters (;, \\n, \\r not allowed)",
));
}
if data.len() > 10_000_000 {
eprintln!(
"WARNING: Very large base64 image data ({} chars, ~{:.1}MB). \
This may exceed API limits or cause performance issues.",
data.len(),
(data.len() as f64 * 0.75) / 1_000_000.0
);
}
let url = format!("data:{};base64,{}", mime, data);
Ok(Self {
url,
detail: ImageDetail::default(),
})
}
pub fn from_file_path(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
use base64::{Engine as _, engine::general_purpose};
let path = path.as_ref();
let bytes = std::fs::read(path).map_err(|e| {
crate::Error::invalid_input(format!(
"Failed to read image file '{}': {}",
path.display(),
e
))
})?;
let mime_type = match path.extension().and_then(|e| e.to_str()) {
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
Some("svg") => "image/svg+xml",
Some(ext) => {
return Err(crate::Error::invalid_input(format!(
"Unsupported image file extension: .{}. Supported: jpg, jpeg, png, gif, webp, bmp, svg",
ext
)));
}
None => {
return Err(crate::Error::invalid_input(
"Image file path must have a file extension (e.g., .jpg, .png)",
));
}
};
let base64_data = general_purpose::STANDARD.encode(&bytes);
Self::from_base64(&base64_data, mime_type)
}
pub fn with_detail(mut self, detail: ImageDetail) -> Self {
self.detail = detail;
self
}
pub fn url(&self) -> &str {
&self.url
}
pub fn detail(&self) -> ImageDetail {
self.detail
}
}