diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index ff01aaa9..eff686f8 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -17,6 +17,50 @@ function 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 账户 async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = null) { 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 限流错误 if (upstream.status === 429) { logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`) diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js index 5ed3f29b..47a9cb46 100644 --- a/src/services/openaiAccountService.js +++ b/src/services/openaiAccountService.js @@ -115,6 +115,85 @@ setInterval( 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) { try { @@ -650,6 +729,8 @@ async function getAllAccounts() { for (const key of keys) { const accountData = await client.hgetall(key) if (accountData && Object.keys(accountData).length > 0) { + const codexUsage = buildCodexUsageSnapshot(accountData) + // 解密敏感数据(但不返回给前端) if (accountData.email) { accountData.email = decrypt(accountData.email) @@ -657,12 +738,24 @@ async function getAllAccounts() { // 先保存 refreshToken 是否存在的标记 const hasRefreshTokenFlag = !!accountData.refreshToken + const maskedAccessToken = accountData.accessToken ? '[ENCRYPTED]' : '' + const maskedRefreshToken = accountData.refreshToken ? '[ENCRYPTED]' : '' + const maskedOauth = accountData.openaiOauth ? '[ENCRYPTED]' : '' // 屏蔽敏感信息(token等不应该返回给前端) delete accountData.idToken delete accountData.accessToken delete accountData.refreshToken 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) @@ -682,9 +775,9 @@ async function getAllAccounts() { ...accountData, isActive: accountData.isActive === 'true', schedulable: accountData.schedulable !== 'false', - openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '', - accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', - refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '', + openaiOauth: maskedOauth, + accessToken: maskedAccessToken, + refreshToken: maskedRefreshToken, // 添加 scopes 字段用于判断认证方式 // 处理空字符串的情况 scopes: @@ -706,7 +799,8 @@ async function getAllAccounts() { rateLimitedAt: null, rateLimitResetAt: null, minutesRemaining: 0 - } + }, + codexUsage }) } } @@ -1043,6 +1137,41 @@ async function updateAccountUsage(accountId, tokens = 0) { // 为了兼容性,保留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 = { createAccount, getAccount, @@ -1059,6 +1188,7 @@ module.exports = { getAccountRateLimitInfo, updateAccountUsage, recordUsage, // 别名,指向updateAccountUsage + updateCodexUsageSnapshot, encrypt, decrypt, generateEncryptionKey, diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 54dd8ade..1c42ce64 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -654,6 +654,82 @@ +
+
+
+
+
+ + 5小时窗口 +
+ + {{ formatCodexUsagePercent(account.codexUsage.primary.usedPercent) }} + +
+
+
+
+
+ + {{ formatCodexWindowDisplay(account.codexUsage.primary.windowMinutes) }} + +
+
+ 重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }} +
+
+
+
+
+ + 7天窗口 +
+ + {{ formatCodexUsagePercent(account.codexUsage.secondary.usedPercent) }} + +
+
+
+
+
+ + {{ formatCodexWindowDisplay(account.codexUsage.secondary.windowMinutes) }} + +
+
+ 重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }} +
+
+
+ 短期/长期占比 + {{ formatCodexUsagePercent(account.codexUsage.primaryOverSecondaryPercent) }} +
+
+ 更新 {{ formatRelativeTime(account.codexUsage.updatedAt) }} +
+
+
+ N/A +
+
@@ -898,6 +974,78 @@ 已结束
+
+
+
+
+ + 5小时窗口 +
+ + {{ formatCodexUsagePercent(account.codexUsage.primary.usedPercent) }} + +
+
+
+
+
+ + {{ formatCodexWindowDisplay(account.codexUsage.primary.windowMinutes) }} + +
+
+ 重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }} +
+
+
+
+
+ + 7天窗口 +
+ + {{ formatCodexUsagePercent(account.codexUsage.secondary.usedPercent) }} + +
+
+
+
+
+ + {{ formatCodexWindowDisplay(account.codexUsage.secondary.windowMinutes) }} + +
+
+ 重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }} +
+
+
+ 短期/长期占比 + {{ formatCodexUsagePercent(account.codexUsage.primaryOverSecondaryPercent) }} +
+
+ 更新 {{ formatRelativeTime(account.codexUsage.updatedAt) }} +
+
暂无统计
+
@@ -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) => { if (!cost || cost === 0) return '0.0000'