From f3001d8dd1222694b7041faedd49a393fd46096b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9C=B1=E5=B1=B9=E6=9D=B0?= <772894975@qq.com>
Date: Wed, 22 Jul 2026 14:22:10 +0800
Subject: [PATCH] feat: improve Image API error diagnostics
Include HTTP metadata, request identifiers, safe response previews, and request context when image generation fails.
---
src/lib/api.test.ts | 5 +-
src/lib/imageApiShared.test.ts | 39 ++++++++++++
src/lib/imageApiShared.ts | 92 +++++++++++++++++++++++++----
src/lib/openaiCompatibleImageApi.ts | 50 +++++++++++++++-
4 files changed, 172 insertions(+), 14 deletions(-)
create mode 100644 src/lib/imageApiShared.test.ts
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 occurredA 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)
}