调整 VectorEngine 图片模型兜底顺序
统一 gpt-image-2 首发并在符合条件的失败后切换 gpt-image-2-c 保留总尝试预算、worker deadline 与首选失败审计 补齐响应图片校验、脚本兜底和回归测试 同步后端架构、开发运维和项目共享记忆文档
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: gpt-image-2-apimart
|
||||
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs gpt-image-2 request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
||||
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow with gpt-image-2-c fallback. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
||||
---
|
||||
|
||||
# gpt-image-2 VectorEngine
|
||||
|
||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2` path. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine image path. Keep the product/price model identifier and primary provider request as `gpt-image-2`, then fall back once to `gpt-image-2-c` for eligible provider failures. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -65,9 +65,9 @@ size=1024x1024
|
||||
image=@reference.png
|
||||
```
|
||||
|
||||
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
|
||||
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths prefer `gpt-image-2`; on an eligible upstream/model failure they retry with `gpt-image-2-c`. Do not fall back for authentication, local validation, request-budget exhaustion, uncertain send/connection failure, content-safety rejection, or a generated image URL download failure. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
|
||||
|
||||
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine GPT-image-2 currently returns synchronously; do not poll APIMart task endpoints.
|
||||
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints.
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
|
||||
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
|
||||
const defaultTimeoutMs = 1000000;
|
||||
const preferredImageModel = 'gpt-image-2';
|
||||
const fallbackImageModel = 'gpt-image-2-c';
|
||||
|
||||
const prompts = [
|
||||
{
|
||||
@@ -165,6 +167,25 @@ function extractBase64Images(payload) {
|
||||
return values;
|
||||
}
|
||||
|
||||
function decodeStrictBase64Image(raw) {
|
||||
const normalized = String(raw || '').trim();
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.length % 4 !== 0 ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const bytes = Buffer.from(normalized, 'base64');
|
||||
return bytes.length > 0 &&
|
||||
bytes.toString('base64') === normalized &&
|
||||
inferExtensionFromBytes(bytes)
|
||||
? bytes
|
||||
: null;
|
||||
}
|
||||
|
||||
function inferExtensionFromContentType(contentType) {
|
||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalized === 'image/png') {
|
||||
@@ -192,7 +213,13 @@ function inferExtensionFromBytes(bytes) {
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
return 'png';
|
||||
if (
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF87a' ||
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF89a'
|
||||
) {
|
||||
return 'gif';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options, timeoutMs) {
|
||||
@@ -205,9 +232,20 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
const error = new Error(
|
||||
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
|
||||
);
|
||||
error.vectorEngineStatus = response.status;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
@@ -218,6 +256,83 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldFallbackImageModel(error) {
|
||||
const raw = `${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
||||
if (error?.vectorEngineResponseParse) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const status = Number(error?.vectorEngineStatus || 0);
|
||||
if (status === 408 || status >= 500) {
|
||||
return true;
|
||||
}
|
||||
if (status === 429) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const mentionsImageModel =
|
||||
raw.includes('model') ||
|
||||
raw.includes('模型') ||
|
||||
raw.includes(preferredImageModel) ||
|
||||
raw.includes(fallbackImageModel);
|
||||
return (
|
||||
[400, 404, 422].includes(status) &&
|
||||
mentionsImageModel &&
|
||||
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
|
||||
raw,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function containsContentRejection(raw) {
|
||||
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
|
||||
raw,
|
||||
);
|
||||
}
|
||||
|
||||
async function requestImagePayload(env, entry) {
|
||||
for (const model of [preferredImageModel, fallbackImageModel]) {
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt: buildPrompt(entry),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
|
||||
if (
|
||||
extractImageUrls(payload)[0] ||
|
||||
base64Image
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
const error = new Error(`VectorEngine returned no image for ${entry.id}`);
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||
throw error;
|
||||
} catch (error) {
|
||||
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`VectorEngine returned no image for ${entry.id}`);
|
||||
}
|
||||
|
||||
async function downloadUrl(url, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
@@ -244,25 +359,7 @@ async function downloadUrl(url, timeoutMs) {
|
||||
}
|
||||
|
||||
async function generateOne(env, entry, outDir) {
|
||||
const requestBody = {
|
||||
model: 'gpt-image-2',
|
||||
prompt: buildPrompt(entry),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const payload = await requestImagePayload(env, entry);
|
||||
|
||||
const urls = extractImageUrls(payload);
|
||||
const b64Images = extractBase64Images(payload);
|
||||
@@ -271,7 +368,10 @@ async function generateOne(env, entry, outDir) {
|
||||
if (urls[0]) {
|
||||
image = await downloadUrl(urls[0], env.timeoutMs);
|
||||
} else if (b64Images[0]) {
|
||||
const bytes = Buffer.from(b64Images[0], 'base64');
|
||||
const bytes = decodeStrictBase64Image(b64Images[0]);
|
||||
if (!bytes) {
|
||||
throw new Error(`VectorEngine returned invalid base64 image for ${entry.id}`);
|
||||
}
|
||||
image = {
|
||||
bytes,
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
@@ -304,8 +404,9 @@ if (dryRun) {
|
||||
requests: selectedPrompts.map((entry) => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
fallbackModel: fallbackImageModel,
|
||||
body: {
|
||||
model: 'gpt-image-2',
|
||||
model: preferredImageModel,
|
||||
prompt: buildPrompt(entry),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
|
||||
@@ -14,6 +14,8 @@ const promptsPath = path.join(
|
||||
);
|
||||
const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates');
|
||||
const defaultTimeoutMs = 1000000;
|
||||
const preferredImageModel = 'gpt-image-2';
|
||||
const fallbackImageModel = 'gpt-image-2-c';
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
@@ -131,6 +133,25 @@ function extractBase64Images(payload) {
|
||||
return values;
|
||||
}
|
||||
|
||||
function decodeStrictBase64Image(raw) {
|
||||
const normalized = String(raw || '').trim();
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.length % 4 !== 0 ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const bytes = Buffer.from(normalized, 'base64');
|
||||
return bytes.length > 0 &&
|
||||
bytes.toString('base64') === normalized &&
|
||||
inferExtensionFromBytes(bytes)
|
||||
? bytes
|
||||
: null;
|
||||
}
|
||||
|
||||
function inferExtensionFromContentType(contentType) {
|
||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalized === 'image/png') {
|
||||
@@ -158,7 +179,13 @@ function inferExtensionFromBytes(bytes) {
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
return 'png';
|
||||
if (
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF87a' ||
|
||||
bytes.subarray(0, 6).toString('ascii') === 'GIF89a'
|
||||
) {
|
||||
return 'gif';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options, timeoutMs) {
|
||||
@@ -171,9 +198,20 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
const error = new Error(
|
||||
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
|
||||
);
|
||||
error.vectorEngineStatus = response.status;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = text;
|
||||
throw error;
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
@@ -184,6 +222,83 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldFallbackImageModel(error) {
|
||||
const raw = `${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
||||
if (error?.vectorEngineResponseParse) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const status = Number(error?.vectorEngineStatus || 0);
|
||||
if (status === 408 || status >= 500) {
|
||||
return true;
|
||||
}
|
||||
if (status === 429) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const mentionsImageModel =
|
||||
raw.includes('model') ||
|
||||
raw.includes('模型') ||
|
||||
raw.includes(preferredImageModel) ||
|
||||
raw.includes(fallbackImageModel);
|
||||
return (
|
||||
[400, 404, 422].includes(status) &&
|
||||
mentionsImageModel &&
|
||||
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
|
||||
raw,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function containsContentRejection(raw) {
|
||||
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
|
||||
raw,
|
||||
);
|
||||
}
|
||||
|
||||
async function requestImagePayload(env, template) {
|
||||
for (const model of [preferredImageModel, fallbackImageModel]) {
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
|
||||
if (
|
||||
extractImageUrls(payload)[0] ||
|
||||
base64Image
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
const error = new Error(`VectorEngine returned no image for ${template.id}`);
|
||||
error.vectorEngineResponseParse = true;
|
||||
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||
throw error;
|
||||
} catch (error) {
|
||||
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`VectorEngine returned no image for ${template.id}`);
|
||||
}
|
||||
|
||||
async function downloadUrl(url, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
@@ -210,25 +325,7 @@ async function downloadUrl(url, timeoutMs) {
|
||||
}
|
||||
|
||||
async function generateOne(env, template, outDir) {
|
||||
const requestBody = {
|
||||
model: 'gpt-image-2',
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const payload = await requestImagePayload(env, template);
|
||||
|
||||
const urls = extractImageUrls(payload);
|
||||
const b64Images = extractBase64Images(payload);
|
||||
@@ -237,7 +334,10 @@ async function generateOne(env, template, outDir) {
|
||||
if (urls[0]) {
|
||||
image = await downloadUrl(urls[0], env.timeoutMs);
|
||||
} else if (b64Images[0]) {
|
||||
const bytes = Buffer.from(b64Images[0], 'base64');
|
||||
const bytes = decodeStrictBase64Image(b64Images[0]);
|
||||
if (!bytes) {
|
||||
throw new Error(`VectorEngine returned invalid base64 image for ${template.id}`);
|
||||
}
|
||||
image = {
|
||||
bytes,
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
@@ -274,8 +374,9 @@ if (dryRun) {
|
||||
requests: selectedTemplates.map((template) => ({
|
||||
id: template.id,
|
||||
title: template.title,
|
||||
fallbackModel: fallbackImageModel,
|
||||
body: {
|
||||
model: 'gpt-image-2',
|
||||
model: preferredImageModel,
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
|
||||
@@ -4366,3 +4366,10 @@
|
||||
## 2026-07-20 VectorEngine 图片任务预算收口到 worker deadline
|
||||
|
||||
- 决策:`editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation` 和 `editor_ui_design_asset_extraction` 使用默认 `1800s` long job 预算。worker 从同一起点计算绝对 job deadline,并向 provider 提前保留 `min(60s, job 预算 / 2)` 作为审计、OSS 和终态写回窗口。deadline 只经进程内 `RequestContext` 传递;VectorEngine 单 attempt 取配置 timeout 与剩余预算的较小值,退避加下一次 attempt 无法落在同一 deadline 内时停止重试,参考图和响应图片下载也受同一 deadline 限制。普通 HTTP / `inline` 保持无 deadline 行为;`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 默认仍为 `1000000`,配置加载层允许显式值更低。lease 续租 / fencing、迟到写回仲裁、attempt 耗尽和原子退款语义不变。
|
||||
|
||||
## 2026-07-21 VectorEngine 图片首选 gpt-image-2 并以 gpt-image-2-c 兜底
|
||||
|
||||
- 决策:前端、DTO、计费配置、持久化和 `platform-image` 的 `/v1/images/generations` / `/v1/images/edits` provider 首选请求统一使用 `gpt-image-2`;符合条件时才回退到兜底模型 `gpt-image-2-c`。不在业务 handler、前端或价格表中新增平行模型。
|
||||
- 回退边界:明确模型不存在 / 不支持、408、非内容拒绝类 429、5xx、响应解析失败或非拒绝类缺图可以切模型;401 / 403、普通参数 / 内容安全拒绝、本地配置与参考图错误、发送 / 连接错误、request budget 耗尽和已生成图片下载失败不切模型。一次业务请求总发送上限仍为 5 次,两个模型共享同一 worker provider deadline 和 attempt 预算。
|
||||
- 观测边界:审计 `image_model` 记录实际 provider attempt;首选 `gpt-image-2` 失败但兜底 `gpt-image-2-c` 恢复成功时,首选失败仍写入 `external_api_call_failure`,最终成功运行摘要记录 `recoveredFailureCount`。日志用 `fallback_from_model` / `fallback_to_model` 标识切换,不改变业务模型、扣费、素材 metadata 或终态语义。
|
||||
- 脚本边界:仓库 `gpt-image-2-apimart` skill 的现役生成脚本采用同一首选 / 回退顺序;认证、请求发送不确定错误和下载失败不重新生图,避免重复上游成本。
|
||||
|
||||
@@ -1510,7 +1510,7 @@
|
||||
|
||||
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。
|
||||
- 原因:2026-05-21 后 GPT-image-2 图片生成按 VectorEngine 创建/编辑接口分流;2026-07-05 后创意 Agent 文本链路也改为 VectorEngine Chat Completions `gpt-5.4-mini`,APIMart 不再作为当前创意 Agent 来源。
|
||||
- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,模型为 `gpt-image-2`。
|
||||
- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,业务 / 计费与 provider 首发模型均为 `gpt-image-2`,仅在符合条件的 provider 失败后切到兜底模型 `gpt-image-2-c`。
|
||||
- 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。
|
||||
- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ npm run check:server-rs-ddd
|
||||
2. Adapter 输入应显式包含 provider、prompt、reference images、OSS prefix/path/file name、asset kind、entity kind/id、slot、owner/profile/source job、metadata 和可选透明背景后处理。
|
||||
3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。
|
||||
4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。
|
||||
5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。
|
||||
5. 图片 provider 协议不再放在玩法模块里实现。产品、计费、DTO、持久化和 VectorEngine 创建 / 编辑首选请求统一使用 `gpt-image-2`;只有符合回退条件时,provider 边界才切到兜底模型 `gpt-image-2-c`。URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。
|
||||
6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。generated 私有对象上传时必须由 OSS 对象头承载浏览器 / CDN 缓存策略,默认写入 `Cache-Control: public, max-age=31536000, immutable`,不得改成 api-server 本地磁盘静态资源兜底。
|
||||
7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。
|
||||
8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。
|
||||
@@ -240,7 +240,7 @@ npm run check:server-rs-ddd
|
||||
## 外部服务与资产
|
||||
|
||||
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。
|
||||
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。
|
||||
- 图片生成:VectorEngine 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;逻辑 SKU 与 provider 首选模型均固定为 `gpt-image-2`,只在明确模型不可用、408 / 非拒绝类 429 / 5xx、响应解析失败或非拒绝类缺图时切换兜底模型 `gpt-image-2-c`。401 / 403、普通参数或安全拒绝、本地配置 / 参考图错误、无法确认上游是否已受理的发送错误、request budget 耗尽和生成成功后的图片下载失败不得切模型。一次业务请求总发送上限仍为 5 次;切换兜底模型会消耗后续 attempt,不允许两个模型各重试 5 次。`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id、结果摘要和 recovered failure 数量;首选模型失败但兜底模型成功时,首选失败仍落 `external_api_call_failure`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`fallback_from_model`、`fallback_to_model`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。
|
||||
- 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。
|
||||
- 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。
|
||||
- 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。
|
||||
|
||||
@@ -153,9 +153,9 @@ spacetime sql <database> "SELECT * FROM runtime_setting LIMIT 1" --server http:/
|
||||
|
||||
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.6.1`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;遇到版本不匹配时直接执行 `spacetime version install <version> && spacetime version use <version>`,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。
|
||||
|
||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 VectorEngine 生成链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
|
||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 VectorEngine 生成链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 VectorEngine provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
|
||||
|
||||
VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会对同一请求最多发送 5 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。worker 从 job 开始的同一时钟起点计算绝对 deadline,常规保留最后 `60` 秒给审计、OSS 和终态写回;job 预算小于 `120` 秒时保留一半。VectorEngine 单次 attempt timeout 取配置值和剩余 provider 预算的较小值;退避后已没有下一次 attempt 的预算时立即停止重试。该 deadline 覆盖参考图、provider 请求 / 响应和响应图片下载的整次 provider future,但只在 worker 进程内通过 `RequestContext` 传递;普通 HTTP / `inline` 没有该 deadline,继续保持原有 timeout 和重试行为。日志中 `VectorEngine 图片请求发送失败,准备重试` 或 `VectorEngine 图片上游状态可重试,准备重试` 表示本次失败确有预算进入下一次尝试;预算耗尽或最终仍失败时才会写入 `external_api_call_failure` 并返回 504 / 502。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。这项收口不修改 lease 续租 / fencing、迟到写回仲裁、attempt 耗尽与原子退款语义。
|
||||
VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`connect`、libcurl 35 SSL connect reset、libcurl 56 receive error / `unexpected eof while reading`、recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会在一次业务请求总上限 5 次内处理;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。首个 provider attempt 使用 `gpt-image-2`;明确模型不可用、408 / 非拒绝类 429 / 5xx、响应解析失败或非拒绝类缺图时,下一 attempt 直接切兜底模型 `gpt-image-2-c`,之后只在剩余次数内重试兜底模型。发送 / 连接错误无法确认上游是否已受理,只重试同一首选模型,不切模型;认证、普通参数、安全拒绝、图片下载和 budget 错误同样不切。worker 从 job 开始的同一时钟起点计算绝对 deadline,常规保留最后 `60` 秒给审计、OSS 和终态写回;job 预算小于 `120` 秒时保留一半。VectorEngine 单次 attempt timeout 取配置值和剩余 provider 预算的较小值;退避或模型切换后已没有下一次 attempt 的预算时立即停止。该 deadline 覆盖参考图、provider 请求 / 响应和响应图片下载的整次 provider future,但只在 worker 进程内通过 `RequestContext` 传递;普通 HTTP / `inline` 没有该 deadline,继续保持原有 timeout 和重试行为。日志中 `VectorEngine 首选图片模型失败,切换兼容模型` 会携带 `fallback_from_model` / `fallback_to_model`;即使回退成功,首选模型错误仍写入 `external_api_call_failure`,成功运行摘要的 `recoveredFailureCount` 同时递增。排查生产失败时应同时统计 fallback / retry 日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。这项收口不修改 lease 续租 / fencing、迟到写回仲裁、attempt 耗尽与原子退款语义。
|
||||
|
||||
图片编辑器生成属于持久队列长任务:提交接口返回 job 后,前端通过 `/api/runtime/external-generation/jobs/{jobId}` 与编辑器项目资源状态收敛。生产排查小程序或 WebView `Failed to fetch` 时,若 Nginx access log 为 `499`、`upstream_status=-`,先按提交请求的 `request_id`、job id、worker 日志和 `external_api_call_failure` 对齐真实任务,不把客户端断开直接判定为 provider 失败。
|
||||
|
||||
@@ -603,7 +603,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
|
||||
- debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。
|
||||
- api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
|
||||
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
|
||||
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt;图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
|
||||
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、实际 provider `image_model`、request_params 和 raw_excerpt;发生模型回退时另带 `fallback_from_model` / `fallback_to_model`。图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage / imageModel 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是模型不可用、限流、超时、解析失败还是未返回图片。
|
||||
- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;角色动画逐帧额外按 `frame_index`、`operation=source_put|final_put|final_head`、`attempt/max_attempts`、`will_retry`、`oss_code` 和 `oss_request_id` 对齐同一对象的请求尝试。`请求 OSS 失败` 时,`timeout/connect/transport=true` 表示传输类失败,OSS PutObject 的 `status=400, oss_code=RequestTimeout, timeout=true`、`status=429` 或 `500–599` 表示暂时性失败,PUT 的 `status=400`、`oss_code` 为空且 `timeout=true` 或 `transport=true`(message 含「错误响应体读取失败」,即 400 错误体读取超时/断流)也会重试;除这两类例外外,其他 400、401/403/404、配置、URL 和签名错误是确定性失败,不会重试。最终帧 HEAD 失败只会重试 HEAD,不会重复 PUT。日志不得包含 AccessKey、policy、signature、Authorization header、完整 signed URL 或 OSS 错误响应体;`oss_request_id` 只用于关联 OSS 服务端排障。排查 generated 图片重复下载时,先确认前端输入是否为 `/generated-*` legacy path 或可归一化的 `https://*.oss-*.aliyuncs.com/generated-*`;正确链路应先调 `/api/assets/read-url`,再由浏览器请求 signed URL,且同一路径、同一 `refreshKey` 版本和未临近过期的 signed URL 应复用。新上传 generated 私有对象应带 `Cache-Control: public, max-age=31536000, immutable`;旧对象若只有 `ETag` / `Last-Modified`,浏览器会走 304 协商缓存而不是长期强缓存,可通过刷新 OSS 元数据或 CDN 配置补齐。
|
||||
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
|
||||
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
|
||||
|
||||
@@ -434,6 +434,9 @@ async fn map_platform_image_result(
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
match result {
|
||||
Ok(value) => {
|
||||
for audit in &value.recovered_failure_audits {
|
||||
record_openai_image_failure_audit_if_configured(settings, audit).await;
|
||||
}
|
||||
if let Some(state) = settings.external_api_audit_state.as_ref() {
|
||||
record_external_generation_run_after_success(
|
||||
state,
|
||||
@@ -448,6 +451,7 @@ async fn map_platform_image_result(
|
||||
Some(json!({
|
||||
"imageCount": value.images.len(),
|
||||
"actualPromptChars": value.actual_prompt.as_ref().map(|prompt| prompt.chars().count()),
|
||||
"recoveredFailureCount": value.recovered_failure_audits.len(),
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
@@ -455,6 +459,9 @@ async fn map_platform_image_result(
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) => {
|
||||
for audit in error.recovered_failure_audits() {
|
||||
record_openai_image_failure_audit_if_configured(settings, audit).await;
|
||||
}
|
||||
if let Some(state) = settings.external_api_audit_state.as_ref() {
|
||||
record_external_generation_run_after_success(
|
||||
state,
|
||||
@@ -478,14 +485,21 @@ async fn map_platform_image_result(
|
||||
pub(crate) async fn record_openai_image_failure_if_configured(
|
||||
settings: &OpenAiImageSettings,
|
||||
error: &PlatformImageError,
|
||||
) {
|
||||
let Some(audit) = error.audit() else {
|
||||
return;
|
||||
};
|
||||
record_openai_image_failure_audit_if_configured(settings, audit).await;
|
||||
}
|
||||
|
||||
async fn record_openai_image_failure_audit_if_configured(
|
||||
settings: &OpenAiImageSettings,
|
||||
audit: &platform_image::PlatformImageFailureAudit,
|
||||
) {
|
||||
let Some(state) = settings.external_api_audit_state.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(draft) = build_openai_image_failure_audit_draft(error) else {
|
||||
return;
|
||||
};
|
||||
let draft = draft
|
||||
let draft = build_external_api_failure_draft_from_platform_image_audit(audit)
|
||||
.with_user_id(settings.external_api_audit_user_id.clone())
|
||||
.with_profile_id(settings.external_api_audit_profile_id.clone())
|
||||
.with_request_id(settings.external_api_audit_request_id.clone());
|
||||
@@ -501,6 +515,7 @@ pub(crate) fn build_openai_image_failure_audit_draft(
|
||||
}
|
||||
|
||||
pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
|
||||
let error = error.into_final_error();
|
||||
let status = match error.status_hint() {
|
||||
PlatformImageStatusHint::BadRequest => StatusCode::BAD_REQUEST,
|
||||
PlatformImageStatusHint::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -545,6 +560,9 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
|
||||
details["rawExcerpt"] = json!(raw_excerpt);
|
||||
}
|
||||
PlatformImageError::MissingImage { .. } => {}
|
||||
PlatformImageError::FallbackFailed { .. } => {
|
||||
unreachable!("fallback wrapper should be removed before HTTP error mapping")
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(audit) = error.audit() {
|
||||
|
||||
@@ -3,7 +3,7 @@ pub mod generated_assets;
|
||||
pub mod vector_engine;
|
||||
|
||||
pub use vector_engine::{
|
||||
DownloadedImage, GPT_IMAGE_2_MODEL, GeneratedImages, PlatformImageError,
|
||||
DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, PlatformImageError,
|
||||
PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage,
|
||||
VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
|
||||
build_vector_engine_image_http_client, build_vector_engine_image_request_body,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::constants::{VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER};
|
||||
use super::constants::VECTOR_ENGINE_PROVIDER;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlatformImageFailureAudit {
|
||||
@@ -33,6 +33,7 @@ pub(crate) fn build_failure_audit(
|
||||
latency_ms: Option<u64>,
|
||||
prompt_chars: Option<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
image_model: Option<&'static str>,
|
||||
) -> PlatformImageFailureAudit {
|
||||
PlatformImageFailureAudit {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
@@ -49,7 +50,7 @@ pub(crate) fn build_failure_audit(
|
||||
latency_ms,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL),
|
||||
image_model,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ fn retry_delay_fits_request_deadline_at(
|
||||
pub(crate) fn request_budget_exhausted_error(
|
||||
request_url: &str,
|
||||
operation: &str,
|
||||
image_model: Option<&'static str>,
|
||||
latency_ms: Option<u64>,
|
||||
prompt_chars: Option<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
@@ -73,6 +74,7 @@ pub(crate) fn request_budget_exhausted_error(
|
||||
latency_ms,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
);
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
@@ -82,6 +84,7 @@ pub(crate) fn request_budget_exhausted_error(
|
||||
elapsed_ms = latency_ms,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
operation,
|
||||
"VectorEngine 图片请求执行预算已耗尽"
|
||||
);
|
||||
@@ -162,6 +165,7 @@ mod tests {
|
||||
let error = request_budget_exhausted_error(
|
||||
"https://vector.example/v1/images/generations",
|
||||
"生成图片失败",
|
||||
None,
|
||||
Some(900),
|
||||
Some(12),
|
||||
Some(1),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2";
|
||||
pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c";
|
||||
pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL;
|
||||
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
|
||||
@@ -148,6 +148,7 @@ pub(crate) fn map_curl_error(
|
||||
context: &str,
|
||||
request_url: &str,
|
||||
failure_stage: &'static str,
|
||||
image_model: Option<&'static str>,
|
||||
error: VectorEngineCurlError,
|
||||
latency_ms: u64,
|
||||
prompt_chars: Option<usize>,
|
||||
@@ -172,6 +173,7 @@ pub(crate) fn map_curl_error(
|
||||
Some(latency_ms),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
);
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
@@ -189,6 +191,7 @@ pub(crate) fn map_curl_error(
|
||||
elapsed_ms = latency_ms,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
request_params = %request_params
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default(),
|
||||
|
||||
@@ -42,6 +42,10 @@ pub enum PlatformImageError {
|
||||
message: String,
|
||||
audit: Option<PlatformImageFailureAudit>,
|
||||
},
|
||||
FallbackFailed {
|
||||
final_error: Box<PlatformImageError>,
|
||||
recovered_failure_audits: Vec<PlatformImageFailureAudit>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlatformImageError {
|
||||
@@ -53,6 +57,7 @@ impl PlatformImageError {
|
||||
| Self::Upstream { provider, .. }
|
||||
| Self::ResponseParse { provider, .. }
|
||||
| Self::MissingImage { provider, .. } => provider,
|
||||
Self::FallbackFailed { final_error, .. } => final_error.provider(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +69,7 @@ impl PlatformImageError {
|
||||
| Self::Upstream { message, .. }
|
||||
| Self::ResponseParse { message, .. }
|
||||
| Self::MissingImage { message, .. } => message,
|
||||
Self::FallbackFailed { final_error, .. } => final_error.message(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +79,42 @@ impl PlatformImageError {
|
||||
| Self::Upstream { audit, .. }
|
||||
| Self::ResponseParse { audit, .. }
|
||||
| Self::MissingImage { audit, .. } => audit.as_ref(),
|
||||
Self::FallbackFailed { final_error, .. } => final_error.audit(),
|
||||
Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recovered_failure_audits(&self) -> &[PlatformImageFailureAudit] {
|
||||
match self {
|
||||
Self::FallbackFailed {
|
||||
recovered_failure_audits,
|
||||
..
|
||||
} => recovered_failure_audits.as_slice(),
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_recovered_failure_audits(
|
||||
self,
|
||||
recovered_failure_audits: Vec<PlatformImageFailureAudit>,
|
||||
) -> Self {
|
||||
if recovered_failure_audits.is_empty() {
|
||||
self
|
||||
} else {
|
||||
Self::FallbackFailed {
|
||||
final_error: Box::new(self),
|
||||
recovered_failure_audits,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_final_error(self) -> Self {
|
||||
match self {
|
||||
Self::FallbackFailed { final_error, .. } => final_error.into_final_error(),
|
||||
error => error,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_hint(&self) -> PlatformImageStatusHint {
|
||||
match self {
|
||||
Self::InvalidConfig { .. } => PlatformImageStatusHint::ServiceUnavailable,
|
||||
@@ -93,6 +131,7 @@ impl PlatformImageError {
|
||||
| Self::Upstream { .. }
|
||||
| Self::ResponseParse { .. }
|
||||
| Self::MissingImage { .. } => PlatformImageStatusHint::BadGateway,
|
||||
Self::FallbackFailed { final_error, .. } => final_error.status_hint(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ async fn download_remote_image_with_deadline(
|
||||
return Err(request_budget_exhausted_error(
|
||||
image_url,
|
||||
operation,
|
||||
None,
|
||||
Some(0),
|
||||
None,
|
||||
None,
|
||||
@@ -83,6 +84,7 @@ async fn download_remote_image_with_deadline(
|
||||
request_budget_exhausted_error(
|
||||
image_url,
|
||||
operation,
|
||||
None,
|
||||
Some(started_at.elapsed().as_millis() as u64),
|
||||
None,
|
||||
None,
|
||||
@@ -117,6 +119,7 @@ pub(crate) async fn download_images_from_urls(
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
recovered_failure_audits: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -222,12 +225,13 @@ pub(crate) fn images_from_base64(
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
recovered_failure_audits: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_generated_image_base64(raw: &str) -> Option<DownloadedImage> {
|
||||
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
|
||||
let mime_type = infer_image_mime_type(bytes.as_slice());
|
||||
let mime_type = infer_image_mime_type(bytes.as_slice())?;
|
||||
Some(DownloadedImage {
|
||||
extension: mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
@@ -258,20 +262,20 @@ pub(crate) fn mime_to_extension(mime_type: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn infer_image_mime_type(bytes: &[u8]) -> String {
|
||||
pub(crate) fn infer_image_mime_type(bytes: &[u8]) -> Option<String> {
|
||||
if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
|
||||
return "image/png".to_string();
|
||||
return Some("image/png".to_string());
|
||||
}
|
||||
if bytes.starts_with(b"\xFF\xD8\xFF") {
|
||||
return "image/jpeg".to_string();
|
||||
return Some("image/jpeg".to_string());
|
||||
}
|
||||
if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
|
||||
return "image/webp".to_string();
|
||||
return Some("image/webp".to_string());
|
||||
}
|
||||
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
|
||||
return "image/gif".to_string();
|
||||
return Some("image/gif".to_string());
|
||||
}
|
||||
"image/png".to_string()
|
||||
None
|
||||
}
|
||||
|
||||
fn map_simple_request_error(message: String, endpoint: Option<String>) -> PlatformImageError {
|
||||
|
||||
@@ -19,7 +19,9 @@ pub use client::{
|
||||
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
|
||||
create_vector_engine_nanobanana_generate_content,
|
||||
};
|
||||
pub use constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER};
|
||||
pub use constants::{
|
||||
GPT_IMAGE_2_C_MODEL, GPT_IMAGE_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 request::{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{
|
||||
constants::GPT_IMAGE_2_MODEL,
|
||||
constants::{GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL},
|
||||
types::{ReferenceImage, VectorEngineImageSettings},
|
||||
};
|
||||
|
||||
@@ -90,6 +90,13 @@ pub fn normalize_vector_engine_image_model(model: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
pub fn normalize_image_size_for_model(model: &str, size: &str) -> String {
|
||||
let size = size.trim();
|
||||
let normalized_size = match size {
|
||||
@@ -102,7 +109,7 @@ pub fn normalize_image_size_for_model(model: &str, size: &str) -> String {
|
||||
_ => "1024x1024".to_string(),
|
||||
};
|
||||
|
||||
if normalize_vector_engine_image_model(model) == GPT_IMAGE_2_MODEL {
|
||||
if is_gpt_image_2_family_model(model) {
|
||||
clamp_gpt_image_2_pixel_size(normalized_size.as_str())
|
||||
} else {
|
||||
normalized_size
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(crate) async fn handle_vector_engine_response(
|
||||
request_url: &str,
|
||||
response_status: u16,
|
||||
response_text: &str,
|
||||
image_model: Option<&'static str>,
|
||||
failure_context: &str,
|
||||
latency_ms: u64,
|
||||
prompt_chars: Option<usize>,
|
||||
@@ -42,6 +43,7 @@ pub(crate) async fn handle_vector_engine_response(
|
||||
Some(latency_ms),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
);
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
@@ -79,6 +81,7 @@ pub(crate) async fn handle_vector_engine_response(
|
||||
Some(latency_ms),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
);
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
@@ -136,6 +139,7 @@ pub(crate) async fn handle_vector_engine_response(
|
||||
Some(download_started_at.elapsed().as_millis() as u64),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
);
|
||||
return Err(error.with_audit(audit));
|
||||
}
|
||||
@@ -154,6 +158,40 @@ pub(crate) async fn handle_vector_engine_response(
|
||||
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);
|
||||
if generated.images.is_empty() {
|
||||
let message = format!("{failure_context}:VectorEngine 返回的 base64 图片无法解码");
|
||||
let raw_excerpt = truncate_raw(response_text);
|
||||
let audit = build_failure_audit(
|
||||
request_url,
|
||||
failure_context,
|
||||
"response_parse",
|
||||
Some(response_status),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
message.as_str(),
|
||||
None,
|
||||
Some(raw_excerpt.clone()),
|
||||
Some(latency_ms),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
);
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
status = response_status,
|
||||
image_model,
|
||||
raw_excerpt = %raw_excerpt,
|
||||
"VectorEngine 图片 base64 解码失败"
|
||||
);
|
||||
return Err(PlatformImageError::ResponseParse {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message,
|
||||
raw_excerpt,
|
||||
audit: Some(audit),
|
||||
});
|
||||
}
|
||||
generated.actual_prompt = actual_prompt;
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
@@ -180,6 +218,7 @@ pub(crate) async fn handle_vector_engine_response(
|
||||
Some(latency_ms),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
image_model,
|
||||
);
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use super::audit::PlatformImageFailureAudit;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VectorEngineImageSettings {
|
||||
pub base_url: String,
|
||||
@@ -11,6 +13,7 @@ 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)]
|
||||
|
||||
@@ -79,6 +79,13 @@ impl PlatformImageError {
|
||||
message,
|
||||
audit: Some(audit),
|
||||
},
|
||||
Self::FallbackFailed {
|
||||
final_error,
|
||||
recovered_failure_audits,
|
||||
} => Self::FallbackFailed {
|
||||
final_error: Box::new(final_error.with_audit(audit)),
|
||||
recovered_failure_audits,
|
||||
},
|
||||
Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => self,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use platform_image::vector_engine::{
|
||||
GPT_IMAGE_2_MODEL, PlatformImageError, ReferenceImage, VECTOR_ENGINE_PROVIDER,
|
||||
VectorEngineImageSettings, build_vector_engine_image_http_client,
|
||||
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,
|
||||
@@ -17,6 +17,7 @@ use std::{
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -32,6 +33,7 @@ fn vector_engine_module_exposes_provider_protocol_helpers() {
|
||||
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");
|
||||
@@ -109,8 +111,17 @@ fn vector_engine_only_enforces_the_gpt_image_2_pixel_budget_for_that_model() {
|
||||
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");
|
||||
}
|
||||
@@ -207,6 +218,8 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
|
||||
.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 {
|
||||
@@ -214,9 +227,13 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
|
||||
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 mut buffer = [0_u8; 4096];
|
||||
let _ = stream.read(&mut buffer).await;
|
||||
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;
|
||||
@@ -261,10 +278,55 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
|
||||
|
||||
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")
|
||||
@@ -393,7 +455,7 @@ async fn nanobanana_generate_content_posts_native_body_and_reads_inline_data() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_engine_image_generation_retries_upstream_502_once_and_succeeds() {
|
||||
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");
|
||||
@@ -402,6 +464,8 @@ async fn vector_engine_image_generation_retries_upstream_502_once_and_succeeds()
|
||||
.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 {
|
||||
@@ -409,9 +473,13 @@ async fn vector_engine_image_generation_retries_upstream_502_once_and_succeeds()
|
||||
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 mut buffer = [0_u8; 4096];
|
||||
let _ = stream.read(&mut buffer).await;
|
||||
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!(
|
||||
@@ -458,6 +526,305 @@ async fn vector_engine_image_generation_retries_upstream_502_once_and_succeeds()
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user