mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user