feat(test,export,headers): 模型测试复用统一运行时、实时进度展示、导出增强与请求头大小写保留

- 模型测试 failover 从手动 FailoverEngine 改为 TaskService.execute_sync_candidates 统一运行时
- 前端新增实时 trace 轮询进度展示(候选状态、测试账号、进度条)
- 用户导出/导入支持明文 Key 优先(版本升至 1.2),新增 email_verified 字段
- SENSITIVE_CREDENTIAL_FIELDS 统一到 provider_ops/types.py,补充 refresh_token
- 请求头大小写保留机制(resolve_header_name_case + HeaderBuilder.add 语义修改)
- Codex envelope 移除合成头部,保留客户端原始请求头
- endpoint_checker 支持自定义超时透传
- 新增 x-forwarded-scheme 到上游丢弃头部列表
This commit is contained in:
fawney19
2026-03-06 21:06:45 +08:00
parent 7950ba7dc5
commit 90760da499
28 changed files with 1485 additions and 429 deletions

View File

@@ -34,6 +34,12 @@ export interface OAuthProviderExport {
is_enabled?: boolean
}
export interface SystemConfigExport {
key: string
value: unknown
description?: string | null
}
// 配置导出数据结构
export interface ConfigExportData {
version: string
@@ -42,6 +48,7 @@ export interface ConfigExportData {
providers: ProviderExport[]
ldap_config?: LDAPConfigExport | null
oauth_providers?: OAuthProviderExport[]
system_configs?: SystemConfigExport[]
}
// 用户导出数据结构
@@ -54,6 +61,7 @@ export interface UsersExportData {
export interface UserExport {
email: string
email_verified?: boolean
username: string
password_hash: string
role: string
@@ -69,6 +77,7 @@ export interface UserExport {
}
export interface UserApiKeyExport {
key?: string | null
key_hash: string
key_encrypted?: string | null
name?: string | null
@@ -105,14 +114,18 @@ export interface ProviderExport {
name: string
description?: string | null
website?: string | null
provider_type?: string
billing_type?: string | null
monthly_quota_usd?: number | null
quota_reset_day?: number
rpm_limit?: number | null
provider_priority?: number
keep_priority_on_conversion?: boolean
enable_format_conversion?: boolean
is_active: boolean
concurrent_limit?: number | null
max_retries?: number | null
stream_first_byte_timeout?: number | null
request_timeout?: number | null
proxy?: Record<string, unknown>
config?: Record<string, unknown>
endpoints: EndpointExport[]
@@ -123,19 +136,24 @@ export interface ProviderExport {
export interface EndpointExport {
api_format: string
base_url: string
headers?: Record<string, unknown>
header_rules?: Record<string, unknown>[] | null
body_rules?: Record<string, unknown>[] | null
max_retries?: number
is_active: boolean
custom_path?: string | null
config?: Record<string, unknown>
format_acceptance_config?: Record<string, unknown> | null
proxy?: Record<string, unknown>
}
export interface ProviderKeyExport {
api_key: string
auth_type?: string
auth_config?: string | Record<string, unknown> | null
name?: string | null
note?: string | null
api_formats: string[]
supported_endpoints?: string[]
rate_multipliers?: Record<string, number> | null
internal_priority?: number
global_priority_by_format?: Record<string, number> | null
@@ -144,7 +162,13 @@ export interface ProviderKeyExport {
capabilities?: Record<string, boolean>
cache_ttl_minutes?: number
max_probe_interval_minutes?: number
auto_fetch_models?: boolean
locked_models?: string[] | null
model_include_patterns?: string[] | null
model_exclude_patterns?: string[] | null
is_active: boolean
proxy?: Record<string, unknown> | null
fingerprint?: Record<string, unknown> | null
}
export interface ModelExport {

View File

@@ -131,7 +131,9 @@ export interface TestModelResponse {
}
export async function testModel(data: TestModelRequest): Promise<TestModelResponse> {
const response = await client.post('/api/admin/provider-query/test-model', data)
const response = await client.post('/api/admin/provider-query/test-model', data, {
timeout: 10 * 60 * 1000,
})
return response.data
}
@@ -145,10 +147,12 @@ export interface TestModelFailoverRequest {
api_format?: string
endpoint_id?: string
message?: string
request_id?: string
}
export interface TestAttemptDetail {
candidate_index: number
retry_index?: number
endpoint_api_format: string
endpoint_base_url: string
key_name: string | null
@@ -174,7 +178,9 @@ export interface TestModelFailoverResponse {
}
export async function testModelFailover(data: TestModelFailoverRequest): Promise<TestModelFailoverResponse> {
const response = await client.post('/api/admin/provider-query/test-model-failover', data)
const response = await client.post('/api/admin/provider-query/test-model-failover', data, {
timeout: 10 * 60 * 1000,
})
return response.data
}

View File

@@ -12,6 +12,7 @@ export interface CandidateRecord {
endpoint_name?: string // 端点显示名称api_format
key_id?: string
key_name?: string // 密钥名称
key_account_label?: string // 更适合展示的测试账号标签(优先 OAuth 邮箱)
key_preview?: string // 密钥脱敏预览(如 sk-***abcOAuth 类型不返回
key_auth_type?: string // 密钥认证类型api_key, service_account, oauth 等)
key_oauth_plan_type?: string // OAuth 账号套餐类型free/plus/team/enterprise

View File

@@ -41,22 +41,138 @@
<div
v-else-if="testing"
class="flex flex-col items-center justify-center gap-3 py-10 text-center"
class="space-y-4 py-6"
>
<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"
<div class="flex flex-col items-center justify-center gap-3 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 class="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-4">
<div class="space-y-2">
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>实时进度</span>
<span>{{ liveTraceSummary.completed }}/{{ liveTraceSummary.total || 0 }}</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary transition-all duration-300"
:style="{ width: `${liveProgressPercent}%` }"
/>
</div>
<div class="flex flex-wrap gap-1.5">
<Badge
variant="secondary"
class="text-[10px] px-1.5 py-0"
>
待执行 {{ liveTraceSummary.available }}
</Badge>
<Badge
variant="outline"
class="text-[10px] px-1.5 py-0"
>
进行中 {{ liveTraceSummary.pending }}
</Badge>
<Badge
variant="success"
class="text-[10px] px-1.5 py-0"
>
成功 {{ liveTraceSummary.success }}
</Badge>
<Badge
variant="destructive"
class="text-[10px] px-1.5 py-0"
>
失败 {{ liveTraceSummary.failed }}
</Badge>
<Badge
variant="secondary"
class="text-[10px] px-1.5 py-0"
>
跳过 {{ liveTraceSummary.skipped }}
</Badge>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-md border border-border/60 bg-background/80 p-3 space-y-1">
<div class="text-xs text-muted-foreground">
测试账号
</div>
<div class="text-sm font-medium break-all">
{{ liveAccountTitle }}
</div>
<div class="text-xs text-muted-foreground break-all">
{{ liveAccountMeta }}
</div>
</div>
<div class="rounded-md border border-border/60 bg-background/80 p-3 space-y-1">
<div class="text-xs text-muted-foreground">
实时状态
</div>
<div class="text-sm font-medium">
{{ liveStatusTitle }}
</div>
<div class="text-xs text-muted-foreground break-all">
{{ liveStatusDetail }}
</div>
</div>
</div>
<div
v-if="requestId"
class="text-[11px] text-muted-foreground break-all"
>
端点{{ formatApiFormat(selectedEndpoint.api_format) }} · {{ selectedEndpoint.base_url }}
</p>
请求 ID<code class="bg-muted px-1 py-0.5 rounded">{{ requestId }}</code>
</div>
<div
v-if="liveRecentCandidates.length > 0"
class="space-y-2"
>
<div class="text-xs font-medium text-muted-foreground">
最近状态
</div>
<div class="space-y-2">
<div
v-for="candidate in liveRecentCandidates"
:key="`${candidate.id}-${candidate.status}`"
class="flex items-start justify-between gap-3 rounded-md border border-border/50 bg-background/70 px-3 py-2 text-xs"
>
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 min-w-0">
<span class="text-muted-foreground shrink-0">{{ formatTraceCandidateIndex(candidate) }}</span>
<Badge
:variant="statusVariant(candidate.status)"
class="text-[10px] px-1.5 py-0 shrink-0"
>
{{ candidate.status_code || statusLabel(candidate.status) }}
</Badge>
<span class="truncate font-medium">{{ formatTraceCandidateAccount(candidate) }}</span>
</div>
<div class="text-muted-foreground break-all">
{{ traceCandidateDetail(candidate) }}
</div>
</div>
<div class="shrink-0 text-muted-foreground tabular-nums">
{{ candidate.latency_ms != null ? `${candidate.latency_ms}ms` : '' }}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -118,7 +234,7 @@
>
<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>
<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"
@@ -209,7 +325,7 @@
:class="attemptRowClass(attempt.status)"
>
<td class="pl-3 pr-1 py-2 text-muted-foreground">
{{ attempt.candidate_index }}
{{ formatAttemptIndex(attempt) }}
</td>
<td class="px-3 py-2">
<div
@@ -292,6 +408,7 @@ 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'
import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
type TestEndpointOption = {
id: string
@@ -308,6 +425,8 @@ const props = defineProps<{
endpoints?: TestEndpointOption[]
selectedEndpoint?: TestEndpointOption | null
testing?: boolean
trace?: RequestTrace | null
requestId?: string | null
showEndpointSelector?: boolean
}>()
@@ -318,6 +437,7 @@ const emit = defineEmits<{
}>()
const endpoints = computed(() => props.endpoints ?? [])
const traceCandidates = computed(() => props.trace?.candidates ?? [])
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)
@@ -354,6 +474,82 @@ const hasEffectiveModel = computed(() => {
return props.result.attempts.some(a => a.effective_model && a.effective_model !== props.result?.model)
})
const liveTraceSummary = computed(() => {
const summary = {
total: traceCandidates.value.length,
available: 0,
pending: 0,
success: 0,
failed: 0,
skipped: 0,
completed: 0,
}
for (const candidate of traceCandidates.value) {
if (candidate.status === 'available' || candidate.status === 'unused') summary.available += 1
if (candidate.status === 'pending' || candidate.status === 'streaming') summary.pending += 1
if (candidate.status === 'success') summary.success += 1
if (candidate.status === 'failed' || candidate.status === 'cancelled' || candidate.status === 'stream_interrupted') summary.failed += 1
if (candidate.status === 'skipped') summary.skipped += 1
}
summary.completed = summary.success + summary.failed + summary.skipped
return summary
})
const liveProgressPercent = computed(() => {
if (liveTraceSummary.value.total <= 0) return 6
const raw = Math.round((liveTraceSummary.value.completed / liveTraceSummary.value.total) * 100)
return Math.min(100, Math.max(raw, liveTraceSummary.value.pending > 0 ? 12 : 6))
})
const activeTraceCandidate = computed(() => {
const preferredStatuses = ['pending', 'streaming', 'failed', 'success', 'skipped', 'cancelled']
for (let index = traceCandidates.value.length - 1; index >= 0; index -= 1) {
const candidate = traceCandidates.value[index]
if (preferredStatuses.includes(candidate.status)) return candidate
}
return traceCandidates.value[0] ?? null
})
const liveAccountTitle = computed(() => {
const candidate = activeTraceCandidate.value
if (!candidate) return '等待分配测试账号'
return candidate.key_account_label || candidate.key_name || candidate.key_preview || '等待分配测试账号'
})
const liveAccountMeta = computed(() => {
const candidate = activeTraceCandidate.value
if (!candidate) return '候选创建后会显示测试账号和认证方式'
const parts: string[] = []
if (candidate.key_auth_type) parts.push(formatAuthType(candidate.key_auth_type))
if (candidate.key_oauth_plan_type) parts.push(candidate.key_oauth_plan_type)
if (candidate.key_preview && candidate.key_preview !== candidate.key_account_label) parts.push(candidate.key_preview)
return parts.join(' · ') || '正在等待候选进入执行阶段'
})
const liveStatusTitle = computed(() => {
const candidate = activeTraceCandidate.value
if (!candidate) return '正在创建测试请求'
if (candidate.status === 'pending' || candidate.status === 'streaming') {
return `正在测试 ${formatTraceCandidateIndex(candidate)}`
}
return statusLabel(candidate.status)
})
const liveStatusDetail = computed(() => {
const candidate = activeTraceCandidate.value
if (!candidate) return '等待后端写入候选状态'
return traceCandidateDetail(candidate)
})
const liveRecentCandidates = computed(() => {
return traceCandidates.value
.filter(candidate => !['available', 'unused'].includes(candidate.status))
.slice(-4)
.reverse()
})
function statusVariant(status: string) {
if (status === 'success') return 'success' as const
if (status === 'failed') return 'destructive' as const
@@ -364,6 +560,11 @@ 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
}
@@ -379,6 +580,37 @@ function maskKey(key: string): string {
return `${key.slice(0, 4)}...${key.slice(-4)}`
}
function formatAuthType(authType: string): string {
const lowered = authType.toLowerCase()
if (lowered === 'api_key') return 'API Key'
if (lowered === 'service_account') return 'Service Account'
if (lowered === 'oauth') return 'OAuth'
if (lowered === 'codex') return 'Codex OAuth'
if (lowered === 'antigravity') return 'Antigravity OAuth'
if (lowered === 'kiro') return 'Kiro OAuth'
return authType
}
function formatAttemptIndex(attempt: TestAttemptDetail): string {
const retryIndex = attempt.retry_index ?? 0
return retryIndex > 0 ? `#${attempt.candidate_index}.${retryIndex}` : `#${attempt.candidate_index}`
}
function formatTraceCandidateIndex(candidate: CandidateRecord): string {
return candidate.retry_index > 0 ? `#${candidate.candidate_index}.${candidate.retry_index}` : `#${candidate.candidate_index}`
}
function formatTraceCandidateAccount(candidate: CandidateRecord): string {
return candidate.key_account_label || candidate.key_name || candidate.key_preview || '待分配账号'
}
function traceCandidateDetail(candidate: CandidateRecord): string {
if (candidate.skip_reason) return candidate.skip_reason
if (candidate.error_message) return candidate.error_message
if (candidate.endpoint_name) return `端点:${formatApiFormat(candidate.endpoint_name)}`
return '等待响应中…'
}
function attemptDetail(attempt: TestAttemptDetail): string {
if (attempt.skip_reason) return attempt.skip_reason
if (attempt.error_message) return attempt.error_message

View File

@@ -219,6 +219,8 @@
:endpoints="activeEndpoints"
:selected-endpoint="selectedTestEndpoint"
:testing="!!pendingTestModel && testingModelId === pendingTestModel.id"
:trace="testTrace"
:request-id="currentTestRequestId"
:show-endpoint-selector="activeEndpoints.length > 1"
@close="handleTestDialogClose"
@back="handleTestDialogBack"
@@ -227,7 +229,8 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onBeforeUnmount } from 'vue'
import { isAxiosError } from 'axios'
import { useSmartPagination } from '@/composables/useSmartPagination'
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
@@ -242,6 +245,7 @@ import {
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'
@@ -272,6 +276,10 @@ 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 传入的数据,或使用本地数据
@@ -304,6 +312,47 @@ 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 '-'
@@ -437,6 +486,7 @@ async function toggleModelActive(model: Model) {
}
function resetTestDialogState() {
stopTestTracePolling()
testDialogOpen.value = false
pendingTestModel.value = null
selectedTestEndpoint.value = null
@@ -466,6 +516,8 @@ async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
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
@@ -476,6 +528,7 @@ async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
api_format: endpoint?.api_format,
endpoint_id: endpoint?.id,
message: 'hello',
request_id: requestId,
})
if (result.success) {
@@ -489,9 +542,11 @@ async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
resetTestDialogState()
return
}
stopTestTracePolling({ clearState: false })
testResultMode.value = 'global'
testResult.value = result
} catch (err: unknown) {
stopTestTracePolling()
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
if (activeEndpoints.value.length <= 1) {
resetTestDialogState()
@@ -526,4 +581,8 @@ async function testModelConnection(model: Model) {
defineExpose({
reload: refresh
})
onBeforeUnmount(() => {
stopTestTracePolling()
})
</script>

View File

@@ -58,7 +58,7 @@
>
<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>
<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"
@@ -154,7 +154,7 @@
:class="attemptRowClass(attempt.status)"
>
<td class="pl-3 pr-1 py-2 text-muted-foreground">
{{ attempt.candidate_index }}
{{ formatAttemptIndex(attempt) }}
</td>
<td class="px-3 py-2">
<div
@@ -267,6 +267,11 @@ 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
}
@@ -282,6 +287,11 @@ function maskKey(key: string): string {
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