ai-chatbot/tests/routes/image-provider-error-classification.test.ts
WuYongJie 61e2bbe0e4 修复粒子动画加载与生图 524 错误展示
风格化粒子动画依赖外部 CDN 的 Three.js,全站同域脚本策略会阻止其加载,导致 THREE 未定义。现在使用同域 ES module,保留原 Three.js 0.160.0、动画参数及 MIT 许可。

扶苏在 2026-11-04 09:44 收到的 gpt-image-2.5 HTTP 524 响应是 Cloudflare HTML 页面,错误正文被直接保存和显示。现在将这类响应显示为结果未确认的超时提示;已有历史消息在渲染时同样归一化,不改动原始记录。524 不触发自动模型切换,避免原任务结果未确认时再次生成。普通 HTML 文档和代码示例保持原样。

验证:原生产 CSP 下的本地浏览器验证粒子有可见像素、连续帧变化、窗口缩放及无外部请求、无脚本错误;637 项辅助测试通过、1 项原有跳过,包含供应商 524 只提交一次、结果未确认、不自动切换及正常 503 行为。TypeScript、改动文件 lint、凭据、日志脱敏、环境、元数据、测试与制品检查通过。

已知问题:全量 lint 仍有 1004 项历史诊断。524 的根因是上游长请求超时,本次修复错误展示与未确认结果的处理,不承诺消除线路侧偶发超时。
2026-11-04 10:26:01 +08:00

148 lines
8.4 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 {
getUserFacingImageGenerationErrorMessage,
getUserFacingRequestErrorMessage,
getGatewayErrorPageMessage,
isGatewayResponseTimeout,
isProviderBalanceError,
isRateLimitError,
} from "@/lib/request-error";
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { serverLoader } from "../helpers/canvas-download-server-loader";
test("a bare HTTP 429 is rate limiting, not insufficient balance", () => {
const error = Object.assign(new Error("Too many requests (HTTP 429)"), { status: 429 });
expect(isProviderBalanceError(error)).toBe(false);
expect(isRateLimitError(error)).toBe(true);
expect(getUserFacingImageGenerationErrorMessage(error)).toContain("请求过于频繁");
expect(getUserFacingRequestErrorMessage(error)).toContain("请求过于频繁");
});
const gatewayHtml = '<!DOCTYPE html><html><head><title>vip98k.com | 524: A timeout occurred</title></head><body><div id="cf-error-details">Error code 524</div><script>window.gatewayDebug = true</script></body></html> (HTTP 524)';
test("gateway timeout pages become a concise unconfirmed-result message", () => {
for (const error of [new Error(gatewayHtml), { status: 524, message: "Gateway failed" }, { error: { message: "Request failed (HTTP 524)" } }]) {
expect(isGatewayResponseTimeout(error)).toBe(true);
for (const text of [getUserFacingImageGenerationErrorMessage(error), getUserFacingRequestErrorMessage(error)]) {
expect(text).toContain("HTTP 524");
expect(text).toContain("结果尚未确认");
expect(text).not.toMatch(/<html|<script|cf-error-details/);
}
}
expect(getGatewayErrorPageMessage(gatewayHtml)).toContain("避免重复提交");
});
test("ordinary HTML documents and fenced examples retain their content", () => {
for (const text of ['<!DOCTYPE html><html><title>My page</title><p>Hosted on Cloudflare</p></html>', `\`\`\`html\n${gatewayHtml}\n\`\`\``, "Cloudflare 文档示例", "普通图片错误"]) {
expect(getGatewayErrorPageMessage(text)).toBeNull();
}
});
test("HTTP 524 does not start another model while the original result is unconfirmed", () => {
const { health } = healthHarness();
expect(health.shouldFailoverAiModelError(new Error(gatewayHtml))).toBe(false);
expect(health.shouldFailoverAiModelError({ status: 524, message: "Gateway timeout" })).toBe(false);
expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true);
});
test("the provider submits once and keeps an HTTP 524 response unconfirmed", async () => {
let submissions = 0;
const provider = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => null },
"@/lib/storage/image-archive": {},
"@/lib/storage/service": {},
fetch: async () => {
submissions++;
return new Response(gatewayHtml, { status: 524, headers: { "content-type": "text/html" } });
},
})("lib/ai/providers/vip98k.ts");
const model = provider.createVip98k({ apiKey: "test", baseURL: "https://provider.test" }).chat("gpt-image-2.5");
await expect(model.doGenerate({ messages: [{ role: "user", content: "A desk" }] })).rejects.toThrow(/结果未确认.*HTTP 524/);
expect(submissions).toBe(1);
});
test("an explicit provider balance error takes precedence over HTTP 429", () => {
const error = Object.assign(new Error("余额不足,请点击充值页面续费(HTTP 429)"), { status: 429 });
expect(isProviderBalanceError(error)).toBe(true);
expect(isRateLimitError(error)).toBe(false);
expect(getUserFacingImageGenerationErrorMessage(error)).toContain("服务商线路余额");
expect(getUserFacingImageGenerationErrorMessage(error)).toContain("与您的积分余额无关");
expect(getUserFacingImageGenerationErrorMessage(error)).not.toMatch(/^积分不足/);
});
test("nested billing details and generic quota limits are kept distinct", () => {
const nested = { status: 429, message: "request failed", data: { error: { message: "insufficient_balance" } } };
expect(isProviderBalanceError(nested)).toBe(true);
expect(isRateLimitError(nested)).toBe(false);
const usageLimit = new Error("Rate quota exceeded (HTTP 429)");
expect(isProviderBalanceError(usageLimit)).toBe(false);
expect(isRateLimitError(usageLimit)).toBe(true);
expect(isProviderBalanceError(new Error("该模型额度不足(HTTP 429)"))).toBe(true);
});
test("confirmed balance failures remain isolated until explicitly restored", () => {
const model = {
healthLastEventType: "quota",
healthLastError: "余额不足(HTTP 429)",
};
expect(isImageModelBalanceBlocked(model)).toBe(true);
expect(isImageModelBalanceBlocked({ healthLastEventType: "error", healthAutoDisabledReason: "provider_balance_or_billing_quota: 余额不足" })).toBe(true);
expect(isImageModelBalanceBlocked({ ...model, healthLastEventType: "success", healthLastError: null })).toBe(false);
});
test("old selections use only configured, enabled replacements that preserve the requested inputs", () => {
const requested = { healthLastEventType: "quota", healthLastError: "余额不足", fallbackModelId1: "verified" };
const verified = { model: { id: "verified", modelId: "same-family", isEnabled: true, supportsImageGen: true,
imageGenConfig: { supportedImageSizes: ["2K"], supportedAspectRatios: ["1:1"], maxReferenceImages: 8 } }, provider: { isEnabled: true } };
const input = { imageSize: "2K", aspectRatio: "1:1", referenceImageCount: 2 };
expect(resolveConfiguredImageBalanceFallback(requested, [verified], input)).toBe(verified);
expect(resolveConfiguredImageBalanceFallback(requested, [verified], { ...input, referenceImageCount: 9 })).toBeUndefined();
expect(resolveConfiguredImageBalanceFallback(requested, [verified], { ...input, imageSize: "4K" })).toBeUndefined();
expect(resolveConfiguredImageBalanceFallback(requested, [verified], { ...input, aspectRatio: "9:16" })).toBeUndefined();
expect(resolveConfiguredImageBalanceFallback(requested, [{ ...verified, provider: { isEnabled: false } }], input)).toBeUndefined();
expect(resolveConfiguredImageBalanceFallback({ ...requested, fallbackModelId1: null }, [verified], input)).toBeUndefined();
});
function healthHarness() {
let model: any = { id: "test", modelId: "test", isEnabled: true, excludeFromFailover: true,
excludeFromHealthBan: true, healthStatus: "healthy", healthConsecutiveFailures: 0 };
const events: any[] = [];
const query = (rows: () => any[]) => Object.assign(Promise.resolve().then(rows), {
from() { return this; }, where() { return this; },
limit: async () => rows(), returning: async () => rows(),
});
const db = {
select: () => query(() => [model]),
insert: () => ({ values: async (value: any) => { events.push(value); } }),
update: () => ({ set: (value: any) => { model = { ...model, ...value }; return query(() => [model]); } }),
};
const health = serverLoader({ "@/lib/db": { db } })("lib/ai/runtime/model-health.ts");
return { health, events, get model() { return model; }, age() { model.healthAutoDisabledAt = new Date(Date.now() - 4 * 60 * 60_000); } };
}
test("quota pauses an exempt model, survives cooldown and late completion, and can be restored", async () => {
const h = healthHarness();
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("余额不足(HTTP 429)") });
expect(h.model.isEnabled).toBe(false);
expect(h.model.healthStatus).toBe("auto_disabled");
expect(h.events[0].eventType).toBe("quota");
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("HTTP 503 unavailable") });
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("Too many requests (HTTP 429)") });
expect(h.model.healthStatus).toBe("auto_disabled");
expect(isImageModelBalanceBlocked(h.model)).toBe(true);
h.age();
expect(await h.health.reviveAiModelsFromCooldown()).toEqual([]);
await h.health.recordAiModelHealthSuccess({ model: h.model });
expect(h.model.isEnabled).toBe(false);
await h.health.restoreAiModelHealth(h.model.id);
expect(h.model.isEnabled).toBe(true);
expect(isImageModelBalanceBlocked(h.model)).toBe(false);
});
test("ordinary rate limiting does not trigger billing isolation", async () => {
const h = healthHarness();
await h.health.recordAiModelHealthFailure({ model: h.model, error: new Error("Too many requests (HTTP 429)") });
expect(h.model.isEnabled).toBe(true);
expect(isImageModelBalanceBlocked(h.model)).toBe(false);
});