fix: harden image failover candidate selection
This commit is contained in:
parent
8dea40d137
commit
947495ccfb
@ -222,6 +222,96 @@ type AgentVideoPlan = {
|
|||||||
recommendedVideoParams?: AgentRecommendedVideoParams;
|
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 {
|
function hasUserCustomizedImageParams(params?: PostRequestBody["imageParams"]): boolean {
|
||||||
if (!params) return false;
|
if (!params) return false;
|
||||||
const customizedAspectRatio =
|
const customizedAspectRatio =
|
||||||
@ -2363,27 +2453,17 @@ export async function POST(request: Request) {
|
|||||||
if (!fallbackSuccess) {
|
if (!fallbackSuccess) {
|
||||||
const providerPriority: Record<string, number> = {
|
const providerPriority: Record<string, number> = {
|
||||||
"volcano-vision": 0,
|
"volcano-vision": 0,
|
||||||
gemini: 1,
|
"alibaba-image": 1,
|
||||||
"huanke-gemini": 2,
|
"ruxa-image": 2,
|
||||||
"alibaba-image": 3,
|
"fal-image": 3,
|
||||||
"ruxa-image": 4,
|
gemini: 4,
|
||||||
"fal-image": 5,
|
"huanke-gemini": 5,
|
||||||
};
|
};
|
||||||
const crossProviderCandidates = allModels
|
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
|
||||||
.filter((m) => {
|
currentModelId: finalModelId,
|
||||||
if (!m.model.supportsImageGen || !m.model.isEnabled || m.model.isSubModel) {
|
currentProviderId: alibabaModelConfig?.providerId,
|
||||||
return false;
|
providerPriority,
|
||||||
}
|
});
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const candidate of crossProviderCandidates) {
|
for (const candidate of crossProviderCandidates) {
|
||||||
try {
|
try {
|
||||||
@ -2439,6 +2519,10 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
} catch (crossError) {
|
} catch (crossError) {
|
||||||
|
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
|
||||||
|
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
||||||
|
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
||||||
|
}
|
||||||
console.error(
|
console.error(
|
||||||
`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`,
|
`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`,
|
||||||
crossError
|
crossError
|
||||||
@ -2713,28 +2797,18 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
if (!fallbackSuccess) {
|
if (!fallbackSuccess) {
|
||||||
const providerPriority: Record<string, number> = {
|
const providerPriority: Record<string, number> = {
|
||||||
gemini: 0,
|
"alibaba-image": 0,
|
||||||
"huanke-gemini": 1,
|
"ruxa-image": 1,
|
||||||
"alibaba-image": 2,
|
"fal-image": 2,
|
||||||
"ruxa-image": 3,
|
gemini: 3,
|
||||||
"fal-image": 4,
|
"huanke-gemini": 4,
|
||||||
"volcano-vision": 5,
|
"volcano-vision": 5,
|
||||||
};
|
};
|
||||||
const crossProviderCandidates = allModels
|
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
|
||||||
.filter((m) => {
|
currentModelId: finalModelId,
|
||||||
if (!m.model.supportsImageGen || !m.model.isEnabled || m.model.isSubModel) {
|
currentProviderId: volcanoModelConfig?.providerId,
|
||||||
return false;
|
providerPriority,
|
||||||
}
|
});
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const candidate of crossProviderCandidates) {
|
for (const candidate of crossProviderCandidates) {
|
||||||
try {
|
try {
|
||||||
@ -2790,6 +2864,10 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
} catch (crossError) {
|
} catch (crossError) {
|
||||||
|
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
|
||||||
|
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
||||||
|
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
||||||
|
}
|
||||||
console.error(
|
console.error(
|
||||||
`[Chat API] ❌ Volcano cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`,
|
`[Chat API] ❌ Volcano cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`,
|
||||||
crossError
|
crossError
|
||||||
|
|||||||
@ -29,7 +29,13 @@ const DEFAULT_SAFETY_TOLERANCE = "4";
|
|||||||
const DEFAULT_TIMEOUT_MS = 180000;
|
const DEFAULT_TIMEOUT_MS = 180000;
|
||||||
|
|
||||||
function normalizeFalCredentials(apiKey: string) {
|
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) {
|
function normalizeUrlForCompare(url: string) {
|
||||||
|
|||||||
@ -23,7 +23,13 @@ const DEFAULT_SHOT_TYPE = "customize";
|
|||||||
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
function normalizeFalCredentials(apiKey: string) {
|
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) {
|
function normalizeDuration(value?: number) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user