mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 智能错误检测与 retryable 传播
ChatGPT 适配器: - 解析 conversation SSE 响应,提取文本内容判断图片生成是否开始 - 早期检测速率限制(RateLimitException)和内容拒绝(policy violation) - 动态超时:未检测到图片生成迹象时使用 30s 短超时,避免无谓等待 - 超时后尝试正则提取文本作为错误信息 LMArena 适配器: - 新增 extractError 函数,解析 SSE 中的 a3(模型提供方错误)和 ae(平台错误) - 支持嵌套 JSON 错误提取(如 OpenAI moderation_blocked) - 无图片时返回 error 而非 text Worker: - enrichedMeta: 自动在 meta 中添加 adapter 和 model 信息 - 故障转移中传播 retryable 标志,不可重试错误立即停止 Failover: - 优先使用 result.retryable 而非仅依赖 normalizeError 推断 - 不可重试错误直接返回,不再尝试其他候选 worker
This commit is contained in:
@@ -110,12 +110,79 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
return { error: `API 返回错误: HTTP ${conversationResponse.status()}` };
|
||||
}
|
||||
|
||||
// 5.5 解析 conversation 响应,检查是否是纯文本回复(拒绝/限流场景)
|
||||
let conversationText = '';
|
||||
let isImageGenerationStarted = false;
|
||||
let conversationBody = '';
|
||||
try {
|
||||
conversationBody = await conversationResponse.text();
|
||||
|
||||
// 检查是否有图片生成相关的内容 (dalle 工具调用或 file_ 文件引用)
|
||||
// 注意:不使用 'image' 关键词,因为拒绝消息也会包含这个词
|
||||
isImageGenerationStarted = conversationBody.includes('dalle') || conversationBody.includes('file_');
|
||||
logger.debug('适配器', `isImageGenerationStarted: ${isImageGenerationStarted}`, meta);
|
||||
|
||||
// 提取文本内容
|
||||
const lines = conversationBody.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const dataStr = line.slice(6).trim();
|
||||
if (dataStr === '[DONE]') continue;
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
// 提取初始文本 (channel=final 的 assistant 消息)
|
||||
if (data.v?.message?.channel === 'final' &&
|
||||
data.v?.message?.author?.role === 'assistant' &&
|
||||
data.v?.message?.content?.parts?.length > 0) {
|
||||
const part = data.v.message.content.parts[0];
|
||||
if (typeof part === 'string') {
|
||||
conversationText = part;
|
||||
}
|
||||
}
|
||||
// patch 格式累加 (data.v 是 patch 操作数组)
|
||||
if (Array.isArray(data.v)) {
|
||||
for (const patch of data.v) {
|
||||
if (patch.o === 'append' && patch.p === '/message/content/parts/0' && patch.v) {
|
||||
conversationText += patch.v;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
logger.debug('适配器', `提取到文本 (${conversationText.length} 字符): ${conversationText.substring(0, 200)}...`, meta);
|
||||
} catch (e) {
|
||||
logger.warn('适配器', `解析 conversation 响应失败: ${e.message}`, meta);
|
||||
}
|
||||
|
||||
// 早期检测:如果文本表明是拒绝/限流,立即返回,不等待图片超时
|
||||
if (conversationText) {
|
||||
// 检查是否是速率限制错误 (不重试,同账号重试也没用)
|
||||
const isRateLimit = conversationBody.includes('RateLimitException') ||
|
||||
conversationBody.includes('rate limit') ||
|
||||
/limit.*reset/i.test(conversationText);
|
||||
|
||||
if (isRateLimit) {
|
||||
logger.warn('适配器', `早期检测到速率限制: ${conversationText.substring(0, 200)}...`, meta);
|
||||
return { error: `触发速率限制: ${conversationText.substring(0, 200)}`, retryable: false };
|
||||
}
|
||||
|
||||
// 如果没有图片生成迹象,检查是否是内容被拒绝
|
||||
if (!isImageGenerationStarted) {
|
||||
const isContentRejection = /cannot|can't|unable|sorry|policy|violat/i.test(conversationText);
|
||||
if (isContentRejection) {
|
||||
logger.warn('适配器', `早期检测到内容拒绝: ${conversationText.substring(0, 200)}...`, meta);
|
||||
return { error: `内容被拒绝: ${conversationText.substring(0, 200)}`, retryable: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('适配器', '生成中,等待图片就绪...', meta);
|
||||
|
||||
// 6. 监听文件状态接口,等待图片生成完成
|
||||
// 通过 file_name 是否包含 .part 判断是否生成完成
|
||||
// 如果 conversation 响应中没有图片生成迹象,使用较短超时
|
||||
let downloadUrl = null;
|
||||
let fileName = null;
|
||||
const imageTimeout = isImageGenerationStarted ? 120000 : 30000;
|
||||
|
||||
try {
|
||||
await page.waitForResponse(async (response) => {
|
||||
@@ -128,11 +195,6 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const fn = json.file_name;
|
||||
const dl = json.download_url;
|
||||
|
||||
// 检查是否生成完成:
|
||||
// 1. 必须有 file_name
|
||||
// 2. file_name 开头必须是 user- (生成的图片)
|
||||
// 3. file_name 不能包含 .part(表示中间状态)
|
||||
// 4. 必须有 download_url
|
||||
if (fn && fn.startsWith('user-') && !fn.includes('.part') && dl) {
|
||||
fileName = fn;
|
||||
downloadUrl = dl;
|
||||
@@ -145,8 +207,34 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { timeout: waitTimeout });
|
||||
}, { timeout: imageTimeout });
|
||||
} catch (e) {
|
||||
logger.debug('适配器', `等待图片超时, conversationText长度: ${conversationText.length}, downloadUrl: ${downloadUrl}`, meta);
|
||||
|
||||
// 超时时检查是否有 conversation 中的文本内容
|
||||
if (conversationText && !downloadUrl) {
|
||||
const isRateLimit = conversationBody.includes('RateLimitException') ||
|
||||
conversationBody.includes('rate limit') ||
|
||||
/limit.*reset/i.test(conversationText);
|
||||
|
||||
if (isRateLimit) {
|
||||
logger.warn('适配器', `触发速率限制: ${conversationText.substring(0, 200)}...`, meta);
|
||||
return { error: `触发速率限制: ${conversationText.substring(0, 200)}`, retryable: false };
|
||||
}
|
||||
|
||||
logger.warn('适配器', `模型返回文本而非图片: ${conversationText.substring(0, 200)}...`, meta);
|
||||
return { error: `模型返回文本而非图片: ${conversationText.substring(0, 200)}`, retryable: false };
|
||||
}
|
||||
|
||||
// 如果没有提取到文本,但有原始响应体,尝试用简单方式提取
|
||||
if (!conversationText && conversationBody) {
|
||||
const partsMatch = conversationBody.match(/"parts":\s*\["([^"]+)"\]/);
|
||||
if (partsMatch && partsMatch[1]) {
|
||||
logger.warn('适配器', `通过正则提取到文本: ${partsMatch[1].substring(0, 200)}...`, meta);
|
||||
return { error: `模型返回文本而非图片: ${partsMatch[1].substring(0, 200)}`, retryable: false };
|
||||
}
|
||||
}
|
||||
|
||||
const pageError = normalizePageError(e, meta);
|
||||
if (pageError) return pageError;
|
||||
throw e;
|
||||
|
||||
@@ -40,6 +40,53 @@ function extractImage(text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从响应文本中提取错误信息
|
||||
* SSE 错误格式:
|
||||
* - a3: 模型提供方错误 (如 OpenAI moderation_blocked)
|
||||
* - ae: Arena 平台错误 (如内容审核拦截)
|
||||
* @param {string} text - 响应文本内容
|
||||
* @returns {string|null} 提取到的错误信息,如果未找到则返回 null
|
||||
*/
|
||||
function extractError(text) {
|
||||
if (!text) return null;
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
// a3: 模型提供方错误
|
||||
if (line.startsWith('a3:')) {
|
||||
try {
|
||||
const errorMsg = JSON.parse(line.substring(3));
|
||||
if (typeof errorMsg === 'string') {
|
||||
// 尝试提取嵌套的 JSON 错误
|
||||
const jsonMatch = errorMsg.match(/\{[\s\S]*"error"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const nested = JSON.parse(jsonMatch[0]);
|
||||
if (nested.error?.message) {
|
||||
return `[模型错误] ${nested.error.message} (code: ${nested.error.code || 'unknown'})`;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return `[模型错误] ${errorMsg}`;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
// ae: Arena 平台错误
|
||||
if (line.startsWith('ae:')) {
|
||||
try {
|
||||
const errorData = JSON.parse(line.substring(3));
|
||||
if (errorData?.message) {
|
||||
return `[平台错误] ${errorData.message}`;
|
||||
}
|
||||
if (typeof errorData === 'string') {
|
||||
return `[平台错误] ${errorData}`;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
@@ -146,7 +193,14 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const httpError = normalizeHttpError(response, content);
|
||||
if (httpError) {
|
||||
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
|
||||
return { error: `请求生成时返回错误: ${httpError.error}` };
|
||||
return { error: `请求生成时返回错误: ${httpError.error}`, retryable: httpError.retryable };
|
||||
}
|
||||
|
||||
// 8.5 检查 SSE 错误 (a3/ae 行)
|
||||
const sseError = extractError(content);
|
||||
if (sseError) {
|
||||
logger.warn('适配器', `SSE 错误: ${sseError}`, meta);
|
||||
return { error: sseError, retryable: false };
|
||||
}
|
||||
|
||||
// 9. 提取图片 URL
|
||||
@@ -170,7 +224,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
return result;
|
||||
} else {
|
||||
logger.warn('适配器', '未获得结果,响应中无图片数据', { ...meta, preview: content.substring(0, 150) });
|
||||
return { text: `未获得结果,响应中无图片数据: ${content}` };
|
||||
return { error: `未获得结果,响应中无图片数据: ${content.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
|
||||
@@ -405,6 +405,7 @@ export class Worker {
|
||||
|
||||
const maxAttempts = maxRetries === 0 ? candidateTypes.length : Math.min(maxRetries + 1, candidateTypes.length);
|
||||
let lastError = null;
|
||||
let lastRetryable = undefined;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const { type, modelId: actualModelId } = candidateTypes[i];
|
||||
@@ -415,12 +416,19 @@ export class Worker {
|
||||
}
|
||||
|
||||
lastError = result.error;
|
||||
lastRetryable = result.retryable;
|
||||
|
||||
// 如果明确标记为不可重试(如内容安全问题),立即返回
|
||||
if (result.retryable === false) {
|
||||
return { error: `所有支持该模型的适配器都无法使用: ${lastError}`, retryable: false };
|
||||
}
|
||||
|
||||
if (i < maxAttempts - 1) {
|
||||
logger.warn('工作池', `[${this.name}] ${type} 失败,尝试下一个适配器...`, { error: lastError, ...meta });
|
||||
}
|
||||
}
|
||||
|
||||
return { error: `所有支持该模型的适配器都无法使用: ${lastError}` };
|
||||
return { error: `所有支持该模型的适配器都无法使用: ${lastError}`, retryable: lastRetryable };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,10 +487,13 @@ export class Worker {
|
||||
userDataDir: this.userDataDir
|
||||
};
|
||||
|
||||
// 扩展 meta,添加 adapter 和 model 信息
|
||||
const enrichedMeta = { ...meta, adapter: type, model: modelId };
|
||||
|
||||
this.busyCount++;
|
||||
try {
|
||||
// 传递原始 modelId,由适配器自己解析
|
||||
return await adapter.generate(subContext, prompt, paths, modelId, meta);
|
||||
return await adapter.generate(subContext, prompt, paths, modelId, enrichedMeta);
|
||||
} finally {
|
||||
this.busyCount--;
|
||||
}
|
||||
|
||||
@@ -66,11 +66,15 @@ export function createFailoverExecutor(options = {}) {
|
||||
// 记录错误
|
||||
lastError = result.error;
|
||||
|
||||
// 检查是否可重试
|
||||
const normalized = normalizeError(lastError);
|
||||
if (!normalized.retryable && i < maxAttempts - 1) {
|
||||
// 不可重试的错误,但还有候选,继续尝试
|
||||
logger.debug('故障转移', `不可重试错误,跳过: ${lastError}`, meta);
|
||||
// 优先使用 result 中的 retryable,否则通过 normalizeError 推断
|
||||
const retryable = result.retryable !== undefined
|
||||
? result.retryable
|
||||
: normalizeError(lastError).retryable;
|
||||
|
||||
// 不可重试的错误(如内容安全问题),直接返回,不尝试其他候选
|
||||
if (!retryable) {
|
||||
logger.debug('故障转移', `不可重试错误,停止故障转移: ${lastError}`, meta);
|
||||
return { error: lastError, code: 'NOT_RETRYABLE', retryable: false };
|
||||
}
|
||||
|
||||
// 触发重试回调
|
||||
|
||||
@@ -21,8 +21,8 @@ export function isRetryableError(errorMessage) {
|
||||
const retryablePatterns = [
|
||||
// 网络错误
|
||||
/network|net::|econnreset|econnrefused|etimedout/i,
|
||||
// 超时
|
||||
/timeout|timed out/i,
|
||||
// 超时(含中文)
|
||||
/timeout|timed out|加载超时|请求超时/i,
|
||||
// 页面崩溃
|
||||
/crashed|crash/i,
|
||||
// 5xx 服务端错误
|
||||
@@ -63,6 +63,11 @@ export function normalizePageError(err, meta = {}) {
|
||||
logger.error('适配器', timeoutMsg, meta);
|
||||
return { error: timeoutMsg, code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true };
|
||||
}
|
||||
// 页面加载超时 (gotoWithCheck 抛出的中文超时错误)
|
||||
if (err.message?.includes('页面加载超时') || err.message?.includes('页面加载失败')) {
|
||||
logger.error('适配器', err.message, meta);
|
||||
return { error: err.message, code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true };
|
||||
}
|
||||
// 兼容原生 TimeoutError (其他地方抛出的)
|
||||
if (err.name === 'TimeoutError' || err.message?.includes('Timeout')) {
|
||||
logger.error('适配器', '请求超时', meta);
|
||||
|
||||
Reference in New Issue
Block a user