隔离 Raw 图片代理实现
移除对现有图片编辑 client 与 curl transport 的复用 将 Raw GPT Image 2 请求、multipart 构造和响应解析收敛到独立文件 保持现有 editor 图片链路代码不变
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<GeneratedImages, PlatformImageError> {
|
||||
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<GeneratedImages, PlatformImageError> {
|
||||
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,
|
||||
|
||||
@@ -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<VectorEngineCurlResponse, VectorEngineCurlError> {
|
||||
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<ReferenceImage>,
|
||||
options: Option<RawImageEditOptions>,
|
||||
reference_images: &[ReferenceImage],
|
||||
timeout_ms: u64,
|
||||
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
|
||||
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<()>,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String>,
|
||||
pub background: Option<String>,
|
||||
pub output_format: Option<String>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub mask: Option<ReferenceImage>,
|
||||
}
|
||||
|
||||
/// 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<GeneratedImages, PlatformImageError> {
|
||||
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<E: std::fmt::Display>(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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub background: Option<String>,
|
||||
pub output_format: Option<String>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub mask: Option<ReferenceImage>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user