import { RequestValidationError } from './image-request-utils'; import { isChannelFailure } from './channel-router'; import { NextResponse } from 'next/server'; export type AgentErrorCode = | 'validation_error' | 'unauthorized' | 'configuration_error' | 'idempotency_key_required' | 'idempotency_conflict' | 'request_in_progress' | 'artifact_not_found' | 'job_not_found' | 'job_expired' | 'upstream_rate_limited' | 'upstream_auth_failed' | 'upstream_unavailable' | 'unexpected_error'; export type AgentErrorDiagnostics = { elapsed_ms?: number; selected_channel_id?: string; upstream_host?: string; upstream_status?: number; transport_error?: boolean; retry_after_seconds?: number; channel_cooldown_scope?: 'credential' | 'channel'; response_headers?: Record; }; export type AgentErrorBody = { error: { code: AgentErrorCode; message: string; retryable: boolean; details?: Record; upstream_status?: number; diagnostics?: AgentErrorDiagnostics; request_id: string; }; }; export function statusForAgentErrorCode(code: AgentErrorCode, upstreamStatus?: number): number { if (code === 'validation_error') return 422; if (code === 'unauthorized') return 401; if (code === 'idempotency_key_required') return 400; if (code === 'idempotency_conflict' || code === 'request_in_progress') return 409; if (code === 'artifact_not_found') return 404; if (code === 'job_not_found') return 404; if (code === 'job_expired') return 410; if (code === 'upstream_rate_limited') return 429; if (code === 'upstream_auth_failed') return upstreamStatus === 403 ? 403 : 401; if (code === 'upstream_unavailable') return 502; return 500; } export class AgentApiError extends Error { readonly code: AgentErrorCode; readonly status: number; readonly retryable: boolean; readonly details?: Record; readonly upstreamStatus?: number; readonly retryAfterSeconds?: number; readonly diagnostics?: AgentErrorDiagnostics; constructor(options: { code: AgentErrorCode; message: string; status: number; retryable?: boolean; details?: Record; upstreamStatus?: number; retryAfterSeconds?: number; diagnostics?: AgentErrorDiagnostics; }) { super(options.message); this.name = 'AgentApiError'; this.code = options.code; this.status = options.status; this.retryable = options.retryable ?? false; this.details = options.details; this.upstreamStatus = options.upstreamStatus; this.retryAfterSeconds = options.retryAfterSeconds; this.diagnostics = cleanDiagnostics(options.diagnostics); } } type ErrorDiagnosticsInput = AgentErrorDiagnostics & { upstreamStatus?: number; retryAfterSeconds?: number; }; const DIAGNOSTIC_HEADER_ALLOWLIST = new Set([ 'content-type', 'date', 'retry-after', 'server', 'cf-ray', 'x-request-id', 'x-ratelimit-reset-requests', 'x-ratelimit-reset-tokens' ]); const MAX_UPSTREAM_RETRY_AFTER_SECONDS = 3600; function buildDiagnostics(error: unknown, input: ErrorDiagnosticsInput = {}): AgentErrorDiagnostics | undefined { const { upstreamStatus, retryAfterSeconds, ...base } = input; const upstreamStatusValue = base.upstream_status ?? upstreamStatus; const retryAfterSecondsValue = base.retry_after_seconds ?? retryAfterSeconds ?? readRetryAfterSeconds(error); const transportError = base.transport_error ?? (isTransportError(error) || undefined); const responseHeaders = base.response_headers ?? readWhitelistedHeaders(error); return cleanDiagnostics({ ...base, ...(upstreamStatusValue !== undefined ? { upstream_status: upstreamStatusValue } : {}), ...(retryAfterSecondsValue !== undefined ? { retry_after_seconds: retryAfterSecondsValue } : {}), ...(transportError !== undefined ? { transport_error: transportError } : {}), ...(responseHeaders ? { response_headers: responseHeaders } : {}) }); } function cleanDiagnostics(diagnostics: AgentErrorDiagnostics | undefined): AgentErrorDiagnostics | undefined { if (!diagnostics) return undefined; const responseHeaders = readWhitelistedHeaderSource(diagnostics.response_headers); const retryAfterSeconds = diagnostics.retry_after_seconds !== undefined ? normalizeRetryAfterSeconds(diagnostics.retry_after_seconds) : undefined; const cleaned: AgentErrorDiagnostics = { ...(diagnostics.elapsed_ms !== undefined ? { elapsed_ms: Math.max(0, Math.round(diagnostics.elapsed_ms)) } : {}), ...(diagnostics.selected_channel_id ? { selected_channel_id: diagnostics.selected_channel_id } : {}), ...(diagnostics.upstream_host ? { upstream_host: diagnostics.upstream_host } : {}), ...(diagnostics.upstream_status !== undefined ? { upstream_status: diagnostics.upstream_status } : {}), ...(diagnostics.transport_error !== undefined ? { transport_error: diagnostics.transport_error } : {}), ...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}), ...(diagnostics.channel_cooldown_scope ? { channel_cooldown_scope: diagnostics.channel_cooldown_scope } : {}), ...(responseHeaders ? { response_headers: responseHeaders } : {}) }; return Object.keys(cleaned).length > 0 ? cleaned : undefined; } function readNumberField(error: unknown, field: string): number | undefined { if (typeof error !== 'object' || error === null || !(field in error)) return undefined; const value = (error as Record)[field]; return typeof value === 'number' ? value : undefined; } function readStringField(error: unknown, field: string): string | undefined { if (typeof error !== 'object' || error === null || !(field in error)) return undefined; const value = (error as Record)[field]; return typeof value === 'string' ? value : undefined; } function readRetryAfterSeconds(error: unknown): number | undefined { const fromField = readNumberField(error, 'retryAfterSeconds'); const normalizedField = fromField !== undefined ? normalizeRetryAfterSeconds(fromField) : undefined; if (normalizedField !== undefined) return normalizedField; const headers = readWhitelistedHeaders(error); const retryAfter = headers?.['retry-after']; if (!retryAfter || !/^\d+$/.test(retryAfter)) return undefined; return normalizeRetryAfterSeconds(Number(retryAfter)); } function normalizeRetryAfterSeconds(value: number): number | undefined { if (!Number.isFinite(value)) return undefined; const rounded = Math.round(value); if (!Number.isSafeInteger(rounded) || rounded < 1 || rounded > MAX_UPSTREAM_RETRY_AFTER_SECONDS) return undefined; return rounded; } function readWhitelistedHeaders(error: unknown): Record | undefined { const headers = readHeadersSource(error); return readWhitelistedHeaderSource(headers); } function readWhitelistedHeaderSource(headers: unknown): Record | undefined { if (!headers) return undefined; const result: Record = {}; for (const name of DIAGNOSTIC_HEADER_ALLOWLIST) { const value = readHeaderValue(headers, name); if (value) { result[name] = value; } } return Object.keys(result).length > 0 ? result : undefined; } function readHeadersSource(error: unknown): unknown { if (typeof error !== 'object' || error === null) return undefined; const object = error as Record; if (object.headers) return object.headers; const response = object.response; if (typeof response !== 'object' || response === null) return undefined; return (response as Record).headers; } function readHeaderValue(headers: unknown, name: string): string | undefined { if (headers instanceof Headers) { return headers.get(name) || undefined; } if (typeof headers !== 'object' || headers === null) return undefined; const entries = Object.entries(headers as Record); const match = entries.find(([key]) => key.toLowerCase() === name); const value = match?.[1]; return typeof value === 'string' && value ? value : undefined; } function parseValidationDetails(message: string): Record | undefined { try { const parsed = JSON.parse(message); if (typeof parsed === 'object' && parsed !== null) { return parsed as Record; } } catch { return undefined; } return undefined; } function inferValidationDetails(message: string): Record | undefined { const fields: Record = {}; const lowerMessage = message.toLowerCase(); if (lowerMessage.includes('response_mode')) { fields.response_mode = message; } else if (lowerMessage.includes('missing required parameter: prompt') || lowerMessage.includes('prompt')) { fields.prompt = message; } else if (lowerMessage.startsWith('n ')) { fields.n = message; } else if (lowerMessage.includes('model')) { fields.model = message; } else if (lowerMessage.includes('image file') || lowerMessage.includes('image data')) { fields.image_0 = message; } else if (lowerMessage.includes('mask')) { fields.mask = message; } else if (lowerMessage.includes('size')) { fields.size = message; } else if (lowerMessage.includes('quality')) { fields.quality = message; } return Object.keys(fields).length > 0 ? { fields } : undefined; } function isTransportError(error: unknown): boolean { const name = readStringField(error, 'name') || readConstructorName(error); if (name === 'APIConnectionError' || name === 'APIConnectionTimeoutError') return true; const code = readStringField(error, 'code') || readCauseChainString(error, 'code'); if (code === 'ENOTFOUND' || code === 'ECONNRESET' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'EAI_AGAIN') { return true; } const message = (readStringField(error, 'message') || '') .trim() .toLowerCase() .replace(/[.!?]+$/, ''); return message.includes('connection error') || message.includes('request timed out') || message.includes('fetch failed'); } function readConstructorName(error: unknown): string | undefined { if (typeof error !== 'object' || error === null) return undefined; const constructorValue = (error as { constructor?: unknown }).constructor; return typeof constructorValue === 'function' ? constructorValue.name : undefined; } function readCauseChainString(error: unknown, fieldName: string, depth = 0): string | undefined { if (depth > 4 || typeof error !== 'object' || error === null || !('cause' in error)) return undefined; const cause = (error as { cause?: unknown }).cause; return readStringField(cause, fieldName) || readCauseChainString(cause, fieldName, depth + 1); } export function createAgentErrorBody(error: AgentApiError, requestId: string): AgentErrorBody { return { error: { code: error.code, message: error.message, retryable: error.retryable, ...(error.details ? { details: error.details } : {}), ...(error.upstreamStatus ? { upstream_status: error.upstreamStatus } : {}), ...(error.diagnostics ? { diagnostics: error.diagnostics } : {}), request_id: requestId } }; } export function agentErrorResponse(error: AgentApiError, requestId: string): NextResponse { const headers: Record = { 'X-Request-Id': requestId }; if (error.retryAfterSeconds) { headers['Retry-After'] = String(error.retryAfterSeconds); } return NextResponse.json(createAgentErrorBody(error, requestId), { status: error.status, headers }); } export function storedAgentErrorResponse( errorBody: AgentErrorBody, headers: Record = {} ): NextResponse { const terminalError = toTerminalAgentErrorBody(errorBody); return NextResponse.json(terminalError, { status: statusForAgentErrorCode(terminalError.error.code, terminalError.error.upstream_status), headers: { ...headers, 'X-Request-Id': terminalError.error.request_id } }); } export function toTerminalAgentErrorBody(errorBody: AgentErrorBody): AgentErrorBody { return { error: { ...errorBody.error, retryable: false } }; } export function normalizeAgentError(error: unknown, diagnostics: AgentErrorDiagnostics = {}): AgentApiError { if (error instanceof AgentApiError) return error; if (error instanceof RequestValidationError) { const details = parseValidationDetails(error.message) ?? inferValidationDetails(error.message); return new AgentApiError({ code: error.status >= 500 ? 'configuration_error' : 'validation_error', message: details ? '请求校验失败。' : error.message, status: error.status === 400 ? 422 : error.status, retryable: false, ...(details ? { details } : {}), diagnostics: buildDiagnostics(error, diagnostics) }); } const status = readNumberField(error, 'status') ?? readNumberField(error, 'statusCode'); const message = error instanceof Error ? error.message : (readStringField(error, 'message') ?? '发生未知错误。'); if (status === 401 || status === 403) { return new AgentApiError({ code: 'upstream_auth_failed', message, status, retryable: false, upstreamStatus: status, diagnostics: buildDiagnostics(error, { ...diagnostics, upstreamStatus: status, channel_cooldown_scope: 'credential' }) }); } if (status === 400) { return new AgentApiError({ code: 'validation_error', message: '上游拒绝了请求参数。', status: 422, retryable: false, details: inferValidationDetails(message), upstreamStatus: status, diagnostics: buildDiagnostics(error, { ...diagnostics, upstreamStatus: status }) }); } if (status === 429) { const retryAfterSeconds = readRetryAfterSeconds(error) ?? 30; return new AgentApiError({ code: 'upstream_rate_limited', message, status, retryable: true, upstreamStatus: status, retryAfterSeconds, diagnostics: buildDiagnostics(error, { ...diagnostics, upstreamStatus: status, retryAfterSeconds, channel_cooldown_scope: 'credential' }) }); } if (status && [500, 502, 503, 504, 520, 522, 523, 524].includes(status)) { const retryAfterSeconds = readRetryAfterSeconds(error) ?? 15; return new AgentApiError({ code: 'upstream_unavailable', message, status: 502, retryable: true, upstreamStatus: status, retryAfterSeconds, diagnostics: buildDiagnostics(error, { ...diagnostics, upstreamStatus: status, retryAfterSeconds, channel_cooldown_scope: 'channel' }) }); } if (isChannelFailure(error)) { const retryAfterSeconds = readRetryAfterSeconds(error) ?? 15; return new AgentApiError({ code: 'upstream_unavailable', message, status: 502, retryable: true, retryAfterSeconds, diagnostics: buildDiagnostics(error, { ...diagnostics, retryAfterSeconds, channel_cooldown_scope: 'channel', transport_error: true }) }); } return new AgentApiError({ code: 'unexpected_error', message, status: 500, retryable: false, diagnostics: buildDiagnostics(error, diagnostics) }); }