fix: harden image failover candidate selection
This commit is contained in:
parent
8dea40d137
commit
947495ccfb
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user