use anyllm_translate::openai::{ChatContentPart, ChatMessage};
use turbospark_vision_io::{
GridThw, MropePositions, PreprocessParams, PreprocessedImage, VisionSpecialIds,
};
pub(crate) const MAX_IMAGES_PER_REQUEST: usize = 8;
const MAX_SOURCE_PIXELS_PER_REQUEST: u64 = 16 * 1024 * 1024;
const MAX_PATCH_BYTES_PER_REQUEST: u64 = 128 * 1024 * 1024;
pub(crate) fn image_count(message: &ChatMessage) -> usize {
let Some(anyllm_translate::openai::ChatContent::Parts(parts)) = &message.content else {
return 0;
};
parts
.iter()
.filter(|p| matches!(p, ChatContentPart::ImageUrl { .. }))
.count()
}
#[derive(Debug, Clone)]
pub struct VisionInfo {
pub params: PreprocessParams,
pub specials: VisionSpecialIds,
}
#[derive(Debug, Clone)]
pub struct RequestImages {
pub images: Vec<PreprocessedImage>,
pub positions: MropePositions,
}
pub(crate) fn image_bytes(message: &ChatMessage) -> Result<Vec<Vec<u8>>, String> {
let Some(anyllm_translate::openai::ChatContent::Parts(parts)) = &message.content else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for part in parts {
if let ChatContentPart::ImageUrl { image_url } = part {
out.push(decode_data_url(&image_url.url)?);
}
}
Ok(out)
}
pub(crate) fn validate_urls(message: &ChatMessage) -> Result<(), String> {
let Some(anyllm_translate::openai::ChatContent::Parts(parts)) = &message.content else {
return Ok(());
};
for part in parts {
if let ChatContentPart::ImageUrl { image_url } = part {
split_data_url(&image_url.url)?;
}
}
Ok(())
}
pub(crate) fn has_images(message: &ChatMessage) -> bool {
matches!(
&message.content,
Some(anyllm_translate::openai::ChatContent::Parts(parts))
if parts
.iter()
.any(|p| matches!(p, ChatContentPart::ImageUrl { .. }))
)
}
pub fn decode_data_url(url: &str) -> Result<Vec<u8>, String> {
base64_decode(split_data_url(url)?)
}
fn split_data_url(url: &str) -> Result<&str, String> {
let is_data_scheme = url
.get(..5)
.is_some_and(|s| s.eq_ignore_ascii_case("data:"));
if !is_data_scheme {
return Err(format!(
"image_url must be a data: URL; this server does not fetch remote images (got {})",
elide(url)
));
}
let rest = &url[5..];
let Some((meta, payload)) = rest.split_once(',') else {
return Err(
"malformed data: URL, expected a comma between the media type and the payload"
.to_string(),
);
};
if !meta
.split(';')
.any(|token| token.eq_ignore_ascii_case("base64"))
{
return Err(format!(
"data: URL is not base64-encoded (media type {meta:?}); send ;base64 data"
));
}
Ok(payload)
}
fn elide(url: &str) -> String {
const MAX: usize = 60;
let mut chars = url.chars();
let head: String = chars.by_ref().take(MAX).collect();
if chars.next().is_none() {
return head;
}
format!("{head}... [{} bytes]", url.len())
}
pub fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
const INVALID: u8 = 0xFF;
let value = |c: u8| -> u8 {
match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
_ => INVALID,
}
};
let mut out = Vec::with_capacity(input.len() / 4 * 3);
let mut acc: u32 = 0;
let mut bits = 0u32;
for (i, c) in input.bytes().enumerate() {
if c.is_ascii_whitespace() || c == b'=' {
continue;
}
let v = value(c);
if v == INVALID {
return Err(format!(
"invalid base64 character {:?} at offset {i}",
c as char
));
}
acc = (acc << 6) | v as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
if out.is_empty() {
return Err("data: URL carries an empty payload".to_string());
}
Ok(out)
}
pub(crate) fn preprocess_all(
encoded: &[Vec<u8>],
info: &VisionInfo,
) -> Result<Vec<PreprocessedImage>, String> {
encoded
.iter()
.enumerate()
.map(|(i, bytes)| {
let decoded = turbospark_vision_io::decode_image_bytes(bytes)
.map_err(|e| format!("image {i}: {e}"))?;
turbospark_vision_io::preprocess(&decoded, &info.params)
.map_err(|e| format!("image {i}: {e}"))
})
.collect()
}
pub(crate) fn preflight_all(
encoded: &[Vec<u8>],
info: &VisionInfo,
) -> Result<Vec<GridThw>, String> {
let mut source_pixels = 0u64;
let mut patch_bytes = 0u64;
let mut grids = Vec::with_capacity(encoded.len());
for (i, bytes) in encoded.iter().enumerate() {
let (width, height) =
turbospark_vision_io::image_dimensions(bytes).map_err(|e| format!("image {i}: {e}"))?;
source_pixels = source_pixels
.checked_add(u64::from(width) * u64::from(height))
.ok_or_else(|| "image pixel count overflows".to_string())?;
if source_pixels > MAX_SOURCE_PIXELS_PER_REQUEST {
return Err(format!(
"images contain {source_pixels} source pixels; the per-request limit is {MAX_SOURCE_PIXELS_PER_REQUEST}"
));
}
let (resized_h, resized_w) =
turbospark_vision_io::resized_dims(height as usize, width as usize, &info.params)
.map_err(|e| format!("image {i}: {e}"))?;
let grid = GridThw::new(
1,
resized_h / info.params.patch_size,
resized_w / info.params.patch_size,
);
let bytes = (grid.patches() as u64)
.checked_mul(info.params.patch_dim() as u64)
.and_then(|n| n.checked_mul(std::mem::size_of::<f32>() as u64))
.ok_or_else(|| "image preprocessing size overflows".to_string())?;
patch_bytes = patch_bytes
.checked_add(bytes)
.ok_or_else(|| "image preprocessing size overflows".to_string())?;
if patch_bytes > MAX_PATCH_BYTES_PER_REQUEST {
return Err(format!(
"images need {patch_bytes} bytes of preprocessed patches; the per-request limit is {MAX_PATCH_BYTES_PER_REQUEST}"
));
}
grids.push(grid);
}
Ok(grids)
}
#[cfg(test)]
mod tests {
use super::*;
fn params(max_pixels: usize) -> PreprocessParams {
PreprocessParams {
patch_size: 16,
temporal_patch_size: 2,
merge_size: 2,
in_channels: 3,
min_pixels: 65_536,
max_pixels,
image_mean: [0.5; 3],
image_std: [0.5; 3],
rescale_factor: 1.0 / 255.0,
}
}
fn gray_png(width: u32, height: u32) -> Vec<u8> {
let image = image::GrayImage::from_pixel(width, height, image::Luma([0]));
let mut bytes = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageLuma8(image)
.write_to(&mut bytes, image::ImageFormat::Png)
.unwrap();
bytes.into_inner()
}
#[test]
fn preflight_refuses_aggregate_patch_memory_before_preprocessing() {
let png = gray_png(2048, 2048);
let info = VisionInfo {
params: params(16_777_216),
specials: VisionSpecialIds {
vision_start: 1,
image_pad: 2,
},
};
let err = preflight_all(&[png.clone(), png], &info).unwrap_err();
assert!(err.contains("preprocessed patches"), "{err}");
assert!(err.contains("134217728"), "{err}");
}
#[test]
fn a_base64_data_url_decodes() {
assert_eq!(
decode_data_url("data:image/png;base64,aGVsbG8=").unwrap(),
b"hello"
);
}
#[test]
fn padding_and_whitespace_are_tolerated() {
assert_eq!(
decode_data_url("data:image/png;base64,aGVsbG8").unwrap(),
b"hello"
);
assert_eq!(
decode_data_url("data:image/png;base64,aGVs\n bG8=").unwrap(),
b"hello"
);
}
#[test]
fn a_remote_url_is_refused_and_names_what_to_send() {
let err = decode_data_url("https://example.com/cat.png").unwrap_err();
assert!(err.contains("does not fetch remote"), "{err}");
assert!(err.contains("data:"), "{err}");
}
#[test]
fn a_non_base64_data_url_is_refused() {
let err = decode_data_url("data:image/png,%89PNG").unwrap_err();
assert!(err.contains("not base64"), "{err}");
}
#[test]
fn an_invalid_character_is_named_with_its_offset() {
let err = decode_data_url("data:image/png;base64,aGVs!G8=").unwrap_err();
assert!(err.contains("invalid base64"), "{err}");
}
#[test]
fn the_scheme_and_base64_token_are_case_insensitive() {
assert_eq!(
decode_data_url("DATA:image/png;BASE64,aGVsbG8=").unwrap(),
b"hello"
);
assert_eq!(
decode_data_url("Data:image/png;Base64,aGVsbG8=").unwrap(),
b"hello"
);
}
#[test]
fn the_url_safe_alphabet_decodes_the_same_bytes_as_standard() {
assert_eq!(
base64_decode("+/+/").unwrap(),
base64_decode("-_-_").unwrap()
);
}
#[test]
fn a_long_url_is_elided_in_the_error() {
let long = format!("https://example.com/{}", "a".repeat(5000));
let err = decode_data_url(&long).unwrap_err();
assert!(err.len() < 200, "error was {} bytes", err.len());
assert!(err.contains(&format!("{} bytes", long.len())), "{err}");
}
#[test]
fn a_multibyte_character_straddling_the_truncation_point_does_not_panic() {
let url = format!("h{}", "\u{00e9}".repeat(30));
assert!(!url.is_char_boundary(60), "fixture must straddle byte 60");
let err = decode_data_url(&url).unwrap_err();
assert!(err.contains("does not fetch remote"), "{err}");
}
}