From 82040bfc21f7e9e0f5affb4410761744bd117320 Mon Sep 17 00:00:00 2001 From: zhefox Date: Mon, 8 Jun 2026 13:22:08 +0800 Subject: [PATCH] fix(gateway): support OpenAI image edit requests --- .../execution_runtime/chatgpt_web_image.rs | 48 +++++++++++- .../src/formats/openai/image/request.rs | 75 +++++++++++++++++++ .../src/openai_image/mod.rs | 11 +++ 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs b/apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs index cf78ba00e..e439c0c9a 100644 --- a/apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs +++ b/apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs @@ -187,6 +187,7 @@ async fn execute_chatgpt_web_image( request_id = %plan.request_id, candidate_id = ?plan.candidate_id, base_url = %base_url, + operation = %request.operation, image_count = request.images.len(), size = %request.size, ratio = %request.ratio, @@ -311,6 +312,7 @@ async fn execute_chatgpt_web_image( #[derive(Debug, Clone)] struct ChatGptWebImageRequest { + operation: String, model: String, web_model: String, prompt: String, @@ -342,6 +344,7 @@ impl ChatGptWebImageRequest { .map(ToOwned::to_owned) .collect::>(); Ok(Self { + operation: chatgpt_web_image_operation(body.get("operation")), 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()), prompt: text("prompt").unwrap_or_else(|| "Generate a high quality image.".to_string()), @@ -2434,10 +2437,23 @@ fn json_u64(value: Option<&Value>) -> Option { }) } +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 { let failed = if failure.get("type").and_then(Value::as_str) == Some("response.failed") { failure.clone() } else { + let operation = match request.operation.as_str() { + "edit" => "edit", + _ => "generation", + }; json!({ "type": "response.failed", "response": { @@ -2445,7 +2461,7 @@ fn build_failed_sse(request: &ChatGptWebImageRequest, failure: &Value) -> String "model": request.model, "error": failure.get("error").cloned().unwrap_or_else(|| json!({ "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 { let mut image_request = Map::new(); image_request.insert( "operation".to_string(), - Value::String("generate".to_string()), + Value::String(chatgpt_web_image_operation(body.get("operation"))), ); for key in [ "model", @@ -3223,6 +3239,7 @@ mod tests { #[test] fn chatgpt_web_success_sse_includes_estimated_image_usage() { let request = ChatGptWebImageRequest { + operation: "generate".to_string(), model: "gpt-image-2".to_string(), web_model: "gpt-5-5-thinking".to_string(), prompt: "draw a test image".to_string(), @@ -3276,6 +3293,7 @@ mod tests { #[test] fn chatgpt_web_success_sse_uses_image_dimensions_not_output_text() { let request = ChatGptWebImageRequest { + operation: "generate".to_string(), model: "gpt-image-2".to_string(), web_model: "gpt-5-5-thinking".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] fn chatgpt_web_image_quota_refresh_plan_uses_conversation_init() { let plan = sample_plan( diff --git a/crates/aether-ai-formats/src/formats/openai/image/request.rs b/crates/aether-ai-formats/src/formats/openai/image/request.rs index b83f76705..d68b0ac06 100644 --- a/crates/aether-ai-formats/src/formats/openai/image/request.rs +++ b/crates/aether-ai-formats/src/formats/openai/image/request.rs @@ -1577,6 +1577,47 @@ mod tests { 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] fn chatgpt_web_accepts_1k_tier_and_1024_size() { let parts = request_parts("/v1/images/generations", Some("application/json")); @@ -1630,6 +1671,40 @@ mod tests { 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] fn chatgpt_web_rejects_oversized_resolution_or_size() { let parts = request_parts("/v1/images/generations", Some("application/json")); diff --git a/crates/aether-provider-transport/src/openai_image/mod.rs b/crates/aether-provider-transport/src/openai_image/mod.rs index a9002d979..2df86a71b 100644 --- a/crates/aether-provider-transport/src/openai_image/mod.rs +++ b/crates/aether-provider-transport/src/openai_image/mod.rs @@ -189,6 +189,17 @@ mod tests { 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] fn chatgpt_web_is_supported_by_dedicated_openai_image_transport_policy() { let mut transport = sample_transport();