修复 Raw 图片失败审计与响应格式
为 Raw provider 失败分支补齐结构化 failure audit 并接入 api-server 记录链 使用 VectorEngine 响应 output_format 生成返回图片 MIME 与扩展名 补充响应格式和审计字段定向测试并同步技术方案
This commit is contained in:
@@ -82,7 +82,7 @@ raw 操作使用独立的 operation / ledger 命名空间,例如 `raw-image-ed
|
||||
|
||||
`platform-image` 保留 VectorEngine 协议细节。raw handler 只负责:认证、JSON DTO、base64 解码、预检查、计费编排和响应映射。provider 请求仍由 `platform-image` 统一构造,并携带 `model`、`n`、`quality`、`background`、`output_format`、尺寸及图片参考字节。
|
||||
|
||||
provider 结果统一解码为图片字节;raw handler 只将这些字节编码到 `data[].b64_json`。
|
||||
provider 结果统一解码为图片字节;每项结果的 MIME 与扩展名以 VectorEngine 响应中的真实 `output_format` 为准,不得从请求参数反推。发送、响应读取、上游状态、响应解析和缺图失败必须生成 `PlatformImageFailureAudit`,由 api-server 写入现有外部 API 失败审计链。raw handler 只将结果字节编码到 `data[].b64_json`。
|
||||
|
||||
## 代码拆分
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ use crate::{
|
||||
},
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{map_platform_image_error, require_openai_image_settings},
|
||||
openai_image_generation::{
|
||||
map_platform_image_error, record_openai_image_failure_if_configured,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -65,8 +68,9 @@ pub(crate) async fn edit_raw_image(
|
||||
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 audit_settings = settings.clone();
|
||||
let operation = async move {
|
||||
let generated = create_vector_engine_raw_image_edit(
|
||||
let generated = match create_vector_engine_raw_image_edit(
|
||||
&provider_settings,
|
||||
prepared.prompt.as_str(),
|
||||
&prepared.image,
|
||||
@@ -74,7 +78,13 @@ pub(crate) async fn edit_raw_image(
|
||||
"raw_image_edit",
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_image_error)?;
|
||||
{
|
||||
Ok(generated) => generated,
|
||||
Err(error) => {
|
||||
record_openai_image_failure_if_configured(&audit_settings, &error).await;
|
||||
return Err(map_platform_image_error(error));
|
||||
}
|
||||
};
|
||||
let data = generated
|
||||
.images
|
||||
.into_iter()
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
audit::build_failure_audit,
|
||||
constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
|
||||
error::PlatformImageError,
|
||||
request::vector_engine_images_edit_url,
|
||||
types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings},
|
||||
util::truncate_raw,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -30,6 +32,9 @@ pub async fn create_vector_engine_raw_image_edit(
|
||||
failure_context: &str,
|
||||
) -> Result<GeneratedImages, PlatformImageError> {
|
||||
let url = vector_engine_images_edit_url(settings);
|
||||
let started_at = Instant::now();
|
||||
let prompt_chars = Some(prompt.chars().count());
|
||||
let reference_image_count = Some(1_usize + usize::from(options.mask.is_some()));
|
||||
let mut form = Form::new()
|
||||
.text("model", GPT_IMAGE_2_MODEL.to_string())
|
||||
.text("n", "1".to_string())
|
||||
@@ -72,50 +77,134 @@ pub async fn create_vector_engine_raw_image_edit(
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| request_error(&url, failure_context, error))?;
|
||||
.map_err(|error| {
|
||||
request_error(
|
||||
&url,
|
||||
failure_context,
|
||||
"request_send",
|
||||
error,
|
||||
started_at,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| request_error(&url, failure_context, error))?;
|
||||
let body = response.text().await.map_err(|error| {
|
||||
request_error(
|
||||
&url,
|
||||
failure_context,
|
||||
"response_read",
|
||||
error,
|
||||
started_at,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
)
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
let message = format!(
|
||||
"{failure_context}:上游图片编辑失败(HTTP {})",
|
||||
status.as_u16()
|
||||
);
|
||||
let raw_excerpt = truncate_raw(body.as_str());
|
||||
let audit = build_failure_audit(
|
||||
url.as_str(),
|
||||
failure_context,
|
||||
"upstream_status",
|
||||
Some(status.as_u16()),
|
||||
Some(status_class(status.as_u16())),
|
||||
false,
|
||||
false,
|
||||
message.as_str(),
|
||||
None,
|
||||
Some(raw_excerpt.clone()),
|
||||
Some(started_at.elapsed().as_millis() as u64),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
Some(GPT_IMAGE_2_MODEL),
|
||||
);
|
||||
return Err(PlatformImageError::Upstream {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: format!(
|
||||
"{failure_context}:上游图片编辑失败(HTTP {})",
|
||||
status.as_u16()
|
||||
),
|
||||
message,
|
||||
upstream_status: status.as_u16(),
|
||||
raw_excerpt: body.chars().take(2_000).collect(),
|
||||
audit: None,
|
||||
raw_excerpt,
|
||||
audit: Some(audit),
|
||||
});
|
||||
}
|
||||
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 payload: Value = match serde_json::from_str(body.as_str()) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("{failure_context}:上游响应不是 JSON:{error}");
|
||||
let audit = build_failure_audit(
|
||||
url.as_str(),
|
||||
failure_context,
|
||||
"response_parse",
|
||||
Some(status.as_u16()),
|
||||
Some(status_class(status.as_u16())),
|
||||
false,
|
||||
false,
|
||||
message.as_str(),
|
||||
Some(error.to_string()),
|
||||
Some(truncate_raw(body.as_str())),
|
||||
Some(started_at.elapsed().as_millis() as u64),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
Some(GPT_IMAGE_2_MODEL),
|
||||
);
|
||||
return Err(PlatformImageError::ResponseParse {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message,
|
||||
raw_excerpt: truncate_raw(body.as_str()),
|
||||
audit: Some(audit),
|
||||
});
|
||||
}
|
||||
};
|
||||
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 bytes = match BASE64_STANDARD.decode(value) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
let message = format!("{failure_context}:上游 b64_json 解码失败:{error}");
|
||||
let audit = build_failure_audit(
|
||||
url.as_str(),
|
||||
failure_context,
|
||||
"response_parse",
|
||||
Some(status.as_u16()),
|
||||
Some(status_class(status.as_u16())),
|
||||
false,
|
||||
false,
|
||||
message.as_str(),
|
||||
Some(error.to_string()),
|
||||
Some(truncate_raw(body.as_str())),
|
||||
Some(started_at.elapsed().as_millis() as u64),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
Some(GPT_IMAGE_2_MODEL),
|
||||
);
|
||||
return Err(PlatformImageError::ResponseParse {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message,
|
||||
raw_excerpt: truncate_raw(body.as_str()),
|
||||
audit: Some(audit),
|
||||
});
|
||||
}
|
||||
})?;
|
||||
let (mime_type, extension) = match options.output_format.as_deref() {
|
||||
Some("jpeg") => ("image/jpeg", "jpg"),
|
||||
Some("webp") => ("image/webp", "webp"),
|
||||
_ => ("image/png", "png"),
|
||||
};
|
||||
let (mime_type, extension) =
|
||||
response_image_format(&payload, entry).map_err(|message| {
|
||||
response_parse_error(
|
||||
&url,
|
||||
failure_context,
|
||||
message,
|
||||
status.as_u16(),
|
||||
started_at,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
&body,
|
||||
)
|
||||
})?;
|
||||
images.push(DownloadedImage {
|
||||
bytes,
|
||||
mime_type: mime_type.to_string(),
|
||||
@@ -124,10 +213,27 @@ pub async fn create_vector_engine_raw_image_edit(
|
||||
}
|
||||
}
|
||||
if images.is_empty() {
|
||||
let message = format!("{failure_context}:上游未返回 b64_json 图片");
|
||||
let audit = build_failure_audit(
|
||||
url.as_str(),
|
||||
failure_context,
|
||||
"missing_image",
|
||||
Some(status.as_u16()),
|
||||
Some(status_class(status.as_u16())),
|
||||
false,
|
||||
false,
|
||||
message.as_str(),
|
||||
None,
|
||||
Some(truncate_raw(body.as_str())),
|
||||
Some(started_at.elapsed().as_millis() as u64),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
Some(GPT_IMAGE_2_MODEL),
|
||||
);
|
||||
return Err(PlatformImageError::MissingImage {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: format!("{failure_context}:上游未返回 b64_json 图片"),
|
||||
audit: None,
|
||||
message,
|
||||
audit: Some(audit),
|
||||
});
|
||||
}
|
||||
Ok(GeneratedImages {
|
||||
@@ -156,17 +262,159 @@ fn invalid_config(message: String) -> PlatformImageError {
|
||||
}
|
||||
}
|
||||
|
||||
fn request_error<E: std::fmt::Display>(url: &str, context: &str, error: E) -> PlatformImageError {
|
||||
fn response_parse_error(
|
||||
url: &str,
|
||||
context: &str,
|
||||
message: &str,
|
||||
status: u16,
|
||||
started_at: Instant,
|
||||
prompt_chars: Option<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
body: &str,
|
||||
) -> PlatformImageError {
|
||||
let audit = build_failure_audit(
|
||||
url,
|
||||
context,
|
||||
"response_parse",
|
||||
Some(status),
|
||||
Some(status_class(status)),
|
||||
false,
|
||||
false,
|
||||
message,
|
||||
None,
|
||||
Some(truncate_raw(body)),
|
||||
Some(started_at.elapsed().as_millis() as u64),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
Some(GPT_IMAGE_2_MODEL),
|
||||
);
|
||||
PlatformImageError::ResponseParse {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: format!("{context}:{message}"),
|
||||
raw_excerpt: truncate_raw(body),
|
||||
audit: Some(audit),
|
||||
}
|
||||
}
|
||||
|
||||
fn response_image_format(
|
||||
payload: &Value,
|
||||
entry: &Value,
|
||||
) -> Result<(&'static str, &'static str), &'static str> {
|
||||
let Some(value) = entry
|
||||
.get("output_format")
|
||||
.or_else(|| payload.get("output_format"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Err("上游响应缺少 output_format");
|
||||
};
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"png" => Ok(("image/png", "png")),
|
||||
"jpeg" | "jpg" => Ok(("image/jpeg", "jpg")),
|
||||
"webp" => Ok(("image/webp", "webp")),
|
||||
"gif" => Ok(("image/gif", "gif")),
|
||||
_ => Err("上游响应包含不支持的 output_format"),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_error(
|
||||
url: &str,
|
||||
context: &str,
|
||||
failure_stage: &'static str,
|
||||
error: reqwest::Error,
|
||||
started_at: Instant,
|
||||
prompt_chars: Option<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
) -> PlatformImageError {
|
||||
let timeout = error.is_timeout();
|
||||
let connect = error.is_connect();
|
||||
let source = error.to_string();
|
||||
let message = format!("{context}:上游请求失败:{source}");
|
||||
let audit = build_failure_audit(
|
||||
url,
|
||||
context,
|
||||
failure_stage,
|
||||
None,
|
||||
Some("transport"),
|
||||
timeout,
|
||||
connect,
|
||||
message.as_str(),
|
||||
Some(source.clone()),
|
||||
None,
|
||||
Some(started_at.elapsed().as_millis() as u64),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
Some(GPT_IMAGE_2_MODEL),
|
||||
);
|
||||
PlatformImageError::Request {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: format!("{context}:上游请求失败:{error}"),
|
||||
message,
|
||||
endpoint: Some(url.to_string()),
|
||||
timeout: false,
|
||||
connect: false,
|
||||
timeout,
|
||||
connect,
|
||||
request: true,
|
||||
body: false,
|
||||
status_code: None,
|
||||
source: Some(error.to_string()),
|
||||
audit: None,
|
||||
source: Some(source),
|
||||
audit: Some(audit),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_class(status: u16) -> &'static str {
|
||||
match status {
|
||||
100..=199 => "1xx",
|
||||
200..=299 => "2xx",
|
||||
300..=399 => "3xx",
|
||||
400..=499 => "4xx",
|
||||
_ => "5xx",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn response_format_uses_vector_engine_output_format() {
|
||||
let payload = json!({"output_format": "png"});
|
||||
assert_eq!(
|
||||
response_image_format(&payload, &json!({"output_format": "webp"})),
|
||||
Ok(("image/webp", "webp"))
|
||||
);
|
||||
assert_eq!(
|
||||
response_image_format(&payload, &json!({})),
|
||||
Ok(("image/png", "png"))
|
||||
);
|
||||
assert_eq!(
|
||||
response_image_format(&json!({}), &json!({"output_format": "jpeg"})),
|
||||
Ok(("image/jpeg", "jpg"))
|
||||
);
|
||||
assert!(response_image_format(&json!({}), &json!({})).is_err());
|
||||
assert!(
|
||||
response_image_format(&json!({}), &json!({"output_format": "bmp"})).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_parse_error_contains_structured_audit() {
|
||||
let error = response_parse_error(
|
||||
"https://vector.example/v1/images/edits",
|
||||
"raw_image_edit",
|
||||
"上游响应缺少 output_format",
|
||||
200,
|
||||
Instant::now(),
|
||||
Some(12),
|
||||
Some(2),
|
||||
"{\"data\":[]}",
|
||||
);
|
||||
let audit = error.audit().expect("response error should carry audit");
|
||||
assert_eq!(audit.failure_stage, "response_parse");
|
||||
assert_eq!(audit.status_code, Some(200));
|
||||
assert_eq!(audit.status_class, Some("2xx"));
|
||||
assert_eq!(audit.prompt_chars, Some(12));
|
||||
assert_eq!(audit.reference_image_count, Some(2));
|
||||
assert_eq!(audit.image_model, Some(GPT_IMAGE_2_MODEL));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user