feat(codex): add optional minimum quota reserve for pool scheduling

This commit is contained in:
ZheFox
2026-09-23 14:44:29 +08:00
parent 2a9d8d3b25
commit 1a4eba1005
16 changed files with 951 additions and 47 deletions
@@ -822,6 +822,8 @@ export interface PoolAdvancedConfig {
sticky_session_ttl_seconds?: number | null
load_threshold_percent?: number | null
skip_exhausted_accounts?: boolean | null
// Codex only: treat remaining quota <= 1% as exhausted (default false).
reserve_minimum_quota?: boolean
// 旧字段(兼容读取)
lru_enabled?: boolean
scheduling_mode?: 'lru' | 'multi_score' | null
@@ -72,6 +72,33 @@
</div>
</div>
<div
v-if="isCodex"
class="flex items-start justify-between gap-4 rounded-2xl border border-border/60 bg-card/70 p-4"
>
<div class="space-y-1">
<label
for="pool-reserve-minimum-quota"
class="text-sm font-medium"
>
保留最低额度
</label>
<p
id="pool-reserve-minimum-quota-description"
class="text-xs leading-5 text-muted-foreground"
>
账号剩余额度不高于 1% 时提前标记为额度耗尽并停止调度,待额度恢复后再使用。
</p>
</div>
<Switch
id="pool-reserve-minimum-quota"
v-model="reserveMinimumQuota"
aria-describedby="pool-reserve-minimum-quota-description"
:disabled="loading"
class="mt-0.5 shrink-0"
/>
</div>
<!-- Section 2: 策略调度 (非互斥, 可叠加组合 + 拖拽排序) -->
<div class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4">
<div class="space-y-1">
@@ -437,6 +464,8 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
const { success, error: showError } = useToast()
const loading = ref(false)
const reserveMinimumQuota = ref(false)
const isCodex = computed(() => normalizeProviderType(props.providerType) === 'codex')
let dialogRevision = 0
const presetDefs = ref<PoolPresetMeta[]>([])
const presetDefsLoaded = ref(false)
@@ -850,6 +879,7 @@ watch([() => props.modelValue, () => props.providerId], async ([open]) => {
const revision = ++dialogRevision
loading.value = false
if (!open) return
reserveMinimumQuota.value = props.currentConfig?.reserve_minimum_quota === true
await ensurePresetDefsLoaded()
if (!props.modelValue || dialogRevision !== revision) return
presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig))
@@ -883,6 +913,7 @@ async function handleSave() {
const latestAdvanced = latestProvider.pool_advanced
const mergedAdvanced = mergePoolAdvancedPatch(latestAdvanced, {
scheduling_presets: schedulingPresets,
...(isCodex.value ? { reserve_minimum_quota: reserveMinimumQuota.value } : {}),
})
const payload: Parameters<typeof updateProvider>[1] = {
pool_advanced: mergedAdvanced as PoolAdvancedConfig,
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import type { PoolAdvancedConfig } from '@/api/endpoints/types/provider'
import PoolSchedulingDialog from '../PoolSchedulingDialog.vue'
const endpointMocks = vi.hoisted(() => ({
getPoolSchedulingPresets: vi.fn(),
getProvider: vi.fn(),
updateProvider: vi.fn(),
}))
vi.mock('@/api/endpoints', () => ({
getProvider: endpointMocks.getProvider,
updateProvider: endpointMocks.updateProvider,
}))
vi.mock('@/api/endpoints/pool', () => ({
getPoolSchedulingPresets: endpointMocks.getPoolSchedulingPresets,
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn() }),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
async function settle(): Promise<void> {
for (let index = 0; index < 4; index += 1) {
await Promise.resolve()
await nextTick()
}
}
function mountDialog(providerType = 'codex', currentConfig: PoolAdvancedConfig | null = null) {
const root = document.createElement('div')
document.body.appendChild(root)
const open = ref(false)
const TestHost = defineComponent({
setup() {
void nextTick(() => { open.value = true })
return () => h(PoolSchedulingDialog, {
modelValue: open.value,
providerId: 'provider-1',
providerType,
currentConfig,
'onUpdate:modelValue': (value: boolean) => { open.value = value },
})
},
})
const app = createApp(TestHost)
app.mount(root)
mountedApps.push({ app, root })
return open
}
function quotaSwitch() {
return document.body.querySelector<HTMLButtonElement>('#pool-reserve-minimum-quota')
}
async function saveDialog() {
const saveButton = [...document.body.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.trim() === '保存')
expect(saveButton).toBeDefined()
saveButton?.click()
await settle()
}
beforeEach(() => {
endpointMocks.getPoolSchedulingPresets.mockReset()
endpointMocks.getProvider.mockReset()
endpointMocks.updateProvider.mockReset()
endpointMocks.getPoolSchedulingPresets.mockResolvedValue([{
name: 'lru',
label: 'LRU 轮转',
description: '最久未使用的 Key 优先',
providers: [],
default_enabled: true,
modes: null,
default_mode: null,
mutex_group: 'distribution_mode',
}])
endpointMocks.getProvider.mockResolvedValue({ id: 'provider-1', pool_advanced: {} })
endpointMocks.updateProvider.mockResolvedValue({ id: 'provider-1' })
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('PoolSchedulingDialog minimum quota reserve', () => {
it('defaults to disabled and saves enabling it while preserving the latest settings', async () => {
endpointMocks.getProvider.mockResolvedValue({
id: 'provider-1',
pool_advanced: { rate_limit_cooldown_seconds: 900, skip_exhausted_accounts: false },
})
mountDialog('codex', { rate_limit_cooldown_seconds: 300 })
await settle()
expect(quotaSwitch()?.getAttribute('aria-checked')).toBe('false')
expect(document.body.textContent).toContain('剩余额度不高于 1%')
quotaSwitch()?.click()
await nextTick()
await saveDialog()
expect(endpointMocks.updateProvider).toHaveBeenCalledWith('provider-1', {
pool_advanced: {
rate_limit_cooldown_seconds: 900,
skip_exhausted_accounts: false,
reserve_minimum_quota: true,
scheduling_presets: [{ preset: 'lru', enabled: true }],
},
})
})
it('loads an enabled reserve and saves disabling it', async () => {
mountDialog('codex', { reserve_minimum_quota: true })
await settle()
expect(quotaSwitch()?.getAttribute('aria-checked')).toBe('true')
quotaSwitch()?.click()
await nextTick()
await saveDialog()
expect(endpointMocks.updateProvider).toHaveBeenCalledWith('provider-1', {
pool_advanced: {
reserve_minimum_quota: false,
scheduling_presets: [{ preset: 'lru', enabled: true }],
},
})
})
it('discards unsaved reserve changes when the dialog is reopened', async () => {
const open = mountDialog()
await settle()
quotaSwitch()?.click()
await nextTick()
expect(quotaSwitch()?.getAttribute('aria-checked')).toBe('true')
open.value = false
await settle()
open.value = true
await settle()
expect(quotaSwitch()?.getAttribute('aria-checked')).toBe('false')
expect(endpointMocks.updateProvider).not.toHaveBeenCalled()
})
it.each(['openai', 'kiro'])('does not expose or add the reserve setting for %s', async (providerType) => {
mountDialog(providerType)
await settle()
expect(quotaSwitch()).toBeNull()
await saveDialog()
expect(endpointMocks.updateProvider).toHaveBeenCalledWith('provider-1', {
pool_advanced: {
scheduling_presets: [{ preset: 'lru', enabled: true }],
},
})
})
})
+2
View File
@@ -1662,6 +1662,8 @@ export const legacyUiEnglishMessages: Record<string, string> = {
'依据窗口成本/Token 用量,缺失时回退配额使用率': 'Use window cost or token usage, falling back to quota utilization if unavailable',
'调度策略元数据加载失败,请重试': 'Failed to load scheduling policy metadata. Try again.',
'号池调度已保存': 'Pool scheduling saved',
'保留最低额度': 'Reserve minimum quota',
'账号剩余额度不高于 1% 时提前标记为额度耗尽并停止调度,待额度恢复后再使用。': 'Mark accounts as exhausted and stop scheduling when remaining quota is 1% or less, until quota recovers.',
'自适应热池': 'Adaptive warm pool',
'自动维护热池,缺口时异步补位。': 'Maintain a warm pool and replenish missing capacity asynchronously.',
'账号自检': 'Account self-check',