feat(pool): 号池额度主动探测、封禁自动清除、调度硬优先级与前端重构

- 新增 PoolQuotaProbeScheduler,按 probing_interval_minutes 主动探测静默 Key 额度
- pool_advanced 增加 probing_enabled / auto_remove_banned_keys 配置项
- error_handler 和 quota_service 支持封禁 Key 自动删除及缓存清理
- multi_score 策略从加权混合重构为硬优先级排序,引入 mutex_group 互斥组
- 指纹注入从 handler 层下移至 ClaudeCode envelope 层
- OAuth 批量导入支持 concurrency 并发参数
- 前端号池管理拆分高级设置/账号批量/代理设置为独立组件
- 号池总览接口精简,仅返回已启用调度的 Provider
This commit is contained in:
fawney19
2026-03-05 15:15:26 +08:00
parent fdb50a065b
commit b1be413dc0
29 changed files with 3098 additions and 852 deletions

View File

@@ -482,6 +482,10 @@ export interface PoolAdvancedConfig {
proactive_refresh_seconds?: number | null proactive_refresh_seconds?: number | null
health_policy_enabled?: boolean health_policy_enabled?: boolean
unschedulable_rules?: Array<Record<string, unknown>> | null unschedulable_rules?: Array<Record<string, unknown>> | null
batch_concurrency?: number | null
probing_enabled?: boolean
probing_interval_minutes?: number | null
auto_remove_banned_keys?: boolean
} }
export interface FailoverRuleItem { export interface FailoverRuleItem {

View File

@@ -0,0 +1,110 @@
<template>
<div class="relative">
<button
type="button"
:class="cn(
'h-9 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors gap-1',
triggerClass,
)"
:disabled="disabled"
@click="isOpen = !isOpen"
>
<span
:class="modelValue.length ? 'text-foreground' : 'text-muted-foreground'"
class="text-xs truncate"
>
{{ displayText }}
</span>
<ChevronDown
class="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform"
:class="isOpen ? 'rotate-180' : ''"
/>
</button>
<div
v-if="isOpen"
class="fixed inset-0 z-[80]"
@click.stop="isOpen = false"
/>
<div
v-if="isOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
:style="dropdownMinWidth ? { minWidth: dropdownMinWidth } : undefined"
>
<div
v-for="item in options"
:key="item.value"
class="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer text-xs"
@click="toggle(item.value)"
>
<input
type="checkbox"
:checked="modelValue.includes(item.value)"
class="h-4 w-4 rounded border-border/60 bg-card/80 text-primary shadow-sm accent-primary cursor-pointer"
@click.stop
@change="toggle(item.value)"
>
<span class="text-sm">{{ item.label }}</span>
</div>
<div
v-if="options.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
{{ emptyText }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
export interface MultiSelectOption {
value: string
label: string
}
const props = withDefaults(defineProps<{
modelValue: string[]
options: MultiSelectOption[]
placeholder?: string
emptyText?: string
triggerClass?: string
dropdownMinWidth?: string
disabled?: boolean
}>(), {
placeholder: '请选择',
emptyText: '暂无选项',
triggerClass: '',
dropdownMinWidth: undefined,
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const isOpen = ref(false)
const displayText = computed(() => {
if (props.modelValue.length === 0) return props.placeholder
if (props.modelValue.length <= 2) {
return props.modelValue
.map(v => props.options.find(o => o.value === v)?.label ?? v)
.join(', ')
}
return `已选择 ${props.modelValue.length}`
})
function toggle(value: string) {
const newValue = [...props.modelValue]
const index = newValue.indexOf(value)
if (index === -1) {
newValue.push(value)
} else {
newValue.splice(index, 1)
}
emit('update:modelValue', newValue)
}
</script>

View File

@@ -10,4 +10,5 @@ export { default as LoadingState } from './LoadingState.vue'
// 表单组件 // 表单组件
export { default as ModelMultiSelect } from './ModelMultiSelect.vue' export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
export { default as MultiSelect } from './MultiSelect.vue'
export { default as TimeRangePicker } from './TimeRangePicker.vue' export { default as TimeRangePicker } from './TimeRangePicker.vue'

View File

@@ -0,0 +1,677 @@
<template>
<Dialog
:model-value="modelValue"
title="账号批量操作"
:description="dialogDescription"
size="xl"
persistent
@update:model-value="emit('update:modelValue', $event)"
>
<div class="space-y-4">
<div class="flex items-center gap-2">
<MultiSelect
:model-value="activeQuickSelectors"
:options="QUICK_SELECT_OPTIONS"
placeholder="快捷多选"
trigger-class="h-8 w-40"
dropdown-min-width="10rem"
:disabled="loading || executing || allKeys.length === 0"
@update:model-value="onQuickSelectChange"
/>
<Input
:model-value="searchText"
placeholder="搜索账号名 / 套餐 / 额度 / 代理状态"
class="h-8 flex-1"
@update:model-value="(v) => searchText = String(v || '')"
/>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:disabled="loading || executing"
@click="loadAllKeys()"
>
<RefreshCw
class="h-3.5 w-3.5"
:class="loading ? 'animate-spin' : ''"
/>
</Button>
</div>
<div
v-if="activeQuickSelectors.length > 0"
class="flex flex-wrap gap-1"
>
<Badge
v-for="sel in activeQuickSelectors"
:key="sel"
variant="secondary"
class="text-[10px] px-1.5 py-0 h-5 cursor-pointer hover:bg-destructive/10 hover:text-destructive"
@click="removeQuickSelector(sel)"
>
{{ QUICK_SELECT_OPTIONS.find(s => s.value === sel)?.label }}
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="ml-0.5"
><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
</Badge>
</div>
<div class="flex items-center justify-between text-xs">
<div class="text-muted-foreground">
{{ allKeys.length }} 个账号筛选 {{ filteredKeys.length }} 已选 {{ selectedKeyIds.length }}
</div>
<div class="flex items-center gap-2">
<Checkbox
:checked="isAllFilteredSelected"
:indeterminate="isPartiallyFilteredSelected"
:disabled="filteredKeys.length === 0 || loading || executing"
@update:checked="toggleSelectFiltered"
/>
<span class="text-muted-foreground">全选筛选结果</span>
</div>
</div>
<div class="max-h-[380px] overflow-y-auto rounded-lg border">
<div
v-if="loading"
class="py-10 text-center text-sm text-muted-foreground"
>
正在加载账号列表...
</div>
<div
v-else-if="filteredKeys.length === 0"
class="py-10 text-center text-sm text-muted-foreground"
>
无匹配账号
</div>
<label
v-for="key in pagedKeys"
: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"
>
<Checkbox
:checked="selectedIdSet.has(key.key_id)"
:disabled="executing"
@update:checked="(checked) => toggleOne(key.key_id, checked === true)"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="text-xs font-medium truncate">{{ key.key_name || '未命名' }}</span>
<Badge
v-if="isOAuthInvalid(key)"
variant="destructive"
class="text-[10px] px-1 py-0 h-4 shrink-0"
>OAuth失效</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] px-1 py-0 h-4 shrink-0"
>{{ normalizeAuthTypeLabel(key.auth_type) }}</Badge>
<Badge
v-if="key.oauth_plan_type"
variant="outline"
class="text-[10px] px-1 py-0 h-4 shrink-0"
>{{ key.oauth_plan_type }}</Badge>
<Badge
v-if="isBannedKey(key)"
variant="destructive"
class="text-[10px] px-1 py-0 h-4 shrink-0"
>封号</Badge>
</div>
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
<span v-if="key.proxy?.node_id">独立代理</span>
<span
v-if="key.last_used_at"
class="ml-auto shrink-0"
>{{ formatRelativeTime(key.last_used_at) }}</span>
</div>
</div>
</label>
</div>
<div
v-if="totalPages > 1"
class="flex items-center justify-between text-xs text-muted-foreground"
>
<span> {{ currentPage }} / {{ totalPages }} </span>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage <= 1"
@click="currentPage = 1"
>
<ChevronsLeft class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage <= 1"
@click="currentPage -= 1"
>
<ChevronLeft class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage >= totalPages"
@click="currentPage += 1"
>
<ChevronRight class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="currentPage >= totalPages"
@click="currentPage = totalPages"
>
<ChevronsRight class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<Select v-model="selectedAction">
<SelectTrigger class="h-8 text-xs flex-1">
<SelectValue placeholder="选择动作" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in ACTION_OPTIONS"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:disabled="executing || selectedKeyIds.length === 0 || loading"
@click="executeAction"
>
<Play
class="h-3.5 w-3.5"
:class="executing ? 'animate-pulse' : ''"
/>
</Button>
</div>
<ProxyNodeSelect
v-if="selectedAction === 'set_proxy'"
:model-value="proxyNodeIdForAction"
trigger-class="h-8"
@update:model-value="(v: string) => proxyNodeIdForAction = v"
/>
</div>
<div
v-if="lastResultMessage"
class="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground"
>
{{ lastResultMessage }}
</div>
</div>
<template #footer>
<Button
variant="outline"
:disabled="executing"
@click="emit('update:modelValue', false)"
>
关闭
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Dialog, Button, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Checkbox, Badge } from '@/components/ui'
import { MultiSelect } from '@/components/common'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { RefreshCw, Play, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError } from '@/utils/errorParser'
import { listPoolKeys, type PoolKeyDetail } from '@/api/endpoints/pool'
import { deleteEndpointKey, refreshProviderQuota, updateProviderKey } from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
type QuickSelectorValue =
| 'banned'
| 'no_quota'
| 'plan_free'
| 'plan_team'
| 'oauth_invalid'
| 'proxy_unset'
| 'proxy_set'
| 'disabled'
| 'enabled'
type BatchActionValue =
| 'delete'
| 'refresh_oauth'
| 'refresh_quota'
| 'clear_proxy'
| 'set_proxy'
| 'enable'
| 'disable'
const props = defineProps<{
modelValue: boolean
providerId: string
providerName?: string
batchConcurrency?: number | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
changed: []
}>()
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
{ value: 'banned', label: '已封号' },
{ value: 'no_quota', label: '无额度' },
{ value: 'plan_free', label: '全部 Free' },
{ value: 'plan_team', label: '全部 Team' },
{ value: 'oauth_invalid', label: 'OAuth 失效' },
{ value: 'proxy_unset', label: '未配置代理' },
{ value: 'proxy_set', label: '已配置独立代理' },
{ value: 'disabled', label: '已禁用' },
{ value: 'enabled', label: '已启用' },
]
const ACTION_OPTIONS: Array<{ value: BatchActionValue; label: string }> = [
{ value: 'delete', label: '删除账号' },
{ value: 'refresh_oauth', label: '刷新 OAuth' },
{ value: 'refresh_quota', label: '刷新额度' },
{ value: 'clear_proxy', label: '清除代理' },
{ value: 'set_proxy', label: '配置代理' },
{ value: 'enable', label: '启用' },
{ value: 'disable', label: '禁用' },
]
const { success, warning, error: showError } = useToast()
const { confirm } = useConfirm()
const proxyNodesStore = useProxyNodesStore()
const loading = ref(false)
const executing = ref(false)
const allKeys = ref<PoolKeyDetail[]>([])
const selectedKeyIds = ref<string[]>([])
const searchText = ref('')
const selectedAction = ref<BatchActionValue>('delete')
const proxyNodeIdForAction = ref('')
const lastResultMessage = ref('')
const activeQuickSelectors = ref<QuickSelectorValue[]>([])
const currentPage = ref(1)
const PAGE_SIZE = 50
const dialogDescription = computed(() => {
const name = (props.providerName || '').trim()
return name ? `${name} - 选择账号并批量执行动作` : '选择账号并批量执行动作'
})
const selectedIdSet = computed(() => new Set(selectedKeyIds.value))
const filteredKeys = computed(() => {
const keyword = normalizeText(searchText.value)
if (!keyword) return allKeys.value
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 {
return String(value || '').trim().toLowerCase()
}
function normalizeAuthTypeLabel(authType: string): string {
const text = normalizeText(authType)
if (text === 'oauth') return 'OAuth'
if (text === 'service_account') return 'Service'
return 'API Key'
}
function isBannedKey(key: PoolKeyDetail): boolean {
const reason = normalizeText(key.oauth_invalid_reason)
if (reason && /(banned|forbidden|blocked|suspend|封|禁|受限)/.test(reason)) return true
if (Array.isArray(key.scheduling_reasons)) {
return key.scheduling_reasons.some((item) => {
const code = normalizeText(item.code)
return code === 'account_banned' || code === 'account_forbidden' || code === 'account_blocked'
})
}
return false
}
function hasNoQuota(key: PoolKeyDetail): boolean {
const quotaText = normalizeText(key.account_quota)
if (!quotaText) return false
if (/(无额度|额度不足|已耗尽|耗尽|depleted|exhausted|insufficient)/.test(quotaText)) return true
if (/剩余\s*0(\.0+)?/.test(quotaText)) return true
if (/\b0(\.0+)?\s*\/\s*\d/.test(quotaText)) return true
if (/\b0(\.0+)?%/.test(quotaText)) return true
return false
}
function isOAuthInvalid(key: PoolKeyDetail): boolean {
if (normalizeText(key.auth_type) !== 'oauth') return false
if (key.oauth_invalid_at != null || normalizeText(key.oauth_invalid_reason)) return true
if (typeof key.oauth_expires_at === 'number' && key.oauth_expires_at > 0) {
return key.oauth_expires_at * 1000 <= Date.now()
}
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_quota') return hasNoQuota(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 {
const ts = new Date(value).getTime()
if (!Number.isFinite(ts)) return '-'
const diff = Date.now() - ts
if (diff < 60_000) return '刚刚'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分钟前`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}小时前`
return `${Math.floor(diff / 86_400_000)}天前`
}
function shortenQuota(raw: string): string {
// "周剩余 0.0%5天3小时后重置|5H剩余100.05小时0分钟后重置"
// -> "周0.0% 5d3h | 5H100.0% 5h"
return raw.split('|').map((seg) => {
let s = seg.trim()
s = s.replace(/剩余\s*/g, '')
s = s.replace(//g, '%')
s = s.replace(/[(]\s*(\d+)\s*天\s*(\d+)\s*小时.*?[)]/g, ' $1d$2h')
s = s.replace(/[(]\s*(\d+)\s*小时\s*(\d+)\s*分钟.*?[)]/g, ' $1h$2m')
s = s.replace(/[(]\s*(\d+)\s*小时.*?[)]/g, ' $1h')
s = s.replace(/[(]\s*(\d+)\s*分钟.*?[)]/g, ' $1m')
s = s.replace(/[(]\s*(\d+)\s*天.*?[)]/g, ' $1d')
s = s.replace(/[(].*?[)]/g, '')
return s.trim()
}).join(' | ')
}
async function loadAllKeys(): Promise<void> {
if (!props.providerId) {
allKeys.value = []
selectedKeyIds.value = []
return
}
loading.value = true
try {
const pageSize = 200
let page = 1
let total = 0
const collected: PoolKeyDetail[] = []
while (page <= 50) {
const res = await listPoolKeys(props.providerId, {
page,
page_size: pageSize,
status: 'all',
})
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
const validIds = new Set(collected.map((key) => key.key_id))
selectedKeyIds.value = selectedKeyIds.value.filter((id) => validIds.has(id))
} catch (err) {
showError(parseApiError(err, '加载账号列表失败'))
allKeys.value = []
selectedKeyIds.value = []
} finally {
loading.value = false
}
}
async function executeAction(): Promise<void> {
if (executing.value) return
if (selectedKeyIds.value.length === 0) {
warning('请先选择账号')
return
}
const selectedMap = new Set(selectedKeyIds.value)
const selectedKeys = allKeys.value.filter((key) => selectedMap.has(key.key_id))
if (selectedKeys.length === 0) {
warning('未找到可执行账号,请刷新列表重试')
return
}
if (selectedAction.value === 'delete') {
const confirmed = await confirm({
title: '删除账号',
message: `将删除 ${selectedKeys.length} 个账号,操作不可恢复,是否继续?`,
confirmText: '确认删除',
variant: 'destructive',
})
if (!confirmed) return
}
if (selectedAction.value === 'set_proxy' && !proxyNodeIdForAction.value) {
warning('请先选择代理节点')
return
}
executing.value = true
let successCount = 0
let failedCount = 0
let skippedCount = 0
try {
if (selectedAction.value === 'refresh_quota') {
const targetIds = selectedKeys.map((key) => key.key_id)
const result = await refreshProviderQuota(props.providerId, targetIds)
successCount = Number(result.success || 0)
failedCount = Number(result.failed || 0)
skippedCount = Math.max(0, targetIds.length - Number(result.total || 0))
} else {
const CONCURRENCY = props.batchConcurrency || 8
const taskForKey = (key: PoolKeyDetail): (() => Promise<'success' | 'skip'>) | null => {
if (selectedAction.value === 'delete') {
return () => deleteEndpointKey(key.key_id).then(() => 'success' as const)
}
if (selectedAction.value === 'refresh_oauth') {
if (normalizeText(key.auth_type) !== 'oauth') return null
return () => refreshProviderOAuth(key.key_id).then(() => 'success' as const)
}
if (selectedAction.value === 'clear_proxy') {
return () => updateProviderKey(key.key_id, { proxy: null }).then(() => 'success' as const)
}
if (selectedAction.value === 'set_proxy') {
return () => updateProviderKey(key.key_id, {
proxy: { node_id: proxyNodeIdForAction.value, enabled: true },
}).then(() => 'success' as const)
}
if (selectedAction.value === 'enable') {
return () => updateProviderKey(key.key_id, { is_active: true }).then(() => 'success' as const)
}
if (selectedAction.value === 'disable') {
return () => updateProviderKey(key.key_id, { is_active: false }).then(() => 'success' as const)
}
return null
}
const tasks: Array<() => Promise<'success' | 'skip'>> = []
for (const key of selectedKeys) {
const task = taskForKey(key)
if (task) tasks.push(task)
else skippedCount += 1
}
// 并发执行,限制并发数
let cursor = 0
const runNext = async (): Promise<void> => {
while (cursor < tasks.length) {
const idx = cursor++
try {
await tasks[idx]()
successCount += 1
} catch {
failedCount += 1
}
}
}
const workers = Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, () => runNext())
await Promise.all(workers)
}
lastResultMessage.value = `执行完成:成功 ${successCount},失败 ${failedCount},跳过 ${skippedCount}`
if (failedCount > 0) warning(lastResultMessage.value)
else success(lastResultMessage.value)
const shouldClearSelection = selectedAction.value === 'delete'
const previousSelection = new Set(selectedKeyIds.value)
await loadAllKeys()
if (shouldClearSelection) {
selectedKeyIds.value = []
} else {
const existingIds = new Set(allKeys.value.map((key) => key.key_id))
selectedKeyIds.value = [...previousSelection].filter((id) => existingIds.has(id))
}
emit('changed')
} catch (err) {
showError(parseApiError(err, '批量操作失败'))
} finally {
executing.value = false
}
}
watch(
() => props.modelValue,
(open) => {
if (!open) return
searchText.value = ''
lastResultMessage.value = ''
activeQuickSelectors.value = []
proxyNodesStore.ensureLoaded()
loadAllKeys()
},
)
watch(
() => props.providerId,
(newId, oldId) => {
if (!props.modelValue || !newId || newId === oldId) return
selectedKeyIds.value = []
loadAllKeys()
},
)
watch(filteredKeys, () => {
currentPage.value = 1
})
</script>

View File

@@ -0,0 +1,490 @@
<template>
<Dialog
:model-value="modelValue"
title="高级设置"
description="冷却、健康、成本控制与其他高级参数"
size="lg"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="space-y-4">
<!-- Cooldown & Health -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
冷却与健康
</h3>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">健康策略</span>
<p class="text-xs text-muted-foreground">
按上游错误自动冷却并跳过账号
</p>
</div>
<Switch
:model-value="form.health_policy_enabled"
@update:model-value="(v: boolean) => form.health_policy_enabled = v"
/>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">主动探测</span>
<p class="text-xs text-muted-foreground">
定期检查 Key 可用性,提前发现异常
</p>
</div>
<Switch
:model-value="form.probing_enabled"
@update:model-value="(v: boolean) => form.probing_enabled = v"
/>
</div>
<div
v-if="form.probing_enabled"
class="grid grid-cols-2 gap-4"
>
<div class="space-y-1.5">
<Label>
探测间隔
<span class="text-xs text-muted-foreground">(分钟)</span>
</Label>
<Input
:model-value="form.probing_interval_minutes ?? ''"
type="number"
min="1"
max="1440"
placeholder="10"
@update:model-value="(v) => form.probing_interval_minutes = parseNum(v)"
/>
</div>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">封号自动清除</span>
<p class="text-xs text-muted-foreground">
检测到账号被封禁时自动从号池中移除
</p>
</div>
<Switch
:model-value="form.auto_remove_banned_keys"
@update:model-value="(v: boolean) => form.auto_remove_banned_keys = v"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
429 冷却
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.rate_limit_cooldown_seconds ?? ''"
type="number"
min="10"
max="3600"
placeholder="300"
@update:model-value="(v) => form.rate_limit_cooldown_seconds = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
529 冷却
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.overload_cooldown_seconds ?? ''"
type="number"
min="5"
max="600"
placeholder="30"
@update:model-value="(v) => form.overload_cooldown_seconds = parseNum(v)"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
粘性会话 TTL
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.sticky_session_ttl_seconds ?? ''"
type="number"
min="60"
max="86400"
placeholder="3600 (留空禁用)"
@update:model-value="(v) => form.sticky_session_ttl_seconds = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
全局优先级
<span class="text-xs text-muted-foreground">(global_key)</span>
</Label>
<Input
:model-value="form.global_priority ?? ''"
type="number"
min="0"
max="999999"
placeholder="留空回退 provider_priority"
@update:model-value="(v) => form.global_priority = parseNum(v)"
/>
</div>
</div>
</div>
<!-- Batch Operations -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
批量操作
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
并发数
</Label>
<Input
:model-value="form.batch_concurrency ?? ''"
type="number"
min="1"
max="32"
placeholder="8"
@update:model-value="(v) => form.batch_concurrency = parseNum(v)"
/>
<p class="text-[11px] text-muted-foreground">
批量刷新 OAuth / 额度等操作的并行请求数
</p>
</div>
</div>
</div>
<!-- Claude Code -->
<div
v-if="isClaudeCode"
class="space-y-3"
>
<h3 class="text-sm font-medium border-b pb-2">
Claude Code
</h3>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">Session ID 伪装</span>
<p class="text-xs text-muted-foreground">
固定 metadata.user_id 中 session 片段
</p>
</div>
<Switch
:model-value="claudeForm.session_id_masking_enabled"
@update:model-value="(v: boolean) => claudeForm.session_id_masking_enabled = v"
/>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">仅限 CLI 客户端</span>
<p class="text-xs text-muted-foreground">
仅允许 Claude Code CLI 格式请求
</p>
</div>
<Switch
:model-value="claudeForm.cli_only_enabled"
@update:model-value="(v: boolean) => claudeForm.cli_only_enabled = v"
/>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">Cache TTL 统一</span>
<p class="text-xs text-muted-foreground">
强制所有 cache_control 使用相同 TTL 类型
</p>
</div>
<Switch
:model-value="claudeForm.cache_ttl_override_enabled"
@update:model-value="(v: boolean) => claudeForm.cache_ttl_override_enabled = v"
/>
</div>
<div
v-if="claudeForm.cache_ttl_override_enabled"
class="pl-3"
>
<div class="space-y-1.5">
<Label>TTL 类型</Label>
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md w-fit">
<button
v-for="opt in ['ephemeral']"
:key="opt"
type="button"
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
:class="[
claudeForm.cache_ttl_override_target === opt
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
@click="claudeForm.cache_ttl_override_target = opt"
>
{{ opt }}
</button>
</div>
</div>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">会话数量控制</span>
<p class="text-xs text-muted-foreground">
限制单 Key 同时活跃会话数
</p>
</div>
<Switch
:model-value="claudeForm.session_control_enabled"
@update:model-value="(v: boolean) => claudeForm.session_control_enabled = v"
/>
</div>
<div
v-if="claudeForm.session_control_enabled"
class="grid grid-cols-2 gap-4"
>
<div class="space-y-1.5">
<Label>
最大会话数
</Label>
<Input
:model-value="claudeForm.max_sessions ?? ''"
type="number"
min="1"
max="100"
placeholder="留空 = 不限"
@update:model-value="(v) => claudeForm.max_sessions = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
空闲超时
<span class="text-xs text-muted-foreground">(分钟)</span>
</Label>
<Input
:model-value="claudeForm.session_idle_timeout_minutes ?? ''"
type="number"
min="1"
max="1440"
placeholder="5"
@update:model-value="(v) => claudeForm.session_idle_timeout_minutes = parseNum(v) ?? 5"
/>
</div>
</div>
</div>
<!-- Cost Control -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
成本控制
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
成本窗口
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.cost_window_seconds ?? ''"
type="number"
min="3600"
max="86400"
placeholder="18000 (5 小时)"
@update:model-value="(v) => form.cost_window_seconds = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
Key 窗口限额
<span class="text-xs text-muted-foreground">(tokens)</span>
</Label>
<Input
:model-value="form.cost_limit_per_key_tokens ?? ''"
type="number"
min="0"
placeholder="留空 = 不限"
@update:model-value="(v) => form.cost_limit_per_key_tokens = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
软阈值
<span class="text-xs text-muted-foreground">(%)</span>
</Label>
<Input
:model-value="form.cost_soft_threshold_percent ?? ''"
type="number"
min="0"
max="100"
placeholder="80"
@update:model-value="(v) => form.cost_soft_threshold_percent = parseNum(v)"
/>
</div>
</div>
</div>
</div>
<template #footer>
<Button
variant="outline"
:disabled="loading"
@click="emit('update:modelValue', false)"
>
取消
</Button>
<Button
:disabled="loading"
@click="handleSave"
>
{{ loading ? '保存中...' : '保存' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints'
import type {
PoolAdvancedConfig,
ClaudeCodeAdvancedConfig,
ProviderWithEndpointsSummary,
} from '@/api/endpoints/types/provider'
const props = defineProps<{
modelValue: boolean
providerId: string
providerType?: string
currentConfig: PoolAdvancedConfig | null
currentClaudeConfig?: ClaudeCodeAdvancedConfig | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: [provider: ProviderWithEndpointsSummary]
}>()
const { success, error: showError } = useToast()
const loading = ref(false)
const isClaudeCode = computed(() => {
return (props.providerType || '').trim().toLowerCase() === 'claude_code'
})
const form = ref({
global_priority: null as number | null | undefined,
sticky_session_ttl_seconds: null as number | null | undefined,
health_policy_enabled: true,
rate_limit_cooldown_seconds: null as number | null | undefined,
overload_cooldown_seconds: null as number | null | undefined,
cost_window_seconds: null as number | null | undefined,
cost_limit_per_key_tokens: null as number | null | undefined,
cost_soft_threshold_percent: null as number | null | undefined,
batch_concurrency: null as number | null | undefined,
probing_enabled: false,
probing_interval_minutes: null as number | null | undefined,
auto_remove_banned_keys: false,
})
interface ClaudeFormState {
session_control_enabled: boolean
max_sessions: number | undefined
session_idle_timeout_minutes: number
session_id_masking_enabled: boolean
cache_ttl_override_enabled: boolean
cache_ttl_override_target: string
cli_only_enabled: boolean
}
const claudeForm = ref<ClaudeFormState>({
session_control_enabled: true,
max_sessions: undefined,
session_idle_timeout_minutes: 5,
session_id_masking_enabled: true,
cache_ttl_override_enabled: false,
cache_ttl_override_target: 'ephemeral',
cli_only_enabled: false,
})
function parseNum(v: string | number): number | undefined {
if (v === '' || v === null || v === undefined) return undefined
const n = Number(v)
return Number.isNaN(n) ? undefined : n
}
watch(() => props.modelValue, (open) => {
if (!open) return
const cfg = props.currentConfig
form.value = {
global_priority: cfg?.global_priority ?? null,
sticky_session_ttl_seconds: cfg?.sticky_session_ttl_seconds ?? null,
health_policy_enabled: cfg?.health_policy_enabled !== false,
rate_limit_cooldown_seconds: cfg?.rate_limit_cooldown_seconds ?? null,
overload_cooldown_seconds: cfg?.overload_cooldown_seconds ?? null,
cost_window_seconds: cfg?.cost_window_seconds ?? null,
cost_limit_per_key_tokens: cfg?.cost_limit_per_key_tokens ?? null,
cost_soft_threshold_percent: cfg?.cost_soft_threshold_percent ?? null,
batch_concurrency: cfg?.batch_concurrency ?? null,
probing_enabled: cfg?.probing_enabled ?? false,
probing_interval_minutes: cfg?.probing_interval_minutes ?? null,
auto_remove_banned_keys: cfg?.auto_remove_banned_keys ?? false,
}
const cc = props.currentClaudeConfig
claudeForm.value = {
session_control_enabled: cc?.max_sessions !== null,
max_sessions: cc?.max_sessions ?? undefined,
session_idle_timeout_minutes: cc?.session_idle_timeout_minutes ?? 5,
session_id_masking_enabled: cc?.session_id_masking_enabled !== false,
cache_ttl_override_enabled: cc?.cache_ttl_override_enabled ?? false,
cache_ttl_override_target: cc?.cache_ttl_override_target ?? 'ephemeral',
cli_only_enabled: cc?.cli_only_enabled ?? false,
}
})
async function handleSave() {
loading.value = true
try {
// 合并已有配置(保留 scheduling_presets 等不在此对话框编辑的字段)
const poolAdvanced: Record<string, unknown> = {
...(props.currentConfig ?? {}),
global_priority: form.value.global_priority ?? undefined,
sticky_session_ttl_seconds: form.value.sticky_session_ttl_seconds ?? undefined,
cost_window_seconds: form.value.cost_window_seconds ?? undefined,
cost_limit_per_key_tokens: form.value.cost_limit_per_key_tokens ?? undefined,
cost_soft_threshold_percent: form.value.cost_soft_threshold_percent ?? undefined,
rate_limit_cooldown_seconds: form.value.rate_limit_cooldown_seconds ?? undefined,
overload_cooldown_seconds: form.value.overload_cooldown_seconds ?? undefined,
health_policy_enabled: form.value.health_policy_enabled,
batch_concurrency: form.value.batch_concurrency ?? undefined,
probing_enabled: form.value.probing_enabled,
probing_interval_minutes: form.value.probing_enabled
? (form.value.probing_interval_minutes ?? undefined)
: undefined,
auto_remove_banned_keys: form.value.auto_remove_banned_keys,
}
const payload: Parameters<typeof updateProvider>[1] = {
pool_advanced: poolAdvanced as PoolAdvancedConfig,
}
if (isClaudeCode.value) {
const cf = claudeForm.value
payload.claude_code_advanced = {
max_sessions: cf.session_control_enabled ? (cf.max_sessions ?? null) : null,
session_idle_timeout_minutes: cf.session_control_enabled ? cf.session_idle_timeout_minutes : null,
session_id_masking_enabled: cf.session_id_masking_enabled,
cache_ttl_override_enabled: cf.cache_ttl_override_enabled,
cache_ttl_override_target: cf.cache_ttl_override_enabled ? cf.cache_ttl_override_target : undefined,
cli_only_enabled: cf.cli_only_enabled,
}
}
const updatedProvider = await updateProvider(props.providerId, payload)
success('高级设置已保存')
emit('saved', updatedProvider)
emit('update:modelValue', false)
} catch (err) {
showError(parseApiError(err))
} finally {
loading.value = false
}
}
</script>

View File

@@ -125,267 +125,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Advanced toggle -->
<div class="pt-1">
<Button
type="button"
size="sm"
variant="ghost"
@click="showAdvanced = !showAdvanced"
>
{{ showAdvanced ? '收起高级参数' : '展开高级参数' }}
</Button>
</div>
<!-- Advanced options -->
<div
v-if="showAdvanced"
class="space-y-4"
>
<!-- Cooldown & Health -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
冷却与健康
</h3>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">健康策略</span>
<p class="text-xs text-muted-foreground">
按上游错误自动冷却并跳过账号
</p>
</div>
<Switch
:model-value="form.health_policy_enabled"
@update:model-value="(v: boolean) => form.health_policy_enabled = v"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
429 冷却
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.rate_limit_cooldown_seconds ?? ''"
type="number"
min="10"
max="3600"
placeholder="300"
@update:model-value="(v) => form.rate_limit_cooldown_seconds = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
529 冷却
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.overload_cooldown_seconds ?? ''"
type="number"
min="5"
max="600"
placeholder="30"
@update:model-value="(v) => form.overload_cooldown_seconds = parseNum(v)"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
粘性会话 TTL
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.sticky_session_ttl_seconds ?? ''"
type="number"
min="60"
max="86400"
placeholder="3600 (留空禁用)"
@update:model-value="(v) => form.sticky_session_ttl_seconds = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
全局优先级
<span class="text-xs text-muted-foreground">(global_key)</span>
</Label>
<Input
:model-value="form.global_priority ?? ''"
type="number"
min="0"
max="999999"
placeholder="留空回退 provider_priority"
@update:model-value="(v) => form.global_priority = parseNum(v)"
/>
</div>
</div>
</div>
<!-- Claude Code -->
<div
v-if="isClaudeCode"
class="space-y-3"
>
<h3 class="text-sm font-medium border-b pb-2">
Claude Code
</h3>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">Session ID 伪装</span>
<p class="text-xs text-muted-foreground">
固定 metadata.user_id 中 session 片段
</p>
</div>
<Switch
:model-value="claudeForm.session_id_masking_enabled"
@update:model-value="(v: boolean) => claudeForm.session_id_masking_enabled = v"
/>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">仅限 CLI 客户端</span>
<p class="text-xs text-muted-foreground">
仅允许 Claude Code CLI 格式请求
</p>
</div>
<Switch
:model-value="claudeForm.cli_only_enabled"
@update:model-value="(v: boolean) => claudeForm.cli_only_enabled = v"
/>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">Cache TTL 统一</span>
<p class="text-xs text-muted-foreground">
强制所有 cache_control 使用相同 TTL 类型
</p>
</div>
<Switch
:model-value="claudeForm.cache_ttl_override_enabled"
@update:model-value="(v: boolean) => claudeForm.cache_ttl_override_enabled = v"
/>
</div>
<div
v-if="claudeForm.cache_ttl_override_enabled"
class="pl-3"
>
<div class="space-y-1.5">
<Label>TTL 类型</Label>
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md w-fit">
<button
v-for="opt in ['ephemeral']"
:key="opt"
type="button"
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
:class="[
claudeForm.cache_ttl_override_target === opt
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
@click="claudeForm.cache_ttl_override_target = opt"
>
{{ opt }}
</button>
</div>
</div>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">会话数量控制</span>
<p class="text-xs text-muted-foreground">
限制单 Key 同时活跃会话数
</p>
</div>
<Switch
:model-value="claudeForm.session_control_enabled"
@update:model-value="(v: boolean) => claudeForm.session_control_enabled = v"
/>
</div>
<div
v-if="claudeForm.session_control_enabled"
class="grid grid-cols-2 gap-4"
>
<div class="space-y-1.5">
<Label>
最大会话数
</Label>
<Input
:model-value="claudeForm.max_sessions ?? ''"
type="number"
min="1"
max="100"
placeholder="留空 = 不限"
@update:model-value="(v) => claudeForm.max_sessions = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
空闲超时
<span class="text-xs text-muted-foreground">(分钟)</span>
</Label>
<Input
:model-value="claudeForm.session_idle_timeout_minutes ?? ''"
type="number"
min="1"
max="1440"
placeholder="5"
@update:model-value="(v) => claudeForm.session_idle_timeout_minutes = parseNum(v) ?? 5"
/>
</div>
</div>
</div>
<!-- Cost Control -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
成本控制
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
成本窗口
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.cost_window_seconds ?? ''"
type="number"
min="3600"
max="86400"
placeholder="18000 (5 小时)"
@update:model-value="(v) => form.cost_window_seconds = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
Key 窗口限额
<span class="text-xs text-muted-foreground">(tokens)</span>
</Label>
<Input
:model-value="form.cost_limit_per_key_tokens ?? ''"
type="number"
min="0"
placeholder="留空 = 不限"
@update:model-value="(v) => form.cost_limit_per_key_tokens = parseNum(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
软阈值
<span class="text-xs text-muted-foreground">(%)</span>
</Label>
<Input
:model-value="form.cost_soft_threshold_percent ?? ''"
type="number"
min="0"
max="100"
placeholder="80"
@update:model-value="(v) => form.cost_soft_threshold_percent = parseNum(v)"
/>
</div>
</div>
</div>
</div>
</div> </div>
<template #footer> <template #footer>
@@ -409,7 +148,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { GripVertical } from 'lucide-vue-next' import { GripVertical } from 'lucide-vue-next'
import { Dialog, Button, Input, Label, Switch } from '@/components/ui' import { Dialog, Button, Switch } from '@/components/ui'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints' import { updateProvider } from '@/api/endpoints'
@@ -417,7 +156,6 @@ import { getPoolSchedulingPresets } from '@/api/endpoints/pool'
import type { PoolPresetMeta } from '@/api/endpoints/pool' import type { PoolPresetMeta } from '@/api/endpoints/pool'
import type { import type {
PoolAdvancedConfig, PoolAdvancedConfig,
ClaudeCodeAdvancedConfig,
SchedulingPresetItem, SchedulingPresetItem,
ProviderWithEndpointsSummary, ProviderWithEndpointsSummary,
} from '@/api/endpoints/types/provider' } from '@/api/endpoints/types/provider'
@@ -444,7 +182,6 @@ const props = defineProps<{
providerId: string providerId: string
providerType?: string providerType?: string
currentConfig: PoolAdvancedConfig | null currentConfig: PoolAdvancedConfig | null
currentClaudeConfig?: ClaudeCodeAdvancedConfig | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -546,7 +283,6 @@ const DEFAULT_ENABLED_PRESETS = new Set(['lru', 'quota_balanced'])
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
const loading = ref(false) const loading = ref(false)
const showAdvanced = ref(false)
const presetDefs = ref<PoolPresetMeta[]>([]) const presetDefs = ref<PoolPresetMeta[]>([])
const presetDefsLoaded = ref(false) const presetDefsLoaded = ref(false)
const loadingPresetDefs = ref(false) const loadingPresetDefs = ref(false)
@@ -555,45 +291,6 @@ const draggedIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null) const dragOverIndex = ref<number | null>(null)
const presetList = ref<PresetListItem[]>([]) const presetList = ref<PresetListItem[]>([])
const form = ref({
global_priority: null as number | null | undefined,
sticky_session_ttl_seconds: null as number | null | undefined,
health_policy_enabled: true,
rate_limit_cooldown_seconds: null as number | null | undefined,
overload_cooldown_seconds: null as number | null | undefined,
cost_window_seconds: null as number | null | undefined,
cost_limit_per_key_tokens: null as number | null | undefined,
cost_soft_threshold_percent: null as number | null | undefined,
})
const isClaudeCode = computed(() => normalizeProviderType(props.providerType) === 'claude_code')
interface ClaudeFormState {
session_control_enabled: boolean
max_sessions: number | undefined
session_idle_timeout_minutes: number
session_id_masking_enabled: boolean
cache_ttl_override_enabled: boolean
cache_ttl_override_target: string
cli_only_enabled: boolean
}
const claudeForm = ref<ClaudeFormState>({
session_control_enabled: true,
max_sessions: undefined,
session_idle_timeout_minutes: 5,
session_id_masking_enabled: true,
cache_ttl_override_enabled: false,
cache_ttl_override_target: 'ephemeral',
cli_only_enabled: false,
})
function parseNum(v: string | number): number | undefined {
if (v === '' || v === null || v === undefined) return undefined
const n = Number(v)
return Number.isNaN(n) ? undefined : n
}
function normalizeProviderType(value: string | undefined): string { function normalizeProviderType(value: string | undefined): string {
return (value || '').trim().toLowerCase() return (value || '').trim().toLowerCase()
} }
@@ -1006,32 +703,8 @@ function handleDrop(dropIndex: number) {
watch(() => props.modelValue, async (open) => { watch(() => props.modelValue, async (open) => {
if (!open) return if (!open) return
showAdvanced.value = false
await ensurePresetDefsLoaded() await ensurePresetDefsLoaded()
presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig)) presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig))
const cfg = props.currentConfig
form.value = {
global_priority: cfg?.global_priority ?? null,
sticky_session_ttl_seconds: cfg?.sticky_session_ttl_seconds ?? null,
health_policy_enabled: cfg?.health_policy_enabled !== false,
rate_limit_cooldown_seconds: cfg?.rate_limit_cooldown_seconds ?? null,
overload_cooldown_seconds: cfg?.overload_cooldown_seconds ?? null,
cost_window_seconds: cfg?.cost_window_seconds ?? null,
cost_limit_per_key_tokens: cfg?.cost_limit_per_key_tokens ?? null,
cost_soft_threshold_percent: cfg?.cost_soft_threshold_percent ?? null,
}
const cc = props.currentClaudeConfig
claudeForm.value = {
session_control_enabled: cc?.max_sessions !== null,
max_sessions: cc?.max_sessions ?? undefined,
session_idle_timeout_minutes: cc?.session_idle_timeout_minutes ?? 5,
session_id_masking_enabled: cc?.session_id_masking_enabled !== false,
cache_ttl_override_enabled: cc?.cache_ttl_override_enabled ?? false,
cache_ttl_override_target: cc?.cache_ttl_override_target ?? 'ephemeral',
cli_only_enabled: cc?.cli_only_enabled ?? false,
}
}) })
async function handleSave() { async function handleSave() {
@@ -1049,32 +722,13 @@ async function handleSave() {
return result return result
}) })
const payload: Parameters<typeof updateProvider>[1] = { // 合并已有配置,仅覆盖 scheduling_presets保留其他字段
pool_advanced: { const mergedAdvanced: Record<string, unknown> = {
global_priority: form.value.global_priority ?? undefined, ...(props.currentConfig ?? {}),
sticky_session_ttl_seconds: form.value.sticky_session_ttl_seconds ?? undefined, scheduling_presets: schedulingPresets,
scheduling_presets: schedulingPresets,
scoring_weights: undefined,
latency_window_seconds: undefined,
latency_sample_limit: undefined,
cost_window_seconds: form.value.cost_window_seconds ?? undefined,
cost_limit_per_key_tokens: form.value.cost_limit_per_key_tokens ?? undefined,
cost_soft_threshold_percent: form.value.cost_soft_threshold_percent ?? undefined,
rate_limit_cooldown_seconds: form.value.rate_limit_cooldown_seconds ?? undefined,
overload_cooldown_seconds: form.value.overload_cooldown_seconds ?? undefined,
health_policy_enabled: form.value.health_policy_enabled,
},
} }
if (isClaudeCode.value) { const payload: Parameters<typeof updateProvider>[1] = {
const cf = claudeForm.value pool_advanced: mergedAdvanced as PoolAdvancedConfig,
payload.claude_code_advanced = {
max_sessions: cf.session_control_enabled ? (cf.max_sessions ?? null) : null,
session_idle_timeout_minutes: cf.session_control_enabled ? cf.session_idle_timeout_minutes : null,
session_id_masking_enabled: cf.session_id_masking_enabled,
cache_ttl_override_enabled: cf.cache_ttl_override_enabled,
cache_ttl_override_target: cf.cache_ttl_override_enabled ? cf.cache_ttl_override_target : undefined,
cli_only_enabled: cf.cli_only_enabled,
}
} }
const updatedProvider = await updateProvider(props.providerId, payload) const updatedProvider = await updateProvider(props.providerId, payload)
success('号池调度已保存') success('号池调度已保存')

View File

@@ -0,0 +1,67 @@
<template>
<Popover
:open="open"
@update:open="emit('update:open', $event)"
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="nodeId ? 'text-blue-600' : ''"
:disabled="saving"
:title="title"
>
<Globe class="w-3.5 h-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
class="w-72 p-3"
side="bottom"
align="end"
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium">提供商代理节点</span>
<Button
v-if="nodeId"
variant="ghost"
size="sm"
class="h-6 px-2 text-[10px] text-muted-foreground"
:disabled="saving"
@click="emit('clear')"
>
清除
</Button>
</div>
<ProxyNodeSelect
:model-value="nodeId || ''"
trigger-class="h-8"
@update:model-value="emit('select', $event)"
/>
<p class="text-[10px] text-muted-foreground">
{{ nodeId ? '当前使用提供商独立代理' : '未设置,使用系统默认网络出口' }}
</p>
</div>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { Globe } from 'lucide-vue-next'
import { Button, Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
defineProps<{
open: boolean
nodeId: string | null | undefined
saving: boolean
title: string
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
select: [nodeId: string]
clear: []
}>()
</script>

View File

@@ -709,6 +709,28 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
const attempts = audit.attempts const attempts = audit.attempts
if (!Array.isArray(attempts) || attempts.length === 0) return [] if (!Array.isArray(attempts) || attempts.length === 0) return []
const providerNameById = new Map<string, string>()
for (const candidate of rawTimeline.value) {
const providerId = String(candidate.provider_id || '').trim()
const providerName = String(candidate.provider_name || '').trim()
if (!providerId || !providerName) continue
if (!providerNameById.has(providerId)) {
providerNameById.set(providerId, providerName)
}
}
const providerTypeLikeNames = new Set<string>([
'codex',
'kiro',
'antigravity',
'claude_code',
'claude code',
'gemini_cli',
'gemini cli',
'oauth',
'api_key',
'api key',
])
const traceMap = new Map<string, CandidateRecord>() const traceMap = new Map<string, CandidateRecord>()
for (const candidate of rawTimeline.value) { for (const candidate of rawTimeline.value) {
traceMap.set(makeAttemptKey(candidate.candidate_index, candidate.retry_index), candidate) traceMap.set(makeAttemptKey(candidate.candidate_index, candidate.retry_index), candidate)
@@ -757,6 +779,21 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
pool_group_id: finalPoolGroupId, pool_group_id: finalPoolGroupId,
} }
} }
const mergedProviderId = String(merged.provider_id || '').trim()
if (mergedProviderId) {
const inferredProviderName = providerNameById.get(mergedProviderId)
const currentProviderName = String(merged.provider_name || '').trim()
if (
inferredProviderName
&& (
!currentProviderName
|| providerTypeLikeNames.has(currentProviderName.toLowerCase())
)
) {
merged.provider_name = inferredProviderName
}
}
return merged return merged
}) })
.filter((item): item is CandidateRecord => item !== null) .filter((item): item is CandidateRecord => item !== null)
@@ -807,14 +844,23 @@ const normalizeProviderName = (value: string): string => {
return text.replace(/反代$/u, '').trim() || text return text.replace(/反代$/u, '').trim() || text
} }
const getProviderDisplayName = (attempt: CandidateRecord | null | undefined): string => { const getProviderDisplayName = (
attempt: CandidateRecord | null | undefined,
options: { allowAuthTypeFallback?: boolean } = {},
): string => {
const allowAuthTypeFallback = options.allowAuthTypeFallback ?? true
if (!attempt) return '未知' if (!attempt) return '未知'
const authType = String(attempt.key_auth_type || '').trim().toLowerCase() // 优先使用提供商名称(管理后台设置的名称)
if (authType && AUTH_TYPE_PROVIDER_LABEL_MAP[authType]) {
return AUTH_TYPE_PROVIDER_LABEL_MAP[authType]
}
const providerName = String(attempt.provider_name || '').trim() const providerName = String(attempt.provider_name || '').trim()
return providerName ? normalizeProviderName(providerName) : '未知' if (providerName) return normalizeProviderName(providerName)
if (allowAuthTypeFallback) {
// 回退:根据 auth_type 推断显示名称
const authType = String(attempt.key_auth_type || '').trim().toLowerCase()
if (authType && AUTH_TYPE_PROVIDER_LABEL_MAP[authType]) {
return AUTH_TYPE_PROVIDER_LABEL_MAP[authType]
}
}
return '未知'
} }
const normalizeProviderIdentity = (value: unknown): string => { const normalizeProviderIdentity = (value: unknown): string => {
@@ -898,7 +944,7 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
poolGroups.push({ poolGroups.push({
id: `pool:${groupId}`, id: `pool:${groupId}`,
providerName: getProviderDisplayName(poolPrimary), providerName: getProviderDisplayName(poolPrimary, { allowAuthTypeFallback: false }),
primary: poolPrimary, primary: poolPrimary,
primaryStatus: poolPrimaryStatus, primaryStatus: poolPrimaryStatus,
allAttempts: attempts, allAttempts: attempts,

View File

@@ -12,14 +12,13 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h3 class="text-base font-semibold"> <h3 class="text-base font-semibold">
号池管理 号池管理
<span
v-if="poolHeaderMetaText"
class="ml-2 text-xs font-normal text-muted-foreground"
>
| {{ poolHeaderMetaText }}
</span>
</h3> </h3>
<Badge
v-if="selectedProviderType"
variant="outline"
class="text-[10px] px-1.5 py-0 h-5 text-muted-foreground"
>
{{ selectedProviderType }}
</Badge>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<Button <Button
@@ -32,13 +31,33 @@
> >
<Upload class="w-3.5 h-3.5" /> <Upload class="w-3.5 h-3.5" />
</Button> </Button>
<ProviderProxyPopover
v-if="selectedProviderId"
:open="providerProxyMobilePopoverOpen"
:node-id="selectedProviderData?.proxy?.node_id"
:saving="savingProviderProxy"
:title="getProviderProxyButtonTitle()"
@update:open="(open: boolean) => handleProviderProxyPopoverToggle('mobile', open)"
@select="setProviderProxy"
@clear="clearProviderProxy"
/>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="高级设置"
@click="showAdvancedDialog = true"
>
<Settings2 class="w-3.5 h-3.5" />
</Button>
<Button <Button
v-if="selectedProviderId" v-if="selectedProviderId"
variant="outline" variant="outline"
size="sm" size="sm"
class="h-8 px-2 text-xs gap-1" class="h-8 px-2 text-xs gap-1"
title="调整号池调度" title="号池调度"
@click="showSchedulingDialog = true" @click="openSchedulingDialog()"
> >
调度 调度
<ChevronDown class="w-3 h-3 text-muted-foreground" /> <ChevronDown class="w-3 h-3 text-muted-foreground" />
@@ -47,11 +66,23 @@
v-if="selectedProviderId" v-if="selectedProviderId"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8 text-destructive hover:text-destructive" class="h-8 w-8"
title="清理已知封号账号" title="账号"
@click="handleCleanupBannedKeys" @click="showAccountBatchDialog = true"
> >
<Ban class="w-3.5 h-3.5" /> <Users class="w-3.5 h-3.5" />
</Button>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
:class="getProviderToggleButtonClass()"
:disabled="togglingProviderStatus"
:title="getProviderToggleButtonTitle()"
@click="toggleSelectedProviderStatus"
>
<Power class="w-3.5 h-3.5" />
</Button> </Button>
<RefreshButton <RefreshButton
:loading="refreshCurrentPageLoading" :loading="refreshCurrentPageLoading"
@@ -80,6 +111,10 @@
> >
{{ item.provider_name }} {{ item.provider_name }}
<span class="text-muted-foreground ml-1">({{ item.total_keys }})</span> <span class="text-muted-foreground ml-1">({{ item.total_keys }})</span>
<span
v-if="!item.pool_enabled"
class="ml-1 text-[10px] text-amber-600"
>未启用</span>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -122,14 +157,13 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h3 class="text-base font-semibold"> <h3 class="text-base font-semibold">
号池管理 号池管理
<span
v-if="poolHeaderMetaText"
class="ml-2 text-xs font-normal text-muted-foreground"
>
| {{ poolHeaderMetaText }}
</span>
</h3> </h3>
<Badge
v-if="selectedProviderType"
variant="outline"
class="text-[10px] px-1.5 py-0 h-5 text-muted-foreground"
>
{{ selectedProviderType }}
</Badge>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Select <Select
@@ -150,6 +184,10 @@
> >
{{ item.provider_name }} {{ item.provider_name }}
<span class="text-muted-foreground ml-1">({{ item.total_keys }})</span> <span class="text-muted-foreground ml-1">({{ item.total_keys }})</span>
<span
v-if="!item.pool_enabled"
class="ml-1 text-[10px] text-amber-600"
>未启用</span>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -193,7 +231,7 @@
v-if="selectedProviderId" v-if="selectedProviderId"
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs" class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
title="点击调整号池调度" title="点击调整号池调度"
@click="showSchedulingDialog = true" @click="openSchedulingDialog()"
> >
<span class="text-muted-foreground/80 hidden lg:inline">调度:</span> <span class="text-muted-foreground/80 hidden lg:inline">调度:</span>
<span class="font-medium text-foreground/90">{{ poolSchedulingLabel }}</span> <span class="font-medium text-foreground/90">{{ poolSchedulingLabel }}</span>
@@ -213,15 +251,47 @@
> >
<Upload class="w-3.5 h-3.5" /> <Upload class="w-3.5 h-3.5" />
</Button> </Button>
<ProviderProxyPopover
v-if="selectedProviderId"
:open="providerProxyDesktopPopoverOpen"
:node-id="selectedProviderData?.proxy?.node_id"
:saving="savingProviderProxy"
:title="getProviderProxyButtonTitle()"
@update:open="(open: boolean) => handleProviderProxyPopoverToggle('desktop', open)"
@select="setProviderProxy"
@clear="clearProviderProxy"
/>
<Button <Button
v-if="selectedProviderId" v-if="selectedProviderId"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8 text-destructive hover:text-destructive" class="h-8 w-8"
title="清理已知封号账号" title="高级设置"
@click="handleCleanupBannedKeys" @click="showAdvancedDialog = true"
> >
<Ban class="w-3.5 h-3.5" /> <Settings2 class="w-3.5 h-3.5" />
</Button>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="账号"
@click="showAccountBatchDialog = true"
>
<Users class="w-3.5 h-3.5" />
</Button>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
:class="getProviderToggleButtonClass()"
:disabled="togglingProviderStatus"
:title="getProviderToggleButtonTitle()"
@click="toggleSelectedProviderStatus"
>
<Power class="w-3.5 h-3.5" />
</Button> </Button>
<RefreshButton <RefreshButton
:loading="refreshCurrentPageLoading" :loading="refreshCurrentPageLoading"
@@ -283,28 +353,44 @@
v-if="keyPage.keys.length > 0" v-if="keyPage.keys.length > 0"
class="hidden xl:block overflow-x-auto" class="hidden xl:block overflow-x-auto"
> >
<Table class="min-w-[1400px]"> <Table class="w-full table-fixed">
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[320px] font-semibold whitespace-nowrap"> <TableHead
class="font-semibold whitespace-nowrap"
:style="{ width: desktopColumnWidths.name }"
>
名称 名称
</TableHead> </TableHead>
<TableHead <TableHead
v-if="showAccountQuotaColumn" v-if="showAccountQuotaColumn"
class="w-[240px] font-semibold whitespace-nowrap" class="font-semibold whitespace-nowrap"
:style="{ width: desktopColumnWidths.quota }"
> >
配额 配额
</TableHead> </TableHead>
<TableHead class="w-24 font-semibold whitespace-nowrap"> <TableHead
状态 class="px-2 font-semibold text-center whitespace-nowrap"
</TableHead> :style="{ width: desktopColumnWidths.stats }"
<TableHead class="w-24 font-semibold whitespace-nowrap"> >
最后使用
</TableHead>
<TableHead class="w-[160px] font-semibold whitespace-nowrap">
统计 统计
</TableHead> </TableHead>
<TableHead class="w-[220px] font-semibold text-center whitespace-nowrap"> <TableHead
class="font-semibold text-center whitespace-nowrap"
:style="{ width: desktopColumnWidths.lastUsed }"
>
最后使用
</TableHead>
<TableHead
class="font-semibold text-center whitespace-nowrap"
:style="{ width: desktopColumnWidths.status }"
>
状态
</TableHead>
<TableHead
class="px-2 font-semibold text-center whitespace-nowrap"
:style="{ width: desktopColumnWidths.actions }"
>
操作 操作
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -319,13 +405,13 @@
<TableCell <TableCell
class="py-3" class="py-3"
> >
<div class="max-w-[320px] min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5 min-w-0">
<span class="text-sm truncate block"> <span class="text-sm truncate block">
{{ key.key_name || '未命名' }} {{ key.key_name || '未命名' }}
</span> </span>
</div> </div>
<div class="flex items-center gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0"> <div class="flex items-center flex-wrap gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0">
<input <input
v-if="editingPriorityKeyId === key.key_id" v-if="editingPriorityKeyId === key.key_id"
:value="editingPriorityValue" :value="editingPriorityValue"
@@ -419,7 +505,7 @@
</TableCell> </TableCell>
<TableCell <TableCell
v-if="showAccountQuotaColumn" v-if="showAccountQuotaColumn"
class="py-3" class="py-3 align-middle"
> >
<div <div
v-if="quotaProgressMap[key.key_id]?.length" v-if="quotaProgressMap[key.key_id]?.length"
@@ -464,22 +550,8 @@
class="text-xs text-muted-foreground" class="text-xs text-muted-foreground"
>-</span> >-</span>
</TableCell> </TableCell>
<TableCell class="py-3"> <TableCell class="py-3 px-2 align-middle">
<Badge <div class="grid grid-rows-3 gap-0.5 w-[136px] mx-auto text-[10px] leading-4">
:variant="getSchedulingBadgeVariant(key)"
class="text-[10px]"
:title="getSchedulingTitle(key)"
>
{{ getSchedulingBadgeLabel(key) }}
</Badge>
</TableCell>
<TableCell class="py-3">
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ key.last_used_at ? formatRelativeTime(key.last_used_at) : '-' }}
</span>
</TableCell>
<TableCell class="py-3">
<div class="grid grid-rows-3 gap-0.5 w-[150px] text-[10px] leading-4">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">请求</span> <span class="text-muted-foreground">请求</span>
<span class="tabular-nums text-foreground/90"> <span class="tabular-nums text-foreground/90">
@@ -500,7 +572,21 @@
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell class="py-3"> <TableCell class="py-3 text-center">
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ key.last_used_at ? formatRelativeTime(key.last_used_at) : '-' }}
</span>
</TableCell>
<TableCell class="py-3 text-center">
<Badge
:variant="getSchedulingBadgeVariant(key)"
class="text-[10px]"
:title="getSchedulingTitle(key)"
>
{{ getSchedulingBadgeLabel(key) }}
</Badge>
</TableCell>
<TableCell class="py-3 px-2 align-middle">
<div class="flex justify-center gap-0.5"> <div class="flex justify-center gap-0.5">
<Button <Button
v-if="key.cooldown_reason" v-if="key.cooldown_reason"
@@ -975,9 +1061,25 @@
:provider-id="selectedProviderId" :provider-id="selectedProviderId"
:provider-type="selectedProviderType" :provider-type="selectedProviderType"
:current-config="selectedProviderConfig" :current-config="selectedProviderConfig"
@saved="handleSchedulingSaved"
/>
<PoolAdvancedDialog
v-if="selectedProviderId"
v-model="showAdvancedDialog"
:provider-id="selectedProviderId"
:provider-type="selectedProviderType"
:current-config="selectedProviderConfig"
:current-claude-config="selectedProviderClaudeConfig" :current-claude-config="selectedProviderClaudeConfig"
@saved="handleSchedulingSaved" @saved="handleSchedulingSaved"
/> />
<PoolAccountBatchDialog
v-if="selectedProviderId"
v-model="showAccountBatchDialog"
:provider-id="selectedProviderId"
:provider-name="selectedProviderData?.name || ''"
:batch-concurrency="selectedProviderConfig?.batch_concurrency"
@changed="handleAccountBatchChanged"
/>
<KeyFormDialog <KeyFormDialog
v-if="selectedProviderId" v-if="selectedProviderId"
:open="keyFormDialogOpen" :open="keyFormDialogOpen"
@@ -1020,7 +1122,8 @@ import {
Globe, Globe,
SquarePen, SquarePen,
Trash2, Trash2,
Ban, Users,
Settings2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { import {
@@ -1055,7 +1158,6 @@ import {
getPoolSchedulingPresets, getPoolSchedulingPresets,
listPoolKeys, listPoolKeys,
clearPoolCooldown, clearPoolCooldown,
cleanupBannedPoolKeys,
} from '@/api/endpoints/pool' } from '@/api/endpoints/pool'
import { import {
revealEndpointKey, revealEndpointKey,
@@ -1073,9 +1175,12 @@ import type {
PoolPresetMeta, PoolPresetMeta,
} from '@/api/endpoints/pool' } from '@/api/endpoints/pool'
import type { ClaudeCodeAdvancedConfig, EndpointAPIKey, PoolAdvancedConfig, ProviderWithEndpointsSummary } from '@/api/endpoints/types/provider' import type { ClaudeCodeAdvancedConfig, EndpointAPIKey, PoolAdvancedConfig, ProviderWithEndpointsSummary } from '@/api/endpoints/types/provider'
import { getProvider } from '@/api/endpoints' import { getProvider, updateProvider } from '@/api/endpoints'
import { useProxyNodesStore } from '@/stores/proxy-nodes' import { useProxyNodesStore } from '@/stores/proxy-nodes'
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue' import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vue'
import PoolAccountBatchDialog from '@/features/pool/components/PoolAccountBatchDialog.vue'
import ProviderProxyPopover from '@/features/pool/components/ProviderProxyPopover.vue'
import KeyAllowedModelsEditDialog from '@/features/providers/components/KeyAllowedModelsEditDialog.vue' import KeyAllowedModelsEditDialog from '@/features/providers/components/KeyAllowedModelsEditDialog.vue'
import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue' import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue' import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
@@ -1105,7 +1210,8 @@ async function loadOverview() {
try { try {
const res = await getPoolOverview() const res = await getPoolOverview()
if (requestId !== overviewRequestId) return if (requestId !== overviewRequestId) return
const enabledProviders = res.items.filter(item => item.pool_enabled) const allProviders = Array.isArray(res.items) ? res.items : []
const enabledProviders = allProviders.filter(item => item.pool_enabled)
poolProviders.value = enabledProviders poolProviders.value = enabledProviders
// Keep selected provider aligned with dropdown options. // Keep selected provider aligned with dropdown options.
@@ -1121,6 +1227,8 @@ async function loadOverview() {
} else { } else {
selectedProviderId.value = null selectedProviderId.value = null
selectedProviderData.value = null selectedProviderData.value = null
showAccountBatchDialog.value = false
closeProviderProxyPopovers()
} }
} }
} catch (err) { } catch (err) {
@@ -1139,6 +1247,7 @@ async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSumma
selectedProviderData.value = updatedProvider selectedProviderData.value = updatedProvider
} }
showSchedulingDialog.value = false showSchedulingDialog.value = false
showAdvancedDialog.value = false
await loadOverview() await loadOverview()
} }
@@ -1197,7 +1306,17 @@ async function loadSchedulingPresetMetas(): Promise<void> {
} }
} }
const selectedProviderOverview = computed<PoolOverviewItem | null>(() => {
const selectedId = selectedProviderId.value
if (!selectedId) return null
return poolProviders.value.find(item => item.provider_id === selectedId) || null
})
const poolSchedulingLabel = computed(() => { const poolSchedulingLabel = computed(() => {
if (!selectedProviderConfig.value && selectedProviderOverview.value?.pool_enabled === false) {
return '未启用'
}
const cfg = selectedProviderConfig.value const cfg = selectedProviderConfig.value
const presets = Array.isArray(cfg?.scheduling_presets) ? cfg.scheduling_presets : [] const presets = Array.isArray(cfg?.scheduling_presets) ? cfg.scheduling_presets : []
const presetLabels = presetLabelsByName.value const presetLabels = presetLabelsByName.value
@@ -1239,23 +1358,63 @@ const poolSchedulingLabel = computed(() => {
const selectedProviderType = computed(() => { const selectedProviderType = computed(() => {
const fromDetail = String(selectedProviderData.value?.provider_type || '').trim().toLowerCase() const fromDetail = String(selectedProviderData.value?.provider_type || '').trim().toLowerCase()
if (fromDetail) return fromDetail if (fromDetail) return fromDetail
const fromOverview = poolProviders.value.find(item => item.provider_id === selectedProviderId.value)?.provider_type const fromOverview = selectedProviderOverview.value?.provider_type
return String(fromOverview || '').trim().toLowerCase() return String(fromOverview || '').trim().toLowerCase()
}) })
const selectedProviderStatusText = computed(() => {
if (!selectedProviderId.value) return ''
const providerActive = selectedProviderData.value?.is_active
if (providerActive === false) return '禁用'
if (providerActive === true) return '启用'
if (selectedProviderOverview.value?.pool_enabled === false) return '禁用'
if (selectedProviderOverview.value?.pool_enabled === true) return '启用'
return ''
})
const poolHeaderMetaText = computed(() => {
const providerType = selectedProviderType.value
const status = selectedProviderStatusText.value
if (providerType && status) return `${providerType} | ${status}`
return providerType || status || ''
})
const showAccountQuotaColumn = computed(() => { const showAccountQuotaColumn = computed(() => {
return selectedProviderType.value === 'codex' return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'kiro' || selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'antigravity' || selectedProviderType.value === 'antigravity'
}) })
const desktopColumnWidths = computed(() => {
if (showAccountQuotaColumn.value) {
return {
name: '28%',
quota: '23%',
stats: '15%',
lastUsed: '10%',
status: '8%',
actions: '16%',
}
}
return {
name: '40%',
quota: '0%',
stats: '18%',
lastUsed: '12%',
status: '10%',
actions: '20%',
}
})
async function selectProvider(id: string) { async function selectProvider(id: string) {
const requestId = ++selectProviderRequestId const requestId = ++selectProviderRequestId
selectedProviderId.value = id selectedProviderId.value = id
editingKeyDetail.value = null editingKeyDetail.value = null
showAccountBatchDialog.value = false
keyPermissionsDialogOpen.value = false keyPermissionsDialogOpen.value = false
keyFormDialogOpen.value = false keyFormDialogOpen.value = false
oauthKeyEditDialogOpen.value = false oauthKeyEditDialogOpen.value = false
closeProviderProxyPopovers()
proxyDesktopPopoverOpenKeyId.value = null proxyDesktopPopoverOpenKeyId.value = null
proxyMobilePopoverOpenKeyId.value = null proxyMobilePopoverOpenKeyId.value = null
suppressFiltersWatch = true suppressFiltersWatch = true
@@ -1859,29 +2018,139 @@ async function toggleKeyActive(key: PoolKeyDetail) {
} }
} }
async function handleCleanupBannedKeys() {
if (!selectedProviderId.value) return
const confirmed = await confirm({
title: '清理封号账号',
message: '将删除该 Provider 下已识别为封号/封禁的账号。此操作不可恢复,是否继续?',
confirmText: '确认清理',
variant: 'destructive',
})
if (!confirmed) return
try {
const res = await cleanupBannedPoolKeys(selectedProviderId.value)
success(res.message || `已清理 ${res.affected} 个账号`)
await Promise.all([loadKeys(), loadOverview()])
} catch (err) {
showError(parseApiError(err, '清理封号账号失败'))
}
}
// --- Dialogs --- // --- Dialogs ---
const showImportDialog = ref(false) const showImportDialog = ref(false)
const showSchedulingDialog = ref(false) const showSchedulingDialog = ref(false)
const showAdvancedDialog = ref(false)
const showAccountBatchDialog = ref(false)
const providerProxyMobilePopoverOpen = ref(false)
const providerProxyDesktopPopoverOpen = ref(false)
const savingProviderProxy = ref(false)
const togglingProviderStatus = ref(false)
function openSchedulingDialog() {
showSchedulingDialog.value = true
}
function getProviderProxyNodeName(): string | null {
const nodeId = selectedProviderData.value?.proxy?.node_id
if (!nodeId) return null
const node = proxyNodesStore.nodes.find(n => n.id === nodeId)
return node ? node.name : `${nodeId.slice(0, 8)}...`
}
function getProviderProxyButtonTitle(): string {
const nodeName = getProviderProxyNodeName()
if (nodeName) return `提供商代理(当前: ${nodeName}`
return '提供商代理(未设置)'
}
function closeProviderProxyPopovers(): void {
providerProxyMobilePopoverOpen.value = false
providerProxyDesktopPopoverOpen.value = false
}
function handleProviderProxyPopoverToggle(scope: 'mobile' | 'desktop', open: boolean): void {
if (scope === 'mobile') {
providerProxyMobilePopoverOpen.value = open
if (open) {
providerProxyDesktopPopoverOpen.value = false
}
} else {
providerProxyDesktopPopoverOpen.value = open
if (open) {
providerProxyMobilePopoverOpen.value = false
}
}
if (open) {
proxyNodesStore.ensureLoaded()
proxyDesktopPopoverOpenKeyId.value = null
proxyMobilePopoverOpenKeyId.value = null
}
}
async function setProviderProxy(nodeId: string): Promise<void> {
const providerId = selectedProviderId.value
if (!providerId) return
savingProviderProxy.value = true
try {
const updated = await updateProvider(providerId, {
proxy: { node_id: nodeId, enabled: true },
})
if (selectedProviderId.value === providerId) {
selectedProviderData.value = updated
}
closeProviderProxyPopovers()
success('提供商代理已设置')
} catch (err) {
showError(parseApiError(err, '设置提供商代理失败'))
} finally {
savingProviderProxy.value = false
}
}
async function clearProviderProxy(): Promise<void> {
const providerId = selectedProviderId.value
if (!providerId) return
savingProviderProxy.value = true
try {
const updated = await updateProvider(providerId, { proxy: null })
if (selectedProviderId.value === providerId) {
selectedProviderData.value = updated
}
closeProviderProxyPopovers()
success('提供商代理已清除')
} catch (err) {
showError(parseApiError(err, '清除提供商代理失败'))
} finally {
savingProviderProxy.value = false
}
}
function getProviderToggleButtonTitle(): string {
const active = selectedProviderData.value?.is_active !== false
return active ? '当前状态:已启用,点击禁用提供商' : '当前状态:已禁用,点击启用提供商'
}
function getProviderToggleButtonClass(): string {
return ''
}
async function toggleSelectedProviderStatus(): Promise<void> {
if (togglingProviderStatus.value) return
const providerId = selectedProviderId.value
const current = selectedProviderData.value
if (!providerId || !current) return
const nextStatus = !current.is_active
if (!nextStatus) {
const confirmed = await confirm({
title: '禁用提供商',
message: `禁用后该提供商(${current.name})将不再参与调度,是否继续?`,
confirmText: '确认禁用',
variant: 'destructive',
})
if (!confirmed) return
}
togglingProviderStatus.value = true
try {
const updated = await updateProvider(providerId, { is_active: nextStatus })
if (selectedProviderId.value === providerId) {
selectedProviderData.value = updated
}
success(nextStatus ? '提供商已启用' : '提供商已禁用')
await loadOverview()
} catch (err) {
showError(parseApiError(err, nextStatus ? '启用提供商失败' : '禁用提供商失败'))
} finally {
togglingProviderStatus.value = false
}
}
async function handleAccountBatchChanged(): Promise<void> {
await Promise.all([loadKeys(), loadOverview()])
}
async function handleAccountDialogSaved() { async function handleAccountDialogSaved() {
showImportDialog.value = false showImportDialog.value = false

View File

@@ -644,22 +644,16 @@ class AdminListSchedulingPresetsAdapter(AdminApiAdapter):
class AdminPoolOverviewAdapter(AdminApiAdapter): class AdminPoolOverviewAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
providers = ( providers = db.query(Provider).order_by(Provider.provider_priority.asc()).all()
db.query(Provider)
.filter(Provider.is_active.is_(True))
.order_by(Provider.provider_priority.asc())
.all()
)
# 先批量计算池化 Provider 的 Key 总数/启用数,避免每个 Provider 单独查询N+1 # 仅保留号池调度已开启的 Provider
enabled_providers: list[Provider] = []
pool_provider_ids: list[str] = [] pool_provider_ids: list[str] = []
pool_enabled_map: dict[str, bool] = {}
for p in providers: for p in providers:
pid = str(p.id) if parse_pool_config(getattr(p, "config", None)) is None:
enabled = parse_pool_config(getattr(p, "config", None)) is not None continue
pool_enabled_map[pid] = enabled enabled_providers.append(p)
if enabled: pool_provider_ids.append(str(p.id))
pool_provider_ids.append(pid)
key_ids_by_provider: dict[str, list[str]] = {pid: [] for pid in pool_provider_ids} key_ids_by_provider: dict[str, list[str]] = {pid: [] for pid in pool_provider_ids}
key_stats_by_provider: dict[str, dict[str, int]] = { key_stats_by_provider: dict[str, dict[str, int]] = {
@@ -710,19 +704,8 @@ class AdminPoolOverviewAdapter(AdminApiAdapter):
) )
items: list[PoolOverviewItem] = [] items: list[PoolOverviewItem] = []
for p in providers: for p in enabled_providers:
pid = str(p.id) pid = str(p.id)
if not pool_enabled_map.get(pid, False):
items.append(
PoolOverviewItem(
provider_id=pid,
provider_name=p.name,
provider_type=str(getattr(p, "provider_type", "custom") or "custom"),
pool_enabled=False,
)
)
continue
key_stats = key_stats_by_provider.get(pid, {"total": 0, "active": 0}) key_stats = key_stats_by_provider.get(pid, {"total": 0, "active": 0})
items.append( items.append(

View File

@@ -40,6 +40,7 @@ from src.core.provider_templates.types import ProviderType
from src.database import create_session from src.database import create_session
from src.database.database import get_db from src.database.database import get_db
from src.models.database import Provider, ProviderAPIKey, User from src.models.database import Provider, ProviderAPIKey, User
from src.services.provider.pool.config import parse_pool_config
from src.utils.auth_utils import require_admin from src.utils.auth_utils import require_admin
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"]) router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
@@ -1879,6 +1880,7 @@ async def _batch_import_standard_oauth_internal(
proxy_config: dict[str, Any] | None = None, proxy_config: dict[str, Any] | None = None,
key_proxy: dict[str, Any] | None = None, key_proxy: dict[str, Any] | None = None,
progress_hook: BatchImportProgressHook | None = None, progress_hook: BatchImportProgressHook | None = None,
concurrency: int = 1,
) -> BatchImportResponse: ) -> BatchImportResponse:
"""标准 OAuth Provider 批量导入(不含 Kiro""" """标准 OAuth Provider 批量导入(不含 Kiro"""
template = _require_oauth_template(provider_type) template = _require_oauth_template(provider_type)
@@ -1893,238 +1895,253 @@ async def _batch_import_standard_oauth_internal(
is_json = "anthropic.com" in token_url is_json = "anthropic.com" in token_url
scope_str = " ".join(template.oauth.scopes) if template.oauth.scopes else "" scope_str = " ".join(template.oauth.scopes) if template.oauth.scopes else ""
results: list[BatchImportResultItem] = [] total = len(import_entries)
results: list[BatchImportResultItem] = [None] * total # type: ignore[list-item]
success_count = 0 success_count = 0
failed_count = 0 failed_count = 0
processed_count = 0
db_lock = asyncio.Lock()
sem = asyncio.Semaphore(max(concurrency, 1))
for idx, import_entry in enumerate(import_entries): async def _process_entry(idx: int, import_entry: dict[str, Any]) -> None:
refresh_token = import_entry.get("refresh_token", "") nonlocal success_count, failed_count, processed_count
result_item: BatchImportResultItem result_item: BatchImportResultItem
try:
if not refresh_token or len(refresh_token) < 10: async with sem:
try:
refresh_token = import_entry.get("refresh_token", "")
if not refresh_token or len(refresh_token) < 10:
result_item = BatchImportResultItem(
index=idx,
status="error",
error="Token 无效或过短",
)
failed_count += 1
else:
if is_json:
body: dict[str, Any] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if scope_str:
body["scope"] = scope_str
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
data = None
json_body = body
else:
form: dict[str, str] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if scope_str:
form["scope"] = scope_str
if template.oauth.client_secret:
form["client_secret"] = template.oauth.client_secret
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
data = form
json_body = None
try:
resp = await post_oauth_token(
provider_type=provider_type,
token_url=token_url,
headers=headers,
data=data,
json_body=json_body,
proxy_config=proxy_config,
timeout_seconds=timeout_seconds,
)
except Exception as exc:
result_item = BatchImportResultItem(
index=idx,
status="error",
error=f"Token 刷新请求失败: {exc}",
)
failed_count += 1
processed_count += 1
results[idx] = result_item
if progress_hook is not None:
await progress_hook(
total, processed_count, success_count, failed_count, result_item
)
return
if resp.status_code < 200 or resp.status_code >= 300:
error_reason = f"HTTP {resp.status_code}"
try:
error_body = resp.json()
if "error" in error_body:
error_reason = str(
error_body.get("error_description") or error_body.get("error")
)
except Exception:
error_reason = (
resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
)
result_item = BatchImportResultItem(
index=idx,
status="error",
error=f"Token 验证失败: {error_reason}",
)
failed_count += 1
processed_count += 1
results[idx] = result_item
if progress_hook is not None:
await progress_hook(
total, processed_count, success_count, failed_count, result_item
)
return
token_data = resp.json()
access_token = str(token_data.get("access_token") or "")
new_refresh_token = str(token_data.get("refresh_token") or "") or refresh_token
if not access_token:
result_item = BatchImportResultItem(
index=idx,
status="error",
error="Token 刷新返回缺少 access_token",
)
failed_count += 1
processed_count += 1
results[idx] = result_item
if progress_hook is not None:
await progress_hook(
total, processed_count, success_count, failed_count, result_item
)
return
expires_in = token_data.get("expires_in")
expires_at: int | None = None
try:
if expires_in is not None:
expires_at = int(time.time()) + int(expires_in)
except Exception:
expires_at = None
auth_config: dict[str, Any] = {
"provider_type": provider_type,
"token_type": token_data.get("token_type"),
"refresh_token": new_refresh_token or None,
"expires_at": expires_at,
"scope": token_data.get("scope"),
"updated_at": int(time.time()),
}
try:
auth_config = await enrich_auth_config(
provider_type=provider_type,
auth_config=auth_config,
token_response=token_data,
access_token=access_token,
proxy_config=proxy_config,
)
except Exception as exc:
logger.warning("批量导入: enrich_auth_config 失败 (index={}): {}", idx, exc)
if provider_type == ProviderType.CODEX.value:
_apply_codex_import_hints(auth_config, import_entry)
async with db_lock:
try:
existing_key = _check_duplicate_oauth_account(
db, provider_id, auth_config
)
except InvalidRequestException as exc:
result_item = BatchImportResultItem(
index=idx,
status="error",
error=str(exc),
)
failed_count += 1
processed_count += 1
results[idx] = result_item
if progress_hook is not None:
await progress_hook(
total,
processed_count,
success_count,
failed_count,
result_item,
)
return
replaced = False
if existing_key:
new_key = _update_existing_oauth_key(
db,
existing_key,
access_token,
auth_config,
flush_only=True,
proxy=key_proxy,
)
name = existing_key.name
replaced = True
else:
email = auth_config.get("email")
if email:
name = f"{provider_type}_{email}"
else:
name = f"{provider_type}_{int(time.time())}_{idx}"
if len(name) > 100:
name = name[:100]
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=auth_config,
api_formats=api_formats,
flush_only=True,
proxy=key_proxy,
)
result_item = BatchImportResultItem(
index=idx,
status="success",
key_id=str(new_key.id),
key_name=name,
replaced=replaced,
)
success_count += 1
except Exception as exc:
logger.error("批量导入 OAuth 凭据失败 (index={}): {}", idx, exc)
result_item = BatchImportResultItem( result_item = BatchImportResultItem(
index=idx, index=idx,
status="error", status="error",
error="Token 无效或过短", error=f"导入失败: {exc}",
) )
failed_count += 1 failed_count += 1
else:
if is_json:
body: dict[str, Any] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if scope_str:
body["scope"] = scope_str
headers = {"Content-Type": "application/json", "Accept": "application/json"}
data = None
json_body = body
else:
form: dict[str, str] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
if scope_str:
form["scope"] = scope_str
if template.oauth.client_secret:
form["client_secret"] = template.oauth.client_secret
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
data = form
json_body = None
try: processed_count += 1
resp = await post_oauth_token( results[idx] = result_item
provider_type=provider_type,
token_url=token_url,
headers=headers,
data=data,
json_body=json_body,
proxy_config=proxy_config,
timeout_seconds=timeout_seconds,
)
except Exception as exc:
result_item = BatchImportResultItem(
index=idx,
status="error",
error=f"Token 刷新请求失败: {exc}",
)
failed_count += 1
results.append(result_item)
if progress_hook is not None:
await progress_hook(
len(import_entries),
idx + 1,
success_count,
failed_count,
result_item,
)
continue
if resp.status_code < 200 or resp.status_code >= 300:
error_reason = f"HTTP {resp.status_code}"
try:
error_body = resp.json()
if "error" in error_body:
error_reason = str(
error_body.get("error_description") or error_body.get("error")
)
except Exception:
error_reason = resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
result_item = BatchImportResultItem(
index=idx,
status="error",
error=f"Token 验证失败: {error_reason}",
)
failed_count += 1
results.append(result_item)
if progress_hook is not None:
await progress_hook(
len(import_entries),
idx + 1,
success_count,
failed_count,
result_item,
)
continue
token_data = resp.json()
access_token = str(token_data.get("access_token") or "")
new_refresh_token = str(token_data.get("refresh_token") or "") or refresh_token
if not access_token:
result_item = BatchImportResultItem(
index=idx,
status="error",
error="Token 刷新返回缺少 access_token",
)
failed_count += 1
results.append(result_item)
if progress_hook is not None:
await progress_hook(
len(import_entries),
idx + 1,
success_count,
failed_count,
result_item,
)
continue
expires_in = token_data.get("expires_in")
expires_at: int | None = None
try:
if expires_in is not None:
expires_at = int(time.time()) + int(expires_in)
except Exception:
expires_at = None
auth_config: dict[str, Any] = {
"provider_type": provider_type,
"token_type": token_data.get("token_type"),
"refresh_token": new_refresh_token or None,
"expires_at": expires_at,
"scope": token_data.get("scope"),
"updated_at": int(time.time()),
}
try:
auth_config = await enrich_auth_config(
provider_type=provider_type,
auth_config=auth_config,
token_response=token_data,
access_token=access_token,
proxy_config=proxy_config,
)
except Exception as exc:
logger.warning("批量导入: enrich_auth_config 失败 (index={}): {}", idx, exc)
if provider_type == ProviderType.CODEX.value:
_apply_codex_import_hints(auth_config, import_entry)
try:
existing_key = _check_duplicate_oauth_account(db, provider_id, auth_config)
except InvalidRequestException as exc:
result_item = BatchImportResultItem(
index=idx,
status="error",
error=str(exc),
)
failed_count += 1
results.append(result_item)
if progress_hook is not None:
await progress_hook(
len(import_entries),
idx + 1,
success_count,
failed_count,
result_item,
)
continue
replaced = False
if existing_key:
new_key = _update_existing_oauth_key(
db,
existing_key,
access_token,
auth_config,
flush_only=True,
proxy=key_proxy,
)
name = existing_key.name
replaced = True
else:
email = auth_config.get("email")
if email:
name = f"{provider_type}_{email}"
else:
name = f"{provider_type}_{int(time.time())}_{idx}"
if len(name) > 100:
name = name[:100]
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=auth_config,
api_formats=api_formats,
flush_only=True,
proxy=key_proxy,
)
result_item = BatchImportResultItem(
index=idx,
status="success",
key_id=str(new_key.id),
key_name=name,
replaced=replaced,
)
success_count += 1
except Exception as exc:
logger.error("批量导入 OAuth 凭据失败 (index={}): {}", idx, exc)
result_item = BatchImportResultItem(
index=idx,
status="error",
error=f"导入失败: {exc}",
)
failed_count += 1
results.append(result_item)
if progress_hook is not None: if progress_hook is not None:
await progress_hook( await progress_hook(total, processed_count, success_count, failed_count, result_item)
len(import_entries), idx + 1, success_count, failed_count, result_item
) await asyncio.gather(
*[_process_entry(i, e) for i, e in enumerate(import_entries)],
return_exceptions=True,
)
if success_count > 0: if success_count > 0:
db.commit() db.commit()
final_results = [r for r in results if r is not None]
if len(final_results) != total:
logger.warning("[BATCH_IMPORT] 结果不完整: expected={}, got={}", total, len(final_results))
logger.info( logger.info(
"[BATCH_IMPORT] Provider {} ({}): 成功 {}/{}, 失败 {}", "[BATCH_IMPORT] Provider {} ({}): 成功 {}/{}, 失败 {}",
provider_id, provider_id,
@@ -2138,7 +2155,7 @@ async def _batch_import_standard_oauth_internal(
total=len(import_entries), total=len(import_entries),
success=success_count, success=success_count,
failed=failed_count, failed=failed_count,
results=results, results=final_results,
) )
@@ -2174,6 +2191,10 @@ async def batch_import_oauth(
getattr(provider, "proxy", None), payload.proxy_node_id getattr(provider, "proxy", None), payload.proxy_node_id
) )
# 从 pool_advanced 读取批量并发数
_pool_cfg = parse_pool_config(getattr(provider, "config", None))
batch_concurrency = (_pool_cfg.batch_concurrency or 8) if _pool_cfg else 8
if provider_type == ProviderType.KIRO.value: if provider_type == ProviderType.KIRO.value:
return await _batch_import_kiro_internal( return await _batch_import_kiro_internal(
provider_id=provider_id, provider_id=provider_id,
@@ -2182,6 +2203,7 @@ async def batch_import_oauth(
db=db, db=db,
proxy_config=proxy_config, proxy_config=proxy_config,
key_proxy=key_proxy, key_proxy=key_proxy,
concurrency=batch_concurrency,
) )
return await _batch_import_standard_oauth_internal( return await _batch_import_standard_oauth_internal(
@@ -2192,6 +2214,7 @@ async def batch_import_oauth(
db=db, db=db,
proxy_config=proxy_config, proxy_config=proxy_config,
key_proxy=key_proxy, key_proxy=key_proxy,
concurrency=batch_concurrency,
) )
@@ -2224,6 +2247,10 @@ async def _run_batch_import_task(
getattr(provider, "proxy", None), payload.proxy_node_id getattr(provider, "proxy", None), payload.proxy_node_id
) )
# 从 pool_advanced 读取批量并发数
_pool_cfg = parse_pool_config(getattr(provider, "config", None))
batch_concurrency = (_pool_cfg.batch_concurrency or 8) if _pool_cfg else 8
async def progress_hook( async def progress_hook(
total: int, total: int,
processed: int, processed: int,
@@ -2261,6 +2288,7 @@ async def _run_batch_import_task(
proxy_config=proxy_config, proxy_config=proxy_config,
key_proxy=key_proxy, key_proxy=key_proxy,
progress_hook=progress_hook, progress_hook=progress_hook,
concurrency=batch_concurrency,
) )
else: else:
result = await _batch_import_standard_oauth_internal( result = await _batch_import_standard_oauth_internal(
@@ -2272,6 +2300,7 @@ async def _run_batch_import_task(
proxy_config=proxy_config, proxy_config=proxy_config,
key_proxy=key_proxy, key_proxy=key_proxy,
progress_hook=progress_hook, progress_hook=progress_hook,
concurrency=batch_concurrency,
) )
state["status"] = "completed" state["status"] = "completed"
@@ -2391,12 +2420,14 @@ async def _batch_import_kiro_internal(
proxy_config: dict[str, Any] | None = None, proxy_config: dict[str, Any] | None = None,
key_proxy: dict[str, Any] | None = None, key_proxy: dict[str, Any] | None = None,
progress_hook: BatchImportProgressHook | None = None, progress_hook: BatchImportProgressHook | None = None,
concurrency: int = 1,
) -> BatchImportResponse: ) -> BatchImportResponse:
"""Kiro 批量导入内部实现(供通用端点调用)。 """Kiro 批量导入内部实现(供通用端点调用)。
Args: Args:
proxy_config: 本次操作使用的代理配置(已由调用方解析) proxy_config: 本次操作使用的代理配置(已由调用方解析)
key_proxy: 需要保存到 Key 上的代理配置 key_proxy: 需要保存到 Key 上的代理配置
concurrency: 并发数(从 pool_advanced.batch_concurrency 读取)
""" """
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
from src.services.provider.adapters.kiro.token_manager import refresh_access_token from src.services.provider.adapters.kiro.token_manager import refresh_access_token
@@ -2409,141 +2440,154 @@ async def _batch_import_kiro_internal(
api_formats = _get_provider_api_formats(provider) api_formats = _get_provider_api_formats(provider)
results: list[BatchImportResultItem] = [] total = len(credentials)
results: list[BatchImportResultItem] = [None] * total # type: ignore[list-item]
success_count = 0 success_count = 0
failed_count = 0 failed_count = 0
processed_count = 0
db_lock = asyncio.Lock()
sem = asyncio.Semaphore(max(concurrency, 1))
for idx, cred in enumerate(credentials): async def _process_entry(idx: int, cred: dict[str, Any]) -> None:
nonlocal success_count, failed_count, processed_count
result_item: BatchImportResultItem result_item: BatchImportResultItem
try:
is_valid, error_msg = KiroAuthConfig.validate_required_fields(cred) async with sem:
if not is_valid: try:
is_valid, error_msg = KiroAuthConfig.validate_required_fields(cred)
if not is_valid:
result_item = BatchImportResultItem(
index=idx,
status="error",
error=error_msg,
)
failed_count += 1
processed_count += 1
results[idx] = result_item
if progress_hook is not None:
await progress_hook(
total, processed_count, success_count, failed_count, result_item
)
return
cfg = KiroAuthConfig.from_dict(cred)
cfg.provider_type = ProviderType.KIRO.value
try:
access_token, new_cfg = await refresh_access_token(
cfg,
proxy_config=proxy_config,
timeout_seconds=timeout_seconds,
)
except Exception as exc:
result_item = BatchImportResultItem(
index=idx,
status="error",
error=f"Token 验证失败: {exc}",
)
failed_count += 1
processed_count += 1
results[idx] = result_item
if progress_hook is not None:
await progress_hook(
total, processed_count, success_count, failed_count, result_item
)
return
email = await _fetch_kiro_email(new_cfg.to_dict(), proxy_config=proxy_config)
if email and not new_cfg.email:
new_cfg.email = email
async with db_lock:
try:
existing_key = _check_duplicate_oauth_account(
db, provider_id, new_cfg.to_dict()
)
except InvalidRequestException as exc:
result_item = BatchImportResultItem(
index=idx,
status="error",
error=str(exc),
)
failed_count += 1
processed_count += 1
results[idx] = result_item
if progress_hook is not None:
await progress_hook(
total,
processed_count,
success_count,
failed_count,
result_item,
)
return
replaced = False
if existing_key:
new_key = _update_existing_oauth_key(
db,
existing_key,
access_token,
new_cfg.to_dict(),
flush_only=True,
proxy=key_proxy,
)
name = existing_key.name
replaced = True
else:
name = _build_kiro_key_name(
email, new_cfg.auth_method, new_cfg.refresh_token
)
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=new_cfg.to_dict(),
api_formats=api_formats,
flush_only=True,
proxy=key_proxy,
)
result_item = BatchImportResultItem( result_item = BatchImportResultItem(
index=idx, index=idx,
status="error", status="success",
error=error_msg, key_id=str(new_key.id),
key_name=name,
auth_method=new_cfg.auth_method or "social",
replaced=replaced,
) )
failed_count += 1 success_count += 1
results.append(result_item)
if progress_hook is not None:
await progress_hook(
len(credentials),
idx + 1,
success_count,
failed_count,
result_item,
)
continue
cfg = KiroAuthConfig.from_dict(cred)
cfg.provider_type = ProviderType.KIRO.value
try:
access_token, new_cfg = await refresh_access_token(
cfg,
proxy_config=proxy_config,
timeout_seconds=timeout_seconds,
)
except Exception as exc: except Exception as exc:
logger.error("批量导入 Kiro 凭据失败 (index={}): {}", idx, exc)
result_item = BatchImportResultItem( result_item = BatchImportResultItem(
index=idx, index=idx,
status="error", status="error",
error=f"Token 验证失败: {exc}", error=f"导入失败: {exc}",
) )
failed_count += 1 failed_count += 1
results.append(result_item)
if progress_hook is not None:
await progress_hook(
len(credentials),
idx + 1,
success_count,
failed_count,
result_item,
)
continue
email = await _fetch_kiro_email(new_cfg.to_dict(), proxy_config=proxy_config) processed_count += 1
if email and not new_cfg.email: results[idx] = result_item
new_cfg.email = email
try:
existing_key = _check_duplicate_oauth_account(db, provider_id, new_cfg.to_dict())
except InvalidRequestException as exc:
result_item = BatchImportResultItem(
index=idx,
status="error",
error=str(exc),
)
failed_count += 1
results.append(result_item)
if progress_hook is not None:
await progress_hook(
len(credentials),
idx + 1,
success_count,
failed_count,
result_item,
)
continue
replaced = False
if existing_key:
new_key = _update_existing_oauth_key(
db,
existing_key,
access_token,
new_cfg.to_dict(),
flush_only=True,
proxy=key_proxy,
)
name = existing_key.name
replaced = True
else:
name = _build_kiro_key_name(email, new_cfg.auth_method, new_cfg.refresh_token)
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=new_cfg.to_dict(),
api_formats=api_formats,
flush_only=True,
proxy=key_proxy,
)
result_item = BatchImportResultItem(
index=idx,
status="success",
key_id=str(new_key.id),
key_name=name,
auth_method=new_cfg.auth_method or "social",
replaced=replaced,
)
success_count += 1
except Exception as exc:
logger.error("批量导入 Kiro 凭据失败 (index={}): {}", idx, exc)
result_item = BatchImportResultItem(
index=idx,
status="error",
error=f"导入失败: {exc}",
)
failed_count += 1
results.append(result_item)
if progress_hook is not None: if progress_hook is not None:
await progress_hook( await progress_hook(total, processed_count, success_count, failed_count, result_item)
len(credentials),
idx + 1, await asyncio.gather(
success_count, *[_process_entry(i, c) for i, c in enumerate(credentials)],
failed_count, return_exceptions=True,
result_item, )
)
# 提交所有成功的记录 # 提交所有成功的记录
if success_count > 0: if success_count > 0:
db.commit() db.commit()
final_results = [r for r in results if r is not None]
if len(final_results) != total:
logger.warning(
"[KIRO_BATCH_IMPORT] 结果不完整: expected={}, got={}", total, len(final_results)
)
logger.info( logger.info(
"[KIRO_BATCH_IMPORT] Provider {}: 成功 {}/{}, 失败 {}", "[KIRO_BATCH_IMPORT] Provider {}: 成功 {}/{}, 失败 {}",
provider_id, provider_id,
@@ -2556,7 +2600,7 @@ async def _batch_import_kiro_internal(
total=len(credentials), total=len(credentials),
success=success_count, success=success_count,
failed=failed_count, failed=failed_count,
results=results, results=final_results,
) )

View File

@@ -77,8 +77,6 @@ from src.models.database import (
User, User,
) )
from src.services.provider.behavior import get_provider_behavior from src.services.provider.behavior import get_provider_behavior
from src.services.provider.fingerprint import ensure_key_fingerprint
from src.services.provider.request_context import set_current_fingerprint
from src.services.provider.stream_policy import ( from src.services.provider.stream_policy import (
enforce_stream_mode_for_upstream, enforce_stream_mode_for_upstream,
get_upstream_stream_policy, get_upstream_stream_policy,
@@ -721,8 +719,6 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else: else:
request_body = dict(original_request_body) request_body = dict(original_request_body)
set_current_fingerprint(ensure_key_fingerprint(key, persist_if_missing=True))
provider_type = str(getattr(provider, "provider_type", "") or "").lower() provider_type = str(getattr(provider, "provider_type", "") or "").lower()
behavior = get_provider_behavior( behavior = get_provider_behavior(
provider_type=provider_type, provider_type=provider_type,
@@ -751,6 +747,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
key_id=str(getattr(key, "id", "") or ""), key_id=str(getattr(key, "id", "") or ""),
is_stream=upstream_is_stream, is_stream=upstream_is_stream,
provider_id=str(getattr(provider, "id", "") or ""), provider_id=str(getattr(provider, "id", "") or ""),
key=key,
) )
# 跨格式:先做请求体转换(失败触发 failover # 跨格式:先做请求体转换(失败触发 failover

View File

@@ -40,8 +40,6 @@ from src.core.exceptions import (
) )
from src.core.logger import logger from src.core.logger import logger
from src.services.provider.behavior import get_provider_behavior from src.services.provider.behavior import get_provider_behavior
from src.services.provider.fingerprint import ensure_key_fingerprint
from src.services.provider.request_context import set_current_fingerprint
from src.services.provider.stream_policy import ( from src.services.provider.stream_policy import (
enforce_stream_mode_for_upstream, enforce_stream_mode_for_upstream,
get_upstream_stream_policy, get_upstream_stream_policy,
@@ -327,8 +325,6 @@ class CliStreamMixin:
) )
ctx.needs_conversion = needs_conversion ctx.needs_conversion = needs_conversion
set_current_fingerprint(ensure_key_fingerprint(key, persist_if_missing=True))
provider_type = str(getattr(provider, "provider_type", "") or "").lower() provider_type = str(getattr(provider, "provider_type", "") or "").lower()
behavior = get_provider_behavior( behavior = get_provider_behavior(
provider_type=provider_type, provider_type=provider_type,
@@ -357,6 +353,7 @@ class CliStreamMixin:
key_id=str(getattr(key, "id", "") or ""), key_id=str(getattr(key, "id", "") or ""),
is_stream=upstream_is_stream, is_stream=upstream_is_stream,
provider_id=str(getattr(provider, "id", "") or ""), provider_id=str(getattr(provider, "id", "") or ""),
key=key,
) )
# 跨格式:先做请求体转换(失败触发 failover # 跨格式:先做请求体转换(失败触发 failover

View File

@@ -33,8 +33,6 @@ from src.core.exceptions import (
) )
from src.core.logger import logger from src.core.logger import logger
from src.services.provider.behavior import get_provider_behavior from src.services.provider.behavior import get_provider_behavior
from src.services.provider.fingerprint import ensure_key_fingerprint
from src.services.provider.request_context import set_current_fingerprint
from src.services.provider.stream_policy import ( from src.services.provider.stream_policy import (
enforce_stream_mode_for_upstream, enforce_stream_mode_for_upstream,
get_upstream_stream_policy, get_upstream_stream_policy,
@@ -143,8 +141,6 @@ class CliSyncMixin:
) )
needs_conversion = bool(getattr(candidate, "needs_conversion", False)) needs_conversion = bool(getattr(candidate, "needs_conversion", False))
set_current_fingerprint(ensure_key_fingerprint(key, persist_if_missing=True))
provider_type = str(getattr(provider, "provider_type", "") or "").lower() provider_type = str(getattr(provider, "provider_type", "") or "").lower()
behavior = get_provider_behavior( behavior = get_provider_behavior(
provider_type=provider_type, provider_type=provider_type,
@@ -173,6 +169,7 @@ class CliSyncMixin:
key_id=str(getattr(key, "id", "") or ""), key_id=str(getattr(key, "id", "") or ""),
is_stream=upstream_is_stream, is_stream=upstream_is_stream,
provider_id=str(getattr(provider, "id", "") or ""), provider_id=str(getattr(provider, "id", "") or ""),
key=key,
) )
# 跨格式:先做请求体转换(失败触发 failover # 跨格式:先做请求体转换(失败触发 failover

View File

@@ -235,6 +235,9 @@ async def lifespan(app: FastAPI) -> Any:
# 启动月卡额度重置调度器(仅一个 worker 执行) # 启动月卡额度重置调度器(仅一个 worker 执行)
logger.info("启动月卡额度重置调度器...") logger.info("启动月卡额度重置调度器...")
from src.services.model.fetch_scheduler import get_model_fetch_scheduler from src.services.model.fetch_scheduler import get_model_fetch_scheduler
from src.services.provider_keys.pool_quota_probe_scheduler import (
get_pool_quota_probe_scheduler,
)
from src.services.system.maintenance_scheduler import get_maintenance_scheduler from src.services.system.maintenance_scheduler import get_maintenance_scheduler
from src.services.task.task_poller import get_task_poller from src.services.task.task_poller import get_task_poller
from src.services.usage.quota_scheduler import get_quota_scheduler from src.services.usage.quota_scheduler import get_quota_scheduler
@@ -243,6 +246,7 @@ async def lifespan(app: FastAPI) -> Any:
quota_scheduler = get_quota_scheduler() quota_scheduler = get_quota_scheduler()
maintenance_scheduler = get_maintenance_scheduler() maintenance_scheduler = get_maintenance_scheduler()
model_fetch_scheduler = get_model_fetch_scheduler() model_fetch_scheduler = get_model_fetch_scheduler()
pool_quota_probe_scheduler = get_pool_quota_probe_scheduler()
task_poller = get_task_poller() task_poller = get_task_poller()
task_coordinator = StartupTaskCoordinator(redis_client) task_coordinator = StartupTaskCoordinator(redis_client)
@@ -272,6 +276,15 @@ async def lifespan(app: FastAPI) -> Any:
logger.info("检测到其他 worker 已运行模型获取调度器,本实例跳过") logger.info("检测到其他 worker 已运行模型获取调度器,本实例跳过")
model_fetch_scheduler = None # type: ignore[assignment] model_fetch_scheduler = None # type: ignore[assignment]
# 启动号池额度主动探测调度器
pool_quota_probe_scheduler_active = await task_coordinator.acquire("pool_quota_probe_scheduler")
if pool_quota_probe_scheduler_active:
logger.info("启动号池额度主动探测调度器...")
await pool_quota_probe_scheduler.start()
else:
logger.info("检测到其他 worker 已运行号池额度主动探测调度器,本实例跳过")
pool_quota_probe_scheduler = None # type: ignore[assignment]
# 启动异步任务轮询服务(当前仅视频) # 启动异步任务轮询服务(当前仅视频)
task_poller_active = await task_coordinator.acquire("task_poller:video") task_poller_active = await task_coordinator.acquire("task_poller:video")
if task_poller_active: if task_poller_active:
@@ -339,6 +352,11 @@ async def lifespan(app: FastAPI) -> Any:
await model_fetch_scheduler.stop() await model_fetch_scheduler.stop()
await task_coordinator.release("model_fetch_scheduler") await task_coordinator.release("model_fetch_scheduler")
if pool_quota_probe_scheduler:
logger.info("停止号池额度主动探测调度器...")
await pool_quota_probe_scheduler.stop()
await task_coordinator.release("pool_quota_probe_scheduler")
if task_poller: if task_poller:
logger.info("停止 TaskPollervideo...") logger.info("停止 TaskPollervideo...")
await task_poller.stop() await task_poller.stop()

View File

@@ -283,6 +283,20 @@ class PoolAdvancedConfig(BaseModel):
None, None,
description="关键词临时不可调度规则: [{'keyword': '...', 'duration_minutes': 5}]", description="关键词临时不可调度规则: [{'keyword': '...', 'duration_minutes': 5}]",
) )
batch_concurrency: int | None = Field(
None,
ge=1,
le=32,
description="批量操作并发数(前端批量刷新 OAuth/额度等)。默认 8",
)
probing_enabled: bool = Field(False, description="启用主动探测(定期检查 Key 可用性)")
probing_interval_minutes: int | None = Field(
None,
ge=1,
le=1440,
description="主动探测间隔(分钟)。默认 10",
)
auto_remove_banned_keys: bool = Field(False, description="检测到封号时自动清除账号")
class ClaudeCodeAdvancedConfig(BaseModel): class ClaudeCodeAdvancedConfig(BaseModel):

View File

@@ -7,6 +7,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import re import re
from typing import Any from typing import Any
@@ -25,6 +26,7 @@ from src.core.logger import logger
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.health.monitor import health_monitor from src.services.health.monitor import health_monitor
from src.services.provider.format import normalize_endpoint_signature from src.services.provider.format import normalize_endpoint_signature
from src.services.provider.pool.config import parse_pool_config
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
from src.services.rate_limit.detector import RateLimitType, detect_rate_limit_type from src.services.rate_limit.detector import RateLimitType, detect_rate_limit_type
from src.services.scheduling.aware_scheduler import CacheAwareScheduler from src.services.scheduling.aware_scheduler import CacheAwareScheduler
@@ -168,7 +170,7 @@ class ErrorHandlerService:
and str(getattr(key, "auth_type", "") or "").lower() == "oauth" and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
and self._is_account_validation_required(error_response_text) and self._is_account_validation_required(error_response_text)
): ):
self._mark_oauth_key_blocked(key, request_id) self._mark_oauth_key_blocked(key, request_id, provider=provider)
# 403 suspended -> 标记 OAuth key 为账号被暂停 # 403 suspended -> 标记 OAuth key 为账号被暂停
elif ( elif (
status_code == 403 status_code == 403
@@ -176,7 +178,12 @@ class ErrorHandlerService:
and str(getattr(key, "auth_type", "") or "").lower() == "oauth" and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
and self._is_account_suspended(error_response_text) and self._is_account_suspended(error_response_text)
): ):
self._mark_oauth_key_blocked(key, request_id, reason="AWS 账号被暂停") self._mark_oauth_key_blocked(
key,
request_id,
reason="AWS 账号被暂停",
provider=provider,
)
return return
# 限流错误 # 限流错误
@@ -345,6 +352,8 @@ class ErrorHandlerService:
key: ProviderAPIKey, key: ProviderAPIKey,
request_id: str | None, request_id: str | None,
reason: str = "Google 要求验证账号", reason: str = "Google 要求验证账号",
*,
provider: Provider,
) -> None: ) -> None:
"""标记 OAuth key 为账号级别封禁""" """标记 OAuth key 为账号级别封禁"""
try: try:
@@ -355,6 +364,26 @@ class ErrorHandlerService:
key.oauth_invalid_at = datetime.now(timezone.utc) key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = f"{OAUTH_ACCOUNT_BLOCK_PREFIX}{reason}" key.oauth_invalid_reason = f"{OAUTH_ACCOUNT_BLOCK_PREFIX}{reason}"
key.is_active = False key.is_active = False
pool_cfg = parse_pool_config(getattr(provider, "config", None))
auto_remove_enabled = bool(pool_cfg and pool_cfg.auto_remove_banned_keys)
if auto_remove_enabled:
key_id = str(getattr(key, "id", "") or "")
provider_id = str(getattr(key, "provider_id", "") or "")
display = self._format_key_display(key)
self.db.delete(key)
self.db.commit()
self._schedule_auto_cleanup_after_delete(provider_id=provider_id, key_id=key_id)
logger.warning(
" [{}] {}{} 已标记为账号异常并自动清除",
request_id,
display,
reason,
)
return
self.db.commit() self.db.commit()
logger.warning( logger.warning(
" [{}] {}{} 已标记为账号异常并自动停用", " [{}] {}{} 已标记为账号异常并自动停用",
@@ -364,3 +393,31 @@ class ErrorHandlerService:
) )
except Exception as mark_exc: except Exception as mark_exc:
logger.debug(" [{}] 标记 oauth_invalid 失败: {}", request_id, mark_exc) logger.debug(" [{}] 标记 oauth_invalid 失败: {}", request_id, mark_exc)
@staticmethod
def _schedule_auto_cleanup_after_delete(*, provider_id: str, key_id: str) -> None:
if not provider_id or not key_id:
return
async def _cleanup() -> None:
from src.api.base.models_service import invalidate_models_list_cache
from src.services.cache.provider_cache import ProviderCacheService
from src.services.provider.pool import redis_ops as pool_redis
await ProviderCacheService.invalidate_provider_api_key_cache(key_id)
await invalidate_models_list_cache()
await asyncio.gather(
pool_redis.clear_cooldown(provider_id, key_id),
pool_redis.clear_cost(provider_id, key_id),
return_exceptions=True,
)
task = asyncio.get_running_loop().create_task(_cleanup())
def _log_async_error(done_task: asyncio.Task[Any]) -> None:
try:
done_task.result()
except Exception as exc:
logger.debug("auto cleanup side effect failed for key {}: {}", key_id[:8], exc)
task.add_done_callback(_log_async_error)

View File

@@ -585,11 +585,19 @@ class ClaudeCodeEnvelope:
key_id: str, key_id: str,
is_stream: bool, is_stream: bool,
provider_id: str | None = None, provider_id: str | None = None,
key: Any = None,
) -> str | None: ) -> str | None:
from src.services.provider.adapters.claude_code.context import ( from src.services.provider.adapters.claude_code.context import (
build_and_set_claude_code_request_context, build_and_set_claude_code_request_context,
) )
# 在 envelope 层设置指纹 context var仅 Claude Code 需要指纹注入)
if key is not None:
from src.services.provider.fingerprint import ensure_key_fingerprint
from src.services.provider.request_context import set_current_fingerprint
set_current_fingerprint(ensure_key_fingerprint(key, persist_if_missing=True))
_ctx, tls_profile = build_and_set_claude_code_request_context( _ctx, tls_profile = build_and_set_claude_code_request_context(
provider_config=provider_config, provider_config=provider_config,
key_id=key_id, key_id=key_id,

View File

@@ -61,6 +61,7 @@ class ProviderEnvelope(Protocol):
key_id: str, key_id: str,
is_stream: bool, is_stream: bool,
provider_id: str | None = None, provider_id: str | None = None,
key: Any = None,
) -> str | None: ) -> str | None:
"""Pre-wrap hook: build provider-specific request context. """Pre-wrap hook: build provider-specific request context.

View File

@@ -82,6 +82,11 @@ class PoolConfig:
# -- Temporary Unschedulable Rules ---------------------------------------- # -- Temporary Unschedulable Rules ----------------------------------------
unschedulable_rules: list[UnschedulableRule] = field(default_factory=list) unschedulable_rules: list[UnschedulableRule] = field(default_factory=list)
# -- Quota Probing --------------------------------------------------------
probing_enabled: bool = False
probing_interval_minutes: int = 10
auto_remove_banned_keys: bool = False
# -- Stream Timeout Auto-Pause -------------------------------------------- # -- Stream Timeout Auto-Pause --------------------------------------------
stream_timeout_threshold: int = 3 # N timeouts within window trigger cooldown stream_timeout_threshold: int = 3 # N timeouts within window trigger cooldown
stream_timeout_window_seconds: int = 1800 # 30 min counting window stream_timeout_window_seconds: int = 1800 # 30 min counting window
@@ -188,6 +193,9 @@ def parse_pool_config(provider_config: Any) -> PoolConfig | None:
proactive_refresh_seconds=_int_or("proactive_refresh_seconds", 180), proactive_refresh_seconds=_int_or("proactive_refresh_seconds", 180),
health_policy_enabled=_bool_or("health_policy_enabled", True), health_policy_enabled=_bool_or("health_policy_enabled", True),
unschedulable_rules=rules, unschedulable_rules=rules,
probing_enabled=_bool_or("probing_enabled", False),
probing_interval_minutes=max(1, min(_int_or("probing_interval_minutes", 10), 1440)),
auto_remove_banned_keys=_bool_or("auto_remove_banned_keys", False),
stream_timeout_threshold=_int_or("stream_timeout_threshold", 3), stream_timeout_threshold=_int_or("stream_timeout_threshold", 3),
stream_timeout_window_seconds=_int_or("stream_timeout_window_seconds", 1800), stream_timeout_window_seconds=_int_or("stream_timeout_window_seconds", 1800),
stream_timeout_cooldown_seconds=_int_or("stream_timeout_cooldown_seconds", 300), stream_timeout_cooldown_seconds=_int_or("stream_timeout_cooldown_seconds", 300),

View File

@@ -9,18 +9,31 @@ from src.services.provider.pool.dimensions import get_preset_dimension, get_pres
from src.services.provider.pool.dimensions._helpers import rank_ascending, safe_float from src.services.provider.pool.dimensions._helpers import rank_ascending, safe_float
from src.services.provider.pool.strategy import register_pool_strategy from src.services.provider.pool.strategy import register_pool_strategy
# When LRU is enabled alongside presets, this fraction of the final score
# comes from the LRU rank (tiebreaker to avoid same-score collisions). def _normalize_mutex_group(value: Any) -> str | None:
_LRU_BLEND_FACTOR = 0.04 if not isinstance(value, str):
# Positional weight decay factor: weight = 1 / (1 + DECAY * index). return None
_POSITIONAL_DECAY = 0.6 normalized = value.strip().lower()
return normalized or None
def _get_preset_mutex_group(preset_name: str) -> str | None:
# LRU is a built-in preset (not in registry) but shares the distribution mutex group.
if preset_name == "lru":
return "distribution_mode"
dim = get_preset_dimension(preset_name)
if dim is None:
return None
return _normalize_mutex_group(getattr(dim, "mutex_group", None))
def _normalize_presets_from_config(config: Any) -> tuple[tuple[str, str | None], ...]: def _normalize_presets_from_config(config: Any) -> tuple[tuple[str, str | None], ...]:
"""Extract enabled (preset_name, mode) tuples from config.scheduling_presets. """Extract enabled (preset_name, mode) tuples from config.scheduling_presets.
Supports both new SchedulingPreset objects and legacy string lists. Supports both new SchedulingPreset objects and legacy string lists.
Excludes ``lru`` since LRU is handled separately as a blend factor. Excludes ``lru`` from output (LRU is a final tie-breaker only).
For mutex groups, enabled members inherit the group's first appearance index
so the selected member keeps the group's visible priority slot.
""" """
raw = getattr(config, "scheduling_presets", ()) raw = getattr(config, "scheduling_presets", ())
@@ -28,9 +41,9 @@ def _normalize_presets_from_config(config: Any) -> tuple[tuple[str, str | None],
return () return ()
allowed = get_preset_names() | {"lru"} allowed = get_preset_names() | {"lru"}
ordered: list[tuple[str, str | None]] = [] entries: list[tuple[int, str, bool, str | None]] = []
seen: set[str] = set() seen: set[str] = set()
for item in raw: for idx, item in enumerate(raw):
preset_name: str | None = None preset_name: str | None = None
enabled = True enabled = True
mode: str | None = None mode: str | None = None
@@ -48,13 +61,36 @@ def _normalize_presets_from_config(config: Any) -> tuple[tuple[str, str | None],
if not preset_name or preset_name not in allowed or preset_name in seen: if not preset_name or preset_name not in allowed or preset_name in seen:
continue continue
if not enabled:
continue
if preset_name == "lru":
continue
seen.add(preset_name) seen.add(preset_name)
ordered.append((preset_name, mode)) entries.append((idx, preset_name, enabled, mode))
return tuple(ordered)
if not entries:
return ()
group_anchor_index: dict[str, int] = {}
for idx, preset_name, _enabled, _mode in entries:
mutex_group = _get_preset_mutex_group(preset_name)
if mutex_group and mutex_group not in group_anchor_index:
group_anchor_index[mutex_group] = idx
ordered_enabled: list[tuple[int, int, str, str | None]] = []
group_enabled: dict[str, tuple[int, int, str, str | None]] = {}
for idx, preset_name, enabled, mode in entries:
if not enabled or preset_name == "lru":
continue
mutex_group = _get_preset_mutex_group(preset_name)
if not mutex_group:
ordered_enabled.append((idx, idx, preset_name, mode))
continue
anchor = group_anchor_index.get(mutex_group, idx)
existing = group_enabled.get(mutex_group)
if existing is None or idx < existing[1]:
group_enabled[mutex_group] = (anchor, idx, preset_name, mode)
ordered_enabled.extend(group_enabled.values())
ordered_enabled.sort(key=lambda item: (item[0], item[1]))
return tuple((preset_name, mode) for _anchor, _idx, preset_name, mode in ordered_enabled)
class MultiScoreStrategy: class MultiScoreStrategy:
@@ -143,34 +179,64 @@ class MultiScoreStrategy:
keys_by_id: dict[str, Any], keys_by_id: dict[str, Any],
context: dict[str, Any], context: dict[str, Any],
) -> float: ) -> float:
lru_rank_asc = rank_ascending(key_id, lru_scores, all_key_ids) cache_signature = (tuple(all_key_ids), presets, bool(lru_enabled))
cache = context.get("_preset_hard_order_cache")
if (
isinstance(cache, dict)
and cache.get("signature") == cache_signature
and isinstance(cache.get("ranks"), dict)
):
cached_rank = safe_float(cache["ranks"].get(key_id))
if cached_rank is not None:
return max(0.0, min(cached_rank, 1.0))
weighted_sum = 0.0 # Hard-priority semantics:
weight_sum = 0.0 # 1) Compare by preset[0] metric first;
# 2) only if tied, compare preset[1], preset[2], ...
for idx, (preset_name, mode) in enumerate(presets): # 3) if all preset metrics tie and LRU is enabled, use LRU as final tiebreak.
metric = 0.5 metric_vectors: dict[str, tuple[float, ...]] = {}
dim = get_preset_dimension(preset_name) for kid in all_key_ids:
if dim is not None: vector_parts: list[float] = []
metric = dim.compute_metric( for preset_name, mode in presets:
key_id=key_id, metric = 0.5
all_key_ids=all_key_ids, dim = get_preset_dimension(preset_name)
keys_by_id=keys_by_id, if dim is not None:
lru_scores=lru_scores, metric = dim.compute_metric(
context=context, key_id=kid,
mode=mode, all_key_ids=all_key_ids,
keys_by_id=keys_by_id,
lru_scores=lru_scores,
context=context,
mode=mode,
)
metric_value = safe_float(metric)
vector_parts.append(
max(0.0, min(metric_value, 1.0)) if metric_value is not None else 0.5
) )
weight = 1.0 / (1.0 + _POSITIONAL_DECAY * idx)
weighted_sum += metric * weight
weight_sum += weight
if weight_sum <= 0: if lru_enabled:
return lru_rank_asc vector_parts.append(rank_ascending(kid, lru_scores, all_key_ids))
lru_blend = _LRU_BLEND_FACTOR if lru_enabled else 0.0 metric_vectors[kid] = tuple(vector_parts)
preset_blend = 1.0 - lru_blend
blended = (weighted_sum / weight_sum) * preset_blend + lru_rank_asc * lru_blend decorated = [
return max(0.0, min(blended, 1.0)) (metric_vectors.get(kid, (0.5,)), idx, kid) for idx, kid in enumerate(all_key_ids)
]
decorated.sort(key=lambda item: (item[0], item[1]))
total = len(decorated)
ranks: dict[str, float] = {}
for rank_idx, (_vec, _idx, kid) in enumerate(decorated):
ranks[kid] = 0.0 if total <= 1 else rank_idx / float(total - 1)
context["_preset_hard_order_cache"] = {
"signature": cache_signature,
"ranks": ranks,
}
rank = safe_float(ranks.get(key_id))
if rank is None:
return 0.5
return max(0.0, min(rank, 1.0))
register_pool_strategy("multi_score", MultiScoreStrategy()) register_pool_strategy("multi_score", MultiScoreStrategy())

View File

@@ -13,6 +13,10 @@ from src.core.logger import logger
from src.core.provider_types import ProviderType, normalize_provider_type from src.core.provider_types import ProviderType, normalize_provider_type
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.model.upstream_fetcher import merge_upstream_metadata from src.services.model.upstream_fetcher import merge_upstream_metadata
from src.services.provider.pool import redis_ops as pool_redis
from src.services.provider.pool.account_state import resolve_pool_account_state
from src.services.provider.pool.config import parse_pool_config
from src.services.provider_keys.key_side_effects import run_delete_key_side_effects
from src.services.provider_keys.quota_refresh import ( from src.services.provider_keys.quota_refresh import (
refresh_antigravity_key_quota, refresh_antigravity_key_quota,
refresh_codex_key_quota, refresh_codex_key_quota,
@@ -74,6 +78,8 @@ async def refresh_provider_quota_for_provider(
provider_type = normalize_provider_type(getattr(provider, "provider_type", "")) provider_type = normalize_provider_type(getattr(provider, "provider_type", ""))
if provider_type not in {ProviderType.CODEX, ProviderType.ANTIGRAVITY, ProviderType.KIRO}: if provider_type not in {ProviderType.CODEX, ProviderType.ANTIGRAVITY, ProviderType.KIRO}:
raise InvalidRequestException("仅支持 Codex / Antigravity / Kiro 类型的 Provider 刷新限额") raise InvalidRequestException("仅支持 Codex / Antigravity / Kiro 类型的 Provider 刷新限额")
pool_cfg = parse_pool_config(getattr(provider, "config", None))
auto_remove_banned_keys = bool(pool_cfg and pool_cfg.auto_remove_banned_keys)
selected_key_ids: list[str] | None = None selected_key_ids: list[str] | None = None
if key_ids is not None: if key_ids is not None:
@@ -100,6 +106,7 @@ async def refresh_provider_quota_for_provider(
"total": 0, "total": 0,
"results": [], "results": [],
"message": "未提供可刷新的 Key", "message": "未提供可刷新的 Key",
"auto_removed": 0,
} }
keys_query = keys_query.filter(ProviderAPIKey.id.in_(selected_key_ids)) keys_query = keys_query.filter(ProviderAPIKey.id.in_(selected_key_ids))
@@ -111,6 +118,7 @@ async def refresh_provider_quota_for_provider(
"total": 0, "total": 0,
"results": [], "results": [],
"message": "没有可刷新的 Key", "message": "没有可刷新的 Key",
"auto_removed": 0,
} }
endpoint = _select_refresh_endpoint(provider, provider_type) endpoint = _select_refresh_endpoint(provider, provider_type)
@@ -159,7 +167,14 @@ async def refresh_provider_quota_for_provider(
failed_count += 1 failed_count += 1
# 统一更新数据库(避免在并发任务中操作 session # 统一更新数据库(避免在并发任务中操作 session
if metadata_updates or state_updates: auto_removed_contexts: list[tuple[str, str | None, list[str] | None]] = []
result_index_by_key_id: dict[str, dict[str, Any]] = {}
for result in results:
rid = str(result.get("key_id", "")).strip()
if rid:
result_index_by_key_id[rid] = result
if metadata_updates or state_updates or auto_remove_banned_keys:
for key in keys: for key in keys:
key_dirty = False key_dirty = False
if key.id in metadata_updates: if key.id in metadata_updates:
@@ -173,11 +188,58 @@ async def refresh_provider_quota_for_provider(
for field_name, field_value in updates.items(): for field_name, field_value in updates.items():
setattr(key, field_name, field_value) setattr(key, field_name, field_value)
key_dirty = True key_dirty = True
if auto_remove_banned_keys:
account_state = resolve_pool_account_state(
provider_type=provider_type,
upstream_metadata=getattr(key, "upstream_metadata", None),
oauth_invalid_reason=getattr(key, "oauth_invalid_reason", None),
)
if account_state.blocked:
key_id = str(getattr(key, "id", "") or "")
auto_removed_contexts.append(
(
key_id,
(
str(getattr(key, "provider_id", "") or "")
if getattr(key, "provider_id", None)
else None
),
getattr(key, "allowed_models", None),
)
)
if key_id and key_id in result_index_by_key_id:
result_index_by_key_id[key_id]["auto_removed"] = True
db.delete(key)
continue
if key_dirty: if key_dirty:
db.add(key) db.add(key)
db.commit() db.commit()
if auto_removed_contexts:
cleanup_coros = []
for key_id, pid, _allowed_models in auto_removed_contexts:
if not key_id or not pid:
continue
cleanup_coros.append(pool_redis.clear_cooldown(pid, key_id))
cleanup_coros.append(pool_redis.clear_cost(pid, key_id))
if cleanup_coros:
await asyncio.gather(*cleanup_coros, return_exceptions=True)
for _key_id, pid, allowed_models in auto_removed_contexts:
await run_delete_key_side_effects(
db=db,
provider_id=pid,
deleted_key_allowed_models=allowed_models,
)
logger.warning(
"[QUOTA_REFRESH] Provider {}: auto removed {} banned key(s): {}",
provider_id,
len(auto_removed_contexts),
[ctx[0][:8] for ctx in auto_removed_contexts if ctx[0]],
)
failed_details = [ failed_details = [
f"{r.get('key_name', r.get('key_id', '?'))}: {r.get('message', 'unknown')}" f"{r.get('key_name', r.get('key_id', '?'))}: {r.get('message', 'unknown')}"
for r in results for r in results
@@ -205,4 +267,5 @@ async def refresh_provider_quota_for_provider(
"failed": failed_count, "failed": failed_count,
"total": len(keys), "total": len(keys),
"results": results, "results": results,
"auto_removed": len(auto_removed_contexts),
} }

View File

@@ -0,0 +1,367 @@
"""
号池额度主动探测调度器。
行为:
- 当 provider.pool_advanced.probing_enabled=true 时启用
- Key 在静默超过 probing_interval_minutes 后,主动触发额度刷新
- Key 一旦被实际请求使用last_used_at 变新),探测冷却自动重置
"""
from __future__ import annotations
import os
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from src.clients.redis_client import get_redis_client
from src.core.logger import logger
from src.core.provider_types import ProviderType, normalize_provider_type
from src.database import create_session
from src.models.database import Provider, ProviderAPIKey
from src.services.provider.pool.config import parse_pool_config
from src.services.provider_keys.key_quota_service import refresh_provider_quota_for_provider
from src.services.system.scheduler import get_scheduler
# 与 admin 刷新额度 API 保持一致
_CODEX_WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
_REDIS_PREFIX = "ap:quota_probe:last"
_DEFAULT_INTERVAL_MINUTES = 10
_DEFAULT_SCAN_INTERVAL_SECONDS = 60
_DEFAULT_MAX_KEYS_PER_PROVIDER = 50
_MAX_INTERVAL_MINUTES = 1440
_SUPPORTED_PROVIDER_TYPES = {
ProviderType.CODEX.value,
ProviderType.KIRO.value,
ProviderType.ANTIGRAVITY.value,
}
def _probe_stamp_key(provider_id: str, key_id: str) -> str:
return f"{_REDIS_PREFIX}:{provider_id}:{key_id}"
def _to_unix_seconds(value: datetime | None) -> int | None:
if not isinstance(value, datetime):
return None
dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
try:
return int(dt.timestamp())
except Exception:
return None
def _to_float(value: Any) -> float | None:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
text = value.strip()
if not text:
return None
try:
return float(text)
except ValueError:
return None
return None
def _extract_quota_updated_at(provider_type: str, upstream_metadata: Any) -> int | None:
if not isinstance(upstream_metadata, dict):
return None
normalized = normalize_provider_type(provider_type)
if normalized == ProviderType.CODEX.value:
bucket = upstream_metadata.get("codex")
elif normalized == ProviderType.KIRO.value:
bucket = upstream_metadata.get("kiro")
elif normalized == ProviderType.ANTIGRAVITY.value:
bucket = upstream_metadata.get("antigravity")
else:
return None
if not isinstance(bucket, dict):
return None
updated_at = _to_float(bucket.get("updated_at"))
if updated_at is None or updated_at <= 0:
return None
# 兼容毫秒时间戳
if updated_at > 1_000_000_000_000:
updated_at /= 1000
return int(updated_at)
def _parse_probe_stamp(raw_value: Any) -> int | None:
parsed = _to_float(raw_value)
if parsed is None or parsed <= 0:
return None
return int(parsed)
def _normalize_probe_interval_minutes(raw_value: Any) -> int:
parsed = _to_float(raw_value)
if parsed is None:
return _DEFAULT_INTERVAL_MINUTES
return max(1, min(int(parsed), _MAX_INTERVAL_MINUTES))
def _select_probe_key_ids(
*,
keys: list[ProviderAPIKey],
provider_type: str,
now_ts: int,
interval_seconds: int,
last_probe_timestamps: dict[str, int],
limit: int,
) -> list[str]:
stale: list[tuple[int, str]] = []
for key in keys:
key_id = str(getattr(key, "id", "") or "")
if not key_id:
continue
last_used_ts = _to_unix_seconds(getattr(key, "last_used_at", None))
quota_updated_ts = _extract_quota_updated_at(
provider_type,
getattr(key, "upstream_metadata", None),
)
last_probe_ts = last_probe_timestamps.get(key_id)
anchor_ts = max(last_used_ts or 0, quota_updated_ts or 0, last_probe_ts or 0)
if anchor_ts <= 0 or (now_ts - anchor_ts) >= interval_seconds:
stale.append((anchor_ts, key_id))
# anchor 越小说明越久未被探测/使用,优先探测
stale.sort(key=lambda item: item[0])
if limit > 0:
stale = stale[:limit]
return [key_id for _, key_id in stale]
@dataclass(frozen=True, slots=True)
class _ProviderProbeTask:
provider_id: str
provider_type: str
probe_key_ids: list[str]
interval_seconds: int
class PoolQuotaProbeScheduler:
"""按号池高级配置执行额度主动探测。"""
def __init__(self) -> None:
scan_interval_raw = os.getenv(
"POOL_QUOTA_PROBE_SCAN_INTERVAL_SECONDS",
str(_DEFAULT_SCAN_INTERVAL_SECONDS),
)
max_keys_raw = os.getenv(
"POOL_QUOTA_PROBE_MAX_KEYS_PER_PROVIDER",
str(_DEFAULT_MAX_KEYS_PER_PROVIDER),
)
self.scan_interval_seconds = max(
15, int(_to_float(scan_interval_raw) or _DEFAULT_SCAN_INTERVAL_SECONDS)
)
self.max_keys_per_provider = max(
0, int(_to_float(max_keys_raw) or _DEFAULT_MAX_KEYS_PER_PROVIDER)
)
self.running = False
async def start(self) -> Any:
if self.running:
logger.warning("PoolQuotaProbeScheduler already running")
return
self.running = True
logger.info(
"PoolQuotaProbeScheduler started: scan={}s, max_keys_per_provider={}",
self.scan_interval_seconds,
self.max_keys_per_provider,
)
scheduler = get_scheduler()
scheduler.add_interval_job(
self._scheduled_probe_check,
seconds=self.scan_interval_seconds,
job_id="pool_quota_probe_check",
name="号池额度主动探测检查",
)
# 启动时立即执行一次,避免首次等待一个轮询周期
await self._run_probe_cycle()
async def stop(self) -> Any:
if not self.running:
return
self.running = False
logger.info("PoolQuotaProbeScheduler stopped")
async def _scheduled_probe_check(self) -> None:
if not self.running:
return
await self._run_probe_cycle()
async def _load_probe_timestamps(
self,
*,
redis_client: Any,
provider_id: str,
key_ids: list[str],
) -> dict[str, int]:
if redis_client is None or not key_ids:
return {}
redis_keys = [_probe_stamp_key(provider_id, key_id) for key_id in key_ids]
try:
values = await redis_client.mget(redis_keys)
except Exception as exc:
logger.debug("PoolQuotaProbeScheduler mget probe stamps failed: {}", exc)
return {}
mapping: dict[str, int] = {}
for key_id, raw in zip(key_ids, values, strict=False):
parsed = _parse_probe_stamp(raw)
if parsed is not None:
mapping[key_id] = parsed
return mapping
async def _mark_probe_timestamps(
self,
*,
redis_client: Any,
provider_id: str,
key_ids: list[str],
now_ts: int,
interval_seconds: int,
) -> None:
if redis_client is None or not key_ids:
return
ttl_seconds = max(interval_seconds * 2, 120)
try:
pipe = redis_client.pipeline(transaction=False)
value = str(now_ts)
for key_id in key_ids:
pipe.set(_probe_stamp_key(provider_id, key_id), value, ex=ttl_seconds)
await pipe.execute()
except Exception as exc:
logger.debug("PoolQuotaProbeScheduler set probe stamps failed: {}", exc)
async def _run_probe_cycle(self) -> None:
now_ts = int(time.time())
redis_client = await get_redis_client(require_redis=False)
# 第一阶段:用一个短生命周期 session 查出需要探测的 provider / key 信息
probe_tasks: list[_ProviderProbeTask] = []
db = create_session()
try:
providers = db.query(Provider).filter(Provider.is_active == True).all() # noqa: E712
for provider in providers:
provider_id = str(getattr(provider, "id", "") or "")
provider_type = normalize_provider_type(getattr(provider, "provider_type", ""))
if not provider_id or provider_type not in _SUPPORTED_PROVIDER_TYPES:
continue
pool_cfg = parse_pool_config(getattr(provider, "config", None))
if pool_cfg is None or not pool_cfg.probing_enabled:
continue
interval_minutes = _normalize_probe_interval_minutes(
pool_cfg.probing_interval_minutes
)
interval_seconds = interval_minutes * 60
keys = (
db.query(ProviderAPIKey)
.filter(
ProviderAPIKey.provider_id == provider_id,
ProviderAPIKey.is_active == True, # noqa: E712
)
.all()
)
if not keys:
continue
key_ids = [str(key.id) for key in keys if getattr(key, "id", None)]
probe_stamps = await self._load_probe_timestamps(
redis_client=redis_client,
provider_id=provider_id,
key_ids=key_ids,
)
probe_key_ids = _select_probe_key_ids(
keys=keys,
provider_type=provider_type,
now_ts=now_ts,
interval_seconds=interval_seconds,
last_probe_timestamps=probe_stamps,
limit=self.max_keys_per_provider,
)
if not probe_key_ids:
continue
probe_tasks.append(
_ProviderProbeTask(
provider_id=provider_id,
provider_type=provider_type,
probe_key_ids=probe_key_ids,
interval_seconds=interval_seconds,
)
)
finally:
db.close()
# 第二阶段:每个 provider 使用独立 session 执行探测
for task in probe_tasks:
# 先写探测节流时间戳,避免异常时高频重入
await self._mark_probe_timestamps(
redis_client=redis_client,
provider_id=task.provider_id,
key_ids=task.probe_key_ids,
now_ts=now_ts,
interval_seconds=task.interval_seconds,
)
probe_db = create_session()
try:
result = await refresh_provider_quota_for_provider(
db=probe_db,
provider_id=task.provider_id,
codex_wham_usage_url=_CODEX_WHAM_USAGE_URL,
key_ids=task.probe_key_ids,
)
logger.info(
"[POOL_PROBE] Provider {} ({}) 静默探测完成: selected={}, success={}, failed={}",
task.provider_id[:8],
task.provider_type,
len(task.probe_key_ids),
int(result.get("success") or 0),
int(result.get("failed") or 0),
)
except Exception as exc:
try:
probe_db.rollback()
except Exception:
pass
logger.warning(
"[POOL_PROBE] Provider {} ({}) 静默探测失败: {}",
task.provider_id[:8],
task.provider_type,
exc,
)
finally:
probe_db.close()
_pool_quota_probe_scheduler: PoolQuotaProbeScheduler | None = None
def get_pool_quota_probe_scheduler() -> PoolQuotaProbeScheduler:
global _pool_quota_probe_scheduler
if _pool_quota_probe_scheduler is None:
_pool_quota_probe_scheduler = PoolQuotaProbeScheduler()
return _pool_quota_probe_scheduler
__all__ = [
"PoolQuotaProbeScheduler",
"get_pool_quota_probe_scheduler",
"_select_probe_key_ids",
]

View File

@@ -12,6 +12,10 @@ async def test_enrich_auth_config_antigravity_adds_project_id_and_email() -> Non
auth_config: dict[str, object] = {} auth_config: dict[str, object] = {}
token_response: dict[str, object] = {} token_response: dict[str, object] = {}
from src.services.provider.envelope import ensure_providers_bootstrapped
ensure_providers_bootstrapped()
with ( with (
patch( patch(
"src.core.provider_oauth_utils.fetch_google_email", "src.core.provider_oauth_utils.fetch_google_email",

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, cast
from src.services.orchestration.error_handler import ErrorHandlerService
class _FakeDB:
def __init__(self) -> None:
self.deleted: list[object] = []
self.commit_count = 0
def delete(self, obj: object) -> None:
self.deleted.append(obj)
def commit(self) -> None:
self.commit_count += 1
def _build_key() -> SimpleNamespace:
return SimpleNamespace(
id="k1",
provider_id="p1",
name="k1-name",
auth_type="oauth",
auth_config=None,
oauth_invalid_at=None,
oauth_invalid_reason=None,
is_active=True,
)
def test_mark_oauth_key_blocked_auto_remove_enabled(monkeypatch: Any) -> None:
db = _FakeDB()
service = ErrorHandlerService(db=cast(Any, db))
key = _build_key()
provider = SimpleNamespace(config={"pool_advanced": {"auto_remove_banned_keys": True}})
monkeypatch.setattr(
ErrorHandlerService,
"_schedule_auto_cleanup_after_delete",
staticmethod(lambda **kwargs: None),
)
service._mark_oauth_key_blocked(cast(Any, key), "req-1", provider=cast(Any, provider))
assert db.commit_count == 1
assert db.deleted == [key]
assert key.is_active is False
assert str(key.oauth_invalid_reason).startswith("[ACCOUNT_BLOCK] ")
def test_mark_oauth_key_blocked_auto_remove_disabled() -> None:
db = _FakeDB()
service = ErrorHandlerService(db=cast(Any, db))
key = _build_key()
provider = SimpleNamespace(config={"pool_advanced": {"auto_remove_banned_keys": False}})
service._mark_oauth_key_blocked(cast(Any, key), "req-1", provider=cast(Any, provider))
assert db.commit_count == 1
assert db.deleted == []
assert key.is_active is False
assert str(key.oauth_invalid_reason).startswith("[ACCOUNT_BLOCK] ")

View File

@@ -39,6 +39,9 @@ def test_parse_pool_config_returns_defaults_for_empty_advanced() -> None:
assert cfg.proactive_refresh_seconds == 180 assert cfg.proactive_refresh_seconds == 180
assert cfg.health_policy_enabled is True assert cfg.health_policy_enabled is True
assert cfg.unschedulable_rules == [] assert cfg.unschedulable_rules == []
assert cfg.probing_enabled is False
assert cfg.probing_interval_minutes == 10
assert cfg.auto_remove_banned_keys is False
def test_parse_pool_config_overrides_values_legacy_string_list() -> None: def test_parse_pool_config_overrides_values_legacy_string_list() -> None:
@@ -66,6 +69,9 @@ def test_parse_pool_config_overrides_values_legacy_string_list() -> None:
"overload_cooldown_seconds": 60, "overload_cooldown_seconds": 60,
"proactive_refresh_seconds": 300, "proactive_refresh_seconds": 300,
"health_policy_enabled": False, "health_policy_enabled": False,
"probing_enabled": True,
"probing_interval_minutes": 15,
"auto_remove_banned_keys": True,
} }
} }
) )
@@ -94,6 +100,9 @@ def test_parse_pool_config_overrides_values_legacy_string_list() -> None:
assert cfg.overload_cooldown_seconds == 60 assert cfg.overload_cooldown_seconds == 60
assert cfg.proactive_refresh_seconds == 300 assert cfg.proactive_refresh_seconds == 300
assert cfg.health_policy_enabled is False assert cfg.health_policy_enabled is False
assert cfg.probing_enabled is True
assert cfg.probing_interval_minutes == 15
assert cfg.auto_remove_banned_keys is True
def test_parse_pool_config_new_object_list_format() -> None: def test_parse_pool_config_new_object_list_format() -> None:

View File

@@ -257,3 +257,58 @@ def test_multi_score_disabled_presets_are_skipped() -> None:
# quota_balanced only: k2 (20%) should score lower (better) than k1 (80%) # quota_balanced only: k2 (20%) should score lower (better) than k1 (80%)
# free_team_first is disabled so plan_type should not matter # free_team_first is disabled so plan_type should not matter
assert s2 < s1 assert s2 < s1
def test_multi_score_preset_hard_priority_overrides_later_presets() -> None:
"""Earlier preset should dominate later presets (lexicographic hard priority)."""
strategy = MultiScoreStrategy()
cfg = PoolConfig(
scheduling_mode="multi_score",
lru_enabled=False,
scheduling_presets=(
SchedulingPreset(preset="priority_first", enabled=True),
SchedulingPreset(preset="quota_balanced", enabled=True),
),
)
ctx = {
"all_key_ids": ["k1", "k2", "k3"],
"lru_scores": {"k1": 100.0, "k2": 100.0, "k3": 100.0},
"keys_by_id": {
"k1": _key_with_metadata({"codex": {"primary_used_percent": 90}}, internal_priority=1),
"k2": _key_with_metadata({"codex": {"primary_used_percent": 10}}, internal_priority=2),
"k3": _key_with_metadata({"codex": {"primary_used_percent": 50}}, internal_priority=3),
},
}
s1 = strategy.compute_score(key_id="k1", config=cfg, context=ctx)
s2 = strategy.compute_score(key_id="k2", config=cfg, context=ctx)
assert s1 is not None and s2 is not None
# k1 has better priority_first rank even though quota_balanced is worse.
assert s1 < s2
def test_multi_score_mutex_group_selected_member_uses_group_priority_slot() -> None:
"""Selecting single_account should keep distribution group's first priority slot."""
strategy = MultiScoreStrategy()
cfg = PoolConfig(
scheduling_mode="multi_score",
lru_enabled=False,
scheduling_presets=(
SchedulingPreset(preset="lru", enabled=False),
SchedulingPreset(preset="quota_balanced", enabled=True),
SchedulingPreset(preset="single_account", enabled=True),
),
)
ctx = {
"all_key_ids": ["k1", "k2", "k3"],
"lru_scores": {"k1": 100.0, "k2": 100.0, "k3": 100.0},
"keys_by_id": {
"k1": _key_with_metadata({"codex": {"primary_used_percent": 90}}, internal_priority=1),
"k2": _key_with_metadata({"codex": {"primary_used_percent": 10}}, internal_priority=2),
"k3": _key_with_metadata({"codex": {"primary_used_percent": 20}}, internal_priority=3),
},
}
s1 = strategy.compute_score(key_id="k1", config=cfg, context=ctx)
s2 = strategy.compute_score(key_id="k2", config=cfg, context=ctx)
assert s1 is not None and s2 is not None
# single_account is selected in distribution_mode and should outrank quota_balanced.
assert s1 < s2

View File

@@ -0,0 +1,88 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from src.services.provider_keys.pool_quota_probe_scheduler import _select_probe_key_ids
def _key(
key_id: str,
*,
last_used_at: datetime | None = None,
upstream_metadata: dict | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
id=key_id,
last_used_at=last_used_at,
upstream_metadata=upstream_metadata or {},
)
def test_select_probe_key_ids_selects_silent_keys_only() -> None:
now = datetime(2026, 3, 5, 12, 0, 0, tzinfo=timezone.utc)
now_ts = int(now.timestamp())
keys = [
_key("k1"), # never used, should be probed
_key("k2", last_used_at=now - timedelta(minutes=2)), # recently used, skip
_key(
"k3",
upstream_metadata={"codex": {"updated_at": now_ts - (20 * 60)}},
), # long-time no refresh, should be probed
]
selected = _select_probe_key_ids(
keys=keys, # type: ignore[arg-type]
provider_type="codex",
now_ts=now_ts,
interval_seconds=10 * 60,
last_probe_timestamps={},
limit=0,
)
assert selected == ["k1", "k3"]
def test_select_probe_key_ids_resets_probe_window_after_key_usage() -> None:
now = datetime(2026, 3, 5, 12, 0, 0, tzinfo=timezone.utc)
now_ts = int(now.timestamp())
keys = [
_key(
"k1",
last_used_at=now - timedelta(seconds=30),
upstream_metadata={"codex": {"updated_at": now_ts - (40 * 60)}},
)
]
# 上一次主动探测非常早,但 key 刚刚被真实流量使用,应跳过本次探测
selected = _select_probe_key_ids(
keys=keys, # type: ignore[arg-type]
provider_type="codex",
now_ts=now_ts,
interval_seconds=10 * 60,
last_probe_timestamps={"k1": now_ts - (25 * 60)},
limit=0,
)
assert selected == []
def test_select_probe_key_ids_applies_limit_by_oldest_anchor_first() -> None:
now = datetime(2026, 3, 5, 12, 0, 0, tzinfo=timezone.utc)
now_ts = int(now.timestamp())
keys = [
_key("k1", last_used_at=now - timedelta(minutes=60)),
_key("k2", last_used_at=now - timedelta(minutes=50)),
_key("k3", last_used_at=now - timedelta(minutes=40)),
]
selected = _select_probe_key_ids(
keys=keys, # type: ignore[arg-type]
provider_type="codex",
now_ts=now_ts,
interval_seconds=10 * 60,
last_probe_timestamps={},
limit=2,
)
assert selected == ["k1", "k2"]

View File

@@ -50,6 +50,7 @@ class _FakeDB:
self._provider = provider self._provider = provider
self._keys = keys self._keys = keys
self.added: list[object] = [] self.added: list[object] = []
self.deleted: list[object] = []
self.commit_count = 0 self.commit_count = 0
def query(self, model: Any) -> _FakeQuery: def query(self, model: Any) -> _FakeQuery:
@@ -63,6 +64,9 @@ class _FakeDB:
def add(self, obj: object) -> None: def add(self, obj: object) -> None:
self.added.append(obj) self.added.append(obj)
def delete(self, obj: object) -> None:
self.deleted.append(obj)
def commit(self) -> None: def commit(self) -> None:
self.commit_count += 1 self.commit_count += 1
@@ -148,6 +152,7 @@ async def test_refresh_provider_quota_no_active_keys_returns_empty() -> None:
"total": 0, "total": 0,
"results": [], "results": [],
"message": "没有可刷新的 Key", "message": "没有可刷新的 Key",
"auto_removed": 0,
} }
@@ -174,6 +179,7 @@ async def test_refresh_provider_quota_empty_key_ids_returns_empty() -> None:
"total": 0, "total": 0,
"results": [], "results": [],
"message": "未提供可刷新的 Key", "message": "未提供可刷新的 Key",
"auto_removed": 0,
} }
@@ -324,3 +330,84 @@ async def test_refresh_provider_quota_handler_exception_returns_error(
assert result["total"] == 1 assert result["total"] == 1
assert result["results"][0]["status"] == "error" assert result["results"][0]["status"] == "error"
assert "unit-test boom" in result["results"][0]["message"] assert "unit-test boom" in result["results"][0]["message"]
@pytest.mark.asyncio
async def test_refresh_provider_quota_auto_removes_banned_keys_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
provider = SimpleNamespace(
id="p1",
provider_type=ProviderType.CODEX,
endpoints=[SimpleNamespace(api_format="openai:cli", is_active=True)],
config={"pool_advanced": {"auto_remove_banned_keys": True}},
)
key1 = SimpleNamespace(
id="k1",
name="K1",
provider_id="p1",
allowed_models=["gpt-4o"],
upstream_metadata={},
is_active=True,
oauth_invalid_reason=None,
)
key2 = SimpleNamespace(
id="k2",
name="K2",
provider_id="p1",
allowed_models=None,
upstream_metadata={},
is_active=True,
oauth_invalid_reason=None,
)
db = _FakeDB(provider=provider, keys=[key1, key2])
deleted_side_effect_calls: list[tuple[str | None, list[str] | None]] = []
redis_cleared: list[tuple[str, str]] = []
async def _fake_handler(**kwargs: Any) -> dict[str, Any]:
key = kwargs["key"]
state_updates = kwargs["state_updates"]
if key.id == "k1":
state_updates[key.id] = {"is_active": False, "oauth_invalid_reason": "账户已封禁: test"}
return {"key_id": key.id, "key_name": key.name, "status": "banned"}
return {"key_id": key.id, "key_name": key.name, "status": "success"}
async def _fake_run_delete_side_effects(
*,
db: Any,
provider_id: str | None,
deleted_key_allowed_models: list[str] | None,
) -> None:
_ = db
deleted_side_effect_calls.append((provider_id, deleted_key_allowed_models))
async def _fake_clear(provider_id: str, key_id: str) -> None:
redis_cleared.append((provider_id, key_id))
monkeypatch.setattr(
quota_service_module,
"_select_refresh_endpoint",
lambda provider, provider_type: provider.endpoints[0],
)
monkeypatch.setattr(
quota_service_module, "_resolve_quota_refresh_handler", lambda _: _fake_handler
)
monkeypatch.setattr(
quota_service_module, "run_delete_key_side_effects", _fake_run_delete_side_effects
)
monkeypatch.setattr(quota_service_module.pool_redis, "clear_cooldown", _fake_clear)
monkeypatch.setattr(quota_service_module.pool_redis, "clear_cost", _fake_clear)
result = await refresh_provider_quota_for_provider(
db=cast(Any, db),
provider_id="p1",
codex_wham_usage_url="https://example.test/wham/usage",
)
assert result["auto_removed"] == 1
assert db.commit_count == 1
assert db.deleted == [key1]
assert key2 not in db.deleted
assert result["results"][0]["auto_removed"] is True
assert deleted_side_effect_calls == [("p1", ["gpt-4o"])]
assert redis_cleared == [("p1", "k1"), ("p1", "k1")]