From 57e75cd526ac82b7c189539748296af773cbcf29 Mon Sep 17 00:00:00 2001 From: sususu98 Date: Fri, 3 Oct 2025 22:34:31 +0800 Subject: [PATCH 01/10] =?UTF-8?q?fix:=20=E7=94=A8=E6=88=B7=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=8E=A5=E5=8F=A3=E5=9C=A8=E5=BC=80=E5=8F=91=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=20404=20=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 user.js 中未使用 API_PREFIX 导致的路径问题。 现在开发环境正确使用 /webapi 前缀进行代理转发。 --- web/admin-spa/src/stores/user.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/admin-spa/src/stores/user.js b/web/admin-spa/src/stores/user.js index db6a4dfc..7a4ca30c 100644 --- a/web/admin-spa/src/stores/user.js +++ b/web/admin-spa/src/stores/user.js @@ -1,8 +1,9 @@ import { defineStore } from 'pinia' import axios from 'axios' import { showToast } from '@/utils/toast' +import { API_PREFIX } from '@/config/api' -const API_BASE = '/users' +const API_BASE = `${API_PREFIX}/users` export const useUserStore = defineStore('user', { state: () => ({ From 4d380e03f10b782d632c3c7b76cfa21fd0119aa0 Mon Sep 17 00:00:00 2001 From: rxchi1d Date: Sat, 4 Oct 2025 15:22:00 +0800 Subject: [PATCH 02/10] docs: update Cherry Studio integration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update Claude Sonnet model ID to claude-sonnet-4-5-20250929 (v4.5) and fix API endpoint formats for Cherry Studio compatibility. Remove trailing slashes from all endpoint URLs to allow Cherry Studio to automatically append v1 version parameter. Add important notes about Cherry Studio URL format requirements, explaining that URLs without trailing slashes and URLs with /v1/ suffix are equivalent, while URLs with single trailing slash will ignore the v1 version. Changes: - Update Claude Sonnet model from claude-sonnet-4-20250514 to claude-sonnet-4-5-20250929 - Fix Claude endpoint: /claude/ → /claude - Fix Gemini endpoint: /gemini/ → /gemini - Fix Codex endpoint: /openai/ → /openai - Add URL format explanation and best practices section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 41a84644..2bcf7194 100644 --- a/README.md +++ b/README.md @@ -473,23 +473,23 @@ Cherry Studio支持多种AI服务的接入,下面是不同账号类型的详 ``` # API地址 -http://你的服务器:3000/claude/ +http://你的服务器:3000/claude # 模型ID示例 -claude-sonnet-4-20250514 # Claude Sonnet 4 +claude-sonnet-4-5-20250929 # Claude Sonnet 4.5 claude-opus-4-20250514 # Claude Opus 4 ``` 配置步骤: - 供应商类型选择"Anthropic" -- API地址填入:`http://你的服务器:3000/claude/` +- API地址填入:`http://你的服务器:3000/claude` - API Key填入:后台创建的API密钥(cr_开头) **2. Gemini账号接入:** ``` # API地址 -http://你的服务器:3000/gemini/ +http://你的服务器:3000/gemini # 模型ID示例 gemini-2.5-pro # Gemini 2.5 Pro @@ -497,14 +497,14 @@ gemini-2.5-pro # Gemini 2.5 Pro 配置步骤: - 供应商类型选择"Gemini" -- API地址填入:`http://你的服务器:3000/gemini/` +- API地址填入:`http://你的服务器:3000/gemini` - API Key填入:后台创建的API密钥(cr_开头) **3. Codex接入:** ``` # API地址 -http://你的服务器:3000/openai/ +http://你的服务器:3000/openai # 模型ID(固定) gpt-5 # Codex使用固定模型ID @@ -512,10 +512,17 @@ gpt-5 # Codex使用固定模型ID 配置步骤: - 供应商类型选择"Openai-Response" -- API地址填入:`http://你的服务器:3000/openai/` +- API地址填入:`http://你的服务器:3000/openai` - API Key填入:后台创建的API密钥(cr_开头) - **重要**:Codex只支持Openai-Response标准 +**Cherry Studio 地址格式重要说明:** + +- ✅ **推荐格式**:`http://你的服务器:3000/claude`(不加结尾 `/`,让 Cherry Studio 自动加上 v1) +- ✅ **等效格式**:`http://你的服务器:3000/claude/v1/`(手动指定 v1 并加结尾 `/`) +- 💡 **说明**:这两种格式在 Cherry Studio 中是完全等效的 +- ❌ **错误格式**:`http://你的服务器:3000/claude/`(单独的 `/` 结尾会被 Cherry Studio 忽略 v1 版本) + #### 其他第三方工具接入 **接入要点:** From 454f366c5059aa88a836bc6043c3f74e526113ca Mon Sep 17 00:00:00 2001 From: litongtongxue Date: Tue, 7 Oct 2025 11:27:29 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=97=A5?= =?UTF-8?q?=E5=9D=87=E8=B4=B9=E7=94=A8=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题描述: - 之前的日均费用计算是基于固定的30天窗口,而不是账户实际使用的天数 - 这导致新创建的账户显示的日均费用不准确 修复方案: - 获取账户的创建时间(createdAt字段) - 计算从账户创建到当前时间的实际天数 - 使用实际天数来计算日均费用(30天总费用 / 实际天数) - 在前端显示实际使用天数,让用户了解计算基准 修改内容: - 后端:在 /accounts/:accountId/usage-history 端点中添加实际天数计算逻辑 - 前端:在详情弹窗中显示基于实际使用天数的提示信息 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/routes/admin.js | 51 +++++++++++++++++-- .../accounts/AccountUsageDetailModal.vue | 7 ++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index cd79b76f..f55d092d 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -4162,6 +4162,36 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, gemini: 'gemini-1.5-flash' } + // 获取账户信息以获取创建时间 + let accountData = null + let accountCreatedAt = null + + try { + switch (platform) { + case 'claude': + accountData = await claudeAccountService.getAccount(accountId) + break + case 'claude-console': + accountData = await claudeConsoleAccountService.getAccount(accountId) + break + case 'openai': + accountData = await openaiAccountService.getAccount(accountId) + break + case 'openai-responses': + accountData = await openaiResponsesAccountService.getAccount(accountId) + break + case 'gemini': + accountData = await geminiAccountService.getAccount(accountId) + break + } + + if (accountData && accountData.createdAt) { + accountCreatedAt = new Date(accountData.createdAt) + } + } catch (error) { + logger.warn(`Failed to get account data for avgDailyCost calculation: ${error.message}`) + } + const client = redis.getClientSafe() const fallbackModel = fallbackModelMap[platform] || 'unknown' const daysCount = Math.min(Math.max(parseInt(days, 10) || 30, 1), 60) @@ -4281,9 +4311,22 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, }) } - const avgDailyCost = daysCount > 0 ? totalCost / daysCount : 0 - const avgDailyRequests = daysCount > 0 ? totalRequests / daysCount : 0 - const avgDailyTokens = daysCount > 0 ? totalTokens / daysCount : 0 + // 计算实际使用天数(从账户创建到现在) + let actualDaysForAvg = daysCount + if (accountCreatedAt) { + const now = new Date() + const diffTime = Math.abs(now - accountCreatedAt) + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + // 使用实际使用天数,但不超过请求的天数范围 + actualDaysForAvg = Math.min(diffDays, daysCount) + // 至少为1天,避免除零 + actualDaysForAvg = Math.max(actualDaysForAvg, 1) + } + + // 使用实际天数计算日均值 + const avgDailyCost = actualDaysForAvg > 0 ? totalCost / actualDaysForAvg : 0 + const avgDailyRequests = actualDaysForAvg > 0 ? totalRequests / actualDaysForAvg : 0 + const avgDailyTokens = actualDaysForAvg > 0 ? totalTokens / actualDaysForAvg : 0 const todayData = history.length > 0 ? history[history.length - 1] : null @@ -4293,6 +4336,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, history, summary: { days: daysCount, + actualDaysUsed: actualDaysForAvg, // 实际使用的天数(用于计算日均值) + accountCreatedAt: accountCreatedAt ? accountCreatedAt.toISOString() : null, totalCost, totalCostFormatted: CostCalculator.formatCost(totalCost), totalRequests, diff --git a/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue b/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue index 58c2e75a..0b136972 100644 --- a/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue +++ b/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue @@ -38,6 +38,9 @@

近 {{ summary?.days || 30 }} 天内的费用与请求趋势 + + (日均基于实际使用 {{ summary.actualDaysUsed }} 天) +

@@ -443,7 +446,9 @@ const primaryMetrics = computed(() => [ key: 'avgCost', label: '日均费用', value: props.summary?.avgDailyCostFormatted || formatCost(props.summary?.avgDailyCost || 0), - subtitle: '平均每日成本', + subtitle: props.summary?.actualDaysUsed && props.summary?.actualDaysUsed < props.summary?.days + ? `基于 ${props.summary.actualDaysUsed} 天实际使用` + : '平均每日成本', icon: 'fa-wave-square', iconClass: 'text-purple-500' }, From 52af60b3c9636f17695e76237fdbe4504f9f0ad0 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 7 Oct 2025 14:00:09 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20=E9=80=82=E9=85=8DClaude=20agent-s?= =?UTF-8?q?dk=E8=BD=AC=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/contents.js | 5 +++++ src/validators/clients/claudeCodeValidator.js | 11 +---------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/utils/contents.js b/src/utils/contents.js index 499fd708..ccf2828e 100644 --- a/src/utils/contents.js +++ b/src/utils/contents.js @@ -74,6 +74,11 @@ const PROMPT_DEFINITIONS = { title: 'Claude Agent SDK System Prompt', text: "You are a Claude agent, built on Anthropic's Claude Agent SDK." }, + claudeOtherSystemPrompt4: { + category: 'system', + title: 'Claude Code Compact System Prompt Agent SDK2', + text: "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK." + }, claudeOtherSystemPromptCompact: { category: 'system', title: 'Claude Code Compact System Prompt', diff --git a/src/validators/clients/claudeCodeValidator.js b/src/validators/clients/claudeCodeValidator.js index 04e04ef8..f012030b 100644 --- a/src/validators/clients/claudeCodeValidator.js +++ b/src/validators/clients/claudeCodeValidator.js @@ -74,16 +74,7 @@ class ClaudeCodeValidator { const userAgent = req.headers['user-agent'] || '' const path = req.path || '' - // 1. 先检查是否是 Claude Code 的 User-Agent - // 支持的格式: - // - claude-cli/1.0.86 (external, cli) - 原有 CLI 格式 - // - claude-cli/2.0.0 (external, claude-vscode) - VSCode 插件格式 - // - claude-cli/x.x.x (external, sdk-py) - Python SDK 格式 - // - claude-cli/x.x.x (external, sdk-js) - JavaScript SDK 格式 - // - 其他 (external, claude-xxx) 或 (external, sdk-xxx) 格式 - - const claudeCodePattern = - /^claude-cli\/[\d.]+(?:[-\w]*)?\s+\(external,\s*(?:cli|claude-[\w-]+|sdk-[\w-]+)\)$/i + const claudeCodePattern = /^claude-cli\/\d+\.\d+\.\d+/i; if (!claudeCodePattern.test(userAgent)) { // 不是 Claude Code 的请求,此验证器不处理 From 1777309218799de4c7582d1aeebf727cb48ce822 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Oct 2025 06:09:40 +0000 Subject: [PATCH 05/10] chore: sync VERSION file with release v1.1.163 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 38dfb5fe..19cc93ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.162 +1.1.163 From 88429e1a24091df44cc9782873b81155cedadf7c Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 7 Oct 2025 14:37:28 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96cache=20control?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/claudeRelayService.js | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 595b52b2..71e567e1 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -39,17 +39,8 @@ class ClaudeRelayService { } // 🔍 判断是否是真实的 Claude Code 请求 - isRealClaudeCodeRequest(requestBody, clientHeaders) { - // 使用 claudeCodeValidator 来进行完整的验证 - // 注意:claudeCodeValidator.validate() 需要一个完整的 req 对象 - // 我们需要构造一个最小化的 req 对象来满足验证器的需求 - const mockReq = { - headers: clientHeaders || {}, - body: requestBody, - path: '/api/v1/messages' - } - - return ClaudeCodeValidator.validate(mockReq) + isRealClaudeCodeRequest(requestBody) { + return ClaudeCodeValidator.hasClaudeCodeSystemPrompt(requestBody) } // 🚀 转发请求到Claude API @@ -397,7 +388,7 @@ class ClaudeRelayService { if ( clientHeaders && Object.keys(clientHeaders).length > 0 && - this.isRealClaudeCodeRequest(requestBody, clientHeaders) + this.isRealClaudeCodeRequest(requestBody) ) { await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) } @@ -459,7 +450,7 @@ class ClaudeRelayService { this._stripTtlFromCacheControl(processedBody) // 判断是否是真实的 Claude Code 请求 - const isRealClaudeCode = this.isRealClaudeCodeRequest(processedBody, clientHeaders) + const isRealClaudeCode = this.isRealClaudeCodeRequest(processedBody) // 如果不是真实的 Claude Code 请求,需要设置 Claude Code 系统提示词 if (!isRealClaudeCode) { @@ -760,7 +751,7 @@ class ClaudeRelayService { const filteredHeaders = this._filterClientHeaders(clientHeaders) // 判断是否是真实的 Claude Code 请求 - const isRealClaudeCode = this.isRealClaudeCodeRequest(body, clientHeaders) + const isRealClaudeCode = this.isRealClaudeCodeRequest(body) // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers const finalHeaders = { ...filteredHeaders } @@ -1065,7 +1056,7 @@ class ClaudeRelayService { const filteredHeaders = this._filterClientHeaders(clientHeaders) // 判断是否是真实的 Claude Code 请求 - const isRealClaudeCode = this.isRealClaudeCodeRequest(body, clientHeaders) + const isRealClaudeCode = this.isRealClaudeCodeRequest(body) // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers const finalHeaders = { ...filteredHeaders } @@ -1595,7 +1586,7 @@ class ClaudeRelayService { if ( clientHeaders && Object.keys(clientHeaders).length > 0 && - this.isRealClaudeCodeRequest(body, clientHeaders) + this.isRealClaudeCodeRequest(body) ) { await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) } From cac1b90d2357075a8a7e5c011e37c4bd35039261 Mon Sep 17 00:00:00 2001 From: litongtongxue Date: Tue, 7 Oct 2025 14:33:53 +0800 Subject: [PATCH 07/10] =?UTF-8?q?chore:=20=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=AC=A6=E5=90=88=20Prettier=20=E8=A7=84?= =?UTF-8?q?=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/accounts/AccountUsageDetailModal.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue b/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue index 0b136972..8b9fbf33 100644 --- a/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue +++ b/web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue @@ -446,9 +446,10 @@ const primaryMetrics = computed(() => [ key: 'avgCost', label: '日均费用', value: props.summary?.avgDailyCostFormatted || formatCost(props.summary?.avgDailyCost || 0), - subtitle: props.summary?.actualDaysUsed && props.summary?.actualDaysUsed < props.summary?.days - ? `基于 ${props.summary.actualDaysUsed} 天实际使用` - : '平均每日成本', + subtitle: + props.summary?.actualDaysUsed && props.summary?.actualDaysUsed < props.summary?.days + ? `基于 ${props.summary.actualDaysUsed} 天实际使用` + : '平均每日成本', icon: 'fa-wave-square', iconClass: 'text-purple-500' }, From 6e770146fdfcdef57627ddfb81138e50b3950213 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 7 Oct 2025 15:14:08 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96cache=20control?= =?UTF-8?q?=E9=97=AE=E9=A2=982?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/claudeRelayService.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 71e567e1..8d8909b8 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -142,8 +142,7 @@ class ClaudeRelayService { // 获取有效的访问token const accessToken = await claudeAccountService.getValidAccessToken(accountId) - // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) - const processedBody = this._processRequestBody(requestBody, clientHeaders, account) + const processedBody = this._processRequestBody(requestBody, account) // 获取代理配置 const proxyAgent = await this._getProxyAgent(accountId) @@ -435,7 +434,7 @@ class ClaudeRelayService { } // 🔄 处理请求体 - _processRequestBody(body, clientHeaders = {}, account = null) { + _processRequestBody(body, account = null) { if (!body) { return body } @@ -998,8 +997,7 @@ class ClaudeRelayService { // 获取有效的访问token const accessToken = await claudeAccountService.getValidAccessToken(accountId) - // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) - const processedBody = this._processRequestBody(requestBody, clientHeaders, account) + const processedBody = this._processRequestBody(requestBody, account) // 获取代理配置 const proxyAgent = await this._getProxyAgent(accountId) From eb304c7e7018d18a1c7decedd45fa6b2d270595e Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 8 Oct 2025 08:36:43 +0800 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20openai=E8=BD=AC=E5=8F=91=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0apikey=E9=80=9F=E7=8E=87=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/api.js | 214 ++++++++++++------------------- src/routes/geminiRoutes.js | 50 ++++++++ src/routes/openaiClaudeRoutes.js | 58 +++++++++ src/routes/openaiRoutes.js | 50 ++++++++ src/utils/rateLimitHelper.js | 71 ++++++++++ 5 files changed, 308 insertions(+), 135 deletions(-) create mode 100644 src/utils/rateLimitHelper.js diff --git a/src/routes/api.js b/src/routes/api.js index d4b572d4..f784cae6 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -6,15 +6,37 @@ const ccrRelayService = require('../services/ccrRelayService') const bedrockAccountService = require('../services/bedrockAccountService') const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') const apiKeyService = require('../services/apiKeyService') -const pricingService = require('../services/pricingService') const { authenticateApiKey } = require('../middleware/auth') const logger = require('../utils/logger') -const redis = require('../models/redis') const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper') const sessionHelper = require('../utils/sessionHelper') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const router = express.Router() +function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { + if (!rateLimitInfo) { + return Promise.resolve({ totalTokens: 0, totalCost: 0 }) + } + + const label = context ? ` (${context})` : '' + + return updateRateLimitCounters(rateLimitInfo, usageSummary, model) + .then(({ totalTokens, totalCost }) => { + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) + } + return { totalTokens, totalCost } + }) + .catch((error) => { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + return { totalTokens: 0, totalCost: 0 } + }) +} + // 🔧 共享的消息处理函数 async function handleMessagesRequest(req, res) { try { @@ -191,35 +213,17 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to record stream usage:', error) }) - // 更新时间窗口内的token计数和费用 - if (req.rateLimitInfo) { - const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens - - // 更新Token计数(向后兼容) - redis - .getClient() - .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) - .catch((error) => { - logger.error('❌ Failed to update rate limit token count:', error) - }) - logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) - - // 计算并更新费用计数(新功能) - if (req.rateLimitInfo.costCountKey) { - const costInfo = pricingService.calculateCost(usageData, model) - if (costInfo.totalCost > 0) { - redis - .getClient() - .incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost) - .catch((error) => { - logger.error('❌ Failed to update rate limit cost count:', error) - }) - logger.api( - `💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}` - ) - } - } - } + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'claude-stream' + ) usageDataCaptured = true logger.api( @@ -300,35 +304,17 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to record stream usage:', error) }) - // 更新时间窗口内的token计数和费用 - if (req.rateLimitInfo) { - const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens - - // 更新Token计数(向后兼容) - redis - .getClient() - .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) - .catch((error) => { - logger.error('❌ Failed to update rate limit token count:', error) - }) - logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) - - // 计算并更新费用计数(新功能) - if (req.rateLimitInfo.costCountKey) { - const costInfo = pricingService.calculateCost(usageData, model) - if (costInfo.totalCost > 0) { - redis - .getClient() - .incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost) - .catch((error) => { - logger.error('❌ Failed to update rate limit cost count:', error) - }) - logger.api( - `💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}` - ) - } - } - } + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'claude-console-stream' + ) usageDataCaptured = true logger.api( @@ -368,33 +354,17 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to record Bedrock stream usage:', error) }) - // 更新时间窗口内的token计数和费用 - if (req.rateLimitInfo) { - const totalTokens = inputTokens + outputTokens - - // 更新Token计数(向后兼容) - redis - .getClient() - .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) - .catch((error) => { - logger.error('❌ Failed to update rate limit token count:', error) - }) - logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) - - // 计算并更新费用计数(新功能) - if (req.rateLimitInfo.costCountKey) { - const costInfo = pricingService.calculateCost(result.usage, result.model) - if (costInfo.totalCost > 0) { - redis - .getClient() - .incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost) - .catch((error) => { - logger.error('❌ Failed to update rate limit cost count:', error) - }) - logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`) - } - } - } + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }, + result.model, + 'bedrock-stream' + ) usageDataCaptured = true logger.api( @@ -469,35 +439,17 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to record CCR stream usage:', error) }) - // 更新时间窗口内的token计数和费用 - if (req.rateLimitInfo) { - const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens - - // 更新Token计数(向后兼容) - redis - .getClient() - .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) - .catch((error) => { - logger.error('❌ Failed to update rate limit token count:', error) - }) - logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) - - // 计算并更新费用计数(新功能) - if (req.rateLimitInfo.costCountKey) { - const costInfo = pricingService.calculateCost(usageData, model) - if (costInfo.totalCost > 0) { - redis - .getClient() - .incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost) - .catch((error) => { - logger.error('❌ Failed to update rate limit cost count:', error) - }) - logger.api( - `💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}` - ) - } - } - } + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'ccr-stream' + ) usageDataCaptured = true logger.api( @@ -685,25 +637,17 @@ async function handleMessagesRequest(req, res) { responseAccountId ) - // 更新时间窗口内的token计数和费用 - if (req.rateLimitInfo) { - const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens - - // 更新Token计数(向后兼容) - await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens) - logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) - - // 计算并更新费用计数(新功能) - if (req.rateLimitInfo.costCountKey) { - const costInfo = pricingService.calculateCost(jsonData.usage, model) - if (costInfo.totalCost > 0) { - await redis - .getClient() - .incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost) - logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`) - } - } - } + await queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'claude-non-stream' + ) usageRecorded = true logger.api( diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index df447fb7..532979cf 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -8,6 +8,7 @@ const crypto = require('crypto') const sessionHelper = require('../utils/sessionHelper') const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const apiKeyService = require('../services/apiKeyService') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') // const { OAuth2Client } = require('google-auth-library'); // OAuth2Client is not used in this file // 生成会话哈希 @@ -49,6 +50,31 @@ function ensureGeminiPermission(req, res) { return false } +async function applyRateLimitTracking(req, usageSummary, model, context = '') { + if (!req.rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + req.rateLimitInfo, + usageSummary, + model + ) + + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) + } + } catch (error) { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + } +} + // Gemini 消息处理端点 router.post('/messages', authenticateApiKey, async (req, res) => { const startTime = Date.now() @@ -679,6 +705,18 @@ async function handleGenerateContent(req, res) { logger.info( `📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}` ) + + await applyRateLimitTracking( + req, + { + inputTokens: usage.promptTokenCount || 0, + outputTokens: usage.candidatesTokenCount || 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }, + model, + 'gemini-non-stream' + ) } catch (error) { logger.error('Failed to record Gemini usage:', error) } @@ -935,6 +973,18 @@ async function handleStreamGenerateContent(req, res) { logger.info( `📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}` ) + + await applyRateLimitTracking( + req, + { + inputTokens: totalUsage.promptTokenCount || 0, + outputTokens: totalUsage.candidatesTokenCount || 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }, + model, + 'gemini-stream' + ) } catch (error) { logger.error('Failed to record Gemini usage:', error) } diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js index e1514d5b..f5db5665 100644 --- a/src/routes/openaiClaudeRoutes.js +++ b/src/routes/openaiClaudeRoutes.js @@ -15,6 +15,7 @@ const apiKeyService = require('../services/apiKeyService') const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') const sessionHelper = require('../utils/sessionHelper') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') // 加载模型定价数据 let modelPricingData = {} @@ -33,6 +34,27 @@ function checkPermissions(apiKeyData, requiredPermission = 'claude') { return permissions === 'all' || permissions === requiredPermission } +function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { + if (!rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + updateRateLimitCounters(rateLimitInfo, usageSummary, model) + .then(({ totalTokens, totalCost }) => { + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) + } + }) + .catch((error) => { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + }) +} + // 📋 OpenAI 兼容的模型列表端点 router.get('/v1/models', authenticateApiKey, async (req, res) => { try { @@ -263,6 +285,12 @@ async function handleChatCompletion(req, res, apiKeyData) { // 记录使用统计 if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) { const model = usage.model || claudeRequest.model + const cacheCreateTokens = + (usage.cache_creation && typeof usage.cache_creation === 'object' + ? (usage.cache_creation.ephemeral_5m_input_tokens || 0) + + (usage.cache_creation.ephemeral_1h_input_tokens || 0) + : usage.cache_creation_input_tokens || 0) || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 // 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据 apiKeyService @@ -275,6 +303,18 @@ async function handleChatCompletion(req, res, apiKeyData) { .catch((error) => { logger.error('❌ Failed to record usage:', error) }) + + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'openai-claude-stream' + ) } }, // 流转换器 @@ -334,6 +374,12 @@ async function handleChatCompletion(req, res, apiKeyData) { // 记录使用统计 if (claudeData.usage) { const { usage } = claudeData + const cacheCreateTokens = + (usage.cache_creation && typeof usage.cache_creation === 'object' + ? (usage.cache_creation.ephemeral_5m_input_tokens || 0) + + (usage.cache_creation.ephemeral_1h_input_tokens || 0) + : usage.cache_creation_input_tokens || 0) || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 // 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据 apiKeyService .recordUsageWithDetails( @@ -345,6 +391,18 @@ async function handleChatCompletion(req, res, apiKeyData) { .catch((error) => { logger.error('❌ Failed to record usage:', error) }) + + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens, + cacheReadTokens + }, + claudeRequest.model, + 'openai-claude-non-stream' + ) } // 返回 OpenAI 格式响应 diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index 604d8c35..13776c8d 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -11,6 +11,7 @@ const openaiResponsesRelayService = require('../services/openaiResponsesRelaySer const apiKeyService = require('../services/apiKeyService') const crypto = require('crypto') const ProxyHelper = require('../utils/proxyHelper') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') // 创建代理 Agent(使用统一的代理工具) function createProxyAgent(proxy) { @@ -67,6 +68,31 @@ function extractCodexUsageHeaders(headers) { return hasData ? snapshot : null } +async function applyRateLimitTracking(req, usageSummary, model, context = '') { + if (!req.rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + req.rateLimitInfo, + usageSummary, + model + ) + + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) + } + } catch (error) { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + } +} + // 使用统一调度器选择 OpenAI 账户 async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = null) { try { @@ -579,6 +605,18 @@ const handleResponses = async (req, res) => { logger.info( `📊 Recorded OpenAI non-stream usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), Output: ${outputTokens}, Total: ${usageData.total_tokens || totalInputTokens + outputTokens}, Model: ${actualModel}` ) + + await applyRateLimitTracking( + req, + { + inputTokens: actualInputTokens, + outputTokens, + cacheCreateTokens: 0, + cacheReadTokens + }, + actualModel, + 'openai-non-stream' + ) } // 返回响应 @@ -700,6 +738,18 @@ const handleResponses = async (req, res) => { `📊 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 + + await applyRateLimitTracking( + req, + { + inputTokens: actualInputTokens, + outputTokens, + cacheCreateTokens: 0, + cacheReadTokens + }, + modelToRecord, + 'openai-stream' + ) } catch (error) { logger.error('Failed to record OpenAI usage:', error) } diff --git a/src/utils/rateLimitHelper.js b/src/utils/rateLimitHelper.js new file mode 100644 index 00000000..38c38568 --- /dev/null +++ b/src/utils/rateLimitHelper.js @@ -0,0 +1,71 @@ +const redis = require('../models/redis') +const pricingService = require('../services/pricingService') +const CostCalculator = require('./costCalculator') + +function toNumber(value) { + const num = Number(value) + return Number.isFinite(num) ? num : 0 +} + +async function updateRateLimitCounters(rateLimitInfo, usageSummary, model) { + if (!rateLimitInfo) { + return { totalTokens: 0, totalCost: 0 } + } + + const client = redis.getClient() + if (!client) { + throw new Error('Redis 未连接,无法更新限流计数') + } + + const inputTokens = toNumber(usageSummary.inputTokens) + const outputTokens = toNumber(usageSummary.outputTokens) + const cacheCreateTokens = toNumber(usageSummary.cacheCreateTokens) + const cacheReadTokens = toNumber(usageSummary.cacheReadTokens) + + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (totalTokens > 0 && rateLimitInfo.tokenCountKey) { + await client.incrby(rateLimitInfo.tokenCountKey, Math.round(totalTokens)) + } + + let totalCost = 0 + const usagePayload = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + try { + const costInfo = pricingService.calculateCost(usagePayload, model) + const { totalCost: calculatedCost } = costInfo || {} + if (typeof calculatedCost === 'number') { + totalCost = calculatedCost + } + } catch (error) { + // 忽略此处错误,后续使用备用计算 + totalCost = 0 + } + + if (totalCost === 0) { + try { + const fallback = CostCalculator.calculateCost(usagePayload, model) + const { costs } = fallback || {} + if (costs && typeof costs.total === 'number') { + totalCost = costs.total + } + } catch (error) { + totalCost = 0 + } + } + + if (totalCost > 0 && rateLimitInfo.costCountKey) { + await client.incrbyfloat(rateLimitInfo.costCountKey, totalCost) + } + + return { totalTokens, totalCost } +} + +module.exports = { + updateRateLimitCounters +} From cffd023239dee1615a80a3dcc1aef30aaf9b9b76 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Oct 2025 01:38:07 +0000 Subject: [PATCH 10/10] chore: sync VERSION file with release v1.1.164 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 19cc93ed..299460d9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.163 +1.1.164