diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs index fe2d4125f..43124676e 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs @@ -1314,21 +1314,19 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts( .as_object_mut()? .insert("stream".to_string(), Value::Bool(true)); } - provider_request_body = project_openai_image_api_request_body( - &provider_request_body, - &prepared_candidate.mapped_model, - converted.operation, - crate::image_capabilities::openai_image_provider_max_generation_count_for_model( - transport.provider.provider_type.as_str(), - Some(prepared_candidate.mapped_model.as_str()), - ), - )?; - if is_codex { - provider_request_body = project_codex_openai_image_api_request_body( + provider_request_body = if is_codex { + project_codex_openai_image_api_request_body(&provider_request_body, converted.operation)? + } else { + project_openai_image_api_request_body( &provider_request_body, + &prepared_candidate.mapped_model, converted.operation, - )?; - } + crate::image_capabilities::openai_image_provider_max_generation_count_for_model( + transport.provider.provider_type.as_str(), + Some(prepared_candidate.mapped_model.as_str()), + ), + )? + }; let request_path = match converted.operation { OpenAiImageOperation::Generate => "/v1/images/generations", OpenAiImageOperation::Edit => "/v1/images/edits", diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs index 53106406f..e6c4dc14c 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs @@ -1417,38 +1417,20 @@ async fn resolve_openai_chat_to_openai_image_payload_parts( return Ok(None); }; if !is_chatgpt_web { - let Some(projected) = project_openai_image_api_request_body( - &provider_request_body, - &prepared_candidate.mapped_model, - operation, - crate::image_capabilities::openai_image_provider_max_generation_count_for_model( - transport.provider.provider_type.as_str(), - Some(prepared_candidate.mapped_model.as_str()), - ), - ) else { - mark_skipped_local_openai_chat_candidate_with_extra_data( - state, - input, - trace_id, - candidate, - candidate_index, - candidate_id, - "provider_request_body_build_failed", - request_body_build_failure_extra_data( - body_json, - "openai:chat", - provider_api_format, + let projected = if is_codex { + project_codex_openai_image_api_request_body(&provider_request_body, operation) + } else { + project_openai_image_api_request_body( + &provider_request_body, + &prepared_candidate.mapped_model, + operation, + crate::image_capabilities::openai_image_provider_max_generation_count_for_model( + transport.provider.provider_type.as_str(), + Some(prepared_candidate.mapped_model.as_str()), ), ) - .await; - return Ok(None); }; - provider_request_body = projected; - } - if is_codex { - let Some(projected) = - project_codex_openai_image_api_request_body(&provider_request_body, operation) - else { + let Some(projected) = projected else { mark_skipped_local_openai_chat_candidate_with_extra_data( state, input, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs index 6656feca5..30ab4b12f 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs @@ -1395,7 +1395,10 @@ async fn resolve_openai_responses_to_openai_image_payload_parts( return None; }; let operation = openai_image_operation_from_summary(&image_request_summary)?; - if !is_chatgpt_web { + if is_codex { + provider_request_body = + project_codex_openai_image_api_request_body(&provider_request_body, operation)?; + } else if !is_chatgpt_web { provider_request_body = project_openai_image_api_request_body( &provider_request_body, &prepared_candidate.mapped_model, @@ -1406,10 +1409,6 @@ async fn resolve_openai_responses_to_openai_image_payload_parts( ), )?; } - if is_codex { - provider_request_body = - project_codex_openai_image_api_request_body(&provider_request_body, operation)?; - } let upstream_url = if is_chatgpt_web { chatgpt_web_image_internal_url(&transport.endpoint.base_url) diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/image.rs b/apps/aether-gateway/src/tests/ai_execute/sync/image.rs index cd65a8a9b..9f7d917e1 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/image.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/image.rs @@ -412,7 +412,8 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() { authorization: String, model: String, prompt: String, - image_url: String, + images: serde_json::Value, + has_legacy_image_field: bool, request_stream: bool, body_stream: Option, } @@ -605,12 +606,8 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() { .and_then(|value| value.as_str()) .unwrap_or_default() .to_string(), - image_url: body_json - .get("image") - .and_then(|value| value.get("image_url")) - .and_then(|value| value.as_str()) - .unwrap_or_default() - .to_string(), + images: body_json.get("images").cloned().unwrap_or_default(), + has_legacy_image_field: body_json.get("image").is_some(), request_stream: payload .get("stream") .and_then(|value| value.as_bool()) @@ -735,9 +732,10 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() { "Change the background" ); assert_eq!( - seen_execution_runtime_request.image_url, - "data:image/png;base64,aGVsbG8=" + seen_execution_runtime_request.images, + json!([{"image_url": "data:image/png;base64,aGVsbG8="}]) ); + assert!(!seen_execution_runtime_request.has_legacy_image_field); assert!(!seen_execution_runtime_request.request_stream); assert_eq!(seen_execution_runtime_request.body_stream, None); 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 923784aa7..a28050af5 100644 --- a/crates/aether-ai/formats/src/formats/openai/image/request.rs +++ b/crates/aether-ai/formats/src/formats/openai/image/request.rs @@ -543,11 +543,54 @@ pub fn build_openai_image_api_provider_request_body( mapped_model: Option<&str>, upstream_is_stream: bool, ) -> Option { - let model = mapped_model + let model = resolve_openai_image_api_provider_model(request, mapped_model); + let body = build_unprojected_openai_image_api_provider_request_body( + request, + model, + upstream_is_stream, + ); + project_openai_image_api_request_body( + &body, + model, + request.operation, + request.max_generation_count, + ) +} + +pub fn build_codex_openai_image_api_provider_request_body( + request: &NormalizedOpenAiImageRequest, + mapped_model: Option<&str>, + upstream_is_stream: bool, +) -> Option { + let model = resolve_openai_image_api_provider_model(request, mapped_model); + let body = build_unprojected_openai_image_api_provider_request_body( + request, + model, + upstream_is_stream, + ); + project_codex_openai_image_api_request_body_with_max_generation_count( + &body, + request.operation, + request.max_generation_count, + ) +} + +fn resolve_openai_image_api_provider_model<'a>( + request: &'a NormalizedOpenAiImageRequest, + mapped_model: Option<&'a str>, +) -> &'a str { + mapped_model .map(str::trim) .filter(|value| !value.is_empty()) .or(request.requested_model.as_deref()) - .unwrap_or_else(|| default_model_for_openai_image_operation(request.operation)); + .unwrap_or_else(|| default_model_for_openai_image_operation(request.operation)) +} + +fn build_unprojected_openai_image_api_provider_request_body( + request: &NormalizedOpenAiImageRequest, + model: &str, + upstream_is_stream: bool, +) -> Value { let mut body = Map::new(); body.insert("model".to_string(), Value::String(model.to_string())); if let Some(prompt) = request.prompt.as_ref() { @@ -578,22 +621,7 @@ pub fn build_openai_image_api_provider_request_body( .or_insert_with(|| response_format.clone()); } insert_standard_openai_image_inputs(&mut body, request.images.clone()); - project_openai_image_api_request_body( - &Value::Object(body), - model, - request.operation, - request.max_generation_count, - ) -} - -pub fn build_codex_openai_image_api_provider_request_body( - request: &NormalizedOpenAiImageRequest, - mapped_model: Option<&str>, - upstream_is_stream: bool, -) -> Option { - let body = - build_openai_image_api_provider_request_body(request, mapped_model, upstream_is_stream)?; - project_codex_openai_image_api_request_body(&body, request.operation) + Value::Object(body) } pub fn project_openai_image_api_request_body( @@ -981,13 +1009,31 @@ pub fn project_codex_openai_image_api_request_body( body: &Value, operation: OpenAiImageOperation, ) -> Option { - let model = body.get("model").and_then(Value::as_str)?; - let projected_body = project_openai_image_api_request_body( + project_codex_openai_image_api_request_body_with_max_generation_count( body, - model, operation, OPENAI_IMAGE_MAX_GENERATION_COUNT, - )?; + ) +} + +fn project_codex_openai_image_api_request_body_with_max_generation_count( + body: &Value, + operation: OpenAiImageOperation, + max_generation_count: u64, +) -> Option { + let source_object = body.as_object()?; + let source_images = if operation == OpenAiImageOperation::Edit { + let images = collect_codex_openai_image_urls(source_object)?; + if images.is_empty() || images.len() > 5 { + return None; + } + images + } else { + Vec::new() + }; + let model = body.get("model").and_then(Value::as_str)?; + let projected_body = + project_openai_image_api_request_body(body, model, operation, max_generation_count)?; let object = projected_body.as_object()?; if object.keys().any(|key| { !matches!( @@ -1023,11 +1069,7 @@ pub fn project_codex_openai_image_api_request_body( let mut projected = Map::new(); if operation == OpenAiImageOperation::Edit { - let images = collect_codex_openai_image_urls(object)?; - if images.is_empty() || images.len() > 5 { - return None; - } - projected.insert("images".to_string(), Value::Array(images)); + projected.insert("images".to_string(), Value::Array(source_images)); } else if object.contains_key("image") || object.contains_key("images") { return None; } @@ -2071,6 +2113,62 @@ mod tests { } } + #[test] + fn codex_image_projection_rejects_invalid_source_image_parts() { + for body in [ + json!({ + "model": "gpt-image-2", + "prompt": "edit image", + "image": { + "type": "wrong_type", + "image_url": "https://example.test/input.png" + } + }), + json!({ + "model": "gpt-image-2", + "prompt": "edit image", + "images": [{ + "type": "input_image", + "image_url": "https://example.test/input.png", + "detail": "high" + }] + }), + json!({ + "model": "gpt-image-2", + "prompt": "edit image", + "images": [{"file_id": "file_123"}] + }), + ] { + assert!( + project_codex_openai_image_api_request_body(&body, OpenAiImageOperation::Edit,) + .is_none(), + "invalid Codex image part should be rejected: {body}" + ); + } + } + + #[test] + fn codex_image_projection_accepts_untyped_plural_image_urls() { + let body = json!({ + "model": "gpt-image-2", + "prompt": "edit image", + "images": [{ + "image_url": "https://example.test/input.png" + }] + }); + + assert_eq!( + project_codex_openai_image_api_request_body(&body, OpenAiImageOperation::Edit), + Some(json!({ + "images": [{ + "image_url": "https://example.test/input.png" + }], + "prompt": "edit image", + "model": "gpt-image-2" + })) + ); + } + #[test] fn codex_image_projection_enforces_the_openai_output_count_range() { let valid = json!({ @@ -2380,6 +2478,31 @@ mod tests { assert!(provider_request_body.get("image").is_none()); } + #[test] + fn build_image_api_provider_edit_request_preserves_file_id() { + let parts = request_parts("/v1/images/edits", Some("application/json")); + let request = normalize_openai_image_request( + &parts, + &json!({ + "model": "gpt-image-2", + "prompt": "edit the uploaded image", + "image": {"file_id": "file_123"} + }), + None, + ) + .expect("file ID edit request should normalize"); + + let provider_request_body = + build_openai_image_api_provider_request_body(&request, None, false) + .expect("standard Images edit body should project"); + + assert_eq!( + provider_request_body["images"], + json!([{"file_id": "file_123"}]) + ); + assert!(provider_request_body.get("image").is_none()); + } + #[test] fn chatgpt_web_accepts_1k_tier_and_1024_size() { let parts = request_parts("/v1/images/generations", Some("application/json"));