refactor(gateway): 重构格式转换候选资格检查并优化测试基础设施

- 将 format_conversion_disabled 的过滤提前到候选遴选阶段,替代原来的 skip_reason 标记机制
- 为测试添加 execution_runtime_sync_override 直接注入支持,避免启动额外 HTTP 服务器
- 将 submit_terminal_event 改为 async record_terminal_event 确保失败用量事件可靠入库
- 新增 test_support 模块封装可重试的 loopback 端口绑定逻辑
- 前端:poolTrace 将 skipped 从隐藏状态移除,新增 buildPoolParticipatedCandidates 统一新旧链路逻辑,并将 skipped 状态色改为 foreground
This commit is contained in:
fawney19
2026-04-20 16:59:18 +08:00
parent 87afe4898e
commit 226a6e58d5
21 changed files with 380 additions and 247 deletions

View File

@@ -33,9 +33,6 @@
<h4 class="text-sm font-semibold">
请求链路追踪
</h4>
<span class="text-xs text-muted-foreground">
按实际调度顺序
</span>
<Badge :variant="getFinalStatusBadgeVariant(computedFinalStatus)">
{{ getFinalStatusLabel(computedFinalStatus) }}
</Badge>
@@ -499,9 +496,8 @@ import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { resolveTimelineFinalStatus } from '../utils/status'
import {
buildPoolAttemptCandidatesFromAudit,
buildPoolParticipatedCandidates,
extractPoolGroupId,
isPoolAttemptedCandidate,
makeAttemptKey,
TIMELINE_STATUS,
} from '../utils/poolTrace'
@@ -736,21 +732,10 @@ const schedulingAudit = computed<Record<string, unknown> | null>(() => {
})
const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
// 新链路:优先使用后端写入的 extra_data.pool_group_id
// 但仅展示实际进入号池执行的 key排除 available/unused/skipped
const fromTrace = rawTimeline.value.filter(
(candidate) => extractPoolGroupId(candidate) !== null && isPoolAttemptedCandidate(candidate),
)
if (fromTrace.length > 0) {
return fromTrace
}
// 兼容旧链路:回退到 request_metadata.scheduling_audit.attempts。
const audit = schedulingAudit.value
if (!audit) return []
return buildPoolAttemptCandidatesFromAudit(
const auditAttempts = schedulingAudit.value?.attempts
return buildPoolParticipatedCandidates(
rawTimeline.value,
audit.attempts,
auditAttempts,
props.requestId,
)
})
@@ -1732,7 +1717,7 @@ const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string =
.sub-dot.status-failed { background: #ef4444; color: #ef4444; }
.sub-dot.status-cancelled { background: #f59e0b; color: #f59e0b; }
.sub-dot.status-pending { background: #3b82f6; color: #3b82f6; }
.sub-dot.status-skipped { background: hsl(var(--primary)); color: hsl(var(--primary)); }
.sub-dot.status-skipped { background: hsl(var(--foreground)); color: hsl(var(--foreground)); }
.sub-dot.status-available { background: #d1d5db; color: #d1d5db; }
/* 选中状态:呼吸动画 + 涟漪效果 */
@@ -1795,7 +1780,7 @@ const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string =
.node-dot.status-failed { color: #ef4444; }
.node-dot.status-cancelled { color: #f59e0b; }
.node-dot.status-pending { color: #3b82f6; }
.node-dot.status-skipped { color: hsl(var(--primary)); }
.node-dot.status-skipped { color: hsl(var(--foreground)); }
.node-dot.status-available { color: #d1d5db; }
/* 连接线容器 */
@@ -1858,7 +1843,7 @@ const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string =
.title-dot.status-failed { background: #ef4444; }
.title-dot.status-cancelled { background: #f59e0b; }
.title-dot.status-pending { background: #3b82f6; }
.title-dot.status-skipped { background: hsl(var(--primary)); }
.title-dot.status-skipped { background: hsl(var(--foreground)); }
.title-dot.status-available { background: #d1d5db; }
.title-text {
@@ -1951,8 +1936,8 @@ const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string =
}
.status-tag.status-skipped {
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
background: hsl(var(--foreground) / 0.08);
color: hsl(var(--foreground));
}
.status-tag.status-available {

View File

@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { CandidateRecord } from '@/api/requestTrace'
import { buildPoolAttemptCandidatesFromAudit } from '@/features/usage/utils/poolTrace'
import {
buildPoolAttemptCandidatesFromAudit,
buildPoolParticipatedCandidates,
} from '@/features/usage/utils/poolTrace'
function buildCandidate(
overrides: Partial<CandidateRecord> = {},
@@ -19,7 +22,7 @@ function buildCandidate(
}
describe('poolTrace', () => {
it('keeps only actually attempted pool nodes from scheduling audit fallback', () => {
it('keeps only pool nodes that actually participated in scheduling audit fallback', () => {
const attempts = buildPoolAttemptCandidatesFromAudit([], [
{
candidate_index: 0,
@@ -63,9 +66,11 @@ describe('poolTrace', () => {
},
], 'req-1')
expect(attempts).toHaveLength(1)
expect(attempts).toHaveLength(2)
expect(attempts[0].key_id).toBe('key-success')
expect(attempts[0].status).toBe('success')
expect(attempts[1].key_id).toBe('key-skipped')
expect(attempts[1].status).toBe('skipped')
})
it('preserves real trace attempts even when audit status is non-standard', () => {
@@ -111,4 +116,58 @@ describe('poolTrace', () => {
expect(attempts[0].status).toBe('failed')
expect(attempts[0].provider_name).toBe('Codex反代')
})
it('merges audit-only skipped pool nodes when trace only carries partial pool metadata', () => {
const rawTimeline = [
buildCandidate({
id: 'cand-success',
candidate_index: 1,
retry_index: 0,
provider_id: 'provider-1',
provider_name: 'Codex反代',
key_id: 'key-success',
key_name: 'Success Key',
status: 'success',
extra_data: { pool_group_id: 'provider-1' },
started_at: '2026-04-19T12:00:00.000Z',
}),
buildCandidate({
id: 'cand-skipped',
candidate_index: 2,
retry_index: 0,
provider_id: 'provider-1',
provider_name: 'Codex反代',
key_id: 'key-skipped',
key_name: 'Skipped Key',
status: 'skipped',
}),
]
const attempts = buildPoolParticipatedCandidates(rawTimeline, [
{
candidate_index: 1,
retry_index: 0,
provider_id: 'provider-1',
provider_name: 'Codex反代',
key_id: 'key-success',
key_name: 'Success Key',
status: 'success',
pool_group_id: 'provider-1',
},
{
candidate_index: 2,
retry_index: 0,
provider_id: 'provider-1',
provider_name: 'Codex反代',
key_id: 'key-skipped',
key_name: 'Skipped Key',
status: 'skipped',
pool_group_id: 'provider-1',
},
], 'req-1')
expect(attempts).toHaveLength(2)
expect(attempts.map(item => item.key_id)).toEqual(['key-success', 'key-skipped'])
expect(attempts[1].extra_data?.pool_group_id).toBe('provider-1')
})
})

View File

@@ -12,10 +12,9 @@ export const TIMELINE_STATUS: CandidateRecord['status'][] = [
'stream_interrupted',
]
const POOL_UNATTEMPTED_STATUS = new Set<CandidateRecord['status']>([
const POOL_HIDDEN_STATUS = new Set<CandidateRecord['status']>([
'available',
'unused',
'skipped',
])
const PROVIDER_TYPE_LIKE_NAMES = new Set<string>([
@@ -40,8 +39,8 @@ export const makeAttemptKey = (candidateIndex: number, retryIndex: number): stri
return `${candidateIndex}:${retryIndex}`
}
export const isPoolAttemptedCandidate = (candidate: CandidateRecord): boolean => {
if (POOL_UNATTEMPTED_STATUS.has(candidate.status)) return false
export const isPoolParticipatedCandidate = (candidate: CandidateRecord): boolean => {
if (POOL_HIDDEN_STATUS.has(candidate.status)) return false
if (candidate.status === 'pending' && !candidate.started_at) return false
return true
}
@@ -66,6 +65,38 @@ export const extractPoolGroupId = (
return text || null
}
export function buildPoolParticipatedCandidates(
rawTimeline: CandidateRecord[],
attempts: unknown,
requestId?: string | null,
): CandidateRecord[] {
const fromTrace = rawTimeline.filter(
candidate => extractPoolGroupId(candidate) !== null && isPoolParticipatedCandidate(candidate),
)
const fromAudit = buildPoolAttemptCandidatesFromAudit(rawTimeline, attempts, requestId)
if (fromTrace.length === 0) return fromAudit
if (fromAudit.length === 0) return fromTrace
const traceKeys = new Set(
fromTrace.map(candidate => makeAttemptKey(candidate.candidate_index, candidate.retry_index)),
)
const merged = [...fromTrace]
for (const candidate of fromAudit) {
const key = makeAttemptKey(candidate.candidate_index, candidate.retry_index)
if (!traceKeys.has(key)) {
merged.push(candidate)
}
}
return merged.sort((a, b) => {
if (a.candidate_index !== b.candidate_index) {
return a.candidate_index - b.candidate_index
}
return a.retry_index - b.retry_index
})
}
export function buildPoolAttemptCandidatesFromAudit(
rawTimeline: CandidateRecord[],
attempts: unknown,
@@ -154,7 +185,7 @@ export function buildPoolAttemptCandidatesFromAudit(
}
}
return isPoolAttemptedCandidate(merged) ? merged : null
return isPoolParticipatedCandidate(merged) ? merged : null
})
.filter((item): item is CandidateRecord => item !== null)
}