feat: 适配claude新opus周限规则

This commit is contained in:
shaw
2025-10-04 10:49:40 +08:00
parent bda1875466
commit d44582dc31
4 changed files with 323 additions and 58 deletions

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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'
})
}
}