From 3616245d4923a5b3f8454a7df3ce99662e86640f Mon Sep 17 00:00:00 2001 From: shinegod Date: Wed, 6 Aug 2025 14:27:57 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E9=9D=A2=E5=A2=9E=E5=BC=BA=20?= =?UTF-8?q?Claude=20Code=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=B8=8E=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🚀 新功能 - **智能认证系统**: 根据 API Key 格式自动选择认证方式 - `sk-ant-*` 开头使用 `x-api-key` 认证(兼容 Anthropic 官方) - 其他格式使用 `Authorization: Bearer` 认证(兼容标准 REST API) - **Claude Code 客户端完整支持**: 新增必需的 API 端点 - `GET /v1/models` - 返回支持的模型列表 - `GET /v1/me` - 用户信息端点 - `GET /v1/organizations/:org_id/usage` - 使用统计查询 ## 🔧 修复与优化 - **HTTP 协议合规性**: 修复响应头冲突导致的 502 错误 - 避免同时发送 `Content-Length` 和 `Transfer-Encoding` 头部 - 优化响应头过滤机制,确保代理兼容性 - **完全透传错误响应**: 保持上游 API 原始响应格式 - 透传原始状态码、响应头和内容 - 移除错误包装,直接转发原始 JSON 格式 - 支持流式和非流式请求的错误透传 - **流式响应处理优化**: - 添加 `validateStatus: () => true` 配置 - 改进错误处理逻辑,避免异常中断 ## 📝 代码质量 - 修复 ESLint 代码规范警告 - 优化敏感头部过滤列表 - 改进调试日志输出 ## 🎯 解决的问题 - Claude Code 客户端无法连接(502 Bad Gateway) - 错误响应被包装而非透传原始格式 - sk-ant-* 格式 API Key 认证失败 - HTTP/2 代理环境下的响应头冲突 ## ✅ 测试验证 - 本地测试完全透传上游错误响应 - Claude Code 客户端连接测试通过 - 智能认证机制验证成功 - HTTP 协议合规性确认 --- src/routes/api.js | 92 ++++++++++++++++++++++- src/services/claudeConsoleRelayService.js | 67 +++++++++++------ src/services/pricingService.js | 2 +- 3 files changed, 137 insertions(+), 24 deletions(-) diff --git a/src/routes/api.js b/src/routes/api.js index a9184db0..96e88e73 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -180,9 +180,10 @@ async function handleMessagesRequest(req, res) { res.status(response.statusCode); - // 设置响应头 + // 设置响应头,避免 Content-Length 和 Transfer-Encoding 冲突 + const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length']; Object.keys(response.headers).forEach(key => { - if (key.toLowerCase() !== 'content-encoding') { + if (!skipHeaders.includes(key.toLowerCase())) { res.setHeader(key, response.headers[key]); } }); @@ -282,6 +283,51 @@ router.post('/v1/messages', authenticateApiKey, handleMessagesRequest); // 🚀 Claude API messages 端点 - /claude/v1/messages (别名) router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest); +// 📋 模型列表端点 - Claude Code 客户端需要 +router.get('/v1/models', authenticateApiKey, async (req, res) => { + try { + // 返回支持的模型列表 + const models = [ + { + id: 'claude-3-5-sonnet-20241022', + object: 'model', + created: 1669599635, + owned_by: 'anthropic' + }, + { + id: 'claude-3-5-haiku-20241022', + object: 'model', + created: 1669599635, + owned_by: 'anthropic' + }, + { + id: 'claude-3-opus-20240229', + object: 'model', + created: 1669599635, + owned_by: 'anthropic' + }, + { + id: 'claude-sonnet-4-20250514', + object: 'model', + created: 1669599635, + owned_by: 'anthropic' + } + ]; + + res.json({ + object: 'list', + data: models + }); + + } catch (error) { + logger.error('❌ Models list error:', error); + res.status(500).json({ + error: 'Failed to get models list', + message: error.message + }); + } +}); + // 🏥 健康检查端点 router.get('/health', async (req, res) => { try { @@ -349,4 +395,46 @@ router.get('/v1/usage', authenticateApiKey, async (req, res) => { } }); +// 👤 用户信息端点 - Claude Code 客户端需要 +router.get('/v1/me', authenticateApiKey, async (req, res) => { + try { + // 返回基础用户信息 + res.json({ + id: 'user_' + req.apiKey.id, + type: 'user', + display_name: req.apiKey.name || 'API User', + created_at: new Date().toISOString() + }); + } catch (error) { + logger.error('❌ User info error:', error); + res.status(500).json({ + error: 'Failed to get user info', + message: error.message + }); + } +}); + +// 💰 余额/限制端点 - Claude Code 客户端需要 +router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, res) => { + try { + const usage = await apiKeyService.getUsageStats(req.apiKey.id); + + res.json({ + object: 'usage', + data: [ + { + type: 'credit_balance', + credit_balance: req.apiKey.tokenLimit - (usage.totalTokens || 0) + } + ] + }); + } catch (error) { + logger.error('❌ Organization usage error:', error); + res.status(500).json({ + error: 'Failed to get usage info', + message: error.message + }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index ca28d4c8..361d106a 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -86,11 +86,8 @@ class ClaudeConsoleRelayService { data: modifiedRequestBody, headers: { 'Content-Type': 'application/json', - 'x-api-key': account.apiKey, 'anthropic-version': '2023-06-01', 'User-Agent': account.userAgent || this.defaultUserAgent, - 'anthropic-dangerous-direct-browser-access': true, - 'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14', ...filteredHeaders }, httpsAgent: proxyAgent, @@ -99,6 +96,17 @@ class ClaudeConsoleRelayService { validateStatus: () => true // 接受所有状态码 }; + // 根据 API Key 格式选择认证方式 + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + // Anthropic 官方 API Key 使用 x-api-key + requestConfig.headers['x-api-key'] = account.apiKey; + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key'); + } else { + // 其他 API Key 使用 Authorization Bearer + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`; + logger.debug('[DEBUG] Using Authorization Bearer authentication'); + } + logger.debug(`[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}`); // 添加beta header如果需要 @@ -230,18 +238,27 @@ class ClaudeConsoleRelayService { data: body, headers: { 'Content-Type': 'application/json', - 'x-api-key': account.apiKey, 'anthropic-version': '2023-06-01', 'User-Agent': account.userAgent || this.defaultUserAgent, - 'anthropic-dangerous-direct-browser-access': true, - 'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14', ...filteredHeaders }, httpsAgent: proxyAgent, timeout: config.proxy.timeout || 60000, - responseType: 'stream' + responseType: 'stream', + validateStatus: () => true // 接受所有状态码 }; + // 根据 API Key 格式选择认证方式 + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + // Anthropic 官方 API Key 使用 x-api-key + requestConfig.headers['x-api-key'] = account.apiKey; + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key'); + } else { + // 其他 API Key 使用 Authorization Bearer + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`; + logger.debug('[DEBUG] Using Authorization Bearer authentication'); + } + // 添加beta header如果需要 if (requestOptions.betaHeader) { requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader; @@ -261,24 +278,31 @@ class ClaudeConsoleRelayService { claudeConsoleAccountService.markAccountRateLimited(accountId); } - // 收集错误数据 - let errorData = ''; + // 设置错误响应的状态码和响应头 + if (!responseStream.headersSent) { + const errorHeaders = { + 'Content-Type': response.headers['content-type'] || 'application/json', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }; + // 避免 Transfer-Encoding 冲突,让 Express 自动处理 + delete errorHeaders['Transfer-Encoding']; + delete errorHeaders['Content-Length']; + responseStream.writeHead(response.status, errorHeaders); + } + + // 直接透传错误数据,不进行包装 response.data.on('data', chunk => { - errorData += chunk.toString(); + if (!responseStream.destroyed) { + responseStream.write(chunk); + } }); response.data.on('end', () => { if (!responseStream.destroyed) { - responseStream.write('event: error\n'); - responseStream.write(`data: ${JSON.stringify({ - error: 'Claude Console API error', - status: response.status, - details: errorData, - timestamp: new Date().toISOString() - })}\n\n`); responseStream.end(); } - reject(new Error(`Claude Console API error: ${response.status}`)); + resolve(); // 不抛出异常,正常完成流处理 }); return; } @@ -459,15 +483,16 @@ class ClaudeConsoleRelayService { _filterClientHeaders(clientHeaders) { const sensitiveHeaders = [ 'content-type', - "user-agent", - 'x-api-key', + 'user-agent', 'authorization', + 'x-api-key', 'host', 'content-length', 'connection', 'proxy-authorization', 'content-encoding', - 'transfer-encoding' + 'transfer-encoding', + 'anthropic-version' ]; const filteredHeaders = {}; diff --git a/src/services/pricingService.js b/src/services/pricingService.js index 1b4466b6..6d8b586d 100644 --- a/src/services/pricingService.js +++ b/src/services/pricingService.js @@ -314,7 +314,7 @@ class PricingService { // 记录初始的修改时间 let lastMtime = fs.statSync(this.pricingFile).mtimeMs; - fs.watchFile(this.pricingFile, watchOptions, (curr, prev) => { + fs.watchFile(this.pricingFile, watchOptions, (curr, _prev) => { // 检查文件是否真的被修改了(不仅仅是访问) if (curr.mtimeMs !== lastMtime) { lastMtime = curr.mtimeMs;