diff --git a/server-rs/crates/api-server/src/raw_image.rs b/server-rs/crates/api-server/src/raw_image.rs index f26294861..c083a7fd7 100644 --- a/server-rs/crates/api-server/src/raw_image.rs +++ b/server-rs/crates/api-server/src/raw_image.rs @@ -14,9 +14,7 @@ use crate::{ }, auth::AuthenticatedAccessToken, http_error::AppError, - openai_image_generation::{ - build_openai_image_http_client, map_platform_image_error, require_openai_image_settings, - }, + openai_image_generation::{map_platform_image_error, require_openai_image_settings}, request_context::RequestContext, state::AppState, }; @@ -63,14 +61,12 @@ pub(crate) async fn edit_raw_image( Some(authenticated.claims().user_id().to_string()), None, ); - let http_client = build_openai_image_http_client(&settings)?; let provider_settings = settings.provider_settings(); let user_id = authenticated.claims().user_id().to_string(); let request_id = request_context.request_id().to_string(); let points_cost = raw_image_edit_price(&state, prepared.width, prepared.height).await?; let operation = async move { let generated = create_vector_engine_raw_image_edit( - &http_client, &provider_settings, prepared.prompt.as_str(), &prepared.image, diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs index f81a4613e..24b10196a 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -15,7 +15,7 @@ use super::{ }, curl_transport::{ map_curl_error, send_vector_engine_json_request_with_curl, - send_vector_engine_multipart_edit_request_with_curl_options, + send_vector_engine_multipart_edit_request_with_curl, }, error::PlatformImageError, image_source::resolve_reference_images, @@ -28,7 +28,7 @@ use super::{ vector_engine_nanobanana_generate_content_url, }, response::handle_vector_engine_response, - types::{GeneratedImages, RawImageEditOptions, ReferenceImage, VectorEngineImageSettings}, + types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings}, util::truncate_raw, }; @@ -537,34 +537,6 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( candidate_count: u32, reference_images: &[ReferenceImage], failure_context: &str, -) -> Result { - create_vector_engine_image_edit_with_references_and_model_and_options( - http_client, - settings, - model, - prompt, - negative_prompt, - size, - candidate_count, - reference_images, - None, - failure_context, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -pub async fn create_vector_engine_image_edit_with_references_and_model_and_options( - http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, - model: &str, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - candidate_count: u32, - reference_images: &[ReferenceImage], - options: Option<&RawImageEditOptions>, - failure_context: &str, ) -> Result { let requested_model = normalize_vector_engine_image_model(model); if reference_images.is_empty() { @@ -639,7 +611,7 @@ pub async fn create_vector_engine_image_edit_with_references_and_model_and_optio &mut recovered_failure_audits, )); }; - let response = send_vector_engine_multipart_edit_request_with_curl_options( + let response = match send_vector_engine_multipart_edit_request_with_curl( request_url.as_str(), settings.api_key.as_str(), upstream_model, @@ -648,11 +620,10 @@ pub async fn create_vector_engine_image_edit_with_references_and_model_and_optio normalized_size.as_str(), candidate_count, reference_images, - options, attempt_timeout_ms, ) - .await; - let response = match response { + .await + { Ok(response) => { if should_retry_vector_engine_upstream_response( response.status, diff --git a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs index 59ab22df2..fbe94e1b1 100644 --- a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs +++ b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs @@ -7,11 +7,8 @@ use curl::{ use serde_json::Value; use super::{ - audit::build_failure_audit, - constants::VECTOR_ENGINE_PROVIDER, - error::PlatformImageError, - request::build_prompt_with_negative, - types::{RawImageEditOptions, ReferenceImage}, + audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, + request::build_prompt_with_negative, types::ReferenceImage, }; #[derive(Debug)] @@ -112,7 +109,7 @@ pub(crate) async fn send_vector_engine_json_request_with_curl( } #[allow(clippy::too_many_arguments)] -pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl_options( +pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( request_url: &str, api_key: &str, model: &str, @@ -121,7 +118,6 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl_options( normalized_size: &str, candidate_count: u32, reference_images: &[ReferenceImage], - options: Option<&RawImageEditOptions>, timeout_ms: u64, ) -> Result { let request_url = request_url.to_string(); @@ -131,7 +127,6 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl_options( let negative_prompt = negative_prompt.map(str::to_string); let normalized_size = normalized_size.to_string(); let reference_images = reference_images.to_vec(); - let options = options.cloned(); tokio::task::spawn_blocking(move || { send_multipart_edit_request_with_curl_blocking( request_url.as_str(), @@ -141,8 +136,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl_options( negative_prompt.as_deref(), normalized_size.as_str(), candidate_count, - reference_images, - options, + reference_images.as_slice(), timeout_ms, ) }) @@ -244,8 +238,7 @@ fn send_multipart_edit_request_with_curl_blocking( negative_prompt: Option<&str>, normalized_size: &str, candidate_count: u32, - reference_images: Vec, - options: Option, + reference_images: &[ReferenceImage], timeout_ms: u64, ) -> Result { let mut form = Form::new(); @@ -260,49 +253,13 @@ fn send_multipart_edit_request_with_curl_blocking( .contents(normalized_size.as_bytes()) .add()?; - if let Some(options) = options { - let RawImageEditOptions { - quality, - background, - output_format, - mask, - .. - } = options; - if let Some(quality) = quality { - form.part("quality").contents(quality.as_bytes()).add()?; - } - if let Some(background) = background { - form.part("background") - .contents(background.as_bytes()) - .add()?; - } - if let Some(output_format) = output_format { - form.part("output_format") - .contents(output_format.as_bytes()) - .add()?; - } - if let Some(mask) = mask { - let ReferenceImage { - bytes, - mime_type, - file_name, - } = mask; - form.part("mask") - .buffer(file_name.as_str(), bytes) - .content_type(mime_type.as_str()) - .add()?; - } - } - for reference_image in reference_images { - let ReferenceImage { - bytes, - mime_type, - file_name, - } = reference_image; form.part("image") - .buffer(file_name.as_str(), bytes) - .content_type(mime_type.as_str()) + .buffer( + reference_image.file_name.as_str(), + reference_image.bytes.clone(), + ) + .content_type(reference_image.mime_type.as_str()) .add()?; } @@ -372,7 +329,7 @@ mod tests { #[tokio::test] async fn vector_engine_curl_transport_posts_multipart_request() { let (base_url, server, request_rx) = start_single_response_server().await; - let response = send_vector_engine_multipart_edit_request_with_curl_options( + let response = send_vector_engine_multipart_edit_request_with_curl( format!("{base_url}/v1/images/edits").as_str(), "test-key", GPT_IMAGE_2_MODEL, @@ -385,7 +342,6 @@ mod tests { mime_type: "image/png".to_string(), file_name: "reference.png".to_string(), }], - None, 1_000, ) .await @@ -403,53 +359,6 @@ mod tests { server.abort(); } - #[tokio::test] - async fn vector_engine_curl_transport_posts_raw_edit_options_and_mask() { - let (base_url, server, request_rx) = start_single_response_server().await; - let response = send_vector_engine_multipart_edit_request_with_curl_options( - format!("{base_url}/v1/images/edits").as_str(), - "test-key", - GPT_IMAGE_2_MODEL, - "测试提示词", - None, - "1536x1024", - 1, - &[ReferenceImage { - bytes: b"reference".to_vec(), - mime_type: "image/webp".to_string(), - file_name: "reference.webp".to_string(), - }], - Some(&RawImageEditOptions { - quality: Some("high".to_string()), - background: Some("transparent".to_string()), - output_format: Some("png".to_string()), - width: 1536, - height: 1024, - mask: Some(ReferenceImage { - bytes: b"mask-bytes".to_vec(), - mime_type: "image/png".to_string(), - file_name: "mask.png".to_string(), - }), - }), - 1_000, - ) - .await - .expect("curl multipart raw edit request should succeed"); - - assert_eq!(response.status, 200); - let request = request_rx - .await - .expect("mock server should capture request"); - let request_text = String::from_utf8_lossy(request.as_slice()); - assert!(request_text.contains("name=\"quality\"\r\n\r\nhigh")); - assert!(request_text.contains("name=\"background\"\r\n\r\ntransparent")); - assert!(request_text.contains("name=\"output_format\"\r\n\r\npng")); - assert!(request_text.contains("name=\"mask\"; filename=\"mask.png\"")); - assert!(request_text.contains("Content-Type: image/png")); - assert!(request_text.contains("mask-bytes")); - server.abort(); - } - async fn start_single_response_server() -> ( String, tokio::task::JoinHandle<()>, diff --git a/server-rs/crates/platform-image/src/vector_engine/mod.rs b/server-rs/crates/platform-image/src/vector_engine/mod.rs index a20f76189..aacb3256d 100644 --- a/server-rs/crates/platform-image/src/vector_engine/mod.rs +++ b/server-rs/crates/platform-image/src/vector_engine/mod.rs @@ -26,7 +26,7 @@ pub use constants::{ }; pub use error::{PlatformImageError, PlatformImageStatusHint}; pub use image_source::download_remote_image; -pub use raw_edit::create_vector_engine_raw_image_edit; +pub use raw_edit::{RawImageEditOptions, create_vector_engine_raw_image_edit}; pub use request::{ build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model, build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size_for_model, @@ -34,7 +34,4 @@ pub use request::{ vector_engine_nanobanana_generate_content_url, }; pub use transport::build_vector_engine_image_http_client; -pub use types::{ - DownloadedImage, GeneratedImages, RawImageEditOptions, ReferenceImage, - VectorEngineImageSettings, -}; +pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings}; diff --git a/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs b/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs index b7d6f49dd..435a9e1f3 100644 --- a/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs +++ b/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs @@ -1,33 +1,172 @@ +use std::time::Duration; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use reqwest::multipart::{Form, Part}; +use serde_json::Value; + use super::{ - client::create_vector_engine_image_edit_with_references_and_model_and_options, - constants::GPT_IMAGE_2_MODEL, + constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER}, error::PlatformImageError, - types::{GeneratedImages, RawImageEditOptions, ReferenceImage, VectorEngineImageSettings}, + request::vector_engine_images_edit_url, + types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings}, }; -/// Sends the raw GPT Image 2 edit contract while keeping VectorEngine's -/// multipart transport inside this crate. -#[allow(clippy::too_many_arguments)] +#[derive(Clone, Debug, Default)] +pub struct RawImageEditOptions { + pub quality: Option, + pub background: Option, + pub output_format: Option, + pub width: u32, + pub height: u32, + pub mask: Option, +} + +/// Independent raw GPT Image 2 proxy; it does not call the editor image-edit client. pub async fn create_vector_engine_raw_image_edit( - http_client: &reqwest::Client, settings: &VectorEngineImageSettings, prompt: &str, image: &ReferenceImage, options: RawImageEditOptions, failure_context: &str, ) -> Result { - let size = format!("{}x{}", options.width, options.height); - create_vector_engine_image_edit_with_references_and_model_and_options( - http_client, - settings, - GPT_IMAGE_2_MODEL, - prompt, - None, - size.as_str(), - 1, - std::slice::from_ref(image), - Some(&options), - failure_context, - ) - .await + let url = vector_engine_images_edit_url(settings); + let mut form = Form::new() + .text("model", GPT_IMAGE_2_MODEL.to_string()) + .text("n", "1".to_string()) + .text("prompt", prompt.to_string()) + .text("size", format!("{}x{}", options.width, options.height)) + .part( + "image", + Part::bytes(image.bytes.clone()) + .file_name(image.file_name.clone()) + .mime_str(image.mime_type.as_str()) + .map_err(|error| invalid_request(failure_context, error.to_string()))?, + ); + if let Some(value) = options.quality.clone() { + form = form.text("quality", value); + } + if let Some(value) = options.background.clone() { + form = form.text("background", value); + } + if let Some(value) = options.output_format.clone() { + form = form.text("output_format", value); + } + if let Some(mask) = options.mask.clone() { + form = form.part( + "mask", + Part::bytes(mask.bytes) + .file_name(mask.file_name) + .mime_str(mask.mime_type.as_str()) + .map_err(|error| invalid_request(failure_context, error.to_string()))?, + ); + } + + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms.max(1))) + .http1_only() + .build() + .map_err(|error| invalid_config(error.to_string()))?; + let response = client + .post(url.as_str()) + .bearer_auth(settings.api_key.as_str()) + .multipart(form) + .send() + .await + .map_err(|error| request_error(&url, failure_context, error))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| request_error(&url, failure_context, error))?; + if !status.is_success() { + return Err(PlatformImageError::Upstream { + provider: VECTOR_ENGINE_PROVIDER, + message: format!( + "{failure_context}:上游图片编辑失败(HTTP {})", + status.as_u16() + ), + upstream_status: status.as_u16(), + raw_excerpt: body.chars().take(2_000).collect(), + audit: None, + }); + } + let payload: Value = + serde_json::from_str(body.as_str()).map_err(|error| PlatformImageError::ResponseParse { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:上游响应不是 JSON:{error}"), + raw_excerpt: body.chars().take(2_000).collect(), + audit: None, + })?; + let mut images = Vec::new(); + if let Some(entries) = payload.get("data").and_then(Value::as_array) { + for entry in entries { + let Some(value) = entry.get("b64_json").and_then(Value::as_str) else { + continue; + }; + let bytes = BASE64_STANDARD.decode(value).map_err(|error| { + PlatformImageError::ResponseParse { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:上游 b64_json 解码失败:{error}"), + raw_excerpt: body.chars().take(2_000).collect(), + audit: None, + } + })?; + let (mime_type, extension) = match options.output_format.as_deref() { + Some("jpeg") => ("image/jpeg", "jpg"), + Some("webp") => ("image/webp", "webp"), + _ => ("image/png", "png"), + }; + images.push(DownloadedImage { + bytes, + mime_type: mime_type.to_string(), + extension: extension.to_string(), + }); + } + } + if images.is_empty() { + return Err(PlatformImageError::MissingImage { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:上游未返回 b64_json 图片"), + audit: None, + }); + } + Ok(GeneratedImages { + task_id: payload + .get("id") + .and_then(Value::as_str) + .unwrap_or("raw-image-edit") + .to_string(), + actual_prompt: None, + images, + recovered_failure_audits: Vec::new(), + }) +} + +fn invalid_request(context: &str, message: String) -> PlatformImageError { + PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{context}:构造上游请求失败:{message}"), + } +} + +fn invalid_config(message: String) -> PlatformImageError { + PlatformImageError::InvalidConfig { + provider: VECTOR_ENGINE_PROVIDER, + message, + } +} + +fn request_error(url: &str, context: &str, error: E) -> PlatformImageError { + PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{context}:上游请求失败:{error}"), + endpoint: Some(url.to_string()), + timeout: false, + connect: false, + request: true, + body: false, + status_code: None, + source: Some(error.to_string()), + audit: None, + } } diff --git a/server-rs/crates/platform-image/src/vector_engine/types.rs b/server-rs/crates/platform-image/src/vector_engine/types.rs index 818cafe60..77fbd19f9 100644 --- a/server-rs/crates/platform-image/src/vector_engine/types.rs +++ b/server-rs/crates/platform-image/src/vector_engine/types.rs @@ -29,15 +29,3 @@ pub struct ReferenceImage { pub mime_type: String, pub file_name: String, } - -/// Raw GPT Image 2 edit options. The API layer owns validation; this type only -/// carries values that must be forwarded to VectorEngine. -#[derive(Clone, Debug, Default)] -pub struct RawImageEditOptions { - pub quality: Option, - pub background: Option, - pub output_format: Option, - pub width: u32, - pub height: u32, - pub mask: Option, -}