protocol-proxy 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -0
- package/config/proxies.json +3 -0
- package/lib/config-store.js +119 -0
- package/lib/converters/anthropic-to-openai.js +366 -0
- package/lib/converters/openai-to-anthropic.js +321 -0
- package/lib/converters/sse-helpers.js +51 -0
- package/lib/detector.js +38 -0
- package/lib/proxy-manager.js +79 -0
- package/lib/proxy-server.js +216 -0
- package/package.json +53 -0
- package/public/app.js +459 -0
- package/public/index.html +136 -0
- package/public/style.css +639 -0
- package/server.js +202 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI → Anthropic 协议转换
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { encodeAnthropicEvent } = require('./sse-helpers');
|
|
6
|
+
|
|
7
|
+
// ==================== 请求转换 ====================
|
|
8
|
+
|
|
9
|
+
function convertRequest(body, targetModel) {
|
|
10
|
+
const result = {
|
|
11
|
+
model: targetModel,
|
|
12
|
+
max_tokens: body.max_tokens || 4096,
|
|
13
|
+
stream: body.stream || false,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (body.temperature !== undefined) result.temperature = body.temperature;
|
|
17
|
+
if (body.top_p !== undefined) result.top_p = body.top_p;
|
|
18
|
+
if (body.stop !== undefined) result.stop_sequences = Array.isArray(body.stop) ? body.stop : [body.stop];
|
|
19
|
+
|
|
20
|
+
// 处理 messages 和 system
|
|
21
|
+
const systemMessages = [];
|
|
22
|
+
const otherMessages = [];
|
|
23
|
+
|
|
24
|
+
for (const msg of (body.messages || [])) {
|
|
25
|
+
if (msg.role === 'system') {
|
|
26
|
+
systemMessages.push(typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content));
|
|
27
|
+
} else {
|
|
28
|
+
otherMessages.push(msg);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (systemMessages.length > 0) {
|
|
33
|
+
result.system = systemMessages.join('\n\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 转换消息角色和内容
|
|
37
|
+
result.messages = otherMessages.map(msg => convertMessage(msg));
|
|
38
|
+
|
|
39
|
+
// 转换 tools / functions
|
|
40
|
+
if (body.tools && Array.isArray(body.tools)) {
|
|
41
|
+
result.tools = body.tools.map(t => convertTool(t));
|
|
42
|
+
} else if (body.functions && Array.isArray(body.functions)) {
|
|
43
|
+
// 旧版 functions 转 tools
|
|
44
|
+
result.tools = body.functions.map(f => ({
|
|
45
|
+
name: f.name,
|
|
46
|
+
description: f.description,
|
|
47
|
+
input_schema: f.parameters || { type: 'object', properties: {} },
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 转换 tool_choice
|
|
52
|
+
if (body.tool_choice) {
|
|
53
|
+
result.tool_choice = convertToolChoice(body.tool_choice);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function convertMessage(msg) {
|
|
60
|
+
if (msg.role === 'tool') {
|
|
61
|
+
// OpenAI tool result → Anthropic tool_result content block
|
|
62
|
+
return {
|
|
63
|
+
role: 'user',
|
|
64
|
+
content: [{
|
|
65
|
+
type: 'tool_result',
|
|
66
|
+
tool_use_id: msg.tool_call_id,
|
|
67
|
+
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
68
|
+
}],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (msg.role === 'assistant' && msg.tool_calls) {
|
|
73
|
+
// assistant message with tool_calls → assistant with tool_use blocks
|
|
74
|
+
const content = [];
|
|
75
|
+
if (msg.content) {
|
|
76
|
+
content.push({ type: 'text', text: msg.content });
|
|
77
|
+
}
|
|
78
|
+
for (const tc of msg.tool_calls) {
|
|
79
|
+
content.push({
|
|
80
|
+
type: 'tool_use',
|
|
81
|
+
id: tc.id,
|
|
82
|
+
name: tc.function?.name,
|
|
83
|
+
input: tc.function?.arguments ? JSON.parse(tc.function.arguments) : {},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return { role: 'assistant', content };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 普通消息
|
|
90
|
+
return {
|
|
91
|
+
role: msg.role,
|
|
92
|
+
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function convertTool(tool) {
|
|
97
|
+
return {
|
|
98
|
+
name: tool.function?.name || tool.name,
|
|
99
|
+
description: tool.function?.description || tool.description,
|
|
100
|
+
input_schema: tool.function?.parameters || tool.input_schema || { type: 'object', properties: {} },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function convertToolChoice(tc) {
|
|
105
|
+
if (tc === 'auto') return { type: 'auto' };
|
|
106
|
+
if (tc === 'none') return { type: 'none' };
|
|
107
|
+
if (tc === 'required') return { type: 'any' };
|
|
108
|
+
if (typeof tc === 'object' && tc.type === 'function') {
|
|
109
|
+
return { type: 'tool', name: tc.function?.name };
|
|
110
|
+
}
|
|
111
|
+
return { type: 'auto' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ==================== 响应转换 ====================
|
|
115
|
+
|
|
116
|
+
function convertResponse(anthropicBody) {
|
|
117
|
+
const choice = {
|
|
118
|
+
index: 0,
|
|
119
|
+
message: {
|
|
120
|
+
role: 'assistant',
|
|
121
|
+
content: '',
|
|
122
|
+
},
|
|
123
|
+
finish_reason: null,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// 提取文本内容和 tool_calls
|
|
127
|
+
const toolCalls = [];
|
|
128
|
+
const textParts = [];
|
|
129
|
+
|
|
130
|
+
for (const block of (anthropicBody.content || [])) {
|
|
131
|
+
if (block.type === 'text') {
|
|
132
|
+
textParts.push(block.text);
|
|
133
|
+
} else if (block.type === 'tool_use') {
|
|
134
|
+
toolCalls.push({
|
|
135
|
+
id: block.id,
|
|
136
|
+
type: 'function',
|
|
137
|
+
function: {
|
|
138
|
+
name: block.name,
|
|
139
|
+
arguments: JSON.stringify(block.input || {}),
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
choice.message.content = textParts.join('');
|
|
146
|
+
if (toolCalls.length > 0) {
|
|
147
|
+
choice.message.tool_calls = toolCalls;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 映射 stop_reason
|
|
151
|
+
choice.finish_reason = mapStopReason(anthropicBody.stop_reason);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
id: anthropicBody.id,
|
|
155
|
+
object: 'chat.completion',
|
|
156
|
+
created: Math.floor(Date.now() / 1000),
|
|
157
|
+
model: anthropicBody.model,
|
|
158
|
+
choices: [choice],
|
|
159
|
+
usage: anthropicBody.usage ? {
|
|
160
|
+
prompt_tokens: anthropicBody.usage.input_tokens,
|
|
161
|
+
completion_tokens: anthropicBody.usage.output_tokens,
|
|
162
|
+
total_tokens: (anthropicBody.usage.input_tokens || 0) + (anthropicBody.usage.output_tokens || 0),
|
|
163
|
+
} : undefined,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function mapStopReason(reason) {
|
|
168
|
+
if (!reason) return null;
|
|
169
|
+
if (reason === 'end_turn') return 'stop';
|
|
170
|
+
if (reason === 'max_tokens') return 'length';
|
|
171
|
+
if (reason === 'tool_use') return 'tool_calls';
|
|
172
|
+
if (reason === 'stop_sequence') return 'stop';
|
|
173
|
+
return reason;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ==================== SSE 流式转换 ====================
|
|
177
|
+
|
|
178
|
+
function createSSEConverter(targetModel) {
|
|
179
|
+
const state = {
|
|
180
|
+
messageId: null,
|
|
181
|
+
blockType: null,
|
|
182
|
+
blockIndex: 0,
|
|
183
|
+
toolUseId: null,
|
|
184
|
+
toolName: null,
|
|
185
|
+
sentRole: false,
|
|
186
|
+
sentToolInit: false,
|
|
187
|
+
buffer: '',
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
convertChunk(chunkText) {
|
|
192
|
+
let output = '';
|
|
193
|
+
state.buffer += chunkText;
|
|
194
|
+
const lines = state.buffer.split('\n');
|
|
195
|
+
state.buffer = lines.pop() || ''; // 保留不完整的最后一行
|
|
196
|
+
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
const converted = processLine(line.trim(), state, targetModel);
|
|
199
|
+
if (converted) output += converted;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return output;
|
|
203
|
+
},
|
|
204
|
+
flush() {
|
|
205
|
+
// 结束时不发送额外内容,[DONE] 在 finish_reason 时已经发送
|
|
206
|
+
return '';
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function processLine(line, state, targetModel) {
|
|
212
|
+
if (!line.startsWith('data:')) return '';
|
|
213
|
+
const dataStr = line.slice(5).trim();
|
|
214
|
+
if (dataStr === '[DONE]') return '';
|
|
215
|
+
|
|
216
|
+
let event;
|
|
217
|
+
try {
|
|
218
|
+
event = JSON.parse(dataStr);
|
|
219
|
+
} catch {
|
|
220
|
+
return '';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!event || !event.type) return '';
|
|
224
|
+
|
|
225
|
+
switch (event.type) {
|
|
226
|
+
case 'message_start': {
|
|
227
|
+
state.messageId = event.message?.id;
|
|
228
|
+
state.sentRole = false;
|
|
229
|
+
return '';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case 'content_block_start': {
|
|
233
|
+
state.blockType = event.content_block?.type;
|
|
234
|
+
state.blockIndex = event.index;
|
|
235
|
+
state.sentToolInit = false;
|
|
236
|
+
|
|
237
|
+
if (state.blockType === 'tool_use') {
|
|
238
|
+
state.toolUseId = event.content_block.id;
|
|
239
|
+
state.toolName = event.content_block.name;
|
|
240
|
+
}
|
|
241
|
+
return '';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case 'content_block_delta': {
|
|
245
|
+
const delta = event.delta;
|
|
246
|
+
if (!delta) return '';
|
|
247
|
+
|
|
248
|
+
// 发送 role(如果是第一个内容块)
|
|
249
|
+
let prefix = '';
|
|
250
|
+
if (!state.sentRole) {
|
|
251
|
+
prefix = encodeOpenAIChunk(state.messageId, targetModel, { role: 'assistant' });
|
|
252
|
+
state.sentRole = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (delta.type === 'text_delta' && delta.text) {
|
|
256
|
+
return prefix + encodeOpenAIChunk(state.messageId, targetModel, { content: delta.text });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (delta.type === 'input_json_delta' && delta.partial_json !== undefined) {
|
|
260
|
+
if (!state.sentToolInit) {
|
|
261
|
+
state.sentToolInit = true;
|
|
262
|
+
const toolCallChunk = {
|
|
263
|
+
tool_calls: [{
|
|
264
|
+
index: 0,
|
|
265
|
+
id: state.toolUseId,
|
|
266
|
+
type: 'function',
|
|
267
|
+
function: { name: state.toolName, arguments: delta.partial_json },
|
|
268
|
+
}],
|
|
269
|
+
};
|
|
270
|
+
return prefix + encodeOpenAIChunk(state.messageId, targetModel, toolCallChunk);
|
|
271
|
+
}
|
|
272
|
+
return prefix + encodeOpenAIChunk(state.messageId, targetModel, {
|
|
273
|
+
tool_calls: [{ index: 0, function: { arguments: delta.partial_json } }],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return prefix;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case 'content_block_stop': {
|
|
281
|
+
state.blockType = null;
|
|
282
|
+
return '';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
case 'message_delta': {
|
|
286
|
+
const stopReason = event.delta?.stop_reason;
|
|
287
|
+
if (stopReason) {
|
|
288
|
+
return encodeOpenAIChunk(state.messageId, targetModel, {}, mapStopReason(stopReason));
|
|
289
|
+
}
|
|
290
|
+
return '';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
case 'message_stop': {
|
|
294
|
+
return 'data: [DONE]\n\n';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
default:
|
|
298
|
+
return '';
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function encodeOpenAIChunk(id, model, delta, finishReason = null) {
|
|
303
|
+
const chunk = {
|
|
304
|
+
id: id || 'chatcmpl-proxy',
|
|
305
|
+
object: 'chat.completion.chunk',
|
|
306
|
+
created: Math.floor(Date.now() / 1000),
|
|
307
|
+
model: model || 'proxy-model',
|
|
308
|
+
choices: [{
|
|
309
|
+
index: 0,
|
|
310
|
+
delta,
|
|
311
|
+
finish_reason: finishReason,
|
|
312
|
+
}],
|
|
313
|
+
};
|
|
314
|
+
return `data: ${JSON.stringify(chunk)}\n\n`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = {
|
|
318
|
+
convertRequest,
|
|
319
|
+
convertResponse,
|
|
320
|
+
createSSEConverter,
|
|
321
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE 解析与编码辅助函数
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function parseSSELines(buffer) {
|
|
6
|
+
const lines = buffer.split('\n');
|
|
7
|
+
const events = [];
|
|
8
|
+
let currentEvent = { event: null, data: null };
|
|
9
|
+
|
|
10
|
+
for (const line of lines) {
|
|
11
|
+
const trimmed = line.trim();
|
|
12
|
+
if (trimmed === '') {
|
|
13
|
+
// 空行表示一个事件结束
|
|
14
|
+
if (currentEvent.data !== null) {
|
|
15
|
+
events.push(currentEvent);
|
|
16
|
+
}
|
|
17
|
+
currentEvent = { event: null, data: null };
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (trimmed.startsWith('event:')) {
|
|
21
|
+
currentEvent.event = trimmed.slice(6).trim();
|
|
22
|
+
} else if (trimmed.startsWith('data:')) {
|
|
23
|
+
const data = trimmed.slice(5).trim();
|
|
24
|
+
currentEvent.data = currentEvent.data === null ? data : currentEvent.data + '\n' + data;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 如果最后一行没有空行结束,保留未完成的事件
|
|
29
|
+
const remainder = currentEvent.data !== null ? currentEvent : null;
|
|
30
|
+
|
|
31
|
+
return { events, remainder };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function encodeOpenAIEvent(obj) {
|
|
35
|
+
return `data: ${JSON.stringify(obj)}\n\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function encodeOpenAIDone() {
|
|
39
|
+
return 'data: [DONE]\n\n';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function encodeAnthropicEvent(eventName, obj) {
|
|
43
|
+
return `event: ${eventName}\ndata: ${JSON.stringify(obj)}\n\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
parseSSELines,
|
|
48
|
+
encodeOpenAIEvent,
|
|
49
|
+
encodeOpenAIDone,
|
|
50
|
+
encodeAnthropicEvent,
|
|
51
|
+
};
|
package/lib/detector.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 检测入站请求使用的协议类型
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function detectInboundProtocol(req, body) {
|
|
6
|
+
const path = req.path || req.url || '';
|
|
7
|
+
|
|
8
|
+
// Anthropic 特征路径
|
|
9
|
+
if (path.includes('/v1/messages')) {
|
|
10
|
+
return 'anthropic';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// OpenAI 特征路径
|
|
14
|
+
if (path.includes('/v1/chat/completions')) {
|
|
15
|
+
return 'openai';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 根据 body 结构推断
|
|
19
|
+
if (body && typeof body === 'object') {
|
|
20
|
+
// Anthropic: 有 system 顶级字段,messages 中角色没有 system
|
|
21
|
+
if (body.system !== undefined && Array.isArray(body.messages)) {
|
|
22
|
+
return 'anthropic';
|
|
23
|
+
}
|
|
24
|
+
// OpenAI: messages 数组中包含 role: system
|
|
25
|
+
if (Array.isArray(body.messages) && body.messages.some(m => m && m.role === 'system')) {
|
|
26
|
+
return 'openai';
|
|
27
|
+
}
|
|
28
|
+
// 默认按 functions/tools 字段判断
|
|
29
|
+
if (body.functions !== undefined || (body.tools && Array.isArray(body.tools))) {
|
|
30
|
+
return 'openai';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 无法确定时,默认 openai
|
|
35
|
+
return 'openai';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { detectInboundProtocol };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const { createProxyApp } = require('./proxy-server');
|
|
2
|
+
|
|
3
|
+
class ProxyManager {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.servers = new Map(); // id -> { app, server, config }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async startProxy(proxyConfig) {
|
|
9
|
+
const id = proxyConfig.id;
|
|
10
|
+
|
|
11
|
+
// 如果已存在,先停止
|
|
12
|
+
if (this.servers.has(id)) {
|
|
13
|
+
await this.stopProxy(id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const entry = { app: null, server: null, config: proxyConfig };
|
|
18
|
+
const app = createProxyApp(() => entry.config);
|
|
19
|
+
const server = app.listen(proxyConfig.port, () => {
|
|
20
|
+
console.log(`[Proxy] ${proxyConfig.name} started on port ${proxyConfig.port}`);
|
|
21
|
+
entry.app = app;
|
|
22
|
+
entry.server = server;
|
|
23
|
+
this.servers.set(id, entry);
|
|
24
|
+
resolve(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
server.on('error', (err) => {
|
|
28
|
+
console.error(`[Proxy] ${proxyConfig.name} error:`, err.message);
|
|
29
|
+
this.servers.delete(id);
|
|
30
|
+
reject(err);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async stopProxy(id) {
|
|
36
|
+
const entry = this.servers.get(id);
|
|
37
|
+
if (!entry) return false;
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
entry.server.close(() => {
|
|
41
|
+
console.log(`[Proxy] ${entry.config.name} stopped on port ${entry.config.port}`);
|
|
42
|
+
resolve(true);
|
|
43
|
+
});
|
|
44
|
+
this.servers.delete(id);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async restartProxy(proxyConfig) {
|
|
49
|
+
await this.stopProxy(proxyConfig.id);
|
|
50
|
+
return this.startProxy(proxyConfig);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
updateProxyConfig(proxyConfig) {
|
|
54
|
+
const entry = this.servers.get(proxyConfig.id);
|
|
55
|
+
if (!entry) return false;
|
|
56
|
+
entry.config = proxyConfig;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isRunning(id) {
|
|
61
|
+
return this.servers.has(id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getRunningPorts() {
|
|
65
|
+
return Array.from(this.servers.values()).map(s => ({
|
|
66
|
+
id: s.config.id,
|
|
67
|
+
name: s.config.name,
|
|
68
|
+
port: s.config.port,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async stopAll() {
|
|
73
|
+
for (const [id] of this.servers) {
|
|
74
|
+
await this.stopProxy(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = new ProxyManager();
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const { detectInboundProtocol } = require('./detector');
|
|
3
|
+
const o2a = require('./converters/openai-to-anthropic');
|
|
4
|
+
const a2o = require('./converters/anthropic-to-openai');
|
|
5
|
+
|
|
6
|
+
function createProxyApp(proxyConfigOrGetter) {
|
|
7
|
+
const getProxyConfig = typeof proxyConfigOrGetter === 'function'
|
|
8
|
+
? proxyConfigOrGetter
|
|
9
|
+
: () => proxyConfigOrGetter;
|
|
10
|
+
const app = express();
|
|
11
|
+
app.use(express.json({ limit: '50mb' }));
|
|
12
|
+
|
|
13
|
+
// reasoning_content 缓存(用于 DeepSeek 等 reasoning model)
|
|
14
|
+
// key: assistant message content, value: reasoning_content
|
|
15
|
+
const reasoningCache = new Map();
|
|
16
|
+
const MAX_CACHE_SIZE = 100;
|
|
17
|
+
|
|
18
|
+
function setReasoning(content, reasoning) {
|
|
19
|
+
if (!content || !reasoning) return;
|
|
20
|
+
if (reasoningCache.size >= MAX_CACHE_SIZE) {
|
|
21
|
+
const firstKey = reasoningCache.keys().next().value;
|
|
22
|
+
reasoningCache.delete(firstKey);
|
|
23
|
+
}
|
|
24
|
+
reasoningCache.set(content, reasoning);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getReasoning(content) {
|
|
28
|
+
return reasoningCache.get(content);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function injectReasoningToMessages(messages) {
|
|
32
|
+
if (!Array.isArray(messages)) return;
|
|
33
|
+
for (const msg of messages) {
|
|
34
|
+
if (msg.role === 'assistant') {
|
|
35
|
+
const reasoning = getReasoning(msg.content);
|
|
36
|
+
// DeepSeek 等 reasoning model 要求 assistant message 必须包含 reasoning_content 字段
|
|
37
|
+
msg.reasoning_content = reasoning || '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractReasoningFromResponse(body) {
|
|
43
|
+
const choice = body.choices?.[0];
|
|
44
|
+
const message = choice?.message;
|
|
45
|
+
if (message?.role === 'assistant' && message.reasoning_content) {
|
|
46
|
+
setReasoning(message.content, message.reasoning_content);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
app.use((req, res, next) => {
|
|
51
|
+
res.header('Access-Control-Allow-Origin', '*');
|
|
52
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
53
|
+
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Api-Key');
|
|
54
|
+
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
app.use((req, res, next) => {
|
|
59
|
+
const proxyConfig = getProxyConfig();
|
|
60
|
+
if (!proxyConfig.requireAuth || !proxyConfig.authToken) {
|
|
61
|
+
return next();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const token = req.headers.authorization?.replace('Bearer ', '') || req.headers['x-api-key'];
|
|
65
|
+
if (token !== proxyConfig.authToken) {
|
|
66
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
67
|
+
}
|
|
68
|
+
next();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
app.post('/v1/chat/completions', handleRequest);
|
|
72
|
+
app.post('/v1/messages', handleRequest);
|
|
73
|
+
|
|
74
|
+
async function handleRequest(req, res) {
|
|
75
|
+
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
76
|
+
try {
|
|
77
|
+
const proxyConfig = getProxyConfig();
|
|
78
|
+
const inboundProtocol = detectInboundProtocol(req, req.body);
|
|
79
|
+
const target = proxyConfig.target;
|
|
80
|
+
|
|
81
|
+
if (!target) {
|
|
82
|
+
return res.status(500).json({ error: 'Proxy target not configured' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const targetProtocol = target.protocol;
|
|
86
|
+
const isStream = req.body?.stream === true;
|
|
87
|
+
|
|
88
|
+
console.log(`[${requestId}] ⬅️ ${inboundProtocol.toUpperCase()} → ${targetProtocol.toUpperCase()} | path=${req.path}`);
|
|
89
|
+
|
|
90
|
+
// 决定转换方向
|
|
91
|
+
let convertReq, convertRes, createSSEConv;
|
|
92
|
+
if (inboundProtocol === 'openai' && targetProtocol === 'anthropic') {
|
|
93
|
+
convertReq = o2a.convertRequest;
|
|
94
|
+
convertRes = o2a.convertResponse;
|
|
95
|
+
createSSEConv = o2a.createSSEConverter;
|
|
96
|
+
} else if (inboundProtocol === 'anthropic' && targetProtocol === 'openai') {
|
|
97
|
+
convertReq = a2o.convertRequest;
|
|
98
|
+
convertRes = a2o.convertResponse;
|
|
99
|
+
createSSEConv = a2o.createSSEConverter;
|
|
100
|
+
} else {
|
|
101
|
+
convertReq = (body, model) => ({ ...body, model: body.model || model });
|
|
102
|
+
convertRes = (body) => body;
|
|
103
|
+
createSSEConv = null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 如果请求没有 model,注入默认 model
|
|
107
|
+
const inboundModel = req.body?.model;
|
|
108
|
+
const effectiveModel = target.defaultModel || inboundModel;
|
|
109
|
+
if (effectiveModel) {
|
|
110
|
+
req.body = { ...req.body, model: effectiveModel };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const targetBody = convertReq(req.body, effectiveModel);
|
|
114
|
+
|
|
115
|
+
// 注入 reasoning_content(针对 DeepSeek 等 reasoning model)
|
|
116
|
+
injectReasoningToMessages(targetBody.messages);
|
|
117
|
+
|
|
118
|
+
// 构建目标 URL
|
|
119
|
+
const targetUrl = buildTargetUrl(target.providerUrl, targetProtocol, req.path);
|
|
120
|
+
console.log(`[${requestId}] 🔗 ${targetUrl} | model=${effectiveModel}`);
|
|
121
|
+
|
|
122
|
+
// 构建请求头
|
|
123
|
+
const headers = {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
'Accept': isStream ? 'text/event-stream' : 'application/json',
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (targetProtocol === 'openai') {
|
|
129
|
+
headers['Authorization'] = `Bearer ${target.apiKey}`;
|
|
130
|
+
} else if (targetProtocol === 'anthropic') {
|
|
131
|
+
headers['X-Api-Key'] = target.apiKey;
|
|
132
|
+
headers['Anthropic-Version'] = '2023-06-01';
|
|
133
|
+
headers['Authorization'] = `Bearer ${target.apiKey}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const fetchRes = await fetch(targetUrl, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers,
|
|
139
|
+
body: JSON.stringify(targetBody),
|
|
140
|
+
signal: AbortSignal.timeout(120000),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!fetchRes.ok) {
|
|
144
|
+
const errBody = await fetchRes.text();
|
|
145
|
+
console.log(`[${requestId}] ❌ Target error: HTTP ${fetchRes.status} | ${errBody.slice(0, 500)}`);
|
|
146
|
+
res.status(fetchRes.status);
|
|
147
|
+
res.set('Content-Type', fetchRes.headers.get('content-type') || 'application/json');
|
|
148
|
+
return res.send(errBody);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 流式响应
|
|
152
|
+
if (isStream && fetchRes.headers.get('content-type')?.includes('text/event-stream')) {
|
|
153
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
154
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
155
|
+
res.setHeader('Connection', 'keep-alive');
|
|
156
|
+
|
|
157
|
+
const sseConverter = createSSEConv ? createSSEConv(effectiveModel) : null;
|
|
158
|
+
const reader = fetchRes.body.getReader();
|
|
159
|
+
const decoder = new TextDecoder();
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
while (true) {
|
|
163
|
+
const { done, value } = await reader.read();
|
|
164
|
+
if (done) break;
|
|
165
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
166
|
+
if (sseConverter) {
|
|
167
|
+
const converted = sseConverter.convertChunk(chunk);
|
|
168
|
+
if (converted) res.write(converted);
|
|
169
|
+
} else {
|
|
170
|
+
res.write(chunk);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (sseConverter) {
|
|
174
|
+
const flushed = sseConverter.flush();
|
|
175
|
+
if (flushed) res.write(flushed);
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error(`[${requestId}] Stream error:`, err.message);
|
|
179
|
+
} finally {
|
|
180
|
+
res.end();
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const responseBody = await fetchRes.json();
|
|
186
|
+
extractReasoningFromResponse(responseBody);
|
|
187
|
+
const convertedBody = convertRes(responseBody);
|
|
188
|
+
res.json(convertedBody);
|
|
189
|
+
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.error(`[${requestId}] ❌ Proxy error:`, err.message);
|
|
192
|
+
res.status(500).json({ error: 'Proxy error', message: err.message });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return app;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function buildTargetUrl(providerUrl, targetProtocol, originalPath) {
|
|
200
|
+
const base = providerUrl.replace(/\/$/, '');
|
|
201
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
202
|
+
|
|
203
|
+
if (targetProtocol === 'openai') {
|
|
204
|
+
if (hasV1Suffix) return `${base}/chat/completions`;
|
|
205
|
+
return `${base}/v1/chat/completions`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (targetProtocol === 'anthropic') {
|
|
209
|
+
if (hasV1Suffix) return `${base}/messages`;
|
|
210
|
+
return `${base}/v1/messages`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return base + originalPath;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { createProxyApp };
|