mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 增加每日费用限制
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"install:web": "cd web && npm install",
|
||||
"setup": "node scripts/setup.js",
|
||||
"cli": "node cli/index.js",
|
||||
"init:costs": "node src/cli/initCosts.js",
|
||||
"service": "node scripts/manage.js",
|
||||
"service:start": "node scripts/manage.js start",
|
||||
"service:start:daemon": "node scripts/manage.js start -d",
|
||||
|
||||
10
src/app.js
10
src/app.js
@@ -51,6 +51,16 @@ class Application {
|
||||
logger.info('🔄 Initializing admin credentials...');
|
||||
await this.initializeAdmin();
|
||||
|
||||
// 💰 初始化费用数据
|
||||
logger.info('💰 Checking cost data initialization...');
|
||||
const costInitService = require('./services/costInitService');
|
||||
const needsInit = await costInitService.needsInitialization();
|
||||
if (needsInit) {
|
||||
logger.info('💰 Initializing cost data for all API Keys...');
|
||||
const result = await costInitService.initializeAllCosts();
|
||||
logger.info(`💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors`);
|
||||
}
|
||||
|
||||
// 🛡️ 安全中间件
|
||||
this.app.use(helmet({
|
||||
contentSecurityPolicy: false, // 允许内联样式和脚本
|
||||
|
||||
32
src/cli/initCosts.js
Normal file
32
src/cli/initCosts.js
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const costInitService = require('../services/costInitService');
|
||||
const logger = require('../utils/logger');
|
||||
const redis = require('../models/redis');
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// 连接Redis
|
||||
await redis.connect();
|
||||
|
||||
console.log('💰 Starting cost data initialization...\n');
|
||||
|
||||
// 执行初始化
|
||||
const result = await costInitService.initializeAllCosts();
|
||||
|
||||
console.log('\n✅ Cost initialization completed!');
|
||||
console.log(` Processed: ${result.processed} API Keys`);
|
||||
console.log(` Errors: ${result.errors}`);
|
||||
|
||||
// 断开连接
|
||||
await redis.disconnect();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Cost initialization failed:', error.message);
|
||||
logger.error('Cost initialization failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行主函数
|
||||
main();
|
||||
@@ -239,6 +239,27 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
};
|
||||
}
|
||||
|
||||
// 检查每日费用限制
|
||||
const dailyCostLimit = validation.keyData.dailyCostLimit || 0;
|
||||
if (dailyCostLimit > 0) {
|
||||
const dailyCost = validation.keyData.dailyCost || 0;
|
||||
|
||||
if (dailyCost >= dailyCostLimit) {
|
||||
logger.security(`💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`);
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Daily cost limit exceeded',
|
||||
message: `已达到每日费用限制 ($${dailyCostLimit})`,
|
||||
currentCost: dailyCost,
|
||||
costLimit: dailyCostLimit,
|
||||
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置
|
||||
});
|
||||
}
|
||||
|
||||
// 记录当前费用使用情况
|
||||
logger.api(`💰 Cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`);
|
||||
}
|
||||
|
||||
// 将验证信息添加到请求对象(只包含必要信息)
|
||||
req.apiKey = {
|
||||
id: validation.keyData.id,
|
||||
@@ -254,6 +275,8 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
restrictedModels: validation.keyData.restrictedModels,
|
||||
enableClientRestriction: validation.keyData.enableClientRestriction,
|
||||
allowedClients: validation.keyData.allowedClients,
|
||||
dailyCostLimit: validation.keyData.dailyCostLimit,
|
||||
dailyCost: validation.keyData.dailyCost,
|
||||
usage: validation.keyData.usage
|
||||
};
|
||||
req.usage = validation.keyData.usage;
|
||||
|
||||
@@ -467,6 +467,66 @@ class RedisClient {
|
||||
};
|
||||
}
|
||||
|
||||
// 💰 获取当日费用
|
||||
async getDailyCost(keyId) {
|
||||
const today = getDateStringInTimezone();
|
||||
const costKey = `usage:cost:daily:${keyId}:${today}`;
|
||||
const cost = await this.client.get(costKey);
|
||||
const result = parseFloat(cost || 0);
|
||||
logger.debug(`💰 Getting daily cost for ${keyId}, date: ${today}, key: ${costKey}, value: ${cost}, result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 💰 增加当日费用
|
||||
async incrementDailyCost(keyId, amount) {
|
||||
const today = getDateStringInTimezone();
|
||||
const tzDate = getDateInTimezone();
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`;
|
||||
|
||||
const dailyKey = `usage:cost:daily:${keyId}:${today}`;
|
||||
const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}`;
|
||||
const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}`;
|
||||
const totalKey = `usage:cost:total:${keyId}`;
|
||||
|
||||
logger.debug(`💰 Incrementing cost for ${keyId}, amount: $${amount}, date: ${today}, dailyKey: ${dailyKey}`);
|
||||
|
||||
const results = await Promise.all([
|
||||
this.client.incrbyfloat(dailyKey, amount),
|
||||
this.client.incrbyfloat(monthlyKey, amount),
|
||||
this.client.incrbyfloat(hourlyKey, amount),
|
||||
this.client.incrbyfloat(totalKey, amount),
|
||||
// 设置过期时间
|
||||
this.client.expire(dailyKey, 86400 * 30), // 30天
|
||||
this.client.expire(monthlyKey, 86400 * 90), // 90天
|
||||
this.client.expire(hourlyKey, 86400 * 7) // 7天
|
||||
]);
|
||||
|
||||
logger.debug(`💰 Cost incremented successfully, new daily total: $${results[0]}`);
|
||||
}
|
||||
|
||||
// 💰 获取费用统计
|
||||
async getCostStats(keyId) {
|
||||
const today = getDateStringInTimezone();
|
||||
const tzDate = getDateInTimezone();
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`;
|
||||
|
||||
const [daily, monthly, hourly, total] = await Promise.all([
|
||||
this.client.get(`usage:cost:daily:${keyId}:${today}`),
|
||||
this.client.get(`usage:cost:monthly:${keyId}:${currentMonth}`),
|
||||
this.client.get(`usage:cost:hourly:${keyId}:${currentHour}`),
|
||||
this.client.get(`usage:cost:total:${keyId}`)
|
||||
]);
|
||||
|
||||
return {
|
||||
daily: parseFloat(daily || 0),
|
||||
monthly: parseFloat(monthly || 0),
|
||||
hourly: parseFloat(hourly || 0),
|
||||
total: parseFloat(total || 0)
|
||||
};
|
||||
}
|
||||
|
||||
// 📊 获取账户使用统计
|
||||
async getAccountUsageStats(accountId) {
|
||||
const accountKey = `account_usage:${accountId}`;
|
||||
@@ -1023,4 +1083,11 @@ class RedisClient {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RedisClient();
|
||||
const redisClient = new RedisClient();
|
||||
|
||||
// 导出时区辅助函数
|
||||
redisClient.getDateInTimezone = getDateInTimezone;
|
||||
redisClient.getDateStringInTimezone = getDateStringInTimezone;
|
||||
redisClient.getHourInTimezone = getHourInTimezone;
|
||||
|
||||
module.exports = redisClient;
|
||||
@@ -18,6 +18,37 @@ const router = express.Router();
|
||||
|
||||
// 🔑 API Keys 管理
|
||||
|
||||
// 调试:获取API Key费用详情
|
||||
router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const costStats = await redis.getCostStats(keyId);
|
||||
const dailyCost = await redis.getDailyCost(keyId);
|
||||
const today = redis.getDateStringInTimezone();
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 获取所有相关的Redis键
|
||||
const costKeys = await client.keys(`usage:cost:*:${keyId}:*`);
|
||||
const keyValues = {};
|
||||
|
||||
for (const key of costKeys) {
|
||||
keyValues[key] = await client.get(key);
|
||||
}
|
||||
|
||||
res.json({
|
||||
keyId,
|
||||
today,
|
||||
dailyCost,
|
||||
costStats,
|
||||
redisKeys: keyValues,
|
||||
timezone: config.system.timezoneOffset || 8
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get cost debug info:', error);
|
||||
res.status(500).json({ error: 'Failed to get cost debug info', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取所有API Keys
|
||||
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
@@ -29,20 +60,26 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
let searchPatterns = [];
|
||||
|
||||
if (timeRange === 'today') {
|
||||
// 今日
|
||||
const dateStr = now.toISOString().split('T')[0];
|
||||
// 今日 - 使用时区日期
|
||||
const redis = require('../models/redis');
|
||||
const tzDate = redis.getDateInTimezone(now);
|
||||
const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`;
|
||||
searchPatterns.push(`usage:daily:*:${dateStr}`);
|
||||
} else if (timeRange === '7days') {
|
||||
// 最近7天
|
||||
const redis = require('../models/redis');
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const tzDate = redis.getDateInTimezone(date);
|
||||
const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`;
|
||||
searchPatterns.push(`usage:daily:*:${dateStr}`);
|
||||
}
|
||||
} else if (timeRange === 'monthly') {
|
||||
// 本月
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
const redis = require('../models/redis');
|
||||
const tzDate = redis.getDateInTimezone(now);
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
searchPatterns.push(`usage:monthly:*:${currentMonth}`);
|
||||
}
|
||||
|
||||
@@ -149,11 +186,16 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
|
||||
// 计算指定时间范围的费用
|
||||
let totalCost = 0;
|
||||
const redis = require('../models/redis');
|
||||
const tzToday = redis.getDateStringInTimezone(now);
|
||||
const tzDate = redis.getDateInTimezone(now);
|
||||
const tzMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
const modelKeys = timeRange === 'today'
|
||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:${now.toISOString().split('T')[0]}`)
|
||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
|
||||
: timeRange === '7days'
|
||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
|
||||
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`);
|
||||
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`);
|
||||
|
||||
const modelStatsMap = new Map();
|
||||
|
||||
@@ -277,7 +319,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
enableModelRestriction,
|
||||
restrictedModels,
|
||||
enableClientRestriction,
|
||||
allowedClients
|
||||
allowedClients,
|
||||
dailyCostLimit
|
||||
} = req.body;
|
||||
|
||||
// 输入验证
|
||||
@@ -342,7 +385,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
enableModelRestriction,
|
||||
restrictedModels,
|
||||
enableClientRestriction,
|
||||
allowedClients
|
||||
allowedClients,
|
||||
dailyCostLimit
|
||||
});
|
||||
|
||||
logger.success(`🔑 Admin created new API key: ${name}`);
|
||||
@@ -357,7 +401,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt } = req.body;
|
||||
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt, dailyCostLimit } = req.body;
|
||||
|
||||
// 只允许更新指定字段
|
||||
const updates = {};
|
||||
@@ -453,6 +497,15 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理每日费用限制
|
||||
if (dailyCostLimit !== undefined && dailyCostLimit !== null && dailyCostLimit !== '') {
|
||||
const costLimit = Number(dailyCostLimit);
|
||||
if (isNaN(costLimit) || costLimit < 0) {
|
||||
return res.status(400).json({ error: 'Daily cost limit must be a non-negative number' });
|
||||
}
|
||||
updates.dailyCostLimit = costLimit;
|
||||
}
|
||||
|
||||
await apiKeyService.updateApiKey(keyId, updates);
|
||||
|
||||
logger.success(`📝 Admin updated API key: ${keyId}`);
|
||||
|
||||
@@ -26,7 +26,8 @@ class ApiKeyService {
|
||||
enableModelRestriction = false,
|
||||
restrictedModels = [],
|
||||
enableClientRestriction = false,
|
||||
allowedClients = []
|
||||
allowedClients = [],
|
||||
dailyCostLimit = 0
|
||||
} = options;
|
||||
|
||||
// 生成简单的API Key (64字符十六进制)
|
||||
@@ -51,6 +52,7 @@ class ApiKeyService {
|
||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||
enableClientRestriction: String(enableClientRestriction || false),
|
||||
allowedClients: JSON.stringify(allowedClients || []),
|
||||
dailyCostLimit: String(dailyCostLimit || 0),
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
expiresAt: expiresAt || '',
|
||||
@@ -79,6 +81,7 @@ class ApiKeyService {
|
||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
createdBy: keyData.createdBy
|
||||
@@ -114,6 +117,9 @@ class ApiKeyService {
|
||||
|
||||
// 获取使用统计(供返回数据使用)
|
||||
const usage = await redis.getUsageStats(keyData.id);
|
||||
|
||||
// 获取当日费用统计
|
||||
const dailyCost = await redis.getDailyCost(keyData.id);
|
||||
|
||||
// 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时)
|
||||
// 注意:lastUsedAt的更新已移至recordUsage方法中
|
||||
@@ -152,6 +158,8 @@ class ApiKeyService {
|
||||
restrictedModels: restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients: allowedClients,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
dailyCost: dailyCost || 0,
|
||||
usage
|
||||
}
|
||||
};
|
||||
@@ -178,6 +186,8 @@ class ApiKeyService {
|
||||
key.enableModelRestriction = key.enableModelRestriction === 'true';
|
||||
key.enableClientRestriction = key.enableClientRestriction === 'true';
|
||||
key.permissions = key.permissions || 'all'; // 兼容旧数据
|
||||
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0);
|
||||
key.dailyCost = await redis.getDailyCost(key.id) || 0;
|
||||
try {
|
||||
key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [];
|
||||
} catch (e) {
|
||||
@@ -207,7 +217,7 @@ class ApiKeyService {
|
||||
}
|
||||
|
||||
// 允许更新的字段
|
||||
const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients'];
|
||||
const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit'];
|
||||
const updatedData = { ...keyData };
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
@@ -261,9 +271,26 @@ class ApiKeyService {
|
||||
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);
|
||||
|
||||
// 记录API Key级别的使用统计
|
||||
await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
||||
|
||||
// 记录费用统计
|
||||
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}`);
|
||||
}
|
||||
|
||||
// 获取API Key数据以确定关联的账户
|
||||
const keyData = await redis.getApiKey(keyId);
|
||||
if (keyData && Object.keys(keyData).length > 0) {
|
||||
@@ -276,7 +303,7 @@ class ApiKeyService {
|
||||
await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
||||
logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`);
|
||||
} else {
|
||||
logger.debug(`⚠️ No accountId provided for usage recording, skipping account-level statistics`);
|
||||
logger.debug('⚠️ No accountId provided for usage recording, skipping account-level statistics');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
182
src/services/costInitService.js
Normal file
182
src/services/costInitService.js
Normal file
@@ -0,0 +1,182 @@
|
||||
const redis = require('../models/redis');
|
||||
const apiKeyService = require('./apiKeyService');
|
||||
const CostCalculator = require('../utils/costCalculator');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class CostInitService {
|
||||
/**
|
||||
* 初始化所有API Key的费用数据
|
||||
* 扫描历史使用记录并计算费用
|
||||
*/
|
||||
async initializeAllCosts() {
|
||||
try {
|
||||
logger.info('💰 Starting cost initialization for all API Keys...');
|
||||
|
||||
const apiKeys = await apiKeyService.getAllApiKeys();
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
let processedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const apiKey of apiKeys) {
|
||||
try {
|
||||
await this.initializeApiKeyCosts(apiKey.id, client);
|
||||
processedCount++;
|
||||
|
||||
if (processedCount % 10 === 0) {
|
||||
logger.info(`💰 Processed ${processedCount} API Keys...`);
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`);
|
||||
return { processed: processedCount, errors: errorCount };
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize costs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化单个API Key的费用数据
|
||||
*/
|
||||
async initializeApiKeyCosts(apiKeyId, client) {
|
||||
// 获取所有时间的模型使用统计
|
||||
const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`);
|
||||
|
||||
// 按日期分组统计
|
||||
const dailyCosts = new Map(); // date -> cost
|
||||
const monthlyCosts = new Map(); // month -> cost
|
||||
const hourlyCosts = new Map(); // hour -> cost
|
||||
|
||||
for (const key of modelKeys) {
|
||||
// 解析key格式: usage:{keyId}:model:{period}:{model}:{date}
|
||||
const match = key.match(/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, , period, model, dateStr] = match;
|
||||
|
||||
// 获取使用数据
|
||||
const data = await client.hgetall(key);
|
||||
if (!data || Object.keys(data).length === 0) continue;
|
||||
|
||||
// 计算费用
|
||||
const usage = {
|
||||
input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0,
|
||||
output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0,
|
||||
cache_creation_input_tokens: parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0,
|
||||
cache_read_input_tokens: parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
|
||||
};
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, model);
|
||||
const cost = costResult.costs.total;
|
||||
|
||||
// 根据period分组累加费用
|
||||
if (period === 'daily') {
|
||||
const currentCost = dailyCosts.get(dateStr) || 0;
|
||||
dailyCosts.set(dateStr, currentCost + cost);
|
||||
} else if (period === 'monthly') {
|
||||
const currentCost = monthlyCosts.get(dateStr) || 0;
|
||||
monthlyCosts.set(dateStr, currentCost + cost);
|
||||
} else if (period === 'hourly') {
|
||||
const currentCost = hourlyCosts.get(dateStr) || 0;
|
||||
hourlyCosts.set(dateStr, currentCost + cost);
|
||||
}
|
||||
}
|
||||
|
||||
// 将计算出的费用写入Redis
|
||||
const promises = [];
|
||||
|
||||
// 写入每日费用
|
||||
for (const [date, cost] of dailyCosts) {
|
||||
const key = `usage:cost:daily:${apiKeyId}:${date}`;
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 30) // 30天过期
|
||||
);
|
||||
}
|
||||
|
||||
// 写入每月费用
|
||||
for (const [month, cost] of monthlyCosts) {
|
||||
const key = `usage:cost:monthly:${apiKeyId}:${month}`;
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 90) // 90天过期
|
||||
);
|
||||
}
|
||||
|
||||
// 写入每小时费用
|
||||
for (const [hour, cost] of hourlyCosts) {
|
||||
const key = `usage:cost:hourly:${apiKeyId}:${hour}`;
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 7) // 7天过期
|
||||
);
|
||||
}
|
||||
|
||||
// 计算总费用
|
||||
let totalCost = 0;
|
||||
for (const cost of dailyCosts.values()) {
|
||||
totalCost += cost;
|
||||
}
|
||||
|
||||
// 写入总费用
|
||||
if (totalCost > 0) {
|
||||
const totalKey = `usage:cost:total:${apiKeyId}`;
|
||||
promises.push(client.set(totalKey, totalCost.toString()));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
logger.debug(`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要初始化费用数据
|
||||
*/
|
||||
async needsInitialization() {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 检查是否有任何费用数据
|
||||
const costKeys = await client.keys('usage:cost:*');
|
||||
|
||||
// 如果没有费用数据,需要初始化
|
||||
if (costKeys.length === 0) {
|
||||
logger.info('💰 No cost data found, initialization needed');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否有使用数据但没有对应的费用数据
|
||||
const sampleKeys = await client.keys('usage:*:model:daily:*:*');
|
||||
if (sampleKeys.length > 10) {
|
||||
// 抽样检查
|
||||
const sampleSize = Math.min(10, sampleKeys.length);
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)];
|
||||
const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/);
|
||||
if (match) {
|
||||
const [, keyId, , date] = match;
|
||||
const costKey = `usage:cost:daily:${keyId}:${date}`;
|
||||
const hasCost = await client.exists(costKey);
|
||||
if (!hasCost) {
|
||||
logger.info(`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('💰 Cost data appears to be up to date');
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to check initialization status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CostInitService();
|
||||
@@ -133,7 +133,8 @@ const app = createApp({
|
||||
allowedClients: [],
|
||||
expireDuration: '', // 过期时长选择
|
||||
customExpireDate: '', // 自定义过期日期
|
||||
expiresAt: null // 实际的过期时间戳
|
||||
expiresAt: null, // 实际的过期时间戳
|
||||
dailyCostLimit: '' // 每日费用限制
|
||||
},
|
||||
apiKeyModelStats: {}, // 存储每个key的模型统计数据
|
||||
expandedApiKeys: {}, // 跟踪展开的API Keys
|
||||
@@ -192,7 +193,8 @@ const app = createApp({
|
||||
restrictedModels: [],
|
||||
modelInput: '',
|
||||
enableClientRestriction: false,
|
||||
allowedClients: []
|
||||
allowedClients: [],
|
||||
dailyCostLimit: ''
|
||||
},
|
||||
|
||||
// 支持的客户端列表
|
||||
@@ -2075,7 +2077,8 @@ const app = createApp({
|
||||
restrictedModels: this.apiKeyForm.restrictedModels,
|
||||
enableClientRestriction: this.apiKeyForm.enableClientRestriction,
|
||||
allowedClients: this.apiKeyForm.allowedClients,
|
||||
expiresAt: this.apiKeyForm.expiresAt
|
||||
expiresAt: this.apiKeyForm.expiresAt,
|
||||
dailyCostLimit: this.apiKeyForm.dailyCostLimit && this.apiKeyForm.dailyCostLimit.trim() ? parseFloat(this.apiKeyForm.dailyCostLimit) : 0
|
||||
})
|
||||
});
|
||||
|
||||
@@ -2113,7 +2116,8 @@ const app = createApp({
|
||||
allowedClients: [],
|
||||
expireDuration: '',
|
||||
customExpireDate: '',
|
||||
expiresAt: null
|
||||
expiresAt: null,
|
||||
dailyCostLimit: ''
|
||||
};
|
||||
|
||||
// 重新加载API Keys列表
|
||||
@@ -2280,7 +2284,8 @@ const app = createApp({
|
||||
restrictedModels: key.restrictedModels ? [...key.restrictedModels] : [],
|
||||
modelInput: '',
|
||||
enableClientRestriction: key.enableClientRestriction || false,
|
||||
allowedClients: key.allowedClients ? [...key.allowedClients] : []
|
||||
allowedClients: key.allowedClients ? [...key.allowedClients] : [],
|
||||
dailyCostLimit: key.dailyCostLimit || ''
|
||||
};
|
||||
this.showEditApiKeyModal = true;
|
||||
},
|
||||
@@ -2301,7 +2306,8 @@ const app = createApp({
|
||||
restrictedModels: [],
|
||||
modelInput: '',
|
||||
enableClientRestriction: false,
|
||||
allowedClients: []
|
||||
allowedClients: [],
|
||||
dailyCostLimit: ''
|
||||
};
|
||||
},
|
||||
|
||||
@@ -2321,7 +2327,8 @@ const app = createApp({
|
||||
enableModelRestriction: this.editApiKeyForm.enableModelRestriction,
|
||||
restrictedModels: this.editApiKeyForm.restrictedModels,
|
||||
enableClientRestriction: this.editApiKeyForm.enableClientRestriction,
|
||||
allowedClients: this.editApiKeyForm.allowedClients
|
||||
allowedClients: this.editApiKeyForm.allowedClients,
|
||||
dailyCostLimit: this.editApiKeyForm.dailyCostLimit && this.editApiKeyForm.dailyCostLimit.toString().trim() !== '' ? parseFloat(this.editApiKeyForm.dailyCostLimit) : 0
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -663,6 +663,13 @@
|
||||
<span class="text-gray-600">费用:</span>
|
||||
<span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span>
|
||||
</div>
|
||||
<!-- 每日费用限制 -->
|
||||
<div v-if="key.dailyCostLimit > 0" class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">今日费用:</span>
|
||||
<span :class="['font-medium', (key.dailyCost || 0) >= key.dailyCostLimit ? 'text-red-600' : 'text-blue-600']">
|
||||
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 并发限制 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">并发限制:</span>
|
||||
@@ -2088,6 +2095,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="apiKeyForm.dailyCostLimit = '50'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$50</button>
|
||||
<button type="button" @click="apiKeyForm.dailyCostLimit = '100'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$100</button>
|
||||
<button type="button" @click="apiKeyForm.dailyCostLimit = '200'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$200</button>
|
||||
<button type="button" @click="apiKeyForm.dailyCostLimit = ''" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">自定义</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="apiKeyForm.dailyCostLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500">设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制 (可选)</label>
|
||||
<input
|
||||
@@ -2422,6 +2450,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="editApiKeyForm.dailyCostLimit = '50'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$50</button>
|
||||
<button type="button" @click="editApiKeyForm.dailyCostLimit = '100'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$100</button>
|
||||
<button type="button" @click="editApiKeyForm.dailyCostLimit = '200'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$200</button>
|
||||
<button type="button" @click="editApiKeyForm.dailyCostLimit = ''" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">自定义</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="editApiKeyForm.dailyCostLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0 表示无限制"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500">设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制</label>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user