216407d93e
冻结资源画布布局数据与 CAS 合同 实现双模式本地 sidecar 安全读写 接入二维拖动、默认排版和跨重启恢复 补齐并发冲突、安全边界和界面测试 同步技术文档与共享决策 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/116 Reviewed-by: 段舒康 <kdletters@qq.com> Co-authored-by: menghao <mh18530625731@163.com> Co-committed-by: menghao <mh18530625731@163.com>
839 lines
28 KiB
Rust
839 lines
28 KiB
Rust
use platform_image::vector_engine::{
|
|
GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, PlatformImageError, ReferenceImage,
|
|
VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client,
|
|
build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model,
|
|
build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit,
|
|
create_vector_engine_image_generation, create_vector_engine_nanobanana_generate_content,
|
|
vector_engine_images_edit_url, vector_engine_images_generation_url,
|
|
vector_engine_nanobanana_generate_content_url,
|
|
};
|
|
use std::{
|
|
sync::{
|
|
Arc,
|
|
atomic::{AtomicUsize, Ordering},
|
|
},
|
|
time::{Duration, Instant},
|
|
};
|
|
use tokio::{
|
|
io::{AsyncReadExt, AsyncWriteExt},
|
|
net::TcpListener,
|
|
sync::Mutex,
|
|
};
|
|
|
|
#[test]
|
|
fn vector_engine_module_exposes_provider_protocol_helpers() {
|
|
let settings = VectorEngineImageSettings {
|
|
base_url: "https://vector.example/v1".to_string(),
|
|
api_key: "test-key".to_string(),
|
|
request_timeout_ms: 1_000,
|
|
request_deadline: None,
|
|
};
|
|
|
|
let body =
|
|
build_vector_engine_image_request_body("雾海神殿", Some("文字,水印"), "16:9", 9, &[]);
|
|
|
|
assert_eq!(GPT_IMAGE_2_MODEL, "gpt-image-2");
|
|
assert_eq!(GPT_IMAGE_2_C_MODEL, "gpt-image-2-c");
|
|
assert_eq!(VECTOR_ENGINE_PROVIDER, "vector-engine");
|
|
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
|
|
assert_eq!(body["size"], "1536x1024");
|
|
assert_eq!(body["n"], 4);
|
|
assert_eq!(body["prompt"], "雾海神殿\n避免:文字,水印");
|
|
assert_eq!(
|
|
vector_engine_images_generation_url(&settings),
|
|
"https://vector.example/v1/images/generations"
|
|
);
|
|
assert_eq!(
|
|
vector_engine_images_edit_url(&settings),
|
|
"https://vector.example/v1/images/edits"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vector_engine_clamps_gpt_image_2_explicit_pixel_sizes_to_its_supported_pixel_budget() {
|
|
let cover = build_vector_engine_image_request_body("宣发首图", None, "720x540", 1, &[]);
|
|
let detail = build_vector_engine_image_request_body("详情单图", None, "720x1280", 1, &[]);
|
|
let poster = build_vector_engine_image_request_body("运营海报", None, "1280x720", 1, &[]);
|
|
|
|
assert_eq!(cover["size"], "944x704");
|
|
assert_eq!(detail["size"], "720x1280");
|
|
assert_eq!(poster["size"], "1280x720");
|
|
}
|
|
|
|
#[test]
|
|
fn vector_engine_normalizes_2k_landscape_spec_size() {
|
|
let body = build_vector_engine_image_request_body("生成规范图", None, "2048x1152", 1, &[]);
|
|
|
|
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
|
|
assert_eq!(body["size"], "2048x1152");
|
|
assert_eq!(body["n"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn vector_engine_request_body_can_use_nanobanana2_model() {
|
|
let body = build_vector_engine_image_request_body_with_model(
|
|
"gemini-3.1-flash-image-preview",
|
|
"生成图标 spritesheet",
|
|
None,
|
|
"512x512",
|
|
1,
|
|
&[],
|
|
);
|
|
|
|
assert_eq!(body["model"], "gemini-3.1-flash-image-preview");
|
|
assert_eq!(body["size"], "512x512");
|
|
assert_eq!(body["n"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn vector_engine_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() {
|
|
let gpt_body = build_vector_engine_image_request_body_with_model(
|
|
GPT_IMAGE_2_MODEL,
|
|
"小尺寸图",
|
|
None,
|
|
"640x640",
|
|
1,
|
|
&[],
|
|
);
|
|
let nanobanana_body = build_vector_engine_image_request_body_with_model(
|
|
"gemini-3.1-flash-image-preview",
|
|
"小尺寸图",
|
|
None,
|
|
"640x640",
|
|
1,
|
|
&[],
|
|
);
|
|
let oversized_gpt_body = build_vector_engine_image_request_body_with_model(
|
|
GPT_IMAGE_2_MODEL,
|
|
"大尺寸图",
|
|
None,
|
|
"4096x4096",
|
|
1,
|
|
&[],
|
|
);
|
|
let fallback_gpt_body = build_vector_engine_image_request_body_with_model(
|
|
GPT_IMAGE_2_C_MODEL,
|
|
"小尺寸图",
|
|
None,
|
|
"640x640",
|
|
1,
|
|
&[],
|
|
);
|
|
|
|
assert_eq!(gpt_body["size"], "816x816");
|
|
assert_eq!(fallback_gpt_body["size"], "816x816");
|
|
assert_eq!(nanobanana_body["size"], "640x640");
|
|
assert_eq!(oversized_gpt_body["size"], "2880x2880");
|
|
}
|
|
|
|
#[test]
|
|
fn vector_engine_gpt_image_2_sizes_always_meet_the_full_provider_envelope() {
|
|
for size in [
|
|
"1x1",
|
|
"720x540",
|
|
"3841x1280",
|
|
"4096x4096",
|
|
"3200x400",
|
|
"16x4096",
|
|
"3840x3840",
|
|
] {
|
|
let body = build_vector_engine_image_request_body("约束测试", None, size, 1, &[]);
|
|
let normalized = body["size"].as_str().expect("size should be a string");
|
|
let (width, height) = normalized
|
|
.split_once('x')
|
|
.expect("gpt-image-2 size should be explicit pixels");
|
|
let width = width.parse::<u32>().expect("width should be numeric");
|
|
let height = height.parse::<u32>().expect("height should be numeric");
|
|
let pixels = u64::from(width) * u64::from(height);
|
|
|
|
assert!(width <= 3_840 && height <= 3_840, "{size} -> {normalized}");
|
|
assert!(width.is_multiple_of(16) && height.is_multiple_of(16));
|
|
assert!((655_360..=8_294_400).contains(&pixels));
|
|
assert!(width.max(height) <= width.min(height) * 3);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn vector_engine_request_body_can_use_nanobanana2_half_k() {
|
|
let body = build_vector_engine_image_request_body_with_model(
|
|
"gemini-3.1-flash-image-preview",
|
|
"生成图标 spritesheet",
|
|
None,
|
|
"512",
|
|
1,
|
|
&[],
|
|
);
|
|
|
|
assert_eq!(body["model"], "gemini-3.1-flash-image-preview");
|
|
assert_eq!(body["size"], "512");
|
|
}
|
|
|
|
#[test]
|
|
fn nanobanana_generate_content_body_carries_aspect_ratio_and_image_size() {
|
|
let body = build_vector_engine_nanobanana_generate_content_request_body(
|
|
"生成角色图",
|
|
Some("文字、水印"),
|
|
"2:3",
|
|
"512",
|
|
&[],
|
|
);
|
|
|
|
assert_eq!(body["contents"][0]["role"], "user");
|
|
assert_eq!(
|
|
body["contents"][0]["parts"][0]["text"],
|
|
"生成角色图\n避免:文字、水印"
|
|
);
|
|
assert_eq!(body["generationConfig"]["responseModalities"][0], "IMAGE");
|
|
assert_eq!(
|
|
body["generationConfig"]["imageConfig"]["aspectRatio"],
|
|
"2:3"
|
|
);
|
|
assert_eq!(body["generationConfig"]["imageConfig"]["imageSize"], "512");
|
|
assert!(body.get("model").is_none());
|
|
assert!(body.get("n").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn nanobanana_generate_content_url_uses_model_path() {
|
|
let settings = VectorEngineImageSettings {
|
|
base_url: "https://vector.example/v1".to_string(),
|
|
api_key: "test-key".to_string(),
|
|
request_timeout_ms: 1_000,
|
|
request_deadline: None,
|
|
};
|
|
|
|
assert_eq!(
|
|
vector_engine_nanobanana_generate_content_url(&settings, "gemini-3.1-flash-image-preview"),
|
|
"https://vector.example/v1beta/models/gemini-3.1-flash-image-preview:generateContent"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
|
|
let listener = TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("mock server should bind");
|
|
let server_addr = listener
|
|
.local_addr()
|
|
.expect("mock server address should be readable");
|
|
let request_count = Arc::new(AtomicUsize::new(0));
|
|
let request_count_for_server = Arc::clone(&request_count);
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
let requests_for_server = Arc::clone(&requests);
|
|
|
|
let server = tokio::spawn(async move {
|
|
loop {
|
|
let Ok((mut stream, _)) = listener.accept().await else {
|
|
break;
|
|
};
|
|
let request_index = request_count_for_server.fetch_add(1, Ordering::SeqCst);
|
|
let requests_for_connection = Arc::clone(&requests_for_server);
|
|
tokio::spawn(async move {
|
|
let request = read_http_request(&mut stream).await;
|
|
requests_for_connection
|
|
.lock()
|
|
.await
|
|
.push(String::from_utf8_lossy(request.as_slice()).into_owned());
|
|
if request_index == 0 {
|
|
tokio::time::sleep(Duration::from_millis(120)).await;
|
|
return;
|
|
}
|
|
|
|
let body = r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#;
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(response.as_bytes()).await;
|
|
});
|
|
}
|
|
});
|
|
|
|
let settings = VectorEngineImageSettings {
|
|
base_url: format!("http://{server_addr}/v1"),
|
|
api_key: "test-key".to_string(),
|
|
request_timeout_ms: 40,
|
|
request_deadline: None,
|
|
};
|
|
let http_client =
|
|
build_vector_engine_image_http_client(&settings).expect("client should build");
|
|
let reference_image = ReferenceImage {
|
|
bytes: b"reference".to_vec(),
|
|
mime_type: "image/png".to_string(),
|
|
file_name: "reference.png".to_string(),
|
|
};
|
|
|
|
let generated = create_vector_engine_image_edit(
|
|
&http_client,
|
|
&settings,
|
|
"测试提示词",
|
|
None,
|
|
"1024x1024",
|
|
&reference_image,
|
|
"测试 VectorEngine 图片编辑失败",
|
|
)
|
|
.await
|
|
.expect("second attempt should return generated image");
|
|
|
|
assert_eq!(generated.images.len(), 1);
|
|
assert_eq!(generated.images[0].mime_type, "image/png");
|
|
assert!(generated.recovered_failure_audits.is_empty());
|
|
assert_eq!(request_count.load(Ordering::SeqCst), 2);
|
|
let requests = requests.lock().await;
|
|
assert!(
|
|
requests
|
|
.iter()
|
|
.all(|request| request.contains("\r\n\r\ngpt-image-2\r\n"))
|
|
);
|
|
server.abort();
|
|
}
|
|
|
|
async fn read_http_request(stream: &mut tokio::net::TcpStream) -> Vec<u8> {
|
|
let mut request = Vec::new();
|
|
let mut buffer = [0_u8; 4096];
|
|
loop {
|
|
let Ok(read) = stream.read(&mut buffer).await else {
|
|
return request;
|
|
};
|
|
if read == 0 {
|
|
return request;
|
|
}
|
|
request.extend_from_slice(&buffer[..read]);
|
|
let Some(header_start) = request.windows(4).position(|window| window == b"\r\n\r\n") else {
|
|
continue;
|
|
};
|
|
let header_end = header_start + 4;
|
|
let headers = String::from_utf8_lossy(&request[..header_end]);
|
|
let content_length = headers
|
|
.lines()
|
|
.find_map(|line| {
|
|
line.strip_prefix("Content-Length:")
|
|
.or_else(|| line.strip_prefix("content-length:"))
|
|
})
|
|
.and_then(|value| value.trim().parse::<usize>().ok())
|
|
.unwrap_or_default();
|
|
let expected_len = header_end + content_length;
|
|
while request.len() < expected_len {
|
|
let Ok(read) = stream.read(&mut buffer).await else {
|
|
return request;
|
|
};
|
|
if read == 0 {
|
|
return request;
|
|
}
|
|
request.extend_from_slice(&buffer[..read]);
|
|
}
|
|
return request;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vector_engine_deadline_clips_stalled_attempt_and_prevents_retry() {
|
|
let listener = TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("mock server should bind");
|
|
let server_addr = listener
|
|
.local_addr()
|
|
.expect("mock server address should be readable");
|
|
let request_count = Arc::new(AtomicUsize::new(0));
|
|
let request_count_for_server = Arc::clone(&request_count);
|
|
let server = tokio::spawn(async move {
|
|
loop {
|
|
let Ok((mut stream, _)) = listener.accept().await else {
|
|
break;
|
|
};
|
|
request_count_for_server.fetch_add(1, Ordering::SeqCst);
|
|
tokio::spawn(async move {
|
|
let mut buffer = [0_u8; 4096];
|
|
let _ = stream.read(&mut buffer).await;
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
});
|
|
}
|
|
});
|
|
|
|
let mut settings = VectorEngineImageSettings {
|
|
base_url: format!("http://{server_addr}/v1"),
|
|
api_key: "test-key".to_string(),
|
|
request_timeout_ms: 5_000,
|
|
request_deadline: None,
|
|
};
|
|
let http_client =
|
|
build_vector_engine_image_http_client(&settings).expect("client should build");
|
|
let started_at = Instant::now();
|
|
settings.request_deadline = Some(started_at + Duration::from_secs(1));
|
|
|
|
let error = create_vector_engine_image_generation(
|
|
&http_client,
|
|
&settings,
|
|
"测试提示词",
|
|
None,
|
|
"1024x1024",
|
|
1,
|
|
&[],
|
|
"测试 VectorEngine 图片生成失败",
|
|
)
|
|
.await
|
|
.expect_err("stalled request should exhaust the shared deadline");
|
|
|
|
assert!(matches!(
|
|
error,
|
|
PlatformImageError::Request { timeout: true, .. }
|
|
));
|
|
assert!(
|
|
started_at.elapsed() < Duration::from_secs(3),
|
|
"attempt 应使用剩余 deadline,而不是完整配置 timeout"
|
|
);
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
while request_count.load(Ordering::SeqCst) == 0 {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("mock server should observe the single attempted request");
|
|
assert_eq!(request_count.load(Ordering::SeqCst), 1);
|
|
server.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn nanobanana_generate_content_posts_native_body_and_reads_inline_data() {
|
|
let listener = TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("mock server should bind");
|
|
let server_addr = listener
|
|
.local_addr()
|
|
.expect("mock server address should be readable");
|
|
let server = tokio::spawn(async move {
|
|
let Ok((mut stream, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
let mut request = Vec::new();
|
|
let mut buffer = [0_u8; 4096];
|
|
loop {
|
|
let Ok(read) = stream.read(&mut buffer).await else {
|
|
return;
|
|
};
|
|
if read == 0 {
|
|
return;
|
|
}
|
|
request.extend_from_slice(&buffer[..read]);
|
|
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
break;
|
|
}
|
|
}
|
|
let request_text = String::from_utf8_lossy(request.as_slice());
|
|
assert!(
|
|
request_text.contains("/v1beta/models/gemini-3.1-flash-image-preview:generateContent")
|
|
);
|
|
assert!(request_text.contains("\"aspectRatio\":\"2:3\""));
|
|
assert!(request_text.contains("\"imageSize\":\"512\""));
|
|
|
|
let body = r#"{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgpyZXN0"}}]}}]}"#;
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(response.as_bytes()).await;
|
|
});
|
|
let settings = VectorEngineImageSettings {
|
|
base_url: format!("http://{}", server_addr),
|
|
api_key: "test-key".to_string(),
|
|
request_timeout_ms: 1_000,
|
|
request_deadline: None,
|
|
};
|
|
let client = build_vector_engine_image_http_client(&settings).expect("client should build");
|
|
|
|
let generated = create_vector_engine_nanobanana_generate_content(
|
|
&client,
|
|
&settings,
|
|
"gemini-3.1-flash-image-preview",
|
|
"生成角色图",
|
|
Some("文字、水印"),
|
|
"2:3",
|
|
"512",
|
|
&[],
|
|
"测试 nanobanana",
|
|
)
|
|
.await
|
|
.expect("nanobanana response should parse");
|
|
|
|
assert_eq!(generated.images.len(), 1);
|
|
assert_eq!(generated.images[0].mime_type, "image/png");
|
|
server.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vector_engine_image_generation_falls_back_after_upstream_502_and_succeeds() {
|
|
let listener = TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("mock server should bind");
|
|
let server_addr = listener
|
|
.local_addr()
|
|
.expect("mock server address should be readable");
|
|
let request_count = Arc::new(AtomicUsize::new(0));
|
|
let request_count_for_server = Arc::clone(&request_count);
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
let requests_for_server = Arc::clone(&requests);
|
|
|
|
let server = tokio::spawn(async move {
|
|
loop {
|
|
let Ok((mut stream, _)) = listener.accept().await else {
|
|
break;
|
|
};
|
|
let request_index = request_count_for_server.fetch_add(1, Ordering::SeqCst);
|
|
let requests_for_connection = Arc::clone(&requests_for_server);
|
|
tokio::spawn(async move {
|
|
let request = read_http_request(&mut stream).await;
|
|
requests_for_connection
|
|
.lock()
|
|
.await
|
|
.push(String::from_utf8_lossy(request.as_slice()).into_owned());
|
|
if request_index == 0 {
|
|
let body = "<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center><hr><center>nginx</center></body></html>";
|
|
let response = format!(
|
|
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(response.as_bytes()).await;
|
|
return;
|
|
}
|
|
|
|
let body = r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#;
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(response.as_bytes()).await;
|
|
});
|
|
}
|
|
});
|
|
|
|
let settings = VectorEngineImageSettings {
|
|
base_url: format!("http://{server_addr}/v1"),
|
|
api_key: "test-key".to_string(),
|
|
request_timeout_ms: 1_000,
|
|
request_deadline: None,
|
|
};
|
|
let http_client =
|
|
build_vector_engine_image_http_client(&settings).expect("client should build");
|
|
|
|
let generated = create_vector_engine_image_generation(
|
|
&http_client,
|
|
&settings,
|
|
"测试提示词",
|
|
None,
|
|
"1024x1024",
|
|
1,
|
|
&[],
|
|
"测试 VectorEngine 图片生成失败",
|
|
)
|
|
.await
|
|
.expect("second attempt should return generated image");
|
|
|
|
assert_eq!(generated.images.len(), 1);
|
|
assert_eq!(generated.images[0].mime_type, "image/png");
|
|
assert_eq!(generated.recovered_failure_audits.len(), 1);
|
|
assert_eq!(
|
|
generated.recovered_failure_audits[0].image_model,
|
|
Some(GPT_IMAGE_2_MODEL)
|
|
);
|
|
assert_eq!(request_count.load(Ordering::SeqCst), 2);
|
|
let requests = requests.lock().await;
|
|
assert!(requests[0].contains("\"model\":\"gpt-image-2\""));
|
|
assert!(requests[1].contains("\"model\":\"gpt-image-2-c\""));
|
|
server.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vector_engine_image_generation_uses_gpt_image_2_without_fallback_on_success() {
|
|
let (base_url, server, requests) = start_http_response_sequence(vec![MockResponse {
|
|
status: "200 OK",
|
|
content_type: "application/json",
|
|
body: r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#,
|
|
}])
|
|
.await;
|
|
let settings = test_vector_engine_settings(base_url);
|
|
let http_client =
|
|
build_vector_engine_image_http_client(&settings).expect("client should build");
|
|
|
|
let generated = create_vector_engine_image_generation(
|
|
&http_client,
|
|
&settings,
|
|
"测试提示词",
|
|
None,
|
|
"1024x1024",
|
|
1,
|
|
&[],
|
|
"测试 VectorEngine 图片生成失败",
|
|
)
|
|
.await
|
|
.expect("preferred model should generate image");
|
|
|
|
assert_eq!(generated.images.len(), 1);
|
|
assert!(generated.recovered_failure_audits.is_empty());
|
|
let requests = requests.lock().await;
|
|
assert_eq!(requests.len(), 1);
|
|
assert!(requests[0].contains("\"model\":\"gpt-image-2\""));
|
|
server.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vector_engine_image_edit_falls_back_when_preferred_model_is_unsupported() {
|
|
let (base_url, server, requests) = start_http_response_sequence(vec![
|
|
MockResponse {
|
|
status: "400 Bad Request",
|
|
content_type: "application/json",
|
|
body: r#"{"error":{"message":"model gpt-image-2 is not supported"}}"#,
|
|
},
|
|
MockResponse {
|
|
status: "200 OK",
|
|
content_type: "application/json",
|
|
body: r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#,
|
|
},
|
|
])
|
|
.await;
|
|
let settings = test_vector_engine_settings(base_url);
|
|
let http_client =
|
|
build_vector_engine_image_http_client(&settings).expect("client should build");
|
|
let reference = ReferenceImage {
|
|
bytes: b"reference".to_vec(),
|
|
mime_type: "image/png".to_string(),
|
|
file_name: "reference.png".to_string(),
|
|
};
|
|
|
|
let generated = create_vector_engine_image_edit(
|
|
&http_client,
|
|
&settings,
|
|
"测试提示词",
|
|
None,
|
|
"1024x1024",
|
|
&reference,
|
|
"测试 VectorEngine 图片编辑失败",
|
|
)
|
|
.await
|
|
.expect("fallback model should recover unsupported preferred model");
|
|
|
|
assert_eq!(generated.images.len(), 1);
|
|
assert_eq!(generated.recovered_failure_audits.len(), 1);
|
|
assert_eq!(
|
|
generated.recovered_failure_audits[0].image_model,
|
|
Some(GPT_IMAGE_2_MODEL)
|
|
);
|
|
let requests = requests.lock().await;
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(requests[0].contains("\r\n\r\ngpt-image-2\r\n"));
|
|
assert!(requests[1].contains("\r\n\r\ngpt-image-2-c\r\n"));
|
|
server.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vector_engine_image_generation_does_not_fallback_on_auth_failure() {
|
|
let (base_url, server, requests) = start_http_response_sequence(vec![MockResponse {
|
|
status: "401 Unauthorized",
|
|
content_type: "application/json",
|
|
body: r#"{"error":{"message":"invalid api key"}}"#,
|
|
}])
|
|
.await;
|
|
let settings = test_vector_engine_settings(base_url);
|
|
let http_client =
|
|
build_vector_engine_image_http_client(&settings).expect("client should build");
|
|
|
|
let error = create_vector_engine_image_generation(
|
|
&http_client,
|
|
&settings,
|
|
"测试提示词",
|
|
None,
|
|
"1024x1024",
|
|
1,
|
|
&[],
|
|
"测试 VectorEngine 图片生成失败",
|
|
)
|
|
.await
|
|
.expect_err("authentication failure should remain terminal");
|
|
|
|
assert!(matches!(
|
|
error,
|
|
PlatformImageError::Upstream {
|
|
upstream_status: 401,
|
|
..
|
|
}
|
|
));
|
|
let requests = requests.lock().await;
|
|
assert_eq!(requests.len(), 1);
|
|
server.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vector_engine_image_generation_falls_back_after_non_image_base64_response() {
|
|
let (base_url, server, requests) = start_http_response_sequence(vec![
|
|
MockResponse {
|
|
status: "200 OK",
|
|
content_type: "application/json",
|
|
body: r#"{"data":[{"b64_json":"bm90IGFuIGltYWdl"}]}"#,
|
|
},
|
|
MockResponse {
|
|
status: "200 OK",
|
|
content_type: "application/json",
|
|
body: r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#,
|
|
},
|
|
])
|
|
.await;
|
|
let settings = test_vector_engine_settings(base_url);
|
|
let http_client =
|
|
build_vector_engine_image_http_client(&settings).expect("client should build");
|
|
|
|
let generated = create_vector_engine_image_generation(
|
|
&http_client,
|
|
&settings,
|
|
"测试提示词",
|
|
None,
|
|
"1024x1024",
|
|
1,
|
|
&[],
|
|
"测试 VectorEngine 图片生成失败",
|
|
)
|
|
.await
|
|
.expect("fallback model should recover invalid preferred response");
|
|
|
|
assert_eq!(generated.images.len(), 1);
|
|
assert_eq!(generated.recovered_failure_audits.len(), 1);
|
|
assert_eq!(
|
|
generated.recovered_failure_audits[0].failure_stage,
|
|
"response_parse"
|
|
);
|
|
let requests = requests.lock().await;
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(requests[0].contains("\"model\":\"gpt-image-2\""));
|
|
assert!(requests[1].contains("\"model\":\"gpt-image-2-c\""));
|
|
server.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vector_engine_image_generation_preserves_primary_audit_when_fallback_also_fails() {
|
|
let (base_url, server, requests) = start_http_response_sequence(vec![
|
|
MockResponse {
|
|
status: "502 Bad Gateway",
|
|
content_type: "text/html",
|
|
body: "<h1>502 Bad Gateway</h1>",
|
|
},
|
|
MockResponse {
|
|
status: "401 Unauthorized",
|
|
content_type: "application/json",
|
|
body: r#"{"error":{"message":"invalid api key"}}"#,
|
|
},
|
|
])
|
|
.await;
|
|
let settings = test_vector_engine_settings(base_url);
|
|
let http_client =
|
|
build_vector_engine_image_http_client(&settings).expect("client should build");
|
|
|
|
let error = create_vector_engine_image_generation(
|
|
&http_client,
|
|
&settings,
|
|
"测试提示词",
|
|
None,
|
|
"1024x1024",
|
|
1,
|
|
&[],
|
|
"测试 VectorEngine 图片生成失败",
|
|
)
|
|
.await
|
|
.expect_err("fallback authentication failure should remain terminal");
|
|
|
|
assert_eq!(error.recovered_failure_audits().len(), 1);
|
|
assert_eq!(
|
|
error.recovered_failure_audits()[0].image_model,
|
|
Some(GPT_IMAGE_2_MODEL)
|
|
);
|
|
assert_eq!(
|
|
error.audit().and_then(|audit| audit.image_model),
|
|
Some(GPT_IMAGE_2_C_MODEL)
|
|
);
|
|
let requests = requests.lock().await;
|
|
assert_eq!(requests.len(), 2);
|
|
server.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vector_engine_image_generation_does_not_fallback_on_safety_parse_failure() {
|
|
let (base_url, server, requests) = start_http_response_sequence(vec![MockResponse {
|
|
status: "200 OK",
|
|
content_type: "application/json",
|
|
body: "safety refusal: 内容审核拒绝",
|
|
}])
|
|
.await;
|
|
let settings = test_vector_engine_settings(base_url);
|
|
let http_client =
|
|
build_vector_engine_image_http_client(&settings).expect("client should build");
|
|
|
|
let error = create_vector_engine_image_generation(
|
|
&http_client,
|
|
&settings,
|
|
"测试提示词",
|
|
None,
|
|
"1024x1024",
|
|
1,
|
|
&[],
|
|
"测试 VectorEngine 图片生成失败",
|
|
)
|
|
.await
|
|
.expect_err("content rejection should not switch models");
|
|
|
|
assert!(matches!(error, PlatformImageError::ResponseParse { .. }));
|
|
let requests = requests.lock().await;
|
|
assert_eq!(requests.len(), 1);
|
|
server.abort();
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct MockResponse {
|
|
status: &'static str,
|
|
content_type: &'static str,
|
|
body: &'static str,
|
|
}
|
|
|
|
async fn start_http_response_sequence(
|
|
responses: Vec<MockResponse>,
|
|
) -> (String, tokio::task::JoinHandle<()>, Arc<Mutex<Vec<String>>>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("mock server should bind");
|
|
let server_addr = listener
|
|
.local_addr()
|
|
.expect("mock server address should be readable");
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
let requests_for_server = Arc::clone(&requests);
|
|
let server = tokio::spawn(async move {
|
|
for response_spec in responses {
|
|
let Ok((mut stream, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
let request = read_http_request(&mut stream).await;
|
|
requests_for_server
|
|
.lock()
|
|
.await
|
|
.push(String::from_utf8_lossy(request.as_slice()).into_owned());
|
|
let response = format!(
|
|
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
response_spec.status,
|
|
response_spec.content_type,
|
|
response_spec.body.len(),
|
|
response_spec.body,
|
|
);
|
|
let _ = stream.write_all(response.as_bytes()).await;
|
|
}
|
|
});
|
|
(format!("http://{server_addr}/v1"), server, requests)
|
|
}
|
|
|
|
fn test_vector_engine_settings(base_url: String) -> VectorEngineImageSettings {
|
|
VectorEngineImageSettings {
|
|
base_url,
|
|
api_key: "test-key".to_string(),
|
|
request_timeout_ms: 1_000,
|
|
request_deadline: None,
|
|
}
|
|
}
|