mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
feat(codex): add optional minimum quota reserve for pool scheduling
This commit is contained in:
@@ -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,
|
||||
|
||||
+166
@@ -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 }],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user