|
|
const { v4: uuidv4 } = require('uuid') |
|
|
const crypto = require('crypto') |
|
|
const redis = require('../models/redis') |
|
|
const logger = require('../utils/logger') |
|
|
const config = require('../../config/config') |
|
|
const LRUCache = require('../utils/lruCache') |
|
|
|
|
|
class OpenAIResponsesAccountService { |
|
|
constructor() { |
|
|
|
|
|
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' |
|
|
this.ENCRYPTION_SALT = 'openai-responses-salt' |
|
|
|
|
|
|
|
|
this.ACCOUNT_KEY_PREFIX = 'openai_responses_account:' |
|
|
this.SHARED_ACCOUNTS_KEY = 'shared_openai_responses_accounts' |
|
|
|
|
|
|
|
|
this._encryptionKeyCache = null |
|
|
|
|
|
|
|
|
this._decryptCache = new LRUCache(500) |
|
|
|
|
|
|
|
|
setInterval( |
|
|
() => { |
|
|
this._decryptCache.cleanup() |
|
|
logger.info( |
|
|
'🧹 OpenAI-Responses decrypt cache cleanup completed', |
|
|
this._decryptCache.getStats() |
|
|
) |
|
|
}, |
|
|
10 * 60 * 1000 |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
async createAccount(options = {}) { |
|
|
const { |
|
|
name = 'OpenAI Responses Account', |
|
|
description = '', |
|
|
baseApi = '', |
|
|
apiKey = '', |
|
|
userAgent = '', |
|
|
priority = 50, |
|
|
proxy = null, |
|
|
isActive = true, |
|
|
accountType = 'shared', |
|
|
schedulable = true, |
|
|
dailyQuota = 0, |
|
|
quotaResetTime = '00:00', |
|
|
rateLimitDuration = 60 |
|
|
} = options |
|
|
|
|
|
|
|
|
if (!baseApi || !apiKey) { |
|
|
throw new Error('Base API URL and API Key are required for OpenAI-Responses account') |
|
|
} |
|
|
|
|
|
|
|
|
const normalizedBaseApi = baseApi.endsWith('/') ? baseApi.slice(0, -1) : baseApi |
|
|
|
|
|
const accountId = uuidv4() |
|
|
|
|
|
const accountData = { |
|
|
id: accountId, |
|
|
platform: 'openai-responses', |
|
|
name, |
|
|
description, |
|
|
baseApi: normalizedBaseApi, |
|
|
apiKey: this._encryptSensitiveData(apiKey), |
|
|
userAgent, |
|
|
priority: priority.toString(), |
|
|
proxy: proxy ? JSON.stringify(proxy) : '', |
|
|
isActive: isActive.toString(), |
|
|
accountType, |
|
|
schedulable: schedulable.toString(), |
|
|
|
|
|
|
|
|
|
|
|
subscriptionExpiresAt: options.subscriptionExpiresAt || null, |
|
|
|
|
|
createdAt: new Date().toISOString(), |
|
|
lastUsedAt: '', |
|
|
status: 'active', |
|
|
errorMessage: '', |
|
|
|
|
|
rateLimitedAt: '', |
|
|
rateLimitStatus: '', |
|
|
rateLimitDuration: rateLimitDuration.toString(), |
|
|
|
|
|
dailyQuota: dailyQuota.toString(), |
|
|
dailyUsage: '0', |
|
|
lastResetDate: redis.getDateStringInTimezone(), |
|
|
quotaResetTime, |
|
|
quotaStoppedAt: '' |
|
|
} |
|
|
|
|
|
|
|
|
await this._saveAccount(accountId, accountData) |
|
|
|
|
|
logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`) |
|
|
|
|
|
return { |
|
|
...accountData, |
|
|
apiKey: '***' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async getAccount(accountId) { |
|
|
const client = redis.getClientSafe() |
|
|
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
const accountData = await client.hgetall(key) |
|
|
|
|
|
if (!accountData || !accountData.id) { |
|
|
return null |
|
|
} |
|
|
|
|
|
|
|
|
accountData.apiKey = this._decryptSensitiveData(accountData.apiKey) |
|
|
|
|
|
|
|
|
if (accountData.proxy) { |
|
|
try { |
|
|
accountData.proxy = JSON.parse(accountData.proxy) |
|
|
} catch (e) { |
|
|
accountData.proxy = null |
|
|
} |
|
|
} |
|
|
|
|
|
return accountData |
|
|
} |
|
|
|
|
|
|
|
|
async updateAccount(accountId, updates) { |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
throw new Error('Account not found') |
|
|
} |
|
|
|
|
|
|
|
|
if (updates.apiKey) { |
|
|
updates.apiKey = this._encryptSensitiveData(updates.apiKey) |
|
|
} |
|
|
|
|
|
|
|
|
if (updates.proxy !== undefined) { |
|
|
updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' |
|
|
} |
|
|
|
|
|
|
|
|
if (updates.baseApi) { |
|
|
updates.baseApi = updates.baseApi.endsWith('/') |
|
|
? updates.baseApi.slice(0, -1) |
|
|
: updates.baseApi |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (updates.subscriptionExpiresAt !== undefined) { |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
const client = redis.getClientSafe() |
|
|
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
await client.hset(key, updates) |
|
|
|
|
|
logger.info(`📝 Updated OpenAI-Responses account: ${account.name}`) |
|
|
|
|
|
return { success: true } |
|
|
} |
|
|
|
|
|
|
|
|
async deleteAccount(accountId) { |
|
|
const client = redis.getClientSafe() |
|
|
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
|
|
|
|
|
|
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) |
|
|
|
|
|
|
|
|
await client.del(key) |
|
|
|
|
|
logger.info(`🗑️ Deleted OpenAI-Responses account: ${accountId}`) |
|
|
|
|
|
return { success: true } |
|
|
} |
|
|
|
|
|
|
|
|
async getAllAccounts(includeInactive = false) { |
|
|
const client = redis.getClientSafe() |
|
|
const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY) |
|
|
const accounts = [] |
|
|
|
|
|
for (const accountId of accountIds) { |
|
|
const account = await this.getAccount(accountId) |
|
|
if (account) { |
|
|
|
|
|
if (includeInactive || account.isActive === 'true') { |
|
|
|
|
|
account.apiKey = '***' |
|
|
|
|
|
|
|
|
const rateLimitInfo = this._getRateLimitInfo(account) |
|
|
|
|
|
|
|
|
account.rateLimitStatus = rateLimitInfo.isRateLimited |
|
|
? { |
|
|
isRateLimited: true, |
|
|
rateLimitedAt: account.rateLimitedAt || null, |
|
|
minutesRemaining: rateLimitInfo.remainingMinutes || 0 |
|
|
} |
|
|
: { |
|
|
isRateLimited: false, |
|
|
rateLimitedAt: null, |
|
|
minutesRemaining: 0 |
|
|
} |
|
|
|
|
|
|
|
|
account.schedulable = account.schedulable !== 'false' |
|
|
|
|
|
account.isActive = account.isActive === 'true' |
|
|
|
|
|
|
|
|
account.expiresAt = account.subscriptionExpiresAt || null |
|
|
account.platform = account.platform || 'openai-responses' |
|
|
|
|
|
accounts.push(account) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`) |
|
|
for (const key of keys) { |
|
|
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '') |
|
|
if (!accountIds.includes(accountId)) { |
|
|
const accountData = await client.hgetall(key) |
|
|
if (accountData && accountData.id) { |
|
|
|
|
|
if (includeInactive || accountData.isActive === 'true') { |
|
|
|
|
|
accountData.apiKey = '***' |
|
|
|
|
|
if (accountData.proxy) { |
|
|
try { |
|
|
accountData.proxy = JSON.parse(accountData.proxy) |
|
|
} catch (e) { |
|
|
accountData.proxy = null |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const rateLimitInfo = this._getRateLimitInfo(accountData) |
|
|
|
|
|
|
|
|
accountData.rateLimitStatus = rateLimitInfo.isRateLimited |
|
|
? { |
|
|
isRateLimited: true, |
|
|
rateLimitedAt: accountData.rateLimitedAt || null, |
|
|
minutesRemaining: rateLimitInfo.remainingMinutes || 0 |
|
|
} |
|
|
: { |
|
|
isRateLimited: false, |
|
|
rateLimitedAt: null, |
|
|
minutesRemaining: 0 |
|
|
} |
|
|
|
|
|
|
|
|
accountData.schedulable = accountData.schedulable !== 'false' |
|
|
|
|
|
accountData.isActive = accountData.isActive === 'true' |
|
|
|
|
|
|
|
|
accountData.expiresAt = accountData.subscriptionExpiresAt || null |
|
|
accountData.platform = accountData.platform || 'openai-responses' |
|
|
|
|
|
accounts.push(accountData) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return accounts |
|
|
} |
|
|
|
|
|
|
|
|
async markAccountRateLimited(accountId, duration = null) { |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
return |
|
|
} |
|
|
|
|
|
const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60 |
|
|
const now = new Date() |
|
|
const resetAt = new Date(now.getTime() + rateLimitDuration * 60000) |
|
|
|
|
|
await this.updateAccount(accountId, { |
|
|
rateLimitedAt: now.toISOString(), |
|
|
rateLimitStatus: 'limited', |
|
|
rateLimitResetAt: resetAt.toISOString(), |
|
|
rateLimitDuration: rateLimitDuration.toString(), |
|
|
status: 'rateLimited', |
|
|
schedulable: 'false', |
|
|
errorMessage: `Rate limited until ${resetAt.toISOString()}` |
|
|
}) |
|
|
|
|
|
logger.warn( |
|
|
`⏳ Account ${account.name} marked as rate limited for ${rateLimitDuration} minutes (until ${resetAt.toISOString()})` |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
async markAccountUnauthorized(accountId, reason = 'OpenAI Responses账号认证失败(401错误)') { |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
return |
|
|
} |
|
|
|
|
|
const now = new Date().toISOString() |
|
|
const currentCount = parseInt(account.unauthorizedCount || '0', 10) |
|
|
const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 |
|
|
|
|
|
await this.updateAccount(accountId, { |
|
|
status: 'unauthorized', |
|
|
schedulable: 'false', |
|
|
errorMessage: reason, |
|
|
unauthorizedAt: now, |
|
|
unauthorizedCount: unauthorizedCount.toString() |
|
|
}) |
|
|
|
|
|
logger.warn( |
|
|
`🚫 OpenAI-Responses account ${account.name || accountId} marked 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-Responses account ${account.name || accountId} unauthorized state` |
|
|
) |
|
|
} catch (webhookError) { |
|
|
logger.error('Failed to send unauthorized webhook notification:', webhookError) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async checkAndClearRateLimit(accountId) { |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account || account.rateLimitStatus !== 'limited') { |
|
|
return false |
|
|
} |
|
|
|
|
|
const now = new Date() |
|
|
let shouldClear = false |
|
|
|
|
|
|
|
|
if (account.rateLimitResetAt) { |
|
|
const resetAt = new Date(account.rateLimitResetAt) |
|
|
shouldClear = now >= resetAt |
|
|
} else { |
|
|
|
|
|
const rateLimitedAt = new Date(account.rateLimitedAt) |
|
|
const rateLimitDuration = parseInt(account.rateLimitDuration) || 60 |
|
|
shouldClear = now - rateLimitedAt > rateLimitDuration * 60000 |
|
|
} |
|
|
|
|
|
if (shouldClear) { |
|
|
|
|
|
await this.updateAccount(accountId, { |
|
|
rateLimitedAt: '', |
|
|
rateLimitStatus: '', |
|
|
rateLimitResetAt: '', |
|
|
status: 'active', |
|
|
schedulable: 'true', |
|
|
errorMessage: '' |
|
|
}) |
|
|
|
|
|
logger.info(`✅ Rate limit cleared for account ${account.name}`) |
|
|
return true |
|
|
} |
|
|
|
|
|
return false |
|
|
} |
|
|
|
|
|
|
|
|
async toggleSchedulable(accountId) { |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
throw new Error('Account not found') |
|
|
} |
|
|
|
|
|
const newSchedulableStatus = account.schedulable === 'true' ? 'false' : 'true' |
|
|
await this.updateAccount(accountId, { |
|
|
schedulable: newSchedulableStatus |
|
|
}) |
|
|
|
|
|
logger.info( |
|
|
`🔄 Toggled schedulable status for account ${account.name}: ${newSchedulableStatus}` |
|
|
) |
|
|
|
|
|
return { |
|
|
success: true, |
|
|
schedulable: newSchedulableStatus === 'true' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async updateUsageQuota(accountId, amount) { |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
return |
|
|
} |
|
|
|
|
|
|
|
|
const today = redis.getDateStringInTimezone() |
|
|
if (account.lastResetDate !== today) { |
|
|
|
|
|
await this.updateAccount(accountId, { |
|
|
dailyUsage: amount.toString(), |
|
|
lastResetDate: today, |
|
|
quotaStoppedAt: '' |
|
|
}) |
|
|
} else { |
|
|
|
|
|
const currentUsage = parseFloat(account.dailyUsage) || 0 |
|
|
const newUsage = currentUsage + amount |
|
|
const dailyQuota = parseFloat(account.dailyQuota) || 0 |
|
|
|
|
|
const updates = { |
|
|
dailyUsage: newUsage.toString() |
|
|
} |
|
|
|
|
|
|
|
|
if (dailyQuota > 0 && newUsage >= dailyQuota) { |
|
|
updates.status = 'quotaExceeded' |
|
|
updates.quotaStoppedAt = new Date().toISOString() |
|
|
updates.errorMessage = `Daily quota exceeded: $${newUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}` |
|
|
logger.warn(`💸 Account ${account.name} exceeded daily quota`) |
|
|
} |
|
|
|
|
|
await this.updateAccount(accountId, updates) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async updateAccountUsage(accountId, tokens = 0) { |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
return |
|
|
} |
|
|
|
|
|
const updates = { |
|
|
lastUsedAt: new Date().toISOString() |
|
|
} |
|
|
|
|
|
|
|
|
if (tokens > 0) { |
|
|
const currentTokens = parseInt(account.totalUsedTokens) || 0 |
|
|
updates.totalUsedTokens = (currentTokens + tokens).toString() |
|
|
} |
|
|
|
|
|
await this.updateAccount(accountId, updates) |
|
|
} |
|
|
|
|
|
|
|
|
async recordUsage(accountId, tokens = 0) { |
|
|
return this.updateAccountUsage(accountId, tokens) |
|
|
} |
|
|
|
|
|
|
|
|
async resetAccountStatus(accountId) { |
|
|
const account = await this.getAccount(accountId) |
|
|
if (!account) { |
|
|
throw new Error('Account not found') |
|
|
} |
|
|
|
|
|
const updates = { |
|
|
|
|
|
status: account.apiKey ? 'active' : 'created', |
|
|
|
|
|
schedulable: 'true', |
|
|
|
|
|
errorMessage: '', |
|
|
rateLimitedAt: '', |
|
|
rateLimitStatus: '', |
|
|
rateLimitResetAt: '', |
|
|
rateLimitDuration: '' |
|
|
} |
|
|
|
|
|
await this.updateAccount(accountId, updates) |
|
|
logger.info(`✅ Reset all error status for OpenAI-Responses account ${accountId}`) |
|
|
|
|
|
|
|
|
try { |
|
|
const webhookNotifier = require('../utils/webhookNotifier') |
|
|
await webhookNotifier.sendAccountAnomalyNotification({ |
|
|
accountId, |
|
|
accountName: account.name || accountId, |
|
|
platform: 'openai-responses', |
|
|
status: 'recovered', |
|
|
errorCode: 'STATUS_RESET', |
|
|
reason: 'Account status manually reset', |
|
|
timestamp: new Date().toISOString() |
|
|
}) |
|
|
logger.info( |
|
|
`📢 Webhook notification sent for OpenAI-Responses 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' } |
|
|
} |
|
|
|
|
|
|
|
|
isSubscriptionExpired(account) { |
|
|
if (!account.subscriptionExpiresAt) { |
|
|
return false |
|
|
} |
|
|
|
|
|
const expiryDate = new Date(account.subscriptionExpiresAt) |
|
|
const now = new Date() |
|
|
|
|
|
if (expiryDate <= now) { |
|
|
logger.debug( |
|
|
`⏰ OpenAI-Responses Account ${account.name} (${account.id}) subscription expired at ${account.subscriptionExpiresAt}` |
|
|
) |
|
|
return true |
|
|
} |
|
|
|
|
|
return false |
|
|
} |
|
|
|
|
|
|
|
|
_getRateLimitInfo(accountData) { |
|
|
if (accountData.rateLimitStatus !== 'limited') { |
|
|
return { isRateLimited: false } |
|
|
} |
|
|
|
|
|
const now = new Date() |
|
|
let willBeAvailableAt |
|
|
let remainingMinutes |
|
|
|
|
|
|
|
|
if (accountData.rateLimitResetAt) { |
|
|
willBeAvailableAt = new Date(accountData.rateLimitResetAt) |
|
|
remainingMinutes = Math.max(0, Math.ceil((willBeAvailableAt - now) / 60000)) |
|
|
} else { |
|
|
|
|
|
const rateLimitedAt = new Date(accountData.rateLimitedAt) |
|
|
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60 |
|
|
const elapsedMinutes = Math.floor((now - rateLimitedAt) / 60000) |
|
|
remainingMinutes = Math.max(0, rateLimitDuration - elapsedMinutes) |
|
|
willBeAvailableAt = new Date(rateLimitedAt.getTime() + rateLimitDuration * 60000) |
|
|
} |
|
|
|
|
|
return { |
|
|
isRateLimited: remainingMinutes > 0, |
|
|
remainingMinutes, |
|
|
willBeAvailableAt |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
_encryptSensitiveData(text) { |
|
|
if (!text) { |
|
|
return '' |
|
|
} |
|
|
|
|
|
const key = this._getEncryptionKey() |
|
|
const iv = crypto.randomBytes(16) |
|
|
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) |
|
|
|
|
|
let encrypted = cipher.update(text) |
|
|
encrypted = Buffer.concat([encrypted, cipher.final()]) |
|
|
|
|
|
return `${iv.toString('hex')}:${encrypted.toString('hex')}` |
|
|
} |
|
|
|
|
|
|
|
|
_decryptSensitiveData(text) { |
|
|
if (!text || text === '') { |
|
|
return '' |
|
|
} |
|
|
|
|
|
|
|
|
const cacheKey = crypto.createHash('sha256').update(text).digest('hex') |
|
|
const cached = this._decryptCache.get(cacheKey) |
|
|
if (cached !== undefined) { |
|
|
return cached |
|
|
} |
|
|
|
|
|
try { |
|
|
const key = this._getEncryptionKey() |
|
|
const [ivHex, encryptedHex] = text.split(':') |
|
|
|
|
|
const iv = Buffer.from(ivHex, 'hex') |
|
|
const encryptedText = Buffer.from(encryptedHex, 'hex') |
|
|
|
|
|
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) |
|
|
let decrypted = decipher.update(encryptedText) |
|
|
decrypted = Buffer.concat([decrypted, decipher.final()]) |
|
|
|
|
|
const result = decrypted.toString() |
|
|
|
|
|
|
|
|
this._decryptCache.set(cacheKey, result, 5 * 60 * 1000) |
|
|
|
|
|
return result |
|
|
} catch (error) { |
|
|
logger.error('Decryption error:', error) |
|
|
return '' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
_getEncryptionKey() { |
|
|
if (!this._encryptionKeyCache) { |
|
|
this._encryptionKeyCache = crypto.scryptSync( |
|
|
config.security.encryptionKey, |
|
|
this.ENCRYPTION_SALT, |
|
|
32 |
|
|
) |
|
|
} |
|
|
return this._encryptionKeyCache |
|
|
} |
|
|
|
|
|
|
|
|
async _saveAccount(accountId, accountData) { |
|
|
const client = redis.getClientSafe() |
|
|
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` |
|
|
|
|
|
|
|
|
await client.hset(key, accountData) |
|
|
|
|
|
|
|
|
if (accountData.accountType === 'shared') { |
|
|
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
module.exports = new OpenAIResponsesAccountService() |
|
|
|