diff --git a/package-lock.json b/package-lock.json index d9ebcff0..e6898fe4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "google-auth-library": "^10.1.0", + "heapdump": "^0.3.15", "helmet": "^7.1.0", "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", @@ -5398,6 +5399,19 @@ "node": ">= 0.4" } }, + "node_modules/heapdump": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/heapdump/-/heapdump-0.3.15.tgz", + "integrity": "sha512-n8aSFscI9r3gfhOcAECAtXFaQ1uy4QSke6bnaL+iymYZ/dWs9cqDqHM+rALfsHUwukUbxsdlECZ0pKmJdQ/4OA==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "nan": "^2.13.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/helmet": { "version": "7.2.0", "resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz", @@ -7013,6 +7027,12 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "license": "ISC" }, + "node_modules/nan": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", diff --git a/package.json b/package.json index 6ef88e60..043cbb2e 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "google-auth-library": "^10.1.0", + "heapdump": "^0.3.15", "helmet": "^7.1.0", "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index bb3b823e..90b18e17 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -21,6 +21,9 @@ const { isStreamWritable } = require('../utils/streamHelper') class ClaudeRelayService { constructor() { this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true' + // 🧹 内存优化:用于存储请求体字符串,避免闭包捕获 + this.bodyStore = new Map() + this._bodyStoreIdCounter = 0 this.apiVersion = config.claude.apiVersion this.betaHeader = config.claude.betaHeader this.systemPrompt = config.claude.systemPrompt @@ -539,7 +542,8 @@ class ClaudeRelayService { const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders) const processedBody = this._processRequestBody(requestBody, account) - const baseRequestBody = JSON.parse(JSON.stringify(processedBody)) + // 🧹 内存优化:存储序列化字符串用于重试,避免重复转换工具名 + const originalBodyString = JSON.stringify(processedBody) // 获取代理配置 const proxyAgent = await this._getProxyAgent(accountId) @@ -567,8 +571,16 @@ class ClaudeRelayService { let shouldRetry = false do { + // 🧹 每次重试从字符串解析新对象,避免使用被修改的 body + let retryRequestBody + try { + retryRequestBody = JSON.parse(originalBodyString) + } catch (parseError) { + logger.error(`❌ Failed to parse originalBodyString for retry: ${parseError.message}`) + throw new Error(`Request body parse failed: ${parseError.message}`) + } response = await this._makeClaudeRequest( - JSON.parse(JSON.stringify(baseRequestBody)), + retryRequestBody, accessToken, proxyAgent, clientHeaders, @@ -1716,14 +1728,17 @@ class ClaudeRelayService { const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders) const processedBody = this._processRequestBody(requestBody, account) - const baseRequestBody = JSON.parse(JSON.stringify(processedBody)) + // 🧹 内存优化:存储到 bodyStore,不放入 requestOptions 避免闭包捕获 + const originalBodyString = JSON.stringify(processedBody) + const bodyStoreId = ++this._bodyStoreIdCounter + this.bodyStore.set(bodyStoreId, originalBodyString) // 获取代理配置 const proxyAgent = await this._getProxyAgent(accountId) // 发送流式请求并捕获usage数据 await this._makeClaudeStreamRequestWithUsageCapture( - JSON.parse(JSON.stringify(baseRequestBody)), + processedBody, accessToken, proxyAgent, clientHeaders, @@ -1740,7 +1755,7 @@ class ClaudeRelayService { streamTransformer, { ...options, - originalRequestBody: baseRequestBody, + bodyStoreId: bodyStoreId, isRealClaudeCodeRequest }, isDedicatedOfficialAccount, @@ -1943,9 +1958,17 @@ class ClaudeRelayService { try { // 递归调用自身进行重试 - const retryBody = requestOptions.originalRequestBody - ? JSON.parse(JSON.stringify(requestOptions.originalRequestBody)) - : body + // 🧹 从 bodyStore 获取字符串用于重试 + if (!requestOptions.bodyStoreId || !this.bodyStore.has(requestOptions.bodyStoreId)) { + throw new Error('529 retry requires valid bodyStoreId') + } + let retryBody + try { + retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId)) + } catch (parseError) { + logger.error(`❌ Failed to parse body for 529 retry: ${parseError.message}`) + throw new Error(`529 retry body parse failed: ${parseError.message}`) + } const retryResult = await this._makeClaudeStreamRequestWithUsageCapture( retryBody, accessToken, @@ -2050,10 +2073,17 @@ class ClaudeRelayService { if ( this._isClaudeCodeCredentialError(errorData) && requestOptions.useRandomizedToolNames !== true && - requestOptions.originalRequestBody + requestOptions.bodyStoreId && this.bodyStore.has(requestOptions.bodyStoreId) ) { + let retryBody + try { + retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId)) + } catch (parseError) { + logger.error(`❌ Failed to parse body for 403 retry: ${parseError.message}`) + reject(new Error(`403 retry body parse failed: ${parseError.message}`)) + return + } try { - const retryBody = JSON.parse(JSON.stringify(requestOptions.originalRequestBody)) const retryResult = await this._makeClaudeStreamRequestWithUsageCapture( retryBody, accessToken, @@ -2149,6 +2179,11 @@ class ClaudeRelayService { let rateLimitDetected = false // 限流检测标志 // 监听数据块,解析SSE并寻找usage信息 + // 🧹 内存优化:在闭包创建前提取需要的值,避免闭包捕获 body 和 requestOptions + // body 和 requestOptions 只在闭包外使用,闭包内只引用基本类型 + const requestedModel = body?.model || 'unknown' + const isRealClaudeCodeRequest = requestOptions.isRealClaudeCodeRequest + res.on('data', (chunk) => { try { const chunkStr = chunk.toString() @@ -2354,7 +2389,7 @@ class ClaudeRelayService { // 打印原始的usage数据为JSON字符串,避免嵌套问题 logger.info( - `📊 === Stream Request Usage Summary === Model: ${body.model}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}` + `📊 === Stream Request Usage Summary === Model: ${requestedModel}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}` ) // 一般一个请求只会使用一个模型,即使有多个usage事件也应该合并 @@ -2364,7 +2399,7 @@ class ClaudeRelayService { output_tokens: totalUsage.output_tokens, cache_creation_input_tokens: totalUsage.cache_creation_input_tokens, cache_read_input_tokens: totalUsage.cache_read_input_tokens, - model: allUsageData[allUsageData.length - 1].model || body.model // 使用最后一个模型或请求模型 + model: allUsageData[allUsageData.length - 1].model || requestedModel // 使用最后一个模型或请求模型 } // 如果有详细的cache_creation数据,合并它们 @@ -2476,12 +2511,16 @@ class ClaudeRelayService { if ( clientHeaders && Object.keys(clientHeaders).length > 0 && - this.isRealClaudeCodeRequest(body) + isRealClaudeCodeRequest ) { await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) } } + // 🧹 清理 bodyStore + if (requestOptions.bodyStoreId) { + this.bodyStore.delete(requestOptions.bodyStoreId) + } logger.debug('🌊 Claude stream response with usage capture completed') resolve() }) @@ -2538,6 +2577,10 @@ class ClaudeRelayService { ) responseStream.end() } + // 🧹 清理 bodyStore + if (requestOptions.bodyStoreId) { + this.bodyStore.delete(requestOptions.bodyStoreId) + } reject(error) }) @@ -2567,6 +2610,10 @@ class ClaudeRelayService { ) responseStream.end() } + // 🧹 清理 bodyStore + if (requestOptions.bodyStoreId) { + this.bodyStore.delete(requestOptions.bodyStoreId) + } reject(new Error('Request timeout')) })