use std::{error::Error, time::Duration}; use axum::http::StatusCode; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use reqwest::header; use serde_json::{Map, Value, json}; use crate::{ external_api_audit::{ ExternalApiFailureDraft, app_error_status_class, is_retryable_external_api_failure, record_external_api_failure, }, http_error::AppError, state::AppState, }; pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; #[derive(Clone)] pub(crate) struct OpenAiImageSettings { pub base_url: String, pub api_key: String, pub request_timeout_ms: u64, pub external_api_audit_state: Option, } impl std::fmt::Debug for OpenAiImageSettings { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("OpenAiImageSettings") .field("base_url", &self.base_url) .field("api_key", &"") .field("request_timeout_ms", &self.request_timeout_ms) .field( "external_api_audit_enabled", &self.external_api_audit_state.is_some(), ) .finish() } } #[derive(Clone, Debug)] pub(crate) struct OpenAiGeneratedImages { pub task_id: String, pub actual_prompt: Option, pub images: Vec, } #[derive(Clone, Debug)] pub(crate) struct DownloadedOpenAiImage { pub bytes: Vec, pub mime_type: String, pub extension: String, } #[derive(Clone, Debug)] pub(crate) struct OpenAiReferenceImage { pub bytes: Vec, pub mime_type: String, pub file_name: String, } // 中文注释:RPG、方洞等图片资产统一走后端 VectorEngine GPT-image-2,避免把密钥或供应商协议暴露到前端。 pub(crate) fn require_openai_image_settings( state: &AppState, ) -> Result { let base_url = state .config .vector_engine_base_url .trim() .trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "reason": "VECTOR_ENGINE_BASE_URL 未配置", })), ); } let api_key = state .config .vector_engine_api_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "reason": "VECTOR_ENGINE_API_KEY 未配置", })) })?; Ok(OpenAiImageSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1), external_api_audit_state: Some(state.clone()), }) } pub(crate) fn build_openai_image_http_client( settings: &OpenAiImageSettings, ) -> Result { reqwest::Client::builder() .timeout(Duration::from_millis(settings.request_timeout_ms)) // 中文注释:参考图会走 multipart edits;强制 HTTP/1.1 可避开部分网关对长耗时上传流的兼容问题。 .http1_only() .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"), })) }) } pub(crate) async fn create_openai_image_generation( http_client: &reqwest::Client, settings: &OpenAiImageSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, candidate_count: u32, reference_images: &[String], failure_context: &str, ) -> Result { if !reference_images.is_empty() { let resolved_references = resolve_openai_reference_images(http_client, reference_images, failure_context).await?; return create_openai_image_edit_with_references( http_client, settings, prompt, negative_prompt, size, candidate_count, resolved_references.as_slice(), failure_context, ) .await; } let request_url = vector_engine_images_generation_url(settings); let normalized_size = normalize_image_size(size); let request_body = build_openai_image_request_body( prompt, negative_prompt, normalized_size.as_str(), candidate_count, reference_images, ); let started_at = std::time::Instant::now(); let response = match http_client .post(request_url.as_str()) .header( header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .header(header::ACCEPT, "application/json") .header(header::CONTENT_TYPE, "application/json") .json(&request_body) .send() .await { Ok(response) => response, Err(error) => { let latency_ms = started_at.elapsed().as_millis() as u64; let timeout = error.is_timeout(); let connect = error.is_connect(); let source = error.source().map(ToString::to_string); let message = format!("{failure_context}:创建图片生成任务失败:{error}"); record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "request_send", None, None, timeout, connect, message.as_str(), source, None, Some(latency_ms), Some(prompt.chars().count()), Some(reference_images.len()), ), ) .await; return Err(map_openai_image_reqwest_error( format!("{failure_context}:创建图片生成任务失败").as_str(), request_url.as_str(), error, )); } }; let response_status = response.status(); tracing::info!( provider = VECTOR_ENGINE_PROVIDER, endpoint = %request_url, status = response_status.as_u16(), prompt_chars = prompt.chars().count(), size = %normalized_size, reference_image_count = reference_images.len(), elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, "VectorEngine 图片生成 HTTP 返回" ); let response_text = match response.text().await { Ok(response_text) => response_text, Err(error) => { let latency_ms = started_at.elapsed().as_millis() as u64; let timeout = error.is_timeout(); let connect = error.is_connect(); let source = error.source().map(ToString::to_string); let message = format!("{failure_context}:读取图片生成响应失败:{error}"); record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "response_body", Some(response_status.as_u16()), None, timeout, connect, message.as_str(), source, None, Some(latency_ms), Some(prompt.chars().count()), Some(reference_images.len()), ), ) .await; return Err(map_openai_image_reqwest_error( format!("{failure_context}:读取图片生成响应失败").as_str(), request_url.as_str(), error, )); } }; if !response_status.is_success() { record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "upstream_status", Some(response_status.as_u16()), None, false, false, parse_api_error_message(response_text.as_str(), failure_context).as_str(), None, Some(truncate_raw(response_text.as_str())), Some(started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), Some(reference_images.len()), ), ) .await; return Err(map_openai_image_upstream_error( response_status.as_u16(), response_text.as_str(), failure_context, )); } let response_json = match parse_json_payload(response_text.as_str(), failure_context) { Ok(response_json) => response_json, Err(error) => { record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "response_parse", Some(response_status.as_u16()), None, false, false, error.body_text().as_str(), None, Some(truncate_raw(response_text.as_str())), Some(started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), Some(reference_images.len()), ), ) .await; return Err(error); } }; let generation_id = extract_generation_id(&response_json.payload) .unwrap_or_else(|| format!("vector-engine-{}", current_utc_micros())); let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); let image_urls = extract_image_urls(&response_json.payload); if !image_urls.is_empty() { let download_started_at = std::time::Instant::now(); let mut generated = match download_images_from_urls( http_client, generation_id, image_urls, candidate_count, ) .await { Ok(generated) => generated, Err(error) => { record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "image_download", Some(response_status.as_u16()), Some(app_error_status_class(error.status_code())), false, false, error.body_text().as_str(), None, None, Some(download_started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), Some(reference_images.len()), ), ) .await; return Err(error); } }; generated.actual_prompt = actual_prompt; tracing::info!( provider = VECTOR_ENGINE_PROVIDER, endpoint = %request_url, image_count = generated.images.len(), elapsed_ms = download_started_at.elapsed().as_millis() as u64, failure_context, "VectorEngine 图片下载完成" ); return Ok(generated); } let b64_images = extract_b64_images(&response_json.payload); if !b64_images.is_empty() { let mut generated = images_from_base64(generation_id, b64_images, candidate_count); generated.actual_prompt = actual_prompt; tracing::info!( provider = VECTOR_ENGINE_PROVIDER, endpoint = %request_url, image_count = generated.images.len(), failure_context, "VectorEngine 图片 base64 解码完成" ); return Ok(generated); } record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "missing_image", Some(response_status.as_u16()), None, false, false, format!("{failure_context}:VectorEngine 未返回图片地址").as_str(), None, Some(truncate_raw(response_text.as_str())), Some(started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), Some(reference_images.len()), ), ) .await; Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": format!("{failure_context}:VectorEngine 未返回图片地址"), })), ) } pub(crate) async fn create_openai_image_edit( http_client: &reqwest::Client, settings: &OpenAiImageSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, reference_image: &OpenAiReferenceImage, failure_context: &str, ) -> Result { create_openai_image_edit_with_references( http_client, settings, prompt, negative_prompt, size, 1, std::slice::from_ref(reference_image), failure_context, ) .await } pub(crate) async fn create_openai_image_edit_with_references( http_client: &reqwest::Client, settings: &OpenAiImageSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, candidate_count: u32, reference_images: &[OpenAiReferenceImage], failure_context: &str, ) -> Result { if reference_images.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), })), ); } let request_url = vector_engine_images_edit_url(settings); let normalized_size = normalize_image_size(size); let mut form = reqwest::multipart::Form::new() .text("model", GPT_IMAGE_2_MODEL.to_string()) .text( "prompt", build_prompt_with_negative(prompt, negative_prompt), ) .text("n", candidate_count.clamp(1, 4).to_string()) .text("size", normalized_size.clone()); for reference_image in reference_images.iter().take(5) { let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) .file_name(reference_image.file_name.clone()) .mime_str(reference_image.mime_type.as_str()) .map_err(|error| { map_openai_image_request_error(format!( "{failure_context}:构造参考图失败:{error}" )) })?; form = form.part("image", image_part); } let reference_image_count = reference_images.iter().take(5).count(); let started_at = std::time::Instant::now(); let response = match http_client .post(request_url.as_str()) .header( header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .header(header::ACCEPT, "application/json") .multipart(form) .send() .await { Ok(response) => response, Err(error) => { let latency_ms = started_at.elapsed().as_millis() as u64; let timeout = error.is_timeout(); let connect = error.is_connect(); let source = error.source().map(ToString::to_string); let message = format!("{failure_context}:创建图片编辑任务失败:{error}"); record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "request_send", None, None, timeout, connect, message.as_str(), source, None, Some(latency_ms), Some(prompt.chars().count()), Some(reference_image_count), ), ) .await; return Err(map_openai_image_reqwest_error( format!("{failure_context}:创建图片编辑任务失败").as_str(), request_url.as_str(), error, )); } }; let response_status = response.status(); tracing::info!( provider = VECTOR_ENGINE_PROVIDER, endpoint = %request_url, status = response_status.as_u16(), prompt_chars = prompt.chars().count(), size = %normalized_size, reference_image_count, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, "VectorEngine 图片编辑 HTTP 返回" ); let response_text = match response.text().await { Ok(response_text) => response_text, Err(error) => { let latency_ms = started_at.elapsed().as_millis() as u64; let timeout = error.is_timeout(); let connect = error.is_connect(); let source = error.source().map(ToString::to_string); let message = format!("{failure_context}:读取图片编辑响应失败:{error}"); record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "response_body", Some(response_status.as_u16()), None, timeout, connect, message.as_str(), source, None, Some(latency_ms), Some(prompt.chars().count()), Some(reference_image_count), ), ) .await; return Err(map_openai_image_reqwest_error( format!("{failure_context}:读取图片编辑响应失败").as_str(), request_url.as_str(), error, )); } }; if !response_status.is_success() { record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "upstream_status", Some(response_status.as_u16()), None, false, false, parse_api_error_message(response_text.as_str(), failure_context).as_str(), None, Some(truncate_raw(response_text.as_str())), Some(started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), Some(reference_image_count), ), ) .await; return Err(map_openai_image_upstream_error( response_status.as_u16(), response_text.as_str(), failure_context, )); } let response_json = match parse_json_payload(response_text.as_str(), failure_context) { Ok(response_json) => response_json, Err(error) => { record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "response_parse", Some(response_status.as_u16()), None, false, false, error.body_text().as_str(), None, Some(truncate_raw(response_text.as_str())), Some(started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), Some(reference_image_count), ), ) .await; return Err(error); } }; let task_id = extract_generation_id(&response_json.payload) .unwrap_or_else(|| format!("vector-engine-edit-{}", current_utc_micros())); let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); let image_urls = extract_image_urls(&response_json.payload); if !image_urls.is_empty() { let download_started_at = std::time::Instant::now(); let mut generated = match download_images_from_urls( http_client, task_id, image_urls, candidate_count, ) .await { Ok(generated) => generated, Err(error) => { record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "image_download", Some(response_status.as_u16()), Some(app_error_status_class(error.status_code())), false, false, error.body_text().as_str(), None, None, Some(download_started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), Some(reference_image_count), ), ) .await; return Err(error); } }; generated.actual_prompt = actual_prompt; return Ok(generated); } let b64_images = extract_b64_images(&response_json.payload); if !b64_images.is_empty() { let mut generated = images_from_base64(task_id, b64_images, candidate_count); generated.actual_prompt = actual_prompt; return Ok(generated); } record_openai_image_failure_if_configured( settings, build_openai_image_failure_audit_draft( request_url.as_str(), failure_context, "missing_image", Some(response_status.as_u16()), None, false, false, format!("{failure_context}:VectorEngine 未返回编辑图片").as_str(), None, Some(truncate_raw(response_text.as_str())), Some(started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), Some(reference_image_count), ), ) .await; Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": format!("{failure_context}:VectorEngine 未返回图片"), })), ) } pub(crate) fn build_openai_image_request_body( prompt: &str, negative_prompt: Option<&str>, size: &str, candidate_count: u32, _reference_images: &[String], ) -> Value { let body = Map::from_iter([ ( "model".to_string(), Value::String(GPT_IMAGE_2_MODEL.to_string()), ), ( "prompt".to_string(), Value::String(build_prompt_with_negative(prompt, negative_prompt)), ), ("n".to_string(), json!(candidate_count.clamp(1, 4))), ( "size".to_string(), Value::String(normalize_image_size(size)), ), ]); Value::Object(body) } fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String { let prompt = prompt.trim(); let Some(negative_prompt) = negative_prompt .map(str::trim) .filter(|value| !value.is_empty()) else { return prompt.to_string(); }; format!("{prompt}\n避免:{negative_prompt}") } fn normalize_image_size(size: &str) -> String { match size.trim() { "1024*1024" | "1024x1024" | "1:1" => "1024x1024", "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152" | "2k" => "1536x1024", "1024*1536" | "1024x1536" | "9:16" => "1024x1536", value if !value.is_empty() => value, _ => "1024x1024", } .to_string() } async fn download_images_from_urls( http_client: &reqwest::Client, task_id: String, image_urls: Vec, candidate_count: u32, ) -> Result { let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize); for image_url in image_urls .into_iter() .take(candidate_count.clamp(1, 4) as usize) { images.push(download_remote_image(http_client, image_url.as_str()).await?); } Ok(OpenAiGeneratedImages { task_id, actual_prompt: None, images, }) } fn images_from_base64( task_id: String, b64_images: Vec, candidate_count: u32, ) -> OpenAiGeneratedImages { let images = b64_images .into_iter() .take(candidate_count.clamp(1, 4) as usize) .filter_map(|raw| decode_generated_image_base64(raw.as_str())) .collect(); OpenAiGeneratedImages { task_id, actual_prompt: None, images, } } fn decode_generated_image_base64(raw: &str) -> Option { let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; let mime_type = infer_image_mime_type(bytes.as_slice()); Some(DownloadedOpenAiImage { extension: mime_to_extension(mime_type.as_str()).to_string(), mime_type, bytes, }) } pub(crate) async fn download_remote_image( http_client: &reqwest::Client, image_url: &str, ) -> Result { let response = http_client.get(image_url).send().await.map_err(|error| { map_openai_image_request_error(format!("下载生成图片失败:{error}")) })?; let status = response.status(); let content_type = response .headers() .get(header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or("image/jpeg") .to_string(); let body = response.bytes().await.map_err(|error| { map_openai_image_request_error(format!("读取生成图片内容失败:{error}")) })?; if !status.is_success() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": "下载生成图片失败", "status": status.as_u16(), })), ); } let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str()); Ok(DownloadedOpenAiImage { extension: mime_to_extension(normalized_mime_type.as_str()).to_string(), mime_type: normalized_mime_type, bytes: body.to_vec(), }) } async fn resolve_openai_reference_images( http_client: &reqwest::Client, reference_images: &[String], failure_context: &str, ) -> Result, AppError> { let mut resolved = Vec::new(); for (index, source) in reference_images.iter().take(5).enumerate() { let source = source.trim(); if source.is_empty() { continue; } if let Some(reference_image) = parse_openai_reference_image_data_url(source, index)? { resolved.push(reference_image); continue; } if source.starts_with("http://") || source.starts_with("https://") { let downloaded = download_remote_image(http_client, source) .await .map_err(|error| { map_openai_image_request_error(format!( "{failure_context}:下载参考图失败:{}", error.body_text() )) })?; resolved.push(OpenAiReferenceImage { bytes: downloaded.bytes, mime_type: downloaded.mime_type.clone(), file_name: format!( "reference-{index}.{}", mime_to_extension(downloaded.mime_type.as_str()) ), }); continue; } return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"), })), ); } if resolved.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": format!("{failure_context}:图片编辑需要至少一张参考图。"), })), ); } Ok(resolved) } fn parse_openai_reference_image_data_url( source: &str, index: usize, ) -> Result, AppError> { let Some(body) = source.strip_prefix("data:") else { return Ok(None); }; let Some((mime_type, data)) = body.split_once(";base64,") else { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": "参考图 Data URL 必须是 base64 图片。", })), ); }; if !mime_type.starts_with("image/") { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": "参考图 Data URL 必须是图片类型。", })), ); } let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": format!("参考图 Data URL 解码失败:{error}"), })) })?; let mime_type = normalize_downloaded_image_mime_type(mime_type); Ok(Some(OpenAiReferenceImage { bytes, file_name: format!( "reference-{index}.{}", mime_to_extension(mime_type.as_str()) ), mime_type, })) } fn parse_json_payload( raw_text: &str, failure_context: &str, ) -> Result { serde_json::from_str::(raw_text) .map(|payload| ParsedJsonPayload { payload }) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": format!("{failure_context}:解析响应失败:{error}"), "rawExcerpt": truncate_raw(raw_text), })) }) } fn map_openai_image_request_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": message, })) } fn map_openai_image_reqwest_error( context: &str, request_url: &str, error: reqwest::Error, ) -> AppError { let is_timeout = error.is_timeout(); let is_connect = error.is_connect(); let source = error.source().map(ToString::to_string).unwrap_or_default(); let message = format!("{context}:{error}"); let status = if is_timeout { StatusCode::GATEWAY_TIMEOUT } else { StatusCode::BAD_GATEWAY }; tracing::warn!( provider = VECTOR_ENGINE_PROVIDER, endpoint = %request_url, timeout = is_timeout, connect = is_connect, request = error.is_request(), body = error.is_body(), source = %source, message = %message, "VectorEngine 图片请求发送失败" ); AppError::from_status(status).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": message, "endpoint": request_url, "timeout": is_timeout, "connect": is_connect, "request": error.is_request(), "body": error.is_body(), "source": source, })) } fn map_openai_image_upstream_error( upstream_status: u16, raw_text: &str, failure_context: &str, ) -> AppError { let message = parse_api_error_message(raw_text, failure_context); tracing::warn!( provider = VECTOR_ENGINE_PROVIDER, upstream_status, raw_excerpt = %truncate_raw(raw_text), message, "VectorEngine 图片生成上游错误" ); AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": message, "upstreamStatus": upstream_status, "rawExcerpt": truncate_raw(raw_text), })) } async fn record_openai_image_failure_if_configured( settings: &OpenAiImageSettings, draft: ExternalApiFailureDraft, ) { if let Some(state) = settings.external_api_audit_state.as_ref() { record_external_api_failure(state, draft).await; } } fn build_openai_image_failure_audit_draft( request_url: &str, failure_context: &str, failure_stage: &'static str, status_code: Option, status_class: Option<&'static str>, timeout: bool, connect: bool, error_message: &str, error_source: Option, raw_excerpt: Option, latency_ms: Option, prompt_chars: Option, reference_image_count: Option, ) -> ExternalApiFailureDraft { ExternalApiFailureDraft::new( VECTOR_ENGINE_PROVIDER, request_url.to_string(), failure_context.to_string(), failure_stage, error_message.to_string(), ) .with_status_code(status_code) .with_optional_status_class(status_class) .with_timeout(timeout) .with_retryable(is_retryable_external_api_failure( status_code, timeout, connect, )) .with_error_source(error_source) .with_raw_excerpt(raw_excerpt) .with_latency_ms(latency_ms) .with_prompt_chars(prompt_chars) .with_reference_image_count(reference_image_count) .with_image_model(Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL)) } fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { if raw_text.trim().is_empty() { return fallback_message.to_string(); } if let Ok(parsed) = serde_json::from_str::(raw_text) { for pointer in [ "/error/message", "/message", "/output/message", "/data/message", ] { if let Some(message) = parsed .pointer(pointer) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { return message.to_string(); } } for pointer in ["/error/code", "/code", "/output/code", "/data/code"] { if let Some(code) = parsed .pointer(pointer) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { return format!("{fallback_message}({code})"); } } } raw_text.trim().to_string() } fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { match value { Value::Array(entries) => { for entry in entries { collect_strings_by_key(entry, target_key, results); } } Value::Object(object) => { for (key, nested_value) in object { if key == target_key { match nested_value { Value::String(text) => { let text = text.trim(); if !text.is_empty() { results.push(text.to_string()); continue; } } Value::Array(entries) => { for entry in entries { if let Some(text) = entry .as_str() .map(str::trim) .filter(|value| !value.is_empty()) { results.push(text.to_string()); } } } _ => {} } } collect_strings_by_key(nested_value, target_key, results); } } _ => {} } } fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { let mut results = Vec::new(); collect_strings_by_key(value, target_key, &mut results); results.into_iter().next() } fn extract_generation_id(payload: &Value) -> Option { find_first_string_by_key(payload, "id") .or_else(|| find_first_string_by_key(payload, "created")) .or_else(|| find_first_string_by_key(payload, "request_id")) } fn extract_image_urls(payload: &Value) -> Vec { let mut urls = Vec::new(); collect_strings_by_key(payload, "url", &mut urls); collect_strings_by_key(payload, "image", &mut urls); collect_strings_by_key(payload, "image_url", &mut urls); let mut deduped = Vec::new(); for url in urls { if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) { deduped.push(url); } } deduped } fn extract_b64_images(payload: &Value) -> Vec { let mut values = Vec::new(); collect_strings_by_key(payload, "b64_json", &mut values); values } fn vector_engine_images_generation_url(settings: &OpenAiImageSettings) -> String { if settings.base_url.ends_with("/v1") { format!("{}/images/generations", settings.base_url) } else { format!("{}/v1/images/generations", settings.base_url) } } fn vector_engine_images_edit_url(settings: &OpenAiImageSettings) -> String { if settings.base_url.ends_with("/v1") { format!("{}/images/edits", settings.base_url) } else { format!("{}/v1/images/edits", settings.base_url) } } fn normalize_downloaded_image_mime_type(content_type: &str) -> String { let mime_type = content_type .split(';') .next() .map(str::trim) .unwrap_or("image/jpeg"); match mime_type { "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { mime_type.to_string() } _ => "image/jpeg".to_string(), } } fn mime_to_extension(mime_type: &str) -> &str { match mime_type { "image/png" => "png", "image/webp" => "webp", "image/gif" => "gif", _ => "jpg", } } fn infer_image_mime_type(bytes: &[u8]) -> String { if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { return "image/png".to_string(); } if bytes.starts_with(b"\xFF\xD8\xFF") { return "image/jpeg".to_string(); } if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { return "image/webp".to_string(); } if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { return "image/gif".to_string(); } "image/png".to_string() } fn truncate_raw(raw_text: &str) -> String { raw_text.chars().take(800).collect() } fn current_utc_micros() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time should be after unix epoch"); i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } struct ParsedJsonPayload { payload: Value, } #[cfg(test)] mod tests { use super::*; #[test] fn gpt_image_2_generation_request_uses_create_model_without_reference_images() { let body = build_openai_image_request_body( "雾海神殿", Some("文字,水印"), "1280*720", 2, &["data:image/png;base64,abcd".to_string()], ); assert_eq!(body["model"], GPT_IMAGE_2_MODEL); assert_eq!(body["size"], "1536x1024"); assert_eq!(body["n"], 2); assert!(body.get("official_fallback").is_none()); assert!(body.get("image").is_none()); assert!(body["prompt"].as_str().unwrap_or_default().contains("避免")); } #[test] fn vector_engine_generation_url_normalizes_base_url() { let root_settings = OpenAiImageSettings { base_url: "https://vector.example".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, }; assert_eq!( vector_engine_images_generation_url(&root_settings), "https://vector.example/v1/images/generations" ); assert_eq!( vector_engine_images_generation_url(&v1_settings), "https://vector.example/v1/images/generations" ); } #[test] fn vector_engine_edit_url_normalizes_base_url() { let root_settings = OpenAiImageSettings { base_url: "https://vector.example".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, }; assert_eq!( vector_engine_images_edit_url(&root_settings), "https://vector.example/v1/images/edits" ); assert_eq!( vector_engine_images_edit_url(&v1_settings), "https://vector.example/v1/images/edits" ); } #[tokio::test] async fn vector_engine_multi_reference_edit_rejects_empty_references() { let settings = OpenAiImageSettings { base_url: "https://vector.example".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, }; let http_client = reqwest::Client::new(); let result = create_openai_image_edit_with_references( &http_client, &settings, "提示词", None, "1:1", 1, &[], "测试图片编辑失败", ) .await; let error = result.expect_err("empty references should be rejected locally"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!(error.body_text().contains("缺少参考图")); } #[test] fn reference_data_url_resolves_to_edit_image_part() { let source = format!( "data:image/png;base64,{}", BASE64_STANDARD.encode(b"pngbytes") ); let image = parse_openai_reference_image_data_url(source.as_str(), 2) .expect("data url should parse") .expect("data url should resolve image"); assert_eq!(image.bytes, b"pngbytes"); assert_eq!(image.mime_type, "image/png"); assert_eq!(image.file_name, "reference-2.png"); } #[test] fn b64_json_response_decodes_png_image() { let images = images_from_base64( "task-1".to_string(), vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")], 1, ); assert_eq!(images.images.len(), 1); assert_eq!(images.images[0].mime_type, "image/png"); assert_eq!(images.images[0].extension, "png"); } #[test] fn vector_engine_upstream_failure_builds_tracking_ready_audit_event() { let audit = build_openai_image_failure_audit_draft( "https://vector.example/v1/images/generations", "拼图 UI 背景图生成失败", "upstream_status", Some(429), None, false, false, "上游限流", None, Some("{\"error\":\"rate limited\"}".to_string()), Some(321), Some(42), Some(1), ); let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft(&audit); assert_eq!( tracking.event_key, crate::external_api_audit::EXTERNAL_API_FAILURE_EVENT_KEY ); assert_eq!(tracking.scope_id, VECTOR_ENGINE_PROVIDER); assert_eq!(tracking.metadata["provider"], VECTOR_ENGINE_PROVIDER); assert_eq!(tracking.metadata["statusCode"], 429); assert_eq!(tracking.metadata["statusClass"], "4xx"); assert_eq!(tracking.metadata["failureStage"], "upstream_status"); assert_eq!(tracking.metadata["retryable"], true); assert_eq!(tracking.metadata["promptChars"], 42); assert_eq!(tracking.metadata["referenceImageCount"], 1); assert_eq!( tracking.metadata["imageModel"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL ); } }