diff --git a/VERSION b/VERSION index eef0324e..8b8c5239 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.134 +1.1.135 diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 4d4364ac..95408797 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -373,6 +373,92 @@ const authenticateApiKey = async (req, res, next) => { } } + // 🔒 安全检查 GPT-5 High 推理级别周费用限制 + const weeklyGPT5HighCostLimit = parseFloat(validation.keyData.weeklyGPT5HighCostLimit) || 0 + + if (weeklyGPT5HighCostLimit > 0) { + try { + // 从请求中获取模型和推理级别信息 + const requestBody = req.body || {} + const model = String(requestBody.model || '').toLowerCase() + + // 只对 GPT-5 模型进行检查 + if (model.includes('gpt-5')) { + // 安全提取推理级别 + let detectedLevel = 'medium' // 默认值 + + try { + // 多种方式提取推理级别 + detectedLevel = requestBody.reasoning_effort || + requestBody.model_reasoning_effort || + req.headers['reasoning-effort'] || + 'medium' + + // 检查 reasoning 字段 + const reasoningField = requestBody.reasoning + if (reasoningField) { + if (typeof reasoningField === 'string') { + if (reasoningField.includes('high') || reasoningField.includes('maximum')) { + detectedLevel = 'high' + } + } else if (typeof reasoningField === 'object' && reasoningField.effort) { + detectedLevel = reasoningField.effort + } + } + + // 确保级别值是字符串 + detectedLevel = String(detectedLevel).toLowerCase() + } catch (levelError) { + logger.warn('Error extracting reasoning level, using default:', levelError) + detectedLevel = 'medium' + } + + // 只对 High 级别进行限制检查 + if (detectedLevel === 'high') { + const weeklyGPT5HighCost = parseFloat(validation.keyData.weeklyGPT5HighCost) || 0 + + if (weeklyGPT5HighCost >= weeklyGPT5HighCostLimit) { + logger.security( + `💰 Weekly GPT-5 High cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${weeklyGPT5HighCost.toFixed(2)}/$${weeklyGPT5HighCostLimit}` + ) + + // 安全计算下周一的重置时间 + let resetDate + try { + const now = new Date() + const nextMonday = new Date(now) + nextMonday.setDate(now.getDate() + (7 - now.getDay() + 1) % 7 || 7) + nextMonday.setHours(0, 0, 0, 0) + resetDate = nextMonday + } catch (dateError) { + logger.warn('Error calculating reset date:', dateError) + resetDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7天后 + } + + return res.status(429).json({ + error: 'Weekly GPT-5 High cost limit exceeded', + message: `已达到 GPT-5 High推理级别周费用限制 ($${weeklyGPT5HighCostLimit})`, + currentCost: weeklyGPT5HighCost, + costLimit: weeklyGPT5HighCostLimit, + reasoningLevel: 'high', + resetAt: resetDate.toISOString() + }) + } + + // 记录使用情况(不影响性能) + if (weeklyGPT5HighCostLimit > 0) { + logger.api( + `💰 GPT-5 High weekly cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${weeklyGPT5HighCost.toFixed(2)}/$${weeklyGPT5HighCostLimit} (${((weeklyGPT5HighCost / weeklyGPT5HighCostLimit) * 100).toFixed(1)}%)` + ) + } + } + } + } catch (gpt5Error) { + logger.warn('Error in GPT-5 High cost check, continuing with request:', gpt5Error) + // 发生错误时不阻止请求,确保服务可用性 + } + } + // 将验证信息添加到请求对象(只包含必要信息) req.apiKey = { id: validation.keyData.id, @@ -1120,4 +1206,4 @@ module.exports = { errorHandler, globalRateLimit, requestSizeLimit -} +} \ No newline at end of file diff --git a/src/models/redis.js b/src/models/redis.js index 7ef64ebf..cd5046f4 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -733,6 +733,62 @@ class RedisClient { logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`) } + // 💰 获取本周 GPT-5 High 费用 + async getWeeklyGPT5HighCost(keyId) { + try { + if (!keyId) { + logger.warn('getWeeklyGPT5HighCost: keyId is required') + return 0 + } + + const currentWeek = getWeekStringInTimezone() + const costKey = `usage:gpt5-high:weekly:${keyId}:${currentWeek}` + const cost = await this.client.get(costKey) + const result = parseFloat(cost || 0) + + logger.debug( + `💰 Getting weekly GPT-5 High cost for ${keyId}, week: ${currentWeek}, result: $${result.toFixed(4)}` + ) + return result + } catch (error) { + logger.error('Error getting weekly GPT-5 High cost:', error) + return 0 // 发生错误时返回0,不影响正常流程 + } + } + + // 💰 增加本周 GPT-5 High 费用 + async incrementWeeklyGPT5HighCost(keyId, amount) { + try { + if (!keyId || !amount || amount <= 0) { + logger.warn('incrementWeeklyGPT5HighCost: invalid parameters', { keyId, amount }) + return + } + + const currentWeek = getWeekStringInTimezone() + const weeklyKey = `usage:gpt5-high:weekly:${keyId}:${currentWeek}` + const totalKey = `usage:gpt5-high:total:${keyId}` + + logger.debug( + `💰 Incrementing weekly GPT-5 High cost for ${keyId}, week: ${currentWeek}, amount: $${amount.toFixed(4)}` + ) + + // 使用 pipeline 批量执行,提高性能和一致性 + const pipeline = this.client.pipeline() + pipeline.incrbyfloat(weeklyKey, amount) + pipeline.incrbyfloat(totalKey, amount) + pipeline.expire(weeklyKey, 60 * 60 * 24 * 14) // 2周后过期 + + await pipeline.exec() + + logger.debug( + `💰 Weekly GPT-5 High cost updated successfully for ${keyId}` + ) + } catch (error) { + logger.error('Error incrementing weekly GPT-5 High cost:', error) + // 不抛出错误,避免影响主流程 + } + } + // 💰 计算账户的每日费用(基于模型使用) async getAccountDailyCost(accountId) { const CostCalculator = require('../utils/costCalculator') @@ -1356,12 +1412,9 @@ class RedisClient { } // 🔗 会话sticky映射管理 - async setSessionAccountMapping(sessionHash, accountId, ttl = null) { - const appConfig = require('../../config/config') - // 从配置读取TTL(小时),转换为秒,默认1小时 - const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlHours || 1) * 60 * 60 + async setSessionAccountMapping(sessionHash, accountId, ttl = 3600) { const key = `sticky_session:${sessionHash}` - await this.client.set(key, accountId, 'EX', defaultTTL) + await this.client.set(key, accountId, 'EX', ttl) } async getSessionAccountMapping(sessionHash) { @@ -1369,57 +1422,6 @@ class RedisClient { return await this.client.get(key) } - // 🚀 智能会话TTL续期:剩余时间少于阈值时自动续期 - async extendSessionAccountMappingTTL(sessionHash) { - const appConfig = require('../../config/config') - const key = `sticky_session:${sessionHash}` - - // 📊 从配置获取参数 - const ttlHours = appConfig.session?.stickyTtlHours || 1 // 小时,默认1小时 - const thresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 // 分钟,默认0(不续期) - - // 如果阈值为0,不执行续期 - if (thresholdMinutes === 0) { - return true - } - - const fullTTL = ttlHours * 60 * 60 // 转换为秒 - const renewalThreshold = thresholdMinutes * 60 // 转换为秒 - - try { - // 获取当前剩余TTL(秒) - const remainingTTL = await this.client.ttl(key) - - // 键不存在或已过期 - if (remainingTTL === -2) { - return false - } - - // 键存在但没有TTL(永不过期,不需要处理) - if (remainingTTL === -1) { - return true - } - - // 🎯 智能续期策略:仅在剩余时间少于阈值时才续期 - if (remainingTTL < renewalThreshold) { - await this.client.expire(key, fullTTL) - logger.debug( - `🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}min, renewed to ${ttlHours}h)` - ) - return true - } - - // 剩余时间充足,无需续期 - logger.debug( - `✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}min)` - ) - return true - } catch (error) { - logger.error('❌ Failed to extend session TTL:', error) - return false - } - } - async deleteSessionAccountMapping(sessionHash) { const key = `sticky_session:${sessionHash}` return await this.client.del(key) @@ -1707,4 +1709,4 @@ redisClient.getDateStringInTimezone = getDateStringInTimezone redisClient.getHourInTimezone = getHourInTimezone redisClient.getWeekStringInTimezone = getWeekStringInTimezone -module.exports = redisClient +module.exports = redisClient \ No newline at end of file diff --git a/src/routes/admin.js b/src/routes/admin.js index 009d369c..98680aaf 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -123,7 +123,7 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => // 获取所有API Keys router.get('/api-keys', authenticateAdmin, async (req, res) => { try { - const { timeRange = 'all', startDate, endDate } = req.query // all, 7days, monthly, custom + const { timeRange = 'all' } = req.query // all, 7days, monthly const apiKeys = await apiKeyService.getAllApiKeys() // 获取用户服务来补充owner信息 @@ -133,32 +133,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { const now = new Date() const searchPatterns = [] - if (timeRange === 'custom' && startDate && endDate) { - // 自定义日期范围 - const redisClient = require('../models/redis') - const start = new Date(startDate) - const end = new Date(endDate) - - // 确保日期范围有效 - if (start > end) { - return res.status(400).json({ error: 'Start date must be before or equal to end date' }) - } - - // 限制最大范围为365天 - const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 - if (daysDiff > 365) { - return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) - } - - // 生成日期范围内每天的搜索模式 - const currentDate = new Date(start) - while (currentDate <= end) { - const tzDate = redisClient.getDateInTimezone(currentDate) - const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}` - searchPatterns.push(`usage:daily:*:${dateStr}`) - currentDate.setDate(currentDate.getDate() + 1) - } - } else if (timeRange === 'today') { + if (timeRange === 'today') { // 今日 - 使用时区日期 const redisClient = require('../models/redis') const tzDate = redisClient.getDateInTimezone(now) @@ -259,7 +234,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost) } } else { - // 7天、本月或自定义日期范围:重新计算统计数据 + // 7天或本月:重新计算统计数据 const tempUsage = { requests: 0, tokens: 0, @@ -300,28 +275,12 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { const tzDate = redisClient.getDateInTimezone(now) const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` - let modelKeys = [] - if (timeRange === 'custom' && startDate && endDate) { - // 自定义日期范围:获取范围内所有日期的模型统计 - const start = new Date(startDate) - const end = new Date(endDate) - const currentDate = new Date(start) - - while (currentDate <= end) { - const tzDateForKey = redisClient.getDateInTimezone(currentDate) - const dateStr = `${tzDateForKey.getUTCFullYear()}-${String(tzDateForKey.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDateForKey.getUTCDate()).padStart(2, '0')}` - const dayKeys = await client.keys(`usage:${apiKey.id}:model:daily:*:${dateStr}`) - modelKeys = modelKeys.concat(dayKeys) - currentDate.setDate(currentDate.getDate() + 1) - } - } else { - modelKeys = - timeRange === 'today' - ? 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:*:${tzMonth}`) - } + const modelKeys = + timeRange === 'today' + ? 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:*:${tzMonth}`) const modelStatsMap = new Map() @@ -337,8 +296,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { continue } } - } else if (timeRange === 'today' || timeRange === 'custom') { - // today和custom选项已经在查询时过滤了,不需要额外处理 + } else if (timeRange === 'today') { + // today选项已经在查询时过滤了,不需要额外处理 } const modelMatch = key.match( @@ -989,8 +948,10 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { expiresAt, dailyCostLimit, weeklyOpusCostLimit, + weeklyGPT5HighCostLimit, tags, - ownerId // 新增:所有者ID字段 + ownerId, // 新增:所有者ID字段 + icon // 新增:图标(base64编码) } = req.body // 只允许更新指定字段 @@ -1153,6 +1114,22 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.weeklyOpusCostLimit = costLimit } + // 处理 GPT-5 High 周费用限制 + if ( + weeklyGPT5HighCostLimit !== undefined && + weeklyGPT5HighCostLimit !== null && + weeklyGPT5HighCostLimit !== '' + ) { + const costLimit = Number(weeklyGPT5HighCostLimit) + // 明确验证非负数(0 表示禁用,负数无意义) + if (isNaN(costLimit) || costLimit < 0) { + return res + .status(400) + .json({ error: 'Weekly GPT-5 High cost limit must be a non-negative number' }) + } + updates.weeklyGPT5HighCostLimit = costLimit + } + // 处理标签 if (tags !== undefined) { if (!Array.isArray(tags)) { @@ -1164,6 +1141,19 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.tags = tags } + // 处理图标 + if (icon !== undefined) { + // icon 可以是空字符串(清除图标)或 base64 编码的字符串 + if (icon !== '' && typeof icon !== 'string') { + return res.status(400).json({ error: 'Icon must be a string' }) + } + // 简单验证 base64 格式(如果不为空) + if (icon && !icon.startsWith('data:image/')) { + return res.status(400).json({ error: 'Icon must be a valid base64 image' }) + } + updates.icon = icon + } + // 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能 if (isActive !== undefined) { if (typeof isActive !== 'boolean') { @@ -4338,10 +4328,10 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: 'Start date must be before or equal to end date' }) } - // 限制最大范围为365天 + // 限制最大范围为31天 const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 - if (daysDiff > 365) { - return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) + if (daysDiff > 31) { + return res.status(400).json({ error: 'Date range cannot exceed 31 days' }) } // 生成日期范围内所有日期的搜索模式 @@ -4802,10 +4792,10 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) = return res.status(400).json({ error: 'Start date must be before or equal to end date' }) } - // 限制最大范围为365天 + // 限制最大范围为31天 const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 - if (daysDiff > 365) { - return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) + if (daysDiff > 31) { + return res.status(400).json({ error: 'Date range cannot exceed 31 days' }) } // 生成日期范围内所有日期的搜索模式 diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index dfe27719..cd22e535 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -2,13 +2,31 @@ const express = require('express') const axios = require('axios') const router = express.Router() const logger = require('../utils/logger') -const config = require('../../config/config') const { authenticateApiKey } = require('../middleware/auth') const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler') const openaiAccountService = require('../services/openaiAccountService') const apiKeyService = require('../services/apiKeyService') const crypto = require('crypto') const ProxyHelper = require('../utils/proxyHelper') +const redis = require('../models/redis') // 新增:用于GPT-5 High费用记录 + +// 🔥 计算GPT-5 High推理级别的额外费用 +function calculateGPT5HighCost(usageData) { + if (!usageData) return 0 + + // GPT-5 High推理级别费用(示例费率,实际需要根据OpenAI官方定价调整) + const inputTokens = usageData.prompt_tokens || 0 + const outputTokens = usageData.completion_tokens || 0 + + // High推理级别的额外费用(美元) + const inputCostPerToken = 0.00002 // $0.02 per 1K tokens for input + const outputCostPerToken = 0.0001 // $0.10 per 1K tokens for output + + const inputCost = (inputTokens / 1000) * inputCostPerToken + const outputCost = (outputTokens / 1000) * outputCostPerToken + + return inputCost + outputCost +} // 创建代理 Agent(使用统一的代理工具) function createProxyAgent(proxy) { @@ -104,7 +122,103 @@ const handleResponses = async (req, res) => { null // 从请求体中提取模型和流式标志 - let requestedModel = req.body?.model || null + const originalModel = req.body?.model || null // 保存原始模型名称用于限制检查 + let requestedModel = originalModel + + // 🔍 详细分析 Codex CLI 请求格式(用于推理级别识别) + logger.info(`🔍 Codex CLI request analysis:`, { + model: req.body?.model, + temperature: req.body?.temperature, + max_tokens: req.body?.max_tokens, + reasoning_effort: req.body?.reasoning_effort, + model_reasoning_effort: req.body?.model_reasoning_effort, + stream: req.body?.stream, + allHeaders: Object.keys(req.headers), + allBodyKeys: Object.keys(req.body || {}) + }) + + // 🎯 尝试从请求中识别推理级别 + let effectiveModel = originalModel + + // 检查所有可能的推理级别字段 + const reasoningEffort = + req.body?.reasoning_effort || + req.body?.model_reasoning_effort || + req.headers['reasoning-effort'] + + // 🔥 检查 reasoning 字段(可能包含推理级别信息) + const reasoningField = req.body?.reasoning + + logger.info(`🔥 Detailed reasoning analysis:`, { + reasoningField, + reasoningEffort, + reasoningType: typeof reasoningField + }) + + // 如果是 GPT-5,尝试从各种字段中提取推理级别 + if (originalModel === 'gpt-5') { + let detectedLevel = null + + // 方法1: 直接从 reasoning_effort 获取 + if (reasoningEffort) { + detectedLevel = reasoningEffort + } + // 方法2: 从 reasoning 字段分析(可能是对象或字符串) + else if (reasoningField) { + if (typeof reasoningField === 'string') { + // 如果是字符串,查找级别关键词 + if (reasoningField.includes('high') || reasoningField.includes('maximum')) { + detectedLevel = 'high' + } else if (reasoningField.includes('medium') || reasoningField.includes('balanced')) { + detectedLevel = 'medium' + } else if (reasoningField.includes('low') || reasoningField.includes('fast')) { + detectedLevel = 'low' + } else if (reasoningField.includes('minimal') || reasoningField.includes('quick')) { + detectedLevel = 'minimal' + } + } else if (typeof reasoningField === 'object') { + // 检查 effort 字段 (Codex CLI 使用这种格式) + if (reasoningField.effort) { + detectedLevel = reasoningField.effort + } else if (reasoningField.level) { + detectedLevel = reasoningField.level + } + } + } + + if (detectedLevel) { + effectiveModel = `gpt-5 ${detectedLevel}` + logger.info( + `🎯 Detected GPT-5 with reasoning level: ${detectedLevel} → Effective model for restriction: ${effectiveModel}` + ) + } + } + + // 🚀 检查模型限制(使用有效模型名称) + if ( + apiKeyData.enableModelRestriction && + apiKeyData.restrictedModels && + apiKeyData.restrictedModels.length > 0 + ) { + logger.info( + `🔒 OpenAI Model restriction check - Original: ${originalModel}, Effective: ${effectiveModel}, Restricted: ${JSON.stringify(apiKeyData.restrictedModels)}` + ) + + if (effectiveModel && apiKeyData.restrictedModels.includes(effectiveModel)) { + logger.warn( + `🚫 OpenAI Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${effectiveModel}` + ) + return res.status(403).json({ + error: { + type: 'forbidden', + message: '暂无该模型访问权限', + code: 'model_restricted' + } + }) + } + } + + // 如果通过限制检查,再进行模型规范化 // 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),则覆盖为 gpt-5 if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5') { @@ -180,7 +294,7 @@ const handleResponses = async (req, res) => { // 配置请求选项 const axiosConfig = { headers, - timeout: config.requestTimeout || 600000, + timeout: 60 * 1000 * 10, validateStatus: () => true } @@ -368,6 +482,61 @@ const handleResponses = async (req, res) => { logger.info( `📊 Recorded OpenAI non-stream usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${actualModel}` ) + + // 🔥 安全记录 GPT-5 High 推理级别费用(非流式响应) + try { + if (actualModel && String(actualModel).toLowerCase().includes('gpt-5')) { + // 安全提取推理级别 + const originalRequestBody = req.body || {} + let detectedLevel = 'medium' // 安全默认值 + + try { + detectedLevel = + originalRequestBody.reasoning_effort || + originalRequestBody.model_reasoning_effort || + 'medium' + + // 检查 reasoning 字段 + const reasoningField = originalRequestBody.reasoning + if (reasoningField) { + if (typeof reasoningField === 'string') { + if (reasoningField.includes('high') || reasoningField.includes('maximum')) { + detectedLevel = 'high' + } + } else if (typeof reasoningField === 'object' && reasoningField.effort) { + detectedLevel = reasoningField.effort + } + } + } catch (levelError) { + logger.debug( + 'Error extracting reasoning level for cost recording (non-stream):', + levelError + ) + } + + // 如果是 High 级别,记录额外的费用 + if (String(detectedLevel).toLowerCase() === 'high') { + const gpt5HighCost = calculateGPT5HighCost(usageData) + + if (gpt5HighCost > 0) { + // 记录GPT-5 High专门的费用统计(用于周限制) + await redis.incrementWeeklyGPT5HighCost(apiKeyData.id, gpt5HighCost) + logger.info( + `💰 Recorded GPT-5 High weekly cost (non-stream): $${gpt5HighCost.toFixed(4)} for key ${apiKeyData.id} (${apiKeyData.name})` + ) + + // 🔧 关键修复:同时记录到常规费用统计中 + await redis.incrementDailyCost(apiKeyData.id, gpt5HighCost) + logger.info( + `💰 Recorded GPT-5 High to daily cost (non-stream): $${gpt5HighCost.toFixed(4)} for key ${apiKeyData.id} (${apiKeyData.name})` + ) + } + } + } + } catch (gpt5CostError) { + logger.warn('Error in GPT-5 High cost recording (non-stream):', gpt5CostError) + // 不影响主流程 + } } // 返回响应 @@ -487,6 +656,58 @@ const handleResponses = async (req, res) => { logger.info( `📊 Recorded OpenAI usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${modelToRecord} (actual: ${actualModel}, requested: ${requestedModel})` ) + + // 🔥 安全记录 GPT-5 High 推理级别费用 + try { + if (actualModel && String(actualModel).toLowerCase().includes('gpt-5')) { + // 安全提取推理级别 + const originalRequestBody = req.body || {} + let detectedLevel = 'medium' // 安全默认值 + + try { + detectedLevel = + originalRequestBody.reasoning_effort || + originalRequestBody.model_reasoning_effort || + 'medium' + + // 检查 reasoning 字段 + const reasoningField = originalRequestBody.reasoning + if (reasoningField) { + if (typeof reasoningField === 'string') { + if (reasoningField.includes('high') || reasoningField.includes('maximum')) { + detectedLevel = 'high' + } + } else if (typeof reasoningField === 'object' && reasoningField.effort) { + detectedLevel = reasoningField.effort + } + } + } catch (levelError) { + logger.debug('Error extracting reasoning level for cost recording:', levelError) + } + + // 如果是 High 级别,记录额外的费用 + if (String(detectedLevel).toLowerCase() === 'high') { + const gpt5HighCost = calculateGPT5HighCost(usageData) + + if (gpt5HighCost > 0) { + // 记录GPT-5 High专门的费用统计(用于周限制) + await redis.incrementWeeklyGPT5HighCost(apiKeyData.id, gpt5HighCost) + logger.info( + `💰 Recorded GPT-5 High weekly cost: $${gpt5HighCost.toFixed(4)} for key ${apiKeyData.id} (${apiKeyData.name})` + ) + + // 🔧 关键修复:同时记录到常规费用统计中 + await redis.incrementDailyCost(apiKeyData.id, gpt5HighCost) + logger.info( + `💰 Recorded GPT-5 High to daily cost: $${gpt5HighCost.toFixed(4)} for key ${apiKeyData.id} (${apiKeyData.name})` + ) + } + } + } + } catch (gpt5CostError) { + logger.warn('Error in GPT-5 High cost recording:', gpt5CostError) + // 不影响主流程 + } usageReported = true } catch (error) { logger.error('Failed to record OpenAI usage:', error) diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index e27aafd5..e4eb8b9e 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -34,6 +34,7 @@ class ApiKeyService { allowedClients = [], dailyCostLimit = 0, weeklyOpusCostLimit = 0, + weeklyGPT5HighCostLimit = 0, // 新增:GPT-5 High推理级别周费用限制 tags = [], activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能) expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活) @@ -69,6 +70,7 @@ class ApiKeyService { allowedClients: JSON.stringify(allowedClients || []), dailyCostLimit: String(dailyCostLimit || 0), weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), + weeklyGPT5HighCostLimit: String(weeklyGPT5HighCostLimit || 0), // 新增:GPT-5 High周费用限制 tags: JSON.stringify(tags || []), activationDays: String(activationDays || 0), // 新增:激活后有效天数 expirationMode: expirationMode || 'fixed', // 新增:过期模式 @@ -112,6 +114,7 @@ class ApiKeyService { allowedClients: JSON.parse(keyData.allowedClients || '[]'), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), + weeklyGPT5HighCostLimit: parseFloat(keyData.weeklyGPT5HighCostLimit || 0), // 新增:GPT-5 High周费用限制 tags: JSON.parse(keyData.tags || '[]'), activationDays: parseInt(keyData.activationDays || 0), expirationMode: keyData.expirationMode || 'fixed', @@ -119,7 +122,8 @@ class ApiKeyService { activatedAt: keyData.activatedAt, createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, - createdBy: keyData.createdBy + createdBy: keyData.createdBy, + icon: keyData.icon || '' // 新增:图标 } } @@ -412,8 +416,10 @@ class ApiKeyService { key.permissions = key.permissions || 'all' // 兼容旧数据 key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) + key.weeklyGPT5HighCostLimit = parseFloat(key.weeklyGPT5HighCostLimit || 0) // 新增:GPT-5 High周费用限制 key.dailyCost = (await redis.getDailyCost(key.id)) || 0 key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0 + key.weeklyGPT5HighCost = (await redis.getWeeklyGPT5HighCost(key.id)) || 0 // 新增:GPT-5 High当前周费用 key.activationDays = parseInt(key.activationDays || 0) key.expirationMode = key.expirationMode || 'fixed' key.isActivated = key.isActivated === 'true' diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index 60c404f1..f9e8eeb0 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -16,9 +16,9 @@ const { const tokenRefreshService = require('./tokenRefreshService') const LRUCache = require('../utils/lruCache') -// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据 -const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com' -const OAUTH_CLIENT_SECRET = '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' +// Gemini CLI OAuth 配置 - 从环境变量或配置文件读取 +const OAUTH_CLIENT_ID = process.env.GEMINI_OAUTH_CLIENT_ID || config.gemini?.oauthClientId || 'your-oauth-client-id' +const OAUTH_CLIENT_SECRET = process.env.GEMINI_OAUTH_CLIENT_SECRET || config.gemini?.oauthClientSecret || 'your-oauth-client-secret' const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform'] // 加密相关常量 diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index 4e78520f..19680b51 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -110,26 +110,70 @@ class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm" >名称 * -
- -
+

{{ errors.name }}

+ +
+ +
+ +
+
+ API Key图标 +
+ +
+ + +
+ + +

+ 支持 PNG、JPG 格式,建议尺寸 64x64px +

+
+
+
+
+ +
+ +
+
+ + + + +
+ +

+ 设置 GPT-5 High推理级别的周费用限制(周一到周日),0 或留空表示无限制 +

+
+
+
import { ref, reactive, computed, onMounted } from 'vue' +import { ElMessage } from 'element-plus' import { showToast } from '@/utils/toast' import { useClientsStore } from '@/stores/clients' import { useApiKeysStore } from '@/stores/apiKeys' @@ -862,6 +957,7 @@ const form = reactive({ concurrencyLimit: '', dailyCostLimit: '', weeklyOpusCostLimit: '', + weeklyGPT5HighCostLimit: '', // 新增:GPT-5 High推理级别周费用限制 expireDuration: '', customExpireDate: '', expiresAt: null, @@ -877,7 +973,8 @@ const form = reactive({ modelInput: '', enableClientRestriction: false, allowedClients: [], - tags: [] + tags: [], + icon: '' // 新增:图标(base64编码) }) // 加载支持的客户端和已存在的标签 @@ -898,6 +995,35 @@ onMounted(async () => { } }) +// 处理图标上传 +const handleIconUpload = (event) => { + const file = event.target.files?.[0] + if (!file) return + + // 检查文件类型 + if (!file.type.startsWith('image/')) { + ElMessage.error('请选择图片文件') + return + } + + // 检查文件大小 (限制为 2MB) + if (file.size > 2 * 1024 * 1024) { + ElMessage.error('图片大小不能超过 2MB') + return + } + + // 读取文件并转换为 base64 + const reader = new FileReader() + reader.onload = (e) => { + form.icon = e.target.result + ElMessage.success('图标上传成功') + } + reader.onerror = () => { + ElMessage.error('图标上传失败') + } + reader.readAsDataURL(file) +} + // 刷新账号列表 const refreshAccounts = async () => { accountsLoading.value = true @@ -1052,7 +1178,22 @@ const removeRestrictedModel = (index) => { } // 常用模型列表 -const commonModels = ref(['claude-opus-4-20250514', 'claude-opus-4-1-20250805']) +const commonModels = ref([ + // Claude 模型 + 'claude-opus-4-20250514', + 'claude-opus-4-1-20250805', + // OpenAI 模型 + 'gpt-5', + 'gpt-5 minimal', + 'gpt-5 low', + 'gpt-5 medium', + 'gpt-5 high', + 'gpt-4o', + 'gpt-4o-mini', + 'o1', + 'o1-mini', + 'o1-preview' +]) // 可用的快捷模型(过滤掉已在限制列表中的) const availableQuickModels = computed(() => { @@ -1155,6 +1296,11 @@ const createApiKey = async () => { form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null ? parseFloat(form.weeklyOpusCostLimit) : 0, + // 新增:GPT-5 High推理级别周费用限制 + weeklyGPT5HighCostLimit: + form.weeklyGPT5HighCostLimit !== '' && form.weeklyGPT5HighCostLimit !== null + ? parseFloat(form.weeklyGPT5HighCostLimit) + : 0, expiresAt: form.expirationMode === 'fixed' ? form.expiresAt || undefined : undefined, expirationMode: form.expirationMode, activationDays: form.expirationMode === 'activation' ? form.activationDays : undefined, @@ -1163,7 +1309,8 @@ const createApiKey = async () => { enableModelRestriction: form.enableModelRestriction, restrictedModels: form.restrictedModels, enableClientRestriction: form.enableClientRestriction, - allowedClients: form.allowedClients + allowedClients: form.allowedClients, + icon: form.icon || undefined // 新增:图标 } // 处理Claude账户绑定(区分OAuth和Console) diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index fda19ade..e0bb5844 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -32,16 +32,14 @@ class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm" >名称 -
- -
+

用于识别此 API Key 的用途

@@ -322,6 +320,56 @@
+ +
+ +
+
+ + + + +
+ +

+ 设置 GPT-5 High推理级别的周费用限制(周一到周日),0 或留空表示无限制 +

+
+
+
{ } // 常用模型列表 -const commonModels = ref(['claude-opus-4-20250514', 'claude-opus-4-1-20250805']) +const commonModels = ref([ + // Claude 模型 + 'claude-opus-4-20250514', + 'claude-opus-4-1-20250805', + // OpenAI 模型 + 'gpt-5', + 'gpt-5 minimal', + 'gpt-5 low', + 'gpt-5 medium', + 'gpt-5 high', + 'gpt-4o', + 'gpt-4o-mini', + 'o1', + 'o1-mini', + 'o1-preview' +]) // 可用的快捷模型(过滤掉已在限制列表中的) const availableQuickModels = computed(() => { @@ -830,6 +894,11 @@ const updateApiKey = async () => { form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null ? parseFloat(form.weeklyOpusCostLimit) : 0, + // 新增:GPT-5 High推理级别周费用限制 + weeklyGPT5HighCostLimit: + form.weeklyGPT5HighCostLimit !== '' && form.weeklyGPT5HighCostLimit !== null + ? parseFloat(form.weeklyGPT5HighCostLimit) + : 0, permissions: form.permissions, tags: form.tags } @@ -1053,6 +1122,7 @@ onMounted(async () => { form.concurrencyLimit = props.apiKey.concurrencyLimit || '' form.dailyCostLimit = props.apiKey.dailyCostLimit || '' form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || '' + form.weeklyGPT5HighCostLimit = props.apiKey.weeklyGPT5HighCostLimit || '' // 新增 form.permissions = props.apiKey.permissions || 'all' // 处理 Claude 账号(区分 OAuth 和 Console) if (props.apiKey.claudeConsoleAccountId) { diff --git a/web/admin-spa/src/components/apikeys/LimitProgressBar.vue b/web/admin-spa/src/components/apikeys/LimitProgressBar.vue index a11e582f..6e0181ae 100644 --- a/web/admin-spa/src/components/apikeys/LimitProgressBar.vue +++ b/web/admin-spa/src/components/apikeys/LimitProgressBar.vue @@ -1,6 +1,6 @@