feat(pool): 新增调度维度、互斥组机制、健康策略扩展与配额刷新增强

- 新增 priority_first/health_first/latency_first/cost_first 四个调度维度
- 引入 mutex_group 互斥组机制,lru 与 single_account 归入 distribution_mode
- 维度 compute_metric 签名扩展 context 参数,支持获取 cost_totals 等上下文
- 各维度增加 evidence_hint 字段描述评分依据
- 健康策略扩展 408/409/423/425/5xx 瞬态状态码冷却,403 按 body 分级冷却
- Codex 配额刷新增强 401/402/403 错误处理,402 生成 fallback 元数据
- 前端号池管理支持账号优先级内联编辑与互斥维度切换 UI
- 列表排序改为 internal_priority + created_at,移除 sticky_counts 查询
This commit is contained in:
fawney19
2026-03-05 09:53:23 +08:00
parent 32ccf61baa
commit 1ac59d4894
24 changed files with 1172 additions and 88 deletions

View File

@@ -21,10 +21,11 @@
<div class="space-y-0.5">
<div
v-for="(item, index) in presetList"
v-show="!isMutexFollower(index)"
:key="item.preset"
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200"
:class="[
!item.applicable
!displayItems[index].applicable
? 'border-border/30 bg-muted/20 opacity-50'
: draggedIndex === index
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
@@ -32,25 +33,45 @@
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
]"
:draggable="item.applicable"
@dragstart="item.applicable && handleDragStart(index, $event)"
:draggable="canDragPreset(index)"
@dragstart="canDragPreset(index) && handleDragStart(index, $event)"
@dragend="handleDragEnd"
@dragover.prevent="item.applicable && handleDragOver(index)"
@dragover.prevent="canDragPreset(index) && handleDragOver(index)"
@dragleave="handleDragLeave"
@drop="item.applicable && handleDrop(index)"
@drop="canDragPreset(index) && handleDrop(index)"
>
<!-- Drag handle -->
<div
class="p-1 rounded transition-colors shrink-0"
:class="item.applicable
:class="canDragPreset(index)
? 'cursor-grab active:cursor-grabbing text-muted-foreground/40 group-hover:text-muted-foreground'
: 'text-muted-foreground/15 cursor-default'"
>
<GripVertical class="w-4 h-4" />
</div>
<!-- Enable/disable switch -->
<template v-if="item.mutexGroup">
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md shrink-0">
<button
v-for="member in getMutexGroupItems(index)"
:key="member.preset"
type="button"
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
:disabled="!member.applicable"
:class="[
member.preset === displayItems[index].preset
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50',
!member.applicable ? 'opacity-40 cursor-not-allowed' : ''
]"
@click="selectMutexPreset(index, member.preset)"
>
{{ member.label }}
</button>
</div>
</template>
<Switch
v-else
:model-value="item.enabled"
:disabled="!item.applicable"
@update:model-value="(v: boolean) => togglePreset(index, v)"
@@ -61,35 +82,41 @@
<div class="flex items-center gap-2">
<span
class="text-sm font-medium"
:class="!item.applicable ? 'text-muted-foreground' : ''"
>{{ item.label }}</span>
:class="!displayItems[index].applicable ? 'text-muted-foreground' : ''"
>{{ displayItems[index].label }}</span>
<span
v-if="!item.applicable"
v-if="!displayItems[index].applicable"
class="text-[10px] text-muted-foreground/60"
>
(不适用)
</span>
</div>
<p class="text-xs text-muted-foreground mt-0.5">
{{ item.desc }}
{{ displayItems[index].desc }}
</p>
<p
v-if="displayItems[index].evidenceHint"
class="text-[11px] text-muted-foreground/80 mt-1"
>
依据: {{ displayItems[index].evidenceHint }}
</p>
<!-- Mode sub-config -->
<div
v-if="item.modeOptions.length > 0 && item.enabled && item.applicable"
v-if="displayItems[index].modeOptions.length > 0 && displayItems[index].enabled && displayItems[index].applicable"
class="flex gap-0.5 mt-2 p-0.5 bg-muted/40 rounded-md w-fit"
>
<button
v-for="modeOpt in item.modeOptions"
v-for="modeOpt in displayItems[index].modeOptions"
:key="modeOpt.value"
type="button"
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
:class="[
item.mode === modeOpt.value
displayItems[index].mode === modeOpt.value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
@click="setPresetMode(index, modeOpt.value)"
@click="setPresetModeByPreset(displayItems[index].preset, modeOpt.value)"
>
{{ modeOpt.label }}
</button>
@@ -388,7 +415,12 @@ import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints'
import { getPoolSchedulingPresets } from '@/api/endpoints/pool'
import type { PoolPresetMeta } from '@/api/endpoints/pool'
import type { PoolAdvancedConfig, ClaudeCodeAdvancedConfig, SchedulingPresetItem } from '@/api/endpoints/types/provider'
import type {
PoolAdvancedConfig,
ClaudeCodeAdvancedConfig,
SchedulingPresetItem,
ProviderWithEndpointsSummary,
} from '@/api/endpoints/types/provider'
interface PresetModeOption {
value: string
@@ -403,6 +435,8 @@ interface PresetListItem {
mode: string | null
modeOptions: PresetModeOption[]
applicable: boolean
mutexGroup: string | null
evidenceHint: string
}
const props = defineProps<{
@@ -415,7 +449,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
saved: [provider: ProviderWithEndpointsSummary]
}>()
const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
@@ -423,6 +457,8 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
name: 'lru',
label: 'LRU 轮转',
description: '最久未使用的 Key 优先',
mutex_group: 'distribution_mode',
evidence_hint: '依据 LRU 时间戳(最近未使用优先)',
providers: [],
modes: null,
default_mode: null,
@@ -431,6 +467,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
name: 'free_team_first',
label: 'Free/Team 优先',
description: '优先消耗低档账号(依赖 plan_type',
evidence_hint: '依据 plan_typeoauth_plan_type 或 upstream_metadata',
providers: ['codex', 'kiro'],
modes: [
{ value: 'free_only', label: 'Free' },
@@ -443,6 +480,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
name: 'quota_balanced',
label: '额度平均',
description: '优先选额度消耗最少的账号',
evidence_hint: '依据账号配额使用率;无配额时回退到窗口成本使用',
providers: [],
modes: null,
default_mode: null,
@@ -451,6 +489,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
name: 'recent_refresh',
label: '额度刷新优先',
description: '优先选即将刷新额度的账号',
evidence_hint: '依据账号额度重置倒计时next_reset / reset_seconds',
providers: ['codex', 'kiro'],
modes: null,
default_mode: null,
@@ -459,6 +498,44 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
name: 'single_account',
label: '单号优先',
description: '集中使用同一账号(反向 LRU',
mutex_group: 'distribution_mode',
evidence_hint: '先按账号优先级internal_priority同级再按反向 LRU 集中',
providers: [],
modes: null,
default_mode: null,
},
{
name: 'priority_first',
label: '优先级优先',
description: '按账号优先级顺序调度(数字越小越优先)',
evidence_hint: '依据 internal_priority支持拖拽/手工编辑)',
providers: [],
modes: null,
default_mode: null,
},
{
name: 'health_first',
label: '健康优先',
description: '优先选择健康分更高、失败更少的账号',
evidence_hint: '依据 health_by_format 聚合分(含熔断/失败衰减)',
providers: [],
modes: null,
default_mode: null,
},
{
name: 'latency_first',
label: '延迟优先',
description: '优先选择最近延迟更低的账号',
evidence_hint: '依据号池延迟窗口均值latency_window_seconds',
providers: [],
modes: null,
default_mode: null,
},
{
name: 'cost_first',
label: '成本优先',
description: '优先选择窗口消耗更低的账号',
evidence_hint: '依据窗口成本/Token 用量,缺失时回退配额使用率',
providers: [],
modes: null,
default_mode: null,
@@ -530,6 +607,11 @@ function normalizeMode(value: unknown): string | null {
return normalized || null
}
function normalizeMutexGroup(value: unknown): string | null {
const normalized = String(value ?? '').trim().toLowerCase()
return normalized || null
}
function normalizePresetDefs(defs: PoolPresetMeta[]): PoolPresetMeta[] {
const ordered: PoolPresetMeta[] = []
const seen = new Set<string>()
@@ -556,6 +638,8 @@ function normalizePresetDefs(defs: PoolPresetMeta[]): PoolPresetMeta[] {
providers,
modes: modes && modes.length > 0 ? modes : null,
default_mode: defaultMode,
mutex_group: normalizeMutexGroup(raw.mutex_group),
evidence_hint: String(raw.evidence_hint ?? '').trim() || null,
})
}
return ordered
@@ -622,6 +706,8 @@ function buildDefaultPresetList(): PresetListItem[] {
mode: defaultModeForPreset(def),
modeOptions: getModeOptions(def),
applicable: isApplicablePreset(def),
mutexGroup: normalizeMutexGroup(def.mutex_group),
evidenceHint: String(def.evidence_hint ?? '').trim(),
}))
}
@@ -675,6 +761,8 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
mode: resolveMode(def, ci.mode),
modeOptions: getModeOptions(def),
applicable: isApplicablePreset(def),
mutexGroup: normalizeMutexGroup(def.mutex_group),
evidenceHint: String(def.evidence_hint ?? '').trim(),
})
}
@@ -688,6 +776,8 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
mode: defaultModeForPreset(def),
modeOptions: getModeOptions(def),
applicable: isApplicablePreset(def),
mutexGroup: normalizeMutexGroup(def.mutex_group),
evidenceHint: String(def.evidence_hint ?? '').trim(),
})
}
return ordered
@@ -708,6 +798,8 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
mode: null,
modeOptions: [],
applicable: isApplicablePreset(lruDef),
mutexGroup: normalizeMutexGroup(lruDef.mutex_group),
evidenceHint: String(lruDef.evidence_hint ?? '').trim(),
})
seen.add('lru')
}
@@ -725,6 +817,8 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
mode: resolveMode(def, undefined),
modeOptions: getModeOptions(def),
applicable: isApplicablePreset(def),
mutexGroup: normalizeMutexGroup(def.mutex_group),
evidenceHint: String(def.evidence_hint ?? '').trim(),
})
}
@@ -738,19 +832,143 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
mode: defaultModeForPreset(def),
modeOptions: getModeOptions(def),
applicable: isApplicablePreset(def),
mutexGroup: normalizeMutexGroup(def.mutex_group),
evidenceHint: String(def.evidence_hint ?? '').trim(),
})
}
return ordered
}
function togglePreset(index: number, enabled: boolean) {
presetList.value[index].enabled = enabled
function normalizeMutexSelection(items: PresetListItem[]): PresetListItem[] {
const next = [...items]
const groups = new Map<string, number[]>()
next.forEach((item, index) => {
if (!item.mutexGroup) return
if (!groups.has(item.mutexGroup)) groups.set(item.mutexGroup, [])
groups.get(item.mutexGroup)?.push(index)
})
for (const indexes of groups.values()) {
if (indexes.length <= 1) continue
const enabledApplicable = indexes.find(index => {
const item = next[index]
return item.enabled && item.applicable
})
const firstApplicable = indexes.find(index => next[index].applicable)
const winner = enabledApplicable ?? firstApplicable ?? indexes[0]
indexes.forEach((index) => {
next[index].enabled = index === winner && next[index].applicable
})
}
return next
}
function setPresetMode(index: number, mode: string) {
presetList.value[index].mode = mode
function togglePreset(index: number, enabled: boolean) {
const item = presetList.value[index]
if (!item) return
if (!item.mutexGroup) {
item.enabled = enabled
return
}
const memberIndexes = getMutexGroupIndexes(index)
if (enabled) {
for (const memberIndex of memberIndexes) {
const member = presetList.value[memberIndex]
if (!member) continue
member.enabled = member.preset === item.preset && member.applicable
}
return
}
for (const memberIndex of memberIndexes) {
const member = presetList.value[memberIndex]
if (!member) continue
member.enabled = false
}
}
function setPresetModeByPreset(preset: string, mode: string) {
const targetIndex = presetList.value.findIndex(item => item.preset === preset)
if (targetIndex < 0) return
presetList.value[targetIndex].mode = mode
}
const mutexGroupIndexMap = computed<Record<string, number[]>>(() => {
const grouped: Record<string, number[]> = {}
presetList.value.forEach((item, index) => {
if (!item.mutexGroup) return
if (!grouped[item.mutexGroup]) grouped[item.mutexGroup] = []
grouped[item.mutexGroup].push(index)
})
return grouped
})
function getMutexGroupIndexes(index: number): number[] {
const item = presetList.value[index]
if (!item?.mutexGroup) return []
return mutexGroupIndexMap.value[item.mutexGroup] || []
}
function isMutexFollower(index: number): boolean {
const memberIndexes = getMutexGroupIndexes(index)
if (memberIndexes.length <= 1) return false
return memberIndexes[0] !== index
}
function getMutexGroupItems(index: number): PresetListItem[] {
return getMutexGroupIndexes(index)
.map(memberIndex => presetList.value[memberIndex])
.filter((item): item is PresetListItem => Boolean(item))
}
function getMutexSelectedItem(index: number): PresetListItem | null {
const members = getMutexGroupItems(index)
if (members.length === 0) return null
return members.find(member => member.enabled && member.applicable)
|| members.find(member => member.applicable)
|| members[0]
}
function selectMutexPreset(index: number, presetName: string) {
const memberIndexes = getMutexGroupIndexes(index)
if (memberIndexes.length === 0) return
for (const memberIndex of memberIndexes) {
const member = presetList.value[memberIndex]
if (!member) continue
member.enabled = member.preset === presetName && member.applicable
}
}
function canDragPreset(index: number): boolean {
const item = presetList.value[index]
if (!item) return false
if (item.mutexGroup) return false
return item.applicable
}
const EMPTY_DISPLAY_ITEM: PresetListItem = {
preset: '',
label: '',
desc: '',
enabled: false,
mode: null,
modeOptions: [],
applicable: false,
mutexGroup: null,
evidenceHint: '',
}
const displayItems = computed<PresetListItem[]>(() =>
presetList.value.map((item, index) => {
if (!item) return EMPTY_DISPLAY_ITEM
if (!item.mutexGroup) return item
return getMutexSelectedItem(index) || item
}),
)
function handleDragStart(index: number, event: DragEvent) {
draggedIndex.value = index
if (event.dataTransfer) {
@@ -790,7 +1008,7 @@ watch(() => props.modelValue, async (open) => {
if (!open) return
showAdvanced.value = false
await ensurePresetDefsLoaded()
presetList.value = loadFromConfig(props.currentConfig)
presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig))
const cfg = props.currentConfig
form.value = {
@@ -819,6 +1037,7 @@ watch(() => props.modelValue, async (open) => {
async function handleSave() {
loading.value = true
try {
presetList.value = normalizeMutexSelection(presetList.value)
const schedulingPresets: SchedulingPresetItem[] = presetList.value.map(item => {
const result: SchedulingPresetItem = {
preset: item.preset,
@@ -857,9 +1076,9 @@ async function handleSave() {
cli_only_enabled: cf.cli_only_enabled,
}
}
await updateProvider(props.providerId, payload)
const updatedProvider = await updateProvider(props.providerId, payload)
success('号池调度已保存')
emit('saved')
emit('saved', updatedProvider)
emit('update:modelValue', false)
} catch (err) {
showError(parseApiError(err))