mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
feat: 适配claude新opus周限规则
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user