Skip to content

Commit 8fca183

Browse files
committed
fix(openai): downscale oversized inline images
1 parent e9c159a commit 8fca183

3 files changed

Lines changed: 280 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "kit"
3-
version = "0.1.115"
3+
version = "0.1.116"
44
edition = "2024"
55
rust-version = "1.94.0"
66
publish = false

src/provider/chatgpt.rs

Lines changed: 278 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
use std::{collections::HashMap, sync::Arc, time::Duration};
1+
use std::{collections::HashMap, io::Cursor, sync::Arc, time::Duration};
22

3-
use agentkit_core::{MetadataMap, Part};
3+
use agentkit_core::{DataRef, ItemKind, MetadataMap, Modality, Part, ToolOutput};
44
use agentkit_http::{
55
Authentication, AuthenticationAttempt, AuthenticationProvider, HeaderMap, HeaderValue,
66
HttpClient, HttpError, HttpRequest, HttpResponse, ResilienceConfig,
@@ -13,7 +13,12 @@ use agentkit_provider_openai::{
1313
OpenAIResponsesSession, OpenAIResponsesTurn as UpstreamOpenAIResponsesTurn,
1414
};
1515
use async_trait::async_trait;
16+
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
1617
use futures_util::StreamExt as _;
18+
use image::{
19+
DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits, Rgb, RgbImage,
20+
codecs::jpeg::JpegEncoder, imageops::FilterType,
21+
};
1722
use serde_json::Value;
1823
use zeroize::Zeroizing;
1924

@@ -33,6 +38,13 @@ const MAX_ATTEMPT_BYTES: usize = 16 * 1024 * 1024;
3338
const MAX_WIRE_BYTES: usize = 4 * MAX_ATTEMPT_BYTES;
3439
const MAX_ITEMS: usize = 10_000;
3540
const MAX_FIELD_BYTES: usize = 1024 * 1024;
41+
const MAX_SOURCE_IMAGE_BYTES: usize = 10 * 1024 * 1024;
42+
const MAX_DECODED_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
43+
const MAX_IMAGE_PIXELS: u64 = 10_000_000;
44+
const MAX_IMAGE_DIMENSION: u32 = 8_192;
45+
const MAX_TOOL_RESULT_DEPTH: usize = 8;
46+
const JPEG_DATA_URL_PREFIX: &str = "data:image/jpeg;base64,";
47+
const MAX_NORMALIZED_IMAGE_BYTES: usize = ((MAX_FIELD_BYTES - JPEG_DATA_URL_PREFIX.len()) / 4) * 3;
3648
const MAX_SERVER_DELAY: Duration = Duration::from_secs(10 * 60);
3749
const MAX_SUBSCRIPTION_AUTH_TIMEOUT: Duration = Duration::from_secs(30);
3850
const LEGACY_CONTINUATION_METADATA: &str = "openai.subscription.v1";
@@ -208,6 +220,12 @@ impl ModelSession for OpenAiSubscriptionSession {
208220
cancellation: Option<agentkit_core::TurnCancellation>,
209221
) -> Result<Self::Turn, LoopError> {
210222
migrate_legacy_continuations(&mut request, &self.model, &self.authentication_binding)?;
223+
let normalization_cancellation = cancellation.clone();
224+
let request = tokio::task::spawn_blocking(move || {
225+
normalize_openai_images(request, normalization_cancellation.as_ref())
226+
})
227+
.await
228+
.map_err(|_| protocol("Responses image normalization task failed"))??;
211229
self.inner
212230
.begin_turn(request, cancellation)
213231
.await
@@ -224,6 +242,190 @@ impl ModelSession for OpenAiSubscriptionSession {
224242
}
225243
}
226244

245+
fn normalize_openai_images(
246+
mut request: TurnRequest,
247+
cancellation: Option<&agentkit_core::TurnCancellation>,
248+
) -> Result<TurnRequest, LoopError> {
249+
for item in &mut request.transcript {
250+
check_image_cancellation(cancellation)?;
251+
if matches!(
252+
item.kind,
253+
ItemKind::User | ItemKind::Context | ItemKind::Tool
254+
) {
255+
normalize_openai_parts(&mut item.parts, cancellation, 0)?;
256+
}
257+
}
258+
Ok(request)
259+
}
260+
261+
fn normalize_openai_parts(
262+
parts: &mut [Part],
263+
cancellation: Option<&agentkit_core::TurnCancellation>,
264+
depth: usize,
265+
) -> Result<(), LoopError> {
266+
if depth > MAX_TOOL_RESULT_DEPTH {
267+
return Err(protocol("Responses tool-result media nesting is too deep"));
268+
}
269+
for part in parts {
270+
check_image_cancellation(cancellation)?;
271+
match part {
272+
Part::Media(media) if media.modality == Modality::Image => {
273+
if serialized_image_bytes(&media.data, &media.mime_type)
274+
.is_some_and(|bytes| bytes > MAX_FIELD_BYTES)
275+
{
276+
let source = inline_image_bytes(&media.data, &media.mime_type)?
277+
.ok_or_else(|| protocol("Responses oversized image is not inline data"))?;
278+
let normalized = normalize_image_bytes(source, &media.mime_type, cancellation)?;
279+
media.mime_type = "image/jpeg".into();
280+
media.data = DataRef::InlineBytes(normalized);
281+
}
282+
}
283+
Part::ToolResult(result) => {
284+
if let ToolOutput::Parts(parts) = &mut result.output {
285+
normalize_openai_parts(parts, cancellation, depth + 1)?;
286+
}
287+
}
288+
_ => {}
289+
}
290+
}
291+
Ok(())
292+
}
293+
294+
fn check_image_cancellation(
295+
cancellation: Option<&agentkit_core::TurnCancellation>,
296+
) -> Result<(), LoopError> {
297+
if cancellation.is_some_and(agentkit_core::TurnCancellation::is_cancelled) {
298+
Err(LoopError::Cancelled)
299+
} else {
300+
Ok(())
301+
}
302+
}
303+
304+
fn serialized_image_bytes(data: &DataRef, mime_type: &str) -> Option<usize> {
305+
let prefix = format!("data:{mime_type};base64,").len();
306+
match data {
307+
DataRef::InlineBytes(bytes) => Some(prefix + bytes.len().div_ceil(3) * 4),
308+
DataRef::InlineText(text) if text.starts_with("data:") => Some(text.len()),
309+
DataRef::InlineText(text) => Some(prefix + text.len()),
310+
DataRef::Uri(uri) if uri.starts_with("data:") => Some(uri.len()),
311+
DataRef::Uri(_) | DataRef::Handle(_) => None,
312+
}
313+
}
314+
315+
fn inline_image_bytes(data: &DataRef, mime_type: &str) -> Result<Option<Vec<u8>>, LoopError> {
316+
if let DataRef::InlineBytes(bytes) = data {
317+
if bytes.len() > MAX_SOURCE_IMAGE_BYTES {
318+
return Err(protocol("Responses image exceeds the 10 MiB source limit"));
319+
}
320+
return Ok(Some(bytes.clone()));
321+
}
322+
323+
let text = match data {
324+
DataRef::InlineText(text) | DataRef::Uri(text) if text.starts_with("data:") => text
325+
.strip_prefix(&format!("data:{mime_type};base64,"))
326+
.ok_or_else(|| protocol("Responses image data URL is not canonical base64"))?,
327+
DataRef::InlineText(text) => text,
328+
DataRef::Uri(_) | DataRef::Handle(_) => return Ok(None),
329+
DataRef::InlineBytes(_) => unreachable!("handled above"),
330+
};
331+
let max_base64_bytes = MAX_SOURCE_IMAGE_BYTES.div_ceil(3) * 4;
332+
if text.len() > max_base64_bytes {
333+
return Err(protocol("Responses image exceeds the 10 MiB source limit"));
334+
}
335+
let bytes = BASE64
336+
.decode(text)
337+
.map_err(|_| protocol("Responses image is not valid base64"))?;
338+
if bytes.len() > MAX_SOURCE_IMAGE_BYTES {
339+
return Err(protocol("Responses image exceeds the 10 MiB source limit"));
340+
}
341+
Ok(Some(bytes))
342+
}
343+
344+
fn normalize_image_bytes(
345+
bytes: Vec<u8>,
346+
mime_type: &str,
347+
cancellation: Option<&agentkit_core::TurnCancellation>,
348+
) -> Result<Vec<u8>, LoopError> {
349+
check_image_cancellation(cancellation)?;
350+
let mut reader = ImageReader::new(Cursor::new(bytes));
351+
if let Some(format) = ImageFormat::from_mime_type(mime_type) {
352+
reader.set_format(format);
353+
} else {
354+
reader = reader
355+
.with_guessed_format()
356+
.map_err(|_| protocol("Responses image format could not be detected"))?;
357+
}
358+
let mut limits = Limits::default();
359+
limits.max_image_width = Some(MAX_IMAGE_DIMENSION);
360+
limits.max_image_height = Some(MAX_IMAGE_DIMENSION);
361+
limits.max_alloc = Some(MAX_DECODED_IMAGE_BYTES);
362+
reader.limits(limits);
363+
364+
let mut decoder = reader
365+
.into_decoder()
366+
.map_err(|_| protocol("Responses image could not be decoded"))?;
367+
let (width, height) = decoder.dimensions();
368+
if width > MAX_IMAGE_DIMENSION
369+
|| height > MAX_IMAGE_DIMENSION
370+
|| u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS
371+
|| decoder.total_bytes() > MAX_DECODED_IMAGE_BYTES
372+
{
373+
return Err(protocol("Responses image dimensions are too large"));
374+
}
375+
let orientation = decoder
376+
.orientation()
377+
.map_err(|_| protocol("Responses image orientation could not be read"))?;
378+
let mut image = DynamicImage::from_decoder(decoder)
379+
.map_err(|_| protocol("Responses image could not be decoded"))?;
380+
image.apply_orientation(orientation);
381+
check_image_cancellation(cancellation)?;
382+
let rgba = image.into_rgba8();
383+
let rgb = RgbImage::from_fn(rgba.width(), rgba.height(), |x, y| {
384+
let pixel = rgba.get_pixel(x, y).0;
385+
let alpha = u16::from(pixel[3]);
386+
let flatten =
387+
|channel: u8| ((u16::from(channel) * alpha + 255 * (255 - alpha) + 127) / 255) as u8;
388+
Rgb([flatten(pixel[0]), flatten(pixel[1]), flatten(pixel[2])])
389+
});
390+
encode_image_to_budget(DynamicImage::ImageRgb8(rgb), cancellation)
391+
}
392+
393+
fn encode_image_to_budget(
394+
mut image: DynamicImage,
395+
cancellation: Option<&agentkit_core::TurnCancellation>,
396+
) -> Result<Vec<u8>, LoopError> {
397+
const QUALITIES: [u8; 5] = [85, 75, 65, 55, 45];
398+
loop {
399+
let mut smallest = Vec::new();
400+
for quality in QUALITIES {
401+
check_image_cancellation(cancellation)?;
402+
let mut encoded = Vec::new();
403+
JpegEncoder::new_with_quality(&mut encoded, quality)
404+
.encode_image(&image)
405+
.map_err(|_| protocol("Responses image could not be encoded"))?;
406+
if encoded.len() <= MAX_NORMALIZED_IMAGE_BYTES {
407+
return Ok(encoded);
408+
}
409+
smallest = encoded;
410+
}
411+
412+
let (width, height) = (image.width(), image.height());
413+
if width == 1 && height == 1 {
414+
return Err(protocol(
415+
"Responses image could not be reduced to the media limit",
416+
));
417+
}
418+
let ratio = ((MAX_NORMALIZED_IMAGE_BYTES as f64 / smallest.len() as f64).sqrt() * 0.9)
419+
.clamp(0.5, 0.85);
420+
let next_width =
421+
((width as f64 * ratio).floor() as u32).clamp(1, width.saturating_sub(1).max(1));
422+
let next_height =
423+
((height as f64 * ratio).floor() as u32).clamp(1, height.saturating_sub(1).max(1));
424+
check_image_cancellation(cancellation)?;
425+
image = image.resize(next_width, next_height, FilterType::Lanczos3);
426+
}
427+
}
428+
227429
pub struct OpenAiSubscriptionTurn {
228430
inner: UpstreamOpenAIResponsesTurn,
229431
context_window: Option<u64>,
@@ -672,6 +874,80 @@ mod tests {
672874
assert!(SubscriptionConfig::new("not a model".into()).is_err());
673875
}
674876

877+
#[test]
878+
fn normalized_jpeg_budget_accounts_for_base64_expansion() {
879+
let at_limit = DataRef::InlineBytes(vec![0; MAX_NORMALIZED_IMAGE_BYTES]);
880+
let over_limit = DataRef::InlineBytes(vec![0; MAX_NORMALIZED_IMAGE_BYTES + 1]);
881+
882+
assert!(serialized_image_bytes(&at_limit, "image/jpeg").unwrap() <= MAX_FIELD_BYTES);
883+
assert!(serialized_image_bytes(&over_limit, "image/jpeg").unwrap() > MAX_FIELD_BYTES);
884+
}
885+
886+
#[test]
887+
fn oversized_acp_png_is_normalized_before_responses_encoding() {
888+
let png = noisy_png(600, 600);
889+
let data_url = format!("data:image/png;base64,{}", BASE64.encode(&png));
890+
assert!(data_url.len() > MAX_FIELD_BYTES);
891+
let metadata = MetadataMap::from([("source".into(), json!("acp"))]);
892+
let mut parts = vec![Part::Media(
893+
agentkit_core::MediaPart::new(Modality::Image, "image/png", DataRef::Uri(data_url))
894+
.with_metadata(metadata.clone()),
895+
)];
896+
897+
normalize_openai_parts(&mut parts, None, 0).unwrap();
898+
899+
let Part::Media(media) = &parts[0] else {
900+
panic!("expected normalized media");
901+
};
902+
assert_eq!(media.mime_type, "image/jpeg");
903+
assert_eq!(media.metadata, metadata);
904+
let DataRef::InlineBytes(bytes) = &media.data else {
905+
panic!("expected normalized inline bytes");
906+
};
907+
assert!(bytes.len() <= MAX_NORMALIZED_IMAGE_BYTES);
908+
assert!(serialized_image_bytes(&media.data, &media.mime_type).unwrap() <= MAX_FIELD_BYTES);
909+
let decoded = ImageReader::new(Cursor::new(bytes))
910+
.with_guessed_format()
911+
.unwrap()
912+
.decode()
913+
.unwrap();
914+
assert_eq!((decoded.width(), decoded.height()), (600, 600));
915+
}
916+
917+
#[test]
918+
fn image_normalization_observes_turn_cancellation() {
919+
let controller = agentkit_core::CancellationController::new();
920+
let cancellation = controller.handle().checkpoint();
921+
controller.interrupt();
922+
let mut parts = vec![Part::media(
923+
Modality::Image,
924+
"image/png",
925+
DataRef::InlineBytes(vec![0; MAX_NORMALIZED_IMAGE_BYTES + 1]),
926+
)];
927+
928+
assert!(matches!(
929+
normalize_openai_parts(&mut parts, Some(&cancellation), 0),
930+
Err(LoopError::Cancelled)
931+
));
932+
}
933+
934+
fn noisy_png(width: u32, height: u32) -> Vec<u8> {
935+
let image = RgbImage::from_fn(width, height, |x, y| {
936+
let mut value = x
937+
.wrapping_mul(747_796_405)
938+
.wrapping_add(y.wrapping_mul(2_891_336_453))
939+
.wrapping_add(2_891_336_453);
940+
value = (value ^ (value >> 16)).wrapping_mul(2_246_822_519);
941+
value ^= value >> 13;
942+
Rgb([value as u8, (value >> 8) as u8, (value >> 16) as u8])
943+
});
944+
let mut bytes = Cursor::new(Vec::new());
945+
DynamicImage::ImageRgb8(image)
946+
.write_to(&mut bytes, ImageFormat::Png)
947+
.unwrap();
948+
bytes.into_inner()
949+
}
950+
675951
#[test]
676952
fn subscription_caps_server_retry_hints_at_ten_minutes() {
677953
let mut headers = HeaderMap::new();

0 commit comments

Comments
 (0)