fix(gateway): support OpenAI image edit requests

This commit is contained in:
zhefox
2026-06-08 13:22:08 +08:00
parent ebd59246a8
commit 82040bfc21
3 changed files with 132 additions and 2 deletions
@@ -187,6 +187,7 @@ async fn execute_chatgpt_web_image(
request_id = %plan.request_id, request_id = %plan.request_id,
candidate_id = ?plan.candidate_id, candidate_id = ?plan.candidate_id,
base_url = %base_url, base_url = %base_url,
operation = %request.operation,
image_count = request.images.len(), image_count = request.images.len(),
size = %request.size, size = %request.size,
ratio = %request.ratio, ratio = %request.ratio,
@@ -311,6 +312,7 @@ async fn execute_chatgpt_web_image(
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct ChatGptWebImageRequest { struct ChatGptWebImageRequest {
operation: String,
model: String, model: String,
web_model: String, web_model: String,
prompt: String, prompt: String,
@@ -342,6 +344,7 @@ impl ChatGptWebImageRequest {
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
Ok(Self { Ok(Self {
operation: chatgpt_web_image_operation(body.get("operation")),
model: text("model").unwrap_or_else(|| "gpt-image-2".to_string()), model: text("model").unwrap_or_else(|| "gpt-image-2".to_string()),
web_model: text("web_model").unwrap_or_else(|| "gpt-5-5-thinking".to_string()), web_model: text("web_model").unwrap_or_else(|| "gpt-5-5-thinking".to_string()),
prompt: text("prompt").unwrap_or_else(|| "Generate a high quality image.".to_string()), prompt: text("prompt").unwrap_or_else(|| "Generate a high quality image.".to_string()),
@@ -2434,10 +2437,23 @@ fn json_u64(value: Option<&Value>) -> Option<u64> {
}) })
} }
fn chatgpt_web_image_operation(value: Option<&Value>) -> String {
value
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.filter(|value| matches!(value.as_str(), "generate" | "edit"))
.unwrap_or_else(|| "generate".to_string())
}
fn build_failed_sse(request: &ChatGptWebImageRequest, failure: &Value) -> String { fn build_failed_sse(request: &ChatGptWebImageRequest, failure: &Value) -> String {
let failed = if failure.get("type").and_then(Value::as_str) == Some("response.failed") { let failed = if failure.get("type").and_then(Value::as_str) == Some("response.failed") {
failure.clone() failure.clone()
} else { } else {
let operation = match request.operation.as_str() {
"edit" => "edit",
_ => "generation",
};
json!({ json!({
"type": "response.failed", "type": "response.failed",
"response": { "response": {
@@ -2445,7 +2461,7 @@ fn build_failed_sse(request: &ChatGptWebImageRequest, failure: &Value) -> String
"model": request.model, "model": request.model,
"error": failure.get("error").cloned().unwrap_or_else(|| json!({ "error": failure.get("error").cloned().unwrap_or_else(|| json!({
"code": "chatgpt_web_image_failed", "code": "chatgpt_web_image_failed",
"message": "ChatGPT-Web image generation failed" "message": format!("ChatGPT-Web image {operation} failed")
})) }))
} }
}) })
@@ -2641,7 +2657,7 @@ fn chatgpt_web_image_request_context(plan: &ExecutionPlan) -> Option<Value> {
let mut image_request = Map::new(); let mut image_request = Map::new();
image_request.insert( image_request.insert(
"operation".to_string(), "operation".to_string(),
Value::String("generate".to_string()), Value::String(chatgpt_web_image_operation(body.get("operation"))),
); );
for key in [ for key in [
"model", "model",
@@ -3223,6 +3239,7 @@ mod tests {
#[test] #[test]
fn chatgpt_web_success_sse_includes_estimated_image_usage() { fn chatgpt_web_success_sse_includes_estimated_image_usage() {
let request = ChatGptWebImageRequest { let request = ChatGptWebImageRequest {
operation: "generate".to_string(),
model: "gpt-image-2".to_string(), model: "gpt-image-2".to_string(),
web_model: "gpt-5-5-thinking".to_string(), web_model: "gpt-5-5-thinking".to_string(),
prompt: "draw a test image".to_string(), prompt: "draw a test image".to_string(),
@@ -3276,6 +3293,7 @@ mod tests {
#[test] #[test]
fn chatgpt_web_success_sse_uses_image_dimensions_not_output_text() { fn chatgpt_web_success_sse_uses_image_dimensions_not_output_text() {
let request = ChatGptWebImageRequest { let request = ChatGptWebImageRequest {
operation: "generate".to_string(),
model: "gpt-image-2".to_string(), model: "gpt-image-2".to_string(),
web_model: "gpt-5-5-thinking".to_string(), web_model: "gpt-5-5-thinking".to_string(),
prompt: "draw a test image".to_string(), prompt: "draw a test image".to_string(),
@@ -3334,6 +3352,32 @@ mod tests {
); );
} }
#[test]
fn chatgpt_web_image_request_context_preserves_edit_operation() {
let plan = sample_plan(
CHATGPT_WEB_DEFAULT_BASE_URL,
json!({
"operation": "edit",
"model": "gpt-image-2",
"web_model": "gpt-5-5-thinking",
"prompt": "adjust this image",
"size": "512x512",
"ratio": "1:1",
"images": ["data:image/png;base64,aW1hZ2U="],
"count": 1,
"output_format": "png"
}),
true,
);
let context = chatgpt_web_stream_observer_context(&plan, None);
assert_eq!(context["image_request"]["operation"], json!("edit"));
assert_eq!(context["image_request"]["model"], json!("gpt-image-2"));
assert_eq!(context["image_request"]["size"], json!("512x512"));
assert_eq!(context["provider_api_format"], json!("openai:image"));
}
#[test] #[test]
fn chatgpt_web_image_quota_refresh_plan_uses_conversation_init() { fn chatgpt_web_image_quota_refresh_plan_uses_conversation_init() {
let plan = sample_plan( let plan = sample_plan(
@@ -1577,6 +1577,47 @@ mod tests {
assert!(provider_request_body.get("tools").is_none()); assert!(provider_request_body.get("tools").is_none());
} }
#[test]
fn build_image_api_provider_edit_request_body_keeps_images_edit_shape() {
let parts = request_parts("/v1/images/edits", Some("application/json"));
let request = normalize_openai_image_request(
&parts,
&json!({
"model": "gpt-image-2",
"prompt": "replace the background",
"image": "data:image/png;base64,aW1hZ2U=",
"mask": "data:image/png;base64,bWFzaw==",
"input_fidelity": "high",
"output_format": "png",
"response_format": "url",
"user": "user-123"
}),
None,
)
.expect("edit request should normalize");
let provider_request_body =
build_openai_image_api_provider_request_body(&request, Some("mapped-edit-model"));
assert_eq!(provider_request_body["model"], "mapped-edit-model");
assert_eq!(provider_request_body["prompt"], "replace the background");
assert_eq!(provider_request_body["input_fidelity"], "high");
assert_eq!(provider_request_body["output_format"], "png");
assert_eq!(provider_request_body["response_format"], "url");
assert_eq!(provider_request_body["user"], "user-123");
assert_eq!(
provider_request_body["image"]["image_url"],
"data:image/png;base64,aW1hZ2U="
);
assert_eq!(
provider_request_body["mask"]["image_url"],
"data:image/png;base64,bWFzaw=="
);
assert!(provider_request_body.get("input").is_none());
assert!(provider_request_body.get("tools").is_none());
assert!(provider_request_body.get("action").is_none());
}
#[test] #[test]
fn chatgpt_web_accepts_1k_tier_and_1024_size() { fn chatgpt_web_accepts_1k_tier_and_1024_size() {
let parts = request_parts("/v1/images/generations", Some("application/json")); let parts = request_parts("/v1/images/generations", Some("application/json"));
@@ -1630,6 +1671,40 @@ mod tests {
assert_eq!(body["output_format"], "png"); assert_eq!(body["output_format"], "png");
} }
#[test]
fn chatgpt_web_accepts_openai_image_edit_requests() {
let parts = request_parts("/v1/images/edits", Some("application/json"));
let body = build_chatgpt_web_image_request_body(
&parts,
&json!({
"model": "gpt-image-2",
"prompt": "make the sky brighter",
"image": {
"b64_json": "aW1hZ2U=",
"mime_type": "image/png"
},
"size": "1024x1024",
"quality": "high",
"response_format": "b64_json",
"output_format": "png",
"user": "user-123"
}),
None,
)
.expect("ChatGPT-Web edit request should pass");
assert_eq!(body["operation"], "edit");
assert_eq!(body["model"], "gpt-image-2");
assert_eq!(body["prompt"], "make the sky brighter");
assert_eq!(body["size"], "1024x1024");
assert_eq!(body["quality"], "high");
assert_eq!(body["response_format"], "b64_json");
assert_eq!(body["output_format"], "png");
assert_eq!(body["user"], "user-123");
assert_eq!(body["count"], 1);
assert_eq!(body["images"], json!(["data:image/png;base64,aW1hZ2U="]));
}
#[test] #[test]
fn chatgpt_web_rejects_oversized_resolution_or_size() { fn chatgpt_web_rejects_oversized_resolution_or_size() {
let parts = request_parts("/v1/images/generations", Some("application/json")); let parts = request_parts("/v1/images/generations", Some("application/json"));
@@ -189,6 +189,17 @@ mod tests {
assert_eq!(url, "https://api.openai.com/v1/images/generations?trace=1"); assert_eq!(url, "https://api.openai.com/v1/images/generations?trace=1");
} }
#[test]
fn standard_openai_image_url_preserves_edit_surface() {
let mut transport = sample_transport();
transport.provider.provider_type = "openai".to_string();
let url =
build_openai_image_upstream_url(&transport, Some("/v1/images/edits"), Some("trace=1"));
assert_eq!(url, "https://api.openai.com/v1/images/edits?trace=1");
}
#[test] #[test]
fn chatgpt_web_is_supported_by_dedicated_openai_image_transport_policy() { fn chatgpt_web_is_supported_by_dedicated_openai_image_transport_policy() {
let mut transport = sample_transport(); let mut transport = sample_transport();