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

@@ -87,6 +87,8 @@ export interface PoolPresetMeta {
providers: string[] providers: string[]
modes?: PoolPresetModeMeta[] | null modes?: PoolPresetModeMeta[] | null
default_mode?: string | null default_mode?: string | null
mutex_group?: string | null
evidence_hint?: string | null
} }
export interface PoolKeyDetail { export interface PoolKeyDetail {

View File

@@ -21,10 +21,11 @@
<div class="space-y-0.5"> <div class="space-y-0.5">
<div <div
v-for="(item, index) in presetList" v-for="(item, index) in presetList"
v-show="!isMutexFollower(index)"
:key="item.preset" :key="item.preset"
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200" class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200"
:class="[ :class="[
!item.applicable !displayItems[index].applicable
? 'border-border/30 bg-muted/20 opacity-50' ? 'border-border/30 bg-muted/20 opacity-50'
: draggedIndex === index : draggedIndex === index
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]' ? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
@@ -32,25 +33,45 @@
? 'border-primary/30 bg-primary/5' ? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30' : 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
]" ]"
:draggable="item.applicable" :draggable="canDragPreset(index)"
@dragstart="item.applicable && handleDragStart(index, $event)" @dragstart="canDragPreset(index) && handleDragStart(index, $event)"
@dragend="handleDragEnd" @dragend="handleDragEnd"
@dragover.prevent="item.applicable && handleDragOver(index)" @dragover.prevent="canDragPreset(index) && handleDragOver(index)"
@dragleave="handleDragLeave" @dragleave="handleDragLeave"
@drop="item.applicable && handleDrop(index)" @drop="canDragPreset(index) && handleDrop(index)"
> >
<!-- Drag handle --> <!-- Drag handle -->
<div <div
class="p-1 rounded transition-colors shrink-0" 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' ? 'cursor-grab active:cursor-grabbing text-muted-foreground/40 group-hover:text-muted-foreground'
: 'text-muted-foreground/15 cursor-default'" : 'text-muted-foreground/15 cursor-default'"
> >
<GripVertical class="w-4 h-4" /> <GripVertical class="w-4 h-4" />
</div> </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 <Switch
v-else
:model-value="item.enabled" :model-value="item.enabled"
:disabled="!item.applicable" :disabled="!item.applicable"
@update:model-value="(v: boolean) => togglePreset(index, v)" @update:model-value="(v: boolean) => togglePreset(index, v)"
@@ -61,35 +82,41 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
class="text-sm font-medium" class="text-sm font-medium"
:class="!item.applicable ? 'text-muted-foreground' : ''" :class="!displayItems[index].applicable ? 'text-muted-foreground' : ''"
>{{ item.label }}</span> >{{ displayItems[index].label }}</span>
<span <span
v-if="!item.applicable" v-if="!displayItems[index].applicable"
class="text-[10px] text-muted-foreground/60" class="text-[10px] text-muted-foreground/60"
> >
(不适用) (不适用)
</span> </span>
</div> </div>
<p class="text-xs text-muted-foreground mt-0.5"> <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> </p>
<!-- Mode sub-config --> <!-- Mode sub-config -->
<div <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" class="flex gap-0.5 mt-2 p-0.5 bg-muted/40 rounded-md w-fit"
> >
<button <button
v-for="modeOpt in item.modeOptions" v-for="modeOpt in displayItems[index].modeOptions"
:key="modeOpt.value" :key="modeOpt.value"
type="button" type="button"
class="px-2.5 py-1 text-xs font-medium rounded transition-all" class="px-2.5 py-1 text-xs font-medium rounded transition-all"
:class="[ :class="[
item.mode === modeOpt.value displayItems[index].mode === modeOpt.value
? 'bg-primary text-primary-foreground shadow-sm' ? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50' : 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]" ]"
@click="setPresetMode(index, modeOpt.value)" @click="setPresetModeByPreset(displayItems[index].preset, modeOpt.value)"
> >
{{ modeOpt.label }} {{ modeOpt.label }}
</button> </button>
@@ -388,7 +415,12 @@ import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints' import { updateProvider } from '@/api/endpoints'
import { getPoolSchedulingPresets } from '@/api/endpoints/pool' import { getPoolSchedulingPresets } from '@/api/endpoints/pool'
import type { PoolPresetMeta } 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 { interface PresetModeOption {
value: string value: string
@@ -403,6 +435,8 @@ interface PresetListItem {
mode: string | null mode: string | null
modeOptions: PresetModeOption[] modeOptions: PresetModeOption[]
applicable: boolean applicable: boolean
mutexGroup: string | null
evidenceHint: string
} }
const props = defineProps<{ const props = defineProps<{
@@ -415,7 +449,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
saved: [] saved: [provider: ProviderWithEndpointsSummary]
}>() }>()
const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
@@ -423,6 +457,8 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
name: 'lru', name: 'lru',
label: 'LRU 轮转', label: 'LRU 轮转',
description: '最久未使用的 Key 优先', description: '最久未使用的 Key 优先',
mutex_group: 'distribution_mode',
evidence_hint: '依据 LRU 时间戳(最近未使用优先)',
providers: [], providers: [],
modes: null, modes: null,
default_mode: null, default_mode: null,
@@ -431,6 +467,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
name: 'free_team_first', name: 'free_team_first',
label: 'Free/Team 优先', label: 'Free/Team 优先',
description: '优先消耗低档账号(依赖 plan_type', description: '优先消耗低档账号(依赖 plan_type',
evidence_hint: '依据 plan_typeoauth_plan_type 或 upstream_metadata',
providers: ['codex', 'kiro'], providers: ['codex', 'kiro'],
modes: [ modes: [
{ value: 'free_only', label: 'Free' }, { value: 'free_only', label: 'Free' },
@@ -443,6 +480,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
name: 'quota_balanced', name: 'quota_balanced',
label: '额度平均', label: '额度平均',
description: '优先选额度消耗最少的账号', description: '优先选额度消耗最少的账号',
evidence_hint: '依据账号配额使用率;无配额时回退到窗口成本使用',
providers: [], providers: [],
modes: null, modes: null,
default_mode: null, default_mode: null,
@@ -451,6 +489,7 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
name: 'recent_refresh', name: 'recent_refresh',
label: '额度刷新优先', label: '额度刷新优先',
description: '优先选即将刷新额度的账号', description: '优先选即将刷新额度的账号',
evidence_hint: '依据账号额度重置倒计时next_reset / reset_seconds',
providers: ['codex', 'kiro'], providers: ['codex', 'kiro'],
modes: null, modes: null,
default_mode: null, default_mode: null,
@@ -459,6 +498,44 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
name: 'single_account', name: 'single_account',
label: '单号优先', label: '单号优先',
description: '集中使用同一账号(反向 LRU', 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: [], providers: [],
modes: null, modes: null,
default_mode: null, default_mode: null,
@@ -530,6 +607,11 @@ function normalizeMode(value: unknown): string | null {
return normalized || null return normalized || null
} }
function normalizeMutexGroup(value: unknown): string | null {
const normalized = String(value ?? '').trim().toLowerCase()
return normalized || null
}
function normalizePresetDefs(defs: PoolPresetMeta[]): PoolPresetMeta[] { function normalizePresetDefs(defs: PoolPresetMeta[]): PoolPresetMeta[] {
const ordered: PoolPresetMeta[] = [] const ordered: PoolPresetMeta[] = []
const seen = new Set<string>() const seen = new Set<string>()
@@ -556,6 +638,8 @@ function normalizePresetDefs(defs: PoolPresetMeta[]): PoolPresetMeta[] {
providers, providers,
modes: modes && modes.length > 0 ? modes : null, modes: modes && modes.length > 0 ? modes : null,
default_mode: defaultMode, default_mode: defaultMode,
mutex_group: normalizeMutexGroup(raw.mutex_group),
evidence_hint: String(raw.evidence_hint ?? '').trim() || null,
}) })
} }
return ordered return ordered
@@ -622,6 +706,8 @@ function buildDefaultPresetList(): PresetListItem[] {
mode: defaultModeForPreset(def), mode: defaultModeForPreset(def),
modeOptions: getModeOptions(def), modeOptions: getModeOptions(def),
applicable: isApplicablePreset(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), mode: resolveMode(def, ci.mode),
modeOptions: getModeOptions(def), modeOptions: getModeOptions(def),
applicable: isApplicablePreset(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), mode: defaultModeForPreset(def),
modeOptions: getModeOptions(def), modeOptions: getModeOptions(def),
applicable: isApplicablePreset(def), applicable: isApplicablePreset(def),
mutexGroup: normalizeMutexGroup(def.mutex_group),
evidenceHint: String(def.evidence_hint ?? '').trim(),
}) })
} }
return ordered return ordered
@@ -708,6 +798,8 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
mode: null, mode: null,
modeOptions: [], modeOptions: [],
applicable: isApplicablePreset(lruDef), applicable: isApplicablePreset(lruDef),
mutexGroup: normalizeMutexGroup(lruDef.mutex_group),
evidenceHint: String(lruDef.evidence_hint ?? '').trim(),
}) })
seen.add('lru') seen.add('lru')
} }
@@ -725,6 +817,8 @@ function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
mode: resolveMode(def, undefined), mode: resolveMode(def, undefined),
modeOptions: getModeOptions(def), modeOptions: getModeOptions(def),
applicable: isApplicablePreset(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), mode: defaultModeForPreset(def),
modeOptions: getModeOptions(def), modeOptions: getModeOptions(def),
applicable: isApplicablePreset(def), applicable: isApplicablePreset(def),
mutexGroup: normalizeMutexGroup(def.mutex_group),
evidenceHint: String(def.evidence_hint ?? '').trim(),
}) })
} }
return ordered return ordered
} }
function togglePreset(index: number, enabled: boolean) { function normalizeMutexSelection(items: PresetListItem[]): PresetListItem[] {
presetList.value[index].enabled = enabled 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) { function togglePreset(index: number, enabled: boolean) {
presetList.value[index].mode = mode 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) { function handleDragStart(index: number, event: DragEvent) {
draggedIndex.value = index draggedIndex.value = index
if (event.dataTransfer) { if (event.dataTransfer) {
@@ -790,7 +1008,7 @@ watch(() => props.modelValue, async (open) => {
if (!open) return if (!open) return
showAdvanced.value = false showAdvanced.value = false
await ensurePresetDefsLoaded() await ensurePresetDefsLoaded()
presetList.value = loadFromConfig(props.currentConfig) presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig))
const cfg = props.currentConfig const cfg = props.currentConfig
form.value = { form.value = {
@@ -819,6 +1037,7 @@ watch(() => props.modelValue, async (open) => {
async function handleSave() { async function handleSave() {
loading.value = true loading.value = true
try { try {
presetList.value = normalizeMutexSelection(presetList.value)
const schedulingPresets: SchedulingPresetItem[] = presetList.value.map(item => { const schedulingPresets: SchedulingPresetItem[] = presetList.value.map(item => {
const result: SchedulingPresetItem = { const result: SchedulingPresetItem = {
preset: item.preset, preset: item.preset,
@@ -857,9 +1076,9 @@ async function handleSave() {
cli_only_enabled: cf.cli_only_enabled, cli_only_enabled: cf.cli_only_enabled,
} }
} }
await updateProvider(props.providerId, payload) const updatedProvider = await updateProvider(props.providerId, payload)
success('号池调度已保存') success('号池调度已保存')
emit('saved') emit('saved', updatedProvider)
emit('update:modelValue', false) emit('update:modelValue', false)
} catch (err) { } catch (err) {
showError(parseApiError(err)) showError(parseApiError(err))

View File

@@ -189,16 +189,6 @@
v-if="selectedProviderId" v-if="selectedProviderId"
class="h-4 w-px bg-border" class="h-4 w-px bg-border"
/> />
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="添加账号"
@click="showImportDialog = true"
>
<Upload class="w-3.5 h-3.5" />
</Button>
<button <button
v-if="selectedProviderId" v-if="selectedProviderId"
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs" class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
@@ -209,6 +199,20 @@
<span class="font-medium text-foreground/90">{{ poolSchedulingLabel }}</span> <span class="font-medium text-foreground/90">{{ poolSchedulingLabel }}</span>
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" /> <ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
</button> </button>
<div
v-if="selectedProviderId"
class="h-4 w-px bg-border"
/>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="添加账号"
@click="showImportDialog = true"
>
<Upload class="w-3.5 h-3.5" />
</Button>
<Button <Button
v-if="selectedProviderId" v-if="selectedProviderId"
variant="ghost" variant="ghost"
@@ -279,7 +283,7 @@
v-if="keyPage.keys.length > 0" v-if="keyPage.keys.length > 0"
class="hidden xl:block overflow-x-auto" class="hidden xl:block overflow-x-auto"
> >
<Table class="min-w-[1420px]"> <Table class="min-w-[1400px]">
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[320px] font-semibold whitespace-nowrap"> <TableHead class="w-[320px] font-semibold whitespace-nowrap">
@@ -312,14 +316,38 @@
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors" class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
:class="getRowClass(key)" :class="getRowClass(key)"
> >
<TableCell class="py-3"> <TableCell
<div class="max-w-[260px] min-w-0"> class="py-3"
>
<div class="max-w-[320px] min-w-0">
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5 min-w-0">
<span class="text-sm truncate block"> <span class="text-sm truncate block">
{{ key.key_name || '未命名' }} {{ key.key_name || '未命名' }}
</span> </span>
</div> </div>
<div class="flex items-center gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0"> <div class="flex items-center gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0">
<input
v-if="editingPriorityKeyId === key.key_id"
:value="editingPriorityValue"
type="number"
min="1"
max="999999"
autofocus
class="h-[18px] w-10 rounded border border-primary/50 bg-background px-1 text-[10px] tabular-nums text-foreground outline-none ring-1 ring-primary/30 shrink-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
@input="(e) => editingPriorityValue = Number((e.target as HTMLInputElement).value || 0)"
@blur="(e) => finishEditInternalPriority(key, e)"
@keydown.enter.prevent="(e) => finishEditInternalPriority(key, e)"
@keydown.esc.prevent="cancelEditInternalPriority"
>
<button
v-else
type="button"
class="h-4 px-1 rounded text-[10px] tabular-nums text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors shrink-0"
title="点击编辑优先级"
@click="startEditInternalPriority(key)"
>
P{{ key.internal_priority ?? 50 }}
</button>
<Button <Button
v-if="key.auth_type === 'oauth'" v-if="key.auth_type === 'oauth'"
variant="ghost" variant="ghost"
@@ -622,6 +650,14 @@
</span> </span>
</div> </div>
<div class="flex items-center gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0"> <div class="flex items-center gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0">
<button
type="button"
class="h-4 px-1 rounded text-[10px] tabular-nums text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors shrink-0"
title="点击修改优先级"
@click="quickEditInternalPriority(key)"
>
P{{ key.internal_priority ?? 50 }}
</button>
<Button <Button
v-if="key.auth_type === 'oauth'" v-if="key.auth_type === 'oauth'"
variant="ghost" variant="ghost"
@@ -940,7 +976,7 @@
:provider-type="selectedProviderType" :provider-type="selectedProviderType"
:current-config="selectedProviderConfig" :current-config="selectedProviderConfig"
:current-claude-config="selectedProviderClaudeConfig" :current-claude-config="selectedProviderClaudeConfig"
@saved="loadOverview" @saved="handleSchedulingSaved"
/> />
<KeyFormDialog <KeyFormDialog
v-if="selectedProviderId" v-if="selectedProviderId"
@@ -1080,7 +1116,8 @@ async function loadOverview() {
if (!selectedStillExists) { if (!selectedStillExists) {
if (enabledProviders.length > 0) { if (enabledProviders.length > 0) {
await selectProvider(enabledProviders[0].provider_id) // Do not block overview loading on key list fetch; keys area has its own loader.
void selectProvider(enabledProviders[0].provider_id)
} else { } else {
selectedProviderId.value = null selectedProviderId.value = null
selectedProviderData.value = null selectedProviderData.value = null
@@ -1096,6 +1133,15 @@ async function loadOverview() {
} }
} }
async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) {
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
if (selectedProviderId.value && updatedProvider.id === selectedProviderId.value) {
selectedProviderData.value = updatedProvider
}
showSchedulingDialog.value = false
await loadOverview()
}
// --- Provider Selection --- // --- Provider Selection ---
const selectedProviderId = ref<string | null>(null) const selectedProviderId = ref<string | null>(null)
const selectedProviderData = ref<ProviderWithEndpointsSummary | null>(null) const selectedProviderData = ref<ProviderWithEndpointsSummary | null>(null)
@@ -1160,11 +1206,10 @@ const poolSchedulingLabel = computed(() => {
// New format: object list with { preset, enabled } // New format: object list with { preset, enabled }
const first = presets[0] const first = presets[0]
if (typeof first === 'object' && first !== null && 'preset' in first) { if (typeof first === 'object' && first !== null && 'preset' in first) {
const enabledLabels = (presets as Array<{ preset: string; enabled?: boolean }>) const enabledCount = (presets as Array<{ preset: string; enabled?: boolean }>)
.filter(p => p.enabled !== false) .filter(p => p.enabled !== false)
.map(p => presetLabels[normalizePresetName(p.preset)]) .length
.filter(Boolean) return enabledCount > 0 ? `${enabledCount} 维度` : '无启用维度'
return enabledLabels.length > 0 ? enabledLabels.join('+') : '无启用维度'
} }
// Legacy string list format // Legacy string list format
@@ -1172,7 +1217,7 @@ const poolSchedulingLabel = computed(() => {
const labels = (presets as string[]) const labels = (presets as string[])
.map(p => presetLabels[normalizePresetName(p)]) .map(p => presetLabels[normalizePresetName(p)])
.filter(Boolean) .filter(Boolean)
if (labels.length > 0) return labels.join('+') if (labels.length > 0) return `${labels.length} 维度`
} }
} }
@@ -1259,6 +1304,9 @@ const proxyDesktopPopoverOpenKeyId = ref<string | null>(null)
const proxyMobilePopoverOpenKeyId = ref<string | null>(null) const proxyMobilePopoverOpenKeyId = ref<string | null>(null)
const deletingKeyId = ref<string | null>(null) const deletingKeyId = ref<string | null>(null)
const togglingKeyId = ref<string | null>(null) const togglingKeyId = ref<string | null>(null)
const editingPriorityKeyId = ref<string | null>(null)
const editingPriorityValue = ref<number>(0)
const prioritySavingKeyId = ref<string | null>(null)
const keyPermissionsDialogOpen = ref(false) const keyPermissionsDialogOpen = ref(false)
const keyFormDialogOpen = ref(false) const keyFormDialogOpen = ref(false)
@@ -1510,6 +1558,66 @@ const editingKey = computed<EndpointAPIKey | null>(() => {
return toEndpointApiKey(editingKeyDetail.value) return toEndpointApiKey(editingKeyDetail.value)
}) })
function sortCurrentPageKeysByPriority() {
keyPage.value.keys = [...keyPage.value.keys].sort((a, b) => {
const pa = Number(a.internal_priority ?? 50)
const pb = Number(b.internal_priority ?? 50)
if (pa !== pb) return pa - pb
return (a.created_at || '').localeCompare(b.created_at || '')
})
}
function startEditInternalPriority(key: PoolKeyDetail) {
editingPriorityKeyId.value = key.key_id
editingPriorityValue.value = Number(key.internal_priority ?? 50)
}
function cancelEditInternalPriority() {
editingPriorityKeyId.value = null
editingPriorityValue.value = 0
}
async function applyInternalPriority(key: PoolKeyDetail, nextPriority: number) {
const normalized = Math.max(1, Math.min(999999, Math.floor(nextPriority)))
if (Number(key.internal_priority ?? 50) === normalized) return
prioritySavingKeyId.value = key.key_id
try {
await updateProviderKey(key.key_id, { internal_priority: normalized })
key.internal_priority = normalized
sortCurrentPageKeysByPriority()
success('账号优先级已更新')
} catch (err) {
showError(parseApiError(err, '更新优先级失败'))
} finally {
prioritySavingKeyId.value = null
}
}
async function quickEditInternalPriority(key: PoolKeyDetail) {
const raw = window.prompt('设置账号优先级1-999999数字越小越优先', String(key.internal_priority ?? 50))
if (raw === null) return
const parsed = Number(raw)
if (!Number.isFinite(parsed)) {
showWarning('请输入有效数字')
return
}
await applyInternalPriority(key, parsed)
}
async function finishEditInternalPriority(
key: PoolKeyDetail,
event: FocusEvent | KeyboardEvent,
) {
if (prioritySavingKeyId.value) return
const target = event.target as HTMLInputElement | null
const raw = target?.value ?? String(editingPriorityValue.value)
const parsed = Number(raw)
const nextPriority = Number.isFinite(parsed) ? parsed : Number(key.internal_priority ?? 50)
cancelEditInternalPriority()
await applyInternalPriority(key, nextPriority)
}
function handleEditKey(key: PoolKeyDetail) { function handleEditKey(key: PoolKeyDetail) {
editingKeyDetail.value = key editingKeyDetail.value = key
if (key.auth_type === 'oauth') { if (key.auth_type === 'oauth') {
@@ -1790,6 +1898,13 @@ const COOLDOWN_REASON_MAP: Record<string, string> = {
auth_failed_401: '401 认证失败', auth_failed_401: '401 认证失败',
payment_required_402: '402 欠费', payment_required_402: '402 欠费',
server_error_500: '500 错误', server_error_500: '500 错误',
request_timeout_408: '408 超时',
conflict_409: '409 冲突',
locked_423: '423 锁定',
too_early_425: '425 Too Early',
bad_gateway_502: '502 网关错误',
service_unavailable_503: '503 服务不可用',
gateway_timeout_504: '504 网关超时',
} }
function formatCooldownReason(reason: string): string { function formatCooldownReason(reason: string): string {
@@ -2150,9 +2265,10 @@ function formatRelativeTime(isoStr: string): string {
} }
// --- Init --- // --- Init ---
onMounted(async () => { onMounted(() => {
startCountdownTimer() startCountdownTimer()
await Promise.all([loadSchedulingPresetMetas(), loadOverview()]) void loadSchedulingPresetMetas()
void loadOverview()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {

View File

@@ -160,6 +160,13 @@ _COOLDOWN_REASON_LABELS: dict[str, str] = {
"auth_failed_401": "401 认证失败", "auth_failed_401": "401 认证失败",
"payment_required_402": "402 欠费", "payment_required_402": "402 欠费",
"server_error_500": "500 错误", "server_error_500": "500 错误",
"request_timeout_408": "408 超时",
"conflict_409": "409 冲突",
"locked_423": "423 锁定",
"too_early_425": "425 Too Early",
"bad_gateway_502": "502 网关错误",
"service_unavailable_503": "503 服务不可用",
"gateway_timeout_504": "504 网关超时",
} }
@@ -607,6 +614,8 @@ class AdminListSchedulingPresetsAdapter(AdminApiAdapter):
providers=[], providers=[],
modes=None, modes=None,
default_mode=None, default_mode=None,
mutex_group="distribution_mode",
evidence_hint="依据 LRU 时间戳(最近未使用优先)",
) )
] ]
@@ -625,6 +634,8 @@ class AdminListSchedulingPresetsAdapter(AdminApiAdapter):
providers=list(meta.providers), providers=list(meta.providers),
modes=modes, modes=modes,
default_mode=meta.default_mode, default_mode=meta.default_mode,
mutex_group=meta.mutex_group,
evidence_hint=meta.evidence_hint,
) )
) )
return items return items
@@ -766,7 +777,14 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
# Limit scan range to avoid loading the entire table into memory. # Limit scan range to avoid loading the entire table into memory.
if self.status == "cooldown": if self.status == "cooldown":
_max_scan = 2000 _max_scan = 2000
all_keys = q.order_by(ProviderAPIKey.created_at.desc()).limit(_max_scan).all() all_keys = (
q.order_by(
ProviderAPIKey.internal_priority.asc(),
ProviderAPIKey.created_at.asc(),
)
.limit(_max_scan)
.all()
)
key_ids = [str(k.id) for k in all_keys] key_ids = [str(k.id) for k in all_keys]
cooldowns = await pool_redis.batch_get_cooldowns(pid, key_ids) if key_ids else {} cooldowns = await pool_redis.batch_get_cooldowns(pid, key_ids) if key_ids else {}
all_keys = [k for k in all_keys if cooldowns.get(str(k.id)) is not None] all_keys = [k for k in all_keys if cooldowns.get(str(k.id)) is not None]
@@ -777,7 +795,10 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
total = int(q.with_entities(func.count(ProviderAPIKey.id)).scalar() or 0) total = int(q.with_entities(func.count(ProviderAPIKey.id)).scalar() or 0)
offset = (self.page - 1) * self.page_size offset = (self.page - 1) * self.page_size
keys = ( keys = (
q.order_by(ProviderAPIKey.created_at.desc()) q.order_by(
ProviderAPIKey.internal_priority.asc(),
ProviderAPIKey.created_at.asc(),
)
.offset(offset) .offset(offset)
.limit(self.page_size) .limit(self.page_size)
.all() .all()
@@ -785,6 +806,9 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
# Batch fetch Redis state (parallel where possible) # Batch fetch Redis state (parallel where possible)
key_ids = [str(k.id) for k in keys] key_ids = [str(k.id) for k in keys]
# Sticky session counts are no longer fetched from Redis to reduce
# round-trips; the field is kept at 0 for schema compatibility.
sticky_counts: dict[str, int] = {kid: 0 for kid in key_ids}
if key_ids: if key_ids:
_lru_coro = ( _lru_coro = (
pool_redis.get_lru_scores(pid, key_ids) pool_redis.get_lru_scores(pid, key_ids)
@@ -807,18 +831,15 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
lru_scores, lru_scores,
latency_avgs, latency_avgs,
cost_totals, cost_totals,
sticky_counts,
) = await asyncio.gather( ) = await asyncio.gather(
pool_redis.batch_get_cooldowns(pid, key_ids), pool_redis.batch_get_cooldowns(pid, key_ids),
pool_redis.batch_get_cooldown_ttls(pid, key_ids), pool_redis.batch_get_cooldown_ttls(pid, key_ids),
_lru_coro, _lru_coro,
_latency_coro, _latency_coro,
_cost_coro, _cost_coro,
pool_redis.batch_get_key_sticky_counts(pid, key_ids),
) )
else: else:
cooldowns, cooldown_ttls, lru_scores, latency_avgs, cost_totals, sticky_counts = ( cooldowns, cooldown_ttls, lru_scores, latency_avgs, cost_totals = (
{},
{}, {},
{}, {},
{}, {},

View File

@@ -46,6 +46,8 @@ class PresetDimensionMetaResponse(BaseModel):
providers: list[str] = Field(default_factory=list) providers: list[str] = Field(default_factory=list)
modes: list[PresetModeMetaResponse] | None = None modes: list[PresetModeMetaResponse] | None = None
default_mode: str | None = None default_mode: str | None = None
mutex_group: str | None = None
evidence_hint: str | None = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -5,7 +5,11 @@ Importing this package registers all built-in preset dimensions.
from __future__ import annotations from __future__ import annotations
from . import cost_first # noqa: F401
from . import free_team_first # noqa: F401 from . import free_team_first # noqa: F401
from . import health_first # noqa: F401
from . import latency_first # noqa: F401
from . import priority_first # noqa: F401
from . import quota_balanced # noqa: F401 from . import quota_balanced # noqa: F401
from . import recent_refresh # noqa: F401 from . import recent_refresh # noqa: F401
from . import single_account # noqa: F401 from . import single_account # noqa: F401

View File

@@ -187,6 +187,37 @@ def extract_usage_ratio(key_obj: Any) -> float | None:
return None return None
def extract_internal_priority(key_obj: Any) -> int:
raw = getattr(key_obj, "internal_priority", None)
parsed = safe_float(raw)
if parsed is None:
return 999999
return max(0, int(parsed))
def extract_health_score(key_obj: Any) -> float | None:
direct = safe_float(getattr(key_obj, "health_score", None))
if direct is not None:
return max(0.0, min(direct, 1.0))
health_by_format = getattr(key_obj, "health_by_format", None)
if not isinstance(health_by_format, dict) or not health_by_format:
return None
scores: list[float] = []
for payload in health_by_format.values():
if not isinstance(payload, dict):
continue
score = safe_float(payload.get("health_score"))
if score is None:
continue
scores.append(max(0.0, min(score, 1.0)))
if not scores:
return None
return min(scores)
def plan_priority_score(plan_type: str | None, mode: str | None = None) -> float: def plan_priority_score(plan_type: str | None, mode: str | None = None) -> float:
"""Score a key based on plan type and free_team_first mode.""" """Score a key based on plan type and free_team_first mode."""
@@ -215,6 +246,8 @@ def plan_priority_score(plan_type: str | None, mode: str | None = None) -> float
__all__ = [ __all__ = [
"extract_health_score",
"extract_internal_priority",
"extract_plan_type", "extract_plan_type",
"extract_reset_seconds", "extract_reset_seconds",
"extract_usage_ratio", "extract_usage_ratio",

View File

@@ -0,0 +1,62 @@
"""cost_first preset dimension."""
from __future__ import annotations
from typing import Any
from ._helpers import extract_usage_ratio, rank_ascending, safe_float
from .registry import PresetDimensionBase, register_preset_dimension
class CostFirstDimension(PresetDimensionBase):
@property
def name(self) -> str:
return "cost_first"
@property
def label(self) -> str:
return "成本优先"
@property
def description(self) -> str:
return "优先选择窗口消耗更低的账号"
@property
def evidence_hint(self) -> str | None:
return "依据窗口成本/Token 用量,缺失时回退配额使用率"
def compute_metric(
self,
*,
key_id: str,
all_key_ids: list[str],
keys_by_id: dict[str, Any],
lru_scores: dict[str, Any],
context: dict[str, Any],
mode: str | None,
) -> float:
cost_totals = context.get("cost_totals")
if not isinstance(cost_totals, dict):
cost_totals = {}
cost_limit = safe_float(context.get("cost_limit_per_key_tokens"))
cost_scores: dict[str, float] = {}
for kid in all_key_ids:
used = safe_float(cost_totals.get(kid))
if used is not None and used >= 0:
if cost_limit is not None and cost_limit > 0:
cost_scores[kid] = max(0.0, min(used / cost_limit, 1.0))
else:
cost_scores[kid] = min(1.0, used / (used + 10000.0))
continue
usage_ratio = extract_usage_ratio(keys_by_id.get(kid))
if usage_ratio is not None:
cost_scores[kid] = usage_ratio
if not cost_scores:
return rank_ascending(key_id, lru_scores, all_key_ids)
return rank_ascending(key_id, cost_scores, all_key_ids)
register_preset_dimension(CostFirstDimension())

View File

@@ -21,6 +21,10 @@ class FreeTeamFirstDimension(PresetDimensionBase):
def description(self) -> str: def description(self) -> str:
return "优先消耗低档账号(依赖 plan_type" return "优先消耗低档账号(依赖 plan_type"
@property
def evidence_hint(self) -> str | None:
return "依据 plan_typeoauth_plan_type 或 upstream_metadata"
@property @property
def providers(self) -> tuple[str, ...]: def providers(self) -> tuple[str, ...]:
return ("codex", "kiro") return ("codex", "kiro")
@@ -40,12 +44,15 @@ class FreeTeamFirstDimension(PresetDimensionBase):
all_key_ids: list[str], all_key_ids: list[str],
keys_by_id: dict[str, Any], keys_by_id: dict[str, Any],
lru_scores: dict[str, Any], lru_scores: dict[str, Any],
context: dict[str, Any],
mode: str | None, mode: str | None,
) -> float: ) -> float:
plan_scores = { plan_scores = {
kid: plan_priority_score(extract_plan_type(keys_by_id.get(kid)), mode) kid: plan_priority_score(extract_plan_type(keys_by_id.get(kid)), mode)
for kid in all_key_ids for kid in all_key_ids
} }
if len(set(plan_scores.values())) <= 1:
return rank_ascending(key_id, lru_scores, all_key_ids)
return rank_ascending(key_id, plan_scores, all_key_ids) return rank_ascending(key_id, plan_scores, all_key_ids)

View File

@@ -0,0 +1,57 @@
"""health_first preset dimension."""
from __future__ import annotations
from typing import Any
from ._helpers import extract_health_score, rank_ascending, safe_float
from .registry import PresetDimensionBase, register_preset_dimension
class HealthFirstDimension(PresetDimensionBase):
@property
def name(self) -> str:
return "health_first"
@property
def label(self) -> str:
return "健康优先"
@property
def description(self) -> str:
return "优先选择健康分更高、失败更少的账号"
@property
def evidence_hint(self) -> str | None:
return "依据 health_by_format 聚合分(含熔断/失败衰减)"
def compute_metric(
self,
*,
key_id: str,
all_key_ids: list[str],
keys_by_id: dict[str, Any],
lru_scores: dict[str, Any],
context: dict[str, Any],
mode: str | None,
) -> float:
health_scores_ctx = context.get("health_scores")
if not isinstance(health_scores_ctx, dict):
health_scores_ctx = {}
penalty_scores: dict[str, float] = {}
for kid in all_key_ids:
score = safe_float(health_scores_ctx.get(kid))
if score is None:
score = extract_health_score(keys_by_id.get(kid))
if score is None:
continue
normalized = max(0.0, min(score, 1.0))
penalty_scores[kid] = 1.0 - normalized
if not penalty_scores:
return rank_ascending(key_id, lru_scores, all_key_ids)
return rank_ascending(key_id, penalty_scores, all_key_ids)
register_preset_dimension(HealthFirstDimension())

View File

@@ -0,0 +1,54 @@
"""latency_first preset dimension."""
from __future__ import annotations
from typing import Any
from ._helpers import rank_ascending, safe_float
from .registry import PresetDimensionBase, register_preset_dimension
class LatencyFirstDimension(PresetDimensionBase):
@property
def name(self) -> str:
return "latency_first"
@property
def label(self) -> str:
return "延迟优先"
@property
def description(self) -> str:
return "优先选择最近延迟更低的账号"
@property
def evidence_hint(self) -> str | None:
return "依据号池延迟窗口均值latency_window_seconds"
def compute_metric(
self,
*,
key_id: str,
all_key_ids: list[str],
keys_by_id: dict[str, Any],
lru_scores: dict[str, Any],
context: dict[str, Any],
mode: str | None,
) -> float:
latency_avgs = context.get("latency_avgs")
if not isinstance(latency_avgs, dict):
latency_avgs = {}
latency_scores: dict[str, float] = {}
for kid in all_key_ids:
latency = safe_float(latency_avgs.get(kid))
if latency is None or latency < 0:
continue
latency_scores[kid] = latency
if not latency_scores:
return rank_ascending(key_id, lru_scores, all_key_ids)
return rank_ascending(key_id, latency_scores, all_key_ids)
register_preset_dimension(LatencyFirstDimension())

View File

@@ -0,0 +1,46 @@
"""priority_first preset dimension."""
from __future__ import annotations
from typing import Any
from ._helpers import extract_internal_priority, rank_ascending
from .registry import PresetDimensionBase, register_preset_dimension
class PriorityFirstDimension(PresetDimensionBase):
@property
def name(self) -> str:
return "priority_first"
@property
def label(self) -> str:
return "优先级优先"
@property
def description(self) -> str:
return "按账号优先级顺序调度(数字越小越优先)"
@property
def evidence_hint(self) -> str | None:
return "依据 internal_priority支持拖拽/手工编辑)"
def compute_metric(
self,
*,
key_id: str,
all_key_ids: list[str],
keys_by_id: dict[str, Any],
lru_scores: dict[str, Any],
context: dict[str, Any],
mode: str | None,
) -> float:
priority_scores = {
kid: float(extract_internal_priority(keys_by_id.get(kid))) for kid in all_key_ids
}
if len(set(priority_scores.values())) <= 1:
return rank_ascending(key_id, lru_scores, all_key_ids)
return rank_ascending(key_id, priority_scores, all_key_ids)
register_preset_dimension(PriorityFirstDimension())

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from ._helpers import extract_usage_ratio, rank_ascending from ._helpers import extract_usage_ratio, rank_ascending, safe_float
from .registry import PresetDimensionBase, register_preset_dimension from .registry import PresetDimensionBase, register_preset_dimension
@@ -21,6 +21,10 @@ class QuotaBalancedDimension(PresetDimensionBase):
def description(self) -> str: def description(self) -> str:
return "优先选额度消耗最少的账号" return "优先选额度消耗最少的账号"
@property
def evidence_hint(self) -> str | None:
return "依据账号配额使用率;无配额时回退到窗口成本使用"
def compute_metric( def compute_metric(
self, self,
*, *,
@@ -28,13 +32,29 @@ class QuotaBalancedDimension(PresetDimensionBase):
all_key_ids: list[str], all_key_ids: list[str],
keys_by_id: dict[str, Any], keys_by_id: dict[str, Any],
lru_scores: dict[str, Any], lru_scores: dict[str, Any],
context: dict[str, Any],
mode: str | None, mode: str | None,
) -> float: ) -> float:
usage_scores: dict[str, float] = {} usage_scores: dict[str, float] = {}
cost_totals = context.get("cost_totals")
if not isinstance(cost_totals, dict):
cost_totals = {}
cost_limit = safe_float(context.get("cost_limit_per_key_tokens"))
for kid in all_key_ids: for kid in all_key_ids:
usage_ratio = extract_usage_ratio(keys_by_id.get(kid)) key_obj = keys_by_id.get(kid)
usage_ratio = extract_usage_ratio(key_obj)
if usage_ratio is None:
used = safe_float(cost_totals.get(kid))
if used is not None and used >= 0:
if cost_limit is not None and cost_limit > 0:
usage_ratio = max(0.0, min(used / cost_limit, 1.0))
else:
# 无明确上限时用 log 归一化,确保维度仍有区分能力。
usage_ratio = min(1.0, used / (used + 10000.0))
if usage_ratio is not None: if usage_ratio is not None:
usage_scores[kid] = usage_ratio usage_scores[kid] = usage_ratio
if not usage_scores:
return rank_ascending(key_id, lru_scores, all_key_ids)
return rank_ascending(key_id, usage_scores, all_key_ids) return rank_ascending(key_id, usage_scores, all_key_ids)

View File

@@ -25,6 +25,10 @@ class RecentRefreshDimension(PresetDimensionBase):
def providers(self) -> tuple[str, ...]: def providers(self) -> tuple[str, ...]:
return ("codex", "kiro") return ("codex", "kiro")
@property
def evidence_hint(self) -> str | None:
return "依据账号额度重置倒计时next_reset / reset_seconds"
def compute_metric( def compute_metric(
self, self,
*, *,
@@ -32,6 +36,7 @@ class RecentRefreshDimension(PresetDimensionBase):
all_key_ids: list[str], all_key_ids: list[str],
keys_by_id: dict[str, Any], keys_by_id: dict[str, Any],
lru_scores: dict[str, Any], lru_scores: dict[str, Any],
context: dict[str, Any],
mode: str | None, mode: str | None,
) -> float: ) -> float:
reset_scores: dict[str, float] = {} reset_scores: dict[str, float] = {}
@@ -39,6 +44,8 @@ class RecentRefreshDimension(PresetDimensionBase):
reset_seconds = extract_reset_seconds(keys_by_id.get(kid)) reset_seconds = extract_reset_seconds(keys_by_id.get(kid))
if reset_seconds is not None: if reset_seconds is not None:
reset_scores[kid] = reset_seconds reset_scores[kid] = reset_seconds
if not reset_scores:
return rank_ascending(key_id, lru_scores, all_key_ids)
return rank_ascending(key_id, reset_scores, all_key_ids) return rank_ascending(key_id, reset_scores, all_key_ids)

View File

@@ -18,6 +18,8 @@ class PresetDimensionMeta:
providers: tuple[str, ...] providers: tuple[str, ...]
modes: tuple[str, ...] | None modes: tuple[str, ...] | None
default_mode: str | None default_mode: str | None
mutex_group: str | None
evidence_hint: str | None
class PresetDimensionBase(ABC): class PresetDimensionBase(ABC):
@@ -59,6 +61,21 @@ class PresetDimensionBase(ABC):
return None return None
@property
def mutex_group(self) -> str | None:
"""Optional mutual-exclusion group key.
Presets in the same group are expected to be mutually exclusive in UI.
"""
return None
@property
def evidence_hint(self) -> str | None:
"""Human-readable hint about which data this preset uses."""
return None
@abstractmethod @abstractmethod
def compute_metric( def compute_metric(
self, self,
@@ -67,6 +84,7 @@ class PresetDimensionBase(ABC):
all_key_ids: list[str], all_key_ids: list[str],
keys_by_id: dict[str, Any], keys_by_id: dict[str, Any],
lru_scores: dict[str, Any], lru_scores: dict[str, Any],
context: dict[str, Any],
mode: str | None, mode: str | None,
) -> float: ) -> float:
"""Compute normalized metric in [0, 1], lower is better.""" """Compute normalized metric in [0, 1], lower is better."""
@@ -142,6 +160,16 @@ def register_preset_dimension(dim: PresetDimensionBase) -> None:
return default_mode return default_mode
return modes[0] return modes[0]
@property
def mutex_group(self) -> str | None:
raw = _normalize_name(self._wrapped.mutex_group)
return raw or None
@property
def evidence_hint(self) -> str | None:
raw = str(self._wrapped.evidence_hint or "").strip()
return raw or None
def compute_metric( def compute_metric(
self, self,
*, *,
@@ -149,6 +177,7 @@ def register_preset_dimension(dim: PresetDimensionBase) -> None:
all_key_ids: list[str], all_key_ids: list[str],
keys_by_id: dict[str, Any], keys_by_id: dict[str, Any],
lru_scores: dict[str, Any], lru_scores: dict[str, Any],
context: dict[str, Any],
mode: str | None, mode: str | None,
) -> float: ) -> float:
return self._wrapped.compute_metric( return self._wrapped.compute_metric(
@@ -156,6 +185,7 @@ def register_preset_dimension(dim: PresetDimensionBase) -> None:
all_key_ids=all_key_ids, all_key_ids=all_key_ids,
keys_by_id=keys_by_id, keys_by_id=keys_by_id,
lru_scores=lru_scores, lru_scores=lru_scores,
context=context,
mode=mode, mode=mode,
) )
@@ -201,6 +231,8 @@ def get_preset_dimension_metas() -> list[PresetDimensionMeta]:
providers=dim.providers, providers=dim.providers,
modes=dim.modes, modes=dim.modes,
default_mode=dim.default_mode, default_mode=dim.default_mode,
mutex_group=dim.mutex_group,
evidence_hint=dim.evidence_hint,
) )
) )
return metas return metas

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from ._helpers import rank_descending from ._helpers import extract_internal_priority, rank_ascending, rank_descending
from .registry import PresetDimensionBase, register_preset_dimension from .registry import PresetDimensionBase, register_preset_dimension
@@ -21,6 +21,14 @@ class SingleAccountDimension(PresetDimensionBase):
def description(self) -> str: def description(self) -> str:
return "集中使用同一账号(反向 LRU" return "集中使用同一账号(反向 LRU"
@property
def mutex_group(self) -> str | None:
return "distribution_mode"
@property
def evidence_hint(self) -> str | None:
return "先按账号优先级internal_priority同级再按反向 LRU 集中"
def compute_metric( def compute_metric(
self, self,
*, *,
@@ -28,9 +36,16 @@ class SingleAccountDimension(PresetDimensionBase):
all_key_ids: list[str], all_key_ids: list[str],
keys_by_id: dict[str, Any], keys_by_id: dict[str, Any],
lru_scores: dict[str, Any], lru_scores: dict[str, Any],
context: dict[str, Any],
mode: str | None, mode: str | None,
) -> float: ) -> float:
return rank_descending(key_id, lru_scores, all_key_ids) priority_scores = {
kid: float(extract_internal_priority(keys_by_id.get(kid))) for kid in all_key_ids
}
priority_rank = rank_ascending(key_id, priority_scores, all_key_ids)
lru_concentrate_rank = rank_descending(key_id, lru_scores, all_key_ids)
# 强化“单号优先”的可控性:优先级优先,反向 LRU 作为次级聚合。
return max(0.0, min(priority_rank * 0.75 + lru_concentrate_rank * 0.25, 1.0))
register_preset_dimension(SingleAccountDimension()) register_preset_dimension(SingleAccountDimension())

View File

@@ -2,15 +2,16 @@
Maps upstream HTTP status codes to pool-level actions: Maps upstream HTTP status codes to pool-level actions:
| Code | Action | | Code | Action |
|------|--------------------------------------------------------------| |--------------|------------------------------------------------------------|
| 401 | Invalidate OAuth token cache -> attempt refresh -> disable | | 401 | Invalidate OAuth token cache + short cooldown |
| 402 | Auto-disable key (payment issue) | | 402 | Long cooldown (payment issue) |
| 403 | Auto-disable key (suspended/banned) | | 403 | Graded cooldown: severe (suspended/banned) 1h, else 300s+ |
| 400 | Check body for "organization has been disabled" -> disable | | 400 | Check body for "organization has been disabled" -> cooldown |
| 429 | Set cooldown (retry-after header or config default) | | 429 | Cooldown (retry-after or rate_limit_cooldown_seconds) |
| 529 | Set cooldown (config default) | | 529 | Cooldown (overload_cooldown_seconds) |
| * | Check unschedulable_rules keyword matching | | * | Check unschedulable_rules keyword matching |
| 408/5xx/etc | Transient cooldown (overload_cooldown_seconds) |
""" """
from __future__ import annotations from __future__ import annotations
@@ -32,6 +33,26 @@ _ACCOUNT_DISABLE_PATTERNS = (
"account_disabled", "account_disabled",
) )
# 需要更长冷却的账号异常语义403 body 关键字)。
_FORBIDDEN_ACCOUNT_PATTERNS = (
"account suspended",
"account banned",
"subscription inactive",
"suspended",
"banned",
)
_TRANSIENT_STATUS_COOLDOWN_REASON: dict[int, str] = {
408: "request_timeout_408",
409: "conflict_409",
423: "locked_423",
425: "too_early_425",
500: "server_error_500",
502: "bad_gateway_502",
503: "service_unavailable_503",
504: "gateway_timeout_504",
}
def _parse_retry_after(headers: dict[str, str] | None) -> int | None: def _parse_retry_after(headers: dict[str, str] | None) -> int | None:
"""Extract retry-after seconds from response headers.""" """Extract retry-after seconds from response headers."""
@@ -65,6 +86,22 @@ def _extract_error_message(error_body: str | None) -> str:
return error_body[:500] return error_body[:500]
def _resolve_transient_cooldown_ttl(
*,
status_code: int,
retry_after_seconds: int | None,
config: PoolConfig,
) -> int:
"""Resolve cooldown ttl for transient upstream status codes."""
if status_code in (429, 503):
if retry_after_seconds is not None:
return retry_after_seconds
if status_code == 429:
return config.rate_limit_cooldown_seconds
# 408/409/423/425/5xx: 统一走短时过载冷却,避免雪崩重试。
return config.overload_cooldown_seconds
async def apply_health_policy( async def apply_health_policy(
*, *,
provider_id: str, provider_id: str,
@@ -133,11 +170,15 @@ async def _apply(
# --- 403 Forbidden ------------------------------------------------------- # --- 403 Forbidden -------------------------------------------------------
if status_code == 403: if status_code == 403:
await redis_ops.set_cooldown(provider_id, key_id, "forbidden_403", ttl=3600) error_lower = error_msg.lower()
severe = any(pattern in error_lower for pattern in _FORBIDDEN_ACCOUNT_PATTERNS)
ttl = 3600 if severe else max(config.rate_limit_cooldown_seconds, 300)
await redis_ops.set_cooldown(provider_id, key_id, "forbidden_403", ttl=ttl)
logger.warning( logger.warning(
"Pool[{}]: key {} got 403 (forbidden/suspended), cooldown 1h", "Pool[{}]: key {} got 403 (forbidden), cooldown {}s",
provider_id[:8], provider_id[:8],
key_id[:8], key_id[:8],
ttl,
) )
return return
@@ -160,7 +201,11 @@ async def _apply(
# --- 429 Rate Limited ---------------------------------------------------- # --- 429 Rate Limited ----------------------------------------------------
if status_code == 429: if status_code == 429:
retry_after = _parse_retry_after(response_headers) retry_after = _parse_retry_after(response_headers)
ttl = retry_after or config.rate_limit_cooldown_seconds ttl = _resolve_transient_cooldown_ttl(
status_code=status_code,
retry_after_seconds=retry_after,
config=config,
)
await redis_ops.set_cooldown(provider_id, key_id, "rate_limited_429", ttl=ttl) await redis_ops.set_cooldown(provider_id, key_id, "rate_limited_429", ttl=ttl)
logger.info( logger.info(
"Pool[{}]: key {} got 429, cooldown {}s", "Pool[{}]: key {} got 429, cooldown {}s",
@@ -203,6 +248,26 @@ async def _apply(
) )
return return
# --- Transient status bucket (408/409/423/425/5xx) ----------------------
reason = _TRANSIENT_STATUS_COOLDOWN_REASON.get(status_code)
if reason:
retry_after = _parse_retry_after(response_headers)
ttl = _resolve_transient_cooldown_ttl(
status_code=status_code,
retry_after_seconds=retry_after,
config=config,
)
await redis_ops.set_cooldown(provider_id, key_id, reason, ttl=ttl)
logger.info(
"Pool[{}]: key {} got {}, cooldown {}s ({})",
provider_id[:8],
key_id[:8],
status_code,
ttl,
reason,
)
return
async def apply_stream_timeout_policy( async def apply_stream_timeout_policy(
*, *,

View File

@@ -124,7 +124,10 @@ class PoolManager:
_cooldown_coro = redis_ops.batch_get_cooldowns(pid, all_key_ids, include_ttl=True) _cooldown_coro = redis_ops.batch_get_cooldowns(pid, all_key_ids, include_ttl=True)
_cost_coro = ( _cost_coro = (
redis_ops.batch_get_cost_totals(pid, all_key_ids, self.config.cost_window_seconds) redis_ops.batch_get_cost_totals(pid, all_key_ids, self.config.cost_window_seconds)
if self.config.cost_limit_per_key_tokens is not None if (
self.config.cost_limit_per_key_tokens is not None
or self.config.scheduling_mode == "multi_score"
)
else None else None
) )
_lru_coro = redis_ops.get_lru_scores(pid, all_key_ids) if self.config.lru_enabled else None _lru_coro = redis_ops.get_lru_scores(pid, all_key_ids) if self.config.lru_enabled else None
@@ -170,12 +173,12 @@ class PoolManager:
if _cost_idx >= 0: if _cost_idx >= 0:
cost_totals = gathered[_cost_idx] cost_totals = gathered[_cost_idx]
limit = self.config.cost_limit_per_key_tokens limit = self.config.cost_limit_per_key_tokens
assert limit is not None # guarded by _cost_idx >= 0 if limit is not None:
for kid, total in cost_totals.items(): for kid, total in cost_totals.items():
if total >= limit: if total >= limit:
cost_exhausted.add(kid) cost_exhausted.add(kid)
elif total >= limit * self.config.cost_soft_threshold_percent / 100: elif total >= limit * self.config.cost_soft_threshold_percent / 100:
cost_soft.add(kid) cost_soft.add(kid)
# LRU scores # LRU scores
lru_scores: dict[str, float] = {} lru_scores: dict[str, float] = {}
@@ -197,6 +200,7 @@ class PoolManager:
"all_key_ids": all_key_ids, "all_key_ids": all_key_ids,
"lru_scores": lru_scores, "lru_scores": lru_scores,
"cost_totals": cost_totals, "cost_totals": cost_totals,
"cost_limit_per_key_tokens": self.config.cost_limit_per_key_tokens,
"latency_avgs": latency_avgs, "latency_avgs": latency_avgs,
"health_scores": health_scores, "health_scores": health_scores,
"keys_by_id": {str(c.key.id): c.key for c in candidates}, "keys_by_id": {str(c.key.id): c.key for c in candidates},
@@ -460,7 +464,10 @@ class PoolManager:
_cooldown_coro = redis_ops.batch_get_cooldowns(pid, key_ids) _cooldown_coro = redis_ops.batch_get_cooldowns(pid, key_ids)
_cost_coro = ( _cost_coro = (
redis_ops.batch_get_cost_totals(pid, key_ids, self.config.cost_window_seconds) redis_ops.batch_get_cost_totals(pid, key_ids, self.config.cost_window_seconds)
if self.config.cost_limit_per_key_tokens is not None if (
self.config.cost_limit_per_key_tokens is not None
or self.config.scheduling_mode == "multi_score"
)
else None else None
) )
_lru_coro = redis_ops.get_lru_scores(pid, key_ids) if self.config.lru_enabled else None _lru_coro = redis_ops.get_lru_scores(pid, key_ids) if self.config.lru_enabled else None
@@ -492,9 +499,10 @@ class PoolManager:
cost_totals: dict[str, int] = {} cost_totals: dict[str, int] = {}
if _cost_idx_sk >= 0: if _cost_idx_sk >= 0:
cost_totals = gathered_sk[_cost_idx_sk] cost_totals = gathered_sk[_cost_idx_sk]
for kid, total in cost_totals.items(): if self.config.cost_limit_per_key_tokens is not None:
if total >= self.config.cost_limit_per_key_tokens: # type: ignore[operator] for kid, total in cost_totals.items():
cost_exhausted.add(kid) if total >= self.config.cost_limit_per_key_tokens:
cost_exhausted.add(kid)
lru_scores: dict[str, float] = {} lru_scores: dict[str, float] = {}
if _lru_idx_sk >= 0: if _lru_idx_sk >= 0:
@@ -515,6 +523,7 @@ class PoolManager:
"all_key_ids": key_ids, "all_key_ids": key_ids,
"lru_scores": lru_scores, "lru_scores": lru_scores,
"cost_totals": cost_totals if _cost_idx_sk >= 0 else {}, "cost_totals": cost_totals if _cost_idx_sk >= 0 else {},
"cost_limit_per_key_tokens": self.config.cost_limit_per_key_tokens,
"latency_avgs": latency_avgs, "latency_avgs": latency_avgs,
"health_scores": health_scores, "health_scores": health_scores,
"keys_by_id": {str(k.id): k for k in keys}, "keys_by_id": {str(k.id): k for k in keys},

View File

@@ -101,6 +101,7 @@ class MultiScoreStrategy:
lru_enabled=lru_enabled, lru_enabled=lru_enabled,
lru_scores=lru_scores, lru_scores=lru_scores,
keys_by_id=keys_by_id, keys_by_id=keys_by_id,
context=context,
) )
weights = getattr(config, "scoring_weights", None) weights = getattr(config, "scoring_weights", None)
@@ -140,6 +141,7 @@ class MultiScoreStrategy:
lru_enabled: bool, lru_enabled: bool,
lru_scores: dict[str, Any], lru_scores: dict[str, Any],
keys_by_id: dict[str, Any], keys_by_id: dict[str, Any],
context: dict[str, Any],
) -> float: ) -> float:
lru_rank_asc = rank_ascending(key_id, lru_scores, all_key_ids) lru_rank_asc = rank_ascending(key_id, lru_scores, all_key_ids)
@@ -155,6 +157,7 @@ class MultiScoreStrategy:
all_key_ids=all_key_ids, all_key_ids=all_key_ids,
keys_by_id=keys_by_id, keys_by_id=keys_by_id,
lru_scores=lru_scores, lru_scores=lru_scores,
context=context,
mode=mode, mode=mode,
) )
weight = 1.0 / (1.0 + _POSITIONAL_DECAY * idx) weight = 1.0 / (1.0 + _POSITIONAL_DECAY * idx)

View File

@@ -5,6 +5,8 @@ Codex 配额刷新策略。
from __future__ import annotations from __future__ import annotations
import json import json
import time
from datetime import datetime, timezone
from typing import Any from typing import Any
import httpx import httpx
@@ -14,7 +16,10 @@ from src.api.handlers.base.request_builder import get_provider_auth
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.provider_keys.auth_type import normalize_auth_type from src.services.provider_keys.auth_type import normalize_auth_type
from src.services.provider_keys.codex_usage_parser import parse_codex_wham_usage_response from src.services.provider_keys.codex_usage_parser import (
parse_codex_usage_headers,
parse_codex_wham_usage_response,
)
def _normalize_plan_type(value: Any) -> str | None: def _normalize_plan_type(value: Any) -> str | None:
@@ -24,6 +29,40 @@ def _normalize_plan_type(value: Any) -> str | None:
return normalized or None return normalized or None
def _build_quota_exhausted_fallback_metadata(plan_type: str | None) -> dict[str, Any]:
"""Build conservative Codex quota metadata when wham/usage returns 402."""
normalized_plan = _normalize_plan_type(plan_type)
metadata: dict[str, Any] = {"updated_at": int(time.time())}
if normalized_plan:
metadata["plan_type"] = normalized_plan
# primary_* = weekly, secondary_* = 5H (aligned with parser semantics)
metadata["primary_used_percent"] = 100.0
if normalized_plan != "free":
metadata["secondary_used_percent"] = 100.0
return metadata
def _extract_error_message_from_response(response: httpx.Response) -> str:
"""Best-effort extraction of upstream error message for diagnostics."""
try:
payload = response.json()
if isinstance(payload, dict):
err = payload.get("error")
if isinstance(err, dict):
message = str(err.get("message", "")).strip()
if message:
return message
if isinstance(err, str) and err.strip():
return err.strip()
message = str(payload.get("message", "")).strip()
if message:
return message
except Exception:
pass
text = str(getattr(response, "text", "") or "").strip()
return text[:300] if text else ""
async def refresh_codex_key_quota( async def refresh_codex_key_quota(
*, *,
db: Session, db: Session,
@@ -96,12 +135,68 @@ async def refresh_codex_key_quota(
response = await client.get(codex_wham_usage_url, headers=headers) response = await client.get(codex_wham_usage_url, headers=headers)
if response.status_code != 200: if response.status_code != 200:
status_code = int(response.status_code)
err_msg = _extract_error_message_from_response(response)
header_quota = parse_codex_usage_headers(dict(response.headers) if response.headers else {})
if isinstance(header_quota, dict) and header_quota:
metadata_updates[key.id] = {"codex": header_quota}
if status_code == 401:
state_updates[key.id] = {
"is_active": False,
"oauth_invalid_at": datetime.now(timezone.utc),
"oauth_invalid_reason": "Codex Token 无效或已过期 (401)",
}
return {
"key_id": key.id,
"key_name": key.name,
"status": "auth_invalid",
"message": f"wham/usage API 返回状态码 401{f': {err_msg}' if err_msg else ''}",
"status_code": 401,
"auto_disabled": True,
}
if status_code == 402:
if key.id not in metadata_updates:
metadata_updates[key.id] = {
"codex": _build_quota_exhausted_fallback_metadata(oauth_plan_type)
}
state_updates[key.id] = {
"oauth_invalid_at": None,
"oauth_invalid_reason": None,
}
return {
"key_id": key.id,
"key_name": key.name,
"status": "quota_exhausted",
"message": f"wham/usage API 返回状态码 402{f': {err_msg}' if err_msg else ''}",
"status_code": 402,
}
if status_code == 403:
state_updates[key.id] = {
"is_active": False,
"oauth_invalid_at": datetime.now(timezone.utc),
"oauth_invalid_reason": "Codex 账户访问受限 (403)",
}
return {
"key_id": key.id,
"key_name": key.name,
"status": "forbidden",
"message": f"wham/usage API 返回状态码 403{f': {err_msg}' if err_msg else ''}",
"status_code": 403,
"auto_disabled": True,
}
return { return {
"key_id": key.id, "key_id": key.id,
"key_name": key.name, "key_name": key.name,
"status": "error", "status": "error",
"message": f"wham/usage API 返回状态码 {response.status_code}", "message": (
"status_code": response.status_code, f"wham/usage API 返回状态码 {status_code}{f': {err_msg}' if err_msg else ''}"
),
"status_code": status_code,
} }
# 解析 JSON 响应 # 解析 JSON 响应

View File

@@ -118,7 +118,7 @@ async def test_402_sets_long_cooldown(config: PoolConfig) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_403_sets_long_cooldown(config: PoolConfig) -> None: async def test_403_default_sets_medium_cooldown(config: PoolConfig) -> None:
with patch( with patch(
"src.services.provider.pool.redis_ops.set_cooldown", "src.services.provider.pool.redis_ops.set_cooldown",
new_callable=AsyncMock, new_callable=AsyncMock,
@@ -132,6 +132,24 @@ async def test_403_sets_long_cooldown(config: PoolConfig) -> None:
config=config, config=config,
) )
mock_cd.assert_called_once_with(PID, KID, "forbidden_403", ttl=300)
@pytest.mark.asyncio
async def test_403_suspended_body_sets_long_cooldown(config: PoolConfig) -> None:
with patch(
"src.services.provider.pool.redis_ops.set_cooldown",
new_callable=AsyncMock,
) as mock_cd:
await apply_health_policy(
provider_id=PID,
key_id=KID,
status_code=403,
error_body=json.dumps({"error": {"message": "account suspended"}}),
response_headers=None,
config=config,
)
mock_cd.assert_called_once_with(PID, KID, "forbidden_403", ttl=3600) mock_cd.assert_called_once_with(PID, KID, "forbidden_403", ttl=3600)
@@ -228,6 +246,42 @@ async def test_529_uses_overload_cooldown(config: PoolConfig) -> None:
mock_cd.assert_called_once_with(PID, KID, "overloaded_529", ttl=30) mock_cd.assert_called_once_with(PID, KID, "overloaded_529", ttl=30)
@pytest.mark.asyncio
async def test_503_uses_retry_after_when_present(config: PoolConfig) -> None:
with patch(
"src.services.provider.pool.redis_ops.set_cooldown",
new_callable=AsyncMock,
) as mock_cd:
await apply_health_policy(
provider_id=PID,
key_id=KID,
status_code=503,
error_body=None,
response_headers={"retry-after": "45"},
config=config,
)
mock_cd.assert_called_once_with(PID, KID, "service_unavailable_503", ttl=45)
@pytest.mark.asyncio
async def test_500_uses_overload_cooldown(config: PoolConfig) -> None:
with patch(
"src.services.provider.pool.redis_ops.set_cooldown",
new_callable=AsyncMock,
) as mock_cd:
await apply_health_policy(
provider_id=PID,
key_id=KID,
status_code=500,
error_body=None,
response_headers=None,
config=config,
)
mock_cd.assert_called_once_with(PID, KID, "server_error_500", ttl=30)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Unschedulable keyword rules # Unschedulable keyword rules
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -19,8 +19,8 @@ def _context() -> dict:
} }
def _key_with_metadata(metadata: dict) -> SimpleNamespace: def _key_with_metadata(metadata: dict, **kwargs: object) -> SimpleNamespace:
return SimpleNamespace(upstream_metadata=metadata) return SimpleNamespace(upstream_metadata=metadata, **kwargs)
def test_multi_score_returns_none_when_mode_not_enabled() -> None: def test_multi_score_returns_none_when_mode_not_enabled() -> None:
@@ -159,7 +159,7 @@ def test_multi_score_preset_recent_refresh_prefers_nearer_reset() -> None:
assert s2 < s1 assert s2 < s1
def test_multi_score_preset_single_account_prefers_latest_used() -> None: def test_multi_score_preset_single_account_prefers_internal_priority_then_reverse_lru() -> None:
strategy = MultiScoreStrategy() strategy = MultiScoreStrategy()
cfg = PoolConfig( cfg = PoolConfig(
scheduling_mode="multi_score", scheduling_mode="multi_score",
@@ -168,7 +168,33 @@ def test_multi_score_preset_single_account_prefers_latest_used() -> None:
ctx = { ctx = {
"all_key_ids": ["k1", "k2", "k3"], "all_key_ids": ["k1", "k2", "k3"],
"lru_scores": {"k1": 100.0, "k2": 900.0, "k3": 400.0}, "lru_scores": {"k1": 100.0, "k2": 900.0, "k3": 400.0},
"keys_by_id": {}, "keys_by_id": {
"k1": _key_with_metadata({}, internal_priority=30),
"k2": _key_with_metadata({}, internal_priority=1),
"k3": _key_with_metadata({}, internal_priority=10),
},
}
s1 = strategy.compute_score(key_id="k1", config=cfg, context=ctx)
s2 = strategy.compute_score(key_id="k2", config=cfg, context=ctx)
s3 = strategy.compute_score(key_id="k3", config=cfg, context=ctx)
assert s1 is not None and s2 is not None and s3 is not None
assert s2 < s3 < s1
def test_multi_score_preset_priority_first_prefers_low_internal_priority() -> None:
strategy = MultiScoreStrategy()
cfg = PoolConfig(
scheduling_mode="multi_score",
scheduling_presets=(SchedulingPreset(preset="priority_first", enabled=True),),
)
ctx = {
"all_key_ids": ["k1", "k2", "k3"],
"lru_scores": {"k1": 100.0, "k2": 100.0, "k3": 100.0},
"keys_by_id": {
"k1": _key_with_metadata({}, internal_priority=20),
"k2": _key_with_metadata({}, internal_priority=3),
"k3": _key_with_metadata({}, internal_priority=11),
},
} }
s1 = strategy.compute_score(key_id="k1", config=cfg, context=ctx) s1 = strategy.compute_score(key_id="k1", config=cfg, context=ctx)
s2 = strategy.compute_score(key_id="k2", config=cfg, context=ctx) s2 = strategy.compute_score(key_id="k2", config=cfg, context=ctx)

View File

@@ -14,7 +14,16 @@ def _key(metadata: dict, *, plan_type: str | None = None) -> SimpleNamespace:
def test_registry_discovers_builtin_dimensions() -> None: def test_registry_discovers_builtin_dimensions() -> None:
names = get_preset_names() names = get_preset_names()
assert {"free_team_first", "recent_refresh", "quota_balanced", "single_account"}.issubset(names) assert {
"free_team_first",
"recent_refresh",
"quota_balanced",
"single_account",
"priority_first",
"health_first",
"latency_first",
"cost_first",
}.issubset(names)
def test_universal_dimensions_are_applicable_to_any_provider() -> None: def test_universal_dimensions_are_applicable_to_any_provider() -> None:
@@ -78,6 +87,7 @@ def test_builtin_dimensions_compute_metric_in_range() -> None:
all_key_ids=all_key_ids, all_key_ids=all_key_ids,
keys_by_id=keys_by_id, keys_by_id=keys_by_id,
lru_scores=lru_scores, lru_scores=lru_scores,
context={},
mode=mode, mode=mode,
) )
assert 0.0 <= metric <= 1.0 assert 0.0 <= metric <= 1.0

View File

@@ -33,10 +33,20 @@ class _FakeResponse:
status_code: int, status_code: int,
payload: Any = None, payload: Any = None,
json_exc: Exception | None = None, json_exc: Exception | None = None,
*,
headers: dict[str, str] | None = None,
text: str | None = None,
) -> None: ) -> None:
self.status_code = status_code self.status_code = status_code
self._payload = payload self._payload = payload
self._json_exc = json_exc self._json_exc = json_exc
self.headers = headers or {}
if text is not None:
self.text = text
elif isinstance(payload, (dict, list)):
self.text = json.dumps(payload, ensure_ascii=False)
else:
self.text = ""
def json(self) -> Any: def json(self) -> Any:
if self._json_exc: if self._json_exc:
@@ -139,6 +149,121 @@ async def test_codex_refresher_http_non_200_returns_error(
assert result["status_code"] == 503 assert result["status_code"] == 503
@pytest.mark.asyncio
async def test_codex_refresher_http_401_marks_auth_invalid_and_disables(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.services.provider_keys.quota_refresh import codex_refresher as module
key = SimpleNamespace(
id="k1", name="K1", api_key="enc", auth_type="api_key", auth_config=None, proxy=None
)
provider = SimpleNamespace(proxy=None)
endpoint = SimpleNamespace()
metadata_updates: dict[str, dict[str, Any]] = {}
state_updates: dict[str, dict[str, Any]] = {}
async def _fake_auth_info(_endpoint: Any, _key: Any) -> Any:
return None
_install_module(
monkeypatch,
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
monkeypatch.setattr(module.crypto_service, "decrypt", lambda _v: "sk-test")
response = _FakeResponse(status_code=401, payload={"error": {"message": "token expired"}})
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
provider=cast(Any, provider),
key=cast(Any, key),
endpoint=cast(Any, endpoint),
codex_wham_usage_url="https://example.test",
metadata_updates=metadata_updates,
state_updates=state_updates,
)
assert result["status"] == "auth_invalid"
assert result["status_code"] == 401
assert result["auto_disabled"] is True
assert metadata_updates == {}
assert state_updates["k1"]["is_active"] is False
assert "401" in str(state_updates["k1"]["oauth_invalid_reason"])
@pytest.mark.asyncio
async def test_codex_refresher_http_402_sets_quota_exhausted_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.services.provider_keys.quota_refresh import codex_refresher as module
key = SimpleNamespace(
id="k1",
name="K1",
api_key="enc-key",
auth_type="oauth",
auth_config="enc-config",
proxy=None,
)
provider = SimpleNamespace(proxy=None)
endpoint = SimpleNamespace()
metadata_updates: dict[str, dict[str, Any]] = {}
state_updates: dict[str, dict[str, Any]] = {}
async def _fake_auth_info(_endpoint: Any, _key: Any) -> Any:
return None
_install_module(
monkeypatch,
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
monkeypatch.setattr(
module.crypto_service,
"decrypt",
lambda value: (
"sk-test"
if value == "enc-key"
else json.dumps({"plan_type": "team", "account_id": "acc-1"})
),
)
response = _FakeResponse(status_code=402, payload={"error": {"message": "payment required"}})
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
provider=cast(Any, provider),
key=cast(Any, key),
endpoint=cast(Any, endpoint),
codex_wham_usage_url="https://example.test",
metadata_updates=metadata_updates,
state_updates=state_updates,
)
assert result["status"] == "quota_exhausted"
assert result["status_code"] == 402
codex_meta = metadata_updates["k1"]["codex"]
assert codex_meta["plan_type"] == "team"
assert codex_meta["primary_used_percent"] == 100.0
assert codex_meta["secondary_used_percent"] == 100.0
assert state_updates["k1"]["oauth_invalid_at"] is None
assert state_updates["k1"]["oauth_invalid_reason"] is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_codex_refresher_success_updates_metadata( async def test_codex_refresher_success_updates_metadata(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,