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 } ;
44use 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} ;
1515use async_trait:: async_trait;
16+ use base64:: { Engine as _, engine:: general_purpose:: STANDARD as BASE64 } ;
1617use futures_util:: StreamExt as _;
18+ use image:: {
19+ DynamicImage , ImageDecoder , ImageFormat , ImageReader , Limits , Rgb , RgbImage ,
20+ codecs:: jpeg:: JpegEncoder , imageops:: FilterType ,
21+ } ;
1722use serde_json:: Value ;
1823use zeroize:: Zeroizing ;
1924
@@ -33,6 +38,13 @@ const MAX_ATTEMPT_BYTES: usize = 16 * 1024 * 1024;
3338const MAX_WIRE_BYTES : usize = 4 * MAX_ATTEMPT_BYTES ;
3439const MAX_ITEMS : usize = 10_000 ;
3540const 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 ;
3648const MAX_SERVER_DELAY : Duration = Duration :: from_secs ( 10 * 60 ) ;
3749const MAX_SUBSCRIPTION_AUTH_TIMEOUT : Duration = Duration :: from_secs ( 30 ) ;
3850const 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+
227429pub 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