|
|
const crypto = require('crypto') |
|
|
const { v4: uuidv4 } = require('uuid') |
|
|
const config = require('../../config/config') |
|
|
const redis = require('../models/redis') |
|
|
const logger = require('../utils/logger') |
|
|
|
|
|
const ACCOUNT_TYPE_CONFIG = { |
|
|
claude: { prefix: 'claude:account:' }, |
|
|
'claude-console': { prefix: 'claude_console_account:' }, |
|
|
openai: { prefix: 'openai:account:' }, |
|
|
'openai-responses': { prefix: 'openai_responses_account:' }, |
|
|
'azure-openai': { prefix: 'azure_openai:account:' }, |
|
|
gemini: { prefix: 'gemini_account:' }, |
|
|
droid: { prefix: 'droid:account:' } |
|
|
} |
|
|
|
|
|
const ACCOUNT_TYPE_PRIORITY = [ |
|
|
'openai', |
|
|
'openai-responses', |
|
|
'azure-openai', |
|
|
'claude', |
|
|
'claude-console', |
|
|
'gemini', |
|
|
'droid' |
|
|
] |
|
|
|
|
|
const ACCOUNT_CATEGORY_MAP = { |
|
|
claude: 'claude', |
|
|
'claude-console': 'claude', |
|
|
openai: 'openai', |
|
|
'openai-responses': 'openai', |
|
|
'azure-openai': 'openai', |
|
|
gemini: 'gemini', |
|
|
droid: 'droid' |
|
|
} |
|
|
|
|
|
function normalizeAccountTypeKey(type) { |
|
|
if (!type) { |
|
|
return null |
|
|
} |
|
|
const lower = String(type).toLowerCase() |
|
|
if (lower === 'claude_console') { |
|
|
return 'claude-console' |
|
|
} |
|
|
if (lower === 'openai_responses' || lower === 'openai-response' || lower === 'openai-responses') { |
|
|
return 'openai-responses' |
|
|
} |
|
|
if (lower === 'azure_openai' || lower === 'azureopenai' || lower === 'azure-openai') { |
|
|
return 'azure-openai' |
|
|
} |
|
|
return lower |
|
|
} |
|
|
|
|
|
function sanitizeAccountIdForType(accountId, accountType) { |
|
|
if (!accountId || typeof accountId !== 'string') { |
|
|
return accountId |
|
|
} |
|
|
if (accountType === 'openai-responses') { |
|
|
return accountId.replace(/^responses:/, '') |
|
|
} |
|
|
return accountId |
|
|
} |
|
|
|
|
|
class ApiKeyService { |
|
|
constructor() { |
|
|
this.prefix = config.security.apiKeyPrefix |
|
|
} |
|
|
|
|
|
|
|
|
async generateApiKey(options = {}) { |
|
|
const { |
|
|
name = 'Unnamed Key', |
|
|
description = '', |
|
|
tokenLimit = 0, |
|
|
expiresAt = null, |
|
|
claudeAccountId = null, |
|
|
claudeConsoleAccountId = null, |
|
|
geminiAccountId = null, |
|
|
openaiAccountId = null, |
|
|
azureOpenaiAccountId = null, |
|
|
bedrockAccountId = null, |
|
|
droidAccountId = null, |
|
|
permissions = 'all', |
|
|
isActive = true, |
|
|
concurrencyLimit = 0, |
|
|
rateLimitWindow = null, |
|
|
rateLimitRequests = null, |
|
|
rateLimitCost = null, |
|
|
enableModelRestriction = false, |
|
|
restrictedModels = [], |
|
|
enableClientRestriction = false, |
|
|
allowedClients = [], |
|
|
dailyCostLimit = 0, |
|
|
totalCostLimit = 0, |
|
|
weeklyOpusCostLimit = 0, |
|
|
tags = [], |
|
|
activationDays = 0, |
|
|
activationUnit = 'days', |
|
|
expirationMode = 'fixed', |
|
|
icon = '' |
|
|
} = options |
|
|
|
|
|
|
|
|
const apiKey = `${this.prefix}${this._generateSecretKey()}` |
|
|
const keyId = uuidv4() |
|
|
const hashedKey = this._hashApiKey(apiKey) |
|
|
|
|
|
const keyData = { |
|
|
id: keyId, |
|
|
name, |
|
|
description, |
|
|
apiKey: hashedKey, |
|
|
tokenLimit: String(tokenLimit ?? 0), |
|
|
concurrencyLimit: String(concurrencyLimit ?? 0), |
|
|
rateLimitWindow: String(rateLimitWindow ?? 0), |
|
|
rateLimitRequests: String(rateLimitRequests ?? 0), |
|
|
rateLimitCost: String(rateLimitCost ?? 0), |
|
|
isActive: String(isActive), |
|
|
claudeAccountId: claudeAccountId || '', |
|
|
claudeConsoleAccountId: claudeConsoleAccountId || '', |
|
|
geminiAccountId: geminiAccountId || '', |
|
|
openaiAccountId: openaiAccountId || '', |
|
|
azureOpenaiAccountId: azureOpenaiAccountId || '', |
|
|
bedrockAccountId: bedrockAccountId || '', |
|
|
droidAccountId: droidAccountId || '', |
|
|
permissions: permissions || 'all', |
|
|
enableModelRestriction: String(enableModelRestriction), |
|
|
restrictedModels: JSON.stringify(restrictedModels || []), |
|
|
enableClientRestriction: String(enableClientRestriction || false), |
|
|
allowedClients: JSON.stringify(allowedClients || []), |
|
|
dailyCostLimit: String(dailyCostLimit || 0), |
|
|
totalCostLimit: String(totalCostLimit || 0), |
|
|
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), |
|
|
tags: JSON.stringify(tags || []), |
|
|
activationDays: String(activationDays || 0), |
|
|
activationUnit: activationUnit || 'days', |
|
|
expirationMode: expirationMode || 'fixed', |
|
|
isActivated: expirationMode === 'fixed' ? 'true' : 'false', |
|
|
activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', |
|
|
createdAt: new Date().toISOString(), |
|
|
lastUsedAt: '', |
|
|
expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', |
|
|
createdBy: options.createdBy || 'admin', |
|
|
userId: options.userId || '', |
|
|
userUsername: options.userUsername || '', |
|
|
icon: icon || '' |
|
|
} |
|
|
|
|
|
|
|
|
await redis.setApiKey(keyId, keyData, hashedKey) |
|
|
|
|
|
logger.success(`🔑 Generated new API key: ${name} (${keyId})`) |
|
|
|
|
|
return { |
|
|
id: keyId, |
|
|
apiKey, |
|
|
name: keyData.name, |
|
|
description: keyData.description, |
|
|
tokenLimit: parseInt(keyData.tokenLimit), |
|
|
concurrencyLimit: parseInt(keyData.concurrencyLimit), |
|
|
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), |
|
|
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), |
|
|
rateLimitCost: parseFloat(keyData.rateLimitCost || 0), |
|
|
isActive: keyData.isActive === 'true', |
|
|
claudeAccountId: keyData.claudeAccountId, |
|
|
claudeConsoleAccountId: keyData.claudeConsoleAccountId, |
|
|
geminiAccountId: keyData.geminiAccountId, |
|
|
openaiAccountId: keyData.openaiAccountId, |
|
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId, |
|
|
bedrockAccountId: keyData.bedrockAccountId, |
|
|
droidAccountId: keyData.droidAccountId, |
|
|
permissions: keyData.permissions, |
|
|
enableModelRestriction: keyData.enableModelRestriction === 'true', |
|
|
restrictedModels: JSON.parse(keyData.restrictedModels), |
|
|
enableClientRestriction: keyData.enableClientRestriction === 'true', |
|
|
allowedClients: JSON.parse(keyData.allowedClients || '[]'), |
|
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), |
|
|
totalCostLimit: parseFloat(keyData.totalCostLimit || 0), |
|
|
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), |
|
|
tags: JSON.parse(keyData.tags || '[]'), |
|
|
activationDays: parseInt(keyData.activationDays || 0), |
|
|
activationUnit: keyData.activationUnit || 'days', |
|
|
expirationMode: keyData.expirationMode || 'fixed', |
|
|
isActivated: keyData.isActivated === 'true', |
|
|
activatedAt: keyData.activatedAt, |
|
|
createdAt: keyData.createdAt, |
|
|
expiresAt: keyData.expiresAt, |
|
|
createdBy: keyData.createdBy |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async validateApiKey(apiKey) { |
|
|
try { |
|
|
if (!apiKey || !apiKey.startsWith(this.prefix)) { |
|
|
return { valid: false, error: 'Invalid API key format' } |
|
|
} |
|
|
|
|
|
|
|
|
const hashedKey = this._hashApiKey(apiKey) |
|
|
|
|
|
|
|
|
const keyData = await redis.findApiKeyByHash(hashedKey) |
|
|
|
|
|
if (!keyData) { |
|
|
return { valid: false, error: 'API key not found' } |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.isActive !== 'true') { |
|
|
return { valid: false, error: 'API key is disabled' } |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') { |
|
|
|
|
|
const now = new Date() |
|
|
const activationPeriod = parseInt(keyData.activationDays || 30) |
|
|
const activationUnit = keyData.activationUnit || 'days' |
|
|
|
|
|
|
|
|
let milliseconds |
|
|
if (activationUnit === 'hours') { |
|
|
milliseconds = activationPeriod * 60 * 60 * 1000 |
|
|
} else { |
|
|
milliseconds = activationPeriod * 24 * 60 * 60 * 1000 |
|
|
} |
|
|
|
|
|
const expiresAt = new Date(now.getTime() + milliseconds) |
|
|
|
|
|
|
|
|
keyData.isActivated = 'true' |
|
|
keyData.activatedAt = now.toISOString() |
|
|
keyData.expiresAt = expiresAt.toISOString() |
|
|
keyData.lastUsedAt = now.toISOString() |
|
|
|
|
|
|
|
|
await redis.setApiKey(keyData.id, keyData) |
|
|
|
|
|
logger.success( |
|
|
`🔓 API key activated: ${keyData.id} (${ |
|
|
keyData.name |
|
|
}), will expire in ${activationPeriod} ${activationUnit} at ${expiresAt.toISOString()}` |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { |
|
|
return { valid: false, error: 'API key has expired' } |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.userId) { |
|
|
try { |
|
|
const userService = require('./userService') |
|
|
const user = await userService.getUserById(keyData.userId, false) |
|
|
if (!user || !user.isActive) { |
|
|
return { valid: false, error: 'User account is disabled' } |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error('❌ Error checking user status during API key validation:', error) |
|
|
return { valid: false, error: 'Unable to validate user status' } |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const usage = await redis.getUsageStats(keyData.id) |
|
|
|
|
|
|
|
|
const [dailyCost, costStats] = await Promise.all([ |
|
|
redis.getDailyCost(keyData.id), |
|
|
redis.getCostStats(keyData.id) |
|
|
]) |
|
|
const totalCost = costStats?.total || 0 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logger.api(`🔓 API key validated successfully: ${keyData.id}`) |
|
|
|
|
|
|
|
|
let restrictedModels = [] |
|
|
try { |
|
|
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [] |
|
|
} catch (e) { |
|
|
restrictedModels = [] |
|
|
} |
|
|
|
|
|
|
|
|
let allowedClients = [] |
|
|
try { |
|
|
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [] |
|
|
} catch (e) { |
|
|
allowedClients = [] |
|
|
} |
|
|
|
|
|
|
|
|
let tags = [] |
|
|
try { |
|
|
tags = keyData.tags ? JSON.parse(keyData.tags) : [] |
|
|
} catch (e) { |
|
|
tags = [] |
|
|
} |
|
|
|
|
|
return { |
|
|
valid: true, |
|
|
keyData: { |
|
|
id: keyData.id, |
|
|
name: keyData.name, |
|
|
description: keyData.description, |
|
|
createdAt: keyData.createdAt, |
|
|
expiresAt: keyData.expiresAt, |
|
|
claudeAccountId: keyData.claudeAccountId, |
|
|
claudeConsoleAccountId: keyData.claudeConsoleAccountId, |
|
|
geminiAccountId: keyData.geminiAccountId, |
|
|
openaiAccountId: keyData.openaiAccountId, |
|
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId, |
|
|
bedrockAccountId: keyData.bedrockAccountId, |
|
|
droidAccountId: keyData.droidAccountId, |
|
|
permissions: keyData.permissions || 'all', |
|
|
tokenLimit: parseInt(keyData.tokenLimit), |
|
|
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), |
|
|
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), |
|
|
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), |
|
|
rateLimitCost: parseFloat(keyData.rateLimitCost || 0), |
|
|
enableModelRestriction: keyData.enableModelRestriction === 'true', |
|
|
restrictedModels, |
|
|
enableClientRestriction: keyData.enableClientRestriction === 'true', |
|
|
allowedClients, |
|
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), |
|
|
totalCostLimit: parseFloat(keyData.totalCostLimit || 0), |
|
|
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), |
|
|
dailyCost: dailyCost || 0, |
|
|
totalCost, |
|
|
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0, |
|
|
tags, |
|
|
usage |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error('❌ API key validation error:', error) |
|
|
return { valid: false, error: 'Internal validation error' } |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async validateApiKeyForStats(apiKey) { |
|
|
try { |
|
|
if (!apiKey || !apiKey.startsWith(this.prefix)) { |
|
|
return { valid: false, error: 'Invalid API key format' } |
|
|
} |
|
|
|
|
|
|
|
|
const hashedKey = this._hashApiKey(apiKey) |
|
|
|
|
|
|
|
|
const keyData = await redis.findApiKeyByHash(hashedKey) |
|
|
|
|
|
if (!keyData) { |
|
|
return { valid: false, error: 'API key not found' } |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.isActive !== 'true') { |
|
|
return { valid: false, error: 'API key is disabled' } |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if ( |
|
|
keyData.isActivated === 'true' && |
|
|
keyData.expiresAt && |
|
|
new Date() > new Date(keyData.expiresAt) |
|
|
) { |
|
|
return { valid: false, error: 'API key has expired' } |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.userId) { |
|
|
try { |
|
|
const userService = require('./userService') |
|
|
const user = await userService.getUserById(keyData.userId, false) |
|
|
if (!user || !user.isActive) { |
|
|
return { valid: false, error: 'User account is disabled' } |
|
|
} |
|
|
} catch (userError) { |
|
|
|
|
|
logger.warn(`Failed to check user status for API key ${keyData.id}:`, userError) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const [dailyCost, costStats] = await Promise.all([ |
|
|
redis.getDailyCost(keyData.id), |
|
|
redis.getCostStats(keyData.id) |
|
|
]) |
|
|
|
|
|
|
|
|
const usage = await redis.getUsageStats(keyData.id) |
|
|
|
|
|
|
|
|
let restrictedModels = [] |
|
|
try { |
|
|
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [] |
|
|
} catch (e) { |
|
|
restrictedModels = [] |
|
|
} |
|
|
|
|
|
|
|
|
let allowedClients = [] |
|
|
try { |
|
|
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [] |
|
|
} catch (e) { |
|
|
allowedClients = [] |
|
|
} |
|
|
|
|
|
|
|
|
let tags = [] |
|
|
try { |
|
|
tags = keyData.tags ? JSON.parse(keyData.tags) : [] |
|
|
} catch (e) { |
|
|
tags = [] |
|
|
} |
|
|
|
|
|
return { |
|
|
valid: true, |
|
|
keyData: { |
|
|
id: keyData.id, |
|
|
name: keyData.name, |
|
|
description: keyData.description, |
|
|
createdAt: keyData.createdAt, |
|
|
expiresAt: keyData.expiresAt, |
|
|
|
|
|
expirationMode: keyData.expirationMode || 'fixed', |
|
|
isActivated: keyData.isActivated === 'true', |
|
|
activationDays: parseInt(keyData.activationDays || 0), |
|
|
activationUnit: keyData.activationUnit || 'days', |
|
|
activatedAt: keyData.activatedAt || null, |
|
|
claudeAccountId: keyData.claudeAccountId, |
|
|
claudeConsoleAccountId: keyData.claudeConsoleAccountId, |
|
|
geminiAccountId: keyData.geminiAccountId, |
|
|
openaiAccountId: keyData.openaiAccountId, |
|
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId, |
|
|
bedrockAccountId: keyData.bedrockAccountId, |
|
|
droidAccountId: keyData.droidAccountId, |
|
|
permissions: keyData.permissions || 'all', |
|
|
tokenLimit: parseInt(keyData.tokenLimit), |
|
|
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), |
|
|
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), |
|
|
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), |
|
|
rateLimitCost: parseFloat(keyData.rateLimitCost || 0), |
|
|
enableModelRestriction: keyData.enableModelRestriction === 'true', |
|
|
restrictedModels, |
|
|
enableClientRestriction: keyData.enableClientRestriction === 'true', |
|
|
allowedClients, |
|
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), |
|
|
totalCostLimit: parseFloat(keyData.totalCostLimit || 0), |
|
|
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), |
|
|
dailyCost: dailyCost || 0, |
|
|
totalCost: costStats?.total || 0, |
|
|
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0, |
|
|
tags, |
|
|
usage |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error('❌ API key validation error (stats):', error) |
|
|
return { valid: false, error: 'Internal validation error' } |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async getAllApiKeys(includeDeleted = false) { |
|
|
try { |
|
|
let apiKeys = await redis.getAllApiKeys() |
|
|
const client = redis.getClientSafe() |
|
|
const accountInfoCache = new Map() |
|
|
|
|
|
|
|
|
if (!includeDeleted) { |
|
|
apiKeys = apiKeys.filter((key) => key.isDeleted !== 'true') |
|
|
} |
|
|
|
|
|
|
|
|
for (const key of apiKeys) { |
|
|
key.usage = await redis.getUsageStats(key.id) |
|
|
const costStats = await redis.getCostStats(key.id) |
|
|
|
|
|
if (key.usage && costStats) { |
|
|
key.usage.total = key.usage.total || {} |
|
|
key.usage.total.cost = costStats.total |
|
|
key.usage.totalCost = costStats.total |
|
|
} |
|
|
key.totalCost = costStats ? costStats.total : 0 |
|
|
key.tokenLimit = parseInt(key.tokenLimit) |
|
|
key.concurrencyLimit = parseInt(key.concurrencyLimit || 0) |
|
|
key.rateLimitWindow = parseInt(key.rateLimitWindow || 0) |
|
|
key.rateLimitRequests = parseInt(key.rateLimitRequests || 0) |
|
|
key.rateLimitCost = parseFloat(key.rateLimitCost || 0) |
|
|
key.currentConcurrency = await redis.getConcurrency(key.id) |
|
|
key.isActive = key.isActive === 'true' |
|
|
key.enableModelRestriction = key.enableModelRestriction === 'true' |
|
|
key.enableClientRestriction = key.enableClientRestriction === 'true' |
|
|
key.permissions = key.permissions || 'all' |
|
|
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) |
|
|
key.totalCostLimit = parseFloat(key.totalCostLimit || 0) |
|
|
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) |
|
|
key.dailyCost = (await redis.getDailyCost(key.id)) || 0 |
|
|
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0 |
|
|
key.activationDays = parseInt(key.activationDays || 0) |
|
|
key.activationUnit = key.activationUnit || 'days' |
|
|
key.expirationMode = key.expirationMode || 'fixed' |
|
|
key.isActivated = key.isActivated === 'true' |
|
|
key.activatedAt = key.activatedAt || null |
|
|
|
|
|
|
|
|
if (key.rateLimitWindow > 0) { |
|
|
const requestCountKey = `rate_limit:requests:${key.id}` |
|
|
const tokenCountKey = `rate_limit:tokens:${key.id}` |
|
|
const costCountKey = `rate_limit:cost:${key.id}` |
|
|
const windowStartKey = `rate_limit:window_start:${key.id}` |
|
|
|
|
|
key.currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') |
|
|
key.currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') |
|
|
key.currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') |
|
|
|
|
|
|
|
|
const windowStart = await client.get(windowStartKey) |
|
|
if (windowStart) { |
|
|
const now = Date.now() |
|
|
const windowStartTime = parseInt(windowStart) |
|
|
const windowDuration = key.rateLimitWindow * 60 * 1000 |
|
|
const windowEndTime = windowStartTime + windowDuration |
|
|
|
|
|
|
|
|
if (now < windowEndTime) { |
|
|
key.windowStartTime = windowStartTime |
|
|
key.windowEndTime = windowEndTime |
|
|
key.windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000)) |
|
|
} else { |
|
|
|
|
|
key.windowStartTime = null |
|
|
key.windowEndTime = null |
|
|
key.windowRemainingSeconds = 0 |
|
|
|
|
|
key.currentWindowRequests = 0 |
|
|
key.currentWindowTokens = 0 |
|
|
key.currentWindowCost = 0 |
|
|
} |
|
|
} else { |
|
|
|
|
|
key.windowStartTime = null |
|
|
key.windowEndTime = null |
|
|
key.windowRemainingSeconds = null |
|
|
} |
|
|
} else { |
|
|
key.currentWindowRequests = 0 |
|
|
key.currentWindowTokens = 0 |
|
|
key.currentWindowCost = 0 |
|
|
key.windowStartTime = null |
|
|
key.windowEndTime = null |
|
|
key.windowRemainingSeconds = null |
|
|
} |
|
|
|
|
|
try { |
|
|
key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [] |
|
|
} catch (e) { |
|
|
key.restrictedModels = [] |
|
|
} |
|
|
try { |
|
|
key.allowedClients = key.allowedClients ? JSON.parse(key.allowedClients) : [] |
|
|
} catch (e) { |
|
|
key.allowedClients = [] |
|
|
} |
|
|
try { |
|
|
key.tags = key.tags ? JSON.parse(key.tags) : [] |
|
|
} catch (e) { |
|
|
key.tags = [] |
|
|
} |
|
|
|
|
|
if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) { |
|
|
delete key.ccrAccountId |
|
|
} |
|
|
|
|
|
let lastUsageRecord = null |
|
|
try { |
|
|
const usageRecords = await redis.getUsageRecords(key.id, 1) |
|
|
if (Array.isArray(usageRecords) && usageRecords.length > 0) { |
|
|
lastUsageRecord = usageRecords[0] |
|
|
} |
|
|
} catch (error) { |
|
|
logger.debug(`加载 API Key ${key.id} 的使用记录失败:`, error) |
|
|
} |
|
|
|
|
|
if (lastUsageRecord && (lastUsageRecord.accountId || lastUsageRecord.accountType)) { |
|
|
const resolvedAccount = await this._resolveLastUsageAccount( |
|
|
key, |
|
|
lastUsageRecord, |
|
|
accountInfoCache, |
|
|
client |
|
|
) |
|
|
|
|
|
if (resolvedAccount) { |
|
|
key.lastUsage = { |
|
|
accountId: resolvedAccount.accountId, |
|
|
rawAccountId: lastUsageRecord.accountId || resolvedAccount.accountId, |
|
|
accountType: resolvedAccount.accountType, |
|
|
accountCategory: resolvedAccount.accountCategory, |
|
|
accountName: resolvedAccount.accountName, |
|
|
recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null |
|
|
} |
|
|
} else { |
|
|
key.lastUsage = { |
|
|
accountId: null, |
|
|
rawAccountId: lastUsageRecord.accountId || null, |
|
|
accountType: 'deleted', |
|
|
accountCategory: 'deleted', |
|
|
accountName: '已删除', |
|
|
recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null |
|
|
} |
|
|
} |
|
|
} else { |
|
|
key.lastUsage = null |
|
|
} |
|
|
|
|
|
delete key.apiKey |
|
|
} |
|
|
|
|
|
return apiKeys |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to get API keys:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async updateApiKey(keyId, updates) { |
|
|
try { |
|
|
const keyData = await redis.getApiKey(keyId) |
|
|
if (!keyData || Object.keys(keyData).length === 0) { |
|
|
throw new Error('API key not found') |
|
|
} |
|
|
|
|
|
|
|
|
const allowedUpdates = [ |
|
|
'name', |
|
|
'description', |
|
|
'tokenLimit', |
|
|
'concurrencyLimit', |
|
|
'rateLimitWindow', |
|
|
'rateLimitRequests', |
|
|
'rateLimitCost', |
|
|
'isActive', |
|
|
'claudeAccountId', |
|
|
'claudeConsoleAccountId', |
|
|
'geminiAccountId', |
|
|
'openaiAccountId', |
|
|
'azureOpenaiAccountId', |
|
|
'bedrockAccountId', |
|
|
'droidAccountId', |
|
|
'permissions', |
|
|
'expiresAt', |
|
|
'activationDays', |
|
|
'activationUnit', |
|
|
'expirationMode', |
|
|
'isActivated', |
|
|
'activatedAt', |
|
|
'enableModelRestriction', |
|
|
'restrictedModels', |
|
|
'enableClientRestriction', |
|
|
'allowedClients', |
|
|
'dailyCostLimit', |
|
|
'totalCostLimit', |
|
|
'weeklyOpusCostLimit', |
|
|
'tags', |
|
|
'userId', |
|
|
'userUsername', |
|
|
'createdBy' |
|
|
] |
|
|
const updatedData = { ...keyData } |
|
|
|
|
|
for (const [field, value] of Object.entries(updates)) { |
|
|
if (allowedUpdates.includes(field)) { |
|
|
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') { |
|
|
|
|
|
updatedData[field] = JSON.stringify(value || []) |
|
|
} else if ( |
|
|
field === 'enableModelRestriction' || |
|
|
field === 'enableClientRestriction' || |
|
|
field === 'isActivated' |
|
|
) { |
|
|
|
|
|
updatedData[field] = String(value) |
|
|
} else if (field === 'expiresAt' || field === 'activatedAt') { |
|
|
|
|
|
updatedData[field] = value || '' |
|
|
} else { |
|
|
updatedData[field] = (value !== null && value !== undefined ? value : '').toString() |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
updatedData.updatedAt = new Date().toISOString() |
|
|
|
|
|
|
|
|
await redis.setApiKey(keyId, updatedData) |
|
|
|
|
|
logger.success(`📝 Updated API key: ${keyId}`) |
|
|
|
|
|
return { success: true } |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to update API key:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async deleteApiKey(keyId, deletedBy = 'system', deletedByType = 'system') { |
|
|
try { |
|
|
const keyData = await redis.getApiKey(keyId) |
|
|
if (!keyData || Object.keys(keyData).length === 0) { |
|
|
throw new Error('API key not found') |
|
|
} |
|
|
|
|
|
|
|
|
const updatedData = { |
|
|
...keyData, |
|
|
isDeleted: 'true', |
|
|
deletedAt: new Date().toISOString(), |
|
|
deletedBy, |
|
|
deletedByType, |
|
|
isActive: 'false' |
|
|
} |
|
|
|
|
|
await redis.setApiKey(keyId, updatedData) |
|
|
|
|
|
|
|
|
if (keyData.apiKey) { |
|
|
await redis.deleteApiKeyHash(keyData.apiKey) |
|
|
} |
|
|
|
|
|
logger.success(`🗑️ Soft deleted API key: ${keyId} by ${deletedBy} (${deletedByType})`) |
|
|
|
|
|
return { success: true } |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to delete API key:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async restoreApiKey(keyId, restoredBy = 'system', restoredByType = 'system') { |
|
|
try { |
|
|
const keyData = await redis.getApiKey(keyId) |
|
|
if (!keyData || Object.keys(keyData).length === 0) { |
|
|
throw new Error('API key not found') |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.isDeleted !== 'true') { |
|
|
throw new Error('API key is not deleted') |
|
|
} |
|
|
|
|
|
|
|
|
const updatedData = { ...keyData } |
|
|
updatedData.isActive = 'true' |
|
|
updatedData.restoredAt = new Date().toISOString() |
|
|
updatedData.restoredBy = restoredBy |
|
|
updatedData.restoredByType = restoredByType |
|
|
|
|
|
|
|
|
delete updatedData.isDeleted |
|
|
delete updatedData.deletedAt |
|
|
delete updatedData.deletedBy |
|
|
delete updatedData.deletedByType |
|
|
|
|
|
|
|
|
await redis.setApiKey(keyId, updatedData) |
|
|
|
|
|
|
|
|
const keyName = `apikey:${keyId}` |
|
|
await redis.client.hdel(keyName, 'isDeleted', 'deletedAt', 'deletedBy', 'deletedByType') |
|
|
|
|
|
|
|
|
if (keyData.apiKey) { |
|
|
await redis.setApiKeyHash(keyData.apiKey, { |
|
|
id: keyId, |
|
|
name: keyData.name, |
|
|
isActive: 'true' |
|
|
}) |
|
|
} |
|
|
|
|
|
logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`) |
|
|
|
|
|
return { success: true, apiKey: updatedData } |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to restore API key:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async permanentDeleteApiKey(keyId) { |
|
|
try { |
|
|
const keyData = await redis.getApiKey(keyId) |
|
|
if (!keyData || Object.keys(keyData).length === 0) { |
|
|
throw new Error('API key not found') |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.isDeleted !== 'true') { |
|
|
throw new Error('只能彻底删除已经删除的API Key') |
|
|
} |
|
|
|
|
|
|
|
|
const today = new Date().toISOString().split('T')[0] |
|
|
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0] |
|
|
|
|
|
|
|
|
await redis.client.del(`usage:daily:${today}:${keyId}`) |
|
|
await redis.client.del(`usage:daily:${yesterday}:${keyId}`) |
|
|
|
|
|
|
|
|
const currentMonth = today.substring(0, 7) |
|
|
await redis.client.del(`usage:monthly:${currentMonth}:${keyId}`) |
|
|
|
|
|
|
|
|
const usageKeys = await redis.client.keys(`usage:*:${keyId}*`) |
|
|
if (usageKeys.length > 0) { |
|
|
await redis.client.del(...usageKeys) |
|
|
} |
|
|
|
|
|
|
|
|
await redis.deleteApiKey(keyId) |
|
|
|
|
|
logger.success(`🗑️ Permanently deleted API key: ${keyId}`) |
|
|
|
|
|
return { success: true } |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to permanently delete API key:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async clearAllDeletedApiKeys() { |
|
|
try { |
|
|
const allKeys = await this.getAllApiKeys(true) |
|
|
const deletedKeys = allKeys.filter((key) => key.isDeleted === 'true') |
|
|
|
|
|
let successCount = 0 |
|
|
let failedCount = 0 |
|
|
const errors = [] |
|
|
|
|
|
for (const key of deletedKeys) { |
|
|
try { |
|
|
await this.permanentDeleteApiKey(key.id) |
|
|
successCount++ |
|
|
} catch (error) { |
|
|
failedCount++ |
|
|
errors.push({ |
|
|
keyId: key.id, |
|
|
keyName: key.name, |
|
|
error: error.message |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
logger.success(`🧹 Cleared deleted API keys: ${successCount} success, ${failedCount} failed`) |
|
|
|
|
|
return { |
|
|
success: true, |
|
|
total: deletedKeys.length, |
|
|
successCount, |
|
|
failedCount, |
|
|
errors |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to clear all deleted API keys:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async recordUsage( |
|
|
keyId, |
|
|
inputTokens = 0, |
|
|
outputTokens = 0, |
|
|
cacheCreateTokens = 0, |
|
|
cacheReadTokens = 0, |
|
|
model = 'unknown', |
|
|
accountId = null |
|
|
) { |
|
|
try { |
|
|
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens |
|
|
|
|
|
|
|
|
const CostCalculator = require('../utils/costCalculator') |
|
|
const costInfo = CostCalculator.calculateCost( |
|
|
{ |
|
|
input_tokens: inputTokens, |
|
|
output_tokens: outputTokens, |
|
|
cache_creation_input_tokens: cacheCreateTokens, |
|
|
cache_read_input_tokens: cacheReadTokens |
|
|
}, |
|
|
model |
|
|
) |
|
|
|
|
|
|
|
|
let isLongContextRequest = false |
|
|
if (model && model.includes('[1m]')) { |
|
|
const totalInputTokens = inputTokens + cacheCreateTokens + cacheReadTokens |
|
|
isLongContextRequest = totalInputTokens > 200000 |
|
|
} |
|
|
|
|
|
|
|
|
await redis.incrementTokenUsage( |
|
|
keyId, |
|
|
totalTokens, |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens, |
|
|
model, |
|
|
0, |
|
|
0, |
|
|
isLongContextRequest |
|
|
) |
|
|
|
|
|
|
|
|
if (costInfo.costs.total > 0) { |
|
|
await redis.incrementDailyCost(keyId, costInfo.costs.total) |
|
|
logger.database( |
|
|
`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}` |
|
|
) |
|
|
} else { |
|
|
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`) |
|
|
} |
|
|
|
|
|
|
|
|
const keyData = await redis.getApiKey(keyId) |
|
|
if (keyData && Object.keys(keyData).length > 0) { |
|
|
|
|
|
keyData.lastUsedAt = new Date().toISOString() |
|
|
await redis.setApiKey(keyId, keyData) |
|
|
|
|
|
|
|
|
if (accountId) { |
|
|
await redis.incrementAccountUsage( |
|
|
accountId, |
|
|
totalTokens, |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens, |
|
|
model, |
|
|
isLongContextRequest |
|
|
) |
|
|
logger.database( |
|
|
`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` |
|
|
) |
|
|
} else { |
|
|
logger.debug( |
|
|
'⚠️ No accountId provided for usage recording, skipping account-level statistics' |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const usageCost = costInfo && costInfo.costs ? costInfo.costs.total || 0 : 0 |
|
|
await redis.addUsageRecord(keyId, { |
|
|
timestamp: new Date().toISOString(), |
|
|
model, |
|
|
accountId: accountId || null, |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens, |
|
|
totalTokens, |
|
|
cost: Number(usageCost.toFixed(6)), |
|
|
costBreakdown: costInfo && costInfo.costs ? costInfo.costs : undefined |
|
|
}) |
|
|
|
|
|
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] |
|
|
if (cacheCreateTokens > 0) { |
|
|
logParts.push(`Cache Create: ${cacheCreateTokens}`) |
|
|
} |
|
|
if (cacheReadTokens > 0) { |
|
|
logParts.push(`Cache Read: ${cacheReadTokens}`) |
|
|
} |
|
|
logParts.push(`Total: ${totalTokens} tokens`) |
|
|
|
|
|
logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`) |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to record usage:', error) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async recordOpusCost(keyId, cost, model, accountType) { |
|
|
try { |
|
|
|
|
|
if (!model || !model.toLowerCase().includes('claude-opus')) { |
|
|
return |
|
|
} |
|
|
|
|
|
|
|
|
if ( |
|
|
!accountType || |
|
|
(accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr') |
|
|
) { |
|
|
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`) |
|
|
return |
|
|
} |
|
|
|
|
|
|
|
|
await redis.incrementWeeklyOpusCost(keyId, cost) |
|
|
logger.database( |
|
|
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed( |
|
|
6 |
|
|
)}, model: ${model}, account type: ${accountType}` |
|
|
) |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to record Opus cost:', error) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async recordUsageWithDetails( |
|
|
keyId, |
|
|
usageObject, |
|
|
model = 'unknown', |
|
|
accountId = null, |
|
|
accountType = null |
|
|
) { |
|
|
try { |
|
|
|
|
|
const inputTokens = usageObject.input_tokens || 0 |
|
|
const outputTokens = usageObject.output_tokens || 0 |
|
|
const cacheCreateTokens = usageObject.cache_creation_input_tokens || 0 |
|
|
const cacheReadTokens = usageObject.cache_read_input_tokens || 0 |
|
|
|
|
|
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens |
|
|
|
|
|
|
|
|
let costInfo = { totalCost: 0, ephemeral5mCost: 0, ephemeral1hCost: 0 } |
|
|
try { |
|
|
const pricingService = require('./pricingService') |
|
|
|
|
|
if (!pricingService.pricingData) { |
|
|
logger.warn('⚠️ PricingService not initialized, initializing now...') |
|
|
await pricingService.initialize() |
|
|
} |
|
|
costInfo = pricingService.calculateCost(usageObject, model) |
|
|
|
|
|
|
|
|
if (!costInfo || typeof costInfo.totalCost !== 'number') { |
|
|
logger.error(`❌ Invalid cost calculation result for model ${model}:`, costInfo) |
|
|
|
|
|
const CostCalculator = require('../utils/costCalculator') |
|
|
const fallbackCost = CostCalculator.calculateCost(usageObject, model) |
|
|
if (fallbackCost && fallbackCost.costs && fallbackCost.costs.total > 0) { |
|
|
logger.warn( |
|
|
`⚠️ Using fallback cost calculation for ${model}: $${fallbackCost.costs.total}` |
|
|
) |
|
|
costInfo = { |
|
|
totalCost: fallbackCost.costs.total, |
|
|
ephemeral5mCost: 0, |
|
|
ephemeral1hCost: 0 |
|
|
} |
|
|
} else { |
|
|
costInfo = { totalCost: 0, ephemeral5mCost: 0, ephemeral1hCost: 0 } |
|
|
} |
|
|
} |
|
|
} catch (pricingError) { |
|
|
logger.error(`❌ Failed to calculate cost for model ${model}:`, pricingError) |
|
|
logger.error(` Usage object:`, JSON.stringify(usageObject)) |
|
|
|
|
|
try { |
|
|
const CostCalculator = require('../utils/costCalculator') |
|
|
const fallbackCost = CostCalculator.calculateCost(usageObject, model) |
|
|
if (fallbackCost && fallbackCost.costs && fallbackCost.costs.total > 0) { |
|
|
logger.warn( |
|
|
`⚠️ Using fallback cost calculation for ${model}: $${fallbackCost.costs.total}` |
|
|
) |
|
|
costInfo = { |
|
|
totalCost: fallbackCost.costs.total, |
|
|
ephemeral5mCost: 0, |
|
|
ephemeral1hCost: 0 |
|
|
} |
|
|
} |
|
|
} catch (fallbackError) { |
|
|
logger.error(`❌ Fallback cost calculation also failed:`, fallbackError) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
let ephemeral5mTokens = 0 |
|
|
let ephemeral1hTokens = 0 |
|
|
|
|
|
if (usageObject.cache_creation && typeof usageObject.cache_creation === 'object') { |
|
|
ephemeral5mTokens = usageObject.cache_creation.ephemeral_5m_input_tokens || 0 |
|
|
ephemeral1hTokens = usageObject.cache_creation.ephemeral_1h_input_tokens || 0 |
|
|
} |
|
|
|
|
|
|
|
|
await redis.incrementTokenUsage( |
|
|
keyId, |
|
|
totalTokens, |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens, |
|
|
model, |
|
|
ephemeral5mTokens, |
|
|
ephemeral1hTokens, |
|
|
costInfo.isLongContextRequest || false |
|
|
) |
|
|
|
|
|
|
|
|
if (costInfo.totalCost > 0) { |
|
|
await redis.incrementDailyCost(keyId, costInfo.totalCost) |
|
|
logger.database( |
|
|
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}` |
|
|
) |
|
|
|
|
|
|
|
|
await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType) |
|
|
|
|
|
|
|
|
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) { |
|
|
logger.database( |
|
|
`💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed( |
|
|
6 |
|
|
)}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}` |
|
|
) |
|
|
} |
|
|
} else { |
|
|
|
|
|
if (totalTokens > 0) { |
|
|
logger.warn( |
|
|
`⚠️ No cost recorded for ${keyId} - zero cost for model: ${model} (tokens: ${totalTokens})` |
|
|
) |
|
|
logger.warn(` This may indicate a pricing issue or model not found in pricing data`) |
|
|
} else { |
|
|
logger.debug(`💰 No cost recorded for ${keyId} - zero tokens for model: ${model}`) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const keyData = await redis.getApiKey(keyId) |
|
|
if (keyData && Object.keys(keyData).length > 0) { |
|
|
|
|
|
keyData.lastUsedAt = new Date().toISOString() |
|
|
await redis.setApiKey(keyId, keyData) |
|
|
|
|
|
|
|
|
if (accountId) { |
|
|
await redis.incrementAccountUsage( |
|
|
accountId, |
|
|
totalTokens, |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens, |
|
|
model, |
|
|
costInfo.isLongContextRequest || false |
|
|
) |
|
|
logger.database( |
|
|
`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` |
|
|
) |
|
|
} else { |
|
|
logger.debug( |
|
|
'⚠️ No accountId provided for usage recording, skipping account-level statistics' |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
const usageRecord = { |
|
|
timestamp: new Date().toISOString(), |
|
|
model, |
|
|
accountId: accountId || null, |
|
|
accountType: accountType || null, |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens, |
|
|
ephemeral5mTokens, |
|
|
ephemeral1hTokens, |
|
|
totalTokens, |
|
|
cost: Number((costInfo.totalCost || 0).toFixed(6)), |
|
|
costBreakdown: { |
|
|
input: costInfo.inputCost || 0, |
|
|
output: costInfo.outputCost || 0, |
|
|
cacheCreate: costInfo.cacheCreateCost || 0, |
|
|
cacheRead: costInfo.cacheReadCost || 0, |
|
|
ephemeral5m: costInfo.ephemeral5mCost || 0, |
|
|
ephemeral1h: costInfo.ephemeral1hCost || 0 |
|
|
}, |
|
|
isLongContext: costInfo.isLongContextRequest || false |
|
|
} |
|
|
|
|
|
await redis.addUsageRecord(keyId, usageRecord) |
|
|
|
|
|
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] |
|
|
if (cacheCreateTokens > 0) { |
|
|
logParts.push(`Cache Create: ${cacheCreateTokens}`) |
|
|
|
|
|
|
|
|
if (usageObject.cache_creation) { |
|
|
const { ephemeral_5m_input_tokens, ephemeral_1h_input_tokens } = |
|
|
usageObject.cache_creation |
|
|
if (ephemeral_5m_input_tokens > 0) { |
|
|
logParts.push(`5m: ${ephemeral_5m_input_tokens}`) |
|
|
} |
|
|
if (ephemeral_1h_input_tokens > 0) { |
|
|
logParts.push(`1h: ${ephemeral_1h_input_tokens}`) |
|
|
} |
|
|
} |
|
|
} |
|
|
if (cacheReadTokens > 0) { |
|
|
logParts.push(`Cache Read: ${cacheReadTokens}`) |
|
|
} |
|
|
logParts.push(`Total: ${totalTokens} tokens`) |
|
|
|
|
|
logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`) |
|
|
|
|
|
|
|
|
this._publishBillingEvent({ |
|
|
keyId, |
|
|
keyName: keyData?.name, |
|
|
userId: keyData?.userId, |
|
|
model, |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens, |
|
|
ephemeral5mTokens, |
|
|
ephemeral1hTokens, |
|
|
totalTokens, |
|
|
cost: costInfo.totalCost || 0, |
|
|
costBreakdown: { |
|
|
input: costInfo.inputCost || 0, |
|
|
output: costInfo.outputCost || 0, |
|
|
cacheCreate: costInfo.cacheCreateCost || 0, |
|
|
cacheRead: costInfo.cacheReadCost || 0, |
|
|
ephemeral5m: costInfo.ephemeral5mCost || 0, |
|
|
ephemeral1h: costInfo.ephemeral1hCost || 0 |
|
|
}, |
|
|
accountId, |
|
|
accountType, |
|
|
isLongContext: costInfo.isLongContextRequest || false, |
|
|
requestTimestamp: usageRecord.timestamp |
|
|
}).catch((err) => { |
|
|
|
|
|
logger.warn('⚠️ Failed to publish billing event:', err.message) |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to record usage:', error) |
|
|
} |
|
|
} |
|
|
|
|
|
async _fetchAccountInfo(accountId, accountType, cache, client) { |
|
|
if (!client || !accountId || !accountType) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const cacheKey = `${accountType}:${accountId}` |
|
|
if (cache.has(cacheKey)) { |
|
|
return cache.get(cacheKey) |
|
|
} |
|
|
|
|
|
const accountConfig = ACCOUNT_TYPE_CONFIG[accountType] |
|
|
if (!accountConfig) { |
|
|
cache.set(cacheKey, null) |
|
|
return null |
|
|
} |
|
|
|
|
|
const redisKey = `${accountConfig.prefix}${accountId}` |
|
|
let accountData = null |
|
|
try { |
|
|
accountData = await client.hgetall(redisKey) |
|
|
} catch (error) { |
|
|
logger.debug(`加载账号信息失败 ${redisKey}:`, error) |
|
|
} |
|
|
|
|
|
if (accountData && Object.keys(accountData).length > 0) { |
|
|
const displayName = |
|
|
accountData.name || |
|
|
accountData.displayName || |
|
|
accountData.email || |
|
|
accountData.username || |
|
|
accountData.description || |
|
|
accountId |
|
|
|
|
|
const info = { id: accountId, name: displayName } |
|
|
cache.set(cacheKey, info) |
|
|
return info |
|
|
} |
|
|
|
|
|
cache.set(cacheKey, null) |
|
|
return null |
|
|
} |
|
|
|
|
|
async _resolveAccountByUsageRecord(usageRecord, cache, client) { |
|
|
if (!usageRecord || !client) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const rawAccountId = usageRecord.accountId || null |
|
|
const rawAccountType = normalizeAccountTypeKey(usageRecord.accountType) |
|
|
const modelName = usageRecord.model || usageRecord.actualModel || usageRecord.service || null |
|
|
|
|
|
if (!rawAccountId && !rawAccountType) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const candidateIds = new Set() |
|
|
if (rawAccountId) { |
|
|
candidateIds.add(rawAccountId) |
|
|
if (typeof rawAccountId === 'string' && rawAccountId.startsWith('responses:')) { |
|
|
candidateIds.add(rawAccountId.replace(/^responses:/, '')) |
|
|
} |
|
|
} |
|
|
|
|
|
if (candidateIds.size === 0) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const typeCandidates = [] |
|
|
const pushType = (type) => { |
|
|
const normalized = normalizeAccountTypeKey(type) |
|
|
if (normalized && ACCOUNT_TYPE_CONFIG[normalized] && !typeCandidates.includes(normalized)) { |
|
|
typeCandidates.push(normalized) |
|
|
} |
|
|
} |
|
|
|
|
|
pushType(rawAccountType) |
|
|
|
|
|
if (modelName) { |
|
|
const lowerModel = modelName.toLowerCase() |
|
|
if (lowerModel.includes('gpt') || lowerModel.includes('openai')) { |
|
|
pushType('openai') |
|
|
pushType('openai-responses') |
|
|
pushType('azure-openai') |
|
|
} else if (lowerModel.includes('gemini')) { |
|
|
pushType('gemini') |
|
|
} else if (lowerModel.includes('claude') || lowerModel.includes('anthropic')) { |
|
|
pushType('claude') |
|
|
pushType('claude-console') |
|
|
} else if (lowerModel.includes('droid')) { |
|
|
pushType('droid') |
|
|
} |
|
|
} |
|
|
|
|
|
ACCOUNT_TYPE_PRIORITY.forEach(pushType) |
|
|
|
|
|
for (const type of typeCandidates) { |
|
|
const accountConfig = ACCOUNT_TYPE_CONFIG[type] |
|
|
if (!accountConfig) { |
|
|
continue |
|
|
} |
|
|
|
|
|
for (const candidateId of candidateIds) { |
|
|
const normalizedId = sanitizeAccountIdForType(candidateId, type) |
|
|
const accountInfo = await this._fetchAccountInfo(normalizedId, type, cache, client) |
|
|
if (accountInfo) { |
|
|
return { |
|
|
accountId: normalizedId, |
|
|
accountName: accountInfo.name, |
|
|
accountType: type, |
|
|
accountCategory: ACCOUNT_CATEGORY_MAP[type] || 'other', |
|
|
rawAccountId: rawAccountId || normalizedId |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return null |
|
|
} |
|
|
|
|
|
async _resolveLastUsageAccount(apiKey, usageRecord, cache, client) { |
|
|
return await this._resolveAccountByUsageRecord(usageRecord, cache, client) |
|
|
} |
|
|
|
|
|
|
|
|
async _publishBillingEvent(eventData) { |
|
|
try { |
|
|
const billingEventPublisher = require('./billingEventPublisher') |
|
|
await billingEventPublisher.publishBillingEvent(eventData) |
|
|
} catch (error) { |
|
|
|
|
|
logger.debug('Failed to publish billing event:', error.message) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
_generateSecretKey() { |
|
|
return crypto.randomBytes(32).toString('hex') |
|
|
} |
|
|
|
|
|
|
|
|
_hashApiKey(apiKey) { |
|
|
return crypto |
|
|
.createHash('sha256') |
|
|
.update(apiKey + config.security.encryptionKey) |
|
|
.digest('hex') |
|
|
} |
|
|
|
|
|
|
|
|
async getUsageStats(keyId, options = {}) { |
|
|
const usageStats = await redis.getUsageStats(keyId) |
|
|
|
|
|
|
|
|
const optionObject = |
|
|
options && typeof options === 'object' && !Array.isArray(options) ? options : {} |
|
|
|
|
|
if (optionObject.includeRecords === false) { |
|
|
return usageStats |
|
|
} |
|
|
|
|
|
const recordLimit = optionObject.recordLimit || 20 |
|
|
const recentRecords = await redis.getUsageRecords(keyId, recordLimit) |
|
|
|
|
|
return { |
|
|
...usageStats, |
|
|
recentRecords |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async getAccountUsageStats(accountId) { |
|
|
return await redis.getAccountUsageStats(accountId) |
|
|
} |
|
|
|
|
|
|
|
|
async getAllAccountsUsageStats() { |
|
|
return await redis.getAllAccountsUsageStats() |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async createApiKey(options = {}) { |
|
|
return await this.generateApiKey(options) |
|
|
} |
|
|
|
|
|
|
|
|
async getUserApiKeys(userId, includeDeleted = false) { |
|
|
try { |
|
|
const allKeys = await redis.getAllApiKeys() |
|
|
let userKeys = allKeys.filter((key) => key.userId === userId) |
|
|
|
|
|
|
|
|
if (!includeDeleted) { |
|
|
userKeys = userKeys.filter((key) => key.isDeleted !== 'true') |
|
|
} |
|
|
|
|
|
|
|
|
const userKeysWithUsage = [] |
|
|
for (const key of userKeys) { |
|
|
const usage = await redis.getUsageStats(key.id) |
|
|
const dailyCost = (await redis.getDailyCost(key.id)) || 0 |
|
|
const costStats = await redis.getCostStats(key.id) |
|
|
|
|
|
userKeysWithUsage.push({ |
|
|
id: key.id, |
|
|
name: key.name, |
|
|
description: key.description, |
|
|
key: key.apiKey ? `${this.prefix}****${key.apiKey.slice(-4)}` : null, |
|
|
tokenLimit: parseInt(key.tokenLimit || 0), |
|
|
isActive: key.isActive === 'true', |
|
|
createdAt: key.createdAt, |
|
|
lastUsedAt: key.lastUsedAt, |
|
|
expiresAt: key.expiresAt, |
|
|
usage, |
|
|
dailyCost, |
|
|
totalCost: costStats.total, |
|
|
dailyCostLimit: parseFloat(key.dailyCostLimit || 0), |
|
|
totalCostLimit: parseFloat(key.totalCostLimit || 0), |
|
|
userId: key.userId, |
|
|
userUsername: key.userUsername, |
|
|
createdBy: key.createdBy, |
|
|
droidAccountId: key.droidAccountId, |
|
|
|
|
|
isDeleted: key.isDeleted, |
|
|
deletedAt: key.deletedAt, |
|
|
deletedBy: key.deletedBy, |
|
|
deletedByType: key.deletedByType |
|
|
}) |
|
|
} |
|
|
|
|
|
return userKeysWithUsage |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to get user API keys:', error) |
|
|
return [] |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async getApiKeyById(keyId, userId = null) { |
|
|
try { |
|
|
const keyData = await redis.getApiKey(keyId) |
|
|
if (!keyData) { |
|
|
return null |
|
|
} |
|
|
|
|
|
|
|
|
if (userId && keyData.userId !== userId) { |
|
|
return null |
|
|
} |
|
|
|
|
|
return { |
|
|
id: keyData.id, |
|
|
name: keyData.name, |
|
|
description: keyData.description, |
|
|
key: keyData.apiKey, |
|
|
tokenLimit: parseInt(keyData.tokenLimit || 0), |
|
|
isActive: keyData.isActive === 'true', |
|
|
createdAt: keyData.createdAt, |
|
|
lastUsedAt: keyData.lastUsedAt, |
|
|
expiresAt: keyData.expiresAt, |
|
|
userId: keyData.userId, |
|
|
userUsername: keyData.userUsername, |
|
|
createdBy: keyData.createdBy, |
|
|
permissions: keyData.permissions, |
|
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), |
|
|
totalCostLimit: parseFloat(keyData.totalCostLimit || 0), |
|
|
droidAccountId: keyData.droidAccountId |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to get API key by ID:', error) |
|
|
return null |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async regenerateApiKey(keyId) { |
|
|
try { |
|
|
const existingKey = await redis.getApiKey(keyId) |
|
|
if (!existingKey) { |
|
|
throw new Error('API key not found') |
|
|
} |
|
|
|
|
|
|
|
|
const newApiKey = `${this.prefix}${this._generateSecretKey()}` |
|
|
const newHashedKey = this._hashApiKey(newApiKey) |
|
|
|
|
|
|
|
|
const oldHashedKey = existingKey.apiKey |
|
|
await redis.deleteApiKeyHash(oldHashedKey) |
|
|
|
|
|
|
|
|
const updatedKeyData = { |
|
|
...existingKey, |
|
|
apiKey: newHashedKey, |
|
|
updatedAt: new Date().toISOString() |
|
|
} |
|
|
|
|
|
|
|
|
await redis.setApiKey(keyId, updatedKeyData, newHashedKey) |
|
|
|
|
|
logger.info(`🔄 Regenerated API key: ${existingKey.name} (${keyId})`) |
|
|
|
|
|
return { |
|
|
id: keyId, |
|
|
name: existingKey.name, |
|
|
key: newApiKey, |
|
|
updatedAt: updatedKeyData.updatedAt |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to regenerate API key:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async hardDeleteApiKey(keyId) { |
|
|
try { |
|
|
const keyData = await redis.getApiKey(keyId) |
|
|
if (!keyData) { |
|
|
throw new Error('API key not found') |
|
|
} |
|
|
|
|
|
|
|
|
await redis.deleteApiKey(keyId) |
|
|
await redis.deleteApiKeyHash(keyData.apiKey) |
|
|
|
|
|
logger.info(`🗑️ Deleted API key: ${keyData.name} (${keyId})`) |
|
|
return true |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to delete API key:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async disableUserApiKeys(userId) { |
|
|
try { |
|
|
const userKeys = await this.getUserApiKeys(userId) |
|
|
let disabledCount = 0 |
|
|
|
|
|
for (const key of userKeys) { |
|
|
if (key.isActive) { |
|
|
await this.updateApiKey(key.id, { isActive: false }) |
|
|
disabledCount++ |
|
|
} |
|
|
} |
|
|
|
|
|
logger.info(`🚫 Disabled ${disabledCount} API keys for user: ${userId}`) |
|
|
return { count: disabledCount } |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to disable user API keys:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async getAggregatedUsageStats(keyIds, options = {}) { |
|
|
try { |
|
|
if (!Array.isArray(keyIds)) { |
|
|
keyIds = [keyIds] |
|
|
} |
|
|
|
|
|
const { period: _period = 'week', model: _model } = options |
|
|
const stats = { |
|
|
totalRequests: 0, |
|
|
totalInputTokens: 0, |
|
|
totalOutputTokens: 0, |
|
|
totalCost: 0, |
|
|
dailyStats: [], |
|
|
modelStats: [] |
|
|
} |
|
|
|
|
|
|
|
|
for (const keyId of keyIds) { |
|
|
const keyStats = await redis.getUsageStats(keyId) |
|
|
const costStats = await redis.getCostStats(keyId) |
|
|
if (keyStats && keyStats.total) { |
|
|
stats.totalRequests += keyStats.total.requests || 0 |
|
|
stats.totalInputTokens += keyStats.total.inputTokens || 0 |
|
|
stats.totalOutputTokens += keyStats.total.outputTokens || 0 |
|
|
stats.totalCost += costStats?.total || 0 |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return stats |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to get usage stats:', error) |
|
|
return { |
|
|
totalRequests: 0, |
|
|
totalInputTokens: 0, |
|
|
totalOutputTokens: 0, |
|
|
totalCost: 0, |
|
|
dailyStats: [], |
|
|
modelStats: [] |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async unbindAccountFromAllKeys(accountId, accountType) { |
|
|
try { |
|
|
|
|
|
const fieldMap = { |
|
|
claude: 'claudeAccountId', |
|
|
'claude-console': 'claudeConsoleAccountId', |
|
|
gemini: 'geminiAccountId', |
|
|
openai: 'openaiAccountId', |
|
|
'openai-responses': 'openaiAccountId', |
|
|
azure_openai: 'azureOpenaiAccountId', |
|
|
bedrock: 'bedrockAccountId', |
|
|
droid: 'droidAccountId', |
|
|
ccr: null |
|
|
} |
|
|
|
|
|
const field = fieldMap[accountType] |
|
|
if (!field) { |
|
|
logger.info(`账号类型 ${accountType} 不需要解绑 API Key`) |
|
|
return 0 |
|
|
} |
|
|
|
|
|
|
|
|
const allKeys = await this.getAllApiKeys() |
|
|
|
|
|
|
|
|
let boundKeys = [] |
|
|
if (accountType === 'openai-responses') { |
|
|
|
|
|
boundKeys = allKeys.filter((key) => key.openaiAccountId === `responses:${accountId}`) |
|
|
} else { |
|
|
|
|
|
boundKeys = allKeys.filter((key) => key[field] === accountId) |
|
|
} |
|
|
|
|
|
|
|
|
for (const key of boundKeys) { |
|
|
const updates = {} |
|
|
if (accountType === 'openai-responses') { |
|
|
updates.openaiAccountId = null |
|
|
} else if (accountType === 'claude-console') { |
|
|
updates.claudeConsoleAccountId = null |
|
|
} else { |
|
|
updates[field] = null |
|
|
} |
|
|
|
|
|
await this.updateApiKey(key.id, updates) |
|
|
logger.info( |
|
|
`✅ 自动解绑 API Key ${key.id} (${key.name}) 从 ${accountType} 账号 ${accountId}` |
|
|
) |
|
|
} |
|
|
|
|
|
if (boundKeys.length > 0) { |
|
|
logger.success( |
|
|
`🔓 成功解绑 ${boundKeys.length} 个 API Key 从 ${accountType} 账号 ${accountId}` |
|
|
) |
|
|
} |
|
|
|
|
|
return boundKeys.length |
|
|
} catch (error) { |
|
|
logger.error(`❌ 解绑 API Keys 失败 (${accountType} 账号 ${accountId}):`, error) |
|
|
return 0 |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async cleanupExpiredKeys() { |
|
|
try { |
|
|
const apiKeys = await redis.getAllApiKeys() |
|
|
const now = new Date() |
|
|
let cleanedCount = 0 |
|
|
|
|
|
for (const key of apiKeys) { |
|
|
|
|
|
if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === 'true') { |
|
|
|
|
|
await this.updateApiKey(key.id, { isActive: false }) |
|
|
logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`) |
|
|
cleanedCount++ |
|
|
} |
|
|
} |
|
|
|
|
|
if (cleanedCount > 0) { |
|
|
logger.success(`🧹 Disabled ${cleanedCount} expired API keys`) |
|
|
} |
|
|
|
|
|
return cleanedCount |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to cleanup expired keys:', error) |
|
|
return 0 |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const apiKeyService = new ApiKeyService() |
|
|
|
|
|
|
|
|
apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService) |
|
|
|
|
|
module.exports = apiKeyService |
|
|
|