fix(routing): harden routed pool scheduling

This commit is contained in:
elky
2026-07-27 22:06:28 +08:00
parent 550cc36760
commit 4148ab1931
62 changed files with 5510 additions and 850 deletions
@@ -530,7 +530,8 @@ import { CircleHelp } from 'lucide-vue-next'
import { Dialog, Button, Input, Label, Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints'
import { getProvider, updateProvider } from '@/api/endpoints'
import { mergePoolAdvancedPatch } from '@/features/pool/utils/poolSchedulingDialog'
import {
buildPoolCooldownFieldLayout,
buildPoolHealthToggleCards,
@@ -557,6 +558,7 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const loading = ref(false)
let dialogRevision = 0
const isClaudeCode = computed(() => {
return (props.providerType || '').trim().toLowerCase() === 'claude_code'
@@ -652,7 +654,9 @@ function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void
}
}
watch(() => props.modelValue, (open) => {
watch([() => props.modelValue, () => props.providerId], ([open]) => {
dialogRevision += 1
loading.value = false
if (!open) return
const cfg = props.currentConfig
@@ -698,12 +702,26 @@ watch(() => props.modelValue, (open) => {
})
async function handleSave() {
const providerId = props.providerId
const revision = dialogRevision
loading.value = true
try {
const latestProvider = await getProvider(providerId)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
const latestAdvanced = (latestProvider as Record<string, unknown>).pool_advanced
const existingPoolAdvanced = mergePoolAdvancedPatch(latestAdvanced, {})
const latestScoreRules = typeof existingPoolAdvanced.score_rules === 'object'
&& existingPoolAdvanced.score_rules !== null
? existingPoolAdvanced.score_rules as Record<string, unknown>
: {}
const latestScoreWeights = typeof latestScoreRules.weights === 'object'
&& latestScoreRules.weights !== null
? latestScoreRules.weights as Record<string, unknown>
: {}
const scoreRules = {
...(props.currentConfig?.score_rules ?? {}),
...latestScoreRules,
weights: {
...(props.currentConfig?.score_rules?.weights ?? {}),
...latestScoreWeights,
manual_priority: form.value.score_weight_manual_priority ?? undefined,
health: form.value.score_weight_health ?? undefined,
probe_freshness: form.value.score_weight_probe_freshness ?? undefined,
@@ -717,31 +735,8 @@ async function handleSave() {
request_failure_penalty: form.value.request_failure_penalty ?? undefined,
probe_failure_cooldown_threshold: form.value.probe_failure_cooldown_threshold ?? undefined,
}
const existingPoolAdvanced: Record<string, unknown> = { ...(props.currentConfig ?? {}) }
for (const key of [
'probing_target_percent',
'probing_target_count',
'probing_active_target_percent',
'probing_active_target_count',
'active_probe_target_percent',
'active_probe_target_count',
'probing_interval_minutes',
'account_self_check_method',
'self_check_method',
'account_self_check_request',
'self_check_request',
'health_policy_enabled',
'sticky_session_ttl_seconds',
'global_priority',
'cost_window_seconds',
'cost_limit_per_key_tokens',
'cost_soft_threshold_percent',
]) {
delete existingPoolAdvanced[key]
}
// 合并已有配置(保留 scheduling_presets 等不在此对话框编辑的字段)
const poolAdvanced: Record<string, unknown> = {
...existingPoolAdvanced,
const poolAdvanced = mergePoolAdvancedPatch(existingPoolAdvanced, {
rate_limit_cooldown_seconds: form.value.rate_limit_cooldown_seconds ?? undefined,
overload_cooldown_seconds: form.value.overload_cooldown_seconds ?? undefined,
batch_concurrency: form.value.batch_concurrency ?? undefined,
@@ -760,7 +755,7 @@ async function handleSave() {
auto_remove_banned_keys: form.value.auto_remove_banned_keys,
auto_remove_quota_exhausted_keys: form.value.auto_remove_quota_exhausted_keys,
skip_exhausted_accounts: form.value.skip_exhausted_accounts,
}
})
const payload: Parameters<typeof updateProvider>[1] = {
pool_advanced: poolAdvanced as PoolAdvancedConfig,
@@ -776,14 +771,17 @@ async function handleSave() {
cli_only_enabled: cf.cli_only_enabled,
}
}
const updatedProvider = await updateProvider(props.providerId, payload)
const updatedProvider = await updateProvider(providerId, payload)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
success('高级设置已保存')
emit('saved', updatedProvider)
emit('update:modelValue', false)
} catch (err) {
showError(parseApiError(err))
} finally {
loading.value = false
if (dialogRevision === revision) {
loading.value = false
}
}
}
</script>
@@ -218,9 +218,13 @@ import { GripVertical } from 'lucide-vue-next'
import { Dialog, Button, Switch } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints'
import { getProvider, updateProvider } from '@/api/endpoints'
import { getPoolSchedulingPresets } from '@/api/endpoints/pool'
import { moveStrategyItem } from '@/features/pool/utils/poolSchedulingDialog'
import {
mergePoolAdvancedPatch,
moveStrategyItem,
normalizeMutexSelection,
} from '@/features/pool/utils/poolSchedulingDialog'
import type { PoolPresetMeta } from '@/api/endpoints/pool'
import type {
PoolAdvancedConfig,
@@ -267,6 +271,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
mutex_group: DISTRIBUTION_GROUP,
evidence_hint: '依据 LRU 时间戳(最近使用优先,与 LRU 轮转相反)',
providers: [],
default_enabled: true,
modes: null,
default_mode: null,
},
@@ -300,12 +305,25 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
modes: null,
default_mode: null,
},
{
name: 'free_team_first',
label: 'Free/Team 优先',
description: '兼容旧配置:优先消耗 Free、Team 或两者',
evidence_hint: '依据 plan_type,保留旧 free_only/team_only/both 语义',
providers: ['codex', 'grok', 'kiro', 'windsurf'],
modes: [
{ value: 'free_only', label: 'Free' },
{ value: 'team_only', label: 'Team' },
{ value: 'both', label: 'Free + Team' },
],
default_mode: 'both',
},
{
name: 'free_first',
label: 'Free 优先',
description: '优先消耗 Free 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Free 账号优先调度)',
providers: ['codex', 'kiro'],
providers: ['codex', 'grok', 'kiro', 'windsurf'],
modes: null,
default_mode: null,
},
@@ -314,7 +332,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Team 优先',
description: '优先消耗 Team 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Team 账号优先调度)',
providers: ['codex', 'kiro'],
providers: ['codex', 'grok', 'kiro', 'windsurf'],
modes: null,
default_mode: null,
},
@@ -323,7 +341,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Plus 优先',
description: '优先消耗 Plus 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Plus 账号优先调度)',
providers: ['codex', 'kiro'],
providers: ['codex', 'grok', 'kiro', 'windsurf'],
modes: null,
default_mode: null,
},
@@ -332,7 +350,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: 'Pro 优先',
description: '优先消耗 Pro 账号(依赖 plan_type)',
evidence_hint: '依据 plan_type(Pro 账号优先调度)',
providers: ['codex', 'kiro'],
providers: ['codex', 'grok', 'kiro', 'windsurf'],
modes: null,
default_mode: null,
},
@@ -350,7 +368,8 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
label: '额度刷新优先',
description: '优先选即将刷新额度的账号',
evidence_hint: '依据账号额度重置倒计时(next_reset / reset_seconds)',
providers: ['codex', 'kiro'],
providers: ['codex', 'grok', 'kiro', 'windsurf'],
default_enabled_providers: ['codex', 'windsurf'],
modes: null,
default_mode: null,
},
@@ -392,10 +411,9 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
},
]
const DEFAULT_ENABLED_PRESETS = new Set(['cache_affinity', 'recent_refresh'])
const { success, error: showError } = useToast()
const loading = ref(false)
let dialogRevision = 0
const presetDefs = ref<PoolPresetMeta[]>([])
const presetDefsLoaded = ref(false)
const loadingPresetDefs = ref(false)
@@ -443,11 +461,16 @@ function normalizePresetDefs(defs: PoolPresetMeta[]): PoolPresetMeta[] {
.filter(mode => Boolean(mode.value))
: null
const defaultMode = normalizeMode(raw.default_mode)
const defaultEnabledProviders = Array.isArray(raw.default_enabled_providers)
? raw.default_enabled_providers.map(p => normalizeProviderType(p)).filter(Boolean)
: []
ordered.push({
name,
label: String(raw.label ?? '').trim() || name,
description: String(raw.description ?? '').trim(),
providers,
default_enabled: raw.default_enabled === true,
default_enabled_providers: defaultEnabledProviders,
modes: modes && modes.length > 0 ? modes : null,
default_mode: defaultMode,
mutex_group: normalizeMutexGroup(raw.mutex_group),
@@ -478,11 +501,11 @@ async function ensurePresetDefsLoaded(): Promise<void> {
const normalized = normalizePresetDefs(Array.isArray(remoteDefs) ? remoteDefs : [])
if (normalized.length > 0) {
presetDefs.value = normalized
presetDefsLoaded.value = true
}
} catch (err) {
showError(parseApiError(err))
} finally {
presetDefsLoaded.value = true
loadingPresetDefs.value = false
}
}
@@ -530,7 +553,15 @@ function buildPresetListItem(def: PoolPresetMeta, enabled: boolean, mode?: unkno
}
function buildDefaultPresetList(): PresetListItem[] {
return getPresetDefs().map(def => buildPresetListItem(def, DEFAULT_ENABLED_PRESETS.has(def.name)))
const providerType = normalizeProviderType(props.providerType)
return getPresetDefs().map((def) => {
const providerDefaults = Array.isArray(def.default_enabled_providers)
? def.default_enabled_providers.map(normalizeProviderType)
: []
const enabled = def.default_enabled === true
|| (Boolean(providerType) && providerDefaults.includes(providerType))
return buildPresetListItem(def, enabled)
})
}
function isNewFormatPresetItem(item: unknown): item is SchedulingPresetItem {
@@ -641,7 +672,7 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
}
insertMissingByPreferredOrder(ordered, seen, defs, defsByName)
return reorderDistributionGroup(ordered)
return reorderDistributionGroup(normalizeMutexSelection(ordered))
}
const legacyPresets = rawPresets as string[]
@@ -664,33 +695,7 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
}
insertMissingByPreferredOrder(ordered, seen, defs, defsByName)
return reorderDistributionGroup(ordered)
}
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
return reorderDistributionGroup(normalizeMutexSelection(ordered))
}
function togglePreset(index: number, enabled: boolean) {
@@ -814,15 +819,26 @@ function handleDrop(dropIndex: number) {
dragOverIndex.value = null
}
watch(() => props.modelValue, async (open) => {
watch([() => props.modelValue, () => props.providerId], async ([open]) => {
const revision = ++dialogRevision
loading.value = false
if (!open) return
await ensurePresetDefsLoaded()
if (!props.modelValue || dialogRevision !== revision) return
presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig))
})
async function handleSave() {
const providerId = props.providerId
const revision = dialogRevision
loading.value = true
try {
await ensurePresetDefsLoaded()
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
if (!presetDefsLoaded.value) {
showError('调度策略元数据加载失败,请重试')
return
}
presetList.value = normalizeMutexSelection(presetList.value)
const schedulingPresets: SchedulingPresetItem[] = presetList.value.map(item => {
const result: SchedulingPresetItem = {
@@ -835,14 +851,17 @@ async function handleSave() {
return result
})
const mergedAdvanced: Record<string, unknown> = {
...(props.currentConfig ?? {}),
const latestProvider = await getProvider(providerId)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
const latestAdvanced = (latestProvider as Record<string, unknown>).pool_advanced
const mergedAdvanced = mergePoolAdvancedPatch(latestAdvanced, {
scheduling_presets: schedulingPresets,
}
})
const payload: Parameters<typeof updateProvider>[1] = {
pool_advanced: mergedAdvanced as PoolAdvancedConfig,
}
const updatedProvider = await updateProvider(props.providerId, payload)
const updatedProvider = await updateProvider(providerId, payload)
if (!props.modelValue || props.providerId !== providerId || dialogRevision !== revision) return
success('号池调度已保存')
emit('saved', updatedProvider)
@@ -850,7 +869,9 @@ async function handleSave() {
} catch (err) {
showError(parseApiError(err))
} finally {
loading.value = false
if (dialogRevision === revision) {
loading.value = false
}
}
}
</script>
@@ -1,22 +1,27 @@
import { describe, expect, it } from 'vitest'
import { moveStrategyItem } from '@/features/pool/utils/poolSchedulingDialog'
import {
mergePoolAdvancedPatch,
moveStrategyItem,
normalizeMutexSelection,
} from '@/features/pool/utils/poolSchedulingDialog'
interface TestPresetItem {
preset: string
mutexGroup: string | null
enabled: boolean
applicable: boolean
}
function buildItems(): TestPresetItem[] {
return [
{ preset: 'cache_affinity', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'lru', mutexGroup: 'distribution_mode', enabled: true },
{ preset: 'single_account', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'load_balance', mutexGroup: 'distribution_mode', enabled: false },
{ preset: 'recent_refresh', mutexGroup: null, enabled: true },
{ preset: 'quota_balanced', mutexGroup: null, enabled: false },
{ preset: 'priority_first', mutexGroup: null, enabled: true },
{ preset: 'cache_affinity', mutexGroup: 'distribution_mode', enabled: false, applicable: true },
{ preset: 'lru', mutexGroup: 'distribution_mode', enabled: true, applicable: true },
{ preset: 'single_account', mutexGroup: 'distribution_mode', enabled: false, applicable: true },
{ preset: 'load_balance', mutexGroup: 'distribution_mode', enabled: false, applicable: true },
{ preset: 'recent_refresh', mutexGroup: null, enabled: true, applicable: true },
{ preset: 'quota_balanced', mutexGroup: null, enabled: false, applicable: true },
{ preset: 'priority_first', mutexGroup: null, enabled: true, applicable: true },
]
}
@@ -69,4 +74,49 @@ describe('poolSchedulingDialog', () => {
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
})
it('keeps the first enabled distribution from the saved order', () => {
const items = buildItems()
items[0].enabled = true
items[1].enabled = false
items[3].enabled = true
const savedOrder = [items[3], items[0], ...items.slice(1, 3), ...items.slice(4)]
const normalized = normalizeMutexSelection(savedOrder)
expect(normalized.find(item => item.preset === 'load_balance')?.enabled).toBe(true)
expect(normalized.find(item => item.preset === 'cache_affinity')?.enabled).toBe(false)
})
it('does not invent a distribution mode when all are disabled', () => {
const items = buildItems().map(item => ({
...item,
enabled: item.mutexGroup ? false : item.enabled,
}))
const normalized = normalizeMutexSelection(items)
expect(normalized.filter(item => item.mutexGroup).every(item => !item.enabled)).toBe(true)
})
it('preserves pool fields that the current dialog does not edit', () => {
const merged = mergePoolAdvancedPatch({
sticky_session_ttl_seconds: 900,
cost_window_seconds: 7200,
cost_limit_per_key_tokens: 100_000,
probing_target_percent: 25,
global_priority: 7,
}, {
score_top_n: 256,
})
expect(merged).toEqual({
sticky_session_ttl_seconds: 900,
cost_window_seconds: 7200,
cost_limit_per_key_tokens: 100_000,
probing_target_percent: 25,
global_priority: 7,
score_top_n: 256,
})
})
})
@@ -2,6 +2,47 @@ export interface SchedulingDialogPresetLike {
mutexGroup: string | null
}
export interface SchedulingDialogSelectablePresetLike extends SchedulingDialogPresetLike {
enabled: boolean
applicable: boolean
}
export function mergePoolAdvancedPatch(
current: unknown,
patch: Record<string, unknown>,
): Record<string, unknown> {
const currentRecord = typeof current === 'object' && current !== null && !Array.isArray(current)
? current as Record<string, unknown>
: {}
return {
...currentRecord,
...patch,
}
}
export function normalizeMutexSelection<T extends SchedulingDialogSelectablePresetLike>(
items: readonly T[],
): T[] {
const next = items.map(item => ({ ...item }))
const groups = new Map<string, number[]>()
next.forEach((item, index) => {
if (!item.mutexGroup) return
const indexes = groups.get(item.mutexGroup) ?? []
indexes.push(index)
groups.set(item.mutexGroup, indexes)
})
for (const indexes of groups.values()) {
const winner = indexes.find(index => next[index].enabled && next[index].applicable)
indexes.forEach((index) => {
next[index].enabled = winner !== undefined && index === winner && next[index].applicable
})
}
return next
}
export function moveStrategyItem<T extends SchedulingDialogPresetLike>(
items: readonly T[],
itemIndex: number,