重构图片 provider 中立执行层

将 platform-image 图片协议从 vector_engine 目录迁移到 image_provider

引入 ImageProviderClient 与 VectorEngine/Tiantoken provider 类型

按 concrete model 白名单拒绝未知模型并移除跨模型回退
This commit is contained in:
2026-09-18 16:03:29 +08:00
parent 0dbc34d13c
commit c34d24c2c8
21 changed files with 563 additions and 602 deletions
@@ -86,7 +86,7 @@ pub(crate) fn request_budget_exhausted_error(
reference_image_count,
image_model,
operation,
"VectorEngine 图片请求执行预算已耗尽"
"ImageProvider 图片请求执行预算已耗尽"
);
PlatformImageError::Request {
provider: VECTOR_ENGINE_PROVIDER,
@@ -1,8 +1,14 @@
pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2";
pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c";
/// Current business model exposed to callers for new image tasks.
pub const GPT_IMAGE_2_5_BUSINESS_NAME: &str = "gpt-image-2.5";
/// Provider/pricing key for new generation tasks.
pub const GPT_IMAGE_2_5_GENERATION_MODEL: &str = "gpt-image-2.5-flare-c";
/// Provider/pricing key for explicit image-edit tasks.
pub const GPT_IMAGE_2_5_EDIT_MODEL: &str = "gpt-image-2.5-sunburst-c";
pub const NANOBANANA_2_MODEL: &str = "gemini-3.1-flash-image-preview";
pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL;
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
pub const TIANTOKEN_PROVIDER: &str = "tiantoken";
pub const VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES: usize = 5;
pub const VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES: usize = 14;
pub const GPT_IMAGE_2_MIN_PIXELS: u64 = 655_360;
@@ -12,19 +12,19 @@ use super::{
};
#[derive(Debug)]
pub(crate) struct VectorEngineCurlResponse {
pub(crate) struct ImageProviderCurlResponse {
pub(crate) status: u16,
pub(crate) body: String,
}
#[derive(Debug)]
pub(crate) enum VectorEngineCurlError {
pub(crate) enum ImageProviderCurlError {
Curl(curl::Error),
Form(FormError),
WorkerJoin(tokio::task::JoinError),
}
impl VectorEngineCurlError {
impl ImageProviderCurlError {
pub(crate) fn is_timeout(&self) -> bool {
match self {
Self::Curl(error) => error.is_operation_timedout(),
@@ -63,7 +63,7 @@ impl VectorEngineCurlError {
}
}
impl fmt::Display for VectorEngineCurlError {
impl fmt::Display for ImageProviderCurlError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Curl(error) => write!(formatter, "{error}"),
@@ -73,26 +73,26 @@ impl fmt::Display for VectorEngineCurlError {
}
}
impl Error for VectorEngineCurlError {}
impl Error for ImageProviderCurlError {}
impl From<curl::Error> for VectorEngineCurlError {
impl From<curl::Error> for ImageProviderCurlError {
fn from(error: curl::Error) -> Self {
Self::Curl(error)
}
}
impl From<FormError> for VectorEngineCurlError {
impl From<FormError> for ImageProviderCurlError {
fn from(error: FormError) -> Self {
Self::Form(error)
}
}
pub(crate) async fn send_vector_engine_json_request_with_curl(
pub(crate) async fn send_image_json_request_with_curl(
request_url: &str,
api_key: &str,
request_body: &Value,
timeout_ms: u64,
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
) -> Result<ImageProviderCurlResponse, ImageProviderCurlError> {
let request_url = request_url.to_string();
let api_key = api_key.to_string();
let request_body = request_body.to_string();
@@ -105,11 +105,11 @@ pub(crate) async fn send_vector_engine_json_request_with_curl(
)
})
.await
.map_err(VectorEngineCurlError::WorkerJoin)?
.map_err(ImageProviderCurlError::WorkerJoin)?
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
pub(crate) async fn send_image_multipart_edit_request_with_curl(
request_url: &str,
api_key: &str,
model: &str,
@@ -119,7 +119,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
candidate_count: u32,
reference_images: &[ReferenceImage],
timeout_ms: u64,
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
) -> Result<ImageProviderCurlResponse, ImageProviderCurlError> {
let request_url = request_url.to_string();
let api_key = api_key.to_string();
let model = model.to_string();
@@ -141,7 +141,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
)
})
.await
.map_err(VectorEngineCurlError::WorkerJoin)?
.map_err(ImageProviderCurlError::WorkerJoin)?
}
pub(crate) fn map_curl_error(
@@ -149,7 +149,7 @@ pub(crate) fn map_curl_error(
request_url: &str,
failure_stage: &'static str,
image_model: Option<&'static str>,
error: VectorEngineCurlError,
error: ImageProviderCurlError,
latency_ms: u64,
prompt_chars: Option<usize>,
reference_image_count: Option<usize>,
@@ -195,7 +195,7 @@ pub(crate) fn map_curl_error(
request_params = %request_params
.map(|value| value.to_string())
.unwrap_or_default(),
"VectorEngine 图片 libcurl 请求失败"
"ImageProvider 图片 libcurl 请求失败"
);
PlatformImageError::Request {
@@ -217,8 +217,8 @@ fn send_json_request_with_curl_blocking(
api_key: &str,
request_body: &str,
timeout_ms: u64,
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
let mut headers = vector_engine_curl_headers(api_key)?;
) -> Result<ImageProviderCurlResponse, ImageProviderCurlError> {
let mut headers = image_curl_headers(api_key)?;
headers.append("Content-Type: application/json")?;
let mut easy = Easy::new();
easy.url(request_url)?;
@@ -240,7 +240,7 @@ fn send_multipart_edit_request_with_curl_blocking(
candidate_count: u32,
reference_images: &[ReferenceImage],
timeout_ms: u64,
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
) -> Result<ImageProviderCurlResponse, ImageProviderCurlError> {
let mut form = Form::new();
form.part("model").contents(model.as_bytes()).add()?;
form.part("prompt")
@@ -263,7 +263,7 @@ fn send_multipart_edit_request_with_curl_blocking(
.add()?;
}
let headers = vector_engine_curl_headers(api_key)?;
let headers = image_curl_headers(api_key)?;
let mut easy = Easy::new();
easy.url(request_url)?;
easy.httppost(form)?;
@@ -272,14 +272,14 @@ fn send_multipart_edit_request_with_curl_blocking(
Ok(perform_curl_request(easy)?)
}
fn vector_engine_curl_headers(api_key: &str) -> Result<List, curl::Error> {
fn image_curl_headers(api_key: &str) -> Result<List, curl::Error> {
let mut headers = List::new();
headers.append(format!("Authorization: Bearer {api_key}").as_str())?;
headers.append("Accept: application/json")?;
Ok(headers)
}
fn perform_curl_request(mut easy: Easy) -> Result<VectorEngineCurlResponse, curl::Error> {
fn perform_curl_request(mut easy: Easy) -> Result<ImageProviderCurlResponse, curl::Error> {
let mut body = Vec::new();
{
let mut transfer = easy.transfer();
@@ -291,13 +291,13 @@ fn perform_curl_request(mut easy: Easy) -> Result<VectorEngineCurlResponse, curl
}
let status = easy.response_code()? as u16;
let body = String::from_utf8_lossy(body.as_slice()).into_owned();
Ok(VectorEngineCurlResponse { status, body })
Ok(ImageProviderCurlResponse { status, body })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vector_engine::{constants::GPT_IMAGE_2_MODEL, types::ReferenceImage};
use crate::image_provider::{constants::GPT_IMAGE_2_MODEL, types::ReferenceImage};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
@@ -305,9 +305,9 @@ mod tests {
};
#[tokio::test]
async fn vector_engine_curl_transport_posts_json_request() {
async fn image_curl_transport_posts_json_request() {
let (base_url, server, request_rx) = start_single_response_server().await;
let response = send_vector_engine_json_request_with_curl(
let response = send_image_json_request_with_curl(
format!("{base_url}/v1/images/generations").as_str(),
"test-key",
&serde_json::json!({"model":"gpt-image-2","prompt":"测试"}),
@@ -327,9 +327,9 @@ mod tests {
}
#[tokio::test]
async fn vector_engine_curl_transport_posts_multipart_request() {
async fn image_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(
let response = send_image_multipart_edit_request_with_curl(
format!("{base_url}/v1/images/edits").as_str(),
"test-key",
GPT_IMAGE_2_MODEL,
@@ -0,0 +1,44 @@
mod audit;
mod budget;
mod client;
mod constants;
mod curl_transport;
mod error;
mod image_source;
mod payload;
mod raw_edit;
mod request;
mod response;
mod transport;
mod types;
mod util;
pub use audit::PlatformImageFailureAudit;
pub use client::{
create_image_edit, create_image_edit_with_references,
create_image_edit_with_references_and_model, create_image_generation,
create_image_generation_with_model, create_nanobanana_generate_content,
};
pub use constants::TIANTOKEN_PROVIDER;
pub use constants::{
GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL,
GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_PROVIDER,
};
pub use error::{PlatformImageError, PlatformImageStatusHint};
pub use image_source::download_remote_image;
pub use raw_edit::{
GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE,
RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, RawImageEditDimensionError, RawImageEditImage,
RawImageEditOptions, RawImageEditResult, create_raw_image_edit,
validate_raw_image_edit_dimensions,
};
pub use request::{
build_image_request_body, build_image_request_body_with_model,
build_nanobanana_generate_content_request_body, images_edit_url, images_generation_url,
nanobanana_generate_content_url, normalize_image_size_for_model, resolve_image_provider,
};
pub use transport::{build_image_http_client, build_image_provider_client};
pub use types::{
DownloadedImage, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings,
ReferenceImage,
};
@@ -8,12 +8,12 @@ use super::{
audit::build_failure_audit,
budget::{effective_request_timeout_ms, request_budget_exhausted_error},
constants::{
GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE, GPT_IMAGE_2_MAX_PIXELS,
GPT_IMAGE_2_MIN_PIXELS, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER,
GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE,
GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, VECTOR_ENGINE_PROVIDER,
},
error::PlatformImageError,
request::vector_engine_images_edit_url,
types::VectorEngineImageSettings,
request::images_edit_url,
types::ImageProviderSettings,
util::truncate_raw,
};
@@ -117,9 +117,9 @@ pub fn validate_raw_image_edit_dimensions(
}
/// Independent raw GPT Image 2 proxy; it does not call the editor image-edit client.
pub async fn create_vector_engine_raw_image_edit(
pub async fn create_raw_image_edit(
http_client: &reqwest::Client,
settings: &VectorEngineImageSettings,
settings: &ImageProviderSettings,
prompt: &str,
image: RawImageEditImage,
options: RawImageEditOptions,
@@ -127,7 +127,7 @@ pub async fn create_vector_engine_raw_image_edit(
) -> Result<RawImageEditResult, PlatformImageError> {
validate_raw_image_edit_dimensions(options.width, options.height)
.map_err(|error| invalid_input(failure_context, error.to_string()))?;
let url = vector_engine_images_edit_url(settings);
let url = 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()));
@@ -137,7 +137,7 @@ pub async fn create_vector_engine_raw_image_edit(
return Err(request_budget_exhausted_error(
url.as_str(),
failure_context,
Some(GPT_IMAGE_2_MODEL),
Some(GPT_IMAGE_2_5_EDIT_MODEL),
Some(started_at.elapsed().as_millis() as u64),
prompt_chars,
reference_image_count,
@@ -149,7 +149,7 @@ pub async fn create_vector_engine_raw_image_edit(
mime_type: image_mime_type,
} = image;
let mut form = Form::new()
.text("model", GPT_IMAGE_2_MODEL.to_string())
.text("model", GPT_IMAGE_2_5_EDIT_MODEL.to_string())
.text("n", "1".to_string())
.text("prompt", prompt.to_string())
.text("size", format!("{}x{}", options.width, options.height))
@@ -209,7 +209,7 @@ pub async fn create_vector_engine_raw_image_edit(
reference_image_count,
elapsed_ms = started_at.elapsed().as_millis() as u64,
failure_context,
"VectorEngine Raw 图片编辑 HTTP 返回"
"ImageProvider Raw 图片编辑 HTTP 返回"
);
let body = response.text().await.map_err(|error| {
request_error(
@@ -242,7 +242,7 @@ pub async fn create_vector_engine_raw_image_edit(
Some(started_at.elapsed().as_millis() as u64),
prompt_chars,
reference_image_count,
Some(GPT_IMAGE_2_MODEL),
Some(GPT_IMAGE_2_5_EDIT_MODEL),
);
return Err(PlatformImageError::Upstream {
provider: VECTOR_ENGINE_PROVIDER,
@@ -271,7 +271,7 @@ pub async fn create_vector_engine_raw_image_edit(
Some(started_at.elapsed().as_millis() as u64),
prompt_chars,
reference_image_count,
Some(GPT_IMAGE_2_MODEL),
Some(GPT_IMAGE_2_5_EDIT_MODEL),
);
return Err(PlatformImageError::ResponseParse {
provider: VECTOR_ENGINE_PROVIDER,
@@ -298,7 +298,7 @@ pub async fn create_vector_engine_raw_image_edit(
Some(started_at.elapsed().as_millis() as u64),
prompt_chars,
reference_image_count,
Some(GPT_IMAGE_2_MODEL),
Some(GPT_IMAGE_2_5_EDIT_MODEL),
);
return Err(PlatformImageError::MissingImage {
provider: VECTOR_ENGINE_PROVIDER,
@@ -354,7 +354,7 @@ fn request_error(
elapsed_ms = started_at.elapsed().as_millis() as u64,
failure_context = context,
error = %source,
"VectorEngine Raw 图片编辑请求失败"
"ImageProvider Raw 图片编辑请求失败"
);
let audit = build_failure_audit(
url,
@@ -370,7 +370,7 @@ fn request_error(
Some(started_at.elapsed().as_millis() as u64),
prompt_chars,
reference_image_count,
Some(GPT_IMAGE_2_MODEL),
Some(GPT_IMAGE_2_5_EDIT_MODEL),
);
PlatformImageError::Request {
provider: VECTOR_ENGINE_PROVIDER,
@@ -2,21 +2,22 @@ use serde_json::{Map, Value, json};
use super::{
constants::{
GPT_IMAGE_2_5_BUSINESS_NAME, GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL,
GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE,
GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, GPT_IMAGE_2_MODEL,
},
types::{ReferenceImage, VectorEngineImageSettings},
types::{ImageProvider, ImageProviderSettings, ReferenceImage},
};
pub fn build_vector_engine_image_request_body(
pub fn build_image_request_body(
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
_reference_images: &[String],
) -> Value {
build_vector_engine_image_request_body_with_model(
GPT_IMAGE_2_MODEL,
build_image_request_body_with_model(
GPT_IMAGE_2_5_GENERATION_MODEL,
prompt,
negative_prompt,
size,
@@ -25,7 +26,7 @@ pub fn build_vector_engine_image_request_body(
)
}
pub fn build_vector_engine_image_request_body_with_model(
pub fn build_image_request_body_with_model(
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
@@ -33,7 +34,7 @@ pub fn build_vector_engine_image_request_body_with_model(
candidate_count: u32,
_reference_images: &[String],
) -> Value {
let model = normalize_vector_engine_image_model(model);
let model = normalize_image_model(model);
let body = Map::from_iter([
("model".to_string(), Value::String(model.to_string())),
(
@@ -50,7 +51,7 @@ pub fn build_vector_engine_image_request_body_with_model(
Value::Object(body)
}
pub fn build_vector_engine_nanobanana_generate_content_request_body(
pub fn build_nanobanana_generate_content_request_body(
prompt: &str,
negative_prompt: Option<&str>,
aspect_ratio: &str,
@@ -86,17 +87,36 @@ pub fn build_vector_engine_nanobanana_generate_content_request_body(
})
}
pub fn normalize_vector_engine_image_model(model: &str) -> &str {
pub fn normalize_image_model(model: &str) -> &str {
match model.trim() {
"" => GPT_IMAGE_2_MODEL,
value => value,
}
}
/// Resolves a persisted/concrete model to its owning provider without rewriting
/// the model string. Legacy GPT Image 2 values remain readable, but are routed
/// to the current Tiantoken task boundary instead of VectorEngine.
pub fn resolve_image_provider(model: &str) -> Result<ImageProvider, &'static str> {
match normalize_image_model(model) {
GPT_IMAGE_2_MODEL
| GPT_IMAGE_2_C_MODEL
| GPT_IMAGE_2_5_BUSINESS_NAME
| GPT_IMAGE_2_5_GENERATION_MODEL
| GPT_IMAGE_2_5_EDIT_MODEL => Ok(ImageProvider::Tiantoken),
"gemini-3.1-flash-image-preview" => Ok(ImageProvider::VectorEngine),
_ => Err("不支持的图片模型"),
}
}
pub(crate) fn is_gpt_image_2_family_model(model: &str) -> bool {
matches!(
normalize_vector_engine_image_model(model),
GPT_IMAGE_2_MODEL | GPT_IMAGE_2_C_MODEL
normalize_image_model(model),
GPT_IMAGE_2_MODEL
| GPT_IMAGE_2_C_MODEL
| GPT_IMAGE_2_5_BUSINESS_NAME
| GPT_IMAGE_2_5_GENERATION_MODEL
| GPT_IMAGE_2_5_EDIT_MODEL
)
}
@@ -134,7 +154,7 @@ fn normalize_explicit_pixel_size(value: &str) -> String {
fn clamp_gpt_image_2_pixel_size(size: &str) -> String {
const MAX_ASPECT_RATIO: f64 = 3.0;
// 中文注释:这里是 VectorEngine 的共享发送边界,只处理 gpt-image-2 的显式像素尺寸。
// 中文注释:这里是 ImageProvider 的共享发送边界,只处理 gpt-image-2 的显式像素尺寸。
let Some((width, height)) = parse_explicit_pixel_size(size) else {
return size.to_string();
};
@@ -232,7 +252,7 @@ fn normalize_nanobanana_aspect_ratio(aspect_ratio: &str) -> &str {
fn normalize_nanobanana_image_size(image_size: &str) -> &str {
match image_size.trim() {
// 中文注释:nanobanana / Gemini 3.1 的 0.5K 在 VectorEngine 文档中要求传 512。
// 中文注释:nanobanana / Gemini 3.1 的 0.5K 在 ImageProvider 文档中要求传 512。
"512" | "0.5K" => "512",
"2K" => "2K",
"4K" => "4K",
@@ -240,7 +260,7 @@ fn normalize_nanobanana_image_size(image_size: &str) -> &str {
}
}
pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) -> String {
pub fn images_generation_url(settings: &ImageProviderSettings) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/generations", settings.base_url)
} else {
@@ -248,7 +268,7 @@ pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings)
}
}
pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> String {
pub fn images_edit_url(settings: &ImageProviderSettings) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/edits", settings.base_url)
} else {
@@ -256,10 +276,7 @@ pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> St
}
}
pub fn vector_engine_nanobanana_generate_content_url(
settings: &VectorEngineImageSettings,
model: &str,
) -> String {
pub fn nanobanana_generate_content_url(settings: &ImageProviderSettings, model: &str) -> String {
let base_url = settings
.base_url
.trim_end_matches("/v1")
@@ -267,11 +284,11 @@ pub fn vector_engine_nanobanana_generate_content_url(
format!(
"{}/v1beta/models/{}:generateContent",
base_url,
normalize_vector_engine_image_model(model)
normalize_image_model(model)
)
}
pub(crate) fn build_vector_engine_image_edit_request_log_params(
pub(crate) fn build_image_edit_request_log_params(
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
@@ -279,7 +296,7 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params(
candidate_count: u32,
reference_images: &[ReferenceImage],
) -> Value {
let model = normalize_vector_engine_image_model(model);
let model = normalize_image_model(model);
let prompt = prompt.trim();
let negative_prompt = negative_prompt
.map(str::trim)
@@ -330,11 +347,11 @@ pub(crate) fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&
#[cfg(test)]
mod tests {
use super::*;
use crate::vector_engine::types::ReferenceImage;
use crate::image_provider::types::ReferenceImage;
#[test]
fn edit_request_log_params_include_reference_image_sizes_without_secrets_or_bytes() {
let params = build_vector_engine_image_edit_request_log_params(
let params = build_image_edit_request_log_params(
GPT_IMAGE_2_MODEL,
" 拼图参考图重绘 ",
Some(" 文字,水印 "),
@@ -389,7 +406,7 @@ mod tests {
"1152x2048",
"2048x1152",
] {
let body = build_vector_engine_image_request_body_with_model(
let body = build_image_request_body_with_model(
GPT_IMAGE_2_MODEL,
"测试",
None,
@@ -12,7 +12,7 @@ use super::{
};
use std::time::Instant;
pub(crate) async fn handle_vector_engine_response(
pub(crate) async fn handle_image_response(
http_client: &reqwest::Client,
request_url: &str,
response_status: u16,
@@ -53,7 +53,7 @@ pub(crate) async fn handle_vector_engine_response(
retryable = audit.retryable,
message = %message,
raw_excerpt = %raw_excerpt,
"VectorEngine 图片生成上游错误"
"ImageProvider 图片生成上游错误"
);
return Err(PlatformImageError::Upstream {
provider: VECTOR_ENGINE_PROVIDER,
@@ -89,7 +89,7 @@ pub(crate) async fn handle_vector_engine_response(
status = response_status,
raw_excerpt = %truncate_raw(response_text),
message = %error.message(),
"VectorEngine 图片响应解析失败"
"ImageProvider 图片响应解析失败"
);
return Err(error.with_audit(audit));
}
@@ -151,7 +151,7 @@ pub(crate) async fn handle_vector_engine_response(
image_count = generated.images.len(),
elapsed_ms = download_started_at.elapsed().as_millis() as u64,
failure_context,
"VectorEngine 图片下载完成"
"ImageProvider 图片下载完成"
);
return Ok(generated);
}
@@ -159,7 +159,7 @@ pub(crate) async fn handle_vector_engine_response(
if !b64_images.is_empty() {
let mut generated = images_from_base64(task_id, b64_images, candidate_count);
if generated.images.is_empty() {
let message = format!("{failure_context}VectorEngine 返回的 base64 图片无法解码");
let message = format!("{failure_context}ImageProvider 返回的 base64 图片无法解码");
let raw_excerpt = truncate_raw(response_text);
let audit = build_failure_audit(
request_url,
@@ -183,7 +183,7 @@ pub(crate) async fn handle_vector_engine_response(
status = response_status,
image_model,
raw_excerpt = %raw_excerpt,
"VectorEngine 图片 base64 解码失败"
"ImageProvider 图片 base64 解码失败"
);
return Err(PlatformImageError::ResponseParse {
provider: VECTOR_ENGINE_PROVIDER,
@@ -198,12 +198,12 @@ pub(crate) async fn handle_vector_engine_response(
endpoint = %request_url,
image_count = generated.images.len(),
failure_context,
"VectorEngine 图片 base64 解码完成"
"ImageProvider 图片 base64 解码完成"
);
return Ok(generated);
}
let message = format!("{failure_context}VectorEngine 未返回图片地址");
let message = format!("{failure_context}ImageProvider 未返回图片地址");
let audit = build_failure_audit(
request_url,
failure_context,
@@ -225,7 +225,7 @@ pub(crate) async fn handle_vector_engine_response(
endpoint = %request_url,
status = response_status,
raw_excerpt = %truncate_raw(response_text),
"VectorEngine 图片响应未返回图片"
"ImageProvider 图片响应未返回图片"
);
Err(PlatformImageError::MissingImage {
provider: VECTOR_ENGINE_PROVIDER,
@@ -6,7 +6,7 @@ mod tests {
#[test]
fn request_body_normalizes_size_prompt_and_candidate_count() {
let body = build_vector_engine_image_request_body(
let body = build_image_request_body(
" 风雨夜里的街道 ",
Some(" 低清,水印 "),
" 1:1 ",
@@ -14,7 +14,7 @@ mod tests {
&["data:image/png;base64,AAAA".to_string()],
);
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(body["model"], GPT_IMAGE_2_5_GENERATION_MODEL);
assert_eq!(body["size"], "1024x1024");
assert_eq!(body["n"], 4);
assert_eq!(body["prompt"], "风雨夜里的街道\n避免:低清,水印");
@@ -23,13 +23,15 @@ mod tests {
#[test]
fn provider_urls_normalize_root_and_v1_base_urls() {
let root_settings = VectorEngineImageSettings {
let root_settings = ImageProviderSettings {
provider: ImageProvider::VectorEngine,
base_url: "https://vector.example".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
request_deadline: None,
};
let v1_settings = VectorEngineImageSettings {
let v1_settings = ImageProviderSettings {
provider: ImageProvider::VectorEngine,
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
@@ -37,19 +39,19 @@ mod tests {
};
assert_eq!(
vector_engine_images_generation_url(&root_settings),
images_generation_url(&root_settings),
"https://vector.example/v1/images/generations"
);
assert_eq!(
vector_engine_images_generation_url(&v1_settings),
images_generation_url(&v1_settings),
"https://vector.example/v1/images/generations"
);
assert_eq!(
vector_engine_images_edit_url(&root_settings),
images_edit_url(&root_settings),
"https://vector.example/v1/images/edits"
);
assert_eq!(
vector_engine_images_edit_url(&v1_settings),
images_edit_url(&v1_settings),
"https://vector.example/v1/images/edits"
);
}
@@ -94,7 +96,7 @@ mod tests {
latency_ms: Some(987),
prompt_chars: Some(64),
reference_image_count: Some(2),
image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL),
image_model: Some(GPT_IMAGE_2_MODEL),
};
let request_error = PlatformImageError::Request {
@@ -162,7 +164,7 @@ mod tests {
assert!(audit_ref.timeout);
assert!(audit_ref.retryable);
assert_eq!(audit_ref.reference_image_count, Some(2));
assert_eq!(audit_ref.image_model, Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL));
assert_eq!(audit_ref.image_model, Some(GPT_IMAGE_2_MODEL));
assert!(invalid_config.audit().is_none());
assert!(invalid_request.audit().is_none());
}
@@ -0,0 +1,30 @@
use std::time::Duration;
use super::{
error::PlatformImageError,
types::{ImageProviderClient, ImageProviderSettings},
};
pub fn build_image_http_client(
settings: &ImageProviderSettings,
) -> Result<reqwest::Client, PlatformImageError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms.max(1)))
.http1_only()
.pool_max_idle_per_host(0)
.build()
.map_err(|error| PlatformImageError::InvalidConfig {
provider: settings.provider.as_str(),
message: format!(
"构造 {} 图片生成 HTTP 客户端失败:{error}",
settings.provider.as_str()
),
})
}
pub fn build_image_provider_client(
settings: ImageProviderSettings,
) -> Result<ImageProviderClient, PlatformImageError> {
let http_client = build_image_http_client(&settings)?;
Ok(ImageProviderClient::new(settings, http_client))
}
@@ -0,0 +1,85 @@
use super::audit::PlatformImageFailureAudit;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ImageProvider {
VectorEngine,
Tiantoken,
}
impl ImageProvider {
pub const fn as_str(self) -> &'static str {
match self {
Self::VectorEngine => super::constants::VECTOR_ENGINE_PROVIDER,
Self::Tiantoken => super::constants::TIANTOKEN_PROVIDER,
}
}
}
#[derive(Clone, Debug)]
pub struct ImageProviderSettings {
pub provider: ImageProvider,
pub base_url: String,
pub api_key: String,
pub request_timeout_ms: u64,
pub request_deadline: Option<std::time::Instant>,
}
#[derive(Clone)]
pub struct ImageProviderClient {
settings: ImageProviderSettings,
http_client: reqwest::Client,
}
impl std::fmt::Debug for ImageProviderClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ImageProviderClient")
.field("provider", &self.settings.provider)
.field("base_url", &self.settings.base_url)
.field("api_key", &"<redacted>")
.finish()
}
}
impl ImageProviderClient {
pub(crate) fn new(settings: ImageProviderSettings, http_client: reqwest::Client) -> Self {
Self {
settings,
http_client,
}
}
pub fn provider(&self) -> ImageProvider {
self.settings.provider
}
pub fn settings(&self) -> &ImageProviderSettings {
&self.settings
}
pub fn http_client(&self) -> &reqwest::Client {
&self.http_client
}
}
#[derive(Clone, Debug)]
pub struct GeneratedImages {
pub task_id: String,
pub actual_prompt: Option<String>,
pub images: Vec<DownloadedImage>,
pub recovered_failure_audits: Vec<PlatformImageFailureAudit>,
}
#[derive(Clone, Debug)]
pub struct DownloadedImage {
pub bytes: Vec<u8>,
pub mime_type: String,
pub extension: String,
}
#[derive(Clone, Debug)]
pub struct ReferenceImage {
pub bytes: Vec<u8>,
pub mime_type: String,
pub file_name: String,
}
+17 -17
View File
@@ -1,26 +1,26 @@
pub mod generated_asset_sheets;
pub mod generated_assets;
pub mod image_provider;
pub mod pixel_art_snapper;
pub mod vector_engine;
pub use image_provider::{
DownloadedImage, GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, GPT_IMAGE_2_5_BUSINESS_NAME,
GPT_IMAGE_2_5_EDIT_MODEL, GPT_IMAGE_2_5_GENERATION_MODEL, GPT_IMAGE_2_C_MODEL,
GPT_IMAGE_2_MODEL, GeneratedImages, ImageProvider, ImageProviderClient, ImageProviderSettings,
NANOBANANA_2_MODEL, PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint,
RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS,
RawImageEditDimensionError, RawImageEditImage, RawImageEditOptions, RawImageEditResult,
ReferenceImage, TIANTOKEN_PROVIDER, VECTOR_ENGINE_PROVIDER, build_image_http_client,
build_image_provider_client, build_image_request_body,
build_nanobanana_generate_content_request_body, create_image_edit,
create_image_edit_with_references, create_image_edit_with_references_and_model,
create_image_generation, create_image_generation_with_model,
create_nanobanana_generate_content, create_raw_image_edit, download_remote_image,
images_edit_url, images_generation_url, nanobanana_generate_content_url,
resolve_image_provider, validate_raw_image_edit_dimensions,
};
pub use pixel_art_snapper::{
PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, PIXEL_ART_ANALYSIS_COLORS, PIXEL_ART_KMEANS_SAMPLE_LIMIT,
PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art_strict_with_deadline,
snap_pixel_art_with_deadline,
};
pub use vector_engine::{
DownloadedImage, GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL,
GeneratedImages, NANOBANANA_2_MODEL, PlatformImageError, PlatformImageFailureAudit,
PlatformImageStatusHint, RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE,
RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, RawImageEditDimensionError, RawImageEditImage,
RawImageEditOptions, RawImageEditResult, ReferenceImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client,
build_vector_engine_image_request_body,
build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit,
create_vector_engine_image_edit_with_references,
create_vector_engine_image_edit_with_references_and_model,
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
create_vector_engine_nanobanana_generate_content, create_vector_engine_raw_image_edit,
download_remote_image, validate_raw_image_edit_dimensions, vector_engine_images_edit_url,
vector_engine_images_generation_url, vector_engine_nanobanana_generate_content_url,
};
@@ -1,42 +0,0 @@
mod audit;
mod budget;
mod client;
mod constants;
mod curl_transport;
mod error;
mod image_source;
mod payload;
mod raw_edit;
mod request;
mod response;
mod transport;
mod types;
mod util;
pub use audit::PlatformImageFailureAudit;
pub use client::{
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
create_vector_engine_image_edit_with_references_and_model,
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
create_vector_engine_nanobanana_generate_content,
};
pub use constants::{
GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
VECTOR_ENGINE_PROVIDER,
};
pub use error::{PlatformImageError, PlatformImageStatusHint};
pub use image_source::download_remote_image;
pub use raw_edit::{
GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE,
RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, RawImageEditDimensionError, RawImageEditImage,
RawImageEditOptions, RawImageEditResult, create_vector_engine_raw_image_edit,
validate_raw_image_edit_dimensions,
};
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,
vector_engine_images_edit_url, vector_engine_images_generation_url,
vector_engine_nanobanana_generate_content_url,
};
pub use transport::build_vector_engine_image_http_client;
pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings};
@@ -1,19 +0,0 @@
use std::time::Duration;
use super::{
constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, types::VectorEngineImageSettings,
};
pub fn build_vector_engine_image_http_client(
settings: &VectorEngineImageSettings,
) -> Result<reqwest::Client, PlatformImageError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms.max(1)))
.http1_only()
.pool_max_idle_per_host(0)
.build()
.map_err(|error| PlatformImageError::InvalidConfig {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"),
})
}
@@ -1,31 +0,0 @@
use super::audit::PlatformImageFailureAudit;
#[derive(Clone, Debug)]
pub struct VectorEngineImageSettings {
pub base_url: String,
pub api_key: String,
pub request_timeout_ms: u64,
pub request_deadline: Option<std::time::Instant>,
}
#[derive(Clone, Debug)]
pub struct GeneratedImages {
pub task_id: String,
pub actual_prompt: Option<String>,
pub images: Vec<DownloadedImage>,
pub recovered_failure_audits: Vec<PlatformImageFailureAudit>,
}
#[derive(Clone, Debug)]
pub struct DownloadedImage {
pub bytes: Vec<u8>,
pub mime_type: String,
pub extension: String,
}
#[derive(Clone, Debug)]
pub struct ReferenceImage {
pub bytes: Vec<u8>,
pub mime_type: String,
pub file_name: String,
}