feat(test): 模型测试支持故障转移,展示每次尝试详情

- 后端新增 test-model-failover 接口,支持 global/direct 两种测试模式
- 利用 FailoverEngine 遍历候选并记录每次尝试的状态、延迟、错误等详情
- 前端 ModelsTab/ModelMappingTab 切换到新接口,移除格式选择下拉菜单
- 新增 TestResultDialog 组件,失败时展示候选尝试详情表格
- 简化组件 props 传递,移除不再需要的 endpoints/mappingPreview 依赖
This commit is contained in:
fawney19
2026-03-01 03:37:41 +08:00
parent fea3d183bf
commit b61fc4eb6b
7 changed files with 786 additions and 242 deletions

View File

@@ -129,6 +129,48 @@ export async function testModel(data: TestModelRequest): Promise<TestModelRespon
return response.data
}
/**
* 带故障转移的模型测试
*/
export interface TestModelFailoverRequest {
provider_id: string
mode: 'global' | 'direct'
model_name: string
api_format?: string
message?: string
}
export interface TestAttemptDetail {
candidate_index: number
endpoint_api_format: string
endpoint_base_url: string
key_name: string | null
key_id: string
auth_type: string
effective_model?: string | null
status: 'success' | 'failed' | 'skipped'
skip_reason?: string | null
error_message?: string | null
status_code?: number | null
latency_ms?: number | null
}
export interface TestModelFailoverResponse {
success: boolean
model: string
provider: { id: string; name: string }
attempts: TestAttemptDetail[]
total_candidates: number
total_attempts: number
data?: Record<string, unknown> | null
error?: string | null
}
export async function testModelFailover(data: TestModelFailoverRequest): Promise<TestModelFailoverResponse> {
const response = await client.post('/api/admin/provider-query/test-model-failover', data)
return response.data
}
/**
* 映射预览相关类型
*/

View File

@@ -891,9 +891,7 @@
v-if="provider"
:key="`models-${provider.id}`"
:provider="provider"
:endpoints="endpoints"
:models="providerModels"
:mapping-preview="providerMappingPreview"
@edit-model="handleEditModel"
@batch-assign="handleBatchAssign"
@refresh="loadEndpoints"
@@ -908,7 +906,6 @@
:provider-keys="providerKeys"
:models="providerModels"
:mapping-preview="providerMappingPreview"
:endpoints="endpoints"
@refresh="handleModelMappingChanged"
/>
</div>

View File

@@ -151,47 +151,14 @@
<span class="font-mono text-sm truncate">
{{ mapping.name }}
</span>
<!-- 测试按钮支持多格式选择 -->
<DropdownMenu
v-if="getItemAvailableFormats(item).length > 1"
v-model:open="formatMenuOpen[`${item.key}-${mapping.name}`]"
>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
title="测试映射"
:disabled="testingMapping === `${item.key}-${mapping.name}`"
>
<Loader2
v-if="testingMapping === `${item.key}-${mapping.name}`"
class="w-3 h-3 animate-spin"
/>
<Play
v-else
class="w-3 h-3"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-for="fmt in getItemAvailableFormats(item)"
:key="fmt"
@select="testMapping(item, mapping, fmt)"
>
{{ fmt }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- 测试按钮直连测试 -->
<Button
v-else
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
title="测试映射"
:disabled="testingMapping === `${item.key}-${mapping.name}` || getItemAvailableFormats(item).length === 0"
@click="testMapping(item, mapping, getItemAvailableFormats(item)[0])"
:disabled="testingMapping === `${item.key}-${mapping.name}`"
@click="testMapping(item, mapping)"
>
<Loader2
v-if="testingMapping === `${item.key}-${mapping.name}`"
@@ -340,6 +307,13 @@
@confirm="confirmDelete"
@cancel="deleteConfirmOpen = false"
/>
<!-- 测试结果对话框仅失败时显示 -->
<TestResultDialog
:result="testResult"
mode="direct"
@close="testResult = null"
/>
</template>
<script setup lang="ts">
@@ -348,21 +322,21 @@ import { useSmartPagination } from '@/composables/useSmartPagination'
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
import {
Card, Button, Badge,
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem
} from '@/components/ui'
import AlertDialog from '@/components/common/AlertDialog.vue'
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
import TestResultDialog from './TestResultDialog.vue'
import { useToast } from '@/composables/useToast'
import {
testModel,
testModelFailover,
type Model,
type ProviderModelAlias,
type ProviderMappingPreviewResponse
type ProviderMappingPreviewResponse,
type TestModelFailoverResponse
} from '@/api/endpoints'
import { type EndpointAPIKey } from '@/api/endpoints/keys'
import type { ProviderEndpoint } from '@/api/endpoints/types'
import { updateModel } from '@/api/endpoints/models'
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
import { parseApiError } from '@/utils/errorParser'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
interface MappingItem {
@@ -394,7 +368,6 @@ const props = defineProps<{
providerKeys?: EndpointAPIKey[]
models?: Model[]
mappingPreview?: ProviderMappingPreviewResponse | null
endpoints?: ProviderEndpoint[]
}>()
const emit = defineEmits<{
@@ -410,15 +383,12 @@ const deleteConfirmOpen = ref(false)
const editingGroup = ref<AliasGroup | null>(null)
const deletingGroup = ref<AliasGroup | null>(null)
const testingMapping = ref<string | null>(null)
const testResult = ref<TestModelFailoverResponse | null>(null)
const preselectedModelId = ref<string | null>(null)
// 测试下拉菜单状态
const formatMenuOpen = ref<Record<string, boolean>>({})
// 使用 props 传入的数据
const models = computed(() => props.models ?? [])
const aliasMappingPreview = computed(() => props.mappingPreview ?? null)
const providerEndpoints = computed(() => props.endpoints ?? [])
const providerKeysState = computed(() => props.providerKeys ?? [])
// 是否有 key 配置了自动获取上游模型
@@ -647,74 +617,24 @@ async function onDialogSaved() {
emit('refresh')
}
// 获取可用的 API 格式(所有端点,去重;测试只关注 Key 是否支持,不依赖端点启用状态
const availableApiFormats = computed(() => {
const formats = new Set(
providerEndpoints.value
.map(ep => ep.api_format)
)
return [...formats]
})
// 获取映射项支持的 API 格式
// 逻辑:找到支持该映射格式的所有活跃 Key获取这些 Key 支持的所有格式,与端点格式取交集
function getItemAvailableFormats(item: CombinedMapping): string[] {
// 精确映射:基于 group.apiFormats 筛选
if (item.type === 'exact' && item.group?.apiFormats && item.group.apiFormats.length > 0) {
const mappingFormats = item.group.apiFormats
// 找到所有支持该映射格式的活跃 Key
const supportingKeys = providerKeysState.value.filter(key => {
if (!key.is_active) return false
// Key 的 api_formats 与映射的 apiFormats 有交集
return key.api_formats?.some(fmt => mappingFormats.includes(fmt))
})
if (supportingKeys.length === 0) {
return []
}
// 收集这些 Key 支持的所有格式
const keyFormats = new Set<string>()
for (const key of supportingKeys) {
for (const fmt of key.api_formats || []) {
keyFormats.add(fmt)
}
}
// 与端点格式取交集
return availableApiFormats.value.filter(fmt => keyFormats.has(fmt))
}
// 正则映射或无限制:返回所有有活跃 Key 支持的端点格式
const allKeyFormats = new Set<string>()
for (const key of providerKeysState.value) {
if (!key.is_active) continue
for (const fmt of key.api_formats || []) {
allKeyFormats.add(fmt)
}
}
return availableApiFormats.value.filter(fmt => allKeyFormats.has(fmt))
}
// 测试精确映射(直接发请求或显示下拉菜单选择格式)
async function testMapping(item: CombinedMapping, mapping: MappingItem, apiFormat?: string) {
const testingKey = `${item.key}-${mapping.name}`
// 测试映射(直连测试,带故障转移
async function runMappingTest(testingKey: string, modelName: string) {
testingMapping.value = testingKey
formatMenuOpen.value[testingKey] = false
try {
const result = await testModel({
const result = await testModelFailover({
provider_id: props.provider.id,
model_name: mapping.name,
mode: 'direct',
model_name: modelName,
message: "hello",
api_format: apiFormat
})
if (result.success) {
showSuccess(`映射 "${mapping.name}" 测试成功`)
const successAttempt = result.attempts.find(a => a.status === 'success')
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
showSuccess(`映射 "${modelName}" 测试成功${latency}`)
} else {
showError(`映射测试失败: ${parseTestModelError(result)}`)
testResult.value = result
}
} catch (err: unknown) {
showError(`映射测试失败: ${parseApiError(err, '测试请求失败')}`)
@@ -723,29 +643,14 @@ async function testMapping(item: CombinedMapping, mapping: MappingItem, apiForma
}
}
// 测试正则映射(指定 Key直接发请求因为已经有 Key 信息)
async function testRegexMapping(item: CombinedMapping, keyItem: MatchedKeyInfo, match: MappingItem) {
const testingKey = `${item.key}-${keyItem.keyId}-${match.name}`
testingMapping.value = testingKey
// 测试精确映射
function testMapping(item: CombinedMapping, mapping: MappingItem) {
runMappingTest(`${item.key}-${mapping.name}`, mapping.name)
}
try {
const result = await testModel({
provider_id: props.provider.id,
model_name: match.name,
message: "hello",
api_key_id: keyItem.keyId
})
if (result.success) {
showSuccess(`映射 "${match.name}" 测试成功`)
} else {
showError(`映射测试失败: ${parseTestModelError(result)}`)
}
} catch (err: unknown) {
showError(`映射测试失败: ${parseApiError(err, '测试请求失败')}`)
} finally {
testingMapping.value = null
}
// 测试正则映射
function testRegexMapping(item: CombinedMapping, keyItem: MatchedKeyInfo, match: MappingItem) {
runMappingTest(`${item.key}-${keyItem.keyId}-${match.name}`, match.name)
}
// 暴露给父组件

View File

@@ -124,47 +124,14 @@
</td>
<td class="align-top px-4 py-3">
<div class="flex justify-end gap-1">
<!-- 测试按钮支持多格式选择 -->
<DropdownMenu
v-if="availableApiFormats.length > 1"
v-model:open="formatMenuOpen[model.id]"
>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="测试模型"
:disabled="testingModelId === model.id"
>
<Loader2
v-if="testingModelId === model.id"
class="w-3.5 h-3.5 animate-spin"
/>
<Play
v-else
class="w-3.5 h-3.5"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
v-for="fmt in availableApiFormats"
:key="fmt"
@select="testModelConnection(model, fmt)"
>
{{ fmt }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- 测试按钮模拟外部请求 -->
<Button
v-else
variant="ghost"
size="icon"
class="h-8 w-8"
title="测试模型"
:disabled="testingModelId === model.id"
@click="testModelConnection(model, availableApiFormats[0])"
@click="testModelConnection(model)"
>
<Loader2
v-if="testingModelId === model.id"
@@ -243,6 +210,13 @@
</p>
</div>
</Card>
<!-- 测试结果对话框 -->
<TestResultDialog
:result="testResult"
:mode="testResultMode"
@close="testResult = null"
/>
</template>
<script setup lang="ts">
@@ -251,36 +225,22 @@ import { useSmartPagination } from '@/composables/useSmartPagination'
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { sortResolutionEntries } from '@/utils/form'
import {
testModel,
testModelFailover,
type Model,
type ProviderMappingPreviewResponse
type TestModelFailoverResponse,
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
import { parseApiError } from '@/utils/errorParser'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
interface Endpoint {
id: string
api_format: string
is_active: boolean
active_keys?: number
}
import TestResultDialog from './TestResultDialog.vue'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
endpoints?: Endpoint[]
models?: Model[]
mappingPreview?: ProviderMappingPreviewResponse | null
}>()
const emit = defineEmits<{
@@ -295,23 +255,12 @@ const { copyToClipboard } = useClipboard()
// 状态
const loading = ref(false)
const localModels = ref<Model[]>([])
const localMappingPreview = ref<ProviderMappingPreviewResponse | null>(null)
const togglingModelId = ref<string | null>(null)
const testingModelId = ref<string | null>(null)
const formatMenuOpen = ref<Record<string, boolean>>({})
const testResult = ref<TestModelFailoverResponse | null>(null)
const testResultMode = ref<'global' | 'direct'>('global')
// 使用 props 传入的数据,或使用本地数据
const models = computed(() => props.models ?? localModels.value)
const mappingPreview = computed(() => props.mappingPreview ?? localMappingPreview.value)
// 获取可用的 API 格式(有活跃端点且有活跃 Key
const availableApiFormats = computed(() => {
if (!props.endpoints) return []
return props.endpoints
.filter(ep => ep.is_active && (ep.active_keys ?? 0) > 0)
.map(ep => ep.api_format)
})
// 按名称排序的模型列表
const sortedModels = computed(() => {
return [...models.value].sort((a, b) => {
@@ -472,63 +421,31 @@ async function toggleModelActive(model: Model) {
}
}
// 查找模型的正则映射信息(返回第一个匹配的活跃 key 和映射名称
function findRegexMapping(model: Model): { keyId: string; mappedName: string } | null {
if (!mappingPreview.value) return null
// 在映射预览中查找该模型的全局模型 ID
const globalModelId = model.global_model_id
if (!globalModelId) return null
for (const keyInfo of mappingPreview.value.keys) {
// 跳过未激活的 key
if (!keyInfo.is_active) continue
for (const gm of keyInfo.matching_global_models) {
if (gm.global_model_id === globalModelId && gm.matched_models.length > 0) {
// 返回第一个匹配的映射名称
return {
keyId: keyInfo.key_id,
mappedName: gm.matched_models[0].allowed_model
}
}
}
}
return null
}
// 测试模型连接性
async function testModelConnection(model: Model, apiFormat?: string) {
// 测试模型连接性(模拟外部请求,带故障转移
async function testModelConnection(model: Model) {
if (testingModelId.value) return
testingModelId.value = model.id
formatMenuOpen.value[model.id] = false
try {
// 检查是否有正则映射,如果有则使用映射名称和指定 key
const regexMapping = findRegexMapping(model)
const modelName = regexMapping?.mappedName || model.provider_model_name
const apiKeyId = regexMapping?.keyId
const modelName = model.global_model_name || model.provider_model_name
const result = await testModel({
const result = await testModelFailover({
provider_id: props.provider.id,
mode: 'global',
model_name: modelName,
message: "hello",
api_format: apiFormat,
api_key_id: apiKeyId
})
if (result.success) {
// 根据响应内容显示不同的成功消息
if (result.data?.response?.choices?.[0]?.message?.content) {
const content = result.data.response.choices[0].message.content
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
} else if (result.data?.content_preview) {
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
} else {
showSuccess(`模型 "${modelName}" 测试成功`)
}
const successAttempt = result.attempts.find(a => a.status === 'success')
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== modelName
? ` -> ${successAttempt.effective_model}`
: ''
showSuccess(`${modelName}${mapped} 测试成功${latency}`)
} else {
showError(`模型测试失败: ${parseTestModelError(result)}`)
testResultMode.value = 'global'
testResult.value = result
}
} catch (err: unknown) {
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)

View File

@@ -0,0 +1,221 @@
<template>
<Dialog
:open="!!result"
size="2xl"
title="模型测试结果"
@update:open="(val: boolean) => { if (!val) $emit('close') }"
>
<div
v-if="result"
class="space-y-4"
>
<!-- 总体状态 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Badge :variant="result.success ? 'success' : 'destructive'">
{{ result.success ? '成功' : '失败' }}
</Badge>
<span class="text-sm text-muted-foreground">
{{ modeLabel }}
</span>
</div>
<div class="text-xs text-muted-foreground">
候选 {{ result.total_candidates }} / 尝试 {{ result.total_attempts }}
</div>
</div>
<!-- 模型信息 -->
<div class="text-sm space-y-1">
<div>
<span class="text-muted-foreground">请求模型: </span>
<span class="font-medium">{{ result.model }}</span>
</div>
<div v-if="successEffectiveModel">
<span class="text-muted-foreground">发送模型: </span>
<span class="font-medium text-primary">{{ successEffectiveModel }}</span>
<span
v-if="successEffectiveModel !== result.model"
class="text-xs text-muted-foreground ml-1"
>(已映射)</span>
</div>
</div>
<!-- 错误信息 -->
<div
v-if="result.error && !result.success"
class="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-xs text-destructive"
>
{{ result.error }}
</div>
<!-- Attempt 详情表 -->
<div
v-if="result.attempts.length > 0"
class="border rounded-md overflow-hidden"
>
<table class="w-full text-xs">
<thead>
<tr class="border-b bg-muted/30">
<th class="px-3 py-2 text-left font-medium">
#
</th>
<th class="px-3 py-2 text-left font-medium">
Key
</th>
<th class="px-3 py-2 text-left font-medium">
格式
</th>
<th
v-if="hasEffectiveModel"
class="px-3 py-2 text-left font-medium"
>
发送模型
</th>
<th class="px-3 py-2 text-left font-medium">
状态
</th>
<th class="px-3 py-2 text-right font-medium">
延迟
</th>
<th class="px-3 py-2 text-left font-medium">
详情
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(attempt, idx) in result.attempts"
:key="idx"
class="border-b last:border-b-0"
:class="attemptRowClass(attempt.status)"
>
<td class="px-3 py-2 text-muted-foreground">
{{ attempt.candidate_index }}
</td>
<td
class="px-3 py-2 max-w-[160px] truncate"
:title="attempt.key_name || attempt.key_id"
>
{{ attempt.key_name || (attempt.key_id.length > 8 ? attempt.key_id.slice(0, 8) + '...' : attempt.key_id) }}
<span class="text-muted-foreground ml-1">({{ attempt.auth_type }})</span>
</td>
<td class="px-3 py-2">
<code class="text-[11px] bg-muted px-1 py-0.5 rounded">
{{ attempt.endpoint_api_format }}
</code>
</td>
<td
v-if="hasEffectiveModel"
class="px-3 py-2 max-w-[180px] truncate"
:title="attempt.effective_model || '-'"
>
{{ attempt.effective_model || '-' }}
</td>
<td class="px-3 py-2">
<Badge
:variant="statusVariant(attempt.status)"
class="text-[10px] px-1.5 py-0"
>
{{ statusLabel(attempt.status) }}
</Badge>
<span
v-if="attempt.status_code"
class="text-muted-foreground ml-1"
>
{{ attempt.status_code }}
</span>
</td>
<td class="px-3 py-2 text-right text-muted-foreground">
{{ attempt.latency_ms != null ? attempt.latency_ms + 'ms' : '-' }}
</td>
<td
class="px-3 py-2 max-w-[200px] truncate text-muted-foreground"
:title="attemptDetail(attempt)"
>
{{ attemptDetail(attempt) }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- attempt -->
<div
v-else
class="text-center text-sm text-muted-foreground py-4"
>
没有可用的候选进行测试
</div>
</div>
<template #footer>
<Button
variant="outline"
size="sm"
@click="$emit('close')"
>
关闭
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Dialog, Badge } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import type { TestModelFailoverResponse, TestAttemptDetail } from '@/api/endpoints/providers'
const props = defineProps<{
result: TestModelFailoverResponse | null
mode?: 'global' | 'direct'
}>()
defineEmits<{
close: []
}>()
const modeLabel = computed(() => {
if (props.mode === 'global') return '模拟外部请求'
if (props.mode === 'direct') return '直接测试'
return ''
})
const successEffectiveModel = computed(() => {
if (!props.result) return null
const successAttempt = props.result.attempts.find(a => a.status === 'success')
return successAttempt?.effective_model || null
})
const hasEffectiveModel = computed(() => {
if (!props.result) return false
return props.result.attempts.some(a => a.effective_model && a.effective_model !== props.result?.model)
})
function statusVariant(status: string) {
if (status === 'success') return 'success' as const
if (status === 'failed') return 'destructive' as const
return 'secondary' as const
}
function statusLabel(status: string) {
if (status === 'success') return '成功'
if (status === 'failed') return '失败'
if (status === 'skipped') return '跳过'
return status
}
function attemptRowClass(status: string) {
if (status === 'success') return 'bg-green-500/5'
if (status === 'failed') return 'bg-red-500/5'
if (status === 'skipped') return 'bg-muted/20'
return ''
}
function attemptDetail(attempt: TestAttemptDetail): string {
if (attempt.skip_reason) return attempt.skip_reason
if (attempt.error_message) return attempt.error_message
if (attempt.status === 'success') return attempt.endpoint_base_url
return '-'
}
</script>

View File

@@ -1,9 +1,11 @@
<template>
<div class="markdown-viewer-container">
<!-- eslint-disable vue/no-v-html -->
<div
class="markdown-body"
v-html="renderedHtml"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
</template>