mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
feat(pool): add bulk key configuration management
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user