diff --git a/apps/aether-gateway/src/ai_pipeline/planner/specialized/image/request.rs b/apps/aether-gateway/src/ai_pipeline/planner/specialized/image/request.rs index 71d8a349..364fec8f 100644 --- a/apps/aether-gateway/src/ai_pipeline/planner/specialized/image/request.rs +++ b/apps/aether-gateway/src/ai_pipeline/planner/specialized/image/request.rs @@ -866,11 +866,7 @@ fn parse_multipart_fields_from_base64( .headers .get(http::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok())?; - let boundary = content_type - .split(';') - .find_map(|segment| segment.trim().strip_prefix("boundary="))? - .trim_matches('"') - .to_string(); + let boundary = multipart_boundary(content_type)?; let body_bytes = base64::engine::general_purpose::STANDARD .decode(body_base64) .ok()?; @@ -905,6 +901,17 @@ fn parse_multipart_fields(body: &[u8], boundary: &str) -> Vec { parts } +fn multipart_boundary(content_type: &str) -> Option { + content_type.split(';').find_map(|segment| { + let (key, value) = segment.trim().split_once('=')?; + if !key.trim().eq_ignore_ascii_case("boundary") { + return None; + } + let boundary = value.trim().trim_matches('"').trim(); + (!boundary.is_empty()).then(|| boundary.to_string()) + }) +} + fn parse_multipart_field(raw: &[u8]) -> Option { let header_end = find_subslice(raw, b"\r\n\r\n")?; let headers = &raw[..header_end]; @@ -1035,6 +1042,44 @@ mod tests { ); } + #[test] + fn normalize_edit_multipart_request_accepts_mixed_case_boundary() { + let boundary = "------------------------OYNWsMZCt0ILTwn8naP4Gb"; + let body = format!( + concat!( + "--{boundary}\r\n", + "Content-Disposition: form-data; name=\"model\"\r\n\r\n", + "gpt-image-2\r\n", + "--{boundary}\r\n", + "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n", + "edit this image\r\n", + "--{boundary}\r\n", + "Content-Disposition: form-data; name=\"image\"; filename=\"image.jpg\"\r\n", + "Content-Type: image/jpeg\r\n\r\n", + "image-bytes\r\n", + "--{boundary}--\r\n" + ), + boundary = boundary, + ); + let body_base64 = base64::engine::general_purpose::STANDARD.encode(body.as_bytes()); + let parts = request_parts( + "/v1/images/edits", + Some(&format!("multipart/form-data; boundary={boundary}")), + ); + + let request = normalize_openai_image_request(&parts, &json!({}), Some(&body_base64)) + .expect("edit request should normalize"); + + assert_eq!(request.operation, OpenAiImageOperation::Edit); + assert_eq!(request.requested_model.as_deref(), Some("gpt-image-2")); + assert_eq!(request.prompt.as_deref(), Some("edit this image")); + assert_eq!(request.images.len(), 1); + assert_eq!( + request.tool.get("action").and_then(|value| value.as_str()), + Some("edit") + ); + } + #[test] fn normalize_edit_json_request_maps_mask_to_input_image_mask() { let parts = request_parts("/v1/images/edits", Some("application/json")); diff --git a/apps/aether-gateway/src/handlers/public/ai_public.rs b/apps/aether-gateway/src/handlers/public/ai_public.rs index b14881f2..caa7521c 100644 --- a/apps/aether-gateway/src/handlers/public/ai_public.rs +++ b/apps/aether-gateway/src/handlers/public/ai_public.rs @@ -318,9 +318,12 @@ fn parse_openai_image_validation_input( }); } - let content_type = content_type.unwrap_or_default().to_ascii_lowercase(); - if content_type.contains("multipart/form-data") { - parse_openai_image_validation_input_from_multipart(request_body, &content_type) + let content_type = content_type.unwrap_or_default(); + if content_type + .to_ascii_lowercase() + .contains("multipart/form-data") + { + parse_openai_image_validation_input_from_multipart(request_body, content_type) } else { parse_openai_image_validation_input_from_json(request_body) } @@ -403,11 +406,7 @@ fn parse_openai_image_validation_input_from_multipart( request_body: &Bytes, content_type: &str, ) -> Result { - let boundary = content_type - .split(';') - .find_map(|segment| segment.trim().strip_prefix("boundary=")) - .map(|value| value.trim_matches('"').to_string()) - .ok_or(OPENAI_IMAGE_INVALID_MULTIPART_DETAIL)?; + let boundary = multipart_boundary(content_type).ok_or(OPENAI_IMAGE_INVALID_MULTIPART_DETAIL)?; let fields = parse_multipart_fields(request_body, &boundary); if fields.is_empty() { return Err(OPENAI_IMAGE_INVALID_MULTIPART_DETAIL); @@ -536,6 +535,17 @@ fn parse_multipart_fields(body: &[u8], boundary: &str) -> Vec { parts } +fn multipart_boundary(content_type: &str) -> Option { + content_type.split(';').find_map(|segment| { + let (key, value) = segment.trim().split_once('=')?; + if !key.trim().eq_ignore_ascii_case("boundary") { + return None; + } + let boundary = value.trim().trim_matches('"').trim(); + (!boundary.is_empty()).then(|| boundary.to_string()) + }) +} + fn parse_multipart_field(raw: &[u8]) -> Option { let header_end = find_subslice(raw, b"\r\n\r\n")?; let headers = &raw[..header_end]; @@ -1081,4 +1091,36 @@ mod tests { assert_eq!(validation.model.as_deref(), Some("Custom/Image-Model:V1")); } + + #[test] + fn image_validation_accepts_multipart_with_mixed_case_boundary() { + let boundary = "------------------------OYNWsMZCt0ILTwn8naP4Gb"; + let body = Bytes::from(format!( + concat!( + "--{boundary}\r\n", + "Content-Disposition: form-data; name=\"model\"\r\n\r\n", + "gpt-image-2\r\n", + "--{boundary}\r\n", + "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n", + "edit this image\r\n", + "--{boundary}\r\n", + "Content-Disposition: form-data; name=\"image\"; filename=\"image.jpg\"\r\n", + "Content-Type: image/jpeg\r\n\r\n", + "image-bytes\r\n", + "--{boundary}--\r\n" + ), + boundary = boundary, + )); + + let validation = parse_openai_image_validation_input( + OpenAiImageOperation::Edit, + Some(&format!("multipart/form-data; boundary={boundary}")), + &body, + ) + .expect("multipart image edit should validate"); + + assert_eq!(validation.model.as_deref(), Some("gpt-image-2")); + assert_eq!(validation.prompt.as_deref(), Some("edit this image")); + assert_eq!(validation.image_count, 1); + } }