1373 lines
46 KiB
Rust
1373 lines
46 KiB
Rust
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<AppState>,
|
||
}
|
||
|
||
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", &"<redacted>")
|
||
.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<String>,
|
||
pub images: Vec<DownloadedOpenAiImage>,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct DownloadedOpenAiImage {
|
||
pub bytes: Vec<u8>,
|
||
pub mime_type: String,
|
||
pub extension: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct OpenAiReferenceImage {
|
||
pub bytes: Vec<u8>,
|
||
pub mime_type: String,
|
||
pub file_name: String,
|
||
}
|
||
|
||
// 中文注释:RPG、方洞等图片资产统一走后端 VectorEngine GPT-image-2,避免把密钥或供应商协议暴露到前端。
|
||
pub(crate) fn require_openai_image_settings(
|
||
state: &AppState,
|
||
) -> Result<OpenAiImageSettings, AppError> {
|
||
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, AppError> {
|
||
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<OpenAiGeneratedImages, AppError> {
|
||
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<OpenAiGeneratedImages, AppError> {
|
||
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<OpenAiGeneratedImages, AppError> {
|
||
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<String>,
|
||
candidate_count: u32,
|
||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||
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<String>,
|
||
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<DownloadedOpenAiImage> {
|
||
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<DownloadedOpenAiImage, AppError> {
|
||
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<Vec<OpenAiReferenceImage>, 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<Option<OpenAiReferenceImage>, 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<ParsedJsonPayload, AppError> {
|
||
serde_json::from_str::<Value>(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<u16>,
|
||
status_class: Option<&'static str>,
|
||
timeout: bool,
|
||
connect: bool,
|
||
error_message: &str,
|
||
error_source: Option<String>,
|
||
raw_excerpt: Option<String>,
|
||
latency_ms: Option<u64>,
|
||
prompt_chars: Option<usize>,
|
||
reference_image_count: Option<usize>,
|
||
) -> 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::<Value>(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<String>) {
|
||
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<String> {
|
||
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<String> {
|
||
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<String> {
|
||
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<String> {
|
||
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
|
||
);
|
||
}
|
||
}
|