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:
fawney19
2026-03-06 13:13:01 +08:00
parent d17472f09e
commit d97ec3fde2
27 changed files with 1109 additions and 105 deletions

View File

@@ -145,6 +145,8 @@ export interface RequestDetail {
output_tokens?: number
total_tokens?: number
cache_creation_input_tokens?: number
cache_creation_input_tokens_5m?: number
cache_creation_input_tokens_1h?: number
cache_read_input_tokens?: number
// Additional cost fields
input_cost?: number
@@ -189,7 +191,8 @@ export interface RequestDetail {
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_read_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}
tiers: Array<{ // 完整阶梯配置列表
@@ -200,7 +203,8 @@ export interface RequestDetail {
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_read_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}>
} | null

View File

@@ -143,6 +143,7 @@ export interface TestModelFailoverRequest {
mode: 'global' | 'direct'
model_name: string
api_format?: string
endpoint_id?: string
message?: string
}

View File

@@ -165,7 +165,7 @@
</p>
</div>
<div class="p-3 rounded-lg border">
<Label class="text-xs text-muted-foreground">缓存创建 ($/M)</Label>
<Label class="text-xs text-muted-foreground">{{ getFirst1hCachePrice(model.default_tiered_pricing) !== '-' ? '5min 缓存创建 ($/M)' : '缓存创建 ($/M)' }}</Label>
<p class="text-sm font-mono mt-1">
{{ getFirstTierPrice(model.default_tiered_pricing, 'cache_creation_price_per_1m') }}
</p>
@@ -182,7 +182,7 @@
v-if="getFirst1hCachePrice(model.default_tiered_pricing) !== '-'"
class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20"
>
<Label class="text-xs text-muted-foreground whitespace-nowrap">1h 缓存</Label>
<Label class="text-xs text-muted-foreground whitespace-nowrap">1h 缓存创建</Label>
<span class="text-sm font-mono">{{ getFirst1hCachePrice(model.default_tiered_pricing) }}</span>
</div>
<!-- 按次计费 -->

View File

@@ -273,7 +273,8 @@ import { useProxyNodesStore } from '@/stores/proxy-nodes'
type QuickSelectorValue =
| 'banned'
| 'no_quota'
| 'no_5h_limit'
| 'no_weekly_limit'
| 'plan_free'
| 'plan_team'
| 'oauth_invalid'
@@ -305,7 +306,8 @@ const emit = defineEmits<{
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
{ value: 'banned', label: '已封号' },
{ value: 'no_quota', label: '无额' },
{ value: 'no_5h_limit', label: '无5H限额' },
{ value: 'no_weekly_limit', label: '无周限额' },
{ value: 'plan_free', label: '全部 Free' },
{ value: 'plan_team', label: '全部 Team' },
{ value: 'oauth_invalid', label: 'OAuth 失效' },
@@ -408,16 +410,33 @@ function isBannedKey(key: PoolKeyDetail): boolean {
return false
}
function hasNoQuota(key: PoolKeyDetail): boolean {
const quotaText = normalizeText(key.account_quota)
if (!quotaText) return false
if (/(无额度|额度不足|已耗尽|耗尽|depleted|exhausted|insufficient)/.test(quotaText)) return true
if (/剩余\s*0(\.0+)?/.test(quotaText)) return true
if (/\b0(\.0+)?\s*\/\s*\d/.test(quotaText)) return true
if (/\b0(\.0+)?%/.test(quotaText)) return true
function getQuotaSegments(accountQuota: string | null | undefined): string[] {
return String(accountQuota || '')
.split('|')
.map((segment) => normalizeText(segment))
.filter(Boolean)
}
function isDepletedQuotaSegment(segment: string): boolean {
if (/(无额度|额度不足|已耗尽|耗尽|depleted|exhausted|insufficient)/.test(segment)) return true
if (/剩余\s*0(\.0+)?/.test(segment)) return true
if (/\b0(\.0+)?\s*\/\s*\d/.test(segment)) return true
if (/\b0(\.0+)?%/.test(segment)) return true
return false
}
function hasNoFiveHourLimit(key: PoolKeyDetail): boolean {
return getQuotaSegments(key.account_quota)
.filter((segment) => /5h|5小时/.test(segment))
.some(isDepletedQuotaSegment)
}
function hasNoWeeklyLimit(key: PoolKeyDetail): boolean {
return getQuotaSegments(key.account_quota)
.filter((segment) => /周|weekly|week/.test(segment))
.some(isDepletedQuotaSegment)
}
function isOAuthInvalid(key: PoolKeyDetail): boolean {
if (normalizeText(key.auth_type) !== 'oauth') return false
if (key.oauth_invalid_at != null || normalizeText(key.oauth_invalid_reason)) return true
@@ -455,7 +474,8 @@ function toggleSelectFiltered(checked: boolean | 'indeterminate'): void {
function matchesSelector(key: PoolKeyDetail, selector: QuickSelectorValue): boolean {
if (selector === 'banned') return isBannedKey(key)
if (selector === 'no_quota') return hasNoQuota(key)
if (selector === 'no_5h_limit') return hasNoFiveHourLimit(key)
if (selector === 'no_weekly_limit') return hasNoWeeklyLimit(key)
if (selector === 'plan_free') return isFreePlan(key)
if (selector === 'plan_team') return isTeamPlan(key)
if (selector === 'oauth_invalid') return isOAuthInvalid(key)

View File

@@ -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

View File

@@ -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 已过期,请重新授权'

View File

@@ -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')
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -193,7 +193,7 @@
<!-- 阶梯标题 -->
<div class="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
<span class="font-medium text-foreground">Token 计费</span>
<span class="text-muted-foreground/60">(输入 {{ formatNumber(detail.tokens?.input || detail.input_tokens || 0) }} + 缓存创建 {{ formatNumber(detail.cache_creation_input_tokens || 0) }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
<span class="text-muted-foreground/60">(输入 {{ formatNumber(detail.tokens?.input || detail.input_tokens || 0) }} + 缓存创建 {{ cacheCreationSummaryText }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
<Badge
v-if="displayTiers.length > 1"
variant="outline"
@@ -236,7 +236,15 @@
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
<span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span>
<span v-if="tier.cache_creation_price_per_1m">
<template v-if="hasTierCacheCreationSplitPricing(tier)">
<span v-if="getTierCachePriceForTTL(tier, 5, 'cache_creation_price_per_1m') !== null">
缓存创建(5min) ${{ formatPrice(getTierCachePriceForTTL(tier, 5, 'cache_creation_price_per_1m') || 0) }}/M
</span>
<span v-if="getTierCachePriceForTTL(tier, 60, 'cache_creation_price_per_1m') !== null">
缓存创建(1h) ${{ formatPrice(getTierCachePriceForTTL(tier, 60, 'cache_creation_price_per_1m') || 0) }}/M
</span>
</template>
<span v-else-if="tier.cache_creation_price_per_1m">
缓存创建 ${{ formatPrice(tier.cache_creation_price_per_1m) }}/M
</span>
<span v-if="tier.cache_read_price_per_1m">
@@ -267,7 +275,7 @@
<!-- 缓存创建 缓存读取 -->
<div class="flex items-center">
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">缓存创建</span>
<span class="text-xs text-muted-foreground w-[56px]">{{ cacheCreationSplitRows.length > 0 ? '创建合计' : '缓存创建' }}</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.cache_creation_input_tokens || 0 }}</span>
<span class="text-xs font-mono">${{ (detail.cache_creation_cost || 0).toFixed(6) }}</span>
</div>
@@ -283,11 +291,22 @@
</div>
<!-- 缓存创建 5m/1h 细分 -->
<div
v-if="(detail.cache_creation_input_tokens_5m || 0) > 0 || (detail.cache_creation_input_tokens_1h || 0) > 0"
class="flex items-center pl-[56px]"
v-if="cacheCreationSplitRows.length > 0"
class="space-y-1 pl-[56px]"
>
<span class="text-xs text-muted-foreground/50">5min: {{ detail.cache_creation_input_tokens_5m || 0 }}</span>
<span class="text-xs text-muted-foreground/50 ml-4">1h: {{ detail.cache_creation_input_tokens_1h || 0 }}</span>
<div
v-for="row in cacheCreationSplitRows"
:key="row.key"
class="flex items-center gap-4 text-xs text-muted-foreground/70"
>
<span class="w-[72px]">{{ row.label }}</span>
<span class="font-mono text-foreground/90">{{ formatNumber(row.tokens) }}</span>
<span v-if="row.pricePer1M !== null">${{ formatPrice(row.pricePer1M) }}/M</span>
<span
v-if="row.cost !== null"
class="font-mono"
>${{ row.cost.toFixed(6) }}</span>
</div>
</div>
</template>
</div>
@@ -714,6 +733,18 @@ const historicalPricing = ref<{
cache_read_price: string
request_price: string
} | null>(null)
type CacheTTLPriceEntry = {
ttl_minutes?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
}
type PricingTierLike = {
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
cache_ttl_pricing?: CacheTTLPriceEntry[] | null
}
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
const autoRefreshing = ref(false)
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
@@ -943,6 +974,70 @@ const currentTierIndex = computed(() => {
return 0
})
const currentTier = computed<PricingTierLike | null>(() => {
const tier = displayTiers.value[currentTierIndex.value]
if (!tier || typeof tier !== 'object') return null
return tier as PricingTierLike
})
const cacheCreationSummaryText = computed(() => {
if (!detail.value) return '0'
const total = detail.value.cache_creation_input_tokens || 0
const cache5m = detail.value.cache_creation_input_tokens_5m || 0
const cache1h = detail.value.cache_creation_input_tokens_1h || 0
if (cache5m <= 0 && cache1h <= 0) {
return formatNumber(total)
}
const parts: string[] = []
if (cache5m > 0) parts.push(`5min ${formatNumber(cache5m)}`)
if (cache1h > 0) parts.push(`1h ${formatNumber(cache1h)}`)
const remaining = Math.max(0, total - cache5m - cache1h)
if (remaining > 0) parts.push(`其他 ${formatNumber(remaining)}`)
return parts.join(' + ')
})
const cacheCreationSplitRows = computed(() => {
if (!detail.value) return []
const rows: Array<{
key: string
label: string
tokens: number
pricePer1M: number | null
cost: number | null
}> = []
const cache5m = detail.value.cache_creation_input_tokens_5m || 0
const cache1h = detail.value.cache_creation_input_tokens_1h || 0
if (cache5m > 0) {
const pricePer1M = getActiveCachePriceForTTL(5, 'cache_creation_price_per_1m')
rows.push({
key: '5m',
label: '5min 创建',
tokens: cache5m,
pricePer1M,
cost: pricePer1M !== null ? (cache5m * pricePer1M) / 1_000_000 : null,
})
}
if (cache1h > 0) {
const pricePer1M = getActiveCachePriceForTTL(60, 'cache_creation_price_per_1m')
rows.push({
key: '1h',
label: '1h 创建',
tokens: cache1h,
pricePer1M,
cost: pricePer1M !== null ? (cache1h * pricePer1M) / 1_000_000 : null,
})
}
return rows
})
// 总输入上下文(输入 + 缓存创建 + 缓存读取)
const _totalInputContext = computed(() => {
if (!detail.value) return 0
@@ -1013,6 +1108,52 @@ function hasContent(data: unknown): boolean {
return true
}
function toFiniteNumber(value: unknown): number | null {
const num = Number(value)
return Number.isFinite(num) ? num : null
}
function getTierCachePriceForTTL(
tier: PricingTierLike | null | undefined,
ttlMinutes: number,
priceKey: 'cache_creation_price_per_1m' | 'cache_read_price_per_1m',
): number | null {
const fallback = toFiniteNumber(tier?.[priceKey])
const ttlPricing = Array.isArray(tier?.cache_ttl_pricing)
? tier.cache_ttl_pricing
.filter((entry): entry is CacheTTLPriceEntry => !!entry && typeof entry === 'object')
.sort((a, b) => Number(a.ttl_minutes || 0) - Number(b.ttl_minutes || 0))
: []
if (ttlPricing.length === 0) return fallback
const matched = ttlPricing.find((entry) => Number(entry.ttl_minutes || 0) >= ttlMinutes)
|| ttlPricing[ttlPricing.length - 1]
const price = toFiniteNumber(matched?.[priceKey])
return price ?? fallback
}
function hasTierCacheCreationSplitPricing(tier: PricingTierLike | null | undefined): boolean {
const ttlPricing = Array.isArray(tier?.cache_ttl_pricing) ? tier.cache_ttl_pricing : []
return ttlPricing.some((entry) =>
Number(entry?.ttl_minutes || 0) >= 60
&& toFiniteNumber(entry?.cache_creation_price_per_1m) !== null,
)
}
function getActiveCachePriceForTTL(
ttlMinutes: number,
priceKey: 'cache_creation_price_per_1m' | 'cache_read_price_per_1m',
): number | null {
const tierPrice = getTierCachePriceForTTL(currentTier.value, ttlMinutes, priceKey)
if (tierPrice !== null) return tierPrice
if (priceKey === 'cache_creation_price_per_1m') {
return toFiniteNumber(detail.value?.cache_creation_price_per_1m)
}
return toFiniteNumber(detail.value?.cache_read_price_per_1m)
}
function getDefaultDataSourceForTab(tab: string): 'client' | 'provider' {
if (!detail.value) {
if (['request-headers', 'request-body'].includes(tab)) return 'provider'

View File

@@ -21,11 +21,18 @@ const KEYWORDS_DISABLED = [
'account deactivated',
'organization has been disabled',
'organization_disabled',
'deactivated_workspace',
'deactivated',
'访问被禁止',
'账户访问被禁止',
]
const KEYWORDS_TOKEN_INVALID = [
'authentication token has been invalidated',
'token has been invalidated',
'codex token 无效或已过期',
]
// 需要验证类
const KEYWORDS_VERIFICATION = [
'validation_required',
@@ -36,6 +43,7 @@ const KEYWORDS_VERIFICATION = [
const ACCOUNT_BLOCK_REASON_KEYWORDS = [
...KEYWORDS_SUSPENDED,
...KEYWORDS_DISABLED,
...KEYWORDS_TOKEN_INVALID,
...KEYWORDS_VERIFICATION,
]
@@ -52,7 +60,9 @@ export function isAccountLevelBlockReason(reason: string | null | undefined): bo
export function classifyAccountBlockLabel(reason: string): string {
if (reason.trim().startsWith('[OAUTH_EXPIRED]')) return 'Token 失效'
const lowered = reason.toLowerCase()
if (KEYWORDS_TOKEN_INVALID.some(kw => lowered.includes(kw))) return 'Token 失效'
if (KEYWORDS_VERIFICATION.some(kw => lowered.includes(kw))) return '需要验证'
if (lowered.includes('deactivated_workspace')) return '工作区停用'
if (KEYWORDS_DISABLED.some(kw => lowered.includes(kw))) return '账号停用'
if (KEYWORDS_SUSPENDED.some(kw => lowered.includes(kw))) return '账号封禁'
return '账号异常'

View File

@@ -2307,7 +2307,10 @@ function getOAuthStatusTitle(key: PoolKeyDetail): 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 已过期,请重新授权'