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