From 3628bb2b7a9ebc0b0883399345a5a9d70cdc14cd Mon Sep 17 00:00:00 2001 From: shaw Date: Sat, 20 Sep 2025 21:43:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dopenai=E8=BE=93?= =?UTF-8?q?=E5=85=A5token=E8=AE=A1=E7=AE=97=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/test-total-cost-limit.js | 157 ------------------ src/routes/openaiRoutes.js | 22 +-- src/services/openaiResponsesRelayService.js | 56 +++++-- .../src/components/apistats/LimitConfig.vue | 2 +- 4 files changed, 51 insertions(+), 186 deletions(-) delete mode 100644 scripts/test-total-cost-limit.js diff --git a/scripts/test-total-cost-limit.js b/scripts/test-total-cost-limit.js deleted file mode 100644 index b0de081d..00000000 --- a/scripts/test-total-cost-limit.js +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env node - -const path = require('path') -const Module = require('module') - -const originalResolveFilename = Module._resolveFilename -Module._resolveFilename = function resolveConfig(request, parent, isMain, options) { - if (request.endsWith('/config/config')) { - return path.resolve(__dirname, '../config/config.example.js') - } - return originalResolveFilename.call(this, request, parent, isMain, options) -} - -const redis = require('../src/models/redis') -const apiKeyService = require('../src/services/apiKeyService') -const { authenticateApiKey } = require('../src/middleware/auth') - -Module._resolveFilename = originalResolveFilename - -function createMockReq(apiKey) { - return { - headers: { - 'x-api-key': apiKey, - 'user-agent': 'total-cost-limit-test' - }, - query: {}, - body: {}, - ip: '127.0.0.1', - connection: {}, - originalUrl: '/test-total-cost-limit', - once: () => {}, - on: () => {}, - get(header) { - return this.headers[header.toLowerCase()] || '' - } - } -} - -function createMockRes() { - const state = { - status: 200, - body: null - } - - return { - once: () => {}, - on: () => {}, - status(code) { - state.status = code - return this - }, - json(payload) { - state.body = payload - return this - }, - getState() { - return state - } - } -} - -async function runAuth(apiKey) { - const req = createMockReq(apiKey) - const res = createMockRes() - let nextCalled = false - - await authenticateApiKey(req, res, () => { - nextCalled = true - }) - - const result = res.getState() - if (nextCalled && result.status === 200) { - return { status: 200, body: null } - } - return result -} - -async function cleanupKey(keyId) { - const client = redis.getClient() - if (!client) { - return - } - - try { - await redis.deleteApiKey(keyId) - const usageKeys = await client.keys(`usage:*:${keyId}*`) - if (usageKeys.length > 0) { - await client.del(...usageKeys) - } - const costKeys = await client.keys(`usage:cost:*:${keyId}*`) - if (costKeys.length > 0) { - await client.del(...costKeys) - } - await client.del(`usage:${keyId}`) - await client.del(`usage:records:${keyId}`) - await client.del(`usage:cost:total:${keyId}`) - } catch (error) { - console.warn(`Failed to cleanup test key ${keyId}:`, error.message) - } -} - -async function main() { - await redis.connect() - - const testName = `TotalCostLimitTest-${Date.now()}` - const totalCostLimit = 1.0 - const newKey = await apiKeyService.generateApiKey({ - name: testName, - permissions: 'all', - totalCostLimit: totalCostLimit - }) - - const keyId = newKey.id - const { apiKey } = newKey - - console.log(`➕ Created test API key ${keyId} with total cost limit $${totalCostLimit}`) - - let authResult = await runAuth(apiKey) - if (authResult.status !== 200) { - throw new Error(`Expected success before any usage, got status ${authResult.status}`) - } - console.log('✅ Authentication succeeds before consuming quota') - - // 增加总费用 - const client = redis.getClient() - await client.set(`usage:cost:total:${keyId}`, '0.6') - - authResult = await runAuth(apiKey) - if (authResult.status !== 200) { - throw new Error(`Expected success under quota, got status ${authResult.status}`) - } - console.log('✅ Authentication succeeds while still under quota ($0.60)') - - // 继续增加总费用超过限制 - await client.set(`usage:cost:total:${keyId}`, '1.1') - - authResult = await runAuth(apiKey) - if (authResult.status !== 429) { - throw new Error(`Expected 429 after exceeding quota, got status ${authResult.status}`) - } - console.log('✅ Authentication returns 429 after exceeding total cost limit ($1.10)') - - await cleanupKey(keyId) - await redis.disconnect() - - console.log('🎉 Total cost limit test completed successfully') -} - -main().catch(async (error) => { - console.error('❌ Total cost limit test failed:', error) - try { - await redis.disconnect() - } catch (_) { - // Ignore disconnect errors during cleanup - } - process.exitCode = 1 -}) \ No newline at end of file diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index fb5e7343..0d664cd7 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -390,23 +390,24 @@ const handleResponses = async (req, res) => { // 记录使用统计 if (usageData) { - const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 + const totalInputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0 - const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0 const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0 + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) await apiKeyService.recordUsage( apiKeyData.id, - inputTokens, + actualInputTokens, // 传递实际输入(不含缓存) outputTokens, - cacheCreateTokens, + 0, // OpenAI没有cache_creation_tokens cacheReadTokens, actualModel, accountId ) logger.info( - `📊 Recorded OpenAI non-stream usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${actualModel}` + `📊 Recorded OpenAI non-stream usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), Output: ${outputTokens}, Total: ${usageData.total_tokens || totalInputTokens + outputTokens}, Model: ${actualModel}` ) } @@ -506,26 +507,27 @@ const handleResponses = async (req, res) => { // 记录使用统计 if (!usageReported && usageData) { try { - const inputTokens = usageData.input_tokens || 0 + const totalInputTokens = usageData.input_tokens || 0 const outputTokens = usageData.output_tokens || 0 - const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0 const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0 + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) // 使用响应中的真实 model,如果没有则使用请求中的 model,最后回退到默认值 const modelToRecord = actualModel || requestedModel || 'gpt-4' await apiKeyService.recordUsage( apiKeyData.id, - inputTokens, + actualInputTokens, // 传递实际输入(不含缓存) outputTokens, - cacheCreateTokens, + 0, // OpenAI没有cache_creation_tokens cacheReadTokens, modelToRecord, accountId ) logger.info( - `📊 Recorded OpenAI usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${modelToRecord} (actual: ${actualModel}, requested: ${requestedModel})` + `📊 Recorded OpenAI usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), Output: ${outputTokens}, Total: ${usageData.total_tokens || totalInputTokens + outputTokens}, Model: ${modelToRecord} (actual: ${actualModel}, requested: ${requestedModel})` ) usageReported = true } catch (error) { diff --git a/src/services/openaiResponsesRelayService.js b/src/services/openaiResponsesRelayService.js index f71a01b5..3770b699 100644 --- a/src/services/openaiResponsesRelayService.js +++ b/src/services/openaiResponsesRelayService.js @@ -385,28 +385,29 @@ class OpenAIResponsesRelayService { if (usageData) { try { // OpenAI-Responses 使用 input_tokens/output_tokens,标准 OpenAI 使用 prompt_tokens/completion_tokens - const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 + const totalInputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0 // 提取缓存相关的 tokens(如果存在) - const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0 const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0 + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) - const totalTokens = usageData.total_tokens || inputTokens + outputTokens + const totalTokens = usageData.total_tokens || totalInputTokens + outputTokens const modelToRecord = actualModel || requestedModel || 'gpt-4' await apiKeyService.recordUsage( apiKeyData.id, - inputTokens, + actualInputTokens, // 传递实际输入(不含缓存) outputTokens, - cacheCreateTokens, + 0, // OpenAI没有cache_creation_tokens cacheReadTokens, modelToRecord, account.id ) logger.info( - `📊 Recorded usage - Input: ${inputTokens}, Output: ${outputTokens}, CacheRead: ${cacheReadTokens}, CacheCreate: ${cacheCreateTokens}, Total: ${totalTokens}, Model: ${modelToRecord}` + `📊 Recorded usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), Output: ${outputTokens}, Total: ${totalTokens}, Model: ${modelToRecord}` ) // 更新账户的 token 使用统计 @@ -414,9 +415,18 @@ class OpenAIResponsesRelayService { // 更新账户使用额度(如果设置了额度限制) if (parseFloat(account.dailyQuota) > 0) { - // 估算费用(根据模型和token数量) - const estimatedCost = this._estimateCost(modelToRecord, inputTokens, outputTokens) - await openaiResponsesAccountService.updateUsageQuota(account.id, estimatedCost) + // 使用CostCalculator正确计算费用(考虑缓存token的不同价格) + const CostCalculator = require('../utils/costCalculator') + const costInfo = CostCalculator.calculateCost( + { + input_tokens: actualInputTokens, // 实际输入(不含缓存) + output_tokens: outputTokens, + cache_creation_input_tokens: 0, // OpenAI没有cache_creation + cache_read_input_tokens: cacheReadTokens + }, + modelToRecord + ) + await openaiResponsesAccountService.updateUsageQuota(account.id, costInfo.costs.total) } } catch (error) { logger.error('Failed to record usage:', error) @@ -502,27 +512,28 @@ class OpenAIResponsesRelayService { if (usageData) { try { // OpenAI-Responses 使用 input_tokens/output_tokens,标准 OpenAI 使用 prompt_tokens/completion_tokens - const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 + const totalInputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0 // 提取缓存相关的 tokens(如果存在) - const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0 const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0 + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) - const totalTokens = usageData.total_tokens || inputTokens + outputTokens + const totalTokens = usageData.total_tokens || totalInputTokens + outputTokens await apiKeyService.recordUsage( apiKeyData.id, - inputTokens, + actualInputTokens, // 传递实际输入(不含缓存) outputTokens, - cacheCreateTokens, + 0, // OpenAI没有cache_creation_tokens cacheReadTokens, actualModel, account.id ) logger.info( - `📊 Recorded non-stream usage - Input: ${inputTokens}, Output: ${outputTokens}, CacheRead: ${cacheReadTokens}, CacheCreate: ${cacheCreateTokens}, Total: ${totalTokens}, Model: ${actualModel}` + `📊 Recorded non-stream usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), Output: ${outputTokens}, Total: ${totalTokens}, Model: ${actualModel}` ) // 更新账户的 token 使用统计 @@ -530,9 +541,18 @@ class OpenAIResponsesRelayService { // 更新账户使用额度(如果设置了额度限制) if (parseFloat(account.dailyQuota) > 0) { - // 估算费用(根据模型和token数量) - const estimatedCost = this._estimateCost(actualModel, inputTokens, outputTokens) - await openaiResponsesAccountService.updateUsageQuota(account.id, estimatedCost) + // 使用CostCalculator正确计算费用(考虑缓存token的不同价格) + const CostCalculator = require('../utils/costCalculator') + const costInfo = CostCalculator.calculateCost( + { + input_tokens: actualInputTokens, // 实际输入(不含缓存) + output_tokens: outputTokens, + cache_creation_input_tokens: 0, // OpenAI没有cache_creation + cache_read_input_tokens: cacheReadTokens + }, + actualModel + ) + await openaiResponsesAccountService.updateUsageQuota(account.id, costInfo.costs.total) } } catch (error) { logger.error('Failed to record usage:', error) diff --git a/web/admin-spa/src/components/apistats/LimitConfig.vue b/web/admin-spa/src/components/apistats/LimitConfig.vue index 44e0b554..bddf547a 100644 --- a/web/admin-spa/src/components/apistats/LimitConfig.vue +++ b/web/admin-spa/src/components/apistats/LimitConfig.vue @@ -215,7 +215,7 @@ class="text-orange-600" > - 限制 {{ statsData.restrictions.allowedClients.length }} 个客户端 + 限 {{ statsData.restrictions.allowedClients.length }} 种客户端使用