mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
chore: claude绑定账号响应限流提示
This commit is contained in:
@@ -102,11 +102,32 @@ async function handleMessagesRequest(req, res) {
|
|||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 使用统一调度选择账号(传递请求的模型)
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model
|
||||||
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
let accountId
|
||||||
req.apiKey,
|
let accountType
|
||||||
sessionHash,
|
try {
|
||||||
requestedModel
|
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') {
|
if (accountType === 'claude-official') {
|
||||||
@@ -513,11 +534,27 @@ async function handleMessagesRequest(req, res) {
|
|||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 使用统一调度选择账号(传递请求的模型)
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model
|
||||||
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
let accountId
|
||||||
req.apiKey,
|
let accountType
|
||||||
sessionHash,
|
try {
|
||||||
requestedModel
|
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
|
let response
|
||||||
|
|||||||
@@ -206,11 +206,23 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
const sessionHash = sessionHelper.generateSessionHash(claudeRequest)
|
const sessionHash = sessionHelper.generateSessionHash(claudeRequest)
|
||||||
|
|
||||||
// 选择可用的Claude账户
|
// 选择可用的Claude账户
|
||||||
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
let accountSelection
|
||||||
apiKeyData,
|
try {
|
||||||
sessionHash,
|
accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
claudeRequest.model
|
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
|
const { accountId } = accountSelection
|
||||||
|
|
||||||
// 获取该账号存储的 Claude Code headers
|
// 获取该账号存储的 Claude Code headers
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ class ClaudeRelayService {
|
|||||||
this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
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) {
|
_buildOpusLimitMessage(resetTime) {
|
||||||
if (!resetTime) {
|
if (!resetTime) {
|
||||||
return '此专属账号的Opus模型已达到周使用限制,请尝试切换其他模型后再试。'
|
return '此专属账号的Opus模型已达到周使用限制,请尝试切换其他模型后再试。'
|
||||||
@@ -71,11 +79,31 @@ class ClaudeRelayService {
|
|||||||
const sessionHash = sessionHelper.generateSessionHash(requestBody)
|
const sessionHash = sessionHelper.generateSessionHash(requestBody)
|
||||||
|
|
||||||
// 选择可用的Claude账户(支持专属绑定和sticky会话)
|
// 选择可用的Claude账户(支持专属绑定和sticky会话)
|
||||||
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
let accountSelection
|
||||||
apiKeyData,
|
try {
|
||||||
sessionHash,
|
accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
requestBody.model
|
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 { accountId } = accountSelection
|
||||||
const { accountType } = accountSelection
|
const { accountType } = accountSelection
|
||||||
|
|
||||||
@@ -170,6 +198,7 @@ class ClaudeRelayService {
|
|||||||
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||||
let isRateLimited = false
|
let isRateLimited = false
|
||||||
let rateLimitResetTimestamp = null
|
let rateLimitResetTimestamp = null
|
||||||
|
let dedicatedRateLimitMessage = null
|
||||||
|
|
||||||
// 检查是否为401状态码(未授权)
|
// 检查是否为401状态码(未授权)
|
||||||
if (response.statusCode === 401) {
|
if (response.statusCode === 401) {
|
||||||
@@ -258,6 +287,11 @@ class ClaudeRelayService {
|
|||||||
`🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})`
|
`🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (isDedicatedOfficialAccount) {
|
||||||
|
dedicatedRateLimitMessage = this._buildStandardRateLimitMessage(
|
||||||
|
rateLimitResetTimestamp || account?.rateLimitEndAt
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 检查响应体中的错误信息
|
// 检查响应体中的错误信息
|
||||||
@@ -284,6 +318,11 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
|
if (isDedicatedOfficialAccount && !dedicatedRateLimitMessage) {
|
||||||
|
dedicatedRateLimitMessage = this._buildStandardRateLimitMessage(
|
||||||
|
rateLimitResetTimestamp || account?.rateLimitEndAt
|
||||||
|
)
|
||||||
|
}
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}`
|
`🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}`
|
||||||
)
|
)
|
||||||
@@ -294,6 +333,18 @@ class ClaudeRelayService {
|
|||||||
sessionHash,
|
sessionHash,
|
||||||
rateLimitResetTimestamp
|
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) {
|
} else if (response.statusCode === 200 || response.statusCode === 201) {
|
||||||
// 提取5小时会话窗口状态
|
// 提取5小时会话窗口状态
|
||||||
@@ -886,11 +937,31 @@ class ClaudeRelayService {
|
|||||||
const sessionHash = sessionHelper.generateSessionHash(requestBody)
|
const sessionHash = sessionHelper.generateSessionHash(requestBody)
|
||||||
|
|
||||||
// 选择可用的Claude账户(支持专属绑定和sticky会话)
|
// 选择可用的Claude账户(支持专属绑定和sticky会话)
|
||||||
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
let accountSelection
|
||||||
apiKeyData,
|
try {
|
||||||
sessionHash,
|
accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
requestBody.model
|
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 { accountId } = accountSelection
|
||||||
const { accountType } = accountSelection
|
const { accountType } = accountSelection
|
||||||
|
|
||||||
@@ -1049,35 +1120,71 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 错误响应处理
|
// 错误响应处理
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
if (res.statusCode === 429 && isOpusModelRequest) {
|
if (res.statusCode === 429) {
|
||||||
const resetHeader = res.headers
|
const resetHeader = res.headers
|
||||||
? res.headers['anthropic-ratelimit-unified-reset']
|
? res.headers['anthropic-ratelimit-unified-reset']
|
||||||
: null
|
: null
|
||||||
const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN
|
const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN
|
||||||
|
|
||||||
if (!Number.isNaN(parsedResetTimestamp)) {
|
if (isOpusModelRequest) {
|
||||||
await claudeAccountService.markAccountOpusRateLimited(accountId, parsedResetTimestamp)
|
if (!Number.isNaN(parsedResetTimestamp)) {
|
||||||
logger.warn(
|
await claudeAccountService.markAccountOpusRateLimited(
|
||||||
`🚫 [Stream] Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}`
|
accountId,
|
||||||
)
|
parsedResetTimestamp
|
||||||
}
|
)
|
||||||
|
logger.warn(
|
||||||
if (isDedicatedOfficialAccount) {
|
`🚫 [Stream] Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}`
|
||||||
const limitMessage = this._buildOpusLimitMessage(parsedResetTimestamp)
|
)
|
||||||
if (!responseStream.headersSent) {
|
|
||||||
responseStream.status(403)
|
|
||||||
responseStream.setHeader('Content-Type', 'application/json')
|
|
||||||
}
|
}
|
||||||
responseStream.write(
|
|
||||||
JSON.stringify({
|
if (isDedicatedOfficialAccount) {
|
||||||
error: 'opus_weekly_limit',
|
const limitMessage = this._buildOpusLimitMessage(parsedResetTimestamp)
|
||||||
message: limitMessage
|
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()
|
logger.warn(`🚫 [Stream] Rate limit detected for account ${accountId}, status 429`)
|
||||||
res.resume()
|
|
||||||
resolve()
|
if (isDedicatedOfficialAccount) {
|
||||||
return
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,25 +159,36 @@ class UnifiedClaudeScheduler {
|
|||||||
|
|
||||||
// 普通专属账户
|
// 普通专属账户
|
||||||
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
||||||
if (
|
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||||
boundAccount &&
|
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
||||||
boundAccount.isActive === 'true' &&
|
if (isRateLimited) {
|
||||||
boundAccount.status !== 'error' &&
|
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
|
||||||
this._isSchedulable(boundAccount.schedulable)
|
const error = new Error('Dedicated Claude account is rate limited')
|
||||||
) {
|
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
|
||||||
if (isOpusRequest) {
|
error.accountId = boundAccount.id
|
||||||
await claudeAccountService.clearExpiredOpusRateLimit(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}`
|
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||||
)
|
logger.warn(
|
||||||
return {
|
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
|
||||||
accountId: apiKeyData.claudeAccountId,
|
)
|
||||||
accountType: 'claude-official'
|
} 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 {
|
} else {
|
||||||
logger.warn(
|
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.isActive === 'true' &&
|
||||||
boundAccount.status !== 'error' &&
|
boundAccount.status !== 'error' &&
|
||||||
boundAccount.status !== 'blocked' &&
|
boundAccount.status !== 'blocked' &&
|
||||||
boundAccount.status !== 'temp_error' &&
|
boundAccount.status !== 'temp_error'
|
||||||
this._isSchedulable(boundAccount.schedulable)
|
|
||||||
) {
|
) {
|
||||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
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(
|
logger.info(
|
||||||
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId})`
|
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId})`
|
||||||
)
|
)
|
||||||
@@ -354,7 +377,7 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
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})`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user