mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 19:52:42 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ce0f091c | ||
|
|
67c20fa30e | ||
|
|
671451253f | ||
|
|
0173ab224b | ||
|
|
11fb77c8bd | ||
|
|
3d67f0b124 | ||
|
|
84f19b348b | ||
|
|
8ec8a59b07 | ||
|
|
00d8ac4bec | ||
|
|
ba93ae55a9 | ||
|
|
0994eb346f | ||
|
|
4863a37328 |
@@ -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 方式访问**
|
||||||
|
|||||||
22
src/app.js
22
src/app.js
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1744,9 +1744,13 @@ 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.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||||
|
} else {
|
||||||
|
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
@@ -1778,7 +1782,14 @@ const requestLogger = (req, res, next) => {
|
|||||||
logMetadata
|
logMetadata
|
||||||
)
|
)
|
||||||
} else if (req.originalUrl !== '/health') {
|
} else if (req.originalUrl !== '/health') {
|
||||||
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
|
if (isDebugRoute) {
|
||||||
|
logger.debug(
|
||||||
|
`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
|
||||||
|
logMetadata
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Key相关日志
|
// API Key相关日志
|
||||||
|
|||||||
@@ -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) {
|
||||||
const count = await client.zcard(key)
|
// 跳过 queue 相关的键(它们有各自的清理逻辑)
|
||||||
await client.del(key)
|
if (key.startsWith('concurrency:queue:')) {
|
||||||
totalCleared += count
|
continue
|
||||||
clearedKeys.push({
|
}
|
||||||
key,
|
|
||||||
clearedCount: count
|
// 检查键类型
|
||||||
})
|
const keyType = await client.type(key)
|
||||||
|
if (keyType === 'zset') {
|
||||||
|
const count = await client.zcard(key)
|
||||||
|
await client.del(key)
|
||||||
|
totalCleared += count
|
||||||
|
clearedKeys.push({
|
||||||
|
key,
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组
|
// 如果是分组类型,将账户添加到分组
|
||||||
|
|||||||
@@ -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 平台分组)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 刷新逻辑,不会覆盖此字段
|
||||||
|
|||||||
@@ -333,17 +333,46 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 发送请求到Claude API(传入回调以获取请求对象)
|
// 发送请求到Claude API(传入回调以获取请求对象)
|
||||||
const response = await this._makeClaudeRequest(
|
// 🔄 403 重试机制:仅对 claude-official 类型账户(OAuth 或 Setup Token)
|
||||||
processedBody,
|
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
|
||||||
accessToken,
|
let retryCount = 0
|
||||||
proxyAgent,
|
let response
|
||||||
clientHeaders,
|
let shouldRetry = false
|
||||||
accountId,
|
|
||||||
(req) => {
|
do {
|
||||||
upstreamRequest = req
|
response = await this._makeClaudeRequest(
|
||||||
},
|
processedBody,
|
||||||
options
|
accessToken,
|
||||||
)
|
proxyAgent,
|
||||||
|
clientHeaders,
|
||||||
|
accountId,
|
||||||
|
(req) => {
|
||||||
|
upstreamRequest = req
|
||||||
|
},
|
||||||
|
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),不是请求完成时刻
|
||||||
@@ -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()
|
||||||
|
|||||||
202
src/utils/warmupInterceptor.js
Normal file
202
src/utils/warmupInterceptor.js
Normal 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
|
||||||
|
}
|
||||||
@@ -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 || '',
|
||||||
|
|||||||
Reference in New Issue
Block a user