mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792ba51290 | ||
|
|
74d138a2fb | ||
|
|
b88698191e | ||
|
|
11c38b23d1 | ||
|
|
b2dfc2eb25 | ||
|
|
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`。
|
||||
|
||||
> 💡 **IntelliJ IDEA 用户推荐**:[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE,支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。
|
||||
|
||||
**Gemini CLI 设置环境变量:**
|
||||
|
||||
**方式一(推荐):通过 Gemini Assist API 方式访问**
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -892,6 +892,7 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -3000,6 +3001,7 @@
|
||||
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3081,6 +3083,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3536,6 +3539,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001737",
|
||||
"electron-to-chromium": "^1.5.211",
|
||||
@@ -4423,6 +4427,7 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4479,6 +4484,7 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -7586,6 +7592,7 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -9104,6 +9111,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.2",
|
||||
|
||||
22
src/app.js
22
src/app.js
@@ -581,10 +581,11 @@ class Application {
|
||||
|
||||
const now = Date.now()
|
||||
let totalCleaned = 0
|
||||
let legacyCleaned = 0
|
||||
|
||||
// 使用 Lua 脚本批量清理所有过期项
|
||||
for (const key of keys) {
|
||||
// 跳过非 Sorted Set 类型的键(这些键有各自的清理逻辑)
|
||||
// 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
|
||||
// - concurrency:queue:stats:* 是 Hash 类型
|
||||
// - concurrency:queue:wait_times:* 是 List 类型
|
||||
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
|
||||
@@ -599,11 +600,21 @@ class Application {
|
||||
}
|
||||
|
||||
try {
|
||||
const cleaned = await redis.client.eval(
|
||||
// 使用原子 Lua 脚本:先检查类型,再执行清理
|
||||
// 返回值:0 = 正常清理无删除,1 = 清理后删除空键,-1 = 遗留键已删除
|
||||
const result = await redis.client.eval(
|
||||
`
|
||||
local key = KEYS[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)
|
||||
|
||||
@@ -622,8 +633,10 @@ class Application {
|
||||
key,
|
||||
now
|
||||
)
|
||||
if (cleaned === 1) {
|
||||
if (result === 1) {
|
||||
totalCleaned++
|
||||
} else if (result === -1) {
|
||||
legacyCleaned++
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
|
||||
@@ -633,6 +646,9 @@ class Application {
|
||||
if (totalCleaned > 0) {
|
||||
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
|
||||
}
|
||||
if (legacyCleaned > 0) {
|
||||
logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Concurrency cleanup task failed:', error)
|
||||
}
|
||||
|
||||
@@ -1744,9 +1744,13 @@ const requestLogger = (req, res, next) => {
|
||||
const referer = req.get('Referer') || 'none'
|
||||
|
||||
// 记录请求开始
|
||||
const isDebugRoute = req.originalUrl.includes('event_logging')
|
||||
if (req.originalUrl !== '/health') {
|
||||
// 避免健康检查日志过多
|
||||
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||
if (isDebugRoute) {
|
||||
logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||
} else {
|
||||
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||
}
|
||||
}
|
||||
|
||||
res.on('finish', () => {
|
||||
@@ -1778,7 +1782,14 @@ const requestLogger = (req, res, next) => {
|
||||
logMetadata
|
||||
)
|
||||
} 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相关日志
|
||||
|
||||
@@ -2140,6 +2140,27 @@ class RedisClient {
|
||||
const results = []
|
||||
|
||||
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: 前缀)
|
||||
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')
|
||||
|
||||
@@ -2251,20 +2289,36 @@ class RedisClient {
|
||||
const client = this.getClientSafe()
|
||||
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)
|
||||
|
||||
logger.warn(
|
||||
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries`
|
||||
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries${isLegacy ? ' (legacy key)' : ''}`
|
||||
)
|
||||
|
||||
return {
|
||||
apiKeyId,
|
||||
key,
|
||||
clearedCount: beforeCount,
|
||||
type: keyType,
|
||||
legacy: isLegacy,
|
||||
success: true
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2283,25 +2337,47 @@ class RedisClient {
|
||||
const keys = await client.keys('concurrency:*')
|
||||
|
||||
let totalCleared = 0
|
||||
let legacyCleared = 0
|
||||
const clearedKeys = []
|
||||
|
||||
for (const key of keys) {
|
||||
const count = await client.zcard(key)
|
||||
await client.del(key)
|
||||
totalCleared += count
|
||||
clearedKeys.push({
|
||||
key,
|
||||
clearedCount: count
|
||||
})
|
||||
// 跳过 queue 相关的键(它们有各自的清理逻辑)
|
||||
if (key.startsWith('concurrency:queue:')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查键类型
|
||||
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(
|
||||
`🧹 Force cleared all concurrency: ${keys.length} keys, ${totalCleared} total entries`
|
||||
`🧹 Force cleared all concurrency: ${clearedKeys.length} keys, ${totalCleared} entries, ${legacyCleared} legacy keys`
|
||||
)
|
||||
|
||||
return {
|
||||
keysCleared: keys.length,
|
||||
keysCleared: clearedKeys.length,
|
||||
totalEntriesCleared: totalCleared,
|
||||
legacyKeysCleared: legacyCleared,
|
||||
clearedKeys,
|
||||
success: true
|
||||
}
|
||||
@@ -2329,9 +2405,30 @@ class RedisClient {
|
||||
}
|
||||
|
||||
let totalCleaned = 0
|
||||
let legacyCleaned = 0
|
||||
const cleanedKeys = []
|
||||
|
||||
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)
|
||||
if (cleaned > 0) {
|
||||
@@ -2350,13 +2447,14 @@ class RedisClient {
|
||||
}
|
||||
|
||||
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 {
|
||||
keysProcessed: keys.length,
|
||||
keysCleaned: cleanedKeys.length,
|
||||
totalEntriesCleaned: totalCleaned,
|
||||
legacyKeysRemoved: legacyCleaned,
|
||||
cleanedKeys,
|
||||
success: true
|
||||
}
|
||||
|
||||
@@ -585,7 +585,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
unifiedClientId,
|
||||
expiresAt,
|
||||
extInfo,
|
||||
maxConcurrency
|
||||
maxConcurrency,
|
||||
interceptWarmup
|
||||
} = req.body
|
||||
|
||||
if (!name) {
|
||||
@@ -631,7 +632,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
|
||||
expiresAt: expiresAt || 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,
|
||||
quotaResetTime,
|
||||
maxConcurrentTasks,
|
||||
disableAutoProtection
|
||||
disableAutoProtection,
|
||||
interceptWarmup
|
||||
} = req.body
|
||||
|
||||
if (!name || !apiUrl || !apiKey) {
|
||||
@@ -186,7 +187,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
||||
? Number(maxConcurrentTasks)
|
||||
: 0,
|
||||
disableAutoProtection: normalizedDisableAutoProtection
|
||||
disableAutoProtection: normalizedDisableAutoProtection,
|
||||
interceptWarmup: interceptWarmup === true || interceptWarmup === 'true'
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||
|
||||
@@ -24,6 +24,7 @@ const usageStatsRoutes = require('./usageStats')
|
||||
const systemRoutes = require('./system')
|
||||
const concurrencyRoutes = require('./concurrency')
|
||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||
const syncRoutes = require('./sync')
|
||||
|
||||
// 挂载所有子路由
|
||||
// 使用完整路径的模块(直接挂载到根路径)
|
||||
@@ -39,6 +40,7 @@ router.use('/', usageStatsRoutes)
|
||||
router.use('/', systemRoutes)
|
||||
router.use('/', concurrencyRoutes)
|
||||
router.use('/', claudeRelayConfigRoutes)
|
||||
router.use('/', syncRoutes)
|
||||
|
||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||
router.use('/account-groups', accountGroupsRoutes)
|
||||
|
||||
460
src/routes/admin/sync.js
Normal file
460
src/routes/admin/sync.js
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Admin Routes - Sync / Export (for migration)
|
||||
* Exports account data (including secrets) for safe server-to-server syncing.
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const redis = require('../../models/redis')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const logger = require('../../utils/logger')
|
||||
|
||||
function toBool(value, defaultValue = false) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return defaultValue
|
||||
}
|
||||
if (value === true || value === 'true') {
|
||||
return true
|
||||
}
|
||||
if (value === false || value === 'false') {
|
||||
return false
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
function normalizeProxy(proxy) {
|
||||
if (!proxy || typeof proxy !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const protocol = proxy.protocol || proxy.type || proxy.scheme || ''
|
||||
const host = proxy.host || ''
|
||||
const port = Number(proxy.port || 0)
|
||||
|
||||
if (!protocol || !host || !Number.isFinite(port) || port <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
protocol: String(protocol),
|
||||
host: String(host),
|
||||
port,
|
||||
username: proxy.username ? String(proxy.username) : '',
|
||||
password: proxy.password ? String(proxy.password) : ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildModelMappingFromSupportedModels(supportedModels) {
|
||||
if (!supportedModels) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(supportedModels)) {
|
||||
const mapping = {}
|
||||
for (const model of supportedModels) {
|
||||
if (typeof model === 'string' && model.trim()) {
|
||||
mapping[model.trim()] = model.trim()
|
||||
}
|
||||
}
|
||||
return Object.keys(mapping).length ? mapping : null
|
||||
}
|
||||
|
||||
if (typeof supportedModels === 'object') {
|
||||
const mapping = {}
|
||||
for (const [from, to] of Object.entries(supportedModels)) {
|
||||
if (typeof from === 'string' && typeof to === 'string' && from.trim() && to.trim()) {
|
||||
mapping[from.trim()] = to.trim()
|
||||
}
|
||||
}
|
||||
return Object.keys(mapping).length ? mapping : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function safeParseJson(raw, fallback = null) {
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return fallback
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch (_) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Export accounts for migration (includes secrets).
|
||||
// GET /admin/sync/export-accounts?include_secrets=true
|
||||
router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const includeSecrets = toBool(req.query.include_secrets, false)
|
||||
if (!includeSecrets) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'include_secrets_required',
|
||||
message: 'Set include_secrets=true to export secrets'
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Claude official OAuth / Setup Token accounts =====
|
||||
const rawClaudeAccounts = await redis.getAllClaudeAccounts()
|
||||
const claudeAccounts = rawClaudeAccounts.map((account) => {
|
||||
// Backward compatible extraction: prefer individual fields, fallback to claudeAiOauth JSON blob.
|
||||
let decryptedClaudeAiOauth = null
|
||||
if (account.claudeAiOauth) {
|
||||
try {
|
||||
const raw = claudeAccountService._decryptSensitiveData(account.claudeAiOauth)
|
||||
decryptedClaudeAiOauth = raw ? JSON.parse(raw) : null
|
||||
} catch (_) {
|
||||
decryptedClaudeAiOauth = null
|
||||
}
|
||||
}
|
||||
|
||||
const rawScopes =
|
||||
account.scopes && account.scopes.trim()
|
||||
? account.scopes
|
||||
: decryptedClaudeAiOauth?.scopes
|
||||
? decryptedClaudeAiOauth.scopes.join(' ')
|
||||
: ''
|
||||
|
||||
const scopes = rawScopes && rawScopes.trim() ? rawScopes.trim().split(' ') : []
|
||||
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||
const authType = isOAuth ? 'oauth' : 'setup-token'
|
||||
|
||||
const accessToken =
|
||||
account.accessToken && String(account.accessToken).trim()
|
||||
? claudeAccountService._decryptSensitiveData(account.accessToken)
|
||||
: decryptedClaudeAiOauth?.accessToken || ''
|
||||
|
||||
const refreshToken =
|
||||
account.refreshToken && String(account.refreshToken).trim()
|
||||
? claudeAccountService._decryptSensitiveData(account.refreshToken)
|
||||
: decryptedClaudeAiOauth?.refreshToken || ''
|
||||
|
||||
let expiresAt = null
|
||||
const expiresAtMs = Number.parseInt(account.expiresAt, 10)
|
||||
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0) {
|
||||
expiresAt = new Date(expiresAtMs).toISOString()
|
||||
} else if (decryptedClaudeAiOauth?.expiresAt) {
|
||||
try {
|
||||
expiresAt = new Date(Number(decryptedClaudeAiOauth.expiresAt)).toISOString()
|
||||
} catch (_) {
|
||||
expiresAt = null
|
||||
}
|
||||
}
|
||||
|
||||
const proxy = account.proxy ? normalizeProxy(safeParseJson(account.proxy)) : null
|
||||
|
||||
// 🔧 Parse subscriptionInfo to extract org_uuid and account_uuid
|
||||
let orgUuid = null
|
||||
let accountUuid = null
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const subscriptionInfo = JSON.parse(account.subscriptionInfo)
|
||||
orgUuid = subscriptionInfo.organizationUuid || null
|
||||
accountUuid = subscriptionInfo.accountUuid || null
|
||||
} catch (_) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 Calculate expires_in from expires_at
|
||||
let expiresIn = null
|
||||
if (expiresAt) {
|
||||
try {
|
||||
const expiresAtTime = new Date(expiresAt).getTime()
|
||||
const nowTime = Date.now()
|
||||
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||
if (diffSeconds > 0) {
|
||||
expiresIn = diffSeconds
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore calculation errors
|
||||
}
|
||||
}
|
||||
// 🔧 Use default expires_in if calculation failed (Anthropic OAuth: 8 hours)
|
||||
if (!expiresIn && isOAuth) {
|
||||
expiresIn = 28800 // 8 hours
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken || undefined,
|
||||
expires_at: expiresAt || undefined,
|
||||
expires_in: expiresIn || undefined,
|
||||
scope: scopes.join(' ') || undefined,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
// 🔧 Add auth info as top-level credentials fields
|
||||
if (orgUuid) {
|
||||
credentials.org_uuid = orgUuid
|
||||
}
|
||||
if (accountUuid) {
|
||||
credentials.account_uuid = accountUuid
|
||||
}
|
||||
|
||||
// 🔧 Store complete original CRS data in extra
|
||||
const extra = {
|
||||
crs_account_id: account.id,
|
||||
crs_kind: 'claude-account',
|
||||
crs_id: account.id,
|
||||
crs_name: account.name,
|
||||
crs_description: account.description || '',
|
||||
crs_platform: account.platform || 'claude',
|
||||
crs_auth_type: authType,
|
||||
crs_is_active: account.isActive === 'true',
|
||||
crs_schedulable: account.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||
crs_status: account.status || 'active',
|
||||
crs_scopes: scopes,
|
||||
crs_subscription_info: account.subscriptionInfo || undefined
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'claude-account',
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
description: account.description || '',
|
||||
platform: account.platform || 'claude',
|
||||
authType,
|
||||
isActive: account.isActive === 'true',
|
||||
schedulable: account.schedulable !== 'false',
|
||||
priority: Number.parseInt(account.priority, 10) || 50,
|
||||
status: account.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra
|
||||
}
|
||||
})
|
||||
|
||||
// ===== Claude Console API Key accounts =====
|
||||
const claudeConsoleSummaries = await claudeConsoleAccountService.getAllAccounts()
|
||||
const claudeConsoleAccounts = []
|
||||
for (const summary of claudeConsoleSummaries) {
|
||||
const full = await claudeConsoleAccountService.getAccount(summary.id)
|
||||
if (!full) {
|
||||
continue
|
||||
}
|
||||
|
||||
const proxy = normalizeProxy(full.proxy)
|
||||
const modelMapping = buildModelMappingFromSupportedModels(full.supportedModels)
|
||||
|
||||
const credentials = {
|
||||
api_key: full.apiKey,
|
||||
base_url: full.apiUrl
|
||||
}
|
||||
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
}
|
||||
|
||||
if (full.userAgent) {
|
||||
credentials.user_agent = full.userAgent
|
||||
}
|
||||
|
||||
claudeConsoleAccounts.push({
|
||||
kind: 'claude-console-account',
|
||||
id: full.id,
|
||||
name: full.name,
|
||||
description: full.description || '',
|
||||
platform: full.platform || 'claude-console',
|
||||
isActive: full.isActive === true,
|
||||
schedulable: full.schedulable !== false,
|
||||
priority: Number.parseInt(full.priority, 10) || 50,
|
||||
status: full.status || 'active',
|
||||
proxy,
|
||||
maxConcurrentTasks: Number.parseInt(full.maxConcurrentTasks, 10) || 0,
|
||||
credentials,
|
||||
extra: {
|
||||
crs_account_id: full.id,
|
||||
crs_kind: 'claude-console-account',
|
||||
crs_id: full.id,
|
||||
crs_name: full.name,
|
||||
crs_description: full.description || '',
|
||||
crs_platform: full.platform || 'claude-console',
|
||||
crs_is_active: full.isActive === true,
|
||||
crs_schedulable: full.schedulable !== false,
|
||||
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||
crs_status: full.status || 'active'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== OpenAI OAuth accounts =====
|
||||
const openaiOAuthAccounts = []
|
||||
{
|
||||
const client = redis.getClientSafe()
|
||||
const openaiKeys = await client.keys('openai:account:*')
|
||||
for (const key of openaiKeys) {
|
||||
const id = key.split(':').slice(2).join(':')
|
||||
const account = await openaiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
continue
|
||||
}
|
||||
|
||||
const accessToken = account.accessToken
|
||||
? openaiAccountService.decrypt(account.accessToken)
|
||||
: ''
|
||||
if (!accessToken) {
|
||||
// Skip broken/legacy records without decryptable token
|
||||
continue
|
||||
}
|
||||
|
||||
const scopes =
|
||||
account.scopes && typeof account.scopes === 'string' && account.scopes.trim()
|
||||
? account.scopes.trim().split(' ')
|
||||
: []
|
||||
|
||||
const proxy = normalizeProxy(account.proxy)
|
||||
|
||||
// 🔧 Calculate expires_in from expires_at
|
||||
let expiresIn = null
|
||||
if (account.expiresAt) {
|
||||
try {
|
||||
const expiresAtTime = new Date(account.expiresAt).getTime()
|
||||
const nowTime = Date.now()
|
||||
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||
if (diffSeconds > 0) {
|
||||
expiresIn = diffSeconds
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore calculation errors
|
||||
}
|
||||
}
|
||||
// 🔧 Use default expires_in if calculation failed (OpenAI OAuth: 10 days)
|
||||
if (!expiresIn) {
|
||||
expiresIn = 864000 // 10 days
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
access_token: accessToken,
|
||||
refresh_token: account.refreshToken || undefined,
|
||||
id_token: account.idToken || undefined,
|
||||
expires_at: account.expiresAt || undefined,
|
||||
expires_in: expiresIn || undefined,
|
||||
scope: scopes.join(' ') || undefined,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
// 🔧 Add auth info as top-level credentials fields
|
||||
if (account.accountId) {
|
||||
credentials.chatgpt_account_id = account.accountId
|
||||
}
|
||||
if (account.chatgptUserId) {
|
||||
credentials.chatgpt_user_id = account.chatgptUserId
|
||||
}
|
||||
if (account.organizationId) {
|
||||
credentials.organization_id = account.organizationId
|
||||
}
|
||||
|
||||
// 🔧 Store complete original CRS data in extra
|
||||
const extra = {
|
||||
crs_account_id: account.id,
|
||||
crs_kind: 'openai-oauth-account',
|
||||
crs_id: account.id,
|
||||
crs_name: account.name,
|
||||
crs_description: account.description || '',
|
||||
crs_platform: account.platform || 'openai',
|
||||
crs_is_active: account.isActive === 'true',
|
||||
crs_schedulable: account.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||
crs_status: account.status || 'active',
|
||||
crs_scopes: scopes,
|
||||
crs_email: account.email || undefined,
|
||||
crs_chatgpt_account_id: account.accountId || undefined,
|
||||
crs_chatgpt_user_id: account.chatgptUserId || undefined,
|
||||
crs_organization_id: account.organizationId || undefined
|
||||
}
|
||||
|
||||
openaiOAuthAccounts.push({
|
||||
kind: 'openai-oauth-account',
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
description: account.description || '',
|
||||
platform: account.platform || 'openai',
|
||||
authType: 'oauth',
|
||||
isActive: account.isActive === 'true',
|
||||
schedulable: account.schedulable !== 'false',
|
||||
priority: Number.parseInt(account.priority, 10) || 50,
|
||||
status: account.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ===== OpenAI Responses API Key accounts =====
|
||||
const openaiResponsesAccounts = []
|
||||
const client = redis.getClientSafe()
|
||||
const openaiResponseKeys = await client.keys('openai_responses_account:*')
|
||||
for (const key of openaiResponseKeys) {
|
||||
const id = key.split(':').slice(1).join(':')
|
||||
const full = await openaiResponsesAccountService.getAccount(id)
|
||||
if (!full) {
|
||||
continue
|
||||
}
|
||||
|
||||
const proxy = normalizeProxy(full.proxy)
|
||||
|
||||
const credentials = {
|
||||
api_key: full.apiKey,
|
||||
base_url: full.baseApi
|
||||
}
|
||||
|
||||
if (full.userAgent) {
|
||||
credentials.user_agent = full.userAgent
|
||||
}
|
||||
|
||||
openaiResponsesAccounts.push({
|
||||
kind: 'openai-responses-account',
|
||||
id: full.id,
|
||||
name: full.name,
|
||||
description: full.description || '',
|
||||
platform: full.platform || 'openai-responses',
|
||||
isActive: full.isActive === 'true',
|
||||
schedulable: full.schedulable !== 'false',
|
||||
priority: Number.parseInt(full.priority, 10) || 50,
|
||||
status: full.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra: {
|
||||
crs_account_id: full.id,
|
||||
crs_kind: 'openai-responses-account',
|
||||
crs_id: full.id,
|
||||
crs_name: full.name,
|
||||
crs_description: full.description || '',
|
||||
crs_platform: full.platform || 'openai-responses',
|
||||
crs_is_active: full.isActive === 'true',
|
||||
crs_schedulable: full.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||
crs_status: full.status || 'active'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
claudeAccounts,
|
||||
claudeConsoleAccounts,
|
||||
openaiOAuthAccounts,
|
||||
openaiResponsesAccounts
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to export accounts for sync:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'export_failed',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -12,6 +12,13 @@ const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelH
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
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 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') {
|
||||
// 官方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
|
||||
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
|
||||
let attempt = 0
|
||||
|
||||
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||
|
||||
const processRequest = async () => {
|
||||
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
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.handleMessagesRequest = handleMessagesRequest
|
||||
|
||||
@@ -92,7 +92,8 @@ class ClaudeAccountService {
|
||||
unifiedClientId = '', // 统一的客户端标识
|
||||
expiresAt = null, // 账户订阅到期时间
|
||||
extInfo = null, // 额外扩展信息
|
||||
maxConcurrency = 0 // 账户级用户消息串行队列:0=使用全局配置,>0=强制启用串行
|
||||
maxConcurrency = 0, // 账户级用户消息串行队列:0=使用全局配置,>0=强制启用串行
|
||||
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||
} = options
|
||||
|
||||
const accountId = uuidv4()
|
||||
@@ -139,7 +140,9 @@ class ClaudeAccountService {
|
||||
// 扩展信息
|
||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||
// 账户级用户消息串行队列限制
|
||||
maxConcurrency: maxConcurrency.toString()
|
||||
maxConcurrency: maxConcurrency.toString(),
|
||||
// 拦截预热请求
|
||||
interceptWarmup: interceptWarmup.toString()
|
||||
}
|
||||
} else {
|
||||
// 兼容旧格式
|
||||
@@ -173,7 +176,9 @@ class ClaudeAccountService {
|
||||
// 扩展信息
|
||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||
// 账户级用户消息串行队列限制
|
||||
maxConcurrency: maxConcurrency.toString()
|
||||
maxConcurrency: maxConcurrency.toString(),
|
||||
// 拦截预热请求
|
||||
interceptWarmup: interceptWarmup.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +226,8 @@ class ClaudeAccountService {
|
||||
useUnifiedUserAgent,
|
||||
useUnifiedClientId,
|
||||
unifiedClientId,
|
||||
extInfo: normalizedExtInfo
|
||||
extInfo: normalizedExtInfo,
|
||||
interceptWarmup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,7 +587,9 @@ class ClaudeAccountService {
|
||||
// 扩展信息
|
||||
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',
|
||||
'subscriptionExpiresAt',
|
||||
'extInfo',
|
||||
'maxConcurrency'
|
||||
'maxConcurrency',
|
||||
'interceptWarmup'
|
||||
]
|
||||
const updatedData = { ...accountData }
|
||||
let shouldClearAutoStopFields = false
|
||||
|
||||
@@ -68,7 +68,8 @@ class ClaudeConsoleAccountService {
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||
maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制
|
||||
disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||
disableAutoProtection = false, // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
@@ -117,7 +118,8 @@ class ClaudeConsoleAccountService {
|
||||
quotaResetTime, // 额度重置时间
|
||||
quotaStoppedAt: '', // 因额度停用的时间
|
||||
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
||||
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
||||
disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护
|
||||
interceptWarmup: interceptWarmup.toString() // 拦截预热请求
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
@@ -156,6 +158,7 @@ class ClaudeConsoleAccountService {
|
||||
quotaStoppedAt: null,
|
||||
maxConcurrentTasks, // 新增:返回并发限制配置
|
||||
disableAutoProtection, // 新增:返回自动防护开关
|
||||
interceptWarmup, // 新增:返回预热请求拦截开关
|
||||
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
||||
}
|
||||
}
|
||||
@@ -217,7 +220,9 @@ class ClaudeConsoleAccountService {
|
||||
// 并发控制相关
|
||||
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
||||
activeTaskCount,
|
||||
disableAutoProtection: accountData.disableAutoProtection === 'true'
|
||||
disableAutoProtection: accountData.disableAutoProtection === 'true',
|
||||
// 拦截预热请求
|
||||
interceptWarmup: accountData.interceptWarmup === 'true'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -375,6 +380,9 @@ class ClaudeConsoleAccountService {
|
||||
if (updates.disableAutoProtection !== undefined) {
|
||||
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||
}
|
||||
if (updates.interceptWarmup !== undefined) {
|
||||
updatedData.interceptWarmup = updates.interceptWarmup.toString()
|
||||
}
|
||||
|
||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
||||
|
||||
@@ -333,17 +333,46 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 发送请求到Claude API(传入回调以获取请求对象)
|
||||
const response = await this._makeClaudeRequest(
|
||||
processedBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
accountId,
|
||||
(req) => {
|
||||
upstreamRequest = req
|
||||
},
|
||||
options
|
||||
)
|
||||
// 🔄 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,
|
||||
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),不是请求完成时刻
|
||||
@@ -408,9 +437,10 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
// 检查是否为403状态码(禁止访问)
|
||||
// 注意:如果进行了重试,retryCount > 0;这里的 403 是重试后最终的结果
|
||||
else if (response.statusCode === 403) {
|
||||
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)
|
||||
}
|
||||
@@ -1517,8 +1547,10 @@ class ClaudeRelayService {
|
||||
streamTransformer = null,
|
||||
requestOptions = {},
|
||||
isDedicatedOfficialAccount = false,
|
||||
onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁
|
||||
onResponseStart = null, // 📬 新增:收到响应头时的回调,用于提前释放队列锁
|
||||
retryCount = 0 // 🔄 403 重试计数器
|
||||
) {
|
||||
const maxRetries = 2 // 最大重试次数
|
||||
// 获取账户信息用于统一 User-Agent
|
||||
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 () => {
|
||||
if (res.statusCode === 401) {
|
||||
@@ -1654,8 +1731,10 @@ class ClaudeRelayService {
|
||||
)
|
||||
}
|
||||
} else if (res.statusCode === 403) {
|
||||
// 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked
|
||||
// 注意:重试逻辑已在 handleErrorResponse 外部提前处理
|
||||
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)
|
||||
} 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()
|
||||
|
||||
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>
|
||||
</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 版本配置 -->
|
||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||
<label class="flex items-start">
|
||||
@@ -2653,6 +2675,25 @@
|
||||
</label>
|
||||
</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 版本配置(编辑模式) -->
|
||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||
<label class="flex items-start">
|
||||
@@ -3988,6 +4029,8 @@ const form = ref({
|
||||
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
|
||||
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
|
||||
serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关
|
||||
interceptWarmup:
|
||||
props.account?.interceptWarmup === true || props.account?.interceptWarmup === 'true', // 拦截预热请求
|
||||
groupId: '',
|
||||
groupIds: [],
|
||||
projectId: props.account?.projectId || '',
|
||||
@@ -4574,6 +4617,7 @@ const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
|
||||
claudeAiOauth: claudeOauthPayload,
|
||||
priority: form.value.priority || 50,
|
||||
autoStopOnWarning: form.value.autoStopOnWarning || false,
|
||||
interceptWarmup: form.value.interceptWarmup || false,
|
||||
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
|
||||
useUnifiedClientId: form.value.useUnifiedClientId || false,
|
||||
unifiedClientId: clientId,
|
||||
@@ -5131,6 +5175,7 @@ const createAccount = async () => {
|
||||
// 上游错误处理(仅 Claude Console)
|
||||
if (form.value.platform === 'claude-console') {
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
data.interceptWarmup = !!form.value.interceptWarmup
|
||||
}
|
||||
// 额度管理字段
|
||||
data.dailyQuota = form.value.dailyQuota || 0
|
||||
@@ -5427,6 +5472,7 @@ const updateAccount = async () => {
|
||||
|
||||
data.priority = form.value.priority || 50
|
||||
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
||||
data.interceptWarmup = form.value.interceptWarmup || false
|
||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||
@@ -5463,6 +5509,8 @@ const updateAccount = async () => {
|
||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||
// 上游错误处理
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
// 拦截预热请求
|
||||
data.interceptWarmup = !!form.value.interceptWarmup
|
||||
// 额度管理字段
|
||||
data.dailyQuota = form.value.dailyQuota || 0
|
||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||
@@ -6031,6 +6079,8 @@ watch(
|
||||
accountType: newAccount.accountType || 'shared',
|
||||
subscriptionType: subscriptionType,
|
||||
autoStopOnWarning: newAccount.autoStopOnWarning || false,
|
||||
interceptWarmup:
|
||||
newAccount.interceptWarmup === true || newAccount.interceptWarmup === 'true',
|
||||
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
|
||||
useUnifiedClientId: newAccount.useUnifiedClientId || false,
|
||||
unifiedClientId: newAccount.unifiedClientId || '',
|
||||
|
||||
Reference in New Issue
Block a user