mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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 {
|
||||||
|
|||||||
@@ -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_type(oauth_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))
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 = (
|
||||||
{},
|
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
62
src/services/provider/pool/dimensions/cost_first.py
Normal file
62
src/services/provider/pool/dimensions/cost_first.py
Normal 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())
|
||||||
@@ -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_type(oauth_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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
57
src/services/provider/pool/dimensions/health_first.py
Normal file
57
src/services/provider/pool/dimensions/health_first.py
Normal 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())
|
||||||
54
src/services/provider/pool/dimensions/latency_first.py
Normal file
54
src/services/provider/pool/dimensions/latency_first.py
Normal 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())
|
||||||
46
src/services/provider/pool/dimensions/priority_first.py
Normal file
46
src/services/provider/pool/dimensions/priority_first.py
Normal 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())
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 响应
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user