mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
feat(admin,pool,billing): 端点级模型测试、Provider 自动置顶、缓存 TTL 分级计费展示与账号状态增强
- 模型测试支持指定端点:新增 ModelTestDialog 组件,多端点时弹窗选择,单端点直接测试; 后端 test-model-failover 接口新增 endpoint_id 参数,支持 global/direct 模式下按端点过滤候选 - 创建 Provider 时优先级自动置顶(provider_priority 默认 None,后端取 min-1), 显式指定优先级时 shift 已有行;前端创建时不发送 priority,更新时保留 - 缓存计费 UI 增强:RequestDetailDrawer 支持 5min/1h 缓存创建 token 分级展示, 含按 TTL 匹配单价和分行成本计算;ModelDetailDrawer/ModelsTab 标签区分 5min/1h 缓存创建 - Pool 批量操作额度筛选拆分为「无5H限额」和「无周限额」,按 | 分隔 segment 匹配 - KeyFormDialog 优化非 vertex_ai 时布局,API 密钥输入内联到 grid 右列 - Codex refresher 结构化错误标记:401/402/403 使用 [OAUTH_EXPIRED]/[ACCOUNT_BLOCK] 前缀, 新增 deactivated_workspace 识别与分类 - 前后端 accountBlock 关键词同步:新增 token invalidated、deactivated_workspace 识别, OAuth 失效提示清理 block 前缀后展示 - PoolConfig 新增 batch_concurrency 配置(默认 8,上限 32) - 预设模型新增 gpt-5.4;TestResultDialog 响应式布局与 key 脱敏优化
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="providerType === 'vertex_ai'">
|
||||
<div v-if="showAuthTypeSelector">
|
||||
<Label :for="authTypeSelectId">认证类型</Label>
|
||||
<Select
|
||||
v-model="form.auth_type"
|
||||
@@ -50,10 +50,30 @@
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Label :for="apiKeyInputId">
|
||||
{{ form.auth_type === 'service_account' ? 'Service Account JSON' : 'API 密钥' }}
|
||||
{{ editingKey ? '' : '*' }}
|
||||
</Label>
|
||||
<Input
|
||||
:id="apiKeyInputId"
|
||||
v-model="form.api_key"
|
||||
:name="apiKeyFieldName"
|
||||
masked
|
||||
:required="!editingKey"
|
||||
:placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'"
|
||||
/>
|
||||
<p
|
||||
v-if="editingKey && form.auth_type === 'api_key'"
|
||||
class="text-xs text-muted-foreground mt-1"
|
||||
>
|
||||
留空表示不修改
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 密钥 / Service Account JSON -->
|
||||
<div>
|
||||
<div v-if="showAuthTypeSelector || form.auth_type === 'service_account'">
|
||||
<Label :for="apiKeyInputId">
|
||||
{{ form.auth_type === 'service_account' ? 'Service Account JSON' : 'API 密钥' }}
|
||||
{{ editingKey ? '' : '*' }}
|
||||
@@ -367,6 +387,8 @@ const visibleApiFormats = computed(() => {
|
||||
return sorted.filter(fmt => allowed.has(normalizeApiFormat(fmt)))
|
||||
})
|
||||
|
||||
const showAuthTypeSelector = computed(() => props.providerType === 'vertex_ai')
|
||||
|
||||
// 默认认证类型
|
||||
const defaultAuthType = 'api_key' as const
|
||||
|
||||
|
||||
@@ -892,6 +892,7 @@
|
||||
:key="`models-${provider.id}`"
|
||||
:provider="provider"
|
||||
:models="providerModels"
|
||||
:endpoints="endpoints"
|
||||
@edit-model="handleEditModel"
|
||||
@batch-assign="handleBatchAssign"
|
||||
@refresh="loadEndpoints"
|
||||
@@ -1101,7 +1102,7 @@ import {
|
||||
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||
import { isAccountLevelBlockReason } from '@/utils/accountBlock'
|
||||
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
|
||||
|
||||
// 扩展端点类型,包含密钥列表
|
||||
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
||||
@@ -2504,7 +2505,10 @@ function getOAuthStatusTitle(key: EndpointAPIKey): string {
|
||||
const status = getKeyOAuthExpires(key)
|
||||
if (!status) return ''
|
||||
if (status.isInvalid) {
|
||||
return status.invalidReason ? `Token 已失效: ${status.invalidReason}` : 'Token 已失效'
|
||||
const cleaned = status.invalidReason && isAccountLevelBlockReason(status.invalidReason)
|
||||
? cleanAccountBlockReason(status.invalidReason)
|
||||
: status.invalidReason
|
||||
return cleaned ? `Token 已失效: ${cleaned}` : 'Token 已失效'
|
||||
}
|
||||
if (status.isExpired) {
|
||||
return 'Token 已过期,请重新授权'
|
||||
|
||||
@@ -437,7 +437,7 @@ const handleSubmit = async () => {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
const basePayload = {
|
||||
name: form.value.name,
|
||||
provider_type: form.value.provider_type,
|
||||
description: form.value.description || undefined,
|
||||
@@ -447,7 +447,6 @@ const handleSubmit = async () => {
|
||||
quota_reset_day: form.value.quota_reset_day,
|
||||
quota_last_reset_at: form.value.quota_last_reset_at || undefined,
|
||||
quota_expires_at: form.value.quota_expires_at || undefined,
|
||||
provider_priority: form.value.provider_priority,
|
||||
keep_priority_on_conversion: form.value.keep_priority_on_conversion,
|
||||
is_active: form.value.is_active,
|
||||
// 请求配置
|
||||
@@ -462,12 +461,15 @@ const handleSubmit = async () => {
|
||||
|
||||
if (isEditMode.value && props.provider) {
|
||||
// 更新提供商
|
||||
const updated = await updateProvider(props.provider.id, payload)
|
||||
const updated = await updateProvider(props.provider.id, {
|
||||
...basePayload,
|
||||
provider_priority: form.value.provider_priority,
|
||||
})
|
||||
success('提供商更新成功')
|
||||
emit('providerUpdated', updated)
|
||||
} else {
|
||||
// 创建提供商
|
||||
await createProvider(payload)
|
||||
// 创建提供商(优先级由后端自动置顶)
|
||||
await createProvider(basePayload)
|
||||
success('提供商已创建,请继续添加端点和密钥,或在优先级管理中调整顺序', '创建成功')
|
||||
emit('providerCreated')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="open"
|
||||
size="2xl"
|
||||
:title="dialogTitle"
|
||||
:description="dialogDescription"
|
||||
@update:open="(val: boolean) => { if (!val) emit('close') }"
|
||||
>
|
||||
<div
|
||||
v-if="showSelection"
|
||||
class="space-y-2"
|
||||
>
|
||||
<button
|
||||
v-for="endpoint in endpoints"
|
||||
:key="endpoint.id"
|
||||
type="button"
|
||||
class="w-full rounded-lg border border-border/60 px-3 py-3 text-left transition-colors hover:bg-muted/40"
|
||||
@click="emit('select-endpoint', endpoint.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{{ formatApiFormat(endpoint.api_format) }}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground truncate">{{ endpoint.base_url }}</div>
|
||||
</div>
|
||||
<Badge variant="outline">{{ endpoint.is_active ? '已启用' : '已禁用' }}</Badge>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-if="endpoints.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/60 px-3 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无可用于测试的活跃端点
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="testing"
|
||||
class="flex flex-col items-center justify-center gap-3 py-10 text-center"
|
||||
>
|
||||
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">正在测试模型</p>
|
||||
<p class="text-xs text-muted-foreground">{{ selectingModelName || '-' }}</p>
|
||||
<p
|
||||
v-if="selectedEndpoint"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
端点:{{ formatApiFormat(selectedEndpoint.api_format) }} · {{ selectedEndpoint.base_url }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-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="selectedEndpoint">
|
||||
<span class="text-muted-foreground">测试端点: </span>
|
||||
<span class="font-medium">{{ formatApiFormat(selectedEndpoint.api_format) }}</span>
|
||||
<span class="text-xs text-muted-foreground ml-1">{{ selectedEndpoint.base_url }}</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">#{{ attempt.candidate_index }}</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>
|
||||
</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 v-if="hasEffectiveModel" class="w-[18%]" />
|
||||
<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
|
||||
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">{{ attempt.candidate_index }}</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
|
||||
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
|
||||
v-if="showResult && canReselect"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="emit('back')"
|
||||
>
|
||||
重新选择端点
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ showSelection ? '取消' : '关闭' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { Dialog, Badge } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { TestModelFailoverResponse, TestAttemptDetail } from '@/api/endpoints/providers'
|
||||
|
||||
type TestEndpointOption = {
|
||||
id: string
|
||||
api_format: string
|
||||
base_url: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
result: TestModelFailoverResponse | null
|
||||
mode?: 'global' | 'direct'
|
||||
selectingModelName?: string | null
|
||||
endpoints?: TestEndpointOption[]
|
||||
selectedEndpoint?: TestEndpointOption | null
|
||||
testing?: boolean
|
||||
showEndpointSelector?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
back: []
|
||||
'select-endpoint': [endpointId: string]
|
||||
}>()
|
||||
|
||||
const endpoints = computed(() => props.endpoints ?? [])
|
||||
const showSelection = computed(() => props.open && !!props.showEndpointSelector && !props.testing && !props.result)
|
||||
const showResult = computed(() => !!props.result)
|
||||
const canReselect = computed(() => !!props.showEndpointSelector && endpoints.value.length > 1)
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (props.result) return '模型测试结果'
|
||||
return '模型测试'
|
||||
})
|
||||
|
||||
const dialogDescription = computed(() => {
|
||||
if (showSelection.value && props.selectingModelName) {
|
||||
return `为 ${props.selectingModelName} 选择端点`
|
||||
}
|
||||
if (props.testing && props.selectedEndpoint) {
|
||||
return `正在通过 ${formatApiFormat(props.selectedEndpoint.api_format)} 测试 ${props.selectingModelName || '模型'}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
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 maskKey(key: string): string {
|
||||
if (key.length <= 8) return key
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -87,14 +87,14 @@
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="getEffectiveCachePrice(model, 'creation') > 0 || getEffectiveCachePrice(model, 'read') > 0">
|
||||
<span class="text-muted-foreground text-right">缓存:</span>
|
||||
<span class="text-muted-foreground text-right">{{ get1hCachePrice(model) > 0 ? '5min 缓存:' : '缓存:' }}</span>
|
||||
<span class="font-mono font-semibold">
|
||||
${{ formatPrice(getEffectiveCachePrice(model, 'creation')) }}/${{ formatPrice(getEffectiveCachePrice(model, 'read')) }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- 1h 缓存价格 -->
|
||||
<template v-if="get1hCachePrice(model) > 0">
|
||||
<span class="text-muted-foreground text-right">1h 缓存:</span>
|
||||
<span class="text-muted-foreground text-right">1h 缓存创建:</span>
|
||||
<span class="font-mono font-semibold">
|
||||
${{ formatPrice(get1hCachePrice(model)) }}
|
||||
</span>
|
||||
@@ -211,11 +211,18 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 测试结果对话框 -->
|
||||
<TestResultDialog
|
||||
<ModelTestDialog
|
||||
:open="testDialogOpen"
|
||||
:result="testResult"
|
||||
:mode="testResultMode"
|
||||
@close="testResult = null"
|
||||
:selecting-model-name="pendingTestModel ? (pendingTestModel.global_model_display_name || pendingTestModel.provider_model_name) : null"
|
||||
:endpoints="activeEndpoints"
|
||||
:selected-endpoint="selectedTestEndpoint"
|
||||
:testing="!!pendingTestModel && testingModelId === pendingTestModel.id"
|
||||
:show-endpoint-selector="activeEndpoints.length > 1"
|
||||
@close="handleTestDialogClose"
|
||||
@back="handleTestDialogBack"
|
||||
@select-endpoint="handleSelectTestEndpoint"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -231,16 +238,19 @@ import { sortResolutionEntries } from '@/utils/form'
|
||||
import {
|
||||
testModelFailover,
|
||||
type Model,
|
||||
type ProviderEndpoint,
|
||||
type TestModelFailoverResponse,
|
||||
} from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import TestResultDialog from './TestResultDialog.vue'
|
||||
import ModelTestDialog from './ModelTestDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
models?: Model[]
|
||||
endpoints?: ProviderEndpoint[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -259,6 +269,11 @@ 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)
|
||||
// 使用 props 传入的数据,或使用本地数据
|
||||
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
|
||||
// 使用 props 传入的数据,或使用本地数据
|
||||
const models = computed(() => props.models ?? localModels.value)
|
||||
// 按名称排序的模型列表
|
||||
@@ -421,11 +436,36 @@ async function toggleModelActive(model: Model) {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试模型连接性(模拟外部请求,带故障转移)
|
||||
async function testModelConnection(model: Model) {
|
||||
function resetTestDialogState() {
|
||||
testDialogOpen.value = false
|
||||
pendingTestModel.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
testResult.value = null
|
||||
}
|
||||
|
||||
function handleTestDialogClose() {
|
||||
resetTestDialogState()
|
||||
}
|
||||
|
||||
function handleTestDialogBack() {
|
||||
if (testingModelId.value) return
|
||||
testResult.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
|
||||
if (testingModelId.value) return
|
||||
|
||||
testingModelId.value = model.id
|
||||
testDialogOpen.value = true
|
||||
selectedTestEndpoint.value = endpoint ?? null
|
||||
try {
|
||||
const modelName = model.global_model_name || model.provider_model_name
|
||||
|
||||
@@ -433,7 +473,9 @@ async function testModelConnection(model: Model) {
|
||||
provider_id: props.provider.id,
|
||||
mode: 'global',
|
||||
model_name: modelName,
|
||||
message: "hello",
|
||||
api_format: endpoint?.api_format,
|
||||
endpoint_id: endpoint?.id,
|
||||
message: 'hello',
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
@@ -442,18 +484,44 @@ async function testModelConnection(model: Model) {
|
||||
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== modelName
|
||||
? ` -> ${successAttempt.effective_model}`
|
||||
: ''
|
||||
showSuccess(`${modelName}${mapped} 测试成功${latency}`)
|
||||
} else {
|
||||
testResultMode.value = 'global'
|
||||
testResult.value = result
|
||||
const endpointPrefix = endpoint ? `[${formatApiFormat(endpoint.api_format)}] ` : ''
|
||||
showSuccess(`${endpointPrefix}${modelName}${mapped} 测试成功${latency}`)
|
||||
resetTestDialogState()
|
||||
return
|
||||
}
|
||||
testResultMode.value = 'global'
|
||||
testResult.value = result
|
||||
} catch (err: unknown) {
|
||||
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 (activeEndpoints.value.length === 0) {
|
||||
showError('暂无可用于测试的活跃端点')
|
||||
return
|
||||
}
|
||||
|
||||
pendingTestModel.value = model
|
||||
selectedTestEndpoint.value = null
|
||||
testResult.value = null
|
||||
testDialogOpen.value = true
|
||||
|
||||
if (activeEndpoints.value.length === 1) {
|
||||
await runModelTest(model, activeEndpoints.value[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件
|
||||
defineExpose({
|
||||
reload: refresh
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
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'">
|
||||
@@ -24,7 +23,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型信息 -->
|
||||
<div class="text-sm space-y-1">
|
||||
<div>
|
||||
<span class="text-muted-foreground">请求模型: </span>
|
||||
@@ -40,7 +38,6 @@
|
||||
</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"
|
||||
@@ -48,65 +45,109 @@
|
||||
{{ result.error }}
|
||||
</div>
|
||||
|
||||
<!-- Attempt 详情表 -->
|
||||
<!-- mobile: list layout -->
|
||||
<div
|
||||
v-if="result.attempts.length > 0"
|
||||
class="border rounded-md overflow-hidden"
|
||||
class="space-y-2 sm:hidden"
|
||||
>
|
||||
<table class="w-full text-xs">
|
||||
<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">#{{ attempt.candidate_index }}</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="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 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>
|
||||
>发送模型</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="border-b last:border-b-0 align-top"
|
||||
: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 class="pl-3 pr-1 py-2 text-muted-foreground">{{ attempt.candidate_index }}</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>
|
||||
<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"
|
||||
class="px-3 py-2 truncate"
|
||||
:title="attempt.effective_model || '-'"
|
||||
>
|
||||
{{ attempt.effective_model || '-' }}
|
||||
@@ -116,30 +157,25 @@
|
||||
:variant="statusVariant(attempt.status)"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ statusLabel(attempt.status) }}
|
||||
{{ attempt.status_code || 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">
|
||||
<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 max-w-[200px] truncate text-muted-foreground"
|
||||
:title="attemptDetail(attempt)"
|
||||
>
|
||||
{{ attemptDetail(attempt) }}
|
||||
<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>
|
||||
|
||||
<!-- 无 attempt -->
|
||||
<div
|
||||
v-else
|
||||
class="text-center text-sm text-muted-foreground py-4"
|
||||
@@ -212,6 +248,11 @@ function attemptRowClass(status: string) {
|
||||
return ''
|
||||
}
|
||||
|
||||
function maskKey(key: string): string {
|
||||
if (key.length <= 8) return key
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||
}
|
||||
|
||||
function attemptDetail(attempt: TestAttemptDetail): string {
|
||||
if (attempt.skip_reason) return attempt.skip_reason
|
||||
if (attempt.error_message) return attempt.error_message
|
||||
|
||||
Reference in New Issue
Block a user