feat(test,quota,failover): 模型并发测试、统一配额读取器与故障转移取消支持

- 新增 QuotaReader 抽象层,统一 Codex/Kiro/Antigravity 配额解析逻辑,
  替换 pool/routes.py 中分散的配额构建函数
- 模型测试支持并发执行多候选,前端新增 useModelTest composable 统一
  ModelsTab 和 ModelMappingTab 的测试逻辑
- ModelTestDialog 增加结果概览摘要、超长结果折叠、端点列和新状态支持,
  删除已合并的 TestResultDialog
- FailoverEngine 新增客户端断开检测,支持取消剩余候选并标记记录
- 刷新配额改为分批执行,直连测试候选按可用性排序
- 修复 error 判断从 "error" in dict 改为 dict.get("error") 避免误判
This commit is contained in:
fawney19
2026-03-07 02:16:03 +08:00
parent 1f3693d3a2
commit fb1aeb789a
22 changed files with 2145 additions and 987 deletions

View File

@@ -202,7 +202,11 @@ export async function refreshProviderQuota(
keyIds?: string[],
): Promise<RefreshQuotaResult> {
const body = keyIds && keyIds.length > 0 ? { key_ids: keyIds } : undefined
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/refresh-quota`, body)
const response = await client.post(
`/api/admin/endpoints/providers/${providerId}/refresh-quota`,
body,
{ timeout: 5 * 60 * 1000 },
)
return response.data
}

View File

@@ -148,6 +148,7 @@ export interface TestModelFailoverRequest {
endpoint_id?: string
message?: string
request_id?: string
concurrency?: number
}
export interface TestAttemptDetail {
@@ -159,7 +160,7 @@ export interface TestAttemptDetail {
key_id: string
auth_type: string
effective_model?: string | null
status: 'success' | 'failed' | 'skipped'
status: 'success' | 'failed' | 'skipped' | 'cancelled' | 'pending' | 'streaming' | 'stream_interrupted' | 'available' | 'unused'
skip_reason?: string | null
error_message?: string | null
status_code?: number | null
@@ -177,9 +178,13 @@ export interface TestModelFailoverResponse {
error?: string | null
}
export async function testModelFailover(data: TestModelFailoverRequest): Promise<TestModelFailoverResponse> {
export async function testModelFailover(
data: TestModelFailoverRequest,
options: { signal?: AbortSignal } = {}
): Promise<TestModelFailoverResponse> {
const response = await client.post('/api/admin/provider-query/test-model-failover', data, {
timeout: 10 * 60 * 1000,
signal: options.signal,
})
return response.data
}

View File

@@ -0,0 +1,183 @@
import { ref, onBeforeUnmount } from 'vue'
import { isAxiosError } from 'axios'
import { useToast } from './useToast'
import {
testModelFailover,
type TestModelFailoverResponse,
} from '@/api/endpoints/providers'
import { requestTraceApi, type RequestTrace } from '@/api/requestTrace'
import { parseApiError } from '@/utils/errorParser'
export interface StartTestParams {
mode: 'global' | 'direct'
modelName: string
displayLabel: string
apiFormat?: string
endpointId?: string
message?: string
concurrency?: number
onSuccess?: (result: TestModelFailoverResponse) => void
/** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */
onFailure?: (result: TestModelFailoverResponse) => boolean | void
/** Return `true` to indicate the error has been handled; otherwise a toast is shown and state is reset. */
onError?: (err: unknown) => boolean | void
}
export interface UseModelTestOptions {
providerId: () => string
pollInterval?: number
}
export function useModelTest(options: UseModelTestOptions) {
const { providerId, pollInterval = 800 } = options
const { success: showSuccess, error: showError } = useToast()
const testing = ref(false)
const testMode = ref<'global' | 'direct'>('global')
const testResult = ref<TestModelFailoverResponse | null>(null)
const testTrace = ref<RequestTrace | null>(null)
const requestId = ref<string | null>(null)
const dialogOpen = ref(false)
let tracePollTimer: ReturnType<typeof setInterval> | null = null
let tracePollToken = 0
let activeAbortController: AbortController | null = null
function buildTestRequestId(): string {
const randomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto)
if (randomUUID) {
return `provider-test-${randomUUID().replace(/-/g, '').slice(0, 20)}`
}
return `provider-test-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
async function pollTestTrace(reqId: string, token: number) {
try {
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
if (tracePollToken !== token || requestId.value !== reqId) return
testTrace.value = trace
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.status === 404) return
}
}
function stopPolling(opts: { clearState?: boolean } = {}) {
tracePollToken += 1
if (tracePollTimer) {
clearInterval(tracePollTimer)
tracePollTimer = null
}
if (opts.clearState !== false) {
requestId.value = null
testTrace.value = null
}
}
function startPolling(reqId: string) {
stopPolling()
requestId.value = reqId
testTrace.value = null
const token = ++tracePollToken
void pollTestTrace(reqId, token)
tracePollTimer = setInterval(() => {
void pollTestTrace(reqId, token)
}, pollInterval)
}
function abortActiveRequest() {
if (!activeAbortController) return
activeAbortController.abort()
activeAbortController = null
}
function isRequestCancelled(err: unknown): boolean {
if (isAxiosError(err)) {
return err.code === 'ERR_CANCELED'
}
return err instanceof DOMException && err.name === 'AbortError'
}
function resetState() {
abortActiveRequest()
stopPolling()
dialogOpen.value = false
testResult.value = null
}
async function startTest(params: StartTestParams) {
abortActiveRequest()
testing.value = true
testMode.value = params.mode
dialogOpen.value = true
testResult.value = null
const abortController = new AbortController()
activeAbortController = abortController
const reqId = buildTestRequestId()
startPolling(reqId)
try {
const result = await testModelFailover({
provider_id: providerId(),
mode: params.mode,
model_name: params.modelName,
api_format: params.apiFormat,
endpoint_id: params.endpointId,
message: params.message ?? 'hello',
request_id: reqId,
concurrency: params.concurrency,
}, {
signal: abortController.signal,
})
if (result.success) {
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 !== params.modelName
? ` -> ${successAttempt.effective_model}`
: ''
params.onSuccess?.(result)
showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`)
resetState()
return
}
stopPolling({ clearState: false })
const handled = params.onFailure?.(result)
if (!handled) {
testResult.value = result
}
} catch (err: unknown) {
if (isRequestCancelled(err)) {
return
}
stopPolling()
const handled = params.onError?.(err)
if (!handled) {
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
resetState()
}
} finally {
if (activeAbortController === abortController) {
activeAbortController = null
}
testing.value = false
}
}
onBeforeUnmount(() => {
resetState()
})
return {
testing,
testMode,
testResult,
testTrace,
requestId,
dialogOpen,
startTest,
resetState,
stopPolling,
}
}

View File

@@ -600,11 +600,25 @@ async function executeAction(): Promise<void> {
try {
if (selectedAction.value === 'refresh_quota') {
const targetIds = selectedKeys.map((key) => key.key_id)
const result = await refreshProviderQuota(props.providerId, targetIds)
successCount = Number(result.success || 0)
failedCount = Number(result.failed || 0)
skippedCount = Math.max(0, targetIds.length - Number(result.total || 0))
progressDone.value = targetIds.length
const BATCH_SIZE = 20
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
for (let i = 0; i < targetIds.length; i += BATCH_SIZE) {
const batchIndex = Math.floor(i / BATCH_SIZE) + 1
const batch = targetIds.slice(i, i + BATCH_SIZE)
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
try {
const result = await refreshProviderQuota(props.providerId, batch)
successCount += Number(result.success || 0)
failedCount += Number(result.failed || 0)
skippedCount += Math.max(0, batch.length - Number(result.total || 0))
} catch {
failedCount += batch.length
}
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
}
} else {
const CONCURRENCY = props.batchConcurrency || 8
const taskForKey = (key: PoolKeyDetail): (() => Promise<'success' | 'skip'>) | null => {

View File

@@ -308,31 +308,35 @@
@cancel="deleteConfirmOpen = false"
/>
<!-- 测试结果对话框仅失败时显示 -->
<TestResultDialog
:result="testResult"
<!-- 模型测试对话框 -->
<ModelTestDialog
:open="modelTest.dialogOpen.value"
:result="modelTest.testResult.value"
mode="direct"
@close="testResult = null"
:selecting-model-name="testingModelName"
:testing="modelTest.testing.value"
:trace="modelTest.testTrace.value"
:request-id="modelTest.requestId.value"
@close="handleTestDialogClose"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useSmartPagination } from '@/composables/useSmartPagination'
import { useModelTest } from '@/composables/useModelTest'
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
import {
Card, Button, Badge,
} from '@/components/ui'
import AlertDialog from '@/components/common/AlertDialog.vue'
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
import TestResultDialog from './TestResultDialog.vue'
import ModelTestDialog from './ModelTestDialog.vue'
import { useToast } from '@/composables/useToast'
import {
testModelFailover,
type Model,
type ProviderModelAlias,
type ProviderMappingPreviewResponse,
type TestModelFailoverResponse
} from '@/api/endpoints'
import { type EndpointAPIKey } from '@/api/endpoints/keys'
import { updateModel } from '@/api/endpoints/models'
@@ -376,6 +380,9 @@ const emit = defineEmits<{
const { error: showError, success: showSuccess } = useToast()
// 模型测试 composable
const modelTest = useModelTest({ providerId: () => props.provider.id })
// 状态
const loading = ref(false)
const dialogOpen = ref(false)
@@ -383,7 +390,7 @@ 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 testingModelName = ref<string | null>(null)
const preselectedModelId = ref<string | null>(null)
// 使用 props 传入的数据
@@ -617,30 +624,25 @@ async function onDialogSaved() {
emit('refresh')
}
// 测试映射(直连测试,带故障转移)
function handleTestDialogClose() {
modelTest.resetState()
testingModelName.value = null
}
// 测试映射(直连测试,带故障转移和实时进度)
async function runMappingTest(testingKey: string, modelName: string) {
testingMapping.value = testingKey
try {
const result = await testModelFailover({
provider_id: props.provider.id,
mode: 'direct',
model_name: modelName,
message: "hello",
})
if (result.success) {
const successAttempt = result.attempts.find(a => a.status === 'success')
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
showSuccess(`映射 "${modelName}" 测试成功${latency}`)
} else {
testResult.value = result
}
} catch (err: unknown) {
showError(`映射测试失败: ${parseApiError(err, '测试请求失败')}`)
} finally {
testingMapping.value = null
}
testingModelName.value = modelName
await modelTest.startTest({
mode: 'direct',
modelName,
displayLabel: `映射 "${modelName}"`,
message: 'hello',
onSuccess: () => {
testingModelName.value = null
},
})
testingMapping.value = null
}
// 测试精确映射

View File

@@ -159,7 +159,7 @@
:variant="statusVariant(candidate.status)"
class="text-[10px] px-1.5 py-0 shrink-0"
>
{{ candidate.status_code || statusLabel(candidate.status) }}
{{ statusDisplay(candidate) }}
</Badge>
<span class="truncate font-medium">{{ formatTraceCandidateAccount(candidate) }}</span>
</div>
@@ -221,13 +221,51 @@
{{ result.error }}
</div>
<div
v-if="attemptSummaryItems.length > 0"
class="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2"
>
<div class="text-xs font-medium text-muted-foreground">
结果概览
</div>
<div class="flex flex-wrap gap-2">
<div
v-for="item in attemptSummaryItems"
:key="item.key"
class="flex items-center gap-2 rounded-md border border-border/60 bg-background/80 px-2.5 py-1.5 text-xs"
>
<Badge
:variant="item.variant"
class="text-[10px] px-1.5 py-0"
>
{{ item.count }}x
</Badge>
<span class="text-muted-foreground break-all">{{ item.label }}</span>
</div>
</div>
</div>
<div
v-if="shouldCollapseAttempts"
class="flex items-center justify-between gap-3 text-xs text-muted-foreground"
>
<span>仅展示前 {{ visibleAttempts.length }} {{ resultAttempts.length }} </span>
<Button
variant="ghost"
size="sm"
@click="showAllAttempts = !showAllAttempts"
>
{{ showAllAttempts ? '收起详情' : `展开全部 ${resultAttempts.length}` }}
</Button>
</div>
<!-- mobile: list layout -->
<div
v-if="result.attempts.length > 0"
v-if="resultAttempts.length > 0"
class="space-y-2 sm:hidden"
>
<div
v-for="(attempt, idx) in result.attempts"
v-for="(attempt, idx) in visibleAttempts"
:key="'m' + idx"
class="rounded-md border px-3 py-2 text-xs"
:class="attemptRowClass(attempt.status)"
@@ -239,7 +277,7 @@
:variant="statusVariant(attempt.status)"
class="text-[10px] px-1.5 py-0 shrink-0"
>
{{ attempt.status_code || statusLabel(attempt.status) }}
{{ statusDisplay(attempt) }}
</Badge>
<span
v-if="attempt.latency_ms != null"
@@ -248,6 +286,10 @@
{{ attempt.latency_ms }}ms
</span>
</div>
<code
v-if="showEndpointColumn"
class="text-[11px] bg-muted px-1 py-0.5 rounded shrink-0"
>{{ attempt.endpoint_api_format }}</code>
</div>
<div class="mt-1.5 space-y-0.5">
<div
@@ -277,16 +319,20 @@
<!-- desktop: table layout -->
<div
v-if="result.attempts.length > 0"
v-if="resultAttempts.length > 0"
class="border rounded-md overflow-hidden hidden sm:block"
>
<table class="w-full text-xs table-fixed">
<colgroup>
<col class="w-8">
<col class="w-[22%]">
<col
v-if="showEndpointColumn"
class="w-20"
>
<col
v-if="hasEffectiveModel"
class="w-[18%]"
class="w-[16%]"
>
<col class="w-16">
<col class="w-16">
@@ -300,6 +346,12 @@
<th class="px-3 py-2 text-left font-medium">
Key
</th>
<th
v-if="showEndpointColumn"
class="px-3 py-2 text-left font-medium"
>
端点
</th>
<th
v-if="hasEffectiveModel"
class="px-3 py-2 text-left font-medium"
@@ -319,7 +371,7 @@
</thead>
<tbody>
<tr
v-for="(attempt, idx) in result.attempts"
v-for="(attempt, idx) in visibleAttempts"
:key="idx"
class="border-b last:border-b-0 align-top"
:class="attemptRowClass(attempt.status)"
@@ -342,6 +394,12 @@
{{ maskKey(attempt.key_id) }}
</div>
</td>
<td
v-if="showEndpointColumn"
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 truncate"
@@ -354,7 +412,7 @@
:variant="statusVariant(attempt.status)"
class="text-[10px] px-1.5 py-0"
>
{{ attempt.status_code || statusLabel(attempt.status) }}
{{ statusDisplay(attempt) }}
</Badge>
</td>
<td class="px-3 py-2 text-right text-muted-foreground tabular-nums">
@@ -402,7 +460,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { Loader2 } from 'lucide-vue-next'
import { Dialog, Badge } from '@/components/ui'
import Button from '@/components/ui/button.vue'
@@ -474,6 +532,67 @@ const hasEffectiveModel = computed(() => {
return props.result.attempts.some(a => a.effective_model && a.effective_model !== props.result?.model)
})
const showEndpointColumn = computed(() => {
if (!props.result) return false
if (props.mode === 'direct') return true
const formats = new Set(props.result.attempts.map(a => a.endpoint_api_format))
return formats.size > 1
})
const resultAttempts = computed(() => props.result?.attempts ?? [])
const showAllAttempts = ref(false)
watch(() => props.result, () => {
showAllAttempts.value = false
})
const shouldCollapseAttempts = computed(() => resultAttempts.value.length > 20)
const visibleAttempts = computed(() => {
if (!shouldCollapseAttempts.value || showAllAttempts.value) {
return resultAttempts.value
}
return resultAttempts.value.slice(0, 20)
})
type AttemptSummaryItem = {
key: string
label: string
count: number
variant: 'success' | 'destructive' | 'secondary'
}
const attemptSummaryItems = computed<AttemptSummaryItem[]>(() => {
const groups = new Map<string, AttemptSummaryItem>()
for (const attempt of resultAttempts.value) {
const label = summarizeAttempt(attempt)
const key = `${attempt.status}:${label}`
const existing = groups.get(key)
if (existing) {
existing.count += 1
continue
}
groups.set(key, {
key,
label,
count: 1,
variant: statusVariant(attempt.status),
})
}
const variantRank: Record<AttemptSummaryItem['variant'], number> = {
destructive: 0,
secondary: 1,
success: 2,
}
return [...groups.values()].sort((left, right) => {
if (right.count !== left.count) return right.count - left.count
return variantRank[left.variant] - variantRank[right.variant]
})
})
const liveTraceSummary = computed(() => {
const summary = {
total: traceCandidates.value.length,
@@ -552,7 +671,7 @@ const liveRecentCandidates = computed(() => {
function statusVariant(status: string) {
if (status === 'success') return 'success' as const
if (status === 'failed') return 'destructive' as const
if (status === 'failed' || status === 'stream_interrupted') return 'destructive' as const
return 'secondary' as const
}
@@ -568,9 +687,44 @@ function statusLabel(status: string) {
return status
}
function statusDisplay(item: { status: string; status_code?: number | null }): string {
const code = item.status_code
const status = item.status
if (!code) return statusLabel(status)
// 失败但 HTTP 状态码是 2xx显示 "200 体内错误" 以区分
if (status === 'failed' && code >= 200 && code < 300) {
return `${code} 体内错误`
}
return String(code)
}
function compactDetail(value: string | null | undefined, maxLength = 64): string | null {
if (!value) return null
const compact = value.replace(/\s+/g, ' ').trim()
if (!compact) return null
return compact.length > maxLength ? `${compact.slice(0, maxLength)}` : compact
}
function summarizeAttempt(attempt: TestAttemptDetail): string {
if (attempt.status === 'skipped') return '跳过'
if (attempt.status === 'cancelled') return '已取消'
if (attempt.status === 'success') return '成功'
const detail = compactDetail(attempt.error_message || attempt.skip_reason)
if (attempt.status_code != null) {
if (detail) return `${attempt.status_code} ${detail}`
if (attempt.status === 'failed' && attempt.status_code >= 200 && attempt.status_code < 300) {
return `${attempt.status_code} 体内错误`
}
return `${attempt.status_code} ${statusLabel(attempt.status)}`
}
return detail || statusLabel(attempt.status)
}
function attemptRowClass(status: string) {
if (status === 'success') return 'bg-green-500/5'
if (status === 'failed') return 'bg-red-500/5'
if (status === 'cancelled') return 'bg-amber-500/5'
if (status === 'skipped') return 'bg-muted/20'
return ''
}
@@ -612,6 +766,7 @@ function traceCandidateDetail(candidate: CandidateRecord): string {
}
function attemptDetail(attempt: TestAttemptDetail): string {
if (attempt.status === 'cancelled') return '测试已取消'
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

View File

@@ -130,11 +130,11 @@
size="icon"
class="h-8 w-8"
title="测试模型"
:disabled="testingModelId === model.id"
:disabled="modelTest.testing.value && pendingTestModel?.id === model.id"
@click="testModelConnection(model)"
>
<Loader2
v-if="testingModelId === model.id"
v-if="modelTest.testing.value && pendingTestModel?.id === model.id"
class="w-3.5 h-3.5 animate-spin"
/>
<Play
@@ -212,15 +212,15 @@
</Card>
<ModelTestDialog
:open="testDialogOpen"
:result="testResult"
:mode="testResultMode"
:open="modelTest.dialogOpen.value"
:result="modelTest.testResult.value"
:mode="modelTest.testMode.value"
:selecting-model-name="pendingTestModel ? (pendingTestModel.global_model_display_name || pendingTestModel.provider_model_name) : null"
:endpoints="activeEndpoints"
:selected-endpoint="selectedTestEndpoint"
:testing="!!pendingTestModel && testingModelId === pendingTestModel.id"
:trace="testTrace"
:request-id="currentTestRequestId"
:testing="modelTest.testing.value"
:trace="modelTest.testTrace.value"
:request-id="modelTest.requestId.value"
:show-endpoint-selector="activeEndpoints.length > 1"
@close="handleTestDialogClose"
@back="handleTestDialogBack"
@@ -229,9 +229,9 @@
</template>
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue'
import { isAxiosError } from 'axios'
import { ref, computed } from 'vue'
import { useSmartPagination } from '@/composables/useSmartPagination'
import { useModelTest } from '@/composables/useModelTest'
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'
@@ -239,13 +239,10 @@ import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { sortResolutionEntries } from '@/utils/form'
import {
testModelFailover,
type Model,
type ProviderEndpoint,
type TestModelFailoverResponse,
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { requestTraceApi, type RequestTrace } from '@/api/requestTrace'
import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
@@ -266,23 +263,16 @@ const emit = defineEmits<{
const { error: showError, success: showSuccess } = useToast()
const { copyToClipboard } = useClipboard()
// 模型测试 composable
const modelTest = useModelTest({ providerId: () => props.provider.id })
// 状态
const loading = ref(false)
const localModels = ref<Model[]>([])
const togglingModelId = ref<string | null>(null)
const testingModelId = ref<string | null>(null)
const testResult = ref<TestModelFailoverResponse | null>(null)
const testResultMode = ref<'global' | 'direct'>('global')
const testDialogOpen = ref(false)
const pendingTestModel = ref<Model | null>(null)
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
const currentTestRequestId = ref<string | null>(null)
const testTrace = ref<RequestTrace | null>(null)
let tracePollTimer: ReturnType<typeof setInterval> | null = null
let tracePollToken = 0
// 使用 props 传入的数据,或使用本地数据
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
// 使用 props 传入的数据,或使用本地数据
const models = computed(() => props.models ?? localModels.value)
// 按名称排序的模型列表
const sortedModels = computed(() => {
@@ -312,47 +302,6 @@ function refresh() {
emit('refresh')
}
function buildTestRequestId(): string {
const randomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto)
if (randomUUID) {
return `provider-test-${randomUUID().replace(/-/g, '').slice(0, 20)}`
}
return `provider-test-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
async function pollTestTrace(requestId: string, token: number) {
try {
const trace = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: false })
if (tracePollToken !== token || currentTestRequestId.value !== requestId) return
testTrace.value = trace
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.status === 404) return
}
}
function stopTestTracePolling(options: { clearState?: boolean } = {}) {
tracePollToken += 1
if (tracePollTimer) {
clearInterval(tracePollTimer)
tracePollTimer = null
}
if (options.clearState !== false) {
currentTestRequestId.value = null
testTrace.value = null
}
}
function startTestTracePolling(requestId: string) {
stopTestTracePolling()
currentTestRequestId.value = requestId
testTrace.value = null
const token = ++tracePollToken
void pollTestTrace(requestId, token)
tracePollTimer = setInterval(() => {
void pollTestTrace(requestId, token)
}, 800)
}
// 格式化价格显示
function formatPrice(price: number | null | undefined): string {
if (price === null || price === undefined) return '-'
@@ -485,21 +434,15 @@ async function toggleModelActive(model: Model) {
}
}
function resetTestDialogState() {
stopTestTracePolling()
testDialogOpen.value = false
function handleTestDialogClose() {
modelTest.resetState()
pendingTestModel.value = null
selectedTestEndpoint.value = null
testResult.value = null
}
function handleTestDialogClose() {
resetTestDialogState()
}
function handleTestDialogBack() {
if (testingModelId.value) return
testResult.value = null
if (modelTest.testing.value) return
modelTest.testResult.value = null
selectedTestEndpoint.value = null
}
@@ -507,60 +450,33 @@ async function handleSelectTestEndpoint(endpointId: string) {
if (!pendingTestModel.value) return
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
if (!endpoint) return
await runModelTest(pendingTestModel.value, endpoint)
selectedTestEndpoint.value = endpoint
const model = pendingTestModel.value
const modelName = model.global_model_name || model.provider_model_name
const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] `
await modelTest.startTest({
mode: 'global',
modelName,
displayLabel: `${endpointPrefix}${modelName}`,
apiFormat: endpoint.api_format,
endpointId: endpoint.id,
message: 'hello',
concurrency: 5,
onSuccess: () => {
pendingTestModel.value = null
selectedTestEndpoint.value = null
},
onError: () => {
if (activeEndpoints.value.length > 1) {
selectedTestEndpoint.value = null
return true
}
},
})
}
async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
if (testingModelId.value) return
testingModelId.value = model.id
testDialogOpen.value = true
selectedTestEndpoint.value = endpoint ?? null
const requestId = buildTestRequestId()
startTestTracePolling(requestId)
try {
const modelName = model.global_model_name || model.provider_model_name
const result = await testModelFailover({
provider_id: props.provider.id,
mode: 'global',
model_name: modelName,
api_format: endpoint?.api_format,
endpoint_id: endpoint?.id,
message: 'hello',
request_id: requestId,
})
if (result.success) {
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}`
: ''
const endpointPrefix = endpoint ? `[${formatApiFormat(endpoint.api_format)}] ` : ''
showSuccess(`${endpointPrefix}${modelName}${mapped} 测试成功${latency}`)
resetTestDialogState()
return
}
stopTestTracePolling({ clearState: false })
testResultMode.value = 'global'
testResult.value = result
} catch (err: unknown) {
stopTestTracePolling()
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
if (activeEndpoints.value.length <= 1) {
resetTestDialogState()
return
}
selectedTestEndpoint.value = null
} finally {
testingModelId.value = null
}
}
// 测试模型连接性(模拟外部请求,带故障转移)
async function testModelConnection(model: Model) {
if (testingModelId.value) return
if (modelTest.testing.value) return
if (activeEndpoints.value.length === 0) {
showError('暂无可用于测试的活跃端点')
@@ -569,11 +485,11 @@ async function testModelConnection(model: Model) {
pendingTestModel.value = model
selectedTestEndpoint.value = null
testResult.value = null
testDialogOpen.value = true
modelTest.testResult.value = null
modelTest.dialogOpen.value = true
if (activeEndpoints.value.length === 1) {
await runModelTest(model, activeEndpoints.value[0])
await handleSelectTestEndpoint(activeEndpoints.value[0].id)
}
}
@@ -581,8 +497,4 @@ async function testModelConnection(model: Model) {
defineExpose({
reload: refresh
})
onBeforeUnmount(() => {
stopTestTracePolling()
})
</script>

View File

@@ -1,301 +0,0 @@
<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>
<!-- mobile: list layout -->
<div
v-if="result.attempts.length > 0"
class="space-y-2 sm:hidden"
>
<div
v-for="(attempt, idx) in result.attempts"
:key="'m' + idx"
class="rounded-md border px-3 py-2 text-xs"
:class="attemptRowClass(attempt.status)"
>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-muted-foreground shrink-0">{{ formatAttemptIndex(attempt) }}</span>
<Badge
:variant="statusVariant(attempt.status)"
class="text-[10px] px-1.5 py-0 shrink-0"
>
{{ attempt.status_code || statusLabel(attempt.status) }}
</Badge>
<span
v-if="attempt.latency_ms != null"
class="text-muted-foreground shrink-0 tabular-nums"
>
{{ attempt.latency_ms }}ms
</span>
</div>
<code class="text-[11px] bg-muted px-1 py-0.5 rounded shrink-0">{{ attempt.endpoint_api_format }}</code>
</div>
<div class="mt-1.5 space-y-0.5">
<div
v-if="attempt.key_name"
class="font-medium truncate"
>
{{ attempt.key_name }}
</div>
<div class="text-muted-foreground">
{{ maskKey(attempt.key_id) }}
</div>
<div
v-if="hasEffectiveModel && attempt.effective_model"
class="text-muted-foreground"
>
模型: {{ attempt.effective_model }}
</div>
<div
v-if="attemptDetail(attempt) !== '-'"
class="text-muted-foreground break-all mt-1"
>
{{ attemptDetail(attempt) }}
</div>
</div>
</div>
</div>
<!-- desktop: table layout -->
<div
v-if="result.attempts.length > 0"
class="border rounded-md overflow-hidden hidden sm:block"
>
<table class="w-full text-xs table-fixed">
<colgroup>
<col class="w-8">
<col class="w-[22%]">
<col class="w-20">
<col
v-if="hasEffectiveModel"
class="w-[16%]"
>
<col class="w-16">
<col class="w-16">
<col>
</colgroup>
<thead>
<tr class="border-b bg-muted/30">
<th class="pl-3 pr-1 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 align-top"
:class="attemptRowClass(attempt.status)"
>
<td class="pl-3 pr-1 py-2 text-muted-foreground">
{{ formatAttemptIndex(attempt) }}
</td>
<td class="px-3 py-2">
<div
v-if="attempt.key_name"
class="font-medium truncate"
:title="attempt.key_name"
>
{{ attempt.key_name }}
</div>
<div
class="text-muted-foreground truncate"
:title="attempt.key_id"
>
{{ maskKey(attempt.key_id) }}
</div>
</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 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"
>
{{ attempt.status_code || statusLabel(attempt.status) }}
</Badge>
</td>
<td class="px-3 py-2 text-right text-muted-foreground tabular-nums">
{{ attempt.latency_ms != null ? attempt.latency_ms + 'ms' : '-' }}
</td>
<td class="px-3 py-2 text-muted-foreground">
<div
class="break-all line-clamp-2"
:title="attemptDetail(attempt)"
>
{{ attemptDetail(attempt) }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<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 '跳过'
if (status === 'pending') return '等待中'
if (status === 'streaming') return '测试中'
if (status === 'cancelled') return '已取消'
if (status === 'stream_interrupted') return '流中断'
if (status === 'available') 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 maskKey(key: string): string {
if (key.length <= 8) return key
return `${key.slice(0, 4)}...${key.slice(-4)}`
}
function formatAttemptIndex(attempt: TestAttemptDetail): string {
const retryIndex = attempt.retry_index ?? 0
return retryIndex > 0 ? `#${attempt.candidate_index}.${retryIndex}` : `#${attempt.candidate_index}`
}
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>