feat: claude账号新增支持拦截预热请求

This commit is contained in:
shaw
2025-12-21 22:28:22 +08:00
parent 00d8ac4bec
commit 8ec8a59b07
7 changed files with 325 additions and 16 deletions

View File

@@ -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
})
// 如果是分组类型,将账户添加到分组

View File

@@ -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 平台分组)

View File

@@ -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,

View File

@@ -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

View File

@@ -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 刷新逻辑,不会覆盖此字段

View File

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

View File

@@ -1651,6 +1651,28 @@
</label>
</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 || '',