mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
fix(conversion): improve stream failures and diagnostic exports
This commit is contained in:
@@ -4,6 +4,7 @@ const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() }))
|
||||
vi.mock('@/api/client', () => ({ default: { get: getMock } }))
|
||||
|
||||
import { dashboardApi } from '@/api/dashboard'
|
||||
import { requestTraceApi } from '@/api/requestTrace'
|
||||
import { cache } from '@/utils/cache'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -13,6 +14,20 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('dashboard body loading', () => {
|
||||
it('reports download progress so diagnostic exports can abort oversized bodies', async () => {
|
||||
const onProgress = vi.fn()
|
||||
getMock.mockResolvedValue({ data: new ArrayBuffer(0), headers: { 'x-aether-body-encoding': 'json', 'x-aether-usage-id': 'usage-1', 'x-aether-body-field': 'response_body' } })
|
||||
await dashboardApi.getRequestBody('usage-1', 'response_body', undefined, onProgress)
|
||||
getMock.mock.calls[0][1].onDownloadProgress({ loaded: 2 * 1024 * 1024 })
|
||||
expect(onProgress).toHaveBeenCalledWith(2 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('records the gateway version at export without confusing it with failure-time metadata', async () => {
|
||||
getMock.mockResolvedValue({ data: { request_id: 'request-1', candidates: [] }, headers: { 'x-aether-build-version': 'test-build' } })
|
||||
expect(await requestTraceApi.getRequestTrace('request-1')).toMatchObject({ gateway_version: 'test-build' })
|
||||
getMock.mockResolvedValue({ data: { request_id: 'request-1', candidates: [] } })
|
||||
expect(await requestTraceApi.getRequestTrace('request-1')).toMatchObject({ gateway_version: null })
|
||||
})
|
||||
it('requests opaque body bytes, outside the JSON detail cache', async () => {
|
||||
const bytes = new ArrayBuffer(20)
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -481,11 +481,12 @@ export const dashboardApi = {
|
||||
return options.signal ? fetchDetail() : cachedRequest(cacheKey, fetchDetail, cacheTtlMs)
|
||||
},
|
||||
|
||||
async getRequestBody(requestId: string, field: RequestBodyField, signal?: AbortSignal) {
|
||||
async getRequestBody(requestId: string, field: RequestBodyField, signal?: AbortSignal, onProgress?: (loaded: number) => void) {
|
||||
const response = await apiClient.get<ArrayBuffer>(`/api/admin/usage/${requestId}`, {
|
||||
params: { include_bodies: true, body_field: field, body_format: 'raw' },
|
||||
responseType: 'arraybuffer',
|
||||
signal,
|
||||
...(onProgress ? { onDownloadProgress: (event: { loaded: number }) => onProgress(event.loaded) } : {}),
|
||||
})
|
||||
const encoding = response.headers['x-aether-body-encoding']
|
||||
if ((encoding !== 'gzip' && encoding !== 'json') || response.headers['x-aether-usage-id'] !== requestId || response.headers['x-aether-body-field'] !== field) {
|
||||
|
||||
@@ -121,6 +121,8 @@ export interface CandidateRecord {
|
||||
}
|
||||
|
||||
export interface RequestTrace {
|
||||
gateway_version?: string | null
|
||||
diagnostic_request?: { usage_id: string, body_state?: string | null } | null
|
||||
request_id: string
|
||||
request_path?: string
|
||||
request_query_string?: string
|
||||
@@ -154,7 +156,7 @@ export const requestTraceApi = {
|
||||
const response = await apiClient.get<RequestTrace>(`/api/admin/monitoring/trace/${requestId}`, {
|
||||
params: { attempted_only: attemptedOnly },
|
||||
})
|
||||
return response.data
|
||||
return { ...response.data, gateway_version: response.headers?.['x-aether-build-version'] ?? response.data.gateway_version ?? null }
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -506,19 +506,30 @@
|
||||
empty-message="无上游响应"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttemptRequestError.diagnostic"
|
||||
class="error-json error-diagnostic-json"
|
||||
>
|
||||
<JsonContentPanel
|
||||
:data="currentAttemptRequestError.diagnostic"
|
||||
:is-dark="isDark"
|
||||
title="失败诊断"
|
||||
empty-message="无失败诊断信息"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="diagnosticDisplay"
|
||||
class="error-json error-diagnostic-json"
|
||||
>
|
||||
<JsonContentPanel
|
||||
:data="diagnosticDisplay"
|
||||
:is-dark="isDark"
|
||||
:custom-copy="true"
|
||||
:copied="diagnosticCopied"
|
||||
:copy-disabled="diagnosticCopying"
|
||||
title="失败诊断"
|
||||
empty-message="无失败诊断信息"
|
||||
@copy="copyFailureDiagnostic"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="diagnosticDisplay"
|
||||
class="text-xs text-muted-foreground mt-1"
|
||||
>
|
||||
{{ diagnosticCopying ? '正在读取并脱敏诊断上下文…' : '复制时补取已采集的正文并脱敏;未采集、无权限或超限的信息会明确标注。分享前请检查诊断内容。' }}
|
||||
</p>
|
||||
|
||||
<!-- 额外数据 -->
|
||||
<details
|
||||
v-if="currentAttemptExtraDataDisplay"
|
||||
@@ -572,6 +583,9 @@ import { formatTokens } from '@/utils/format'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { resolveTimelineFinalStatus } from '../utils/status'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { buildFailureDiagnosticBundle, diagnosticPathFromMessage, visibleFailureRecords } from '../utils/failureDiagnostic'
|
||||
import { prepareDiagnosticExport, sanitizeDiagnostic } from '../utils/diagnosticExport'
|
||||
import {
|
||||
buildPoolGroupVisibleAttempts,
|
||||
buildPoolParticipatedCandidates,
|
||||
@@ -1442,6 +1456,7 @@ const currentAttemptFailureDiagnostic = computed<{
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt) return null
|
||||
const extra = extractObject(attempt.extra_data)
|
||||
if (extractObject(extra?.failure_diagnostic)?.safe_to_show === false) return null
|
||||
const failureDiagnostic = extractVisibleFailureDiagnostic(extra)
|
||||
const error = failureDiagnostic
|
||||
? failureDiagnostic
|
||||
@@ -1462,12 +1477,20 @@ const currentAttemptFailureDiagnostic = computed<{
|
||||
const isGenericExecutionRuntimeStatusMessage = (message: string): boolean =>
|
||||
/execution runtime (stream )?returned non-success status \d+/i.test(message)
|
||||
|
||||
const isStreamTerminalDiagnosticMessage = (message: string): boolean =>
|
||||
/(?:unsupported provider stream finish reason|upstream stream ended with finish reason)\s*:/i.test(message)
|
||||
|
||||
const isStreamFinishErrorDiagnostic = (message: string): boolean =>
|
||||
/(?:unsupported provider stream finish reason|upstream stream ended with finish reason)\s*:\s*error(?:[ \t]*(?:$|\r?\n)|["')])/i.test(message)
|
||||
|
||||
const isLocalSyncFinalizeDiagnostic = (message: string): boolean =>
|
||||
/local sync attempt failed before terminal finalization/i.test(message)
|
||||
|| /unsupported provider stream (event|finish reason)/i.test(message)
|
||||
|
||||
const isActionableDiagnosticMessage = (message: string): boolean =>
|
||||
isLocalSyncFinalizeDiagnostic(message) || isConversionDiagnosticMessage(message)
|
||||
isLocalSyncFinalizeDiagnostic(message)
|
||||
|| isStreamTerminalDiagnosticMessage(message)
|
||||
|| isConversionDiagnosticMessage(message)
|
||||
|
||||
const extractVisibleFailureDiagnostic = (
|
||||
extra: Record<string, unknown> | null | undefined,
|
||||
@@ -1480,10 +1503,8 @@ const extractVisibleFailureDiagnostic = (
|
||||
const extractVisibleDiagnosticObjects = (
|
||||
extra: Record<string, unknown> | null | undefined,
|
||||
): Array<Record<string, unknown>> => [
|
||||
extractVisibleFailureDiagnostic(extra),
|
||||
extractObject(extra?.request_conversion_error),
|
||||
extractObject(extra?.request_body_build_error),
|
||||
].filter((value): value is Record<string, unknown> => Boolean(value))
|
||||
...visibleFailureRecords(extra ?? {}),
|
||||
]
|
||||
|
||||
const extractVisibleDiagnosticMessage = (
|
||||
extra: Record<string, unknown> | null | undefined,
|
||||
@@ -1555,6 +1576,10 @@ const formatUnsupportedStreamEventMessage = (message: string): string => {
|
||||
}
|
||||
|
||||
const formatUnsupportedFinishReasonMessage = (message: string): string => {
|
||||
const terminalMatch = message.match(/^unsupported provider stream finish reason\s*:\s*(.+)$/i)
|
||||
if (terminalMatch?.[1]) {
|
||||
return `流式终态校验失败:上游返回了当前不支持的 finish reason(${terminalMatch[1].trim()}),已按失败处理`
|
||||
}
|
||||
const fieldDetail = extractFieldDetail(message)
|
||||
const legacyMatch = message.match(/unsupported provider stream finish reason\s+(.+?)\s+cannot be converted losslessly/i)
|
||||
const detail = fieldDetail || (legacyMatch?.[1]
|
||||
@@ -1569,7 +1594,7 @@ const formatKnownConversionErrorMessage = (message: string): string => {
|
||||
return `格式转换失败:${formatConversionPair(lossy[1], lossy[2])} 在字段 ${normalizeDiagnosticFieldPath(lossy[3])} 会丢失信息:${lossy[4].trim()}`
|
||||
}
|
||||
|
||||
const unaudited = message.match(/^unaudited field\s+(.+?)\s+in\s+(.+?)\s+cannot be converted to\s+([^:]+):\s*(.+)$/i)
|
||||
const unaudited = message.match(/^unaudited field\s+(.+?)\s+in\s+(.+?)\s+cannot be converted to\s+(\S+):\s*(.+)$/i)
|
||||
if (unaudited) {
|
||||
return `格式转换失败:${formatConversionPair(unaudited[2], unaudited[3])} 的字段 ${normalizeDiagnosticFieldPath(unaudited[1])} 尚未审计,不能安全转换:${unaudited[4].trim()}`
|
||||
}
|
||||
@@ -1579,7 +1604,7 @@ const formatKnownConversionErrorMessage = (message: string): string => {
|
||||
return `格式转换失败:${formatApiFormat(unsupportedField[2])} 不支持字段 ${normalizeDiagnosticFieldPath(unsupportedField[1])}:${unsupportedField[3].trim()}`
|
||||
}
|
||||
|
||||
const invalidEnum = message.match(/^invalid enum value\s+(.+?)\s+for\s+(.+)\.([^.\s]+)$/i)
|
||||
const invalidEnum = message.match(/^invalid enum value\s+(.+?)\s+for\s+([\w:-]+)\.(.+)$/i)
|
||||
if (invalidEnum) {
|
||||
return `格式转换失败:${formatApiFormat(invalidEnum[2])} 字段 ${normalizeDiagnosticFieldPath(invalidEnum[3])} 的枚举值 ${invalidEnum[1].trim()} 无效`
|
||||
}
|
||||
@@ -1606,7 +1631,7 @@ const formatKnownConversionErrorMessage = (message: string): string => {
|
||||
|
||||
const isConversionDiagnosticMessage = (message: string): boolean => {
|
||||
const normalized = message.trim()
|
||||
if (!normalized) return false
|
||||
if (!normalized || isStreamTerminalDiagnosticMessage(normalized)) return false
|
||||
return /conversion|converted|convertible|cannot be converted|lossy conversion|unsupported field|unaudited field|invalid enum value|invalid target field|unsupported ai format|failed to (parse|emit) .+ (request|response)|unsupported provider stream (event|finish reason)|转换|无损|字段 .*不支持/i
|
||||
.test(normalized)
|
||||
}
|
||||
@@ -1622,6 +1647,9 @@ const formatAttemptErrorMessage = (message: string, statusCode?: number): string
|
||||
if (localSyncInternal?.[1]) {
|
||||
return formatAttemptErrorMessage(decodeRustDebugString(localSyncInternal[1]), statusCode)
|
||||
}
|
||||
if (isStreamFinishErrorDiagnostic(normalized)) {
|
||||
return '上游流式响应异常终止:上游返回了错误结束原因(error),已按失败处理'
|
||||
}
|
||||
if (/unsupported provider stream event cannot be converted losslessly/i.test(normalized)) {
|
||||
return formatUnsupportedStreamEventMessage(normalized)
|
||||
}
|
||||
@@ -1645,9 +1673,7 @@ const shouldShowAttemptMessageWithUpstreamResponse = (
|
||||
if (!upstreamResponse || !hasRenderableValue(upstreamResponse.body)) return true
|
||||
const normalized = rawMessage.trim()
|
||||
if (!normalized) return false
|
||||
if (isLocalSyncFinalizeDiagnostic(normalized)) return true
|
||||
if (isConversionDiagnosticMessage(normalized)) return true
|
||||
return false
|
||||
return isActionableDiagnosticMessage(normalized)
|
||||
}
|
||||
|
||||
const currentAttemptRequestError = computed<{
|
||||
@@ -1691,8 +1717,7 @@ const currentAttemptRequestError = computed<{
|
||||
const visibleDiagnosticObjects = extractVisibleDiagnosticObjects(extra)
|
||||
const shouldAttachDiagnostic = Boolean(
|
||||
visibleDiagnosticObjects.length
|
||||
|| isLocalSyncFinalizeDiagnostic(rawMessage)
|
||||
|| isConversionDiagnosticMessage(rawMessage),
|
||||
|| isActionableDiagnosticMessage(rawMessage),
|
||||
)
|
||||
const diagnostic = shouldAttachDiagnostic
|
||||
? buildAttemptDiagnosticPayload(
|
||||
@@ -1736,21 +1761,7 @@ const diagnosticPathFromObject = (value: unknown): string => {
|
||||
|| ''
|
||||
}
|
||||
|
||||
const diagnosticFieldPathFromMessage = (message: string): string => {
|
||||
const normalized = message.trim()
|
||||
if (!normalized) return ''
|
||||
const fieldMatch = normalized.match(/field\s+([^;=]+?)\s*(?:=|is unsupported|不支持)/i)
|
||||
if (fieldMatch?.[1]) return normalizeDiagnosticFieldPath(fieldMatch[1])
|
||||
const lossyMatch = normalized.match(/lossy conversion blocked from\s+\S+\s+to\s+\S+\s+at\s+([^:]+):/i)
|
||||
if (lossyMatch?.[1]) return normalizeDiagnosticFieldPath(lossyMatch[1])
|
||||
const invalidTargetMatch = normalized.match(/invalid target field\s+(.+?)\s+for\s+/i)
|
||||
if (invalidTargetMatch?.[1]) return normalizeDiagnosticFieldPath(invalidTargetMatch[1])
|
||||
const unsupportedFieldMatch = normalized.match(/unsupported field\s+(.+?)\s+in\s+/i)
|
||||
if (unsupportedFieldMatch?.[1]) return normalizeDiagnosticFieldPath(unsupportedFieldMatch[1])
|
||||
const invalidEnumMatch = normalized.match(/invalid enum value\s+.+?\s+for\s+.+\.([^.\s]+)$/i)
|
||||
if (invalidEnumMatch?.[1]) return normalizeDiagnosticFieldPath(invalidEnumMatch[1])
|
||||
return ''
|
||||
}
|
||||
const diagnosticFieldPathFromMessage = diagnosticPathFromMessage
|
||||
|
||||
function resolveAttemptDiagnosticBreakpoint(attempt: CandidateRecord, rawMessageOverride = ''): string {
|
||||
const extra = extractObject(attempt.extra_data)
|
||||
@@ -1766,11 +1777,13 @@ function resolveAttemptDiagnosticBreakpoint(attempt: CandidateRecord, rawMessage
|
||||
readStringField(requestBodyBuildError ?? {}, 'message') ?? '',
|
||||
typeof attempt.error_message === 'string' ? attempt.error_message : '',
|
||||
].find(item => item.trim()) ?? ''
|
||||
const streamFinishReasonPath = isStreamTerminalDiagnosticMessage(rawMessage) ? '$.finish_reason' : ''
|
||||
|
||||
return diagnosticPathFromObject(failureDiagnostic)
|
||||
|| diagnosticPathFromObject(requestConversionError)
|
||||
|| diagnosticPathFromObject(requestBodyBuildError)
|
||||
|| diagnosticFieldPathFromMessage(rawMessage)
|
||||
|| streamFinishReasonPath
|
||||
|| '$'
|
||||
}
|
||||
|
||||
@@ -1814,6 +1827,12 @@ function buildAttemptDiagnosticPayload(
|
||||
: currentAttemptFormatDisplay.value
|
||||
const analysisHint = (() => {
|
||||
const raw = `${summary}\n${rawMessageForBreakpoint}\n${attempt.error_message ?? ''}`.toLowerCase()
|
||||
if (isStreamFinishErrorDiagnostic(raw)) {
|
||||
return '上游通过结束原因报告了流式错误:检查原始 SSE 中的 error/message 及上游日志;保持失败状态,不要映射为正常结束。'
|
||||
}
|
||||
if (isStreamTerminalDiagnosticMessage(raw)) {
|
||||
return '断点在上游流式终态校验:检查原始结束原因及协议兼容性;该错误不代表发生了格式转换,不能确认成功时保持失败闭合。'
|
||||
}
|
||||
if (raw.includes('unsupported provider stream event')) {
|
||||
return '断点在上游流式事件解析/转换矩阵:先按 breakpoint 对应字段确认 event type,再决定是补 canonical mapping 还是加入 known noop。'
|
||||
}
|
||||
@@ -1823,7 +1842,7 @@ function buildAttemptDiagnosticPayload(
|
||||
if (raw.includes('lossy conversion') || raw.includes('无损') || raw.includes('丢失信息') || raw.includes('request_conversion')) {
|
||||
return '断点在请求/响应格式转换器:检查 breakpoint 字段是否能被目标格式表达,不能表达就需要拒绝、降级或新增显式映射策略。'
|
||||
}
|
||||
return '先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / raw.error_message 和 upstream_response。'
|
||||
return '先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / node.error_message 和 upstream_response。'
|
||||
})()
|
||||
|
||||
const payload = {
|
||||
@@ -1853,15 +1872,82 @@ function buildAttemptDiagnosticPayload(
|
||||
},
|
||||
raw: {
|
||||
failure_diagnostic: rawFailureDiagnostic,
|
||||
request_conversion_error: rawRequestConversionError,
|
||||
request_body_build_error: rawRequestBodyBuildError,
|
||||
request_conversion_error: rawFailureDiagnostic?.safe_to_show === false || extractObject(extra?.failure_diagnostic)?.safe_to_show === false ? null : rawRequestConversionError,
|
||||
request_body_build_error: rawFailureDiagnostic?.safe_to_show === false || extractObject(extra?.failure_diagnostic)?.safe_to_show === false ? null : rawRequestBodyBuildError,
|
||||
error_flow: extractObject(extra?.error_flow),
|
||||
upstream_response: upstreamResponseDisplay ?? normalizeUpstreamResponseDisplay(extra?.upstream_response),
|
||||
},
|
||||
}
|
||||
return payload
|
||||
return buildFailureDiagnosticBundle(payload, attempt, trace.value, rawMessageForBreakpoint)
|
||||
}
|
||||
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const diagnosticCopying = ref(false)
|
||||
const diagnosticCopied = ref(false)
|
||||
const exportedDiagnostic = ref<Record<string, unknown> | null>(null)
|
||||
let diagnosticController: AbortController | null = null
|
||||
let diagnosticCopyTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const currentAttemptDiagnostic = computed(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt) return null
|
||||
if (attempt.status === 'failed') return currentAttemptRequestError.value?.diagnostic ?? null
|
||||
if (attempt.status !== 'skipped' || !currentAttemptFailureDiagnostic.value) return null
|
||||
return buildAttemptDiagnosticPayload(
|
||||
attempt,
|
||||
currentAttemptFailureDiagnostic.value.message,
|
||||
attempt.status_code,
|
||||
normalizeUpstreamResponseDisplay(attempt.extra_data?.upstream_response),
|
||||
extractVisibleDiagnosticMessage(attempt.extra_data),
|
||||
)
|
||||
})
|
||||
const diagnosticDisplay = computed(() => exportedDiagnostic.value
|
||||
?? (currentAttemptDiagnostic.value ? sanitizeDiagnostic(currentAttemptDiagnostic.value) as Record<string, unknown> : null))
|
||||
|
||||
function resetDiagnosticCopy() {
|
||||
diagnosticController?.abort()
|
||||
diagnosticController = null
|
||||
clearTimeout(diagnosticCopyTimer)
|
||||
diagnosticCopying.value = false
|
||||
diagnosticCopied.value = false
|
||||
exportedDiagnostic.value = null
|
||||
}
|
||||
|
||||
async function copyFailureDiagnostic() {
|
||||
if (!currentAttemptDiagnostic.value || !currentAttempt.value || diagnosticCopying.value) return
|
||||
const bundle = JSON.parse(JSON.stringify(currentAttemptDiagnostic.value)) as Record<string, unknown>
|
||||
const attempt = JSON.parse(JSON.stringify(currentAttempt.value)) as CandidateRecord
|
||||
const currentTrace = trace.value ? { ...trace.value, candidates: [] } : null
|
||||
const controller = new AbortController()
|
||||
diagnosticController = controller
|
||||
diagnosticCopying.value = true
|
||||
diagnosticCopied.value = false
|
||||
try {
|
||||
const diagnostic = await prepareDiagnosticExport(bundle, attempt, currentTrace, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
exportedDiagnostic.value = diagnostic
|
||||
const copied = await copyToClipboard(JSON.stringify(diagnostic, null, 2))
|
||||
if (controller.signal.aborted) return
|
||||
diagnosticCopied.value = copied
|
||||
if (copied) diagnosticCopyTimer = setTimeout(() => { diagnosticCopied.value = false }, 2000)
|
||||
} catch {
|
||||
if (controller.signal.aborted) return
|
||||
const fallback = sanitizeDiagnostic({
|
||||
...bundle,
|
||||
reproduction: { status: 'insufficient_context', missing_context: ['export_failed'] },
|
||||
}) as Record<string, unknown>
|
||||
exportedDiagnostic.value = fallback
|
||||
diagnosticCopied.value = await copyToClipboard(JSON.stringify(fallback, null, 2))
|
||||
} finally {
|
||||
if (diagnosticController === controller) {
|
||||
diagnosticCopying.value = false
|
||||
diagnosticController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.requestId, () => currentAttempt.value?.id, () => currentAttempt.value?.error_message], resetDiagnosticCopy)
|
||||
onBeforeUnmount(resetDiagnosticCopy)
|
||||
|
||||
const currentAttemptExtraDataDisplay = computed<Record<string, unknown> | null>(() => {
|
||||
const extra = extractObject(currentAttempt.value?.extra_data)
|
||||
if (!extra) return null
|
||||
|
||||
+111
-2
@@ -8,6 +8,13 @@ const requestTraceApiMock = vi.hoisted(() => ({
|
||||
getRequestTrace: vi.fn(),
|
||||
}))
|
||||
|
||||
const diagnosticCopyMock = vi.hoisted(() => ({ prepare: vi.fn(), copy: vi.fn() }))
|
||||
vi.mock('@/composables/useClipboard', () => ({ useClipboard: () => ({ copyToClipboard: diagnosticCopyMock.copy }) }))
|
||||
vi.mock('@/features/usage/utils/diagnosticExport', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@/features/usage/utils/diagnosticExport')>(),
|
||||
prepareDiagnosticExport: diagnosticCopyMock.prepare,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/requestTrace', () => ({
|
||||
requestTraceApi: requestTraceApiMock,
|
||||
}))
|
||||
@@ -62,9 +69,16 @@ vi.mock('../JsonContentPanel.vue', async () => {
|
||||
type: String,
|
||||
default: 'JSON',
|
||||
},
|
||||
customCopy: Boolean,
|
||||
copyDisabled: Boolean,
|
||||
copied: Boolean,
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('pre', { 'data-title': props.title }, JSON.stringify(props.data))
|
||||
emits: ['copy'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('div', [
|
||||
h('pre', { 'data-title': props.title }, JSON.stringify(props.data)),
|
||||
props.customCopy ? h('button', { 'data-copy-diagnostic': '', 'data-copied': props.copied, disabled: props.copyDisabled, onClick: () => emit('copy') }) : null,
|
||||
])
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -173,6 +187,8 @@ function mountTimelineFromApi(
|
||||
|
||||
afterEach(() => {
|
||||
requestTraceApiMock.getRequestTrace.mockReset()
|
||||
diagnosticCopyMock.prepare.mockReset()
|
||||
diagnosticCopyMock.copy.mockReset()
|
||||
vi.useRealTimers()
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
@@ -181,6 +197,50 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('HorizontalRequestTimeline', () => {
|
||||
it('exports a skipped conversion failure with context only after clicking copy', async () => {
|
||||
diagnosticCopyMock.prepare.mockImplementation(async bundle => ({ ...bundle, reproduction: { status: 'sanitized_context' } }))
|
||||
diagnosticCopyMock.copy.mockResolvedValue(true)
|
||||
const root = mountTimeline(buildTrace([buildCandidate({
|
||||
status: 'skipped', skip_reason: 'provider_request_body_build_failed',
|
||||
extra_data: { failure_diagnostic: { kind: 'request_conversion', path: '$.n', message: 'lossy conversion blocked from openai:chat to claude:messages at n: multiple outputs' } },
|
||||
})]))
|
||||
await nextTick()
|
||||
expect(diagnosticCopyMock.prepare).not.toHaveBeenCalled()
|
||||
const button = root.querySelector<HTMLButtonElement>('[data-copy-diagnostic]')!
|
||||
expect(button).not.toBeNull()
|
||||
button.click()
|
||||
await flushPendingUpdates()
|
||||
expect(diagnosticCopyMock.prepare).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(diagnosticCopyMock.copy.mock.calls[0][0])).toMatchObject({ schema_version: 2, breakpoint: '$.n', reproduction: { status: 'sanitized_context' } })
|
||||
expect(button.dataset.copied).toBe('true')
|
||||
})
|
||||
|
||||
it('does not report copy success when the clipboard rejects it', async () => {
|
||||
diagnosticCopyMock.prepare.mockImplementation(async bundle => bundle)
|
||||
diagnosticCopyMock.copy.mockResolvedValue(false)
|
||||
const root = mountTimeline(buildTrace([buildCandidate({ error_message: 'unsupported provider stream finish reason: error' })]))
|
||||
await nextTick()
|
||||
const button = root.querySelector<HTMLButtonElement>('[data-copy-diagnostic]')!
|
||||
button.click()
|
||||
await flushPendingUpdates()
|
||||
expect(button.dataset.copied).toBe('false')
|
||||
})
|
||||
|
||||
it('cancels in-flight diagnostic exports on unmount', async () => {
|
||||
let finish: (value: Record<string, unknown>) => void = () => undefined
|
||||
diagnosticCopyMock.prepare.mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
const root = mountTimeline(buildTrace([buildCandidate({ error_message: 'unsupported provider stream finish reason: error' })]))
|
||||
await nextTick()
|
||||
root.querySelector<HTMLButtonElement>('[data-copy-diagnostic]')!.click()
|
||||
await nextTick()
|
||||
mountedApps.splice(mountedApps.findIndex(item => item.root === root), 1)[0].app.unmount()
|
||||
root.remove()
|
||||
const signal = diagnosticCopyMock.prepare.mock.calls[0][3] as AbortSignal
|
||||
expect(signal.aborted).toBe(true)
|
||||
finish({ reproduction: { status: 'sanitized_context' } })
|
||||
await flushPendingUpdates()
|
||||
expect(diagnosticCopyMock.copy).not.toHaveBeenCalled()
|
||||
})
|
||||
it('only renders allowlisted provider website protocols', async () => {
|
||||
const unsafeRoot = mountTimeline(buildTrace([
|
||||
buildCandidate({ provider_website: 'javascript:alert(document.cookie)' }),
|
||||
@@ -849,6 +909,55 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(diagnosticText).toContain('"breakpoint":"$.finish_reason"')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'unsupported provider stream finish reason: error',
|
||||
'Upstream stream ended with finish reason: error',
|
||||
])('shows an upstream terminal failure rather than a conversion error for %s', async (errorMessage) => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
status: 'failed',
|
||||
status_code: 200,
|
||||
error_type: 'stream_terminal_error',
|
||||
error_message: errorMessage,
|
||||
extra_data: {
|
||||
client_api_format: 'claude:messages',
|
||||
provider_api_format: 'claude:messages',
|
||||
upstream_response: { status_code: 200, body_state: 'reference' },
|
||||
},
|
||||
}),
|
||||
])
|
||||
const root = mountTimeline(trace, { requestApiFormat: 'claude:messages' })
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('.error-msg')?.textContent).toContain('上游流式响应异常终止')
|
||||
expect(root.querySelector('.error-msg')?.textContent).not.toContain('格式转换失败')
|
||||
const diagnostic = JSON.parse(root.querySelector('.error-diagnostic-json')?.textContent ?? '{}')
|
||||
expect(diagnostic.breakpoint).toBe('$.delta.stop_reason')
|
||||
expect(diagnostic.analysis_hint).toContain('不要映射为正常结束')
|
||||
expect(diagnostic.analysis_hint).not.toContain('finish_reason 映射')
|
||||
expect(diagnostic.node.status_code).toBe(200)
|
||||
expect(diagnostic.node.error_message).toBe(errorMessage)
|
||||
})
|
||||
|
||||
it('distinguishes terminal validation from an actual finish reason conversion failure', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
status_code: 200,
|
||||
error_type: 'stream_terminal_error',
|
||||
error_message: 'unsupported provider stream finish reason: future_reason',
|
||||
}),
|
||||
])
|
||||
const root = mountTimeline(trace)
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('.error-msg')?.textContent).toContain('流式终态校验失败')
|
||||
expect(root.querySelector('.error-msg')?.textContent).toContain('future_reason')
|
||||
expect(root.querySelector('.error-msg')?.textContent).not.toContain('格式转换失败')
|
||||
const diagnostic = JSON.parse(root.querySelector('.error-diagnostic-json')?.textContent ?? '{}')
|
||||
expect(diagnostic.breakpoint).toBe('$.finish_reason')
|
||||
expect(diagnostic.analysis_hint).toContain('上游流式终态校验')
|
||||
})
|
||||
|
||||
it('uses conversion messages from error_flow as the diagnostic breakpoint source', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
|
||||
import { diagnosticSamples, prepareDiagnosticExport, sanitizeDiagnostic } from '../diagnosticExport'
|
||||
|
||||
const candidate = (extra: Record<string, unknown> = {}): CandidateRecord => ({
|
||||
id: 'failed-candidate', request_id: 'request-1', candidate_index: 0, retry_index: 0,
|
||||
status: 'failed', is_cached: false, created_at: '2026-09-08T00:00:00Z', extra_data: extra,
|
||||
})
|
||||
const trace: RequestTrace = {
|
||||
request_id: 'request-1', final_status: 'failed', total_candidates: 1, total_latency_ms: 0, candidates: [],
|
||||
diagnostic_request: { usage_id: 'usage-1', body_state: 'reference' },
|
||||
}
|
||||
const bundle = (stage = 'response', path = '$.stop_reason', actual: unknown = 'future') => ({
|
||||
schema_version: 2, summary: 'conversion failed', request: { request_id: 'request-1' },
|
||||
diagnostic: { stage, path, actual, code: 'invalid_enum_value', missing_context: [] },
|
||||
})
|
||||
const context = { usage_id: 'usage-1', body_states: { request_body: 'reference', provider_request_body: 'disabled', response_body: 'reference' } }
|
||||
|
||||
describe('diagnostic export', () => {
|
||||
it('loads only the matched usage and preserves concrete field evidence', async () => {
|
||||
const loader = vi.fn(async (_usageId: string, field: string) => field === 'request_body'
|
||||
? { messages: [{ role: 'user', content: 'private customer text' }] }
|
||||
: { stop_reason: 'future', authorization: 'opaque-secret', message: 'failed with opaque-secret' })
|
||||
const result = await prepareDiagnosticExport(bundle(), candidate({ diagnostic_context: context }), trace, new AbortController().signal, loader)
|
||||
expect(loader.mock.calls).toEqual([['usage-1', 'request_body', expect.any(AbortSignal)], ['usage-1', 'response_body', expect.any(AbortSignal)]])
|
||||
expect(result.reproduction).toMatchObject({ status: 'sanitized_context', replay_ready: false, sources: {
|
||||
response_body: { field_samples: [{ path: '$.stop_reason', value: { stop_reason: 'future' } }] },
|
||||
} })
|
||||
expect(JSON.stringify(result)).not.toContain('opaque-secret')
|
||||
expect(JSON.stringify(result)).not.toContain('private customer text')
|
||||
})
|
||||
|
||||
it('never substitutes the final attempt response for a different failed candidate', async () => {
|
||||
const loader = vi.fn(async () => ({ messages: [] }))
|
||||
const result = await prepareDiagnosticExport(bundle(), candidate(), trace, new AbortController().signal, loader)
|
||||
expect(loader).toHaveBeenCalledTimes(1)
|
||||
expect(loader).toHaveBeenCalledWith('usage-1', 'request_body', expect.any(AbortSignal))
|
||||
expect(result.reproduction).toMatchObject({ status: 'insufficient_context', missing_context: ['response_body'], sources: { response_body: { status: 'unavailable' } } })
|
||||
})
|
||||
|
||||
it('does not follow body_ref URLs or fetch disabled bodies', async () => {
|
||||
const loader = vi.fn()
|
||||
const result = await prepareDiagnosticExport(bundle(), candidate({
|
||||
upstream_response: { body_ref: 'https://untrusted.example/private' },
|
||||
diagnostic_context: { usage_id: 'usage-1', body_states: { request_body: 'disabled', response_body: 'disabled', provider_request_body: 'disabled' } },
|
||||
}), null, new AbortController().signal, loader)
|
||||
expect(loader).not.toHaveBeenCalled()
|
||||
expect(result.reproduction).toMatchObject({ status: 'insufficient_context', sources: { response_body: { status: 'disabled' } } })
|
||||
})
|
||||
|
||||
it.each([[403, 'forbidden'], [404, 'missing'], [500, 'load_failed']])('exports an explicit gap for HTTP %s', async (status, expected) => {
|
||||
const loader = vi.fn().mockRejectedValue({ response: { status } })
|
||||
const result = await prepareDiagnosticExport(bundle(), candidate({ diagnostic_context: context }), trace, new AbortController().signal, loader)
|
||||
expect(result.reproduction).toMatchObject({ status: 'insufficient_context', sources: { response_body: { status: expected } } })
|
||||
})
|
||||
|
||||
it('keeps the failing SSE event, frame index and preceding context without leaking text', async () => {
|
||||
const raw = [
|
||||
['message_start', { type: 'message_start', message: { id: 'msg-1' } }],
|
||||
['content_block_delta', { type: 'content_block_delta', delta: { type: 'text_delta', text: 'confidential content' } }],
|
||||
['message_delta', { type: 'message_delta', delta: { stop_reason: 'error' } }],
|
||||
['message_stop', { type: 'message_stop' }],
|
||||
].map(([event, payload]) => `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`).join('')
|
||||
const result = await prepareDiagnosticExport(bundle('stream', '$.delta.stop_reason', 'error'), candidate({ upstream_response: { body: { raw_response: raw } } }), null, new AbortController().signal, vi.fn())
|
||||
expect(result.reproduction).toMatchObject({ status: 'sanitized_context', sources: { response_body: { sample: { failure_frame_index: 2, selection: 'matched_failure', captured_frames: 4 } } } })
|
||||
expect(JSON.stringify(result)).toContain('message_start')
|
||||
expect(JSON.stringify(result)).not.toContain('confidential content')
|
||||
})
|
||||
|
||||
it('marks a truncated stream without the failing event as incomplete', async () => {
|
||||
const result = await prepareDiagnosticExport(bundle('stream', '$.type', 'future.event'), candidate({ upstream_response: { body: { raw_response: 'event: message_start\ndata: {"type":"message_start"}\n\n' } } }), null, new AbortController().signal, vi.fn())
|
||||
expect(result.reproduction).toMatchObject({ status: 'insufficient_context', missing_context: ['failed_stream_event'] })
|
||||
})
|
||||
|
||||
it('cancels instead of copying another candidate after navigation', async () => {
|
||||
const controller = new AbortController()
|
||||
const loader = vi.fn(async () => { controller.abort(); return {} })
|
||||
await expect(prepareDiagnosticExport(bundle(), candidate({ diagnostic_context: context }), trace, controller.signal, loader)).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('identifies the actual failing element beyond the initial excerpt', () => {
|
||||
const choices = Array.from({ length: 50 }, (_value, index) => ({ finish_reason: index === 40 ? 'future' : 'stop' }))
|
||||
expect(diagnosticSamples({ choices }, '$.choices[*].finish_reason', 'future')).toEqual([{ path: '$.choices[40].finish_reason', value: { finish_reason: 'future' } }])
|
||||
})
|
||||
|
||||
it('redacts credentials, signed URLs, private text and error echoes', () => {
|
||||
const result = sanitizeDiagnostic({
|
||||
diagnostic: { path: '$.api_key', actual: 'plain-secret-value' },
|
||||
summary: 'invalid enum value "plain-secret-value" for api_key',
|
||||
raw: { headers: { Authorization: 'Bearer authorization-private-value', Cookie: 'session=abc', 'x-api-key': 'plain-secret-value', 'x-goog-api-key': 'google-private-key', 'x-auth-token': 'provider-private-token' },
|
||||
error: 'Bearer other-token https://user:[email protected]/test?token=abc [email protected]',
|
||||
content: 'private customer text',
|
||||
},
|
||||
})
|
||||
const output = JSON.stringify(result)
|
||||
for (const privateValue of ['plain-secret-value', 'authorization-private-value', 'session=abc', 'other-token', 'password@', 'token=abc', '[email protected]', 'private customer text', 'google-private-key', 'provider-private-token']) expect(output).not.toContain(privateValue)
|
||||
})
|
||||
|
||||
it('bounds the exported payload even for very large inline responses', async () => {
|
||||
const body = Object.fromEntries(Array.from({ length: 64 }, (_value, index) => [`field${index}`, 'value'.repeat(10000)]))
|
||||
const result = await prepareDiagnosticExport(bundle(), candidate({ upstream_response: { body } }), null, new AbortController().signal, vi.fn())
|
||||
expect(JSON.stringify(result, null, 2).length).toBeLessThanOrEqual(64 * 1024)
|
||||
expect(result.reproduction).toMatchObject({ status: 'insufficient_context' })
|
||||
})
|
||||
|
||||
it('never drops the failure frame when many earlier block starts match', async () => {
|
||||
const events = Array.from({ length: 30 }, () => ({ type: 'content_block_start', index: 0 }))
|
||||
const raw = [...events, { type: 'content_block_delta', index: 0, delta: { type: 'future_delta' } }]
|
||||
.map(payload => `event: ${payload.type}\ndata: ${JSON.stringify(payload)}\n\n`).join('')
|
||||
const result = await prepareDiagnosticExport(bundle('stream', '$.delta.type', 'future_delta'), candidate({ upstream_response: { body: { raw_response: raw } } }), null, new AbortController().signal, vi.fn())
|
||||
expect(JSON.stringify(result)).toContain('future_delta')
|
||||
expect(JSON.stringify(result)).toContain('"frame_index":30')
|
||||
})
|
||||
|
||||
it.each(['too_large', 'decode_failed', 'timeout'])('retains the specific body failure code %s', async code => {
|
||||
const result = await prepareDiagnosticExport(bundle(), candidate({ diagnostic_context: context }), trace, new AbortController().signal, vi.fn().mockRejectedValue(new Error(code)))
|
||||
expect(result.reproduction).toMatchObject({ status: 'insufficient_context', sources: { response_body: { status: code } } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CandidateRecord } from '@/api/requestTrace'
|
||||
import { buildFailureDiagnosticBundle, diagnosticPathFromMessage, visibleFailureRecords } from '../failureDiagnostic'
|
||||
|
||||
const candidate = (overrides: Partial<CandidateRecord> = {}): CandidateRecord => ({
|
||||
id: 'candidate-1', request_id: 'request-1', candidate_index: 0, retry_index: 0,
|
||||
status: 'failed', is_cached: false, created_at: '2026-09-08T00:00:00Z',
|
||||
error_type: 'local_sync_attempt_aborted', ...overrides,
|
||||
})
|
||||
|
||||
const payload = {
|
||||
breakpoint: '$',
|
||||
request: { client_api_format: 'openai:chat', provider_api_format: 'claude:messages' },
|
||||
}
|
||||
|
||||
describe('failure diagnostic paths', () => {
|
||||
it.each([
|
||||
['invalid enum value "future" for claude:messages.content[2].type', '$.content[2].type'],
|
||||
['invalid enum value "future" for openai:chat.choices[].finish_reason', '$.choices[*].finish_reason'],
|
||||
['unaudited field metadata.private in openai:responses cannot be converted to claude:messages: no mapping', '$.metadata.private'],
|
||||
['lossy conversion blocked from openai:chat to claude:messages at messages[1].content[0].type: cannot preserve', '$.messages[1].content[0].type'],
|
||||
['Internal("invalid enum value \\"future\\" for claude:messages.content[2].type")', '$.content[2].type'],
|
||||
['Local sync attempt failed before terminal finalization: Internal("invalid enum value \\"future\\" for claude:messages.content[2].type")', '$.content[2].type'],
|
||||
['unsupported field tools[12].function.strict in claude:messages: unsupported', '$.tools[12].function.strict'],
|
||||
['failed to parse claude:messages response', ''],
|
||||
])('extracts the complete path from %s', (message, path) => {
|
||||
expect(diagnosticPathFromMessage(message)).toBe(path)
|
||||
})
|
||||
|
||||
it('prefers structured diagnostics over legacy text and preserves the actual value', () => {
|
||||
const result = buildFailureDiagnosticBundle(payload, candidate({
|
||||
status: 'skipped',
|
||||
error_message: 'failed to parse claude:messages request',
|
||||
extra_data: { failure_diagnostic: {
|
||||
stage: 'request', source: 'request_converter', path: '$.tools[3].type',
|
||||
details: { code: 'invalid_enum_value', actual: 'future', path: '$.tools[3].type' },
|
||||
} },
|
||||
}))
|
||||
expect(result.schema_version).toBe(2)
|
||||
expect(result.diagnostic).toMatchObject({
|
||||
code: 'invalid_enum_value', stage: 'request', stage_source: 'structured',
|
||||
path: '$.tools[3].type', path_source: 'structured', actual: 'future',
|
||||
source_format: 'openai:chat', target_format: 'claude:messages', converter: 'request_converter',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the reverse format direction for response conversion', () => {
|
||||
const result = buildFailureDiagnosticBundle(payload, candidate({ error_message: 'invalid enum value "future" for claude:messages.stop_reason' }))
|
||||
expect(result.diagnostic).toMatchObject({
|
||||
stage: 'response', path_source: 'message_inference', actual: 'future',
|
||||
source_format: 'claude:messages', target_format: 'openai:chat',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['claude:messages', '$.delta.stop_reason'],
|
||||
['openai:chat', '$.choices[*].finish_reason'],
|
||||
['gemini:generate_content', '$.candidates[*].finishReason'],
|
||||
])('resolves canonical finish reasons to the %s source field', (providerFormat, path) => {
|
||||
const result = buildFailureDiagnosticBundle({
|
||||
breakpoint: '$.finish_reason',
|
||||
request: { client_api_format: 'claude:messages', provider_api_format: providerFormat },
|
||||
}, candidate({ error_type: 'stream_terminal_error', error_message: 'unsupported provider stream finish reason: future_reason' }))
|
||||
expect(result.diagnostic).toMatchObject({
|
||||
stage: 'stream', code: 'unsupported_finish_reason', actual: 'future_reason',
|
||||
reported_path: '$.finish_reason', path, path_source: 'protocol_inference',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not replace a structured finish reason path with a protocol guess', () => {
|
||||
const result = buildFailureDiagnosticBundle(payload, candidate({
|
||||
error_type: 'stream_terminal_error', error_message: 'unsupported provider stream finish reason: future_reason',
|
||||
extra_data: { failure_diagnostic: { stage: 'stream', path: '$.message.stop_reason' } },
|
||||
}))
|
||||
expect(result.diagnostic).toMatchObject({ path: '$.message.stop_reason', path_source: 'structured' })
|
||||
})
|
||||
|
||||
it('keeps generic parse failures explicitly incomplete instead of inventing a path', () => {
|
||||
const result = buildFailureDiagnosticBundle(payload, candidate({ error_message: 'failed to parse claude:messages response' }))
|
||||
expect(result.diagnostic).toMatchObject({ code: 'response_parse_failed', path: '$', path_source: 'unavailable', missing_context: ['field_path'] })
|
||||
expect(result.reproduction).toMatchObject({ status: 'not_loaded' })
|
||||
})
|
||||
|
||||
it('uses the selected error-flow message rather than a generic fallback', () => {
|
||||
const result = buildFailureDiagnosticBundle(payload, candidate({ error_message: 'execution runtime returned non-success status 500' }), null,
|
||||
'unaudited field metadata.private in claude:messages cannot be converted to openai:chat: no mapping')
|
||||
expect(result.diagnostic).toMatchObject({ code: 'unaudited_field', path: '$.metadata.private' })
|
||||
})
|
||||
|
||||
it('does not expose hidden diagnostics through compatibility aliases', () => {
|
||||
expect(visibleFailureRecords({
|
||||
failure_diagnostic: { safe_to_show: false, message: 'private' },
|
||||
request_conversion_error: { message: 'private' },
|
||||
request_body_build_error: { message: 'private' },
|
||||
})).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
import { dashboardApi, type RequestBodyField } from '@/api/dashboard'
|
||||
import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
|
||||
import { decodeBody } from './body-document-engine'
|
||||
import { diagnosticObject } from './failureDiagnostic'
|
||||
|
||||
const MAX_SOURCE_BYTES = 1024 * 1024
|
||||
const MAX_EXPORT_CHARS = 64 * 1024
|
||||
const SECRET_KEY = /^(?:authorization|proxy[-_]authorization|cookie|set[-_]cookie|(?:x[-_])?api[-_]?key|x[-_]goog[-_]api[-_]key|(?:openai|anthropic)[-_]api[-_]?key|(?:x[-_])?auth[-_]token|api[-_]secret|secret[-_]key|session(?:[-_]id)?|key|token|access[-_]token|refresh[-_]token|id[-_]token|client[-_]secret|secret|password|passwd|credential|credentials|private[-_]key)$/i
|
||||
const CONTENT_KEY = /^(?:text|content|prompt|system|instructions|input|arguments|partial_json|thinking|signature|data|image|url|image_url|audio|video|raw_response|body|description|refusal)$/i
|
||||
|
||||
function redactString(value: string): string {
|
||||
return value
|
||||
.replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9_+./=:-]+/gi, '[REDACTED_AUTH]')
|
||||
.replace(/\b(?:sk-[A-Za-z0-9_-]{6,}|AIza[A-Za-z0-9_-]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/g, '[REDACTED_TOKEN]')
|
||||
.replace(/\b(api[_-]?key|access_token|refresh_token|password|secret)\s*[=:]\s*[^\s&,;"']+/gi, '$1=[REDACTED]')
|
||||
.replace(/https?:\/\/[^\s"<>]+/gi, value => {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
url.username = ''
|
||||
url.password = ''
|
||||
if (url.search) url.search = '?REDACTED'
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
} catch { return '[REDACTED_URL]' }
|
||||
})
|
||||
.replace(/[\w.+-]+@[\w.-]+\.[a-z]{2,}/gi, '[REDACTED_EMAIL]')
|
||||
.replace(/data:[^;\s]+;base64,[A-Za-z0-9+/=]+/gi, '[REDACTED_BINARY]')
|
||||
}
|
||||
|
||||
export function sanitizeDiagnostic(value: unknown): unknown {
|
||||
const secrets = new Set<string>()
|
||||
let scanned = 2500
|
||||
const collect = (input: unknown, key = '', depth = 0) => {
|
||||
if (--scanned < 0 || depth > 16) return
|
||||
if (SECRET_KEY.test(key) && typeof input === 'string') secrets.add(input.slice(0, 4096))
|
||||
if (Array.isArray(input)) input.slice(0, 64).forEach(child => collect(child, key, depth + 1))
|
||||
else if (input && typeof input === 'object') Object.entries(input).slice(0, 64).forEach(([name, child]) => collect(child, name, depth + 1))
|
||||
}
|
||||
collect(value)
|
||||
const originalDiagnostic = diagnosticObject(diagnosticObject(value).diagnostic)
|
||||
if (typeof originalDiagnostic.path === 'string' && originalDiagnostic.path.split(/[.[\]]/).some(part => SECRET_KEY.test(part))) {
|
||||
if (typeof originalDiagnostic.actual === 'string') secrets.add(originalDiagnostic.actual)
|
||||
}
|
||||
let remaining = 1200
|
||||
const visit = (input: unknown, key = '', depth = 0): unknown => {
|
||||
if (--remaining < 0 || depth > 16) return '[TRUNCATED]'
|
||||
if (SECRET_KEY.test(key)) return '[REDACTED]'
|
||||
if (typeof input === 'string') {
|
||||
if (CONTENT_KEY.test(key)) return `[REDACTED_TEXT length=${input.length}]`
|
||||
let redacted = input.slice(0, 2048)
|
||||
for (const secret of secrets) {
|
||||
if (secret.length >= 4) {
|
||||
const prefix = secret.slice(0, 32)
|
||||
for (let count = 0; count < 16; count++) {
|
||||
const index = redacted.indexOf(prefix)
|
||||
if (index < 0) break
|
||||
redacted = `${redacted.slice(0, index)}[REDACTED]${redacted.slice(index + secret.length)}`
|
||||
}
|
||||
}
|
||||
else if (secret && redacted === secret) redacted = '[REDACTED]'
|
||||
}
|
||||
redacted = redactString(redacted)
|
||||
return input.length > 2048 ? `${redacted}[TRUNCATED]` : redacted
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
const output = input.slice(0, 32).map(item => visit(item, key, depth + 1))
|
||||
if (input.length > 32) output.push(`[TRUNCATED ${input.length - 32} items]`)
|
||||
return output
|
||||
}
|
||||
if (input && typeof input === 'object') {
|
||||
const entries = Object.entries(input).slice(0, 64)
|
||||
const output: Record<string, unknown> = {}
|
||||
for (const [name, child] of entries) {
|
||||
if (['__proto__', 'constructor', 'prototype'].includes(name)) continue
|
||||
output[redactString(name.slice(0, 128))] = visit(child, name, depth + 1)
|
||||
}
|
||||
if (Object.keys(input).length > 64) output.__truncated__ = true
|
||||
return output
|
||||
}
|
||||
return input
|
||||
}
|
||||
const sanitized = visit(value)
|
||||
const safe = diagnosticObject(sanitized)
|
||||
const diagnostic = diagnosticObject(safe.diagnostic)
|
||||
if (typeof diagnostic.path === 'string' && diagnostic.path.split(/[.[\]]/).some(part => SECRET_KEY.test(part))) {
|
||||
diagnostic.actual = '[REDACTED]'
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
export function diagnosticSamples(body: unknown, path: string, actual?: unknown): Array<{ path: string, value: unknown }> {
|
||||
if (!/^\$(?:\.[\w:-]+|\[(?:\d+|\*)\])+$/.test(path)) return []
|
||||
const parts = path.slice(1).replace(/\[(\d+|\*)\]/g, '.$1').split('.').filter(Boolean)
|
||||
let matches: Array<{ path: string, value: unknown }> = [{ path: '$', value: body }]
|
||||
for (const part of parts) {
|
||||
matches = matches.flatMap(match => {
|
||||
if (part === '*' && Array.isArray(match.value)) {
|
||||
return match.value.slice(0, 64).map((value, index) => ({ path: `${match.path}[${index}]`, value }))
|
||||
}
|
||||
if (match.value === null || typeof match.value !== 'object' || !Object.prototype.hasOwnProperty.call(match.value, part)) return []
|
||||
const value = (match.value as Record<string, unknown>)[part]
|
||||
return [{ path: Array.isArray(match.value) ? `${match.path}[${part}]` : `${match.path}.${part}`, value }]
|
||||
}).slice(0, 64)
|
||||
}
|
||||
return matches.filter(match => actual == null || Object.is(match.value, actual)).slice(0, 8).map(match => ({
|
||||
path: match.path,
|
||||
value: match.path.split(/[.[\]]/).some(part => SECRET_KEY.test(part)) ? '[REDACTED]' : sanitizeDiagnostic({ [parts[parts.length - 1] ?? 'value']: match.value }),
|
||||
}))
|
||||
}
|
||||
|
||||
function streamEvidence(body: unknown, path: string, actual: unknown): Record<string, unknown> | null {
|
||||
const object = diagnosticObject(body)
|
||||
const raw = typeof body === 'string' ? body : object.raw_response ?? object.sse
|
||||
if (typeof raw !== 'string' || !/(?:^|\n)(?:event|data):/.test(raw)) return null
|
||||
const events = raw.slice(0, MAX_SOURCE_BYTES).split(/\r?\n\r?\n/).flatMap((block, index) => {
|
||||
const lines = block.split(/\r?\n/)
|
||||
const data = lines.filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n')
|
||||
if (!data || data === '[DONE]') return []
|
||||
let payload: unknown
|
||||
try { payload = JSON.parse(data) } catch { payload = { unparsed: true, bytes: data.length } }
|
||||
return [{ frame_index: index, event: lines.find(line => line.startsWith('event:'))?.slice(6).trim() ?? diagnosticObject(payload).type ?? null, payload }]
|
||||
})
|
||||
const targetIndex = events.findIndex(event => {
|
||||
const payload = diagnosticObject(event.payload)
|
||||
if (actual !== null && actual !== undefined) {
|
||||
const samples = diagnosticSamples(payload, path, actual)
|
||||
if (samples.some(sample => Object.values(diagnosticObject(sample.value)).some(value => value === actual))) return true
|
||||
}
|
||||
return event.event === 'error' || event.event === 'response.failed' || Boolean(payload.error)
|
||||
})
|
||||
const center = targetIndex >= 0 ? targetIndex : Math.max(0, events.length - 1)
|
||||
const selected = new Set([0])
|
||||
for (let index = Math.max(0, center - 3); index <= Math.min(events.length - 1, center + 1); index++) selected.add(index)
|
||||
const target = diagnosticObject(events[center]?.payload)
|
||||
for (let index = center - 1; index >= 0; index--) {
|
||||
const event = events[index]
|
||||
if (event.event === 'content_block_start' && diagnosticObject(event.payload).index === target.index) {
|
||||
selected.add(index)
|
||||
break
|
||||
}
|
||||
}
|
||||
return {
|
||||
captured_frames: events.length,
|
||||
source_truncated: raw.length > MAX_SOURCE_BYTES,
|
||||
failure_frame_index: targetIndex >= 0 ? events[targetIndex]?.frame_index : null,
|
||||
selection: targetIndex >= 0 ? 'matched_failure' : 'unmatched_tail',
|
||||
windowed: selected.size < events.length,
|
||||
events: [...selected].sort((left, right) => left - right).slice(0, 12).map(index => events[index]).filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
type BodyLoader = (usageId: string, field: RequestBodyField, signal: AbortSignal) => Promise<unknown>
|
||||
|
||||
async function loadBody(usageId: string, field: RequestBodyField, signal: AbortSignal): Promise<unknown> {
|
||||
const controller = new AbortController()
|
||||
let tooLarge = false
|
||||
const abort = () => controller.abort()
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
const timer = setTimeout(abort, 5000)
|
||||
try {
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||
const response = await dashboardApi.getRequestBody(usageId, field, controller.signal, loaded => {
|
||||
if (loaded > MAX_SOURCE_BYTES) {
|
||||
tooLarge = true
|
||||
controller.abort()
|
||||
}
|
||||
})
|
||||
if (response.bytes.byteLength > MAX_SOURCE_BYTES) throw new Error('too_large')
|
||||
return (await decodeBody(response.bytes, response.encoding, MAX_SOURCE_BYTES)).value
|
||||
} catch (error) {
|
||||
if (tooLarge) throw new Error('too_large')
|
||||
if (controller.signal.aborted && !signal.aborted) throw new Error('timeout')
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareDiagnosticExport(
|
||||
bundle: Record<string, unknown>,
|
||||
attempt: CandidateRecord,
|
||||
trace: RequestTrace | null,
|
||||
signal: AbortSignal,
|
||||
loader: BodyLoader = loadBody,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const diagnostic = diagnosticObject(bundle.diagnostic)
|
||||
const stage = diagnostic.stage
|
||||
const context = diagnosticObject(attempt.extra_data?.diagnostic_context)
|
||||
const states = diagnosticObject(context.body_states)
|
||||
const primary = stage === 'request' ? 'request_body' : 'response_body'
|
||||
const fields: RequestBodyField[] = stage === 'request' ? ['request_body', 'provider_request_body'] : ['request_body', 'provider_request_body', 'response_body']
|
||||
const sources: Record<string, unknown> = {}
|
||||
const missing = Array.isArray(diagnostic.missing_context) ? [...diagnostic.missing_context] : []
|
||||
for (const field of fields) {
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||
const usageId = field === 'request_body' ? trace?.diagnostic_request?.usage_id ?? context.usage_id : context.usage_id
|
||||
const state = field === 'request_body' ? trace?.diagnostic_request?.body_state ?? states[field] : states[field]
|
||||
const inline = field === 'response_body' ? diagnosticObject(attempt.extra_data?.upstream_response).body : undefined
|
||||
if (inline === undefined && (typeof usageId !== 'string' || !usageId || ['disabled', 'none'].includes(String(state)))) {
|
||||
sources[field] = { status: state === 'disabled' ? 'disabled' : 'unavailable', candidate_matched: Boolean(context.usage_id) }
|
||||
if (field === primary) missing.push(field)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const body = inline !== undefined ? inline : await loader(usageId as string, field, signal)
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||
const sse = field === 'response_body' && stage === 'stream' ? streamEvidence(body, String(diagnostic.path ?? '$'), diagnostic.actual) : null
|
||||
const samples = field === primary ? diagnosticSamples(body, String(diagnostic.path ?? '$'), diagnostic.actual) : []
|
||||
sources[field] = {
|
||||
status: 'captured',
|
||||
origin: inline !== undefined ? 'candidate_inline' : 'stored_body',
|
||||
usage_id: usageId ?? null,
|
||||
sample: sse ?? body,
|
||||
field_samples: samples,
|
||||
}
|
||||
if (field === primary && stage === 'stream' && (!sse || sse.failure_frame_index === null)) missing.push('failed_stream_event')
|
||||
if (sse?.source_truncated) missing.push('body_size_limit')
|
||||
if (field === primary && state === 'truncated') missing.push('body_capture_truncated')
|
||||
if (field === primary && stage !== 'stream' && diagnostic.path !== '$' && samples.length === 0) missing.push('field_not_found_in_source')
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||
const response = diagnosticObject(diagnosticObject(error).response)
|
||||
const status = response.status
|
||||
const cause = diagnosticObject(response.headers)['x-aether-body-error'] ?? diagnosticObject(error).code ?? (error instanceof Error ? error.message : '')
|
||||
const code = status === 403 ? 'forbidden' : status === 404 ? 'missing' : ['too_large', 'decode_failed', 'timeout', 'missing', 'storage_unavailable'].includes(String(cause)) ? String(cause) : 'load_failed'
|
||||
sources[field] = { status: code }
|
||||
if (field === primary) missing.push(field)
|
||||
}
|
||||
}
|
||||
const result = sanitizeDiagnostic({
|
||||
...bundle,
|
||||
reproduction: {
|
||||
status: missing.length ? 'insufficient_context' : 'sanitized_context',
|
||||
replay_ready: false,
|
||||
missing_context: [...new Set(missing)],
|
||||
redaction: 'Credentials, private text, URLs and binary data are removed; samples may be truncated. Review before sharing.',
|
||||
source_limit_bytes: MAX_SOURCE_BYTES,
|
||||
sources,
|
||||
},
|
||||
}) as Record<string, unknown>
|
||||
if (JSON.stringify(result, null, 2).length > MAX_EXPORT_CHARS) {
|
||||
const reproduction = diagnosticObject(result.reproduction)
|
||||
reproduction.status = 'insufficient_context'
|
||||
reproduction.missing_context = [...new Set([...missing, 'export_size_limit'])]
|
||||
for (const source of Object.values(diagnosticObject(reproduction.sources))) {
|
||||
const object = diagnosticObject(source)
|
||||
object.sample = '[TRUNCATED_EXPORT_SIZE_LIMIT]'
|
||||
object.field_samples = '[TRUNCATED_EXPORT_SIZE_LIMIT]'
|
||||
}
|
||||
result.raw = '[TRUNCATED_EXPORT_SIZE_LIMIT]'
|
||||
if (JSON.stringify(result, null, 2).length > MAX_EXPORT_CHARS) {
|
||||
return {
|
||||
schema_version: 2,
|
||||
summary: '[TRUNCATED_EXPORT_SIZE_LIMIT]',
|
||||
diagnostic: { code: diagnosticObject(result.diagnostic).code, stage: diagnosticObject(result.diagnostic).stage, path: diagnosticObject(result.diagnostic).path },
|
||||
reproduction: { status: 'insufficient_context', missing_context: ['export_size_limit'] },
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
export const diagnosticObject = (value: unknown): JsonObject =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {}
|
||||
|
||||
const text = (value: unknown): string => typeof value === 'string' ? value.trim() : ''
|
||||
|
||||
export function unwrapDiagnosticMessage(message: string): string {
|
||||
let result = message.trim()
|
||||
for (let depth = 0; depth < 4; depth++) {
|
||||
const wrapped = result.match(/^(?:local sync attempt failed before terminal finalization:\s*)?Internal\("((?:\\.|[^"\\])*)"\)$/i)
|
||||
if (!wrapped) break
|
||||
try {
|
||||
result = JSON.parse(`"${wrapped[1]}"`) as string
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function normalizeFailurePath(field: string): string {
|
||||
const normalized = field.trim().replace(/\[\]/g, '[*]')
|
||||
return !normalized || normalized === '$' ? '$' : normalized.startsWith('$') ? normalized : `$.${normalized}`
|
||||
}
|
||||
|
||||
export function diagnosticPathFromMessage(message: string): string {
|
||||
const normalized = unwrapDiagnosticMessage(message)
|
||||
const patterns = [
|
||||
/\bfield\s+([^;=]+?)\s*(?:=|is unsupported|不支持)/i,
|
||||
/lossy conversion blocked from\s+\S+\s+to\s+\S+\s+at\s+([^:]+):/i,
|
||||
/(?:unsupported|unaudited) field\s+(.+?)\s+in\s+/i,
|
||||
/invalid target field\s+(.+?)\s+for\s+/i,
|
||||
/invalid enum value\s+.+?\s+for\s+[\w:-]+\.(.+)$/i,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = normalized.match(pattern)
|
||||
if (match?.[1]) return normalizeFailurePath(match[1])
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function visibleFailureRecords(extra: JsonObject): JsonObject[] {
|
||||
if (diagnosticObject(extra.failure_diagnostic).safe_to_show === false) return []
|
||||
return ['failure_diagnostic', 'request_conversion_error', 'request_body_build_error']
|
||||
.map(key => diagnosticObject(extra[key]))
|
||||
.filter(record => Object.keys(record).length > 0 && record.safe_to_show !== false)
|
||||
}
|
||||
|
||||
export function buildFailureDiagnosticBundle(
|
||||
payload: JsonObject,
|
||||
attempt: CandidateRecord,
|
||||
trace?: RequestTrace | null,
|
||||
rawMessage = '',
|
||||
): JsonObject {
|
||||
const extra = diagnosticObject(attempt.extra_data)
|
||||
const structured = visibleFailureRecords(extra)[0] ?? {}
|
||||
const details = diagnosticObject(structured.details)
|
||||
const request = diagnosticObject(payload.request)
|
||||
const diagnosticContext = diagnosticObject(extra.diagnostic_context)
|
||||
const message = unwrapDiagnosticMessage(rawMessage || text(structured.message) || text(attempt.error_message) || text(payload.summary))
|
||||
const clientFormat = text(request.client_api_format) || text(structured.client_api_format)
|
||||
const providerFormat = text(request.provider_api_format) || text(structured.provider_api_format) || text(attempt.endpoint_name)
|
||||
let stage = text(structured.stage)
|
||||
if (!['request', 'response', 'stream'].includes(stage)) stage = ''
|
||||
const stageSource = stage ? 'structured' : 'inferred'
|
||||
if (!stage) {
|
||||
if (/stream/i.test(attempt.error_type ?? '') || /provider stream|stream ended/i.test(message)) stage = 'stream'
|
||||
else if (attempt.status === 'skipped' || /request|body_rules|header_rules/.test(text(structured.kind)) || /failed to (?:parse|emit) .+ request/i.test(message)) stage = 'request'
|
||||
else if (/local_sync|finaliz|response/i.test(attempt.error_type ?? '') || /failed to (?:parse|emit) .+ response/i.test(message)) stage = 'response'
|
||||
else stage = 'unknown'
|
||||
}
|
||||
const conversionPair = message.match(/(?:lossy conversion blocked from|unaudited field .+? in)\s+(\S+)\s+(?:to|cannot be converted to)\s+([^\s:]+:[^\s:]+(?:[:][^\s:]+)?)/i)
|
||||
if (stage === 'unknown' && conversionPair && clientFormat !== providerFormat) {
|
||||
if (conversionPair[1] === clientFormat && conversionPair[2] === providerFormat) stage = 'request'
|
||||
else if (conversionPair[1] === providerFormat && conversionPair[2] === clientFormat) stage = 'response'
|
||||
}
|
||||
let code = text(details.code) || text(structured.code)
|
||||
if (!code) {
|
||||
const patterns: Array<[RegExp, string]> = [
|
||||
[/(?:unsupported provider stream finish reason|upstream stream ended with finish reason):\s*error\s*$/i, 'stream_terminal_error'],
|
||||
[/unsupported provider stream finish reason/i, 'unsupported_finish_reason'],
|
||||
[/unsupported provider stream event/i, 'unsupported_stream_event'],
|
||||
[/lossy conversion blocked/i, 'lossy_conversion_blocked'],
|
||||
[/unaudited field/i, 'unaudited_field'],
|
||||
[/invalid enum value/i, 'invalid_enum_value'],
|
||||
[/invalid target field/i, 'invalid_target_field'],
|
||||
[/unsupported field/i, 'unsupported_field'],
|
||||
[/failed to parse .+ request/i, 'request_parse_failed'],
|
||||
[/failed to emit .+ request/i, 'request_emit_failed'],
|
||||
[/failed to parse .+ response/i, 'response_parse_failed'],
|
||||
[/failed to emit .+ response/i, 'response_emit_failed'],
|
||||
]
|
||||
code = patterns.find(([pattern]) => pattern.test(message))?.[1] || attempt.error_type || text(structured.kind) || 'unknown_failure'
|
||||
}
|
||||
const structuredPath = text(details.path) || text(structured.path)
|
||||
const parsedPath = diagnosticPathFromMessage(message)
|
||||
const reportedPath = normalizeFailurePath(structuredPath && structuredPath !== '$' ? structuredPath : parsedPath || text(payload.breakpoint))
|
||||
const streamFinishPaths: Record<string, string> = {
|
||||
'claude:messages': '$.delta.stop_reason',
|
||||
'openai:chat': '$.choices[*].finish_reason',
|
||||
'gemini:generate_content': '$.candidates[*].finishReason',
|
||||
}
|
||||
const protocolPath = stage === 'stream' && /finish reason/i.test(message)
|
||||
&& (!structuredPath || structuredPath === '$') && ['$', '$.finish_reason'].includes(reportedPath)
|
||||
? streamFinishPaths[providerFormat] : undefined
|
||||
const path = protocolPath ?? reportedPath
|
||||
const missing = Array.isArray(details.missing_context) ? [...details.missing_context] : []
|
||||
if (path === '$' && !missing.includes('field_path')) missing.push('field_path')
|
||||
if (stage === 'unknown') missing.push('failure_stage')
|
||||
if (!clientFormat || !providerFormat) missing.push('api_formats')
|
||||
let actual: unknown = details.actual ?? null
|
||||
if (actual === null) {
|
||||
const enumMatch = message.match(/invalid enum value\s+(.+?)\s+for\s+/i)
|
||||
const fieldMatch = message.match(/\bfield\s+[^;=]+?\s*=\s*([^;]+)(?:;|$)/i)
|
||||
const finishMatch = message.match(/(?:unsupported provider stream finish reason|upstream stream ended with finish reason):\s*(.+?)\s*$/i)
|
||||
const literal = enumMatch?.[1] ?? fieldMatch?.[1] ?? finishMatch?.[1]
|
||||
if (literal) {
|
||||
try { actual = JSON.parse(literal) } catch { actual = literal }
|
||||
}
|
||||
if (code === 'stream_terminal_error' && /finish reason/.test(message)) actual = 'error'
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
schema_version: 2,
|
||||
breakpoint: path,
|
||||
diagnostic: {
|
||||
code,
|
||||
stage,
|
||||
stage_source: stageSource,
|
||||
operation: details.operation ?? null,
|
||||
reported_format: details.format ?? null,
|
||||
reported_path: reportedPath,
|
||||
path,
|
||||
path_source: path === '$' ? 'unavailable' : structuredPath && structuredPath !== '$' ? 'structured' : protocolPath ? 'protocol_inference' : 'message_inference',
|
||||
source_format: details.source_format ?? structured.source_format ?? conversionPair?.[1] ?? (stage === 'request' ? clientFormat : stage !== 'unknown' ? providerFormat : null),
|
||||
target_format: details.target_format ?? structured.target_format ?? conversionPair?.[2] ?? (stage === 'request' ? providerFormat : stage !== 'unknown' ? clientFormat : null),
|
||||
converter: structured.source ?? null,
|
||||
expected: details.expected ?? details.reason ?? null,
|
||||
actual,
|
||||
missing_context: missing,
|
||||
},
|
||||
versions: {
|
||||
frontend: typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : null,
|
||||
gateway_at_export: trace?.gateway_version ?? null,
|
||||
runtime_at_failure: structured.runtime_version ?? null,
|
||||
},
|
||||
node: {
|
||||
...diagnosticObject(payload.node),
|
||||
candidate_id: attempt.id,
|
||||
provider_id: attempt.provider_id ?? null,
|
||||
endpoint_id: attempt.endpoint_id ?? null,
|
||||
key_id: attempt.key_id ?? null,
|
||||
},
|
||||
request: {
|
||||
...request,
|
||||
client_api_format: clientFormat || null,
|
||||
provider_api_format: providerFormat || null,
|
||||
model: diagnosticContext.model ?? extra.model ?? null,
|
||||
target_model: diagnosticContext.target_model ?? extra.target_model ?? null,
|
||||
created_at: attempt.created_at,
|
||||
started_at: attempt.started_at ?? null,
|
||||
},
|
||||
reproduction: { status: 'not_loaded', sources: {}, missing_context: [...missing, 'source_body'] },
|
||||
}
|
||||
}
|
||||
@@ -1214,12 +1214,18 @@ export const legacyUiEnglishMessages: Record<string, string> = {
|
||||
'未知失败': 'Unknown failure',
|
||||
'未知请求格式': 'Unknown request format',
|
||||
'未知上游格式': 'Unknown upstream format',
|
||||
'正在读取并脱敏诊断上下文…': 'Loading and redacting diagnostic context…',
|
||||
'复制时补取已采集的正文并脱敏;未采集、无权限或超限的信息会明确标注。分享前请检查诊断内容。': 'Copy loads and redacts captured bodies. Missing, forbidden, or oversized context is marked explicitly. Review the diagnostic before sharing.',
|
||||
'上游流式响应异常终止:上游返回了错误结束原因(error),已按失败处理': 'The upstream stream ended with an error finish reason (error) and is marked as failed.',
|
||||
'上游通过结束原因报告了流式错误:检查原始 SSE 中的 error/message 及上游日志;保持失败状态,不要映射为正常结束。': 'The upstream finish reason reports a streaming error. Inspect error/message in the raw SSE and upstream logs. Keep the request failed; do not map it to a normal completion.',
|
||||
'断点在上游流式终态校验:检查原始结束原因及协议兼容性;该错误不代表发生了格式转换,不能确认成功时保持失败闭合。': 'The failure is in upstream stream terminal validation. Check the raw finish reason and protocol compatibility. This does not imply format conversion; keep the request failed unless success can be confirmed.',
|
||||
'断点在上游流式事件解析/转换矩阵:先按 breakpoint 对应字段确认 event type,再决定是补 canonical mapping 还是加入 known noop。': 'The failure is in upstream streaming event parsing or conversion. Check the event type at the breakpoint, then add a canonical mapping or a known no-op.',
|
||||
'断点在 finish_reason 映射:确认该结束原因是否可等价映射;不能无损映射时保持失败闭合。': 'The failure is in finish_reason mapping. Check whether an equivalent mapping exists. Keep the request failed if a lossless mapping is unavailable.',
|
||||
'无损': 'Lossless',
|
||||
'丢失信息': 'Information loss',
|
||||
'断点在请求/响应格式转换器:检查 breakpoint 字段是否能被目标格式表达,不能表达就需要拒绝、降级或新增显式映射策略。': 'The failure is in request or response conversion. Check whether the target format supports the breakpoint field. Otherwise, reject, downgrade, or define an explicit mapping.',
|
||||
'先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / raw.error_message 和 upstream_response。': 'Start replaying from the breakpoint field. If the breakpoint is $, inspect raw.failure_diagnostic, raw.error_message, and upstream_response first.',
|
||||
'先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / node.error_message 和 upstream_response。': 'Start replaying from the breakpoint field. If the breakpoint is $, inspect raw.failure_diagnostic, node.error_message, and upstream_response first.',
|
||||
'随机 (平分)': 'Random (equal weight)',
|
||||
'上游跳过': 'Upstream skipped',
|
||||
'未知 Key': 'Unknown key',
|
||||
|
||||
Reference in New Issue
Block a user