mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(pool): 批量操作对话框改为服务端分页筛选,新增凭据导出功能
- 批量操作对话框从全量加载改为服务端分页+筛选,支持搜索和快捷选择器的服务端过滤 - 新增 resolve-selection API,支持"全选筛选结果"时解析完整匹配列表 - 新增批量导出凭据功能(仅 OAuth 账号),并发下载后导出为 JSON 文件 - 将快捷选择器和全文搜索的匹配逻辑从前端迁移到后端,统一复用 - 提取 pool key 序列化与过滤的公共函数,消除 AdminListPoolKeysAdapter 中的重复代码 - entrypoint.sh 增加 PostgreSQL 就绪等待,避免数据库未启动时迁移失败
This commit is contained in:
@@ -1,6 +1,26 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Wait for PostgreSQL to be ready
|
||||||
|
MAX_ATTEMPTS=30
|
||||||
|
ATTEMPT=0
|
||||||
|
until python -c "
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
import os
|
||||||
|
engine = create_engine(os.environ['DATABASE_URL'])
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text('SELECT 1'))
|
||||||
|
" 2>/dev/null; do
|
||||||
|
ATTEMPT=$((ATTEMPT + 1))
|
||||||
|
if [ "$ATTEMPT" -ge "$MAX_ATTEMPTS" ]; then
|
||||||
|
echo "Database not ready after $MAX_ATTEMPTS attempts, exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Waiting for database... (attempt $ATTEMPT/$MAX_ATTEMPTS)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Database is ready."
|
||||||
|
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,24 @@ export interface PoolKeysQuery {
|
|||||||
page_size?: number
|
page_size?: number
|
||||||
search?: string
|
search?: string
|
||||||
status?: 'all' | 'active' | 'cooldown' | 'inactive'
|
status?: 'all' | 'active' | 'cooldown' | 'inactive'
|
||||||
|
quick_selectors?: string[]
|
||||||
|
search_scope?: 'name' | 'full'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolKeySelectionRequest {
|
||||||
|
search?: string
|
||||||
|
quick_selectors?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolKeySelectionItem {
|
||||||
|
key_id: string
|
||||||
|
key_name: string
|
||||||
|
auth_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolKeySelectionResponse {
|
||||||
|
total: number
|
||||||
|
items: PoolKeySelectionItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PoolBatchAction {
|
export interface PoolBatchAction {
|
||||||
@@ -203,13 +221,29 @@ export async function listPoolKeys(
|
|||||||
providerId: string,
|
providerId: string,
|
||||||
params: PoolKeysQuery = {},
|
params: PoolKeysQuery = {},
|
||||||
): Promise<PoolKeysPageResponse> {
|
): Promise<PoolKeysPageResponse> {
|
||||||
const key = `pool:keys:${providerId}|${params.page ?? ''}|${params.page_size ?? ''}|${params.search ?? ''}|${params.status ?? ''}`
|
const normalizedParams = {
|
||||||
|
...params,
|
||||||
|
quick_selectors: params.quick_selectors?.length ? params.quick_selectors.join(',') : undefined,
|
||||||
|
}
|
||||||
|
const key = `pool:keys:${providerId}|${normalizedParams.page ?? ''}|${normalizedParams.page_size ?? ''}|${normalizedParams.search ?? ''}|${normalizedParams.status ?? ''}|${normalizedParams.quick_selectors ?? ''}|${normalizedParams.search_scope ?? ''}`
|
||||||
return dedupedRequest(key, async () => {
|
return dedupedRequest(key, async () => {
|
||||||
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params })
|
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params: normalizedParams })
|
||||||
return response.data
|
return response.data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolvePoolKeySelection(
|
||||||
|
providerId: string,
|
||||||
|
body: PoolKeySelectionRequest,
|
||||||
|
): Promise<PoolKeySelectionResponse> {
|
||||||
|
const response = await client.post<PoolKeySelectionResponse>(
|
||||||
|
`/api/admin/pool/${providerId}/keys/resolve-selection`,
|
||||||
|
body,
|
||||||
|
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function batchActionPoolKeys(
|
export async function batchActionPoolKeys(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
body: PoolBatchAction,
|
body: PoolBatchAction,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
placeholder="快捷多选"
|
placeholder="快捷多选"
|
||||||
trigger-class="h-8 w-40"
|
trigger-class="h-8 w-40"
|
||||||
dropdown-min-width="10rem"
|
dropdown-min-width="10rem"
|
||||||
:disabled="loading || executing || allKeys.length === 0"
|
:disabled="loading || executing"
|
||||||
@update:model-value="onQuickSelectChange"
|
@update:model-value="onQuickSelectChange"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 shrink-0"
|
class="h-8 w-8 shrink-0"
|
||||||
:disabled="loading || executing"
|
:disabled="loading || executing"
|
||||||
@click="loadAllKeys()"
|
@click="loadKeysPage()"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
class="h-3.5 w-3.5"
|
class="h-3.5 w-3.5"
|
||||||
@@ -67,13 +67,13 @@
|
|||||||
|
|
||||||
<div class="flex items-center justify-between text-xs">
|
<div class="flex items-center justify-between text-xs">
|
||||||
<div class="text-muted-foreground">
|
<div class="text-muted-foreground">
|
||||||
共 {{ allKeys.length }} 个账号,筛选 {{ filteredKeys.length }} 个,已选 {{ selectedKeyIds.length }} 个
|
共 {{ filteredTotal }} 个匹配账号,当前页 {{ pageKeys.length }} 个,已选 {{ selectedCount }} 个
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:checked="isAllFilteredSelected"
|
:checked="isAllFilteredSelected"
|
||||||
:indeterminate="isPartiallyFilteredSelected"
|
:indeterminate="isPartiallyFilteredSelected"
|
||||||
:disabled="filteredKeys.length === 0 || loading || executing"
|
:disabled="filteredTotal === 0 || loading || executing"
|
||||||
@update:checked="toggleSelectFiltered"
|
@update:checked="toggleSelectFiltered"
|
||||||
/>
|
/>
|
||||||
<span class="text-muted-foreground">全选筛选结果</span>
|
<span class="text-muted-foreground">全选筛选结果</span>
|
||||||
@@ -88,19 +88,19 @@
|
|||||||
正在加载账号列表...
|
正在加载账号列表...
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="filteredKeys.length === 0"
|
v-else-if="pageKeys.length === 0"
|
||||||
class="py-10 text-center text-sm text-muted-foreground"
|
class="py-10 text-center text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
无匹配账号
|
无匹配账号
|
||||||
</div>
|
</div>
|
||||||
<label
|
<label
|
||||||
v-for="key in pagedKeys"
|
v-for="key in pageKeys"
|
||||||
:key="key.key_id"
|
:key="key.key_id"
|
||||||
class="flex items-center gap-2.5 px-3 py-2 border-b last:border-b-0 cursor-pointer hover:bg-muted/30"
|
class="flex items-center gap-2.5 px-3 py-2 border-b last:border-b-0 cursor-pointer hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:checked="selectedIdSet.has(key.key_id)"
|
:checked="selectAllFiltered || selectedIdSet.has(key.key_id)"
|
||||||
:disabled="executing"
|
:disabled="executing || selectAllFiltered"
|
||||||
@update:checked="(checked) => toggleOne(key.key_id, checked === true)"
|
@update:checked="(checked) => toggleOne(key.key_id, checked === true)"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-7 w-7"
|
||||||
:disabled="currentPage <= 1"
|
:disabled="currentPage <= 1"
|
||||||
@click="currentPage = 1"
|
@click="goToPage(1)"
|
||||||
>
|
>
|
||||||
<ChevronsLeft class="h-3.5 w-3.5" />
|
<ChevronsLeft class="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-7 w-7"
|
||||||
:disabled="currentPage <= 1"
|
:disabled="currentPage <= 1"
|
||||||
@click="currentPage -= 1"
|
@click="goToPage(currentPage - 1)"
|
||||||
>
|
>
|
||||||
<ChevronLeft class="h-3.5 w-3.5" />
|
<ChevronLeft class="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-7 w-7"
|
||||||
:disabled="currentPage >= totalPages"
|
:disabled="currentPage >= totalPages"
|
||||||
@click="currentPage += 1"
|
@click="goToPage(currentPage + 1)"
|
||||||
>
|
>
|
||||||
<ChevronRight class="h-3.5 w-3.5" />
|
<ChevronRight class="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
class="h-7 w-7"
|
||||||
:disabled="currentPage >= totalPages"
|
:disabled="currentPage >= totalPages"
|
||||||
@click="currentPage = totalPages"
|
@click="goToPage(totalPages)"
|
||||||
>
|
>
|
||||||
<ChevronsRight class="h-3.5 w-3.5" />
|
<ChevronsRight class="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 shrink-0"
|
class="h-8 w-8 shrink-0"
|
||||||
:disabled="executing || selectedKeyIds.length === 0 || loading"
|
:disabled="executing || selectedCount === 0 || loading"
|
||||||
@click="executeAction"
|
@click="executeAction"
|
||||||
>
|
>
|
||||||
<Play
|
<Play
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { Dialog, Button, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Checkbox, Badge } from '@/components/ui'
|
import { Dialog, Button, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Checkbox, Badge } from '@/components/ui'
|
||||||
import { MultiSelect } from '@/components/common'
|
import { MultiSelect } from '@/components/common'
|
||||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||||
@@ -266,11 +266,17 @@ import { RefreshCw, Play, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight
|
|||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { listPoolKeys, batchActionPoolKeys, getPoolBatchDeleteTask, type PoolKeyDetail } from '@/api/endpoints/pool'
|
import {
|
||||||
import { refreshProviderQuota } from '@/api/endpoints/keys'
|
listPoolKeys,
|
||||||
|
batchActionPoolKeys,
|
||||||
|
getPoolBatchDeleteTask,
|
||||||
|
resolvePoolKeySelection,
|
||||||
|
type PoolKeyDetail,
|
||||||
|
type PoolKeySelectionItem,
|
||||||
|
} from '@/api/endpoints/pool'
|
||||||
|
import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
|
||||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
import { hasNoFiveHourLimit as hasNoFiveHourLimitByQuota, hasNoWeeklyLimit as hasNoWeeklyLimitByQuota } from '@/features/pool/utils/quota-selectors'
|
|
||||||
|
|
||||||
type QuickSelectorValue =
|
type QuickSelectorValue =
|
||||||
| 'banned'
|
| 'banned'
|
||||||
@@ -285,6 +291,7 @@ type QuickSelectorValue =
|
|||||||
| 'enabled'
|
| 'enabled'
|
||||||
|
|
||||||
type BatchActionValue =
|
type BatchActionValue =
|
||||||
|
| 'export'
|
||||||
| 'delete'
|
| 'delete'
|
||||||
| 'refresh_oauth'
|
| 'refresh_oauth'
|
||||||
| 'refresh_quota'
|
| 'refresh_quota'
|
||||||
@@ -297,6 +304,7 @@ const props = defineProps<{
|
|||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
providerId: string
|
providerId: string
|
||||||
providerName?: string
|
providerName?: string
|
||||||
|
providerType?: string
|
||||||
batchConcurrency?: number | null
|
batchConcurrency?: number | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -319,6 +327,7 @@ const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }>
|
|||||||
]
|
]
|
||||||
|
|
||||||
const ACTION_OPTIONS: Array<{ value: BatchActionValue; label: string }> = [
|
const ACTION_OPTIONS: Array<{ value: BatchActionValue; label: string }> = [
|
||||||
|
{ value: 'export', label: '导出凭据' },
|
||||||
{ value: 'delete', label: '删除账号' },
|
{ value: 'delete', label: '删除账号' },
|
||||||
{ value: 'refresh_oauth', label: '刷新 OAuth' },
|
{ value: 'refresh_oauth', label: '刷新 OAuth' },
|
||||||
{ value: 'refresh_quota', label: '刷新额度' },
|
{ value: 'refresh_quota', label: '刷新额度' },
|
||||||
@@ -334,8 +343,11 @@ const proxyNodesStore = useProxyNodesStore()
|
|||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const executing = ref(false)
|
const executing = ref(false)
|
||||||
const allKeys = ref<PoolKeyDetail[]>([])
|
const pageKeys = ref<PoolKeyDetail[]>([])
|
||||||
|
const filteredTotal = ref(0)
|
||||||
const selectedKeyIds = ref<string[]>([])
|
const selectedKeyIds = ref<string[]>([])
|
||||||
|
const knownKeysById = ref<Record<string, PoolKeyDetail>>({})
|
||||||
|
const selectAllFiltered = ref(false)
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const selectedAction = ref<BatchActionValue>('delete')
|
const selectedAction = ref<BatchActionValue>('delete')
|
||||||
const proxyNodeIdForAction = ref('')
|
const proxyNodeIdForAction = ref('')
|
||||||
@@ -345,53 +357,61 @@ const progressDone = ref(0)
|
|||||||
const progressLabel = ref('')
|
const progressLabel = ref('')
|
||||||
const activeQuickSelectors = ref<QuickSelectorValue[]>([])
|
const activeQuickSelectors = ref<QuickSelectorValue[]>([])
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
const SEARCH_DEBOUNCE_MS = 250
|
||||||
|
|
||||||
|
let loadRequestId = 0
|
||||||
|
let searchDebounceTimer: number | null = null
|
||||||
|
let suppressFilterWatch = false
|
||||||
|
|
||||||
const dialogDescription = computed(() => {
|
const dialogDescription = computed(() => {
|
||||||
const name = (props.providerName || '').trim()
|
const name = (props.providerName || '').trim()
|
||||||
return name ? `${name} - 选择账号并批量执行动作` : '选择账号并批量执行动作'
|
return name ? `${name} - 选择账号并批量执行动作` : '选择账号并批量执行动作'
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedIdSet = computed(() => new Set(selectedKeyIds.value))
|
const selectedIdSet = computed(() => new Set(selectedKeyIds.value))
|
||||||
|
const selectedCount = computed(() => (selectAllFiltered.value ? filteredTotal.value : selectedKeyIds.value.length))
|
||||||
const filteredKeys = computed(() => {
|
const totalPages = computed(() => Math.max(1, Math.ceil(filteredTotal.value / PAGE_SIZE)))
|
||||||
const keyword = normalizeText(searchText.value)
|
const isAllFilteredSelected = computed(() => selectAllFiltered.value && filteredTotal.value > 0)
|
||||||
if (!keyword) return allKeys.value
|
const isPartiallyFilteredSelected = computed(() => !selectAllFiltered.value && selectedKeyIds.value.length > 0)
|
||||||
return allKeys.value.filter((key) => {
|
|
||||||
const parts = [
|
|
||||||
key.key_name,
|
|
||||||
key.auth_type,
|
|
||||||
key.oauth_plan_type,
|
|
||||||
key.account_quota,
|
|
||||||
key.proxy?.node_id ? '独立代理' : '未配置代理',
|
|
||||||
key.is_active ? '已启用' : '已禁用',
|
|
||||||
key.oauth_invalid_reason,
|
|
||||||
]
|
|
||||||
return parts.some((part) => normalizeText(part).includes(keyword))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredKeys.value.length / PAGE_SIZE)))
|
|
||||||
|
|
||||||
const pagedKeys = computed(() => {
|
|
||||||
const start = (currentPage.value - 1) * PAGE_SIZE
|
|
||||||
return filteredKeys.value.slice(start, start + PAGE_SIZE)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isAllFilteredSelected = computed(() => {
|
|
||||||
if (filteredKeys.value.length === 0) return false
|
|
||||||
return filteredKeys.value.every((key) => selectedIdSet.value.has(key.key_id))
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPartiallyFilteredSelected = computed(() => {
|
|
||||||
if (filteredKeys.value.length === 0) return false
|
|
||||||
const selectedCount = filteredKeys.value.filter((key) => selectedIdSet.value.has(key.key_id)).length
|
|
||||||
return selectedCount > 0 && selectedCount < filteredKeys.value.length
|
|
||||||
})
|
|
||||||
|
|
||||||
function normalizeText(value: unknown): string {
|
function normalizeText(value: unknown): string {
|
||||||
return String(value || '').trim().toLowerCase()
|
return String(value || '').trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeFileNamePart(value: unknown, fallback: string): string {
|
||||||
|
const sanitized = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-zA-Z0-9_\-@.]/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
return sanitized || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExportTimestamp(date: Date = new Date()): string {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBatchExportFilename(): string {
|
||||||
|
const providerType = sanitizeFileNamePart(props.providerType || 'pool', 'pool')
|
||||||
|
const providerName = sanitizeFileNamePart(props.providerName || props.providerId.slice(0, 8), 'provider')
|
||||||
|
return `aether_${providerType}_${providerName}_batch_export_${formatExportTimestamp()}.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadJsonFile(data: unknown, filename: string): void {
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAuthTypeLabel(authType: string): string {
|
function normalizeAuthTypeLabel(authType: string): string {
|
||||||
const text = normalizeText(authType)
|
const text = normalizeText(authType)
|
||||||
if (text === 'oauth') return 'OAuth'
|
if (text === 'oauth') return 'OAuth'
|
||||||
@@ -411,14 +431,6 @@ function isBannedKey(key: PoolKeyDetail): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasNoFiveHourQuota(key: PoolKeyDetail): boolean {
|
|
||||||
return hasNoFiveHourLimitByQuota(key.account_quota)
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasNoWeeklyQuota(key: PoolKeyDetail): boolean {
|
|
||||||
return hasNoWeeklyLimitByQuota(key.account_quota)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOAuthInvalid(key: PoolKeyDetail): boolean {
|
function isOAuthInvalid(key: PoolKeyDetail): boolean {
|
||||||
if (normalizeText(key.auth_type) !== 'oauth') return false
|
if (normalizeText(key.auth_type) !== 'oauth') return false
|
||||||
if (key.oauth_invalid_at != null || normalizeText(key.oauth_invalid_reason)) return true
|
if (key.oauth_invalid_at != null || normalizeText(key.oauth_invalid_reason)) return true
|
||||||
@@ -428,70 +440,6 @@ function isOAuthInvalid(key: PoolKeyDetail): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFreePlan(key: PoolKeyDetail): boolean {
|
|
||||||
return normalizeText(key.oauth_plan_type).includes('free')
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTeamPlan(key: PoolKeyDetail): boolean {
|
|
||||||
return normalizeText(key.oauth_plan_type).includes('team')
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleOne(keyId: string, checked: boolean): void {
|
|
||||||
const set = new Set(selectedKeyIds.value)
|
|
||||||
if (checked) set.add(keyId)
|
|
||||||
else set.delete(keyId)
|
|
||||||
selectedKeyIds.value = [...set]
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectFiltered(checked: boolean | 'indeterminate'): void {
|
|
||||||
const shouldSelect = checked === true
|
|
||||||
const set = new Set(selectedKeyIds.value)
|
|
||||||
if (shouldSelect) {
|
|
||||||
for (const key of filteredKeys.value) set.add(key.key_id)
|
|
||||||
} else {
|
|
||||||
for (const key of filteredKeys.value) set.delete(key.key_id)
|
|
||||||
}
|
|
||||||
selectedKeyIds.value = [...set]
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesSelector(key: PoolKeyDetail, selector: QuickSelectorValue): boolean {
|
|
||||||
if (selector === 'banned') return isBannedKey(key)
|
|
||||||
if (selector === 'no_5h_limit') return hasNoFiveHourQuota(key)
|
|
||||||
if (selector === 'no_weekly_limit') return hasNoWeeklyQuota(key)
|
|
||||||
if (selector === 'plan_free') return isFreePlan(key)
|
|
||||||
if (selector === 'plan_team') return isTeamPlan(key)
|
|
||||||
if (selector === 'oauth_invalid') return isOAuthInvalid(key)
|
|
||||||
if (selector === 'proxy_unset') return !key.proxy?.node_id
|
|
||||||
if (selector === 'proxy_set') return Boolean(key.proxy?.node_id)
|
|
||||||
if (selector === 'disabled') return !key.is_active
|
|
||||||
if (selector === 'enabled') return key.is_active
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function onQuickSelectChange(values: string[]): void {
|
|
||||||
activeQuickSelectors.value = values as QuickSelectorValue[]
|
|
||||||
applyQuickSelectors()
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeQuickSelector(selector: QuickSelectorValue): void {
|
|
||||||
const idx = activeQuickSelectors.value.indexOf(selector)
|
|
||||||
if (idx >= 0) {
|
|
||||||
activeQuickSelectors.value.splice(idx, 1)
|
|
||||||
applyQuickSelectors()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyQuickSelectors(): void {
|
|
||||||
if (activeQuickSelectors.value.length === 0) {
|
|
||||||
selectedKeyIds.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const matched = allKeys.value.filter((key) =>
|
|
||||||
activeQuickSelectors.value.some((sel) => matchesSelector(key, sel))
|
|
||||||
)
|
|
||||||
selectedKeyIds.value = matched.map((key) => key.key_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(value: string): string {
|
function formatRelativeTime(value: string): string {
|
||||||
const ts = new Date(value).getTime()
|
const ts = new Date(value).getTime()
|
||||||
if (!Number.isFinite(ts)) return '-'
|
if (!Number.isFinite(ts)) return '-'
|
||||||
@@ -503,73 +451,149 @@ function formatRelativeTime(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shortenQuota(raw: string): string {
|
function shortenQuota(raw: string): string {
|
||||||
// "周剩余 0.0%(5天3小时后重置)|5H剩余100.0%(5小时0分钟后重置)"
|
return raw.split('|').map((segment) => {
|
||||||
// -> "周0.0% 5d3h | 5H100.0% 5h"
|
let value = segment.trim()
|
||||||
return raw.split('|').map((seg) => {
|
value = value.replace(/剩余\s*/g, '')
|
||||||
let s = seg.trim()
|
value = value.replace(/%/g, '%')
|
||||||
s = s.replace(/剩余\s*/g, '')
|
value = value.replace(/[((]\s*(\d+)\s*天\s*(\d+)\s*小时.*?[))]/g, ' $1d$2h')
|
||||||
s = s.replace(/%/g, '%')
|
value = value.replace(/[((]\s*(\d+)\s*小时\s*(\d+)\s*分钟.*?[))]/g, ' $1h$2m')
|
||||||
s = s.replace(/[((]\s*(\d+)\s*天\s*(\d+)\s*小时.*?[))]/g, ' $1d$2h')
|
value = value.replace(/[((]\s*(\d+)\s*小时.*?[))]/g, ' $1h')
|
||||||
s = s.replace(/[((]\s*(\d+)\s*小时\s*(\d+)\s*分钟.*?[))]/g, ' $1h$2m')
|
value = value.replace(/[((]\s*(\d+)\s*分钟.*?[))]/g, ' $1m')
|
||||||
s = s.replace(/[((]\s*(\d+)\s*小时.*?[))]/g, ' $1h')
|
value = value.replace(/[((]\s*(\d+)\s*天.*?[))]/g, ' $1d')
|
||||||
s = s.replace(/[((]\s*(\d+)\s*分钟.*?[))]/g, ' $1m')
|
value = value.replace(/[((].*?[))]/g, '')
|
||||||
s = s.replace(/[((]\s*(\d+)\s*天.*?[))]/g, ' $1d')
|
return value.trim()
|
||||||
s = s.replace(/[((].*?[))]/g, '')
|
|
||||||
return s.trim()
|
|
||||||
}).join(' | ')
|
}).join(' | ')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAllKeys(): Promise<void> {
|
function clearSearchDebounce(): void {
|
||||||
|
if (searchDebounceTimer !== null) {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberPageKeys(keys: PoolKeyDetail[]): void {
|
||||||
|
if (keys.length === 0) return
|
||||||
|
const next = { ...knownKeysById.value }
|
||||||
|
for (const key of keys) {
|
||||||
|
next[key.key_id] = key
|
||||||
|
}
|
||||||
|
knownKeysById.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSelection(clearKnown = false): void {
|
||||||
|
selectAllFiltered.value = false
|
||||||
|
selectedKeyIds.value = []
|
||||||
|
if (clearKnown) knownKeysById.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelectionFilters(): { search?: string; quick_selectors?: string[] } {
|
||||||
|
const search = searchText.value.trim()
|
||||||
|
const quickSelectors = activeQuickSelectors.value.map((value) => String(value))
|
||||||
|
return {
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
...(quickSelectors.length > 0 ? { quick_selectors: quickSelectors } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKeysPage(): Promise<void> {
|
||||||
if (!props.providerId) {
|
if (!props.providerId) {
|
||||||
allKeys.value = []
|
pageKeys.value = []
|
||||||
selectedKeyIds.value = []
|
filteredTotal.value = 0
|
||||||
|
resetSelection(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestId = ++loadRequestId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const startedAt = performance.now()
|
const startedAt = performance.now()
|
||||||
let fetchedPages = 0
|
|
||||||
let total = 0
|
|
||||||
let loadedCount = 0
|
|
||||||
let ok = false
|
let ok = false
|
||||||
try {
|
try {
|
||||||
const pageSize = 200
|
const res = await listPoolKeys(props.providerId, {
|
||||||
let page = 1
|
page: currentPage.value,
|
||||||
const collected: PoolKeyDetail[] = []
|
page_size: PAGE_SIZE,
|
||||||
|
status: 'all',
|
||||||
|
search: searchText.value.trim() || undefined,
|
||||||
|
quick_selectors: activeQuickSelectors.value,
|
||||||
|
search_scope: 'full',
|
||||||
|
})
|
||||||
|
if (requestId !== loadRequestId) return
|
||||||
|
|
||||||
while (page <= 50) {
|
pageKeys.value = Array.isArray(res.keys) ? res.keys : []
|
||||||
const res = await listPoolKeys(props.providerId, {
|
filteredTotal.value = Number(res.total || 0)
|
||||||
page,
|
rememberPageKeys(pageKeys.value)
|
||||||
page_size: pageSize,
|
|
||||||
status: 'all',
|
|
||||||
})
|
|
||||||
fetchedPages = page
|
|
||||||
const keys = Array.isArray(res.keys) ? res.keys : []
|
|
||||||
collected.push(...keys)
|
|
||||||
total = Number(res.total || 0)
|
|
||||||
if (keys.length < pageSize || collected.length >= total) break
|
|
||||||
page += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
allKeys.value = collected
|
|
||||||
loadedCount = collected.length
|
|
||||||
const validIds = new Set(collected.map((key) => key.key_id))
|
|
||||||
selectedKeyIds.value = selectedKeyIds.value.filter((id) => validIds.has(id))
|
|
||||||
ok = true
|
ok = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (requestId !== loadRequestId) return
|
||||||
|
pageKeys.value = []
|
||||||
|
filteredTotal.value = 0
|
||||||
showError(parseApiError(err, '加载账号列表失败'))
|
showError(parseApiError(err, '加载账号列表失败'))
|
||||||
allKeys.value = []
|
|
||||||
selectedKeyIds.value = []
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (requestId === loadRequestId) {
|
||||||
// eslint-disable-next-line no-console
|
loading.value = false
|
||||||
console.info('[PoolAccountBatchDialog] loadAllKeys timing', {
|
// eslint-disable-next-line no-console
|
||||||
providerId: props.providerId,
|
console.info('[PoolAccountBatchDialog] loadKeysPage timing', {
|
||||||
ok,
|
providerId: props.providerId,
|
||||||
fetchedPages,
|
page: currentPage.value,
|
||||||
total,
|
pageSize: PAGE_SIZE,
|
||||||
loadedCount,
|
search: searchText.value.trim(),
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
quickSelectors: activeQuickSelectors.value,
|
||||||
})
|
total: filteredTotal.value,
|
||||||
|
count: pageKeys.value.length,
|
||||||
|
ok,
|
||||||
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestFilteredReload(debounceMs = 0): void {
|
||||||
|
if (!props.modelValue) return
|
||||||
|
clearSearchDebounce()
|
||||||
|
resetSelection()
|
||||||
|
lastResultMessage.value = ''
|
||||||
|
const run = () => {
|
||||||
|
searchDebounceTimer = null
|
||||||
|
currentPage.value = 1
|
||||||
|
void loadKeysPage()
|
||||||
|
}
|
||||||
|
if (debounceMs > 0) {
|
||||||
|
searchDebounceTimer = window.setTimeout(run, debounceMs)
|
||||||
|
} else {
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToPage(page: number): Promise<void> {
|
||||||
|
const nextPage = Math.min(Math.max(1, page), totalPages.value)
|
||||||
|
currentPage.value = nextPage
|
||||||
|
await loadKeysPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOne(keyId: string, checked: boolean): void {
|
||||||
|
const set = new Set(selectedKeyIds.value)
|
||||||
|
if (checked) set.add(keyId)
|
||||||
|
else set.delete(keyId)
|
||||||
|
selectedKeyIds.value = [...set]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectFiltered(checked: boolean | 'indeterminate'): void {
|
||||||
|
selectAllFiltered.value = checked === true
|
||||||
|
if (selectAllFiltered.value) {
|
||||||
|
selectedKeyIds.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQuickSelectChange(values: string[]): void {
|
||||||
|
activeQuickSelectors.value = values as QuickSelectorValue[]
|
||||||
|
requestFilteredReload()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQuickSelector(selector: QuickSelectorValue): void {
|
||||||
|
const idx = activeQuickSelectors.value.indexOf(selector)
|
||||||
|
if (idx >= 0) {
|
||||||
|
activeQuickSelectors.value.splice(idx, 1)
|
||||||
|
requestFilteredReload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,29 +622,42 @@ async function pollDeleteTask(
|
|||||||
return { status: 'failed', deleted: 0 }
|
return { status: 'failed', deleted: 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await new Promise((r) => setTimeout(r, DELETE_POLL_INTERVAL_MS))
|
await new Promise((resolve) => setTimeout(resolve, DELETE_POLL_INTERVAL_MS))
|
||||||
}
|
}
|
||||||
return { status: 'failed', deleted: 0 }
|
return { status: 'failed', deleted: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveSelectedItems(): Promise<PoolKeySelectionItem[]> {
|
||||||
|
if (!props.providerId) return []
|
||||||
|
|
||||||
|
if (selectAllFiltered.value) {
|
||||||
|
progressLabel.value = '正在解析筛选结果...'
|
||||||
|
const result = await resolvePoolKeySelection(props.providerId, buildSelectionFilters())
|
||||||
|
return Array.isArray(result.items) ? result.items : []
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedKeyIds.value.map((keyId) => {
|
||||||
|
const key = knownKeysById.value[keyId]
|
||||||
|
return {
|
||||||
|
key_id: keyId,
|
||||||
|
key_name: key?.key_name || '',
|
||||||
|
auth_type: key?.auth_type || 'api_key',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function executeAction(): Promise<void> {
|
async function executeAction(): Promise<void> {
|
||||||
if (executing.value) return
|
if (executing.value) return
|
||||||
if (selectedKeyIds.value.length === 0) {
|
if (selectedCount.value === 0) {
|
||||||
warning('请先选择账号')
|
warning('请先选择账号')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedMap = new Set(selectedKeyIds.value)
|
const requestedCount = selectedCount.value
|
||||||
const selectedKeys = allKeys.value.filter((key) => selectedMap.has(key.key_id))
|
|
||||||
if (selectedKeys.length === 0) {
|
|
||||||
warning('未找到可执行账号,请刷新列表重试')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedAction.value === 'delete') {
|
if (selectedAction.value === 'delete') {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: '删除账号',
|
title: '删除账号',
|
||||||
message: `将删除 ${selectedKeys.length} 个账号,操作不可恢复,是否继续?`,
|
message: `将删除 ${requestedCount} 个账号,操作不可恢复,是否继续?`,
|
||||||
confirmText: '确认删除',
|
confirmText: '确认删除',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
@@ -636,17 +673,29 @@ async function executeAction(): Promise<void> {
|
|||||||
let successCount = 0
|
let successCount = 0
|
||||||
let failedCount = 0
|
let failedCount = 0
|
||||||
let skippedCount = 0
|
let skippedCount = 0
|
||||||
|
let resolvedCount = 0
|
||||||
const actionStartedAt = performance.now()
|
const actionStartedAt = performance.now()
|
||||||
let actionPhaseMs = 0
|
let actionPhaseMs = 0
|
||||||
let reloadPhaseMs = 0
|
let reloadPhaseMs = 0
|
||||||
|
|
||||||
const actionLabel = ACTION_OPTIONS.find((a) => a.value === selectedAction.value)?.label || '执行'
|
const actionLabel = ACTION_OPTIONS.find((item) => item.value === selectedAction.value)?.label || '执行'
|
||||||
progressDone.value = 0
|
progressDone.value = 0
|
||||||
progressTotal.value = selectedKeys.length
|
progressTotal.value = 0
|
||||||
progressLabel.value = `正在${actionLabel}...`
|
progressLabel.value = selectAllFiltered.value ? '正在解析筛选结果...' : `正在${actionLabel}...`
|
||||||
lastResultMessage.value = ''
|
lastResultMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const selectedKeys = await resolveSelectedItems()
|
||||||
|
resolvedCount = selectedKeys.length
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
warning('未找到可执行账号,请刷新列表重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
progressDone.value = 0
|
||||||
|
progressTotal.value = selectedKeys.length
|
||||||
|
progressLabel.value = `正在${actionLabel}...`
|
||||||
|
|
||||||
if (selectedAction.value === 'refresh_quota') {
|
if (selectedAction.value === 'refresh_quota') {
|
||||||
const targetIds = selectedKeys.map((key) => key.key_id)
|
const targetIds = selectedKeys.map((key) => key.key_id)
|
||||||
const BATCH_SIZE = 20
|
const BATCH_SIZE = 20
|
||||||
@@ -668,8 +717,47 @@ async function executeAction(): Promise<void> {
|
|||||||
|
|
||||||
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
||||||
}
|
}
|
||||||
|
} else if (selectedAction.value === 'export') {
|
||||||
|
const exportableKeys = selectedKeys.filter((key) => normalizeText(key.auth_type) === 'oauth')
|
||||||
|
const exportedEntries: Array<Record<string, unknown> | null> = Array.from({ length: exportableKeys.length }, () => null)
|
||||||
|
|
||||||
|
skippedCount += selectedKeys.length - exportableKeys.length
|
||||||
|
progressDone.value = 0
|
||||||
|
progressTotal.value = exportableKeys.length
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
progressLabel.value = `正在${actionLabel}...(跳过 ${skippedCount} 个非 OAuth 账号)`
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = 0
|
||||||
|
const CONCURRENCY = props.batchConcurrency || 8
|
||||||
|
const runNext = async (): Promise<void> => {
|
||||||
|
while (cursor < exportableKeys.length) {
|
||||||
|
const idx = cursor++
|
||||||
|
const key = exportableKeys[idx]
|
||||||
|
try {
|
||||||
|
exportedEntries[idx] = await exportKey(key.key_id)
|
||||||
|
successCount += 1
|
||||||
|
} catch (err) {
|
||||||
|
failedCount += 1
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[PoolAccountBatchDialog] export failed (${key.key_id}):`, err)
|
||||||
|
} finally {
|
||||||
|
progressDone.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(CONCURRENCY, exportableKeys.length) },
|
||||||
|
() => runNext(),
|
||||||
|
)
|
||||||
|
await Promise.all(workers)
|
||||||
|
|
||||||
|
const exportedData = exportedEntries.filter((item): item is Record<string, unknown> => item !== null)
|
||||||
|
if (exportedData.length > 0) {
|
||||||
|
downloadJsonFile(exportedData, getBatchExportFilename())
|
||||||
|
}
|
||||||
} else if (selectedAction.value === 'delete') {
|
} else if (selectedAction.value === 'delete') {
|
||||||
// 删除走异步任务模式:提交后轮询进度
|
|
||||||
const targetIds = selectedKeys.map((key) => key.key_id)
|
const targetIds = selectedKeys.map((key) => key.key_id)
|
||||||
const BATCH_SIZE = 2000
|
const BATCH_SIZE = 2000
|
||||||
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
|
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
|
||||||
@@ -688,7 +776,6 @@ async function executeAction(): Promise<void> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.task_id) {
|
if (result.task_id) {
|
||||||
// 异步任务:轮询进度
|
|
||||||
progressLabel.value = `正在${actionLabel}...(后台执行中)`
|
progressLabel.value = `正在${actionLabel}...(后台执行中)`
|
||||||
const taskResult = await pollDeleteTask(props.providerId, result.task_id, i)
|
const taskResult = await pollDeleteTask(props.providerId, result.task_id, i)
|
||||||
successCount += taskResult.deleted
|
successCount += taskResult.deleted
|
||||||
@@ -738,7 +825,6 @@ async function executeAction(): Promise<void> {
|
|||||||
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// refresh_oauth: 逐个并发调用(涉及外部 OAuth 令牌刷新)
|
|
||||||
const CONCURRENCY = props.batchConcurrency || 8
|
const CONCURRENCY = props.batchConcurrency || 8
|
||||||
const tasks: Array<() => Promise<'success' | 'skip'>> = []
|
const tasks: Array<() => Promise<'success' | 'skip'>> = []
|
||||||
for (const key of selectedKeys) {
|
for (const key of selectedKeys) {
|
||||||
@@ -769,24 +855,22 @@ async function executeAction(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastResultMessage.value = `执行完成:成功 ${successCount},失败 ${failedCount},跳过 ${skippedCount}`
|
lastResultMessage.value = `执行完成:成功 ${successCount},失败 ${failedCount},跳过 ${skippedCount}`
|
||||||
if (failedCount > 0) warning(lastResultMessage.value)
|
if (failedCount > 0 || (selectedAction.value === 'export' && successCount === 0)) warning(lastResultMessage.value)
|
||||||
else success(lastResultMessage.value)
|
else success(lastResultMessage.value)
|
||||||
|
|
||||||
actionPhaseMs = performance.now() - actionStartedAt
|
actionPhaseMs = performance.now() - actionStartedAt
|
||||||
const reloadStartedAt = performance.now()
|
if (selectedAction.value !== 'export') {
|
||||||
if (selectedAction.value === 'delete' && successCount > 0 && failedCount === 0) {
|
const reloadStartedAt = performance.now()
|
||||||
// 全部删除成功:直接从本地列表移除,不做全量重载
|
if (selectedAction.value === 'delete' && successCount > 0) {
|
||||||
const deletedIds = new Set(selectedKeys.map((key) => key.key_id))
|
resetSelection(true)
|
||||||
allKeys.value = allKeys.value.filter((key) => !deletedIds.has(key.key_id))
|
}
|
||||||
selectedKeyIds.value = []
|
await loadKeysPage()
|
||||||
} else {
|
if (pageKeys.value.length === 0 && filteredTotal.value > 0 && currentPage.value > totalPages.value) {
|
||||||
const previousSelection = new Set(selectedKeyIds.value)
|
await goToPage(totalPages.value)
|
||||||
await loadAllKeys()
|
}
|
||||||
const existingIds = new Set(allKeys.value.map((key) => key.key_id))
|
reloadPhaseMs = performance.now() - reloadStartedAt
|
||||||
selectedKeyIds.value = [...previousSelection].filter((id) => existingIds.has(id))
|
emit('changed')
|
||||||
}
|
}
|
||||||
reloadPhaseMs = performance.now() - reloadStartedAt
|
|
||||||
emit('changed')
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(parseApiError(err, '批量操作失败'))
|
showError(parseApiError(err, '批量操作失败'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -794,7 +878,8 @@ async function executeAction(): Promise<void> {
|
|||||||
console.info('[PoolAccountBatchDialog] executeAction timing', {
|
console.info('[PoolAccountBatchDialog] executeAction timing', {
|
||||||
providerId: props.providerId,
|
providerId: props.providerId,
|
||||||
action: selectedAction.value,
|
action: selectedAction.value,
|
||||||
selectedCount: selectedKeys.length,
|
requestedCount,
|
||||||
|
resolvedCount,
|
||||||
successCount,
|
successCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
skippedCount,
|
skippedCount,
|
||||||
@@ -809,15 +894,29 @@ async function executeAction(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(searchText, () => {
|
||||||
|
if (suppressFilterWatch || !props.modelValue) return
|
||||||
|
requestFilteredReload(SEARCH_DEBOUNCE_MS)
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(open) => {
|
(open) => {
|
||||||
if (!open) return
|
if (!open) {
|
||||||
|
clearSearchDebounce()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suppressFilterWatch = true
|
||||||
searchText.value = ''
|
searchText.value = ''
|
||||||
lastResultMessage.value = ''
|
lastResultMessage.value = ''
|
||||||
activeQuickSelectors.value = []
|
activeQuickSelectors.value = []
|
||||||
|
resetSelection(true)
|
||||||
|
filteredTotal.value = 0
|
||||||
|
pageKeys.value = []
|
||||||
|
currentPage.value = 1
|
||||||
|
suppressFilterWatch = false
|
||||||
proxyNodesStore.ensureLoaded()
|
proxyNodesStore.ensureLoaded()
|
||||||
loadAllKeys()
|
void loadKeysPage()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -825,12 +924,18 @@ watch(
|
|||||||
() => props.providerId,
|
() => props.providerId,
|
||||||
(newId, oldId) => {
|
(newId, oldId) => {
|
||||||
if (!props.modelValue || !newId || newId === oldId) return
|
if (!props.modelValue || !newId || newId === oldId) return
|
||||||
selectedKeyIds.value = []
|
clearSearchDebounce()
|
||||||
loadAllKeys()
|
suppressFilterWatch = true
|
||||||
|
resetSelection(true)
|
||||||
|
filteredTotal.value = 0
|
||||||
|
pageKeys.value = []
|
||||||
|
currentPage.value = 1
|
||||||
|
suppressFilterWatch = false
|
||||||
|
void loadKeysPage()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(filteredKeys, () => {
|
onBeforeUnmount(() => {
|
||||||
currentPage.value = 1
|
clearSearchDebounce()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1061,6 +1061,7 @@
|
|||||||
v-model="showAccountBatchDialog"
|
v-model="showAccountBatchDialog"
|
||||||
:provider-id="selectedProviderId"
|
:provider-id="selectedProviderId"
|
||||||
:provider-name="selectedProviderData?.name || ''"
|
:provider-name="selectedProviderData?.name || ''"
|
||||||
|
:provider-type="selectedProviderData?.provider_type || selectedProviderType"
|
||||||
:batch-concurrency="selectedProviderConfig?.batch_concurrency"
|
:batch-concurrency="selectedProviderConfig?.batch_concurrency"
|
||||||
@changed="handleAccountBatchChanged"
|
@changed="handleAccountBatchChanged"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -52,6 +53,9 @@ from .schemas import (
|
|||||||
BatchImportRequest,
|
BatchImportRequest,
|
||||||
BatchImportResponse,
|
BatchImportResponse,
|
||||||
PoolKeyDetail,
|
PoolKeyDetail,
|
||||||
|
PoolKeySelectionItem,
|
||||||
|
PoolKeySelectionRequest,
|
||||||
|
PoolKeySelectionResponse,
|
||||||
PoolKeysPageResponse,
|
PoolKeysPageResponse,
|
||||||
PoolOverviewItem,
|
PoolOverviewItem,
|
||||||
PoolOverviewResponse,
|
PoolOverviewResponse,
|
||||||
@@ -117,6 +121,10 @@ async def list_pool_keys(
|
|||||||
page_size: int = Query(50, ge=1, le=200),
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
search: str = Query("", description="Search by key name"),
|
search: str = Query("", description="Search by key name"),
|
||||||
status: str = Query("all", description="all/active/cooldown/inactive"),
|
status: str = Query("all", description="all/active/cooldown/inactive"),
|
||||||
|
quick_selectors: str = Query(
|
||||||
|
"", description="Comma-separated quick selectors for batch dialog"
|
||||||
|
),
|
||||||
|
search_scope: str = Query("name", description="Search scope: name/full"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> PoolKeysPageResponse:
|
) -> PoolKeysPageResponse:
|
||||||
"""Server-side paginated account list for a pool-enabled provider."""
|
"""Server-side paginated account list for a pool-enabled provider."""
|
||||||
@@ -126,6 +134,8 @@ async def list_pool_keys(
|
|||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
search=search,
|
search=search,
|
||||||
status=status,
|
status=status,
|
||||||
|
quick_selectors=quick_selectors.split(",") if quick_selectors else [],
|
||||||
|
search_scope=search_scope,
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|
||||||
@@ -455,6 +465,18 @@ async def batch_action_keys(
|
|||||||
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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{provider_id}/keys/resolve-selection", response_model=PoolKeySelectionResponse)
|
||||||
|
async def resolve_pool_key_selection(
|
||||||
|
provider_id: str,
|
||||||
|
body: PoolKeySelectionRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> PoolKeySelectionResponse:
|
||||||
|
"""Resolve all key ids matching the current batch dialog filters."""
|
||||||
|
adapter = AdminResolvePoolKeySelectionAdapter(provider_id=provider_id, body=body)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{provider_id}/keys/batch-delete-task/{task_id}",
|
"/{provider_id}/keys/batch-delete-task/{task_id}",
|
||||||
response_model=BatchDeleteTaskResponse,
|
response_model=BatchDeleteTaskResponse,
|
||||||
@@ -626,6 +648,511 @@ class AdminPoolOverviewAdapter(AdminApiAdapter):
|
|||||||
return PoolOverviewResponse(items=items)
|
return PoolOverviewResponse(items=items)
|
||||||
|
|
||||||
|
|
||||||
|
_FULL_SEARCH_SCOPE = "full"
|
||||||
|
_ALLOWED_POOL_KEY_QUICK_SELECTORS = frozenset(
|
||||||
|
{
|
||||||
|
"banned",
|
||||||
|
"no_5h_limit",
|
||||||
|
"no_weekly_limit",
|
||||||
|
"plan_free",
|
||||||
|
"plan_team",
|
||||||
|
"oauth_invalid",
|
||||||
|
"proxy_unset",
|
||||||
|
"proxy_set",
|
||||||
|
"disabled",
|
||||||
|
"enabled",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_ACCOUNT_BANNED_CODES = frozenset({"account_banned", "account_forbidden", "account_blocked"})
|
||||||
|
_BANNED_REASON_PATTERN = re.compile(r"(banned|forbidden|blocked|suspend|封|禁|受限)")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_batch_text(value: Any) -> str:
|
||||||
|
return str(value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_pool_search_scope(value: Any) -> str:
|
||||||
|
return _FULL_SEARCH_SCOPE if _normalize_batch_text(value) == _FULL_SEARCH_SCOPE else "name"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_pool_quick_selectors(values: Any) -> list[str]:
|
||||||
|
if values is None:
|
||||||
|
return []
|
||||||
|
if isinstance(values, str):
|
||||||
|
raw_items = values.split(",")
|
||||||
|
elif isinstance(values, (list, tuple, set)):
|
||||||
|
raw_items = [str(item) for item in values]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in raw_items:
|
||||||
|
item = _normalize_batch_text(raw)
|
||||||
|
if not item or item not in _ALLOWED_POOL_KEY_QUICK_SELECTORS or item in seen:
|
||||||
|
continue
|
||||||
|
seen.add(item)
|
||||||
|
normalized.append(item)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_quota_segment(value: Any) -> str:
|
||||||
|
return str(value or "").strip().lower().replace("%", "%")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_quota_segments(account_quota: Any) -> list[str]:
|
||||||
|
return [
|
||||||
|
segment
|
||||||
|
for segment in (
|
||||||
|
_normalize_quota_segment(part) for part in str(account_quota or "").split("|")
|
||||||
|
)
|
||||||
|
if segment
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _quota_segment_has_depleted_keyword(segment: str) -> bool:
|
||||||
|
return bool(
|
||||||
|
re.search(r"(无额度|额度不足|已耗尽|耗尽|depleted|exhausted|insufficient)", segment)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _quota_segment_has_zero_remaining_text(segment: str) -> bool:
|
||||||
|
return bool(re.search(r"剩余\s*0(?:\.0+)?(?!\d)", segment))
|
||||||
|
|
||||||
|
|
||||||
|
def _quota_segment_has_zero_ratio(segment: str) -> bool:
|
||||||
|
for match in re.finditer(r"(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)", segment):
|
||||||
|
numerator = float(match.group(1))
|
||||||
|
denominator = float(match.group(2))
|
||||||
|
if numerator == 0 and denominator > 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _quota_segment_has_zero_percent(segment: str) -> bool:
|
||||||
|
for match in re.finditer(r"(\d+(?:\.\d+)?)\s*%", segment):
|
||||||
|
if float(match.group(1)) == 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_depleted_quota_segment(segment: str) -> bool:
|
||||||
|
return (
|
||||||
|
_quota_segment_has_depleted_keyword(segment)
|
||||||
|
or _quota_segment_has_zero_remaining_text(segment)
|
||||||
|
or _quota_segment_has_zero_ratio(segment)
|
||||||
|
or _quota_segment_has_zero_percent(segment)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_no_five_hour_limit(account_quota: Any) -> bool:
|
||||||
|
return any(
|
||||||
|
_is_depleted_quota_segment(segment)
|
||||||
|
for segment in _get_quota_segments(account_quota)
|
||||||
|
if "5h" in segment or "5小时" in segment
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_no_weekly_limit(account_quota: Any) -> bool:
|
||||||
|
return any(
|
||||||
|
_is_depleted_quota_segment(segment)
|
||||||
|
for segment in _get_quota_segments(account_quota)
|
||||||
|
if "周" in segment or "weekly" in segment or "week" in segment
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_is_oauth_invalid(detail: PoolKeyDetail) -> bool:
|
||||||
|
if _normalize_batch_text(detail.auth_type) != "oauth":
|
||||||
|
return False
|
||||||
|
if detail.oauth_invalid_at is not None or _normalize_batch_text(detail.oauth_invalid_reason):
|
||||||
|
return True
|
||||||
|
expires_at = detail.oauth_expires_at
|
||||||
|
return isinstance(expires_at, int) and expires_at > 0 and expires_at <= int(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_is_banned(detail: PoolKeyDetail) -> bool:
|
||||||
|
reason = _normalize_batch_text(detail.oauth_invalid_reason)
|
||||||
|
if reason and _BANNED_REASON_PATTERN.search(reason):
|
||||||
|
return True
|
||||||
|
for item in detail.scheduling_reasons or []:
|
||||||
|
code = _normalize_batch_text(getattr(item, "code", ""))
|
||||||
|
if code in _ACCOUNT_BANNED_CODES:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_has_proxy(detail: PoolKeyDetail) -> bool:
|
||||||
|
proxy = detail.proxy if isinstance(detail.proxy, dict) else None
|
||||||
|
return bool(_normalize_batch_text((proxy or {}).get("node_id")))
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_pool_key_search(
|
||||||
|
detail: PoolKeyDetail,
|
||||||
|
search: str,
|
||||||
|
*,
|
||||||
|
search_scope: str = _FULL_SEARCH_SCOPE,
|
||||||
|
) -> bool:
|
||||||
|
keyword = _normalize_batch_text(search)
|
||||||
|
if not keyword:
|
||||||
|
return True
|
||||||
|
if search_scope != _FULL_SEARCH_SCOPE:
|
||||||
|
return keyword in _normalize_batch_text(detail.key_name)
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
detail.key_name,
|
||||||
|
detail.auth_type,
|
||||||
|
detail.oauth_plan_type,
|
||||||
|
detail.account_quota,
|
||||||
|
"独立代理" if _detail_has_proxy(detail) else "未配置代理",
|
||||||
|
"已启用" if detail.is_active else "已禁用",
|
||||||
|
detail.oauth_invalid_reason,
|
||||||
|
]
|
||||||
|
return any(keyword in _normalize_batch_text(part) for part in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_pool_key_quick_selector(detail: PoolKeyDetail, selector: str) -> bool:
|
||||||
|
if selector == "banned":
|
||||||
|
return _detail_is_banned(detail)
|
||||||
|
if selector == "no_5h_limit":
|
||||||
|
return _has_no_five_hour_limit(detail.account_quota)
|
||||||
|
if selector == "no_weekly_limit":
|
||||||
|
return _has_no_weekly_limit(detail.account_quota)
|
||||||
|
if selector == "plan_free":
|
||||||
|
return "free" in _normalize_batch_text(detail.oauth_plan_type)
|
||||||
|
if selector == "plan_team":
|
||||||
|
return "team" in _normalize_batch_text(detail.oauth_plan_type)
|
||||||
|
if selector == "oauth_invalid":
|
||||||
|
return _detail_is_oauth_invalid(detail)
|
||||||
|
if selector == "proxy_unset":
|
||||||
|
return not _detail_has_proxy(detail)
|
||||||
|
if selector == "proxy_set":
|
||||||
|
return _detail_has_proxy(detail)
|
||||||
|
if selector == "disabled":
|
||||||
|
return not detail.is_active
|
||||||
|
if selector == "enabled":
|
||||||
|
return detail.is_active
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_pool_key_details(
|
||||||
|
details: list[PoolKeyDetail],
|
||||||
|
*,
|
||||||
|
search: str = "",
|
||||||
|
quick_selectors: list[str] | None = None,
|
||||||
|
search_scope: str = _FULL_SEARCH_SCOPE,
|
||||||
|
require_cooldown: bool = False,
|
||||||
|
) -> list[PoolKeyDetail]:
|
||||||
|
normalized_selectors = _normalize_pool_quick_selectors(quick_selectors)
|
||||||
|
normalized_search_scope = _normalize_pool_search_scope(search_scope)
|
||||||
|
filtered: list[PoolKeyDetail] = []
|
||||||
|
for detail in details:
|
||||||
|
if require_cooldown and not detail.cooldown_reason:
|
||||||
|
continue
|
||||||
|
if not _matches_pool_key_search(detail, search, search_scope=normalized_search_scope):
|
||||||
|
continue
|
||||||
|
if normalized_selectors and not any(
|
||||||
|
_matches_pool_key_quick_selector(detail, selector) for selector in normalized_selectors
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
filtered.append(detail)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pool_keys_base_query(db: Session, provider_id: str) -> Any:
|
||||||
|
return (
|
||||||
|
db.query(ProviderAPIKey)
|
||||||
|
.options(
|
||||||
|
load_only(
|
||||||
|
ProviderAPIKey.id,
|
||||||
|
ProviderAPIKey.provider_id,
|
||||||
|
ProviderAPIKey.name,
|
||||||
|
ProviderAPIKey.auth_type,
|
||||||
|
ProviderAPIKey.auth_config,
|
||||||
|
ProviderAPIKey.is_active,
|
||||||
|
ProviderAPIKey.expires_at,
|
||||||
|
ProviderAPIKey.oauth_invalid_at,
|
||||||
|
ProviderAPIKey.oauth_invalid_reason,
|
||||||
|
ProviderAPIKey.api_formats,
|
||||||
|
ProviderAPIKey.rate_multipliers,
|
||||||
|
ProviderAPIKey.internal_priority,
|
||||||
|
ProviderAPIKey.rpm_limit,
|
||||||
|
ProviderAPIKey.cache_ttl_minutes,
|
||||||
|
ProviderAPIKey.max_probe_interval_minutes,
|
||||||
|
ProviderAPIKey.note,
|
||||||
|
ProviderAPIKey.allowed_models,
|
||||||
|
ProviderAPIKey.capabilities,
|
||||||
|
ProviderAPIKey.auto_fetch_models,
|
||||||
|
ProviderAPIKey.locked_models,
|
||||||
|
ProviderAPIKey.model_include_patterns,
|
||||||
|
ProviderAPIKey.model_exclude_patterns,
|
||||||
|
ProviderAPIKey.proxy,
|
||||||
|
ProviderAPIKey.fingerprint,
|
||||||
|
ProviderAPIKey.health_by_format,
|
||||||
|
ProviderAPIKey.circuit_breaker_by_format,
|
||||||
|
ProviderAPIKey.request_count,
|
||||||
|
ProviderAPIKey.total_tokens,
|
||||||
|
ProviderAPIKey.total_cost_usd,
|
||||||
|
ProviderAPIKey.last_used_at,
|
||||||
|
ProviderAPIKey.created_at,
|
||||||
|
ProviderAPIKey.upstream_metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter(ProviderAPIKey.provider_id == provider_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_pool_key_order(query: Any) -> Any:
|
||||||
|
return query.order_by(
|
||||||
|
ProviderAPIKey.internal_priority.asc(),
|
||||||
|
ProviderAPIKey.created_at.asc(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _serialize_pool_key_details(
|
||||||
|
*,
|
||||||
|
keys: list[ProviderAPIKey],
|
||||||
|
pid: str,
|
||||||
|
provider_type: str,
|
||||||
|
pcfg: Any,
|
||||||
|
) -> tuple[list[PoolKeyDetail], float, float]:
|
||||||
|
redis_state_ms = 0.0
|
||||||
|
key_ids = [str(k.id) for k in keys]
|
||||||
|
sticky_counts: dict[str, int] = {kid: 0 for kid in key_ids}
|
||||||
|
|
||||||
|
if key_ids:
|
||||||
|
_lru_coro = (
|
||||||
|
pool_redis.get_lru_scores(pid, key_ids)
|
||||||
|
if pcfg and pcfg.lru_enabled
|
||||||
|
else asyncio.sleep(0, result={})
|
||||||
|
)
|
||||||
|
_latency_coro = (
|
||||||
|
pool_redis.batch_get_latency_avgs(pid, key_ids, pcfg.latency_window_seconds)
|
||||||
|
if pcfg and pcfg.scheduling_mode == "multi_score"
|
||||||
|
else asyncio.sleep(0, result={})
|
||||||
|
)
|
||||||
|
_cost_coro = (
|
||||||
|
pool_redis.batch_get_cost_totals(pid, key_ids, pcfg.cost_window_seconds)
|
||||||
|
if pcfg
|
||||||
|
else asyncio.sleep(0, result={})
|
||||||
|
)
|
||||||
|
redis_started_at = time.perf_counter()
|
||||||
|
(
|
||||||
|
cooldowns,
|
||||||
|
cooldown_ttls,
|
||||||
|
lru_scores,
|
||||||
|
latency_avgs,
|
||||||
|
cost_totals,
|
||||||
|
) = await asyncio.gather(
|
||||||
|
pool_redis.batch_get_cooldowns(pid, key_ids),
|
||||||
|
pool_redis.batch_get_cooldown_ttls(pid, key_ids),
|
||||||
|
_lru_coro,
|
||||||
|
_latency_coro,
|
||||||
|
_cost_coro,
|
||||||
|
)
|
||||||
|
redis_state_ms += (time.perf_counter() - redis_started_at) * 1000.0
|
||||||
|
else:
|
||||||
|
cooldowns, cooldown_ttls, lru_scores, latency_avgs, cost_totals = (
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
key_details: list[PoolKeyDetail] = []
|
||||||
|
serialize_started_at = time.perf_counter()
|
||||||
|
for k in keys:
|
||||||
|
kid = str(k.id)
|
||||||
|
cd_reason = cooldowns.get(kid)
|
||||||
|
cd_ttl = cooldown_ttls.get(kid) if cd_reason else None
|
||||||
|
health_score, any_circuit_open = _compute_health_aggregate(
|
||||||
|
getattr(k, "health_by_format", None),
|
||||||
|
getattr(k, "circuit_breaker_by_format", None),
|
||||||
|
)
|
||||||
|
cost_usage = int(cost_totals.get(kid, 0) or 0)
|
||||||
|
cost_limit = pcfg.cost_limit_per_key_tokens if pcfg else None
|
||||||
|
latency_avg_raw = latency_avgs.get(kid)
|
||||||
|
latency_avg_ms = float(latency_avg_raw) if latency_avg_raw is not None else None
|
||||||
|
account_state = resolve_pool_account_state(
|
||||||
|
provider_type=provider_type,
|
||||||
|
upstream_metadata=getattr(k, "upstream_metadata", None),
|
||||||
|
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
|
||||||
|
)
|
||||||
|
(
|
||||||
|
scheduling_status,
|
||||||
|
scheduling_reason,
|
||||||
|
scheduling_label,
|
||||||
|
scheduling_reasons,
|
||||||
|
) = _build_pool_scheduling_state(
|
||||||
|
is_active=bool(k.is_active),
|
||||||
|
account_blocked=account_state.blocked,
|
||||||
|
account_block_label=account_state.label,
|
||||||
|
account_block_reason=account_state.reason,
|
||||||
|
latency_avg_ms=latency_avg_ms,
|
||||||
|
cooldown_reason=cd_reason,
|
||||||
|
cooldown_ttl_seconds=cd_ttl,
|
||||||
|
circuit_breaker_open=any_circuit_open,
|
||||||
|
cost_window_usage=cost_usage,
|
||||||
|
cost_limit=cost_limit,
|
||||||
|
cost_soft_threshold_percent=(pcfg.cost_soft_threshold_percent if pcfg else 80),
|
||||||
|
health_score=health_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_allowed_models = getattr(k, "allowed_models", None)
|
||||||
|
allowed_models = (
|
||||||
|
[str(item) for item in raw_allowed_models]
|
||||||
|
if isinstance(raw_allowed_models, list)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
raw_locked_models = getattr(k, "locked_models", None)
|
||||||
|
locked_models = (
|
||||||
|
[str(item) for item in raw_locked_models]
|
||||||
|
if isinstance(raw_locked_models, list)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
raw_include_patterns = getattr(k, "model_include_patterns", None)
|
||||||
|
include_patterns = (
|
||||||
|
[str(item) for item in raw_include_patterns]
|
||||||
|
if isinstance(raw_include_patterns, list)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
raw_exclude_patterns = getattr(k, "model_exclude_patterns", None)
|
||||||
|
exclude_patterns = (
|
||||||
|
[str(item) for item in raw_exclude_patterns]
|
||||||
|
if isinstance(raw_exclude_patterns, list)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
capabilities = (
|
||||||
|
{str(name): bool(enabled) for name, enabled in k.capabilities.items()}
|
||||||
|
if isinstance(getattr(k, "capabilities", None), dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
rate_multipliers: dict[str, float] | None = None
|
||||||
|
if isinstance(getattr(k, "rate_multipliers", None), dict):
|
||||||
|
converted: dict[str, float] = {}
|
||||||
|
for fmt, raw_val in k.rate_multipliers.items():
|
||||||
|
num_val = _to_float(raw_val)
|
||||||
|
if num_val is None:
|
||||||
|
continue
|
||||||
|
converted[str(fmt)] = num_val
|
||||||
|
rate_multipliers = converted or None
|
||||||
|
api_formats = (
|
||||||
|
[str(fmt) for fmt in getattr(k, "api_formats", []) if isinstance(fmt, str)]
|
||||||
|
if isinstance(getattr(k, "api_formats", None), list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
key_request_count = int(getattr(k, "request_count", 0) or 0)
|
||||||
|
key_total_tokens = int(getattr(k, "total_tokens", 0) or 0)
|
||||||
|
key_total_cost_usd = _serialize_money(getattr(k, "total_cost_usd", 0.0))
|
||||||
|
key_last_used_at = getattr(k, "last_used_at", None)
|
||||||
|
oauth_auth_config = _extract_oauth_auth_config(k)
|
||||||
|
|
||||||
|
key_details.append(
|
||||||
|
PoolKeyDetail(
|
||||||
|
key_id=kid,
|
||||||
|
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=_derive_oauth_expires_at(k, auth_config=oauth_auth_config),
|
||||||
|
oauth_invalid_at=(
|
||||||
|
int(k.oauth_invalid_at.timestamp())
|
||||||
|
if getattr(k, "oauth_invalid_at", None)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
health_score=health_score,
|
||||||
|
circuit_breaker_open=any_circuit_open,
|
||||||
|
api_formats=api_formats,
|
||||||
|
rate_multipliers=rate_multipliers,
|
||||||
|
internal_priority=int(getattr(k, "internal_priority", 50) or 50),
|
||||||
|
rpm_limit=getattr(k, "rpm_limit", None),
|
||||||
|
cache_ttl_minutes=(
|
||||||
|
v if (v := getattr(k, "cache_ttl_minutes", None)) is not None else 5
|
||||||
|
),
|
||||||
|
max_probe_interval_minutes=(
|
||||||
|
v if (v := getattr(k, "max_probe_interval_minutes", None)) is not None else 32
|
||||||
|
),
|
||||||
|
note=getattr(k, "note", None),
|
||||||
|
allowed_models=allowed_models,
|
||||||
|
capabilities=capabilities,
|
||||||
|
auto_fetch_models=bool(getattr(k, "auto_fetch_models", False)),
|
||||||
|
locked_models=locked_models,
|
||||||
|
model_include_patterns=include_patterns,
|
||||||
|
model_exclude_patterns=exclude_patterns,
|
||||||
|
proxy=_mask_proxy_password(getattr(k, "proxy", None)),
|
||||||
|
fingerprint=(
|
||||||
|
getattr(k, "fingerprint", None)
|
||||||
|
if isinstance(getattr(k, "fingerprint", None), dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
account_quota=_build_account_quota(
|
||||||
|
provider_type,
|
||||||
|
getattr(k, "upstream_metadata", None),
|
||||||
|
),
|
||||||
|
cooldown_reason=cd_reason,
|
||||||
|
cooldown_ttl_seconds=cd_ttl,
|
||||||
|
cost_window_usage=cost_usage,
|
||||||
|
cost_limit=cost_limit,
|
||||||
|
request_count=key_request_count,
|
||||||
|
total_tokens=key_total_tokens,
|
||||||
|
total_cost_usd=key_total_cost_usd,
|
||||||
|
sticky_sessions=sticky_counts.get(kid, 0),
|
||||||
|
lru_score=lru_scores.get(kid),
|
||||||
|
created_at=(k.created_at.isoformat() if getattr(k, "created_at", None) else None),
|
||||||
|
last_used_at=(key_last_used_at.isoformat() if key_last_used_at else None),
|
||||||
|
scheduling_status=scheduling_status,
|
||||||
|
scheduling_reason=scheduling_reason,
|
||||||
|
scheduling_label=scheduling_label,
|
||||||
|
scheduling_reasons=scheduling_reasons,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
serialize_ms = (time.perf_counter() - serialize_started_at) * 1000.0
|
||||||
|
return key_details, redis_state_ms, serialize_ms
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_POOL_KEY_SCAN_LIMIT = 5000
|
||||||
|
_RESOLVE_SELECTION_SCAN_LIMIT = 10000
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_filtered_pool_key_details(
|
||||||
|
*,
|
||||||
|
query: Any,
|
||||||
|
pid: str,
|
||||||
|
provider_type: str,
|
||||||
|
pcfg: Any,
|
||||||
|
search: str,
|
||||||
|
quick_selectors: list[str],
|
||||||
|
search_scope: str,
|
||||||
|
require_cooldown: bool,
|
||||||
|
max_scan: int = _DEFAULT_POOL_KEY_SCAN_LIMIT,
|
||||||
|
) -> tuple[list[PoolKeyDetail], float, float, float]:
|
||||||
|
keys_query_started_at = time.perf_counter()
|
||||||
|
ordered = _apply_pool_key_order(query)
|
||||||
|
keys = ordered.limit(max_scan).all() if max_scan > 0 else ordered.all()
|
||||||
|
keys_query_ms = (time.perf_counter() - keys_query_started_at) * 1000.0
|
||||||
|
key_details, redis_state_ms, serialize_ms = await _serialize_pool_key_details(
|
||||||
|
keys=keys,
|
||||||
|
pid=pid,
|
||||||
|
provider_type=provider_type,
|
||||||
|
pcfg=pcfg,
|
||||||
|
)
|
||||||
|
filtered_details = _filter_pool_key_details(
|
||||||
|
key_details,
|
||||||
|
search=search,
|
||||||
|
quick_selectors=quick_selectors,
|
||||||
|
search_scope=search_scope,
|
||||||
|
require_cooldown=require_cooldown,
|
||||||
|
)
|
||||||
|
return filtered_details, keys_query_ms, redis_state_ms, serialize_ms
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AdminListPoolKeysAdapter(AdminApiAdapter):
|
class AdminListPoolKeysAdapter(AdminApiAdapter):
|
||||||
provider_id: str = ""
|
provider_id: str = ""
|
||||||
@@ -633,6 +1160,8 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
|||||||
page_size: int = 50
|
page_size: int = 50
|
||||||
search: str = ""
|
search: str = ""
|
||||||
status: str = "all"
|
status: str = "all"
|
||||||
|
quick_selectors: list[str] = field(default_factory=list)
|
||||||
|
search_scope: str = "name"
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
@@ -649,50 +1178,11 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
|||||||
pcfg = parse_pool_config(getattr(provider, "config", None))
|
pcfg = parse_pool_config(getattr(provider, "config", None))
|
||||||
pid = str(provider.id)
|
pid = str(provider.id)
|
||||||
provider_type = str(getattr(provider, "provider_type", "custom") or "custom")
|
provider_type = str(getattr(provider, "provider_type", "custom") or "custom")
|
||||||
|
normalized_quick_selectors = _normalize_pool_quick_selectors(self.quick_selectors)
|
||||||
|
normalized_search_scope = _normalize_pool_search_scope(self.search_scope)
|
||||||
|
|
||||||
# Base query
|
q = _build_pool_keys_base_query(db, pid)
|
||||||
q = (
|
if self.search and normalized_search_scope != _FULL_SEARCH_SCOPE:
|
||||||
db.query(ProviderAPIKey)
|
|
||||||
.options(
|
|
||||||
load_only(
|
|
||||||
ProviderAPIKey.id,
|
|
||||||
ProviderAPIKey.provider_id,
|
|
||||||
ProviderAPIKey.name,
|
|
||||||
ProviderAPIKey.auth_type,
|
|
||||||
ProviderAPIKey.auth_config,
|
|
||||||
ProviderAPIKey.is_active,
|
|
||||||
ProviderAPIKey.expires_at,
|
|
||||||
ProviderAPIKey.oauth_invalid_at,
|
|
||||||
ProviderAPIKey.oauth_invalid_reason,
|
|
||||||
ProviderAPIKey.api_formats,
|
|
||||||
ProviderAPIKey.rate_multipliers,
|
|
||||||
ProviderAPIKey.internal_priority,
|
|
||||||
ProviderAPIKey.rpm_limit,
|
|
||||||
ProviderAPIKey.cache_ttl_minutes,
|
|
||||||
ProviderAPIKey.max_probe_interval_minutes,
|
|
||||||
ProviderAPIKey.note,
|
|
||||||
ProviderAPIKey.allowed_models,
|
|
||||||
ProviderAPIKey.capabilities,
|
|
||||||
ProviderAPIKey.auto_fetch_models,
|
|
||||||
ProviderAPIKey.locked_models,
|
|
||||||
ProviderAPIKey.model_include_patterns,
|
|
||||||
ProviderAPIKey.model_exclude_patterns,
|
|
||||||
ProviderAPIKey.proxy,
|
|
||||||
ProviderAPIKey.fingerprint,
|
|
||||||
ProviderAPIKey.health_by_format,
|
|
||||||
ProviderAPIKey.circuit_breaker_by_format,
|
|
||||||
ProviderAPIKey.request_count,
|
|
||||||
ProviderAPIKey.total_tokens,
|
|
||||||
ProviderAPIKey.total_cost_usd,
|
|
||||||
ProviderAPIKey.last_used_at,
|
|
||||||
ProviderAPIKey.created_at,
|
|
||||||
ProviderAPIKey.upstream_metadata,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.filter(ProviderAPIKey.provider_id == pid)
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.search:
|
|
||||||
escaped = self.search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
escaped = self.search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
q = q.filter(ProviderAPIKey.name.ilike(f"%{escaped}%"))
|
q = q.filter(ProviderAPIKey.name.ilike(f"%{escaped}%"))
|
||||||
|
|
||||||
@@ -700,253 +1190,43 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
|||||||
q = q.filter(ProviderAPIKey.is_active.is_(True))
|
q = q.filter(ProviderAPIKey.is_active.is_(True))
|
||||||
elif self.status == "inactive":
|
elif self.status == "inactive":
|
||||||
q = q.filter(ProviderAPIKey.is_active.is_(False))
|
q = q.filter(ProviderAPIKey.is_active.is_(False))
|
||||||
# "cooldown" filtering is done post-query (Redis state)
|
|
||||||
|
|
||||||
total = 0
|
total = 0
|
||||||
|
if (
|
||||||
# For cooldown filtering we need to fetch all, then filter, then paginate.
|
normalized_quick_selectors
|
||||||
# Limit scan range to avoid loading the entire table into memory.
|
or self.status == "cooldown"
|
||||||
if self.status == "cooldown":
|
or (bool(self.search) and normalized_search_scope == _FULL_SEARCH_SCOPE)
|
||||||
_max_scan = 2000
|
):
|
||||||
keys_query_started_at = time.perf_counter()
|
filtered_details, keys_query_ms, redis_state_ms, serialize_ms = (
|
||||||
all_keys = (
|
await _resolve_filtered_pool_key_details(
|
||||||
q.order_by(
|
query=q,
|
||||||
ProviderAPIKey.internal_priority.asc(),
|
pid=pid,
|
||||||
ProviderAPIKey.created_at.asc(),
|
provider_type=provider_type,
|
||||||
|
pcfg=pcfg,
|
||||||
|
search=self.search,
|
||||||
|
quick_selectors=normalized_quick_selectors,
|
||||||
|
search_scope=normalized_search_scope,
|
||||||
|
require_cooldown=self.status == "cooldown",
|
||||||
)
|
)
|
||||||
.limit(_max_scan)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
keys_query_ms = (time.perf_counter() - keys_query_started_at) * 1000.0
|
total = len(filtered_details)
|
||||||
key_ids = [str(k.id) for k in all_keys]
|
|
||||||
cooldown_scan_started_at = time.perf_counter()
|
|
||||||
cooldowns = await pool_redis.batch_get_cooldowns(pid, key_ids) if key_ids else {}
|
|
||||||
redis_state_ms += (time.perf_counter() - cooldown_scan_started_at) * 1000.0
|
|
||||||
all_keys = [k for k in all_keys if cooldowns.get(str(k.id)) is not None]
|
|
||||||
total = len(all_keys)
|
|
||||||
offset = (self.page - 1) * self.page_size
|
offset = (self.page - 1) * self.page_size
|
||||||
keys = all_keys[offset : offset + self.page_size]
|
key_details = filtered_details[offset : offset + self.page_size]
|
||||||
else:
|
else:
|
||||||
count_query_started_at = time.perf_counter()
|
count_query_started_at = time.perf_counter()
|
||||||
total = int(q.with_entities(func.count(ProviderAPIKey.id)).scalar() or 0)
|
total = int(q.with_entities(func.count(ProviderAPIKey.id)).scalar() or 0)
|
||||||
count_query_ms = (time.perf_counter() - count_query_started_at) * 1000.0
|
count_query_ms = (time.perf_counter() - count_query_started_at) * 1000.0
|
||||||
offset = (self.page - 1) * self.page_size
|
offset = (self.page - 1) * self.page_size
|
||||||
keys_query_started_at = time.perf_counter()
|
keys_query_started_at = time.perf_counter()
|
||||||
keys = (
|
keys = _apply_pool_key_order(q).offset(offset).limit(self.page_size).all()
|
||||||
q.order_by(
|
|
||||||
ProviderAPIKey.internal_priority.asc(),
|
|
||||||
ProviderAPIKey.created_at.asc(),
|
|
||||||
)
|
|
||||||
.offset(offset)
|
|
||||||
.limit(self.page_size)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
keys_query_ms = (time.perf_counter() - keys_query_started_at) * 1000.0
|
keys_query_ms = (time.perf_counter() - keys_query_started_at) * 1000.0
|
||||||
|
key_details, extra_redis_ms, serialize_ms = await _serialize_pool_key_details(
|
||||||
# Batch fetch Redis state (parallel where possible)
|
keys=keys,
|
||||||
key_ids = [str(k.id) for k in keys]
|
pid=pid,
|
||||||
# Sticky session counts are no longer fetched from Redis to reduce
|
|
||||||
# round-trips; the field is kept at 0 for schema compatibility.
|
|
||||||
sticky_counts: dict[str, int] = {kid: 0 for kid in key_ids}
|
|
||||||
if key_ids:
|
|
||||||
_lru_coro = (
|
|
||||||
pool_redis.get_lru_scores(pid, key_ids)
|
|
||||||
if pcfg and pcfg.lru_enabled
|
|
||||||
else asyncio.sleep(0, result={})
|
|
||||||
)
|
|
||||||
_latency_coro = (
|
|
||||||
pool_redis.batch_get_latency_avgs(pid, key_ids, pcfg.latency_window_seconds)
|
|
||||||
if pcfg and pcfg.scheduling_mode == "multi_score"
|
|
||||||
else asyncio.sleep(0, result={})
|
|
||||||
)
|
|
||||||
_cost_coro = (
|
|
||||||
pool_redis.batch_get_cost_totals(pid, key_ids, pcfg.cost_window_seconds)
|
|
||||||
if pcfg
|
|
||||||
else asyncio.sleep(0, result={})
|
|
||||||
)
|
|
||||||
redis_started_at = time.perf_counter()
|
|
||||||
(
|
|
||||||
cooldowns,
|
|
||||||
cooldown_ttls,
|
|
||||||
lru_scores,
|
|
||||||
latency_avgs,
|
|
||||||
cost_totals,
|
|
||||||
) = await asyncio.gather(
|
|
||||||
pool_redis.batch_get_cooldowns(pid, key_ids),
|
|
||||||
pool_redis.batch_get_cooldown_ttls(pid, key_ids),
|
|
||||||
_lru_coro,
|
|
||||||
_latency_coro,
|
|
||||||
_cost_coro,
|
|
||||||
)
|
|
||||||
redis_state_ms += (time.perf_counter() - redis_started_at) * 1000.0
|
|
||||||
else:
|
|
||||||
cooldowns, cooldown_ttls, lru_scores, latency_avgs, cost_totals = (
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
key_details: list[PoolKeyDetail] = []
|
|
||||||
serialize_started_at = time.perf_counter()
|
|
||||||
for k in keys:
|
|
||||||
kid = str(k.id)
|
|
||||||
cd_reason = cooldowns.get(kid)
|
|
||||||
cd_ttl = cooldown_ttls.get(kid) if cd_reason else None
|
|
||||||
health_score, any_circuit_open = _compute_health_aggregate(
|
|
||||||
getattr(k, "health_by_format", None),
|
|
||||||
getattr(k, "circuit_breaker_by_format", None),
|
|
||||||
)
|
|
||||||
cost_usage = int(cost_totals.get(kid, 0) or 0)
|
|
||||||
cost_limit = pcfg.cost_limit_per_key_tokens if pcfg else None
|
|
||||||
latency_avg_raw = latency_avgs.get(kid)
|
|
||||||
latency_avg_ms = float(latency_avg_raw) if latency_avg_raw is not None else None
|
|
||||||
account_state = resolve_pool_account_state(
|
|
||||||
provider_type=provider_type,
|
provider_type=provider_type,
|
||||||
upstream_metadata=getattr(k, "upstream_metadata", None),
|
pcfg=pcfg,
|
||||||
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
|
|
||||||
)
|
)
|
||||||
(
|
redis_state_ms += extra_redis_ms
|
||||||
scheduling_status,
|
|
||||||
scheduling_reason,
|
|
||||||
scheduling_label,
|
|
||||||
scheduling_reasons,
|
|
||||||
) = _build_pool_scheduling_state(
|
|
||||||
is_active=bool(k.is_active),
|
|
||||||
account_blocked=account_state.blocked,
|
|
||||||
account_block_label=account_state.label,
|
|
||||||
account_block_reason=account_state.reason,
|
|
||||||
latency_avg_ms=latency_avg_ms,
|
|
||||||
cooldown_reason=cd_reason,
|
|
||||||
cooldown_ttl_seconds=cd_ttl,
|
|
||||||
circuit_breaker_open=any_circuit_open,
|
|
||||||
cost_window_usage=cost_usage,
|
|
||||||
cost_limit=cost_limit,
|
|
||||||
cost_soft_threshold_percent=(pcfg.cost_soft_threshold_percent if pcfg else 80),
|
|
||||||
health_score=health_score,
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_allowed_models = getattr(k, "allowed_models", None)
|
|
||||||
allowed_models = (
|
|
||||||
[str(item) for item in raw_allowed_models]
|
|
||||||
if isinstance(raw_allowed_models, list)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
raw_locked_models = getattr(k, "locked_models", None)
|
|
||||||
locked_models = (
|
|
||||||
[str(item) for item in raw_locked_models]
|
|
||||||
if isinstance(raw_locked_models, list)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
raw_include_patterns = getattr(k, "model_include_patterns", None)
|
|
||||||
include_patterns = (
|
|
||||||
[str(item) for item in raw_include_patterns]
|
|
||||||
if isinstance(raw_include_patterns, list)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
raw_exclude_patterns = getattr(k, "model_exclude_patterns", None)
|
|
||||||
exclude_patterns = (
|
|
||||||
[str(item) for item in raw_exclude_patterns]
|
|
||||||
if isinstance(raw_exclude_patterns, list)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
capabilities = (
|
|
||||||
{str(name): bool(enabled) for name, enabled in k.capabilities.items()}
|
|
||||||
if isinstance(getattr(k, "capabilities", None), dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
rate_multipliers: dict[str, float] | None = None
|
|
||||||
if isinstance(getattr(k, "rate_multipliers", None), dict):
|
|
||||||
converted: dict[str, float] = {}
|
|
||||||
for fmt, raw_val in k.rate_multipliers.items():
|
|
||||||
num_val = _to_float(raw_val)
|
|
||||||
if num_val is None:
|
|
||||||
continue
|
|
||||||
converted[str(fmt)] = num_val
|
|
||||||
rate_multipliers = converted or None
|
|
||||||
api_formats = (
|
|
||||||
[str(fmt) for fmt in getattr(k, "api_formats", []) if isinstance(fmt, str)]
|
|
||||||
if isinstance(getattr(k, "api_formats", None), list)
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
key_request_count = int(getattr(k, "request_count", 0) or 0)
|
|
||||||
key_total_tokens = int(getattr(k, "total_tokens", 0) or 0)
|
|
||||||
key_total_cost_usd = _serialize_money(getattr(k, "total_cost_usd", 0.0))
|
|
||||||
key_last_used_at = getattr(k, "last_used_at", None)
|
|
||||||
oauth_auth_config = _extract_oauth_auth_config(k)
|
|
||||||
|
|
||||||
key_details.append(
|
|
||||||
PoolKeyDetail(
|
|
||||||
key_id=kid,
|
|
||||||
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=_derive_oauth_expires_at(k, auth_config=oauth_auth_config),
|
|
||||||
oauth_invalid_at=(
|
|
||||||
int(k.oauth_invalid_at.timestamp())
|
|
||||||
if getattr(k, "oauth_invalid_at", None)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
oauth_invalid_reason=getattr(k, "oauth_invalid_reason", None),
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
health_score=health_score,
|
|
||||||
circuit_breaker_open=any_circuit_open,
|
|
||||||
api_formats=api_formats,
|
|
||||||
rate_multipliers=rate_multipliers,
|
|
||||||
internal_priority=int(getattr(k, "internal_priority", 50) or 50),
|
|
||||||
rpm_limit=getattr(k, "rpm_limit", None),
|
|
||||||
cache_ttl_minutes=(
|
|
||||||
v if (v := getattr(k, "cache_ttl_minutes", None)) is not None else 5
|
|
||||||
),
|
|
||||||
max_probe_interval_minutes=(
|
|
||||||
v
|
|
||||||
if (v := getattr(k, "max_probe_interval_minutes", None)) is not None
|
|
||||||
else 32
|
|
||||||
),
|
|
||||||
note=getattr(k, "note", None),
|
|
||||||
allowed_models=allowed_models,
|
|
||||||
capabilities=capabilities,
|
|
||||||
auto_fetch_models=bool(getattr(k, "auto_fetch_models", False)),
|
|
||||||
locked_models=locked_models,
|
|
||||||
model_include_patterns=include_patterns,
|
|
||||||
model_exclude_patterns=exclude_patterns,
|
|
||||||
proxy=_mask_proxy_password(getattr(k, "proxy", None)),
|
|
||||||
fingerprint=(
|
|
||||||
getattr(k, "fingerprint", None)
|
|
||||||
if isinstance(getattr(k, "fingerprint", None), dict)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
account_quota=_build_account_quota(
|
|
||||||
provider_type,
|
|
||||||
getattr(k, "upstream_metadata", None),
|
|
||||||
),
|
|
||||||
cooldown_reason=cd_reason,
|
|
||||||
cooldown_ttl_seconds=cd_ttl,
|
|
||||||
cost_window_usage=cost_usage,
|
|
||||||
cost_limit=cost_limit,
|
|
||||||
request_count=key_request_count,
|
|
||||||
total_tokens=key_total_tokens,
|
|
||||||
total_cost_usd=key_total_cost_usd,
|
|
||||||
sticky_sessions=sticky_counts.get(kid, 0),
|
|
||||||
lru_score=lru_scores.get(kid),
|
|
||||||
created_at=(
|
|
||||||
k.created_at.isoformat() if getattr(k, "created_at", None) else None
|
|
||||||
),
|
|
||||||
last_used_at=(key_last_used_at.isoformat() if key_last_used_at else None),
|
|
||||||
scheduling_status=scheduling_status,
|
|
||||||
scheduling_reason=scheduling_reason,
|
|
||||||
scheduling_label=scheduling_label,
|
|
||||||
scheduling_reasons=scheduling_reasons,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
serialize_ms = (time.perf_counter() - serialize_started_at) * 1000.0
|
|
||||||
|
|
||||||
total_ms = (time.perf_counter() - started_at) * 1000.0
|
total_ms = (time.perf_counter() - started_at) * 1000.0
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -972,6 +1252,47 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminResolvePoolKeySelectionAdapter(AdminApiAdapter):
|
||||||
|
provider_id: str = ""
|
||||||
|
body: PoolKeySelectionRequest = field(default_factory=PoolKeySelectionRequest)
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||||
|
if not provider:
|
||||||
|
raise NotFoundException("Provider not found", "provider")
|
||||||
|
|
||||||
|
pcfg = parse_pool_config(getattr(provider, "config", None))
|
||||||
|
pid = str(provider.id)
|
||||||
|
provider_type = str(getattr(provider, "provider_type", "custom") or "custom")
|
||||||
|
q = _build_pool_keys_base_query(db, pid)
|
||||||
|
|
||||||
|
filtered_details, _, _, _ = await _resolve_filtered_pool_key_details(
|
||||||
|
query=q,
|
||||||
|
pid=pid,
|
||||||
|
provider_type=provider_type,
|
||||||
|
pcfg=pcfg,
|
||||||
|
search=self.body.search,
|
||||||
|
quick_selectors=_normalize_pool_quick_selectors(self.body.quick_selectors),
|
||||||
|
search_scope=_FULL_SEARCH_SCOPE,
|
||||||
|
require_cooldown=False,
|
||||||
|
max_scan=_RESOLVE_SELECTION_SCAN_LIMIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
return PoolKeySelectionResponse(
|
||||||
|
total=len(filtered_details),
|
||||||
|
items=[
|
||||||
|
PoolKeySelectionItem(
|
||||||
|
key_id=detail.key_id,
|
||||||
|
key_name=detail.key_name,
|
||||||
|
auth_type=detail.auth_type,
|
||||||
|
)
|
||||||
|
for detail in filtered_details
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AdminBatchImportKeysAdapter(AdminApiAdapter):
|
class AdminBatchImportKeysAdapter(AdminApiAdapter):
|
||||||
provider_id: str = ""
|
provider_id: str = ""
|
||||||
|
|||||||
@@ -158,6 +158,27 @@ class BatchImportResponse(BaseModel):
|
|||||||
errors: list[BatchImportError] = Field(default_factory=list)
|
errors: list[BatchImportError] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Batch selection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class PoolKeySelectionRequest(BaseModel):
|
||||||
|
search: str = ""
|
||||||
|
quick_selectors: list[str] = Field(default_factory=list, max_length=10)
|
||||||
|
|
||||||
|
|
||||||
|
class PoolKeySelectionItem(BaseModel):
|
||||||
|
key_id: str
|
||||||
|
key_name: str = ""
|
||||||
|
auth_type: str = "api_key"
|
||||||
|
|
||||||
|
|
||||||
|
class PoolKeySelectionResponse(BaseModel):
|
||||||
|
total: int = 0
|
||||||
|
items: list[PoolKeySelectionItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Batch action
|
# Batch action
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user