mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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:
@@ -145,6 +145,8 @@ export interface RequestDetail {
|
|||||||
output_tokens?: number
|
output_tokens?: number
|
||||||
total_tokens?: number
|
total_tokens?: number
|
||||||
cache_creation_input_tokens?: number
|
cache_creation_input_tokens?: number
|
||||||
|
cache_creation_input_tokens_5m?: number
|
||||||
|
cache_creation_input_tokens_1h?: number
|
||||||
cache_read_input_tokens?: number
|
cache_read_input_tokens?: number
|
||||||
// Additional cost fields
|
// Additional cost fields
|
||||||
input_cost?: number
|
input_cost?: number
|
||||||
@@ -189,7 +191,8 @@ export interface RequestDetail {
|
|||||||
cache_read_price_per_1m?: number
|
cache_read_price_per_1m?: number
|
||||||
cache_ttl_pricing?: Array<{
|
cache_ttl_pricing?: Array<{
|
||||||
ttl_minutes: number
|
ttl_minutes: number
|
||||||
cache_read_price_per_1m: number
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
tiers: Array<{ // 完整阶梯配置列表
|
tiers: Array<{ // 完整阶梯配置列表
|
||||||
@@ -200,7 +203,8 @@ export interface RequestDetail {
|
|||||||
cache_read_price_per_1m?: number
|
cache_read_price_per_1m?: number
|
||||||
cache_ttl_pricing?: Array<{
|
cache_ttl_pricing?: Array<{
|
||||||
ttl_minutes: number
|
ttl_minutes: number
|
||||||
cache_read_price_per_1m: number
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
}>
|
}>
|
||||||
}>
|
}>
|
||||||
} | null
|
} | null
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export interface TestModelFailoverRequest {
|
|||||||
mode: 'global' | 'direct'
|
mode: 'global' | 'direct'
|
||||||
model_name: string
|
model_name: string
|
||||||
api_format?: string
|
api_format?: string
|
||||||
|
endpoint_id?: string
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 rounded-lg border">
|
<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">
|
<p class="text-sm font-mono mt-1">
|
||||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'cache_creation_price_per_1m') }}
|
{{ getFirstTierPrice(model.default_tiered_pricing, 'cache_creation_price_per_1m') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
v-if="getFirst1hCachePrice(model.default_tiered_pricing) !== '-'"
|
v-if="getFirst1hCachePrice(model.default_tiered_pricing) !== '-'"
|
||||||
class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20"
|
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>
|
<span class="text-sm font-mono">{{ getFirst1hCachePrice(model.default_tiered_pricing) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 按次计费 -->
|
<!-- 按次计费 -->
|
||||||
|
|||||||
@@ -273,7 +273,8 @@ import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
|||||||
|
|
||||||
type QuickSelectorValue =
|
type QuickSelectorValue =
|
||||||
| 'banned'
|
| 'banned'
|
||||||
| 'no_quota'
|
| 'no_5h_limit'
|
||||||
|
| 'no_weekly_limit'
|
||||||
| 'plan_free'
|
| 'plan_free'
|
||||||
| 'plan_team'
|
| 'plan_team'
|
||||||
| 'oauth_invalid'
|
| 'oauth_invalid'
|
||||||
@@ -305,7 +306,8 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
|
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
|
||||||
{ value: 'banned', label: '已封号' },
|
{ 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_free', label: '全部 Free' },
|
||||||
{ value: 'plan_team', label: '全部 Team' },
|
{ value: 'plan_team', label: '全部 Team' },
|
||||||
{ value: 'oauth_invalid', label: 'OAuth 失效' },
|
{ value: 'oauth_invalid', label: 'OAuth 失效' },
|
||||||
@@ -408,16 +410,33 @@ function isBannedKey(key: PoolKeyDetail): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasNoQuota(key: PoolKeyDetail): boolean {
|
function getQuotaSegments(accountQuota: string | null | undefined): string[] {
|
||||||
const quotaText = normalizeText(key.account_quota)
|
return String(accountQuota || '')
|
||||||
if (!quotaText) return false
|
.split('|')
|
||||||
if (/(无额度|额度不足|已耗尽|耗尽|depleted|exhausted|insufficient)/.test(quotaText)) return true
|
.map((segment) => normalizeText(segment))
|
||||||
if (/剩余\s*0(\.0+)?/.test(quotaText)) return true
|
.filter(Boolean)
|
||||||
if (/\b0(\.0+)?\s*\/\s*\d/.test(quotaText)) return true
|
}
|
||||||
if (/\b0(\.0+)?%/.test(quotaText)) return true
|
|
||||||
|
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
|
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 {
|
function isOAuthInvalid(key: PoolKeyDetail): boolean {
|
||||||
if (normalizeText(key.auth_type) !== 'oauth') return false
|
if (normalizeText(key.auth_type) !== 'oauth') return false
|
||||||
if (key.oauth_invalid_at != null || normalizeText(key.oauth_invalid_reason)) return true
|
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 {
|
function matchesSelector(key: PoolKeyDetail, selector: QuickSelectorValue): boolean {
|
||||||
if (selector === 'banned') return isBannedKey(key)
|
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_free') return isFreePlan(key)
|
||||||
if (selector === 'plan_team') return isTeamPlan(key)
|
if (selector === 'plan_team') return isTeamPlan(key)
|
||||||
if (selector === 'oauth_invalid') return isOAuthInvalid(key)
|
if (selector === 'oauth_invalid') return isOAuthInvalid(key)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
data-1p-ignore="true"
|
data-1p-ignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="providerType === 'vertex_ai'">
|
<div v-if="showAuthTypeSelector">
|
||||||
<Label :for="authTypeSelectId">认证类型</Label>
|
<Label :for="authTypeSelectId">认证类型</Label>
|
||||||
<Select
|
<Select
|
||||||
v-model="form.auth_type"
|
v-model="form.auth_type"
|
||||||
@@ -50,10 +50,30 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- API 密钥 / Service Account JSON -->
|
<!-- API 密钥 / Service Account JSON -->
|
||||||
<div>
|
<div v-if="showAuthTypeSelector || form.auth_type === 'service_account'">
|
||||||
<Label :for="apiKeyInputId">
|
<Label :for="apiKeyInputId">
|
||||||
{{ form.auth_type === 'service_account' ? 'Service Account JSON' : 'API 密钥' }}
|
{{ form.auth_type === 'service_account' ? 'Service Account JSON' : 'API 密钥' }}
|
||||||
{{ editingKey ? '' : '*' }}
|
{{ editingKey ? '' : '*' }}
|
||||||
@@ -367,6 +387,8 @@ const visibleApiFormats = computed(() => {
|
|||||||
return sorted.filter(fmt => allowed.has(normalizeApiFormat(fmt)))
|
return sorted.filter(fmt => allowed.has(normalizeApiFormat(fmt)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showAuthTypeSelector = computed(() => props.providerType === 'vertex_ai')
|
||||||
|
|
||||||
// 默认认证类型
|
// 默认认证类型
|
||||||
const defaultAuthType = 'api_key' as const
|
const defaultAuthType = 'api_key' as const
|
||||||
|
|
||||||
|
|||||||
@@ -892,6 +892,7 @@
|
|||||||
:key="`models-${provider.id}`"
|
:key="`models-${provider.id}`"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:models="providerModels"
|
:models="providerModels"
|
||||||
|
:endpoints="endpoints"
|
||||||
@edit-model="handleEditModel"
|
@edit-model="handleEditModel"
|
||||||
@batch-assign="handleBatchAssign"
|
@batch-assign="handleBatchAssign"
|
||||||
@refresh="loadEndpoints"
|
@refresh="loadEndpoints"
|
||||||
@@ -1101,7 +1102,7 @@ import {
|
|||||||
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
|
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||||
import { isAccountLevelBlockReason } from '@/utils/accountBlock'
|
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
|
||||||
|
|
||||||
// 扩展端点类型,包含密钥列表
|
// 扩展端点类型,包含密钥列表
|
||||||
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
||||||
@@ -2504,7 +2505,10 @@ function getOAuthStatusTitle(key: EndpointAPIKey): string {
|
|||||||
const status = getKeyOAuthExpires(key)
|
const status = getKeyOAuthExpires(key)
|
||||||
if (!status) return ''
|
if (!status) return ''
|
||||||
if (status.isInvalid) {
|
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) {
|
if (status.isExpired) {
|
||||||
return 'Token 已过期,请重新授权'
|
return 'Token 已过期,请重新授权'
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const basePayload = {
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
provider_type: form.value.provider_type,
|
provider_type: form.value.provider_type,
|
||||||
description: form.value.description || undefined,
|
description: form.value.description || undefined,
|
||||||
@@ -447,7 +447,6 @@ const handleSubmit = async () => {
|
|||||||
quota_reset_day: form.value.quota_reset_day,
|
quota_reset_day: form.value.quota_reset_day,
|
||||||
quota_last_reset_at: form.value.quota_last_reset_at || undefined,
|
quota_last_reset_at: form.value.quota_last_reset_at || undefined,
|
||||||
quota_expires_at: form.value.quota_expires_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,
|
keep_priority_on_conversion: form.value.keep_priority_on_conversion,
|
||||||
is_active: form.value.is_active,
|
is_active: form.value.is_active,
|
||||||
// 请求配置
|
// 请求配置
|
||||||
@@ -462,12 +461,15 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
if (isEditMode.value && props.provider) {
|
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('提供商更新成功')
|
success('提供商更新成功')
|
||||||
emit('providerUpdated', updated)
|
emit('providerUpdated', updated)
|
||||||
} else {
|
} else {
|
||||||
// 创建提供商
|
// 创建提供商(优先级由后端自动置顶)
|
||||||
await createProvider(payload)
|
await createProvider(basePayload)
|
||||||
success('提供商已创建,请继续添加端点和密钥,或在优先级管理中调整顺序', '创建成功')
|
success('提供商已创建,请继续添加端点和密钥,或在优先级管理中调整顺序', '创建成功')
|
||||||
emit('providerCreated')
|
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>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="getEffectiveCachePrice(model, 'creation') > 0 || getEffectiveCachePrice(model, 'read') > 0">
|
<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">
|
<span class="font-mono font-semibold">
|
||||||
${{ formatPrice(getEffectiveCachePrice(model, 'creation')) }}/${{ formatPrice(getEffectiveCachePrice(model, 'read')) }}
|
${{ formatPrice(getEffectiveCachePrice(model, 'creation')) }}/${{ formatPrice(getEffectiveCachePrice(model, 'read')) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<!-- 1h 缓存价格 -->
|
<!-- 1h 缓存价格 -->
|
||||||
<template v-if="get1hCachePrice(model) > 0">
|
<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">
|
<span class="font-mono font-semibold">
|
||||||
${{ formatPrice(get1hCachePrice(model)) }}
|
${{ formatPrice(get1hCachePrice(model)) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -211,11 +211,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 测试结果对话框 -->
|
<ModelTestDialog
|
||||||
<TestResultDialog
|
:open="testDialogOpen"
|
||||||
:result="testResult"
|
:result="testResult"
|
||||||
:mode="testResultMode"
|
: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>
|
</template>
|
||||||
|
|
||||||
@@ -231,16 +238,19 @@ import { sortResolutionEntries } from '@/utils/form'
|
|||||||
import {
|
import {
|
||||||
testModelFailover,
|
testModelFailover,
|
||||||
type Model,
|
type Model,
|
||||||
|
type ProviderEndpoint,
|
||||||
type TestModelFailoverResponse,
|
type TestModelFailoverResponse,
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { updateModel } from '@/api/endpoints/models'
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||||
import TestResultDialog from './TestResultDialog.vue'
|
import ModelTestDialog from './ModelTestDialog.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
provider: ProviderWithEndpointsSummary
|
provider: ProviderWithEndpointsSummary
|
||||||
models?: Model[]
|
models?: Model[]
|
||||||
|
endpoints?: ProviderEndpoint[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -259,6 +269,11 @@ const togglingModelId = ref<string | null>(null)
|
|||||||
const testingModelId = ref<string | null>(null)
|
const testingModelId = ref<string | null>(null)
|
||||||
const testResult = ref<TestModelFailoverResponse | null>(null)
|
const testResult = ref<TestModelFailoverResponse | null>(null)
|
||||||
const testResultMode = ref<'global' | 'direct'>('global')
|
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 传入的数据,或使用本地数据
|
// 使用 props 传入的数据,或使用本地数据
|
||||||
const models = computed(() => props.models ?? localModels.value)
|
const models = computed(() => props.models ?? localModels.value)
|
||||||
// 按名称排序的模型列表
|
// 按名称排序的模型列表
|
||||||
@@ -421,11 +436,36 @@ async function toggleModelActive(model: Model) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试模型连接性(模拟外部请求,带故障转移)
|
function resetTestDialogState() {
|
||||||
async function testModelConnection(model: Model) {
|
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
|
if (testingModelId.value) return
|
||||||
|
|
||||||
testingModelId.value = model.id
|
testingModelId.value = model.id
|
||||||
|
testDialogOpen.value = true
|
||||||
|
selectedTestEndpoint.value = endpoint ?? null
|
||||||
try {
|
try {
|
||||||
const modelName = model.global_model_name || model.provider_model_name
|
const modelName = model.global_model_name || model.provider_model_name
|
||||||
|
|
||||||
@@ -433,7 +473,9 @@ async function testModelConnection(model: Model) {
|
|||||||
provider_id: props.provider.id,
|
provider_id: props.provider.id,
|
||||||
mode: 'global',
|
mode: 'global',
|
||||||
model_name: modelName,
|
model_name: modelName,
|
||||||
message: "hello",
|
api_format: endpoint?.api_format,
|
||||||
|
endpoint_id: endpoint?.id,
|
||||||
|
message: 'hello',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -442,18 +484,44 @@ async function testModelConnection(model: Model) {
|
|||||||
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== modelName
|
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== modelName
|
||||||
? ` -> ${successAttempt.effective_model}`
|
? ` -> ${successAttempt.effective_model}`
|
||||||
: ''
|
: ''
|
||||||
showSuccess(`${modelName}${mapped} 测试成功${latency}`)
|
const endpointPrefix = endpoint ? `[${formatApiFormat(endpoint.api_format)}] ` : ''
|
||||||
} else {
|
showSuccess(`${endpointPrefix}${modelName}${mapped} 测试成功${latency}`)
|
||||||
testResultMode.value = 'global'
|
resetTestDialogState()
|
||||||
testResult.value = result
|
return
|
||||||
}
|
}
|
||||||
|
testResultMode.value = 'global'
|
||||||
|
testResult.value = result
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
|
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
|
||||||
|
if (activeEndpoints.value.length <= 1) {
|
||||||
|
resetTestDialogState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedTestEndpoint.value = null
|
||||||
} finally {
|
} finally {
|
||||||
testingModelId.value = null
|
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({
|
defineExpose({
|
||||||
reload: refresh
|
reload: refresh
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
v-if="result"
|
v-if="result"
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<!-- 总体状态 -->
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge :variant="result.success ? 'success' : 'destructive'">
|
<Badge :variant="result.success ? 'success' : 'destructive'">
|
||||||
@@ -24,7 +23,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型信息 -->
|
|
||||||
<div class="text-sm space-y-1">
|
<div class="text-sm space-y-1">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted-foreground">请求模型: </span>
|
<span class="text-muted-foreground">请求模型: </span>
|
||||||
@@ -40,7 +38,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 错误信息 -->
|
|
||||||
<div
|
<div
|
||||||
v-if="result.error && !result.success"
|
v-if="result.error && !result.success"
|
||||||
class="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-xs text-destructive"
|
class="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-xs text-destructive"
|
||||||
@@ -48,65 +45,109 @@
|
|||||||
{{ result.error }}
|
{{ result.error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attempt 详情表 -->
|
<!-- mobile: list layout -->
|
||||||
<div
|
<div
|
||||||
v-if="result.attempts.length > 0"
|
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>
|
<thead>
|
||||||
<tr class="border-b bg-muted/30">
|
<tr class="border-b bg-muted/30">
|
||||||
<th class="px-3 py-2 text-left font-medium">
|
<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>
|
<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
|
<th
|
||||||
v-if="hasEffectiveModel"
|
v-if="hasEffectiveModel"
|
||||||
class="px-3 py-2 text-left font-medium"
|
class="px-3 py-2 text-left font-medium"
|
||||||
>
|
>发送模型</th>
|
||||||
发送模型
|
<th class="px-3 py-2 text-left font-medium">状态</th>
|
||||||
</th>
|
<th class="px-3 py-2 text-right font-medium">延迟</th>
|
||||||
<th class="px-3 py-2 text-left font-medium">
|
<th class="px-3 py-2 text-left font-medium">详情</th>
|
||||||
状态
|
|
||||||
</th>
|
|
||||||
<th class="px-3 py-2 text-right font-medium">
|
|
||||||
延迟
|
|
||||||
</th>
|
|
||||||
<th class="px-3 py-2 text-left font-medium">
|
|
||||||
详情
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="(attempt, idx) in result.attempts"
|
v-for="(attempt, idx) in result.attempts"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="border-b last:border-b-0"
|
class="border-b last:border-b-0 align-top"
|
||||||
:class="attemptRowClass(attempt.status)"
|
:class="attemptRowClass(attempt.status)"
|
||||||
>
|
>
|
||||||
<td class="px-3 py-2 text-muted-foreground">
|
<td class="pl-3 pr-1 py-2 text-muted-foreground">{{ attempt.candidate_index }}</td>
|
||||||
{{ attempt.candidate_index }}
|
<td class="px-3 py-2">
|
||||||
</td>
|
<div
|
||||||
<td
|
v-if="attempt.key_name"
|
||||||
class="px-3 py-2 max-w-[160px] truncate"
|
class="font-medium truncate"
|
||||||
:title="attempt.key_name || attempt.key_id"
|
:title="attempt.key_name"
|
||||||
>
|
>
|
||||||
{{ attempt.key_name || (attempt.key_id.length > 8 ? attempt.key_id.slice(0, 8) + '...' : attempt.key_id) }}
|
{{ attempt.key_name }}
|
||||||
<span class="text-muted-foreground ml-1">({{ attempt.auth_type }})</span>
|
</div>
|
||||||
|
<div class="text-muted-foreground truncate" :title="attempt.key_id">
|
||||||
|
{{ maskKey(attempt.key_id) }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<code class="text-[11px] bg-muted px-1 py-0.5 rounded">
|
<code class="text-[11px] bg-muted px-1 py-0.5 rounded">{{ attempt.endpoint_api_format }}</code>
|
||||||
{{ attempt.endpoint_api_format }}
|
|
||||||
</code>
|
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="hasEffectiveModel"
|
v-if="hasEffectiveModel"
|
||||||
class="px-3 py-2 max-w-[180px] truncate"
|
class="px-3 py-2 truncate"
|
||||||
:title="attempt.effective_model || '-'"
|
:title="attempt.effective_model || '-'"
|
||||||
>
|
>
|
||||||
{{ attempt.effective_model || '-' }}
|
{{ attempt.effective_model || '-' }}
|
||||||
@@ -116,30 +157,25 @@
|
|||||||
:variant="statusVariant(attempt.status)"
|
:variant="statusVariant(attempt.status)"
|
||||||
class="text-[10px] px-1.5 py-0"
|
class="text-[10px] px-1.5 py-0"
|
||||||
>
|
>
|
||||||
{{ statusLabel(attempt.status) }}
|
{{ attempt.status_code || statusLabel(attempt.status) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span
|
|
||||||
v-if="attempt.status_code"
|
|
||||||
class="text-muted-foreground ml-1"
|
|
||||||
>
|
|
||||||
{{ attempt.status_code }}
|
|
||||||
</span>
|
|
||||||
</td>
|
</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' : '-' }}
|
{{ attempt.latency_ms != null ? attempt.latency_ms + 'ms' : '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td class="px-3 py-2 text-muted-foreground">
|
||||||
class="px-3 py-2 max-w-[200px] truncate text-muted-foreground"
|
<div
|
||||||
:title="attemptDetail(attempt)"
|
class="break-all line-clamp-2"
|
||||||
>
|
:title="attemptDetail(attempt)"
|
||||||
{{ attemptDetail(attempt) }}
|
>
|
||||||
|
{{ attemptDetail(attempt) }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 无 attempt -->
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="text-center text-sm text-muted-foreground py-4"
|
class="text-center text-sm text-muted-foreground py-4"
|
||||||
@@ -212,6 +248,11 @@ function attemptRowClass(status: string) {
|
|||||||
return ''
|
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 {
|
function attemptDetail(attempt: TestAttemptDetail): string {
|
||||||
if (attempt.skip_reason) return attempt.skip_reason
|
if (attempt.skip_reason) return attempt.skip_reason
|
||||||
if (attempt.error_message) return attempt.error_message
|
if (attempt.error_message) return attempt.error_message
|
||||||
|
|||||||
@@ -193,7 +193,7 @@
|
|||||||
<!-- 阶梯标题 -->
|
<!-- 阶梯标题 -->
|
||||||
<div class="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
<div class="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||||
<span class="font-medium text-foreground">Token 计费</span>
|
<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
|
<Badge
|
||||||
v-if="displayTiers.length > 1"
|
v-if="displayTiers.length > 1"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -236,7 +236,15 @@
|
|||||||
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
|
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||||
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
|
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
|
||||||
<span>输出 ${{ formatPrice(tier.output_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
|
缓存创建 ${{ formatPrice(tier.cache_creation_price_per_1m) }}/M
|
||||||
</span>
|
</span>
|
||||||
<span v-if="tier.cache_read_price_per_1m">
|
<span v-if="tier.cache_read_price_per_1m">
|
||||||
@@ -267,7 +275,7 @@
|
|||||||
<!-- 缓存创建 缓存读取 -->
|
<!-- 缓存创建 缓存读取 -->
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex items-center flex-1">
|
<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-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>
|
<span class="text-xs font-mono">${{ (detail.cache_creation_cost || 0).toFixed(6) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,11 +291,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 缓存创建 5m/1h 细分 -->
|
<!-- 缓存创建 5m/1h 细分 -->
|
||||||
<div
|
<div
|
||||||
v-if="(detail.cache_creation_input_tokens_5m || 0) > 0 || (detail.cache_creation_input_tokens_1h || 0) > 0"
|
v-if="cacheCreationSplitRows.length > 0"
|
||||||
class="flex items-center pl-[56px]"
|
class="space-y-1 pl-[56px]"
|
||||||
>
|
>
|
||||||
<span class="text-xs text-muted-foreground/50">5min: {{ detail.cache_creation_input_tokens_5m || 0 }}</span>
|
<div
|
||||||
<span class="text-xs text-muted-foreground/50 ml-4">1h: {{ detail.cache_creation_input_tokens_1h || 0 }}</span>
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -714,6 +733,18 @@ const historicalPricing = ref<{
|
|||||||
cache_read_price: string
|
cache_read_price: string
|
||||||
request_price: string
|
request_price: string
|
||||||
} | null>(null)
|
} | 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 autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||||
const autoRefreshing = ref(false)
|
const autoRefreshing = ref(false)
|
||||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||||
@@ -943,6 +974,70 @@ const currentTierIndex = computed(() => {
|
|||||||
return 0
|
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(() => {
|
const _totalInputContext = computed(() => {
|
||||||
if (!detail.value) return 0
|
if (!detail.value) return 0
|
||||||
@@ -1013,6 +1108,52 @@ function hasContent(data: unknown): boolean {
|
|||||||
return true
|
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' {
|
function getDefaultDataSourceForTab(tab: string): 'client' | 'provider' {
|
||||||
if (!detail.value) {
|
if (!detail.value) {
|
||||||
if (['request-headers', 'request-body'].includes(tab)) return 'provider'
|
if (['request-headers', 'request-body'].includes(tab)) return 'provider'
|
||||||
|
|||||||
@@ -21,11 +21,18 @@ const KEYWORDS_DISABLED = [
|
|||||||
'account deactivated',
|
'account deactivated',
|
||||||
'organization has been disabled',
|
'organization has been disabled',
|
||||||
'organization_disabled',
|
'organization_disabled',
|
||||||
|
'deactivated_workspace',
|
||||||
'deactivated',
|
'deactivated',
|
||||||
'访问被禁止',
|
'访问被禁止',
|
||||||
'账户访问被禁止',
|
'账户访问被禁止',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const KEYWORDS_TOKEN_INVALID = [
|
||||||
|
'authentication token has been invalidated',
|
||||||
|
'token has been invalidated',
|
||||||
|
'codex token 无效或已过期',
|
||||||
|
]
|
||||||
|
|
||||||
// 需要验证类
|
// 需要验证类
|
||||||
const KEYWORDS_VERIFICATION = [
|
const KEYWORDS_VERIFICATION = [
|
||||||
'validation_required',
|
'validation_required',
|
||||||
@@ -36,6 +43,7 @@ const KEYWORDS_VERIFICATION = [
|
|||||||
const ACCOUNT_BLOCK_REASON_KEYWORDS = [
|
const ACCOUNT_BLOCK_REASON_KEYWORDS = [
|
||||||
...KEYWORDS_SUSPENDED,
|
...KEYWORDS_SUSPENDED,
|
||||||
...KEYWORDS_DISABLED,
|
...KEYWORDS_DISABLED,
|
||||||
|
...KEYWORDS_TOKEN_INVALID,
|
||||||
...KEYWORDS_VERIFICATION,
|
...KEYWORDS_VERIFICATION,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -52,7 +60,9 @@ export function isAccountLevelBlockReason(reason: string | null | undefined): bo
|
|||||||
export function classifyAccountBlockLabel(reason: string): string {
|
export function classifyAccountBlockLabel(reason: string): string {
|
||||||
if (reason.trim().startsWith('[OAUTH_EXPIRED]')) return 'Token 失效'
|
if (reason.trim().startsWith('[OAUTH_EXPIRED]')) return 'Token 失效'
|
||||||
const lowered = reason.toLowerCase()
|
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 (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_DISABLED.some(kw => lowered.includes(kw))) return '账号停用'
|
||||||
if (KEYWORDS_SUSPENDED.some(kw => lowered.includes(kw))) return '账号封禁'
|
if (KEYWORDS_SUSPENDED.some(kw => lowered.includes(kw))) return '账号封禁'
|
||||||
return '账号异常'
|
return '账号异常'
|
||||||
|
|||||||
@@ -2307,7 +2307,10 @@ function getOAuthStatusTitle(key: PoolKeyDetail): string {
|
|||||||
const status = getKeyOAuthExpires(key)
|
const status = getKeyOAuthExpires(key)
|
||||||
if (!status) return ''
|
if (!status) return ''
|
||||||
if (status.isInvalid) {
|
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) {
|
if (status.isExpired) {
|
||||||
return 'Token 已过期,请重新授权'
|
return 'Token 已过期,请重新授权'
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ class TestModelFailoverRequest(BaseModel):
|
|||||||
mode: str # "global" = 模拟外部请求(用全局模型名), "direct" = 直接测试(用provider_model_name)
|
mode: str # "global" = 模拟外部请求(用全局模型名), "direct" = 直接测试(用provider_model_name)
|
||||||
model_name: str # global 模式传 global_model_name, direct 模式传 provider_model_name
|
model_name: str # global 模式传 global_model_name, direct 模式传 provider_model_name
|
||||||
api_format: str | None = None # 指定 API 格式(endpoint signature)
|
api_format: str | None = None # 指定 API 格式(endpoint signature)
|
||||||
|
endpoint_id: str | None = None # 指定仅使用该端点测试
|
||||||
message: str | None = "Hello"
|
message: str | None = "Hello"
|
||||||
|
|
||||||
|
|
||||||
@@ -1124,6 +1125,7 @@ async def test_model(
|
|||||||
def _build_direct_test_candidates(
|
def _build_direct_test_candidates(
|
||||||
provider: Provider,
|
provider: Provider,
|
||||||
api_format: str | None = None,
|
api_format: str | None = None,
|
||||||
|
endpoint_id: str | None = None,
|
||||||
) -> list[ProviderCandidate]:
|
) -> list[ProviderCandidate]:
|
||||||
"""
|
"""
|
||||||
为直接测试模式构建候选列表。
|
为直接测试模式构建候选列表。
|
||||||
@@ -1134,6 +1136,8 @@ def _build_direct_test_candidates(
|
|||||||
|
|
||||||
candidates: list[ProviderCandidate] = []
|
candidates: list[ProviderCandidate] = []
|
||||||
for endpoint in provider.endpoints or []:
|
for endpoint in provider.endpoints or []:
|
||||||
|
if endpoint_id and str(getattr(endpoint, "id", "") or "") != str(endpoint_id):
|
||||||
|
continue
|
||||||
if not getattr(endpoint, "is_active", False):
|
if not getattr(endpoint, "is_active", False):
|
||||||
continue
|
continue
|
||||||
ep_format = str(getattr(endpoint, "api_format", "") or "")
|
ep_format = str(getattr(endpoint, "api_format", "") or "")
|
||||||
@@ -1161,6 +1165,21 @@ def _build_direct_test_candidates(
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_test_candidates_by_endpoint(
|
||||||
|
candidates: list[ProviderCandidate],
|
||||||
|
endpoint_id: str | None,
|
||||||
|
) -> list[ProviderCandidate]:
|
||||||
|
if not endpoint_id:
|
||||||
|
return list(candidates)
|
||||||
|
|
||||||
|
target_id = str(endpoint_id)
|
||||||
|
return [
|
||||||
|
candidate
|
||||||
|
for candidate in candidates
|
||||||
|
if str(getattr(getattr(candidate, "endpoint", None), "id", "") or "") == target_id
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test-model-failover")
|
@router.post("/test-model-failover")
|
||||||
async def test_model_failover(
|
async def test_model_failover(
|
||||||
request: TestModelFailoverRequest,
|
request: TestModelFailoverRequest,
|
||||||
@@ -1198,6 +1217,19 @@ async def test_model_failover(
|
|||||||
# 2. 构建候选列表
|
# 2. 构建候选列表
|
||||||
candidates = []
|
candidates = []
|
||||||
gm_obj = None # GlobalModel 对象,global 模式下用于 fallback 映射
|
gm_obj = None # GlobalModel 对象,global 模式下用于 fallback 映射
|
||||||
|
endpoint_by_id = {
|
||||||
|
str(getattr(ep, "id", "") or ""): ep
|
||||||
|
for ep in (provider.endpoints or [])
|
||||||
|
if getattr(ep, "id", None)
|
||||||
|
}
|
||||||
|
requested_endpoint = None
|
||||||
|
if request.endpoint_id:
|
||||||
|
requested_endpoint = endpoint_by_id.get(str(request.endpoint_id))
|
||||||
|
if requested_endpoint is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Endpoint not found")
|
||||||
|
ep_format = str(getattr(requested_endpoint, "api_format", "") or "")
|
||||||
|
if request.api_format and ep_format != request.api_format:
|
||||||
|
raise HTTPException(status_code=400, detail="endpoint_id does not match api_format")
|
||||||
|
|
||||||
if request.mode == "global":
|
if request.mode == "global":
|
||||||
# 模拟外部请求:走 CandidateBuilder 候选解析
|
# 模拟外部请求:走 CandidateBuilder 候选解析
|
||||||
@@ -1210,6 +1242,8 @@ async def test_model_failover(
|
|||||||
|
|
||||||
# 确定 client_format
|
# 确定 client_format
|
||||||
client_format = request.api_format
|
client_format = request.api_format
|
||||||
|
if not client_format and requested_endpoint is not None:
|
||||||
|
client_format = str(getattr(requested_endpoint, "api_format", "") or "")
|
||||||
if not client_format:
|
if not client_format:
|
||||||
# 取第一个活跃端点的格式
|
# 取第一个活跃端点的格式
|
||||||
for ep in provider.endpoints or []:
|
for ep in provider.endpoints or []:
|
||||||
@@ -1249,11 +1283,13 @@ async def test_model_failover(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("[test-model-failover] CandidateBuilder failed: {}", e)
|
logger.warning("[test-model-failover] CandidateBuilder failed: {}", e)
|
||||||
candidates = []
|
candidates = []
|
||||||
|
candidates = _filter_test_candidates_by_endpoint(candidates, request.endpoint_id)
|
||||||
else:
|
else:
|
||||||
# 直接测试:简单匹配 Endpoint + Key
|
# 直接测试:简单匹配 Endpoint + Key
|
||||||
candidates = _build_direct_test_candidates(
|
candidates = _build_direct_test_candidates(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
api_format=request.api_format,
|
api_format=request.api_format,
|
||||||
|
endpoint_id=request.endpoint_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
|
|||||||
@@ -69,6 +69,22 @@ def _get_fixed_provider_template(provider_type: str | None) -> Any | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_new_provider_priority(
|
||||||
|
current_min_priority: int | None, requested_priority: int | None
|
||||||
|
) -> tuple[int, bool]:
|
||||||
|
"""Resolve insertion priority for a newly created provider.
|
||||||
|
|
||||||
|
Returns ``(priority, needs_shift)``. When the caller explicitly specifies
|
||||||
|
a priority we need to shift existing rows; when auto-topping we simply pick
|
||||||
|
``min - 1`` so no shift is required.
|
||||||
|
"""
|
||||||
|
if requested_priority is not None:
|
||||||
|
return int(requested_priority), True
|
||||||
|
if current_min_priority is not None:
|
||||||
|
return int(current_min_priority) - 1, False
|
||||||
|
return 100, False
|
||||||
|
|
||||||
|
|
||||||
def _merge_pool_advanced_config(
|
def _merge_pool_advanced_config(
|
||||||
*,
|
*,
|
||||||
provider_config: dict[str, Any] | None,
|
provider_config: dict[str, Any] | None,
|
||||||
@@ -265,7 +281,7 @@ async def create_provider(request: Request, db: Session = Depends(get_db)) -> An
|
|||||||
- `quota_reset_day`: 配额重置日期(1-31)(可选)
|
- `quota_reset_day`: 配额重置日期(1-31)(可选)
|
||||||
- `quota_last_reset_at`: 上次配额重置时间(可选)
|
- `quota_last_reset_at`: 上次配额重置时间(可选)
|
||||||
- `quota_expires_at`: 配额过期时间(可选)
|
- `quota_expires_at`: 配额过期时间(可选)
|
||||||
- `provider_priority`: 提供商优先级(数字越小优先级越高,默认 100)
|
- `provider_priority`: 提供商优先级(数字越小优先级越高;不传时自动置顶,并将原有提供商顺延一位)
|
||||||
- `is_active`: 是否启用(默认 true)
|
- `is_active`: 是否启用(默认 true)
|
||||||
- `concurrent_limit`: 并发限制(可选)
|
- `concurrent_limit`: 并发限制(可选)
|
||||||
- `max_retries`: 最大重试次数(可选)
|
- `max_retries`: 最大重试次数(可选)
|
||||||
@@ -458,6 +474,20 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
|
|||||||
failover_rules_in_payload=validated_data.failover_rules is not None,
|
failover_rules_in_payload=validated_data.failover_rules is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
current_min_priority = db.query(func.min(Provider.provider_priority)).scalar()
|
||||||
|
target_priority, needs_shift = _resolve_new_provider_priority(
|
||||||
|
current_min_priority=current_min_priority,
|
||||||
|
requested_priority=validated_data.provider_priority,
|
||||||
|
)
|
||||||
|
if needs_shift:
|
||||||
|
db.query(Provider).filter(
|
||||||
|
Provider.provider_priority.isnot(None),
|
||||||
|
Provider.provider_priority >= target_priority,
|
||||||
|
).update(
|
||||||
|
{Provider.provider_priority: Provider.provider_priority + 1},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
|
||||||
# 创建 Provider 对象
|
# 创建 Provider 对象
|
||||||
provider = Provider(
|
provider = Provider(
|
||||||
name=validated_data.name,
|
name=validated_data.name,
|
||||||
@@ -469,7 +499,7 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
|
|||||||
quota_reset_day=validated_data.quota_reset_day,
|
quota_reset_day=validated_data.quota_reset_day,
|
||||||
quota_last_reset_at=validated_data.quota_last_reset_at,
|
quota_last_reset_at=validated_data.quota_last_reset_at,
|
||||||
quota_expires_at=validated_data.quota_expires_at,
|
quota_expires_at=validated_data.quota_expires_at,
|
||||||
provider_priority=validated_data.provider_priority,
|
provider_priority=target_priority,
|
||||||
keep_priority_on_conversion=validated_data.keep_priority_on_conversion,
|
keep_priority_on_conversion=validated_data.keep_priority_on_conversion,
|
||||||
is_active=validated_data.is_active,
|
is_active=validated_data.is_active,
|
||||||
concurrent_limit=validated_data.concurrent_limit,
|
concurrent_limit=validated_data.concurrent_limit,
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ class CreateProviderRequest(BaseModel):
|
|||||||
quota_last_reset_at: datetime | None = Field(None, description="当前周期开始时间")
|
quota_last_reset_at: datetime | None = Field(None, description="当前周期开始时间")
|
||||||
quota_expires_at: datetime | None = Field(None, description="配额过期时间")
|
quota_expires_at: datetime | None = Field(None, description="配额过期时间")
|
||||||
provider_priority: int | None = Field(
|
provider_priority: int | None = Field(
|
||||||
100, ge=0, le=10000, description="提供商优先级(数字越小越优先)"
|
None, ge=0, le=10000, description="提供商优先级(数字越小越优先,留空时新建自动置顶)"
|
||||||
)
|
)
|
||||||
keep_priority_on_conversion: bool = Field(
|
keep_priority_on_conversion: bool = Field(
|
||||||
False,
|
False,
|
||||||
|
|||||||
@@ -35,11 +35,18 @@ _KEYWORDS_DISABLED: tuple[str, ...] = (
|
|||||||
"account deactivated",
|
"account deactivated",
|
||||||
"organization has been disabled",
|
"organization has been disabled",
|
||||||
"organization_disabled",
|
"organization_disabled",
|
||||||
|
"deactivated_workspace",
|
||||||
"deactivated",
|
"deactivated",
|
||||||
"访问被禁止",
|
"访问被禁止",
|
||||||
"账户访问被禁止",
|
"账户访问被禁止",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_TOKEN_INVALID_KEYWORDS: tuple[str, ...] = (
|
||||||
|
"authentication token has been invalidated",
|
||||||
|
"token has been invalidated",
|
||||||
|
"codex token 无效或已过期",
|
||||||
|
)
|
||||||
|
|
||||||
# 需要验证类
|
# 需要验证类
|
||||||
_KEYWORDS_VERIFICATION: tuple[str, ...] = (
|
_KEYWORDS_VERIFICATION: tuple[str, ...] = (
|
||||||
"validation_required",
|
"validation_required",
|
||||||
@@ -50,6 +57,7 @@ _KEYWORDS_VERIFICATION: tuple[str, ...] = (
|
|||||||
ACCOUNT_BLOCK_REASON_KEYWORDS: tuple[str, ...] = (
|
ACCOUNT_BLOCK_REASON_KEYWORDS: tuple[str, ...] = (
|
||||||
*_KEYWORDS_SUSPENDED,
|
*_KEYWORDS_SUSPENDED,
|
||||||
*_KEYWORDS_DISABLED,
|
*_KEYWORDS_DISABLED,
|
||||||
|
*_TOKEN_INVALID_KEYWORDS,
|
||||||
*_KEYWORDS_VERIFICATION,
|
*_KEYWORDS_VERIFICATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,8 +65,12 @@ ACCOUNT_BLOCK_REASON_KEYWORDS: tuple[str, ...] = (
|
|||||||
def _classify_block_reason(text: str) -> tuple[str, str]:
|
def _classify_block_reason(text: str) -> tuple[str, str]:
|
||||||
"""Return (code, label) based on the oauth_invalid_reason text."""
|
"""Return (code, label) based on the oauth_invalid_reason text."""
|
||||||
lowered = text.lower()
|
lowered = text.lower()
|
||||||
|
if any(kw in lowered for kw in _TOKEN_INVALID_KEYWORDS):
|
||||||
|
return "oauth_expired", "Token 失效"
|
||||||
if any(kw in lowered for kw in _KEYWORDS_VERIFICATION):
|
if any(kw in lowered for kw in _KEYWORDS_VERIFICATION):
|
||||||
return "account_verification", "需要验证"
|
return "account_verification", "需要验证"
|
||||||
|
if 'deactivated_workspace' in lowered:
|
||||||
|
return "workspace_deactivated", "工作区停用"
|
||||||
if any(kw in lowered for kw in _KEYWORDS_DISABLED):
|
if any(kw in lowered for kw in _KEYWORDS_DISABLED):
|
||||||
return "account_disabled", "账号停用"
|
return "account_disabled", "账号停用"
|
||||||
if any(kw in lowered for kw in _KEYWORDS_SUSPENDED):
|
if any(kw in lowered for kw in _KEYWORDS_SUSPENDED):
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ class PoolConfig:
|
|||||||
# -- Temporary Unschedulable Rules ----------------------------------------
|
# -- Temporary Unschedulable Rules ----------------------------------------
|
||||||
unschedulable_rules: list[UnschedulableRule] = field(default_factory=list)
|
unschedulable_rules: list[UnschedulableRule] = field(default_factory=list)
|
||||||
|
|
||||||
|
# -- Batch Operations -----------------------------------------------------
|
||||||
|
batch_concurrency: int = 8
|
||||||
|
|
||||||
# -- Quota Probing --------------------------------------------------------
|
# -- Quota Probing --------------------------------------------------------
|
||||||
probing_enabled: bool = False
|
probing_enabled: bool = False
|
||||||
probing_interval_minutes: int = 10
|
probing_interval_minutes: int = 10
|
||||||
@@ -193,6 +196,7 @@ def parse_pool_config(provider_config: Any) -> PoolConfig | None:
|
|||||||
proactive_refresh_seconds=_int_or("proactive_refresh_seconds", 180),
|
proactive_refresh_seconds=_int_or("proactive_refresh_seconds", 180),
|
||||||
health_policy_enabled=_bool_or("health_policy_enabled", True),
|
health_policy_enabled=_bool_or("health_policy_enabled", True),
|
||||||
unschedulable_rules=rules,
|
unschedulable_rules=rules,
|
||||||
|
batch_concurrency=max(1, min(_int_or("batch_concurrency", 8), 32)),
|
||||||
probing_enabled=_bool_or("probing_enabled", False),
|
probing_enabled=_bool_or("probing_enabled", False),
|
||||||
probing_interval_minutes=max(1, min(_int_or("probing_interval_minutes", 10), 1440)),
|
probing_interval_minutes=max(1, min(_int_or("probing_interval_minutes", 10), 1440)),
|
||||||
auto_remove_banned_keys=_bool_or("auto_remove_banned_keys", False),
|
auto_remove_banned_keys=_bool_or("auto_remove_banned_keys", False),
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ PRESET_MODELS: dict[str, list[dict[str, Any]]] = {
|
|||||||
"owned_by": "openai",
|
"owned_by": "openai",
|
||||||
"display_name": "GPT-5.3 Codex",
|
"display_name": "GPT-5.3 Codex",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-5.4",
|
||||||
|
"object": "model",
|
||||||
|
"owned_by": "openai",
|
||||||
|
"display_name": "GPT-5.4",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ from src.api.handlers.base.request_builder import get_provider_auth
|
|||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||||
from src.services.provider_keys.auth_type import normalize_auth_type
|
from src.services.provider_keys.auth_type import normalize_auth_type
|
||||||
|
from src.services.provider.pool.account_state import (
|
||||||
|
OAUTH_ACCOUNT_BLOCK_PREFIX,
|
||||||
|
OAUTH_EXPIRED_PREFIX,
|
||||||
|
)
|
||||||
from src.services.provider_keys.codex_usage_parser import (
|
from src.services.provider_keys.codex_usage_parser import (
|
||||||
parse_codex_usage_headers,
|
parse_codex_usage_headers,
|
||||||
parse_codex_wham_usage_response,
|
parse_codex_wham_usage_response,
|
||||||
@@ -63,6 +67,42 @@ def _extract_error_message_from_response(response: httpx.Response) -> str:
|
|||||||
return text[:300] if text else ""
|
return text[:300] if text else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_token_invalidated(message: str | None) -> bool:
|
||||||
|
lowered = str(message or '').strip().lower()
|
||||||
|
return 'authentication token has been invalidated' in lowered or 'token has been invalidated' in lowered
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_account_deactivated(message: str | None) -> bool:
|
||||||
|
lowered = str(message or '').strip().lower()
|
||||||
|
return 'account has been deactivated' in lowered or 'account deactivated' in lowered
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_workspace_deactivated(message: str | None) -> bool:
|
||||||
|
lowered = str(message or '').strip().lower()
|
||||||
|
return 'deactivated_workspace' in lowered or ('workspace' in lowered and 'deactivated' in lowered)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_structured_invalid_reason(*, status_code: int, upstream_message: str | None) -> str:
|
||||||
|
message = str(upstream_message or '').strip()
|
||||||
|
|
||||||
|
if status_code == 402 and _looks_like_workspace_deactivated(message):
|
||||||
|
return f'{OAUTH_ACCOUNT_BLOCK_PREFIX}工作区已停用 (deactivated_workspace)'
|
||||||
|
|
||||||
|
if _looks_like_account_deactivated(message):
|
||||||
|
detail = message or 'OpenAI 账号已停用'
|
||||||
|
return f'{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}'
|
||||||
|
|
||||||
|
if status_code == 401:
|
||||||
|
detail = message or 'Codex Token 无效或已过期 (401)'
|
||||||
|
return f'{OAUTH_EXPIRED_PREFIX}{detail}'
|
||||||
|
|
||||||
|
if status_code == 403:
|
||||||
|
detail = message or 'Codex 账户访问受限 (403)'
|
||||||
|
return f'{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}'
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
async def refresh_codex_key_quota(
|
async def refresh_codex_key_quota(
|
||||||
*,
|
*,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -146,7 +186,10 @@ async def refresh_codex_key_quota(
|
|||||||
state_updates[key.id] = {
|
state_updates[key.id] = {
|
||||||
"is_active": False,
|
"is_active": False,
|
||||||
"oauth_invalid_at": datetime.now(timezone.utc),
|
"oauth_invalid_at": datetime.now(timezone.utc),
|
||||||
"oauth_invalid_reason": "Codex Token 无效或已过期 (401)",
|
"oauth_invalid_reason": _build_structured_invalid_reason(
|
||||||
|
status_code=401,
|
||||||
|
upstream_message=err_msg,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"key_id": key.id,
|
"key_id": key.id,
|
||||||
@@ -158,6 +201,35 @@ async def refresh_codex_key_quota(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if status_code == 402:
|
if status_code == 402:
|
||||||
|
if _looks_like_workspace_deactivated(err_msg):
|
||||||
|
codex_meta = metadata_updates.get(key.id, {}).get('codex')
|
||||||
|
if not isinstance(codex_meta, dict):
|
||||||
|
codex_meta = {}
|
||||||
|
codex_meta = {
|
||||||
|
**codex_meta,
|
||||||
|
'updated_at': int(time.time()),
|
||||||
|
'account_disabled': True,
|
||||||
|
'reason': 'deactivated_workspace',
|
||||||
|
'message': err_msg or 'deactivated_workspace',
|
||||||
|
}
|
||||||
|
if oauth_plan_type and not codex_meta.get('plan_type'):
|
||||||
|
codex_meta['plan_type'] = oauth_plan_type
|
||||||
|
metadata_updates[key.id] = {'codex': codex_meta}
|
||||||
|
state_updates[key.id] = {
|
||||||
|
'oauth_invalid_at': datetime.now(timezone.utc),
|
||||||
|
'oauth_invalid_reason': _build_structured_invalid_reason(
|
||||||
|
status_code=402,
|
||||||
|
upstream_message=err_msg,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'key_id': key.id,
|
||||||
|
'key_name': key.name,
|
||||||
|
'status': 'workspace_deactivated',
|
||||||
|
'message': f"wham/usage API 返回状态码 402{f': {err_msg}' if err_msg else ''}",
|
||||||
|
'status_code': 402,
|
||||||
|
}
|
||||||
|
|
||||||
if key.id not in metadata_updates:
|
if key.id not in metadata_updates:
|
||||||
metadata_updates[key.id] = {
|
metadata_updates[key.id] = {
|
||||||
"codex": _build_quota_exhausted_fallback_metadata(oauth_plan_type)
|
"codex": _build_quota_exhausted_fallback_metadata(oauth_plan_type)
|
||||||
@@ -178,7 +250,10 @@ async def refresh_codex_key_quota(
|
|||||||
state_updates[key.id] = {
|
state_updates[key.id] = {
|
||||||
"is_active": False,
|
"is_active": False,
|
||||||
"oauth_invalid_at": datetime.now(timezone.utc),
|
"oauth_invalid_at": datetime.now(timezone.utc),
|
||||||
"oauth_invalid_reason": "Codex 账户访问受限 (403)",
|
"oauth_invalid_reason": _build_structured_invalid_reason(
|
||||||
|
status_code=403,
|
||||||
|
upstream_message=err_msg,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"key_id": key.id,
|
"key_id": key.id,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import pytest
|
|||||||
|
|
||||||
from src.api.admin.providers.routes import (
|
from src.api.admin.providers.routes import (
|
||||||
_merge_claude_code_advanced_config,
|
_merge_claude_code_advanced_config,
|
||||||
|
_resolve_new_provider_priority,
|
||||||
_should_enable_format_conversion_by_default,
|
_should_enable_format_conversion_by_default,
|
||||||
)
|
)
|
||||||
from src.core.exceptions import InvalidRequestException
|
from src.core.exceptions import InvalidRequestException
|
||||||
@@ -37,3 +38,27 @@ def test_merge_claude_code_advanced_rejects_non_claude_payload() -> None:
|
|||||||
claude_code_advanced={"max_sessions": 9},
|
claude_code_advanced={"max_sessions": 9},
|
||||||
claude_advanced_in_payload=True,
|
claude_advanced_in_payload=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_provider_priority_defaults_to_current_top() -> None:
|
||||||
|
priority, needs_shift = _resolve_new_provider_priority(
|
||||||
|
current_min_priority=3, requested_priority=None
|
||||||
|
)
|
||||||
|
assert priority == 2
|
||||||
|
assert needs_shift is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_provider_priority_defaults_to_100_when_empty() -> None:
|
||||||
|
priority, needs_shift = _resolve_new_provider_priority(
|
||||||
|
current_min_priority=None, requested_priority=None
|
||||||
|
)
|
||||||
|
assert priority == 100
|
||||||
|
assert needs_shift is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_provider_priority_keeps_explicit_value() -> None:
|
||||||
|
priority, needs_shift = _resolve_new_provider_priority(
|
||||||
|
current_min_priority=3, requested_priority=8
|
||||||
|
)
|
||||||
|
assert priority == 8
|
||||||
|
assert needs_shift is True
|
||||||
|
|||||||
@@ -115,3 +115,25 @@ def test_antigravity_oauth_reason_text_detected_as_disabled() -> None:
|
|||||||
assert state.blocked is True
|
assert state.blocked is True
|
||||||
assert state.code == "account_disabled"
|
assert state.code == "account_disabled"
|
||||||
assert state.label == "账号停用"
|
assert state.label == "账号停用"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_from_structured_oauth_reason_workspace_deactivated() -> None:
|
||||||
|
state = resolve_pool_account_state(
|
||||||
|
provider_type="codex",
|
||||||
|
upstream_metadata=None,
|
||||||
|
oauth_invalid_reason="[ACCOUNT_BLOCK] 工作区已停用 (deactivated_workspace)",
|
||||||
|
)
|
||||||
|
assert state.blocked is True
|
||||||
|
assert state.code == "workspace_deactivated"
|
||||||
|
assert state.label == "工作区停用"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_from_structured_oauth_reason_token_invalidated() -> None:
|
||||||
|
state = resolve_pool_account_state(
|
||||||
|
provider_type="codex",
|
||||||
|
upstream_metadata=None,
|
||||||
|
oauth_invalid_reason="[OAUTH_EXPIRED] Your authentication token has been invalidated. Please try signing in again.",
|
||||||
|
)
|
||||||
|
assert state.blocked is True
|
||||||
|
assert state.code == "oauth_expired"
|
||||||
|
assert state.label == "Token 失效"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ def test_parse_pool_config_returns_defaults_for_empty_advanced() -> None:
|
|||||||
assert cfg.proactive_refresh_seconds == 180
|
assert cfg.proactive_refresh_seconds == 180
|
||||||
assert cfg.health_policy_enabled is True
|
assert cfg.health_policy_enabled is True
|
||||||
assert cfg.unschedulable_rules == []
|
assert cfg.unschedulable_rules == []
|
||||||
|
assert cfg.batch_concurrency == 8
|
||||||
assert cfg.probing_enabled is False
|
assert cfg.probing_enabled is False
|
||||||
assert cfg.probing_interval_minutes == 10
|
assert cfg.probing_interval_minutes == 10
|
||||||
assert cfg.auto_remove_banned_keys is False
|
assert cfg.auto_remove_banned_keys is False
|
||||||
@@ -100,11 +101,24 @@ def test_parse_pool_config_overrides_values_legacy_string_list() -> None:
|
|||||||
assert cfg.overload_cooldown_seconds == 60
|
assert cfg.overload_cooldown_seconds == 60
|
||||||
assert cfg.proactive_refresh_seconds == 300
|
assert cfg.proactive_refresh_seconds == 300
|
||||||
assert cfg.health_policy_enabled is False
|
assert cfg.health_policy_enabled is False
|
||||||
|
assert cfg.batch_concurrency == 8
|
||||||
assert cfg.probing_enabled is True
|
assert cfg.probing_enabled is True
|
||||||
assert cfg.probing_interval_minutes == 15
|
assert cfg.probing_interval_minutes == 15
|
||||||
assert cfg.auto_remove_banned_keys is True
|
assert cfg.auto_remove_banned_keys is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_pool_config_parses_batch_concurrency() -> None:
|
||||||
|
cfg = parse_pool_config({"pool_advanced": {"batch_concurrency": 12}})
|
||||||
|
assert cfg is not None
|
||||||
|
assert cfg.batch_concurrency == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_pool_config_clamps_batch_concurrency() -> None:
|
||||||
|
cfg = parse_pool_config({"pool_advanced": {"batch_concurrency": 99}})
|
||||||
|
assert cfg is not None
|
||||||
|
assert cfg.batch_concurrency == 32
|
||||||
|
|
||||||
|
|
||||||
def test_parse_pool_config_new_object_list_format() -> None:
|
def test_parse_pool_config_new_object_list_format() -> None:
|
||||||
"""New object-list format: [{preset, enabled, mode}]."""
|
"""New object-list format: [{preset, enabled, mode}]."""
|
||||||
cfg = parse_pool_config(
|
cfg = parse_pool_config(
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ async def test_codex_refresher_http_401_marks_auth_invalid_and_disables(
|
|||||||
assert result["auto_disabled"] is True
|
assert result["auto_disabled"] is True
|
||||||
assert metadata_updates == {}
|
assert metadata_updates == {}
|
||||||
assert state_updates["k1"]["is_active"] is False
|
assert state_updates["k1"]["is_active"] is False
|
||||||
assert "401" in str(state_updates["k1"]["oauth_invalid_reason"])
|
assert str(state_updates["k1"]["oauth_invalid_reason"]).startswith("[OAUTH_EXPIRED]")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -847,3 +847,66 @@ async def test_kiro_refresher_success_updates_metadata_and_auth_config(
|
|||||||
assert state_updates["k1"]["oauth_invalid_at"] is None
|
assert state_updates["k1"]["oauth_invalid_at"] is None
|
||||||
assert state_updates["k1"]["oauth_invalid_reason"] is None
|
assert state_updates["k1"]["oauth_invalid_reason"] is None
|
||||||
assert state_updates["k1"]["auth_config"].startswith("ENC:")
|
assert state_updates["k1"]["auth_config"].startswith("ENC:")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_codex_refresher_http_402_workspace_deactivated_marks_account_block(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
from src.services.provider_keys.quota_refresh import codex_refresher as module
|
||||||
|
|
||||||
|
key = SimpleNamespace(
|
||||||
|
id="k1",
|
||||||
|
name="K1",
|
||||||
|
api_key="enc-key",
|
||||||
|
auth_type="oauth",
|
||||||
|
auth_config="enc-config",
|
||||||
|
proxy=None,
|
||||||
|
)
|
||||||
|
provider = SimpleNamespace(proxy=None)
|
||||||
|
endpoint = SimpleNamespace()
|
||||||
|
metadata_updates: dict[str, dict[str, Any]] = {}
|
||||||
|
state_updates: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
async def _fake_auth_info(_endpoint: Any, _key: Any) -> Any:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_install_module(
|
||||||
|
monkeypatch,
|
||||||
|
"src.services.proxy_node.resolver",
|
||||||
|
{
|
||||||
|
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||||
|
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.crypto_service,
|
||||||
|
"decrypt",
|
||||||
|
lambda value: (
|
||||||
|
"sk-test"
|
||||||
|
if value == "enc-key"
|
||||||
|
else json.dumps({"plan_type": "team", "account_id": "acc-1"})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = _FakeResponse(status_code=402, payload={"detail": {"code": "deactivated_workspace"}})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await refresh_codex_key_quota(
|
||||||
|
db=cast(Any, _FakeDB()),
|
||||||
|
provider=cast(Any, provider),
|
||||||
|
key=cast(Any, key),
|
||||||
|
endpoint=cast(Any, endpoint),
|
||||||
|
codex_wham_usage_url="https://example.test",
|
||||||
|
metadata_updates=metadata_updates,
|
||||||
|
state_updates=state_updates,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "workspace_deactivated"
|
||||||
|
assert result["status_code"] == 402
|
||||||
|
assert metadata_updates["k1"]["codex"]["account_disabled"] is True
|
||||||
|
assert metadata_updates["k1"]["codex"]["reason"] == "deactivated_workspace"
|
||||||
|
assert state_updates["k1"]["oauth_invalid_at"] is not None
|
||||||
|
assert str(state_updates["k1"]["oauth_invalid_reason"]).startswith("[ACCOUNT_BLOCK]")
|
||||||
|
|||||||
7
tests/unit/test_preset_models.py
Normal file
7
tests/unit/test_preset_models.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from src.services.provider.preset_models import get_preset_models
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_preset_models_include_gpt_5_4() -> None:
|
||||||
|
model_ids = {model["id"] for model in get_preset_models("codex")}
|
||||||
|
|
||||||
|
assert "gpt-5.4" in model_ids
|
||||||
38
tests/unit/test_provider_query_failover_endpoint.py
Normal file
38
tests/unit/test_provider_query_failover_endpoint.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from src.api.admin.provider_query import (
|
||||||
|
_build_direct_test_candidates,
|
||||||
|
_filter_test_candidates_by_endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_provider() -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace]:
|
||||||
|
endpoint_a = SimpleNamespace(id="ep-a", api_format="openai:chat", is_active=True)
|
||||||
|
endpoint_b = SimpleNamespace(id="ep-b", api_format="claude:cli", is_active=True)
|
||||||
|
key_all = SimpleNamespace(
|
||||||
|
id="key-all", is_active=True, api_formats=["openai:chat", "claude:cli"]
|
||||||
|
)
|
||||||
|
key_b = SimpleNamespace(id="key-b", is_active=True, api_formats=["claude:cli"])
|
||||||
|
provider = SimpleNamespace(
|
||||||
|
id="provider-1", endpoints=[endpoint_a, endpoint_b], api_keys=[key_all, key_b]
|
||||||
|
)
|
||||||
|
return provider, endpoint_a, endpoint_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_direct_test_candidates_respects_endpoint_id() -> None:
|
||||||
|
provider, _endpoint_a, endpoint_b = _build_provider()
|
||||||
|
|
||||||
|
candidates = _build_direct_test_candidates(provider, endpoint_id=endpoint_b.id) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert {candidate.endpoint.id for candidate in candidates} == {endpoint_b.id}
|
||||||
|
assert {candidate.key.id for candidate in candidates} == {"key-all", "key-b"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_test_candidates_by_endpoint_keeps_matching_candidates() -> None:
|
||||||
|
provider, endpoint_a, endpoint_b = _build_provider()
|
||||||
|
candidates = _build_direct_test_candidates(provider) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
filtered = _filter_test_candidates_by_endpoint(candidates, endpoint_a.id)
|
||||||
|
|
||||||
|
assert {candidate.endpoint.id for candidate in filtered} == {endpoint_a.id}
|
||||||
|
assert all(candidate.endpoint.id != endpoint_b.id for candidate in filtered)
|
||||||
@@ -12,3 +12,8 @@ def test_update_provider_request_preserves_keep_priority_on_conversion() -> None
|
|||||||
req = UpdateProviderRequest.model_validate({"keep_priority_on_conversion": True})
|
req = UpdateProviderRequest.model_validate({"keep_priority_on_conversion": True})
|
||||||
data = req.model_dump(exclude_unset=True)
|
data = req.model_dump(exclude_unset=True)
|
||||||
assert data["keep_priority_on_conversion"] is True
|
assert data["keep_priority_on_conversion"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_provider_request_defaults_provider_priority_to_none() -> None:
|
||||||
|
req = CreateProviderRequest.model_validate({"name": "Provider Auto Priority"})
|
||||||
|
assert req.provider_priority is None
|
||||||
|
|||||||
Reference in New Issue
Block a user