use std::sync::atomic::{AtomicUsize, Ordering};
use deepseek_recipe_core::multimodal::{ImageDetail, MAX_IMAGE_URL_LEN};
use crate::error::ImageError;
#[derive(Debug, Clone, Copy)]
pub struct ImageLimits {
pub max_images: usize,
pub max_url_len: usize,
pub max_image_bytes: usize,
pub max_total_bytes: usize,
pub max_concurrent_sources: usize,
pub max_dimension_px: u32,
pub max_dimension_on_many_images_px: u32,
pub many_images_threshold: usize,
pub low_detail_max_dimension_px: u32,
}
impl Default for ImageLimits {
fn default() -> Self {
Self {
max_images: 600,
max_url_len: MAX_IMAGE_URL_LEN,
max_image_bytes: 32 * 1024 * 1024,
max_total_bytes: 64 * 1024 * 1024,
max_concurrent_sources: 8,
max_dimension_px: 8192,
max_dimension_on_many_images_px: 4096,
many_images_threshold: 15,
low_detail_max_dimension_px: 512,
}
}
}
impl ImageLimits {
pub const fn max_dimension(&self, image_count: usize) -> u32 {
if image_count < self.many_images_threshold {
self.max_dimension_px
} else {
self.max_dimension_on_many_images_px
}
}
pub const fn preprocess_options(
&self,
detail: ImageDetail,
image_count: usize,
) -> PreprocessOptions {
PreprocessOptions {
detail,
max_dimension_px: self.max_dimension(image_count),
low_detail_max_dimension_px: self.low_detail_max_dimension_px,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ImageQuota {
images: usize,
bytes: usize,
}
impl ImageQuota {
pub const fn new() -> Self {
Self {
images: 0,
bytes: 0,
}
}
pub const fn image_count(&self) -> usize {
self.images
}
pub const fn byte_size(&self) -> usize {
self.bytes
}
pub(crate) fn add(&mut self, images: usize, bytes: usize) {
self.images += images;
self.bytes += bytes;
}
}
#[derive(Debug)]
pub struct ImageByteBudget {
remaining: AtomicUsize,
max_image_bytes: usize,
max_total_bytes: usize,
}
impl ImageByteBudget {
pub const fn new(max_image_bytes: usize, max_total_bytes: usize, used_bytes: usize) -> Self {
Self {
remaining: AtomicUsize::new(max_total_bytes.saturating_sub(used_bytes)),
max_image_bytes,
max_total_bytes,
}
}
pub const fn max_image_bytes(&self) -> usize {
self.max_image_bytes
}
pub fn reserve(&self, bytes: usize) -> Result<(), ImageError> {
let mut available = self.remaining.load(Ordering::Relaxed);
loop {
if available < bytes {
let used = self.max_total_bytes.saturating_sub(available);
return Err(ImageError::TotalSizeTooLarge {
size: used.saturating_add(bytes),
max: self.max_total_bytes,
});
}
match self.remaining.compare_exchange_weak(
available,
available - bytes,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return Ok(()),
Err(current) => available = current,
}
}
}
pub fn release(&self, bytes: usize) {
self.remaining.fetch_add(bytes, Ordering::Relaxed);
}
}
#[derive(Debug, Clone, Copy)]
pub struct PreprocessOptions {
pub detail: ImageDetail,
pub max_dimension_px: u32,
pub low_detail_max_dimension_px: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reserving_bytes_reduces_the_available_bytes() {
let budget = ImageByteBudget::new(8, 10, 0);
assert_eq!(budget.max_image_bytes(), 8);
budget.reserve(6).expect("the total limit is available");
let error = budget
.reserve(5)
.expect_err("the reservation exceeds the total limit");
assert!(
matches!(error, ImageError::TotalSizeTooLarge { size: 11, max: 10 }),
"{error}"
);
budget
.reserve(4)
.expect("the remaining bytes are available");
}
#[test]
fn a_budget_accounts_for_the_bytes_of_earlier_calls() {
let budget = ImageByteBudget::new(8, 10, 6);
let error = budget
.reserve(5)
.expect_err("earlier calls used six bytes of the total limit");
assert!(
matches!(error, ImageError::TotalSizeTooLarge { size: 11, max: 10 }),
"{error}"
);
budget
.reserve(4)
.expect("the remaining bytes are available");
}
#[test]
fn a_release_returns_the_bytes_of_a_failed_attempt() {
let budget = ImageByteBudget::new(8, 10, 0);
budget.reserve(10).expect("the total limit is available");
budget.release(10);
budget
.reserve(10)
.expect("the released bytes are available again");
}
}