feat(routing): consolidate scheduling strategy configuration

This commit is contained in:
fawney
2026-09-03 11:05:59 +08:00
parent e8d9877b79
commit 2cb4d554aa
88 changed files with 1836 additions and 3635 deletions
@@ -24,14 +24,12 @@
:api-format-filters="apiFormatFilters"
:model-filters="modelFilters"
:has-active-filters="hasActiveFilters"
:priority-mode-label="priorityModeConfig.label"
:loading="loading"
@update:search-query="searchQuery = $event"
@update:filter-status="filterStatus = $event"
@update:filter-api-format="filterApiFormat = $event"
@update:filter-model="filterModel = $event"
@reset-filters="resetFilters"
@open-priority-dialog="openPriorityDialog"
@batch-process="openProviderBatchDialog"
@add-provider="openAddProviderDialog"
@refresh="loadProviders"
@@ -215,11 +213,6 @@
@changed="handleProviderBatchChanged"
/>
<PriorityManagementDialog
v-model="priorityDialogOpen"
@saved="handlePrioritySaved"
/>
<ProviderDetailDrawer
v-if="providerDrawerMounted"
:open="providerDrawerOpen"
@@ -250,7 +243,7 @@ import TableHead from '@/components/ui/table-head.vue'
import SortableTableHead from '@/components/ui/sortable-table-head.vue'
import TableFilterMenu from '@/components/ui/table-filter-menu.vue'
import Pagination from '@/components/ui/pagination.vue'
import { ProviderFormDialog, PriorityManagementDialog, ProviderAuthDialog } from '@/features/providers/components'
import { ProviderFormDialog, ProviderAuthDialog } from '@/features/providers/components'
import ProviderBatchActionDialog from '@/features/providers/components/ProviderBatchActionDialog.vue'
import ProviderTableHeader from '@/features/providers/components/ProviderTableHeader.vue'
import ProviderTableRow from '@/features/providers/components/ProviderTableRow.vue'
@@ -271,9 +264,6 @@ import {
getGlobalModels,
type ProviderWithEndpointsSummary,
} from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import { listRoutingGroups } from '@/api/routing-profiles'
import { normalizeRoutingGroupConfig } from '@/features/routing/utils/routingPolicy'
import { parseApiError } from '@/utils/errorParser'
import { useI18n } from '@/i18n'
@@ -309,8 +299,6 @@ let providersRequestId = 0
const providerDialogOpen = ref(false)
const providerBatchDialogOpen = ref(false)
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
const priorityDialogOpen = ref(false)
const priorityMode = ref<'provider' | 'global_key'>('provider')
const providerDrawerOpen = ref(false)
const providerDrawerMounted = ref(false)
const selectedProviderId = ref<string | null>(null)
@@ -325,7 +313,6 @@ const DELETE_POLL_INTERVAL_MS = 2000
const DELETE_POLL_MAX_MS = 30 * 60 * 1000
const DELETE_POLL_MAX_FAILURES = 3
const PROVIDER_SUMMARY_CACHE_TTL_MS = 10 * 1000
const PROVIDER_PRIORITY_MODE_CACHE_TTL_MS = 30 * 1000
const PROVIDER_MODEL_FILTER_CACHE_TTL_MS = 10 * 1000
async function pollProviderDeleteTask(providerId: string, taskId: string) {
@@ -519,13 +506,6 @@ async function saveDescription(_event: Event, provider: ProviderWithEndpointsSum
}
}
// 优先级模式配置
const priorityModeConfig = computed(() => {
return {
label: legacyT(priorityMode.value === 'global_key' ? '全局 Key 优先' : '提供商优先'),
}
})
// 当前已有提供商的最大优先级
const maxProviderPriority = computed(() => {
if (providers.value.length === 0) return undefined
@@ -535,30 +515,6 @@ const maxProviderPriority = computed(() => {
return priorities.length > 0 ? Math.max(...priorities) : undefined
})
// 加载优先级模式:优先使用启用中的系统默认调度策略,旧的系统配置键仅作兜底
async function loadPriorityMode(options: { cacheTtlMs?: number } = {}) {
try {
const groups = await listRoutingGroups()
const systemDefault = groups.items.find(group => group.is_system_default && group.enabled)
if (systemDefault) {
priorityMode.value = normalizeRoutingGroupConfig(systemDefault.config_json).default_policy.priority_mode
return
}
} catch {
// 路由策略不可用时继续尝试旧配置
}
try {
const response = await adminApi.getSystemConfig('provider_priority_mode', {
cacheTtlMs: options.cacheTtlMs ?? 0,
})
if (response.value) {
priorityMode.value = response.value as 'provider' | 'global_key'
}
} catch {
priorityMode.value = 'provider'
}
}
// 加载全局模型列表(用于模型筛选下拉)
async function loadGlobalModelList(options: { cacheTtlMs?: number } = {}) {
try {
@@ -636,11 +592,6 @@ function openAddProviderDialog() {
providerDialogOpen.value = true
}
// 打开优先级管理对话框
function openPriorityDialog() {
priorityDialogOpen.value = true
}
function openProviderBatchDialog() {
providerBatchDialogOpen.value = true
}
@@ -709,12 +660,6 @@ async function handleDrawerRefresh() {
await refreshProviderSnapshot(selectedProviderId.value)
}
// 优先级保存成功回调
async function handlePrioritySaved() {
await loadProviders()
await loadPriorityMode()
}
// 处理提供商添加
function handleProviderAdded() {
void loadProviders()
@@ -791,7 +736,6 @@ function handleGlobalClick(event: MouseEvent) {
onMounted(() => {
void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS })
void loadPriorityMode({ cacheTtlMs: PROVIDER_PRIORITY_MODE_CACHE_TTL_MS })
void loadGlobalModelList({ cacheTtlMs: PROVIDER_MODEL_FILTER_CACHE_TTL_MS })
void loadArchitectureSchemas()
document.addEventListener('click', handleGlobalClick, true)
File diff suppressed because it is too large Load Diff
@@ -81,9 +81,6 @@
:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version"
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
:enable-format-conversion="systemConfig.enable_format_conversion"
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
:enable-standard-text-sync-heartbeat="systemConfig.enable_standard_text_sync_heartbeat"
:cyber-continue-failover="systemConfig.cyber_continue_failover"
:loading="systemConfigLoading || basicConfigLoading"
:has-changes="hasBasicConfigChanges"
@save="saveBasicConfig"
@@ -107,9 +104,6 @@
@update:registration-privacy-policy-version="systemConfig.registration_privacy_policy_version = $event"
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
@update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event"
@update:enable-standard-text-sync-heartbeat="systemConfig.enable_standard_text_sync_heartbeat = $event"
@update:cyber-continue-failover="systemConfig.cyber_continue_failover = $event"
/>
<!-- 请求记录配置 -->
@@ -1,519 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import RoutingProfiles from '../RoutingProfiles.vue'
import type {
RoutingGroupCreateRequest,
RoutingGroupRecord,
RoutingGroupUpdateRequest,
} from '@/api/routing-profiles'
const apiMocks = vi.hoisted(() => ({
listRoutingGroups: vi.fn(),
createRoutingGroup: vi.fn(),
updateRoutingGroup: vi.fn(),
deleteRoutingGroup: vi.fn(),
getGlobalModels: vi.fn(),
}))
const routeMocks = vi.hoisted(() => ({
route: null as null | { name: string; params: Record<string, string> },
push: vi.fn(),
replace: vi.fn(),
}))
const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
vi.mock('vue-router', async () => {
const { reactive } = await import('vue')
routeMocks.route = reactive({
name: 'RoutingProfileDetail',
params: { groupId: 'group-1' },
})
return {
useRoute: () => routeMocks.route,
useRouter: () => ({ push: routeMocks.push, replace: routeMocks.replace }),
}
})
vi.mock('@/api/routing-profiles', () => ({
listRoutingGroups: apiMocks.listRoutingGroups,
createRoutingGroup: apiMocks.createRoutingGroup,
updateRoutingGroup: apiMocks.updateRoutingGroup,
deleteRoutingGroup: apiMocks.deleteRoutingGroup,
}))
vi.mock('@/api/global-models', () => ({ getGlobalModels: apiMocks.getGlobalModels }))
vi.mock('@/composables/useToast', () => ({ useToast: () => toastMocks }))
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn() } }))
vi.mock('@/components/layout', async () => {
const { defineComponent, h } = await import('vue')
return {
PageContainer: defineComponent({
setup(_, { slots }) {
return () => h('main', slots.default?.())
},
}),
}
})
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const wrapper = (tag = 'div') => defineComponent({
inheritAttrs: false,
props: { class: String },
setup(props, { attrs, slots }) {
return () => h(tag, { ...attrs, class: props.class }, [
slots.header?.(),
slots.default?.(),
])
},
})
const Input = defineComponent({
inheritAttrs: false,
props: {
modelValue: { type: [String, Number], default: '' },
class: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
class: props.class,
disabled: props.disabled,
value: props.modelValue,
onInput: (event: Event) => emit(
'update:modelValue',
(event.target as HTMLInputElement).value,
),
})
},
})
const Textarea = defineComponent({
inheritAttrs: false,
props: {
modelValue: { type: String, default: '' },
class: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('textarea', {
...attrs,
class: props.class,
disabled: props.disabled,
value: props.modelValue,
onInput: (event: Event) => emit(
'update:modelValue',
(event.target as HTMLTextAreaElement).value,
),
})
},
})
const Button = defineComponent({
inheritAttrs: false,
props: {
class: String,
disabled: Boolean,
type: { type: String, default: 'button' },
},
setup(props, { attrs, slots }) {
return () => h('button', {
...attrs,
class: props.class,
disabled: props.disabled,
type: props.type,
}, slots.default?.())
},
})
const Switch = defineComponent({
inheritAttrs: false,
props: {
modelValue: { type: Boolean, default: false },
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('button', {
...attrs,
type: 'button',
role: 'switch',
'aria-checked': props.modelValue,
disabled: props.disabled,
onClick: () => emit('update:modelValue', !props.modelValue),
})
},
})
return {
Badge: wrapper(),
Button,
Card: wrapper('section'),
Input,
Switch,
Table: wrapper('table'),
TableBody: wrapper('tbody'),
TableCard: wrapper(),
TableCell: wrapper('td'),
TableHead: wrapper('th'),
TableHeader: wrapper('thead'),
TableRow: wrapper('tr'),
Textarea,
}
})
vi.mock('@/components/ui/dropdown-menu', async () => {
const { defineComponent, h } = await import('vue')
const wrapper = defineComponent({
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
})
return {
DropdownMenu: wrapper,
DropdownMenuContent: wrapper,
DropdownMenuItem: wrapper,
DropdownMenuTrigger: wrapper,
}
})
vi.mock('@/components/common', async () => {
const { defineComponent, h } = await import('vue')
return { AlertDialog: defineComponent({ setup: () => () => h('div') }) }
})
vi.mock('@/features/routing/components', async () => {
const { defineComponent, h } = await import('vue')
return {
RoutingPriorityPolicyEditor: defineComponent({
setup: () => () => h('div', { 'data-testid': 'routing-policy-editor' }),
}),
}
})
let app: App | undefined
let root: HTMLElement | undefined
function routingGroup(
allowedModels: string[] = [],
overrides: Partial<RoutingGroupRecord> = {},
): RoutingGroupRecord {
return {
id: 'group-1',
name: 'Default routing',
description: null,
enabled: true,
is_system_default: true,
config_json: {
allowed_models: allowedModels,
default_policy: {
priority_mode: 'provider',
scheduling_mode: 'cache_affinity',
keep_priority_on_conversion: false,
sticky_key_attempts: 2,
},
model_policies: [],
rules: [],
},
version: 1,
created_at: 1,
updated_at: 1,
published_at: null,
...overrides,
}
}
async function flushPromises(iterations = 5): Promise<void> {
for (let index = 0; index < iterations; index += 1) {
await Promise.resolve()
}
await nextTick()
}
async function mountPage(
input: RoutingGroupRecord | RoutingGroupRecord[] = routingGroup(),
): Promise<void> {
const groups = Array.isArray(input) ? input : [input]
apiMocks.listRoutingGroups.mockResolvedValue({ items: groups, total: groups.length })
apiMocks.getGlobalModels.mockResolvedValue({ models: [] })
apiMocks.updateRoutingGroup.mockImplementation(
async (groupId: string, payload: RoutingGroupUpdateRequest) => {
const group = groups.find(item => item.id === groupId)
if (!group) throw new Error(`unknown routing group: ${groupId}`)
return {
...group,
...payload,
config_json: payload.config_json ?? group.config_json,
updated_at: 2,
}
},
)
root = document.createElement('div')
document.body.appendChild(root)
app = createApp(defineComponent({
setup: () => () => h(RoutingProfiles),
}))
app.mount(root)
await flushPromises()
}
function setTextareaValue(textarea: HTMLTextAreaElement, value: string): void {
textarea.value = value
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
beforeEach(() => {
vi.clearAllMocks()
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.name = 'RoutingProfileDetail'
routeMocks.route.params = { groupId: 'group-1' }
})
afterEach(() => {
app?.unmount()
root?.remove()
app = undefined
root = undefined
})
describe('RoutingProfiles model allowlist', () => {
it('saves one selector per line without an extra apply step', async () => {
await mountPage()
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
expect(textarea).toBeInstanceOf(HTMLTextAreaElement)
setTextareaValue(textarea, 'gpt-5\nclaude-*\nvendor,model')
await nextTick()
const saveButton = root?.querySelector(
'button[aria-label="保存"]',
) as HTMLButtonElement
expect(saveButton.disabled).toBe(false)
saveButton.click()
await flushPromises()
expect(apiMocks.updateRoutingGroup).toHaveBeenCalledWith(
'group-1',
expect.objectContaining({
config_json: expect.objectContaining({
allowed_models: ['gpt-5', 'claude-*', 'vendor,model'],
}),
}),
)
})
it('locks the editor while a save is in flight', async () => {
const group = routingGroup(['model-a'])
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupUpdateRequest | undefined
await mountPage(group)
apiMocks.updateRoutingGroup.mockImplementationOnce(
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveUpdate = resolve
})
},
)
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
setTextareaValue(textarea, 'model-a\nmodel-b')
await nextTick()
const saveButton = root?.querySelector(
'button[aria-label="保存"]',
) as HTMLButtonElement
saveButton.click()
await nextTick()
const editor = root?.querySelector('[aria-busy="true"]') as HTMLElement
const clearButton = root?.querySelector(
'[data-testid="clear-allowed-models"]',
) as HTMLButtonElement
expect(editor.hasAttribute('inert')).toBe(true)
expect(textarea.disabled).toBe(true)
expect(clearButton.disabled).toBe(true)
expect(saveButton.disabled).toBe(true)
setTextareaValue(textarea, 'model-c')
await nextTick()
expect(submittedPayload?.config_json?.allowed_models).toEqual(['model-a', 'model-b'])
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
resolveUpdate({
...group,
...submittedPayload,
config_json: submittedPayload.config_json ?? group.config_json,
updated_at: 2,
})
await flushPromises()
expect(root?.querySelector('[aria-busy="true"]')).toBeNull()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-a\nmodel-b')
expect(apiMocks.updateRoutingGroup).toHaveBeenCalledTimes(1)
})
it('keeps another group selected when an earlier save response arrives', async () => {
const firstGroup = routingGroup(['model-a'], {
id: 'group-1',
name: 'First routing',
})
const secondGroup = routingGroup(['model-b'], {
id: 'group-2',
name: 'Second routing',
is_system_default: false,
})
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupUpdateRequest | undefined
await mountPage([firstGroup, secondGroup])
apiMocks.updateRoutingGroup.mockImplementationOnce(
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveUpdate = resolve
})
},
)
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
setTextareaValue(textarea, 'model-a\nmodel-a-new')
await nextTick()
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
await nextTick()
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.params = { groupId: 'group-2' }
await nextTick()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-b')
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
resolveUpdate({
...firstGroup,
...submittedPayload,
config_json: submittedPayload.config_json ?? firstGroup.config_json,
updated_at: 2,
})
await flushPromises()
expect(root?.querySelector('h2')?.textContent).toContain('Second routing')
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-b')
expect(routeMocks.replace).not.toHaveBeenCalled()
})
it('refreshes a clean draft when returning to the saved group before the response arrives', async () => {
const group = routingGroup(['model-a'])
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupUpdateRequest | undefined
await mountPage(group)
apiMocks.updateRoutingGroup.mockImplementationOnce(
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveUpdate = resolve
})
},
)
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
setTextareaValue(textarea, 'model-a\nmodel-b')
await nextTick()
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
await nextTick()
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.name = 'RoutingProfiles'
routeMocks.route.params = {}
await nextTick()
routeMocks.route.name = 'RoutingProfileDetail'
routeMocks.route.params = { groupId: 'group-1' }
await nextTick()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-a')
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
resolveUpdate({
...group,
...submittedPayload,
config_json: submittedPayload.config_json ?? group.config_json,
updated_at: 2,
})
await flushPromises()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-a\nmodel-b')
expect((root?.querySelector(
'button[aria-label="保存"]',
) as HTMLButtonElement).disabled).toBe(true)
})
it('does not attach an old create response to a recreated draft', async () => {
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.name = 'RoutingProfileCreate'
routeMocks.route.params = {}
let resolveCreate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupCreateRequest | undefined
await mountPage([])
apiMocks.createRoutingGroup.mockImplementationOnce(
async (payload: RoutingGroupCreateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveCreate = resolve
})
},
)
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
await nextTick()
routeMocks.route.name = 'RoutingProfiles'
await nextTick()
routeMocks.route.name = 'RoutingProfileCreate'
await nextTick()
expect(root?.querySelector('h2')?.textContent).toContain('新建调度策略')
if (!resolveCreate || !submittedPayload) throw new Error('create request did not start')
const config = submittedPayload.config_json
resolveCreate({
...routingGroup(config?.allowed_models ?? [], {
id: 'created-group',
name: submittedPayload.name,
description: submittedPayload.description,
enabled: submittedPayload.enabled ?? false,
is_system_default: submittedPayload.is_system_default ?? false,
}),
config_json: config ?? routingGroup().config_json,
})
await flushPromises()
expect(root?.querySelector('h2')?.textContent).toContain('新建调度策略')
expect(routeMocks.replace).not.toHaveBeenCalled()
expect(apiMocks.createRoutingGroup).toHaveBeenCalledTimes(1)
})
})
@@ -151,69 +151,6 @@
</div>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="enable-openai-image-sync-heartbeat"
:checked="enableOpenaiImageSyncHeartbeat"
@update:checked="$emit('update:enableOpenaiImageSyncHeartbeat', $event)"
/>
<div>
<Label
for="enable-openai-image-sync-heartbeat"
class="cursor-pointer"
>
同步生图心跳
</Label>
<p class="text-xs text-muted-foreground">
开启后同步生图外层 HTTP 状态固定为 200,上游失败需读取响应体 error.upstream_status
</p>
</div>
</div>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="enable-standard-text-sync-heartbeat"
:checked="enableStandardTextSyncHeartbeat"
@update:checked="$emit('update:enableStandardTextSyncHeartbeat', $event)"
/>
<div>
<Label
for="enable-standard-text-sync-heartbeat"
class="cursor-pointer"
>
标准文本非流式心跳
</Label>
<p class="text-xs text-muted-foreground">
开启后标准文本非流式接口外层 HTTP 状态固定为 200,上游失败需读取响应体 error.upstream_status
</p>
</div>
</div>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="cyber-continue-failover"
:checked="cyberContinueFailover"
@update:checked="$emit('update:cyberContinueFailover', $event)"
/>
<div>
<Label
for="cyber-continue-failover"
class="cursor-pointer"
>
Cyber继续转移
</Label>
<p class="text-xs text-muted-foreground">
关闭时Cyber Policy错误直接返回客户端;开启后在响应内容开始前按普通错误继续故障转移,可能增加首字等待时间
</p>
</div>
</div>
</div>
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
@@ -539,9 +476,6 @@ defineProps<{
registrationPrivacyPolicyVersion: string
autoDeleteExpiredKeys: boolean
enableFormatConversion: boolean
enableOpenaiImageSyncHeartbeat: boolean
enableStandardTextSyncHeartbeat: boolean
cyberContinueFailover: boolean
loading: boolean
hasChanges: boolean
}>()
@@ -568,8 +502,5 @@ defineEmits<{
'update:registrationPrivacyPolicyVersion': [value: string]
'update:autoDeleteExpiredKeys': [value: boolean]
'update:enableFormatConversion': [value: boolean]
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]
'update:enableStandardTextSyncHeartbeat': [value: boolean]
'update:cyberContinueFailover': [value: boolean]
}>()
</script>
@@ -59,7 +59,6 @@ describe('useSystemConfig', () => {
resolveConfigs?.([
{ key: 'request_record_level', value: 'basic' },
{ key: 'proxy_node_metrics_cleanup_batch_size', value: 5000 },
{ key: 'enable_standard_text_sync_heartbeat', value: false },
])
await loadPromise
@@ -71,50 +70,6 @@ describe('useSystemConfig', () => {
expect(state.hasLogConfigChanges.value).toBe(true)
})
it('loads and saves the standard text sync heartbeat flag as a basic config item', async () => {
getAllSystemConfigsMock.mockResolvedValue([
{ key: 'enable_standard_text_sync_heartbeat', value: false },
])
updateSystemConfigMock.mockResolvedValue({})
const state = useSystemConfig()
await state.loadSystemConfig()
expect(state.systemConfig.value.enable_standard_text_sync_heartbeat).toBe(false)
state.systemConfig.value.enable_standard_text_sync_heartbeat = true
expect(state.hasBasicConfigChanges.value).toBe(true)
await state.saveBasicConfig()
expect(updateSystemConfigMock).toHaveBeenCalledWith(
'enable_standard_text_sync_heartbeat',
true,
'标准文本非流式心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体'
)
expect(state.hasBasicConfigChanges.value).toBe(false)
})
it('keeps Cyber failover disabled by default and saves the enabled state', async () => {
getAllSystemConfigsMock.mockResolvedValue([])
updateSystemConfigMock.mockResolvedValue({})
const state = useSystemConfig()
await state.loadSystemConfig()
expect(state.systemConfig.value.cyber_continue_failover).toBe(false)
state.systemConfig.value.cyber_continue_failover = true
expect(state.hasBasicConfigChanges.value).toBe(true)
await state.saveBasicConfig()
expect(updateSystemConfigMock).toHaveBeenCalledWith(
'cyber_continue_failover',
true,
'Cyber继续转移开关:开启后在响应内容开始前将Cyber Policy错误按普通错误继续故障转移,可能增加首字等待时间'
)
expect(state.hasBasicConfigChanges.value).toBe(false)
})
it('uses backend-compatible defaults when config rows have not been persisted yet', async () => {
getAllSystemConfigsMock.mockResolvedValue([])
@@ -33,12 +33,6 @@ export interface SystemConfig {
auto_delete_expired_keys: boolean
// 格式转换
enable_format_conversion: boolean
// 同步生图心跳
enable_openai_image_sync_heartbeat: boolean
// 标准文本非流式心跳
enable_standard_text_sync_heartbeat: boolean
// Cyber Policy 错误继续故障转移
cyber_continue_failover: boolean
// 请求记录
request_record_level: string
sensitive_headers: string[]
@@ -89,12 +83,6 @@ const CONFIG_KEYS = [
'auto_delete_expired_keys',
// 格式转换
'enable_format_conversion',
// 同步生图心跳
'enable_openai_image_sync_heartbeat',
// 标准文本非流式心跳
'enable_standard_text_sync_heartbeat',
// Cyber Policy 错误继续故障转移
'cyber_continue_failover',
// 请求记录
'request_record_level',
'sensitive_headers',
@@ -147,12 +135,6 @@ function createDefaultConfig(): SystemConfig {
auto_delete_expired_keys: false,
// 格式转换
enable_format_conversion: false,
// 同步生图心跳
enable_openai_image_sync_heartbeat: false,
// 标准文本非流式心跳
enable_standard_text_sync_heartbeat: false,
// Cyber Policy 错误继续故障转移
cyber_continue_failover: false,
// 请求记录
request_record_level: 'full',
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
@@ -235,13 +217,7 @@ export function useSystemConfig() {
systemConfig.value.registration_privacy_policy_version !==
originalConfig.value.registration_privacy_policy_version ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion ||
systemConfig.value.enable_openai_image_sync_heartbeat !==
originalConfig.value.enable_openai_image_sync_heartbeat ||
systemConfig.value.enable_standard_text_sync_heartbeat !==
originalConfig.value.enable_standard_text_sync_heartbeat ||
systemConfig.value.cyber_continue_failover !==
originalConfig.value.cyber_continue_failover
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion
)
})
@@ -490,21 +466,6 @@ export function useSystemConfig() {
value: systemConfig.value.enable_format_conversion,
description: '全局格式转换开关:开启时强制允许所有提供商的格式转换',
},
{
key: 'enable_openai_image_sync_heartbeat',
value: systemConfig.value.enable_openai_image_sync_heartbeat,
description: '同步生图心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体',
},
{
key: 'enable_standard_text_sync_heartbeat',
value: systemConfig.value.enable_standard_text_sync_heartbeat,
description: '标准文本非流式心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体',
},
{
key: 'cyber_continue_failover',
value: systemConfig.value.cyber_continue_failover,
description: 'Cyber继续转移开关:开启后在响应内容开始前将Cyber Policy错误按普通错误继续故障转移,可能增加首字等待时间',
},
]
const turnstileSecret = systemConfig.value.turnstile_secret_key.trim()
if (turnstileSecret) {
@@ -555,12 +516,6 @@ export function useSystemConfig() {
systemConfig.value.auto_delete_expired_keys
originalConfig.value.enable_format_conversion =
systemConfig.value.enable_format_conversion
originalConfig.value.enable_openai_image_sync_heartbeat =
systemConfig.value.enable_openai_image_sync_heartbeat
originalConfig.value.enable_standard_text_sync_heartbeat =
systemConfig.value.enable_standard_text_sync_heartbeat
originalConfig.value.cyber_continue_failover =
systemConfig.value.cyber_continue_failover
}
success('基础配置已保存')
} catch (err) {