Fix Codex image progress heartbeat merge regressions

This commit is contained in:
fawney19
2026-05-10 02:10:23 +08:00
156 changed files with 11741 additions and 943 deletions

View File

@@ -492,38 +492,42 @@
</span>
</div>
<!-- 真实请求错误节点级调试原因和对客户端返回的摘要分开 -->
<!-- 错误信息真实上游响应合并在此处展示 -->
<div
v-if="currentAttempt.status === 'failed' && currentAttemptRequestError"
class="error-block"
>
<div class="error-type">
真实请求错误
<div class="error-heading">
<span class="error-type">错误信息</span>
<span
v-if="currentAttemptRequestError.statusCode != null"
class="error-status-badge"
:class="currentAttemptRequestError.statusCode >= 400 ? 'is-error' : currentAttemptRequestError.statusCode >= 300 ? 'is-warning' : 'is-success'"
>
HTTP {{ currentAttemptRequestError.statusCode }}
</span>
</div>
<div class="error-msg">
<div
v-if="currentAttemptRequestError.message"
class="error-msg"
>
{{ currentAttemptRequestError.message }}
</div>
<div
v-if="currentAttemptRequestError.meta.length > 0"
class="error-flow-meta"
v-if="currentAttemptRequestError.upstreamResponse"
class="error-json"
>
<span
v-for="item in currentAttemptRequestError.meta"
:key="item"
class="error-flow-chip"
>{{ item }}</span>
</div>
<div
v-if="currentAttemptRequestError.safetyHint"
class="error-flow-safety"
>
{{ currentAttemptRequestError.safetyHint }}
<JsonContentPanel
:data="currentAttemptRequestError.upstreamResponse"
:is-dark="isDark"
empty-message="无上游响应信息"
/>
</div>
</div>
<!-- 额外数据 -->
<details
v-if="currentAttempt.extra_data && Object.keys(currentAttempt.extra_data).length > 0"
v-if="currentAttemptExtraDataDisplay"
class="extra-block"
>
<summary class="extra-toggle">
@@ -531,7 +535,7 @@
</summary>
<JsonContentPanel
class="extra-json-panel"
:data="currentAttempt.extra_data"
:data="currentAttemptExtraDataDisplay"
:is-dark="isDark"
empty-message="无额外信息"
/>
@@ -569,6 +573,7 @@ import { requestTraceApi, type RequestTrace, type CandidateRecord, type ImagePro
import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { useDarkMode } from '@/composables/useDarkMode'
import { resolveTimelineFinalStatus } from '../utils/status'
import {
buildPoolGroupVisibleAttempts,
@@ -620,17 +625,6 @@ interface UsageData {
}
}
interface AttemptErrorFlow {
source?: string
statusCode?: number
classification?: string
decision?: string
retryable?: boolean
safeToExpose?: boolean
propagation?: string
message?: string
}
const props = defineProps<{
requestId?: string | null
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
@@ -710,7 +704,7 @@ const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
const loading = ref(false)
const error = ref<string | null>(null)
const internalTrace = ref<RequestTrace | null>(null)
const isDark = computed(() => document.documentElement.classList.contains('dark'))
const { isDark } = useDarkMode()
const trace = computed(() => props.traceData ?? internalTrace.value)
const selectedGroupIndex = ref(0)
const selectedAttemptIndex = ref(0)
@@ -1133,9 +1127,11 @@ const readNumberField = (obj: Record<string, unknown>, key: string): number | un
return undefined
}
const readBooleanField = (obj: Record<string, unknown>, key: string): boolean | undefined => {
const value = obj[key]
return typeof value === 'boolean' ? value : undefined
const hasRenderableValue = (value: unknown): boolean => {
if (value == null) return false
if (typeof value === 'string') return value.trim().length > 0
if (typeof value === 'object') return Object.keys(value as Record<string, unknown>).length > 0
return true
}
const normalizeImageProgress = (value: unknown): ImageProgress | null => {
@@ -1244,60 +1240,35 @@ watch(
{ immediate: true },
)
const normalizeAttemptErrorFlow = (value: unknown): AttemptErrorFlow | null => {
const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknown> | null => {
const raw = extractObject(value)
if (!raw) return null
const statusCode = readNumberField(raw, 'status_code') ?? readNumberField(raw, 'statusCode')
const headers = raw.headers
const body = raw.body
const bodyRef = readStringField(raw, 'body_ref') ?? readStringField(raw, 'bodyRef')
const bodyState = readStringField(raw, 'body_state') ?? readStringField(raw, 'bodyState')
const flow: AttemptErrorFlow = {
source: readStringField(raw, 'source'),
statusCode: readNumberField(raw, 'status_code') ?? readNumberField(raw, 'statusCode'),
classification: readStringField(raw, 'classification'),
decision: readStringField(raw, 'decision'),
retryable: readBooleanField(raw, 'retryable'),
safeToExpose: readBooleanField(raw, 'safe_to_expose') ?? readBooleanField(raw, 'safeToExpose'),
propagation: readStringField(raw, 'propagation'),
message: readStringField(raw, 'message'),
if (
statusCode == null &&
!hasRenderableValue(headers) &&
!hasRenderableValue(body) &&
!bodyRef &&
!bodyState
) {
return null
}
return Object.values(flow).some(value => value !== undefined) ? flow : null
const data: Record<string, unknown> = {}
if (statusCode != null) data.status_code = statusCode
if (hasRenderableValue(headers)) data.headers = headers
if (hasRenderableValue(body)) data.body = body
if (bodyRef) data.body_ref = bodyRef
if (bodyState) data.body_state = bodyState
return data
}
const labelFromMap = (value: string | undefined, labels: Record<string, string>): string | undefined => {
if (!value) return undefined
return labels[value] || value
}
const formatErrorFlowSource = (value?: string): string | undefined => labelFromMap(value, {
upstream_response: '上游响应',
request_validation: '请求校验',
gateway: '网关处理',
transport: '传输层',
scheduler: '调度层',
})
const formatErrorFlowDecision = (value?: string): string | undefined => labelFromMap(value, {
retry_next_candidate: '重试下一个候选',
stop_local_failover: '停止本地转移',
use_default: '默认处理',
return_to_client: '返回客户端',
})
const formatErrorFlowPropagation = (value?: string): string | undefined => labelFromMap(value, {
suppressed: '已抑制',
converted: '已转换',
passthrough: '直接透传',
local: '本地生成',
captured: '仅采集',
})
const formatErrorFlowClassification = (value?: string): string | undefined => labelFromMap(value, {
retryable: '可重试',
terminal: '终止',
provider_auth: '上游认证',
provider_quota: '上游额度',
invalid_request: '请求无效',
})
const extractStringList = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value
@@ -1390,6 +1361,9 @@ const currentAttemptRequestPathDisplay = computed(() => {
const fromAttempt = resolveRequestPathFromObject(attempt?.extra_data)
if (fromAttempt) return fromAttempt
const fromTrace = resolveRequestPathFromObject(trace.value)
if (fromTrace) return fromTrace
const fromRequestMetadata = resolveRequestPathFromObject(props.requestMetadata)
if (fromRequestMetadata) return fromRequestMetadata
@@ -1456,45 +1430,64 @@ const currentAttemptFailureDiagnostic = computed<{
}
})
const formatAttemptErrorMessage = (message: string, statusCode?: number): string => {
const normalized = message.trim()
if (!normalized) return ''
if (/execution runtime (stream )?returned non-success status \d+/i.test(normalized)) {
return statusCode != null ? `上游返回非成功状态 ${statusCode}` : '上游返回非成功状态'
}
return normalized
}
const currentAttemptRequestError = computed<{
message: string
meta: string[]
safetyHint: string
statusCode?: number
upstreamResponse: Record<string, unknown> | null
} | null>(() => {
const attempt = currentAttempt.value
if (!attempt || attempt.status !== 'failed') return null
const extra = extractObject(attempt.extra_data)
const flow = normalizeAttemptErrorFlow(extra?.error_flow)
const upstreamResponse = extractObject(extra?.upstream_response)
const errorFlow = extractObject(extra?.error_flow)
const statusCode = readNumberField(upstreamResponse ?? {}, 'status_code')
?? readNumberField(upstreamResponse ?? {}, 'statusCode')
?? readNumberField(errorFlow ?? {}, 'status_code')
?? readNumberField(errorFlow ?? {}, 'statusCode')
?? attempt.status_code
const flowMessage = errorFlow
? readStringField(errorFlow, 'message')
: ''
const fallbackMessage = typeof attempt.error_message === 'string' && attempt.error_message.trim()
? attempt.error_message.trim()
: ''
const fallbackType = typeof attempt.error_type === 'string' && attempt.error_type.trim()
? attempt.error_type.trim()
: ''
const message = flow?.message || fallbackMessage
if (!message && !fallbackType && !flow) return null
const meta = [
flow?.statusCode != null ? `HTTP ${flow.statusCode}` : (attempt.status_code ? `HTTP ${attempt.status_code}` : ''),
formatErrorFlowSource(flow?.source),
formatErrorFlowClassification(flow?.classification) || fallbackType,
formatErrorFlowDecision(flow?.decision),
formatErrorFlowPropagation(flow?.propagation),
flow?.retryable != null ? (flow.retryable ? '会继续重试' : '不再重试') : '',
].filter((item): item is string => Boolean(item))
const safetyHint = flow?.safeToExpose === false
? '该错误被标记为敏感上游错误:仅在链路节点展示,不应完整返回给客户端。'
: ''
const message = formatAttemptErrorMessage(flowMessage || fallbackMessage, statusCode) || fallbackType
const upstreamResponseDisplay = normalizeUpstreamResponseDisplay(extra?.upstream_response)
if (!message && statusCode == null && !upstreamResponseDisplay) return null
return {
message: message || fallbackType || '未知错误',
meta,
safetyHint,
message: upstreamResponseDisplay ? '' : (message || '未知错误'),
statusCode,
upstreamResponse: upstreamResponseDisplay,
}
})
const currentAttemptExtraDataDisplay = computed<Record<string, unknown> | null>(() => {
const extra = extractObject(currentAttempt.value?.extra_data)
if (!extra) return null
const display = { ...extra }
delete display.upstream_response
delete display.error_flow
delete display.client_response
delete display.provider_response
return Object.keys(display).length > 0 ? display : null
})
// 计算当前尝试启用的能力标签(请求需要的能力)
const activeCapabilities = computed(() => {
if (!currentAttempt.value?.required_capabilities) return []
@@ -2830,50 +2823,66 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
border-radius: 8px;
}
.error-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.error-type {
font-size: 0.75rem;
font-weight: 600;
color: #ef4444;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.error-status-badge {
flex-shrink: 0;
padding: 0.125rem 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
font-family: ui-monospace, monospace;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
.error-status-badge.is-success {
color: #166534;
background: #22c55e18;
}
.error-status-badge.is-warning {
color: #92400e;
background: #f59e0b1f;
}
.error-status-badge.is-error {
color: #991b1b;
background: #ef44441f;
}
.error-msg {
font-size: 0.85rem;
color: #dc2626;
word-break: break-word;
}
.error-flow-meta {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.625rem;
.error-json {
margin-top: 0.75rem;
}
.error-flow-chip {
padding: 0.125rem 0.45rem;
border-radius: 999px;
background: #ef444414;
border: 1px solid #ef44442e;
color: #991b1b;
font-size: 0.72rem;
line-height: 1.35;
.dark .error-status-badge.is-success {
color: #bbf7d0;
}
.error-flow-safety {
margin-top: 0.625rem;
color: #991b1b;
font-size: 0.78rem;
line-height: 1.5;
.dark .error-status-badge.is-warning {
color: #fde68a;
}
.dark .error-flow-chip {
color: #fecaca;
}
.dark .error-flow-safety {
.dark .error-status-badge.is-error {
color: #fecaca;
}

View File

@@ -459,55 +459,6 @@
/>
</div>
<!-- 错误域卡片保持上游响应与客户端响应两个边界可对照 -->
<div
v-if="hasVisibleErrorCards"
class="space-y-3"
>
<div
class="grid gap-3"
:class="visibleErrorCardCount > 1 ? 'lg:grid-cols-2' : 'grid-cols-1'"
>
<Card
v-if="displayClientErrorMessage"
class="border-amber-200 dark:border-amber-800"
>
<div class="p-4">
<h4 class="text-sm font-semibold text-amber-700 dark:text-amber-300 mb-2">
返回客户端错误
</h4>
<div class="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-3 space-y-1">
<p class="text-sm text-amber-900 dark:text-amber-200">
{{ displayClientErrorMessage }}
</p>
</div>
</div>
</Card>
<Card
v-if="normalizedUpstreamError"
class="border-orange-200 dark:border-orange-800"
>
<div class="p-4">
<h4 class="text-sm font-semibold text-orange-700 dark:text-orange-300 mb-2">
上游响应错误
</h4>
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-3 space-y-1">
<p class="text-sm text-orange-900 dark:text-orange-200">
{{ normalizedUpstreamError.message }}
</p>
<p
v-if="formatErrorDomainMeta(normalizedUpstreamError)"
class="text-xs text-orange-800/70 dark:text-orange-200/70 font-mono"
>
{{ formatErrorDomainMeta(normalizedUpstreamError) }}
</p>
</div>
</div>
</Card>
</div>
</div>
<!-- Tabs 区域 -->
<Card>
<div class="p-3 sm:p-4">
@@ -743,6 +694,7 @@ import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import Button from '@/components/ui/button.vue'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useClipboard } from '@/composables/useClipboard'
import { useDarkMode } from '@/composables/useDarkMode'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Separator from '@/components/ui/separator.vue'
@@ -1040,10 +992,7 @@ watch(activeTab, (newTab) => {
}
})
// 检测暗色模式
const isDark = computed(() => {
return document.documentElement.classList.contains('dark')
})
const { isDark } = useDarkMode()
const traceRequestMetadata = computed<Record<string, unknown> | null>(() => {
const meta = detail.value?.metadata
@@ -1074,27 +1023,6 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
return Object.keys(merged).length > 0 ? merged : null
})
const normalizedClientError = computed(() =>
normalizeErrorDomain(detail.value?.errors?.client_error ?? detail.value?.client_error),
)
const normalizedUpstreamError = computed(() =>
normalizeErrorDomain(detail.value?.errors?.upstream_error ?? detail.value?.upstream_error),
)
const displayClientErrorMessage = computed(() =>
normalizedClientError.value?.message ?? '',
)
const hasVisibleErrorCards = computed(() =>
Boolean(displayClientErrorMessage.value || normalizedUpstreamError.value),
)
const visibleErrorCardCount = computed(() =>
(displayClientErrorMessage.value ? 1 : 0)
+ (normalizedUpstreamError.value ? 1 : 0),
)
const settlementInfo = computed<JsonRecord | null>(() =>
asRecord(detail.value?.settlement ?? null),
)

View File

@@ -45,8 +45,14 @@ vi.mock('../JsonContentPanel.vue', async () => {
return {
default: defineComponent({
name: 'JsonContentPanelStub',
setup() {
return () => h('div')
props: {
data: {
type: null,
default: null,
},
},
setup(props) {
return () => h('pre', JSON.stringify(props.data))
},
}),
}
@@ -359,4 +365,81 @@ describe('HorizontalRequestTimeline', () => {
const requestPathCode = root.querySelector<HTMLElement>('.request-path-code')
expect(requestPathCode?.textContent).toContain('/v1beta/models/gemini-2.5-pro:generateContent?alt=sse')
})
it('shows request path from trace payload', async () => {
const trace: RequestTrace = {
...buildTrace([
buildCandidate({
id: 'cand-trace-path',
provider_id: 'provider-path',
provider_name: 'Provider Path',
key_id: 'key-path',
key_name: 'Path Key',
candidate_index: 0,
status: 'failed',
}),
]),
request_path: '/v1/images/generations',
}
const root = mountTimeline(trace)
await nextTick()
expect(root.textContent).toContain('请求路径')
const requestPathCode = root.querySelector<HTMLElement>('.request-path-code')
expect(requestPathCode?.textContent).toContain('/v1/images/generations')
})
it('shows upstream response JSON inside the error block on trace nodes', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-upstream-response',
provider_id: 'provider-upstream',
provider_name: 'Provider Upstream',
key_id: 'key-upstream',
key_name: 'Upstream Key',
candidate_index: 0,
status: 'failed',
error_message: 'execution runtime stream returned non-success status 302',
extra_data: {
upstream_response: {
status_code: 302,
headers: { location: '/' },
},
error_flow: {
source: 'upstream_response',
status_code: 302,
classification: 'use_default',
decision: 'use_default',
propagation: 'none',
retryable: false,
safe_to_expose: false,
message: 'execution runtime stream returned non-success status 302',
},
client_response: {
status_code: 502,
headers: { 'content-type': 'application/json' },
},
},
}),
])
const root = mountTimeline(trace)
await nextTick()
expect(root.textContent).toContain('错误信息')
expect(root.textContent).toContain('HTTP 302')
expect(root.textContent).not.toContain('上游返回非成功状态 302')
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":302')
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"headers"')
expect(root.textContent).not.toContain('上游真实响应')
expect(root.textContent).not.toContain('execution runtime stream returned non-success status 302')
expect(root.textContent).not.toContain('真实请求错误')
expect(root.textContent).not.toContain('返回客户端响应')
expect(root.textContent).not.toContain('上游响应')
expect(root.textContent).not.toContain('默认处理')
expect(root.textContent).not.toContain('none')
expect(root.textContent).not.toContain('不再重试')
expect(root.textContent).not.toContain('该错误被标记为敏感上游错误')
})
})