diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 8150c0fd..ec9e5b11 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -1286,6 +1286,121 @@ class ClaudeAccountService { } } + // 🚫 标记账号的 Opus 限流状态(不影响其他模型调度) + async markAccountOpusRateLimited(accountId, rateLimitResetTimestamp = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + const updatedAccountData = { ...accountData } + const now = new Date() + updatedAccountData.opusRateLimitedAt = now.toISOString() + + if (rateLimitResetTimestamp) { + const resetTime = new Date(rateLimitResetTimestamp * 1000) + updatedAccountData.opusRateLimitEndAt = resetTime.toISOString() + logger.warn( + `🚫 Account ${accountData.name} (${accountId}) reached Opus weekly cap, resets at ${resetTime.toISOString()}` + ) + } else { + // 如果缺少准确时间戳,保留现有值但记录警告,便于后续人工干预 + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) reported Opus limit without reset timestamp` + ) + } + + await redis.setClaudeAccount(accountId, updatedAccountData) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Opus rate limit for account: ${accountId}`, error) + throw error + } + } + + // ✅ 清除账号的 Opus 限流状态 + async clearAccountOpusRateLimit(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return { success: true } + } + + const updatedAccountData = { ...accountData } + delete updatedAccountData.opusRateLimitedAt + delete updatedAccountData.opusRateLimitEndAt + + await redis.setClaudeAccount(accountId, updatedAccountData) + + const redisKey = `claude:account:${accountId}` + if (redis.client && typeof redis.client.hdel === 'function') { + await redis.client.hdel(redisKey, 'opusRateLimitedAt', 'opusRateLimitEndAt') + } + + logger.info(`✅ Cleared Opus rate limit state for account ${accountId}`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to clear Opus rate limit for account: ${accountId}`, error) + throw error + } + } + + // 🔍 检查账号是否处于 Opus 限流状态(自动清理过期标记) + async isAccountOpusRateLimited(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return false + } + + if (!accountData.opusRateLimitEndAt) { + return false + } + + const resetTime = new Date(accountData.opusRateLimitEndAt) + if (Number.isNaN(resetTime.getTime())) { + await this.clearAccountOpusRateLimit(accountId) + return false + } + + const now = new Date() + if (now >= resetTime) { + await this.clearAccountOpusRateLimit(accountId) + return false + } + + return true + } catch (error) { + logger.error(`❌ Failed to check Opus rate limit status for account: ${accountId}`, error) + return false + } + } + + // ♻️ 检查并清理已过期的 Opus 限流标记 + async clearExpiredOpusRateLimit(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return { success: true } + } + + if (!accountData.opusRateLimitEndAt) { + return { success: true } + } + + const resetTime = new Date(accountData.opusRateLimitEndAt) + if (Number.isNaN(resetTime.getTime()) || new Date() >= resetTime) { + await this.clearAccountOpusRateLimit(accountId) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to clear expired Opus rate limit for account: ${accountId}`, error) + throw error + } + } + // ✅ 移除账号的限流状态 async removeAccountRateLimit(accountId) { try { diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index c7d1ad08..fc1b1404 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -55,6 +55,9 @@ class ClaudeRelayService { requestedModel: requestBody.model }) + const isOpusModelRequest = + typeof requestBody?.model === 'string' && requestBody.model.toLowerCase().includes('opus') + // 生成会话哈希用于sticky会话 const sessionHash = sessionHelper.generateSessionHash(requestBody) @@ -71,12 +74,44 @@ class ClaudeRelayService { `📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}` ) + // 获取账户信息 + let account = await claudeAccountService.getAccount(accountId) + + if (isOpusModelRequest) { + await claudeAccountService.clearExpiredOpusRateLimit(accountId) + account = await claudeAccountService.getAccount(accountId) + } + + const isDedicatedOfficialAccount = + apiKeyData.claudeAccountId && + !apiKeyData.claudeAccountId.startsWith('group:') && + apiKeyData.claudeAccountId === accountId + + let opusRateLimitActive = false + let opusRateLimitEndAt = null + if (isOpusModelRequest) { + opusRateLimitActive = await claudeAccountService.isAccountOpusRateLimited(accountId) + opusRateLimitEndAt = account?.opusRateLimitEndAt || null + } + + if (isOpusModelRequest && isDedicatedOfficialAccount && opusRateLimitActive) { + logger.warn( + `🚫 Dedicated account ${account?.name || accountId} is under Opus weekly limit until ${opusRateLimitEndAt}` + ) + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'opus_weekly_limit', + message: '此专属账号的Opus模型已达到本周使用限制,请尝试切换其他模型后再试。' + }), + accountId + } + } + // 获取有效的访问token const accessToken = await claudeAccountService.getValidAccessToken(accountId) - // 获取账户信息 - const account = await claudeAccountService.getAccount(accountId) - // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) const processedBody = this._processRequestBody(requestBody, clientHeaders, account) @@ -181,16 +216,24 @@ class ClaudeRelayService { } // 检查是否为429状态码 else if (response.statusCode === 429) { - isRateLimited = true + const resetHeader = response.headers + ? response.headers['anthropic-ratelimit-unified-reset'] + : null + const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN - // 提取限流重置时间戳 - if (response.headers && response.headers['anthropic-ratelimit-unified-reset']) { - rateLimitResetTimestamp = parseInt( - response.headers['anthropic-ratelimit-unified-reset'] - ) - logger.info( - `🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})` + if (isOpusModelRequest && !Number.isNaN(parsedResetTimestamp)) { + await claudeAccountService.markAccountOpusRateLimited(accountId, parsedResetTimestamp) + logger.warn( + `🚫 Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}` ) + } else { + isRateLimited = true + if (!Number.isNaN(parsedResetTimestamp)) { + rateLimitResetTimestamp = parsedResetTimestamp + logger.info( + `🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})` + ) + } } } else { // 检查响应体中的错误信息 @@ -812,6 +855,9 @@ class ClaudeRelayService { requestedModel: requestBody.model }) + const isOpusModelRequest = + typeof requestBody?.model === 'string' && requestBody.model.toLowerCase().includes('opus') + // 生成会话哈希用于sticky会话 const sessionHash = sessionHelper.generateSessionHash(requestBody) @@ -828,12 +874,42 @@ class ClaudeRelayService { `📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}` ) + // 获取账户信息 + let account = await claudeAccountService.getAccount(accountId) + + if (isOpusModelRequest) { + await claudeAccountService.clearExpiredOpusRateLimit(accountId) + account = await claudeAccountService.getAccount(accountId) + } + + const isDedicatedOfficialAccount = + apiKeyData.claudeAccountId && + !apiKeyData.claudeAccountId.startsWith('group:') && + apiKeyData.claudeAccountId === accountId + + let opusRateLimitActive = false + if (isOpusModelRequest) { + opusRateLimitActive = await claudeAccountService.isAccountOpusRateLimited(accountId) + } + + if (isOpusModelRequest && isDedicatedOfficialAccount && opusRateLimitActive) { + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'opus_weekly_limit', + message: '此专属账号的Opus模型已达到本周使用限制,请尝试切换其他模型后再试。' + }) + ) + responseStream.end() + return + } + // 获取有效的访问token const accessToken = await claudeAccountService.getValidAccessToken(accountId) - // 获取账户信息 - const account = await claudeAccountService.getAccount(accountId) - // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) const processedBody = this._processRequestBody(requestBody, clientHeaders, account) @@ -880,6 +956,9 @@ class ClaudeRelayService { // 获取账户信息用于统一 User-Agent const account = await claudeAccountService.getAccount(accountId) + const isOpusModelRequest = + typeof body?.model === 'string' && body.model.toLowerCase().includes('opus') + // 获取统一的 User-Agent const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account) @@ -1291,22 +1370,34 @@ class ClaudeRelayService { // 处理限流状态 if (rateLimitDetected || res.statusCode === 429) { - // 提取限流重置时间戳 - let rateLimitResetTimestamp = null - if (res.headers && res.headers['anthropic-ratelimit-unified-reset']) { - rateLimitResetTimestamp = parseInt(res.headers['anthropic-ratelimit-unified-reset']) - logger.info( - `🕐 Extracted rate limit reset timestamp from stream: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})` + const resetHeader = res.headers + ? res.headers['anthropic-ratelimit-unified-reset'] + : null + const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN + + if (isOpusModelRequest && !Number.isNaN(parsedResetTimestamp)) { + await claudeAccountService.markAccountOpusRateLimited(accountId, parsedResetTimestamp) + logger.warn( + `🚫 [Stream] Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}` + ) + } else { + const rateLimitResetTimestamp = Number.isNaN(parsedResetTimestamp) + ? null + : parsedResetTimestamp + + if (!Number.isNaN(parsedResetTimestamp)) { + logger.info( + `🕐 Extracted rate limit reset timestamp from stream: ${parsedResetTimestamp} (${new Date(parsedResetTimestamp * 1000).toISOString()})` + ) + } + + await unifiedClaudeScheduler.markAccountRateLimited( + accountId, + accountType, + sessionHash, + rateLimitResetTimestamp ) } - - // 标记账号为限流状态并删除粘性会话映射 - await unifiedClaudeScheduler.markAccountRateLimited( - accountId, - accountType, - sessionHash, - rateLimitResetTimestamp - ) } else if (res.statusCode === 200) { // 请求成功,清除401和500错误计数 await this.clearUnauthorizedErrors(accountId) diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 0870ac52..d923b997 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -131,6 +131,10 @@ class UnifiedClaudeScheduler { logger.debug( `🔍 Model parsing - Original: ${requestedModel}, Vendor: ${vendor}, Effective: ${effectiveModel}` ) + const isOpusRequest = + effectiveModel && typeof effectiveModel === 'string' + ? effectiveModel.toLowerCase().includes('opus') + : false // 如果是 CCR 前缀,只在 CCR 账户池中选择 if (vendor === 'ccr') { @@ -161,6 +165,9 @@ class UnifiedClaudeScheduler { boundAccount.status !== 'error' && this._isSchedulable(boundAccount.schedulable) ) { + if (isOpusRequest) { + await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id) + } logger.info( `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` ) @@ -313,6 +320,10 @@ class UnifiedClaudeScheduler { // 📋 获取所有可用账户(合并官方和Console) async _getAllAvailableAccounts(apiKeyData, requestedModel = null, includeCcr = false) { const availableAccounts = [] + const isOpusRequest = + requestedModel && typeof requestedModel === 'string' + ? requestedModel.toLowerCase().includes('opus') + : false // 如果API Key绑定了专属账户,优先返回 // 1. 检查Claude OAuth账户绑定 @@ -447,15 +458,27 @@ class UnifiedClaudeScheduler { // 检查是否被限流 const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id) - if (!isRateLimited) { - availableAccounts.push({ - ...account, - accountId: account.id, - accountType: 'claude-official', - priority: parseInt(account.priority) || 50, // 默认优先级50 - lastUsedAt: account.lastUsedAt || '0' - }) + if (isRateLimited) { + continue } + + if (isOpusRequest) { + const isOpusRateLimited = await claudeAccountService.isAccountOpusRateLimited(account.id) + if (isOpusRateLimited) { + logger.info( + `🚫 Skipping account ${account.name} (${account.id}) due to active Opus limit` + ) + continue + } + } + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'claude-official', + priority: parseInt(account.priority) || 50, // 默认优先级50 + lastUsedAt: account.lastUsedAt || '0' + }) } } @@ -665,7 +688,23 @@ class UnifiedClaudeScheduler { // 检查是否限流或过载 const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId) const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId) - return !isRateLimited && !isOverloaded + if (isRateLimited || isOverloaded) { + return false + } + + if ( + requestedModel && + typeof requestedModel === 'string' && + requestedModel.toLowerCase().includes('opus') + ) { + const isOpusRateLimited = await claudeAccountService.isAccountOpusRateLimited(accountId) + if (isOpusRateLimited) { + logger.info(`🚫 Account ${accountId} skipped due to active Opus limit (session check)`) + return false + } + } + + return true } else if (accountType === 'claude-console') { const account = await claudeConsoleAccountService.getAccount(accountId) if (!account || !account.isActive) { @@ -1056,6 +1095,10 @@ class UnifiedClaudeScheduler { } const availableAccounts = [] + const isOpusRequest = + requestedModel && typeof requestedModel === 'string' + ? requestedModel.toLowerCase().includes('opus') + : false // 获取所有成员账户的详细信息 for (const memberId of memberIds) { @@ -1115,15 +1158,29 @@ class UnifiedClaudeScheduler { // 检查是否被限流 const isRateLimited = await this.isAccountRateLimited(account.id, accountType) - if (!isRateLimited) { - availableAccounts.push({ - ...account, - accountId: account.id, - accountType, - priority: parseInt(account.priority) || 50, - lastUsedAt: account.lastUsedAt || '0' - }) + if (isRateLimited) { + continue } + + if (accountType === 'claude-official' && isOpusRequest) { + const isOpusRateLimited = await claudeAccountService.isAccountOpusRateLimited( + account.id + ) + if (isOpusRateLimited) { + logger.info( + `🚫 Skipping group member ${account.name} (${account.id}) due to active Opus limit` + ) + continue + } + } + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType, + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) } } diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 94358290..80cd327c 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -466,15 +466,6 @@ >添加方式