mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(pool,priority): 号池聚合显示、API 格式归一化与优先级管理重构
- 优先级管理对话框按 family 分组显示 API 格式,号池 key 聚合为单条目展示 - 拖拽排序改用 key ID 替代数组索引,号池聚合项禁用拖拽/编辑/开关操作 - 后端 key 分组查询增加 API 格式键归一化,返回 provider_id - 提取 OAuth auth_config 解密逻辑,新增 _derive_oauth_expires_at 从加密配置派生过期时间 - 号池管理移除会话列,调整 OAuth 过期信息与刷新按钮的布局顺序
This commit is contained in:
@@ -73,6 +73,62 @@ export const API_FORMAT_ORDER: string[] = [
|
||||
API_FORMATS.GEMINI_VIDEO,
|
||||
]
|
||||
|
||||
// Family 显示名称映射
|
||||
export const API_FORMAT_FAMILY_LABELS: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
claude: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
}
|
||||
|
||||
// Kind 显示名称映射
|
||||
export const API_FORMAT_KIND_LABELS: Record<string, string> = {
|
||||
chat: 'Chat',
|
||||
cli: 'CLI',
|
||||
compact: 'Compact',
|
||||
video: 'Video',
|
||||
}
|
||||
|
||||
// Family 排序顺序
|
||||
const FAMILY_ORDER = ['openai', 'claude', 'gemini']
|
||||
|
||||
// 工具函数:从 API 格式中提取 family 和 kind
|
||||
export function parseApiFormat(format: string): { family: string; kind: string } {
|
||||
const idx = format.indexOf(':')
|
||||
if (idx === -1) return { family: format.toLowerCase(), kind: '' }
|
||||
return { family: format.slice(0, idx).toLowerCase(), kind: format.slice(idx + 1).toLowerCase() }
|
||||
}
|
||||
|
||||
// 工具函数:按 family 分组并排序 API 格式数组
|
||||
export interface ApiFormatGroup {
|
||||
family: string
|
||||
label: string
|
||||
formats: string[]
|
||||
}
|
||||
|
||||
export function groupApiFormats(formats: string[]): ApiFormatGroup[] {
|
||||
const sorted = sortApiFormats(formats)
|
||||
const groups = new Map<string, string[]>()
|
||||
for (const f of sorted) {
|
||||
const { family } = parseApiFormat(f)
|
||||
if (!groups.has(family)) groups.set(family, [])
|
||||
groups.get(family)?.push(f)
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.sort(([a], [b]) => {
|
||||
const ai = FAMILY_ORDER.indexOf(a)
|
||||
const bi = FAMILY_ORDER.indexOf(b)
|
||||
if (ai === -1 && bi === -1) return 0
|
||||
if (ai === -1) return 1
|
||||
if (bi === -1) return -1
|
||||
return ai - bi
|
||||
})
|
||||
.map(([family, fmts]) => ({
|
||||
family,
|
||||
label: API_FORMAT_FAMILY_LABELS[family] || family,
|
||||
formats: fmts,
|
||||
}))
|
||||
}
|
||||
|
||||
// 工具函数:将 API 格式签名转为友好显示名称
|
||||
export function formatApiFormat(format: string | null | undefined): string {
|
||||
if (!format) return '-'
|
||||
|
||||
@@ -174,22 +174,33 @@
|
||||
v-else
|
||||
class="flex gap-0 h-full"
|
||||
>
|
||||
<!-- 左侧:API 格式列表 -->
|
||||
<div class="w-36 shrink-0 space-y-0.5 overflow-y-auto border-r border-border/50 pr-3 mr-3 py-0.5">
|
||||
<button
|
||||
v-for="format in availableFormats"
|
||||
:key="format"
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-xs font-medium rounded-lg text-left transition-all duration-200"
|
||||
:class="[
|
||||
activeFormatTab === format
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
]"
|
||||
@click="activeFormatTab = format"
|
||||
<!-- 左侧:API 格式列表(按 family 分组) -->
|
||||
<div class="w-36 shrink-0 overflow-y-auto border-r border-border/50 pr-3 mr-3 py-0.5">
|
||||
<div
|
||||
v-for="(group, gi) in groupedFormats"
|
||||
:key="group.family"
|
||||
:class="gi > 0 ? 'mt-3' : ''"
|
||||
>
|
||||
{{ formatApiFormat(format) }}
|
||||
</button>
|
||||
<div class="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<button
|
||||
v-for="format in group.formats"
|
||||
:key="format"
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 text-xs font-medium rounded-lg text-left transition-all duration-200"
|
||||
:class="[
|
||||
activeFormatTab === format
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
]"
|
||||
@click="activeFormatTab = format"
|
||||
>
|
||||
{{ formatKind(format) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:Key 列表 -->
|
||||
@@ -201,33 +212,33 @@
|
||||
class="h-full"
|
||||
>
|
||||
<div
|
||||
v-if="keysByFormat[format]?.length > 0"
|
||||
v-if="displayKeysByFormat[format]?.length > 0"
|
||||
class="space-y-0.5 h-full overflow-y-auto pr-1"
|
||||
>
|
||||
<div
|
||||
v-for="(key, index) in keysByFormat[format]"
|
||||
v-for="key in displayKeysByFormat[format]"
|
||||
:key="key.id"
|
||||
class="group flex items-center gap-2 px-2.5 py-1.5 rounded-lg border transition-all duration-200"
|
||||
:class="[
|
||||
!(key.is_active && key.provider_active)
|
||||
? 'border-border/30 bg-muted/20 opacity-50'
|
||||
: draggedKey[format] === index
|
||||
: draggedKey[format] === key.id
|
||||
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
|
||||
: dragOverKey[format] === index
|
||||
: dragOverKey[format] === key.id
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
|
||||
]"
|
||||
:draggable="key.is_active && key.provider_active"
|
||||
@dragstart="(key.is_active && key.provider_active) && handleKeyDragStart(format, index, $event)"
|
||||
:draggable="!key.is_pool_aggregate && key.is_active && key.provider_active"
|
||||
@dragstart="(!key.is_pool_aggregate && key.is_active && key.provider_active) && handleKeyDragStart(format, key.id, $event)"
|
||||
@dragend="handleKeyDragEnd(format)"
|
||||
@dragover.prevent="handleKeyDragOver(format, index)"
|
||||
@dragover.prevent="!key.is_pool_aggregate && handleKeyDragOver(format, key.id)"
|
||||
@dragleave="handleKeyDragLeave(format)"
|
||||
@drop="handleKeyDrop(format, index)"
|
||||
@drop="!key.is_pool_aggregate && handleKeyDrop(format, key.id)"
|
||||
>
|
||||
<!-- 拖拽手柄 -->
|
||||
<div
|
||||
class="p-0.5 rounded transition-colors shrink-0"
|
||||
:class="(key.is_active && key.provider_active)
|
||||
:class="(!key.is_pool_aggregate && key.is_active && key.provider_active)
|
||||
? 'cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover:text-muted-foreground'
|
||||
: 'text-muted-foreground/15 cursor-default'"
|
||||
>
|
||||
@@ -237,7 +248,7 @@
|
||||
<!-- 可编辑序号 -->
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-if="editingKeyPriority[format] === key.id"
|
||||
v-if="!key.is_pool_aggregate && editingKeyPriority[format] === key.id"
|
||||
type="number"
|
||||
min="1"
|
||||
:value="key.priority"
|
||||
@@ -249,9 +260,12 @@
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-5 h-5 rounded bg-muted/50 flex items-center justify-center text-[11px] font-medium text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
title="点击编辑优先级"
|
||||
@click.stop="startEditKeyPriority(format, key)"
|
||||
class="w-5 h-5 rounded bg-muted/50 flex items-center justify-center text-[11px] font-medium transition-colors"
|
||||
:class="key.is_pool_aggregate
|
||||
? 'text-muted-foreground/60 cursor-not-allowed'
|
||||
: 'text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary'"
|
||||
:title="key.is_pool_aggregate ? '号池优先级请在号池配置中调整' : '点击编辑优先级'"
|
||||
@click.stop="!key.is_pool_aggregate && startEditKeyPriority(format, key)"
|
||||
>
|
||||
{{ key.priority }}
|
||||
</div>
|
||||
@@ -266,7 +280,14 @@
|
||||
:class="!(key.is_active && key.provider_active) ? 'text-muted-foreground' : ''"
|
||||
>{{ key.name }}</span>
|
||||
<Badge
|
||||
v-if="key.circuit_breaker_open"
|
||||
v-if="key.is_pool_aggregate"
|
||||
variant="outline"
|
||||
class="text-[9px] h-4 px-1 shrink-0"
|
||||
>
|
||||
号池
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="key.circuit_breaker_open"
|
||||
variant="destructive"
|
||||
class="text-[9px] h-4 px-1 shrink-0"
|
||||
>
|
||||
@@ -282,9 +303,20 @@
|
||||
</div>
|
||||
<!-- 第二行:密钥脱敏 · Provider 名称 + Provider 级别状态 -->
|
||||
<div class="flex items-center gap-0 mt-0.5">
|
||||
<span class="font-mono text-[10px] text-muted-foreground/50 truncate">{{ key.api_key_masked }}</span>
|
||||
<span class="text-[10px] text-muted-foreground/40 mx-1">·</span>
|
||||
<span class="text-[10px] text-muted-foreground shrink-0">{{ key.provider_name }}</span>
|
||||
<template v-if="key.is_pool_aggregate">
|
||||
<span class="text-[10px] text-muted-foreground/70 truncate">
|
||||
号池: {{ key.pool_active_key_count ?? 0 }}/{{ key.pool_key_count ?? 0 }}
|
||||
</span>
|
||||
<template v-if="key.provider_type">
|
||||
<span class="text-[10px] text-muted-foreground/40 mx-1">·</span>
|
||||
<span class="text-[10px] text-muted-foreground shrink-0">{{ formatProviderType(key.provider_type) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="font-mono text-[10px] text-muted-foreground/50 truncate">{{ key.api_key_masked }}</span>
|
||||
<span class="text-[10px] text-muted-foreground/40 mx-1">·</span>
|
||||
<span class="text-[10px] text-muted-foreground shrink-0">{{ key.provider_name }}</span>
|
||||
</template>
|
||||
<Badge
|
||||
v-if="!key.provider_active"
|
||||
variant="secondary"
|
||||
@@ -312,20 +344,26 @@
|
||||
--
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground tabular-nums">
|
||||
{{ key.rate_multipliers?.[format] ?? 1 }}x
|
||||
{{ key.is_pool_aggregate ? 'Pool' : (key.rate_multipliers?.[format] ?? 1) + 'x' }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 快捷启用/禁用开关 -->
|
||||
<button
|
||||
class="p-0.5 rounded transition-colors shrink-0"
|
||||
:class="!key.provider_active
|
||||
:class="(key.is_pool_aggregate || !key.provider_active)
|
||||
? 'text-muted-foreground/20 cursor-not-allowed'
|
||||
: key.is_active
|
||||
? 'text-foreground/70 hover:bg-muted hover:text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'"
|
||||
:title="!key.provider_active ? 'Provider 停用' : key.is_active ? '点击停用' : '点击启用'"
|
||||
:disabled="!key.provider_active"
|
||||
@click.stop="toggleKeyActive(format, key)"
|
||||
:title="key.is_pool_aggregate
|
||||
? '号池聚合项不支持在此单独开关'
|
||||
: !key.provider_active
|
||||
? 'Provider 停用'
|
||||
: key.is_active
|
||||
? '点击停用'
|
||||
: '点击启用'"
|
||||
:disabled="key.is_pool_aggregate || !key.provider_active"
|
||||
@click.stop="!key.is_pool_aggregate && toggleKeyActive(format, key)"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -438,11 +476,12 @@ import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { batchQueryBalance, type ActionResultResponse, type BalanceInfo } from '@/api/providerOps'
|
||||
import { API_FORMAT_SHORT } from '@/api/endpoints/types'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { sortApiFormats, groupApiFormats, parseApiFormat, API_FORMAT_KIND_LABELS } from '@/api/endpoints/types/api-format'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
interface KeyWithMeta {
|
||||
id: string
|
||||
provider_id: string
|
||||
name: string
|
||||
api_key_masked: string
|
||||
internal_priority: number
|
||||
@@ -461,6 +500,10 @@ interface KeyWithMeta {
|
||||
success_rate: number | null
|
||||
avg_response_time_ms: number | null
|
||||
request_count: number
|
||||
is_pool_aggregate?: boolean
|
||||
pool_key_count?: number
|
||||
pool_active_key_count?: number
|
||||
provider_type?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -493,8 +536,8 @@ const dragOverProvider = ref<number | null>(null)
|
||||
|
||||
// Key 排序状态
|
||||
const keysByFormat = ref<Record<string, KeyWithMeta[]>>({})
|
||||
const draggedKey = ref<Record<string, number | null>>({})
|
||||
const dragOverKey = ref<Record<string, number | null>>({})
|
||||
const draggedKey = ref<Record<string, string | null>>({})
|
||||
const dragOverKey = ref<Record<string, string | null>>({})
|
||||
const loadingKeys = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
@@ -561,11 +604,144 @@ async function loadBalances() {
|
||||
}
|
||||
}
|
||||
|
||||
const LEGACY_API_FORMAT_MAP: Record<string, string> = {
|
||||
CLAUDE: 'claude:chat',
|
||||
CLAUDE_CLI: 'claude:cli',
|
||||
OPENAI: 'openai:chat',
|
||||
OPENAI_CLI: 'openai:cli',
|
||||
OPENAI_COMPACT: 'openai:compact',
|
||||
OPENAI_VIDEO: 'openai:video',
|
||||
GEMINI: 'gemini:chat',
|
||||
GEMINI_CLI: 'gemini:cli',
|
||||
GEMINI_VIDEO: 'gemini:video',
|
||||
}
|
||||
|
||||
function normalizeApiFormatKey(value: string | null | undefined): string {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
if (raw.includes(':')) {
|
||||
const [family, kind] = raw.split(':', 2)
|
||||
const familyNorm = family?.trim().toLowerCase()
|
||||
const kindNorm = kind?.trim().toLowerCase()
|
||||
if (familyNorm && kindNorm) return `${familyNorm}:${kindNorm}`
|
||||
}
|
||||
|
||||
const legacy = raw.toUpperCase().replace(/-/g, '_')
|
||||
return LEGACY_API_FORMAT_MAP[legacy] || raw.toLowerCase()
|
||||
}
|
||||
|
||||
function normalizePriorityMap(
|
||||
value: Record<string, unknown> | null | undefined
|
||||
): Record<string, number> {
|
||||
if (!value) return {}
|
||||
const normalized: Record<string, number> = {}
|
||||
for (const [rawFormat, rawPriority] of Object.entries(value)) {
|
||||
const format = normalizeApiFormatKey(rawFormat)
|
||||
if (!format || format in normalized) continue
|
||||
const num = Number(rawPriority)
|
||||
if (!Number.isFinite(num)) continue
|
||||
normalized[format] = Math.trunc(num)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeRateMultipliers(
|
||||
value: Record<string, unknown> | null | undefined
|
||||
): Record<string, number> | null {
|
||||
if (!value) return null
|
||||
const normalized: Record<string, number> = {}
|
||||
for (const [rawFormat, rawMultiplier] of Object.entries(value)) {
|
||||
const format = normalizeApiFormatKey(rawFormat)
|
||||
if (!format || format in normalized) continue
|
||||
const num = Number(rawMultiplier)
|
||||
if (!Number.isFinite(num)) continue
|
||||
normalized[format] = num
|
||||
}
|
||||
return Object.keys(normalized).length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
const providerById = computed(() => {
|
||||
const map = new Map<string, ProviderWithEndpointsSummary>()
|
||||
props.providers.forEach((provider) => {
|
||||
map.set(provider.id, provider)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const providerIdByName = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
props.providers.forEach((provider) => {
|
||||
if (!map.has(provider.name)) {
|
||||
map.set(provider.name, provider.id)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
function resolveProviderId(key: Pick<KeyWithMeta, 'provider_id' | 'provider_name'>): string {
|
||||
if (key.provider_id) return key.provider_id
|
||||
return providerIdByName.value.get(key.provider_name) || ''
|
||||
}
|
||||
|
||||
const poolProviderIds = computed(() => {
|
||||
const set = new Set<string>()
|
||||
props.providers.forEach((provider) => {
|
||||
if (provider.pool_advanced) {
|
||||
set.add(provider.id)
|
||||
}
|
||||
})
|
||||
return set
|
||||
})
|
||||
|
||||
const PROVIDER_TYPE_LABELS: Record<string, string> = {
|
||||
custom: '自定义',
|
||||
vertex_ai: 'Vertex AI',
|
||||
claude_code: 'ClaudeCode',
|
||||
codex: 'Codex',
|
||||
gemini_cli: 'Gemini CLI',
|
||||
antigravity: 'Antigravity',
|
||||
kiro: 'Kiro',
|
||||
}
|
||||
|
||||
function formatProviderType(type?: string): string {
|
||||
if (!type) return ''
|
||||
return PROVIDER_TYPE_LABELS[type] || type
|
||||
}
|
||||
|
||||
function isPoolManagedProvider(providerId: string): boolean {
|
||||
return providerId !== '' && poolProviderIds.value.has(providerId)
|
||||
}
|
||||
|
||||
function isPoolManagedKey(key: KeyWithMeta): boolean {
|
||||
return isPoolManagedProvider(resolveProviderId(key))
|
||||
}
|
||||
|
||||
function isPoolAggregateItem(key: KeyWithMeta): boolean {
|
||||
return key.is_pool_aggregate === true
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
// 可用的 API 格式
|
||||
const availableFormats = computed(() => {
|
||||
return Object.keys(keysByFormat.value).sort()
|
||||
return sortApiFormats(Object.keys(keysByFormat.value))
|
||||
})
|
||||
|
||||
// 按 family 分组的 API 格式(用于侧边栏分组显示)
|
||||
const groupedFormats = computed(() => {
|
||||
return groupApiFormats(availableFormats.value)
|
||||
})
|
||||
|
||||
// 获取格式的 kind 显示名称
|
||||
function formatKind(format: string): string {
|
||||
const { kind } = parseApiFormat(format)
|
||||
return API_FORMAT_KIND_LABELS[kind] || kind || format
|
||||
}
|
||||
|
||||
// 排序 Key:活跃的在前,停用的(Key或Provider)在后,各自按优先级排序
|
||||
function sortKeysByActiveAndPriority(keys: KeyWithMeta[]): KeyWithMeta[] {
|
||||
return [...keys].sort((a, b) => {
|
||||
@@ -576,6 +752,79 @@ function sortKeysByActiveAndPriority(keys: KeyWithMeta[]): KeyWithMeta[] {
|
||||
})
|
||||
}
|
||||
|
||||
function buildPoolAggregateItem(format: string, providerId: string, sourceKeys: KeyWithMeta[]): KeyWithMeta {
|
||||
const provider = providerById.value.get(providerId)
|
||||
const poolPriorityRaw = provider?.pool_advanced?.global_priority
|
||||
const fallbackPriority = provider?.provider_priority ?? 999999
|
||||
const poolPriority = Number.isFinite(poolPriorityRaw ?? NaN)
|
||||
? Number(poolPriorityRaw)
|
||||
: fallbackPriority
|
||||
|
||||
const providerName = provider?.name || sourceKeys[0]?.provider_name || '未知 Provider'
|
||||
const activeKeyCount = sourceKeys.filter((k) => k.is_active).length
|
||||
const providerActive = provider?.is_active ?? sourceKeys.some((k) => k.provider_active)
|
||||
const healthCandidates = sourceKeys.map((k) => k.health_score).filter((v): v is number => v != null)
|
||||
const avgHealth = healthCandidates.length > 0
|
||||
? healthCandidates.reduce((sum, score) => sum + score, 0) / healthCandidates.length
|
||||
: null
|
||||
|
||||
return {
|
||||
id: `pool:${providerId}:${format}`,
|
||||
provider_id: providerId,
|
||||
name: providerName,
|
||||
api_key_masked: '[Pool]',
|
||||
internal_priority: 0,
|
||||
global_priority_by_format: null,
|
||||
format_priority: poolPriority,
|
||||
priority: poolPriority,
|
||||
rate_multipliers: { [format]: 1 },
|
||||
is_active: activeKeyCount > 0,
|
||||
provider_active: providerActive,
|
||||
circuit_breaker_open: false,
|
||||
provider_name: providerName,
|
||||
endpoint_base_url: sourceKeys.find((k) => k.endpoint_base_url)?.endpoint_base_url || '',
|
||||
api_format: format,
|
||||
capabilities: [],
|
||||
health_score: avgHealth,
|
||||
success_rate: null,
|
||||
avg_response_time_ms: null,
|
||||
request_count: sourceKeys.reduce((sum, key) => sum + (key.request_count || 0), 0),
|
||||
is_pool_aggregate: true,
|
||||
pool_key_count: sourceKeys.length,
|
||||
pool_active_key_count: activeKeyCount,
|
||||
provider_type: provider?.provider_type || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const displayKeysByFormat = computed<Record<string, KeyWithMeta[]>>(() => {
|
||||
const display: Record<string, KeyWithMeta[]> = {}
|
||||
|
||||
for (const [format, rawKeys] of Object.entries(keysByFormat.value)) {
|
||||
const normalKeys: KeyWithMeta[] = []
|
||||
const poolGroups = new Map<string, KeyWithMeta[]>()
|
||||
|
||||
for (const key of rawKeys) {
|
||||
const providerId = resolveProviderId(key)
|
||||
if (isPoolManagedProvider(providerId)) {
|
||||
if (!poolGroups.has(providerId)) {
|
||||
poolGroups.set(providerId, [])
|
||||
}
|
||||
poolGroups.get(providerId)?.push(key)
|
||||
} else {
|
||||
normalKeys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
const poolItems = Array.from(poolGroups.entries()).map(([providerId, keys]) =>
|
||||
buildPoolAggregateItem(format, providerId, keys)
|
||||
)
|
||||
|
||||
display[format] = sortKeysByActiveAndPriority([...normalKeys, ...poolItems])
|
||||
}
|
||||
|
||||
return display
|
||||
})
|
||||
|
||||
// 排序 providers:启用的在前,停用的在后,各自按优先级排序
|
||||
function sortProvidersByActiveAndPriority(providers: ProviderWithEndpointsSummary[]) {
|
||||
return [...providers].sort((a, b) => {
|
||||
@@ -632,21 +881,103 @@ async function loadKeysByFormat() {
|
||||
const { default: client } = await import('@/api/client')
|
||||
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format')
|
||||
|
||||
// 每个格式独立管理优先级,使用后端返回的 format_priority
|
||||
// 每个格式独立管理优先级,额外做一次前端归一化兜底,避免历史数据导致重复格式/脏键
|
||||
const data: Record<string, KeyWithMeta[]> = {}
|
||||
for (const [format, keys] of Object.entries(response.data as Record<string, Record<string, unknown>[]>)) {
|
||||
// 计算该格式下的默认优先级
|
||||
let maxPriority = 0
|
||||
const grouped = response.data as Record<string, Record<string, unknown>[]>
|
||||
|
||||
for (const [rawFormat, keys] of Object.entries(grouped)) {
|
||||
const format = normalizeApiFormatKey(rawFormat)
|
||||
if (!format) continue
|
||||
|
||||
if (!data[format]) {
|
||||
data[format] = []
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const providerName = typeof key.provider_name === 'string' ? key.provider_name : ''
|
||||
const providerIdRaw = typeof key.provider_id === 'string' ? key.provider_id : ''
|
||||
const providerId = providerIdRaw || providerIdByName.value.get(providerName) || ''
|
||||
|
||||
const priorityMap = normalizePriorityMap(
|
||||
(key.global_priority_by_format as Record<string, unknown> | null | undefined)
|
||||
)
|
||||
const rateMultipliers = normalizeRateMultipliers(
|
||||
(key.rate_multipliers as Record<string, unknown> | null | undefined)
|
||||
)
|
||||
const explicitFormatPriority = toNumberOrNull(key.format_priority)
|
||||
const inferredFormatPriority = priorityMap[format]
|
||||
const formatPriority = explicitFormatPriority ?? (typeof inferredFormatPriority === 'number'
|
||||
? inferredFormatPriority
|
||||
: null)
|
||||
|
||||
data[format].push({
|
||||
id: String(key.id || ''),
|
||||
provider_id: providerId,
|
||||
name: String(key.name || 'Unnamed Key'),
|
||||
api_key_masked: String(key.api_key_masked || '***'),
|
||||
internal_priority: toNumberOrNull(key.internal_priority) ?? 0,
|
||||
global_priority_by_format: Object.keys(priorityMap).length > 0 ? priorityMap : null,
|
||||
format_priority: formatPriority,
|
||||
priority: formatPriority ?? 0,
|
||||
rate_multipliers: rateMultipliers,
|
||||
is_active: key.is_active !== false,
|
||||
provider_active: key.provider_active !== false,
|
||||
circuit_breaker_open: key.circuit_breaker_open === true,
|
||||
provider_name: providerName || 'Unknown Provider',
|
||||
endpoint_base_url: String(key.endpoint_base_url || ''),
|
||||
api_format: format,
|
||||
capabilities: Array.isArray(key.capabilities)
|
||||
? key.capabilities.map((cap) => String(cap))
|
||||
: [],
|
||||
health_score: toNumberOrNull(key.health_score),
|
||||
success_rate: toNumberOrNull(key.success_rate),
|
||||
avg_response_time_ms: toNumberOrNull(key.avg_response_time_ms),
|
||||
request_count: toNumberOrNull(key.request_count) ?? 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const [format, keys] of Object.entries(data)) {
|
||||
const dedupedById = new Map<string, KeyWithMeta>()
|
||||
for (const key of keys) {
|
||||
if (!key.id) continue
|
||||
const existing = dedupedById.get(key.id)
|
||||
if (!existing) {
|
||||
dedupedById.set(key.id, key)
|
||||
continue
|
||||
}
|
||||
|
||||
const mergedPriorityMap = {
|
||||
...(existing.global_priority_by_format || {}),
|
||||
...(key.global_priority_by_format || {})
|
||||
}
|
||||
const mergedRateMap = {
|
||||
...(existing.rate_multipliers || {}),
|
||||
...(key.rate_multipliers || {})
|
||||
}
|
||||
|
||||
const preferredFormatPriority = existing.format_priority ?? key.format_priority
|
||||
dedupedById.set(key.id, {
|
||||
...existing,
|
||||
...key,
|
||||
global_priority_by_format: Object.keys(mergedPriorityMap).length > 0 ? mergedPriorityMap : null,
|
||||
rate_multipliers: Object.keys(mergedRateMap).length > 0 ? mergedRateMap : null,
|
||||
format_priority: preferredFormatPriority,
|
||||
priority: preferredFormatPriority ?? existing.priority ?? key.priority,
|
||||
})
|
||||
}
|
||||
|
||||
const deduped = Array.from(dedupedById.values())
|
||||
let maxPriority = 0
|
||||
for (const key of deduped) {
|
||||
if (key.format_priority != null) {
|
||||
maxPriority = Math.max(maxPriority, key.format_priority)
|
||||
}
|
||||
}
|
||||
|
||||
let nextPriority = maxPriority + 1
|
||||
data[format] = keys.map((key) => ({
|
||||
data[format] = deduped.map((key) => ({
|
||||
...key,
|
||||
// 使用格式特定优先级,如果没有则分配默认值
|
||||
priority: key.format_priority ?? nextPriority++
|
||||
}))
|
||||
// 按优先级排序:活跃的在前,停用的(Key或Provider)在后,各自按优先级排序
|
||||
@@ -654,7 +985,7 @@ async function loadKeysByFormat() {
|
||||
}
|
||||
keysByFormat.value = data
|
||||
|
||||
const formats = Object.keys(data)
|
||||
const formats = sortApiFormats(Object.keys(data))
|
||||
if (formats.length > 0 && !formats.includes(activeFormatTab.value)) {
|
||||
activeFormatTab.value = formats[0]
|
||||
}
|
||||
@@ -667,6 +998,7 @@ async function loadKeysByFormat() {
|
||||
|
||||
// 快捷切换 Key 启用/禁用状态
|
||||
async function toggleKeyActive(format: string, key: KeyWithMeta) {
|
||||
if (isPoolAggregateItem(key)) return
|
||||
const newStatus = !key.is_active
|
||||
try {
|
||||
await updateProviderKey(key.id, { is_active: newStatus })
|
||||
@@ -690,6 +1022,7 @@ async function toggleKeyActive(format: string, key: KeyWithMeta) {
|
||||
|
||||
// Key 优先级编辑
|
||||
function startEditKeyPriority(format: string, key: KeyWithMeta) {
|
||||
if (isPoolAggregateItem(key)) return
|
||||
editingKeyPriority.value[format] = key.id
|
||||
}
|
||||
|
||||
@@ -698,6 +1031,10 @@ function cancelEditKeyPriority(format: string) {
|
||||
}
|
||||
|
||||
function finishEditKeyPriority(format: string, key: KeyWithMeta, event: FocusEvent) {
|
||||
if (isPoolAggregateItem(key)) {
|
||||
editingKeyPriority.value[format] = null
|
||||
return
|
||||
}
|
||||
const input = event.target as HTMLInputElement
|
||||
const newPriority = parseInt(input.value, 10)
|
||||
|
||||
@@ -825,8 +1162,12 @@ function handleProviderDrop(dropIndex: number) {
|
||||
}
|
||||
|
||||
// Key 拖拽处理
|
||||
function handleKeyDragStart(format: string, index: number, event: DragEvent) {
|
||||
draggedKey.value[format] = index
|
||||
function getEditableKeysForFormat(format: string): KeyWithMeta[] {
|
||||
return (keysByFormat.value[format] || []).filter((key) => !isPoolManagedKey(key))
|
||||
}
|
||||
|
||||
function handleKeyDragStart(format: string, keyId: string, event: DragEvent) {
|
||||
draggedKey.value[format] = keyId
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/html', '')
|
||||
@@ -838,25 +1179,33 @@ function handleKeyDragEnd(format: string) {
|
||||
dragOverKey.value[format] = null
|
||||
}
|
||||
|
||||
function handleKeyDragOver(format: string, index: number) {
|
||||
dragOverKey.value[format] = index
|
||||
function handleKeyDragOver(format: string, keyId: string) {
|
||||
dragOverKey.value[format] = keyId
|
||||
}
|
||||
|
||||
function handleKeyDragLeave(format: string) {
|
||||
dragOverKey.value[format] = null
|
||||
}
|
||||
|
||||
function handleKeyDrop(format: string, dropIndex: number) {
|
||||
const dragIndex = draggedKey.value[format]
|
||||
if (dragIndex === null || dragIndex === dropIndex) {
|
||||
function handleKeyDrop(format: string, dropKeyId: string) {
|
||||
const draggedKeyId = draggedKey.value[format]
|
||||
if (!draggedKeyId || draggedKeyId === dropKeyId) {
|
||||
draggedKey.value[format] = null
|
||||
dragOverKey.value[format] = null
|
||||
return
|
||||
}
|
||||
|
||||
const keys = keysByFormat.value[format]
|
||||
const draggedItem = keys[dragIndex]
|
||||
const targetItem = keys[dropIndex]
|
||||
const editableKeys = getEditableKeysForFormat(format)
|
||||
const dragIndex = editableKeys.findIndex((k) => k.id === draggedKeyId)
|
||||
const dropIndex = editableKeys.findIndex((k) => k.id === dropKeyId)
|
||||
if (dragIndex === -1 || dropIndex === -1) {
|
||||
draggedKey.value[format] = null
|
||||
dragOverKey.value[format] = null
|
||||
return
|
||||
}
|
||||
|
||||
const draggedItem = editableKeys[dragIndex]
|
||||
const targetItem = editableKeys[dropIndex]
|
||||
const draggedPriority = draggedItem.priority
|
||||
const targetPriority = targetItem.priority
|
||||
|
||||
@@ -869,14 +1218,22 @@ function handleKeyDrop(format: string, dropIndex: number) {
|
||||
|
||||
// 记录每个 key 的原始优先级
|
||||
const originalPriorityMap = new Map<string, number>()
|
||||
keys.forEach(k => {
|
||||
editableKeys.forEach(k => {
|
||||
originalPriorityMap.set(k.id, k.priority)
|
||||
})
|
||||
|
||||
// 重排数组:将被拖动项移到目标位置
|
||||
const items = [...keys]
|
||||
items.splice(dragIndex, 1)
|
||||
items.splice(dropIndex, 0, draggedItem)
|
||||
const items = editableKeys.map((key) => ({ ...key }))
|
||||
const dragItemIndex = items.findIndex((k) => k.id === draggedKeyId)
|
||||
const dropItemIndex = items.findIndex((k) => k.id === dropKeyId)
|
||||
if (dragItemIndex === -1 || dropItemIndex === -1) {
|
||||
draggedKey.value[format] = null
|
||||
dragOverKey.value[format] = null
|
||||
return
|
||||
}
|
||||
const draggedClone = items[dragItemIndex]
|
||||
items.splice(dragItemIndex, 1)
|
||||
items.splice(dropItemIndex, 0, draggedClone)
|
||||
|
||||
// 按新顺序分配优先级:被拖动项单独成组,其他同组项保持在一起
|
||||
const groupNewPriority = new Map<number, number>()
|
||||
@@ -885,7 +1242,7 @@ function handleKeyDrop(format: string, dropIndex: number) {
|
||||
items.forEach(key => {
|
||||
const originalPriority = originalPriorityMap.get(key.id) ?? 0
|
||||
|
||||
if (key === draggedItem) {
|
||||
if (key.id === draggedKeyId) {
|
||||
// 被拖动的项单独成组
|
||||
key.priority = currentPriority
|
||||
currentPriority++
|
||||
@@ -902,8 +1259,22 @@ function handleKeyDrop(format: string, dropIndex: number) {
|
||||
}
|
||||
})
|
||||
|
||||
// 按优先级重新排序
|
||||
keysByFormat.value[format] = sortKeysByActiveAndPriority(items)
|
||||
const updatedPriorityById = new Map<string, number>()
|
||||
items.forEach((key) => {
|
||||
updatedPriorityById.set(key.id, key.priority)
|
||||
})
|
||||
|
||||
// 将新的优先级写回原始数据(含号池 key),但只修改非号池条目
|
||||
keysByFormat.value[format] = sortKeysByActiveAndPriority(
|
||||
(keysByFormat.value[format] || []).map((key) => {
|
||||
const nextPriority = updatedPriorityById.get(key.id)
|
||||
if (nextPriority == null) return key
|
||||
return {
|
||||
...key,
|
||||
priority: nextPriority,
|
||||
}
|
||||
})
|
||||
)
|
||||
draggedKey.value[format] = null
|
||||
dragOverKey.value[format] = null
|
||||
}
|
||||
@@ -924,11 +1295,12 @@ async function save() {
|
||||
// 收集每个 Key 的按格式优先级(保留原有其他格式的配置)
|
||||
const keyPriorityByFormatMap = new Map<string, Record<string, number>>()
|
||||
for (const format of Object.keys(keysByFormat.value)) {
|
||||
const keys = keysByFormat.value[format]
|
||||
const keys = keysByFormat.value[format].filter((key) => !isPoolManagedKey(key))
|
||||
keys.forEach((key) => {
|
||||
// 合并原有配置,避免丢失未显示格式的优先级
|
||||
const existing = keyPriorityByFormatMap.get(key.id) || { ...key.global_priority_by_format }
|
||||
existing[format] = key.priority
|
||||
const existing = keyPriorityByFormatMap.get(key.id)
|
||||
|| normalizePriorityMap(key.global_priority_by_format)
|
||||
existing[normalizeApiFormatKey(format)] = key.priority
|
||||
keyPriorityByFormatMap.set(key.id, existing)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,9 +294,6 @@
|
||||
<TableHead class="w-24 font-semibold whitespace-nowrap">
|
||||
状态
|
||||
</TableHead>
|
||||
<TableHead class="w-20 font-semibold text-center whitespace-nowrap">
|
||||
会话
|
||||
</TableHead>
|
||||
<TableHead class="w-24 font-semibold whitespace-nowrap">
|
||||
最后使用
|
||||
</TableHead>
|
||||
@@ -347,18 +344,6 @@
|
||||
{{ key.auth_type === 'oauth' ? '[OAuth Token]' : (key.auth_type === 'service_account' ? '[Service Account]' : '[Key]') }}
|
||||
</span>
|
||||
<template v-if="key.auth_type === 'oauth'">
|
||||
<span
|
||||
v-if="getKeyOAuthExpires(key)"
|
||||
class="text-[10px]"
|
||||
:class="{
|
||||
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
|
||||
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
|
||||
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
|
||||
}"
|
||||
:title="getOAuthStatusTitle(key)"
|
||||
>
|
||||
{{ getKeyOAuthExpires(key)?.text }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -372,6 +357,18 @@
|
||||
:class="{ 'animate-spin': refreshingOAuthKeyId === key.key_id }"
|
||||
/>
|
||||
</Button>
|
||||
<span
|
||||
v-if="getKeyOAuthExpires(key)"
|
||||
class="text-[10px]"
|
||||
:class="{
|
||||
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
|
||||
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
|
||||
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
|
||||
}"
|
||||
:title="getOAuthStatusTitle(key)"
|
||||
>
|
||||
{{ getKeyOAuthExpires(key)?.text }}
|
||||
</span>
|
||||
</template>
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
@@ -529,11 +526,6 @@
|
||||
{{ getSchedulingBadgeLabel(key) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center">
|
||||
<span class="text-xs tabular-nums">
|
||||
{{ formatSessionCount(key.sticky_sessions) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{{ key.last_used_at ? formatRelativeTime(key.last_used_at) : '-' }}
|
||||
@@ -735,18 +727,6 @@
|
||||
{{ key.auth_type === 'oauth' ? '[OAuth Token]' : (key.auth_type === 'service_account' ? '[Service Account]' : '[Key]') }}
|
||||
</span>
|
||||
<template v-if="key.auth_type === 'oauth'">
|
||||
<span
|
||||
v-if="getKeyOAuthExpires(key)"
|
||||
class="text-[10px]"
|
||||
:class="{
|
||||
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
|
||||
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
|
||||
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
|
||||
}"
|
||||
:title="getOAuthStatusTitle(key)"
|
||||
>
|
||||
{{ getKeyOAuthExpires(key)?.text }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -760,6 +740,18 @@
|
||||
:class="{ 'animate-spin': refreshingOAuthKeyId === key.key_id }"
|
||||
/>
|
||||
</Button>
|
||||
<span
|
||||
v-if="getKeyOAuthExpires(key)"
|
||||
class="text-[10px]"
|
||||
:class="{
|
||||
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
|
||||
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
|
||||
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
|
||||
}"
|
||||
:title="getOAuthStatusTitle(key)"
|
||||
>
|
||||
{{ getKeyOAuthExpires(key)?.text }}
|
||||
</span>
|
||||
</template>
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
@@ -973,16 +965,6 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 bg-muted/50 rounded-lg text-xs">
|
||||
<div class="text-muted-foreground mb-0.5">
|
||||
会话
|
||||
</div>
|
||||
<div
|
||||
class="font-medium tabular-nums text-[11px]"
|
||||
>
|
||||
{{ formatSessionCount(key.sticky_sessions) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 bg-muted/50 rounded-lg text-xs">
|
||||
<div class="text-muted-foreground mb-0.5">
|
||||
最后使用
|
||||
@@ -2221,12 +2203,6 @@ function formatStatUsd(value: number | null | undefined): string {
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatSessionCount(value: number | null | undefined): string {
|
||||
const n = Number(value ?? 0)
|
||||
if (!Number.isFinite(n) || n <= 0) return '0'
|
||||
return Math.round(n).toLocaleString('en-US')
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoStr: string): string {
|
||||
const diff = (Date.now() - new Date(isoStr).getTime()) / 1000
|
||||
if (diff < 60) return '刚刚'
|
||||
|
||||
@@ -414,7 +414,60 @@ def _normalize_oauth_plan_type(plan_type: Any, provider_type: str) -> str | None
|
||||
return text or None
|
||||
|
||||
|
||||
def _derive_oauth_plan_type(key: ProviderAPIKey, provider_type: str) -> str | None:
|
||||
def _extract_oauth_auth_config(key: ProviderAPIKey) -> dict[str, Any] | None:
|
||||
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
|
||||
return None
|
||||
|
||||
auth_config_raw = getattr(key, "auth_config", None)
|
||||
if not auth_config_raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
decrypted = crypto_service.decrypt(auth_config_raw)
|
||||
parsed = json.loads(decrypted)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_oauth_expires_at(raw: Any) -> int | None:
|
||||
value = _to_float(raw)
|
||||
if value is None or value <= 0:
|
||||
return None
|
||||
# 兼容毫秒时间戳
|
||||
if value > 1_000_000_000_000:
|
||||
value /= 1000
|
||||
return int(value)
|
||||
|
||||
|
||||
def _derive_oauth_expires_at(
|
||||
key: ProviderAPIKey, auth_config: dict[str, Any] | None = None
|
||||
) -> int | None:
|
||||
if str(getattr(key, "auth_type", "") or "").strip().lower() != "oauth":
|
||||
return None
|
||||
|
||||
cfg = auth_config if isinstance(auth_config, dict) else _extract_oauth_auth_config(key)
|
||||
if cfg:
|
||||
for field in ("expires_at", "expiresAt", "expiry", "exp"):
|
||||
expires_at = _normalize_oauth_expires_at(cfg.get(field))
|
||||
if expires_at is not None:
|
||||
return expires_at
|
||||
|
||||
# 兼容历史字段
|
||||
expires_dt = getattr(key, "expires_at", None)
|
||||
if isinstance(expires_dt, datetime):
|
||||
return int(expires_dt.timestamp())
|
||||
return None
|
||||
|
||||
|
||||
def _derive_oauth_plan_type(
|
||||
key: ProviderAPIKey,
|
||||
provider_type: str,
|
||||
auth_config: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
# Prefer persisted normalized field
|
||||
persisted = _normalize_oauth_plan_type(getattr(key, "oauth_plan_type", None), provider_type)
|
||||
if persisted:
|
||||
@@ -424,20 +477,12 @@ def _derive_oauth_plan_type(key: ProviderAPIKey, provider_type: str) -> str | No
|
||||
return None
|
||||
|
||||
# Fallback 1: encrypted auth_config (common for Codex/Antigravity)
|
||||
auth_config_raw = getattr(key, "auth_config", None)
|
||||
if auth_config_raw:
|
||||
try:
|
||||
decrypted = crypto_service.decrypt(auth_config_raw)
|
||||
auth_config = json.loads(decrypted)
|
||||
if isinstance(auth_config, dict):
|
||||
for plan_key in ("plan_type", "tier", "plan", "subscription_plan"):
|
||||
normalized = _normalize_oauth_plan_type(
|
||||
auth_config.get(plan_key), provider_type
|
||||
)
|
||||
if normalized:
|
||||
return normalized
|
||||
except Exception:
|
||||
pass
|
||||
cfg = auth_config if isinstance(auth_config, dict) else _extract_oauth_auth_config(key)
|
||||
if cfg:
|
||||
for plan_key in ("plan_type", "tier", "plan", "subscription_plan"):
|
||||
normalized = _normalize_oauth_plan_type(cfg.get(plan_key), provider_type)
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
# Fallback 2: upstream_metadata
|
||||
upstream_metadata = getattr(key, "upstream_metadata", None)
|
||||
@@ -902,6 +947,7 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
||||
key_last_used_at = getattr(k, "last_used_at", None) or key_usage_stats.get(
|
||||
"last_used_at"
|
||||
)
|
||||
oauth_auth_config = _extract_oauth_auth_config(k)
|
||||
|
||||
key_details.append(
|
||||
PoolKeyDetail(
|
||||
@@ -909,10 +955,8 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
||||
key_name=k.name or "",
|
||||
is_active=bool(k.is_active),
|
||||
auth_type=str(getattr(k, "auth_type", "api_key") or "api_key"),
|
||||
oauth_expires_at=(
|
||||
int(k.oauth_expires_at.timestamp())
|
||||
if getattr(k, "oauth_expires_at", None)
|
||||
else None
|
||||
oauth_expires_at=_derive_oauth_expires_at(
|
||||
k, auth_config=oauth_auth_config
|
||||
),
|
||||
oauth_invalid_at=(
|
||||
int(k.oauth_invalid_at.timestamp())
|
||||
@@ -920,7 +964,9 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
||||
else None
|
||||
),
|
||||
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
|
||||
oauth_plan_type=_derive_oauth_plan_type(k, provider_type),
|
||||
oauth_plan_type=_derive_oauth_plan_type(
|
||||
k, provider_type, auth_config=oauth_auth_config
|
||||
),
|
||||
quota_updated_at=_extract_quota_updated_at(
|
||||
provider_type,
|
||||
getattr(k, "upstream_metadata", None),
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.api_format.signature import normalize_signature_key
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.key_capabilities import get_capability
|
||||
@@ -19,6 +20,47 @@ from src.models.endpoint_models import EndpointAPIKeyResponse
|
||||
from src.services.provider_keys.auth_type import normalize_auth_type
|
||||
from src.services.provider_keys.response_builder import build_key_response
|
||||
|
||||
_LEGACY_API_FORMAT_MAP: dict[str, str] = {
|
||||
"CLAUDE": "claude:chat",
|
||||
"CLAUDE_CLI": "claude:cli",
|
||||
"OPENAI": "openai:chat",
|
||||
"OPENAI_CLI": "openai:cli",
|
||||
"OPENAI_COMPACT": "openai:compact",
|
||||
"OPENAI_VIDEO": "openai:video",
|
||||
"GEMINI": "gemini:chat",
|
||||
"GEMINI_CLI": "gemini:cli",
|
||||
"GEMINI_VIDEO": "gemini:video",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_api_format_key(raw_format: Any) -> str | None:
|
||||
"""Normalize api_format to canonical signature key; keeps legacy import compatibility."""
|
||||
text = str(raw_format or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
return normalize_signature_key(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
legacy = text.upper().replace("-", "_")
|
||||
return _LEGACY_API_FORMAT_MAP.get(legacy)
|
||||
|
||||
|
||||
def _normalize_format_dict(raw_dict: Any) -> dict[str, Any]:
|
||||
"""Normalize dict keys from any format aliases to canonical api_format."""
|
||||
if not isinstance(raw_dict, dict):
|
||||
return {}
|
||||
|
||||
normalized: dict[str, Any] = {}
|
||||
for raw_key, value in raw_dict.items():
|
||||
format_key = _normalize_api_format_key(raw_key)
|
||||
if not format_key or format_key in normalized:
|
||||
continue
|
||||
normalized[format_key] = value
|
||||
return normalized
|
||||
|
||||
|
||||
def get_keys_grouped_by_format(db: Session) -> dict:
|
||||
"""查询所有 Key,并按 API 格式分组返回。"""
|
||||
@@ -49,11 +91,22 @@ def get_keys_grouped_by_format(db: Session) -> dict:
|
||||
endpoint_base_url_map: dict[tuple[str, str], str] = {}
|
||||
for provider_id, api_format, base_url in endpoints:
|
||||
fmt = api_format.value if hasattr(api_format, "value") else str(api_format)
|
||||
endpoint_base_url_map[(str(provider_id), fmt)] = base_url
|
||||
normalized_fmt = _normalize_api_format_key(fmt)
|
||||
if not normalized_fmt:
|
||||
continue
|
||||
endpoint_base_url_map[(str(provider_id), normalized_fmt)] = base_url
|
||||
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
for key, provider in keys:
|
||||
api_formats = key.api_formats or []
|
||||
raw_api_formats = key.api_formats or []
|
||||
api_formats: list[str] = []
|
||||
seen_formats: set[str] = set()
|
||||
for raw_format in raw_api_formats:
|
||||
normalized_format = _normalize_api_format_key(raw_format)
|
||||
if not normalized_format or normalized_format in seen_formats:
|
||||
continue
|
||||
seen_formats.add(normalized_format)
|
||||
api_formats.append(normalized_format)
|
||||
|
||||
if not api_formats:
|
||||
continue # 跳过没有 API 格式的 Key
|
||||
@@ -88,14 +141,17 @@ def get_keys_grouped_by_format(db: Session) -> dict:
|
||||
caps_list.append(cap_def.short_name if cap_def else cap_name)
|
||||
|
||||
# 构建 Key 信息(基础数据)
|
||||
normalized_rate_multipliers = _normalize_format_dict(key.rate_multipliers)
|
||||
normalized_priority_by_format = _normalize_format_dict(key.global_priority_by_format)
|
||||
key_info = {
|
||||
"id": key.id,
|
||||
"provider_id": str(provider.id),
|
||||
"name": key.name,
|
||||
"auth_type": auth_type,
|
||||
"api_key_masked": masked_key,
|
||||
"internal_priority": key.internal_priority,
|
||||
"global_priority_by_format": key.global_priority_by_format,
|
||||
"rate_multipliers": key.rate_multipliers,
|
||||
"global_priority_by_format": normalized_priority_by_format,
|
||||
"rate_multipliers": normalized_rate_multipliers or None,
|
||||
"is_active": key.is_active,
|
||||
"provider_active": provider.is_active,
|
||||
"provider_name": provider.name,
|
||||
@@ -107,9 +163,14 @@ def get_keys_grouped_by_format(db: Session) -> dict:
|
||||
}
|
||||
|
||||
# 将 Key 添加到每个支持的格式分组中,并附加格式特定的数据
|
||||
health_by_format = key.health_by_format or {}
|
||||
circuit_by_format = key.circuit_breaker_by_format or {}
|
||||
priority_by_format = key.global_priority_by_format or {}
|
||||
health_by_format = _normalize_format_dict(key.health_by_format)
|
||||
circuit_by_format = _normalize_format_dict(key.circuit_breaker_by_format)
|
||||
priority_by_format: dict[str, int] = {}
|
||||
for k, v in normalized_priority_by_format.items():
|
||||
try:
|
||||
priority_by_format[k] = int(v)
|
||||
except Exception:
|
||||
continue
|
||||
provider_id = str(provider.id)
|
||||
for api_format in api_formats:
|
||||
if api_format not in grouped:
|
||||
|
||||
40
tests/unit/test_pool_oauth_expires.py
Normal file
40
tests/unit/test_pool_oauth_expires.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.api.admin.pool import routes as pool_routes
|
||||
|
||||
|
||||
def test_derive_oauth_expires_at_from_auth_config_seconds(monkeypatch) -> None:
|
||||
key = SimpleNamespace(auth_type="oauth", auth_config="enc", expires_at=None)
|
||||
|
||||
monkeypatch.setattr(
|
||||
pool_routes.crypto_service,
|
||||
"decrypt",
|
||||
lambda _v: '{"expires_at": 1710000000}',
|
||||
)
|
||||
|
||||
assert pool_routes._derive_oauth_expires_at(key) == 1710000000
|
||||
|
||||
|
||||
def test_derive_oauth_expires_at_from_auth_config_milliseconds(monkeypatch) -> None:
|
||||
key = SimpleNamespace(auth_type="oauth", auth_config="enc", expires_at=None)
|
||||
|
||||
monkeypatch.setattr(
|
||||
pool_routes.crypto_service,
|
||||
"decrypt",
|
||||
lambda _v: '{"expires_at": 1710000000000}',
|
||||
)
|
||||
|
||||
assert pool_routes._derive_oauth_expires_at(key) == 1710000000
|
||||
|
||||
|
||||
def test_derive_oauth_expires_at_fallback_to_legacy_datetime() -> None:
|
||||
key = SimpleNamespace(
|
||||
auth_type="oauth",
|
||||
auth_config=None,
|
||||
expires_at=datetime(2026, 3, 4, 1, 2, 3, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert pool_routes._derive_oauth_expires_at(key) == 1772586123
|
||||
Reference in New Issue
Block a user