use std::path::Path;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use crate::model_profile::SupportState;
use crate::models::{ContentBlock, ImageUrlContent};
pub const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImageAttachError {
Unreadable { path: String, reason: String },
Empty { path: String },
TooLarge { path: String, bytes: usize },
UnsupportedFormat { path: String, detected: String },
NotAnImage { path: String },
}
impl std::fmt::Display for ImageAttachError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unreadable { path, reason } => {
write!(f, "Cannot attach {path}: {reason}")
}
Self::Empty { path } => {
write!(f, "Cannot attach {path}: the file is empty")
}
Self::TooLarge { path, bytes } => write!(
f,
"Cannot attach {path}: {} exceeds the {} per-image limit. \
Downscale or crop it first.",
human_bytes(*bytes),
human_bytes(MAX_IMAGE_BYTES),
),
Self::UnsupportedFormat { path, detected } => write!(
f,
"Cannot attach {path}: {detected} is not accepted by vision \
models. Convert it to PNG, JPEG, GIF or WebP.",
),
Self::NotAnImage { path } => write!(
f,
"Cannot attach {path}: the file is not a PNG, JPEG, GIF or \
WebP image (its contents do not match any of those formats).",
),
}
}
}
impl std::error::Error for ImageAttachError {}
fn human_bytes(bytes: usize) -> String {
if bytes >= 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{bytes} bytes")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachedImage {
pub media_type: &'static str,
pub data_url: String,
pub source_bytes: usize,
}
pub struct PreparedToolImage {
pub block: Option<codewhale_tools::ToolResultContentBlock>,
pub note: String,
}
#[must_use]
pub fn prepare_tool_image_bytes(bytes: &[u8], mime_type: &str) -> PreparedToolImage {
let mime_type = mime_type.split(';').next().unwrap_or(mime_type).trim();
let valid = sniff_media_type(bytes) == Some(mime_type) && bytes.len() <= MAX_IMAGE_BYTES;
if !valid {
return PreparedToolImage {
block: None,
note: format!(
"Read image file [{mime_type}]\n[Image omitted: unsupported, invalid, or above the 5 MiB inline limit.]"
),
};
}
PreparedToolImage {
block: Some(codewhale_tools::ToolResultContentBlock::Image {
mime_type: mime_type.to_string(),
data: STANDARD.encode(bytes),
}),
note: format!("Read image file [{mime_type}]"),
}
}
fn valid_tool_image(mime_type: &str, data: &str) -> bool {
matches!(
mime_type,
"image/png" | "image/jpeg" | "image/gif" | "image/webp"
) && data.len() <= MAX_IMAGE_BYTES.div_ceil(3) * 4
&& STANDARD.decode(data).is_ok()
}
#[must_use]
pub(crate) fn bound_rich_tool_result(
mut rich: crate::tools::spec::RichToolResult,
) -> crate::tools::spec::RichToolResult {
let mut kept = Vec::with_capacity(1);
let mut omitted = 0usize;
for block in rich.content_blocks.drain(..) {
let codewhale_tools::ToolResultContentBlock::Image { mime_type, data } = █
if kept.is_empty() && valid_tool_image(mime_type, data) {
kept.push(block);
} else {
omitted += 1;
}
}
rich.result.content = tool_result_text_with_omission(&rich.result.content, omitted);
rich.content_blocks = kept;
rich
}
#[must_use]
pub(crate) fn provider_tool_result_image_refs(
blocks: Option<&[serde_json::Value]>,
) -> (Option<(&str, &str)>, usize) {
let mut image = None;
let mut omitted = 0usize;
for block in blocks.unwrap_or_default() {
let fields = block
.get("type")
.and_then(serde_json::Value::as_str)
.filter(|kind| *kind == "image")
.and_then(|_| {
block
.get("mime_type")
.and_then(serde_json::Value::as_str)
.zip(block.get("data").and_then(serde_json::Value::as_str))
});
if image.is_none()
&& let Some((mime_type, data)) = fields
&& valid_tool_image(mime_type, data)
{
image = Some((mime_type, data));
} else {
omitted += 1;
}
}
(image, omitted)
}
#[must_use]
pub(crate) fn tool_result_text_with_omission(content: &str, omitted: usize) -> String {
if omitted == 0 {
return content.to_string();
}
format!(
"{content}\n[{omitted} tool-result image block(s) omitted: invalid, unsupported, oversized, or additional.]"
)
}
#[must_use]
pub(crate) fn safe_tool_result_content_blocks(
blocks: Option<&[serde_json::Value]>,
) -> Option<Vec<serde_json::Value>> {
blocks.map(|blocks| {
blocks
.iter()
.map(|block| {
if block.get("type").and_then(serde_json::Value::as_str) == Some("image") {
serde_json::json!({
"type": "image",
"mime_type": block.get("mime_type").and_then(serde_json::Value::as_str).unwrap_or("application/octet-stream"),
"omission_code": "inline_or_local_image_payload",
"omitted_base64_bytes": block.get("data").and_then(serde_json::Value::as_str).map_or(0, str::len),
})
} else {
block.clone()
}
})
.collect()
})
}
#[must_use]
pub(crate) fn safe_tool_result_message_projection(
messages: &[crate::models::Message],
) -> Vec<crate::models::Message> {
let mut projected = messages.to_vec();
for message in &mut projected {
for block in &mut message.content {
if let ContentBlock::ToolResult { content_blocks, .. } = block {
*content_blocks = safe_tool_result_content_blocks(content_blocks.as_deref());
}
}
}
projected
}
impl AttachedImage {
#[must_use]
pub fn content_block(&self) -> ContentBlock {
ContentBlock::ImageUrl {
image_url: ImageUrlContent {
url: self.data_url.clone(),
},
}
}
}
#[must_use]
pub fn sniff_media_type(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return Some("image/png");
}
if bytes.starts_with(b"\xff\xd8\xff") {
return Some("image/jpeg");
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return Some("image/gif");
}
if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
return Some("image/webp");
}
None
}
#[must_use]
pub fn detect_rejected_format(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"BM") {
return Some("BMP");
}
if bytes.starts_with(b"II\x2a\x00") || bytes.starts_with(b"MM\x00\x2a") {
return Some("TIFF");
}
if bytes.len() >= 12 && bytes.starts_with(b"\0\0\0") && &bytes[4..8] == b"ftyp" {
return Some("HEIC/AVIF");
}
if bytes.starts_with(b"<svg") || bytes.starts_with(b"<?xml") {
return Some("SVG");
}
if bytes.starts_with(b"%PDF") {
return Some("PDF");
}
None
}
pub fn encode_image_bytes(bytes: &[u8], path: &str) -> Result<AttachedImage, ImageAttachError> {
if bytes.is_empty() {
return Err(ImageAttachError::Empty {
path: path.to_string(),
});
}
if bytes.len() > MAX_IMAGE_BYTES {
return Err(ImageAttachError::TooLarge {
path: path.to_string(),
bytes: bytes.len(),
});
}
let Some(media_type) = sniff_media_type(bytes) else {
return Err(match detect_rejected_format(bytes) {
Some(detected) => ImageAttachError::UnsupportedFormat {
path: path.to_string(),
detected: detected.to_string(),
},
None => ImageAttachError::NotAnImage {
path: path.to_string(),
},
});
};
let payload = STANDARD.encode(bytes);
Ok(AttachedImage {
media_type,
data_url: format!("data:{media_type};base64,{payload}"),
source_bytes: bytes.len(),
})
}
pub fn attach_image_from_path(path: &Path) -> Result<AttachedImage, ImageAttachError> {
let display = path.display().to_string();
if let Ok(meta) = std::fs::metadata(path) {
let len = meta.len();
if len > MAX_IMAGE_BYTES as u64 {
return Err(ImageAttachError::TooLarge {
path: display,
bytes: usize::try_from(len).unwrap_or(usize::MAX),
});
}
}
let bytes = std::fs::read(path).map_err(|error| ImageAttachError::Unreadable {
path: display.clone(),
reason: error.to_string(),
})?;
encode_image_bytes(&bytes, &display)
}
#[must_use]
pub fn parse_data_url(url: &str) -> Option<(&str, &str)> {
let rest = url.strip_prefix("data:")?;
let (header, payload) = rest.split_once(',')?;
let media_type = header.strip_suffix(";base64")?;
if media_type.is_empty() || payload.is_empty() {
return None;
}
Some((media_type, payload))
}
#[must_use]
pub fn is_remote_image_url(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://")
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ExpandedAttachments {
pub blocks: Vec<ContentBlock>,
pub notices: Vec<String>,
}
#[must_use]
pub fn expand_attachment_blocks(text: &str) -> ExpandedAttachments {
let references = crate::tui::file_mention::media_attachment_references(text);
let mut out = ExpandedAttachments::default();
for reference in references {
if reference.kind != "image" {
continue;
}
match attach_image_from_path(Path::new(&reference.path)) {
Ok(image) => {
out.blocks
.push(tag_block(&format!("<image path=\"{}\">", reference.path)));
out.blocks.push(image.content_block());
out.blocks.push(tag_block("</image>"));
}
Err(error) => out.notices.push(error.to_string()),
}
}
out
}
fn tag_block(text: &str) -> ContentBlock {
ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}
}
pub fn strip_images_when_unsupported(
messages: &mut [crate::models::Message],
vision: SupportState,
model: &str,
) -> usize {
if vision != SupportState::Unsupported {
return 0;
}
let mut stripped = 0;
for message in messages.iter_mut() {
for block in &mut message.content {
match block {
ContentBlock::ImageUrl { .. } => {
*block = ContentBlock::Text {
text: format!(
"[image content omitted: the active model ({model}) does \
not accept image input. Switch to a vision-capable \
model with /model to see it.]"
),
cache_control: None,
};
stripped += 1;
}
ContentBlock::ToolResult {
content,
content_blocks,
..
} => {
let count = content_blocks.as_ref().map_or(0, Vec::len);
if count > 0 {
*content_blocks = None;
*content = format!(
"{content}\n[{count} image block(s) omitted: the active model ({model}) does not accept image input.]"
);
stripped += count;
}
}
_ => {}
}
}
}
stripped
}
#[must_use]
pub fn notice_block(notices: &[String]) -> Option<ContentBlock> {
if notices.is_empty() {
return None;
}
let body = notices.join("\n");
Some(ContentBlock::Text {
text: format!(
"<attachment_notice>\n{body}\nDo not describe these images from \
memory or from their filenames; ask the user to re-share them.\n\
</attachment_notice>"
),
cache_control: None,
})
}
#[cfg(test)]
#[path = "image_attach/tests.rs"]
pub(crate) mod tests;