From 8ec8a59b0720d75b3c502377c9e41992b9141d71 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 21 Dec 2025 22:28:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20claude=E8=B4=A6=E5=8F=B7=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=94=AF=E6=8C=81=E6=8B=A6=E6=88=AA=E9=A2=84=E7=83=AD?= =?UTF-8?q?=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/admin/claudeAccounts.js | 6 +- src/routes/admin/claudeConsoleAccounts.js | 6 +- src/routes/api.js | 42 +++- src/services/claudeAccountService.js | 21 +- src/services/claudeConsoleAccountService.js | 14 +- src/utils/warmupInterceptor.js | 202 ++++++++++++++++++ .../src/components/accounts/AccountForm.vue | 50 +++++ 7 files changed, 325 insertions(+), 16 deletions(-) create mode 100644 src/utils/warmupInterceptor.js diff --git a/src/routes/admin/claudeAccounts.js b/src/routes/admin/claudeAccounts.js index 52791374..d079e346 100644 --- a/src/routes/admin/claudeAccounts.js +++ b/src/routes/admin/claudeAccounts.js @@ -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 }) // 如果是分组类型,将账户添加到分组 diff --git a/src/routes/admin/claudeConsoleAccounts.js b/src/routes/admin/claudeConsoleAccounts.js index 311806a3..fc0fcf62 100644 --- a/src/routes/admin/claudeConsoleAccounts.js +++ b/src/routes/admin/claudeConsoleAccounts.js @@ -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 平台分组) diff --git a/src/routes/api.js b/src/routes/api.js index 8ca1bb08..d07442ad 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -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, diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 35ce9cff..a2f8e6d2 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -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 diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index 5ffc5d46..a46af870 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -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 刷新逻辑,不会覆盖此字段 diff --git a/src/utils/warmupInterceptor.js b/src/utils/warmupInterceptor.js new file mode 100644 index 00000000..430d622d --- /dev/null +++ b/src/utils/warmupInterceptor.js @@ -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 +} diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index f23a3b5f..1953e76d 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -1651,6 +1651,28 @@ + +
+ +
+
+ +
+ +
+