fix: harden routing failover, model testing, and wallet queries

This commit is contained in:
elky
2026-09-09 10:38:25 +08:00
parent a893bd0557
commit 6630856061
14 changed files with 1530 additions and 84 deletions
@@ -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()) {
+26 -11
View File
@@ -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()
})
})