Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})

Expand Down
39 changes: 39 additions & 0 deletions src/lib/imageApiShared.test.ts
Original file line number Diff line number Diff line change
@@ -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('<!doctype html><html><head><title>524: A timeout occurred</title></head><body><h1>A timeout occurred</h1><p>The origin web server timed out.</p></body></html>', {
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('<!doctype html>')
})

it('redacts API keys and bearer tokens', () => {
expect(redactApiErrorText('Bearer abc.def-123 sk-1234567890abcdef')).toBe('Bearer [REDACTED] sk-[REDACTED]')
})
})
92 changes: 81 additions & 11 deletions src/lib/imageApiShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
const error = record.error
if (error && typeof error === 'object' && !Array.isArray(error)) {
const message = normalizeErrorDetail((error as Record<string, unknown>).message)
if (message) return message
}
return normalizeErrorDetail(record.detail)
|| normalizeErrorDetail(error)
|| normalizeErrorDetail(record.message)
}

function decodeBasicHtmlEntities(value: string): string {
return value
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
}

function getTextErrorPreview(rawText: string, contentType: string): string {
const sanitized = redactApiErrorText(rawText).trim()
if (!sanitized) return ''
if (contentType.includes('text/html') || /<!doctype\s+html|<html\b/i.test(sanitized)) {
const matches = Array.from(sanitized.matchAll(/<(title|h1|h2|p)\b[^>]*>([\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<string> {
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<TaskParams> {
Expand Down
50 changes: 48 additions & 2 deletions src/lib/openaiCompatibleImageApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type CallApiResult,
fetchImageUrlAsDataUrl,
getApiErrorMessage,
getApiResponseRequestIds,
getDataUrlDecodedByteSize,
getDataUrlEncodedByteSize,
isDataUrl,
Expand All @@ -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:'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down