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) { async removeAccountRateLimit(accountId) {
try { try {

View File

@@ -55,6 +55,9 @@ class ClaudeRelayService {
requestedModel: requestBody.model requestedModel: requestBody.model
}) })
const isOpusModelRequest =
typeof requestBody?.model === 'string' && requestBody.model.toLowerCase().includes('opus')
// 生成会话哈希用于sticky会话 // 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(requestBody) 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}` : ''}` `📤 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 // 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId) const accessToken = await claudeAccountService.getValidAccessToken(accountId)
// 获取账户信息
const account = await claudeAccountService.getAccount(accountId)
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
const processedBody = this._processRequestBody(requestBody, clientHeaders, account) const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
@@ -181,16 +216,24 @@ class ClaudeRelayService {
} }
// 检查是否为429状态码 // 检查是否为429状态码
else if (response.statusCode === 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 (isOpusModelRequest && !Number.isNaN(parsedResetTimestamp)) {
if (response.headers && response.headers['anthropic-ratelimit-unified-reset']) { await claudeAccountService.markAccountOpusRateLimited(accountId, parsedResetTimestamp)
rateLimitResetTimestamp = parseInt( logger.warn(
response.headers['anthropic-ratelimit-unified-reset'] `🚫 Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}`
)
logger.info(
`🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 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 { } else {
// 检查响应体中的错误信息 // 检查响应体中的错误信息
@@ -812,6 +855,9 @@ class ClaudeRelayService {
requestedModel: requestBody.model requestedModel: requestBody.model
}) })
const isOpusModelRequest =
typeof requestBody?.model === 'string' && requestBody.model.toLowerCase().includes('opus')
// 生成会话哈希用于sticky会话 // 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(requestBody) 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}` : ''}` `📡 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 // 获取有效的访问token
const accessToken = await claudeAccountService.getValidAccessToken(accountId) const accessToken = await claudeAccountService.getValidAccessToken(accountId)
// 获取账户信息
const account = await claudeAccountService.getAccount(accountId)
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词) // 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
const processedBody = this._processRequestBody(requestBody, clientHeaders, account) const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
@@ -880,6 +956,9 @@ class ClaudeRelayService {
// 获取账户信息用于统一 User-Agent // 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId) const account = await claudeAccountService.getAccount(accountId)
const isOpusModelRequest =
typeof body?.model === 'string' && body.model.toLowerCase().includes('opus')
// 获取统一的 User-Agent // 获取统一的 User-Agent
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account) const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
@@ -1291,22 +1370,34 @@ class ClaudeRelayService {
// 处理限流状态 // 处理限流状态
if (rateLimitDetected || res.statusCode === 429) { if (rateLimitDetected || res.statusCode === 429) {
// 提取限流重置时间戳 const resetHeader = res.headers
let rateLimitResetTimestamp = null ? res.headers['anthropic-ratelimit-unified-reset']
if (res.headers && res.headers['anthropic-ratelimit-unified-reset']) { : null
rateLimitResetTimestamp = parseInt(res.headers['anthropic-ratelimit-unified-reset']) const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN
logger.info(
`🕐 Extracted rate limit reset timestamp from stream: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})` 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) { } else if (res.statusCode === 200) {
// 请求成功清除401和500错误计数 // 请求成功清除401和500错误计数
await this.clearUnauthorizedErrors(accountId) await this.clearUnauthorizedErrors(accountId)

View File

@@ -131,6 +131,10 @@ class UnifiedClaudeScheduler {
logger.debug( logger.debug(
`🔍 Model parsing - Original: ${requestedModel}, Vendor: ${vendor}, Effective: ${effectiveModel}` `🔍 Model parsing - Original: ${requestedModel}, Vendor: ${vendor}, Effective: ${effectiveModel}`
) )
const isOpusRequest =
effectiveModel && typeof effectiveModel === 'string'
? effectiveModel.toLowerCase().includes('opus')
: false
// 如果是 CCR 前缀,只在 CCR 账户池中选择 // 如果是 CCR 前缀,只在 CCR 账户池中选择
if (vendor === 'ccr') { if (vendor === 'ccr') {
@@ -161,6 +165,9 @@ class UnifiedClaudeScheduler {
boundAccount.status !== 'error' && boundAccount.status !== 'error' &&
this._isSchedulable(boundAccount.schedulable) this._isSchedulable(boundAccount.schedulable)
) { ) {
if (isOpusRequest) {
await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id)
}
logger.info( logger.info(
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`
) )
@@ -313,6 +320,10 @@ class UnifiedClaudeScheduler {
// 📋 获取所有可用账户合并官方和Console // 📋 获取所有可用账户合并官方和Console
async _getAllAvailableAccounts(apiKeyData, requestedModel = null, includeCcr = false) { async _getAllAvailableAccounts(apiKeyData, requestedModel = null, includeCcr = false) {
const availableAccounts = [] const availableAccounts = []
const isOpusRequest =
requestedModel && typeof requestedModel === 'string'
? requestedModel.toLowerCase().includes('opus')
: false
// 如果API Key绑定了专属账户优先返回 // 如果API Key绑定了专属账户优先返回
// 1. 检查Claude OAuth账户绑定 // 1. 检查Claude OAuth账户绑定
@@ -447,15 +458,27 @@ class UnifiedClaudeScheduler {
// 检查是否被限流 // 检查是否被限流
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id) const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id)
if (!isRateLimited) { if (isRateLimited) {
availableAccounts.push({ continue
...account,
accountId: account.id,
accountType: 'claude-official',
priority: parseInt(account.priority) || 50, // 默认优先级50
lastUsedAt: account.lastUsedAt || '0'
})
} }
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 isRateLimited = await claudeAccountService.isAccountRateLimited(accountId)
const isOverloaded = await claudeAccountService.isAccountOverloaded(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') { } else if (accountType === 'claude-console') {
const account = await claudeConsoleAccountService.getAccount(accountId) const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account || !account.isActive) { if (!account || !account.isActive) {
@@ -1056,6 +1095,10 @@ class UnifiedClaudeScheduler {
} }
const availableAccounts = [] const availableAccounts = []
const isOpusRequest =
requestedModel && typeof requestedModel === 'string'
? requestedModel.toLowerCase().includes('opus')
: false
// 获取所有成员账户的详细信息 // 获取所有成员账户的详细信息
for (const memberId of memberIds) { for (const memberId of memberIds) {
@@ -1115,15 +1158,29 @@ class UnifiedClaudeScheduler {
// 检查是否被限流 // 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(account.id, accountType) const isRateLimited = await this.isAccountRateLimited(account.id, accountType)
if (!isRateLimited) { if (isRateLimited) {
availableAccounts.push({ continue
...account,
accountId: account.id,
accountType,
priority: parseInt(account.priority) || 50,
lastUsedAt: account.lastUsedAt || '0'
})
} }
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'
})
} }
} }

View File

@@ -466,15 +466,6 @@
>添加方式</label >添加方式</label
> >
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<label v-if="form.platform === 'claude'" class="flex cursor-pointer items-center">
<input
v-model="form.addType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="setup-token"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Setup Token (推荐)</span>
</label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="form.addType" v-model="form.addType"
@@ -482,7 +473,18 @@
type="radio" type="radio"
value="oauth" value="oauth"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">OAuth 授权</span> <span class="text-sm text-gray-700 dark:text-gray-300"
>OAuth 授权 (用量可视化)</span
>
</label>
<label v-if="form.platform === 'claude'" class="flex cursor-pointer items-center">
<input
v-model="form.addType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="setup-token"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Setup Token (效期长)</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -2799,7 +2801,7 @@ const form = ref({
addType: (() => { addType: (() => {
const platform = props.account?.platform || 'claude' const platform = props.account?.platform || 'claude'
if (platform === 'gemini' || platform === 'openai') return 'oauth' if (platform === 'gemini' || platform === 'openai') return 'oauth'
if (platform === 'claude') return 'setup-token' if (platform === 'claude') return 'oauth'
return 'manual' return 'manual'
})(), })(),
name: props.account?.name || '', name: props.account?.name || '',
@@ -3924,8 +3926,8 @@ watch(
) { ) {
form.value.addType = 'manual' // Claude Console、CCR、Bedrock 和 OpenAI-Responses 只支持手动模式 form.value.addType = 'manual' // Claude Console、CCR、Bedrock 和 OpenAI-Responses 只支持手动模式
} else if (newPlatform === 'claude') { } else if (newPlatform === 'claude') {
// 切换到 Claude 时,使用 Setup Token 作为默认方式 // 切换到 Claude 时,使用 oauth 作为默认方式
form.value.addType = 'setup-token' form.value.addType = 'oauth'
} else if (newPlatform === 'gemini') { } else if (newPlatform === 'gemini') {
// 切换到 Gemini 时,使用 OAuth 作为默认方式 // 切换到 Gemini 时,使用 OAuth 作为默认方式
form.value.addType = 'oauth' form.value.addType = 'oauth'