## Summary 生图线路确认欠费后立即停止新请求,跳过同一上游账户的其他模型,保留已生成结果和已提交任务。欠费状态须经管理员恢复,不再定时自动解封,也不会被旧任务的成功回执清除。旧模型选择仅使用明确配置且尺寸、比例和参考图数量兼容的备用模型;固定模型模式仍要求用户自行选择。 98K 文生图优先使用其公开声明的任务接口,原生传递分辨率和比例,清除文生图请求中的示例参考图片。 ## Scope - [x] API / backend logic - [x] Settings / admin configuration ## Required Verification 验证普通 Pro、Banana 2、两个 Plus 和 GPT Image 2 共 10 个真实文生图及参考图编辑用例,均获得可下载的 2048×2048 图片。625 项 helper 回归通过,1 项跳过;TypeScript、日志/品牌/CI/环境/凭据/元数据/测试/产物检查通过。全仓 Biome 仍有历史格式和 lint 诊断(1005 项)。构建机执行生产构建及路由烟测;生产切换前核对任务与请求,保留旧构建和模型配置回滚记录。 ## Release Readiness 仅修改本次生图运行逻辑与测试,凭据和验证产物保存在仓库外或忽略目录中。内部生成接口的权限与访问方式保持兼容。
153 lines
11 KiB
TypeScript
153 lines
11 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import { agentImageProviderHarness as harness } from "../helpers/agent-image-provider-harness";
|
||
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
|
||
import { ImageSubmissionRejectedError, agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||
import * as generationErrors from "@/lib/ai/runtime/image-generation-error";
|
||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||
|
||
test("a balance rejection skips other models on the same account but permits another account of the same provider type", async () => {
|
||
const models = ["primary", "same-account", "backup"].map(id => ({
|
||
model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, imageGenConfig: { maxImages: 1 }, capabilities: { kind: "image" } },
|
||
provider: { id: id === "backup" ? "funded-account" : "exhausted-account", type: "openai-image", apiKey: "synthetic", isEnabled: true },
|
||
}));
|
||
const h = harness({ models, generate: id => {
|
||
if (id !== "backup") throw new Error("余额不足,请充值 (HTTP 429)");
|
||
return "";
|
||
} });
|
||
expect((await h.generate(h.request)).modelRef).toBe("backup");
|
||
expect(h.calls.map(call => call.id)).toEqual(["primary", "backup"]);
|
||
});
|
||
|
||
test("an old disabled balance selection uses its configured compatible replacement without calling the exhausted account", async () => {
|
||
const oldId = "11111111-1111-4111-8111-111111111111";
|
||
const models = [
|
||
{ model: { id: oldId, modelId: "old", displayName: "old", isEnabled: false, supportsImageGen: true, healthLastEventType: "quota", healthLastError: "insufficient balance", fallbackModelId1: "backup" }, provider: { id: "exhausted-account", type: "openai-image", isEnabled: true } },
|
||
{ model: { id: "backup", modelId: "backup", displayName: "backup", isEnabled: true, supportsImageGen: true, supportsVision: true, imageGenConfig: { maxImages: 1, maxReferenceImages: 8, supportedImageSizes: ["2K"], supportedAspectRatios: ["3:4"] } }, provider: { id: "funded-account", type: "openai-image", apiKey: "synthetic", isEnabled: true } },
|
||
];
|
||
const h = harness({ models });
|
||
expect((await h.generate({ ...h.request, model: oldId })).modelRef).toBe("backup");
|
||
expect(h.calls.map(call => call.id)).toEqual(["backup"]);
|
||
await expect(h.generate({ ...h.request, model: oldId, agentImageMode: "pinned" })).rejects.toThrow("余额不足");
|
||
await expect(h.generate({ ...h.request, model: oldId, referenceImages: Array.from({ length: 9 }, (_, i) => `https://fixture.invalid/${i}.png`) })).rejects.toThrow("余额不足");
|
||
expect(h.calls).toHaveLength(1);
|
||
});
|
||
|
||
|
||
test("automatic image generation switches within the same provider and skips a paused model on the next task", async () => {
|
||
const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("channel unavailable"), { status: 503 }); return ""; } });
|
||
const result = await h.generate(h.request);
|
||
expect(result.modelRef).toBe("backup"); expect(h.calls.map(c => c.id)).toEqual(["primary", "backup"]);
|
||
expect([...h.paused]).toEqual(["primary"]);
|
||
expect(h.calls[1].imageParams).toMatchObject({ imageSize: "2K", aspectRatio: "3:4", negativePrompt: "no text", seed: 0, watermark: false });
|
||
await h.generate(h.request);
|
||
expect(h.calls.map(c => c.id)).toEqual(["primary", "backup", "backup"]);
|
||
});
|
||
|
||
test("a pinned model failure never calls any backup or silently substitutes a missing model", async () => {
|
||
const h = harness({ generate: () => { throw Object.assign(new Error("invalid authentication"), { status: 401 }); } });
|
||
await expect(h.generate({ ...h.request, agentImageMode: "pinned", allowFailover: true })).rejects.toThrow();
|
||
expect(h.calls.map(c => c.id)).toEqual(["primary"]);
|
||
await expect(h.generate({ ...h.request, agentImageMode: "pinned", model: "missing" })).rejects.toThrow("指定的图片模型不可用");
|
||
expect(h.calls).toHaveLength(1);
|
||
});
|
||
|
||
test("a partly successful batch keeps its delivered images and only generates the missing count", async () => {
|
||
const h = harness({ generate: (id, _n, call) => { if (id === "primary" && call === 2) throw new Error("VIP98K task failed"); return ``; } });
|
||
const result = await h.generate({ ...h.request, imageCount: 3 });
|
||
expect(h.calls.map(c => c.id)).toEqual(["primary", "primary", "backup", "backup"]);
|
||
expect(result.images).toHaveLength(3);
|
||
expect(result.images.map((i: any) => i.modelRef)).toEqual(["primary", "backup", "backup"]);
|
||
expect(result.images[0].originalUrl).toContain("primary-1");
|
||
});
|
||
|
||
test("if every backup fails, previously delivered images are retained", async () => {
|
||
const h = harness({ generate: (_id, _n, call) => { if (call > 1) throw new Error("VIP98K task failed"); return ""; } });
|
||
const result = await h.generate({ ...h.request, imageCount: 3 });
|
||
expect(result.images).toHaveLength(1); expect(result.images[0].originalUrl).toContain("keep.png");
|
||
});
|
||
|
||
test("a submitted task with an unknown outcome is not submitted again through a backup", async () => {
|
||
const h = harness({ generate: () => { throw new Error("VIP98K task timed out; it may still be running"); } });
|
||
await expect(h.generate(h.request)).rejects.toThrow();
|
||
expect(h.calls.map(c => c.id)).toEqual(["primary"]); expect(h.paused.size).toBe(0);
|
||
expect(agentImageFailurePolicy(Object.assign(new Error("timeout"), { providerOutcomeUnknown: true })).retry).toBe(false);
|
||
expect(agentImageFailurePolicy(new Error("VIP98K task failed: timeout")).retry).toBe(true);
|
||
});
|
||
|
||
test("content restrictions and account limits cannot be bypassed by switching services", () => {
|
||
for (const error of [new Error("content policy rejection"), new Error("用户积分不足")]) expect(agentImageFailurePolicy(error)).toEqual({ retry: false, pause: null });
|
||
});
|
||
|
||
|
||
test("automatic fallback respects requested dimensions, disabled candidates and the user's available credit", async () => {
|
||
const models = [
|
||
{ id: "primary", creditCost: 1, supportedImageSizes: ["2K"] },
|
||
{ id: "wrong-size", creditCost: 1, supportedImageSizes: ["1K"] },
|
||
{ id: "too-expensive", creditCost: 10, supportedImageSizes: ["2K"] },
|
||
{ id: "disabled-health", creditCost: 1, supportedImageSizes: ["2K"], healthStatus: "auto_disabled" },
|
||
{ id: "backup", creditCost: 2, supportedImageSizes: ["2K"] },
|
||
].map(({ id, healthStatus, ...config }) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, healthStatus, supportsImageGen: true, supportsVision: true, imageGenConfig: config }, provider: { type: "openai-image", isEnabled: true } }));
|
||
const h = harness({ models, generate: id => { if (id === "primary") throw new Error("VIP98K task failed"); return ""; } });
|
||
const result = await h.generate({ ...h.request, agentImageMaxCreditCost: 3 });
|
||
expect(result.modelRef).toBe("backup"); expect(h.calls.map(c => c.id)).toEqual(["primary", "backup"]);
|
||
});
|
||
|
||
|
||
test("the existing non-Agent auto alias continues to select its configured default", async () => {
|
||
const h = harness();
|
||
const result = await h.generate({ ...h.request, model: "auto", agentImageMode: undefined, allowFailover: false });
|
||
expect(result.modelRef).toBe("primary"); expect(h.calls).toHaveLength(1);
|
||
});
|
||
|
||
|
||
test("provider adapters that include HTTP status or quota only in their message still recover", () => {
|
||
for (const message of ["API request failed: 503", "Internal server error", "Service Unavailable", "Unauthorized", "API request failed: HTTP 503", "server busy(HTTP 429)", "insufficient balance", "模型配额不足", "No images in response", "BananaImage 生成失败"]) {
|
||
expect(agentImageFailurePolicy(new Error(message)).retry).toBe(true);
|
||
}
|
||
expect(agentImageFailurePolicy(new Error("invalid input (HTTP 400)"))).toEqual({ retry: true, pause: null });
|
||
});
|
||
|
||
|
||
test("exhausted automatic channels and pinned failures show useful messages without provider diagnostics", async () => {
|
||
const h = harness({ generate: () => { throw Object.assign(new Error("provider-internal-trace HTTP 503"), { status: 503 }); } });
|
||
await expect(h.generate(h.request)).rejects.toThrow("暂时未能完成本次出图");
|
||
await expect(h.generate({ ...h.request, agentImageMode: "pinned" })).rejects.toThrow("系统未自动切换");
|
||
});
|
||
|
||
|
||
test("a parameter rejection can recover on a compatible alternate without pausing the first model", async () => {
|
||
const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("parameter not supported (HTTP 400)"), { status: 400 }); return ""; } });
|
||
const result = await h.generate(h.request);
|
||
expect(result.modelRef).toBe("backup"); expect(h.calls.map(c => c.id)).toEqual(["primary", "backup"]);
|
||
expect(h.paused.size).toBe(0);
|
||
expect(h.calls[1].imageParams.aspectRatio).toBe("3:4");
|
||
});
|
||
|
||
test("a pinned quota refusal remains a confirmed failure and asks before switching", async () => {
|
||
const h = harness({ generate: () => { throw new ImageSubmissionRejectedError("余额不足(HTTP 429)", 429); } });
|
||
await expect(h.generate({ ...h.request, agentImageMode: "pinned" })).rejects.toMatchObject({
|
||
confirmedNoResult: true, message: expect.stringMatching(/余额不足.*未生成图片.*切换生图模型.*稍后重试/),
|
||
});
|
||
expect(h.calls.map(c => c.id)).toEqual(["primary"]);
|
||
});
|
||
|
||
test("timeouts and post-generation failures never acquire submission refusal evidence", () => {
|
||
for (const error of [new Error("task timed out"), new Error("结果图片下载失败(HTTP 429)"), new Error("storage failed"), Object.assign(new Error("HTTP 503"), { status: 503 })])
|
||
expect(agentImageGenerationError(error, "pinned").confirmedNoResult).toBe(false);
|
||
});
|
||
|
||
test("both BananaImage submission APIs mark quota refusal before polling or archiving", async () => {
|
||
for (const id of ["gemini-3-pro-image-preview", "gpt-image-2"]) {
|
||
let requests = 0, archived = 0;
|
||
const { createBananaImage } = serverLoader({
|
||
"@/lib/ai/runtime/image-generation-error": generationErrors,
|
||
"@/lib/storage/image-archive": { createDeferredUploadedImage: () => { archived++; throw new Error("unexpected archive"); } },
|
||
"@/lib/storage/service": {},
|
||
fetch: async () => { requests++; return Response.json({ error: { message: "余额不足" } }, { status: 429 }); },
|
||
})("lib/ai/providers/bananaimage.ts");
|
||
await expect(createBananaImage({ apiKey: "synthetic", baseURL: "https://fixture.invalid" }).chat(id).doGenerate({ messages: [{ role: "user", content: "cat" }] }))
|
||
.rejects.toBeInstanceOf(ImageSubmissionRejectedError);
|
||
expect(requests).toBe(1);expect(archived).toBe(0);
|
||
}
|
||
});
|