## 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 仅修改本次生图运行逻辑与测试,凭据和验证产物保存在仓库外或忽略目录中。内部生成接口的权限与访问方式保持兼容。
101 lines
6.0 KiB
TypeScript
101 lines
6.0 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import {
|
||
getUserFacingImageGenerationErrorMessage,
|
||
getUserFacingRequestErrorMessage,
|
||
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("请求过于频繁");
|
||
});
|
||
|
||
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[]) => ({ from() { return this; }, where() { return this; },
|
||
limit: async () => rows(), then: (resolve: any) => Promise.resolve(rows()).then(resolve), 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);
|
||
});
|