fix: harden image failover candidate selection
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-16 00:00:24 +08:00
parent 8dea40d137
commit 947495ccfb
3 changed files with 132 additions and 42 deletions

View File

@ -222,6 +222,96 @@ type AgentVideoPlan = {
recommendedVideoParams?: AgentRecommendedVideoParams;
};
const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"alibaba-image",
"ruxa-image",
"fal-image",
"volcano-vision",
"gemini",
"huanke-gemini",
]);
const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000;
const imageFailoverModelCooldown = new Map<string, { until: number; reason: string }>();
function normalizeFailoverErrorMessage(error: unknown): string {
if (!error) return "";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message || String(error);
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
const msg = errorMessage.toLowerCase();
return (
msg.includes("model not found") ||
msg.includes("model_not_found") ||
msg.includes("no available channel for model") ||
msg.includes("unknown model") ||
msg.includes("invalid model") ||
msg.includes("api key is missing") ||
msg.includes("invalid api key") ||
msg.includes("unauthorized") ||
msg.includes("cannot convert argument to a bytestring")
);
}
function isImageFallbackModelCoolingDown(modelId: string): boolean {
const snapshot = imageFailoverModelCooldown.get(modelId);
if (!snapshot) return false;
if (snapshot.until <= Date.now()) {
imageFailoverModelCooldown.delete(modelId);
return false;
}
return true;
}
function setImageFallbackModelCooldown(modelId: string, reason: string): void {
imageFailoverModelCooldown.set(modelId, {
until: Date.now() + IMAGE_FAILOVER_MODEL_COOLDOWN_MS,
reason: reason.slice(0, 500),
});
}
function getImageCrossProviderCandidates(
allModels: any[],
options: {
currentModelId: string;
currentProviderId?: string;
providerPriority: Record<string, number>;
}
) {
return allModels
.filter((m) => {
if (!m?.model?.supportsImageGen || !m?.model?.isEnabled || m?.model?.isSubModel) {
return false;
}
if (!IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(String(m?.provider?.type || ""))) {
return false;
}
if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
if (isImageFallbackModelCoolingDown(m.model.id)) {
const cooldown = imageFailoverModelCooldown.get(m.model.id);
console.log(
`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "cooldown"}`
);
return false;
}
return true;
})
.sort((a, b) => {
const left = options.providerPriority[a.provider.type as string] ?? 99;
const right = options.providerPriority[b.provider.type as string] ?? 99;
if (left !== right) return left - right;
return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
});
}
function hasUserCustomizedImageParams(params?: PostRequestBody["imageParams"]): boolean {
if (!params) return false;
const customizedAspectRatio =
@ -2363,27 +2453,17 @@ export async function POST(request: Request) {
if (!fallbackSuccess) {
const providerPriority: Record<string, number> = {
"volcano-vision": 0,
gemini: 1,
"huanke-gemini": 2,
"alibaba-image": 3,
"ruxa-image": 4,
"fal-image": 5,
"alibaba-image": 1,
"ruxa-image": 2,
"fal-image": 3,
gemini: 4,
"huanke-gemini": 5,
};
const crossProviderCandidates = allModels
.filter((m) => {
if (!m.model.supportsImageGen || !m.model.isEnabled || m.model.isSubModel) {
return false;
}
if (m.model.id === finalModelId) return false;
if (m.provider.id === alibabaModelConfig?.providerId) return false;
return true;
})
.sort((a, b) => {
const left = providerPriority[a.provider.type as string] ?? 99;
const right = providerPriority[b.provider.type as string] ?? 99;
if (left !== right) return left - right;
return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
});
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
currentProviderId: alibabaModelConfig?.providerId,
providerPriority,
});
for (const candidate of crossProviderCandidates) {
try {
@ -2439,6 +2519,10 @@ export async function POST(request: Request) {
);
break;
} catch (crossError) {
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
}
console.error(
`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`,
crossError
@ -2713,28 +2797,18 @@ export async function POST(request: Request) {
}
if (!fallbackSuccess) {
const providerPriority: Record<string, number> = {
gemini: 0,
"huanke-gemini": 1,
"alibaba-image": 2,
"ruxa-image": 3,
"fal-image": 4,
"alibaba-image": 0,
"ruxa-image": 1,
"fal-image": 2,
gemini: 3,
"huanke-gemini": 4,
"volcano-vision": 5,
};
const crossProviderCandidates = allModels
.filter((m) => {
if (!m.model.supportsImageGen || !m.model.isEnabled || m.model.isSubModel) {
return false;
}
if (m.model.id === finalModelId) return false;
if (m.provider.id === volcanoModelConfig?.providerId) return false;
return true;
})
.sort((a, b) => {
const left = providerPriority[a.provider.type as string] ?? 99;
const right = providerPriority[b.provider.type as string] ?? 99;
if (left !== right) return left - right;
return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
});
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
currentProviderId: volcanoModelConfig?.providerId,
providerPriority,
});
for (const candidate of crossProviderCandidates) {
try {
@ -2790,6 +2864,10 @@ export async function POST(request: Request) {
);
break;
} catch (crossError) {
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
}
console.error(
`[Chat API] ❌ Volcano cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`,
crossError

View File

@ -29,7 +29,13 @@ const DEFAULT_SAFETY_TOLERANCE = "4";
const DEFAULT_TIMEOUT_MS = 180000;
function normalizeFalCredentials(apiKey: string) {
return apiKey.trim().replace(/^bearer\s+/i, "").replace(/^key\s+/i, "");
const normalized = apiKey.replace(/\u00a0/g, " ").trim();
const stripped = normalized.replace(/^bearer\s+/i, "").replace(/^key\s+/i, "");
const kvMatch = stripped.match(/^(?:fal[_-]?key|api[_-]?key|token)\s*[::]\s*(.+)$/i);
if (kvMatch?.[1]) {
return kvMatch[1].trim();
}
return stripped;
}
function normalizeUrlForCompare(url: string) {

View File

@ -23,7 +23,13 @@ const DEFAULT_SHOT_TYPE = "customize";
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
function normalizeFalCredentials(apiKey: string) {
return apiKey.trim().replace(/^bearer\s+/i, "").replace(/^key\s+/i, "");
const normalized = apiKey.replace(/\u00a0/g, " ").trim();
const stripped = normalized.replace(/^bearer\s+/i, "").replace(/^key\s+/i, "");
const kvMatch = stripped.match(/^(?:fal[_-]?key|api[_-]?key|token)\s*[::]\s*(.+)$/i);
if (kvMatch?.[1]) {
return kvMatch[1].trim();
}
return stripped;
}
function normalizeDuration(value?: number) {