mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
698f3d7daa | ||
|
|
5af5e55d80 | ||
|
|
5a18f54abd | ||
|
|
6d3b51510a | ||
|
|
c79fdc4d71 | ||
|
|
659072075d | ||
|
|
cf93128a96 | ||
|
|
909b5ad37f | ||
|
|
bab7073822 |
@@ -6,6 +6,8 @@ const logger = require('../utils/logger')
|
||||
const redis = require('../models/redis')
|
||||
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
||||
const ClientValidator = require('../validators/clientValidator')
|
||||
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||
|
||||
const FALLBACK_CONCURRENCY_CONFIG = {
|
||||
leaseSeconds: 300,
|
||||
@@ -201,6 +203,53 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 🔒 检查全局 Claude Code 限制(与 API Key 级别是 OR 逻辑)
|
||||
// 仅对 Claude 服务端点生效 (/api/v1/messages 和 /claude/v1/messages)
|
||||
if (!skipKeyRestrictions) {
|
||||
const normalizedPath = (req.originalUrl || req.path || '').toLowerCase()
|
||||
const isClaudeMessagesEndpoint =
|
||||
normalizedPath.includes('/v1/messages') &&
|
||||
(normalizedPath.startsWith('/api') || normalizedPath.startsWith('/claude'))
|
||||
|
||||
if (isClaudeMessagesEndpoint) {
|
||||
try {
|
||||
const globalClaudeCodeOnly = await claudeRelayConfigService.isClaudeCodeOnlyEnabled()
|
||||
|
||||
// API Key 级别的 Claude Code 限制
|
||||
const keyClaudeCodeOnly =
|
||||
validation.keyData.enableClientRestriction &&
|
||||
Array.isArray(validation.keyData.allowedClients) &&
|
||||
validation.keyData.allowedClients.length === 1 &&
|
||||
validation.keyData.allowedClients.includes('claude_code')
|
||||
|
||||
// OR 逻辑:全局开启 或 API Key 级别限制为仅 claude_code
|
||||
if (globalClaudeCodeOnly || keyClaudeCodeOnly) {
|
||||
const isClaudeCode = ClaudeCodeValidator.validate(req)
|
||||
|
||||
if (!isClaudeCode) {
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
logger.api(
|
||||
`❌ Claude Code client validation failed (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly}) from ${clientIP}`
|
||||
)
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'client_validation_error',
|
||||
message: 'This endpoint only accepts requests from Claude Code CLI'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.api(
|
||||
`✅ Claude Code client validated (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly})`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Error checking Claude Code restriction:', error)
|
||||
// 配置服务出错时不阻断请求
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并发限制
|
||||
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
||||
if (!skipKeyRestrictions && concurrencyLimit > 0) {
|
||||
|
||||
@@ -919,6 +919,62 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
||||
// 去重(避免日数据和月数据重复计算)
|
||||
const uniqueKeys = [...new Set(allKeys)]
|
||||
|
||||
// 获取实时限制数据(窗口数据不受时间范围筛选影响,始终获取当前窗口状态)
|
||||
let dailyCost = 0
|
||||
let currentWindowCost = 0
|
||||
let windowRemainingSeconds = null
|
||||
let windowStartTime = null
|
||||
let windowEndTime = null
|
||||
let allTimeCost = 0
|
||||
|
||||
try {
|
||||
// 先获取 API Key 配置,判断是否需要查询限制相关数据
|
||||
const apiKey = await redis.getApiKey(keyId)
|
||||
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
|
||||
const dailyCostLimit = parseFloat(apiKey?.dailyCostLimit) || 0
|
||||
const totalCostLimit = parseFloat(apiKey?.totalCostLimit) || 0
|
||||
|
||||
// 只在启用了每日费用限制时查询
|
||||
if (dailyCostLimit > 0) {
|
||||
dailyCost = await redis.getDailyCost(keyId)
|
||||
}
|
||||
|
||||
// 只在启用了总费用限制时查询
|
||||
if (totalCostLimit > 0) {
|
||||
const totalCostKey = `usage:cost:total:${keyId}`
|
||||
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
||||
}
|
||||
|
||||
// 只在启用了窗口限制时查询窗口数据
|
||||
if (rateLimitWindow > 0) {
|
||||
const costCountKey = `rate_limit:cost:${keyId}`
|
||||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||||
|
||||
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
||||
|
||||
// 获取窗口开始时间和计算剩余时间
|
||||
const windowStart = await client.get(windowStartKey)
|
||||
if (windowStart) {
|
||||
const now = Date.now()
|
||||
windowStartTime = parseInt(windowStart)
|
||||
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
|
||||
windowEndTime = windowStartTime + windowDuration
|
||||
|
||||
// 如果窗口还有效
|
||||
if (now < windowEndTime) {
|
||||
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
||||
} else {
|
||||
// 窗口已过期
|
||||
windowRemainingSeconds = 0
|
||||
currentWindowCost = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
||||
}
|
||||
|
||||
// 如果没有使用数据,返回零值但包含窗口数据
|
||||
if (uniqueKeys.length === 0) {
|
||||
return {
|
||||
requests: 0,
|
||||
@@ -928,7 +984,14 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 0,
|
||||
formattedCost: '$0.00'
|
||||
formattedCost: '$0.00',
|
||||
// 实时限制数据(始终返回,不受时间范围影响)
|
||||
dailyCost,
|
||||
currentWindowCost,
|
||||
windowRemainingSeconds,
|
||||
windowStartTime,
|
||||
windowEndTime,
|
||||
allTimeCost
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1029,55 +1092,6 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
||||
|
||||
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
// 获取实时限制数据
|
||||
let dailyCost = 0
|
||||
let currentWindowCost = 0
|
||||
let windowRemainingSeconds = null
|
||||
let windowStartTime = null
|
||||
let windowEndTime = null
|
||||
let allTimeCost = 0
|
||||
|
||||
try {
|
||||
// 获取当日费用
|
||||
dailyCost = await redis.getDailyCost(keyId)
|
||||
|
||||
// 获取历史总费用(用于总费用限制进度条,不受时间范围影响)
|
||||
const totalCostKey = `usage:cost:total:${keyId}`
|
||||
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
||||
|
||||
// 获取 API Key 配置信息以判断是否需要窗口数据
|
||||
const apiKey = await redis.getApiKey(keyId)
|
||||
// 显式转换为整数,与 apiStats.js 保持一致,避免字符串比较问题
|
||||
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
|
||||
|
||||
if (rateLimitWindow > 0) {
|
||||
const costCountKey = `rate_limit:cost:${keyId}`
|
||||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||||
|
||||
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
||||
|
||||
// 获取窗口开始时间和计算剩余时间
|
||||
const windowStart = await client.get(windowStartKey)
|
||||
if (windowStart) {
|
||||
const now = Date.now()
|
||||
windowStartTime = parseInt(windowStart)
|
||||
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
|
||||
windowEndTime = windowStartTime + windowDuration
|
||||
|
||||
// 如果窗口还有效
|
||||
if (now < windowEndTime) {
|
||||
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
||||
} else {
|
||||
// 窗口已过期
|
||||
windowRemainingSeconds = 0
|
||||
currentWindowCost = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
||||
}
|
||||
|
||||
return {
|
||||
requests: totalRequests,
|
||||
tokens,
|
||||
|
||||
130
src/routes/admin/claudeRelayConfig.js
Normal file
130
src/routes/admin/claudeRelayConfig.js
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Claude 转发配置 API 路由
|
||||
* 管理全局 Claude Code 限制和会话绑定配置
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const claudeRelayConfigService = require('../../services/claudeRelayConfigService')
|
||||
const logger = require('../../utils/logger')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
/**
|
||||
* GET /admin/claude-relay-config
|
||||
* 获取 Claude 转发配置
|
||||
*/
|
||||
router.get('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await claudeRelayConfigService.getConfig()
|
||||
return res.json({
|
||||
success: true,
|
||||
config
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude relay config:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get configuration',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* PUT /admin/claude-relay-config
|
||||
* 更新 Claude 转发配置
|
||||
*/
|
||||
router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
claudeCodeOnlyEnabled,
|
||||
globalSessionBindingEnabled,
|
||||
sessionBindingErrorMessage,
|
||||
sessionBindingTtlDays
|
||||
} = req.body
|
||||
|
||||
// 验证输入
|
||||
if (claudeCodeOnlyEnabled !== undefined && typeof claudeCodeOnlyEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'claudeCodeOnlyEnabled must be a boolean' })
|
||||
}
|
||||
|
||||
if (
|
||||
globalSessionBindingEnabled !== undefined &&
|
||||
typeof globalSessionBindingEnabled !== 'boolean'
|
||||
) {
|
||||
return res.status(400).json({ error: 'globalSessionBindingEnabled must be a boolean' })
|
||||
}
|
||||
|
||||
if (sessionBindingErrorMessage !== undefined) {
|
||||
if (typeof sessionBindingErrorMessage !== 'string') {
|
||||
return res.status(400).json({ error: 'sessionBindingErrorMessage must be a string' })
|
||||
}
|
||||
if (sessionBindingErrorMessage.length > 500) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'sessionBindingErrorMessage must be less than 500 characters' })
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionBindingTtlDays !== undefined) {
|
||||
if (
|
||||
typeof sessionBindingTtlDays !== 'number' ||
|
||||
sessionBindingTtlDays < 1 ||
|
||||
sessionBindingTtlDays > 365
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'sessionBindingTtlDays must be a number between 1 and 365' })
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {}
|
||||
if (claudeCodeOnlyEnabled !== undefined)
|
||||
updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled
|
||||
if (globalSessionBindingEnabled !== undefined)
|
||||
updateData.globalSessionBindingEnabled = globalSessionBindingEnabled
|
||||
if (sessionBindingErrorMessage !== undefined)
|
||||
updateData.sessionBindingErrorMessage = sessionBindingErrorMessage
|
||||
if (sessionBindingTtlDays !== undefined)
|
||||
updateData.sessionBindingTtlDays = sessionBindingTtlDays
|
||||
|
||||
const updatedConfig = await claudeRelayConfigService.updateConfig(
|
||||
updateData,
|
||||
req.admin?.username || 'unknown'
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Configuration updated successfully',
|
||||
config: updatedConfig
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update Claude relay config:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to update configuration',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /admin/claude-relay-config/session-bindings
|
||||
* 获取会话绑定统计
|
||||
*/
|
||||
router.get('/claude-relay-config/session-bindings', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const stats = await claudeRelayConfigService.getSessionBindingStats()
|
||||
return res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get session binding stats:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get session binding statistics',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -23,6 +23,7 @@ const dashboardRoutes = require('./dashboard')
|
||||
const usageStatsRoutes = require('./usageStats')
|
||||
const systemRoutes = require('./system')
|
||||
const concurrencyRoutes = require('./concurrency')
|
||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||
|
||||
// 挂载所有子路由
|
||||
// 使用完整路径的模块(直接挂载到根路径)
|
||||
@@ -37,6 +38,7 @@ router.use('/', dashboardRoutes)
|
||||
router.use('/', usageStatsRoutes)
|
||||
router.use('/', systemRoutes)
|
||||
router.use('/', concurrencyRoutes)
|
||||
router.use('/', claudeRelayConfigRoutes)
|
||||
|
||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||
router.use('/account-groups', accountGroupsRoutes)
|
||||
|
||||
@@ -11,6 +11,7 @@ const logger = require('../utils/logger')
|
||||
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
||||
const router = express.Router()
|
||||
|
||||
@@ -141,6 +142,56 @@ async function handleMessagesRequest(req, res) {
|
||||
// 生成会话哈希用于sticky会话
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 🔒 全局会话绑定验证
|
||||
let forcedAccount = null
|
||||
let needSessionBinding = false
|
||||
let originalSessionIdForBinding = null
|
||||
|
||||
try {
|
||||
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
|
||||
|
||||
if (globalBindingEnabled) {
|
||||
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||
|
||||
if (originalSessionId) {
|
||||
const validation = await claudeRelayConfigService.validateNewSession(
|
||||
req.body,
|
||||
originalSessionId
|
||||
)
|
||||
|
||||
if (!validation.valid) {
|
||||
logger.api(
|
||||
`❌ Session binding validation failed: ${validation.code} for session ${originalSessionId}`
|
||||
)
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'session_binding_error',
|
||||
message: validation.error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 如果已有绑定,使用绑定的账户
|
||||
if (validation.binding) {
|
||||
forcedAccount = validation.binding
|
||||
logger.api(
|
||||
`🔗 Using bound account for session ${originalSessionId}: ${forcedAccount.accountId}`
|
||||
)
|
||||
}
|
||||
|
||||
// 标记需要在调度成功后建立绑定
|
||||
if (validation.isNewSession) {
|
||||
needSessionBinding = true
|
||||
originalSessionIdForBinding = originalSessionId
|
||||
logger.api(`📝 New session detected, will create binding: ${originalSessionId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Error in global session binding check:', error)
|
||||
// 配置服务出错时不阻断请求
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号(传递请求的模型)
|
||||
const requestedModel = req.body.model
|
||||
let accountId
|
||||
@@ -149,10 +200,21 @@ async function handleMessagesRequest(req, res) {
|
||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
requestedModel
|
||||
requestedModel,
|
||||
forcedAccount
|
||||
)
|
||||
;({ accountId, accountType } = selection)
|
||||
} catch (error) {
|
||||
// 处理会话绑定账户不可用的错误
|
||||
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
|
||||
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'session_binding_error',
|
||||
message: errorMessage
|
||||
}
|
||||
})
|
||||
}
|
||||
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
||||
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
||||
error.rateLimitEndAt
|
||||
@@ -170,6 +232,41 @@ async function handleMessagesRequest(req, res) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 🔗 在成功调度后建立会话绑定(仅 claude-official 类型)
|
||||
// claude-official 只接受:1) 新会话(messages.length=1) 2) 已绑定的会话
|
||||
if (
|
||||
needSessionBinding &&
|
||||
originalSessionIdForBinding &&
|
||||
accountId &&
|
||||
accountType === 'claude-official'
|
||||
) {
|
||||
// 🚫 新会话必须 messages.length === 1
|
||||
const messages = req.body?.messages
|
||||
if (messages && messages.length > 1) {
|
||||
const cfg = await claudeRelayConfigService.getConfig()
|
||||
logger.warn(
|
||||
`🚫 New session with messages.length > 1 rejected: sessionId=${originalSessionIdForBinding}, messages.length=${messages.length}`
|
||||
)
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
type: 'session_binding_error',
|
||||
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建绑定
|
||||
try {
|
||||
await claudeRelayConfigService.setOriginalSessionBinding(
|
||||
originalSessionIdForBinding,
|
||||
accountId,
|
||||
accountType
|
||||
)
|
||||
} catch (bindingError) {
|
||||
logger.warn(`⚠️ Failed to create session binding:`, bindingError)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据账号类型选择对应的转发服务并调用
|
||||
if (accountType === 'claude-official') {
|
||||
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||
@@ -503,6 +600,55 @@ async function handleMessagesRequest(req, res) {
|
||||
// 生成会话哈希用于sticky会话
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 🔒 全局会话绑定验证(非流式)
|
||||
let forcedAccountNonStream = null
|
||||
let needSessionBindingNonStream = false
|
||||
let originalSessionIdForBindingNonStream = null
|
||||
|
||||
try {
|
||||
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
|
||||
|
||||
if (globalBindingEnabled) {
|
||||
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||
|
||||
if (originalSessionId) {
|
||||
const validation = await claudeRelayConfigService.validateNewSession(
|
||||
req.body,
|
||||
originalSessionId
|
||||
)
|
||||
|
||||
if (!validation.valid) {
|
||||
logger.api(
|
||||
`❌ Session binding validation failed (non-stream): ${validation.code} for session ${originalSessionId}`
|
||||
)
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'session_binding_error',
|
||||
message: validation.error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (validation.binding) {
|
||||
forcedAccountNonStream = validation.binding
|
||||
logger.api(
|
||||
`🔗 Using bound account for session (non-stream) ${originalSessionId}: ${forcedAccountNonStream.accountId}`
|
||||
)
|
||||
}
|
||||
|
||||
if (validation.isNewSession) {
|
||||
needSessionBindingNonStream = true
|
||||
originalSessionIdForBindingNonStream = originalSessionId
|
||||
logger.api(
|
||||
`📝 New session detected (non-stream), will create binding: ${originalSessionId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Error in global session binding check (non-stream):', error)
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号(传递请求的模型)
|
||||
const requestedModel = req.body.model
|
||||
let accountId
|
||||
@@ -511,10 +657,20 @@ async function handleMessagesRequest(req, res) {
|
||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
requestedModel
|
||||
requestedModel,
|
||||
forcedAccountNonStream
|
||||
)
|
||||
;({ accountId, accountType } = selection)
|
||||
} catch (error) {
|
||||
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
|
||||
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'session_binding_error',
|
||||
message: errorMessage
|
||||
}
|
||||
})
|
||||
}
|
||||
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
||||
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
||||
error.rateLimitEndAt
|
||||
@@ -527,6 +683,41 @@ async function handleMessagesRequest(req, res) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型)
|
||||
// claude-official 只接受:1) 新会话(messages.length=1) 2) 已绑定的会话
|
||||
if (
|
||||
needSessionBindingNonStream &&
|
||||
originalSessionIdForBindingNonStream &&
|
||||
accountId &&
|
||||
accountType === 'claude-official'
|
||||
) {
|
||||
// 🚫 新会话必须 messages.length === 1
|
||||
const messages = req.body?.messages
|
||||
if (messages && messages.length > 1) {
|
||||
const cfg = await claudeRelayConfigService.getConfig()
|
||||
logger.warn(
|
||||
`🚫 New session with messages.length > 1 rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${messages.length}`
|
||||
)
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
type: 'session_binding_error',
|
||||
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建绑定
|
||||
try {
|
||||
await claudeRelayConfigService.setOriginalSessionBinding(
|
||||
originalSessionIdForBindingNonStream,
|
||||
accountId,
|
||||
accountType
|
||||
)
|
||||
} catch (bindingError) {
|
||||
logger.warn(`⚠️ Failed to create session binding (non-stream):`, bindingError)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据账号类型选择对应的转发服务
|
||||
let response
|
||||
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
||||
|
||||
437
src/services/claudeRelayConfigService.js
Normal file
437
src/services/claudeRelayConfigService.js
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Claude 转发配置服务
|
||||
* 管理全局 Claude Code 限制和会话绑定配置
|
||||
*/
|
||||
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
const CONFIG_KEY = 'claude_relay_config'
|
||||
const SESSION_BINDING_PREFIX = 'original_session_binding:'
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG = {
|
||||
claudeCodeOnlyEnabled: false,
|
||||
globalSessionBindingEnabled: false,
|
||||
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||
sessionBindingTtlDays: 30, // 会话绑定 TTL(天),默认30天
|
||||
updatedAt: null,
|
||||
updatedBy: null
|
||||
}
|
||||
|
||||
// 内存缓存(避免频繁 Redis 查询)
|
||||
let configCache = null
|
||||
let configCacheTime = 0
|
||||
const CONFIG_CACHE_TTL = 60000 // 1分钟缓存
|
||||
|
||||
class ClaudeRelayConfigService {
|
||||
/**
|
||||
* 从 metadata.user_id 中提取原始 sessionId
|
||||
* 格式: user_{64位十六进制}_account__session_{uuid}
|
||||
* @param {Object} requestBody - 请求体
|
||||
* @returns {string|null} 原始 sessionId 或 null
|
||||
*/
|
||||
extractOriginalSessionId(requestBody) {
|
||||
if (!requestBody?.metadata?.user_id) {
|
||||
return null
|
||||
}
|
||||
|
||||
const userId = requestBody.metadata.user_id
|
||||
const match = userId.match(/session_([a-f0-9-]{36})$/i)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置(带缓存)
|
||||
* @returns {Promise<Object>} 配置对象
|
||||
*/
|
||||
async getConfig() {
|
||||
try {
|
||||
// 检查缓存
|
||||
if (configCache && Date.now() - configCacheTime < CONFIG_CACHE_TTL) {
|
||||
return configCache
|
||||
}
|
||||
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
logger.warn('⚠️ Redis not connected, using default config')
|
||||
return { ...DEFAULT_CONFIG }
|
||||
}
|
||||
|
||||
const data = await client.get(CONFIG_KEY)
|
||||
|
||||
if (data) {
|
||||
configCache = { ...DEFAULT_CONFIG, ...JSON.parse(data) }
|
||||
} else {
|
||||
configCache = { ...DEFAULT_CONFIG }
|
||||
}
|
||||
|
||||
configCacheTime = Date.now()
|
||||
return configCache
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude relay config:', error)
|
||||
return { ...DEFAULT_CONFIG }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
* @param {Object} newConfig - 新配置
|
||||
* @param {string} updatedBy - 更新者
|
||||
* @returns {Promise<Object>} 更新后的配置
|
||||
*/
|
||||
async updateConfig(newConfig, updatedBy) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const currentConfig = await this.getConfig()
|
||||
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
...newConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy
|
||||
}
|
||||
|
||||
await client.set(CONFIG_KEY, JSON.stringify(updatedConfig))
|
||||
|
||||
// 更新缓存
|
||||
configCache = updatedConfig
|
||||
configCacheTime = Date.now()
|
||||
|
||||
logger.info(`✅ Claude relay config updated by ${updatedBy}:`, {
|
||||
claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled,
|
||||
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled
|
||||
})
|
||||
|
||||
return updatedConfig
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update Claude relay config:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否启用全局 Claude Code 限制
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isClaudeCodeOnlyEnabled() {
|
||||
const cfg = await this.getConfig()
|
||||
return cfg.claudeCodeOnlyEnabled === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否启用全局会话绑定
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isGlobalSessionBindingEnabled() {
|
||||
const cfg = await this.getConfig()
|
||||
return cfg.globalSessionBindingEnabled === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话绑定错误信息
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async getSessionBindingErrorMessage() {
|
||||
const cfg = await this.getConfig()
|
||||
return cfg.sessionBindingErrorMessage || DEFAULT_CONFIG.sessionBindingErrorMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始会话绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @returns {Promise<Object|null>} 绑定信息或 null
|
||||
*/
|
||||
async getOriginalSessionBinding(originalSessionId) {
|
||||
if (!originalSessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
const data = await client.get(key)
|
||||
|
||||
if (data) {
|
||||
return JSON.parse(data)
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get session binding for ${originalSessionId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置原始会话绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} accountType - 账户类型
|
||||
* @returns {Promise<Object>} 绑定信息
|
||||
*/
|
||||
async setOriginalSessionBinding(originalSessionId, accountId, accountType) {
|
||||
if (!originalSessionId || !accountId || !accountType) {
|
||||
throw new Error('Invalid parameters for session binding')
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
|
||||
const binding = {
|
||||
accountId,
|
||||
accountType,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 使用配置的 TTL(默认30天)
|
||||
const cfg = await this.getConfig()
|
||||
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||
|
||||
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||
|
||||
logger.info(
|
||||
`🔗 Session binding created: ${originalSessionId} -> ${accountId} (${accountType})`
|
||||
)
|
||||
|
||||
return binding
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to set session binding for ${originalSessionId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话绑定的最后使用时间(续期)
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
*/
|
||||
async touchOriginalSessionBinding(originalSessionId) {
|
||||
if (!originalSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||
if (!binding) {
|
||||
return
|
||||
}
|
||||
|
||||
binding.lastUsedAt = new Date().toISOString()
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
|
||||
// 使用配置的 TTL(默认30天)
|
||||
const cfg = await this.getConfig()
|
||||
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||
|
||||
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ Failed to touch session binding for ${originalSessionId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查原始会话是否已绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isOriginalSessionBound(originalSessionId) {
|
||||
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||
return binding !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证绑定的账户是否可用
|
||||
* @param {Object} binding - 绑定信息
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async validateBoundAccount(binding) {
|
||||
if (!binding || !binding.accountId || !binding.accountType) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const { accountType } = binding
|
||||
const { accountId } = binding
|
||||
|
||||
let accountService
|
||||
switch (accountType) {
|
||||
case 'claude-official':
|
||||
accountService = require('./claudeAccountService')
|
||||
break
|
||||
case 'claude-console':
|
||||
accountService = require('./claudeConsoleAccountService')
|
||||
break
|
||||
case 'bedrock':
|
||||
accountService = require('./bedrockAccountService')
|
||||
break
|
||||
case 'ccr':
|
||||
accountService = require('./ccrAccountService')
|
||||
break
|
||||
default:
|
||||
logger.warn(`Unknown account type for validation: ${accountType}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const account = await accountService.getAccount(accountId)
|
||||
|
||||
if (!account || !account.success || !account.data) {
|
||||
logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
|
||||
return false
|
||||
}
|
||||
|
||||
const accountData = account.data
|
||||
|
||||
// 检查账户是否激活
|
||||
if (accountData.isActive === false || accountData.isActive === 'false') {
|
||||
logger.warn(
|
||||
`Session binding account not active: ${accountId} (${accountType}), isActive: ${accountData.isActive}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查账户状态(如果存在)
|
||||
if (accountData.status && accountData.status === 'error') {
|
||||
logger.warn(
|
||||
`Session binding account has error status: ${accountId} (${accountType}), status: ${accountData.status}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to validate bound account ${binding.accountId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证新会话请求
|
||||
* @param {Object} requestBody - 请求体
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
|
||||
*/
|
||||
async validateNewSession(requestBody, originalSessionId) {
|
||||
const cfg = await this.getConfig()
|
||||
|
||||
if (!cfg.globalSessionBindingEnabled) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// 如果没有 sessionId,跳过验证(可能是非 Claude Code 客户端)
|
||||
if (!originalSessionId) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const existingBinding = await this.getOriginalSessionBinding(originalSessionId)
|
||||
|
||||
// 如果会话已存在绑定
|
||||
if (existingBinding) {
|
||||
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||
if (existingBinding.accountType !== 'claude-official') {
|
||||
logger.info(
|
||||
`🔗 Session binding ignored for non-official account type: ${existingBinding.accountType}`
|
||||
)
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const accountValid = await this.validateBoundAccount(existingBinding)
|
||||
|
||||
if (!accountValid) {
|
||||
return {
|
||||
valid: false,
|
||||
error: cfg.sessionBindingErrorMessage,
|
||||
code: 'SESSION_BINDING_INVALID'
|
||||
}
|
||||
}
|
||||
|
||||
// 续期
|
||||
await this.touchOriginalSessionBinding(originalSessionId)
|
||||
|
||||
// 已有绑定,允许继续(这是正常的会话延续)
|
||||
return { valid: true, binding: existingBinding }
|
||||
}
|
||||
|
||||
// 没有绑定,是新会话
|
||||
// 注意:messages.length 检查在此处无法执行,因为我们不知道最终会调度到哪种账户类型
|
||||
// 绑定会在调度后创建,仅针对 claude-official 账户
|
||||
return { valid: true, isNewSession: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除原始会话绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
*/
|
||||
async deleteOriginalSessionBinding(originalSessionId) {
|
||||
if (!originalSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
await client.del(key)
|
||||
logger.info(`🗑️ Session binding deleted: ${originalSessionId}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to delete session binding for ${originalSessionId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话绑定统计
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getSessionBindingStats() {
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return { totalBindings: 0 }
|
||||
}
|
||||
|
||||
let cursor = '0'
|
||||
let count = 0
|
||||
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${SESSION_BINDING_PREFIX}*`,
|
||||
'COUNT',
|
||||
100
|
||||
)
|
||||
cursor = newCursor
|
||||
count += keys.length
|
||||
} while (cursor !== '0')
|
||||
|
||||
return {
|
||||
totalBindings: count
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get session binding stats:', error)
|
||||
return { totalBindings: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除配置缓存(用于测试或强制刷新)
|
||||
*/
|
||||
clearCache() {
|
||||
configCache = null
|
||||
configCacheTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeRelayConfigService()
|
||||
@@ -18,7 +18,7 @@ const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
class ClaudeRelayService {
|
||||
constructor() {
|
||||
this.claudeApiUrl = config.claude.apiUrl
|
||||
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
|
||||
this.apiVersion = config.claude.apiVersion
|
||||
this.betaHeader = config.claude.betaHeader
|
||||
this.systemPrompt = config.claude.systemPrompt
|
||||
@@ -878,11 +878,102 @@ class ClaudeRelayService {
|
||||
|
||||
// 🔧 过滤客户端请求头
|
||||
_filterClientHeaders(clientHeaders) {
|
||||
// 使用统一的 headerFilter 工具类 - 移除 CDN、浏览器和代理相关 headers
|
||||
// 使用统一的 headerFilter 工具类
|
||||
// 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查
|
||||
return filterForClaude(clientHeaders)
|
||||
}
|
||||
|
||||
// 🔧 准备请求头和 payload(抽离公共逻辑)
|
||||
async _prepareRequestHeadersAndPayload(
|
||||
body,
|
||||
clientHeaders,
|
||||
accountId,
|
||||
accessToken,
|
||||
options = {}
|
||||
) {
|
||||
const { account, accountType, sessionHash, requestOptions = {}, isStream = false } = options
|
||||
|
||||
// 获取统一的 User-Agent
|
||||
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
|
||||
|
||||
// 获取过滤后的客户端 headers
|
||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||
|
||||
// 判断是否是真实的 Claude Code 请求
|
||||
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
||||
|
||||
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
||||
let finalHeaders = { ...filteredHeaders }
|
||||
let requestPayload = body
|
||||
|
||||
if (!isRealClaudeCode) {
|
||||
// 获取该账号存储的 Claude Code headers
|
||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||
|
||||
// 只添加客户端没有提供的 headers
|
||||
Object.keys(claudeCodeHeaders).forEach((key) => {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
||||
finalHeaders[key] = claudeCodeHeaders[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 应用请求身份转换
|
||||
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
|
||||
account,
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
clientHeaders,
|
||||
requestOptions,
|
||||
isStream
|
||||
})
|
||||
|
||||
if (extensionResult.abortResponse) {
|
||||
return { abortResponse: extensionResult.abortResponse }
|
||||
}
|
||||
|
||||
requestPayload = extensionResult.body
|
||||
finalHeaders = extensionResult.headers
|
||||
|
||||
// 序列化请求体,计算 content-length
|
||||
const bodyString = JSON.stringify(requestPayload)
|
||||
const contentLength = Buffer.byteLength(bodyString, 'utf8')
|
||||
|
||||
// 构建最终请求头(包含认证、版本、User-Agent、Beta 等)
|
||||
const headers = {
|
||||
host: 'api.anthropic.com',
|
||||
connection: 'keep-alive',
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(contentLength),
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
'anthropic-version': this.apiVersion,
|
||||
...finalHeaders
|
||||
}
|
||||
|
||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
||||
const userAgent = unifiedUA || headers['user-agent'] || 'claude-cli/1.0.119 (external, cli)'
|
||||
const acceptHeader = headers['accept'] || 'application/json'
|
||||
delete headers['user-agent']
|
||||
delete headers['accept']
|
||||
headers['User-Agent'] = userAgent
|
||||
headers['Accept'] = acceptHeader
|
||||
|
||||
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
||||
|
||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||
const modelId = requestPayload?.model || body?.model
|
||||
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
||||
headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
||||
return {
|
||||
requestPayload,
|
||||
bodyString,
|
||||
headers,
|
||||
isRealClaudeCode
|
||||
}
|
||||
}
|
||||
|
||||
_applyRequestIdentityTransform(body, headers, context = {}) {
|
||||
const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {}
|
||||
|
||||
@@ -928,46 +1019,24 @@ class ClaudeRelayService {
|
||||
// 获取账户信息用于统一 User-Agent
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
|
||||
// 获取统一的 User-Agent
|
||||
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
|
||||
|
||||
// 获取过滤后的客户端 headers
|
||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||
|
||||
// 判断是否是真实的 Claude Code 请求
|
||||
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
||||
|
||||
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
||||
let finalHeaders = { ...filteredHeaders }
|
||||
let requestPayload = body
|
||||
|
||||
if (!isRealClaudeCode) {
|
||||
// 获取该账号存储的 Claude Code headers
|
||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||
|
||||
// 只添加客户端没有提供的 headers
|
||||
Object.keys(claudeCodeHeaders).forEach((key) => {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
||||
finalHeaders[key] = claudeCodeHeaders[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
|
||||
account,
|
||||
accountId,
|
||||
// 使用公共方法准备请求头和 payload
|
||||
const prepared = await this._prepareRequestHeadersAndPayload(
|
||||
body,
|
||||
clientHeaders,
|
||||
requestOptions,
|
||||
isStream: false
|
||||
})
|
||||
accountId,
|
||||
accessToken,
|
||||
{
|
||||
account,
|
||||
requestOptions,
|
||||
isStream: false
|
||||
}
|
||||
)
|
||||
|
||||
if (extensionResult.abortResponse) {
|
||||
return extensionResult.abortResponse
|
||||
if (prepared.abortResponse) {
|
||||
return prepared.abortResponse
|
||||
}
|
||||
|
||||
requestPayload = extensionResult.body
|
||||
finalHeaders = extensionResult.headers
|
||||
const { bodyString, headers } = prepared
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 支持自定义路径(如 count_tokens)
|
||||
@@ -981,30 +1050,14 @@ class ClaudeRelayService {
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: requestPath,
|
||||
path: requestPath + (url.search || ''),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'anthropic-version': this.apiVersion,
|
||||
...finalHeaders
|
||||
},
|
||||
headers,
|
||||
agent: proxyAgent,
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
||||
if (!options.headers['user-agent'] || unifiedUA !== null) {
|
||||
const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)'
|
||||
options.headers['user-agent'] = userAgent
|
||||
}
|
||||
|
||||
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
||||
|
||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||
const modelId = requestPayload?.model || body?.model
|
||||
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
||||
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
||||
console.log(options.path)
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let responseData = Buffer.alloc(0)
|
||||
@@ -1015,32 +1068,32 @@ class ClaudeRelayService {
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
let bodyString = ''
|
||||
let responseBody = ''
|
||||
|
||||
// 根据Content-Encoding处理响应数据
|
||||
const contentEncoding = res.headers['content-encoding']
|
||||
if (contentEncoding === 'gzip') {
|
||||
try {
|
||||
bodyString = zlib.gunzipSync(responseData).toString('utf8')
|
||||
responseBody = zlib.gunzipSync(responseData).toString('utf8')
|
||||
} catch (unzipError) {
|
||||
logger.error('❌ Failed to decompress gzip response:', unzipError)
|
||||
bodyString = responseData.toString('utf8')
|
||||
responseBody = responseData.toString('utf8')
|
||||
}
|
||||
} else if (contentEncoding === 'deflate') {
|
||||
try {
|
||||
bodyString = zlib.inflateSync(responseData).toString('utf8')
|
||||
responseBody = zlib.inflateSync(responseData).toString('utf8')
|
||||
} catch (unzipError) {
|
||||
logger.error('❌ Failed to decompress deflate response:', unzipError)
|
||||
bodyString = responseData.toString('utf8')
|
||||
responseBody = responseData.toString('utf8')
|
||||
}
|
||||
} else {
|
||||
bodyString = responseData.toString('utf8')
|
||||
responseBody = responseData.toString('utf8')
|
||||
}
|
||||
|
||||
const response = {
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
body: bodyString
|
||||
body: responseBody
|
||||
}
|
||||
|
||||
logger.debug(`🔗 Claude API response: ${res.statusCode}`)
|
||||
@@ -1095,7 +1148,7 @@ class ClaudeRelayService {
|
||||
})
|
||||
|
||||
// 写入请求体
|
||||
req.write(JSON.stringify(requestPayload))
|
||||
req.write(bodyString)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
@@ -1248,79 +1301,39 @@ class ClaudeRelayService {
|
||||
const isOpusModelRequest =
|
||||
typeof body?.model === 'string' && body.model.toLowerCase().includes('opus')
|
||||
|
||||
// 获取统一的 User-Agent
|
||||
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
|
||||
|
||||
// 获取过滤后的客户端 headers
|
||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||
|
||||
// 判断是否是真实的 Claude Code 请求
|
||||
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
||||
|
||||
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
||||
let finalHeaders = { ...filteredHeaders }
|
||||
let requestPayload = body
|
||||
|
||||
if (!isRealClaudeCode) {
|
||||
// 获取该账号存储的 Claude Code headers
|
||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||
|
||||
// 只添加客户端没有提供的 headers
|
||||
Object.keys(claudeCodeHeaders).forEach((key) => {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
||||
finalHeaders[key] = claudeCodeHeaders[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
|
||||
account,
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
// 使用公共方法准备请求头和 payload
|
||||
const prepared = await this._prepareRequestHeadersAndPayload(
|
||||
body,
|
||||
clientHeaders,
|
||||
requestOptions,
|
||||
isStream: true
|
||||
})
|
||||
accountId,
|
||||
accessToken,
|
||||
{
|
||||
account,
|
||||
accountType,
|
||||
sessionHash,
|
||||
requestOptions,
|
||||
isStream: true
|
||||
}
|
||||
)
|
||||
|
||||
if (extensionResult.abortResponse) {
|
||||
return extensionResult.abortResponse
|
||||
if (prepared.abortResponse) {
|
||||
return prepared.abortResponse
|
||||
}
|
||||
|
||||
requestPayload = extensionResult.body
|
||||
finalHeaders = extensionResult.headers
|
||||
const { bodyString, headers } = prepared
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.claudeApiUrl)
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname,
|
||||
path: url.pathname + (url.search || ''),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'anthropic-version': this.apiVersion,
|
||||
...finalHeaders
|
||||
},
|
||||
headers,
|
||||
agent: proxyAgent,
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
||||
if (!options.headers['user-agent'] || unifiedUA !== null) {
|
||||
const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)'
|
||||
options.headers['user-agent'] = userAgent
|
||||
}
|
||||
|
||||
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||
const modelId = body?.model
|
||||
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
||||
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
||||
|
||||
const req = https.request(options, async (res) => {
|
||||
logger.debug(`🌊 Claude stream response status: ${res.statusCode}`)
|
||||
|
||||
@@ -1766,15 +1779,15 @@ class ClaudeRelayService {
|
||||
|
||||
// 提取5小时会话窗口状态
|
||||
// 使用大小写不敏感的方式获取响应头
|
||||
const get5hStatus = (headers) => {
|
||||
if (!headers) {
|
||||
const get5hStatus = (resHeaders) => {
|
||||
if (!resHeaders) {
|
||||
return null
|
||||
}
|
||||
// HTTP头部名称不区分大小写,需要处理不同情况
|
||||
return (
|
||||
headers['anthropic-ratelimit-unified-5h-status'] ||
|
||||
headers['Anthropic-Ratelimit-Unified-5h-Status'] ||
|
||||
headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
|
||||
resHeaders['anthropic-ratelimit-unified-5h-status'] ||
|
||||
resHeaders['Anthropic-Ratelimit-Unified-5h-Status'] ||
|
||||
resHeaders['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1942,7 +1955,7 @@ class ClaudeRelayService {
|
||||
})
|
||||
|
||||
// 写入请求体
|
||||
req.write(JSON.stringify(requestPayload))
|
||||
req.write(bodyString)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +22,18 @@ const STAINLESS_HEADER_KEYS = [
|
||||
'x-stainless-runtime',
|
||||
'x-stainless-runtime-version'
|
||||
]
|
||||
|
||||
// 小写 key 到正确大小写格式的映射(用于返回给上游时)
|
||||
const STAINLESS_HEADER_CASE_MAP = {
|
||||
'x-stainless-retry-count': 'X-Stainless-Retry-Count',
|
||||
'x-stainless-timeout': 'X-Stainless-Timeout',
|
||||
'x-stainless-lang': 'X-Stainless-Lang',
|
||||
'x-stainless-package-version': 'X-Stainless-Package-Version',
|
||||
'x-stainless-os': 'X-Stainless-OS',
|
||||
'x-stainless-arch': 'X-Stainless-Arch',
|
||||
'x-stainless-runtime': 'X-Stainless-Runtime',
|
||||
'x-stainless-runtime-version': 'X-Stainless-Runtime-Version'
|
||||
}
|
||||
const MIN_FINGERPRINT_FIELDS = 4
|
||||
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
|
||||
|
||||
@@ -135,7 +147,9 @@ function applyFingerprintToHeaders(headers, fingerprint) {
|
||||
return
|
||||
}
|
||||
removeHeaderCaseInsensitive(nextHeaders, key)
|
||||
nextHeaders[key] = fingerprint[key]
|
||||
// 使用正确的大小写格式返回给上游
|
||||
const properCaseKey = STAINLESS_HEADER_CASE_MAP[key] || key
|
||||
nextHeaders[properCaseKey] = fingerprint[key]
|
||||
})
|
||||
|
||||
return nextHeaders
|
||||
|
||||
@@ -180,8 +180,56 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 🎯 统一调度Claude账号(官方和Console)
|
||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
||||
async selectAccountForApiKey(
|
||||
apiKeyData,
|
||||
sessionHash = null,
|
||||
requestedModel = null,
|
||||
forcedAccount = null
|
||||
) {
|
||||
try {
|
||||
// 🔒 如果有强制绑定的账户(全局会话绑定),仅 claude-official 类型受影响
|
||||
if (forcedAccount && forcedAccount.accountId && forcedAccount.accountType) {
|
||||
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||
if (forcedAccount.accountType !== 'claude-official') {
|
||||
logger.info(
|
||||
`🔗 Session binding ignored for non-official account type: ${forcedAccount.accountType}, proceeding with normal scheduling`
|
||||
)
|
||||
// 不使用 forcedAccount,继续走下面的正常调度逻辑
|
||||
} else {
|
||||
// claude-official 类型需要检查可用性并强制使用
|
||||
logger.info(
|
||||
`🔗 Forced session binding detected: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||
)
|
||||
|
||||
const isAvailable = await this._isAccountAvailableForSessionBinding(
|
||||
forcedAccount.accountId,
|
||||
forcedAccount.accountType,
|
||||
requestedModel
|
||||
)
|
||||
|
||||
if (isAvailable) {
|
||||
logger.info(
|
||||
`✅ Using forced session binding account: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||
)
|
||||
return {
|
||||
accountId: forcedAccount.accountId,
|
||||
accountType: forcedAccount.accountType
|
||||
}
|
||||
} else {
|
||||
// 绑定账户不可用,抛出特定错误(不 fallback)
|
||||
logger.warn(
|
||||
`❌ Forced session binding account unavailable: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||
)
|
||||
const error = new Error('Session binding account unavailable')
|
||||
error.code = 'SESSION_BINDING_ACCOUNT_UNAVAILABLE'
|
||||
error.accountId = forcedAccount.accountId
|
||||
error.accountType = forcedAccount.accountType
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析供应商前缀
|
||||
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
||||
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
||||
@@ -1711,6 +1759,67 @@ class UnifiedClaudeScheduler {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔒 检查 claude-official 账户是否可用于会话绑定
|
||||
* 注意:此方法仅用于 claude-official 类型账户,其他类型不受会话绑定限制
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} accountType - 账户类型(应为 'claude-official')
|
||||
* @param {string} _requestedModel - 请求的模型(保留参数,当前未使用)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _isAccountAvailableForSessionBinding(accountId, accountType, _requestedModel = null) {
|
||||
try {
|
||||
// 此方法仅处理 claude-official 类型
|
||||
if (accountType !== 'claude-official') {
|
||||
logger.warn(
|
||||
`Session binding: _isAccountAvailableForSessionBinding called for non-official type: ${accountType}`
|
||||
)
|
||||
return true // 非 claude-official 类型不受限制
|
||||
}
|
||||
|
||||
const account = await redis.getClaudeAccount(accountId)
|
||||
if (!account) {
|
||||
logger.warn(`Session binding: Claude OAuth account ${accountId} not found`)
|
||||
return false
|
||||
}
|
||||
|
||||
const isActive = account.isActive === 'true' || account.isActive === true
|
||||
const { status } = account
|
||||
|
||||
if (!isActive) {
|
||||
logger.warn(`Session binding: Claude OAuth account ${accountId} is not active`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (status === 'error' || status === 'temp_error') {
|
||||
logger.warn(
|
||||
`Session binding: Claude OAuth account ${accountId} has error status: ${status}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
if (await claudeAccountService.isAccountRateLimited(accountId)) {
|
||||
logger.warn(`Session binding: Claude OAuth account ${accountId} is rate limited`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查临时不可用
|
||||
if (await this.isAccountTemporarilyUnavailable(accountId, accountType)) {
|
||||
logger.warn(`Session binding: Claude OAuth account ${accountId} is temporarily unavailable`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Error checking account availability for session binding: ${accountId} (${accountType})`,
|
||||
error
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UnifiedClaudeScheduler()
|
||||
|
||||
@@ -84,6 +84,11 @@ const PROMPT_DEFINITIONS = {
|
||||
title: 'Claude Code Compact System Prompt',
|
||||
text: 'You are a helpful AI assistant tasked with summarizing conversations.'
|
||||
},
|
||||
exploreAgentSystemPrompt: {
|
||||
category: 'system',
|
||||
title: 'Claude Code Explore Agent System Prompt',
|
||||
text: "You are a file search specialist for Claude Code, Anthropic's official CLI for Claude."
|
||||
},
|
||||
outputStyleInsightsPrompt: {
|
||||
category: 'output_style',
|
||||
title: 'Output Style Insights Addendum',
|
||||
|
||||
@@ -52,50 +52,38 @@ function filterForOpenAI(headers) {
|
||||
|
||||
/**
|
||||
* 为 Claude/Anthropic API 过滤 headers
|
||||
* 在原有逻辑基础上添加 CDN headers 到敏感列表
|
||||
* 使用白名单模式,只允许指定的 headers 通过
|
||||
*/
|
||||
function filterForClaude(headers) {
|
||||
const sensitiveHeaders = [
|
||||
'content-type',
|
||||
'user-agent',
|
||||
'x-api-key',
|
||||
'authorization',
|
||||
'x-authorization',
|
||||
'host',
|
||||
'content-length',
|
||||
'connection',
|
||||
'proxy-authorization',
|
||||
'content-encoding',
|
||||
'transfer-encoding',
|
||||
...cdnHeaders // 添加 CDN headers
|
||||
]
|
||||
|
||||
const browserHeaders = [
|
||||
'origin',
|
||||
'referer',
|
||||
'sec-fetch-mode',
|
||||
'sec-fetch-site',
|
||||
'sec-fetch-dest',
|
||||
'sec-ch-ua',
|
||||
'sec-ch-ua-mobile',
|
||||
'sec-ch-ua-platform',
|
||||
'accept-language',
|
||||
'accept-encoding',
|
||||
// 白名单模式:只允许以下 headers
|
||||
const allowedHeaders = [
|
||||
'accept',
|
||||
'cache-control',
|
||||
'pragma',
|
||||
'anthropic-dangerous-direct-browser-access'
|
||||
'x-stainless-retry-count',
|
||||
'x-stainless-timeout',
|
||||
'x-stainless-lang',
|
||||
'x-stainless-package-version',
|
||||
'x-stainless-os',
|
||||
'x-stainless-arch',
|
||||
'x-stainless-runtime',
|
||||
'x-stainless-runtime-version',
|
||||
'x-stainless-helper-method',
|
||||
'anthropic-dangerous-direct-browser-access',
|
||||
'anthropic-version',
|
||||
'x-app',
|
||||
'anthropic-beta',
|
||||
'accept-language',
|
||||
'sec-fetch-mode',
|
||||
'accept-encoding',
|
||||
'user-agent',
|
||||
'content-type',
|
||||
'connection'
|
||||
]
|
||||
|
||||
const allowedHeaders = ['x-request-id', 'anthropic-version', 'anthropic-beta']
|
||||
|
||||
const filtered = {}
|
||||
Object.keys(headers || {}).forEach((key) => {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (allowedHeaders.includes(lowerKey)) {
|
||||
filtered[key] = headers[key]
|
||||
} else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
|
||||
filtered[key] = headers[key]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -65,8 +65,9 @@ class ClaudeCodeValidator {
|
||||
const { bestScore } = bestSimilarityByTemplates(rawText)
|
||||
if (bestScore < threshold) {
|
||||
logger.error(
|
||||
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}, prompt=${rawText}`
|
||||
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}`
|
||||
)
|
||||
logger.warn(`Claude system prompt detail: ${rawText}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,18 @@
|
||||
<i class="fas fa-bell mr-2"></i>
|
||||
通知设置
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'border-b-2 pb-2 text-sm font-medium transition-colors',
|
||||
activeSection === 'claude'
|
||||
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
]"
|
||||
@click="activeSection = 'claude'"
|
||||
>
|
||||
<i class="fas fa-robot mr-2"></i>
|
||||
Claude 转发
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -629,6 +641,182 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claude 转发配置部分 -->
|
||||
<div v-show="activeSection === 'claude'">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="claudeConfigLoading" class="py-12 text-center">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p class="text-gray-500 dark:text-gray-400">正在加载配置...</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Claude Code 客户端限制 -->
|
||||
<div
|
||||
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-orange-500 to-amber-600 text-white shadow-lg"
|
||||
>
|
||||
<i class="fas fa-terminal"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
仅允许 Claude Code 客户端
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
启用后,所有
|
||||
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700"
|
||||
>/api/v1/messages</code
|
||||
>
|
||||
和
|
||||
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700"
|
||||
>/claude/v1/messages</code
|
||||
>
|
||||
端点将强制验证 Claude Code CLI 客户端
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="claudeConfig.claudeCodeOnlyEnabled"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
@change="saveClaudeConfig"
|
||||
/>
|
||||
<div
|
||||
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-orange-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-orange-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-orange-800"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||
<div class="flex">
|
||||
<i class="fas fa-info-circle mt-0.5 text-amber-500"></i>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||
此设置与 API Key 级别的客户端限制是 <strong>OR 逻辑</strong>:全局启用或 API
|
||||
Key 设置中启用,都会执行 Claude Code 验证。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全局会话绑定 -->
|
||||
<div
|
||||
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 text-white shadow-lg"
|
||||
>
|
||||
<i class="fas fa-link"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
全局会话绑定
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
启用后,系统会将原始会话 ID 绑定到首次使用的账户,确保上下文的一致性
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="claudeConfig.globalSessionBindingEnabled"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
@change="saveClaudeConfig"
|
||||
/>
|
||||
<div
|
||||
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-purple-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-purple-800"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 绑定配置详情(仅在启用时显示) -->
|
||||
<div v-if="claudeConfig.globalSessionBindingEnabled" class="mt-6 space-y-4">
|
||||
<!-- 绑定有效期 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-clock mr-2 text-gray-400"></i>
|
||||
绑定有效期(天)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="claudeConfig.sessionBindingTtlDays"
|
||||
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||
max="365"
|
||||
min="1"
|
||||
placeholder="30"
|
||||
type="number"
|
||||
@change="saveClaudeConfig"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
会话绑定到账户后的有效时间,过期后会自动解除绑定
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示消息 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-exclamation-triangle mr-2 text-gray-400"></i>
|
||||
旧会话污染提示
|
||||
</label>
|
||||
<textarea
|
||||
v-model="claudeConfig.sessionBindingErrorMessage"
|
||||
class="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||
placeholder="你的本地session已污染,请清理后使用。"
|
||||
rows="2"
|
||||
@change="saveClaudeConfig"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
当绑定的账户不可用(状态异常、过载等)时,返回给客户端的错误消息
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg bg-purple-50 p-4 dark:bg-purple-900/20">
|
||||
<div class="flex">
|
||||
<i class="fas fa-lightbulb mt-0.5 text-purple-500"></i>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-purple-700 dark:text-purple-300">
|
||||
<strong>工作原理:</strong>系统会提取请求中的原始 session ID (来自
|
||||
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
|
||||
>metadata.user_id</code
|
||||
>), 并将其与首次调度的账户绑定。后续使用相同 session ID
|
||||
的请求将自动路由到同一账户。
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-purple-700 dark:text-purple-300">
|
||||
<strong>新会话识别:</strong>如果是已存在的绑定会话但请求中
|
||||
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
|
||||
>messages.length > 1</code
|
||||
>, 系统会认为这是一个污染的会话并拒绝请求。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置更新信息 -->
|
||||
<div
|
||||
v-if="claudeConfig.updatedAt"
|
||||
class="rounded-lg bg-gray-50 p-4 text-sm text-gray-500 dark:bg-gray-700/50 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-history mr-2"></i>
|
||||
最后更新:{{ formatDateTime(claudeConfig.updatedAt) }}
|
||||
<span v-if="claudeConfig.updatedBy" class="ml-2">
|
||||
由 <strong>{{ claudeConfig.updatedBy }}</strong> 修改
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1274,6 +1462,17 @@ const webhookConfig = ref({
|
||||
}
|
||||
})
|
||||
|
||||
// Claude 转发配置
|
||||
const claudeConfigLoading = ref(false)
|
||||
const claudeConfig = ref({
|
||||
claudeCodeOnlyEnabled: false,
|
||||
globalSessionBindingEnabled: false,
|
||||
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||
sessionBindingTtlDays: 30,
|
||||
updatedAt: null,
|
||||
updatedBy: null
|
||||
})
|
||||
|
||||
// 平台表单相关
|
||||
const showAddPlatformModal = ref(false)
|
||||
const editingPlatform = ref(null)
|
||||
@@ -1311,6 +1510,8 @@ const sectionWatcher = watch(activeSection, async (newSection) => {
|
||||
if (!isMounted.value) return
|
||||
if (newSection === 'webhook') {
|
||||
await loadWebhookConfig()
|
||||
} else if (newSection === 'claude') {
|
||||
await loadClaudeConfig()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1522,6 +1723,67 @@ const saveWebhookConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 Claude 转发配置
|
||||
const loadClaudeConfig = async () => {
|
||||
if (!isMounted.value) return
|
||||
claudeConfigLoading.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/admin/claude-relay-config', {
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
claudeConfig.value = {
|
||||
claudeCodeOnlyEnabled: response.config?.claudeCodeOnlyEnabled ?? false,
|
||||
globalSessionBindingEnabled: response.config?.globalSessionBindingEnabled ?? false,
|
||||
sessionBindingErrorMessage:
|
||||
response.config?.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。',
|
||||
sessionBindingTtlDays: response.config?.sessionBindingTtlDays ?? 30,
|
||||
updatedAt: response.config?.updatedAt || null,
|
||||
updatedBy: response.config?.updatedBy || null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return
|
||||
if (!isMounted.value) return
|
||||
showToast('获取 Claude 转发配置失败', 'error')
|
||||
console.error(error)
|
||||
} finally {
|
||||
if (isMounted.value) {
|
||||
claudeConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 Claude 转发配置
|
||||
const saveClaudeConfig = async () => {
|
||||
if (!isMounted.value) return
|
||||
try {
|
||||
const payload = {
|
||||
claudeCodeOnlyEnabled: claudeConfig.value.claudeCodeOnlyEnabled,
|
||||
globalSessionBindingEnabled: claudeConfig.value.globalSessionBindingEnabled,
|
||||
sessionBindingErrorMessage: claudeConfig.value.sessionBindingErrorMessage,
|
||||
sessionBindingTtlDays: claudeConfig.value.sessionBindingTtlDays
|
||||
}
|
||||
|
||||
const response = await apiClient.put('/admin/claude-relay-config', payload, {
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
claudeConfig.value = {
|
||||
...claudeConfig.value,
|
||||
updatedAt: response.config?.updatedAt || new Date().toISOString(),
|
||||
updatedBy: response.config?.updatedBy || null
|
||||
}
|
||||
showToast('Claude 转发配置已保存', 'success')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return
|
||||
if (!isMounted.value) return
|
||||
showToast('保存 Claude 转发配置失败', 'error')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 URL
|
||||
const validateUrl = () => {
|
||||
// Bark和SMTP平台不需要验证URL
|
||||
|
||||
Reference in New Issue
Block a user