|
|
const redisClient = require('../models/redis') |
|
|
const { v4: uuidv4 } = require('uuid') |
|
|
const crypto = require('crypto') |
|
|
const axios = require('axios') |
|
|
const ProxyHelper = require('../utils/proxyHelper') |
|
|
const config = require('../../config/config') |
|
|
const logger = require('../utils/logger') |
|
|
|
|
|
const { |
|
|
logRefreshStart, |
|
|
logRefreshSuccess, |
|
|
logRefreshError, |
|
|
logTokenUsage, |
|
|
logRefreshSkipped |
|
|
} = require('../utils/tokenRefreshLogger') |
|
|
const LRUCache = require('../utils/lruCache') |
|
|
const tokenRefreshService = require('./tokenRefreshService') |
|
|
|
|
|
|
|
|
const ALGORITHM = 'aes-256-cbc' |
|
|
const ENCRYPTION_SALT = 'openai-account-salt' |
|
|
const IV_LENGTH = 16 |
|
|
|
|
|
|
|
|
|
|
|
let _encryptionKeyCache = null |
|
|
|
|
|
|
|
|
const decryptCache = new LRUCache(500) |
|
|
|
|
|
|
|
|
function generateEncryptionKey() { |
|
|
if (!_encryptionKeyCache) { |
|
|
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32) |
|
|
logger.info('🔑 OpenAI encryption key derived and cached for performance optimization') |
|
|
} |
|
|
return _encryptionKeyCache |
|
|
} |
|
|
|
|
|
|
|
|
const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:' |
|
|
const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts' |
|
|
const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:' |
|
|
|
|
|
|
|
|
function encrypt(text) { |
|
|
if (!text) { |
|
|
return '' |
|
|
} |
|
|
const key = generateEncryptionKey() |
|
|
const iv = crypto.randomBytes(IV_LENGTH) |
|
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv) |
|
|
let encrypted = cipher.update(text) |
|
|
encrypted = Buffer.concat([encrypted, cipher.final()]) |
|
|
return `${iv.toString('hex')}:${encrypted.toString('hex')}` |
|
|
} |
|
|
|
|
|
|
|
|
function decrypt(text) { |
|
|
if (!text || text === '') { |
|
|
return '' |
|
|
} |
|
|
|
|
|
|
|
|
if (text.length < 33 || text.charAt(32) !== ':') { |
|
|
logger.warn('Invalid encrypted text format, returning empty string', { |
|
|
textLength: text ? text.length : 0, |
|
|
char32: text && text.length > 32 ? text.charAt(32) : 'N/A', |
|
|
first50: text ? text.substring(0, 50) : 'N/A' |
|
|
}) |
|
|
return '' |
|
|
} |
|
|
|
|
|
|
|
|
const cacheKey = crypto.createHash('sha256').update(text).digest('hex') |
|
|
const cached = decryptCache.get(cacheKey) |
|
|
if (cached !== undefined) { |
|
|
return cached |
|
|
} |
|
|
|
|
|
try { |
|
|
const key = generateEncryptionKey() |
|
|
|
|
|
const ivHex = text.substring(0, 32) |
|
|
const encryptedHex = text.substring(33) |
|
|
|
|
|
const iv = Buffer.from(ivHex, 'hex') |
|
|
const encryptedText = Buffer.from(encryptedHex, 'hex') |
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) |
|
|
let decrypted = decipher.update(encryptedText) |
|
|
decrypted = Buffer.concat([decrypted, decipher.final()]) |
|
|
const result = decrypted.toString() |
|
|
|
|
|
|
|
|
decryptCache.set(cacheKey, result, 5 * 60 * 1000) |
|
|
|
|
|
|
|
|
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) { |
|
|
decryptCache.printStats() |
|
|
} |
|
|
|
|
|
return result |
|
|
} catch (error) { |
|
|
logger.error('Decryption error:', error) |
|
|
return '' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
setInterval( |
|
|
() => { |
|
|
decryptCache.cleanup() |
|
|
logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats()) |
|
|
}, |
|
|
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 { |
|
|
|
|
|
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' |
|
|
|
|
|
|
|
|
const requestData = new URLSearchParams({ |
|
|
grant_type: 'refresh_token', |
|
|
client_id: CLIENT_ID, |
|
|
refresh_token: refreshToken, |
|
|
scope: 'openid profile email' |
|
|
}).toString() |
|
|
|
|
|
|
|
|
const requestOptions = { |
|
|
method: 'POST', |
|
|
url: 'https://auth.openai.com/oauth/token', |
|
|
headers: { |
|
|
'Content-Type': 'application/x-www-form-urlencoded', |
|
|
'Content-Length': requestData.length |
|
|
}, |
|
|
data: requestData, |
|
|
timeout: config.requestTimeout || 600000 |
|
|
} |
|
|
|
|
|
|
|
|
const proxyAgent = ProxyHelper.createProxyAgent(proxy) |
|
|
if (proxyAgent) { |
|
|
requestOptions.httpAgent = proxyAgent |
|
|
requestOptions.httpsAgent = proxyAgent |
|
|
requestOptions.proxy = false |
|
|
logger.info( |
|
|
`🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}` |
|
|
) |
|
|
} else { |
|
|
logger.debug('🌐 No proxy configured for OpenAI token refresh') |
|
|
} |
|
|
|
|
|
|
|
|
logger.info('🔍 发送 token 刷新请求,使用代理:', !!requestOptions.httpsAgent) |
|
|
const response = await axios(requestOptions) |
|
|
|
|
|
if (response.status === 200 && response.data) { |
|
|
const result = response.data |
|
|
|
|
|
logger.info('✅ Successfully refreshed OpenAI token') |
|
|
|
|
|
|
|
|
return { |
|
|
access_token: result.access_token, |
|
|
id_token: result.id_token, |
|
|
refresh_token: result.refresh_token || refreshToken, |
|
|
expires_in: result.expires_in || 3600, |
|
|
expiry_date: Date.now() + (result.expires_in || 3600) * 1000 |
|
|
} |
|
|
} else { |
|
|
throw new Error(`Failed to refresh token: ${response.status} ${response.statusText}`) |
|
|
} |
|
|
} catch (error) { |
|
|
if (error.response) { |
|
|
|
|
|
const errorData = error.response.data || {} |
|
|
logger.error('OpenAI token refresh failed:', { |
|
|
status: error.response.status, |
|
|
data: errorData, |
|
|
headers: error.response.headers |
|
|
}) |
|
|
|
|
|
|
|
|
let errorMessage = `OpenAI 服务器返回错误 (${error.response.status})` |
|
|
|
|
|
if (error.response.status === 400) { |
|
|
if (errorData.error === 'invalid_grant') { |
|
|
errorMessage = 'Refresh Token 无效或已过期,请重新授权' |
|
|
} else if (errorData.error === 'invalid_request') { |
|
|
errorMessage = `请求参数错误:${errorData.error_description || errorData.error}` |
|
|
} else { |
|
|
errorMessage = `请求错误:${errorData.error_description || errorData.error || '未知错误'}` |
|
|
} |
|
|
} else if (error.response.status === 401) { |
|
|
errorMessage = '认证失败:Refresh Token 无效' |
|
|
} else if (error.response.status === 403) { |
|
|
errorMessage = '访问被拒绝:可能是 IP 被封或账户被禁用' |
|
|
} else if (error.response.status === 429) { |
|
|
errorMessage = '请求过于频繁,请稍后重试' |
|
|
} else if (error.response.status >= 500) { |
|
|
errorMessage = 'OpenAI 服务器内部错误,请稍后重试' |
|
|
} else if (errorData.error_description) { |
|
|
errorMessage = errorData.error_description |
|
|
} else if (errorData.error) { |
|
|
errorMessage = errorData.error |
|
|
} else if (errorData.message) { |
|
|
errorMessage = errorData.message |
|
|
} |
|
|
|
|
|
const fullError = new Error(errorMessage) |
|
|
fullError.status = error.response.status |
|
|
fullError.details = errorData |
|
|
throw fullError |
|
|
} else if (error.request) { |
|
|
|
|
|
logger.error('OpenAI token refresh no response:', error.message) |
|
|
|
|
|
let errorMessage = '无法连接到 OpenAI 服务器' |
|
|
if (proxy) { |
|
|
errorMessage += `(代理: ${ProxyHelper.getProxyDescription(proxy)})` |
|
|
} |
|
|
if (error.code === 'ECONNREFUSED') { |
|
|
errorMessage += ' - 连接被拒绝' |
|
|
} else if (error.code === 'ETIMEDOUT') { |
|
|
errorMessage += ' - 连接超时' |
|
|
} else if (error.code === 'ENOTFOUND') { |
|
|
errorMessage += ' - 无法解析域名' |
|
|
} else if (error.code === 'EPROTO') { |
|
|
errorMessage += ' - 协议错误(可能是代理配置问题)' |
|
|
} else if (error.message) { |
|
|
errorMessage += ` - ${error.message}` |
|
|
} |
|
|
|
|
|
const fullError = new Error(errorMessage) |
|
|
fullError.code = error.code |
|
|
throw fullError |
|
|
} else { |
|
|
|
|
|
logger.error('OpenAI token refresh error:', error.message) |
|
|
const fullError = new Error(`请求设置错误: ${error.message}`) |
|
|
fullError.originalError = error |
|
|
throw fullError |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function isTokenExpired(account) { |
|
|
if (!account.expiresAt) { |
|
|
return false |
|
|
} |
|
|
return new Date(account.expiresAt) <= new Date() |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function isSubscriptionExpired(account) { |
|
|
if (!account.subscriptionExpiresAt) { |
|
|
return false |
|
|
} |
|
|
const expiryDate = new Date(account.subscriptionExpiresAt) |
|
|
return expiryDate <= new Date() |
|
|
} |
|
|
|
|
|
|
|
|
async function refreshAccountToken(accountId) { |
|
|
let lockAcquired = false |
|
|
let account = null |
|
|
let accountName = accountId |
|
|
|
|
|
try { |
|
|
account = await getAccount(accountId) |
|
|
if (!account) { |
|
|
throw new Error('Account not found') |
|
|
} |
|
|
|
|
|
accountName = account.name || accountId |
|
|
|
|
|
|
|
|
|
|
|
const refreshToken = account.refreshToken || null |
|
|
|
|
|
if (!refreshToken) { |
|
|
logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available') |
|
|
throw new Error('No refresh token available') |
|
|
} |
|
|
|
|
|
|
|
|
lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'openai') |
|
|
|
|
|
if (!lockAcquired) { |
|
|
|
|
|
logger.info( |
|
|
`🔒 Token refresh already in progress for OpenAI account: ${accountName} (${accountId})` |
|
|
) |
|
|
logRefreshSkipped(accountId, accountName, 'openai', 'already_locked') |
|
|
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 2000)) |
|
|
|
|
|
|
|
|
const updatedAccount = await getAccount(accountId) |
|
|
if (updatedAccount && !isTokenExpired(updatedAccount)) { |
|
|
return { |
|
|
access_token: decrypt(updatedAccount.accessToken), |
|
|
id_token: updatedAccount.idToken, |
|
|
refresh_token: updatedAccount.refreshToken, |
|
|
expires_in: 3600, |
|
|
expiry_date: new Date(updatedAccount.expiresAt).getTime() |
|
|
} |
|
|
} |
|
|
|
|
|
throw new Error('Token refresh in progress by another process') |
|
|
} |
|
|
|
|
|
|
|
|
logRefreshStart(accountId, accountName, 'openai') |
|
|
logger.info(`🔄 Starting token refresh for OpenAI account: ${accountName} (${accountId})`) |
|
|
|
|
|
|
|
|
let proxy = null |
|
|
if (account.proxy) { |
|
|
try { |
|
|
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy |
|
|
} catch (e) { |
|
|
logger.warn(`Failed to parse proxy config for account ${accountId}:`, e) |
|
|
} |
|
|
} |
|
|
|
|
|
const newTokens = await refreshAccessToken(refreshToken, proxy) |
|
|
if (!newTokens) { |
|
|
throw new Error('Failed to refresh token') |
|
|
} |
|
|
|
|
|
|
|
|
const updates = { |
|
|
accessToken: newTokens.access_token, |
|
|
expiresAt: new Date(newTokens.expiry_date).toISOString() |
|
|
} |
|
|
|
|
|
|
|
|
if (newTokens.id_token) { |
|
|
updates.idToken = newTokens.id_token |
|
|
|
|
|
|
|
|
if (!account.idToken || account.idToken === '') { |
|
|
try { |
|
|
const idTokenParts = newTokens.id_token.split('.') |
|
|
if (idTokenParts.length === 3) { |
|
|
const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64').toString()) |
|
|
const authClaims = payload['https://api.openai.com/auth'] || {} |
|
|
|
|
|
|
|
|
|
|
|
if (authClaims.chatgpt_account_id) { |
|
|
updates.accountId = authClaims.chatgpt_account_id |
|
|
} |
|
|
if (authClaims.chatgpt_user_id) { |
|
|
updates.chatgptUserId = authClaims.chatgpt_user_id |
|
|
} else if (authClaims.user_id) { |
|
|
|
|
|
updates.chatgptUserId = authClaims.user_id |
|
|
} |
|
|
if (authClaims.organizations?.[0]?.id) { |
|
|
updates.organizationId = authClaims.organizations[0].id |
|
|
} |
|
|
if (authClaims.organizations?.[0]?.role) { |
|
|
updates.organizationRole = authClaims.organizations[0].role |
|
|
} |
|
|
if (authClaims.organizations?.[0]?.title) { |
|
|
updates.organizationTitle = authClaims.organizations[0].title |
|
|
} |
|
|
if (payload.email) { |
|
|
updates.email = payload.email |
|
|
} |
|
|
if (payload.email_verified !== undefined) { |
|
|
updates.emailVerified = payload.email_verified |
|
|
} |
|
|
|
|
|
logger.info(`Updated user info from ID Token for account ${accountId}`) |
|
|
} |
|
|
} catch (e) { |
|
|
logger.warn(`Failed to parse ID Token for account ${accountId}:`, e) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (newTokens.refresh_token && newTokens.refresh_token !== refreshToken) { |
|
|
updates.refreshToken = newTokens.refresh_token |
|
|
logger.info(`Updated refresh token for account ${accountId}`) |
|
|
} |
|
|
|
|
|
|
|
|
await updateAccount(accountId, updates) |
|
|
|
|
|
logRefreshSuccess(accountId, accountName, 'openai', newTokens) |
|
|
return newTokens |
|
|
} catch (error) { |
|
|
logRefreshError(accountId, account?.name || accountName, 'openai', error.message) |
|
|
|
|
|
|
|
|
try { |
|
|
const webhookNotifier = require('../utils/webhookNotifier') |
|
|
await webhookNotifier.sendAccountAnomalyNotification({ |
|
|
accountId, |
|
|
accountName: account?.name || accountName, |
|
|
platform: 'openai', |
|
|
status: 'error', |
|
|
errorCode: 'OPENAI_TOKEN_REFRESH_FAILED', |
|
|
reason: `Token refresh failed: ${error.message}`, |
|
|
timestamp: new Date().toISOString() |
|
|
}) |
|
|
logger.info( |
|
|
`📢 Webhook notification sent for OpenAI account ${account?.name || accountName} refresh failure` |
|
|
) |
|
|
} catch (webhookError) { |
|
|
logger.error('Failed to send webhook notification:', webhookError) |
|
|
} |
|
|
|
|
|
throw error |
|
|
} finally { |
|
|
|
|
|
if (lockAcquired) { |
|
|
await tokenRefreshService.releaseRefreshLock(accountId, 'openai') |
|
|
logger.debug(`🔓 Released refresh lock for OpenAI account ${accountId}`) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function createAccount(accountData) { |
|
|
const accountId = uuidv4() |
|
|
const now = new Date().toISOString() |
|
|
|
|
|
|
|
|
let oauthData = {} |
|
|
if (accountData.openaiOauth) { |
|
|
oauthData = |
|
|
typeof accountData.openaiOauth === 'string' |
|
|
? JSON.parse(accountData.openaiOauth) |
|
|
: accountData.openaiOauth |
|
|
} |
|
|
|
|
|
|
|
|
const accountInfo = accountData.accountInfo || {} |
|
|
|
|
|
|
|
|
const isEmailEncrypted = |
|
|
accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':' |
|
|
|
|
|
const account = { |
|
|
id: accountId, |
|
|
name: accountData.name, |
|
|
description: accountData.description || '', |
|
|
accountType: accountData.accountType || 'shared', |
|
|
groupId: accountData.groupId || null, |
|
|
priority: accountData.priority || 50, |
|
|
rateLimitDuration: |
|
|
accountData.rateLimitDuration !== undefined && accountData.rateLimitDuration !== null |
|
|
? accountData.rateLimitDuration |
|
|
: 60, |
|
|
|
|
|
|
|
|
idToken: oauthData.idToken && oauthData.idToken.trim() ? encrypt(oauthData.idToken) : '', |
|
|
accessToken: |
|
|
oauthData.accessToken && oauthData.accessToken.trim() ? encrypt(oauthData.accessToken) : '', |
|
|
refreshToken: |
|
|
oauthData.refreshToken && oauthData.refreshToken.trim() |
|
|
? encrypt(oauthData.refreshToken) |
|
|
: '', |
|
|
openaiOauth: encrypt(JSON.stringify(oauthData)), |
|
|
|
|
|
accountId: accountInfo.accountId || '', |
|
|
chatgptUserId: accountInfo.chatgptUserId || '', |
|
|
organizationId: accountInfo.organizationId || '', |
|
|
organizationRole: accountInfo.organizationRole || '', |
|
|
organizationTitle: accountInfo.organizationTitle || '', |
|
|
planType: accountInfo.planType || '', |
|
|
|
|
|
email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''), |
|
|
emailVerified: accountInfo.emailVerified === true ? 'true' : 'false', |
|
|
|
|
|
expiresAt: oauthData.expires_in |
|
|
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString() |
|
|
: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), |
|
|
|
|
|
|
|
|
subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, |
|
|
|
|
|
|
|
|
isActive: accountData.isActive !== false ? 'true' : 'false', |
|
|
status: 'active', |
|
|
schedulable: accountData.schedulable !== false ? 'true' : 'false', |
|
|
lastRefresh: now, |
|
|
createdAt: now, |
|
|
updatedAt: now |
|
|
} |
|
|
|
|
|
|
|
|
if (accountData.proxy) { |
|
|
account.proxy = |
|
|
typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy) |
|
|
} |
|
|
|
|
|
const client = redisClient.getClientSafe() |
|
|
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account) |
|
|
|
|
|
|
|
|
if (account.accountType === 'shared') { |
|
|
await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId) |
|
|
} |
|
|
|
|
|
logger.info(`Created OpenAI account: ${accountId}`) |
|
|
return account |
|
|
} |
|
|
|
|
|
|
|
|
async function getAccount(accountId) { |
|
|
const client = redisClient.getClientSafe() |
|
|
const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) |
|
|
|
|
|
if (!accountData || Object.keys(accountData).length === 0) { |
|
|
return null |
|
|
} |
|
|
|
|
|
|
|
|
if (accountData.idToken) { |
|
|
accountData.idToken = decrypt(accountData.idToken) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (accountData.refreshToken) { |
|
|
accountData.refreshToken = decrypt(accountData.refreshToken) |
|
|
} |
|
|
if (accountData.email) { |
|
|
accountData.email = decrypt(accountData.email) |
|
|
} |
|
|
if (accountData.openaiOauth) { |
|
|
try { |
|
|
accountData.openaiOauth = JSON.parse(decrypt(accountData.openaiOauth)) |
|
|
} catch (e) { |
|
|
accountData.openaiOauth = null |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (accountData.proxy && typeof accountData.proxy === 'string') { |
|
|
try { |
|
|
accountData.proxy = JSON.parse(accountData.proxy) |
|
|
} catch (e) { |
|
|
accountData.proxy = null |
|
|
} |
|
|
} |
|
|
|
|
|
return accountData |
|
|
} |
|
|
|
|
|
|
|
|
async function updateAccount(accountId, updates) { |
|
|
const existingAccount = await getAccount(accountId) |
|
|
if (!existingAccount) { |
|
|
throw new Error('Account not found') |
|
|
} |
|
|
|
|
|
updates.updatedAt = new Date().toISOString() |
|
|
|
|
|
|
|
|
if (updates.openaiOauth) { |
|
|
const oauthData = |
|
|
typeof updates.openaiOauth === 'string' |
|
|
? updates.openaiOauth |
|
|
: JSON.stringify(updates.openaiOauth) |
|
|
updates.openaiOauth = encrypt(oauthData) |
|
|
} |
|
|
if (updates.idToken) { |
|
|
updates.idToken = encrypt(updates.idToken) |
|
|
} |
|
|
if (updates.accessToken) { |
|
|
updates.accessToken = encrypt(updates.accessToken) |
|
|
} |
|
|
if (updates.refreshToken && updates.refreshToken.trim()) { |
|
|
updates.refreshToken = encrypt(updates.refreshToken) |
|
|
} |
|
|
if (updates.email) { |
|
|
updates.email = encrypt(updates.email) |
|
|
} |
|
|
|
|
|
|
|
|
if (updates.proxy) { |
|
|
updates.proxy = |
|
|
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (updates.subscriptionExpiresAt !== undefined) { |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
const client = redisClient.getClientSafe() |
|
|
if (updates.accountType && updates.accountType !== existingAccount.accountType) { |
|
|
if (updates.accountType === 'shared') { |
|
|
await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId) |
|
|
} else { |
|
|
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId) |
|
|
} |
|
|
} |
|
|
|
|
|
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) |
|
|
|
|
|
logger.info(`Updated OpenAI account: ${accountId}`) |
|
|
|
|
|
|
|
|
const updatedAccount = { ...existingAccount, ...updates } |
|
|
|
|
|
|
|
|
if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') { |
|
|
try { |
|
|
updatedAccount.proxy = JSON.parse(updatedAccount.proxy) |
|
|
} catch (e) { |
|
|
updatedAccount.proxy = null |
|
|
} |
|
|
} |
|
|
|
|
|
return updatedAccount |
|
|
} |
|
|
|
|
|
|
|
|
async function deleteAccount(accountId) { |
|
|
const account = await getAccount(accountId) |
|
|
if (!account) { |
|
|
throw new Error('Account not found') |
|
|
} |
|
|
|
|
|
|
|
|
const client = redisClient.getClientSafe() |
|
|
await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) |
|
|
|
|
|
|
|
|
if (account.accountType === 'shared') { |
|
|
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId) |
|
|
} |
|
|
|
|
|
|
|
|
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`) |
|
|
for (const key of sessionMappings) { |
|
|
const mappedAccountId = await client.get(key) |
|
|
if (mappedAccountId === accountId) { |
|
|
await client.del(key) |
|
|
} |
|
|
} |
|
|
|
|
|
logger.info(`Deleted OpenAI account: ${accountId}`) |
|
|
return true |
|
|
} |
|
|
|
|
|
|
|
|
async function getAllAccounts() { |
|
|
const client = redisClient.getClientSafe() |
|
|
const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`) |
|
|
const accounts = [] |
|
|
|
|
|
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) |
|
|
} |
|
|
|
|
|
|
|
|
const hasRefreshTokenFlag = !!accountData.refreshToken |
|
|
const maskedAccessToken = accountData.accessToken ? '[ENCRYPTED]' : '' |
|
|
const maskedRefreshToken = accountData.refreshToken ? '[ENCRYPTED]' : '' |
|
|
const maskedOauth = accountData.openaiOauth ? '[ENCRYPTED]' : '' |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
delete accountData.codexUsageUpdatedAt |
|
|
|
|
|
|
|
|
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id) |
|
|
|
|
|
|
|
|
if (accountData.proxy) { |
|
|
try { |
|
|
accountData.proxy = JSON.parse(accountData.proxy) |
|
|
} catch (e) { |
|
|
|
|
|
accountData.proxy = null |
|
|
} |
|
|
} |
|
|
|
|
|
const tokenExpiresAt = accountData.expiresAt || null |
|
|
const subscriptionExpiresAt = |
|
|
accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' |
|
|
? accountData.subscriptionExpiresAt |
|
|
: null |
|
|
|
|
|
|
|
|
accounts.push({ |
|
|
...accountData, |
|
|
isActive: accountData.isActive === 'true', |
|
|
schedulable: accountData.schedulable !== 'false', |
|
|
openaiOauth: maskedOauth, |
|
|
accessToken: maskedAccessToken, |
|
|
refreshToken: maskedRefreshToken, |
|
|
|
|
|
|
|
|
tokenExpiresAt, |
|
|
subscriptionExpiresAt, |
|
|
expiresAt: subscriptionExpiresAt, |
|
|
|
|
|
|
|
|
|
|
|
scopes: |
|
|
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], |
|
|
|
|
|
hasRefreshToken: hasRefreshTokenFlag, |
|
|
|
|
|
rateLimitStatus: rateLimitInfo |
|
|
? { |
|
|
status: rateLimitInfo.status, |
|
|
isRateLimited: rateLimitInfo.isRateLimited, |
|
|
rateLimitedAt: rateLimitInfo.rateLimitedAt, |
|
|
rateLimitResetAt: rateLimitInfo.rateLimitResetAt, |
|
|
minutesRemaining: rateLimitInfo.minutesRemaining |
|
|
} |
|
|
: { |
|
|
status: 'normal', |
|
|
isRateLimited: false, |
|
|
rateLimitedAt: null, |
|
|
rateLimitResetAt: null, |
|
|
minutesRemaining: 0 |
|
|
}, |
|
|
codexUsage |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
return accounts |
|
|
} |
|
|
|
|
|
|
|
|
async function getAccountOverview(accountId) { |
|
|
const client = redisClient.getClientSafe() |
|
|
const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) |
|
|
|
|
|
if (!accountData || Object.keys(accountData).length === 0) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const codexUsage = buildCodexUsageSnapshot(accountData) |
|
|
const rateLimitInfo = await getAccountRateLimitInfo(accountId) |
|
|
|
|
|
if (accountData.proxy) { |
|
|
try { |
|
|
accountData.proxy = JSON.parse(accountData.proxy) |
|
|
} catch (error) { |
|
|
accountData.proxy = null |
|
|
} |
|
|
} |
|
|
|
|
|
const scopes = |
|
|
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [] |
|
|
|
|
|
return { |
|
|
id: accountData.id, |
|
|
accountType: accountData.accountType || 'shared', |
|
|
platform: accountData.platform || 'openai', |
|
|
isActive: accountData.isActive === 'true', |
|
|
schedulable: accountData.schedulable !== 'false', |
|
|
rateLimitStatus: rateLimitInfo || { |
|
|
status: 'normal', |
|
|
isRateLimited: false, |
|
|
rateLimitedAt: null, |
|
|
rateLimitResetAt: null, |
|
|
minutesRemaining: 0 |
|
|
}, |
|
|
codexUsage, |
|
|
scopes |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function selectAvailableAccount(apiKeyId, sessionHash = null) { |
|
|
|
|
|
const client = redisClient.getClientSafe() |
|
|
if (sessionHash) { |
|
|
const mappedAccountId = await client.get(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`) |
|
|
|
|
|
if (mappedAccountId) { |
|
|
const account = await getAccount(mappedAccountId) |
|
|
if (account && account.isActive === 'true' && !isTokenExpired(account)) { |
|
|
logger.debug(`Using sticky session account: ${mappedAccountId}`) |
|
|
return account |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`) |
|
|
|
|
|
|
|
|
if (apiKeyData.openaiAccountId) { |
|
|
const account = await getAccount(apiKeyData.openaiAccountId) |
|
|
if (account && account.isActive === 'true') { |
|
|
|
|
|
const isExpired = isTokenExpired(account) |
|
|
|
|
|
|
|
|
logTokenUsage(account.id, account.name, 'openai', account.expiresAt, isExpired) |
|
|
|
|
|
if (isExpired) { |
|
|
await refreshAccountToken(account.id) |
|
|
return await getAccount(account.id) |
|
|
} |
|
|
|
|
|
|
|
|
if (sessionHash) { |
|
|
await client.setex( |
|
|
`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, |
|
|
3600, |
|
|
account.id |
|
|
) |
|
|
} |
|
|
|
|
|
return account |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const sharedAccountIds = await client.smembers(SHARED_OPENAI_ACCOUNTS_KEY) |
|
|
const availableAccounts = [] |
|
|
|
|
|
for (const accountId of sharedAccountIds) { |
|
|
const account = await getAccount(accountId) |
|
|
if ( |
|
|
account && |
|
|
account.isActive === 'true' && |
|
|
!isRateLimited(account) && |
|
|
!isSubscriptionExpired(account) |
|
|
) { |
|
|
availableAccounts.push(account) |
|
|
} else if (account && isSubscriptionExpired(account)) { |
|
|
logger.debug( |
|
|
`⏰ Skipping expired OpenAI account: ${account.name}, expired at ${account.subscriptionExpiresAt}` |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
if (availableAccounts.length === 0) { |
|
|
throw new Error('No available OpenAI accounts') |
|
|
} |
|
|
|
|
|
|
|
|
const selectedAccount = availableAccounts.reduce((prev, curr) => { |
|
|
const prevUsage = parseInt(prev.totalUsage || 0) |
|
|
const currUsage = parseInt(curr.totalUsage || 0) |
|
|
return prevUsage <= currUsage ? prev : curr |
|
|
}) |
|
|
|
|
|
|
|
|
if (isTokenExpired(selectedAccount)) { |
|
|
await refreshAccountToken(selectedAccount.id) |
|
|
return await getAccount(selectedAccount.id) |
|
|
} |
|
|
|
|
|
|
|
|
if (sessionHash) { |
|
|
await client.setex( |
|
|
`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, |
|
|
3600, |
|
|
selectedAccount.id |
|
|
) |
|
|
} |
|
|
|
|
|
return selectedAccount |
|
|
} |
|
|
|
|
|
|
|
|
function isRateLimited(account) { |
|
|
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { |
|
|
const limitedAt = new Date(account.rateLimitedAt).getTime() |
|
|
const now = Date.now() |
|
|
const limitDuration = 60 * 60 * 1000 |
|
|
|
|
|
return now < limitedAt + limitDuration |
|
|
} |
|
|
return false |
|
|
} |
|
|
|
|
|
|
|
|
async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = null) { |
|
|
const updates = { |
|
|
rateLimitStatus: isLimited ? 'limited' : 'normal', |
|
|
rateLimitedAt: isLimited ? new Date().toISOString() : null, |
|
|
|
|
|
schedulable: isLimited ? 'false' : 'true' |
|
|
} |
|
|
|
|
|
|
|
|
if (isLimited && resetsInSeconds !== null && resetsInSeconds > 0) { |
|
|
const resetTime = new Date(Date.now() + resetsInSeconds * 1000).toISOString() |
|
|
updates.rateLimitResetAt = resetTime |
|
|
logger.info( |
|
|
`🕐 Account ${accountId} will be reset at ${resetTime} (in ${resetsInSeconds} seconds / ${Math.ceil(resetsInSeconds / 60)} minutes)` |
|
|
) |
|
|
} else if (isLimited) { |
|
|
|
|
|
const defaultResetSeconds = 60 * 60 |
|
|
const resetTime = new Date(Date.now() + defaultResetSeconds * 1000).toISOString() |
|
|
updates.rateLimitResetAt = resetTime |
|
|
logger.warn( |
|
|
`⚠️ No reset time provided for account ${accountId}, using default 60 minutes. Reset at ${resetTime}` |
|
|
) |
|
|
} else if (!isLimited) { |
|
|
updates.rateLimitResetAt = null |
|
|
} |
|
|
|
|
|
await updateAccount(accountId, updates) |
|
|
logger.info( |
|
|
`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}, schedulable: ${updates.schedulable}` |
|
|
) |
|
|
|
|
|
|
|
|
if (isLimited) { |
|
|
try { |
|
|
const account = await getAccount(accountId) |
|
|
const webhookNotifier = require('../utils/webhookNotifier') |
|
|
await webhookNotifier.sendAccountAnomalyNotification({ |
|
|
accountId, |
|
|
accountName: account.name || accountId, |
|
|
platform: 'openai', |
|
|
status: 'blocked', |
|
|
errorCode: 'OPENAI_RATE_LIMITED', |
|
|
reason: resetsInSeconds |
|
|
? `Account rate limited (429 error). Reset in ${Math.ceil(resetsInSeconds / 60)} minutes` |
|
|
: 'Account rate limited (429 error). Estimated reset in 1 hour', |
|
|
timestamp: new Date().toISOString() |
|
|
}) |
|
|
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`) |
|
|
} catch (webhookError) { |
|
|
logger.error('Failed to send rate limit webhook notification:', webhookError) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function markAccountUnauthorized(accountId, reason = 'OpenAI账号认证失败(401错误)') { |
|
|
const account = await getAccount(accountId) |
|
|
if (!account) { |
|
|
throw new Error('Account not found') |
|
|
} |
|
|
|
|
|
const now = new Date().toISOString() |
|
|
const currentCount = parseInt(account.unauthorizedCount || '0', 10) |
|
|
const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 |
|
|
|
|
|
const updates = { |
|
|
status: 'unauthorized', |
|
|
schedulable: 'false', |
|
|
errorMessage: reason, |
|
|
unauthorizedAt: now, |
|
|
unauthorizedCount: unauthorizedCount.toString() |
|
|
} |
|
|
|
|
|
await updateAccount(accountId, updates) |
|
|
logger.warn( |
|
|
`🚫 Marked OpenAI account ${account.name || accountId} as unauthorized due to 401 error` |
|
|
) |
|
|
|
|
|
try { |
|
|
const webhookNotifier = require('../utils/webhookNotifier') |
|
|
await webhookNotifier.sendAccountAnomalyNotification({ |
|
|
accountId, |
|
|
accountName: account.name || accountId, |
|
|
platform: 'openai', |
|
|
status: 'unauthorized', |
|
|
errorCode: 'OPENAI_UNAUTHORIZED', |
|
|
reason, |
|
|
timestamp: now |
|
|
}) |
|
|
logger.info( |
|
|
`📢 Webhook notification sent for OpenAI account ${account.name} unauthorized state` |
|
|
) |
|
|
} catch (webhookError) { |
|
|
logger.error('Failed to send unauthorized webhook notification:', webhookError) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function resetAccountStatus(accountId) { |
|
|
const account = await getAccount(accountId) |
|
|
if (!account) { |
|
|
throw new Error('Account not found') |
|
|
} |
|
|
|
|
|
const updates = { |
|
|
|
|
|
status: account.accessToken ? 'active' : 'created', |
|
|
|
|
|
schedulable: 'true', |
|
|
|
|
|
errorMessage: null, |
|
|
rateLimitedAt: null, |
|
|
rateLimitStatus: 'normal', |
|
|
rateLimitResetAt: null |
|
|
} |
|
|
|
|
|
await updateAccount(accountId, updates) |
|
|
logger.info(`✅ Reset all error status for OpenAI account ${accountId}`) |
|
|
|
|
|
|
|
|
try { |
|
|
const webhookNotifier = require('../utils/webhookNotifier') |
|
|
await webhookNotifier.sendAccountAnomalyNotification({ |
|
|
accountId, |
|
|
accountName: account.name || accountId, |
|
|
platform: 'openai', |
|
|
status: 'recovered', |
|
|
errorCode: 'STATUS_RESET', |
|
|
reason: 'Account status manually reset', |
|
|
timestamp: new Date().toISOString() |
|
|
}) |
|
|
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} status reset`) |
|
|
} catch (webhookError) { |
|
|
logger.error('Failed to send status reset webhook notification:', webhookError) |
|
|
} |
|
|
|
|
|
return { success: true, message: 'Account status reset successfully' } |
|
|
} |
|
|
|
|
|
|
|
|
async function toggleSchedulable(accountId) { |
|
|
const account = await getAccount(accountId) |
|
|
if (!account) { |
|
|
throw new Error('Account not found') |
|
|
} |
|
|
|
|
|
|
|
|
const newSchedulable = account.schedulable === 'false' ? 'true' : 'false' |
|
|
|
|
|
await updateAccount(accountId, { |
|
|
schedulable: newSchedulable |
|
|
}) |
|
|
|
|
|
logger.info(`Toggled schedulable status for OpenAI account ${accountId}: ${newSchedulable}`) |
|
|
|
|
|
return { |
|
|
success: true, |
|
|
schedulable: newSchedulable === 'true' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function getAccountRateLimitInfo(accountId) { |
|
|
const account = await getAccount(accountId) |
|
|
if (!account) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const status = account.rateLimitStatus || 'normal' |
|
|
const rateLimitedAt = account.rateLimitedAt || null |
|
|
const rateLimitResetAt = account.rateLimitResetAt || null |
|
|
|
|
|
if (status === 'limited') { |
|
|
const now = Date.now() |
|
|
let remainingTime = 0 |
|
|
|
|
|
if (rateLimitResetAt) { |
|
|
const resetAt = new Date(rateLimitResetAt).getTime() |
|
|
remainingTime = Math.max(0, resetAt - now) |
|
|
} else if (rateLimitedAt) { |
|
|
const limitedAt = new Date(rateLimitedAt).getTime() |
|
|
const limitDuration = 60 * 60 * 1000 |
|
|
remainingTime = Math.max(0, limitedAt + limitDuration - now) |
|
|
} |
|
|
|
|
|
const minutesRemaining = remainingTime > 0 ? Math.ceil(remainingTime / (60 * 1000)) : 0 |
|
|
|
|
|
return { |
|
|
status, |
|
|
isRateLimited: minutesRemaining > 0, |
|
|
rateLimitedAt, |
|
|
rateLimitResetAt, |
|
|
minutesRemaining |
|
|
} |
|
|
} |
|
|
|
|
|
return { |
|
|
status, |
|
|
isRateLimited: false, |
|
|
rateLimitedAt, |
|
|
rateLimitResetAt, |
|
|
minutesRemaining: 0 |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function updateAccountUsage(accountId, tokens = 0) { |
|
|
const account = await getAccount(accountId) |
|
|
if (!account) { |
|
|
return |
|
|
} |
|
|
|
|
|
const updates = { |
|
|
lastUsedAt: new Date().toISOString() |
|
|
} |
|
|
|
|
|
|
|
|
if (tokens > 0) { |
|
|
const totalUsage = parseInt(account.totalUsage || 0) + tokens |
|
|
updates.totalUsage = totalUsage.toString() |
|
|
} |
|
|
|
|
|
await updateAccount(accountId, updates) |
|
|
} |
|
|
|
|
|
|
|
|
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, |
|
|
getAccountOverview, |
|
|
updateAccount, |
|
|
deleteAccount, |
|
|
getAllAccounts, |
|
|
selectAvailableAccount, |
|
|
refreshAccountToken, |
|
|
isTokenExpired, |
|
|
setAccountRateLimited, |
|
|
markAccountUnauthorized, |
|
|
resetAccountStatus, |
|
|
toggleSchedulable, |
|
|
getAccountRateLimitInfo, |
|
|
updateAccountUsage, |
|
|
recordUsage, |
|
|
updateCodexUsageSnapshot, |
|
|
encrypt, |
|
|
decrypt, |
|
|
generateEncryptionKey, |
|
|
decryptCache |
|
|
} |
|
|
|