mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(pool): 新增账号配额展示并清理 Codex 旧限额字段
- 后端池子 Key 列表新增 account_quota 字段,按 provider_type 生成配额摘要(codex/kiro/antigravity) - 前端 PoolManagement 新增“配额”列与进度条展示,桌面端与移动端同步支持 - Provider 详情页移除 Codex code_review 限额展示,仅保留周限额与 5H 限额 - 清理 codex usage parser/realtime quota 中 code_review 相关解析与比较逻辑 - 同步更新调度与配额相关测试,改为忽略无关历史字段
This commit is contained in:
@@ -78,6 +78,7 @@ export interface PoolKeyDetail {
|
|||||||
key_name: string
|
key_name: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
auth_type: string
|
auth_type: string
|
||||||
|
account_quota: string | null
|
||||||
cooldown_reason: string | null
|
cooldown_reason: string | null
|
||||||
cooldown_ttl_seconds: number | null
|
cooldown_ttl_seconds: number | null
|
||||||
cost_window_usage: number
|
cost_window_usage: number
|
||||||
|
|||||||
@@ -284,10 +284,6 @@ export interface CodexUpstreamMetadata {
|
|||||||
secondary_reset_seconds?: number // 5H限额重置剩余秒数
|
secondary_reset_seconds?: number // 5H限额重置剩余秒数
|
||||||
secondary_reset_at?: number // 5H限额重置时间(Unix 时间戳)
|
secondary_reset_at?: number // 5H限额重置时间(Unix 时间戳)
|
||||||
secondary_window_minutes?: number // 5H限额窗口大小(分钟)
|
secondary_window_minutes?: number // 5H限额窗口大小(分钟)
|
||||||
code_review_used_percent?: number // 代码审查限额使用百分比
|
|
||||||
code_review_reset_seconds?: number // 代码审查限额重置剩余秒数
|
|
||||||
code_review_reset_at?: number // 代码审查限额重置时间(Unix 时间戳)
|
|
||||||
code_review_window_minutes?: number // 代码审查限额窗口大小(分钟)
|
|
||||||
has_credits?: boolean // 是否有积分
|
has_credits?: boolean // 是否有积分
|
||||||
credits_balance?: number // 积分余额
|
credits_balance?: number // 积分余额
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -534,10 +534,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 限额并排显示:Team/Plus/Enterprise 账号 3列, Free 账号 2列 -->
|
<!-- 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
|
||||||
<div
|
<div
|
||||||
class="grid gap-3"
|
class="grid gap-3"
|
||||||
:class="isCodexTeamPlan(key) ? 'grid-cols-3' : 'grid-cols-2'"
|
:class="isCodexTeamPlan(key) ? 'grid-cols-2' : 'grid-cols-1'"
|
||||||
>
|
>
|
||||||
<!-- 周限额 -->
|
<!-- 周限额 -->
|
||||||
<div v-if="key.upstream_metadata.codex?.primary_used_percent !== undefined">
|
<div v-if="key.upstream_metadata.codex?.primary_used_percent !== undefined">
|
||||||
@@ -585,28 +585,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 代码审查限额 -->
|
|
||||||
<div v-if="key.upstream_metadata.codex?.code_review_used_percent !== undefined">
|
|
||||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
|
||||||
<span class="text-muted-foreground">审查限额</span>
|
|
||||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.codex.code_review_used_percent)">
|
|
||||||
{{ (100 - key.upstream_metadata.codex.code_review_used_percent).toFixed(1) }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
|
||||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.codex.code_review_used_percent)"
|
|
||||||
:style="{ width: `${Math.max(100 - key.upstream_metadata.codex.code_review_used_percent, 0)}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="key.upstream_metadata.codex.code_review_reset_seconds"
|
|
||||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
|
||||||
>
|
|
||||||
{{ formatResetTime(key.upstream_metadata.codex.code_review_reset_seconds) }}后重置
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
|
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
|
||||||
|
|||||||
@@ -290,6 +290,12 @@
|
|||||||
<TableHead class="font-semibold">
|
<TableHead class="font-semibold">
|
||||||
名称
|
名称
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
v-if="showAccountQuotaColumn"
|
||||||
|
class="w-64 font-semibold"
|
||||||
|
>
|
||||||
|
配额
|
||||||
|
</TableHead>
|
||||||
<TableHead class="w-20 font-semibold">
|
<TableHead class="w-20 font-semibold">
|
||||||
状态
|
状态
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -330,6 +336,51 @@
|
|||||||
{{ key.key_name || '未命名' }}
|
{{ key.key_name || '未命名' }}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
v-if="showAccountQuotaColumn"
|
||||||
|
class="py-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="quotaProgressMap[key.key_id]?.length"
|
||||||
|
class="flex items-stretch gap-2 overflow-x-auto pb-0.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, idx) in quotaProgressMap[key.key_id]"
|
||||||
|
:key="`${key.key_id}-quota-${idx}`"
|
||||||
|
class="min-w-[140px] max-w-[180px] shrink-0"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||||
|
<span class="text-muted-foreground">{{ item.label }}</span>
|
||||||
|
<span :class="getQuotaRemainingClassByRemaining(item.remainingPercent)">
|
||||||
|
{{ item.remainingPercent.toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||||
|
:class="getQuotaRemainingBarColorByRemaining(item.remainingPercent)"
|
||||||
|
:style="{ width: `${item.remainingPercent}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="item.detail"
|
||||||
|
class="text-[9px] text-muted-foreground/70 mt-0.5 truncate"
|
||||||
|
>
|
||||||
|
{{ item.detail }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-else-if="key.account_quota"
|
||||||
|
:class="getQuotaTextClass(key.account_quota)"
|
||||||
|
>
|
||||||
|
{{ key.account_quota }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>-</span>
|
||||||
|
</TableCell>
|
||||||
<TableCell class="py-3">
|
<TableCell class="py-3">
|
||||||
<Badge
|
<Badge
|
||||||
:variant="key.is_active ? (key.cooldown_reason ? 'destructive' : 'default') : 'secondary'"
|
:variant="key.is_active ? (key.cooldown_reason ? 'destructive' : 'default') : 'secondary'"
|
||||||
@@ -488,7 +539,10 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2.5 ml-7 grid grid-cols-3 gap-2">
|
<div
|
||||||
|
class="mt-2.5 ml-7 grid gap-2"
|
||||||
|
:class="showAccountQuotaColumn ? 'grid-cols-4' : 'grid-cols-3'"
|
||||||
|
>
|
||||||
<div class="p-2 bg-muted/50 rounded-lg text-xs">
|
<div class="p-2 bg-muted/50 rounded-lg text-xs">
|
||||||
<div class="text-muted-foreground mb-0.5">
|
<div class="text-muted-foreground mb-0.5">
|
||||||
冷却
|
冷却
|
||||||
@@ -543,6 +597,56 @@
|
|||||||
{{ key.last_used_at ? formatRelativeTime(key.last_used_at) : '-' }}
|
{{ key.last_used_at ? formatRelativeTime(key.last_used_at) : '-' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showAccountQuotaColumn"
|
||||||
|
class="p-2 bg-muted/50 rounded-lg text-xs"
|
||||||
|
>
|
||||||
|
<div class="text-muted-foreground mb-0.5">
|
||||||
|
配额
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="quotaProgressMap[key.key_id]?.length"
|
||||||
|
class="flex items-stretch gap-2 overflow-x-auto pb-0.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, idx) in quotaProgressMap[key.key_id]"
|
||||||
|
:key="`${key.key_id}-quota-mobile-${idx}`"
|
||||||
|
class="min-w-[120px] max-w-[160px] shrink-0"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||||
|
<span class="text-muted-foreground">{{ item.label }}</span>
|
||||||
|
<span :class="getQuotaRemainingClassByRemaining(item.remainingPercent)">
|
||||||
|
{{ item.remainingPercent.toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||||
|
:class="getQuotaRemainingBarColorByRemaining(item.remainingPercent)"
|
||||||
|
:style="{ width: `${item.remainingPercent}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="item.detail"
|
||||||
|
class="text-[9px] text-muted-foreground/70 mt-0.5 truncate"
|
||||||
|
>
|
||||||
|
{{ item.detail }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="key.account_quota"
|
||||||
|
:class="getQuotaTextClass(key.account_quota)"
|
||||||
|
>
|
||||||
|
{{ key.account_quota }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -682,6 +786,19 @@ const selectedProviderConfig = computed<PoolAdvancedConfig | null>(() => {
|
|||||||
return (selectedProviderData.value as Record<string, unknown> | null)?.pool_advanced as PoolAdvancedConfig | null ?? null
|
return (selectedProviderData.value as Record<string, unknown> | null)?.pool_advanced as PoolAdvancedConfig | null ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedProviderType = computed(() => {
|
||||||
|
const fromDetail = String(selectedProviderData.value?.provider_type || '').trim().toLowerCase()
|
||||||
|
if (fromDetail) return fromDetail
|
||||||
|
const fromOverview = poolProviders.value.find(item => item.provider_id === selectedProviderId.value)?.provider_type
|
||||||
|
return String(fromOverview || '').trim().toLowerCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
const showAccountQuotaColumn = computed(() => {
|
||||||
|
return selectedProviderType.value === 'codex'
|
||||||
|
|| selectedProviderType.value === 'kiro'
|
||||||
|
|| selectedProviderType.value === 'antigravity'
|
||||||
|
})
|
||||||
|
|
||||||
async function selectProvider(id: string) {
|
async function selectProvider(id: string) {
|
||||||
selectedProviderId.value = id
|
selectedProviderId.value = id
|
||||||
selectedKeys.value.clear()
|
selectedKeys.value.clear()
|
||||||
@@ -714,6 +831,20 @@ const statusFilter = ref('all')
|
|||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(50)
|
const pageSize = ref(50)
|
||||||
|
|
||||||
|
interface QuotaProgressItem {
|
||||||
|
label: string
|
||||||
|
remainingPercent: number
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotaProgressMap = computed<Record<string, QuotaProgressItem[]>>(() => {
|
||||||
|
const map: Record<string, QuotaProgressItem[]> = {}
|
||||||
|
for (const key of keyPage.value.keys) {
|
||||||
|
map[key.key_id] = parseQuotaProgressItems(key.account_quota)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
async function loadKeys() {
|
async function loadKeys() {
|
||||||
if (!selectedProviderId.value) return
|
if (!selectedProviderId.value) return
|
||||||
keysLoading.value = true
|
keysLoading.value = true
|
||||||
@@ -840,6 +971,70 @@ function getCostBarColor(usage: number, limit: number): string {
|
|||||||
return 'bg-green-500'
|
return 'bg-green-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeQuotaLabel(label: string): string {
|
||||||
|
const normalized = label.trim()
|
||||||
|
if (!normalized) return '额度'
|
||||||
|
if (normalized.includes('周剩余')) return '周限额'
|
||||||
|
if (normalized.includes('5H剩余')) return '5H限额'
|
||||||
|
if (normalized.includes('最低剩余')) return '最低剩余'
|
||||||
|
if (normalized === '剩余') return '剩余额度'
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPercent(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0
|
||||||
|
if (value < 0) return 0
|
||||||
|
if (value > 100) return 100
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuotaProgressItems(quotaText: string | null | undefined): QuotaProgressItem[] {
|
||||||
|
if (!quotaText) return []
|
||||||
|
|
||||||
|
const segments = quotaText
|
||||||
|
.split('|')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const items: QuotaProgressItem[] = []
|
||||||
|
for (const segment of segments) {
|
||||||
|
const match = segment.match(/^(.*?)(-?\d+(?:\.\d+)?)%\s*(.*)$/)
|
||||||
|
if (!match) continue
|
||||||
|
|
||||||
|
const [, rawLabel, rawPercent, rawTail] = match
|
||||||
|
const remainingPercent = clampPercent(Number(rawPercent))
|
||||||
|
const label = normalizeQuotaLabel(rawLabel)
|
||||||
|
const detail = rawTail.trim().replace(/^[()]+|[()]+$/g, '').trim()
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label,
|
||||||
|
remainingPercent,
|
||||||
|
detail: detail || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaRemainingClassByRemaining(remaining: number): string {
|
||||||
|
if (remaining <= 10) return 'text-red-600 dark:text-red-400'
|
||||||
|
if (remaining <= 30) return 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
return 'text-green-600 dark:text-green-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaRemainingBarColorByRemaining(remaining: number): string {
|
||||||
|
if (remaining <= 10) return 'bg-red-500 dark:bg-red-400'
|
||||||
|
if (remaining <= 30) return 'bg-yellow-500 dark:bg-yellow-400'
|
||||||
|
return 'bg-green-500 dark:bg-green-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaTextClass(quotaText: string): string {
|
||||||
|
if (quotaText.includes('封禁') || quotaText.includes('受限')) {
|
||||||
|
return 'text-[11px] text-destructive leading-4'
|
||||||
|
}
|
||||||
|
return 'text-[11px] text-foreground/90 leading-4'
|
||||||
|
}
|
||||||
|
|
||||||
function formatRelativeTime(isoStr: string): string {
|
function formatRelativeTime(isoStr: string): string {
|
||||||
const diff = (Date.now() - new Date(isoStr).getTime()) / 1000
|
const diff = (Date.now() - new Date(isoStr).getTime()) / 1000
|
||||||
if (diff < 60) return '刚刚'
|
if (diff < 60) return '刚刚'
|
||||||
|
|||||||
@@ -109,6 +109,144 @@ async def batch_import_keys(
|
|||||||
ALLOWED_ACTIONS = {"enable", "disable", "delete", "clear_cooldown", "reset_cost"}
|
ALLOWED_ACTIONS = {"enable", "disable", "delete", "clear_cooldown", "reset_cost"}
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
raw = value.strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_percent(value: float) -> str:
|
||||||
|
clamped = max(0.0, min(value, 100.0))
|
||||||
|
return f"{clamped:.1f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_quota_value(value: float) -> str:
|
||||||
|
rounded = round(value)
|
||||||
|
if abs(value - rounded) < 1e-6:
|
||||||
|
return str(rounded)
|
||||||
|
return f"{value:.1f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_codex_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
|
||||||
|
codex = upstream_metadata.get("codex")
|
||||||
|
if not isinstance(codex, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
primary_used = _to_float(codex.get("primary_used_percent"))
|
||||||
|
if primary_used is not None:
|
||||||
|
parts.append(f"周剩余 {_format_percent(100.0 - primary_used)}")
|
||||||
|
|
||||||
|
secondary_used = _to_float(codex.get("secondary_used_percent"))
|
||||||
|
if secondary_used is not None:
|
||||||
|
parts.append(f"5H剩余 {_format_percent(100.0 - secondary_used)}")
|
||||||
|
|
||||||
|
if parts:
|
||||||
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
has_credits = codex.get("has_credits")
|
||||||
|
credits_balance = _to_float(codex.get("credits_balance"))
|
||||||
|
if has_credits is True and credits_balance is not None:
|
||||||
|
return f"积分 {credits_balance:.2f}"
|
||||||
|
if has_credits is True:
|
||||||
|
return "有积分"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_kiro_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
|
||||||
|
kiro = upstream_metadata.get("kiro")
|
||||||
|
if not isinstance(kiro, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if kiro.get("is_banned") is True:
|
||||||
|
return "账号已封禁"
|
||||||
|
|
||||||
|
usage_percentage = _to_float(kiro.get("usage_percentage"))
|
||||||
|
if usage_percentage is not None:
|
||||||
|
remaining = 100.0 - usage_percentage
|
||||||
|
current_usage = _to_float(kiro.get("current_usage"))
|
||||||
|
usage_limit = _to_float(kiro.get("usage_limit"))
|
||||||
|
if (
|
||||||
|
current_usage is not None
|
||||||
|
and usage_limit is not None
|
||||||
|
and usage_limit > 0
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
f"剩余 {_format_percent(remaining)} "
|
||||||
|
f"({_format_quota_value(current_usage)}/{_format_quota_value(usage_limit)})"
|
||||||
|
)
|
||||||
|
return f"剩余 {_format_percent(remaining)}"
|
||||||
|
|
||||||
|
remaining = _to_float(kiro.get("remaining"))
|
||||||
|
usage_limit = _to_float(kiro.get("usage_limit"))
|
||||||
|
if remaining is not None and usage_limit is not None and usage_limit > 0:
|
||||||
|
return f"剩余 {_format_quota_value(remaining)}/{_format_quota_value(usage_limit)}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_antigravity_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
|
||||||
|
antigravity = upstream_metadata.get("antigravity")
|
||||||
|
if not isinstance(antigravity, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if antigravity.get("is_forbidden") is True:
|
||||||
|
return "访问受限"
|
||||||
|
|
||||||
|
quota_by_model = antigravity.get("quota_by_model")
|
||||||
|
if not isinstance(quota_by_model, dict) or not quota_by_model:
|
||||||
|
return None
|
||||||
|
|
||||||
|
remaining_list: list[float] = []
|
||||||
|
for raw_info in quota_by_model.values():
|
||||||
|
if not isinstance(raw_info, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
used_percent = _to_float(raw_info.get("used_percent"))
|
||||||
|
if used_percent is None:
|
||||||
|
remaining_fraction = _to_float(raw_info.get("remaining_fraction"))
|
||||||
|
if remaining_fraction is not None:
|
||||||
|
used_percent = (1.0 - remaining_fraction) * 100.0
|
||||||
|
|
||||||
|
if used_percent is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
remaining = max(0.0, min(100.0 - used_percent, 100.0))
|
||||||
|
remaining_list.append(remaining)
|
||||||
|
|
||||||
|
if not remaining_list:
|
||||||
|
return None
|
||||||
|
|
||||||
|
min_remaining = min(remaining_list)
|
||||||
|
if len(remaining_list) == 1:
|
||||||
|
return f"剩余 {_format_percent(min_remaining)}"
|
||||||
|
return f"最低剩余 {_format_percent(min_remaining)} ({len(remaining_list)} 模型)"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_account_quota(provider_type: str, upstream_metadata: Any) -> str | None:
|
||||||
|
if not isinstance(upstream_metadata, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized_type = provider_type.strip().lower()
|
||||||
|
if normalized_type == "codex":
|
||||||
|
return _build_codex_account_quota(upstream_metadata)
|
||||||
|
if normalized_type == "kiro":
|
||||||
|
return _build_kiro_account_quota(upstream_metadata)
|
||||||
|
if normalized_type == "antigravity":
|
||||||
|
return _build_antigravity_account_quota(upstream_metadata)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{provider_id}/keys/batch-action", response_model=BatchActionResponse)
|
@router.post("/{provider_id}/keys/batch-action", response_model=BatchActionResponse)
|
||||||
async def batch_action_keys(
|
async def batch_action_keys(
|
||||||
provider_id: str,
|
provider_id: str,
|
||||||
@@ -192,6 +330,7 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
pcfg = parse_pool_config(getattr(provider, "config", None))
|
pcfg = parse_pool_config(getattr(provider, "config", None))
|
||||||
pid = str(provider.id)
|
pid = str(provider.id)
|
||||||
|
provider_type = str(getattr(provider, "provider_type", "custom") or "custom")
|
||||||
|
|
||||||
# Base query
|
# Base query
|
||||||
q = db.query(ProviderAPIKey).filter(ProviderAPIKey.provider_id == pid)
|
q = db.query(ProviderAPIKey).filter(ProviderAPIKey.provider_id == pid)
|
||||||
@@ -271,6 +410,10 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
|
|||||||
key_name=k.name or "",
|
key_name=k.name or "",
|
||||||
is_active=bool(k.is_active),
|
is_active=bool(k.is_active),
|
||||||
auth_type=str(getattr(k, "auth_type", "api_key") or "api_key"),
|
auth_type=str(getattr(k, "auth_type", "api_key") or "api_key"),
|
||||||
|
account_quota=_build_account_quota(
|
||||||
|
provider_type,
|
||||||
|
getattr(k, "upstream_metadata", None),
|
||||||
|
),
|
||||||
cooldown_reason=cd_reason,
|
cooldown_reason=cd_reason,
|
||||||
cooldown_ttl_seconds=cd_ttl,
|
cooldown_ttl_seconds=cd_ttl,
|
||||||
cost_window_usage=cost_totals.get(kid, 0),
|
cost_window_usage=cost_totals.get(kid, 0),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class PoolKeyDetail(BaseModel):
|
|||||||
key_name: str
|
key_name: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
auth_type: str = "api_key"
|
auth_type: str = "api_key"
|
||||||
|
account_quota: str | None = None
|
||||||
cooldown_reason: str | None = None
|
cooldown_reason: str | None = None
|
||||||
cooldown_ttl_seconds: int | None = None
|
cooldown_ttl_seconds: int | None = None
|
||||||
cost_window_usage: int = 0
|
cost_window_usage: int = 0
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ _COMPARE_IGNORE_FIELDS = frozenset(
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
"primary_reset_seconds",
|
"primary_reset_seconds",
|
||||||
"secondary_reset_seconds",
|
"secondary_reset_seconds",
|
||||||
"code_review_reset_seconds",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_CACHE_TTL_SECONDS = 30.0
|
_CACHE_TTL_SECONDS = 30.0
|
||||||
|
|||||||
@@ -202,12 +202,10 @@ def parse_codex_wham_usage_response(data: dict[str, Any]) -> dict[str, Any] | No
|
|||||||
|
|
||||||
Free 账号:
|
Free 账号:
|
||||||
- rate_limit.primary_window: 周限额
|
- rate_limit.primary_window: 周限额
|
||||||
- code_review_rate_limit.primary_window: 代码审查周限额
|
|
||||||
|
|
||||||
Team/Plus/Enterprise 账号:
|
Team/Plus/Enterprise 账号:
|
||||||
- rate_limit.primary_window: 5H 限额
|
- rate_limit.primary_window: 5H 限额
|
||||||
- rate_limit.secondary_window: 周限额
|
- rate_limit.secondary_window: 周限额
|
||||||
- code_review_rate_limit.primary_window: 代码审查周限额
|
|
||||||
"""
|
"""
|
||||||
if data is None:
|
if data is None:
|
||||||
return None
|
return None
|
||||||
@@ -261,18 +259,6 @@ def parse_codex_wham_usage_response(data: dict[str, Any]) -> dict[str, Any] | No
|
|||||||
target_prefix="primary",
|
target_prefix="primary",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 解析 code_review_rate_limit (代码审查限额)
|
|
||||||
code_review_limit = _as_dict(data.get("code_review_rate_limit"), "code_review_rate_limit")
|
|
||||||
code_review_primary = _as_dict(
|
|
||||||
code_review_limit.get("primary_window"), "code_review_rate_limit.primary_window"
|
|
||||||
)
|
|
||||||
_write_window(
|
|
||||||
result,
|
|
||||||
source=code_review_primary,
|
|
||||||
source_field="code_review_rate_limit.primary_window",
|
|
||||||
target_prefix="code_review",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 解析 credits
|
# 解析 credits
|
||||||
credits = _as_dict(data.get("credits"), "credits")
|
credits = _as_dict(data.get("credits"), "credits")
|
||||||
has_credits = credits.get("has_credits")
|
has_credits = credits.get("has_credits")
|
||||||
@@ -333,16 +319,6 @@ def parse_codex_usage_headers(headers: Mapping[str, Any] | None) -> dict[str, An
|
|||||||
window_minutes_key="x-codex-secondary-window-minutes",
|
window_minutes_key="x-codex-secondary-window-minutes",
|
||||||
source_field="headers.secondary_window",
|
source_field="headers.secondary_window",
|
||||||
)
|
)
|
||||||
# 兼容未来可能出现的 code review header(当前反代可缺失)
|
|
||||||
code_review_primary = _read_header_window(
|
|
||||||
headers=normalized_headers,
|
|
||||||
used_percent_key="x-codex-code-review-primary-used-percent",
|
|
||||||
reset_seconds_key="x-codex-code-review-primary-reset-after-seconds",
|
|
||||||
reset_at_key="x-codex-code-review-primary-reset-at",
|
|
||||||
window_minutes_key="x-codex-code-review-primary-window-minutes",
|
|
||||||
source_field="headers.code_review.primary_window",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 与 wham/usage 解析保持一致:
|
# 与 wham/usage 解析保持一致:
|
||||||
# - metadata.primary_* 统一表示周限额
|
# - metadata.primary_* 统一表示周限额
|
||||||
# - metadata.secondary_* 统一表示 5H 限额
|
# - metadata.secondary_* 统一表示 5H 限额
|
||||||
@@ -368,13 +344,6 @@ def parse_codex_usage_headers(headers: Mapping[str, Any] | None) -> dict[str, An
|
|||||||
target_prefix="primary",
|
target_prefix="primary",
|
||||||
)
|
)
|
||||||
|
|
||||||
_write_window(
|
|
||||||
result,
|
|
||||||
source=code_review_primary,
|
|
||||||
source_field="headers.code_review.primary_window",
|
|
||||||
target_prefix="code_review",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 当前窗口挤占占比(有值才记录)
|
# 当前窗口挤占占比(有值才记录)
|
||||||
primary_over_secondary_limit = _coerce_optional_float(
|
primary_over_secondary_limit = _coerce_optional_float(
|
||||||
normalized_headers.get("x-codex-primary-over-secondary-limit-percent"),
|
normalized_headers.get("x-codex-primary-over-secondary-limit-percent"),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def is_key_quota_exhausted(
|
|||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- Kiro: account-level quota. When remaining == 0, skip this key; allow again when remaining > 0.
|
- Kiro: account-level quota. When remaining == 0, skip this key; allow again when remaining > 0.
|
||||||
- Codex: only consider weekly quota + 5H quota (ignore code review quota).
|
- Codex: only consider weekly quota + 5H quota.
|
||||||
If either remaining is 0%, skip this key.
|
If either remaining is 0%, skip this key.
|
||||||
- Antigravity: quota is per-model; do not disable the account.
|
- Antigravity: quota is per-model; do not disable the account.
|
||||||
When the requested model's quota is 0%, skip this key.
|
When the requested model's quota is 0%, skip this key.
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ def test_codex_weekly_quota_exhausted_skips(_mock_cb: MagicMock) -> None:
|
|||||||
"codex": {
|
"codex": {
|
||||||
"primary_used_percent": 100.0,
|
"primary_used_percent": 100.0,
|
||||||
"secondary_used_percent": 10.0,
|
"secondary_used_percent": 10.0,
|
||||||
"code_review_used_percent": 100.0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -114,14 +113,14 @@ def test_codex_5h_quota_exhausted_skips(_mock_cb: MagicMock) -> None:
|
|||||||
"src.services.scheduling.candidate_builder.health_monitor.get_circuit_breaker_status",
|
"src.services.scheduling.candidate_builder.health_monitor.get_circuit_breaker_status",
|
||||||
return_value=(True, None),
|
return_value=(True, None),
|
||||||
)
|
)
|
||||||
def test_codex_ignores_code_review_quota(_mock_cb: MagicMock) -> None:
|
def test_codex_ignores_unrelated_metadata_fields(_mock_cb: MagicMock) -> None:
|
||||||
scheduler = CacheAwareScheduler()
|
scheduler = CacheAwareScheduler()
|
||||||
key = _make_key(
|
key = _make_key(
|
||||||
upstream_metadata={
|
upstream_metadata={
|
||||||
"codex": {
|
"codex": {
|
||||||
"primary_used_percent": 10.0,
|
"primary_used_percent": 10.0,
|
||||||
"secondary_used_percent": 20.0,
|
"secondary_used_percent": 20.0,
|
||||||
"code_review_used_percent": 100.0,
|
"legacy_marker": "ignore-me",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ def test_sync_codex_quota_from_headers_updates_and_preserves_existing_fields() -
|
|||||||
upstream_metadata={
|
upstream_metadata={
|
||||||
"codex": {
|
"codex": {
|
||||||
"primary_used_percent": 50.0,
|
"primary_used_percent": 50.0,
|
||||||
"code_review_used_percent": 12.0,
|
"legacy_marker": "keep-me",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -131,8 +131,8 @@ def test_sync_codex_quota_from_headers_updates_and_preserves_existing_fields() -
|
|||||||
codex_meta = key.upstream_metadata["codex"]
|
codex_meta = key.upstream_metadata["codex"]
|
||||||
assert codex_meta["primary_used_percent"] == 64.0
|
assert codex_meta["primary_used_percent"] == 64.0
|
||||||
assert codex_meta["secondary_used_percent"] == 3.0
|
assert codex_meta["secondary_used_percent"] == 3.0
|
||||||
# 旧的 code_review 字段应被保留(wham/usage 补充信息)
|
# 旧字段应被保留(解析器只覆盖已知配额字段)
|
||||||
assert codex_meta["code_review_used_percent"] == 12.0
|
assert codex_meta["legacy_marker"] == "keep-me"
|
||||||
|
|
||||||
|
|
||||||
def test_sync_codex_quota_from_headers_skips_when_only_reset_seconds_changed() -> None:
|
def test_sync_codex_quota_from_headers_skips_when_only_reset_seconds_changed() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user