ai-chatbot/tests/routes/canvas-agent-runtime-image-failover.test.ts
WuYongJie 00cef71be7 暂停欠费生图线路并修正 98K 文生图尺寸传递
## 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

仅修改本次生图运行逻辑与测试,凭据和验证产物保存在仓库外或忽略目录中。内部生成接口的权限与访问方式保持兼容。
2026-11-03 19:20:18 +08:00

153 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 "![image](https://fixture.invalid/ok.png)";
} });
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 "![image](https://fixture.invalid/ok.png)"; } });
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 `![image](https://fixture.invalid/${id}-${call}.png)`; } });
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 "![image](https://fixture.invalid/keep.png)"; } });
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 "![image](https://fixture.invalid/ok.png)"; } });
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 "![image](https://fixture.invalid/ok.png)"; } });
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);
}
});