fix(gateway): 兼容 OpenAI 图像 multipart boundary 大小写 (#331)

This commit is contained in:
Entropy.Xu
2026-04-24 21:41:20 +08:00
committed by GitHub
parent db1059e73b
commit 67b092253d
2 changed files with 100 additions and 13 deletions

View File

@@ -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<MultipartField> {
parts
}
fn multipart_boundary(content_type: &str) -> Option<String> {
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<MultipartField> {
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"));

View File

@@ -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<OpenAiImageValidationInput, &'static str> {
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<MultipartField> {
parts
}
fn multipart_boundary(content_type: &str) -> Option<String> {
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<MultipartField> {
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);
}
}