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)'