From 1e372dd3652dee9750daebf7e94cfb4409208371 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 23 Jul 2025 15:56:27 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E7=BC=93=E5=86=B2=E9=97=AE=E9=A2=98=EF=BC=8C?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=9C=9F=E6=AD=A3=E7=9A=84=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E6=B5=81=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 配置 compression 中间件排除 SSE 流式响应,避免压缩导致的缓冲 - 添加 X-Accel-Buffering: no 响应头,禁用 Nginx 等代理的缓冲 - 使用 res.flushHeaders() 立即发送响应头 - 禁用 Nagle 算法确保数据立即发送 - 在每次写入流数据后调用 flush() 确保实时传输 这些修复确保了流式请求能够正常显示打字机效果,数据从上游 Claude API 接收后能够立即转发给客户端。 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/app.js | 13 +++- src/routes/api.js | 9 +++ src/services/claudeRelayService.js | 96 ++++++++++++++++++------------ 3 files changed, 79 insertions(+), 39 deletions(-) diff --git a/src/app.js b/src/app.js index 26ed5141..42168da5 100644 --- a/src/app.js +++ b/src/app.js @@ -64,8 +64,17 @@ class Application { this.app.use(corsMiddleware); } - // 📦 压缩 - this.app.use(compression()); + // 📦 压缩 - 排除流式响应(SSE) + this.app.use(compression({ + filter: (req, res) => { + // 不压缩 Server-Sent Events + if (res.getHeader('Content-Type') === 'text/event-stream') { + return false; + } + // 使用默认的压缩判断 + return compression.filter(req, res); + } + })); // 🚦 全局速率限制(仅在生产环境启用) if (process.env.NODE_ENV === 'production') { diff --git a/src/routes/api.js b/src/routes/api.js index 272766b5..ddf92714 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -45,6 +45,15 @@ async function handleMessagesRequest(req, res) { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('X-Accel-Buffering', 'no'); // 禁用 Nginx 缓冲 + + // 立即发送响应头,防止缓冲 + res.flushHeaders(); + + // 禁用 Nagle 算法,确保数据立即发送 + if (res.socket && res.socket.setNoDelay) { + res.socket.setNoDelay(true); + } // 流式响应不需要额外处理,中间件已经设置了监听器 diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index a70e2dea..fbf15247 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -36,17 +36,22 @@ class ClaudeRelayService { _hasClaudeCodeSystemPrompt(requestBody) { if (!requestBody || !requestBody.system) return false; - let systemText = ''; + // 如果是字符串格式,一定不是真实的 Claude Code 请求 if (typeof requestBody.system === 'string') { - systemText = requestBody.system; - } else if (Array.isArray(requestBody.system)) { - systemText = requestBody.system - .filter(item => item && item.type === 'text' && item.text) - .map(item => item.text) - .join(' '); + return false; + } + + // 处理数组格式 + if (Array.isArray(requestBody.system) && requestBody.system.length > 0) { + const firstItem = requestBody.system[0]; + // 检查第一个元素是否包含 Claude Code 提示词 + return firstItem && + firstItem.type === 'text' && + firstItem.text && + firstItem.text === this.claudeCodeSystemPrompt; } - return systemText.includes(this.claudeCodeSystemPrompt); + return false; } // 🚀 转发请求到Claude API @@ -203,24 +208,47 @@ class ClaudeRelayService { if (!isRealClaudeCode) { const claudeCodePrompt = { type: 'text', - text: this.claudeCodeSystemPrompt + text: this.claudeCodeSystemPrompt, + cache_control: { + type: 'ephemeral' + } }; if (processedBody.system) { - if (Array.isArray(processedBody.system)) { - // 检查是否已经有 Claude Code 系统提示词 - const hasClaudeCodePrompt = processedBody.system.some(item => - item && item.text && item.text.includes(this.claudeCodeSystemPrompt) - ); + if (typeof processedBody.system === 'string') { + // 字符串格式:转换为数组,Claude Code 提示词在第一位 + const userSystemPrompt = { + type: 'text', + text: processedBody.system + }; + // 如果用户的提示词与 Claude Code 提示词相同,只保留一个 + if (processedBody.system.trim() === this.claudeCodeSystemPrompt) { + processedBody.system = [claudeCodePrompt]; + } else { + processedBody.system = [claudeCodePrompt, userSystemPrompt]; + } + } else if (Array.isArray(processedBody.system)) { + // 检查第一个元素是否是 Claude Code 系统提示词 + const firstItem = processedBody.system[0]; + const isFirstItemClaudeCode = firstItem && + firstItem.type === 'text' && + firstItem.text === this.claudeCodeSystemPrompt; - if (!hasClaudeCodePrompt) { - // 添加 Claude Code 系统提示词到开头 - processedBody.system.unshift(claudeCodePrompt); + if (!isFirstItemClaudeCode) { + // 如果第一个不是 Claude Code 提示词,需要在开头插入 + // 同时检查数组中是否有其他位置包含 Claude Code 提示词,如果有则移除 + const filteredSystem = processedBody.system.filter(item => + !(item && item.type === 'text' && item.text === this.claudeCodeSystemPrompt) + ); + processedBody.system = [claudeCodePrompt, ...filteredSystem]; } } else { - throw new Error('system field must be an array'); + // 其他格式,记录警告但不抛出错误,尝试处理 + logger.warn('⚠️ Unexpected system field type:', typeof processedBody.system); + processedBody.system = [claudeCodePrompt]; } } else { + // 用户没有传递 system,需要添加 Claude Code 提示词 processedBody.system = [claudeCodePrompt]; } } @@ -232,27 +260,17 @@ class ClaudeRelayService { text: this.systemPrompt }; - if (processedBody.system) { - if (Array.isArray(processedBody.system)) { - // 如果system数组存在但为空,或者没有有效内容,则添加系统提示 - const hasValidContent = processedBody.system.some(item => - item && item.text && item.text.trim() - ); - if (!hasValidContent) { - processedBody.system = [systemPrompt]; - } else { - // 不要重复添加相同的系统提示 - const hasSystemPrompt = processedBody.system.some(item => - item && item.text && item.text === this.systemPrompt - ); - if (!hasSystemPrompt) { - processedBody.system.push(systemPrompt); - } - } - } else { - throw new Error('system field must be an array'); + // 经过上面的处理,system 现在应该总是数组格式 + if (processedBody.system && Array.isArray(processedBody.system)) { + // 不要重复添加相同的系统提示 + const hasSystemPrompt = processedBody.system.some(item => + item && item.text && item.text === this.systemPrompt + ); + if (!hasSystemPrompt) { + processedBody.system.push(systemPrompt); } } else { + // 理论上不应该走到这里,但为了安全起见 processedBody.system = [systemPrompt]; } } else { @@ -691,9 +709,13 @@ class ClaudeRelayService { const transformed = streamTransformer(linesToForward); if (transformed) { responseStream.write(transformed); + // 立即刷新数据,确保实时发送 + if (responseStream.flush) responseStream.flush(); } } else { responseStream.write(linesToForward); + // 立即刷新数据,确保实时发送 + if (responseStream.flush) responseStream.flush(); } } From 3553f5cc1f8f12497db42eb31da510158d3d45cb Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 23 Jul 2025 16:13:07 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E7=9A=84=20Parse=20Error=20=E5=92=8C?= =?UTF-8?q?=E7=BC=93=E5=86=B2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要修改: 1. 从 compression 中间件中排除 SSE 流式响应,避免压缩导致的缓冲 2. 移除导致 Parse Error 的 res.flushHeaders() 调用 3. 改进流式响应的错误处理,发送 SSE 错误事件而不是破坏流 4. 在写入数据前检查流状态,避免写入已销毁的流 5. 优化响应结束时的处理逻辑,确保缓冲区数据正确处理 这些修改确保了流式请求能够正常显示打字机效果,同时保留了 usage token 收集功能。 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/routes/api.js | 5 +- src/services/claudeRelayService.js | 114 +++++++++++++++++++---------- 2 files changed, 78 insertions(+), 41 deletions(-) diff --git a/src/routes/api.js b/src/routes/api.js index ddf92714..8f38347b 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -47,11 +47,8 @@ async function handleMessagesRequest(req, res) { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('X-Accel-Buffering', 'no'); // 禁用 Nginx 缓冲 - // 立即发送响应头,防止缓冲 - res.flushHeaders(); - // 禁用 Nagle 算法,确保数据立即发送 - if (res.socket && res.socket.setNoDelay) { + if (res.socket && typeof res.socket.setNoDelay === 'function') { res.socket.setNoDelay(true); } diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index fbf15247..76585547 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -680,11 +680,34 @@ class ClaudeRelayService { } const req = https.request(options, (res) => { - // 设置响应头 - responseStream.statusCode = res.statusCode; - Object.keys(res.headers).forEach(key => { - responseStream.setHeader(key, res.headers[key]); - }); + logger.debug(`🌊 Claude stream response status: ${res.statusCode}`); + + // 错误响应处理 + if (res.statusCode !== 200) { + logger.error(`❌ Claude API returned error status: ${res.statusCode}`); + let errorData = ''; + + res.on('data', (chunk) => { + errorData += chunk.toString(); + }); + + res.on('end', () => { + logger.error('❌ Claude API error response:', errorData); + if (!responseStream.destroyed) { + // 发送错误事件 + responseStream.write('event: error\n'); + responseStream.write(`data: ${JSON.stringify({ + error: 'Claude API error', + status: res.statusCode, + details: errorData, + timestamp: new Date().toISOString() + })}\n\n`); + responseStream.end(); + } + reject(new Error(`Claude API error: ${res.statusCode}`)); + }); + return; + } let buffer = ''; let finalUsageReported = false; // 防止重复统计的标志 @@ -693,31 +716,28 @@ class ClaudeRelayService { // 监听数据块,解析SSE并寻找usage信息 res.on('data', (chunk) => { - const chunkStr = chunk.toString(); - - buffer += chunkStr; - - // 处理完整的SSE行 - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // 保留最后的不完整行 - - // 转发已处理的完整行到客户端 - if (lines.length > 0) { - const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : ''); - // 如果有流转换器,应用转换 - if (streamTransformer) { - const transformed = streamTransformer(linesToForward); - if (transformed) { - responseStream.write(transformed); - // 立即刷新数据,确保实时发送 - if (responseStream.flush) responseStream.flush(); + try { + const chunkStr = chunk.toString(); + + buffer += chunkStr; + + // 处理完整的SSE行 + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // 保留最后的不完整行 + + // 转发已处理的完整行到客户端 + if (lines.length > 0 && !responseStream.destroyed) { + const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : ''); + // 如果有流转换器,应用转换 + if (streamTransformer) { + const transformed = streamTransformer(linesToForward); + if (transformed) { + responseStream.write(transformed); + } + } else { + responseStream.write(linesToForward); } - } else { - responseStream.write(linesToForward); - // 立即刷新数据,确保实时发送 - if (responseStream.flush) responseStream.flush(); } - } for (const line of lines) { // 解析SSE数据寻找usage信息 @@ -764,21 +784,41 @@ class ClaudeRelayService { } } } + } catch (error) { + logger.error('❌ Error processing stream data:', error); + // 发送错误但不破坏流,让它自然结束 + if (!responseStream.destroyed) { + responseStream.write('event: error\n'); + responseStream.write(`data: ${JSON.stringify({ + error: 'Stream processing error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n`); + } + } }); res.on('end', async () => { - // 处理缓冲区中剩余的数据 - if (buffer.trim()) { - if (streamTransformer) { - const transformed = streamTransformer(buffer); - if (transformed) { - responseStream.write(transformed); + try { + // 处理缓冲区中剩余的数据 + if (buffer.trim() && !responseStream.destroyed) { + if (streamTransformer) { + const transformed = streamTransformer(buffer); + if (transformed) { + responseStream.write(transformed); + } + } else { + responseStream.write(buffer); } - } else { - responseStream.write(buffer); } + + // 确保流正确结束 + if (!responseStream.destroyed) { + responseStream.end(); + } + } catch (error) { + logger.error('❌ Error processing stream end:', error); } - responseStream.end(); // 检查是否捕获到usage数据 if (!finalUsageReported) { From 5392ee97991617284f229d304731b61788ff983b Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 23 Jul 2025 16:20:36 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20README.md=20=E4=B8=8E?= =?UTF-8?q?=20main=20=E5=88=86=E6=94=AF=E4=BF=9D=E6=8C=81=E4=B8=80?= =?UTF-8?q?=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4f97574c..c6f05f82 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ - **硬盘**: 30GB可用空间 - **网络**: 能访问到Anthropic API(建议使用US地区的机器) - **建议**: 2核4G的基本够了,网络尽量选回国线路快一点的(为了提高速度,建议不要开代理或者设置服务器的IP直连) +- **经验**: 阿里云、腾讯云的海外主机经测试会被Cloudflare拦截,无法直接访问claude api ### 软件要求 - **Node.js** 18或更高版本