mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
fix: harden routing failover, model testing, and wallet queries
This commit is contained in:
+229
-2
@@ -1,10 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, createSSRApp, h, nextTick, type App } from 'vue'
|
||||
import { renderToString } from '@vue/server-renderer'
|
||||
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import type { EndpointAPIKey } from '@/api/endpoints/keys'
|
||||
import ModelMappingTab from '../provider-tabs/ModelMappingTab.vue'
|
||||
|
||||
const keyMocks = vi.hoisted(() => ({ getProviderKeys: vi.fn() }))
|
||||
vi.mock('@/api/endpoints/keys', () => keyMocks)
|
||||
|
||||
const testMocks = vi.hoisted(() => ({
|
||||
testModel: vi.fn(),
|
||||
getRequestTrace: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/endpoints/providers', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@/api/endpoints/providers')>(),
|
||||
testModel: testMocks.testModel,
|
||||
}))
|
||||
vi.mock('@/api/requestTrace', () => ({
|
||||
requestTraceApi: { getRequestTrace: testMocks.getRequestTrace },
|
||||
}))
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({ error: testMocks.showError, success: testMocks.showSuccess }),
|
||||
}))
|
||||
|
||||
const provider: ProviderWithEndpointsSummary = {
|
||||
id: 'provider-demo',
|
||||
name: 'Demo Provider',
|
||||
@@ -29,7 +51,212 @@ const provider: ProviderWithEndpointsSummary = {
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
type MappingTabProps = InstanceType<typeof ModelMappingTab>['$props']
|
||||
type MappingTestState = {
|
||||
runMappingTest: (key: string, model: string) => void
|
||||
handleSelectTestEndpoint: (id: string) => void
|
||||
selectedTestKeyIds: string[]
|
||||
testKeyOptions: Array<{ value: string; label: string }>
|
||||
loadingTestKeys: boolean
|
||||
handleTestDialogClose: () => void
|
||||
handleStartMappingTest: () => Promise<void>
|
||||
}
|
||||
|
||||
const endpoints = [
|
||||
{ id: 'chat', api_format: 'openai:chat', base_url: 'https://example.com', is_active: true, active_keys: 1 },
|
||||
{ id: 'claude', api_format: 'claude:chat', base_url: 'https://example.com', is_active: true, active_keys: 1 },
|
||||
] as MappingTabProps['endpoints']
|
||||
|
||||
function createTestKey(overrides: Partial<EndpointAPIKey>): EndpointAPIKey {
|
||||
return {
|
||||
id: 'test-key',
|
||||
provider_id: provider.id,
|
||||
name: 'Test Key',
|
||||
api_formats: [],
|
||||
api_key_masked: '',
|
||||
auth_type: 'api_key',
|
||||
internal_priority: 0,
|
||||
cache_ttl_minutes: 0,
|
||||
max_probe_interval_minutes: 1,
|
||||
health_score: 1,
|
||||
consecutive_failures: 0,
|
||||
request_count: 0,
|
||||
success_count: 0,
|
||||
error_count: 0,
|
||||
success_rate: 0,
|
||||
avg_response_time_ms: 0,
|
||||
is_active: true,
|
||||
created_at: provider.created_at,
|
||||
updated_at: provider.updated_at,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const testKeys = [
|
||||
createTestKey({ id: 'chat-key', name: 'Chat Key', api_key_masked: 'sk-****chat', api_formats: ['openai:chat'], internal_priority: 0 }),
|
||||
createTestKey({ id: 'claude-key', name: 'Claude Key', api_formats: ['claude:chat'], internal_priority: 1 }),
|
||||
createTestKey({ id: 'disabled-key', is_active: false, internal_priority: 2 }),
|
||||
]
|
||||
const mounted: Array<{ app: App; root: HTMLElement }> = []
|
||||
|
||||
function mountMappingTab(overrides: Partial<MappingTabProps> = {}) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(ModelMappingTab, { provider, endpoints, models: [], ...overrides })
|
||||
const instance = app.mount(root)
|
||||
const state = (instance.$ as unknown as { setupState: MappingTestState }).setupState
|
||||
mounted.push({ app, root })
|
||||
return state
|
||||
}
|
||||
|
||||
function buttonWithText(text: string): HTMLButtonElement {
|
||||
const button = [...document.querySelectorAll('button')]
|
||||
.find(element => element.textContent?.trim() === text)
|
||||
if (!button) throw new Error(`Missing button: ${text}`)
|
||||
return button
|
||||
}
|
||||
|
||||
async function openMappingTest(state: MappingTestState) {
|
||||
state.runMappingTest('mapping', 'test-model')
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
keyMocks.getProviderKeys.mockResolvedValue(testKeys)
|
||||
testMocks.testModel.mockResolvedValue({ success: true, model: 'test-model' })
|
||||
testMocks.getRequestTrace.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mounted.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('ModelMappingTab response contracts', () => {
|
||||
it('loads test keys and removes incompatible selections when switching endpoints', async () => {
|
||||
const state = mountMappingTab()
|
||||
await openMappingTest(state)
|
||||
|
||||
expect(keyMocks.getProviderKeys).toHaveBeenCalledWith(provider.id)
|
||||
expect(state.testKeyOptions).toEqual([
|
||||
{ value: 'chat-key', label: 'Chat Key · sk-****chat · api_key' },
|
||||
])
|
||||
expect(document.body.textContent).toContain('测试 Key')
|
||||
buttonWithText('默认调度(不指定 Key)').click()
|
||||
await nextTick()
|
||||
const option = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
|
||||
.find(element => element.parentElement?.textContent?.includes('Chat Key'))
|
||||
if (!option) throw new Error('Missing Chat Key option')
|
||||
option.click()
|
||||
await nextTick()
|
||||
expect(state.selectedTestKeyIds).toEqual(['chat-key'])
|
||||
|
||||
state.handleSelectTestEndpoint('claude')
|
||||
expect(state.selectedTestKeyIds).toEqual([])
|
||||
expect(state.testKeyOptions.map(option => option.value)).toEqual(['claude-key'])
|
||||
state.selectedTestKeyIds = ['claude-key']
|
||||
state.handleTestDialogClose()
|
||||
expect(state.selectedTestKeyIds).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ selectedKeyIds: ['chat-key'] },
|
||||
{ selectedKeyIds: ['chat-key', 'shared-key'] },
|
||||
])('passes the selected keys to the test request: $selectedKeyIds', async ({ selectedKeyIds }) => {
|
||||
keyMocks.getProviderKeys.mockResolvedValue([
|
||||
...testKeys,
|
||||
createTestKey({ id: 'shared-key', name: 'Shared Key', internal_priority: 3 }),
|
||||
])
|
||||
const state = mountMappingTab()
|
||||
await openMappingTest(state)
|
||||
state.selectedTestKeyIds = [...selectedKeyIds, selectedKeyIds[0], 'disabled-key', 'claude-key']
|
||||
|
||||
await state.handleStartMappingTest()
|
||||
|
||||
expect(testMocks.testModel).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
provider_id: provider.id,
|
||||
mode: 'direct',
|
||||
model_name: 'test-model',
|
||||
endpoint_id: 'chat',
|
||||
api_format: 'openai:chat',
|
||||
api_key_ids: selectedKeyIds,
|
||||
}), expect.objectContaining({ signal: expect.any(AbortSignal) }))
|
||||
})
|
||||
|
||||
it('keeps default scheduling when no key is selected', async () => {
|
||||
const state = mountMappingTab()
|
||||
await openMappingTest(state)
|
||||
|
||||
await state.handleStartMappingTest()
|
||||
|
||||
expect(testMocks.testModel).toHaveBeenCalledOnce()
|
||||
expect(testMocks.testModel.mock.calls[0][0]).not.toHaveProperty('api_key_ids')
|
||||
})
|
||||
|
||||
it('waits for keys to load before allowing a test', async () => {
|
||||
let resolveKeys!: (keys: EndpointAPIKey[]) => void
|
||||
keyMocks.getProviderKeys.mockReturnValue(new Promise<EndpointAPIKey[]>(resolve => {
|
||||
resolveKeys = resolve
|
||||
}))
|
||||
const state = mountMappingTab()
|
||||
await openMappingTest(state)
|
||||
|
||||
expect(buttonWithText('正在加载 Key').disabled).toBe(true)
|
||||
expect(buttonWithText('开始测试').disabled).toBe(true)
|
||||
await state.handleStartMappingTest()
|
||||
expect(testMocks.testModel).not.toHaveBeenCalled()
|
||||
|
||||
resolveKeys(testKeys)
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
expect(buttonWithText('开始测试').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the selector visible when no compatible keys are available', async () => {
|
||||
keyMocks.getProviderKeys.mockResolvedValue([testKeys[1], testKeys[2]])
|
||||
const state = mountMappingTab()
|
||||
await openMappingTest(state)
|
||||
|
||||
expect(document.body.textContent).toContain('测试 Key')
|
||||
buttonWithText('默认调度(不指定 Key)').click()
|
||||
await nextTick()
|
||||
expect(document.body.textContent).toContain('暂无可选 Key')
|
||||
})
|
||||
|
||||
it('keeps provided keys usable after a loading failure', async () => {
|
||||
keyMocks.getProviderKeys.mockRejectedValue(new Error('Key service unavailable'))
|
||||
const state = mountMappingTab({ providerKeys: testKeys })
|
||||
await openMappingTest(state)
|
||||
|
||||
expect(testMocks.showError).toHaveBeenCalledOnce()
|
||||
expect(state.loadingTestKeys).toBe(false)
|
||||
expect(state.testKeyOptions.map(option => option.value)).toEqual(['chat-key'])
|
||||
expect(buttonWithText('开始测试').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores key responses from a closed dialog', async () => {
|
||||
let resolveKeys!: (keys: EndpointAPIKey[]) => void
|
||||
keyMocks.getProviderKeys.mockReturnValueOnce(new Promise<EndpointAPIKey[]>(resolve => {
|
||||
resolveKeys = resolve
|
||||
}))
|
||||
const state = mountMappingTab()
|
||||
await openMappingTest(state)
|
||||
state.handleTestDialogClose()
|
||||
expect(state.loadingTestKeys).toBe(false)
|
||||
|
||||
keyMocks.getProviderKeys.mockResolvedValue([])
|
||||
await openMappingTest(state)
|
||||
resolveKeys(testKeys)
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
|
||||
expect(state.testKeyOptions).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the module visible when a legacy or malformed preview reaches the component', async () => {
|
||||
const props: InstanceType<typeof ModelMappingTab>['$props'] = {
|
||||
provider,
|
||||
|
||||
@@ -345,18 +345,22 @@
|
||||
:request-body-draft="testRequestBodyDraft"
|
||||
:request-body-reset-value="testRequestBodyResetValue"
|
||||
:request-body-error="testRequestBodyError"
|
||||
:start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
|
||||
:key-options="testKeyOptions"
|
||||
:selected-key-ids="selectedTestKeyIds"
|
||||
:key-options-loading="loadingTestKeys"
|
||||
:start-disabled="loadingTestKeys || !selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
|
||||
@close="handleTestDialogClose"
|
||||
@back="handleTestDialogBack"
|
||||
@select-endpoint="handleSelectTestEndpoint"
|
||||
@start="handleStartMappingTest"
|
||||
@update:request-headers-draft="testRequestHeadersDraft = $event"
|
||||
@update:request-body-draft="testRequestBodyDraft = $event"
|
||||
@update:selected-key-ids="selectedTestKeyIds = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useSmartPagination } from '@/composables/useSmartPagination'
|
||||
import { useModelTest } from '@/composables/useModelTest'
|
||||
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
|
||||
@@ -374,7 +378,7 @@ import {
|
||||
type ProviderMappingPreviewResponse,
|
||||
} from '@/api/endpoints'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { type EndpointAPIKey } from '@/api/endpoints/keys'
|
||||
import { getProviderKeys, type EndpointAPIKey } from '@/api/endpoints/keys'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
@@ -391,6 +395,7 @@ import {
|
||||
isModelTestableApiFormat,
|
||||
isModelTestableEndpoint,
|
||||
modelTestMappingScopeMatchesEndpoint,
|
||||
modelTestKeySupportsEndpoint,
|
||||
parseModelTestRequestHeadersDraft,
|
||||
parseModelTestRequestBodyDraft,
|
||||
selectPreferredModelTestEndpoint,
|
||||
@@ -452,6 +457,56 @@ const testingModelName = ref<string | null>(null)
|
||||
const testingSourceModel = ref<Model | null>(null)
|
||||
const preselectedModelId = ref<string | null>(null)
|
||||
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const selectedTestKeyIds = ref<string[]>([])
|
||||
const testKeys = ref<EndpointAPIKey[] | null>(null)
|
||||
const loadingTestKeys = ref(false)
|
||||
let testKeysLoadVersion = 0
|
||||
const testKeyOptions = computed(() => {
|
||||
const endpoint = selectedTestEndpoint.value
|
||||
if (!endpoint) return []
|
||||
return [...new Map((testKeys.value ?? props.providerKeys ?? []).map(key => [key.id, key])).values()]
|
||||
.filter(key => modelTestKeySupportsEndpoint(key, endpoint, props.provider.provider_type))
|
||||
.sort((left, right) => left.internal_priority - right.internal_priority)
|
||||
.map(key => ({
|
||||
value: key.id,
|
||||
label: [
|
||||
key.name?.trim() || key.api_key_masked?.trim() || key.id,
|
||||
key.name?.trim() ? key.api_key_masked?.trim() : '',
|
||||
key.auth_type?.trim(),
|
||||
].filter(Boolean).join(' · '),
|
||||
}))
|
||||
})
|
||||
|
||||
function pruneSelectedTestKeyIds() {
|
||||
const allowed = new Set(testKeyOptions.value.map(option => option.value))
|
||||
selectedTestKeyIds.value = [...new Set(selectedTestKeyIds.value.filter(id => allowed.has(id)))]
|
||||
}
|
||||
|
||||
async function loadTestKeys() {
|
||||
const version = ++testKeysLoadVersion
|
||||
const providerId = props.provider.id
|
||||
loadingTestKeys.value = true
|
||||
try {
|
||||
const keys = await getProviderKeys(providerId)
|
||||
if (version === testKeysLoadVersion && providerId === props.provider.id) {
|
||||
testKeys.value = keys
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (version === testKeysLoadVersion && providerId === props.provider.id) {
|
||||
showError(parseApiError(err, '加载测试 Key 失败'), '错误')
|
||||
}
|
||||
} finally {
|
||||
if (version === testKeysLoadVersion) loadingTestKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(testKeyOptions, pruneSelectedTestKeyIds)
|
||||
watch(() => props.provider.id, () => {
|
||||
testKeysLoadVersion += 1
|
||||
testKeys.value = null
|
||||
selectedTestKeyIds.value = []
|
||||
loadingTestKeys.value = false
|
||||
})
|
||||
const testRequestHeadersDraft = ref('')
|
||||
const testRequestHeadersResetValue = ref('')
|
||||
const testRequestBodyDraft = ref('')
|
||||
@@ -789,11 +844,15 @@ async function onDialogSaved() {
|
||||
|
||||
function handleTestDialogClose() {
|
||||
modelTest.resetState()
|
||||
testKeysLoadVersion += 1
|
||||
loadingTestKeys.value = false
|
||||
testKeys.value = null
|
||||
pendingMappingKey.value = null
|
||||
testingModelName.value = null
|
||||
testingSourceModel.value = null
|
||||
testingMapping.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
selectedTestKeyIds.value = []
|
||||
mappingTestEndpoints.value = null
|
||||
testRequestHeadersDraft.value = ''
|
||||
testRequestHeadersResetValue.value = ''
|
||||
@@ -811,6 +870,7 @@ function handleSelectTestEndpoint(endpointId: string) {
|
||||
const endpoint = selectableTestEndpoints.value.find(item => item.id === endpointId)
|
||||
if (!endpoint) return
|
||||
selectedTestEndpoint.value = endpoint
|
||||
pruneSelectedTestKeyIds()
|
||||
syncMappingTestRequestBody()
|
||||
}
|
||||
|
||||
@@ -839,6 +899,8 @@ function runMappingTest(
|
||||
return
|
||||
}
|
||||
pendingMappingKey.value = testingKey
|
||||
selectedTestKeyIds.value = []
|
||||
void loadTestKeys()
|
||||
modelTest.testResult.value = null
|
||||
modelTest.dialogOpen.value = true
|
||||
testingMapping.value = null
|
||||
@@ -884,7 +946,7 @@ function syncMappingTestRequestBody() {
|
||||
}
|
||||
|
||||
async function handleStartMappingTest() {
|
||||
if (modelTest.testing.value || !testingModelName.value) return
|
||||
if (modelTest.testing.value || loadingTestKeys.value || !testingModelName.value) return
|
||||
const endpoint = selectedTestEndpoint.value || selectableTestEndpoints.value[0]
|
||||
if (!endpoint) {
|
||||
showError('请选择要测试的端点')
|
||||
@@ -905,6 +967,7 @@ async function handleStartMappingTest() {
|
||||
|
||||
const currentMappingKey = pendingMappingKey.value || testingModelName.value
|
||||
testingMapping.value = pendingMappingKey.value ? currentMappingKey : null
|
||||
pruneSelectedTestKeyIds()
|
||||
await modelTest.startTest({
|
||||
mode: 'direct',
|
||||
modelName: testingModelName.value,
|
||||
@@ -912,6 +975,7 @@ async function handleStartMappingTest() {
|
||||
apiFormat: endpoint.api_format,
|
||||
endpointId: endpoint.id,
|
||||
endpointBaseUrl: endpoint.base_url,
|
||||
apiKeyIds: selectedTestKeyIds.value,
|
||||
requestHeaders,
|
||||
requestBody,
|
||||
})
|
||||
|
||||
@@ -875,7 +875,7 @@ const modelMappingAvailable = computed(
|
||||
() => props.modelMappingAvailable === true && modelMappingOptions.value.length > 0,
|
||||
)
|
||||
const showKeySelector = computed(() => (
|
||||
keyOptionsLoading.value || keyOptions.value.length > 0 || selectedKeyIds.value.length > 0
|
||||
props.keyOptions !== undefined || keyOptionsLoading.value || selectedKeyIds.value.length > 0
|
||||
))
|
||||
const keySelectorPlaceholder = computed(() => (
|
||||
keyOptionsLoading.value && keyOptions.value.length === 0 ? '正在加载 Key' : '默认调度(不指定 Key)'
|
||||
|
||||
@@ -7,17 +7,31 @@ const mounted: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function mountEditor() {
|
||||
const policy = ref(normalizeRoutingFailoverPolicy())
|
||||
const editor = ref<{ commitJsonDrafts: () => boolean } | null>(null)
|
||||
const pending = ref(false)
|
||||
const generation = ref(0)
|
||||
const disabled = ref(false)
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
setup: () => () => h(RoutingFailoverPolicyEditor, {
|
||||
ref: editor,
|
||||
key: generation.value,
|
||||
disabled: disabled.value,
|
||||
modelValue: policy.value,
|
||||
'onUpdate:modelValue': (value: RoutingFailoverPolicy) => { policy.value = value },
|
||||
onPendingChange: (value: boolean) => { pending.value = value },
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
mounted.push({ app, root })
|
||||
return { root, policy }
|
||||
return { root, policy, editor, pending, generation, disabled }
|
||||
}
|
||||
|
||||
async function input(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||
element.value = value
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function control<T extends HTMLElement>(root: HTMLElement, label: string): T {
|
||||
@@ -34,6 +48,108 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('RoutingFailoverPolicyEditor', () => {
|
||||
it.each(['{}', '{"success_failover_pattern":[]}', '{"failover_rules":{"error_stop_patterns":[]}}'])('rejects missing JSON sections instead of silently clearing rules: %s', async draft => {
|
||||
const { root, policy, editor } = mountEditor()
|
||||
policy.value.failover_rules.success_failover_patterns = [{ pattern: 'capacity', status_codes: [] }]
|
||||
await nextTick()
|
||||
control<HTMLButtonElement>(root, '切到成功转移规则 JSON').click()
|
||||
await nextTick()
|
||||
await input(control<HTMLTextAreaElement>(root, '成功转移规则 JSON'), draft)
|
||||
expect(editor.value?.commitJsonDrafts()).toBe(false)
|
||||
expect(policy.value.failover_rules.success_failover_patterns).toEqual([{ pattern: 'capacity', status_codes: [] }])
|
||||
})
|
||||
|
||||
it('accepts a named JSON section nested in a complete policy', async () => {
|
||||
const { root, policy, editor } = mountEditor()
|
||||
control<HTMLButtonElement>(root, '切到成功转移规则 JSON').click()
|
||||
await nextTick()
|
||||
await input(control<HTMLTextAreaElement>(root, '成功转移规则 JSON'), '{"failover_rules":{"success_failover_patterns":[{"pattern":"capacity"}]}}')
|
||||
expect(editor.value?.commitJsonDrafts()).toBe(true)
|
||||
expect(policy.value.failover_rules.success_failover_patterns).toEqual([{ pattern: 'capacity', status_codes: [] }])
|
||||
})
|
||||
|
||||
it('keeps status drafts attached to their rules after a row is deleted', async () => {
|
||||
const { root, policy, editor } = mountEditor()
|
||||
for (const index of [1, 2]) {
|
||||
control<HTMLButtonElement>(root, '添加错误终止规则').click()
|
||||
await nextTick()
|
||||
await input(control<HTMLInputElement>(root, `终止规则 ${index} 状态码`), index === 1 ? '400,' : '429, 503')
|
||||
}
|
||||
control<HTMLButtonElement>(root, '删除错误终止规则 1').click()
|
||||
await nextTick()
|
||||
expect(control<HTMLInputElement>(root, '终止规则 1 状态码').value).toBe('429, 503')
|
||||
expect(editor.value?.commitJsonDrafts()).toBe(true)
|
||||
expect(policy.value.failover_rules.error_stop_patterns).toEqual([{ pattern: '', status_codes: [429, 503] }])
|
||||
})
|
||||
|
||||
it('disables JSON mode switches and formatting while a save is in flight', async () => {
|
||||
const { root, policy, editor, disabled } = mountEditor()
|
||||
control<HTMLButtonElement>(root, '切到成功转移规则 JSON').click()
|
||||
await nextTick()
|
||||
await input(control<HTMLTextAreaElement>(root, '成功转移规则 JSON'), '[{"pattern":"capacity"}]')
|
||||
disabled.value = true
|
||||
await nextTick()
|
||||
expect(control<HTMLButtonElement>(root, '切回成功转移规则表单').disabled).toBe(true)
|
||||
for (const button of root.querySelectorAll<HTMLButtonElement>('button')) expect(button.disabled).toBe(true)
|
||||
expect(editor.value?.commitJsonDrafts()).toBe(false)
|
||||
expect(policy.value.failover_rules.success_failover_patterns).toEqual([])
|
||||
})
|
||||
|
||||
it('commits both JSON sections atomically when saving without returning to the form', async () => {
|
||||
const { root, policy, editor, pending } = mountEditor()
|
||||
control<HTMLButtonElement>(root, '切到成功转移规则 JSON').click()
|
||||
control<HTMLButtonElement>(root, '切到错误终止规则 JSON').click()
|
||||
await nextTick()
|
||||
const [successJson, errorJson] = root.querySelectorAll<HTMLTextAreaElement>('textarea')
|
||||
await input(successJson, '[{"pattern":"(?i)capacity"}]')
|
||||
await input(errorJson, '[{"status_codes":[400,413]}]')
|
||||
expect(editor.value?.commitJsonDrafts()).toBe(true)
|
||||
await nextTick()
|
||||
expect(policy.value.failover_rules.success_failover_patterns).toEqual([{ pattern: '(?i)capacity', status_codes: [] }])
|
||||
expect(policy.value.failover_rules.error_stop_patterns).toEqual([{ pattern: '', status_codes: [400, 413] }])
|
||||
expect(pending.value).toBe(false)
|
||||
})
|
||||
|
||||
it('marks JSON-only edits pending and never partially applies invalid drafts', async () => {
|
||||
const { root, policy, editor, pending } = mountEditor()
|
||||
control<HTMLButtonElement>(root, '切到成功转移规则 JSON').click()
|
||||
control<HTMLButtonElement>(root, '切到错误终止规则 JSON').click()
|
||||
await nextTick()
|
||||
const [successJson, errorJson] = root.querySelectorAll<HTMLTextAreaElement>('textarea')
|
||||
await input(successJson, '[{"pattern":"capacity"}]')
|
||||
expect(pending.value).toBe(true)
|
||||
await input(errorJson, '{')
|
||||
expect(editor.value?.commitJsonDrafts()).toBe(false)
|
||||
expect(policy.value.failover_rules.success_failover_patterns).toEqual([])
|
||||
await input(errorJson, '[{"status_codes":[429]}]')
|
||||
expect(editor.value?.commitJsonDrafts()).toBe(true)
|
||||
await nextTick()
|
||||
expect(root.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves separators during status-code typing and rejects invalid local input at save time', async () => {
|
||||
const { root, policy, editor } = mountEditor()
|
||||
control<HTMLButtonElement>(root, '添加错误终止规则').click()
|
||||
await nextTick()
|
||||
const statuses = control<HTMLInputElement>(root, '终止规则 1 状态码')
|
||||
for (const value of ['4', '40', '400', '400,', '400, ', '400, 4', '400, 41', '400, 413']) {
|
||||
await input(statuses, value)
|
||||
expect(statuses.value).toBe(value)
|
||||
}
|
||||
expect(editor.value?.commitJsonDrafts()).toBe(true)
|
||||
await nextTick()
|
||||
expect(policy.value.failover_rules.error_stop_patterns[0].status_codes).toEqual([400, 413])
|
||||
await input(statuses, 'oops')
|
||||
expect(editor.value?.commitJsonDrafts()).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-finite limits instead of normalizing them to unlimited', async () => {
|
||||
const { policy, editor } = mountEditor()
|
||||
policy.value.max_transfer_count = Number.NaN
|
||||
await nextTick()
|
||||
expect(editor.value?.commitJsonDrafts()).toBe(false)
|
||||
})
|
||||
|
||||
it('edits independent global budgets and documents sticky retry exclusion', async () => {
|
||||
const { root, policy } = mountEditor()
|
||||
expect(root.textContent).toContain('首次尝试和粘性同 Key 重试不计入')
|
||||
@@ -56,7 +172,7 @@ describe('RoutingFailoverPolicyEditor', () => {
|
||||
regex.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(policy.value.failover_rules.success_failover_patterns[0].pattern).toBe('(?i)capacity.*exhausted')
|
||||
control<HTMLButtonElement>(root, '添加错误提前终止规则').click()
|
||||
control<HTMLButtonElement>(root, '添加错误终止规则').click()
|
||||
await nextTick()
|
||||
const statuses = control<HTMLInputElement>(root, '终止规则 1 状态码')
|
||||
statuses.value = '400, 413'
|
||||
@@ -82,28 +198,28 @@ describe('RoutingFailoverPolicyEditor', () => {
|
||||
await nextTick()
|
||||
expect(policy.value.failover_rules.success_failover_patterns).toEqual([{ pattern: 'capacity', status_codes: [] }])
|
||||
|
||||
control<HTMLButtonElement>(root, '切到错误提前终止规则 JSON').click()
|
||||
control<HTMLButtonElement>(root, '切到错误终止规则 JSON').click()
|
||||
await nextTick()
|
||||
const errorJson = root.querySelector<HTMLTextAreaElement>('textarea')
|
||||
if (!errorJson) throw new Error('Missing error JSON editor')
|
||||
errorJson.value = '[{"status_codes":[429,500],"pattern":"rate"}]'
|
||||
errorJson.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
control<HTMLButtonElement>(root, '切回错误提前终止规则表单').click()
|
||||
control<HTMLButtonElement>(root, '切回错误终止规则表单').click()
|
||||
await nextTick()
|
||||
expect(policy.value.failover_rules.error_stop_patterns).toEqual([{ pattern: 'rate', status_codes: [429, 500] }])
|
||||
})
|
||||
|
||||
it('keeps invalid JSON visible until it is corrected', async () => {
|
||||
const { root, policy } = mountEditor()
|
||||
control<HTMLButtonElement>(root, '切到错误提前终止规则 JSON').click()
|
||||
control<HTMLButtonElement>(root, '切到错误终止规则 JSON').click()
|
||||
await nextTick()
|
||||
const editor = root.querySelector<HTMLTextAreaElement>('textarea')
|
||||
if (!editor) throw new Error('Missing JSON editor')
|
||||
editor.value = '{'
|
||||
editor.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
control<HTMLButtonElement>(root, '切回错误提前终止规则表单').click()
|
||||
control<HTMLButtonElement>(root, '切回错误终止规则表单').click()
|
||||
await nextTick()
|
||||
expect(root.querySelector('[role="alert"]')?.textContent).toContain('JSON')
|
||||
expect(policy.value.failover_rules.error_stop_patterns).toHaveLength(0)
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
:key="section.key"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h4 class="text-sm font-medium">
|
||||
{{ section.title }}
|
||||
@@ -59,6 +59,7 @@
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="disabled"
|
||||
:title="jsonMode[section.key] ? `切回${section.title}表单` : `切到${section.title} JSON`"
|
||||
:aria-label="jsonMode[section.key] ? `切回${section.title}表单` : `切到${section.title} JSON`"
|
||||
@click="toggleJsonMode(section.key)"
|
||||
@@ -72,6 +73,7 @@
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="disabled"
|
||||
:title="`格式化${section.title} JSON`"
|
||||
:aria-label="`格式化${section.title} JSON`"
|
||||
@click="formatJsonDraft(section.key)"
|
||||
@@ -100,6 +102,7 @@
|
||||
:model-value="jsonDraft[section.key]"
|
||||
class="min-h-[160px] font-mono text-xs leading-relaxed"
|
||||
:disabled="disabled"
|
||||
:aria-label="`${section.title} JSON`"
|
||||
spellcheck="false"
|
||||
:placeholder="jsonPlaceholder(section.key)"
|
||||
@update:model-value="updateJsonDraft(section.key, $event)"
|
||||
@@ -125,16 +128,18 @@
|
||||
<div
|
||||
v-for="(rule, index) in modelValue.failover_rules[section.key]"
|
||||
:key="index"
|
||||
class="flex min-w-0 items-center gap-1"
|
||||
class="grid min-w-0 grid-cols-[minmax(0,1fr)_2rem] items-start gap-2"
|
||||
:class="section.key === 'error_stop_patterns' ? 'sm:grid-cols-[10rem_minmax(0,1fr)_2rem]' : ''"
|
||||
>
|
||||
<Input
|
||||
v-if="section.key === 'error_stop_patterns'"
|
||||
:model-value="rule.status_codes.join(',')"
|
||||
:model-value="statusDrafts[index] ?? rule.status_codes.join(', ')"
|
||||
:disabled="disabled"
|
||||
size="sm"
|
||||
class="w-full shrink-0 font-mono text-xs sm:w-40"
|
||||
class="col-span-2 min-w-0 w-full font-mono text-xs sm:col-span-1"
|
||||
:aria-label="`终止规则 ${index + 1} 状态码`"
|
||||
placeholder="状态码(选填,可多个)"
|
||||
placeholder="400, 413(选填)"
|
||||
title="状态码用逗号或空格分隔;留空则匹配全部错误状态"
|
||||
@update:model-value="updateStatuses(index, String($event))"
|
||||
/>
|
||||
<Input
|
||||
@@ -174,7 +179,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { AlignLeft, Code2, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { Button, Input, Textarea } from '@/components/ui'
|
||||
import {
|
||||
@@ -187,7 +192,10 @@ import {
|
||||
} from '../utils/routingFailover'
|
||||
|
||||
const props = defineProps<{ modelValue: RoutingFailoverPolicy, disabled?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: RoutingFailoverPolicy] }>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: RoutingFailoverPolicy]
|
||||
'pending-change': [value: boolean]
|
||||
}>()
|
||||
type RuleSection = 'success_failover_patterns' | 'error_stop_patterns'
|
||||
const jsonMode = reactive<Record<RuleSection, boolean>>({
|
||||
success_failover_patterns: false,
|
||||
@@ -205,17 +213,28 @@ const jsonDirty = reactive<Record<RuleSection, boolean>>({
|
||||
success_failover_patterns: false,
|
||||
error_stop_patterns: false,
|
||||
})
|
||||
const statusDrafts = ref<Record<number, string>>({})
|
||||
const ruleSections: Array<{ key: RuleSection, title: string, description: string }> = [
|
||||
{ key: 'success_failover_patterns', title: '成功转移规则', description: 'HTTP 200 的响应体或流式输出前的缓冲内容命中正则时,放弃当前候选并继续转移;不是对所有 200 都重试。' },
|
||||
{ key: 'error_stop_patterns', title: '错误提前终止规则', description: '状态码与正则同时满足时立即终止。可只填状态码,或只填正则匹配全部 400–599 错误;对流内错误使用解析后的错误状态。' },
|
||||
{ key: 'error_stop_patterns', title: '错误终止规则', description: '状态码与正则同时满足时立即终止。可只填状态码,或只填正则匹配全部 400–599 错误;对流内错误使用解析后的错误状态。' },
|
||||
]
|
||||
const validationError = computed(() => validateRoutingFailoverPolicy(props.modelValue))
|
||||
const validationError = computed(() => {
|
||||
const rules = errorRulesFromForm()
|
||||
return typeof rules === 'string' ? rules : validateRoutingFailoverPolicy(props.modelValue)
|
||||
})
|
||||
watch(
|
||||
() => jsonDirty.success_failover_patterns || jsonDirty.error_stop_patterns || Object.keys(statusDrafts.value).length > 0,
|
||||
value => emit('pending-change', value),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function updateLimit(field: 'max_transfer_count' | 'max_transfer_timeout_seconds', value: string | number) {
|
||||
if (props.disabled) return
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: Number(value) })
|
||||
}
|
||||
|
||||
function updateRules(patch: Partial<RoutingFailoverRules>) {
|
||||
if (props.disabled) return
|
||||
emit('update:modelValue', { ...props.modelValue, failover_rules: { ...props.modelValue.failover_rules, ...patch } })
|
||||
}
|
||||
|
||||
@@ -229,13 +248,14 @@ function stringifyRules(rules: RoutingFailoverRule[]): string {
|
||||
return JSON.stringify(rules, null, 2)
|
||||
}
|
||||
|
||||
function refreshJsonDraft(section: RuleSection) {
|
||||
jsonDraft[section] = stringifyRules(props.modelValue.failover_rules[section])
|
||||
function refreshJsonDraft(section: RuleSection, rules = props.modelValue.failover_rules[section]) {
|
||||
jsonDraft[section] = stringifyRules(rules)
|
||||
jsonError[section] = null
|
||||
jsonDirty[section] = false
|
||||
}
|
||||
|
||||
function updateJsonDraft(section: RuleSection, value: string) {
|
||||
if (props.disabled) return
|
||||
jsonDraft[section] = value
|
||||
jsonDirty[section] = true
|
||||
jsonError[section] = null
|
||||
@@ -270,7 +290,7 @@ function parseJsonRules(section: RuleSection, draft: string): RoutingFailoverRul
|
||||
const source = nested !== null && typeof nested === 'object' && !Array.isArray(nested)
|
||||
? nested as Record<string, unknown>
|
||||
: root
|
||||
entries = source[section] ?? []
|
||||
entries = source[section]
|
||||
}
|
||||
if (!Array.isArray(entries)) return `${section} JSON 必须是数组`
|
||||
if (entries.length > MAX_ROUTING_FAILOVER_RULES) return `${section} 最多 ${MAX_ROUTING_FAILOVER_RULES} 条`
|
||||
@@ -312,16 +332,21 @@ function applyJsonDraft(section: RuleSection): boolean {
|
||||
}
|
||||
|
||||
function toggleJsonMode(section: RuleSection) {
|
||||
if (props.disabled) return
|
||||
if (jsonMode[section]) {
|
||||
if (jsonDirty[section] && !applyJsonDraft(section)) return
|
||||
jsonMode[section] = false
|
||||
return
|
||||
}
|
||||
refreshJsonDraft(section)
|
||||
const rules = section === 'error_stop_patterns' ? errorRulesFromForm() : props.modelValue.failover_rules[section]
|
||||
if (typeof rules === 'string') return
|
||||
refreshJsonDraft(section, rules)
|
||||
if (section === 'error_stop_patterns') statusDrafts.value = {}
|
||||
jsonMode[section] = true
|
||||
}
|
||||
|
||||
function formatJsonDraft(section: RuleSection) {
|
||||
if (props.disabled) return
|
||||
const parsed = parseJsonRules(section, jsonDraft[section])
|
||||
if (typeof parsed === 'string') {
|
||||
jsonError[section] = parsed
|
||||
@@ -342,16 +367,50 @@ function updateRule(section: RuleSection, index: number, patch: Partial<RoutingF
|
||||
}
|
||||
|
||||
function updateStatuses(index: number, value: string | number) {
|
||||
const input = String(value).trim()
|
||||
updateRule('error_stop_patterns', index, { status_codes: input ? input.split(/[,,\s]+/).map(Number) : [] })
|
||||
if (props.disabled) return
|
||||
statusDrafts.value[index] = String(value)
|
||||
const codes = parseStatusInput(String(value), index)
|
||||
if (typeof codes !== 'string') updateRule('error_stop_patterns', index, { status_codes: codes })
|
||||
}
|
||||
|
||||
function parseStatusInput(value: string, index: number): number[] | string {
|
||||
const parts = value.trim().split(/[,,\s]+/).filter(Boolean)
|
||||
if (parts.some(part => !/^\d{3}$/.test(part) || Number(part) < 400 || Number(part) > 599)) {
|
||||
return `错误终止规则第 ${index + 1} 条状态码必须为 400–599,多个状态码用逗号或空格分隔`
|
||||
}
|
||||
return [...new Set(parts.map(Number))]
|
||||
}
|
||||
|
||||
function errorRulesFromForm(): RoutingFailoverRule[] | string {
|
||||
const rules = props.modelValue.failover_rules.error_stop_patterns.map(rule => ({ ...rule, status_codes: [...rule.status_codes] }))
|
||||
for (const [rawIndex, value] of Object.entries(statusDrafts.value)) {
|
||||
const index = Number(rawIndex)
|
||||
if (!rules[index]) continue
|
||||
const codes = parseStatusInput(value, index)
|
||||
if (typeof codes === 'string') return codes
|
||||
rules[index].status_codes = codes
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
function removeRule(section: RuleSection, index: number) {
|
||||
if (props.disabled) return
|
||||
if (section === 'error_stop_patterns') {
|
||||
const nextDrafts: Record<number, string> = {}
|
||||
for (const [rawIndex, value] of Object.entries(statusDrafts.value)) {
|
||||
const position = Number(rawIndex)
|
||||
if (position !== index) nextDrafts[position > index ? position - 1 : position] = value
|
||||
}
|
||||
statusDrafts.value = nextDrafts
|
||||
}
|
||||
updateRules({ [section]: props.modelValue.failover_rules[section].filter((_, position) => position !== index) })
|
||||
}
|
||||
|
||||
function commitJsonDrafts(): boolean {
|
||||
const parsedRules = new Map<RuleSection, RoutingFailoverRule[]>()
|
||||
if (props.disabled) return false
|
||||
const formErrors = errorRulesFromForm()
|
||||
if (typeof formErrors === 'string') return false
|
||||
const nextRules = { ...props.modelValue.failover_rules, error_stop_patterns: formErrors }
|
||||
for (const section of ruleSections.map(item => item.key)) {
|
||||
if (!jsonMode[section] || !jsonDirty[section]) continue
|
||||
const parsed = parseJsonRules(section, jsonDraft[section])
|
||||
@@ -359,13 +418,16 @@ function commitJsonDrafts(): boolean {
|
||||
jsonError[section] = parsed
|
||||
return false
|
||||
}
|
||||
parsedRules.set(section, parsed)
|
||||
nextRules[section] = parsed
|
||||
}
|
||||
for (const [section, rules] of parsedRules) {
|
||||
updateRules({ [section]: rules })
|
||||
jsonDraft[section] = stringifyRules(rules)
|
||||
if (validateRoutingFailoverPolicy({ ...props.modelValue, failover_rules: nextRules })) return false
|
||||
updateRules(nextRules)
|
||||
for (const { key: section } of ruleSections) {
|
||||
jsonDraft[section] = stringifyRules(nextRules[section])
|
||||
jsonError[section] = null
|
||||
jsonDirty[section] = false
|
||||
}
|
||||
statusDrafts.value = {}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export function validateRoutingFailoverPolicy(policy: RoutingFailoverPolicy): st
|
||||
}
|
||||
for (const [name, entries, success] of [
|
||||
['成功转移规则', policy.failover_rules.success_failover_patterns, true],
|
||||
['错误提前终止规则', policy.failover_rules.error_stop_patterns, false],
|
||||
['错误终止规则', policy.failover_rules.error_stop_patterns, false],
|
||||
] as const) {
|
||||
if (entries.length > MAX_ROUTING_FAILOVER_RULES) return `${name}最多 ${MAX_ROUTING_FAILOVER_RULES} 条`
|
||||
for (const [index, rule] of entries.entries()) {
|
||||
|
||||
@@ -501,10 +501,12 @@
|
||||
</section>
|
||||
|
||||
<RoutingFailoverPolicyEditor
|
||||
:key="draftGeneration"
|
||||
ref="routingFailoverPolicyEditor"
|
||||
:model-value="draft.config_json.default_policy"
|
||||
:disabled="saving"
|
||||
@update:model-value="draft.config_json.default_policy = { ...draft.config_json.default_policy, ...$event }"
|
||||
@update:model-value="updateRoutingFailoverPolicy"
|
||||
@pending-change="routingFailoverPending = $event"
|
||||
/>
|
||||
|
||||
<section class="space-y-4 rounded-lg border border-border/60 p-4">
|
||||
@@ -890,7 +892,7 @@ import {
|
||||
type RoutingSortingScope,
|
||||
} from '@/features/routing/utils/routingPolicy'
|
||||
import { RoutingFailoverPolicyEditor, RoutingPriorityPolicyEditor } from '@/features/routing/components'
|
||||
import { validateRoutingFailoverPolicy } from '@/features/routing/utils/routingFailover'
|
||||
import { normalizeRoutingFailoverPolicy, validateRoutingFailoverPolicy, type RoutingFailoverPolicy } from '@/features/routing/utils/routingFailover'
|
||||
import {
|
||||
createRoutingGroup,
|
||||
deleteRoutingGroup,
|
||||
@@ -935,6 +937,7 @@ const groups = ref<RoutingGroupRecord[]>([])
|
||||
const selectedGroupId = ref<string | null>(null)
|
||||
const draft = ref<RoutingGroupDraft | null>(null)
|
||||
const routingFailoverPolicyEditor = ref<{ commitJsonDrafts: () => boolean } | null>(null)
|
||||
const routingFailoverPending = ref(false)
|
||||
const savedDraftSnapshot = ref<string | null>(null)
|
||||
const sortingScope = ref<RoutingSortingScope>('unified')
|
||||
const selectedPerModelName = ref<string | null>(null)
|
||||
@@ -952,7 +955,7 @@ const groupActionId = ref<string | null>(null)
|
||||
const draggedGroupId = ref<string | null>(null)
|
||||
const dragOverGroupId = ref<string | null>(null)
|
||||
const isCreating = ref(false)
|
||||
let draftGeneration = 0
|
||||
const draftGeneration = ref(0)
|
||||
|
||||
const switchModelTarget = ref<string | null>(null)
|
||||
const switchModelDialogOpen = ref(false)
|
||||
@@ -1098,7 +1101,8 @@ function paramToString(value: unknown): string | null {
|
||||
}
|
||||
|
||||
function clearDraftState(): void {
|
||||
draftGeneration += 1
|
||||
draftGeneration.value += 1
|
||||
routingFailoverPending.value = false
|
||||
isCreating.value = false
|
||||
selectedGroupId.value = null
|
||||
draft.value = null
|
||||
@@ -1113,7 +1117,8 @@ function clearDraftState(): void {
|
||||
|
||||
function selectGroup(group: RoutingGroupRecord): void {
|
||||
const normalized = normalizeRecord(group)
|
||||
draftGeneration += 1
|
||||
draftGeneration.value += 1
|
||||
routingFailoverPending.value = false
|
||||
isCreating.value = false
|
||||
selectedGroupId.value = normalized.id
|
||||
draft.value = buildDraft(normalized)
|
||||
@@ -1128,7 +1133,8 @@ function setDraftEnabled(value: boolean): void {
|
||||
}
|
||||
|
||||
function startCreate(): void {
|
||||
draftGeneration += 1
|
||||
draftGeneration.value += 1
|
||||
routingFailoverPending.value = false
|
||||
isCreating.value = true
|
||||
selectedGroupId.value = null
|
||||
draft.value = {
|
||||
@@ -1228,7 +1234,7 @@ const editingDirty = computed(() => {
|
||||
const draftDirty = computed(() => {
|
||||
if (!draft.value) return false
|
||||
if (isCreating.value) return true
|
||||
return savedDraftSnapshot.value !== draftSnapshotValue(draft.value)
|
||||
return routingFailoverPending.value || savedDraftSnapshot.value !== draftSnapshotValue(draft.value)
|
||||
})
|
||||
|
||||
const canSaveDraft = computed(() => {
|
||||
@@ -1349,6 +1355,15 @@ function updateExecutionPolicy(
|
||||
})
|
||||
}
|
||||
|
||||
function updateRoutingFailoverPolicy(value: RoutingFailoverPolicy): void {
|
||||
if (!draft.value) return
|
||||
const patch = normalizeRoutingFailoverPolicy(value)
|
||||
Object.assign(draft.value.config_json.default_policy, patch)
|
||||
if (editingConfig.value) {
|
||||
Object.assign(editingConfig.value.default_policy, normalizeRoutingFailoverPolicy(patch))
|
||||
}
|
||||
}
|
||||
|
||||
function removePerModelPolicy(model: string): void {
|
||||
if (!draft.value) return
|
||||
if (perModelEditingActive.value && editingDirty.value) {
|
||||
@@ -1658,19 +1673,19 @@ async function saveDraft(): Promise<void> {
|
||||
return
|
||||
}
|
||||
if (routingFailoverPolicyEditor.value && !routingFailoverPolicyEditor.value.commitJsonDrafts()) return
|
||||
const config = cloneConfig(draft.value.config_json)
|
||||
const failoverError = validateRoutingFailoverPolicy(config.default_policy)
|
||||
const failoverError = validateRoutingFailoverPolicy(draft.value.config_json.default_policy)
|
||||
if (failoverError) {
|
||||
showError(failoverError)
|
||||
return
|
||||
}
|
||||
const config = cloneConfig(draft.value.config_json)
|
||||
if (sortingScope.value === 'per_model' && perModelPolicies.value.length === 0) {
|
||||
showError('按模型排序时至少选择一个模型')
|
||||
return
|
||||
}
|
||||
|
||||
const targetGroupId = draft.value.id ?? null
|
||||
const submittedGeneration = draftGeneration
|
||||
const submittedGeneration = draftGeneration.value
|
||||
const submittedSnapshot = draftSnapshotValue(draft.value)
|
||||
const wasCreating = isCreating.value || !draft.value.id
|
||||
saving.value = true
|
||||
@@ -1689,7 +1704,7 @@ async function saveDraft(): Promise<void> {
|
||||
? await createRoutingGroup(payload)
|
||||
: await updateRoutingGroup(targetGroupId, payload)
|
||||
|
||||
const sameDraftGeneration = draftGeneration === submittedGeneration
|
||||
const sameDraftGeneration = draftGeneration.value === submittedGeneration
|
||||
const stillEditingSubmittedDraft = wasCreating
|
||||
? sameDraftGeneration
|
||||
&& isCreateRoute.value
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick, reactive, type App } from 'vue'
|
||||
import RoutingProfiles from '../RoutingProfiles.vue'
|
||||
import { createEmptyRoutingGroupConfig, getModelScheduling, savePerModelRoutingConfig } from '@/features/routing/utils/routingPolicy'
|
||||
import type { RoutingGroupRecord, RoutingGroupUpdateRequest } from '@/api/routing-profiles'
|
||||
|
||||
const routingApi = vi.hoisted(() => ({
|
||||
listRoutingGroups: vi.fn(),
|
||||
updateRoutingGroup: vi.fn(),
|
||||
createRoutingGroup: vi.fn(),
|
||||
deleteRoutingGroup: vi.fn(),
|
||||
}))
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
|
||||
const route = reactive({ name: 'RoutingProfileDetail', params: { groupId: 'strategy-a' } })
|
||||
|
||||
vi.mock('@/api/routing-profiles', () => routingApi)
|
||||
vi.mock('@/api/global-models', () => ({ getGlobalModels: vi.fn().mockResolvedValue({ models: [] }) }))
|
||||
vi.mock('@/composables/useToast', () => ({ useToast: () => toast }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ replace: vi.fn(), push: vi.fn() }) }))
|
||||
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn(), warn: vi.fn() } }))
|
||||
vi.mock('@/features/routing/components', async () => ({
|
||||
RoutingFailoverPolicyEditor: (await import('@/features/routing/components/RoutingFailoverPolicyEditor.vue')).default,
|
||||
RoutingPriorityPolicyEditor: { render: () => null },
|
||||
}))
|
||||
|
||||
const mounted: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function group(id: string): RoutingGroupRecord {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
enabled: true,
|
||||
is_system_default: false,
|
||||
sort_order: 0,
|
||||
config_json: createEmptyRoutingGroupConfig(),
|
||||
version: 1,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await nextTick()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function mountPage(groups = [group('strategy-a'), group('strategy-b')]) {
|
||||
routingApi.listRoutingGroups.mockResolvedValue({ items: groups, total: groups.length })
|
||||
routingApi.updateRoutingGroup.mockImplementation(async (id: string, payload: RoutingGroupUpdateRequest) => ({
|
||||
...groups.find(entry => entry.id === id),
|
||||
...payload,
|
||||
version: 2,
|
||||
}))
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(RoutingProfiles)
|
||||
app.mount(root)
|
||||
mounted.push({ app, root })
|
||||
await flush()
|
||||
return root
|
||||
}
|
||||
|
||||
function element<T extends HTMLElement>(root: HTMLElement, selector: string): T {
|
||||
const found = root.querySelector<T>(selector)
|
||||
if (!found) throw new Error(`Missing element: ${selector}`)
|
||||
return found
|
||||
}
|
||||
|
||||
function button(root: HTMLElement, label: string): HTMLButtonElement {
|
||||
return element(root, `button[aria-label="${label}"]`)
|
||||
}
|
||||
|
||||
async function input(root: HTMLElement, label: string, value: string) {
|
||||
const field = element<HTMLInputElement | HTMLTextAreaElement>(root, `[aria-label="${label}"]`)
|
||||
field.value = value
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function editJson(root: HTMLElement, section: string, value: string) {
|
||||
button(root, `切到${section} JSON`).click()
|
||||
await nextTick()
|
||||
await input(root, `${section} JSON`, value)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
route.name = 'RoutingProfileDetail'
|
||||
route.params.groupId = 'strategy-a'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mounted.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('RoutingProfiles failover persistence', () => {
|
||||
it('enables Save for JSON-only edits and persists both sections together', async () => {
|
||||
const root = await mountPage()
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
await editJson(root, '成功转移规则', '[{"pattern":"(?i)capacity"}]')
|
||||
await editJson(root, '错误终止规则', '[{"status_codes":[400,413]}]')
|
||||
expect(button(root, '保存').disabled).toBe(false)
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
|
||||
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules).toEqual({
|
||||
success_failover_patterns: [{ pattern: '(?i)capacity', status_codes: [] }],
|
||||
error_stop_patterns: [{ pattern: '', status_codes: [400, 413] }],
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not submit partial JSON drafts when either section is invalid', async () => {
|
||||
const root = await mountPage()
|
||||
await editJson(root, '成功转移规则', '[{"pattern":"capacity"}]')
|
||||
await editJson(root, '错误终止规则', '{')
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
|
||||
expect(root.querySelector('[role="alert"]')).not.toBeNull()
|
||||
await input(root, '错误终止规则 JSON', '[{"status_codes":[429]}]')
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
|
||||
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('discards local rule drafts when navigating to another strategy', async () => {
|
||||
const root = await mountPage()
|
||||
await editJson(root, '成功转移规则', '[{"pattern":"only-strategy-a"}]')
|
||||
route.params.groupId = 'strategy-b'
|
||||
await flush()
|
||||
expect(root.querySelector('textarea[aria-label="成功转移规则 JSON"]')).toBeNull()
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
await input(root, '全局最大转移次数', '3')
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(routingApi.updateRoutingGroup.mock.calls[0][0]).toBe('strategy-b')
|
||||
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves global failover edits while saving an independently edited model', async () => {
|
||||
const strategy = group('strategy-a')
|
||||
strategy.config_json = savePerModelRoutingConfig(strategy.config_json, 'model-a')
|
||||
const root = await mountPage([strategy])
|
||||
const configured = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '已配置')
|
||||
configured?.click()
|
||||
await nextTick()
|
||||
const loadBalance = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '负载均衡')
|
||||
if (!loadBalance) throw new Error('Missing model scheduling control')
|
||||
loadBalance.click()
|
||||
await nextTick()
|
||||
await input(root, '全局最大转移次数', '5')
|
||||
button(root, '添加错误终止规则').click()
|
||||
await nextTick()
|
||||
await input(root, '终止规则 1 状态码', '429')
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
element<HTMLButtonElement>(root, 'button[title="保存到草稿"]').click()
|
||||
await nextTick()
|
||||
expect(button(root, '保存').disabled).toBe(false)
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
|
||||
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
|
||||
expect(saved.default_policy.max_transfer_count).toBe(5)
|
||||
expect(saved.default_policy.failover_rules.error_stop_patterns).toEqual([{ pattern: '', status_codes: [429] }])
|
||||
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('load_balance')
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user