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, 系统会认为这是一个污染的会话并拒绝请求。