feat(pool): 批量操作对话框改为服务端分页筛选,新增凭据导出功能

- 批量操作对话框从全量加载改为服务端分页+筛选,支持搜索和快捷选择器的服务端过滤
- 新增 resolve-selection API,支持"全选筛选结果"时解析完整匹配列表
- 新增批量导出凭据功能(仅 OAuth 账号),并发下载后导出为 JSON 文件
- 将快捷选择器和全文搜索的匹配逻辑从前端迁移到后端,统一复用
- 提取 pool key 序列化与过滤的公共函数,消除 AdminListPoolKeysAdapter 中的重复代码
- entrypoint.sh 增加 PostgreSQL 就绪等待,避免数据库未启动时迁移失败
This commit is contained in:
fawney19
2026-03-11 18:18:59 +08:00
parent 1a1bce3e8c
commit b45f021bba
6 changed files with 994 additions and 492 deletions

View File

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

View File

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

View File

@@ -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.05小时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>

View File

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

View File

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

View File

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