Compare commits

...

12 Commits

Author SHA1 Message Date
github-actions[bot]
59ce0f091c chore: sync VERSION file with release v1.1.239 [skip ci] 2025-12-24 11:56:05 +00:00
shaw
67c20fa30e feat: 为 claude-official 账户添加 403 错误重试机制
针对 OAuth 和 Setup Token 类型的 Claude 账户,遇到 403 错误时:
- 休息 2 秒后进行重试
- 最多重试 2 次(总共最多 3 次请求)
- 重试后仍是 403 才标记账户为 blocked

同时支持流式和非流式请求,并修复了流式请求中的竞态条件问题。
2025-12-24 19:54:25 +08:00
shaw
671451253f fix: 修复并发清理任务 WRONGTYPE 错误
问题:
- 并发清理定时任务在遇到非 zset 类型的遗留键时报 WRONGTYPE 错误
- 错误键如 concurrency:wait:*, concurrency:user:*, concurrency:account:* 等

修复:
- app.js: 使用原子 Lua 脚本先检查键类型再执行清理,消除竞态条件
- redis.js: 为 6 个并发管理函数添加类型检查
  - getAllConcurrencyStatus(): 跳过 queue 键 + 类型检查
  - getConcurrencyStatus(): 类型检查,非 zset 返回 invalidType
  - forceClearConcurrency(): 类型检查,任意类型都删除
  - forceClearAllConcurrency(): 跳过 queue 键 + 类型检查
  - cleanupExpiredConcurrency(): 跳过 queue 键 + 类型检查

- 遗留键会被自动识别并删除,同时记录日志
2025-12-24 17:51:19 +08:00
github-actions[bot]
0173ab224b chore: sync VERSION file with release v1.1.238 [skip ci] 2025-12-21 14:41:29 +00:00
shaw
11fb77c8bd chore: trigger release [force release] 2025-12-21 22:41:03 +08:00
shaw
3d67f0b124 chore: update readme 2025-12-21 22:37:13 +08:00
shaw
84f19b348b fix: 适配cc遥测端点 2025-12-21 22:29:36 +08:00
shaw
8ec8a59b07 feat: claude账号新增支持拦截预热请求 2025-12-21 22:28:22 +08:00
shaw
00d8ac4bec Merge branch 'main' into dev 2025-12-21 21:35:16 +08:00
Wesley Liddick
ba93ae55a9 Merge pull request #811 from sususu98/feat/event-logging-endpoint
feat: 添加 Claude Code 遥测端点并优化日志级别
2025-12-16 19:34:44 -05:00
sususu
0994eb346f format 2025-12-16 18:32:11 +08:00
sususu
4863a37328 feat: 添加 Claude Code 遥测端点并优化日志级别
- 添加 /api/event_logging/batch 端点处理客户端遥测请求
- 将遥测相关请求日志改为 debug 级别,减少日志噪音
2025-12-16 18:31:07 +08:00
13 changed files with 582 additions and 51 deletions

View File

@@ -408,6 +408,8 @@ export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
如果该文件不存在请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。 如果该文件不存在请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
> 💡 **IntelliJ IDEA 用户推荐**[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。
**Gemini CLI 设置环境变量:** **Gemini CLI 设置环境变量:**
**方式一(推荐):通过 Gemini Assist API 方式访问** **方式一(推荐):通过 Gemini Assist API 方式访问**

View File

@@ -1 +1 @@
1.1.237 1.1.239

View File

@@ -581,10 +581,11 @@ class Application {
const now = Date.now() const now = Date.now()
let totalCleaned = 0 let totalCleaned = 0
let legacyCleaned = 0
// 使用 Lua 脚本批量清理所有过期项 // 使用 Lua 脚本批量清理所有过期项
for (const key of keys) { for (const key of keys) {
// 跳过非 Sorted Set 类型的键(这些键有各自的清理逻辑) // 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
// - concurrency:queue:stats:* 是 Hash 类型 // - concurrency:queue:stats:* 是 Hash 类型
// - concurrency:queue:wait_times:* 是 List 类型 // - concurrency:queue:wait_times:* 是 List 类型
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型 // - concurrency:queue:* (不含stats/wait_times) 是 String 类型
@@ -599,11 +600,21 @@ class Application {
} }
try { try {
const cleaned = await redis.client.eval( // 使用原子 Lua 脚本:先检查类型,再执行清理
// 返回值0 = 正常清理无删除1 = 清理后删除空键,-1 = 遗留键已删除
const result = await redis.client.eval(
` `
local key = KEYS[1] local key = KEYS[1]
local now = tonumber(ARGV[1]) local now = tonumber(ARGV[1])
-- 先检查键类型,只对 Sorted Set 执行清理
local keyType = redis.call('TYPE', key)
if keyType.ok ~= 'zset' then
-- 非 ZSET 类型的遗留键,直接删除
redis.call('DEL', key)
return -1
end
-- 清理过期项 -- 清理过期项
redis.call('ZREMRANGEBYSCORE', key, '-inf', now) redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
@@ -622,8 +633,10 @@ class Application {
key, key,
now now
) )
if (cleaned === 1) { if (result === 1) {
totalCleaned++ totalCleaned++
} else if (result === -1) {
legacyCleaned++
} }
} catch (error) { } catch (error) {
logger.error(`❌ Failed to clean concurrency key ${key}:`, error) logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
@@ -633,6 +646,9 @@ class Application {
if (totalCleaned > 0) { if (totalCleaned > 0) {
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`) logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
} }
if (legacyCleaned > 0) {
logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
}
} catch (error) { } catch (error) {
logger.error('❌ Concurrency cleanup task failed:', error) logger.error('❌ Concurrency cleanup task failed:', error)
} }

View File

@@ -1744,10 +1744,14 @@ const requestLogger = (req, res, next) => {
const referer = req.get('Referer') || 'none' const referer = req.get('Referer') || 'none'
// 记录请求开始 // 记录请求开始
const isDebugRoute = req.originalUrl.includes('event_logging')
if (req.originalUrl !== '/health') { if (req.originalUrl !== '/health') {
// 避免健康检查日志过多 if (isDebugRoute) {
logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
} else {
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
} }
}
res.on('finish', () => { res.on('finish', () => {
const duration = Date.now() - start const duration = Date.now() - start
@@ -1778,8 +1782,15 @@ const requestLogger = (req, res, next) => {
logMetadata logMetadata
) )
} else if (req.originalUrl !== '/health') { } else if (req.originalUrl !== '/health') {
if (isDebugRoute) {
logger.debug(
`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
logMetadata
)
} else {
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata) logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
} }
}
// API Key相关日志 // API Key相关日志
if (req.apiKey) { if (req.apiKey) {

View File

@@ -2140,6 +2140,27 @@ class RedisClient {
const results = [] const results = []
for (const key of keys) { for (const key of keys) {
// 跳过已知非 Sorted Set 类型的键
// - concurrency:queue:stats:* 是 Hash 类型
// - concurrency:queue:wait_times:* 是 List 类型
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
if (
key.startsWith('concurrency:queue:stats:') ||
key.startsWith('concurrency:queue:wait_times:') ||
(key.startsWith('concurrency:queue:') &&
!key.includes(':stats:') &&
!key.includes(':wait_times:'))
) {
continue
}
// 检查键类型,只处理 Sorted Set
const keyType = await client.type(key)
if (keyType !== 'zset') {
logger.debug(`🔢 getAllConcurrencyStatus skipped non-zset key: ${key} (type: ${keyType})`)
continue
}
// 提取 apiKeyId去掉 concurrency: 前缀) // 提取 apiKeyId去掉 concurrency: 前缀)
const apiKeyId = key.replace('concurrency:', '') const apiKeyId = key.replace('concurrency:', '')
@@ -2202,6 +2223,23 @@ class RedisClient {
} }
} }
// 检查键类型,只处理 Sorted Set
const keyType = await client.type(key)
if (keyType !== 'zset') {
logger.warn(
`⚠️ getConcurrencyStatus: key ${key} has unexpected type: ${keyType}, expected zset`
)
return {
apiKeyId,
key,
activeCount: 0,
expiredCount: 0,
activeRequests: [],
exists: true,
invalidType: keyType
}
}
// 获取所有成员和分数 // 获取所有成员和分数
const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES') const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES')
@@ -2251,20 +2289,36 @@ class RedisClient {
const client = this.getClientSafe() const client = this.getClientSafe()
const key = `concurrency:${apiKeyId}` const key = `concurrency:${apiKeyId}`
// 获取清理前的状态 // 检查键类型
const beforeCount = await client.zcard(key) const keyType = await client.type(key)
// 删除整个 key let beforeCount = 0
let isLegacy = false
if (keyType === 'zset') {
// 正常的 zset 键,获取条目数
beforeCount = await client.zcard(key)
} else if (keyType !== 'none') {
// 非 zset 且非空的遗留键
isLegacy = true
logger.warn(
`⚠️ forceClearConcurrency: key ${key} has unexpected type: ${keyType}, will be deleted`
)
}
// 删除键(无论什么类型)
await client.del(key) await client.del(key)
logger.warn( logger.warn(
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries` `🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries${isLegacy ? ' (legacy key)' : ''}`
) )
return { return {
apiKeyId, apiKeyId,
key, key,
clearedCount: beforeCount, clearedCount: beforeCount,
type: keyType,
legacy: isLegacy,
success: true success: true
} }
} catch (error) { } catch (error) {
@@ -2283,25 +2337,47 @@ class RedisClient {
const keys = await client.keys('concurrency:*') const keys = await client.keys('concurrency:*')
let totalCleared = 0 let totalCleared = 0
let legacyCleared = 0
const clearedKeys = [] const clearedKeys = []
for (const key of keys) { for (const key of keys) {
// 跳过 queue 相关的键(它们有各自的清理逻辑)
if (key.startsWith('concurrency:queue:')) {
continue
}
// 检查键类型
const keyType = await client.type(key)
if (keyType === 'zset') {
const count = await client.zcard(key) const count = await client.zcard(key)
await client.del(key) await client.del(key)
totalCleared += count totalCleared += count
clearedKeys.push({ clearedKeys.push({
key, key,
clearedCount: count clearedCount: count,
type: 'zset'
}) })
} else {
// 非 zset 类型的遗留键,直接删除
await client.del(key)
legacyCleared++
clearedKeys.push({
key,
clearedCount: 0,
type: keyType,
legacy: true
})
}
} }
logger.warn( logger.warn(
`🧹 Force cleared all concurrency: ${keys.length} keys, ${totalCleared} total entries` `🧹 Force cleared all concurrency: ${clearedKeys.length} keys, ${totalCleared} entries, ${legacyCleared} legacy keys`
) )
return { return {
keysCleared: keys.length, keysCleared: clearedKeys.length,
totalEntriesCleared: totalCleared, totalEntriesCleared: totalCleared,
legacyKeysCleared: legacyCleared,
clearedKeys, clearedKeys,
success: true success: true
} }
@@ -2329,9 +2405,30 @@ class RedisClient {
} }
let totalCleaned = 0 let totalCleaned = 0
let legacyCleaned = 0
const cleanedKeys = [] const cleanedKeys = []
for (const key of keys) { for (const key of keys) {
// 跳过 queue 相关的键(它们有各自的清理逻辑)
if (key.startsWith('concurrency:queue:')) {
continue
}
// 检查键类型
const keyType = await client.type(key)
if (keyType !== 'zset') {
// 非 zset 类型的遗留键,直接删除
await client.del(key)
legacyCleaned++
cleanedKeys.push({
key,
cleanedCount: 0,
type: keyType,
legacy: true
})
continue
}
// 只清理过期的条目 // 只清理过期的条目
const cleaned = await client.zremrangebyscore(key, '-inf', now) const cleaned = await client.zremrangebyscore(key, '-inf', now)
if (cleaned > 0) { if (cleaned > 0) {
@@ -2350,13 +2447,14 @@ class RedisClient {
} }
logger.info( logger.info(
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys` `🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys, ${legacyCleaned} legacy keys removed`
) )
return { return {
keysProcessed: keys.length, keysProcessed: keys.length,
keysCleaned: cleanedKeys.length, keysCleaned: cleanedKeys.length,
totalEntriesCleaned: totalCleaned, totalEntriesCleaned: totalCleaned,
legacyKeysRemoved: legacyCleaned,
cleanedKeys, cleanedKeys,
success: true success: true
} }

View File

@@ -585,7 +585,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
unifiedClientId, unifiedClientId,
expiresAt, expiresAt,
extInfo, extInfo,
maxConcurrency maxConcurrency,
interceptWarmup
} = req.body } = req.body
if (!name) { if (!name) {
@@ -631,7 +632,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
unifiedClientId: unifiedClientId || '', // 统一的客户端标识 unifiedClientId: unifiedClientId || '', // 统一的客户端标识
expiresAt: expiresAt || null, // 账户订阅到期时间 expiresAt: expiresAt || null, // 账户订阅到期时间
extInfo: extInfo || null, extInfo: extInfo || null,
maxConcurrency: maxConcurrency || 0 // 账户级串行队列0=使用全局配置,>0=强制启用 maxConcurrency: maxConcurrency || 0, // 账户级串行队列0=使用全局配置,>0=强制启用
interceptWarmup: interceptWarmup === true // 拦截预热请求默认为false
}) })
// 如果是分组类型,将账户添加到分组 // 如果是分组类型,将账户添加到分组

View File

@@ -132,7 +132,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
dailyQuota, dailyQuota,
quotaResetTime, quotaResetTime,
maxConcurrentTasks, maxConcurrentTasks,
disableAutoProtection disableAutoProtection,
interceptWarmup
} = req.body } = req.body
if (!name || !apiUrl || !apiKey) { if (!name || !apiUrl || !apiKey) {
@@ -186,7 +187,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
? Number(maxConcurrentTasks) ? Number(maxConcurrentTasks)
: 0, : 0,
disableAutoProtection: normalizedDisableAutoProtection disableAutoProtection: normalizedDisableAutoProtection,
interceptWarmup: interceptWarmup === true || interceptWarmup === 'true'
}) })
// 如果是分组类型将账户添加到分组CCR 归属 Claude 平台分组) // 如果是分组类型将账户添加到分组CCR 归属 Claude 平台分组)

View File

@@ -12,6 +12,13 @@ const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelH
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const claudeRelayConfigService = require('../services/claudeRelayConfigService') const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const claudeAccountService = require('../services/claudeAccountService')
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const {
isWarmupRequest,
buildMockWarmupResponse,
sendMockWarmupStream
} = require('../utils/warmupInterceptor')
const { sanitizeUpstreamError } = require('../utils/errorSanitizer') const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
const router = express.Router() const router = express.Router()
@@ -363,6 +370,23 @@ async function handleMessagesRequest(req, res) {
} }
} }
// 🔥 预热请求拦截检查(在转发之前)
if (accountType === 'claude-official' || accountType === 'claude-console') {
const account =
accountType === 'claude-official'
? await claudeAccountService.getAccount(accountId)
: await claudeConsoleAccountService.getAccount(accountId)
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
logger.api(`🔥 Warmup request intercepted for account: ${account.name} (${accountId})`)
if (isStream) {
return sendMockWarmupStream(res, req.body.model)
} else {
return res.json(buildMockWarmupResponse(req.body.model))
}
}
}
// 根据账号类型选择对应的转发服务并调用 // 根据账号类型选择对应的转发服务并调用
if (accountType === 'claude-official') { if (accountType === 'claude-official') {
// 官方Claude账号使用原有的转发服务会自己选择账号 // 官方Claude账号使用原有的转发服务会自己选择账号
@@ -862,6 +886,21 @@ async function handleMessagesRequest(req, res) {
} }
} }
// 🔥 预热请求拦截检查(非流式,在转发之前)
if (accountType === 'claude-official' || accountType === 'claude-console') {
const account =
accountType === 'claude-official'
? await claudeAccountService.getAccount(accountId)
: await claudeConsoleAccountService.getAccount(accountId)
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
logger.api(
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
)
return res.json(buildMockWarmupResponse(req.body.model))
}
}
// 根据账号类型选择对应的转发服务 // 根据账号类型选择对应的转发服务
let response let response
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`) logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
@@ -1354,9 +1393,6 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
const maxAttempts = 2 const maxAttempts = 2
let attempt = 0 let attempt = 0
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const processRequest = async () => { const processRequest = async () => {
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey, req.apiKey,
@@ -1552,5 +1588,10 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
} }
}) })
// Claude Code 客户端遥测端点 - 返回成功响应避免 404 日志
router.post('/api/event_logging/batch', (req, res) => {
res.status(200).json({ success: true })
})
module.exports = router module.exports = router
module.exports.handleMessagesRequest = handleMessagesRequest module.exports.handleMessagesRequest = handleMessagesRequest

View File

@@ -92,7 +92,8 @@ class ClaudeAccountService {
unifiedClientId = '', // 统一的客户端标识 unifiedClientId = '', // 统一的客户端标识
expiresAt = null, // 账户订阅到期时间 expiresAt = null, // 账户订阅到期时间
extInfo = null, // 额外扩展信息 extInfo = null, // 额外扩展信息
maxConcurrency = 0 // 账户级用户消息串行队列0=使用全局配置,>0=强制启用串行 maxConcurrency = 0, // 账户级用户消息串行队列0=使用全局配置,>0=强制启用串行
interceptWarmup = false // 拦截预热请求标题生成、Warmup等
} = options } = options
const accountId = uuidv4() const accountId = uuidv4()
@@ -139,7 +140,9 @@ class ClaudeAccountService {
// 扩展信息 // 扩展信息
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '', extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
// 账户级用户消息串行队列限制 // 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString() maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
} }
} else { } else {
// 兼容旧格式 // 兼容旧格式
@@ -173,7 +176,9 @@ class ClaudeAccountService {
// 扩展信息 // 扩展信息
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '', extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
// 账户级用户消息串行队列限制 // 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString() maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
} }
} }
@@ -221,7 +226,8 @@ class ClaudeAccountService {
useUnifiedUserAgent, useUnifiedUserAgent,
useUnifiedClientId, useUnifiedClientId,
unifiedClientId, unifiedClientId,
extInfo: normalizedExtInfo extInfo: normalizedExtInfo,
interceptWarmup
} }
} }
@@ -581,7 +587,9 @@ class ClaudeAccountService {
// 扩展信息 // 扩展信息
extInfo: parsedExtInfo, extInfo: parsedExtInfo,
// 账户级用户消息串行队列限制 // 账户级用户消息串行队列限制
maxConcurrency: parseInt(account.maxConcurrency || '0', 10) maxConcurrency: parseInt(account.maxConcurrency || '0', 10),
// 拦截预热请求
interceptWarmup: account.interceptWarmup === 'true'
} }
}) })
) )
@@ -674,7 +682,8 @@ class ClaudeAccountService {
'unifiedClientId', 'unifiedClientId',
'subscriptionExpiresAt', 'subscriptionExpiresAt',
'extInfo', 'extInfo',
'maxConcurrency' 'maxConcurrency',
'interceptWarmup'
] ]
const updatedData = { ...accountData } const updatedData = { ...accountData }
let shouldClearAutoStopFields = false let shouldClearAutoStopFields = false

View File

@@ -68,7 +68,8 @@ class ClaudeConsoleAccountService {
dailyQuota = 0, // 每日额度限制美元0表示不限制 dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式 quotaResetTime = '00:00', // 额度重置时间HH:mm格式
maxConcurrentTasks = 0, // 最大并发任务数0表示无限制 maxConcurrentTasks = 0, // 最大并发任务数0表示无限制
disableAutoProtection = false // 是否关闭自动防护429/401/400/529 不自动禁用) disableAutoProtection = false, // 是否关闭自动防护429/401/400/529 不自动禁用)
interceptWarmup = false // 拦截预热请求标题生成、Warmup等
} = options } = options
// 验证必填字段 // 验证必填字段
@@ -117,7 +118,8 @@ class ClaudeConsoleAccountService {
quotaResetTime, // 额度重置时间 quotaResetTime, // 额度重置时间
quotaStoppedAt: '', // 因额度停用的时间 quotaStoppedAt: '', // 因额度停用的时间
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数0表示无限制 maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数0表示无限制
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护 disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护
interceptWarmup: interceptWarmup.toString() // 拦截预热请求
} }
const client = redis.getClientSafe() const client = redis.getClientSafe()
@@ -156,6 +158,7 @@ class ClaudeConsoleAccountService {
quotaStoppedAt: null, quotaStoppedAt: null,
maxConcurrentTasks, // 新增:返回并发限制配置 maxConcurrentTasks, // 新增:返回并发限制配置
disableAutoProtection, // 新增:返回自动防护开关 disableAutoProtection, // 新增:返回自动防护开关
interceptWarmup, // 新增:返回预热请求拦截开关
activeTaskCount: 0 // 新增新建账户当前并发数为0 activeTaskCount: 0 // 新增新建账户当前并发数为0
} }
} }
@@ -217,7 +220,9 @@ class ClaudeConsoleAccountService {
// 并发控制相关 // 并发控制相关
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0, maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
activeTaskCount, activeTaskCount,
disableAutoProtection: accountData.disableAutoProtection === 'true' disableAutoProtection: accountData.disableAutoProtection === 'true',
// 拦截预热请求
interceptWarmup: accountData.interceptWarmup === 'true'
}) })
} }
} }
@@ -375,6 +380,9 @@ class ClaudeConsoleAccountService {
if (updates.disableAutoProtection !== undefined) { if (updates.disableAutoProtection !== undefined) {
updatedData.disableAutoProtection = updates.disableAutoProtection.toString() updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
} }
if (updates.interceptWarmup !== undefined) {
updatedData.interceptWarmup = updates.interceptWarmup.toString()
}
// ✅ 直接保存 subscriptionExpiresAt如果提供 // ✅ 直接保存 subscriptionExpiresAt如果提供
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段 // Claude Console 没有 token 刷新逻辑,不会覆盖此字段

View File

@@ -333,7 +333,14 @@ class ClaudeRelayService {
} }
// 发送请求到Claude API传入回调以获取请求对象 // 发送请求到Claude API传入回调以获取请求对象
const response = await this._makeClaudeRequest( // 🔄 403 重试机制:仅对 claude-official 类型账户OAuth 或 Setup Token
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
let retryCount = 0
let response
let shouldRetry = false
do {
response = await this._makeClaudeRequest(
processedBody, processedBody,
accessToken, accessToken,
proxyAgent, proxyAgent,
@@ -345,6 +352,28 @@ class ClaudeRelayService {
options options
) )
// 检查是否需要重试 403
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
if (shouldRetry) {
retryCount++
logger.warn(
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
)
await this._sleep(2000)
}
} while (shouldRetry)
// 如果进行了重试,记录最终结果
if (retryCount > 0) {
if (response.statusCode === 403) {
logger.error(`🚫 403 error persists for account ${accountId} after ${retryCount} retries`)
} else {
logger.info(
`✅ 403 retry successful for account ${accountId} on attempt ${retryCount}, got status ${response.statusCode}`
)
}
}
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成) // 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
// 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻 // 因为 Claude API 限流基于请求发送时刻计算RPM不是请求完成时刻
if (queueLockAcquired && queueRequestId && selectedAccountId) { if (queueLockAcquired && queueRequestId && selectedAccountId) {
@@ -408,9 +437,10 @@ class ClaudeRelayService {
} }
} }
// 检查是否为403状态码禁止访问 // 检查是否为403状态码禁止访问
// 注意如果进行了重试retryCount > 0这里的 403 是重试后最终的结果
else if (response.statusCode === 403) { else if (response.statusCode === 403) {
logger.error( logger.error(
`🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked` `🚫 Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
) )
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
} }
@@ -1517,8 +1547,10 @@ class ClaudeRelayService {
streamTransformer = null, streamTransformer = null,
requestOptions = {}, requestOptions = {},
isDedicatedOfficialAccount = false, isDedicatedOfficialAccount = false,
onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁 onResponseStart = null, // 📬 新增:收到响应头时的回调,用于提前释放队列锁
retryCount = 0 // 🔄 403 重试计数器
) { ) {
const maxRetries = 2 // 最大重试次数
// 获取账户信息用于统一 User-Agent // 获取账户信息用于统一 User-Agent
const account = await claudeAccountService.getAccount(accountId) const account = await claudeAccountService.getAccount(accountId)
@@ -1631,6 +1663,51 @@ class ClaudeRelayService {
} }
} }
// 🔄 403 重试机制(必须在设置 res.on('data')/res.on('end') 之前处理)
// 否则重试时旧响应的 on('end') 会与新请求产生竞态条件
if (res.statusCode === 403) {
const canRetry =
this._shouldRetryOn403(accountType) &&
retryCount < maxRetries &&
!responseStream.headersSent
if (canRetry) {
logger.warn(
`🔄 [Stream] 403 error for account ${accountId}, retry ${retryCount + 1}/${maxRetries} after 2s`
)
// 消费当前响应并销毁请求
res.resume()
req.destroy()
// 等待 2 秒后递归重试
await this._sleep(2000)
try {
// 递归调用自身进行重试
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
body,
accessToken,
proxyAgent,
clientHeaders,
responseStream,
usageCallback,
accountId,
accountType,
sessionHash,
streamTransformer,
requestOptions,
isDedicatedOfficialAccount,
onResponseStart,
retryCount + 1
)
resolve(retryResult)
} catch (retryError) {
reject(retryError)
}
return // 重要:提前返回,不设置后续的错误处理器
}
}
// 将错误处理逻辑封装在一个异步函数中 // 将错误处理逻辑封装在一个异步函数中
const handleErrorResponse = async () => { const handleErrorResponse = async () => {
if (res.statusCode === 401) { if (res.statusCode === 401) {
@@ -1654,8 +1731,10 @@ class ClaudeRelayService {
) )
} }
} else if (res.statusCode === 403) { } else if (res.statusCode === 403) {
// 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked
// 注意:重试逻辑已在 handleErrorResponse 外部提前处理
logger.error( logger.error(
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked` `🚫 [Stream] Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
) )
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
} else if (res.statusCode === 529) { } else if (res.statusCode === 529) {
@@ -2693,6 +2772,17 @@ class ClaudeRelayService {
} }
} }
} }
// 🔄 判断账户是否应该在 403 错误时进行重试
// 仅 claude-official 类型账户OAuth 或 Setup Token 授权)需要重试
_shouldRetryOn403(accountType) {
return accountType === 'claude-official'
}
// ⏱️ 等待指定毫秒数
_sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
} }
module.exports = new ClaudeRelayService() module.exports = new ClaudeRelayService()

View File

@@ -0,0 +1,202 @@
'use strict'
const { v4: uuidv4 } = require('uuid')
/**
* 预热请求拦截器
* 检测并拦截低价值请求标题生成、Warmup等直接返回模拟响应
*/
/**
* 检测是否为预热请求
* @param {Object} body - 请求体
* @returns {boolean}
*/
function isWarmupRequest(body) {
if (!body) {
return false
}
// 检查 messages
if (body.messages && Array.isArray(body.messages)) {
for (const msg of body.messages) {
// 处理 content 为数组的情况
if (Array.isArray(msg.content)) {
for (const content of msg.content) {
if (content.type === 'text' && typeof content.text === 'string') {
if (isTitleOrWarmupText(content.text)) {
return true
}
}
}
}
// 处理 content 为字符串的情况
if (typeof msg.content === 'string') {
if (isTitleOrWarmupText(msg.content)) {
return true
}
}
}
}
// 检查 system prompt
if (body.system) {
const systemText = extractSystemText(body.system)
if (isTitleExtractionSystemPrompt(systemText)) {
return true
}
}
return false
}
/**
* 检查文本是否为标题生成或Warmup请求
*/
function isTitleOrWarmupText(text) {
if (!text) {
return false
}
return (
text.includes('Please write a 5-10 word title for the following conversation:') ||
text === 'Warmup'
)
}
/**
* 检查system prompt是否为标题提取类型
*/
function isTitleExtractionSystemPrompt(systemText) {
if (!systemText) {
return false
}
return systemText.includes(
'nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title'
)
}
/**
* 从system字段提取文本
*/
function extractSystemText(system) {
if (typeof system === 'string') {
return system
}
if (Array.isArray(system)) {
return system.map((s) => (typeof s === 'object' ? s.text || '' : String(s))).join('')
}
return ''
}
/**
* 生成模拟的非流式响应
* @param {string} model - 模型名称
* @returns {Object}
*/
function buildMockWarmupResponse(model) {
return {
id: `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}`,
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'New Conversation' }],
model: model || 'claude-3-5-sonnet-20241022',
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 2
}
}
}
/**
* 发送模拟的流式响应
* @param {Object} res - Express response对象
* @param {string} model - 模型名称
*/
function sendMockWarmupStream(res, model) {
const effectiveModel = model || 'claude-3-5-sonnet-20241022'
const messageId = `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}`
const events = [
{
event: 'message_start',
data: {
message: {
content: [],
id: messageId,
model: effectiveModel,
role: 'assistant',
stop_reason: null,
stop_sequence: null,
type: 'message',
usage: { input_tokens: 10, output_tokens: 0 }
},
type: 'message_start'
}
},
{
event: 'content_block_start',
data: {
content_block: { text: '', type: 'text' },
index: 0,
type: 'content_block_start'
}
},
{
event: 'content_block_delta',
data: {
delta: { text: 'New', type: 'text_delta' },
index: 0,
type: 'content_block_delta'
}
},
{
event: 'content_block_delta',
data: {
delta: { text: ' Conversation', type: 'text_delta' },
index: 0,
type: 'content_block_delta'
}
},
{
event: 'content_block_stop',
data: { index: 0, type: 'content_block_stop' }
},
{
event: 'message_delta',
data: {
delta: { stop_reason: 'end_turn', stop_sequence: null },
type: 'message_delta',
usage: { input_tokens: 10, output_tokens: 2 }
}
},
{
event: 'message_stop',
data: { type: 'message_stop' }
}
]
let index = 0
const sendNext = () => {
if (index >= events.length) {
res.end()
return
}
const { event, data } = events[index]
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
index++
// 模拟网络延迟
setTimeout(sendNext, 20)
}
sendNext()
}
module.exports = {
isWarmupRequest,
buildMockWarmupResponse,
sendMockWarmupStream
}

View File

@@ -1651,6 +1651,28 @@
</label> </label>
</div> </div>
<!-- 拦截预热请求开关Claude 和 Claude Console -->
<div
v-if="form.platform === 'claude' || form.platform === 'claude-console'"
class="mt-4"
>
<label class="flex items-start">
<input
v-model="form.interceptWarmup"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
拦截预热请求
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
启用后对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
</p>
</div>
</label>
</div>
<!-- Claude User-Agent 版本配置 --> <!-- Claude User-Agent 版本配置 -->
<div v-if="form.platform === 'claude'" class="mt-4"> <div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start"> <label class="flex items-start">
@@ -2653,6 +2675,25 @@
</label> </label>
</div> </div>
<!-- 拦截预热请求开关Claude 和 Claude Console 编辑模式) -->
<div v-if="form.platform === 'claude' || form.platform === 'claude-console'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.interceptWarmup"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
拦截预热请求
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
启用后对标题生成、Warmup 等低价值请求直接返回模拟响应,不消耗上游 API 额度
</p>
</div>
</label>
</div>
<!-- Claude User-Agent 版本配置(编辑模式) --> <!-- Claude User-Agent 版本配置(编辑模式) -->
<div v-if="form.platform === 'claude'" class="mt-4"> <div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start"> <label class="flex items-start">
@@ -3988,6 +4029,8 @@ const form = ref({
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识 useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识 unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关 serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关
interceptWarmup:
props.account?.interceptWarmup === true || props.account?.interceptWarmup === 'true', // 拦截预热请求
groupId: '', groupId: '',
groupIds: [], groupIds: [],
projectId: props.account?.projectId || '', projectId: props.account?.projectId || '',
@@ -4574,6 +4617,7 @@ const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
claudeAiOauth: claudeOauthPayload, claudeAiOauth: claudeOauthPayload,
priority: form.value.priority || 50, priority: form.value.priority || 50,
autoStopOnWarning: form.value.autoStopOnWarning || false, autoStopOnWarning: form.value.autoStopOnWarning || false,
interceptWarmup: form.value.interceptWarmup || false,
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false, useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
useUnifiedClientId: form.value.useUnifiedClientId || false, useUnifiedClientId: form.value.useUnifiedClientId || false,
unifiedClientId: clientId, unifiedClientId: clientId,
@@ -5131,6 +5175,7 @@ const createAccount = async () => {
// 上游错误处理(仅 Claude Console // 上游错误处理(仅 Claude Console
if (form.value.platform === 'claude-console') { if (form.value.platform === 'claude-console') {
data.disableAutoProtection = !!form.value.disableAutoProtection data.disableAutoProtection = !!form.value.disableAutoProtection
data.interceptWarmup = !!form.value.interceptWarmup
} }
// 额度管理字段 // 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0 data.dailyQuota = form.value.dailyQuota || 0
@@ -5427,6 +5472,7 @@ const updateAccount = async () => {
data.priority = form.value.priority || 50 data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false data.autoStopOnWarning = form.value.autoStopOnWarning || false
data.interceptWarmup = form.value.interceptWarmup || false
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
data.useUnifiedClientId = form.value.useUnifiedClientId || false data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || '' data.unifiedClientId = form.value.unifiedClientId || ''
@@ -5463,6 +5509,8 @@ const updateAccount = async () => {
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理 // 上游错误处理
data.disableAutoProtection = !!form.value.disableAutoProtection data.disableAutoProtection = !!form.value.disableAutoProtection
// 拦截预热请求
data.interceptWarmup = !!form.value.interceptWarmup
// 额度管理字段 // 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0 data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00' data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -6031,6 +6079,8 @@ watch(
accountType: newAccount.accountType || 'shared', accountType: newAccount.accountType || 'shared',
subscriptionType: subscriptionType, subscriptionType: subscriptionType,
autoStopOnWarning: newAccount.autoStopOnWarning || false, autoStopOnWarning: newAccount.autoStopOnWarning || false,
interceptWarmup:
newAccount.interceptWarmup === true || newAccount.interceptWarmup === 'true',
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false, useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
useUnifiedClientId: newAccount.useUnifiedClientId || false, useUnifiedClientId: newAccount.useUnifiedClientId || false,
unifiedClientId: newAccount.unifiedClientId || '', unifiedClientId: newAccount.unifiedClientId || '',