feat(pool): add bulk key configuration management

This commit is contained in:
MMEXA
2026-07-14 04:57:05 +08:00
parent 7f61bb43c7
commit a25fab371a
37 changed files with 2510 additions and 379 deletions
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import {
buildPoolKeyBatchUpdatePatch,
parsePoolKeyModelPatterns,
type PoolKeyBatchEditState,
} from '../poolKeyBatchEdit'
function state(overrides: Partial<PoolKeyBatchEditState> = {}): PoolKeyBatchEditState {
return {
applyApiFormats: false,
apiFormats: [],
applyActive: false,
isActive: true,
applyInternalPriority: false,
internalPriority: '0',
applyRpmLimit: false,
rpmLimit: '',
applyConcurrentLimit: false,
concurrentLimit: '',
applyCacheTtl: false,
cacheTtlMinutes: '5',
applyProbeInterval: false,
maxProbeIntervalMinutes: '32',
applyNote: false,
note: '',
applyModels: false,
modelMode: '',
unrestrictedModels: true,
selectedModels: [],
includePatterns: '',
excludePatterns: '',
...overrides,
}
}
describe('buildPoolKeyBatchUpdatePatch', () => {
it('only emits fields explicitly enabled by the operator', () => {
const result = buildPoolKeyBatchUpdatePatch(state({
applyApiFormats: true,
apiFormats: ['openai:responses', 'openai:responses', ' openai:chat '],
applyRpmLimit: true,
rpmLimit: '',
}))
expect(result.error).toBeNull()
expect(result.patch).toEqual({
api_formats: ['openai:responses', 'openai:chat'],
rpm_limit: null,
})
})
it('builds a manual model policy and preserves explicit restrictions while disabling discovery', () => {
const result = buildPoolKeyBatchUpdatePatch(state({
applyModels: true,
modelMode: 'manual',
unrestrictedModels: false,
selectedModels: ['gpt-5.6-sol', 'gpt-5.6-sol', 'gpt-5.6-luna'],
}))
expect(result.patch).toEqual({
auto_fetch_models: false,
allowed_models: ['gpt-5.6-sol', 'gpt-5.6-luna'],
locked_models: [],
model_include_patterns: [],
model_exclude_patterns: [],
})
})
it('builds automatic discovery filters and locked models', () => {
const result = buildPoolKeyBatchUpdatePatch(state({
applyModels: true,
modelMode: 'automatic',
selectedModels: ['gpt-5.6-sol'],
includePatterns: 'gpt-*,\nclaude-*',
excludePatterns: '*-preview, *-beta',
}))
expect(result.patch).toEqual({
auto_fetch_models: true,
locked_models: ['gpt-5.6-sol'],
model_include_patterns: ['gpt-*', 'claude-*'],
model_exclude_patterns: ['*-preview', '*-beta'],
})
})
it('rejects empty fields and invalid ranges before the request is sent', () => {
expect(buildPoolKeyBatchUpdatePatch(state()).error).toBe('请至少启用一个批量编辑字段')
expect(buildPoolKeyBatchUpdatePatch(state({
applyApiFormats: true,
})).error).toBe('请至少选择一个支持的 API')
expect(buildPoolKeyBatchUpdatePatch(state({
applyCacheTtl: true,
cacheTtlMinutes: '61',
})).error).toBe('缓存 TTL 必须是 0-60 的整数')
expect(buildPoolKeyBatchUpdatePatch(state({
applyModels: true,
modelMode: 'manual',
unrestrictedModels: false,
})).error).toBe('请至少选择一个允许的模型')
})
})
describe('parsePoolKeyModelPatterns', () => {
it('normalizes comma and line separated patterns', () => {
expect(parsePoolKeyModelPatterns(' gpt-* , claude-*\ngpt-* ')).toEqual([
'gpt-*',
'claude-*',
])
})
})
@@ -0,0 +1,151 @@
import type { PoolKeyBatchUpdatePatch } from '@/api/endpoints/pool'
export type PoolKeyBatchModelMode = '' | 'manual' | 'automatic'
export interface PoolKeyBatchEditState {
applyApiFormats: boolean
apiFormats: string[]
applyActive: boolean
isActive: boolean
applyInternalPriority: boolean
internalPriority: string
applyRpmLimit: boolean
rpmLimit: string
applyConcurrentLimit: boolean
concurrentLimit: string
applyCacheTtl: boolean
cacheTtlMinutes: string
applyProbeInterval: boolean
maxProbeIntervalMinutes: string
applyNote: boolean
note: string
applyModels: boolean
modelMode: PoolKeyBatchModelMode
unrestrictedModels: boolean
selectedModels: string[]
includePatterns: string
excludePatterns: string
}
export interface PoolKeyBatchPatchBuildResult {
patch: PoolKeyBatchUpdatePatch | null
fieldLabels: string[]
error: string | null
}
function uniqueTrimmed(values: string[]): string[] {
return [...new Set(values.map(value => value.trim()).filter(Boolean))]
}
export function parsePoolKeyModelPatterns(value: string): string[] {
return uniqueTrimmed(value.split(/[,\n]/))
}
function parseIntegerField(
value: string,
label: string,
min: number,
max?: number,
nullable = false,
): { value?: number | null; error?: string } {
const normalized = value.trim()
if (!normalized) {
return nullable ? { value: null } : { error: `${label} 不能为空` }
}
const parsed = Number(normalized)
if (!Number.isInteger(parsed) || parsed < min || (max !== undefined && parsed > max)) {
const range = max === undefined ? `不小于 ${min}` : `${min}-${max}`
return { error: `${label} 必须是 ${range} 的整数` }
}
return { value: parsed }
}
export function buildPoolKeyBatchUpdatePatch(
state: PoolKeyBatchEditState,
): PoolKeyBatchPatchBuildResult {
const patch: PoolKeyBatchUpdatePatch = {}
const fieldLabels: string[] = []
if (state.applyApiFormats) {
const apiFormats = uniqueTrimmed(state.apiFormats)
if (apiFormats.length === 0) {
return { patch: null, fieldLabels, error: '请至少选择一个支持的 API' }
}
patch.api_formats = apiFormats
fieldLabels.push('支持 API')
}
if (state.applyActive) {
patch.is_active = state.isActive
fieldLabels.push('启用状态')
}
if (state.applyInternalPriority) {
const parsed = parseIntegerField(state.internalPriority, '优先级', 0)
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
patch.internal_priority = parsed.value as number
fieldLabels.push('优先级')
}
if (state.applyRpmLimit) {
const parsed = parseIntegerField(state.rpmLimit, 'RPM 限制', 1, 10000, true)
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
patch.rpm_limit = parsed.value
fieldLabels.push('RPM 限制')
}
if (state.applyConcurrentLimit) {
const parsed = parseIntegerField(state.concurrentLimit, '并发请求上限', 0, undefined, true)
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
patch.concurrent_limit = parsed.value
fieldLabels.push('并发请求上限')
}
if (state.applyCacheTtl) {
const parsed = parseIntegerField(state.cacheTtlMinutes, '缓存 TTL', 0, 60)
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
patch.cache_ttl_minutes = parsed.value as number
fieldLabels.push('缓存 TTL')
}
if (state.applyProbeInterval) {
const parsed = parseIntegerField(state.maxProbeIntervalMinutes, '熔断探测', 0, 32)
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
patch.max_probe_interval_minutes = parsed.value as number
fieldLabels.push('熔断探测')
}
if (state.applyNote) {
patch.note = state.note.trim() || null
fieldLabels.push('备注')
}
if (state.applyModels) {
if (!state.modelMode) {
return { patch: null, fieldLabels, error: '请选择模型权限管理方式' }
}
const selectedModels = uniqueTrimmed(state.selectedModels)
if (state.modelMode === 'manual') {
if (!state.unrestrictedModels && selectedModels.length === 0) {
return { patch: null, fieldLabels, error: '请至少选择一个允许的模型' }
}
patch.auto_fetch_models = false
patch.allowed_models = state.unrestrictedModels ? null : selectedModels
patch.locked_models = []
patch.model_include_patterns = []
patch.model_exclude_patterns = []
} else {
patch.auto_fetch_models = true
patch.locked_models = selectedModels
patch.model_include_patterns = parsePoolKeyModelPatterns(state.includePatterns)
patch.model_exclude_patterns = parsePoolKeyModelPatterns(state.excludePatterns)
}
fieldLabels.push('模型权限')
}
if (fieldLabels.length === 0) {
return { patch: null, fieldLabels, error: '请至少启用一个批量编辑字段' }
}
return { patch, fieldLabels, error: null }
}