From 28721982598230b02150f48aabd6ee05c3f85776 Mon Sep 17 00:00:00 2001 From: shaw Date: Sat, 4 Oct 2025 11:31:21 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20claude=E7=BB=91=E5=AE=9A=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=93=8D=E5=BA=94=E9=99=90=E6=B5=81=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/api.js | 57 +++++++-- src/routes/openaiClaudeRoutes.js | 22 +++- src/services/claudeRelayService.js | 171 ++++++++++++++++++++----- src/services/unifiedClaudeScheduler.js | 61 ++++++--- 4 files changed, 245 insertions(+), 66 deletions(-) diff --git a/src/routes/api.js b/src/routes/api.js index 8d91c791..d4b572d4 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -102,11 +102,32 @@ async function handleMessagesRequest(req, res) { // 使用统一调度选择账号(传递请求的模型) const requestedModel = req.body.model - const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( - req.apiKey, - sessionHash, - requestedModel - ) + let accountId + let accountType + try { + const selection = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + ;({ accountId, accountType } = selection) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = claudeRelayService._buildStandardRateLimitMessage( + error.rateLimitEndAt + ) + res.status(403) + res.setHeader('Content-Type', 'application/json') + res.end( + JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }) + ) + return + } + throw error + } // 根据账号类型选择对应的转发服务并调用 if (accountType === 'claude-official') { @@ -513,11 +534,27 @@ async function handleMessagesRequest(req, res) { // 使用统一调度选择账号(传递请求的模型) const requestedModel = req.body.model - const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( - req.apiKey, - sessionHash, - requestedModel - ) + let accountId + let accountType + try { + const selection = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + ;({ accountId, accountType } = selection) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = claudeRelayService._buildStandardRateLimitMessage( + error.rateLimitEndAt + ) + return res.status(403).json({ + error: 'upstream_rate_limited', + message: limitMessage + }) + } + throw error + } // 根据账号类型选择对应的转发服务 let response diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js index b2c43ed9..e1514d5b 100644 --- a/src/routes/openaiClaudeRoutes.js +++ b/src/routes/openaiClaudeRoutes.js @@ -206,11 +206,23 @@ async function handleChatCompletion(req, res, apiKeyData) { const sessionHash = sessionHelper.generateSessionHash(claudeRequest) // 选择可用的Claude账户 - const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( - apiKeyData, - sessionHash, - claudeRequest.model - ) + let accountSelection + try { + accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + claudeRequest.model + ) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = claudeRelayService._buildStandardRateLimitMessage(error.rateLimitEndAt) + return res.status(403).json({ + error: 'upstream_rate_limited', + message: limitMessage + }) + } + throw error + } const { accountId } = accountSelection // 获取该账号存储的 Claude Code headers diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 5c7ac945..595b52b2 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -22,6 +22,14 @@ class ClaudeRelayService { this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." } + _buildStandardRateLimitMessage(resetTime) { + if (!resetTime) { + return '此专属账号已触发 Anthropic 限流控制。' + } + const formattedReset = formatDateWithTimezone(resetTime) + return `此专属账号已触发 Anthropic 限流控制,将于 ${formattedReset} 自动恢复。` + } + _buildOpusLimitMessage(resetTime) { if (!resetTime) { return '此专属账号的Opus模型已达到周使用限制,请尝试切换其他模型后再试。' @@ -71,11 +79,31 @@ class ClaudeRelayService { const sessionHash = sessionHelper.generateSessionHash(requestBody) // 选择可用的Claude账户(支持专属绑定和sticky会话) - const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( - apiKeyData, - sessionHash, - requestBody.model - ) + let accountSelection + try { + accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + requestBody.model + ) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = this._buildStandardRateLimitMessage(error.rateLimitEndAt) + logger.warn( + `🚫 Dedicated account ${error.accountId} is rate limited for API key ${apiKeyData.name}, returning 403` + ) + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }), + accountId: error.accountId + } + } + throw error + } const { accountId } = accountSelection const { accountType } = accountSelection @@ -170,6 +198,7 @@ class ClaudeRelayService { if (response.statusCode !== 200 && response.statusCode !== 201) { let isRateLimited = false let rateLimitResetTimestamp = null + let dedicatedRateLimitMessage = null // 检查是否为401状态码(未授权) if (response.statusCode === 401) { @@ -258,6 +287,11 @@ class ClaudeRelayService { `🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})` ) } + if (isDedicatedOfficialAccount) { + dedicatedRateLimitMessage = this._buildStandardRateLimitMessage( + rateLimitResetTimestamp || account?.rateLimitEndAt + ) + } } } else { // 检查响应体中的错误信息 @@ -284,6 +318,11 @@ class ClaudeRelayService { } if (isRateLimited) { + if (isDedicatedOfficialAccount && !dedicatedRateLimitMessage) { + dedicatedRateLimitMessage = this._buildStandardRateLimitMessage( + rateLimitResetTimestamp || account?.rateLimitEndAt + ) + } logger.warn( `🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}` ) @@ -294,6 +333,18 @@ class ClaudeRelayService { sessionHash, rateLimitResetTimestamp ) + + if (dedicatedRateLimitMessage) { + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'upstream_rate_limited', + message: dedicatedRateLimitMessage + }), + accountId + } + } } } else if (response.statusCode === 200 || response.statusCode === 201) { // 提取5小时会话窗口状态 @@ -886,11 +937,31 @@ class ClaudeRelayService { const sessionHash = sessionHelper.generateSessionHash(requestBody) // 选择可用的Claude账户(支持专属绑定和sticky会话) - const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( - apiKeyData, - sessionHash, - requestBody.model - ) + let accountSelection + try { + accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + requestBody.model + ) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = this._buildStandardRateLimitMessage(error.rateLimitEndAt) + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }) + ) + responseStream.end() + return + } + throw error + } const { accountId } = accountSelection const { accountType } = accountSelection @@ -1049,35 +1120,71 @@ class ClaudeRelayService { // 错误响应处理 if (res.statusCode !== 200) { - if (res.statusCode === 429 && isOpusModelRequest) { + if (res.statusCode === 429) { const resetHeader = res.headers ? res.headers['anthropic-ratelimit-unified-reset'] : null const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN - if (!Number.isNaN(parsedResetTimestamp)) { - await claudeAccountService.markAccountOpusRateLimited(accountId, parsedResetTimestamp) - logger.warn( - `🚫 [Stream] Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}` - ) - } - - if (isDedicatedOfficialAccount) { - const limitMessage = this._buildOpusLimitMessage(parsedResetTimestamp) - if (!responseStream.headersSent) { - responseStream.status(403) - responseStream.setHeader('Content-Type', 'application/json') + if (isOpusModelRequest) { + if (!Number.isNaN(parsedResetTimestamp)) { + await claudeAccountService.markAccountOpusRateLimited( + accountId, + parsedResetTimestamp + ) + logger.warn( + `🚫 [Stream] Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}` + ) } - responseStream.write( - JSON.stringify({ - error: 'opus_weekly_limit', - message: limitMessage - }) + + if (isDedicatedOfficialAccount) { + const limitMessage = this._buildOpusLimitMessage(parsedResetTimestamp) + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'opus_weekly_limit', + message: limitMessage + }) + ) + responseStream.end() + res.resume() + resolve() + return + } + } else { + const rateLimitResetTimestamp = Number.isNaN(parsedResetTimestamp) + ? null + : parsedResetTimestamp + await unifiedClaudeScheduler.markAccountRateLimited( + accountId, + accountType, + sessionHash, + rateLimitResetTimestamp ) - responseStream.end() - res.resume() - resolve() - return + logger.warn(`🚫 [Stream] Rate limit detected for account ${accountId}, status 429`) + + if (isDedicatedOfficialAccount) { + const limitMessage = this._buildStandardRateLimitMessage( + rateLimitResetTimestamp || account?.rateLimitEndAt + ) + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }) + ) + responseStream.end() + res.resume() + resolve() + return + } } } diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index d923b997..fc80a3b1 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -159,25 +159,36 @@ class UnifiedClaudeScheduler { // 普通专属账户 const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) - if ( - boundAccount && - boundAccount.isActive === 'true' && - boundAccount.status !== 'error' && - this._isSchedulable(boundAccount.schedulable) - ) { - if (isOpusRequest) { - await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id) + if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { + const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) + if (isRateLimited) { + const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id) + const error = new Error('Dedicated Claude account is rate limited') + error.code = 'CLAUDE_DEDICATED_RATE_LIMITED' + error.accountId = boundAccount.id + error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null + throw error } - logger.info( - `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` - ) - return { - accountId: apiKeyData.claudeAccountId, - accountType: 'claude-official' + + if (!this._isSchedulable(boundAccount.schedulable)) { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool` + ) + } else { + if (isOpusRequest) { + await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id) + } + logger.info( + `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.claudeAccountId, + accountType: 'claude-official' + } } } else { logger.warn( - `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable}), falling back to pool` + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}), falling back to pool` ) } } @@ -334,11 +345,23 @@ class UnifiedClaudeScheduler { boundAccount.isActive === 'true' && boundAccount.status !== 'error' && boundAccount.status !== 'blocked' && - boundAccount.status !== 'temp_error' && - this._isSchedulable(boundAccount.schedulable) + boundAccount.status !== 'temp_error' ) { const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) - if (!isRateLimited) { + if (isRateLimited) { + const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id) + const error = new Error('Dedicated Claude account is rate limited') + error.code = 'CLAUDE_DEDICATED_RATE_LIMITED' + error.accountId = boundAccount.id + error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null + throw error + } + + if (!this._isSchedulable(boundAccount.schedulable)) { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable})` + ) + } else { logger.info( `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId})` ) @@ -354,7 +377,7 @@ class UnifiedClaudeScheduler { } } else { logger.warn( - `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable})` + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status})` ) } }