diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index c91b8292..b17e1b9f 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -431,7 +431,10 @@ describe('callImageApi', () => { 'data:image/png;base64,aW1hZ2Ut1', 'data:image/png;base64,aW1hZ2Ut3', ]) - expect(result.failedRequests).toEqual([{ requestIndex: 1, error: 'Failed to fetch' }]) + expect(result.failedRequests).toHaveLength(1) + expect(result.failedRequests?.[0]).toMatchObject({ requestIndex: 1 }) + expect(result.failedRequests?.[0]?.error).toContain('Failed to fetch') + expect(result.failedRequests?.[0]?.error).toContain('请求诊断:Image API') expect(result.actualParams).toMatchObject({ n: 2 }) }) diff --git a/src/lib/imageApiShared.test.ts b/src/lib/imageApiShared.test.ts new file mode 100644 index 00000000..5852f74f --- /dev/null +++ b/src/lib/imageApiShared.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { getApiErrorMessage, redactApiErrorText } from './imageApiShared' + +describe('API error diagnostics', () => { + it('includes HTTP status, provider error, and request identifiers', async () => { + const response = new Response(JSON.stringify({ error: { message: 'generation rejected' } }), { + status: 403, + statusText: 'Forbidden', + headers: { + 'Content-Type': 'application/json', + 'X-Request-Id': 'req-123', + 'CF-Ray': 'ray-456', + }, + }) + + await expect(getApiErrorMessage(response)).resolves.toBe([ + 'HTTP 403 Forbidden', + 'generation rejected', + '请求标识:x-request-id=req-123,cf-ray=ray-456', + ].join('\n')) + }) + + it('summarizes HTML gateway errors instead of returning the full page', async () => { + const response = new Response('524: A timeout occurred

A timeout occurred

The origin web server timed out.

', { + status: 524, + headers: { 'Content-Type': 'text/html' }, + }) + + const message = await getApiErrorMessage(response) + expect(message).toContain('HTTP 524') + expect(message).toContain('524: A timeout occurred') + expect(message).toContain('The origin web server timed out.') + expect(message).not.toContain('') + }) + + it('redacts API keys and bearer tokens', () => { + expect(redactApiErrorText('Bearer abc.def-123 sk-1234567890abcdef')).toBe('Bearer [REDACTED] sk-[REDACTED]') + }) +}) diff --git a/src/lib/imageApiShared.ts b/src/lib/imageApiShared.ts index 6b899036..392ee94e 100644 --- a/src/lib/imageApiShared.ts +++ b/src/lib/imageApiShared.ts @@ -154,24 +154,94 @@ export async function fetchImageUrlAsDataUrl(url: string, fallbackMime: string, return blobToDataUrl(blob, fallbackMime) } +const API_ERROR_PREVIEW_MAX_CHARS = 4000 + +export function redactApiErrorText(value: string): string { + return value + .replace(/\bBearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [REDACTED]') + .replace(/\bsk-[A-Za-z0-9_-]{10,}\b/g, 'sk-[REDACTED]') +} + +function normalizeErrorDetail(value: unknown): string { + if (typeof value === 'string') return value.trim() + if (Array.isArray(value)) { + return value + .map((item) => typeof item === 'string' ? item : JSON.stringify(item)) + .join('\n') + .trim() + } + return '' +} + +function getJsonErrorDetail(payload: unknown): string { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return '' + const record = payload as Record + const error = record.error + if (error && typeof error === 'object' && !Array.isArray(error)) { + const message = normalizeErrorDetail((error as Record).message) + if (message) return message + } + return normalizeErrorDetail(record.detail) + || normalizeErrorDetail(error) + || normalizeErrorDetail(record.message) +} + +function decodeBasicHtmlEntities(value: string): string { + return value + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") +} + +function getTextErrorPreview(rawText: string, contentType: string): string { + const sanitized = redactApiErrorText(rawText).trim() + if (!sanitized) return '' + if (contentType.includes('text/html') || /]*>([\s\S]*?)<\/\1>/gi)) + .map((match) => decodeBasicHtmlEntities(match[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim())) + .filter(Boolean) + return Array.from(new Set(matches)).slice(0, 4).join('\n').slice(0, API_ERROR_PREVIEW_MAX_CHARS) + } + return sanitized.slice(0, API_ERROR_PREVIEW_MAX_CHARS) +} + +export function getApiResponseRequestIds(response: Response): string[] { + return ['x-request-id', 'x-client-request-id', 'cf-ray'] + .map((name) => { + const value = response.headers.get(name)?.trim() + return value ? `${name}=${value}` : '' + }) + .filter(Boolean) +} + export async function getApiErrorMessage(response: Response): Promise { - let errorMsg = `HTTP ${response.status}` - const textResponse = response.clone() + const statusLine = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}` + const contentType = response.headers.get('content-type')?.toLowerCase() ?? '' + let rawText = '' try { - const errJson = await response.json() - if (errJson.error?.message) errorMsg = errJson.error.message - else if (typeof errJson.detail === 'string') errorMsg = errJson.detail - else if (Array.isArray(errJson.detail)) errorMsg = errJson.detail.map((item: unknown) => typeof item === 'string' ? item : JSON.stringify(item)).join('\n') - else if (typeof errJson.error === 'string') errorMsg = errJson.error - else if (errJson.message) errorMsg = errJson.message + rawText = await response.text() } catch { + /* ignore */ + } + + let detail = '' + if (rawText) { try { - errorMsg = await textResponse.text() + detail = getJsonErrorDetail(JSON.parse(rawText)) } catch { - /* ignore */ + detail = getTextErrorPreview(rawText, contentType) } } - return errorMsg + + const requestIds = getApiResponseRequestIds(response) + return [ + statusLine, + detail && detail !== statusLine ? redactApiErrorText(detail) : '', + requestIds.length ? `请求标识:${requestIds.join(',')}` : '', + ].filter(Boolean).join('\n') } export function pickActualParams(source: unknown): Partial { diff --git a/src/lib/openaiCompatibleImageApi.ts b/src/lib/openaiCompatibleImageApi.ts index 4144cdb7..06f6133f 100644 --- a/src/lib/openaiCompatibleImageApi.ts +++ b/src/lib/openaiCompatibleImageApi.ts @@ -10,6 +10,7 @@ import { type CallApiResult, fetchImageUrlAsDataUrl, getApiErrorMessage, + getApiResponseRequestIds, getDataUrlDecodedByteSize, getDataUrlEncodedByteSize, isDataUrl, @@ -18,6 +19,7 @@ import { MIME_MAP, normalizeBase64Image, pickActualParams, + redactApiErrorText, } from './imageApiShared' const PROMPT_REWRITE_GUARD_PREFIX = 'Use the following text as the complete prompt. Do not rewrite it:' @@ -564,6 +566,7 @@ async function callImagesApiSingle(opts: CallApiOptions, profile: ApiProfile): P const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), profile.timeout * 1000) + const requestStartedAt = Date.now() try { let response: Response @@ -669,15 +672,58 @@ async function callImagesApiSingle(opts: CallApiOptions, profile: ApiProfile): P } if (!response.ok) { + const rawResponsePayload = redactApiErrorText(await response.clone().text().catch(() => '')).slice(0, 20_000) const errorMessage = await getApiErrorMessage(response) - throw new Error(maybeAppendStreamingHint(errorMessage, response.status, profile.streamImages)) + const error = new Error(maybeAppendStreamingHint(errorMessage, response.status, profile.streamImages)) + if (rawResponsePayload) (error as Error & { rawResponsePayload?: string }).rawResponsePayload = rawResponsePayload + throw error } if (profile.streamImages && isEventStreamResponse(response)) { return parseImagesApiStreamResponse(response, mime, opts.onPartialImage) } - return parseImagesApiResponse(await response.json() as ImageApiResponse, mime, controller.signal) + const rawResponsePayload = await response.text() + let payload: ImageApiResponse + try { + payload = JSON.parse(rawResponsePayload) as ImageApiResponse + } catch (err) { + const requestIds = getApiResponseRequestIds(response) + const contentType = response.headers.get('content-type') || '未知' + const parseMessage = err instanceof Error ? err.message : String(err) + const error = new Error([ + `API 响应解析失败:HTTP ${response.status} 返回的内容不是有效 JSON`, + `Content-Type:${contentType}`, + requestIds.length ? `请求标识:${requestIds.join(',')}` : '', + `解析错误:${parseMessage}`, + `响应摘要:${redactApiErrorText(rawResponsePayload).slice(0, 2000)}`, + ].filter(Boolean).join('\n')) + ;(error as Error & { rawResponsePayload?: string }).rawResponsePayload = redactApiErrorText(rawResponsePayload).slice(0, 20_000) + throw error + } + return parseImagesApiResponse(payload, mime, controller.signal) + } catch (err) { + const elapsedSeconds = ((Date.now() - requestStartedAt) / 1000).toFixed(1) + const diagnostics = `请求诊断:Image API,模型=${profile.model},尺寸=${params.size},质量=${params.quality},格式=${params.output_format},编辑=${isEdit ? '是' : '否'},API代理=${useApiProxy ? '开启' : '关闭'},耗时=${elapsedSeconds}秒` + if (err instanceof Error && !err.message.includes('请求诊断:')) { + try { + err.message = `${err.message}\n${diagnostics}` + } catch { + /* DOMException 等只读错误保留原始消息,诊断仍写入控制台。 */ + } + } + console.error('[Image API 请求失败]', { + model: profile.model, + size: params.size, + quality: params.quality, + outputFormat: params.output_format, + isEdit, + apiProxy: useApiProxy, + elapsedSeconds, + errorName: err instanceof Error ? err.name : typeof err, + errorMessage: redactApiErrorText(err instanceof Error ? err.message : String(err)).slice(0, 4000), + }) + throw err } finally { clearTimeout(timeoutId) }