feat: 适配codex用量数据

This commit is contained in:
shaw
2025-09-25 17:23:26 +08:00
parent 991dd1436f
commit c15ef0b6ae
3 changed files with 431 additions and 4 deletions

View File

@@ -17,6 +17,50 @@ function createProxyAgent(proxy) {
return ProxyHelper.createProxyAgent(proxy) return ProxyHelper.createProxyAgent(proxy)
} }
function normalizeHeaders(headers = {}) {
if (!headers || typeof headers !== 'object') {
return {}
}
const normalized = {}
for (const [key, value] of Object.entries(headers)) {
if (!key) {
continue
}
normalized[key.toLowerCase()] = Array.isArray(value) ? value[0] : value
}
return normalized
}
function toNumberSafe(value) {
if (value === undefined || value === null || value === '') {
return null
}
const num = Number(value)
return Number.isFinite(num) ? num : null
}
function extractCodexUsageHeaders(headers) {
const normalized = normalizeHeaders(headers)
if (!normalized || Object.keys(normalized).length === 0) {
return null
}
const snapshot = {
primaryUsedPercent: toNumberSafe(normalized['x-codex-primary-used-percent']),
primaryResetAfterSeconds: toNumberSafe(normalized['x-codex-primary-reset-after-seconds']),
primaryWindowMinutes: toNumberSafe(normalized['x-codex-primary-window-minutes']),
secondaryUsedPercent: toNumberSafe(normalized['x-codex-secondary-used-percent']),
secondaryResetAfterSeconds: toNumberSafe(normalized['x-codex-secondary-reset-after-seconds']),
secondaryWindowMinutes: toNumberSafe(normalized['x-codex-secondary-window-minutes']),
primaryOverSecondaryPercent: toNumberSafe(
normalized['x-codex-primary-over-secondary-limit-percent']
)
}
const hasData = Object.values(snapshot).some((value) => value !== null)
return hasData ? snapshot : null
}
// 使用统一调度器选择 OpenAI 账户 // 使用统一调度器选择 OpenAI 账户
async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = null) { async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = null) {
try { try {
@@ -266,6 +310,15 @@ const handleResponses = async (req, res) => {
) )
} }
const codexUsageSnapshot = extractCodexUsageHeaders(upstream.headers)
if (codexUsageSnapshot) {
try {
await openaiAccountService.updateCodexUsageSnapshot(accountId, codexUsageSnapshot)
} catch (codexError) {
logger.error('⚠️ 更新 Codex 使用统计失败:', codexError)
}
}
// 处理 429 限流错误 // 处理 429 限流错误
if (upstream.status === 429) { if (upstream.status === 429) {
logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`) logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`)

View File

@@ -115,6 +115,85 @@ setInterval(
10 * 60 * 1000 10 * 60 * 1000
) )
function toNumberOrNull(value) {
if (value === undefined || value === null || value === '') {
return null
}
const num = Number(value)
return Number.isFinite(num) ? num : null
}
function computeResetMeta(updatedAt, resetAfterSeconds) {
if (!updatedAt || resetAfterSeconds === null || resetAfterSeconds === undefined) {
return {
resetAt: null,
remainingSeconds: null
}
}
const updatedMs = Date.parse(updatedAt)
if (Number.isNaN(updatedMs)) {
return {
resetAt: null,
remainingSeconds: null
}
}
const resetMs = updatedMs + resetAfterSeconds * 1000
return {
resetAt: new Date(resetMs).toISOString(),
remainingSeconds: Math.max(0, Math.round((resetMs - Date.now()) / 1000))
}
}
function buildCodexUsageSnapshot(accountData) {
const updatedAt = accountData.codexUsageUpdatedAt
const primaryUsedPercent = toNumberOrNull(accountData.codexPrimaryUsedPercent)
const primaryResetAfterSeconds = toNumberOrNull(accountData.codexPrimaryResetAfterSeconds)
const primaryWindowMinutes = toNumberOrNull(accountData.codexPrimaryWindowMinutes)
const secondaryUsedPercent = toNumberOrNull(accountData.codexSecondaryUsedPercent)
const secondaryResetAfterSeconds = toNumberOrNull(accountData.codexSecondaryResetAfterSeconds)
const secondaryWindowMinutes = toNumberOrNull(accountData.codexSecondaryWindowMinutes)
const overSecondaryPercent = toNumberOrNull(accountData.codexPrimaryOverSecondaryLimitPercent)
const hasPrimaryData =
primaryUsedPercent !== null ||
primaryResetAfterSeconds !== null ||
primaryWindowMinutes !== null
const hasSecondaryData =
secondaryUsedPercent !== null ||
secondaryResetAfterSeconds !== null ||
secondaryWindowMinutes !== null
if (!updatedAt && !hasPrimaryData && !hasSecondaryData) {
return null
}
const primaryMeta = computeResetMeta(updatedAt, primaryResetAfterSeconds)
const secondaryMeta = computeResetMeta(updatedAt, secondaryResetAfterSeconds)
return {
updatedAt,
primary: {
usedPercent: primaryUsedPercent,
resetAfterSeconds: primaryResetAfterSeconds,
windowMinutes: primaryWindowMinutes,
resetAt: primaryMeta.resetAt,
remainingSeconds: primaryMeta.remainingSeconds
},
secondary: {
usedPercent: secondaryUsedPercent,
resetAfterSeconds: secondaryResetAfterSeconds,
windowMinutes: secondaryWindowMinutes,
resetAt: secondaryMeta.resetAt,
remainingSeconds: secondaryMeta.remainingSeconds
},
primaryOverSecondaryPercent: overSecondaryPercent
}
}
// 刷新访问令牌 // 刷新访问令牌
async function refreshAccessToken(refreshToken, proxy = null) { async function refreshAccessToken(refreshToken, proxy = null) {
try { try {
@@ -650,6 +729,8 @@ async function getAllAccounts() {
for (const key of keys) { for (const key of keys) {
const accountData = await client.hgetall(key) const accountData = await client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) { if (accountData && Object.keys(accountData).length > 0) {
const codexUsage = buildCodexUsageSnapshot(accountData)
// 解密敏感数据(但不返回给前端) // 解密敏感数据(但不返回给前端)
if (accountData.email) { if (accountData.email) {
accountData.email = decrypt(accountData.email) accountData.email = decrypt(accountData.email)
@@ -657,12 +738,24 @@ async function getAllAccounts() {
// 先保存 refreshToken 是否存在的标记 // 先保存 refreshToken 是否存在的标记
const hasRefreshTokenFlag = !!accountData.refreshToken const hasRefreshTokenFlag = !!accountData.refreshToken
const maskedAccessToken = accountData.accessToken ? '[ENCRYPTED]' : ''
const maskedRefreshToken = accountData.refreshToken ? '[ENCRYPTED]' : ''
const maskedOauth = accountData.openaiOauth ? '[ENCRYPTED]' : ''
// 屏蔽敏感信息token等不应该返回给前端 // 屏蔽敏感信息token等不应该返回给前端
delete accountData.idToken delete accountData.idToken
delete accountData.accessToken delete accountData.accessToken
delete accountData.refreshToken delete accountData.refreshToken
delete accountData.openaiOauth delete accountData.openaiOauth
delete accountData.codexPrimaryUsedPercent
delete accountData.codexPrimaryResetAfterSeconds
delete accountData.codexPrimaryWindowMinutes
delete accountData.codexSecondaryUsedPercent
delete accountData.codexSecondaryResetAfterSeconds
delete accountData.codexSecondaryWindowMinutes
delete accountData.codexPrimaryOverSecondaryLimitPercent
// 时间戳改由 codexUsage.updatedAt 暴露
delete accountData.codexUsageUpdatedAt
// 获取限流状态信息 // 获取限流状态信息
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id) const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
@@ -682,9 +775,9 @@ async function getAllAccounts() {
...accountData, ...accountData,
isActive: accountData.isActive === 'true', isActive: accountData.isActive === 'true',
schedulable: accountData.schedulable !== 'false', schedulable: accountData.schedulable !== 'false',
openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '', openaiOauth: maskedOauth,
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', accessToken: maskedAccessToken,
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '', refreshToken: maskedRefreshToken,
// 添加 scopes 字段用于判断认证方式 // 添加 scopes 字段用于判断认证方式
// 处理空字符串的情况 // 处理空字符串的情况
scopes: scopes:
@@ -706,7 +799,8 @@ async function getAllAccounts() {
rateLimitedAt: null, rateLimitedAt: null,
rateLimitResetAt: null, rateLimitResetAt: null,
minutesRemaining: 0 minutesRemaining: 0
} },
codexUsage
}) })
} }
} }
@@ -1043,6 +1137,41 @@ async function updateAccountUsage(accountId, tokens = 0) {
// 为了兼容性保留recordUsage作为updateAccountUsage的别名 // 为了兼容性保留recordUsage作为updateAccountUsage的别名
const recordUsage = updateAccountUsage const recordUsage = updateAccountUsage
async function updateCodexUsageSnapshot(accountId, usageSnapshot) {
if (!usageSnapshot || typeof usageSnapshot !== 'object') {
return
}
const fieldMap = {
primaryUsedPercent: 'codexPrimaryUsedPercent',
primaryResetAfterSeconds: 'codexPrimaryResetAfterSeconds',
primaryWindowMinutes: 'codexPrimaryWindowMinutes',
secondaryUsedPercent: 'codexSecondaryUsedPercent',
secondaryResetAfterSeconds: 'codexSecondaryResetAfterSeconds',
secondaryWindowMinutes: 'codexSecondaryWindowMinutes',
primaryOverSecondaryPercent: 'codexPrimaryOverSecondaryLimitPercent'
}
const updates = {}
let hasPayload = false
for (const [key, field] of Object.entries(fieldMap)) {
if (usageSnapshot[key] !== undefined && usageSnapshot[key] !== null) {
updates[field] = String(usageSnapshot[key])
hasPayload = true
}
}
if (!hasPayload) {
return
}
updates.codexUsageUpdatedAt = new Date().toISOString()
const client = redisClient.getClientSafe()
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
}
module.exports = { module.exports = {
createAccount, createAccount,
getAccount, getAccount,
@@ -1059,6 +1188,7 @@ module.exports = {
getAccountRateLimitInfo, getAccountRateLimitInfo,
updateAccountUsage, updateAccountUsage,
recordUsage, // 别名指向updateAccountUsage recordUsage, // 别名指向updateAccountUsage
updateCodexUsageSnapshot,
encrypt, encrypt,
decrypt, decrypt,
generateEncryptionKey, generateEncryptionKey,

View File

@@ -654,6 +654,82 @@
<i class="fas fa-minus" /> <i class="fas fa-minus" />
</div> </div>
</div> </div>
<div v-else-if="account.platform === 'openai'" class="space-y-2">
<div v-if="account.codexUsage" class="space-y-2">
<div class="space-y-1 rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
<div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<i class="fas fa-hourglass-half text-indigo-500" />
<span>5小时窗口</span>
</div>
<span class="font-semibold text-gray-800 dark:text-gray-100">
{{ formatCodexUsagePercent(account.codexUsage.primary.usedPercent) }}
</span>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-600">
<div
:class="[
'h-2 rounded-full transition-all duration-300',
getCodexUsageBarClass(account.codexUsage.primary.usedPercent)
]"
:style="{ width: getCodexUsageWidth(account.codexUsage.primary.usedPercent) }"
/>
</div>
<span class="text-[11px] text-gray-500 dark:text-gray-400">
{{ formatCodexWindowDisplay(account.codexUsage.primary.windowMinutes) }}
</span>
</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }}
</div>
</div>
<div class="space-y-1 rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
<div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<i class="fas fa-calendar-week text-blue-500" />
<span>7天窗口</span>
</div>
<span class="font-semibold text-gray-800 dark:text-gray-100">
{{ formatCodexUsagePercent(account.codexUsage.secondary.usedPercent) }}
</span>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-600">
<div
:class="[
'h-2 rounded-full transition-all duration-300',
getCodexUsageBarClass(account.codexUsage.secondary.usedPercent)
]"
:style="{ width: getCodexUsageWidth(account.codexUsage.secondary.usedPercent) }"
/>
</div>
<span class="text-[11px] text-gray-500 dark:text-gray-400">
{{ formatCodexWindowDisplay(account.codexUsage.secondary.windowMinutes) }}
</span>
</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }}
</div>
</div>
<div
v-if="account.codexUsage.primaryOverSecondaryPercent !== null && account.codexUsage.primaryOverSecondaryPercent !== undefined"
class="text-[11px] text-gray-500 dark:text-gray-400"
>
短期/长期占比
{{ formatCodexUsagePercent(account.codexUsage.primaryOverSecondaryPercent) }}
</div>
<div
v-if="account.codexUsage.updatedAt"
class="text-[11px] text-gray-400 dark:text-gray-500"
>
更新 {{ formatRelativeTime(account.codexUsage.updatedAt) }}
</div>
</div>
<div v-else class="text-sm text-gray-400">
<span class="text-xs">N/A</span>
</div>
</div>
<div v-else-if="account.platform === 'claude'" class="text-sm text-gray-400"> <div v-else-if="account.platform === 'claude'" class="text-sm text-gray-400">
<i class="fas fa-minus" /> <i class="fas fa-minus" />
</div> </div>
@@ -898,6 +974,78 @@
<span v-else class="text-gray-500"> 已结束 </span> <span v-else class="text-gray-500"> 已结束 </span>
</div> </div>
</div> </div>
<div v-else-if="account.platform === 'openai'" class="space-y-2">
<div v-if="account.codexUsage" class="space-y-2 rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<i class="fas fa-hourglass-half text-indigo-500" />
<span>5小时窗口</span>
</div>
<span class="font-semibold text-gray-800 dark:text-gray-100">
{{ formatCodexUsagePercent(account.codexUsage.primary.usedPercent) }}
</span>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-600">
<div
:class="[
'h-2 rounded-full transition-all duration-300',
getCodexUsageBarClass(account.codexUsage.primary.usedPercent)
]"
:style="{ width: getCodexUsageWidth(account.codexUsage.primary.usedPercent) }"
/>
</div>
<span class="text-[11px] text-gray-500 dark:text-gray-400">
{{ formatCodexWindowDisplay(account.codexUsage.primary.windowMinutes) }}
</span>
</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }}
</div>
</div>
<div v-if="account.codexUsage" class="space-y-2 rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
<div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<i class="fas fa-calendar-week text-blue-500" />
<span>7天窗口</span>
</div>
<span class="font-semibold text-gray-800 dark:text-gray-100">
{{ formatCodexUsagePercent(account.codexUsage.secondary.usedPercent) }}
</span>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-600">
<div
:class="[
'h-2 rounded-full transition-all duration-300',
getCodexUsageBarClass(account.codexUsage.secondary.usedPercent)
]"
:style="{ width: getCodexUsageWidth(account.codexUsage.secondary.usedPercent) }"
/>
</div>
<span class="text-[11px] text-gray-500 dark:text-gray-400">
{{ formatCodexWindowDisplay(account.codexUsage.secondary.windowMinutes) }}
</span>
</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }}
</div>
</div>
<div
v-if="account.codexUsage && account.codexUsage.primaryOverSecondaryPercent !== null && account.codexUsage.primaryOverSecondaryPercent !== undefined"
class="text-[11px] text-gray-500 dark:text-gray-400"
>
短期/长期占比
{{ formatCodexUsagePercent(account.codexUsage.primaryOverSecondaryPercent) }}
</div>
<div
v-if="account.codexUsage && account.codexUsage.updatedAt"
class="text-[11px] text-gray-400 dark:text-gray-500"
>
更新 {{ formatRelativeTime(account.codexUsage.updatedAt) }}
</div>
<div v-if="!account.codexUsage" class="text-xs text-gray-400">暂无统计</div>
</div>
<!-- 最后使用时间 --> <!-- 最后使用时间 -->
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
@@ -2051,6 +2199,102 @@ const getSessionProgressBarClass = (status, account = null) => {
} }
} }
// OpenAI 限额进度条颜色
const getCodexUsageBarClass = (percent) => {
if (percent === null || percent === undefined || Number.isNaN(percent)) {
return 'bg-gradient-to-r from-gray-300 to-gray-400'
}
if (percent >= 90) {
return 'bg-gradient-to-r from-red-500 to-red-600'
}
if (percent >= 75) {
return 'bg-gradient-to-r from-yellow-500 to-orange-500'
}
return 'bg-gradient-to-r from-emerald-500 to-teal-500'
}
// 百分比显示
const formatCodexUsagePercent = (percent) => {
if (percent === null || percent === undefined || Number.isNaN(percent)) {
return '--'
}
return `${percent.toFixed(1)}%`
}
// 进度条宽度
const getCodexUsageWidth = (percent) => {
if (percent === null || percent === undefined || Number.isNaN(percent)) {
return '0%'
}
const clamped = Math.max(0, Math.min(100, percent))
return `${clamped}%`
}
// 格式化窗口时长
const formatCodexWindowDisplay = (minutes) => {
if (!minutes || Number.isNaN(Number(minutes))) {
return '窗口 --'
}
const value = Number(minutes)
if (value >= 1440) {
const days = Math.floor(value / 1440)
const hours = Math.floor((value % 1440) / 60)
if (hours > 0) {
return `窗口 ${days}${hours}小时`
}
return `窗口 ${days}`
}
if (value >= 60) {
const hours = Math.floor(value / 60)
const remain = value % 60
if (remain > 0) {
return `窗口 ${hours}小时${remain}分钟`
}
return `窗口 ${hours}小时`
}
return `窗口 ${value}分钟`
}
// 格式化剩余时间
const formatCodexRemaining = (usageItem) => {
if (!usageItem) {
return '--'
}
let seconds = usageItem.remainingSeconds
if (seconds === null || seconds === undefined) {
seconds = usageItem.resetAfterSeconds
}
if (seconds === null || seconds === undefined || Number.isNaN(Number(seconds))) {
return '--'
}
seconds = Math.max(0, Math.floor(Number(seconds)))
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (days > 0) {
if (hours > 0) {
return `${days}${hours}小时`
}
return `${days}`
}
if (hours > 0) {
if (minutes > 0) {
return `${hours}小时${minutes}分钟`
}
return `${hours}小时`
}
if (minutes > 0) {
return `${minutes}分钟`
}
return `${secs}`
}
// 格式化费用显示 // 格式化费用显示
const formatCost = (cost) => { const formatCost = (cost) => {
if (!cost || cost === 0) return '0.0000' if (!cost || cost === 0) return '0.0000'