mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge branch 'dev'
This commit is contained in:
@@ -119,6 +119,7 @@
|
||||
- **硬盘**: 30GB可用空间
|
||||
- **网络**: 能访问到Anthropic API(建议使用US地区的机器)
|
||||
- **建议**: 2核4G的基本够了,网络尽量选回国线路快一点的(为了提高速度,建议不要开代理或者设置服务器的IP直连)
|
||||
- **经验**: 阿里云、腾讯云的海外主机经测试会被Cloudflare拦截,无法直接访问claude api
|
||||
|
||||
### 软件要求
|
||||
- **Node.js** 18或更高版本
|
||||
|
||||
13
src/app.js
13
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') {
|
||||
|
||||
@@ -45,6 +45,12 @@ 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 缓冲
|
||||
|
||||
// 禁用 Nagle 算法,确保数据立即发送
|
||||
if (res.socket && typeof res.socket.setNoDelay === 'function') {
|
||||
res.socket.setNoDelay(true);
|
||||
}
|
||||
|
||||
// 流式响应不需要额外处理,中间件已经设置了监听器
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
return systemText.includes(this.claudeCodeSystemPrompt);
|
||||
// 处理数组格式
|
||||
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 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 {
|
||||
@@ -662,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; // 防止重复统计的标志
|
||||
@@ -675,27 +716,28 @@ class ClaudeRelayService {
|
||||
|
||||
// 监听数据块,解析SSE并寻找usage信息
|
||||
res.on('data', (chunk) => {
|
||||
const chunkStr = chunk.toString();
|
||||
try {
|
||||
const chunkStr = chunk.toString();
|
||||
|
||||
buffer += chunkStr;
|
||||
buffer += chunkStr;
|
||||
|
||||
// 处理完整的SSE行
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // 保留最后的不完整行
|
||||
// 处理完整的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 (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);
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
// 解析SSE数据寻找usage信息
|
||||
@@ -742,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) {
|
||||
|
||||
Reference in New Issue
Block a user