mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 03:39:49 +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)'
|
||||
|
||||
Reference in New Issue
Block a user