mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat(adaptive): 完善自适应 RPM 学习并将 Pool 调度状态与健康分解耦
- orchestration 新增 AdaptiveSuccess 效果,在成功回报路径上根据利用率窗口扩张 learned_rpm_limit - 429 路径改用 429_observation/adjustment 记录以及基于历史的置信度评估,新增 last_rpm_peak 边界字段 - Pool 调度状态不再因 health_score 低或熔断而降级/拦截,前端同步移除相关按钮与文案兜底 - 新增前端 poolTrace 工具(附测试)承接原 HorizontalRequestTimeline 内的候选合并逻辑
This commit is contained in:
@@ -498,6 +498,13 @@ import { log } from '@/utils/logger'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { resolveTimelineFinalStatus } from '../utils/status'
|
||||
import {
|
||||
buildPoolAttemptCandidatesFromAudit,
|
||||
extractPoolGroupId,
|
||||
isPoolAttemptedCandidate,
|
||||
makeAttemptKey,
|
||||
TIMELINE_STATUS,
|
||||
} from '../utils/poolTrace'
|
||||
|
||||
// 节点组类型
|
||||
interface NodeGroup {
|
||||
@@ -691,18 +698,6 @@ const proxyTimingBreakdown = (proxy: Record<string, unknown>): string => {
|
||||
return parts.join(' / ')
|
||||
}
|
||||
|
||||
const TIMELINE_STATUS: CandidateRecord['status'][] = [
|
||||
'success',
|
||||
'failed',
|
||||
'skipped',
|
||||
'cancelled',
|
||||
'pending',
|
||||
'streaming',
|
||||
'available',
|
||||
'unused',
|
||||
'stream_interrupted',
|
||||
]
|
||||
|
||||
const STATUS_PRIORITY: Record<string, number> = {
|
||||
available: 0,
|
||||
unused: 0,
|
||||
@@ -715,47 +710,6 @@ const STATUS_PRIORITY: Record<string, number> = {
|
||||
success: 4,
|
||||
}
|
||||
|
||||
const toInt = (value: unknown, defaultValue = 0): number => {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? Math.trunc(num) : defaultValue
|
||||
}
|
||||
|
||||
const makeAttemptKey = (candidateIndex: number, retryIndex: number): string => {
|
||||
return `${candidateIndex}:${retryIndex}`
|
||||
}
|
||||
|
||||
const POOL_UNATTEMPTED_STATUS = new Set<CandidateRecord['status']>([
|
||||
'available',
|
||||
'unused',
|
||||
'skipped',
|
||||
])
|
||||
|
||||
const isPoolAttemptedCandidate = (candidate: CandidateRecord): boolean => {
|
||||
if (POOL_UNATTEMPTED_STATUS.has(candidate.status)) return false
|
||||
// pending 只有开始执行后才算真正进入号池内部尝试
|
||||
if (candidate.status === 'pending' && !candidate.started_at) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const normalizeTimelineStatus = (value: unknown): CandidateRecord['status'] => {
|
||||
if (typeof value !== 'string') return 'failed'
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if ((TIMELINE_STATUS as string[]).includes(normalized)) {
|
||||
return normalized as CandidateRecord['status']
|
||||
}
|
||||
// 兜底:内部调度轨迹里非标准状态统一按失败展示
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
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 rawTimeline = computed<CandidateRecord[]>(() => {
|
||||
if (!trace.value) return []
|
||||
@@ -794,97 +748,11 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
||||
// 兼容旧链路:回退到 request_metadata.scheduling_audit.attempts。
|
||||
const audit = schedulingAudit.value
|
||||
if (!audit) return []
|
||||
const attempts = audit.attempts
|
||||
if (!Array.isArray(attempts) || attempts.length === 0) return []
|
||||
|
||||
const providerNameById = new Map<string, string>()
|
||||
for (const candidate of rawTimeline.value) {
|
||||
const providerId = String(candidate.provider_id || '').trim()
|
||||
const providerName = String(candidate.provider_name || '').trim()
|
||||
if (!providerId || !providerName) continue
|
||||
if (!providerNameById.has(providerId)) {
|
||||
providerNameById.set(providerId, providerName)
|
||||
}
|
||||
}
|
||||
const providerTypeLikeNames = new Set<string>([
|
||||
'codex',
|
||||
'kiro',
|
||||
'antigravity',
|
||||
'claude_code',
|
||||
'claude code',
|
||||
'gemini_cli',
|
||||
'gemini cli',
|
||||
'oauth',
|
||||
'api_key',
|
||||
'api key',
|
||||
])
|
||||
|
||||
const traceMap = new Map<string, CandidateRecord>()
|
||||
for (const candidate of rawTimeline.value) {
|
||||
traceMap.set(makeAttemptKey(candidate.candidate_index, candidate.retry_index), candidate)
|
||||
}
|
||||
|
||||
return attempts
|
||||
.map((item, index) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
|
||||
const raw = item as Record<string, unknown>
|
||||
const candidateIndex = toInt(raw.candidate_index, index)
|
||||
const retryIndex = toInt(raw.retry_index, 0)
|
||||
const key = makeAttemptKey(candidateIndex, retryIndex)
|
||||
const fromTrace = traceMap.get(key)
|
||||
|
||||
const merged: CandidateRecord = fromTrace
|
||||
? { ...fromTrace }
|
||||
: {
|
||||
id: `pool-${props.requestId}-${candidateIndex}-${retryIndex}-${index}`,
|
||||
request_id: props.requestId,
|
||||
candidate_index: candidateIndex,
|
||||
retry_index: retryIndex,
|
||||
provider_id: undefined,
|
||||
provider_name: undefined,
|
||||
endpoint_id: undefined,
|
||||
key_id: undefined,
|
||||
key_name: undefined,
|
||||
status: 'failed',
|
||||
is_cached: false,
|
||||
created_at: new Date(0).toISOString(),
|
||||
}
|
||||
|
||||
merged.status = normalizeTimelineStatus(raw.status ?? merged.status)
|
||||
if (typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
|
||||
if (typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
|
||||
if (typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
|
||||
if (typeof raw.key_id === 'string') merged.key_id = raw.key_id
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
const mergedProviderId = String(merged.provider_id || '').trim()
|
||||
if (mergedProviderId) {
|
||||
const inferredProviderName = providerNameById.get(mergedProviderId)
|
||||
const currentProviderName = String(merged.provider_name || '').trim()
|
||||
if (
|
||||
inferredProviderName
|
||||
&& (
|
||||
!currentProviderName
|
||||
|| providerTypeLikeNames.has(currentProviderName.toLowerCase())
|
||||
)
|
||||
) {
|
||||
merged.provider_name = inferredProviderName
|
||||
}
|
||||
}
|
||||
return merged
|
||||
})
|
||||
.filter((item): item is CandidateRecord => item !== null)
|
||||
return buildPoolAttemptCandidatesFromAudit(
|
||||
rawTimeline.value,
|
||||
audit.attempts,
|
||||
props.requestId,
|
||||
)
|
||||
})
|
||||
|
||||
const poolAttemptsByGroup = computed<Map<string, CandidateRecord[]>>(() => {
|
||||
|
||||
114
frontend/src/features/usage/utils/__tests__/poolTrace.spec.ts
Normal file
114
frontend/src/features/usage/utils/__tests__/poolTrace.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CandidateRecord } from '@/api/requestTrace'
|
||||
import { buildPoolAttemptCandidatesFromAudit } from '@/features/usage/utils/poolTrace'
|
||||
|
||||
function buildCandidate(
|
||||
overrides: Partial<CandidateRecord> = {},
|
||||
): CandidateRecord {
|
||||
return {
|
||||
id: 'cand-1',
|
||||
request_id: 'req-1',
|
||||
candidate_index: 0,
|
||||
retry_index: 0,
|
||||
status: 'failed',
|
||||
is_cached: false,
|
||||
created_at: '1970-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('poolTrace', () => {
|
||||
it('keeps only actually attempted pool nodes from scheduling audit fallback', () => {
|
||||
const attempts = buildPoolAttemptCandidatesFromAudit([], [
|
||||
{
|
||||
candidate_index: 0,
|
||||
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: 1,
|
||||
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',
|
||||
},
|
||||
{
|
||||
candidate_index: 2,
|
||||
retry_index: 0,
|
||||
provider_id: 'provider-1',
|
||||
provider_name: 'Codex反代',
|
||||
key_id: 'key-available',
|
||||
key_name: 'Available Key',
|
||||
status: 'available',
|
||||
pool_group_id: 'provider-1',
|
||||
},
|
||||
{
|
||||
candidate_index: 3,
|
||||
retry_index: 0,
|
||||
provider_id: 'provider-1',
|
||||
provider_name: 'Codex反代',
|
||||
key_id: 'key-unknown',
|
||||
key_name: 'Unknown Key',
|
||||
status: 'selected',
|
||||
pool_group_id: 'provider-1',
|
||||
},
|
||||
], 'req-1')
|
||||
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(attempts[0].key_id).toBe('key-success')
|
||||
expect(attempts[0].status).toBe('success')
|
||||
})
|
||||
|
||||
it('preserves real trace attempts even when audit status is non-standard', () => {
|
||||
const rawTimeline = [
|
||||
buildCandidate({
|
||||
id: 'cand-trace-1',
|
||||
candidate_index: 4,
|
||||
retry_index: 0,
|
||||
provider_id: 'provider-1',
|
||||
provider_name: 'Codex反代',
|
||||
key_id: 'key-trace',
|
||||
key_name: 'Trace Key',
|
||||
status: 'failed',
|
||||
started_at: '2026-04-19T12:00:00.000Z',
|
||||
}),
|
||||
]
|
||||
|
||||
const attempts = buildPoolAttemptCandidatesFromAudit(rawTimeline, [
|
||||
{
|
||||
candidate_index: 4,
|
||||
retry_index: 0,
|
||||
provider_id: 'provider-1',
|
||||
provider_name: 'oauth',
|
||||
key_id: 'key-trace',
|
||||
key_name: 'Trace Key',
|
||||
status: 'selected',
|
||||
pool_group_id: 'provider-1',
|
||||
},
|
||||
{
|
||||
candidate_index: 5,
|
||||
retry_index: 0,
|
||||
provider_id: 'provider-1',
|
||||
provider_name: 'oauth',
|
||||
key_id: 'key-ghost',
|
||||
key_name: 'Ghost Key',
|
||||
status: 'selected',
|
||||
pool_group_id: 'provider-1',
|
||||
},
|
||||
], 'req-1')
|
||||
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(attempts[0].id).toBe('cand-trace-1')
|
||||
expect(attempts[0].status).toBe('failed')
|
||||
expect(attempts[0].provider_name).toBe('Codex反代')
|
||||
})
|
||||
})
|
||||
160
frontend/src/features/usage/utils/poolTrace.ts
Normal file
160
frontend/src/features/usage/utils/poolTrace.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { CandidateRecord } from '@/api/requestTrace'
|
||||
|
||||
export const TIMELINE_STATUS: CandidateRecord['status'][] = [
|
||||
'success',
|
||||
'failed',
|
||||
'skipped',
|
||||
'cancelled',
|
||||
'pending',
|
||||
'streaming',
|
||||
'available',
|
||||
'unused',
|
||||
'stream_interrupted',
|
||||
]
|
||||
|
||||
const POOL_UNATTEMPTED_STATUS = new Set<CandidateRecord['status']>([
|
||||
'available',
|
||||
'unused',
|
||||
'skipped',
|
||||
])
|
||||
|
||||
const PROVIDER_TYPE_LIKE_NAMES = new Set<string>([
|
||||
'codex',
|
||||
'kiro',
|
||||
'antigravity',
|
||||
'claude_code',
|
||||
'claude code',
|
||||
'gemini_cli',
|
||||
'gemini cli',
|
||||
'oauth',
|
||||
'api_key',
|
||||
'api key',
|
||||
])
|
||||
|
||||
const toInt = (value: unknown, defaultValue = 0): number => {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? Math.trunc(num) : defaultValue
|
||||
}
|
||||
|
||||
export const makeAttemptKey = (candidateIndex: number, retryIndex: number): string => {
|
||||
return `${candidateIndex}:${retryIndex}`
|
||||
}
|
||||
|
||||
export const isPoolAttemptedCandidate = (candidate: CandidateRecord): boolean => {
|
||||
if (POOL_UNATTEMPTED_STATUS.has(candidate.status)) return false
|
||||
if (candidate.status === 'pending' && !candidate.started_at) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export const parseTimelineStatus = (value: unknown): CandidateRecord['status'] | null => {
|
||||
if (typeof value !== 'string') return null
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if ((TIMELINE_STATUS as string[]).includes(normalized)) {
|
||||
return normalized as CandidateRecord['status']
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const extractPoolGroupId = (
|
||||
candidate: Pick<CandidateRecord, 'extra_data'>,
|
||||
): 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
|
||||
}
|
||||
|
||||
export function buildPoolAttemptCandidatesFromAudit(
|
||||
rawTimeline: CandidateRecord[],
|
||||
attempts: unknown,
|
||||
requestId?: string | null,
|
||||
): CandidateRecord[] {
|
||||
if (!Array.isArray(attempts) || attempts.length === 0) return []
|
||||
|
||||
const providerNameById = new Map<string, string>()
|
||||
for (const candidate of rawTimeline) {
|
||||
const providerId = String(candidate.provider_id || '').trim()
|
||||
const providerName = String(candidate.provider_name || '').trim()
|
||||
if (!providerId || !providerName) continue
|
||||
if (!providerNameById.has(providerId)) {
|
||||
providerNameById.set(providerId, providerName)
|
||||
}
|
||||
}
|
||||
|
||||
const traceMap = new Map<string, CandidateRecord>()
|
||||
for (const candidate of rawTimeline) {
|
||||
traceMap.set(makeAttemptKey(candidate.candidate_index, candidate.retry_index), candidate)
|
||||
}
|
||||
|
||||
return attempts
|
||||
.map((item, index) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
|
||||
const raw = item as Record<string, unknown>
|
||||
const candidateIndex = toInt(raw.candidate_index, index)
|
||||
const retryIndex = toInt(raw.retry_index, 0)
|
||||
const key = makeAttemptKey(candidateIndex, retryIndex)
|
||||
const fromTrace = traceMap.get(key)
|
||||
const parsedStatus = parseTimelineStatus(raw.status)
|
||||
|
||||
if (!fromTrace && parsedStatus === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const merged: CandidateRecord = fromTrace
|
||||
? { ...fromTrace }
|
||||
: {
|
||||
id: `pool-${requestId || 'unknown'}-${candidateIndex}-${retryIndex}-${index}`,
|
||||
request_id: requestId || '',
|
||||
candidate_index: candidateIndex,
|
||||
retry_index: retryIndex,
|
||||
provider_id: undefined,
|
||||
provider_name: undefined,
|
||||
endpoint_id: undefined,
|
||||
key_id: undefined,
|
||||
key_name: undefined,
|
||||
status: 'failed',
|
||||
is_cached: false,
|
||||
created_at: new Date(0).toISOString(),
|
||||
}
|
||||
|
||||
if (parsedStatus !== null) {
|
||||
merged.status = parsedStatus
|
||||
}
|
||||
if (typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
|
||||
if (typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
|
||||
if (typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
|
||||
if (typeof raw.key_id === 'string') merged.key_id = raw.key_id
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
const mergedProviderId = String(merged.provider_id || '').trim()
|
||||
if (mergedProviderId) {
|
||||
const inferredProviderName = providerNameById.get(mergedProviderId)
|
||||
const currentProviderName = String(merged.provider_name || '').trim()
|
||||
if (
|
||||
inferredProviderName
|
||||
&& (
|
||||
!currentProviderName
|
||||
|| PROVIDER_TYPE_LIKE_NAMES.has(currentProviderName.toLowerCase())
|
||||
)
|
||||
) {
|
||||
merged.provider_name = inferredProviderName
|
||||
}
|
||||
}
|
||||
|
||||
return isPoolAttemptedCandidate(merged) ? merged : null
|
||||
})
|
||||
.filter((item): item is CandidateRecord => item !== null)
|
||||
}
|
||||
Reference in New Issue
Block a user