mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix(gateway): 兼容 OpenAI 图像 multipart boundary 大小写 (#331)
This commit is contained in:
@@ -866,11 +866,7 @@ fn parse_multipart_fields_from_base64(
|
|||||||
.headers
|
.headers
|
||||||
.get(http::header::CONTENT_TYPE)
|
.get(http::header::CONTENT_TYPE)
|
||||||
.and_then(|value| value.to_str().ok())?;
|
.and_then(|value| value.to_str().ok())?;
|
||||||
let boundary = content_type
|
let boundary = multipart_boundary(content_type)?;
|
||||||
.split(';')
|
|
||||||
.find_map(|segment| segment.trim().strip_prefix("boundary="))?
|
|
||||||
.trim_matches('"')
|
|
||||||
.to_string();
|
|
||||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
let body_bytes = base64::engine::general_purpose::STANDARD
|
||||||
.decode(body_base64)
|
.decode(body_base64)
|
||||||
.ok()?;
|
.ok()?;
|
||||||
@@ -905,6 +901,17 @@ fn parse_multipart_fields(body: &[u8], boundary: &str) -> Vec<MultipartField> {
|
|||||||
parts
|
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> {
|
fn parse_multipart_field(raw: &[u8]) -> Option<MultipartField> {
|
||||||
let header_end = find_subslice(raw, b"\r\n\r\n")?;
|
let header_end = find_subslice(raw, b"\r\n\r\n")?;
|
||||||
let headers = &raw[..header_end];
|
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]
|
#[test]
|
||||||
fn normalize_edit_json_request_maps_mask_to_input_image_mask() {
|
fn normalize_edit_json_request_maps_mask_to_input_image_mask() {
|
||||||
let parts = request_parts("/v1/images/edits", Some("application/json"));
|
let parts = request_parts("/v1/images/edits", Some("application/json"));
|
||||||
|
|||||||
@@ -318,9 +318,12 @@ fn parse_openai_image_validation_input(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let content_type = content_type.unwrap_or_default().to_ascii_lowercase();
|
let content_type = content_type.unwrap_or_default();
|
||||||
if content_type.contains("multipart/form-data") {
|
if content_type
|
||||||
parse_openai_image_validation_input_from_multipart(request_body, &content_type)
|
.to_ascii_lowercase()
|
||||||
|
.contains("multipart/form-data")
|
||||||
|
{
|
||||||
|
parse_openai_image_validation_input_from_multipart(request_body, content_type)
|
||||||
} else {
|
} else {
|
||||||
parse_openai_image_validation_input_from_json(request_body)
|
parse_openai_image_validation_input_from_json(request_body)
|
||||||
}
|
}
|
||||||
@@ -403,11 +406,7 @@ fn parse_openai_image_validation_input_from_multipart(
|
|||||||
request_body: &Bytes,
|
request_body: &Bytes,
|
||||||
content_type: &str,
|
content_type: &str,
|
||||||
) -> Result<OpenAiImageValidationInput, &'static str> {
|
) -> Result<OpenAiImageValidationInput, &'static str> {
|
||||||
let boundary = content_type
|
let boundary = multipart_boundary(content_type).ok_or(OPENAI_IMAGE_INVALID_MULTIPART_DETAIL)?;
|
||||||
.split(';')
|
|
||||||
.find_map(|segment| segment.trim().strip_prefix("boundary="))
|
|
||||||
.map(|value| value.trim_matches('"').to_string())
|
|
||||||
.ok_or(OPENAI_IMAGE_INVALID_MULTIPART_DETAIL)?;
|
|
||||||
let fields = parse_multipart_fields(request_body, &boundary);
|
let fields = parse_multipart_fields(request_body, &boundary);
|
||||||
if fields.is_empty() {
|
if fields.is_empty() {
|
||||||
return Err(OPENAI_IMAGE_INVALID_MULTIPART_DETAIL);
|
return Err(OPENAI_IMAGE_INVALID_MULTIPART_DETAIL);
|
||||||
@@ -536,6 +535,17 @@ fn parse_multipart_fields(body: &[u8], boundary: &str) -> Vec<MultipartField> {
|
|||||||
parts
|
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> {
|
fn parse_multipart_field(raw: &[u8]) -> Option<MultipartField> {
|
||||||
let header_end = find_subslice(raw, b"\r\n\r\n")?;
|
let header_end = find_subslice(raw, b"\r\n\r\n")?;
|
||||||
let headers = &raw[..header_end];
|
let headers = &raw[..header_end];
|
||||||
@@ -1081,4 +1091,36 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(validation.model.as_deref(), Some("Custom/Image-Model:V1"));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user