Compare commits

..

3 Commits

Author SHA1 Message Date
Wesley Liddick
ba93ae55a9 Merge pull request #811 from sususu98/feat/event-logging-endpoint
feat: 添加 Claude Code 遥测端点并优化日志级别
2025-12-16 19:34:44 -05:00
sususu
0994eb346f format 2025-12-16 18:32:11 +08:00
sususu
4863a37328 feat: 添加 Claude Code 遥测端点并优化日志级别
- 添加 /api/event_logging/batch 端点处理客户端遥测请求
- 将遥测相关请求日志改为 debug 级别,减少日志噪音
2025-12-16 18:31:07 +08:00
6 changed files with 48 additions and 122 deletions

View File

@@ -1 +1 @@
1.1.228 1.1.226

View File

@@ -1112,9 +1112,13 @@ const requestLogger = (req, res, next) => {
const referer = req.get('Referer') || 'none' const referer = req.get('Referer') || 'none'
// 记录请求开始 // 记录请求开始
const isDebugRoute = req.originalUrl.includes('event_logging')
if (req.originalUrl !== '/health') { if (req.originalUrl !== '/health') {
// 避免健康检查日志过多 if (isDebugRoute) {
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
} else {
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
}
} }
res.on('finish', () => { res.on('finish', () => {
@@ -1146,7 +1150,14 @@ const requestLogger = (req, res, next) => {
logMetadata logMetadata
) )
} else if (req.originalUrl !== '/health') { } else if (req.originalUrl !== '/health') {
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata) if (isDebugRoute) {
logger.debug(
`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
logMetadata
)
} else {
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
}
} }
// API Key相关日志 // API Key相关日志

View File

@@ -38,73 +38,6 @@ function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '')
}) })
} }
/**
* 判断是否为旧会话(污染的会话)
* Claude Code 发送的请求特点:
* - messages 数组通常只有 1 个元素
* - 历史对话记录嵌套在单个 message 的 content 数组中
* - content 数组中包含 <system-reminder> 开头的系统注入内容
*
* 污染会话的特征:
* 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 || ''
// 剔除以 <system-reminder> 开头的
return !text.trimStart().startsWith('<system-reminder>')
})
// 多个用户输入 = 旧会话
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) { async function handleMessagesRequest(req, res) {
try { try {
@@ -300,18 +233,19 @@ async function handleMessagesRequest(req, res) {
} }
// 🔗 在成功调度后建立会话绑定(仅 claude-official 类型) // 🔗 在成功调度后建立会话绑定(仅 claude-official 类型)
// claude-official 只接受1) 新会话 2) 已绑定的会话 // claude-official 只接受1) 新会话(messages.length=1) 2) 已绑定的会话
if ( if (
needSessionBinding && needSessionBinding &&
originalSessionIdForBinding && originalSessionIdForBinding &&
accountId && accountId &&
accountType === 'claude-official' accountType === 'claude-official'
) { ) {
// 🚫 检测旧会话(污染的会话) // 🚫 新会话必须 messages.length === 1
if (isOldSession(req.body)) { const messages = req.body?.messages
if (messages && messages.length > 1) {
const cfg = await claudeRelayConfigService.getConfig() const cfg = await claudeRelayConfigService.getConfig()
logger.warn( logger.warn(
`🚫 Old session rejected: sessionId=${originalSessionIdForBinding}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true` `🚫 New session with messages.length > 1 rejected: sessionId=${originalSessionIdForBinding}, messages.length=${messages.length}`
) )
return res.status(400).json({ return res.status(400).json({
error: { error: {
@@ -750,18 +684,19 @@ async function handleMessagesRequest(req, res) {
} }
// 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型) // 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型)
// claude-official 只接受1) 新会话 2) 已绑定的会话 // claude-official 只接受1) 新会话(messages.length=1) 2) 已绑定的会话
if ( if (
needSessionBindingNonStream && needSessionBindingNonStream &&
originalSessionIdForBindingNonStream && originalSessionIdForBindingNonStream &&
accountId && accountId &&
accountType === 'claude-official' accountType === 'claude-official'
) { ) {
// 🚫 检测旧会话(污染的会话) // 🚫 新会话必须 messages.length === 1
if (isOldSession(req.body)) { const messages = req.body?.messages
if (messages && messages.length > 1) {
const cfg = await claudeRelayConfigService.getConfig() const cfg = await claudeRelayConfigService.getConfig()
logger.warn( logger.warn(
`🚫 Old session rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true` `🚫 New session with messages.length > 1 rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${messages.length}`
) )
return res.status(400).json({ return res.status(400).json({
error: { error: {
@@ -1222,41 +1157,6 @@ 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}`) logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
const sessionHash = sessionHelper.generateSessionHash(req.body) const sessionHash = sessionHelper.generateSessionHash(req.body)
@@ -1462,5 +1362,10 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
} }
}) })
// Claude Code 客户端遥测端点 - 返回成功响应避免 404 日志
router.post('/event_logging/batch', (req, res) => {
res.status(200).json({ success: true })
})
module.exports = router module.exports = router
module.exports.handleMessagesRequest = handleMessagesRequest module.exports.handleMessagesRequest = handleMessagesRequest

View File

@@ -283,13 +283,12 @@ class ClaudeRelayConfigService {
const account = await accountService.getAccount(accountId) const account = await accountService.getAccount(accountId)
// getAccount() 直接返回账户数据对象或 null不是 { success, data } 格式 if (!account || !account.success || !account.data) {
if (!account) {
logger.warn(`Session binding account not found: ${accountId} (${accountType})`) logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
return false return false
} }
const accountData = account const accountData = account.data
// 检查账户是否激活 // 检查账户是否激活
if (accountData.isActive === false || accountData.isActive === 'false') { if (accountData.isActive === false || accountData.isActive === 'false') {

View File

@@ -1157,6 +1157,7 @@
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/lodash": "*" "@types/lodash": "*"
} }
@@ -1351,6 +1352,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1587,6 +1589,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001726", "caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173", "electron-to-chromium": "^1.5.173",
@@ -3060,13 +3063,15 @@
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash-unified": { "node_modules/lodash-unified": {
"version": "1.0.3", "version": "1.0.3",
@@ -3618,6 +3623,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -3764,6 +3770,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -4028,6 +4035,7 @@
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==", "integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -4525,6 +4533,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -4915,6 +4924,7 @@
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -5115,6 +5125,7 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.18", "@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18", "@vue/compiler-sfc": "3.5.18",

View File

@@ -720,7 +720,7 @@
</div> </div>
<div> <div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200"> <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
强制会话绑定 全局会话绑定
</h2> </h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
启用后系统会将原始会话 ID 绑定到首次使用的账户确保上下文的一致性 启用后系统会将原始会话 ID 绑定到首次使用的账户确保上下文的一致性
@@ -777,7 +777,7 @@
@change="saveClaudeConfig" @change="saveClaudeConfig"
></textarea> ></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
检测到为旧的sessionId且未在系统中有调度记录时提示返回给客户端的错误消息 绑定的账户不可用状态异常过载等返回给客户端的错误消息
</p> </p>
</div> </div>
</div> </div>
@@ -794,7 +794,7 @@
的请求将自动路由到同一账户 的请求将自动路由到同一账户
</p> </p>
<p class="mt-2 text-sm text-purple-700 dark:text-purple-300"> <p class="mt-2 text-sm text-purple-700 dark:text-purple-300">
<strong>新会话识别</strong>如果绑定会话历史中没有该sessionId但请求中 <strong>新会话识别</strong>如果是已存在的绑定会话但请求中
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800" <code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
>messages.length > 1</code >messages.length > 1</code
> 系统会认为这是一个污染的会话并拒绝请求 > 系统会认为这是一个污染的会话并拒绝请求