mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(pool,ui,trace): 号池按页配额刷新、配额倒计时、Trace 全量候选与 UI 用语统一
- 号池管理支持按当前页 Key 刷新配额,后端 refresh-quota 接口支持 key_ids 参数筛选 - 配额进度条 tooltip 展示重置倒计时,前端解析后端重置时间并实时倒计时 - Provider 选择器在无号池提供商时禁用,切换/刷新后保持选中状态对齐 - 启停账号后立即更新调度标签并刷新列表 - Trace 监控展示全量候选记录,不再过滤 available/unused 状态 - 请求时间线号池节点与 Provider 节点去重 - 全局 UI 用语统一:已停用/已禁用 -> 停用/禁用 - 导航菜单调整模型管理与号池管理顺序
This commit is contained in:
@@ -197,8 +197,12 @@ export interface RefreshQuotaResult {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshProviderQuota(providerId: string): Promise<RefreshQuotaResult> {
|
export async function refreshProviderQuota(
|
||||||
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/refresh-quota`)
|
providerId: string,
|
||||||
|
keyIds?: string[],
|
||||||
|
): Promise<RefreshQuotaResult> {
|
||||||
|
const body = keyIds && keyIds.length > 0 ? { key_ids: keyIds } : undefined
|
||||||
|
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/refresh-quota`, body)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1102,7 +1102,7 @@ function getKeyTooltip(key: RoutingKeyInfo): string {
|
|||||||
parts.push(`名称: ${key.name}`)
|
parts.push(`名称: ${key.name}`)
|
||||||
parts.push(`健康度: ${((key.health_score || 0) * 100).toFixed(0)}%`)
|
parts.push(`健康度: ${((key.health_score || 0) * 100).toFixed(0)}%`)
|
||||||
if (!key.is_active) {
|
if (!key.is_active) {
|
||||||
parts.push('状态: 已禁用')
|
parts.push('状态: 禁用')
|
||||||
} else if (key.circuit_breaker_open) {
|
} else if (key.circuit_breaker_open) {
|
||||||
parts.push(`熔断中: ${key.circuit_breaker_formats.join(', ')}`)
|
parts.push(`熔断中: ${key.circuit_breaker_formats.join(', ')}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
已停用
|
停用
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -323,7 +323,7 @@
|
|||||||
: key.is_active
|
: key.is_active
|
||||||
? 'text-foreground/70 hover:bg-muted hover:text-foreground'
|
? 'text-foreground/70 hover:bg-muted hover:text-foreground'
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'"
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'"
|
||||||
:title="!key.provider_active ? 'Provider 已停用' : key.is_active ? '点击停用' : '点击启用'"
|
:title="!key.provider_active ? 'Provider 停用' : key.is_active ? '点击停用' : '点击启用'"
|
||||||
:disabled="!key.provider_active"
|
:disabled="!key.provider_active"
|
||||||
@click.stop="toggleKeyActive(format, key)"
|
@click.stop="toggleKeyActive(format, key)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
:variant="provider.is_active ? 'default' : 'secondary'"
|
:variant="provider.is_active ? 'default' : 'secondary'"
|
||||||
class="text-xs shrink-0"
|
class="text-xs shrink-0"
|
||||||
>
|
>
|
||||||
{{ provider.is_active ? '活跃' : '已停用' }}
|
{{ provider.is_active ? '活跃' : '停用' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
:variant="provider.is_active ? 'success' : 'secondary'"
|
:variant="provider.is_active ? 'success' : 'secondary'"
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
{{ provider.is_active ? '活跃' : '已停用' }}
|
{{ provider.is_active ? '活跃' : '停用' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ function getVideoPricingTooltip(model: Model): string {
|
|||||||
// 获取状态指示灯样式
|
// 获取状态指示灯样式
|
||||||
function getStatusIndicatorClass(model: Model): string {
|
function getStatusIndicatorClass(model: Model): string {
|
||||||
if (!model.is_active) {
|
if (!model.is_active) {
|
||||||
// 已停用 - 灰色
|
// 停用 - 灰色
|
||||||
return 'bg-gray-400 dark:bg-gray-600'
|
return 'bg-gray-400 dark:bg-gray-600'
|
||||||
}
|
}
|
||||||
if (model.is_available) {
|
if (model.is_available) {
|
||||||
@@ -386,7 +386,7 @@ function getStatusIndicatorClass(model: Model): string {
|
|||||||
// 获取状态提示文本
|
// 获取状态提示文本
|
||||||
function getStatusTitle(model: Model): string {
|
function getStatusTitle(model: Model): string {
|
||||||
if (!model.is_active) {
|
if (!model.is_active) {
|
||||||
return '已停用'
|
return '停用'
|
||||||
}
|
}
|
||||||
if (model.is_available) {
|
if (model.is_available) {
|
||||||
return '活跃且可用'
|
return '活跃且可用'
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function getEndpointTooltip(endpoint: EndpointHealthDetail): string {
|
|||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'disabled':
|
case 'disabled':
|
||||||
return `${format}: 端点已禁用`
|
return `${format}: 端点禁用`
|
||||||
case 'no_keys':
|
case 'no_keys':
|
||||||
return `${format}: 未配置密钥`
|
return `${format}: 未配置密钥`
|
||||||
case 'keys_disabled':
|
case 'keys_disabled':
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function useProviderFilters(
|
|||||||
const statusFilters: FilterOption[] = [
|
const statusFilters: FilterOption[] = [
|
||||||
{ value: 'all', label: '全部状态' },
|
{ value: 'all', label: '全部状态' },
|
||||||
{ value: 'active', label: '活跃' },
|
{ value: 'active', label: '活跃' },
|
||||||
{ value: 'inactive', label: '已停用' },
|
{ value: 'inactive', label: '停用' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const apiFormatFilters: FilterOption[] = [
|
const apiFormatFilters: FilterOption[] = [
|
||||||
|
|||||||
@@ -754,6 +754,11 @@ const getProviderDisplayName = (attempt: CandidateRecord | null | undefined): st
|
|||||||
return providerName ? normalizeProviderName(providerName) : '未知'
|
return providerName ? normalizeProviderName(providerName) : '未知'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeProviderIdentity = (value: unknown): string => {
|
||||||
|
if (typeof value !== 'string') return ''
|
||||||
|
return normalizeProviderName(value).trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => {
|
const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => {
|
||||||
const groups: NodeGroup[] = []
|
const groups: NodeGroup[] = []
|
||||||
let currentGroup: NodeGroup | null = null
|
let currentGroup: NodeGroup | null = null
|
||||||
@@ -829,7 +834,31 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
|
|||||||
isPoolGroup: true,
|
isPoolGroup: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
return [poolGroup, ...providerGroups]
|
const poolProviderIds = new Set(
|
||||||
|
poolAttempts
|
||||||
|
.map(item => String(item.provider_id || '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
const poolProviderNames = new Set(
|
||||||
|
poolAttempts
|
||||||
|
.map(item => normalizeProviderIdentity(item.provider_name))
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
|
||||||
|
const dedupedProviderGroups = providerGroups.filter((group) => {
|
||||||
|
const sameProviderById = group.allAttempts.some((attempt) => {
|
||||||
|
const providerId = String(attempt.provider_id || '').trim()
|
||||||
|
return providerId !== '' && poolProviderIds.has(providerId)
|
||||||
|
})
|
||||||
|
if (sameProviderById) return false
|
||||||
|
|
||||||
|
const groupName = normalizeProviderIdentity(group.primary.provider_name || group.providerName)
|
||||||
|
if (groupName && poolProviderNames.has(groupName)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return [poolGroup, ...dedupedProviderGroups]
|
||||||
})
|
})
|
||||||
|
|
||||||
// 格式转换分界点索引(首个 hasConversion=true 的 group index)
|
// 格式转换分界点索引(首个 hasConversion=true 的 group index)
|
||||||
|
|||||||
@@ -556,8 +556,8 @@ const navigation = computed(() => {
|
|||||||
items: [
|
items: [
|
||||||
{ name: '用户管理', href: '/admin/users', icon: Users },
|
{ name: '用户管理', href: '/admin/users', icon: Users },
|
||||||
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
|
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
|
||||||
{ name: '号池管理', href: '/admin/pool', icon: Database },
|
|
||||||
{ name: '模型管理', href: '/admin/models', icon: Layers },
|
{ name: '模型管理', href: '/admin/models', icon: Layers },
|
||||||
|
{ name: '号池管理', href: '/admin/pool', icon: Database },
|
||||||
{ name: '独立密钥', href: '/admin/keys', icon: Key },
|
{ name: '独立密钥', href: '/admin/keys', icon: Key },
|
||||||
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
|
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
|
||||||
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
|
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
|
||||||
|
|||||||
@@ -1229,7 +1229,7 @@ const batchManageShortcuts = computed(() => {
|
|||||||
const defs: { label: string; description: string; filter: (m: GlobalModelResponse) => boolean }[] = [
|
const defs: { label: string; description: string; filter: (m: GlobalModelResponse) => boolean }[] = [
|
||||||
{ label: '无提供商', description: '没有关联任何提供商的模型', filter: m => (m.provider_count || 0) === 0 },
|
{ label: '无提供商', description: '没有关联任何提供商的模型', filter: m => (m.provider_count || 0) === 0 },
|
||||||
{ label: '无活跃提供商', description: '有提供商但没有活跃提供商的模型', filter: m => (m.active_provider_count || 0) === 0 && (m.provider_count || 0) > 0 },
|
{ label: '无活跃提供商', description: '有提供商但没有活跃提供商的模型', filter: m => (m.active_provider_count || 0) === 0 && (m.provider_count || 0) > 0 },
|
||||||
{ label: '已禁用', description: '被禁用的模型', filter: m => !m.is_active },
|
{ label: '禁用', description: '被禁用的模型', filter: m => !m.is_active },
|
||||||
{ label: '未调用', description: '没有调用记录的模型', filter: m => (m.usage_count || 0) === 0 },
|
{ label: '未调用', description: '没有调用记录的模型', filter: m => (m.usage_count || 0) === 0 },
|
||||||
{ label: '无价格', description: '没有配置任何价格的模型', filter: m => hasNoPrice(m) },
|
{ label: '无价格', description: '没有配置任何价格的模型', filter: m => hasNoPrice(m) },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
class="text-sm"
|
class="text-sm"
|
||||||
:class="module.enabled ? 'text-foreground' : 'text-muted-foreground'"
|
:class="module.enabled ? 'text-foreground' : 'text-muted-foreground'"
|
||||||
>
|
>
|
||||||
{{ module.enabled ? '已启用' : '已禁用' }}
|
{{ module.enabled ? '启用' : '禁用' }}
|
||||||
</span>
|
</span>
|
||||||
<!-- 配置未验证提示(小字) -->
|
<!-- 配置未验证提示(小字) -->
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -53,15 +53,21 @@
|
|||||||
<Ban class="w-3.5 h-3.5" />
|
<Ban class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<RefreshButton
|
<RefreshButton
|
||||||
:loading="overviewLoading || keysLoading"
|
:loading="keysLoading"
|
||||||
@click="refresh"
|
@click="refreshCurrentPage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Filters (mobile) -->
|
<!-- Filters (mobile) -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Select v-model="selectedProviderIdProxy">
|
<Select
|
||||||
<SelectTrigger class="flex-1 h-8 text-xs border-border/60">
|
v-model="selectedProviderIdProxy"
|
||||||
|
:disabled="providerSelectDisabled"
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
class="flex-1 h-8 text-xs border-border/60"
|
||||||
|
:disabled="providerSelectDisabled"
|
||||||
|
>
|
||||||
<SelectValue placeholder="选择 Provider" />
|
<SelectValue placeholder="选择 Provider" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -90,7 +96,7 @@
|
|||||||
冷却中
|
冷却中
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="inactive">
|
<SelectItem value="inactive">
|
||||||
已禁用
|
禁用
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -124,8 +130,14 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Select v-model="selectedProviderIdProxy">
|
<Select
|
||||||
<SelectTrigger class="w-36 h-8 text-xs border-border/60">
|
v-model="selectedProviderIdProxy"
|
||||||
|
:disabled="providerSelectDisabled"
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
class="w-36 h-8 text-xs border-border/60"
|
||||||
|
:disabled="providerSelectDisabled"
|
||||||
|
>
|
||||||
<SelectValue placeholder="选择 Provider" />
|
<SelectValue placeholder="选择 Provider" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -167,7 +179,7 @@
|
|||||||
冷却中
|
冷却中
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="inactive">
|
<SelectItem value="inactive">
|
||||||
已禁用
|
禁用
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -206,8 +218,8 @@
|
|||||||
<Ban class="w-3.5 h-3.5" />
|
<Ban class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<RefreshButton
|
<RefreshButton
|
||||||
:loading="overviewLoading || keysLoading"
|
:loading="keysLoading"
|
||||||
@click="refresh"
|
@click="refreshCurrentPage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -384,20 +396,18 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="quotaProgressMap[key.key_id]?.length"
|
v-if="quotaProgressMap[key.key_id]?.length"
|
||||||
:class="
|
class="space-y-1 max-w-[220px]"
|
||||||
quotaProgressMap[key.key_id]?.length === 1
|
|
||||||
? 'h-[33px] max-w-[220px] flex items-center'
|
|
||||||
: 'grid grid-rows-[16px_16px] gap-1 max-w-[220px]'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(item, idx) in quotaProgressMap[key.key_id].slice(0, 2)"
|
v-for="(item, idx) in quotaProgressMap[key.key_id].slice(0, 2)"
|
||||||
:key="`${key.key_id}-quota-${idx}`"
|
:key="`${key.key_id}-quota-${idx}`"
|
||||||
class="w-full h-4"
|
class="w-full"
|
||||||
:title="item.detail || ''"
|
>
|
||||||
|
<div class="h-4 grid grid-cols-[20px_minmax(0,1fr)_42px] items-center gap-1 text-[10px] leading-tight">
|
||||||
|
<span
|
||||||
|
class="text-muted-foreground whitespace-nowrap text-right tabular-nums"
|
||||||
|
:title="getQuotaProgressTooltip(item)"
|
||||||
>
|
>
|
||||||
<div class="h-full grid grid-cols-[20px_minmax(0,1fr)_46px] items-center gap-1.5 text-[10px] leading-tight">
|
|
||||||
<span class="text-muted-foreground whitespace-nowrap text-right tabular-nums">
|
|
||||||
{{ getQuotaProgressLabel(item.label) }}
|
{{ getQuotaProgressLabel(item.label) }}
|
||||||
</span>
|
</span>
|
||||||
<div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden">
|
<div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden">
|
||||||
@@ -408,7 +418,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="tabular-nums text-right"
|
class="tabular-nums text-right whitespace-nowrap"
|
||||||
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
|
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
|
||||||
>
|
>
|
||||||
{{ item.remainingPercent.toFixed(1) }}%
|
{{ item.remainingPercent.toFixed(1) }}%
|
||||||
@@ -1027,10 +1037,12 @@
|
|||||||
v-for="(item, idx) in quotaProgressMap[key.key_id]"
|
v-for="(item, idx) in quotaProgressMap[key.key_id]"
|
||||||
:key="`${key.key_id}-quota-mobile-${idx}`"
|
:key="`${key.key_id}-quota-mobile-${idx}`"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:title="item.detail || ''"
|
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-[20px_minmax(0,1fr)_46px] items-center gap-1.5 text-[10px] leading-tight">
|
<div class="grid grid-cols-[20px_minmax(0,1fr)_42px] items-center gap-1 text-[10px] leading-tight">
|
||||||
<span class="text-muted-foreground whitespace-nowrap text-right tabular-nums">
|
<span
|
||||||
|
class="text-muted-foreground whitespace-nowrap text-right tabular-nums"
|
||||||
|
:title="getQuotaProgressTooltip(item)"
|
||||||
|
>
|
||||||
{{ getQuotaProgressLabel(item.label) }}
|
{{ getQuotaProgressLabel(item.label) }}
|
||||||
</span>
|
</span>
|
||||||
<div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden">
|
<div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden">
|
||||||
@@ -1041,7 +1053,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="tabular-nums text-right"
|
class="tabular-nums text-right whitespace-nowrap"
|
||||||
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
|
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
|
||||||
>
|
>
|
||||||
{{ item.remainingPercent.toFixed(1) }}%
|
{{ item.remainingPercent.toFixed(1) }}%
|
||||||
@@ -1203,6 +1215,7 @@ import {
|
|||||||
exportKey,
|
exportKey,
|
||||||
deleteEndpointKey,
|
deleteEndpointKey,
|
||||||
updateProviderKey,
|
updateProviderKey,
|
||||||
|
refreshProviderQuota,
|
||||||
} from '@/api/endpoints/keys'
|
} from '@/api/endpoints/keys'
|
||||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||||
import { recoverKeyHealth } from '@/api/endpoints/health'
|
import { recoverKeyHealth } from '@/api/endpoints/health'
|
||||||
@@ -1235,10 +1248,22 @@ async function loadOverview() {
|
|||||||
overviewLoading.value = true
|
overviewLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getPoolOverview()
|
const res = await getPoolOverview()
|
||||||
poolProviders.value = res.items.filter(item => item.pool_enabled)
|
const enabledProviders = res.items.filter(item => item.pool_enabled)
|
||||||
// Auto-select first provider if none selected
|
poolProviders.value = enabledProviders
|
||||||
if (!selectedProviderId.value && res.items.length > 0) {
|
|
||||||
await selectProvider(res.items[0].provider_id)
|
// Keep selected provider aligned with dropdown options.
|
||||||
|
const selectedId = selectedProviderId.value
|
||||||
|
const selectedStillExists = Boolean(
|
||||||
|
selectedId && enabledProviders.some(item => item.provider_id === selectedId),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!selectedStillExists) {
|
||||||
|
if (enabledProviders.length > 0) {
|
||||||
|
await selectProvider(enabledProviders[0].provider_id)
|
||||||
|
} else {
|
||||||
|
selectedProviderId.value = null
|
||||||
|
selectedProviderData.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(parseApiError(err))
|
showError(parseApiError(err))
|
||||||
@@ -1261,6 +1286,8 @@ const selectedProviderIdProxy = computed({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const providerSelectDisabled = computed(() => poolProviders.value.length === 0)
|
||||||
|
|
||||||
const selectedProviderConfig = computed<PoolAdvancedConfig | null>(() => {
|
const selectedProviderConfig = computed<PoolAdvancedConfig | null>(() => {
|
||||||
return (selectedProviderData.value as Record<string, unknown> | null)?.pool_advanced as PoolAdvancedConfig | null ?? null
|
return (selectedProviderData.value as Record<string, unknown> | null)?.pool_advanced as PoolAdvancedConfig | null ?? null
|
||||||
})
|
})
|
||||||
@@ -1303,15 +1330,14 @@ async function loadProviderData(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
await loadOverview()
|
|
||||||
if (selectedProviderId.value) {
|
|
||||||
await loadKeys()
|
await loadKeys()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Keys ---
|
// --- Keys ---
|
||||||
const keyPage = ref<PoolKeysPageResponse>({ total: 0, page: 1, page_size: 50, keys: [] })
|
const keyPage = ref<PoolKeysPageResponse>({ total: 0, page: 1, page_size: 50, keys: [] })
|
||||||
const keysLoading = ref(false)
|
const keysLoading = ref(false)
|
||||||
|
const refreshingCurrentPageQuota = ref(false)
|
||||||
|
const queuedCurrentPageQuotaRefresh = ref(false)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const statusFilter = ref('all')
|
const statusFilter = ref('all')
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
@@ -1336,6 +1362,7 @@ interface QuotaProgressItem {
|
|||||||
label: string
|
label: string
|
||||||
remainingPercent: number
|
remainingPercent: number
|
||||||
detail?: string
|
detail?: string
|
||||||
|
resetAtSeconds?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotaProgressMap = computed<Record<string, QuotaProgressItem[]>>(() => {
|
const quotaProgressMap = computed<Record<string, QuotaProgressItem[]>>(() => {
|
||||||
@@ -1346,6 +1373,65 @@ const quotaProgressMap = computed<Record<string, QuotaProgressItem[]>>(() => {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const quotaRefreshSupported = computed(() => {
|
||||||
|
return selectedProviderType.value === 'codex'
|
||||||
|
|| selectedProviderType.value === 'kiro'
|
||||||
|
|| selectedProviderType.value === 'antigravity'
|
||||||
|
})
|
||||||
|
|
||||||
|
function getCurrentPageQuotaKeyIds(): string[] {
|
||||||
|
const ids: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const key of keyPage.value.keys) {
|
||||||
|
const id = String(key.key_id || '').trim()
|
||||||
|
if (!id || seen.has(id)) continue
|
||||||
|
seen.add(id)
|
||||||
|
ids.push(id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCurrentPageQuotaInBackground(options: { silent?: boolean } = {}) {
|
||||||
|
if (!selectedProviderId.value || !quotaRefreshSupported.value) return
|
||||||
|
|
||||||
|
const providerId = selectedProviderId.value
|
||||||
|
const keyIds = getCurrentPageQuotaKeyIds()
|
||||||
|
if (keyIds.length === 0) return
|
||||||
|
|
||||||
|
if (refreshingCurrentPageQuota.value) {
|
||||||
|
queuedCurrentPageQuotaRefresh.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshingCurrentPageQuota.value = true
|
||||||
|
try {
|
||||||
|
const result = await refreshProviderQuota(providerId, keyIds)
|
||||||
|
const successCount = Number(result.success || 0)
|
||||||
|
const failedCount = Number(result.failed || 0)
|
||||||
|
|
||||||
|
// 刷新当前页数据,展示最新额度与状态
|
||||||
|
if (selectedProviderId.value === providerId) {
|
||||||
|
await loadKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.silent) {
|
||||||
|
success(`当前页额度刷新完成:成功 ${successCount},失败 ${failedCount}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError(parseApiError(err, '刷新当前页额度失败'))
|
||||||
|
} finally {
|
||||||
|
refreshingCurrentPageQuota.value = false
|
||||||
|
if (queuedCurrentPageQuotaRefresh.value) {
|
||||||
|
queuedCurrentPageQuotaRefresh.value = false
|
||||||
|
void refreshCurrentPageQuotaInBackground(options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCurrentPage() {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadKeys() {
|
async function loadKeys() {
|
||||||
if (!selectedProviderId.value) return
|
if (!selectedProviderId.value) return
|
||||||
keysLoading.value = true
|
keysLoading.value = true
|
||||||
@@ -1659,7 +1745,19 @@ async function toggleKeyActive(key: PoolKeyDetail) {
|
|||||||
const nextStatus = !key.is_active
|
const nextStatus = !key.is_active
|
||||||
await updateProviderKey(key.key_id, { is_active: nextStatus })
|
await updateProviderKey(key.key_id, { is_active: nextStatus })
|
||||||
key.is_active = nextStatus
|
key.is_active = nextStatus
|
||||||
|
if (nextStatus) {
|
||||||
|
delete key.scheduling_label
|
||||||
|
delete key.scheduling_status
|
||||||
|
if (key.scheduling_reason === 'manual_disabled') {
|
||||||
|
delete key.scheduling_reason
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
key.scheduling_label = '禁用'
|
||||||
|
key.scheduling_status = 'blocked'
|
||||||
|
key.scheduling_reason = 'manual_disabled'
|
||||||
|
}
|
||||||
success(nextStatus ? '账号已启用' : '账号已停用')
|
success(nextStatus ? '账号已启用' : '账号已停用')
|
||||||
|
await loadKeys()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(parseApiError(err))
|
showError(parseApiError(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1727,7 +1825,11 @@ function getSchedulingStatus(key: PoolKeyDetail): 'available' | 'degraded' | 'bl
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSchedulingBadgeLabel(key: PoolKeyDetail): string {
|
function getSchedulingBadgeLabel(key: PoolKeyDetail): string {
|
||||||
if (key.scheduling_label) return key.scheduling_label
|
const rawLabel = String(key.scheduling_label || '').trim()
|
||||||
|
if (rawLabel) {
|
||||||
|
if (rawLabel === '禁用' || rawLabel === '停用') return '禁用'
|
||||||
|
return rawLabel
|
||||||
|
}
|
||||||
|
|
||||||
if (!key.is_active) return '禁用'
|
if (!key.is_active) return '禁用'
|
||||||
if (key.cooldown_reason) return '冷却'
|
if (key.cooldown_reason) return '冷却'
|
||||||
@@ -1965,6 +2067,14 @@ function getQuotaProgressLabel(label: string): string {
|
|||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getQuotaProgressTooltip(item: QuotaProgressItem): string {
|
||||||
|
const detail = item.detail?.trim() || ''
|
||||||
|
if ((item.label === '5H' || item.label === '周') && item.resetAtSeconds != null) {
|
||||||
|
return `${formatQuotaInlineCountdown(item.resetAtSeconds)} 后重置`
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
function getQuotaLabelOrder(label: string): number {
|
function getQuotaLabelOrder(label: string): number {
|
||||||
if (label === '5H') return 0
|
if (label === '5H') return 0
|
||||||
if (label === '周') return 1
|
if (label === '周') return 1
|
||||||
@@ -1980,6 +2090,49 @@ function clampPercent(value: number): number {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseQuotaResetRemainingSeconds(detail: string | undefined): number | null {
|
||||||
|
if (!detail) return null
|
||||||
|
const text = detail.replace(/\s+/g, '')
|
||||||
|
|
||||||
|
if (text.includes('已重置')) return 0
|
||||||
|
if (text.includes('即将重置')) return 1
|
||||||
|
if (!text.includes('后重置')) return null
|
||||||
|
|
||||||
|
const dayMatch = text.match(/(\d+)天/)
|
||||||
|
const hourMatch = text.match(/(\d+)小时/)
|
||||||
|
const minuteMatch = text.match(/(\d+)分钟/)
|
||||||
|
const secondMatch = text.match(/(\d+)秒/)
|
||||||
|
|
||||||
|
const days = dayMatch ? Number(dayMatch[1]) : 0
|
||||||
|
const hours = hourMatch ? Number(hourMatch[1]) : 0
|
||||||
|
const minutes = minuteMatch ? Number(minuteMatch[1]) : 0
|
||||||
|
const seconds = secondMatch ? Number(secondMatch[1]) : 0
|
||||||
|
const total = days * 86400 + hours * 3600 + minutes * 60 + seconds
|
||||||
|
|
||||||
|
if (total <= 0) return 1
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQuotaInlineCountdown(resetAtSeconds: number): string {
|
||||||
|
// 触发响应式更新,保持倒计时每秒刷新
|
||||||
|
void countdownTick.value
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const remain = Math.max(0, Math.floor(resetAtSeconds - now))
|
||||||
|
const days = Math.floor(remain / 86400)
|
||||||
|
const hours = Math.floor((remain % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((remain % 3600) / 60)
|
||||||
|
const seconds = remain % 60
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days}d${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${String(minutes).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
function parseQuotaProgressItems(quotaText: string | null | undefined): QuotaProgressItem[] {
|
function parseQuotaProgressItems(quotaText: string | null | undefined): QuotaProgressItem[] {
|
||||||
if (!quotaText) return []
|
if (!quotaText) return []
|
||||||
|
|
||||||
@@ -1997,11 +2150,16 @@ function parseQuotaProgressItems(quotaText: string | null | undefined): QuotaPro
|
|||||||
const remainingPercent = clampPercent(Number(rawPercent))
|
const remainingPercent = clampPercent(Number(rawPercent))
|
||||||
const label = normalizeQuotaLabel(rawLabel)
|
const label = normalizeQuotaLabel(rawLabel)
|
||||||
const detail = rawTail.trim().replace(/^[()]+|[()]+$/g, '').trim()
|
const detail = rawTail.trim().replace(/^[()]+|[()]+$/g, '').trim()
|
||||||
|
const resetRemainingSeconds = parseQuotaResetRemainingSeconds(detail || undefined)
|
||||||
|
const resetAtSeconds = resetRemainingSeconds == null
|
||||||
|
? null
|
||||||
|
: Math.floor(Date.now() / 1000) + resetRemainingSeconds
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
label,
|
label,
|
||||||
remainingPercent,
|
remainingPercent,
|
||||||
detail: detail || undefined,
|
detail: detail || undefined,
|
||||||
|
resetAtSeconds,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2064,5 +2222,6 @@ function formatRelativeTime(isoStr: string): string {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
startCountdownTimer()
|
startCountdownTimer()
|
||||||
await loadOverview()
|
await loadOverview()
|
||||||
|
void refreshCurrentPageQuotaInBackground({ silent: true })
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -551,7 +551,7 @@
|
|||||||
:variant="apiKey.is_active ? 'success' : 'secondary'"
|
:variant="apiKey.is_active ? 'success' : 'secondary'"
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
{{ apiKey.is_active ? '活跃' : '已禁用' }}
|
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="apiKey.is_locked"
|
v-if="apiKey.is_locked"
|
||||||
|
|||||||
@@ -389,7 +389,7 @@
|
|||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-muted-foreground">账户状态</span>
|
<span class="text-muted-foreground">账户状态</span>
|
||||||
<span :class="profile?.is_active ? 'text-success' : 'text-destructive'">
|
<span :class="profile?.is_active ? 'text-success' : 'text-destructive'">
|
||||||
{{ profile?.is_active ? '活跃' : '已停用' }}
|
{{ profile?.is_active ? '活跃' : '停用' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
@@ -349,10 +350,15 @@ CODEX_WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
|
|||||||
# ========== Kiro Quota Refresh API ==========
|
# ========== Kiro Quota Refresh API ==========
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshProviderQuotaRequest(BaseModel):
|
||||||
|
key_ids: list[str] | None = Field(default=None, description="仅刷新指定 Key 列表(可选)")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/providers/{provider_id}/refresh-quota")
|
@router.post("/providers/{provider_id}/refresh-quota")
|
||||||
async def refresh_provider_quota(
|
async def refresh_provider_quota(
|
||||||
provider_id: str,
|
provider_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
payload: RefreshProviderQuotaRequest | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -365,13 +371,18 @@ async def refresh_provider_quota(
|
|||||||
|
|
||||||
**路径参数**:
|
**路径参数**:
|
||||||
- `provider_id`: Provider ID
|
- `provider_id`: Provider ID
|
||||||
|
**请求体**(可选):
|
||||||
|
- `key_ids`: 仅刷新指定 Key 列表,不传时刷新所有活跃 Key
|
||||||
|
|
||||||
**返回字段**:
|
**返回字段**:
|
||||||
- `success`: 成功刷新的 Key 数量
|
- `success`: 成功刷新的 Key 数量
|
||||||
- `failed`: 失败的 Key 数量
|
- `failed`: 失败的 Key 数量
|
||||||
- `results`: 每个 Key 的刷新结果
|
- `results`: 每个 Key 的刷新结果
|
||||||
"""
|
"""
|
||||||
adapter = AdminRefreshProviderQuotaAdapter(provider_id=provider_id)
|
adapter = AdminRefreshProviderQuotaAdapter(
|
||||||
|
provider_id=provider_id,
|
||||||
|
key_ids=payload.key_ids if payload else None,
|
||||||
|
)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@@ -380,10 +391,12 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
|||||||
"""刷新 Provider 所有 Keys 的限额信息"""
|
"""刷新 Provider 所有 Keys 的限额信息"""
|
||||||
|
|
||||||
provider_id: str
|
provider_id: str
|
||||||
|
key_ids: list[str] | None = None
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
return await refresh_provider_quota_for_provider(
|
return await refresh_provider_quota_for_provider(
|
||||||
db=context.db,
|
db=context.db,
|
||||||
provider_id=self.provider_id,
|
provider_id=self.provider_id,
|
||||||
codex_wham_usage_url=CODEX_WHAM_USAGE_URL,
|
codex_wham_usage_url=CODEX_WHAM_USAGE_URL,
|
||||||
|
key_ids=self.key_ids,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -163,18 +163,17 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
|||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
|
|
||||||
# 查询所有候选后,默认只展示已发生调度结果的子集:
|
# 查询并展示该请求的全量候选:
|
||||||
# - 过滤 available/unused(预创建但未实际参与本次调度)
|
# - 包含 available/unused(未执行)
|
||||||
# - 若过滤后为空(例如请求尚未开始),回退到全量,避免前端空白
|
# - 包含 skipped(跳过)
|
||||||
|
# - 包含 pending/streaming/success/failed/cancelled(执行中/结果)
|
||||||
all_candidates = RequestCandidateService.get_candidates_by_request_id(db, self.request_id)
|
all_candidates = RequestCandidateService.get_candidates_by_request_id(db, self.request_id)
|
||||||
|
|
||||||
# 如果没有数据,返回 404
|
# 如果没有数据,返回 404
|
||||||
if not all_candidates:
|
if not all_candidates:
|
||||||
raise HTTPException(status_code=404, detail="Request not found")
|
raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
|
||||||
candidates = [
|
candidates = all_candidates
|
||||||
c for c in all_candidates if c.status not in ("available", "unused")
|
|
||||||
] or all_candidates
|
|
||||||
|
|
||||||
# 计算总延迟(只统计已完成的候选:success, failed, cancelled)
|
# 计算总延迟(只统计已完成的候选:success, failed, cancelled)
|
||||||
# 使用显式的 is not None 检查,避免过滤掉 0ms 的快速响应
|
# 使用显式的 is not None 检查,避免过滤掉 0ms 的快速响应
|
||||||
|
|||||||
@@ -234,6 +234,28 @@ def _format_quota_value(value: float) -> str:
|
|||||||
return f"{value:.1f}"
|
return f"{value:.1f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_reset_after(seconds_raw: Any) -> str | None:
|
||||||
|
seconds = _to_float(seconds_raw)
|
||||||
|
if seconds is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total_seconds = int(seconds)
|
||||||
|
if total_seconds <= 0:
|
||||||
|
return "已重置"
|
||||||
|
|
||||||
|
days = total_seconds // 86400
|
||||||
|
hours = (total_seconds % 86400) // 3600
|
||||||
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
|
||||||
|
if days > 0:
|
||||||
|
return f"{days}天{hours}小时后重置"
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}小时{minutes}分钟后重置"
|
||||||
|
if minutes > 0:
|
||||||
|
return f"{minutes}分钟后重置"
|
||||||
|
return "即将重置"
|
||||||
|
|
||||||
|
|
||||||
def _build_codex_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
|
def _build_codex_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
|
||||||
codex = upstream_metadata.get("codex")
|
codex = upstream_metadata.get("codex")
|
||||||
if not isinstance(codex, dict):
|
if not isinstance(codex, dict):
|
||||||
@@ -243,11 +265,19 @@ def _build_codex_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
|
|||||||
|
|
||||||
primary_used = _to_float(codex.get("primary_used_percent"))
|
primary_used = _to_float(codex.get("primary_used_percent"))
|
||||||
if primary_used is not None:
|
if primary_used is not None:
|
||||||
parts.append(f"周剩余 {_format_percent(100.0 - primary_used)}")
|
part = f"周剩余 {_format_percent(100.0 - primary_used)}"
|
||||||
|
reset_text = _format_reset_after(codex.get("primary_reset_seconds"))
|
||||||
|
if reset_text:
|
||||||
|
part = f"{part} ({reset_text})"
|
||||||
|
parts.append(part)
|
||||||
|
|
||||||
secondary_used = _to_float(codex.get("secondary_used_percent"))
|
secondary_used = _to_float(codex.get("secondary_used_percent"))
|
||||||
if secondary_used is not None:
|
if secondary_used is not None:
|
||||||
parts.append(f"5H剩余 {_format_percent(100.0 - secondary_used)}")
|
part = f"5H剩余 {_format_percent(100.0 - secondary_used)}"
|
||||||
|
reset_text = _format_reset_after(codex.get("secondary_reset_seconds"))
|
||||||
|
if reset_text:
|
||||||
|
part = f"{part} ({reset_text})"
|
||||||
|
parts.append(part)
|
||||||
|
|
||||||
if parts:
|
if parts:
|
||||||
return " | ".join(parts)
|
return " | ".join(parts)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class PoolSchedulingDimension(Protocol):
|
|||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _ManualEnableDimension:
|
class _ManualEnableDimension:
|
||||||
code: str = "manual_disabled"
|
code: str = "manual_disabled"
|
||||||
label: str = "已禁用"
|
label: str = "禁用"
|
||||||
source: str = "manual"
|
source: str = "manual"
|
||||||
weight: int = 8
|
weight: int = 8
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ async def refresh_provider_quota_for_provider(
|
|||||||
db: Session,
|
db: Session,
|
||||||
provider_id: str,
|
provider_id: str,
|
||||||
codex_wham_usage_url: str,
|
codex_wham_usage_url: str,
|
||||||
|
key_ids: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""刷新 Provider 限额信息(惰性导入实现)。"""
|
"""刷新 Provider 限额信息(惰性导入实现)。"""
|
||||||
from src.services.provider_keys.key_quota_service import (
|
from src.services.provider_keys.key_quota_service import (
|
||||||
@@ -111,4 +112,5 @@ async def refresh_provider_quota_for_provider(
|
|||||||
db=db,
|
db=db,
|
||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
codex_wham_usage_url=codex_wham_usage_url,
|
codex_wham_usage_url=codex_wham_usage_url,
|
||||||
|
key_ids=key_ids,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ async def refresh_provider_quota_for_provider(
|
|||||||
db: Session,
|
db: Session,
|
||||||
provider_id: str,
|
provider_id: str,
|
||||||
codex_wham_usage_url: str,
|
codex_wham_usage_url: str,
|
||||||
|
key_ids: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""刷新指定 Provider 下所有活跃 Key 的限额信息。"""
|
"""刷新指定 Provider 的限额信息(默认所有活跃 Key,可按 key_ids 限定)。"""
|
||||||
provider = db.query(Provider).filter(Provider.id == provider_id).first()
|
provider = db.query(Provider).filter(Provider.id == provider_id).first()
|
||||||
if not provider:
|
if not provider:
|
||||||
raise NotFoundException(f"Provider {provider_id} 不存在")
|
raise NotFoundException(f"Provider {provider_id} 不存在")
|
||||||
@@ -74,21 +75,42 @@ async def refresh_provider_quota_for_provider(
|
|||||||
if provider_type not in {ProviderType.CODEX, ProviderType.ANTIGRAVITY, ProviderType.KIRO}:
|
if provider_type not in {ProviderType.CODEX, ProviderType.ANTIGRAVITY, ProviderType.KIRO}:
|
||||||
raise InvalidRequestException("仅支持 Codex / Antigravity / Kiro 类型的 Provider 刷新限额")
|
raise InvalidRequestException("仅支持 Codex / Antigravity / Kiro 类型的 Provider 刷新限额")
|
||||||
|
|
||||||
keys = (
|
selected_key_ids: list[str] | None = None
|
||||||
db.query(ProviderAPIKey)
|
if key_ids is not None:
|
||||||
.filter(
|
deduped: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in key_ids:
|
||||||
|
value = str(raw).strip()
|
||||||
|
if not value or value in seen:
|
||||||
|
continue
|
||||||
|
seen.add(value)
|
||||||
|
deduped.append(value)
|
||||||
|
selected_key_ids = deduped
|
||||||
|
|
||||||
|
keys_query = db.query(ProviderAPIKey).filter(
|
||||||
ProviderAPIKey.provider_id == provider_id,
|
ProviderAPIKey.provider_id == provider_id,
|
||||||
ProviderAPIKey.is_active.is_(True),
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
if selected_key_ids is None:
|
||||||
|
keys_query = keys_query.filter(ProviderAPIKey.is_active.is_(True))
|
||||||
|
else:
|
||||||
|
if not selected_key_ids:
|
||||||
|
return {
|
||||||
|
"success": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"results": [],
|
||||||
|
"message": "未提供可刷新的 Key",
|
||||||
|
}
|
||||||
|
keys_query = keys_query.filter(ProviderAPIKey.id.in_(selected_key_ids))
|
||||||
|
|
||||||
|
keys = keys_query.all()
|
||||||
if not keys:
|
if not keys:
|
||||||
return {
|
return {
|
||||||
"success": 0,
|
"success": 0,
|
||||||
"failed": 0,
|
"failed": 0,
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"results": [],
|
"results": [],
|
||||||
"message": "没有活跃的 Key",
|
"message": "没有可刷新的 Key",
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint = _select_refresh_endpoint(provider, provider_type)
|
endpoint = _select_refresh_endpoint(provider, provider_type)
|
||||||
|
|||||||
@@ -147,7 +147,33 @@ async def test_refresh_provider_quota_no_active_keys_returns_empty() -> None:
|
|||||||
"failed": 0,
|
"failed": 0,
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"results": [],
|
"results": [],
|
||||||
"message": "没有活跃的 Key",
|
"message": "没有可刷新的 Key",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_provider_quota_empty_key_ids_returns_empty() -> None:
|
||||||
|
provider = SimpleNamespace(
|
||||||
|
id="p1",
|
||||||
|
provider_type=ProviderType.CODEX,
|
||||||
|
endpoints=[SimpleNamespace(api_format="openai:cli", is_active=True)],
|
||||||
|
)
|
||||||
|
key = SimpleNamespace(id="k1", name="K1", upstream_metadata={})
|
||||||
|
db = _FakeDB(provider=provider, keys=[key])
|
||||||
|
|
||||||
|
result = await refresh_provider_quota_for_provider(
|
||||||
|
db=cast(Any, db),
|
||||||
|
provider_id="p1",
|
||||||
|
codex_wham_usage_url="https://example.test/wham/usage",
|
||||||
|
key_ids=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"success": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"results": [],
|
||||||
|
"message": "未提供可刷新的 Key",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def _context() -> SimpleNamespace:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_trace_prefers_attempted_subset(monkeypatch: object) -> None:
|
def test_trace_returns_all_candidates_including_unused(monkeypatch: object) -> None:
|
||||||
candidates = [
|
candidates = [
|
||||||
_candidate(status="available"),
|
_candidate(status="available"),
|
||||||
_candidate(status="unused"),
|
_candidate(status="unused"),
|
||||||
@@ -57,13 +57,13 @@ def test_trace_prefers_attempted_subset(monkeypatch: object) -> None:
|
|||||||
adapter = AdminGetRequestTraceAdapter(request_id="req-1")
|
adapter = AdminGetRequestTraceAdapter(request_id="req-1")
|
||||||
response = asyncio.run(adapter.handle(_context()))
|
response = asyncio.run(adapter.handle(_context()))
|
||||||
|
|
||||||
assert response.total_candidates == 1
|
assert response.total_candidates == 3
|
||||||
assert len(response.candidates) == 1
|
assert len(response.candidates) == 3
|
||||||
assert response.candidates[0].status == "failed"
|
assert {c.status for c in response.candidates} == {"available", "unused", "failed"}
|
||||||
assert response.total_latency_ms == 123
|
assert response.total_latency_ms == 123
|
||||||
|
|
||||||
|
|
||||||
def test_trace_falls_back_to_all_when_no_attempted(monkeypatch: object) -> None:
|
def test_trace_returns_unattempted_candidates(monkeypatch: object) -> None:
|
||||||
candidates = [
|
candidates = [
|
||||||
_candidate(status="available"),
|
_candidate(status="available"),
|
||||||
_candidate(status="unused"),
|
_candidate(status="unused"),
|
||||||
|
|||||||
Reference in New Issue
Block a user