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
@@ -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')
})
})
+36 -5
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)
}