feat(pool): 号池候选重构为 PoolCandidate 单候选模式与池内 key 故障转移

- 新增 PoolCandidate 子类,排序阶段作为单候选参与,执行阶段在 pool_keys 内部选择/切换 key
- FailoverEngine 新增 _execute_pool_candidate 方法,支持池内 key 级别故障转移与重试
- 提取 _execute_attempt / _attach_attempt_context / _classify_attempt_error 公共方法
- CandidateBuilder 对号池 Provider 构建单个 PoolCandidate(包含所有可用 key)
- CandidateSorter 支持 PoolCandidate 独立优先级分组(global_priority / pool_priority)
- CandidateResolver PRE_EXPAND 模式按 pool_keys 展开预创建记录,附加 pool_group_id
- TaskService._apply_pool_reorder 改为对 PoolCandidate 调用 select_pool_keys
- PoolManager 新增 select_pool_keys 方法,复用 reorder_candidates 逻辑
- 新增 global_priority 号池配置字段(前后端同步)
- 前端 Timeline 支持按 pool_group_id 分组显示多号池尝试
- 异步提交路径新增 _expand_pool_candidates_for_async_submit 展开逻辑
This commit is contained in:
fawney19
2026-03-03 17:24:22 +08:00
parent dcba7c62a2
commit 4ea187cfac
14 changed files with 992 additions and 209 deletions

View File

@@ -454,6 +454,7 @@ export interface ClaudeCodeAdvancedConfig {
}
export interface PoolAdvancedConfig {
global_priority?: number | null
sticky_session_ttl_seconds?: number | null
load_threshold_percent?: number | null
lru_enabled?: boolean

View File

@@ -47,6 +47,23 @@
同一对话始终路由到同一 Key
</p>
</div>
<div class="space-y-1.5">
<Label>
全局优先级
<span class="text-xs text-muted-foreground">(global_key)</span>
</Label>
<Input
:model-value="form.global_priority ?? ''"
type="number"
min="0"
max="999999"
placeholder="留空回退 provider_priority"
@update:model-value="(v) => form.global_priority = parseNum(v)"
/>
<p class="text-xs text-muted-foreground">
global_key 模式下号池整体排序值(越小越优先)
</p>
</div>
</div>
</div>
@@ -320,6 +337,7 @@ const { success, error: showError } = useToast()
const loading = ref(false)
const form = ref<PoolAdvancedConfig>({
global_priority: null,
sticky_session_ttl_seconds: null,
lru_enabled: true,
cost_window_seconds: null,
@@ -363,6 +381,7 @@ watch(() => props.modelValue, (v) => {
form.value = { ...props.currentConfig }
} else if (v) {
form.value = {
global_priority: null,
sticky_session_ttl_seconds: null,
lru_enabled: true,
cost_window_seconds: null,
@@ -411,6 +430,7 @@ async function handleSave() {
try {
const payload: Record<string, unknown> = {
pool_advanced: {
global_priority: form.value.global_priority ?? undefined,
sticky_session_ttl_seconds: form.value.sticky_session_ttl_seconds ?? undefined,
lru_enabled: form.value.lru_enabled,
cost_window_seconds: form.value.cost_window_seconds ?? undefined,

View File

@@ -666,7 +666,23 @@ const schedulingAudit = computed<Record<string, unknown> | null>(() => {
return raw as Record<string, unknown>
})
const extractPoolGroupId = (candidate: CandidateRecord): string | null => {
const extra = candidate.extra_data
if (!extra || typeof extra !== 'object' || Array.isArray(extra)) return null
const value = (extra as Record<string, unknown>).pool_group_id
if (typeof value !== 'string') return null
const text = value.trim()
return text || null
}
const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
// 新链路:优先使用后端写入的 extra_data.pool_group_id。
const fromTrace = rawTimeline.value.filter((candidate) => extractPoolGroupId(candidate) !== null)
if (fromTrace.length > 0) {
return fromTrace
}
// 兼容旧链路:回退到 request_metadata.scheduling_audit.attempts。
const audit = schedulingAudit.value
if (!audit) return []
const attempts = audit.attempts
@@ -711,11 +727,37 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
if (typeof raw.key_name === 'string') merged.key_name = raw.key_name
if (typeof raw.status_code === 'number') merged.status_code = raw.status_code
if (typeof raw.error_type === 'string') merged.error_type = raw.error_type
const rawPoolGroupId = typeof raw.pool_group_id === 'string' ? raw.pool_group_id.trim() : ''
const fallbackPoolGroupId = typeof raw.provider_id === 'string' ? raw.provider_id.trim() : ''
const finalPoolGroupId = rawPoolGroupId || fallbackPoolGroupId
if (finalPoolGroupId) {
merged.extra_data = {
...(merged.extra_data || {}),
pool_group_id: finalPoolGroupId,
}
}
return merged
})
.filter((item): item is CandidateRecord => item !== null)
})
const poolAttemptsByGroup = computed<Map<string, CandidateRecord[]>>(() => {
const grouped = new Map<string, CandidateRecord[]>()
for (const attempt of poolAttemptCandidates.value) {
const groupId =
extractPoolGroupId(attempt)
|| String(attempt.provider_id || '').trim()
|| '__pool_group__'
const existing = grouped.get(groupId)
if (existing) {
existing.push(attempt)
} else {
grouped.set(groupId, [attempt])
}
}
return grouped
})
const poolAttemptKeySet = computed<Set<string>>(() => {
return new Set(
poolAttemptCandidates.value.map((item) => makeAttemptKey(item.candidate_index, item.retry_index)),
@@ -805,46 +847,57 @@ const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => {
// 将相同 Provider 的所有请求合并为组(同提供商的 Key 放在子节点)
const groupedTimeline = computed<NodeGroup[]>(() => {
const providerGroups = buildProviderGroups(timeline.value)
const poolAttempts = poolAttemptCandidates.value
if (poolAttempts.length === 0) {
if (poolAttemptsByGroup.value.size === 0) {
return providerGroups
}
const poolPrimaryStatus = poolAttempts.reduce((best, current) => {
const bestPriority = STATUS_PRIORITY[best] ?? 0
const currentPriority = STATUS_PRIORITY[current.status] ?? 0
return currentPriority > bestPriority ? current.status : best
}, poolAttempts[0].status)
const poolProviderIds = new Set<string>()
const poolProviderNames = new Set<string>()
const poolGroups: NodeGroup[] = []
const successAttempt = poolAttempts.find((item) => item.status === 'success')
const poolPrimary = successAttempt || poolAttempts[poolAttempts.length - 1] || poolAttempts[0]
for (const [groupId, attemptsRaw] of poolAttemptsByGroup.value.entries()) {
const attempts = [...attemptsRaw].sort((a, b) => {
if (a.candidate_index !== b.candidate_index) {
return a.candidate_index - b.candidate_index
}
return a.retry_index - b.retry_index
})
if (attempts.length === 0) continue
const poolGroup: NodeGroup = {
id: '__pool_group__',
providerName: getProviderDisplayName(poolPrimary),
primary: poolPrimary,
primaryStatus: poolPrimaryStatus,
allAttempts: poolAttempts,
retryCount: Math.max(0, poolAttempts.length - 1),
totalLatency: poolAttempts.reduce((sum, item) => sum + (item.latency_ms || 0), 0),
startIndex: 0,
endIndex: poolAttempts.length - 1,
hasConversion: poolAttempts.some((item) => item.extra_data?.needs_conversion === true),
providerApiFormat: null,
isPoolGroup: true,
const poolPrimaryStatus = attempts.reduce((best, current) => {
const bestPriority = STATUS_PRIORITY[best] ?? 0
const currentPriority = STATUS_PRIORITY[current.status] ?? 0
return currentPriority > bestPriority ? current.status : best
}, attempts[0].status)
const successAttempt = attempts.find((item) => item.status === 'success')
const poolPrimary = successAttempt || attempts[attempts.length - 1] || attempts[0]
const startIndex = Math.min(...attempts.map(item => item.candidate_index))
const endIndex = Math.max(...attempts.map(item => item.candidate_index))
poolGroups.push({
id: `pool:${groupId}`,
providerName: getProviderDisplayName(poolPrimary),
primary: poolPrimary,
primaryStatus: poolPrimaryStatus,
allAttempts: attempts,
retryCount: Math.max(0, attempts.length - 1),
totalLatency: attempts.reduce((sum, item) => sum + (item.latency_ms || 0), 0),
startIndex,
endIndex,
hasConversion: attempts.some((item) => item.extra_data?.needs_conversion === true),
providerApiFormat: null,
isPoolGroup: true,
})
for (const attempt of attempts) {
const providerId = String(attempt.provider_id || '').trim()
if (providerId) poolProviderIds.add(providerId)
const providerName = normalizeProviderIdentity(attempt.provider_name)
if (providerName) poolProviderNames.add(providerName)
}
}
const poolProviderIds = new Set(
poolAttempts
.map(item => String(item.provider_id || '').trim())
.filter(Boolean),
)
const poolProviderNames = new Set(
poolAttempts
.map(item => normalizeProviderIdentity(item.provider_name))
.filter(Boolean),
)
const dedupedProviderGroups = providerGroups.filter((group) => {
const sameProviderById = group.allAttempts.some((attempt) => {
const providerId = String(attempt.provider_id || '').trim()
@@ -858,7 +911,8 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
return true
})
return [poolGroup, ...dedupedProviderGroups]
poolGroups.sort((a, b) => a.startIndex - b.startIndex)
return [...poolGroups, ...dedupedProviderGroups]
})
// 格式转换分界点索引(首个 hasConversion=true 的 group index