diff --git a/src/routes/api.js b/src/routes/api.js index 8fe0676b..4d298716 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -38,6 +38,73 @@ function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') }) } +/** + * 判断是否为旧会话(污染的会话) + * Claude Code 发送的请求特点: + * - messages 数组通常只有 1 个元素 + * - 历史对话记录嵌套在单个 message 的 content 数组中 + * - content 数组中包含 开头的系统注入内容 + * + * 污染会话的特征: + * 1. messages.length > 1 + * 2. messages.length === 1 但 content 中有多个用户输入 + * 3. "warmup" 请求:单条简单消息 + 无 tools(真正新会话会带 tools) + * + * @param {Object} body - 请求体 + * @returns {boolean} 是否为旧会话 + */ +function isOldSession(body) { + const messages = body?.messages + const tools = body?.tools + + if (!messages || messages.length === 0) { + return false + } + + // 1. 多条消息 = 旧会话 + if (messages.length > 1) { + return true + } + + // 2. 单条消息,分析 content + const firstMessage = messages[0] + const content = firstMessage?.content + + if (!content) { + return false + } + + // 如果 content 是字符串,只有一条输入,需要检查 tools + if (typeof content === 'string') { + // 有 tools = 正常新会话,无 tools = 可疑 + return !tools || tools.length === 0 + } + + // 如果 content 是数组,统计非 system-reminder 的元素 + if (Array.isArray(content)) { + const userInputs = content.filter((item) => { + if (item.type !== 'text') { + return false + } + const text = item.text || '' + // 剔除以 开头的 + return !text.trimStart().startsWith('') + }) + + // 多个用户输入 = 旧会话 + if (userInputs.length > 1) { + return true + } + + // Warmup 检测:单个消息 + 无 tools = 旧会话 + if (userInputs.length === 1 && (!tools || tools.length === 0)) { + return true + } + } + + return false +} + // 🔧 共享的消息处理函数 async function handleMessagesRequest(req, res) { try { @@ -233,19 +300,18 @@ async function handleMessagesRequest(req, res) { } // 🔗 在成功调度后建立会话绑定(仅 claude-official 类型) - // claude-official 只接受:1) 新会话(messages.length=1) 2) 已绑定的会话 + // claude-official 只接受:1) 新会话 2) 已绑定的会话 if ( needSessionBinding && originalSessionIdForBinding && accountId && accountType === 'claude-official' ) { - // 🚫 新会话必须 messages.length === 1 - const messages = req.body?.messages - if (messages && messages.length > 1) { + // 🚫 检测旧会话(污染的会话) + if (isOldSession(req.body)) { const cfg = await claudeRelayConfigService.getConfig() logger.warn( - `🚫 New session with messages.length > 1 rejected: sessionId=${originalSessionIdForBinding}, messages.length=${messages.length}` + `🚫 Old session rejected: sessionId=${originalSessionIdForBinding}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true` ) return res.status(400).json({ error: { @@ -684,19 +750,18 @@ async function handleMessagesRequest(req, res) { } // 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型) - // claude-official 只接受:1) 新会话(messages.length=1) 2) 已绑定的会话 + // claude-official 只接受:1) 新会话 2) 已绑定的会话 if ( needSessionBindingNonStream && originalSessionIdForBindingNonStream && accountId && accountType === 'claude-official' ) { - // 🚫 新会话必须 messages.length === 1 - const messages = req.body?.messages - if (messages && messages.length > 1) { + // 🚫 检测旧会话(污染的会话) + if (isOldSession(req.body)) { const cfg = await claudeRelayConfigService.getConfig() logger.warn( - `🚫 New session with messages.length > 1 rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${messages.length}` + `🚫 Old session rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true` ) return res.status(400).json({ error: { @@ -1157,6 +1222,41 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => }) } + // 🔗 会话绑定验证(与 messages 端点保持一致) + const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body) + const sessionValidation = await claudeRelayConfigService.validateNewSession( + req.body, + originalSessionId + ) + + if (!sessionValidation.valid) { + logger.warn( + `🚫 Session binding validation failed (count_tokens): ${sessionValidation.code} for session ${originalSessionId}` + ) + return res.status(400).json({ + error: { + type: 'session_binding_error', + message: sessionValidation.error + } + }) + } + + // 🔗 检测旧会话(污染的会话)- 仅对需要绑定的新会话检查 + if (sessionValidation.isNewSession && originalSessionId) { + if (isOldSession(req.body)) { + const cfg = await claudeRelayConfigService.getConfig() + logger.warn( + `🚫 Old session rejected (count_tokens): sessionId=${originalSessionId}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true` + ) + return res.status(400).json({ + error: { + type: 'session_binding_error', + message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。' + } + }) + } + } + logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`) const sessionHash = sessionHelper.generateSessionHash(req.body) diff --git a/src/services/claudeRelayConfigService.js b/src/services/claudeRelayConfigService.js index 3b9790ac..5d4c3bd5 100644 --- a/src/services/claudeRelayConfigService.js +++ b/src/services/claudeRelayConfigService.js @@ -283,12 +283,13 @@ class ClaudeRelayConfigService { const account = await accountService.getAccount(accountId) - if (!account || !account.success || !account.data) { + // getAccount() 直接返回账户数据对象或 null,不是 { success, data } 格式 + if (!account) { logger.warn(`Session binding account not found: ${accountId} (${accountType})`) return false } - const accountData = account.data + const accountData = account // 检查账户是否激活 if (accountData.isActive === false || accountData.isActive === 'false') { diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json index 9405609e..481df56a 100644 --- a/web/admin-spa/package-lock.json +++ b/web/admin-spa/package-lock.json @@ -1157,7 +1157,6 @@ "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/lodash": "*" } @@ -1352,7 +1351,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1589,7 +1587,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3063,15 +3060,13 @@ "version": "4.17.21", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-unified": { "version": "1.0.3", @@ -3623,7 +3618,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3770,7 +3764,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4035,7 +4028,6 @@ "integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4533,7 +4525,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4924,7 +4915,6 @@ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5125,7 +5115,6 @@ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz", "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.18", "@vue/compiler-sfc": "3.5.18", diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue index a42ce79b..c60651e6 100644 --- a/web/admin-spa/src/views/SettingsView.vue +++ b/web/admin-spa/src/views/SettingsView.vue @@ -720,7 +720,7 @@

- 全局会话绑定 + 强制会话绑定

启用后,系统会将原始会话 ID 绑定到首次使用的账户,确保上下文的一致性 @@ -777,7 +777,7 @@ @change="saveClaudeConfig" >

- 当绑定的账户不可用(状态异常、过载等)时,返回给客户端的错误消息 + 当检测到为旧的sessionId且未在系统中有调度记录时提示,返回给客户端的错误消息

@@ -794,7 +794,7 @@ 的请求将自动路由到同一账户。

- 新会话识别:如果是已存在的绑定会话但请求中 + 新会话识别:如果绑定会话历史中没有该sessionId但请求中 messages.length > 1, 系统会认为这是一个污染的会话并拒绝请求。