diff --git a/scripts/test-openai-refresh.js b/scripts/test-openai-refresh.js deleted file mode 100644 index 5158e9f8..00000000 --- a/scripts/test-openai-refresh.js +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env node - -/** - * OpenAI Token 刷新功能测试脚本 - * 用于测试 openaiAccountService 的 token 刷新功能 - */ - -const openaiAccountService = require('../src/services/openaiAccountService') -const logger = require('../src/utils/logger') - -// 测试配置(可以通过环境变量或命令行参数传入) -const TEST_REFRESH_TOKEN = process.env.OPENAI_REFRESH_TOKEN || process.argv[2] - -async function testRefreshToken() { - if (!TEST_REFRESH_TOKEN) { - console.error('❌ 请提供 refresh token 作为参数或设置环境变量 OPENAI_REFRESH_TOKEN') - console.log('使用方法:') - console.log(' node scripts/test-openai-refresh.js ') - console.log(' 或') - console.log(' OPENAI_REFRESH_TOKEN= node scripts/test-openai-refresh.js') - process.exit(1) - } - - console.log('🔄 开始测试 OpenAI token 刷新功能...\n') - - try { - // 测试不带代理的刷新 - console.log('1️⃣ 测试直接刷新(无代理)...') - const result = await openaiAccountService.refreshAccessToken(TEST_REFRESH_TOKEN) - - console.log('✅ 刷新成功!') - console.log(' Access Token:', result.access_token ? result.access_token.substring(0, 30) + '...' : 'N/A') - console.log(' ID Token:', result.id_token ? result.id_token.substring(0, 30) + '...' : 'N/A') - console.log(' Refresh Token:', result.refresh_token ? result.refresh_token.substring(0, 30) + '...' : 'N/A') - console.log(' 有效期:', result.expires_in, '秒') - console.log(' 过期时间:', new Date(result.expiry_date).toLocaleString()) - - // 如果返回了新的 refresh token - if (result.refresh_token && result.refresh_token !== TEST_REFRESH_TOKEN) { - console.log('\n⚠️ 注意:收到了新的 refresh token,请保存以供后续使用') - } - - // 测试带代理的刷新(如果配置了代理) - if (process.env.PROXY_HOST && process.env.PROXY_PORT) { - console.log('\n2️⃣ 测试通过代理刷新...') - const proxy = { - type: process.env.PROXY_TYPE || 'http', - host: process.env.PROXY_HOST, - port: parseInt(process.env.PROXY_PORT), - username: process.env.PROXY_USERNAME, - password: process.env.PROXY_PASSWORD - } - - console.log(' 代理配置:', `${proxy.type}://${proxy.host}:${proxy.port}`) - - const proxyResult = await openaiAccountService.refreshAccessToken( - result.refresh_token || TEST_REFRESH_TOKEN, - proxy - ) - - console.log('✅ 通过代理刷新成功!') - console.log(' Access Token:', proxyResult.access_token ? proxyResult.access_token.substring(0, 30) + '...' : 'N/A') - } - - // 测试完整的账户刷新流程(如果提供了账户ID) - if (process.env.OPENAI_ACCOUNT_ID) { - console.log('\n3️⃣ 测试账户刷新流程...') - console.log(' 账户ID:', process.env.OPENAI_ACCOUNT_ID) - - try { - const account = await openaiAccountService.getAccount(process.env.OPENAI_ACCOUNT_ID) - if (account) { - console.log(' 账户名称:', account.name) - console.log(' 当前过期时间:', account.expiresAt) - - const refreshResult = await openaiAccountService.refreshAccountToken(process.env.OPENAI_ACCOUNT_ID) - console.log('✅ 账户 token 刷新成功!') - console.log(' 新的过期时间:', new Date(refreshResult.expiry_date).toLocaleString()) - } - } catch (error) { - console.log('⚠️ 账户刷新测试失败:', error.message) - } - } - - console.log('\n✅ 所有测试完成!') - - } catch (error) { - console.error('\n❌ 测试失败:', error.message) - if (error.response) { - console.error('响应状态:', error.response.status) - console.error('响应数据:', error.response.data) - } - process.exit(1) - } -} - -// 运行测试 -testRefreshToken().then(() => { - process.exit(0) -}).catch((error) => { - console.error('Unexpected error:', error) - process.exit(1) -}) \ No newline at end of file diff --git a/src/routes/api.js b/src/routes/api.js index a2ab2562..3b1c4160 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -96,22 +96,42 @@ async function handleMessagesRequest(req, res) { ) { const inputTokens = usageData.input_tokens || 0 const outputTokens = usageData.output_tokens || 0 - const cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + // 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens + let cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + let ephemeral5mTokens = 0 + let ephemeral1hTokens = 0 + + if (usageData.cache_creation && typeof usageData.cache_creation === 'object') { + ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0 + ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0 + // 总的缓存创建 tokens 是两者之和 + cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens + } + const cacheReadTokens = usageData.cache_read_input_tokens || 0 const model = usageData.model || 'unknown' // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) const { accountId: usageAccountId } = usageData + + // 构建 usage 对象以传递给 recordUsage + const usageObject = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + // 如果有详细的缓存创建数据,添加到 usage 对象中 + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usageObject.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + apiKeyService - .recordUsage( - req.apiKey.id, - inputTokens, - outputTokens, - cacheCreateTokens, - cacheReadTokens, - model, - usageAccountId - ) + .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId) .catch((error) => { logger.error('❌ Failed to record stream usage:', error) }) @@ -161,22 +181,42 @@ async function handleMessagesRequest(req, res) { ) { const inputTokens = usageData.input_tokens || 0 const outputTokens = usageData.output_tokens || 0 - const cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + // 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens + let cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + let ephemeral5mTokens = 0 + let ephemeral1hTokens = 0 + + if (usageData.cache_creation && typeof usageData.cache_creation === 'object') { + ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0 + ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0 + // 总的缓存创建 tokens 是两者之和 + cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens + } + const cacheReadTokens = usageData.cache_read_input_tokens || 0 const model = usageData.model || 'unknown' // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) const usageAccountId = usageData.accountId + + // 构建 usage 对象以传递给 recordUsage + const usageObject = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + // 如果有详细的缓存创建数据,添加到 usage 对象中 + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usageObject.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + apiKeyService - .recordUsage( - req.apiKey.id, - inputTokens, - outputTokens, - cacheCreateTokens, - cacheReadTokens, - model, - usageAccountId - ) + .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId) .catch((error) => { logger.error('❌ Failed to record stream usage:', error) }) diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index fcb49e34..9f4033b4 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -455,6 +455,104 @@ class ApiKeyService { } } + // 📊 记录使用情况(新版本,支持详细的缓存类型) + async recordUsageWithDetails(keyId, usageObject, model = 'unknown', accountId = null) { + try { + // 提取 token 数量 + 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 + + // 计算费用(支持详细的缓存类型) + const pricingService = require('./pricingService') + const costInfo = pricingService.calculateCost(usageObject, model) + + // 记录API Key级别的使用统计 + await redis.incrementTokenUsage( + keyId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model + ) + + // 记录费用统计 + if (costInfo.totalCost > 0) { + await redis.incrementDailyCost(keyId, costInfo.totalCost) + logger.database( + `💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}` + ) + + // 记录详细的缓存费用(如果有) + if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) { + logger.database( + `💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed(6)}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}` + ) + } + } 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) { + // 更新最后使用时间 + keyData.lastUsedAt = new Date().toISOString() + await redis.setApiKey(keyId, keyData) + + // 记录账户级别的使用统计(只统计实际处理请求的账户) + if (accountId) { + 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' + ) + } + } + + 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(', ')}`) + } catch (error) { + logger.error('❌ Failed to record usage:', error) + } + } + // 🔐 生成密钥 _generateSecretKey() { return crypto.randomBytes(32).toString('hex') diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index d59eea11..99297787 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -451,6 +451,23 @@ class ClaudeConsoleRelayService { collectedUsageData.cache_read_input_tokens = data.message.usage.cache_read_input_tokens || 0 collectedUsageData.model = data.message.model + + // 检查是否有详细的 cache_creation 对象 + if ( + data.message.usage.cache_creation && + typeof data.message.usage.cache_creation === 'object' + ) { + collectedUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + logger.info( + '📊 Collected detailed cache creation data:', + JSON.stringify(collectedUsageData.cache_creation) + ) + } } if ( diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index b2ac5fec..71ed496b 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -939,6 +939,23 @@ class ClaudeRelayService { data.message.usage.cache_read_input_tokens || 0 collectedUsageData.model = data.message.model + // 检查是否有详细的 cache_creation 对象 + if ( + data.message.usage.cache_creation && + typeof data.message.usage.cache_creation === 'object' + ) { + collectedUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + logger.info( + '📊 Collected detailed cache creation data:', + JSON.stringify(collectedUsageData.cache_creation) + ) + } + logger.info( '📊 Collected input/cache data from message_start:', JSON.stringify(collectedUsageData) diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js index d5e13cf6..a1265295 100644 --- a/src/services/openaiAccountService.js +++ b/src/services/openaiAccountService.js @@ -72,7 +72,7 @@ async function refreshAccessToken(refreshToken, proxy = null) { try { // Codex CLI 的官方 CLIENT_ID const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' - + // 准备请求数据 const requestData = new URLSearchParams({ grant_type: 'refresh_token', @@ -96,15 +96,13 @@ async function refreshAccessToken(refreshToken, proxy = null) { // 配置代理(如果有) if (proxy && proxy.host && proxy.port) { if (proxy.type === 'socks5') { - const proxyAuth = proxy.username && proxy.password - ? `${proxy.username}:${proxy.password}@` - : '' + const proxyAuth = + proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' const socksProxy = `socks5://${proxyAuth}${proxy.host}:${proxy.port}` requestOptions.httpsAgent = new SocksProxyAgent(socksProxy) } else if (proxy.type === 'http' || proxy.type === 'https') { - const proxyAuth = proxy.username && proxy.password - ? `${proxy.username}:${proxy.password}@` - : '' + const proxyAuth = + proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' const httpProxy = `http://${proxyAuth}${proxy.host}:${proxy.port}` requestOptions.httpsAgent = new HttpsProxyAgent(httpProxy) } @@ -115,16 +113,16 @@ async function refreshAccessToken(refreshToken, proxy = null) { if (response.status === 200 && response.data) { const result = response.data - + logger.info('✅ Successfully refreshed OpenAI token') - + // 返回新的 token 信息 return { access_token: result.access_token, id_token: result.id_token, refresh_token: result.refresh_token || refreshToken, // 如果没有返回新的,保留原来的 expires_in: result.expires_in || 3600, - expiry_date: Date.now() + ((result.expires_in || 3600) * 1000) // 计算过期时间 + expiry_date: Date.now() + (result.expires_in || 3600) * 1000 // 计算过期时间 } } else { throw new Error(`Failed to refresh token: ${response.status} ${response.statusText}`) @@ -137,7 +135,9 @@ async function refreshAccessToken(refreshToken, proxy = null) { data: error.response.data, headers: error.response.headers }) - throw new Error(`Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`) + throw new Error( + `Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}` + ) } else if (error.request) { // 请求已发出但没有收到响应 logger.error('OpenAI token refresh no response:', error.message) diff --git a/src/services/pricingService.js b/src/services/pricingService.js index e31960b7..5ded4c0a 100644 --- a/src/services/pricingService.js +++ b/src/services/pricingService.js @@ -20,6 +20,41 @@ class PricingService { this.updateInterval = 24 * 60 * 60 * 1000 // 24小时 this.fileWatcher = null // 文件监听器 this.reloadDebounceTimer = null // 防抖定时器 + + // 硬编码的 1 小时缓存价格(美元/百万 token) + // ephemeral_5m 的价格使用 model_pricing.json 中的 cache_creation_input_token_cost + // ephemeral_1h 的价格需要硬编码 + this.ephemeral1hPricing = { + // Opus 系列: $30/MTok + 'claude-opus-4-1': 0.00003, + 'claude-opus-4-1-20250805': 0.00003, + 'claude-opus-4': 0.00003, + 'claude-opus-4-20250514': 0.00003, + 'claude-3-opus': 0.00003, + 'claude-3-opus-latest': 0.00003, + 'claude-3-opus-20240229': 0.00003, + + // Sonnet 系列: $6/MTok + 'claude-3-5-sonnet': 0.000006, + 'claude-3-5-sonnet-latest': 0.000006, + 'claude-3-5-sonnet-20241022': 0.000006, + 'claude-3-5-sonnet-20240620': 0.000006, + 'claude-3-sonnet': 0.000006, + 'claude-3-sonnet-20240307': 0.000006, + 'claude-sonnet-3': 0.000006, + 'claude-sonnet-3-5': 0.000006, + 'claude-sonnet-3-7': 0.000006, + 'claude-sonnet-4': 0.000006, + + // Haiku 系列: $1.6/MTok + 'claude-3-5-haiku': 0.0000016, + 'claude-3-5-haiku-latest': 0.0000016, + 'claude-3-5-haiku-20241022': 0.0000016, + 'claude-3-haiku': 0.0000016, + 'claude-3-haiku-20240307': 0.0000016, + 'claude-haiku-3': 0.0000016, + 'claude-haiku-3-5': 0.0000016 + } } // 初始化价格服务 @@ -258,6 +293,40 @@ class PricingService { return null } + // 获取 1 小时缓存价格 + getEphemeral1hPricing(modelName) { + if (!modelName) { + return 0 + } + + // 尝试直接匹配 + if (this.ephemeral1hPricing[modelName]) { + return this.ephemeral1hPricing[modelName] + } + + // 处理各种模型名称变体 + const modelLower = modelName.toLowerCase() + + // 检查是否是 Opus 系列 + if (modelLower.includes('opus')) { + return 0.00003 // $30/MTok + } + + // 检查是否是 Sonnet 系列 + if (modelLower.includes('sonnet')) { + return 0.000006 // $6/MTok + } + + // 检查是否是 Haiku 系列 + if (modelLower.includes('haiku')) { + return 0.0000016 // $1.6/MTok + } + + // 默认返回 0(未知模型) + logger.debug(`💰 No 1h cache pricing found for model: ${modelName}`) + return 0 + } + // 计算使用费用 calculateCost(usage, modelName) { const pricing = this.getModelPricing(modelName) @@ -268,6 +337,8 @@ class PricingService { outputCost: 0, cacheCreateCost: 0, cacheReadCost: 0, + ephemeral5mCost: 0, + ephemeral1hCost: 0, totalCost: 0, hasPricing: false } @@ -275,23 +346,52 @@ class PricingService { const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0) const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0) - const cacheCreateCost = - (usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0) const cacheReadCost = (usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0) + // 处理缓存创建费用: + // 1. 如果有详细的 cache_creation 对象,使用它 + // 2. 否则使用总的 cache_creation_input_tokens(向后兼容) + let ephemeral5mCost = 0 + let ephemeral1hCost = 0 + let cacheCreateCost = 0 + + if (usage.cache_creation && typeof usage.cache_creation === 'object') { + // 有详细的缓存创建数据 + const ephemeral5mTokens = usage.cache_creation.ephemeral_5m_input_tokens || 0 + const ephemeral1hTokens = usage.cache_creation.ephemeral_1h_input_tokens || 0 + + // 5分钟缓存使用标准的 cache_creation_input_token_cost + ephemeral5mCost = ephemeral5mTokens * (pricing.cache_creation_input_token_cost || 0) + + // 1小时缓存使用硬编码的价格 + const ephemeral1hPrice = this.getEphemeral1hPricing(modelName) + ephemeral1hCost = ephemeral1hTokens * ephemeral1hPrice + + // 总的缓存创建费用 + cacheCreateCost = ephemeral5mCost + ephemeral1hCost + } else if (usage.cache_creation_input_tokens) { + // 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容) + cacheCreateCost = + (usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0) + ephemeral5mCost = cacheCreateCost + } + return { inputCost, outputCost, cacheCreateCost, cacheReadCost, + ephemeral5mCost, + ephemeral1hCost, totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost, hasPricing: true, pricing: { input: pricing.input_cost_per_token || 0, output: pricing.output_cost_per_token || 0, cacheCreate: pricing.cache_creation_input_token_cost || 0, - cacheRead: pricing.cache_read_input_token_cost || 0 + cacheRead: pricing.cache_read_input_token_cost || 0, + ephemeral1h: this.getEphemeral1hPricing(modelName) } } } diff --git a/src/utils/costCalculator.js b/src/utils/costCalculator.js index 5caab3b7..a0fe6700 100644 --- a/src/utils/costCalculator.js +++ b/src/utils/costCalculator.js @@ -69,6 +69,12 @@ class CostCalculator { * @returns {Object} 费用详情 */ static calculateCost(usage, model = 'unknown') { + // 如果 usage 包含详细的 cache_creation 对象,使用 pricingService 来处理 + if (usage.cache_creation && typeof usage.cache_creation === 'object') { + return pricingService.calculateCost(usage, model) + } + + // 否则使用旧的逻辑(向后兼容) const inputTokens = usage.input_tokens || 0 const outputTokens = usage.output_tokens || 0 const cacheCreateTokens = usage.cache_creation_input_tokens || 0